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

本文详细探讨了Vue的响应式原理,包括对象属性劫持、数组方法的劫持,以及模板编译成AST语法树的过程。接着,介绍了如何通过虚拟DOM实现高效更新,包括代码生成和真实DOM的生成。文章还涵盖了Vue实例初始化、数据观测、模板解析到AST转换,以及最终生成render函数和虚拟DOM节点的详细步骤。
摘要由CSDN通过智能技术生成

主要内容

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

环境准备:

npm install rollup
//将高级语法转换为低级语法
npm install rollup rollup-plugin-babel @babel/core @babel/preset-env --save-dev
npm 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元素上

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

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

  1. script标签引用的vue.global.js这个编译过程是在浏览器运行的
  2. 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核心流程

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值