第1-3章总体概览
vue的3大核心模块
3.3关于组件
组件的本质是一组DOM元素的封装
可以暂定一个函数代表组件,函数的返回值就是组件要渲染的内容,也是虚拟DOM
const MyComponent = function(){return {tag: "div",props: {onClick: () => alert("component")},children: "click"}}
通过这样定义成函数,就可以在renderer渲染器中通过typeof进行判断,类型是组件还是元素。
function renderer(vnode, container){if(typeof vnode.tag === 'string'){// vnode描述的是标签元素mountElement(vnode, container)} else if(typeof vnode.tag === 'function'){// 说明 vnode此时是组件mountComponent(vnode, container)}}
mountElement创建元素
packages/runtime-core/src/renderer.ts
const mountElement = (vnode, container, anchor = null) => {const { props, shapeFlag, type, children } = vnode;let el = (vnode.el = hostCreateElement(type));// 渲染元素属性if (props) {for (let prop in props) {hostPatchProp(el, prop, null, props[prop]);}}if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {hostSetElementText(el, children); //文本节点的创建} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {// 数组,需要进行对children创建挂载mountChildren(children, el);}hostInsert(el, container, anchor);};
mountElement语意化直白实现
function mountElement(vnode, container) {// 使用vnode.tag标签创建DOM元素const el = document.createElement(vnode.tag)// 遍历vnode.props,将事件和属性添加到DOM元素上for (const key in vnode.props) {if (/^on/.test(key)) {// 对以on开头的事件做处理,onClick --> click; vnode.props[key]是事件处理函数el.addEventListener(key.substr(2).toLowerCase(), vnode.props[key])} else{// 其他的属性,直接进行setAttribute设置el.setAttribute(key, vnode.props[key]);}}// 处理childrenif(typeof vnode.children === "string") {// 如果vnode.children是字符串,说明是文本节点el.appendChild(document.createTextNode(vnode.children))}else if(Array.isArray(vnode.children)){// 是数组,递归调用mountElement函数,渲染出子节点vnode.children.forEach(child => mountElement(child, el))}container.appendChild(el)}
mountComponent挂载组件
由于vnode.tag是函数,返回值是虚拟DOM,首先获取到该函数的值const subTree = vnode.tag();这样subTree也是虚拟dom。再次递归调用renderer渲染器
function mountComponent(vnode, container){// 调用组件函数,获取到函数的返回值,即虚拟DOMconst subTree = vnode.tag();// 递归调用renderer渲染 subTreerenderer(subTree, container)}
3.4编译器-处理模版
vue的一大核心就是可以编写template模版,便于开发。
编译器是处理模版,让模板编译成渲染函数。以.vue文件为例
编译器把template模版的内容编译出渲染函数,并添加到script标签块的组件对象上。
无论是模板还是渲染函数render,对于一个组件来说,渲染的内容都是通过渲染函数产生。然后渲染器把虚拟DOM渲染为真实DOM。
第4-6章响应式
4.响应式系统
4.1-4.3副作用effect
effect副作用函数,会直接或间接影响其他函数的执行
响应式数据,当更新该数据后,依赖该数据进行显示的都会同步更新。那么这个数据就是响应式的,在vue2中使用Object.defineProperty(只能代理对象上的属性)拦截get/set方法进行依赖的收集和派发。vue3中采用Proxy,可以代理整个对象。
全局变量activeEffect
为了解决副作用函数命名,定义了个全局变量activeEffect(初始值为undefined),作用是存储被注册的副作用函数。
// -----------定义effect-----------let activeEffect = undefined;function effect(fn){// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffectactiveEffect = fn;// 执行副作用函数fn();}// -----------使用 effect函数-----------// 用一个匿名的副作用函数作为effect函数的参数effect(()=>{document.body.innerText = 'hello'})
target/key/effect对应关系
此时存在问题,如果更改了响应式对象obj.other属性,那么effect也会再次执行,显然不符合逻辑。
需要建立三个角色的对应关系
- target:被代理的对象
- key:被操作的属性
- effect:要执行的副作用函数
对应关系为
根据上图对应关系,构建出数据结构,我们分别使用WeakMap存target,用Map存key,用Set存effect
- WeakMap 由 target —-> Map 构成
- Map 由 key —-> Set 构成

new Proxy(data, {get(target, key) {// 将 activeEffect 存储的副作用函数收集到deps中if (!activeEffect) return target[key];// 获取 target为索引的 depsMap,它是Map类型: key --> effects 结构let depsMap = targetMap.get(target);if (!depsMap) {targetMap.set(target, (depsMap = new Map()));}let deps = depsMap.get(key);if (!deps) {depsMap.set(key, (deps = new Set()));}deps.add(activeEffect);return target[key];},set(target, key, value) {target[key] = value;const depsMap = targetMap.get(target);if (!depsMap) return;const effects = depsMap.get(key);effects && effects.forEach((fn) => fn());return true;}});
4.4分支切换和cleanup
const data = {ok: true, text: "hello vue3!"}const obj = new Proxy(data, { ... })effect(()=>{// 没有第9行的清理,effect run会被打印3次console.log("effect run");document.getElementById("app").innerText = obj.ok ? obj.text : "not";})
代码链接
当effect函数内存在三元表达式,分支切换可能会遗留下副作用函数。
解决方案:在每次副作用函数执行时,先把它从所有的关联依赖集合中删除。
- 要将副作用函数[activeEffect]从所有与之关联的依赖集合[deps]移除,需要知道哪些依赖集合[deps]包含它
- 重新设计副作用函数,在副作用函数内部,添加deps属性[是数组]用来存储该副作用的相关联依赖集合

let activeEffect = undefined;function effect(fn) {const effectFn = () => {cleanup(effectFn)// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffectactiveEffect = effectFn;// 执行副作用函数fn();};// 新增deps属性,用来存储所有包含当前副作用函数的依赖集合effectFn.deps = [];// 执行副作用函数effectFn();}// 清除副作用相关联的依赖集合depsfunction cleanup(effectFn) {// effectFn.deps是数组类型for (let i = 0; i < effectFn.deps.length; i++) {const deps = effectFn.deps[i]; // deps是集合Set类型deps.delete(effectFn); // 清除掉关联依赖集合内的所有副作用}// 然后才能设置deps为[]effectFn.deps.length = 0;}
cleanup函数接收副作用函数作为参数,遍历副作用函数的effect.deps数组,该数组的每项都是依赖集合deps。
然后将该副作用从依赖集合中移除。
处理trigger内部的无限循环执行
trigger函数内部,遍历effects集合,里面存放着副作用函数,当副作用函数执行时,会调用cleanup清除effects集合中的当前执行的副作用函数。但是副作用函数的执行会导致activeEffect重新被收集到集合中。
在调用forEach遍历Set集合时,如果一个值已经被访问过,但该值被删除并重新添加到集合,此时forEach遍历还没有结束,那么该值会被重新访问。forEach遍历会无限循环
const s = new Set([1]);s.forEach(item=>{s.delete(1);s.add(1);console.log('run')})
const s = new Set([1]);const newS = new Set(s);newS.forEach(item=>{s.delete(1);s.add(1);console.log('run')})
所以就有了trigger函数中的72,73行代码
代码参考
4.5嵌套的effect【effect栈结构】
effect是可以嵌套的
const data = {foo:true, bar: true, text: 'hello vue3'}effect(function effect1() {console.log("effect1 run");effect(function effect2() {console.log("effect2 run");temp2 = obj.bar;});temp1 = obj.foo;});
当修改obj.foo的值时,会输出结果:
"effect1 run""effect2 run""effect2 run"
effect2被执行2次,显然不符合预期。
代码示例
问题出现在effect和activeEffect的关系上,使用activeEffect来存储effect函数注册的副作用函数,意味着同一个时刻只能有一个activeEffect,当副作用发生嵌套,内层副作用effect会覆盖activeEffect,并且不会恢复原值。即使再有响应式数据进行依赖收集,收集的副作用函数也是内层的副作用函数。
为了解决effect嵌套问题,需要建立个副作用函数栈effectStack,在副作用函数执行时,将前副作用函数压入栈中,副作用执行完毕将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。
let activeEffect = undefined;// effect栈结构,存在effectlet effectStack = []function effect(fn) {const effectFn = () => {// 调用cleanup函数 完成清除副作用相关联的依赖集合depscleanup(effectFn);// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffectactiveEffect = effectFn;effectStack.push(effectFn);// 执行副作用函数fn();effectStack.pop();activeEffect = effectStack[effectStack.length -1]};// 新增deps属性,用来存储所有包含当前副作用函数的依赖集合effectFn.deps = [];// 执行副作用函数effectFn();}
4.6避免无限递归循环
const data = {count:1}const obj=new Proxy(data, {})effect(() => obj.count++ ) // 副作用的自增操作,会引起栈溢出
问题出现:数据的读取和设置操作在同一个副作用函数内进行。此时无论是track收集的副作用函数,还是trigger是触发执行的副作用函数,都是activeEffect。
如果在trigger触发执行副作用函数与当前正在执行的副作用函数相同,则不触发执行。
// 更新依赖function trigger(target, key) {const depsMap = targetMap.get(target);if (!depsMap) return;const effects = depsMap.get(key);//为了避免重复添加删除,造成死循环const effectsToRun = new Set();effects && effects.forEach(item=>{// trigger触发的副作用函数 与 当前正在执行的副作用函数相同,则不触发更新if(item !== activeEffect){effectsToRun.add(item)}})effectsToRun.forEach((fn) => fn());}
⭐️4.7scheduler 调度执行
- 目前副作用的执行不受控制,现在会离开执行,并且会重复执行,为了解决这个问题,使用scheduler
- 可调度性是响应式系统非常重要的特性。
vue中的computed和watch实现都依赖scheduler。
effect(() => {console.log("run effect", obj.count);});obj.count++;console.log("over");// 打印出//run effect 1//run effect 2//over
如果希望over打印显示在第二行,此时只能用户端调整打印顺序到effect上边。
代码演示控制执行时机
通过给effect副作用函数添加options,设置scheduler调度
function effect(fn, options = {}) {const effectFn = () => {// 调用cleanup函数 完成清除副作用相关联的依赖集合depscleanup(effectFn);// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffectactiveEffect = effectFn;effectStack.push(effectFn);// 执行副作用函数fn();effectStack.pop();activeEffect = effectStack[effectStack.length - 1];};// 可以自定义执行规则,添加schedulereffectFn.options = options;// 新增deps属性,用来存储所有包含当前副作用函数的依赖集合effectFn.deps = [];// 执行副作用函数effectFn();}
然后在trigger触发更新时判断是否有调度规则,如果有,则执行调度函数,并把副作用effec作为参数传递
function trigger(target, key) {const depsMap = targetMap.get(target);if (!depsMap) return;const effects = depsMap.get(key);//为了避免重复添加删除,造成死循环const effectsToRun = new Set();effects &&effects.forEach((effect) => {if (effect !== activeEffect) {effectsToRun.add(effect);}});effectsToRun.forEach((fn) => {// 如果用户使用的effect有 scheduler 配置,则走调度逻辑if (fn.options.scheduler) {fn.options.scheduler(fn);} else {fn();}});}
经过设置,再次调用effect,就可以随意控制后续effect的执行时机
effect(() => {console.log("run effect", obj.count);},{scheduler(fn) {setTimeout(fn);}});obj.count++;console.log("over");
控制执行次数
下面的副作用执行,会重复执行4次,但是中间2次只是过度过程,用户并不关心。可以通过scheduler控制中间的过程不显示。
effect(() => {console.log("run effect", obj.count);});obj.count++;obj.count++;obj.count++;
创建一个任务执行队列
const jobQueue = new Set();const p = Promise.resolve();let isFlushing = false;function flushJob(){if(isFlushing) return;isFlushing = true;p.then(()=>{jobQueue.forEach(job => job())}).finally(()=>{isFlushing = false;})}effect(() => {console.log("run effect", obj.count);},{scheduler(fn) {jobQueue.add(fn);flushJob();}});
通过定义jobQueue任务队列,将正在执行的副作用添加到任务队列中,利用Promise的微任务执行,可以等到所有的effect副作用都添加完毕后,在一次执行所有的副作用函数。由于jobQueue时Set数据结构,所以存储的只有一个effect,就是当前执行的副作用函数。这样就简单实现多个同步任务,只执行最后一次。
⭐️4.8计算属性computed
处理立即执行问题
目前创建的effect副作用都是立即执行的,如果有些场景不希望立即执行,而是在它需要的时候才执行。例如计算属性,只有被依赖的值发生变化,副作用才会执行。这时就可以通过effect的options中的lazy属性完成。
effect(()=>{console.log(obj.foo);}, {lazy: true // 通过指定lazy属性,设置effect不立即执行})
修改effect的实现逻辑
let activeEffect = undefined;// effect栈结构,存在effectlet effectStack = [];function effect(fn, options = {}) {const effectFn = () => {// 调用cleanup函数 完成清除副作用相关联的依赖集合depscleanup(effectFn);// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffectactiveEffect = effectFn;effectStack.push(effectFn);// 执行副作用函数fn();effectStack.pop();activeEffect = effectStack[effectStack.length - 1];};// 可以自定义执行规则,添加schedulereffectFn.options = options;// 新增deps属性,用来存储所有包含当前副作用函数的依赖集合effectFn.deps = [];/** 计算属性 🇭相关**/if (!options.lazy) {// 只有在非lazy属性时,才会执行effectFneffectFn();}// 默认情况下,只返回副作用函数,并不会执行return effectFn;}
修改effect后,如果传递的options中有参数lazy:true,则不立即执行。
代码演示 默认只有effect第一次执行,后边需要手动调用。
如果仅仅满足手动执行副作用,也没太大用途。可以把effect内的函数作为一个getter,这个getter函数可以返回任何值。
调整effect函数,通过对effectFn进行包装,effectFn是包装后的副作用,此包装副作用的返回值才是真正的副作用。代码第16行。如果是lazy的情况下,只是返回副作用的函数第29行,并不会执行第26行。只有在非lazy的情况下,才能返回包装副作用effectFn函数的执行结果,从而才能执行真正的副作用函数fn。 ```typescript let activeEffect = undefined; // effect栈结构,存在effect let effectStack = []; function effect(fn, options = {}) { // 通过effectFn进行了对fn的一层包装,可以处理不立即执行的情况。 const effectFn = () => { // 调用cleanup函数 完成清除副作用相关联的依赖集合deps cleanup(effectFn); // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect activeEffect = effectFn; effectStack.push(effectFn); // 将fn的执行结果存储到res中 const res = fn(); effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; // 将res作为effectFn的返回值。 return res; }; // 可以自定义执行规则,添加scheduler effectFn.options = options; // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合 effectFn.deps = [];/ 计算属性 🇭相关/ if (!options.lazy) { // 只有在非lazy属性时,才会执行effectFn effectFn(); } // 默认情况下,只返回副作用函数,并不会执行 return effectFn; }
// computed计算属性的定义 function computed(getter) { // 把getter作为副作用函数,创建个lazy的effect const effectFn = effect(getter, { lazy: true }); const obj = { // 当读取value 值时,才执行effectFn get value() { return effectFn(); } }; return obj; }
使用computed```typescriptconst data = { foo: 1, bar: 2 };const obj = new Proxy(data, {/** */})const sum = computed(() => obj.foo + obj.bar);console.log(sum, "sum");
处理缓存问题
如果多次访问sum.value的值,即使obj.foo和obj.bar没有变化,也会导致effectFn进行多次计算。为了解决这个问题,需要在computed函数添加对值的缓存功能。
// computed计算属性的定义function computed(getter) {// 用value缓存上次计算的值let value;// 判断是否需要重新计算,dirty为true才重新计算let dirty = true;// 把getter作为副作用函数,创建个lazy的effectconst effectFn = effect(getter, {lazy: true,scheduler() {// 在调度器中将dirty 设置为truedirty = true;}});const obj = {// 当读取value 值时,才执行effectFnget value() {if (dirty) {value = effectFn();dirty = false;}return value;}};return obj;}
通过设置dirty,控制是否重新执行effectFn。然后又在effect的options中添加scheduler调度函数,该调度函数会在所依赖的响应式数据变化时执行,同时将dirty设置为true,下次进行计算就能获取到最新值。
代码示例
⭐️4.9watch属性
watch本质是观察一个响应式数据,当数据发生变化,执行对应的回调函数。
利用effect和options.scheduler选项实现
effect(()=>{console.log(obj.foo)},{scheduler(){// 当obj.foo发生变化,会执行这里的内容}})
watch的实现就是依赖effect中的scheduler,当响应式数据发生变化,如果副作用函数存在scheduler选项,则触发scheduler函数执行,而不是直接触发副作用函数执行。 依据这一特性,简单实现watch
// watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数function watch(source, cb){effect(()=> source.foo,{scheduler(){cb()}})}
const data = {foo:1}const obj = new Proxy(data, { /* */})watch(obj, ()=>{console.log("foo的数据变化了")})obj.foo++;
观察对象的属性
上面通过source.foo硬编码实现对对象foo的监测,为了让watch具有通用行,需要封装一个通用的读取操作:
// watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数function watch(source, cb) {let getter;// 如果第一个参数是函数,直接执行函数,读取返回值if (typeof source === "function") {getter = source;} else {// 不是函数,则读取对象的多个属性getter = () => traverse(source);}// 通过traverse来读取source的值effect(() => getter(source), {scheduler() {// 当数据变化时,执行cbcb();}});}function traverse(value, seen = new Set()) {// 如果是原始数据 或者已经读取过, 不进行处理if (typeof value !== "object" || value === null || seen.has(value)) return;// 将数据添加到seen中,表示已经读取过,避免循环引用,陷入死循环seen.add(value);// 假设观察的是对象,使用for in 读取数据的每各属性值,递归调用traversefor (let key in value) {traverse(value[key], seen);}return value;}
通过traverse函数,对传入的第一个对象进行监听。如果第一个参数传入的是函数,只监听该函数返回值;如果传入的是对象,则监听对象上的所有属性,通过traverse递归操作。
代码实例获取新值newval和旧值oldval
在使用watch时,经常使用newValue和oldValue值做对比,然后再进行下一步操作。但是上面的cb回调函数并没有传递任何参数。接下来就将newValue和oldValue通过cb传递给用户端使用。
修改watch的实现:在14行,将effect副作用函数保存为effectFn变量,第26行,手动执行effectFn函数得到的返回值就是oldval,即第一次执行的结果。// watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数function watch(source, cb) {let getter;// 如果第一个参数是函数,直接执行函数,读取返回值if (typeof source === "function") {getter = source;} else {// 不是函数,则读取对象的多个属性getter = () => traverse(source);}let newVal, oldVal;// 通过traverse来读取source的值// 通过options的lazy属性,把返回值存储到effectFn中const effectFn = effect(() => getter(), {lazy: true,scheduler() {// scheduler重新执行副作用函数,得到最新值newVal = effectFn();// 将新值和旧值作为cb的参数cb(newVal, oldVal);// 更新下 旧值,否则下次得到的旧值会是错误的oldVal = newVal;}});// 手动调用副作用函数,得到旧值oldVal = effectFn();}function traverse(value, seen = new Set()) {// 如果是原始数据 或者已经读取过, 不进行处理if (typeof value !== "object" || value === null || seen.has(value)) return;// 将数据添加到seen中,表示已经读取过,避免循环引用,陷入死循环seen.add(value);// 假设观察的是对象,使用for in 读取数据的每各属性值,递归调用traversefor (let key in value) {traverse(value[key], seen);}return value;}
watch(() => obj.foo,(nv, ov) => {console.log("foo的数据变化了", nv, ov); // 2 1});obj.foo++;
4.10立即执行 watch
立即执行回调函数
- 回调函数的执行时机
watch的实现,使用了options的lazy属性,所以不会立即执行。为了能够让watch的回调函数在创建时立刻执行一次,可以给watch添加第三个参数 immediate: true;
/ watch函数接收3个参数,// source是响应式数据,cb是数据变化执行的回调函数,options设置执行时机function watch(source, cb, options = {}) {let getter;// 如果第一个参数是函数,直接执行函数,读取返回值if (typeof source === "function") {getter = source;} else {// 不是函数,则读取对象的多个属性getter = () => traverse(source);}let newVal, oldVal;// 将scheduler调度函数,封装成 job 函数const job = () => {newVal = effectFn();// 当数据变化时,执行cbcb(newVal, oldVal);oldVal = newVal;};// 通过traverse来读取source的值const effectFn = effect(() => getter(), {lazy: true,scheduler: job});if (options.immediate) {// immediate属性为真,自动执行下job任务job();} else {oldVal = effectFn();}}
除了给watch的第三个参数options设置immediate还可设置flush来控制回调函数的执行时机。
watch(() => obj.foo,(nv, ov) => {console.log("foo的数据变化了", nv, ov); // 2 1},{// immediate : true,回调函数会立即执行一次flush: 'post'});obj.foo++;
- post : 回调函数需要将副作用函数放到微任务队列中
- sync:实现同步执行
pre: 组件更新前执行 ```typescript function watch(source, cb, options = {}) { let getter; // 如果第一个参数是函数,直接执行函数,读取返回值 if (typeof source === “function”) { getter = source; } else { // 不是函数,则读取对象的多个属性 getter = () => traverse(source); } let newVal, oldVal; // 将scheduler调度函数,封装成 job 函数 const job = () => { newVal = effectFn(); // 当数据变化时,执行cb cb(newVal, oldVal); oldVal = newVal; };
// 通过traverse来读取source的值 const effectFn = effect(() => getter(), { lazy: true, scheduler: () => {
if (options.flush === "post") {const p = Promise.resolve();p.then(job);} else {job();}
} });
if (options.immediate) { // immediate属性为真,自动执行下job任务 job(); } else { oldVal = effectFn(); } }
/ 使用watch / watch( () => obj.foo, (nv, ov) => { console.log(“foo的数据变化了”, nv, ov); }, { flush: “post” //添加上,则在out 之后打印 } ); obj.foo++; console.log(“out 同步执行函数”);
<a name="vmzwu"></a>### 4.11过期的副作用,可以被取消正在执行的副作用,要能够被取消,否则会发生[“竞态”问题](https://juejin.cn/post/6844903863749705741)。该问题可以在原始的xhr的abort中解决,也可在axios封装的 isCancel 中取消请求。<br /><br />因此需要一种让副作用过期的技术。<br />watch的**回调函数**现在接收到newValue和oldvalue2个参数,通过**设置第3个参数 onInvalidate 函数**,这个函数类似事件监听器。使用onInvalidate注册回调函数,该回调函数在当前副作用函数过期时执行。```typescriptfunction delay(time) {return new Promise((resolve) => {setTimeout(() => {resolve(time);}, time);});}
// watch函数接收3个参数,// source是响应式数据,cb是数据变化执行的回调函数,options设置执行时机function watch(source, cb, options = {}) {let getter;// 如果第一个参数是函数,直接执行函数,读取返回值if (typeof source === "function") {getter = source;} else {// 不是函数,则读取对象的多个属性getter = () => traverse(source);}let newVal, oldVal;// cleanup 存储用户注册的过期回调let cleanup;// onInvalidate 函数function onInvalidate(fn) {// 将过期回调 存储到cleanupcleanup = fn;}// 将scheduler调度函数,封装成 job 函数const job = () => {newVal = effectFn();if (cleanup) {cleanup();}// 将onInvalidate作为回调的第3个参数,以便用户使用cb(newVal, oldVal, onInvalidate);oldVal = newVal;};// 通过traverse来读取source的值const effectFn = effect(() => getter(), {lazy: true,scheduler: () => {if (options.flush === "post") {const p = Promise.resolve();p.then(job);} else {job();}}});if (options.immediate) {// immediate属性为真,自动执行下job任务job();} else {oldVal = effectFn();}}
在watch中首先定义cleanup变量,用来存储用户通过onInvalidate函数注册的回调。
在job函数内,每次执行回调函数cb之前,先检查是否存在过期的回调,如果存在,则执行过期回调函数cleanup
最后把onInvalidate回调函数作为第三个参数传递给cb。
测试onInvalidate,
- 通过模拟发生接口请求,后边的接口请求返回速度快,
- 如果没有onInvalidate函数,则结果会显示为前一次接口返回的结果。这是错误的
- 通过设置onInvalidate函数,把上次的副作用函数给取消掉,就不会发生前次接口值覆盖最新接口值的情况。 ```typescript
let finalData; let initTime = 2200; watch( () => obj.foo, async (newVal, oldVal, onInvalidate) => { console.log(“foo的数据变化了”); let flag = false;
onInvalidate(() => {flag = true;});// 模拟后边发送接口请求,比上次的提前返回initTime = initTime - 1000;const res = await delay(initTime);if (!flag) {finalData = res;// onInvalidate生效,则会显示第二次请求的结果,不会被前一次结果覆盖// 如果注释掉 onInvalidate,则最终会显示 第一次发生的请求document.getElementById("app").innerHTML = finalData;}console.log("watch 内 finalData", finalData);
} ); obj.foo++; obj.foo++;
[代码实例](https://codesandbox.io/s/vue-design-15bzo9?file=/4part/4.11.js)<a name="fr1eR"></a>## 5.对象类型的响应式方案reactive/proxy<a name="MmNBi"></a>### 5.1理解Proxy和Reflect对象- Proxy只能代理对象类型- 代理是指,能够对对象的基本操作进行拦截,通过上面虚线定义的那些方法处理对象。- Proxy只能拦截对象的基本操作。复合操作处理不了,如obj.foo();```typescriptfunction fn(name) {console.log(`my name is ${name}, ${this.name}`);}const p = new Proxy(fn, {// 使用apply 拦截函数的调用apply(target, thisArg, argArray) {console.log(thisArg, argArray, "apply调用函数");target.call(thisArg, argArray);}});p.call({ name: "CallName" }, "北鸟南游"); // my name is 北鸟南游, CallName
Reflect下的方法和Proxy的拦截器方法名称相同,任何通过Proxy拦截的方法都能在Reflect中找到。Reflect的重要意义在于receiver参数,可以理解为函数调用过程中的this。通过改变receiver,可以调整getter中的this。
Reflect对象中的receiver重要性
const Obj = {get count() {return this.c;}};console.log(Reflect.get(Obj, "count", { c: 99 })); //99const po = new Proxy(Obj, {get(target, key, receiver) {if (key === "c") return 6;// return target[key]; //获取不到count的值,target找不到creturn Reflect.get(target, key, receiver); //通过recevier可以改变属性访问器getter的this}});console.log(po.count, "count"); // 6
在getter属性访问器内,通过target[key]返回属性值,此时target是原始对象Obj,key是count,第11行相当于获取Obj.count。当打印po.count即访问count属性时,getter内的this指向原来的Obj对象,此时Obj下不存在属性c。所以用第11行,结果返回的是undefined。
当使用Reflect,并且要传递第三个参数receiver。那么此时的po.count,访问po代理对象的count属性时,recever就是po,访问器属性count的getter函数内的this就是代理对象po。当key为c时结果就会返回 6
5.2js对象及Proxy工作原理
js对象分为:常规对象(ordinary object)和异质对象(exotic object);
在js中对象的实际语意是由对象的内部方法(internalmethod)指定的,内部方法是当对一个对象进行操作时,在引擎内部调用的方法,这些方法对于我们使用者不可见。

以上2个表中定义了14个内部方法,ECMAScript 定义的内部方法。
在js中,一个对象必须包括table5中的12个必要的内部方法。table6中的 [[Call]] 和 [[Construct]]是对象作为函数调用必须包含的内部方法。
- 常规对象是内部方法必须是9.1表中定义实现。
- 对象的内部方法有重新改写定义9.2-9.5定义的对象,则是异质对象。
- Proxy对象的内部方法[[Get]]就有新定义,所以是异质对象。
创建代理对象时的拦截方法,实质上是自定义代理对象本身的内部方法和行为。
const obj = { foo: 1 };const po = new Proxy(obj, {deleteProperty(target, key) {return Reflect.deleteProperty(target, key);}});console.log(po.foo); // 1delete po.foo;console.log(po.foo); // undefined
5.3如何代理对象
前面一直使用get拦截对象属性的读取,但在响应系统中,读取是一个很宽泛概念,使用in操作符检查对象上是否具有给定的key也是读取操作。一个普通对象的所有读取操作可能有:
- 访问属性:obj.foo
- 判断对象或原型上是否存在给定的key: key in obj
- 使用for… in 遍历对象: for(const key in obj) {}
第一种情况,可以直接使用get进行拦截。如果使用了in操作符,就需要查看对应的拦截函数。
可以看到in操作符运算结果是通过HasProperty的抽象方法得到。关于HasProperty 抽象方法可以看到内部对应的拦截函数是has。
in操作符使用has进行拦截。
通过查找for… in的规范,可以看到是通过ownKeys进行拦截。
function* enumerate(obj) {let visited=new Set;for (let key of Reflect.ownKeys(obj)) {if (typeof key === "string") {let desc = Reflect.getOwnPropertyDescriptor(obj,key);if (desc) {visited.add(key);if (desc.enumerable) yield key;}}}let proto = Reflect.getPrototypeOf(obj)if (proto === null) return;for (let protoName of Reflect.enumerate(proto)) {if (!visited.has(protoName)) yield protoName;}}
拦截ownKeys操作即可间接拦截for…in循环。
由于ownKeys,只能获取到目标对象target,没有传入key参数。 在track函数中需要key值,通过 const ITERATE_KEY = Symbol(); 作为key值。
const obj = { count: 1 };const po = new Proxy(obj, {// 拦截读取操作get(target, key, receiver) {// 将副作用函数 activeEffect 添加到存储副作用函数的桶中track(target, key);// 返回属性值return Reflect.get(target, key, receiver);},// 拦截设置操作set(target, key, newVal) {// 设置属性值target[key] = newVal;trigger(target, key);},has(target, key) {track(target, key);return Reflect.has(target, key);},ownKeys(target) {//将副作用函数 与 ITERATE_KEY 关联track(target, ITERATE_KEY);return Reflect.ownKeys(target);},deleteProperty(target, key) {// 检查被删除的属性是否是对象自身的属性const hadKey = Object.prototype.hasOwnProperty.call(target, key);// 使用Reflect.deleteProperty 完成属性删除const res = Reflect.deleteProperty(target, key);if (res && hadKey) {trigger(target, key, "DELETE");}}});effect(() => {// console.log(po);for (const key in po) {console.log("key", key);}});po.bar = 2;
po原来只有count属性,因此for…in循环一次,第42行给它添加了新属性bar,所以for…in循环就会由执行1次变成2次。也就是说当为对象添加属性时,需要触发ITERATE_KEY相关联的副作用重新执行。
给trigger方法添加 ITERATE_KEY相关的副作用函数
function trigger(target, key) {const depsMap = targetMap.get(target);if (!depsMap) return;const effects = depsMap.get(key);//为了避免重复添加删除,造成死循环const effectsToRun = new Set();effects &&effects.forEach((effect) => {if (effect !== activeEffect) {effectsToRun.add(effect);}});// 删除操作会影响for...in循环次数// if (type === "ADD" || type === "DELETE") {// 取到与 ITERATE_KEY 相关的副作用函数const iterateEffects = depsMap.get(ITERATE_KEY);iterateEffects &&iterateEffects.forEach((effectFn) => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn);}});// }effectsToRun.forEach((fn) => {// 如果用户使用的effect有 scheduler 配置,则走调度逻辑if (fn.options.scheduler) {fn.options.scheduler(fn);} else {fn();}});}
区分是新增属性还是更新设置属性?
按照上面给po新增了bar属性,effect副作用内的for…in会重新执行。但是更新po.count =2时,for…in也会重新执行。这样违背了修改属性不会对for…in循环产生影响。
在更新属性时,不需要多for…in产生影响,应该在Proxy的set方法中进行判断,是新增属性还是设置属性。
const type = Object.prototype.hasOwnProperty.call(target, key)? "SET": "ADD";
检查当前操作属性key是否存在目标对象上,如果存在,则是“SET”修改属性,否则是新增属性。可以把该参数传递给trigger。
// 更新依赖function trigger(target, key, type) {const depsMap = targetMap.get(target);if (!depsMap) return;const effects = depsMap.get(key);//为了避免重复添加删除,造成死循环const effectsToRun = new Set();effects &&effects.forEach((effect) => {if (effect !== activeEffect) {effectsToRun.add(effect);}});// 删除操作会影响for...in循环次数if (type === "ADD") {// 取到与 ITERATE_KEY 相关的副作用函数const iterateEffects = depsMap.get(ITERATE_KEY);iterateEffects &&iterateEffects.forEach((effectFn) => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn);}});}effectsToRun.forEach((fn) => {// 如果用户使用的effect有 scheduler 配置,则走调度逻辑if (fn.options.scheduler) {fn.options.scheduler(fn);} else {fn();}});}
只有在“ADD”时,才触发与 ITERATE_KEY 相关的副作用函数重新执行。代码实例
代理对象的删除操作
删除对象自身的属性,如果删除成功,则会影响for…in的遍历,也会触发effect副作用。
因此需要检查被删除的属性是否属于自身const hadKey=Object.prototype.hasOwnProperty.call(target, key);,然后调用Reflect.deleteProperty(target, key);完成属性的删除。
const po = new Proxy(obj, {deleteProperty(target, key) {// 检查被删除的属性是否是对象自身的属性const hadKey = Object.prototype.hasOwnProperty.call(target, key);// 使用Reflect.deleteProperty 完成属性删除const res = Reflect.deleteProperty(target, key);if (res && hadKey) {trigger(target, key, "DELETE");}return res;}});
操作类型type为“DELETE”也应该触发与 ITERATE_KEY 相关联的副作用函数重新执行。
function trigger(target, key, type) {const depsMap = targetMap.get(target);if (!depsMap) return;const effects = depsMap.get(key);//为了避免重复添加删除,造成死循环const effectsToRun = new Set();effects &&effects.forEach((effect) => {if (effect !== activeEffect) {effectsToRun.add(effect);}});console.log(type, key);// 删除操作会影响for...in循环次数if (type === "ADD" || type === "DELETE") {// 取到与 ITERATE_KEY 相关的副作用函数const iterateEffects = depsMap.get(ITERATE_KEY);iterateEffects &&iterateEffects.forEach((effectFn) => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn);}});}effectsToRun.forEach((fn) => {// 如果用户使用的effect有 scheduler 配置,则走调度逻辑if (fn.options.scheduler) {fn.options.scheduler(fn);} else {fn();}});}
最后可以测试,删除自身属性foo及非自身属性bar的区别。删除bar不会再次触发effect副作用函数执行。
代码实例
5.4合理的触发响应
NaN引起的不必要更新
为了监听更新而触发副作用,以上解决方法面临第一个问题,设置的值没有变化,也触发副作用。
const obj = {foo:1};const p = new Proxy(obj, { //...})effect(()=>{console.log(p.foo)})p.foo = 1; //设置值,但是没更新,仍然触发了effect副作用执行。
代码示例
为了解决这个问题,可以修改set拦截函数的代码,在调用trigger函数触发响应前,判断值是否发生变化。
const p = new Proxy(obj, {//...set(target, key, newVal, receiver) {// 先存储下旧值const oldVal = target[key];const res = Reflect.set(target, key, newVal, receiver);// 比较新值和旧值,只有在不相等的时候才触发响应if (oldVal !== newVal) {trigger(target, key);}return res;},//...})
代码示例, 经过改造后,设置的值没有变化,就不触发effect更新。
上面使用了全等进行对比,在处理NaN时会有bug,因为NaN永远不等NaN,那么也会进行更新。所以还需要排除掉NaN数据。
NaN === NaN; // falseNaN !== NaN; // true
继续修改setter操作函数。
const p = new Proxy(obj, {//...set(target, key, newVal, receiver) {// 先存储下旧值const oldVal = target[key];const res = Reflect.set(target, key, newVal, receiver);// 比较新值和旧值,只有在不相等的时候才触发响应;并且不是NaNif (oldVal !== newVal && (oldVal ===oldVal || newVal === newVal) ) {trigger(target, key);}return res;},//...})
屏蔽原型链引起副作用更新
先把创建代理对象封装成通用的方法 reactive。这样可以方便创建多个代理对象。
const obj = { foo: 1 };const child = reactive(obj);const parent = reactive({ bar: 2 });//设置parent 为child的原型Object.setPrototypeOf(child, parent);console.log("判断obj的原型是不是parent", Object.getPrototypeOf(obj) === parent);effect(() => {console.log(child.bar);});child.bar = 3; //这里的修改,会触发2次effect的执行
- 给child设置了parent作为原型。
- child和parent都是响应式对象
- 修改child.bar属性,由于child自身上没有bar属性,会找到原型对象parent上。parent也是响应式对象,从而就触发了2次effect。
代码示例
解决办法:既然是执行2次,那么只要屏蔽掉一次就可以。两次更新都是在set拦截函数中触发,因此需要在拦截函数set中设置触发更新的条件。
// child 的set拦截函数set(target, key, newVal, receiver){// target是原始对象 obj// receiver是代理对象 child}// parent 的set拦截函数set(target, key, newVal, receiver){// target是原始对象 原型proto 即parent// receiver是代理对象 child}
可以发现,target在两次代理过程中是发生变化的,receiver是不变的。可以通过给receiver设置一个”raw”属性让它为原来的对象obj;
child.raw === obj; //trueparent.raw === obj; // false
修改reactive的getter和setter拦截函数
function reactive(obj) {return new Proxy(obj, {// 拦截读取操作get(target, key, receiver) {if (key === "raw") { // 设置raw属性,访问该属性时,获取到被代理的原始值return target;}// 将副作用函数 activeEffect 添加到存储副作用函数的桶中track(target, key);// 返回属性值return Reflect.get(target, key, receiver);},// 拦截设置操作set(target, key, newVal, receiver) {const oldVal = target[key];// 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性const type = Object.prototype.hasOwnProperty.call(target, key)? "SET": "ADD";// 设置属性值const res = Reflect.set(target, key, newVal, receiver);console.log(target === receiver.raw);if (target === receiver.raw) { // 排除掉原型链上属性的更新if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {trigger(target, key, type);}}return res;}// ...}
5.5浅响应和深响应
以前创建的代理,只能代理对象的一层。
const obj = reactive({ foo : { bar: 0}});effect(()=>{console.log(obj.foo.bar)});// 修改obj.foo.bar的值,不能触发effectobj.foo.bar = 2;
由于在get拦截函数中,Reflect.get函数返回的是obj.foo的结果 {bar: 0}。这是一个普通对象,并不是响应式对象,所以不能建立响应。改造get拦截函数
function reactive(obj){return new Proxy(obj, {get(target, key, receiver){if(key === "raw"){return target}track(target, key);// 得到返回结果const res = Reflect.get(target, key, receiver);if(typeof res === "object" && res !== null){//如果是对象类型,并且不是null,继续调用reactivereturn reavtive(res)}return res;}})}
这样就可实现对象的深层次代理。修改obj.foo.bar的值,也能触发effect的更新。
代码实例
但是并不是所有情况都希望深度代理,这就产生了shallowReactive浅响应。
const obj = shallowReactive({foo: {bar: 1}})effect(()=>{console.log(obj.foo.bar)})// obj.foo是响应的,可以触发effect执行obj.foo = {bar: 32}// obj.foo.bar不是响应的,不能触发effect函数重新执行obj.foo.bar = 2
使用函数柯里化,继续封装一层createReactive函数,将创建不同类型的响应式数据通过参数创建。
function createReactive(obj, isShallow = false){return new Proxy(obj, {// 拦截getget(target, key, receiver){if(key === "raw"){return target}const res = Reflect.get(target, key, receiver);track(target, key);// 如果isShallow为真, 浅代理,直接返回res对象if(isShallow){return res}if(typeof res === "object" && res !== null){return reactive(res)}return res;}})}function reactive(obj){return createReactive(obj); //深代理}function shallowReactive(obj){return createReactive(obj, true); //浅代理}
5.6只读和浅只读
有时希望对数据进行保护,给数据设置为只读。当用户修改值或删除值时都发出警告。
const obj = readOnly({foo:1});// 当修改数据,会弹出警告obj.foo = 2
可以看出只读也是对数据的代理操作,在setter拦截函数中进行设置。给createReactive传递第3个参数
function createReactive(obj, isShallow = false, isReadonly = false){return new Proxy(obj, {// 设置的拦截set(target, key, newVal, receiver){// 如果是只读, isReadonly为真if(isReadonly){console.warn(`${key} 是只读的,不能修改`)return true;}const oldVal = target[key];// 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性const type = Object.prototype.hasOwnProperty.call(target, key)? "SET": "ADD";// 设置属性值const res = Reflect.set(target, key, newVal, receiver);if (target === receiver.raw) {if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {trigger(target, key, type);}}return res;},deleteProperty(target, key) {if (isReadonly) {console.warn(`属性 ${key} 是只读的`);return true;}const hadKey = Object.prototype.hasOwnProperty.call(target, key);const res = Reflect.deleteProperty(target, key);if (res && hadKey) {trigger(target, key, "DELETE");}return res;}}})}
设置和删除属性时,都会有警告提示。
如果一个数据是只读,那么就无法修改它,也就没必要建立响应联系。修改getter拦截函数,只有非只读情况下才建立响应式track。
function createReactive(obj, isShallow = false, isReadonly = false){return new Proxy(obj, {// 拦截读取操作get(target, key, receiver) {if (key === "raw") {return target;}// 非只读的时候才需要建立响应联系if (!isReadonly) {track(target, key);}const res = Reflect.get(target, key, receiver);if (isShallow) {return res;}if (typeof res === "object" && res !== null) {// 深响应return reactive(res);}return res;}}}
此时实现的readonly只读函数,只是浅只读shallowReadonly,还没有做深度处理。
如果要对数据做深度的只读处理,通过给createReactive传递第3个参数,设置为真。
function createReactive(obj, isShallow = false, isReadonly = false){return new Proxy(obj, {// 拦截读取操作get(target, key, receiver) {if (key === "raw") {return target;}// 非只读的时候才需要建立响应联系if (!isReadonly) {track(target, key);}const res = Reflect.get(target, key, receiver);if (isShallow) {return res;}if (typeof res === "object" && res !== null) {// 深只读和深响应return isReadonly ? readonly(res) : reactive(res);}return res;}}}function readonly(obj){return createReactive(obj, false, true)}// 只需要修改第二个参数即可,浅响应,并且做了只读处理function shallowReadonly{return createReactive(obj, true, true)}
5.7数组 5.8Map和Set
6.原始值类型响应式方案ref的实现,getter/setter
第5章实现的响应式方案是建立在非原始值的对象上。如果是原始值基本类型:Boolean、Number、String、null、undefined、BigInt、Symbol类型的值。原始值是按值传递,而非引用传递,如果函数接收原始值作为参数,那么形参和实参直接没有关系,代理也就没意义。
JavaScript中的Proxy无法对原始值进行代理。
引入ref概念
原始值无法响应代理,通过包裹一层属性,变成对象类型。
// 封装ref函数function ref(val){//在ref内创建包裹对象const wrapper={value: val}// 将包裹对象变成响应式return reactive(wrapper)}
现在通过ref就可以给原始值创建响应式数据
const refVal = ref(1);effect(()=>{//在副作用内通过value属性读原始值console.log(refVal.value);})// 修改值能触发副作用effect函数重新执行refVal.value = 2
为了区分ref创建的响应式数据还是reactive创建的,需要在创建ref是添加__v_isRef属性
// 封装ref函数function ref(val){//在ref内创建包裹对象const wrapper={value: val}//使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_RefObject.defineProperty(wrapper, "__is_Ref", {value: true})// 将包裹对象变成响应式return reactive(wrapper)}
转换ref的方法toRef和toRefs
使用上面方法创建的响应式数据,无法进行展开,展开后响应式就会丢失。
export defalut{setup(){const obj = reactive({foo:1, bar:2});return { ...obj }}}// 使用展开运算符(...)导致响应丢失,相当于导出的是return {foo:1,bar:2}
为了解决响应式丢失问题,可以创建个newObj对象,在该对象下具有与obj的同名属性。每个属性值又是对象
const obj = reactive({foo:1, bar:2});// newObj对象下具有obj对象的同名属性,每个属性值都是对象const newObj = {foo: {get value(){return obj.foo}},bar: {get value(){return obj.bar}}}effect(()=>{console.log(newObj.foo)})obj.foo = 3
从newObj对象可以看出,结构存在相似。因此可以抽象出来,封装成函数toRef。
function toRef(obj, key){const wrapper={get value(){return obj[key]}}//使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_RefObject.defineProperty(wrapper, "__is_Ref", {value: true})return wrapper}
toRef函数接收2个参数,第1个参数obj是响应数据,第2个是obj对象的一个键。该函数会返回类似ref结构的wrapper对象。
toRef只能一次解决对象的一个key,可以在做一次封装,将所有key都做代理,封装成toRefs函数
function toRefs(obj){const ret = {};// for in循环遍历for(const key in obj){// 循环调用 toRef 完成转换ret[key]=toRef(obj, key)}return ret}// 这样只需一步操作,可完成整个对象的响应式转换const newObj = {...toRefs(obj)}
现在通过toRef和toRefs方法,实现了将基本类型转成响应式。
此时toRef只实现了value属性的getter,还需要实现setter,增加设置时触发effect响应
function toRef(obj, key){const wrapper={get value(){return obj[key]},// 可以设置值set value(val){obj[key] = val;}}//使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_RefObject.defineProperty(wrapper, "__is_Ref", {value: true})return wrapper}
自动脱ref方法proxyRefs
toRef函数转化解决响应丢失问题,但是带来新的问题,使用时必须通过value属性访问值,增加使用麻烦。
因此对包含有__v_isRef属性的数据做特殊处理,使用时自动去掉value属性
function proxyRefs(target){return new Proxy(target, {get(target, key, receiver){const value = Reflect.get(target, key, receiver);//如果是Ref,则获取的是 value 值return value.__v_isRef ? value.value : value;},set(target, key, newVal, receiver){const value = target[key];// 如果是Ref,则设置其对应的 value 属性值if(value.__v_isRef){value.value = newValue;return true}return Reflect.set(target, key, newVal, receiver)}})}
- 第6行,设置getter的去value属性
- 第13行,设置setter的去value属性
第7-11章 渲染器
7实现自定义渲染器
渲染器是执行渲染任务。vue3渲染器不仅包括Diff算法,还包含特有的快捷路径更新策略,充分结合编译器实现性能优化。
7.1渲染器与响应式数据结合
最基本的渲染器,就是一个函数
function renderer(domString, container){container.innerHTML = domString;}// 使用方法renderer("<h1>vue3 renderer</h1>", document.getElementById("app"))
以上就实现了一个渲染器,并将h1标签的内容,插入到页面id为app内。
在vue中结合响应式数据。
function renderer(domString, container){container.innerHTML = domString;}let count = ref(1);// 使用方法effect(()=>{renderer(`<h1>vue3 renderer, ${count}</h1>`, document.getElementById("app"))})count.value++;
- 定义响应式数据count
- 在副作用函数effect中调用渲染器renderer函数执行
- count数据发生变化,渲染器重新执行,更新页面内容。
可以使用vue的reactive.global.js模拟上述过程
<script src="https://unpkg.com/@vue/reactivity@3.2.35/dist/reactivity.global.js"></script><script>const {effect, ref} = VueReactivity;function renderer(domString, container){container.innerHTML = domString;}let count = ref(1);// 使用方法effect(() => {renderer(`<h1>vue3 renderer, ${count.value}</h1>`,document.getElementById("app"));});setTimeout(() => {count.value++;}, 400);</script>
7.2渲染器基本概念
renderer是渲染器,名词。render是渲染,动词。渲染器把虚拟DOM渲染成真实DOM元素,这个过程叫挂载。
渲染器要接收一个挂载点作为参数,用来指定挂载的位置。
使用一个函数createRenderer来创建渲染器
function createRenderer(){function render(vnode, container){}function hydrate(vnode, container){}return { render, hydrate }}
渲染器不仅包含render函数,还包含hydrate函数(和服务端渲染相关)。
用渲染器执行任务
const renderer = createRenderer();// 渲染任务renderer.render(vnode, container)// 第二次渲染renderer.render(newVnode, container)
- 首先用createRenderer创建一个渲染器renderer,接着调用render函数进行渲染工作。
渲染器除了挂载节点外,还有多次渲染的更新动作。更新节点即patch的过程
function createRenderer(){function render(vnode, container){if(vnode){ //vnode存在,进行挂载动作// vnode:新节点, container._vnode:旧节点,使用patch函数打补丁patch(container._vnode, vnode, container)} else {//vnode不存在if(container._vnode){ //container._vnode存在,说明是卸载过程//需要将container内的DOM清空container.innerHTML = "";}}container._vnode = vnode;}function patch(n1, n2, container){}return { render }}
patch函数的三个参数
n1:旧vnode
- n2:新vnode
- 第三个参数container:挂载容器
在首次渲染时,容器元素container._vnode属性不存在,为undefined。意味着首次渲染传递给patch函数的第一个参数n1是undefined。
演示连续调用3次的过程
const renderer = createRenderer();// firstrenderer.render(vnode1, container);// secondrenderer.render(vnode2, container);// thirdrenderer.render(null, container);
7.3自定义渲染器
渲染器可以通过配置特定API,可实现渲染到任意平台的目标。
创建一个以浏览器为渲染目标平台的渲染器,然后可以将浏览器API进行抽象,即可转换为通用渲染器。
定义一个h1的vnode对象
const vnode = {type: "h1",children: "hello"}
用type属性来描述vnode类型,当type是字符串,可认为是普通标签,并将type作为标签名。
使用renderer渲染vnode
const vnode = {type: "h1",children: "hello"}const renderer = createRenderer();renderer.render(vnode, container);function createRenderer(){function patch(n1, n2, container){if(!n1){mountElement(n2, container)} else {// n1存在,进行更新操作}}funtion mountElement(vnode, container){// 创建DOM元素let el = document.createElement(vnode.type);// 处理子节点,如果子节点是字符串,代表元素具有文本节点if(typeof vnode.children === "string"){//设置元素的textContent属性即可el.textContent = vnode.children;}//将元素添加到容器中container.appendChild(el)}function render(vnode, container){if(vnode){ //vnode存在,进行挂载动作// vnode:新节点, container._vnode:旧节点,使用patch函数打补丁patch(container._vnode, vnode, container)} else {//vnode不存在if(container._vnode){ //container._vnode存在,说明是卸载过程//需要将container内的DOM清空container.innerHTML = "";}}container._vnode = vnode;}return {render}}
以上过程先调用document.createElement函数,用vnode.type作为标签名创建新DOM元素,接着处理vnode.children.如果是字符串,则将内容设置为元素的textContent属性,最后完成appendChild操作。
这是挂载一个普通标签元素的流程。我们的目标是设计一个不依赖浏览器平台的通用渲染器。只需将mountElement函数依赖的浏览器特有API进行抽离。
function createRenderer(options){//通过options传入特定APIconst {createElement, insert, setElementText} = options;//在mountElement函数中,使用特定APIfunction mountElement(vnode, container){// 调用createElement函数创建元素const el = createElement(vnode.type)if(typeof vnode.children === "string"){//调用setElementText设置元素的文本节点setElementText(el, vnode.children)}//调用insert函数将元素插入到容器insert(el, container)}}// 自定义传入打印流程APIconst renderer = createRenerer({createElement(tag){console.log("创建元素",tag)return {tag}},setElementText(el, text){console.log(`设置${JSON.stringify(el)} 的文本内容: ${text}`)el.text = text;},insert(el, parent, anchor=null){console.log(`将 ${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)} 下`)parent.children = el}})
通过给createRenderer传入不同的配置项,这样就可以实现自定义的渲染器。
代码示例
自定义渲染器案例项目
8.挂载和更新
8.1处理子节点和元素属性
子节点可能包含多个,所以需要设置成数组类型;即将children设置成数组
const vnode = {type: 'div',children: [{},{}]}
定义成数组类型,然后就需要修改mountElement方法,增加对数组类型处理。
function mountedElement(vnode, container){const el= createElement(vnode.type);// 处理vnode 的children属性if (typeof vnode.children === "string") {setElementText(el, vnode.children);} else if (Array.isArray(vnode.children)) {+ vnode.children.forEach((child) => {+ patch(null, child, el);+ });}}
vnode.children是数组类型,则进行循环遍历操作。执行patch函数,在patch函数内部,挂载阶段会递归调用mountedElement方法。
处理过子节点后,开始处理props属性。
function mountedElement(vnode, container){const el= createElement(vnode.type);// 处理vnode 的children属性if (typeof vnode.children === "string") {setElementText(el, vnode.children);} else if (Array.isArray(vnode.children)) {vnode.children.forEach((child) => {patch(null, child, el);});}// 处理vnode 的props属性+ if (vnode.props) {+ for (let key in vnode.props) {+ el.setAttribute(key, vnode.props[key]);+ }+ }}
这里简单的用setAttribute进行元素属性的设置。
为元素设置属性需要处理很多边界条件,在后边会单独分析。
挂载元素的流程
8.2HTML Attributs 和DOM Properties
理解HTML Attributes和DOM Properties差异,能正确的设计虚拟节点的结构,正确的为元素设置属性。<input id="my-input" type="text" value="foo"/>
以上这段html代码,其中标签上的属性 id=”my-input”、 type=”text”、value=”foo”就是HTML Attributes。
当用js获取这段html代码时,得到的对象就是DOM对象,dom对象的属性就是 Properties。const el = document.querySelector("my-input")
- DOM Properties 和HTML Attributes的名称不是一一对应,比如样式class在html中是class,在dom中用className表示。
- 不是所有的DOM Properties都有对应的HTML Attributes。比如可以使用el.textContent给元素设置文本内容,但是HTML Attributes没有对应的属性。
关于值的变化
在input标签中,如果用户没有修改文本框的内容,那么通过el.value和el.getAttributes都是获取的foo。
如果用户修改了文本框的内容为bar。console.log(el.value); // "bar"console.log(el.getAttributes); // 仍是 "foo"文本框内容的修改不会影响el.getAttributes的返回值,该值表示HTML Attributes的意义。 DOM Properties始终存储的是当前最新值。
仍然可以通过defaultValue获取到默认值, console.log(el.defaultValue);
⭐️⭐️⭐️⭐️核心关系:HTML Attributes的作用是设置DOM Properties的初始值。
8.3正确的设置元素属性
默认情况下浏览器会自动分析html attributes并设置合适的dom properties,但是在使用vue模版时,就不能被浏览器解析,所以这部分设置属性工作需要vue框架来完成。
以设置按钮禁用属性为例<button disabled>button</button>, 浏览器解析html时会设置一个disabled的属性给html attributes。并将el.disabled的DOM Properties值设置为true。
同样代码在vue模版中会被编译成vnode;
vnode的props.disabled值为空字符串,如果在渲染器中调用setAttribute函数设置属性:el.setAttribute("disabled", ""),这样可以给按钮设置禁用状态。
但是当用户设置<button :disabled="false">不禁用按钮</button>时,经过转换为vnode后
const button = {type: "button",props: {disabled: false // 不禁用按钮}}
渲染器使用el.setAttribute函数设置属性,那么按钮就被禁用了 ,因为使用el.setAttribute函数时,总是会被字符串化,结果为el.setAttribute(“disabled”, “false”); 只要disabled属性存在,按钮就会被禁用;
为了解决这个问题,需要在vue框架中特殊处理
- 优先设置元素DOM Properties
- 当值为空字符串时,要手动改正为true。
```javascript
function mountElement(vnode, container) {
const el = createElement(vnode.type);
// 处理vnode 的children属性
if (typeof vnode.children === “string”) {
} else if (Array.isArray(vnode.children)) {setElementText(el, vnode.children);
} // 处理vnode 的props属性 if (vnode.props) {console.log("child", vnode.children);vnode.children.forEach((child) => {patch(null, child, el);});
- for (let key in vnode.props) {
- // 先设置 properties属性
- if (key in el) {
- const type = typeof el[key];
- const value = vnode.props[key];
- //如果是boolean类型,且值为空,手动修复为 true
- if (type === “boolean” && value === “”) {
- el[key] = true;
- } else {
- el[key] = value;
- }
- } else {
- // 如果没有对应的dom properties,则使用setAttribute函数设置属性
- el.setAttributes(key, vnode.props[key]);
- }
- }
}
// 将生成的el元素插入到container中
insert(el, container);
}
代码示例[代码示例](https://codesandbox.io/s/vue-design-15bzo9?file=/8part/8.3.js)<a name="Mbi4J"></a>#### 处理特殊属性,只能用setAttribute但是这样处理还是有问题,有一些DOM Properties属性是只读的。 `<input form="form1" />`,input标签的form属性(HTML Attributes),它对应的DOM Properties是el.form,但是el.form是只读属性,那么就只能通过setAttribute函数来设置它。```javascriptfunction shouldSetProps(el, key, value) {// 特殊处理只能通过setAttribute函数设置的属性if (key === "form" && el.tagName === "INPUT") return false;return key in el;}function mountedElement(vnode, container){// ...// 处理vnode 的props属性if (vnode.props) {for (let key in vnode.props) {const value = vnode.props[key];// 先设置 properties属性// 通过shouldSetProps方法进行判断,排除掉一些只能用setAttribute设置的属性if (shouldSetProps(el, key, value)) {const type = typeof el[key];//如果是boolean类型,且值为空,手动修复为 trueif (type === "boolean" && value === "") {el[key] = true;} else {el[key] = value;}} else {// 如果没有对应的dom properties,则使用setAttribute函数设置属性el.setAttributes(key, vnode.props[key]);}}}//...}
将属性处理方法抽离为与平台无关
将属性的设置操作提取到渲染器选项中,通过创建renderer实例的options进行设置处理。增加了灵活性。
代码示例const renderer = createRenderer({createElement(tag) {return document.createElement(tag)},setElementText(el, text) {el.textContent = text},insert(el, parent, anchor = null) {parent.insertBefore(el, anchor)},patchProps(el, key, preValue, nextValue) {if (shouldSetAsProps(el, key, nextValue)) {const type = typeof el[key]if (type === 'boolean' && nextValue === '') {el[key] = true} else {el[key] = nextValue}} else {el.setAttribute(key, nextValue)}}})
8.4class属性设置
在vue框架中对class属性做了增强。序列化处理class
方式1:指定class为字符串值
方式2:指定class为对象
方式3:class可以包含上面2中类型的数组
class可以包含多种类型值,需要使用normalizeClass函数将不同类型的class值转为正常的字符串。
通过normalizeClass转换vnode的class值
设置class属性
作者对比3种设置class方式【el.className, el.setAttributes, classList】的性能,发现el.className性能最佳。
调整patchProps函数 ```javascript const renderer = createRenderer({ //… patchProps(el, key, prevValue, nextValue){ - if (key === “class”) {
- // 对class属性进行处理
- el.className = nextValue || “”;
} else if (shouldSetProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === “boolean” && nextValue === “”) {
el[key] = true;
} else {
el[key] = nextValue;
}
} else {
el.setAttribute(key, nextValue);
}
}
})
当给render的第一个参数设置为null,就是执行的卸载。[完整代码示例](https://codesandbox.io/s/vue-design-15bzo9?file=/8part/8.4.js)<br />其实处理class需要特殊格式化处理,还有style也需要类似的处理,详情可以查看[vue源码](https://github.com/shenshuai89/core/blob/main/packages/shared/src/normalizeProp.ts#L6)<a name="WMtUp"></a>### 8.5卸载操作前面4节介绍了挂载操作,这节介绍卸载操作。<br />卸载发生在更新阶段,更新指的是在初次挂载完成后,后续渲染触发的属性或值的变化。```javascript// 初次挂载renderer.render(vnode, document.querySelector("#app"));// 更新renderer.render(newVnode, document.querySelector("#app"))// 卸载renderer.render(null, document.querySelector("#app"));
在前面mountElement函数中的render方法,如果container._vnode不存在,则直接container.innerHTML = “”;
这么做是不严谨的,主要原因有:function render(vnode, container){if(vnode){ //vnode存在,进行挂载动作// vnode:新节点, container._vnode:旧节点,使用patch函数打补丁patch(container._vnode, vnode, container)} else {//vnode不存在if(container._vnode){ //container._vnode存在,说明是卸载过程//需要将container内的DOM清空container.innerHTML = "";}}container._vnode = vnode;}
- 容器的内容可能有某个或多个组件渲染的,当卸载操作发生时,应当正确的调用这些组件的beforeUnmount、unmounted等生命周期的函数
- 还有些元素存在自定义指令,应该在卸载的时候正确执行对应的指令钩子。
- 使用innerHTML清空容器元素,不会移除绑定在DOM元素上的事件处理函数。
正确的卸载办法: 根据vnode对象获取与之相关联的真实DOM元素,然后使用DOM操作方法,将该DOM移除。 因此需要建立vnode和真实DOM元素之间的关系。 const el = vnode.el = createElement(vnode.type)
function mountElement(vnode, container){//...function render(vnode, container) {console.log("render", vnode, container);// vnode存在,说明是挂在创建阶段if (vnode) {patch(container._vnode, vnode, container);} else {// 新vnode节点不存在,并且判断下旧的_vnode存在,说明是卸载阶段if (container._vnode) {// 重新调整卸载操作,根据vnode.el值 移除真实DOM内容const el = container._vnode.el;// 获取el的父元素const parent = el.parentNode;if (parent) parent.removeChild(el);}}// 把 vnode 存储到 container._vnode 下,作为后续渲染中的旧 vnode节点存在container._vnode = vnode;}//...}
container._vnode代表旧vnode,要被卸载的vnode,然后通过container._vnode.el取得真实DOM元素,并调用removeChild函数将其从父元素中移除。
由于卸载操作是比较常见的基本操作,可以单独封装到unmount函数中。
function unmount(vnode){const parent = vnode.el.parentNode;if(parent){parent.removeChild(vnode.el);}}
8.6区分vnode类型
在patch函数中,对比n1和n2元素进入打补丁操作。
function patch(n1, n2, container){if(!n1){mountElement(n2, container);} else {// update}}
在更新操作时,先对比n1和n2 的type是否相同。如果不同,就没有patch的意义,可以直接将n1卸载。
function patch(n1, n2, container){if(n1 && n1.type !== n2.type){// 新旧节点的类型不同,直接将旧的vnode节点n1卸载unmount(n1);n1 = null;}if(!n1){mountElement(n2, container);}else {// update}}
vnode.type的类型不同,需要进行的操作处理不同,因此需要调整patch进行不同类型的处理
function mountElement(vnode, container){//...function patch(n1, n2, container) {if (n1 && n1.type !== n2.type) {unmount(n1);n1 = null;}const { type } = n2;// 根据不同type类型,分情况处理,如果是string,直接更新element,如果是对象,则更新组件if (typeof type === "string") {if (!n1) {mountElement(n2, container);} else {patchElement(n1, n2);}} else if (typeof type === "object") {//如果n2.type的值的类型是对象,表示的是组件} else if (type === "xxx") {// 处理其它类型的值}}// ...}
8.7事件处理
像处理普通属性一样处理事件
把事件当作一种特殊的属性,可以按照约定,在vnode.props对象中,凡是以字符串on开头的属性都是事件。
const vnode = {type: "p",props: {onClick: ()=>{alert("clicked");}},children: 'text'}
解决了事件在虚拟节点层面的问题,接下来处理如何将事件添加到DOM元素上,调整patchProps,增加addEventListener函数绑定事件。
function patchProps(el, key, prevValue, nextValue){// 匹配以on开头的属性if(/^on/.test(key)){const eventName = key.slice(2).toLowerCase();el.addEventListener(eventName, nextValue);}else if(key === "class"){// ...}//...}
那么更新事件呢,按照处理props属性的方式,先移除之前的,再添加新的。
function patchProps(el, key, prevValue, nextValue){// 匹配以on开头的属性if(/^on/.test(key)){const eventName = key.slice(2).toLowerCase();// 移除之前的事件函数prevValue && el.removeEventListener(eventName, prevValue);// 设置最新的事件函数el.addEventListener(eventName, nextValue);}else if(key === "class"){// ...}//...}
处理特殊事件属性
可以伪造一个绑定事件处理函数invoker,然后把真正的事件处理函数设置为invoker.value属性的值。这样当更新事件的时候,将不再需要调用removeEventListener函数来移除上次绑定的事件。
patchProps(el, key, prevValue, nextValue) {if (/^on/.test(key)) {const invokers = el._vei || (el._vei = {})let invoker = invokers[key]const name = key.slice(2).toLowerCase()if (nextValue) {if (!invoker) {// el._evi设置成对象invoker = el._vei[key] = (e) => {// 一个事件类型还可以绑定多个事件处理函数。因此在vnode的props中存在数组情况if (Array.isArray(invoker.value)) {invoker.value.forEach(fn => fn(e))} else {invoker.value(e)}}invoker.value = nextValueel.addEventListener(name, invoker)} else {invoker.value = nextValue}} else if (invoker) {el.removeEventListener(name, invoker)}} else if (key === 'class') {el.className = nextValue || ''} else if (shouldSetAsProps(el, key, nextValue)) {const type = typeof el[key]if (type === 'boolean' && nextValue === '') {el[key] = true} else {el[key] = nextValue}} else {el.setAttribute(key, nextValue)}}
由于一个元素上可以绑定多个事件,为了避免事件覆盖,需要将el._evi的数据结构设置为对象,它的键是事件名称,它的值是对应的事件处理函数。
同一个类型的事件,还可以绑定多个事件处理函数。
const vnode = {type: "p",props: {onClick:[()=>{alert("111")},()=>{alert("222")}]},children: "text"}
8.8事件冒泡和更新时机
主要目的是:屏蔽到所有绑定时间【attached】晚于事件触发时间【timeStamp】的所有事件执行。 原因很简单,点击时事件还没进行绑定的事件,一律不执行。否则会引发错误。
更新patchProps方法
patchProps(el, key, prevValue, nextValue) {if (/^on/.test(key)) {const invokers = el._vei || (el._vei = {})let invoker = invokers[key]const name = key.slice(2).toLowerCase()if (nextValue) {if (!invoker) {invoker = el._vei[key] = (e) => {+ console.log(e.timeStamp) // 事件触发时间+ console.log(invoker.attached) //事件绑定时间+ if (e.timeStamp < invoker.attached) returnif (Array.isArray(invoker.value)) {invoker.value.forEach(fn => fn(e))} else {invoker.value(e)}}invoker.value = nextValue+ invoker.attached = performance.now()el.addEventListener(name, invoker)} else {invoker.value = nextValue}} else if (invoker) {el.removeEventListener(name, invoker)}} else if (key === 'class') {el.className = nextValue || ''} else if (shouldSetAsProps(el, key, nextValue)) {const type = typeof el[key]if (type === 'boolean' && nextValue === '') {el[key] = true} else {el[key] = nextValue}} else {el.setAttribute(key, nextValue)}}
8.9 更新子节点
前面所有示例都只是实现挂载操作,并没进行更新处理。在挂载子节点时,首先区分其类型。
- 如果vnode.children是字符串,说明元素是文本子节点
- 如果vnode.children是数组,说明元素具有多个子节点
子节点类型的规范化,有利于处理更新逻辑。
对于元素的更新,主要有以下3种情况
<!--没有子节点--><div></div><!--文本子节点--><div>123</div><!--多个子节点--><div><p></p><h1></h1></div>
- 没有子节点,vnode.children的值是null
- 具有文本子节点,vnode.children的值是字符串,代表文本内容
- 其他情况,无论是单个元素子节点,还是多个子节点,都可以用数组来表示
一个vnode的子节点有3种可能,那么当渲染器更新时,新旧子节点都分别是3种可能。
用代码实现更新的过程
function patchElement(n1, n2) {const el = n2.el = n1.elconst oldProps = n1.propsconst newProps = n2.props// 更新propsfor (const key in newProps) {if (newProps[key] !== oldProps[key]) {patchProps(el, key, oldProps[key], newProps[key])}}for (const key in oldProps) {if (!(key in newProps)) {patchProps(el, key, oldProps[key], null)}}// 更新children,是对一个元素进行patch打补丁的最后一步操作patchChildren(n1, n2, el)}
新的children类型是字符串
function patchChildren(n1, n2, container){// 判断新子节点的类型是否是文本节点if(typeof n2.children === "string"){// 旧的子节点有三种类型可能:只有当是一组节点时才需要逐个卸载if(Array.isArray(n1.children)){n1.children.forEach((c) => unmount(c))}setElementText(container, n2.children)}}
以上代码表示,首先检测新节点类型是否是文本节点,如果是则要检查旧子节点的类型。旧子节点类型有三种可能,只有旧子节点是一组子节点时,需要循环遍历他们,并逐个调用unmount函数进行卸载。其他2种情况不需要任何操作处理。
新的子节点类型是数组
如果新子节点不是文本,再增加新的处理逻辑分支
function patchChildren(n1, n2, container){// 判断新子节点的类型是否是文本节点if(typeof n2.children === "string"){// 旧的子节点有三种类型可能:只有当是一组节点时才需要逐个卸载if(Array.isArray(n1.children)){n1.children.forEach((c) => unmount(c))}setElementText(container, n2.children)}// 以下为新增else if(Array.isArray(n2.children)){// 新元素子节点类型是数组//判断旧子节点n1的children是否也是一组子节点if(Array.isArray(n1.children)){// 新旧子节点都是一组子节点,这里涉及到了核心的Diff算法,后续进行处理// todo}else{// 旧的子节点要么是文本子节点,要么不存在// 无论哪种情况,都只需要将容器清空,然后将新的一组子节点逐个挂载setElementText(container, '')n2.children.forEach(c => patch(null, c, container))}}}
以上代码新增了对n2.children类型判断,检测它是否为一组子节点,如果是则接着判断旧子节点的类型。
- 旧子节点是一组子节点,涉及到新旧两组子节点对比,就是vue的diff算法。后续进行详细分析,这里可以采用简单的处理方式:把旧节点全部卸载,再将新的一组子节点进行挂载。
- 如果旧子节点是没有子节点或只是文本节点,只需要将容器元素清空,然后再逐个将新的一组子节点挂载到容器中即可。
```javascript
function patchChildren(n1, n2, container) {
if (typeof n2.children === ‘string’) {
if (Array.isArray(n1.children)) {
} setElementText(container, n2.children) } else if (Array.isArray(n2.children)) { if (Array.isArray(n1.children)) {n1.children.forEach((c) => unmount(c))
- n1.children.forEach(c => unmount(c))
- n2.children.forEach(c => patch(null, c, container))
} else {
setElementText(container, ‘’)
n2.children.forEach(c => patch(null, c, container))
}
}
}
```
最后一个情况,新的子节点为null
```javascript function patchChildren(n1, n2, container) { if (typeof n2.children === ‘string’) { if (Array.isArray(n1.children)) { n1.children.forEach((c) => unmount(c)) } setElementText(container, n2.children) } else if (Array.isArray(n2.children)) { if (Array.isArray(n1.children)) { n1.children.forEach(c => unmount(c)) n2.children.forEach(c => patch(null, c, container)) } else { setElementText(container, ‘’) n2.children.forEach(c => patch(null, c, container)) } } else { // 新的子节点不存在 - if (Array.isArray(n1.children)) { // 旧的子节点是一组,需要逐个卸载
- n1.children.forEach(c => unmount(c))
- } else if (typeof n1.children === ‘string’) { // 旧的子节点是文本,直接清空
- setElementText(container, ‘’)
- }
- } } ``` 最后走到else分支,说明新的子节点不存在。这是仍需要判断旧的子节点类型;
- 如果旧子节点不存在,什么都不需要做
- 旧的子节点是文本节点,则清空文本内容
- 旧的子节点是一组节点,则逐个卸载。
8.10文本节点和注释节点
使用虚拟DOM描述多种类型的真实DOM,最常见的两种节点类型是文本节点和注释节点。
vnode.type属性代表一个vnode的类型,如果vnode.type的值是字符串,则表示描述的是普通标签,并且该值就是标签的名称,如div,p; 但是注射节点和文本解读不同于普通标签节点,它没有标签,因此需要创造出唯一的标识,来表示注释节点和文本节点的type属性值:
// 文本节点的type标识const Text = Symbol();const TextVnode = {type: Text;children: "text text"}// 注释节点的type标识const Comment = Symbol();const commentVnode = {type: Comment,children: "commentVnode"}
有了文本节点和注释节点的vnode对象后,就可以使用渲染器来渲染他们。
function patch(n1, n2, container) {if (n1 && n1.type !== n2.type) {unmount(n1)n1 = null}const { type } = n2if (typeof type === 'string') {if (!n1) {mountElement(n2, container)} else {patchElement(n1, n2)}} else if (type === Text) {if (!n1) {// 创建文本节点const el = n2.el = document.createTextNode(n2.children)// 将文本节点插入到容器中insert(el, container)} else {// 如果旧vnode存在,只需要更新旧节点的内容const el = n2.el = n1.elif (n2.children !== n1.children) {el.nodeValue = n2.children;}}}}
patch函数依赖平台特有API,可以通过createTextNode和setText方式实现更新。
在创建renderer实例时,给options新增createTextNode和setText方法
const renderer = createRenderer({//...createTextNode(text){return document.createTextNode(text)},setText(){el.nodeValue = text;}//...})
修改patch中的操作,使用特定的平台API;
function patch(n1, n2, container) {if (n1 && n1.type !== n2.type) {unmount(n1)n1 = null}const { type } = n2if (typeof type === 'string') {if (!n1) {mountElement(n2, container)} else {patchElement(n1, n2)}} else if (type === Text) {if (!n1) {const el = n2.el = createText(n2.children)insert(el, container)} else {const el = n2.el = n1.elif (n2.children !== n1.children) {setText(el, n2.children)}}}}
注释节点的处理和文本节点处理方式类似,只需使用document.createComment函数创建注释节点元素
代码示例:
8.11 Fragment多根节点标签
Fragment是vue3新增的节点标签,也需要创建单独的type类型。Fragment主要是为了解决多根元素节点的标签。
<template><li>1</li><li>1</li><li>1</li></template>// 对应的虚拟节点 vnodeconst vnode = {type: Fragment,children: [{type: "li", children: "1"},{type: "li", children: "2"},{type: "li", children: "3"},]}
增加了Fragment标签,调整渲染器的渲染逻辑处理,渲染Fragment标签本身不会渲染任何内容,所以只会渲染Fragment子节点内容。
function patch(n1, n2, container) {if (n1 && n1.type !== n2.type) {unmount(n1)n1 = null}const { type } = n2if (typeof type === 'string') {if (!n1) {mountElement(n2, container)} else {patchElement(n1, n2)}} else if (type === Text) {if (!n1) {const el = n2.el = createText(n2.children)insert(el, container)} else {const el = n2.el = n1.elif (n2.children !== n1.children) {setText(el, n2.children)}}+ } else if (type === Fragment) {+ if (!n1) {+ n2.children.forEach(c => patch(null, c, container))+ } else {+ patchChildren(n1, n2, container)+ }+ }}
在patch函数中增加了Fragment类型虚拟节点的处理,在卸载时也需要支持Fragment类型的卸载
function unmount(vnode) {if (vnode.type === Fragment) {vnode.children.forEach(c => unmount(c))return}const parent = vnode.el.parentNodeif (parent) {parent.removeChild(vnode.el)}}

