深入Vue框架

第一部分 Vue2响应式原理

1. Object.defineProperty

diff 复制代码
Object.defineProperty(obj, prop, descriptor)

- obj:必需,目标对象
- prop:必需,需定义或修改的属性名
- descriptor:必需,目标属性所拥有的特性

descriptor描述符包含以下内容:

  • value:被定义的属性值,默认为undefined

  • writable:属性值是否可以被重写,true可以重写,false不能重写。默认值为false

  • enumerable:属性值是否可以被枚举(使用for...in或Object.keys()),true可以被枚举,false不能被枚举。默认为false

  • configurable:是否可以删除属性或是否可以再次修改属性的特性(writable, configurable, enumerable),true可以被删除或可以重新设置特性,false不能被可以被删除或不可以重新设置特性。默认为false

存取器getter/setter

如果使用getter或setter方法时不能允许再使用writable和value这两个属性

  • getter:当访问该属性时,该方法会被执行。函数的返回值会作为该属性的值返回

  • setter:当属性值修改时,该方法会被执行。该方法将接受唯一参数,即该属性新的参数值

javascript 复制代码
var obj = {};
var initValue = 'hello';
Object.defineProperty(obj,"newKey",{
    get:function(){
        //当获取值的时候触发的函数
        return initValue;
    },
    set:function (value){
        //当设置值的时候触发的函数,设置的新值通过参数value拿到
        initValue = value;
    }
});
//获取值
console.log(obj.newKey); //hello
//设置值
obj.newKey = 'change value';
console.log( obj.newKey ); //change value

不要在getter中再次获取该属性值,也不要在setter中再次设置改属性,否则会栈溢出!

2. 数据代理

构建Vue实例对象代码如下所示:

csharp 复制代码
let vm = new Vue({
    data:{
        message:'hello vue.js'
    }
})

由于是通过new Vue创建的Vue实例对象,所以如果想模拟Vue底层的数据代理原理,在创建Vue时应该构建一个Vue类,并且类会接收一个options配置对象。

暂时先不考虑new Vue(函数)的情况,只讨论new Vue({...})

kotlin 复制代码
class Vue {
    constructor(options){
        this.$options = options
        this._data = options.data
        // 初始化数据代理
        this.initData()
    }
    initData(){
        let data = this._data
        let keys = Object.keys(data)
        // 通过Object.defineProperty对options中的每一个属性实现代理
        for (let i = 0;i < keys.length; i++) {
            Object.defineProperty(this,keys[i],{
                enumerable:true,
                configurable:true,
                // 函数名称可有可无,名称函数是为了后续操作方便
                get:function proxyGetter(){
                    return data[keys[i]]
                },
                set:function proxySetter(value){
                    data[keys[i]] = value
                }
            })
        }
    }
}

3. 数据劫持

当data中的属性值是基本数据类型时,单层for循环即可实现数据代理和劫持。

javascript 复制代码
class Vue {
    constructor(options) {
        this.$options = options
        this._data = options.data
        this.initData()
    }
    initData(){
        let data = this._data
        let keys = Object.keys(data)
        // 数据代理
        for(let i = 0; i <keys.length; i++){
            Object.defineProperty(this,keys[i],{
                enumerable:true,
                configurable:true,
                get:function proxyGetter() {
                    return data[keys[i]]
                },
                set:function proxySetter(value) {
                    data[keys[i]] = value
                }
            })
        }
        // 数据劫持
        for(let i=0;i<keys.length;i++){
            let value = data[keys[i]];
            Object.defineProperty(this,keys[i],{
                enumerable:true,
                configurable:true,
                get: function reactiveGetter(){
                    console.log(`data中的${keys[i]}被读取`);
                    return value;
                },
                set: function reactiveSetter(val){
                    if(val === value) return;
                    console.log(`data中的${keys[i]}被修改为${val}`);
                    value = val;
                }
            })
        }
    }
}

4. 数据递归劫持

css 复制代码
let vm = new Vue({
    data:{
        message:'hello',
        person:{
            name:'小明',
            age:25,
            sex:"Man"
        }
    }
});

🤕:修改person的age属性时无法触发proxySetter监听函数

当data中的属性值为引用类型时,单层for循环则无法实现数据的深度劫持,需要对以上方法进行改写。

javascript 复制代码
// 数据劫持
observe(data);

// 判断data的数据类型
function observe(data) {
    let type = Object.prototype.toString.call(data);
    if (type !== '[object Object]' && type !== '[object Array]') {
        return;
    }
    // 通过vue内置的Observer类实现引用类型的劫持
    new Observer(data);
}

// 抽离数据劫持代码
function defineReactive(obj, key, value) {
    // 递归判断数据类型
    observe(obj[key]);
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            console.log(`${key}被读取`);
            return value;
        },
        set: function reactiveSetter(val) {
            if (val === value) return;
            console.log(`${key}被修改为${val}`);
            value = val;
        }
    })
}
// 实现内置的Observer类
class Observer {
    constructor(data) {
        this.walk(data);
    }
    // 抽离遍历data属性值的方法
    walk(data) {
        let keys = Object.keys(data);
        for (let i = 0; i < keys.length; i++) {
            defineReactive(data,keys[i],data[keys[i]]);
        }
    }
}

5. Watcher监听

Vue项目中Watcher监听的调用如下:

javascript 复制代码
// 第一种方式
vm.$watcher('message',()=>{
    ....
})

// 第二种方式
watch:{
    message(){
        ....
    }
}

Watcher监听的逻辑:

  • 每个属性可以有多个监听回调,所以需要使用数组存放每个属性的监听回调函数
  • 一个回调可能包含多个属性,所以getter需要具有收集当前回调的能力
  • 回调触发前需要收集,触发后需要移除来节省内存空间,所以可以存放在一个公共区域
  • 属性的回调函数异步执行
  • 属性的多个相同的回调函数会合并执行

实现Watcher数据监听

第一步:实现存放监听回调的筐"发布-订阅者模式"

javascript 复制代码
class Dep{
    constructor(){
        this.subs = []
    }
    // 收集回调
    depend(){
        if(Dep.target){
            this.subs.push(Dep.target)
        }
    }
    // 触发回调
    notify(){
        this.subs.forEach(watcher=>{
            watcher.run()
        })
    }
}

第二步:发布/订阅监听的回调函数

csharp 复制代码
get: function reactiveGetter() {
    // 订阅
    dep.depend();
},

set: function reactiveSetter(val) {
    // 发布
    dep.notify();
}

第三步:构建watcher类(每个回调函数都是一个watcher实例对象)

kotlin 复制代码
let watchId = 0; // 回调函数唯一标识
let watchQueue = []; // 当前执行的回调函数队列

class Watcher{
    // vm实例、exp属性、cb回调函数
    constructor(vm,exp,cb){
        this.vm = vm
        this.exp = exp
        this.cb = cb
        // id自增,保持唯一性
        this.id = ++watchId
        this.get()
    }
    // 对属性求值
    get(){
        Dep.target = this
        this.vm[this.exp]
        Dep.target = null
    }
    // 更新队列中的回调函数,并异步执行当前回调
    run(){
        if(watchQueue.indexOf(this.id) !== -1){
            return;
        }
        watchQueue.push(this.id);
        let index = watchQueue.length-1;
        // 通过promise实现异步执行
        Promise.resolve().then(()=>{
            this.cb.call(this.vm);
            watchQueue.splice(index,1)
        })
    }
}

6. $set设置响应式属性

vue.$set方法可以随时添加响应式的属性,其实现逻辑如下所示:

  1. 在创建observer实例时,再创建一个新的dep筐,并挂在observer实例上
  2. 把observer实例挂载到对象的__ob__属性上
  3. 触发getter时,把watcher收集一份到之前的"筐"和创建的新dep筐
  4. 用户调用$set时,手动地触发__ob__.dep.notify()
  5. 在notify()执行前调用defineReactive把新的属性也定义成响应式
kotlin 复制代码
function set(target, key, val) {
    var ob = target.__ob__; // 1. 新框
    if (isArray(target) && isValidArrayIndex(key)) {
        target.length = Math.max(target.length, key);
        target.splice(key, 1, val);
        // 2. 收集watcher
        if (ob && !ob.shallow && ob.mock) {
            observe(val, false, true);
        }
        return val;
    }
    // 4. 将属性定义为响应式
    defineReactive(ob.value, key, val, undefined, ob.shallow, ob.mock);
    // 5. 触发通知函数
    {
        ob.dep.notify({
            type: "add" /* TriggerOpTypes.ADD */,
            target: target,
            key: key,
            newValue: val,
            oldValue: undefined
        });
    }
    return val;
}

7. Vue2响应式存在的问题

  • 对象:通过Object.defineProperty属性拦截实现响应式
    • 缺点1:对象每一个属性都需要重写,添加getter和setter
    • 缺点2:对象新增属性无法监测,需要借助$set方法
    • 缺点3:对象删除属性无法监测,需要借助$delete方法
    • 缺点4:多层对象需要通过递归实现响应式
  • 数组:重写数组原生API(push,shift,unshift,pop,splice,sort,reverse),实现数组元素的响应式
    • 缺点1:无法通过Object.defineProperty拦截,需要单独处理
    • 缺点2:通过索引改变数组,或者改变数组长度无法触发视图更新
    • 缺点3:ES6新增的Map、Set数据结构不支持

第二部分 虚拟DOM和diff算法

1. 虚拟DOM

Virtual DOM 就是用js对象来描述真实DOM,是对真实DOM的抽象

直接操作真实DOM性能很低,而且js层的操作效率相对较高,所以可以将DOM操作转化成对象操作,最终通过diff算法比对差异更新真实DOM。

虚拟DOM不依赖真实平台环境从而也可以实现跨平台应用。

1.1 createElement函数

createElement方法接收三个参数:

  • type表示标签类型
  • props表示标签属性
  • children表示标签子元素

createElement方法返回值为一个Element实例对象,用于创建虚拟节点。

typescript 复制代码
function createElement(type,props,children){
    return new Element(type,props,children)
}
kotlin 复制代码
class Element{
    constructor(type,props,children){
        this.type = type
        this.props = props
        this.children = children
    }
    ... ...
}

虚拟DOM数据格式实例

  • sel:元素选择器
  • elm:对应的真实DOM节点,undefined表示该虚拟DOM还没有被添加到DOM树上
  • key:元素唯一标识符
  • data:元素的标签属性
  • text:文本内容
  • children:子元素

1.2 render函数

render函数将虚拟DOM转化为真实DOM

ini 复制代码
function render(vDom){
    const { type, props,children } = vDom;
    const el = document.createElement(type);
    // 处理props属性
    for(let key in props){
        switch (key){
            case 'value':
                if(el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'){
                    el.value = props[key];
                }else{
                    el.setAttribute(key,props[key]);
                }
            break;
            case 'style':
                el.style.cssText = props[key];
            break;
            default:
                el.setAttribute(key,props[key]);
        }
    }
    // 处理子元素
    children.map((c)=>{
        c = c instanceof Element ? render(c) : document.createTextNode(c);
        el.appendChild(c);
    })

    return el;
}

1.3 renderDOM函数

renderDOM函数用于渲染真实DOM到页面上

scss 复制代码
function renderDOM(el,rootEl){
    // el虚拟DOM元素,rootEl根节点
    rootEl.appendChild(el);
}
renderDOM(vDom,document.getElementById('app'))

2. Diff算法

2.1 传统diff算法

假设新旧虚拟DOM看作两棵节点树,节点个数为n

  1. 左侧树的节点需要与右侧树的节点一一对比,需要O(n²)复杂度
  2. 删除未找到的节点,寻找合适节点放到被删除位置,需要O(n)复杂度
  3. 添加新节点,需要O(n)复杂度

所以,传统diff算法比较新旧虚拟DOM的复杂度是O(n³)

2.2 Vue中的diff优化

  1. 只比较同一层级节点,对于节点间跨层级的移动操作忽略不计
  2. 标签名不同直接删除,不再进行深度比较
  3. 标签名和key都相同,则认为是相同的节点,不再进行深度比较

Vue中的diff算法比较新旧虚拟DOM的复杂度是O(n)

2.3 diff算法逻辑

当数据发生变化的时候,会触发setter,通知所有的订阅者Watcher,订阅者会调用patch方法更新虚拟DOM及真实DOM。

以下代码仅包括关键环节,具体细节在此处省略

scss 复制代码
function patch(oldNode,vNode){
    // 如果oldNode不是虚拟节点,则通过emptyNodeAt方法根据oldNode创建虚拟节点
    if(!isVnode(oldNode)){
        oldNode = emptyNodeAt(oldNode);
    }
    // 是否为同一个节点
    if(sameVnode(oldNode,vNode)){
        // 继续进行节点间的diff对比
        patchVnode(oldNode,vNode,insertedVnodeQueue);
    }else{
        // 删除旧节点,创建新节点
        elm = oldNode.elm;
        parent = api.parentNode(elm);
        createElm(vNode,insertedVnodeQueue);
        if(parent !== null){
            removeVnodes(parent,[oldNode],0,0);
        }
    }
}

sameVnode()方法判断是否为同一个节点

vbnet 复制代码
function sameVnode(vnode1,vnode2){
    return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

patchVnode()方法进行两个节点的diff对比

scss 复制代码
function patchVnode(oldVnode,newVnode){
    if(oldVnode === newVnode) return;
    if(newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)){
        // 新虚拟节点有text属性
        if(newVnode.text !== oldVnode.text){
            oldVnode.elm.innerText = newVnode.text;
        }
    }else{
        // 新虚拟节点没有text属性
        if(oldVnode.children !== undefined && oldVnode.children.length > 0){
            // 需要递归处理oldVnode和newVnode的子元素
            updateChildren(oldVnode.children,newVnode.children)
        }else{
            oldVnode.elm.innerHTML = '';
            for(let i=0;i<newVnode.children.length;i++){
                let dom = createElement(newVnode.children[i]);
                oldVnode.elm.appendChild(dom);
            }
        }
    }
}

updateChildren()方法进行子节点的对比

对于同一节点的children元素,需要添加唯一key值进行区分。

节点的对比过程包括四个指针:旧前、旧后、新前、新后

节点的对比流程如下所示:

  • 首尾对比:新前&旧前,新后&旧后
  • 交叉对比:新后&旧前,新前&旧后

如果命中一种查找条件后,不会继续向下执行其他查找条件 如果四个条件均为命中,则需要在oldVnode中遍历查找当前节点

  • 1️⃣/2️⃣命中:删除多余旧节点,创建新节点
  • 3️⃣新后与旧前命中:将新后指针指向的节点移动到老节点的旧后指针的后面
  • 4️⃣新前与旧后命中:将新前指针指向的节点移动到老节点的旧前指针的前面
ini 复制代码
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    var oldStartIdx = 0; // 旧前
    var newStartIdx = 0; // 新前
    var oldEndIdx = oldCh.length - 1; // 旧后
    var oldStartVnode = oldCh[0];
    var oldEndVnode = oldCh[oldEndIdx];
    var newEndIdx = newCh.length - 1; // 新后
    var newStartVnode = newCh[0];
    var newEndVnode = newCh[newEndIdx];
    var oldKeyToIdx, idxInOld, vnodeToMove, refElm;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
            oldStartVnode = oldCh[++oldStartIdx];
        }else if (isUndef(oldEndVnode)) {
            oldEndVnode = oldCh[--oldEndIdx];
        }else if (sameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        }else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        }else if (sameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
            canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        }else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
            canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        }else {
            ... ...
        }
    }
}

2.4 Vue2与Vue3的差异

两者在剩余节点的处理方式上略有不同:

Vue2:Key To Index哈希表

  • 首先进行新老节点头尾对比,头与头、尾与尾对比,寻找未移动的节点

  • 新老节点头尾对比完后,进行交叉对比,头与尾、尾与头对比,寻找移动后可复用的节点

  • 然后在剩余新老节点中对比寻找可复用节点,创建一个旧节点的key To Index哈希表记录key,然后继续遍历新节点索引,通过key查找能复用的旧的节点

  • 节点遍历完成后,通过新老索引,进行移除多余旧节点或者增加新节点的操作

Vue3:剩余节点与旧节点索引哈希表

  • 创建一个映射关系数组,存放新节点数组中的剩余节点旧节点数组索引的映射关系

  • 通过数组直接确定可复用节点,然后通过映射关系表计算最长递增子序列(子序列内所有节点位置均正确,不用更新)

  • 最后将新节点数组中的剩余节点移动到正确的位置

2.5 patch更新

通过diff算法对比新旧虚拟DOM树后,会得到一个patches补丁集合,其数据格式如下所示:

go 复制代码
patches={
    '0':[ // 元素下标
        {
            type:'ATTR', // 补丁类型
            attr:'list-wrap' // 新虚拟DOM较旧虚拟DOM的变化
        }
    ],
    '2':[
        {
            type:'ATTR',
            attr:'list-wrap'
        }
    ],
    ... ...
}

将获取的patches补丁包作用在真实DOM上,完成真实DOM得更新操作。

第三部分 Vue3响应式原理

1. Proxy

Proxy对象用于创建一个普通对象的代理,也可以理解成在对象前面设了一层拦截,包括基本操作的拦截和一些自定义操作(比如一些赋值、属性查找、函数调用等)。

ini 复制代码
var proxy = new Proxy(target, handler);
  • target:目标对象,需要代理的对象
  • handler:代理行为,包括各种操作的拦截函数

2. Reflect

Reflect是es6为操作对象而提供的新API,设计它的目的有:

  • 把Object对象上一些明显属于语言内部的方法放到Reflect对象身上,比如Object.defineProperty

  • 修改某些object方法返回的结果

  • 让Object操作都变成函数行为

  • Reflect对象上的方法和Proxy对象上的方法一一对应,这样就可以让Proxy对象方便地调用对应的Reflect方法

scss 复制代码
Reflect.get(target, propertyKey, receiver)等价于target[propertyKey]

Reflect.get方法查找并返回target对象的propertyKey属性,如果没有该属性,则返回undefined

scss 复制代码
Reflect.set(target, propertyKey, value, receiver)等价于target[propertyKey] = value

Reflect.set方法设置target对象的propertyKey属性等于value

3. Proxy和Reflect的使用

javascript 复制代码
const obj = {
  name: 'win'
}

const handler = {
  get: function(target, key){
    return Reflect.get(...arguments)  
  },
  set: function(target, key, value){
    return Reflect.set(...arguments)
  }
}

const data = new Proxy(obj, handler);

4. 使用Proxy和Reflect完成响应式

reactive是一个返回Proxy对象的函数

javascript 复制代码
function reactive(obj){
  const handler = {
    get(target, key, receiver){
      console.log('get--', key)
      const value = Reflect.get(...arguments);
      if(typeof value === 'object'){
        return reactive(value)
      }else{
        return value
      }
    },
    set(target, key, val, receiver){
      console.log('set--', key, '=', value)
      return Reflect.set(...arguments)
    }
  };
  
  return new Proxy(obj, handler);
}

const data = reactive({name: 'win'});
相关推荐
cs_dn_Jie3 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic3 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿4 小时前
webWorker基本用法
前端·javascript·vue.js
customer084 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
getaxiosluo5 小时前
react jsx基本语法,脚手架,父子传参,refs等详解
前端·vue.js·react.js·前端框架·hook·jsx
理想不理想v6 小时前
vue种ref跟reactive的区别?
前端·javascript·vue.js·webpack·前端框架·node.js·ecmascript
栈老师不回家6 小时前
Vue 计算属性和监听器
前端·javascript·vue.js
前端啊龙7 小时前
用vue3封装丶高仿element-plus里面的日期联级选择器,日期选择器
前端·javascript·vue.js
小远yyds7 小时前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
程序媛小果7 小时前
基于java+SpringBoot+Vue的宠物咖啡馆平台设计与实现
java·vue.js·spring boot