【手写 Vue2.x 源码】第二十八篇 - diff算法-问题分析与patch优化
创始人
2024-05-12 23:03:18
0

一,前言

首先对 6 月更文内容做一下简单的回顾:

  • Vue2.x 源码环境的搭建
  • Vue2.x 初始化流程介绍
  • 对象的单层、深层劫持
  • 数组的单层、深层劫持
  • 数据代理的实现
  • 对象、数组数据变化的观测
  • Vue 数据渲染流程介绍
  • 模板生成 AST 语法树
  • AST 语法树生成 render 函数
  • render 函数生成 Vnode
  • 根据 Vnode 创建真实节点
  • 真实节点替换原始节点
  • Vue2.x 依赖收集的流程分析
  • 依赖收集和视图更新流程(dep 和 watcher 关联)
  • 异步更新流程说明
  • 数组的依赖收集
  • Vue 生命周期和 Mixin 的实现

本篇开始,继续Vue2.x源码的diff算法部分;


二,当前版本存在的问题

1,初始化与更新流程分析

Vue 初始化,会在挂载时调用 mountComponent 方法

// src/init.jsVue.prototype.$mount = function (el) {const vm = this;const opts = vm.$options;el = document.querySelector(el); // 获取真实的元素vm.$el = el; // vm.$el 表示当前页面上的真实元素// 如果没有 render, 看 templateif (!opts.render) {// 如果没有 template, 采用元素内容let template = opts.template;if (!template) {// 拿到整个元素标签,将模板编译为 render 函数template = el.outerHTML;}let render = compileToFunction(template);opts.render = render;}mountComponent(vm);}

在 mountComponent 方法中,会创建一个 watcher

// src/lifeCycle.jsexport function mountComponent(vm) {let updateComponent = ()=>{vm._update(vm._render());  }// 当视图渲染前,调用钩子: beforeCreatecallHook(vm, 'beforeCreate');// 渲染 watcher :每个组件都有一个 watchernew Watcher(vm, updateComponent, ()=>{// 视图更新后,调用钩子: createdcallHook(vm, 'created');},true)// 当视图挂载完成,调用钩子: mountedcallHook(vm, 'mounted');
}

数据更新时,会进入 set 方法

// src/observe/index.jsfunction defineReactive(obj, key, value) {// childOb 是数据组进行观测后返回的结果,内部 new Observe 只处理数组或对象类型let childOb = observe(value);// 递归实现深层观测let dep = new Dep();  // 为每个属性添加一个 depObject.defineProperty(obj, key, {// get方法构成闭包:取obj属性时需返回原值value,// value会查找上层作用域的value,所以defineReactive函数不能被释放销毁get() {if(Dep.target){// 对象属性的依赖收集dep.depend();// 数组或对象本身的依赖收集if(childOb){  // 如果 childOb 有值,说明数据是数组或对象类型// observe 方法中,会通过 new Observe 为数组或对象本身添加 dep 属性childOb.dep.depend();    // 让数组和对象本身的 dep 记住当前 watcherif(Array.isArray(value)){// 如果当前数据是数组类型// 可能数组中继续嵌套数组,需递归处理dependArray(value)}  }}return value;},set(newValue) { // 确保新对象为响应式数据:如果新设置的值为对象,需要再次进行劫持console.log("修改了被观测属性 key = " + key + ", newValue = " + JSON.stringify(newValue))if (newValue === value) returnobserve(newValue);  // observe方法:如果是对象,会 new Observer 深层观测value = newValue;dep.notify(); // 通知当前 dep 中收集的所有 watcher 依次执行视图更新}})
}

此时,会调用 dep.notify() 通知对应的 watcher 调用 update 方法做更新

class Dep {constructor(){this.id = id++;this.subs = [];}// 让 watcher 记住 dep(查重),再让 dep 记住 watcherdepend(){Dep.target.addDep(this);  }// 让 dep 记住 watcher - 在 watcher 中被调用addSub(watcher){this.subs.push(watcher);}// dep 中收集的全部 watcher 依次执行更新方法 updatenotify(){this.subs.forEach(watcher => watcher.update())}
}

在 Watcher 类的 update 方法中,调用了 queueWatcher 方法将 watcher 进行缓存、去重操作

// src/observe/watcher.jsclass Watcher {constructor(vm, fn, cb, options){this.vm = vm;this.fn = fn;this.cb = cb;this.options = options;this.id = id++;   // watcher 唯一标记this.depsId = new Set();  // 用于当前 watcher 保存 dep 实例的唯一idthis.deps = []; // 用于当前 watcher 保存 dep 实例this.getter = fn; // fn 为页面渲染逻辑this.get();}addDep(dep){let did = dep.id;// dep 查重 if(!this.depsId.has(did)){// 让 watcher 记住 depthis.depsId.add(did);this.deps.push(dep);// 让 dep 也记住 watcherdep.addSub(this); }}get(){Dep.target = this;  // 在触发视图渲染前,将 watcher 记录到 Dep.target 上this.getter();      // 调用页面渲染逻辑Dep.target = null;  // 渲染完成后,清除 Watcher 记录}update(){console.log("watcher-update", "查重并缓存需要更新的 watcher")queueWatcher(this);}run(){console.log("watcher-run", "真正执行视图更新")this.get();}
}

queueWatcher 方法:

// src/observe/scheduler.js/*** 将 watcher 进行查重并缓存,最后统一执行更新* @param {*} watcher 需更新的 watcher*/
export function queueWatcher(watcher) {let id = watcher.id;if (has[id] == null) {has[id] = true;queue.push(watcher);  // 缓存住watcher,后续统一处理if (!pending) {       // 等效于防抖nextTick(flushschedulerQueue);pending = true;     // 首次进入被置为 true,使微任务执行完成后宏任务才执行}}
}/*** 刷新队列:执行所有 watcher.run 并将队列清空;*/
function flushschedulerQueue() {// 更新前,执行生命周期:beforeUpdatequeue.forEach(watcher => watcher.run()) // 依次触发视图更新queue = [];       // resethas = {};         // resetpending = false;  // reset// 更新完成,执行生命周期:updated
}

flushschedulerQueue 方法执行时,会调用 watcher 的 run 方法

run 内部调用watcher 的 get 方法,get方法中记录当前 watcher 并调用 getter

this.getter 即 watcher 初始化时传入的视图更新方法 fn,

即 updateComponent 视图渲染逻辑

// src/lifeCycle.jsexport function mountComponent(vm) {let updateComponent = ()=>{vm._update(vm._render());  }// 当视图渲染前,调用钩子: beforeCreatecallHook(vm, 'beforeCreate');// 渲染 watcher :每个组件都有一个 watchernew Watcher(vm, updateComponent, ()=>{// 视图更新后,调用钩子: createdcallHook(vm, 'created');},true)// 当视图挂载完成,调用钩子: mountedcallHook(vm, 'mounted');
}

这样,就会再次执行 updateComponent->vm._render,

会根据当前的最新数据,重新生成虚拟节点,并且再次调用 update

// src/lifeCycle.jsexport function lifeCycleMixin(Vue){Vue.prototype._update = function (vnode) {const vm = this;// 传入当前真实元素vm.$el,虚拟节点vnode,返回新的真实元素vm.$el = patch(vm.$el, vnode);}
}

附一张 Vue 流程图:

vue 流程图.png

2,问题分析与优化思路

update 方法会使用新的虚拟节点重新生成真实 dom,并替换掉原来的dom

在 Vue 的实现中,会做一次 diff 算法优化:尽可能复用原有节点,以提升渲染性能

所以,patch方法即为重点优化对象:

当前的 patch 方法,仅考虑了初始化的情况,还需要处理更新操作
patch 方法需要对新老虚拟节点进行一次比对,尽可能复用原有节点,以提升渲染性能
  • 首次渲染,根据虚拟节点生成真实节点,替换掉原来的节点
  • 更新渲染,生成新的虚拟节点,并和老的虚拟节点进行对比,再渲染

三,模拟新老虚拟节点比对

模拟两个虚拟节点的比对:

  • 生成虚拟节点1
  • 生成虚拟节点2
  • 调用 patch 方法进行新老虚拟节点比对

1,生成第一个虚拟节点

首次,生成虚拟节点后,直接进行挂载

// src/index.js// 1,生成第一个虚拟节点
// new Vue会对数据进行劫持
let vm1 = new Vue({data(){return {name:'Brave'}}
})
// 将模板 render1 生成为 render 函数
let render1 = compileToFunction('
{{name}}
');// 调用 compileToFunction,将模板生成 render 函数,会解析模板,最终包成一个 function // 调用 render 函数,产生虚拟节点 let oldVnode = render1.call(vm1) // oldVnode:第一次的虚拟节点 // 将虚拟节点生成真实节点 let el1 = createElm(oldVnode); // 将真实节点渲染到页面上 document.body.appendChild(el1);

2,生成第二个虚拟节点

// src/index.js// 2,生成第二个虚拟节点
let vm2 = new Vue({data(){return {name:'BraveWang'}}
})
let render2 = compileToFunction('

{{name}}

'); let newVnode = render2.call(vm2);// 延迟看效果:初始化完成显示 el1,1 秒后移除 el1 显示 el2 setTimeout(()=>{let el2 = createElm(newVnode);document.body.removeChild(el1);document.body.appendChild(el2); }, 1000);export default Vue;

3,patch 方法比对新老虚拟节点

patch 方法:将新老虚拟节点进行一次比对,尽可能复用原有节点,以提升渲染性能

节点复用逻辑:标签名和key相同即判定可复用

// 如果标签名一样就复用
// 3,调用 patch 方法进行比对
setTimeout(()=>{// 比对新老虚拟节点的差异,尽可能复用原有节点,以提升渲染性能patch(oldVnode,newVnode); 
}, 1000);

4,查看新老节点

let vm = new Vue({data(){return {name:'Brave'}}
})
let render = compileToFunction('
{{name}}
');/ let oldVnode = render.call(vm) let el = createElm(oldVnode); document.body.appendChild(el);// 数据更新后,再次调用 render 函数 vm.name = 'BraveWang'; let newVnode = render.call(vm);setTimeout(()=>{patch(oldVnode, newVnode); }, 1000);

查看生成的两个真实节点
image.png

接下来开始改造patch方法,以实现节点对比和复用


四,patch 方法优化

1,当前的 patch 方法

当前的 patch 方法仅考虑到初始化的情况,所以每次都会直接替换掉老节点

export function patch(el, vnode) {// 1,根据虚拟节点创建真实节点const elm = createElm(vnode);// 2,使用真实节点替换掉老节点// 找到元素的父亲节点const parentNode = el.parentNode;// 找到老节点的下一个兄弟节点(nextSibling 若不存在将返回 null)const nextSibling = el.nextSibling;// 将新节点elm插入到老节点el的下一个兄弟节点nextSibling的前面// 备注:若nextSibling为 null,insertBefore 等价与 appendChildparentNode.insertBefore(elm, nextSibling); // 删除老节点 elparentNode.removeChild(el);return elm;
}

2,改造 patch 方法

当前patch方法的两个入参分别是:元素和虚拟节点
将虚拟节点创建为真实节点,直接进行元素替换,完成数据更新现在需要将新老虚拟节点进行比对,尽可能复用原有节点,提高渲染性能
所以patch方法需改造为入参是新老虚拟节点:oldVnode、vnode当前的 patch 方法仅考虑到初始化的情况;
现在还需要支持数据更新的情况;
export function patch(oldVnode, vnode) {const elm = createElm(vnode);const parentNode = oldVnode.parentNode;parentNode.insertBefore(elm, oldVnode.nextSibling); parentNode.removeChild(oldVnode);return elm;
}

问题:初渲染 OR 更新渲染?

通过判断 oldVnode.nodeType 节点类型是否为真实节点;
是真实节点,需要进行新老虚拟节点比对
非真实节点,即为真实dom时,进行初渲染逻辑

改造完成后的 patch 方法:

export function patch(oldVnode, vnode) {const isRealElement = oldVnode.nodeType;if(isRealElement){// 真实节点,走老逻辑const elm = createElm(vnode);const parentNode = oldVnode.parentNode;;parentNode.insertBefore(elm, oldVnode.nextSibling); parentNode.removeChild(oldVnode);return elm;}else{// 虚拟节点:做 diff 算法,新老节点比对console.log(oldVnode, vnode)}
}

后边开始针对更新渲染的情况,进行新老虚拟节点的比对,即 diff 算法逻辑


五,结尾

本篇,diff算法问题分析与patch方法改造,主要涉及以下几点:

  • 初始化与更新流程分析;
  • 问题分析与优化思路;
  • 新老虚拟节点比对模拟;
  • patch 方法改造;

下篇,diff 算法-节点比对


维护日志

20210802:添加“四,patch 方法优化”;添加 Vue 执行流程图;更新文章标题和摘要;
20210806:调整布局与格式,修改部分错别字和歧义语句;

相关内容

热门资讯

【NI Multisim 14...   目录 序言 一、工具栏 🍊1.“标准”工具栏 🍊 2.视图工具...
银河麒麟V10SP1高级服务器... 银河麒麟高级服务器操作系统简介: 银河麒麟高级服务器操作系统V10是针对企业级关键业务...
不能访问光猫的的管理页面 光猫是现代家庭宽带网络的重要组成部分,它可以提供高速稳定的网络连接。但是,有时候我们会遇到不能访问光...
AWSECS:访问外部网络时出... 如果您在AWS ECS中部署了应用程序,并且该应用程序需要访问外部网络,但是无法正常访问,可能是因为...
Android|无法访问或保存... 这个问题可能是由于权限设置不正确导致的。您需要在应用程序清单文件中添加以下代码来请求适当的权限:此外...
北信源内网安全管理卸载 北信源内网安全管理是一款网络安全管理软件,主要用于保护内网安全。在日常使用过程中,卸载该软件是一种常...
AWSElasticBeans... 在Dockerfile中手动配置nginx反向代理。例如,在Dockerfile中添加以下代码:FR...
AsusVivobook无法开... 首先,我们可以尝试重置BIOS(Basic Input/Output System)来解决这个问题。...
ASM贪吃蛇游戏-解决错误的问... 要解决ASM贪吃蛇游戏中的错误问题,你可以按照以下步骤进行:首先,确定错误的具体表现和问题所在。在贪...
月入8000+的steam搬砖... 大家好,我是阿阳 今天要给大家介绍的是 steam 游戏搬砖项目,目前...