当前位置:首页 » 《随便一记》 » 正文

【Vue2源码解析】01.响应式原理

11 人参与  2022年07月21日 14:50  分类 : 《随便一记》  评论

点击全文阅读


主要内容

Vue响应式原理支持,对象属性劫持实现对数组的方法劫持模板编译原理,将模板转化成ast语法树代码生成,实现虚拟DOM通过虚拟DOM生成真实DOM

环境准备:

npm install rollup//将高级语法转换为低级语法npm install rollup rollup-plugin-babel @babel/core @babel/preset-env --save-devnpm i @rollup/plugin-node-resolve

package.json

{  "scripts": {    "dev": "rollup -cw"  },  "devDependencies": {    "@babel/core": "^7.18.6",    "@babel/preset-env": "^7.18.6",    "rollup": "^2.75.7",    "rollup-plugin-babel": "^4.4.0"  }}

.babelrc

{    "presets": [        "@babel/preset-env"    ]}

rollup.config.js

//rollup默认可以导出一个对象,作为打包的配置文件import babel from 'rollup-plugin-babel'export default {    input: './src/index.js', //入口    output: {        file: './dist/vue.js', //出口        name: 'Vue',        format: 'umd', //esm es6模块, commonjs模块 iife自执行函数 umd统一模块规范        sourcemap: true, //希望可以调试源代码    },    plugins: [        babel({            exclude: 'node_modules/**'  //排除node_modules所有文件        })    ]}

打包命令:npm rundev

初始化数据

创建Vue实例

import { initMixin } from "./init";function Vue(options){    // debugger    this._init(options)}//给Vue实例添加初始化方法,将用户选项挂载到实例上,并开始初始化状态Vue.prototype._init = function(options){        //用于初始化操作        // $表示Vue自带的属性        const vm = this;        vm.$options = options;//将用户的选项挂载到实例上        //初始化状态        if(vm.$options.data){//        data = typeof data === 'function' ? data.call(vm) : data    }    }

劫持对象观测

遍历对象data中的每一个元素进行属性劫持,保证数据访问或者更新时能拦截,如果key存储的是对象则进行递归监测

class Observer{    constructor(data){        //Object.defineProperty只能劫持已经存在的属性,后续增加的无法监听(vue2会为此单独写一个例如 $set $delete的api)        Object.defineProperty(data, '__ob__', {            value: this, //将Observer类实例赋值给data的__ob__属性,如果数据上有这个属性则说明这个属性被观测过了            enumerable: false //将obj变成不可枚举,解决如果data初始是对象,在调用walker方法进行观测时,内部的observe方法会对__ob__属性所代表的data对象无限调用        })        if(Array.isArray(data)){            this.observeArray(data);//监测数组对象中的变化        }else{            this.walker(data)        }       }    walker(data){        //循环对象,对属性依次进行劫持        //重新定义属性,因为要重新构建所以性能很低        Object.keys(data).forEach(key => defineReactive(data, key, data[key]))    }    observeArray(data){//观测数组        data.forEach(item => observe(item))    }}export function defineReactive(target, key, value){//闭包 属性劫持    // debugger    observe(value) //对多层对象递归进行属性劫持    Object.defineProperty(target, key, {        get(){//取值的时候会执行get            return value        },        set(newV){            if(newV === value) return            observe(newV)            value = newV //Q_lys:这里有一点不是很明白,defineReactive中的参数value应该只在defineReactive的函数内部有效,为什么这里直接更改value会反向更改data中的值            // console.log('set data:', target)        }    })}export function observe(data){    // 对这个对象进行劫持    if(typeof data !== 'object' || data == null){        return; //只对对象进行劫持    }    if(data.__ob__ instanceof Observer){//说明这个对象被代理过了        return data.__ob__    }    // 如果一个对象被劫持过了,那就不需要再被劫持了(要判断一个对象是否被劫持过,可以增添一个实例,用实例来判断是否被劫持过)    return new Observer(data)}

劫持数组观测

对Array的原型方法进行重写并返回,这里用到了一个很巧妙地方法,在Observer类中给data增添了一个__ob__属性,该属性存储的是Obsever实例对象,便于在array.js文件中能直接获取observe方法进行数组观测

array.js

let oldArrayProto = Array.prototype;//获取数组的原型export let newArrayProto = Object.create(oldArrayProto)let methods = [//所有会修改原数组的方法    'push',    'pop',    'shift',    'reverse',    'sort',    'splice']methods.forEach(method => {    newArrayProto[method] = function(...args){//重写数组方法        //这里指的arr        const result = oldArrayProto[method].call(this, ...args)//内部调用原来的方法 函数劫持 切片编程        //对新增的数据进行劫持        let inserted;        let ob = this.__ob__;//拿到Observer实例        switch(method){            case 'push':            case 'unshift':                inserted = args;                break;            case 'splice': //Array.splice(idx, 删除的个数, 新增内容)                inserted = args.slice(2)            default:                break        }        if(inserted){//有新增的内容对新增数组进行观测            ob.observeArray(inserted)        }        return result    }})

解析模板参数

el: ‘#app’ //将数据解析到el元素上

模板引擎 性能很差 正则匹配替换 vue1.0没有引入虚拟DOM的改变采用寻DOM,数据变化后比较虚拟DOM的差异,最后更新需要更新的地方核心就是需要将模板变成js语法,最后通过js语法生成虚拟DOM
先变成语法树,再重新组装成新的语法,将temolate语法转换成render语法

注意: 有现成的包解析html,htmlparser2

script标签引用的vue.global.js这个编译过程是在浏览器运行的runtime是不包含模板编译的,整个编译是在打包的时候通过loader来转义.vue文件,用runtime的时候是不能使用模板template

正则表达式图形可视化网站
startTagOpen: ^<((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)>
匹配标签名,例如:<div,标签名不能以数字开头,还能匹配带命名空间的标签:<div:xxx>
在这里插入图片描述
endTag: /^<\/((?:[a-zA-Z_][\-\.0-9_a-zA-Z]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Z]*)[^>]*>/
在这里插入图片描述

属性匹配:/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>]+)))?/`
第一个分组就是属性的key,value就是分组3/分组4/分组5
在这里插入图片描述

转换抽象语法树AST

对html进行解析时,不断解析到开始标签<div、结束标签>、属性style="color:red"、文本内容{{name}}(普通文本hello),解析一段便删除一段,直到删完
html

<div id="app">        <div style="color:red;font-size:14px">{{name}} hello</div>        <span>{{age}}</span></div>

解析开始标签及中间的属性和结束标签

function parseStartTag(){        const start = html.match(startTagOpen);        // console.log(start)        if(start){            const match = {                tagName: start[1],//标签名                attrs: []            }            advance(start[0].length);            // 如果不是开始标签的结束,就一直匹配属性            let attr, end;            while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))){                advance(attr[0].length)                match.attrs.push({name: attr[1], value: attr[3] || attr[4] || attr[5] || true})            }            if(end){//删掉结束标签>                advance(end[0].length)            }            // console.log('match:',match)                        return match        }                return false; //不是开始标签    }

解析流程: 通过判断html中<的位置,如果位置为0,可能是开始标签<div style="color:red">或者是结束标签(开始标签和文本内容都截取完后,只剩下</div>),而如果位置大于0,则说明是文本内容(hello</div>)。如果开始标签解析出来有内容,则说明接下来要解析文本内容,跳过下面的结束标签解析判断,提升代码性能。同理如果是结束标签解析完毕,则不会再进行解析文本内容也直接continue.

while(html){        // <div>hello</div>        // textEnd为0说明是一个开始标签或者结束标签        // 如果textEnd>0说明是文本的结束位置        let textEnd = html.indexOf('<'); //如果indexOf中的索引是0则说明是个标签        if(textEnd==0){            const startTagMathch = parseStartTag();            if(startTagMathch){//解析到的开始标签                start(startTagMathch.tagName, startTagMathch.attrs)                continue            }            let endTagMatch = html.match(endTag);            if(endTagMatch){                advance(endTagMatch[0].length);                end(endTagMatch[1])                continue;            }            // break        }        if(textEnd>0){            let text = html.substring(0, textEnd);//文本内容            if(text){                chars(text)                advance(text.length)            }        }    }

AST语法树:利用栈形结构创造树,将开始标签入栈,并创建AST节点,并创建父亲儿子指向,遇到结束标签则出栈。文本直接放到当前指向的节点中

//最终需要转换成一颗抽象语法树    const ELEMENT_TYPE = 1;    const TEXT_TYPE = 3;    const stack = []; //用于存放元素的,栈中最后一个元素是当前匹配到开始标签的父亲    let currentParent;//指向栈中的最后一个    let root    function createASTElement(tag, attrs){//抽象语法树的节点,标签,类型,父亲,儿子,属性        return {            tag,            type: ELEMENT_TYPE,            children: [],            attrs,            parent: null        }    }    function start(tag, attr){//开始标签内容        let node = createASTElement(tag, attr);//创造一个ast节点        if(!root){            root = node;//如果root为空,则该节点为树的根节点        }        if(currentParent){            node.parent = currentParent            currentParent.children.push(node)        }        stack.push(node);        currentParent = node;//currentParent为栈中最后一个    }    function chars(text){//文本内容        text = text.replace(/\s/g, '')        text && currentParent.children.push({            type: TEXT_TYPE,            text,            parent: currentParent        })    }    function end(tag){//可以校验标签是否合法        stack.pop();        currentParent = stack[stack.length-1]    }

上面的HTML结构就会解析成如下的抽象语法树
在这里插入图片描述

代码生成

代码生成,将抽象语法树转换成render方法
在这里插入图片描述

_c(tag, attrs, children, text):这个函数是要创建一个tag的元素,并且它的属性是attrs,儿子是children,文本内容是text_s(变量):这个函数的作用是将要插值表达式中的变量{{name}}转成字符串_v(text):这个函数的作用是要创建文本的
import { parseHTMl } from "./parse";function genProps(attrs){    let str = ''//    for(let i=0;i<attrs.length;i++){        let attr = attrs[i];        let obj = {}        if(attr.name === 'style'){            // color:red;background:red => {color:'red'}            attr.value.split(';').forEach(el=>{//qs库                let [key, value] = el.split(':')                obj[key] = value            })            attr.value = obj        }        str += `${attr.name}:${JSON.stringify(attr.value)},`    }    return `{${str.slice(0,-1)}}`}const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g //{{any}}匹配到的内容就是表达式的变量function gen(node){    if(node.type === 1){        return codegen(node);//如果孩子是元素则调用codegen生成    }else{        let text = node.text        if(!defaultTagRE.test(text)){//普通文本            return `_v(${JSON.stringify(text)})`        }else{//文本内容是带有{{}}            let tokens = []            let match;            defaultTagRE.lastIndex = 0;            let lastIndex = 0;            while(match = defaultTagRE.exec(text)){                let index = match.index;//匹配的位置 {{name}} hello {{age}} hello                if(index>lastIndex){//将中间hello匹配到                    tokens.push(JSON.stringify(text.slice(lastIndex, index)))                }                tokens.push(`_s(${match[1].trim()})`)                lastIndex = index + match[0].length             }            if(lastIndex < text.length){//将最后的hello匹配到                tokens.push(JSON.stringify(text.slice(lastIndex)))            }            return `_v(${tokens.join('+')})`        }    }}function genChildren(children){    return children.map(child => gen(child)).join(',')} function codegen(ast){        // debugger    let children = genChildren(ast.children)    let code = (`_c('${ast.tag}',${ast.attrs.length>0 ? genProps(ast.attrs) : 'null'                }${ast.children.length ? `,${children}`:''            })`)    return code}export function compileToFunction(template){    // console.log('template:',template)    // 1.就是将template转换成ast语法树    let ast = parseHTMl(template);    console.log('ast:', ast)    //2.生成render方法,render方法执行后的返回结果就是虚拟DOM    // _c('div', {id:"app"},_c('div', {style:{"color":"red","font-size":"14px"}},_v(_s(name)+"hello")),_c('span', null,_v(_s(age))))    let code = codegen(ast)    // 模板引擎的实现原理 就是with+new Function    code = `with(this){return ${code}}`; //对象属性直接变成with作用域下的变量    let render = new Function(code);    // console.log('render:', render)    return render}

实现虚拟DOM生成真实DOM

_c函数的具体实现:

// _c('div', attrs, ...childs)    Vue.prototype._c = function(){        return createElementVNode(this, ...arguments)    }    export function createElementVNode(vm, tag, data={}, ...children){    if(!data){        data = {}    }    let key = data.key;    if(key){        delete data.key    }    return vnode(vm,tag, key, data, children)}

_s函数的具体实现:

Vue.prototype._s = function(value){        if(typeof value !== 'object') return value        return JSON.stringify(value)    }

_v函数的具体实现:

//_v(text)    Vue.prototype._v = function(){        return createTextVNode(this, ...arguments)    }    export function createTextVNode(vm, text){    return vnode(vm, undefined, undefined, undefined, undefined, text)}

vnode:

function vnode(vm, tag, key, data, children, text){//这里的data就是ast中的属性    return {        vm,tag,key,data,children, text    }}

通过上面的操作可以将render函数产生虚拟节点,下面将根据生成的虚拟节点创造真实DOM,

patch函数: oldVNode如果是初渲染状态就是el挂载的节点,如果是更新状态就是待更新节点,vnode虚拟节点。根据虚拟节点生成真实节点,将原来的oldVNode删除,并在下面插入真实节点
function patch(oldVNode, vnode){    const isRealElement = oldVNode.nodeType;    if(isRealElement){//初渲染流程         const elm = oldVNode //获取真实元素        const parentElm = elm.parentNode; //获取父元素        let newElm = createElm(vnode)        console.log("newElm:", newElm)        parentElm.insertBefore(newElm, elm.nextSibling);//先在老节点下面插入新节点        parentElm.removeChild(elm);//删除老节点    }else{        //diff算法    }}
createElm(vnode):根据虚拟节点创造真实节点,并在虚拟节点上挂载真实节点,方便后续更新
function createElm(vnode){    let {tag, data, children, text} = vnode;    if(typeof tag === 'string'){//标签        vnode.el = document.createElement(tag)//虚拟节点上挂载了真实节点        patchProps(vnode.el, data);        children.forEach(child => {            vnode.el.appendChild(createElm(child))        });    }else{        vnode.el = document.createTextNode(text)    }    return vnode.el}function patchProps(el, props){    for(let key in props){        if(key === 'style'){            for(let styleName in props.style){                el.style[styleName] = props.style[styleName];            }        }else{            el.setAttribute(key, props[key]);        }    }}

Vue核心流程

创造响应式数据模板转换成AST语法树将AST语法树转换成render函数,render函数会去产生虚拟节点(使用响应式数据),后续每次数据更新可以只执行render函数(无需再次执行ast转换过程)根据生成的虚拟节点创造真实DOM

点击全文阅读


本文链接:http://zhangshiyu.com/post/43634.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1