《Vue.js 设计与实现》 笔记
目录
- 1. 权衡的艺术
- 2. 框架设计的核心要素
- 3. Vue.js 3的设计思路
- 4. 响应系统的作用和实现
- 5. 非原始值的响应式方案
- 6. 原始值的响应式方案
- 7. 渲染器的设计
- 8. 挂载和更新
- 9. 简单Diff算法
- 10. 双端Diff算法
- 11. 快速Diff算法
- 12. TODO 组件的实现原理
- 13. TODO 异步组件与函数式组件
- 14. TODO 内建组件和模块
- 15. TODO 编译器核心技术概览
- 16. TODO 解析器
- 17. TODO 编译优化
- 18. TODO 同构渲染
1. 权衡的艺术
1.1. 命令式与声明式
命令式关注于 过程
const div = document.querySelector('div'); div.innerText = "hello world"; div.onclick = ()=>console.log("ok");
声明式关注于 结果, Vue 封装了命令式的过程
<template> <div @click="alert('ok')">hello world</div> </template>
1.2. 性能和可维护性的权衡
- 声明式的更新性能损耗 = 找出差异的性能损耗 + 直接修改的性能损耗
- 声明式代码的性能 不优于 命令式代码的性能
1.3. 虚拟DOM的性能到底如何
1.3.1. 比较纯js运算与dom运算
console.time('pure'); let app = []; for (let i = 0; i < 10000; i++) { const div = { tag: 'div' }; app.push(div); } console.timeEnd('pure'); // pure: 2.31689453125 ms console.time('dom'); let container = document.createElement('div'); for (let i = 0; i < 10000; i++) { container.appendChild(document.createElement('div')); } console.timeEnd('dom'); // dom: 8.001220703125 ms
可见dom操作比纯js操作慢几倍
1.3.2. 对比虚拟DOM和innerHTML 创建 的效率
虚拟DOM | innerHTML | |
---|---|---|
纯js运算 | 创建js对象(VNode) | 拼接html字符串 |
DOM运算 | 新建所有DOM元素 | 新建所有DOM元素 |
可见使用 innerHTML
可以更快的创建
1.3.3. 对比虚拟DOM和innerHTML 更新 效率
虚拟DOM | innerHTML | |
---|---|---|
纯js运算 | 创建js对象+Diff出更新VNode | 拼接html字符串 |
DOM运算 | 必要的DOM更新 | 销毁所有旧的DOM元素, 新建所有新的DOM元素 |
性能因素 | 与数据量变化相关 | 与模板大小相关 |
可见,更新的节点越多,使用虚拟DOM的性能越佳
1.4. 运行时和编译时
1.4.1. 纯运行时
const Render = (obj, root) => { const el = document.createElement(obj.tag); if (typeof obj.children === 'string') { el.appendChild(document.createTextNode(obj.children)); } else if (obj.children) { obj.children.forEach((childObj) => { Render(childObj, el); }); } root.appendChild(el); };
如上,设计一个 Render
函数,在运行时解析对象渲染成DOM
1.4.2. 运行时+编译时
<div> <p>hello</p> <ol> <li>list item</li> </ol> </div>
实现一个 Compile
函数,将上述的html转成obj对象,然后传递给 Render
,这就是 运行时编译 。该方式在代码执行编译步骤时,会产生性能损耗。
如果把编译步骤放到构建阶段,那么就可以省略该性能消耗。
1.4.3. 编译时
如果上述的 Compile
直接把html转成命令式的过程,那么就变成了纯编译时。
其中 Vue3 使用的是 运行时+编译时
2. 框架设计的核心要素
2.1. 提升用户的开发体验
源码中提供的 warn
函数,打印了更丰富的信息
图1 丰富的组件栈信息
Vue 提供了 initCustomFormatter
,当在 DevTools 勾选 Setting -> Perference -> Console -> Enable custom formatters
const count = ref(0) console.log(count)
图2 更直观的输出内容
initCustomFormatter
的作用,就是向 window.devtoolsFormatters
数组添加 formatter
2.2. 控制框架代码体积
Vue3 中有大量的 __DEV__
if (__DEV__ && __BROWSER__ && dir.exp) { validateBrowserExpression(dir.exp as SimpleExpressionNode, context) }
当构建生产环境时,其被替换成 false
,其块内的代码变成 dead code, 那么就不会包含到最终的包中,从而减少体积
2.3. 框架要做到良好的 Tree-Shaking
Tree-Shaking 指的就是消除那些永远不会被执行的代码
实现 Tree-Shaking 的几个要点:
- 模块必须是 ESM (ES Module), 因为其依赖于ESM的静态结构
- 如果函数调用没有 副作用 ,那么显示指定
/*#__PURE__*/
也可以将其移除
// b.js export function foo() { console.log('hello'); } export function bar() { console.log(1); } export function baz() { console.log(2); } export const a = /*#__PURE__*/ bar(); export const b = baz();
// a.js import { foo } from './b'; foo();
// output.js function foo() { console.log('hello'); } function baz() { console.log(2); } baz(); foo();
如上,输出结果不会包含 bar
相关的内容
Vue3 中包含大量如下代码
export const isSpecialBooleanAttr = /*#__PURE__*/ makeMap(specialBooleanAttrs)
如果没有引用 isSpecialBooleanAttr
这类变量,那么该变量相关内容就不会包含在最终包中
2.4. 框架应该输出怎样的构建产物
文件名 | 说明 |
---|---|
vue.global.js | 可在 <script> 中引用,会注入 Vue 到 window |
vue.esm-browser.js | 可以 <script type="module"> 引用 |
vue.esm-bundler.js | 在 package.json 的 module 中引用 |
vue.cjs.js | 给node环境使用,用于SSR |
vue.esm-bundler.js 构建时其 __DEV__
为 (process.env.NODE_ENV !== 'production')
,这样子包使用者就可以控制是否使用 prod 版本
2.5. 特性开关
通过控制全局变量,显式关闭某些特性, 从而减少体积
// vite.config.js export default { define: { __VUE_OPTIONS_API__: JSON.stringify(false), }, }
2.6. 错误处理
Vue会通过 callWithErrorHandling
来调用函数,从而统一异常处理。
异步函数抛出的异常不能处理
同时可以注册全局的异常处理函数
import App from 'App.vue' const app = createApp(App) app.config.errorHandler = (err) => { console.log(err) }
3. Vue.js 3的设计思路
3.1. 声明式地描述UI
编写前端页面涉及的内容:
- DOM元素
- 属性
- 事件
- 元素的层级结构
3.1.1. 通过模板
<template> <div class="wrap" @click="handleClick" :id="dynamicId"> hello world </div> </template>
3.1.2. 通过 js object
let div = { tag: 'div', children: 'hello world', onClick: handleClick, attr: { className: 'wrap', id: dynamicId } }
使用 js 对象会被使用模板更灵活,而虚拟DOM其实就是 js 对象
Vue 组件可通过手写 渲染函数 使用虚拟DOM来描述UI
import { h } from 'vue' export default { render(){ return h('h1', { onClick: handler}) // 虚拟DOM } }
如果 Vue 组件中同时存在 <template>
和 render
,那么会忽略 render
Vue 根据组件的 render
的返回值拿到虚拟DOM, 然后渲染出内容
3.2. 初识渲染器
渲染器的作用,就是将虚拟DOM转化成真实的DOM
渲染器的工作原理就是递归遍历 vnode, 并调用原生DOM API完成真实DOM的创建。在更新阶段,它会通过 Diff 算法找出变更点,并且只更新需要更新的内容
虚拟DOM h('div', 'hello')
-> 渲染器 -> 真实DOM
例如,将下面的 vnode 转化成真实的DOM
const vnode = { tag: 'div', props: { id: 'container', }, children: [ { tag: 'button', props: { onClick: () => alert('hello'), }, children: 'click me', }, ], };
可实现一个 renderer
const renderer = (vnode, container) => { // 创建元素 const el = document.createElement(vnode.tag); // 设置属性和事件监听器 for (const key in vnode.props) { if (key.startsWith('on')) { el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key]); } else { el.setAttribute(key, vnode.props[key]); } } // 处理 children if (typeof vnode.children === 'string') { el.appendChild(document.createTextNode(vnode.children)); } else { vnode.children.forEach((child) => { renderer(child, el); }); } // 挂载父节点 container.appendChild(el); };
这就是一个简单的渲染器,其主要工作如下:
- 创建元素
- 为该元素添加属性和事件
- 设置子节点
- 挂载父节点
3.3. 组件的本质
组件就是一组DOM元素的封装
3.3.1. 使用 function 表示组件
假设组件的表现形式为返回 vnode
的函数
const Component = function () { return { tag: 'div', children: 'hello', }; };
可见,该函数,实质就是一个 渲染函数.
改造一下 renderer
const renderer = (vnode, container) => { if (typeof vnode.tag === 'string') { mountElement(vnode, container); } else if (typeof vnode.tag === 'function') { mountComponent(vnode, container); } };
其中 mountElement
就是上述的 renderer
const mountElement = (vnode, container) => { const el = document.createElement(vnode.tag); for (const key in vnode.props) { if (key.startsWith('on')) { el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key]); } else { el.setAttribute(key, vnode.props[key]); } } if (typeof vnode.children === 'string') { el.appendChild(document.createTextNode(vnode.children)); } else { vnode.children.forEach((child) => { renderer(child, el); }); } container.appendChild(el); };
mountComponent
可如下实现
const mountComponent = (vnode, container) => { renderer(vnode.tag(), container); };
3.3.2. 使用 object 表示组件
组件为包含渲染函数 render
的对象
const ComponentB = { render() { return { tag: 'div', children: 'hello', }; }, };
那么 mountComponent
改成
const mountComponent = (vnode, container) => { renderer(vnode.tag.render(), container); };
3.3.3. 总结
不管是 object
还是 function,
当 mount
的时候,都是调用函数,得到一个新的 vnode
对象实例,这样子组件间就不会相互影响
3.4. 模板的工作原理
编译器的作用,就是把模板编译成渲染函数
如下组件
<template> <div @click="handler">hello</div> </template> <script> export default { data() { return {}; }, methods: { handler() {}, }, }; </script>
编译器会编译 <template>
中的内容为渲染函数(render
),并添加到 script 的组件对象上
export default { data() { return {}; }, methods: { handler() {}, }, render() { return h("div", { onClick: this.handler }, "hello"); }, };
3.5. Vue 是各个模块组件的有机整体
编译器和渲染器之间是可以通过虚拟DOM进行信息交流的,例如
<div id="id1" :class="cls"></div>
上述模板,可以看出,id不可变,而 class 可变,因此编译后的渲染函数可以为
render() { return { tag: 'div', props: { id: 'id1', class: cls }, patchFlag: 1 // 假设1表示 class 是动态的 } }
由此,渲染器当看到 patchFlags 时,就知道只有class属性会变更,从而减少了寻找变更点的时间,以此提升了性能
4. 响应系统的作用和实现
4.1. 响应式数据与副作用函数
let obj = { text: 'hello' } function effect(){ document.body.innerText = obj.text; }
- 当函数的执行,会导致外部状态的改变,那么该函数就是 副作用函数
- 当
obj.text
被修改时,函数effect
会自动重新执行,那么obj
就是响应式数据
4.2. 响应式数据的基本实现
effect
执行时,会触发obj
的 读 , 对obj.text
赋值时,会触发 写 。如果可以拦截其读写, 当触发 读 时, 将该
effect
收集到一个桶中, 触发 写 时, 从桶中取处该effect
并执行。 那么obj
就可以成为响应式数据。const bucket = new Set(); let activeEffect; function reactive(obj) { let reactiveObj = new Proxy(obj, { get() { bucket.add(activeEffect) return Reflect.get(...arguments) }, set(target, prop, value) { target[prop] = value; bucket.forEach(activeEffectFn => { activeEffectFn() }) return true } }) return reactiveObj } const obj = reactive({ text: 'hello' }) function effect() { activeEffect = effect; console.log('obj.text is ', obj.text) } effect() obj.text += ' world' obj.noExist = 1
obj.text is hello obj.text is hello world obj.text is hello world
4.3. 设计一个完善的响应系统
上述例子有几个缺陷:
effect
函数名硬编码了- 响应式对象与副作用函数之间没有建立一一对应关系
- 当给未存在的属性赋值时,依然执行了副作用函数
对此的解决方案:
- 用一个注册函数,接受一个匿名的副作用函数,解决硬编码问题
- 使用
WeakmMap
收集副作用函数 - 使用
Map
将属性与副作用函数对应
const bucket = new WeakMap() let activeEffect; function effect(fn) { activeEffect = fn // 为了触发依赖收集 fn() activeEffect = null } function track(target, key) { if (!activeEffect) { return } let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, depsMap = new Map()) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, deps = new Set()) } deps.add(activeEffect) } function trigger(target, key) { let depsMap = bucket.get(target) if (!depsMap) { return } let deps = depsMap.get(key) if (!deps) { return } deps.forEach(effectFn => { effectFn() }) } function reactive(obj) { const reactiveObj = new Proxy(obj, { get(target, prop) { track(target, prop) return Reflect.get(...arguments) }, set(target, prop, val) { target[prop] = val; trigger(target, prop) return true }, }) return reactiveObj } let obj1 = reactive({ a: 1 }) effect(() => { console.log('obj1.a is', obj1.a) }) let obj2 = reactive({ b: 10 }) effect(() => { console.log('obj2.b is', obj2.b) }) obj1.a = 2; obj2.b = 4 obj2.c = 3
obj1.a is 1 obj2.b is 10 obj1.a is 2 obj2.b is 4
4.4. 分支切换与 cleanup
考虑下面场景
const obj = reactive({ ok: true, text: 'hello' }) effect(() => { document.innerHTML = obj.ok ? obj.text : 'empty' }) obj.ok = false; obj.text = "world"
- 当
obj.ok
为true
时,obj
中的ok
和text
都收集到依赖中 - 如果这时,
obj.ok
被赋值为false
, 对应到副作用函数重新执行时,其不会收集text
到依赖中。 但是由于之前收集的text
依赖, 依然残留在bucket
中。 故给text
赋值时, 依然会执行副作用函数 - 问题是,
ok
为false
时,text
的值的变更,不会有任何副作用, 理想情况下, 应该不执行副作用函数
解决方案:
- 每次执行副作用函数前,先将该副作用函数从上次的 依赖集合 中移除
const bucket = new WeakMap() let activeEffect; function effect(fn) { // 新增 const effectFn = () => { // 执行副作用函数时, 将其设置为激活状态 activeEffect = effectFn fn() } effectFn.deps = [] // 为了触发依赖收集 effectFn() activeEffect = null } function track(target, key) { if (!activeEffect) { return } let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, depsMap = new Map()) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, deps = new Set()) } deps.add(activeEffect) // 新增 activeEffect.deps.push(deps) } function trigger(target, key) { let depsMap = bucket.get(target) if (!depsMap) { return } let deps = depsMap.get(key) if (!deps) { return } // 新增 let depsToRun = new Set(deps) depsToRun.forEach(effectFn => { cleanup(effectFn) effectFn() }) } // 新增 function cleanup(effectFn) { let deps = effectFn.deps.pop(); while (deps) { deps.delete(effectFn) deps = effectFn.deps.pop(); } } function reactive(obj) { const reactiveObj = new Proxy(obj, { get(target, prop) { track(target, prop) return Reflect.get(...arguments) }, set(target, prop, val) { target[prop] = val; trigger(target, prop) return true }, }) return reactiveObj } let obj1 = reactive({ ok: true, text: 'hello' }) effect(() => { console.log('obj1 is', obj1.ok ? obj1.text : 'empty') }) obj1.ok = false; obj1.text = 'world'
obj1 is hello obj1 is empty
4.5. 嵌套的 effect 与 effect 栈
在嵌套 effect
的情况, 当里层的 effect
执行完成, activeEffect
丢失外层的 effect
的引用, 从而导致不能 track
副作用函数到依赖集合中(比如组件嵌套渲染)。 如下:
effect(() => { effect(() => { console.log(reactiveObj.a) }) console.log(reactiveObj.b) })
解决方案: activeEffect
的数据结构改成 栈
const bucket = new WeakMap() let effectStack = []; let activeEffect; function effect(fn) { const effectFn = () => { // 新增 // 执行副作用函数时, 将其设置为激活状态 effectStack.push(activeEffect = effectFn) cleanup(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.deps = [] // 为了触发依赖收集 effectFn() } function track(target, key) { if (!activeEffect) { return } let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, depsMap = new Map()) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, deps = new Set()) } deps.add(activeEffect) activeEffect.deps.push(deps) } function trigger(target, key) { let depsMap = bucket.get(target) if (!depsMap) { return } let deps = depsMap.get(key) if (!deps) { return } let depsToRun = new Set(deps) depsToRun.forEach(effectFn => { effectFn() }) } function cleanup(effectFn) { let deps = effectFn.deps.pop(); while (deps) { deps.delete(effectFn) deps = effectFn.deps.pop(); } } function reactive(obj) { const reactiveObj = new Proxy(obj, { get(target, prop) { track(target, prop) return Reflect.get(...arguments) }, set(target, prop, val) { target[prop] = val; trigger(target, prop) return true }, }) return reactiveObj } let obj1 = reactive({ ok: true, text: 'hello', num: 2 }) effect(() => { effect(() => { console.log('num is', obj1.num) }) console.log('obj1 is', obj1.ok ? obj1.text : 'empty') }) console.log('----') obj1.ok = false; obj1.text = 'world' obj1.num = 10
num is 2 obj1 is hello ---- num is 2 obj1 is empty num is 10 num is 10
4.5.1. TODO 副作用函数重复注册问题
如上述结果, 该方案有个问题, 嵌套的副作用函数,会被重复收集,从而执行多次(因为每次执行外层 effect
, 传给里层 effect
的副作用函数都是新的, 因此在依赖集合里不能被去重),故 num is 10
被打印了两次
4.6. 避免无限递归循环
上述方案有个缺陷,当在副作用函数中同时存在读和写时,会导致无限递归循环
解决方案: 在 trigger
中加个判断, 如果将要执行读 effectFn
跟 activeEffect
一致,那么就跳过执行
const bucket = new WeakMap() let effectStack = []; let activeEffect; function effect(fn) { const effectFn = () => { // 执行副作用函数时, 将其设置为激活状态 effectStack.push(activeEffect = effectFn) cleanup(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.deps = [] // 为了触发依赖收集 effectFn() } function track(target, key) { if (!activeEffect) { return } let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, depsMap = new Map()) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, deps = new Set()) } deps.add(activeEffect) activeEffect.deps.push(deps) } function trigger(target, key) { let depsMap = bucket.get(target) if (!depsMap) { return } let deps = depsMap.get(key) if (!deps) { return } let depsToRun = new Set(deps) depsToRun.forEach(effectFn => { activeEffect !== effectFn && effectFn() }) } function cleanup(effectFn) { let deps = effectFn.deps.pop(); while (deps) { deps.delete(effectFn) deps = effectFn.deps.pop(); } } function reactive(obj) { const reactiveObj = new Proxy(obj, { get(target, prop) { track(target, prop) return Reflect.get(...arguments) }, set(target, prop, val) { target[prop] = val; trigger(target, prop) return true }, }) return reactiveObj } let obj1 = reactive({ ok: true, text: 'hello', num: 2 }) effect(() => { console.log('obj1 is', obj1.ok ? obj1.text : 'empty') console.log(obj1.num++) }) console.log('----') obj1.ok = false; obj1.text = 'world' obj1.num = 44
obj1 is hello 2 ---- obj1 is empty 3 obj1 is empty 44
4.7. 调度执行
可调度 指当 trigger
触发副作用函数执行时, 外部环境有能力决定副作用函数执行当时机、次数以及方式
考虑下面场景
const obj = reactive({ foo: 1 }) effect(() => { console.log(obj.foo) }) obj.foo++ console.log('end')
假设想要让 obj.foo++
导致当副作用函数当执行, 放在下一个 tick 中执行, 也就是说,结果为:
1 end 2
该如何?
此时可以给 effect
传递一个 options.scheduler
,将副作用函数的执行权反转到外部。
const bucket = new WeakMap() let effectStack = []; let activeEffect; function effect(fn, options) { const effectFn = () => { // 执行副作用函数时, 将其设置为激活状态 effectStack.push(activeEffect = effectFn) cleanup(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.deps = [] effectFn.options = options // 新增 // 为了触发依赖收集 effectFn() } function track(target, key) { if (!activeEffect) { return } let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, depsMap = new Map()) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, deps = new Set()) } deps.add(activeEffect) activeEffect.deps.push(deps) } function trigger(target, key) { let depsMap = bucket.get(target) if (!depsMap) { return } let deps = depsMap.get(key) if (!deps) { return } let depsToRun = new Set(deps) depsToRun.forEach(effectFn => { if (activeEffect !== effectFn) { // 新增 effectFn?.options?.scheduler ? effectFn?.options?.scheduler(effectFn) : effectFn() } }) } function cleanup(effectFn) { let deps = effectFn.deps.pop(); while (deps) { deps.delete(effectFn) deps = effectFn.deps.pop(); } } function reactive(obj) { const reactiveObj = new Proxy(obj, { get(target, prop) { track(target, prop) return Reflect.get(...arguments) }, set(target, prop, val) { target[prop] = val; trigger(target, prop) return true }, }) return reactiveObj } let obj1 = reactive({ foo: 1 }) effect(() => { console.log(obj1.foo) }, { scheduler(fn) { Promise.resolve().then(fn) } }) console.log('----') obj1.foo++ console.log('end') console.log('---排除过渡状态') const jobQueue = new Set() let isFlushing = false; function flushJob() { if (isFlushing) return isFlushing = true; Promise.resolve().then(() => { jobQueue.forEach(fn => fn()) isFlushing = false }) } let obj2 = reactive({ foo: 2 }) effect(() => { console.log('obj2', obj2.foo) }, { scheduler(fn) { jobQueue.add(fn) flushJob() } }) console.log('----') obj2.foo++ obj2.foo++ Promise.resolve().then(() => obj2.foo++) Promise.resolve().then(() => obj2.foo++)
1 ---- end ---排除过渡状态 obj2 2 ---- 2 obj2 4 obj2 6
4.8. 计算属性 computed 与 lazy
假如想要注册副作用函数时,不先执行,而是把执行权给外部,可如下操作:
function effect(fn, options) { const effectFn = () => { cleanup(effectFn) effectFnStack.push(activeEffectFn = fn) let result = fn(); // 新增 effectFnStack.pop() activeEffectFn = effectFnStack[effectFnStack.length - 1] return result // 新增 } effectFn.options = options effectFn.deps = [] if (!options.lazy) { // 新增 effectFn() } return effectFn }
此时, 如果把 fn
看成一个 getter
,那么就可以利用此实现一个 computed
, 如下:
function computed(getter) { const effectFn = effect(getter, { lazy: true }) const obj = { get value() { // 仅在获取值时,才调用副作用函数 return effectFn() } } return obj }
同时,可以加入缓存功能:
function computed(getter) { let dirty = true; const effectFn = effect(getter, { lazy: true, scheduler(fn) { // 此处不需要执行 fn , 因为执行权放到了下面到 get value中 // getter 中的响应式数据变化时, 就说明值的结果已过期 dirty = true; } }) let cached; const obj = { get value() { if (dirty) { cached = effectFn() dirty = false } return cached } } return obj }
解决在 effect
中的 computed
未响应的问题,如下:
const sumRes = computed(() => obj.a + obj.b) effect(() => { // 之前的响应式值,是通过 reactive 创建的, 其 getter 中会执行 track, setter 中执行 trigger // 但是该对象由 computed 创建, sumRes.value 的 getter 和 setter 并没有调用 track 和 trigger // 故 sumRes 与 value 其对应到副作用作用函数,并没有收集到依赖中 console.log(sumRes.value) }) obj.a++ // 不会执行上面到副作用函数
解决方案, 在 computed
中调用 track
和 trigger
:
function computed(getter) { let dirty = true; let cached; const effectFn = effect(getter, { lazy: true, scheduler() { dirty = true // 执行了调度函数, 说明触发了依赖的响应式数据的 setter, 那么obj.value 也应该重新 set // 故可以把该处当成是 obj.value 的 setter trigger(obj, 'value') } }) let obj = { get value() { if (dirty) { cached = effectFn() dirty = false } track(obj, 'value') return cached } } return effectFn }
完整到演示效果:
const bucket = new WeakMap() let effectStack = []; let activeEffect; function effect(fn, options) { const effectFn = () => { // 执行副作用函数时, 将其设置为激活状态 effectStack.push(activeEffect = effectFn) cleanup(effectFn) let res = fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] return res } effectFn.deps = [] effectFn.options = options // 新增 if (!options?.lazy) { // 为了触发依赖收集 effectFn() } return effectFn } function track(target, key) { if (!activeEffect) { return } let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, depsMap = new Map()) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, deps = new Set()) } deps.add(activeEffect) activeEffect.deps.push(deps) } function trigger(target, key) { let depsMap = bucket.get(target) if (!depsMap) { return } let deps = depsMap.get(key) if (!deps) { return } let depsToRun = new Set(deps) depsToRun.forEach(effectFn => { if (activeEffect !== effectFn) { // 新增 effectFn?.options?.scheduler ? effectFn?.options?.scheduler(effectFn) : effectFn() } }) } function cleanup(effectFn) { let deps = effectFn.deps.pop(); while (deps) { deps.delete(effectFn) deps = effectFn.deps.pop(); } } function reactive(obj) { const reactiveObj = new Proxy(obj, { get(target, prop) { track(target, prop) return Reflect.get(...arguments) }, set(target, prop, val) { target[prop] = val; trigger(target, prop) return true }, }) return reactiveObj } function computed(getter) { let cached; let dirty = true; let effectFn = effect(getter, { lazy: true, scheduler() { dirty = true trigger(obj, 'value') } }) let obj = { get value() { if (dirty) { cached = effectFn() dirty = false } track(obj, 'value') return cached } } return obj } let obj = reactive({ a: 1, b: 2 }) let sumRes = computed(() => obj.a + obj.b) console.log('sum is', sumRes.value) effect(() => { console.log('sum', sumRes.value) }) console.log('---') obj.a++ console.log('new sum is', sumRes.value)
sum is 3 sum 3 --- sum 4 new sum is 4
4.9. watch 的实现原理
watch
的使用如下:
watch(obj, () => { console.log('obj change') }) obj.foo++
其本质是, 响应式数据变更时, 执行回调函数。 effect
中注册的副作用函数,会在每次 set 时触发执行, 故可以利用此来执行回调。
function watch(source, cb) { let oldVal; let getter; if (typeof source === 'function') { getter = source } else { // 遍历触发其属性到读取 getter = () => traverse(source) } let effectFn = effect(() => getter(), { lazy: true, scheduler(fn) { let newVal = fn() cb(newVal, oldVal) oldVal = newVal } }) oldVal = effectFn() } function traverse(obj, seen = new Set()) { // 去重, 防止嵌套自引用导致到无限循环 if (seen.has(obj)) return if (typeof obj !== 'object' && obj !== null) { return } seen.add(obj) for (let k in obj) { traverse(obj[k], seen) } return obj }
const bucket = new WeakMap() let effectStack = []; let activeEffect; function effect(fn, options) { const effectFn = () => { // 执行副作用函数时, 将其设置为激活状态 effectStack.push(activeEffect = effectFn) cleanup(effectFn) let res = fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] return res } effectFn.deps = [] effectFn.options = options // 新增 if (!options?.lazy) { // 为了触发依赖收集 effectFn() } return effectFn } function track(target, key) { if (!activeEffect) { return } let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, depsMap = new Map()) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, deps = new Set()) } deps.add(activeEffect) activeEffect.deps.push(deps) } function trigger(target, key) { let depsMap = bucket.get(target) if (!depsMap) { return } let deps = depsMap.get(key) if (!deps) { return } let depsToRun = new Set(deps) depsToRun.forEach(effectFn => { if (activeEffect !== effectFn) { // 新增 effectFn?.options?.scheduler ? effectFn?.options?.scheduler(effectFn) : effectFn() } }) } function cleanup(effectFn) { let deps = effectFn.deps.pop(); while (deps) { deps.delete(effectFn) deps = effectFn.deps.pop(); } } function reactive(obj) { const reactiveObj = new Proxy(obj, { get(target, prop) { track(target, prop) return Reflect.get(...arguments) }, set(target, prop, val) { target[prop] = val; trigger(target, prop) return true }, }) return reactiveObj } function computed(getter) { let cached; let dirty = true; let effectFn = effect(getter, { lazy: true, scheduler() { dirty = true trigger(obj, 'value') } }) let obj = { get value() { if (dirty) { cached = effectFn() dirty = false } track(obj, 'value') return cached } } return obj } function watch(source, cb) { let oldVal; let getter; if (typeof source === 'function') { getter = source } else { // 遍历触发其属性到读取 getter = () => traverse(source) } let effectFn = effect(() => getter(), { lazy: true, scheduler(fn) { let newVal = fn() cb(newVal, oldVal) oldVal = newVal } }) oldVal = effectFn() } function traverse(obj, seen = new Set()) { // 去重, 防止嵌套自引用导致到无限循环 if (seen.has(obj)) return if (typeof obj !== 'object' && obj !== null) { return } seen.add(obj) for (let k in obj) { traverse(obj[k], seen) } return obj } let obj = reactive({ a: 1, b: 2 }) watch(() => obj.a, (v) => console.log('obj.a is', v)) obj.a++ obj.a++ watch(obj, (newV, oldV) => console.log('newV', newV)) obj.b++ obj.b++
obj.a is 2 obj.a is 3 newV { a: 3, b: 3 } newV { a: 3, b: 4 }
4.10. 立即执行的 watch 与回调执行时机
上述的方案有两个问题:
- 回调函数不能马上执行一次(实现
immediate
) - 不能控制执行时机(实现
flush
)(DOM
更新前(sync
)执行还是更新后(post
)执行)
可以如下实现:
function watch(source, cb, options = {}) { let getter; if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) } let oldV; let job = () => { let newV = effectFn(); if (options.flush === 'pre') { cb(newV, oldV) } else if (options.flush === 'post') { Promise.resolve().then(() => { cb(newV, oldV) }) } oldV = newV } let effectFn = effect(() => getter(), { lazy: true, scheduler() { job() } }) if (options.immediate) { job() } else { oldV = effectFn() } }
完整示例如下:
const bucket = new WeakMap() let effectStack = []; let activeEffect; function effect(fn, options) { const effectFn = () => { // 执行副作用函数时, 将其设置为激活状态 effectStack.push(activeEffect = effectFn) cleanup(effectFn) let res = fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] return res } effectFn.deps = [] effectFn.options = options // 新增 if (!options?.lazy) { // 为了触发依赖收集 effectFn() } return effectFn } function track(target, key) { if (!activeEffect) { return } let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, depsMap = new Map()) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, deps = new Set()) } deps.add(activeEffect) activeEffect.deps.push(deps) } function trigger(target, key) { let depsMap = bucket.get(target) if (!depsMap) { return } let deps = depsMap.get(key) if (!deps) { return } let depsToRun = new Set(deps) depsToRun.forEach(effectFn => { if (activeEffect !== effectFn) { // 新增 effectFn?.options?.scheduler ? effectFn?.options?.scheduler(effectFn) : effectFn() } }) } function cleanup(effectFn) { let deps = effectFn.deps.pop(); while (deps) { deps.delete(effectFn) deps = effectFn.deps.pop(); } } function reactive(obj) { const reactiveObj = new Proxy(obj, { get(target, prop) { track(target, prop) return Reflect.get(...arguments) }, set(target, prop, val) { target[prop] = val; trigger(target, prop) return true }, }) return reactiveObj } function computed(getter) { let cached; let dirty = true; let effectFn = effect(getter, { lazy: true, scheduler() { dirty = true trigger(obj, 'value') } }) let obj = { get value() { if (dirty) { cached = effectFn() dirty = false } track(obj, 'value') return cached } } return obj } function traverse(obj, seen = new Set()) { // 去重, 防止嵌套自引用导致到无限循环 if (seen.has(obj)) return if (typeof obj !== 'object' && obj !== null) { return } seen.add(obj) for (let k in obj) { traverse(obj[k], seen) } return obj } function watch(source, cb, options = {}) { let getter; if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) } let oldV; let job = () => { let newV = effectFn(); if (options.flush === 'pre') { cb(newV, oldV) } else if (options.flush === 'post') { Promise.resolve().then(() => { cb(newV, oldV) }) } oldV = newV } let effectFn = effect(() => getter(), { lazy: true, scheduler() { job() } }) if (options.immediate) { job() } else { oldV = effectFn() } } let obj = reactive({ a: 1, b: 2 }) watch(() => obj.a, (v) => console.log('obj.a is', v), { immediate: true, flush: 'post' }) obj.a++ console.log('end')
end obj.a is 1 obj.a is 2
4.11. 过期的副作用
当 watch
的回调是一个异步函数时, 可能会存在 竞态问题 。如下:
watch(obj, async () => { let resp = await fetch(url, { data: obj }) finalData = resp })
假设 obj
变更了两次, 期望 finalData
是第二次变更的结果。 但是由于不能保证两次请求会按照请求次序返回,故有可能导致 finalData
是一个过期的值。
解决方案: 可传入一个注册函数给回调函数, 让其内部可以监听 值 过期的情景
watch(obj, async (newV, oldV, onCleanup) => { let expired = false; onCleanup(() => { expired = true }) const resp = await fetch(url, { data: obj }) if (!expired) { finalData = resp; } })
对应的 watch
的实现逻辑:
function watch(source, cb, options = {}) { let getter; if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) } let oldV; let clean; let onCleanup = (fn) => { clean = fn } let job = () => { // 每次变更前, 清理一下 clean && clean() let newV = effectFn(); cb(newV, oldV, onCleanup) oldV = newV } let effectFn = effect(() => getter(), { lazy: true, scheduler() { if (options.flush === 'pre') { job() } else if (options.flush === 'post') { Promise.resolve().then(job) } } }) if (options.immediate) { job() } else { oldV = effectFn() } }
4.12. 总结
实现一个响应式系统,需要处理下面几个问题:
- 用
effect
注册副作用函数 - 在
Proxy
的getter
中实现track
将对应的target
,key
,effectFn
收集到依赖集合中 (数据结构依次是,WeakMap
Map
Set
) - 实现
cleanup
清理掉因分支切换导致的无效的副作用函数(通过effectFn.deps
反向收集对该副作用函数的依赖集合, 从而进行清理) - 处理在
effect
同时执行 get 和 set 操作,导致无限递归问题(同时执行时,activeEffect
会与trigger
中的需要执行的effectFn
相同) - 处理
effect
嵌套导致activeEffect
丢失外层引用effectFn
的问题 (使用effectStack
来收集activeEffect
) effect
实现一个scheduler
,把effectFn
的执行权交给外部
实现 computed
:
effect
实现lazy
,可选地延时调用副作用函数, 同时把副作用函数的控制权返回给调用方- 可缓存计算结果 (内部缓存一个值, 同时储存一个flag
dirty
, 当触发依赖值更新时, 设置dirty
为true
从而再次计算) - 可在
effect
中收集该计算对象 (显示地调用track
和trigger
)
实现 watch
:
- 在
effect
的副作用函数中遍历监听对象的属性,即可将该对象收集到依赖集合中(依赖的响应式数据,每次 set 都会导致副作用函数的执行, 从而导致scheduler
的执行) - 在
scheduler
中来执行回调 - 实现
immediate
(立刻执行)和flush
(DOM更新前还是更新后) - 处理过期的副作用, 传入
onCleanup
给副作用函数, 执行下次cb
前, 都执行一下cleanup
5. 非原始值的响应式方案
5.1. 理解 Proxy 和 Reflect
Proxy
仅拦截对对象的 基本操作 , 例如get
set
apply
- 对于 复合操作 , 例如
obj.foo()
,Proxy
不会递归拦截
考虑下面场景
const obj = { foo: 1, get bar() { return this.foo } } const reactiveObj = new Proxy(obj, { get(target, key) { track(target, key) return target[key] }, set(target, key) { trigger(target, key) return true } }) effect(() => { console.log(reactiveObj.bar) }) reactiveObj.foo++
- 期望的结果是,
foo
自增时,会执行副作用函数 - 实际结果并没有, 因为
get
中target[key]
的target
指代的是obj
, 其并没有代理拦截,所以导致foo
并没有收集到依赖中
解决方案:
const reactiveObj = new Proxy(obj, { get(target, key, receiver) { track(target, key) return Reflect.get(target, key, receiver) }, })
receiver
指代的是reactiveObj
, 而不是原始的obj
Reflect.get
中设置receiver
可以显示制定target[key]
的this
指向
5.2. JS 对象和 Proxy 的工作原理
在 ECMA2022 中,对于一个对象,其定义了下列内部方法 ecma2022 :
内部方法 | 签名 |
---|---|
[[GetPrototypeof]] | () => Object | Null |
[[SetPrototypeof]] | (Object | null) => Boolean |
[[IsExtensible]] | () => Boolean |
[[PreventExtensions]] | () => Boolean |
[[GetOwnProperty]] | (propKey) => Undefined | Property Descriptor |
[[DefineOwnProperty]] | (propKey, descriptor) => Boolean |
[[HasProperty]] | (propKey) => Boolean |
[[Get]] | (propKey, receiver) => any |
[[Set]] | (propKey, value, receiver) => Boolean |
[[Delete]] | (propKey) => Boolean |
[[OwnPropertyKeys]] | ()=> list of propKey |
对于函数对象,有额外两个
内部方法 | 签名 |
---|---|
[[Call]] | (any, list of any) => any |
[[Construct]] | (list of any) => Object |
- 常规对象 (Ordinary Object): 上述的内置方法由 emca-10.1 和 ecma-10.2 中定义
- 异质对象 (Exotic Object): 常规对象以外的对象, 比如
Proxy
代理对象有与常规对象相同的内置方法, 但是其定义遵守 ecma-10.5 :
内置方法 | handler |
---|---|
[[GetPrototypeOf]] | getPrototypeOf |
[[SetPrototypeOf]] | setPrototypeOf |
[[IsExtensible]] | isExtensible |
[[PreventExtensions]] | preventExtensions |
[[GetOwnProperty]] | getOwnPropertyDescriptor |
[[DefineOwnProperty]] | defineProperty |
[[HasProperty]] | has |
[[Get]] | get |
[[Set]] | set |
[[Delete]] | deleteProperty |
[[OwnPropertyKeys]] | ownKeys |
[[Call]] | apply |
[[Construct]] | construct |
创建一个代理对象时, 其 handler
定义了代理对象本身的内置方法的处理行为, 对于被代理对象的内置方法, 并没有影响
5.3. 如何代理 Object
对对象的代理操作,涉及到下面几种:
- 属性访问
[[Get]]
in
操作符for...in
遍历delete
属性
5.3.1. in
根据规范 ecma-13.10 , in
涉及到 [[HasProperty]]
内置方法,故可以如下拦截
new Proxy(obj, { has(target, key) { track(target, key) return Reflect.has(...arguments) } })
5.3.2. for...in
例如要响应下面示例:
effect(() => { for (key in reactiveObj) { console.log(key) } })
根据规范 14.7.5.6 和 14.7.5.9 , for...in
涉及到 [[OwnPropertyKeys]]
内置方法,故可以如下写:
const ITERATE_KEY = Symbol() new Proxy(obj, { ownKeys(target) { track(target, ITERATE_KEY) return Reflect.ownKeys(target) } })
其中,在添加属性时, 需要运行副作用函数, 故需要改写触发时机:
new Proxy(obj, { set(target, key, value) { let res = Reflect.set(...arguments) const triggerType = target.hasOwnProperty(key) ? 'ADD' : 'SET' trigger(target, key, triggerType) return res } }) function trigger(target, key, triggerType) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) || new Set() const effectsToRun = new Set([...effects, ...(triggerType === 'ADD' ? depsMap.get(ITERATE_KEY) || [] : [])]) effectsToRun.forEach(effectFn => { effectFn.options.scheduler ? effectFn.options.scheduler(effectFn) : effectFn() }) }
5.3.3. delete
当删除属性时, 会同时影响到 in
和 for...in
的结果, 故也要触发 ITERATE_KEY
的副作用函数。 根据规范 ecma-13.5.1 其涉及到 [[Delete]]
内置方法, 故如下实现:
new Proxy(obj, { deleteProperty(target, key) { const res = Reflect.deleteProperty(...arguments) if (res && Reflect.hasOwnProperty(target, key)) { trigger(target, key, 'DELETE') } return res } }) function trigger(target, key, triggerType) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) || new Set() const effectsToRun = new Set([...effects, ...( triggerType === 'ADD' || triggerType === 'DELETE' ? depsMap.get(ITERATE_KEY) || [] : [] )]) effectsToRun.forEach(effectFn => { effectFn.options.scheduler ? effectFn.options.scheduler(effectFn) : effectFn() }) }
5.4. 合理地触发响应
触发响应,需要处理下面几个问题:
- 如果新值和旧值相等,那么应该不执行副作用函数
- 假设
parent
时child
的原型, 且两者都是响应式对象, 当从原型链上设置值时,应该只触发一次副作用函数
对于问题1, 可如下解决:
function reactive(obj) { return new Proxy(obj, { set(target, key, value, receiver) { const oldVal = target[key] const res = Reflect.set(target, key, value, receiver) const exists = target.hasOwnProperty(key) if (oldVal !== val && !(Number.isNaN(value) && Number.isNaN(oldVal))) { trigger(target, key, exists ? 'SET' : 'ADD') } return res } }) }
对于问题2, 考虑以下场景:
const obj = {} const proto = { foo: 1 } const child = reactive(obj) const parent = reactive(proto) Object.setPrototypeOf(child, parent) effect(() => { console.log(child.foo) }) child.foo++
- 因为
child.foo
是从parent
上读取的, 所以会调用child
和parent
的内置方法[[GET]]
, 因此副作用函数会分别收集到child.foo
和parent.foo
的依赖中 - 当触发
child.foo
时,根据 ecma-10.1.92 , 因为child
没有foo
这个属性,所以也会调用原型上的[[Set]]
(注意原型链上对应的属性的值并不会被设置),因此导致该副作用函数会执行两遍 - 两次拦截的
set
中的receiver
都是child
,利用此点,可以只在第一次set
时才触发执行 (即target
与receiver
的代理原始对象相等时)
const RAW = Symbol() function reactive(obj) { return new Proxy(obj, { get(target, key) { if (key === RAW) { return obj } track(target, key) return Reflect.get(...arguments) }, set(target, key, value, receiver) { const oldVal = target[key] const res = Reflect.set(...arguments) if (oldVal !== value && !(Number.isNaN(value) && Number.isNaN(oldVal)) && receiver[raw] === target) { const type = target.hasOwnProperty(key) ? 'SET' : 'ADD' trigger(target, key, type) } return res } }) }
5.5. 浅响应与深响应
目前创建的响应式对象,都是浅响应的,如果要做到深层响应,只要递归执行 reactive
即可, 如下
function createReactive(obj, shallow = false) { return new Proxy(obj, { get(target, key) { if (key === RAW) { return obj } const val = target[key] const res = Reflect.get(...arguments) track(target, key) if (shallow) { return res } if (typeof val === 'object' && val !== null) { return createReactive(val, shallow) } return res } }) } function reactive(obj) { return createReactive(obj, false) } function shallowReactive(obj) { return createReactive(obj, true) }
5.6. 只读和浅只读
实现只读,需注意下面几点:
- 拦截
set
, 不设置值 - 拦截
deleteProperty
, 不进行操作 - 只读对象不会变更, 那么就不需要
track
- 如果要做到深层只读, 那么递归调用
readonly
即可
function createReactive(obj, isShallow, isReadonly) { return new Proxy(obj, { get(target, key) { if (key === RAW) { return obj } if (!isReadonly) { track(target, key) } const value = target[key] const res = Reflect.get(...arguments) if (isShallow) { return res } if (typeof value === 'object' && value !== null) { return createReactive(obj, isShallow, isReadonly) } return res }, set(target, key, value, receiver) { if (!isReadonly) { console.warn(`${key} is readonly`) return true } const oldVal = target[key] const res = Reflect.set(...arguments) const type = target.hasOwnProperty(key) ? 'SET' : 'ADD' if (oldVal !== value && !(Number.isNaN(oldVal) && Number.isNaN(value)) && receiver[RAW] === target ) { trigger(target, key, type) } return res }, deleteProperty(target, key) { if (isReadonly) { console.warn(`${key} is readonly`) return true } const hadKey = target.hasOwnProperty(key) const res = Reflect.deleteProperty(...arguments) if (hadKey && res) { trigger(target, key, 'DELETE') } return res } }) } function readonly(obj) { return createReactive(obj, false, true) } function shallowReadonly(obj) { return createReactive(obj, true, true) }
5.7. 代理数组
代理数组需要处理下面几个问题:
- 通过数组索引赋值, 导致
length
变更 - 修改
length
导致超过length
的索引的值为undefined
- 数组的查询方法涉及到的对象相等性的比较
- 数组的栈方法导致数组的变更
5.7.1. 数组索引与 length
示例
const arr = reactive([1]) effect(() => { console.log(arr.length) }) arr[10] = 12 // 期望可以触发 effect(() => { console.log(arr[0]) }) arr.length = 0 // 期望可以触发
- 数组的
[[DefineOwnProperty]]
由 10.4.2.1 定义, 所以数组也是 异质对象 - 当数组索引赋值的
index
≥length
时,会导致length
的变更 length
的变更, 也会影响到数组元素
根据上面几点,可如下处理
function isObject(val) { return typeof val === 'object' && val !== null } const isArray = Array.isArray function isEqual(a, b) { return a === b || (Number.isNaN(a) && Number.isNaN(b)) } function createReactive(obj, isShallow, isReadonly) { return new Proxy(obj, { set(target, key, value, receiver) { if (isReadonly) { console.log(`${key} is readonly`) return } const oldVal = target[key] const type = isArray(target) ? Number(key) >= target.length ? 'ADD' : 'SET' : target.hasOwnProperty(key) ? 'SET' : 'ADD' const res = Reflect.set(...arguments) if (target === receiver[RAW] && !isEqual(oldVal, value)) { trigger(target, key, type, value) } return res } }) } function trigger(target, key, type, newVal) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) || [] const effectsToRun = new Set(effects) if (isArray(target)) { const effectsLength = depsMap.get('length') if (type === 'ADD') { effectsLength && effectsLength.forEach(effect => effectsToRun.add(effect)) } if (key === 'length') { depsMap.forEach((effectsIndex, idx) => { if (idx >= newVal) { effectsIndex.forEach(effect => effectsToRun.add(effect)) } }) } } else { if (type === 'ADD' || type === 'DELETE') { (depsMap.get(ITERATE_KEY) || []).forEach(fn => effectsToRun.add(fn)) } } effectsToRun.forEach(effectFn => { if (effectFn === activeEffect) return effectFn.options.scheduler ? effectFn.options.scheduler(effectFn) : effectFn() }) }
5.7.2. for...in
和 for...of
问题示例:
const arr = reactive([1, 2]) effect(() => { for (const i in arr) { console.log(arr[i]) } }) // 期望都能执行 arr[10] = 3; arr.length = 0
- 处理对象的
for...in
时,之前是在ownKeys
中track
ITERATE_KEY
。对于数组来说,ADD
和DELETE
都会引起length
的变更, 所以可以直接track
length
- 对于
for...of
, 根据 23.1.5 , 迭代过程中会访问length
以及所有索引, 所以不需要做更改就已经track
了对应的副作用函数 - 为了避免意外错误, 不
track
类似Symbol.iterator
这种 symbol 值
function createReactive(obj, isShallow, isReadonly) { return new Proxy(obj, { get(target, key, receiver) { if (key === RAW) { return target } const res = Reflect.get(...arguments) if (!isReadonly && typeof key !== 'symbol') { track(target, key) } if (!isShallow && isObject(res)) { return createReactive(res, isShallow, isReadonly) } return res }, ownKeys(target) { track(target, isArray(target) ? 'length' : ITERATE_KEY) return Reflect.ownKeys(target) } }) }
5.7.3. 处理查询方法
对于 includes
indexOf
lastIndexOf
这类方法,要处以下问题
示例
const obj = {} const arr = reactive([obj]) console.log(arr.includes(obj)) // 期望是 true
- 当代理的对象数组上,使用
includes
原始对象时, 要返回true
, 故需要重写对应的方法 - 处理每次递归
track
都会新建代理对象的问题
function toRaw(observed) { const raw = observed && observed[RAW] return raw ? toRaw(observed) : raw // 即使是嵌套创建响应式对象, 也可以拿到最初的原始对象 } const arrayInstrumentations = createArrayInstrumentations() function createArrayInstrumentations() { const instrumentations = {} ['includes', 'lastIndexOf', 'indexOf'].forEach(method => { const originMethod = Array.prototype[method] instrumentations[method] = function (...args) { let result = originMethod.apply(this, args) if (result === false || result === -1) { return originMethod.apply(toRaw(this), args.map(toRaw)) } return result } }) return instrumentations } const proxyMap = new WeakMap() function createReactive(obj, isShallow, isReadonly) { const existProxy = proxyMap.get(target) if (existProxy) { return existProxy } const proxy = new Proxy(obj, { get(target, key, receiver) { if (key === RAW) { return target } if (isArray(target) && arrayInstrumentations.hasOwnProperty(key)) { return Reflect.get(arrayInstrumentations, key, receiver) } // ...省略 } }) proxyMap.set(obj, proxy) return proxy }
5.7.4. 处理会修改数组长度的方法
诸如 push
pop
shift
unshift
splice
这类方法,需要处理以下问题
示例:
const arr = reactive([]) effect(() => { arr.push(1) }) effect(() => { arr.push(2) })
- 依据 23.1.3.20 , 方法运行时, 会读取
length
和写入length
- 当一个响应式数组,在两个副作用函数中都使用了
push
时, 会导致来回执行, 从而堆栈溢出 - 解决思路: 把这类方法看成是写操作, 屏蔽读取
length
的track
调用
const arrayInstrumentations = createArrayInstrumentations() let shouldTrack = true function createArrayInstrumentations() { const instrumentations = {} ['includes', 'lastIndexOf', 'indexOf'].forEach(method => { instrumentations[method] = function (...args) { const raw = toRaw(this) const result = raw[method].apply(this, args) if (result === false || result === -1) { return raw[method].apply(raw, args.map(toRaw)) } return result } }) ['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => { instrumentations[method] = function (...args) { shouldTrack = false const result = toRaw(this)[method].apply(raw, args) shouldTrack = true return result } }) return instrumentations } function track(target, key) { if (!activeEffect || !shouldTrack) return // ... 省略 }
5.8. 代理 Set 和 Map
5.8.1. 处理 this
指向问题
Set
和 Map
的公共原型属性/方法
- size
- clear()
- delete()
- has()
- keys()
- values()
- entries()
- forEach()
Set
特有
- add()
Map
特有
- set()
- get()
代理 Set
和 Map
需要解决的问题:
const s = new Set([1]) const p = new Proxy(s, {}) p.size // throw error , 因为在不兼容的 receiver 上调用 get size
根据 24.2.3.9 , 调用 get size 的 receiver 需要有 [[SetData]]
这个内部槽 (同理, Map
需要有 [[MapData]]
), 而 Proxy
不会有。
解决方法:
- 把 receiver 指向原始的对象
function reactive(obj) { const p = new Proxy(obj, { get(target, key) { track(target, key) if (key === 'size') { return Reflect.get(target, key, obj) } return Reflect.get(...arguments) } }) return p }
调用方法时:
const s = reactive(new Set([1])) s.delete(1) // throw error
因为上述方法调用的 this
指向的时 Proxy
, 所以也不存在内部槽 [[SetData]] 或 [[MapData]] 。
解决方法:
- 重新绑定
this
const p = new Proxy(new Set(), { get(target, key) { if (key === 'size') { return Reflect.get(target, key, target) } return target[key].bind(target) } })
将其封装好:
const bucket = new WeakMap() function reactive(obj) { let cached = bucket.get(obj) if (!cached) { cached = createReactive(obj, false, false) bucket.set(obj, cached) } return cached } function createReactive(obj, shallow, readonly) { return new Proxy(obj, { get(target, key) { if (key === 'size') { return Reflect.get(target, key, target) } return target[key].bind(target) } }) }
5.8.2. 建立响应关系
const m = reactive(new Map([['key', 1]])) effect(() => { m.get('m') m.size }) m.set('m', 12) // 没有触发副作用函数的执行 m.set('a', 12) // 没有触发
- 访问时 track
ITERATE_KEY
- 重写
get
以 track add
,set
,delete
时 trigger, 即重写对应的方法
const instruments = createInstruments() function createInstruments() { return { delete(key) { const target = toRaw(this) const hadKey = target.has(key) const res = target.delete(key) if (hadKey) { trigger(target, key, 'DELETE') } return res } clear() { const target = toRaw(this) const hadItems = target.size > 0 target.clear() if (hadItems) { trigger(target, ITERATE_KEY, 'CLEAR') } } add(key) { const target = toRaw(this) const hadKey = target.has(key) const res = target.add(key) if (!hadKey) { trigger(target, key, 'ADD') } return res } get(key) { const target = toRaw(this) track(target, key) const hadKey = target.has(key) const res = target.get(key) if (hadKey) { return isObject(res) ? reactive(res) : res // 仍然返回响应式对象 } } set(key, value) { const target = toRaw(this) const hadKey = target.has(key) const oldValue = target.get(key) const res = target.set(key, toRaw(value)) // 不要让响应式数据污染源对象 if (!hadKey) { trigger(target, key, 'ADD') } else if (!isEqual(oldValue, value)) { trigger(target, key, 'SET') } return res } } } function createReactive(obj) { return new Proxy(obj, { get(target, key) { if (key === 'size') { track(target, ITERATE_KEY) return Reflect.get(target, key, target) } return instruments[key] } }) }
5.8.3. 处理 forEach
- 因为
delete
,add
,clear
都会影响到迭代, 故在forEach
调用时, trackITERATE_KEY
function wrap(v) { return isObject(v) ? reactive(v) : v } mutationInstrumentaions = { forEach(fn, thisArg) { const target = toRaw(this) track(target, ITERATE_KEY) target.forEach((v, k) => { fn.apply(thisArg, [wrap(v), wrap(k), this]) }) } }
对于 Map
的 forEach
, 即使是重新 set
新值时, 也应该重新执行副作用函数。故如下修改:
function isMap(obj) { Object.prototype.toString.call(obj) === '[object Map]' } function trigger(target, key, type) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) || [] const effectsToRun = new Set(effects) if (type === 'DELETE' || type === 'ADD' || (type === 'SET' && isMap(target))) { (depsMap.get(ITERATE_KEY) || []).forEach(fn => effectsToRun.add(fn)) } // ... 省略 }
5.8.4. 处理迭代器方法
- Map.entries 与 Map[Symbol.iterator] 是相等的
- 在
for..of
中, 应该返回 响应式数据
实现 可迭代协议 指的是实现了 Symbol.iterator
方法, 实现 迭代器协议 指的是实现了 next
方法
function iterateMethod() { const target = toRaw(this) track(target, ITERATE_KEY) const it = target[Symbol.iterator]() // 返回迭代器对象 return { next() { const { value, done } = it.next() const return { value: value ? [wrap[value[0], wrap(value[1])]] : value, done } }, // 实现可迭代协议 [Symbol.iterator]() { return this } } } const mutationInstrumentations = { [Symbol.iterator]: iterateMethod, entries: iterateMethod }
5.8.5. 处理 values
和 keys
与上述类似
const MAP_KEY_ITERATOR_KEY = Symbol() const mutationInstrumentations = { values() { const target = toRaw(this) track(target, ITERATE_KEY) const it = target.values() return { next() { const { value, done } = it.next() return { value: wrap(value), done } }, [Symbol.iterator]() { return this } } }, keys() { const target = toRaw(this) track(target, MAP_KEY_ITERATOR_KEY) const it = target.keys() return { next() { const { value, done } = it.next() return { value: wrap(value), done } }, [Symbol.iterator]() { return this } } } } function trigger(target, key, type) { // .. if (type === 'ADD' || type === 'DELETE' || (type === 'SET' && isMap(target))) { (effects.get(MAP_KEY_ITERATOR_KEY) || []).forEach(fn => effectsToRun.add(fn)) } }
5.9. 总结
- 代理对象需要处理属性的添加和删除触发
for..in
,in
相关副作用函数的执行 - 代理数组需要处理
length
与索引下表赋值的互相影响- 跟踪
for..in
和for..of
(这个不用特殊处理) includes
,indexOf
,lastIndexOf
传入响应式数据的情况pop
push
unshift
shift
splice
同时触发length
的读写, 导致存在两个副作用函数时,无限递归
- 代理
Map
Set
需要处理- size 的
this
指向 get
set
方法需要收集对应 key 的依赖add
delete
等方法对forEach
values
keys
entries
size
Symbol.iterator
for..of
的影响
- size 的
- 实现对象的 深响应/浅响应, 深只读/浅只读
完整示例
const isObject = (v) => typeof v === 'object' && v !== null const isMap = (v) => Object.prototype.toString.call(v) === '[object Map]' const isSet = (v) => Object.prototype.toString.call(v) === '[object Set]' const isArray = Array.isArray const isEqual = (a, b) => a === b || (Number.isNaN(a) && Number.isNaN(b)) function toRaw(obj) { const v = obj && obj[RAW] return v ? toRaw(v) : obj } const RAW = Symbol() const ITERATE_KEY = Symbol() const MAP_KEY_ITERATE_KEY = Symbol() const activeEffectStack = [] let activeEffect; const bucket = new WeakMap() function effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn activeEffectStack.push(effectFn) const res = fn() activeEffectStack.pop() activeEffect = activeEffectStack[activeEffectStack.length - 1] return res } effectFn.options = options effectFn.deps = [] if (!effectFn.options.lazy) { effectFn() } return effectFn } function track(target, key) { if (!activeEffect || !shouldTrack) return let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, depsMap = new Map()) } let effects = depsMap.get(key) if (!effects) { depsMap.set(key, effects = new Set()) } activeEffect.deps.push(effects) effects.add(activeEffect) } function trigger(target, key, type, newValue) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) || [] const effectsToRun = new Set(effects) const iterateEffects = depsMap.get(ITERATE_KEY) || [] const mapKeyIterateEffects = depsMap.get(MAP_KEY_ITERATE_KEY) || [] if (type === 'add' || type === 'delete') { iterateEffects.forEach(fn => effectsToRun.add(fn)) mapKeyIterateEffects.forEach(fn => effectsToRun.add(fn)) } if (isMap(target) && type === 'set') { iterateEffects.forEach(fn => effectsToRun.add(fn)) } if (isArray(target)) { if (type === 'add') { const lengthEffects = depsMap.get('length') || [] lengthEffects.forEach(fn => effectsToRun.add(fn)) } if (key === 'length') { Array.from(depsMap.keys()) .forEach(idx => idx >= newValue && depsMap.get(idx).forEach(fn => effectsToRun.add(fn)) ) } } effectsToRun.forEach(effectFn => { if (effectFn !== activeEffect) { effectFn.options.scheduler ? effectFn.options.scheduler(effectFn) : effectFn() } }) } function cleanup(effectFn) { effectFn.deps.forEach(effects => { effects.delete(effectFn) }) effectFn.deps.length = 0 } let shouldTrack = true const arrayInstrumentations = createArrayInstrumentations() function createArrayInstrumentations() { const instrumentations = {}; ['includes', 'indexOf', 'lastIndexOf'].forEach(method => { instrumentations[method] = function (...args) { const target = toRaw(this) const res = target[method](...args) if (res === false || res === -1) { return target[method](...args.map(toRaw)) } return res } }); ['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => { instrumentations[method] = function (...args) { const target = toRaw(this) shouldTrack = false; const res = target[method](...args) shouldTrack = true return res } }) return instrumentations } function wrap(v) { return isObject(v) ? reactive(v) : v } const mutationInstrumentations = createMutationInstrumentations() function createMutationInstrumentations() { const iterFn = function () { const target = toRaw(this) const it = target[Symbol.iterator]() track(target, ITERATE_KEY) return { next() { const { done, value } = it.next() return { done, value: value ? [wrap(value[0]), wrap(value[1])] : value } }, [Symbol.iterator]() { return this } } } return { get(key) { const target = toRaw(this) track(target, key) const res = target.get(key) return wrap(res) }, add(key) { const target = toRaw(this) const hadKey = target.has(key) const res = target.add(key) if (!hadKey) { trigger(target, key, 'add') } return res }, set(key, val) { const target = toRaw(this) const hadKey = target.has(key) const oldValue = target.get(key) const res = target.set(key, toRaw(val)) if (!hadKey) { trigger(target, key, 'add', val) } else if (!isEqual(val, oldValue)) { trigger(target, key, 'set', val) } return res }, delete(key) { const target = toRaw(this) const hadKey = target.has(key) const res = target.delete(key) if (hadKey) { trigger(target, key, 'delete') } return res }, forEach(fn, thisArg) { const target = toRaw(this) track(target, ITERATE_KEY) target.forEach((v, k) => { fn.call(thisArg, wrap(v), wrap(k), this) }) }, entries: iterFn, [Symbol.iterator]: iterFn, keys() { const target = toRaw(this) track(target, MAP_KEY_ITERATE_KEY) const it = target.keys() return { next() { const { done, value } = it.next() return { done, value: wrap(value) } }, [Symbol.iterator]() { return this } } }, values() { const target = toRaw(this) track(target, ITERATE_KEY) const it = target.values() return { next() { const { done, value } = it.next() return { done, value: wrap(value) } }, [Symbol.iterator]() { return this } } } } } function createReactiveObject(obj, proxyMap, isShallow = false, isReadonly = false) { let cached = proxyMap.get(obj) if (!cached) { cached = new Proxy(obj, { get(target, key, receiver) { if (key === RAW) { return obj } if (arrayInstrumentations.hasOwnProperty(key)) { return arrayInstrumentations[key] } if (isMap(target) || isSet(target)) { if (mutationInstrumentations.hasOwnProperty(key)) { return mutationInstrumentations[key] } if (key === 'size') { track(target, ITERATE_KEY) return target[key] } } if (!isReadonly && typeof key !== 'symbol') { track(target, key) } const res = Reflect.get(target, key, receiver) if (isObject(res) && !isShallow) { return createReactiveObject(obj, proxyMap, isShallow, isReadonly) } return res }, set(target, key, value, receiver) { if (isReadonly) { console.log(`${key} is readonly`) return true } const hadKey = target.hasOwnProperty(key) const oldValue = target[key] const type = isArray(target) ? key >= target.length ? 'add' : 'set' : hadKey ? 'set' : 'add' const res = Reflect.set(target, key, value, receiver) if (!isEqual(oldValue, value) && target === toRaw(receiver) // 忽略原型链上的赋值 ) { trigger(target, key, type, value) } return res }, deleteProperty(target, key) { const hadKey = target.hasOwnProperty(key) const res = Reflect.deleteProperty(target, key) if (hadKey) { trigger(target, key, 'delete') } return res }, has(target, key) { track(target, key) return Reflect.has(target, key) }, ownKeys(target) { isArray(target) ? track(target, 'length') : track(target, ITERATE_KEY) return Reflect.ownKeys(target) } }) proxyMap.set(obj, cached) } return cached } const reactiveMap = new WeakMap() function reactive(obj) { return createReactiveObject(obj, reactiveMap) } const shallowReactiveMap = new WeakMap() function shallowReactive(obj) { return createReactiveObject(obj, shallowReactiveMap, true) } const readonlyMap = new WeakMap() function readonly(obj) { return createReactiveObject(obj, readonlyMap, false, true) } const shallowReadonlyMap = new WeakMap() function shallowReadonly(obj) { return createReactiveObject(obj, shallowReadonlyMap, true, true) } let obj = reactive({ foo: 2, baz: 10 }) effect(() => { console.log('1. foo in obj', 'foo' in obj) }) delete obj.foo effect(() => { for (const key in obj) { console.log(`2. ${key} in obj`) } console.log('---') }) obj.bar = 3 obj.bar = 5 delete obj.bar effect(() => { console.log('obj.baz', obj.baz) }) obj.baz = 12 console.log('值没变化,不触发') obj.baz = 12 console.log('继承的属性赋值应该只触发一次') obj = {} const proto = { bar: 1 } const child = reactive(obj) const parent = reactive(proto) Object.setPrototypeOf(child, parent) effect(() => { console.log('child.bar', child.bar) }) child.bar = 12 console.log('深层响应') obj = reactive({ foo: { bar: 1 } }) effect(() => { console.log('obj.foo.bar', obj.foo.bar) }) obj.foo.bar = 12 console.log('浅响应') obj = shallowReactive({ foo: { bar: 1 } }) effect(() => { console.log('obj.foo.bar', obj.foo.bar) }) obj.foo = { bar: 3 } obj.foo.bar = 10 console.log('只读') obj = readonly({ foo: 1, bar: { baz: 3 } }) obj.foo = 2 // 警告 obj.bar.baz = 12 // 警告 console.log('浅只读') obj = shallowReadonly({ foo: 1, bar: { baz: 1 } }) obj.foo = 2 // 警告 obj.bar.baz = 3 // 不警告 console.log('代理数组') let arr = reactive(['foo']) effect(() => { console.log(arr[0]) }) arr[0] = 'bar' effect(() => { console.log('length', arr.length) }) console.log('设置越界索引能够触发 length 的响应') arr[1] = 'xxx' // 触发响应 arr = reactive([0, 1]) effect(() => { console.log('arr[0]', arr[0]) }) effect(() => { console.log('arr[1]', arr[1]) }) console.log('修改length导致的元素被删除,也会触发响应') arr.length = 1 arr = reactive([1]) effect(() => { for (const key in arr) { console.log(`arr[${key}]`) } }) console.log('正确触发 for..in') arr[2] = 'bar' console.log('---') arr.length = 1 arr = reactive([1]) effect(() => { for (const v of arr) { console.log(v) } }) console.log('正确触发 for..of') arr[1] = 3 console.log('---') arr.length = 1 obj = {} arr = reactive([obj]) console.log('includes', arr.includes(obj)) console.log('不会堆栈溢出') arr = reactive([]) effect(() => { arr.push(1) }) effect(() => { arr.push(1) }) let map = reactive(new Map([['key', 1]])) effect(() => { console.log('map.get(key)', map.get('key')) }) console.log('Map.set') map.set('key', 2) console.log('----') map.set('key2', 3) console.log('map.size', map.size) console.log('map.delete', map.delete('key')) console.log('不污染原始数据') const m = new Map() const p1 = reactive(m) const p2 = reactive(new Map()) p1.set('p2', p2) effect(() => { console.log(m.get('p2').size) }) m.get('p2').set('a', 1) // 不触发 let p = reactive(new Map([[{ key: 1 }, { value: 1 }]])) effect(() => { p.forEach((v, k) => { console.log(`${k}: ${v}`) }) }) console.log('forEach') p.set({ key: 2 }, { value: 2 }) let key = { key: 1 } let value = new Set([1, 2, 3]) p = reactive(new Map([[key, value]])) console.log('----') effect(() => { p.forEach((v) => { console.log('v.size', v.size) }) }) console.log('forEach获取的应该也是响应式的') p.get(key).delete(1) console.log('Map.set 也要触发 forEach') p = reactive(new Map([['key', 1]])) effect(() => { p.forEach((v, k) => { console.log(`${k}: ${v}`) }) }) p.set('key', 4) console.log('迭代器方法') p = reactive(new Map([['key1', 'value1'], ['key2', 'value2']])) effect(() => { for (const [key, value] of p) { console.log(`${key}: ${value}`) } for (const k of p.keys()) { console.log('key: ', k) } for (const v of p.values()) { console.log('value: ', v) } }) p.set('key3', 'value3') p = reactive(new Map([['key', 'value']])) effect(() => { for (const k of p.keys()) { console.log('k: ', k) } }) console.log('应该不执行') p.set('key', 2) console.log('----')
1. foo in obj true 1. foo in obj false 2. baz in obj --- 2. baz in obj 2. bar in obj --- 2. baz in obj --- obj.baz 10 obj.baz 12 值没变化,不触发 继承的属性赋值应该只触发一次 child.bar 1 child.bar 12 深层响应 obj.foo.bar undefined obj.foo.bar 12 浅响应 obj.foo.bar 1 obj.foo.bar 3 只读 foo is readonly baz is readonly 浅只读 foo is readonly 代理数组 foo bar length 1 设置越界索引能够触发 length 的响应 length 2 arr[0] 0 arr[1] 1 修改length导致的元素被删除,也会触发响应 arr[1] undefined arr[0] 正确触发 for..in arr[0] arr[2] --- arr[0] 1 正确触发 for..of 1 3 --- 1 includes true 不会堆栈溢出 map.get(key) 1 Map.set map.get(key) 2 ---- map.size 2 map.get(key) undefined map.delete true 不污染原始数据 0 [object Object]: [object Object] forEach [object Object]: [object Object] [object Object]: [object Object] ---- v.size 3 forEach获取的应该也是响应式的 v.size 2 Map.set 也要触发 forEach key: 1 key: 4 迭代器方法 key1: value1 key2: value2 key: key1 key: key2 value: value1 value: value2 key1: value1 key2: value2 key3: value3 key: key1 key: key2 key: key3 value: value1 value: value2 value: value3 k: key 应该不执行 ----
6. 原始值的响应式方案
6.1. ref
实现 ref 需要处理的问题:
- 统一的规范
- 外部可以识别出其为 ref
function ref(v) { const wrapper = { value: v } Object.defineProperty(wrapper, '__v_isRef', { value: true }) return reactive(wrapper) }
6.2. 响应丢失问题
reactive
解构赋值时会存在响应丢失, 如下:
const reactiveObj = reactive({ foo: 1, bar: 1 }) const obj = { ...reactiveObj } effect(() => { obj.foo // 非响应值 })
可以改成下面的模式:
const reactiveObj = reactive({ foo: 1 }) const obj = { ...toRefs(reactiveObj) } obj.foo.value // 1
实现
function toRef(obj, key) { const wrapper = { get value() { return obj[key] }, set value(v) { obj[key] = v } } Object.defineProperty(wrapper, '__v_isRef', { value: true }) return wrapper } function toRefs(obj) { const wrapper = {} for (const key in obj) { wrapper[key] = toRef(obj, key) } return wrapper }
6.3. 自动 unref
传给 template
的响应式对象,以及传给 reactive
的 ref
都有自动 unref 的能力。
实现思路如下, 其中传给 template
的响应式对象都会先经过 proxyRefs 处理
function isRef(ref) { return ref && ref.__v_isRef } function unref(ref) { return isRef(ref) ? ref.value : ref } function proxyRefs(target) { return new Proxy(target, { get(target, key, receiver) { return unref(Reflect.get(target, key, receiver)) }, set(target, key, value) { if (isRef(target, key)) { target[key].value = value return true } return Reflect.set(...arguments) } }) }
6.4. 总结
ref
是对reactive
的二次封装toRef
和toRefs
的返回值也实现了*.value
的模式- 传给
template
和reactive
的ref
都会自动unref
7. 渲染器的设计
7.1. 渲染器与响应式系统的结合
在vue中, 其结合方式类似如下
const count = ref(0) effect(() => { renderer(`<div>count: ${count.value}`, document.getElementById('app')) }) count.value++ function renderer(domString, container) { container.innerHTML = domString }
7.2. 渲染器的基本概念
vnode
vdom
树形结构的 js 对象renderer
渲染器, 其中的render
函数可以把vnode
转成真实的元素。(注意不要和 option api 上的render
函数混淆, 那个是返回组件vdom
的)patch
比较新旧节点,然后变更节点。其中 挂载/卸载/更新 都在这里面
实际的使用场景
const renderer = createRenderer() renderer.render(vnode, $app) renderer.render(vnode2, $app) renderer.render(null, $app)
渲染器类似如下
function createRenderer() { const render = (vnode, container) => { if (vnode) { patch(container._vnode, vnode, container) } else { if (container._vnode) { // 旧的存在, 没有新的, 说明时 unmounted container.innerHTML = '' } } container._vnode = vnode } const hydrate = () => { } // SSR 时会用到 return { render, hydrate } }
7.3. 自定义渲染器
为了实现跨平台渲染,可如下操作:
- 涉及到对应平台 API 的操作, 抽象成配置项传给
createRenderer
function createRenderer({ createElement, setElementText, insert }) { function render(vnode, container) { if (vnode) { patch(container._vnode, vnode, container) } else { if (container._vnode) { container.innerHTML = '' } } container._vnode = vnode } function patch(n1, n2, container) { if (!n1) { // 挂载 mountElement(n2, container) } } function mountElement(vnode, container) { let el = createElement(vnode.tag) if (typeof vnode.children === 'string') { setElementText(el, vnode.children) } else { vnode.children.forEach(childNode => { mountElement(childNode, el) }) } insert(el, container) } return { render } } // Nodejs 环境运行的 const renderer = createRenderer({ createElement(type) { console.log(`创建元素 ${type}`) return { type } }, setElementText(node, text) { console.log(`在 ${JSON.stringify(node)} 设置文本 ${text}`) node.textContent = text }, insert(el, parent) { console.log(`将 ${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)}`) parent.children = el } }) renderer.render({ tag: 'h1', children: 'hello world' }, { type: 'root' })
7.4. 总结
- 渲染器的作用是将 vdom 转成真实的元素
- 渲染器的实现与响应式无关
patch
用来更新真实元素- 为了实现跨平台渲染,可以把具体平台的API抽象成配置项传给
createRenderer
8. 挂载和更新
8.1. 挂载子节点和元素的属性
function mountElement(vnode, container) { const el = createElement(vnode.tag) if (typeof vnode.children === 'string') { setElementText(el, vnode.children) } else if (isArray(vnode.children)) { vnode.children.forEach(child => { patch(null, child, el) }) } if (vnode.props) { // 设置属性 for (let key in vnode.props) { el[key] = vnode.props[key] } } insert(el, container) }
8.2. HTML Attribute 和 DOM properties
- Attribute 是 html 上设置的值, properties 是 js dom 的属性值
- 有些属性, 两者是直接映射关系,例如 id
- 有些属性, Attribute 上有, properties 没有, 例如 aria-*
- 有些属性, properties 有, Attribute 没有, 例如 textContent
- 有些属性, Attribute 是初始值, 而 properties 是实时的值, 例如 value
- 有些属性, properties 是 Attribute 矫正后的值, 例如
<input type=
"foo">= , el.type 得到的是 text - 核心原则是, Attribute 设置的是 properties 的初始值
8.3. 正确地设置元素的属性
属性设置遵从下面顺序
- properties 存在的属性, 优先设置 properties
- 对于 properties 存在的属性, 但其类型是
boolean
的, 把空字符串自动转成true
(解决类似<button disabled>btn</button>
中的disabled
这种特性的问题) - 对于只读的 properties 直接设置 Attribute, (例如
<input form="form1">
)
大概逻辑如下
function mountElement(vnode, container) { const el = createElement(vnode.tag) if (typeof vnode.children === 'string') { setElementText(el, vnode.children) } else if (isArray(vnode.children)) { vnode.children.forEach(child => { patch(null, child, el) }) } if (vnode.props) { for (const key in vnode.props) { patchProps(el, key, null, vnode.props[key]) } } insert(el, container) } function shouldSetAsProps(el, key) { return (key in el) && (el.type !== 'input' && key !== 'form') } createRenderer({ patchProps(el, key, prevValue, nextValue) { if (shouldSetAsProps(el, key)) { if (typeof el[key] === 'boolean' && nextValue === '') { el[key] = true } else { el[key] = nextValue } } else { el.setAttribute(key, nextValue) } } })
8.4. class 的处理
编译成 vnode
的 class
有3种形式
class: 'foo bar'
class: {foo:true, bar: true}
class: ['foo bar', {baz: true}]
因此需要实现一个 normalizeClass
, 对其统一处理
props.class = normalizeClass(props.class)
与之相同的还有 style
同时, 因为 set el.className
的性能高于 el.classList
和 el.setAttribute('class',$class)
, 所以对其进行特殊处理
function patchProps(el, key, prevValue, nextValue) { if (key === 'class') { el.className = nextValue } else if (shouldSetAsProps(el, key)) { if (typeof el[key] === 'boolean' && nextValue === '') { el[key] = true } else { el[key] = nextValue } } else { el.setAttribute(key, nextValue) } }
8.5. 卸载操作
直接用 innerHTML=''
的弊端
- 不会移除事件监听器
- 无法触发自定义组件的生命周期函数
- 无法触发自定义指令的钩子函数
故如下处理
function render(vnode, container) { if (vnode) { patch(container._vnode, vnode, container) } else { if (container._vnode) { unmount(container._vnode) } } container._vnode = vnode } function mountElement(vnode, container) { const el = vnode.el = createElement(vnode.type) // ... } function unmount(vnode) { const parent = vnode.el.parentNode if (parent) { parent.removeChild(vnode.el) } }
8.6. 区分 vnode 的类型
- 如果新旧
vnode
的type 不一样, 应该直接 umount 旧, 然后 mountElement 新的 - 同时根据
vnode.type
的类型, 区分不同的操作
修改 patch
函数:
function patch(n1, n2, container) { if (n1.type !== n2.type) { umount(n1) n1 = null } const { type } = n2 if (typeof type === 'string') { // 标签元素 if (!n1) { mountElement(n2, container) } else { patchElement(n1, n2, container) } } else if (typeof type === 'object') { // 组件 } }
8.7. 事件的处理
- 约定
^onXX
类型的 prop 为事件监听器 - 调用
addEventListener
和removeEventListener
添加/移除事件 - 使用一个伪造的事件处理函数
invoker
, 其内部调用真正的 handler, 从而减少频繁的 add/remove - 处理多个不同事件,以及同种事件多个监听器的情况
function patchProps(el, key, prevValue, nextValue) { if (/^on/.test(key)) { const invokers = el._ver || (el._ver = {}) const eventName = key.slice(2).toLowerCase() let invoker = invokers[key] if (nextValue) { if (!invoker) { invoker = invokers[key] = (e) => { if (isArray(invoker.value)) { invoker.value.forEach(fn => fn(e)) } else { invoker.value(e) } } el.addEventListener(eventName, invoker) } invoker.value = nextValue } else if (invoker) { el.removeEvnetListener(eventName, invoker) invokers[key] = undefined } } else if (key === 'class') { // ... } else if (shouldSetAsProps(el, key, nextValue)) { // ... } else { // ... } }
8.8. 事件冒泡与更新时机问题
考虑下面场景
const bol = ref(false) effect(() => { const vnode = { tag: 'div', props: { onClick: bol.value ? () => alert('parent') : undefined }, children: [ { type: 'p', props: { onClick: () => bol.value = true } } ] } renderer.render(vnode, document.getElementById('app')) })
- 首次渲染时,
div
没有监听器,p
有监听器 - 点击
p
时,bol.value
会变成true
, 触发副作用函数,导致 vnode 更新, 从而div
加上了监听器 - 然后是事件冒泡到
div
的监听器, 从而执行 - 但是期望的结果是:
div
的监听器不应该执行, 因为点击的时候该监听器还不存在 - 解决方法是:执行监听器时,过滤掉绑定时间迟于事件触发事件的监听器
function patchProps(el, key, prevValue, nextValue) { if (/^on/.test(key)) { const invokers = el._ver || (el._ver = {}) const eventName = key.slice(2).toLowerCase() let invoker = invokers[eventName] if (nextValue) { if (!invoker) { invoker = invokers[eventName] = (e) => { if (invoker.attached > e.timestamp) return if (isArray(invoker.value)) { invoker.value.forEach(fn => fn(e)) } else { invoker.value(e) } } el.addEventListener(eventName, invoker) } invoker.value = nextValue invoker.attached = Date.now() } else { // ... } } else { // ... } }
8.9. 更新子节点
更新子节点为下面几种情况的组合:
- 旧节点为: 不存在、文本节点、数组
- 新节点为: 不存在、文本节点、数组
function patchElement(n1, n2) { const el = n1.el = n2.el; const oldProps = n1.props; const newProps = n2.props; for (const key in newProps) { // 更新 props if (newProps[key] !== oldProps[key]) { patchProps(el, key, oldProps[key], newProps[key]) } } for (const key in oldProps) { // 移除 props if (!(key in newProps)) { patchProps(el, key, oldProps[key], null) } } patchChildren(n1, n2, el) } function patchChildren(n1, n2, container) { if (typeof n2.children === 'string') { if (isArray(n1.children)) { // 卸载旧的节点 n1.children.forEach(child => umount(child)) } setElementText(container, n2.children) } else if (isArray(n2.children)) { if (isArray(n1.children)) { // 这里应该用到 diff 算法, 现在先简单处理, 卸载旧的, 挂载新的 n1.children.forEach(child => umount(child)) n2.children.forEach(c => patch(null, c, container)) } else { // 旧的不存在,或者是文本 setElementText(container, '') n2.children.forEach(c => patch(null, c, container)) } } else { // 没有新的 if (isArray(n1.children)) { n1.children.forEach(child => umount(child)) } else if (typeof n1.children === 'string') { setElementText(container, '') } } }
8.10. 文本节点和注释节点
- 使用
vnode.type
判断节点类型 - 对于文本节点和注释节点, 新建
Symbol
type 表示 - 将
createText
,createComment
,setText
抽象出来
const Text = Symbol() const Comment = Symbol() function patch(n1, n2, container) { if (n1 && n1.type !== n2.type) { umount(n1) n1 = null } const { type } = n2 if (typeof type === 'string') { if (!n1) { mountElement(n2, container) } else { patchElement(n1, n2, container) } } else if (type === Text) { if (!n1) { const el = n2.el = createText(n2.children) insert(el, container) } else { if (n1.children != n2.children) { setText(n1.el, n2.children) } } } else if (type === Comment) { if (!n1) { const el = n2.el = createComment(n2.children) insert(el, container) } else { // 不支持动态注释 n2.el = n1.el } } }
8.11. Fragment
Fragment
是为了处理多个根节点的问题Fragment
本身不渲染真实的DOM, 所以没有el
const Fragment = Symbol() function patch(n1, n2, container) { // .. const { type } = n2; if (typeof type === 'string') { // .. } else if (type === Fragment) { if (!n1) { // 将 children 挂载到容器上 n2.children.forEach(c => patch(null, c, container)) } else { // 都是 Fragment, 更新 children 就可以 patchChildren(n1, n2, container) } } } function unmount(vnode) { if (vnode.type === Fragment) { vnode.children.forEach(c => unmount(c)) return } const parent = vnode.el.parentNode if (parent) { parent.removeChild(vnode.el) } }
8.12. 总结
- 使用
patch
挂载节点 - 使用
patchProps
处理 props 更新 - 使用
invokers
让事件监听器不需要频繁 remove - 通过比较监听器
attached
时间和事件触发事件, 解决事件冒泡时添加监听器的问题 - 使用
patchChildren
更新子节点 - 处理子节点更新,需考虑
children
为 字符串、数组、null 3种情况 - 当新旧子节点都是数组时,需要用到 diff 算法
- 对于普通标签元素,使用
mountElement
,patchElement
- 对于组件, 使用
mountComponent
,patchComponent
- 处理
vnode.type
为文本节点和注释节点或Fragment
9. 简单Diff算法
9.1. 减少 DOM 操作的性能开销
考虑下面场景:
const oldVNode = { type: 'div', children: [ { type: 'p', children: '1' }, { type: 'p', children: '2' }, { type: 'p', children: '3' }, ] } const newVNode = { type: 'div', children: [ { type: 'p', children: '11' }, { type: 'p', children: '22' }, { type: 'p', children: '32' }, ] }
- 如果直接 unmount 旧的, patch 新的, 需要 DOM 操作 6次
- 更新上述节点, 使得 DOM 操作数小, 可以通过比对
children
,相同类型的只需重新设置children
,不需要卸载了。 这样子 DOM 操作变成了 3次 - 需要处理两个
children
个数相同或不同的情景
function patchChildren(n1, n2, container) { if (isArray(n2.children)) { if (isArray(n1.children)) { const n1Len = n1.children.length; const n2Len = n2.children.length; const commonLength = Math.min(n1Len, n2Len); for (let i = 0; i < commonLength; i++) { // 相同的 patch(n1.children[i], n2.children[i], container) } if (n1Len > n2Len) { for (let i = commonLength; i < n1Len; i++) { // 卸载旧的 unmount(n1.children[i]) } } else if (n2Len > n1Len) { for (let i = commonLength; i < n2Len; i++) { // 新增新的 patch(null, n2.children[i], container) } } } } else { // ... } }
9.2. DOM 复用与 key 的作用
考虑下面场景
// oldChildren [ { type: 'p' }, { type: 'div' }, { type: 'span' } ] // newChildren [ { type: 'span' }, { type: 'p' }, { type: 'div' } ]
- 新旧 children 只是顺序不一样, 如果按照上面的方法, DOM 操作数还是6次
- 可以给每个
vnode
用key
作为标识, 从而复用之前创建的 DOM
例如:
[ { type: 'p', key: '1' }, { type: 'div', key: '2' }, { type: 'span', key: '3' } ] // newChildren [ { type: 'span', key: '3' }, { type: 'p', key: '1' }, { type: 'div', key: '2' } ]
- 当遍历 newChildren, 如果可以在 oldChildren 找到对应的
key
那么就复用之前的 - 该方案没有解决如何移动
function patchChildren(n1, n2, container) { if (isArray(n2.children)) { if (isArray(n1.children)) { const oldChildren = n1.children const newChildren = n2.children for (let i = 0; i < newChildren.length; i++) { const newChild = newChildren[i] for (let j = 0; j < oldChildren.length; j++) { const oldChild = oldChildren[i] if (oldChild.key === newChild.key) { patch(oldChild, newChild, container) break } } } } else { // .. } } else { // .. } }
9.3. 找到需要移动的元素
- 比较新旧节点, 使用
lastIndex
记录最大的复用节点的索引 - 如果当前内层 oldChildren 的循环中, 索引小于
lastIndex
(说明不是递增趋势),说明该oldChild
需要移动
9.4. 如何移动元素
- 接着上文,
oldChild
移动的位置, 就是当前外层循环的 前一个newChild
的后面
function patchChildren(n1, n2, container) { if (isArray(n2.children)) { if (isArray(n1.children)) { const oldChildren = n1.children const newChildren = n2.children let lastIndex = 0; for (let i = 0; i < newChildren.length; i++) { const newChild = newChildren[i] for (let j = 0; j < oldChildren.length; j++) { const oldChild = oldChildren[j] if (oldChild.key === newChild.key) { patch(oldChild, newChild, container) if (j >= lastIndex) { lastIndex = j } else { // 需要移动 const prevChild = newChildren[i - 1] if (prevChild) { const anchor = prevChild.el.nextSibling insert(newChild.el, container, anchor) } } break } } } } // .. } // .. } // 修改 insert 实现 function insert(el, parent, anchor = null) { parent.insertBefore(el, anchor) }
9.5. 添加新元素
- 上述算法执行过程中,如果没找到复用节点,那么应该创建 DOM, 并挂载将其挂载到 prevNode 后面
function patchChildren(n1, n2, container) { if (isArray(n2.children)) { if (isArray(n1.children)) { const oldChildren = n1.children const newChildren = n2.children let lastIndex = 0; for (let i = 0; i < newChildren.length; i++) { const newNode = newChildren[i] let exist = false; for (let j = 0; j < oldChildren.length; j++) { const oldNode = oldChildren[j] if (oldNode.key === newNode.key) { exist = true patch(oldNode, newNode, container) if (lastIndex <= j) { // 不需要移动 lastIndex = j } else { const prevNode = newChildren[i - 1] if (prevNode) { const anchor = prevNode.el.nextSibling; insert(newNode.el, container, anchor) } } break } } if (!exist) { // 没找到复用节点, 需要新增 const prevNode = newChildren[i - 1] let anchor = null if (prevNode) { anchor = prevNode.el.nextSibling } else { // 新挂载的是 第一个子节点 anchor = container.firstChild } patch(null, newNode, container, anchor) } } } // .. } // .. } function patch(n1, n2, container, anchor) { // ... const { type } = n2; if (typeof type === 'string') { if (!n1) { mountElement(n2, container, anchor) } else { patchElement(n1, n2) } } } function mountElement(vnode, container, anchor) { insert(vnode.el, container.anchor) }
9.6. 移除不存在的元素
- 遍历旧节点, 如果在新节点中找不到,就 unmount
function parentChildren(n1, n2, container) { // .. for (let i = 0; i < oldChildren.length; i++) { const oldNode = oldChildren[i] const has = newChildren.find(a => a.key === oldNode.key) if (!has) { unmount(oldNode) } } }
9.7. 总结
- 使用
key
来标识是否为可复用节点 - 遍历新的节点, 然后拿该新的节点去旧节点中匹配
key
, 如果找到了,那么就是存在复用节点, 记录索引。 如果该索引小于记录过的最大索引, 说明该节点需要移动 - 复用节点移动的位置, 是遍历的 newChildren 的前一个节点的
- 如果找不到, 说明需要新增节点, 插入到前一个节点的后面。 如果没有前一个节点, 说明第一个节点是新增的, 插入到
parent.firstChild
的前面 - 使用
el.nextSibling
作为anchor
, 然后 DOM 操作中调用insertBefore
插入到指定位置 - 最后遍历旧节点, 如果在新节点中找不到, 那么 unmount
10. 双端Diff算法
10.1. 双端diff比较的原理
- 每次都比较4个节点:
- 新开始 - 旧开始
- 新结尾 - 旧结尾
- 新结尾 - 旧开始
- 新开始 - 旧结尾
- 如果 key 不同,那么不做操作
- 如果 key 相同,表示可复用
- 对于可复用节点, 比较新旧位置, 对真实的 DOM
el
就行insert
操作 - 比较完后, 对于可复用的节点,尾部的 index -1 , 头部的 index + 1
如下:
function patchKeyChildren(n1, n2, container) { const oldChildren = n1.children const newChildren = n2.children let newStartIdx = 0 let oldStartIdx = 0 let newEndIdx = newChildren.length - 1 let oldEndIdx = oldChildren.length - 1 while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) { const newStartVNode = newChildren[newStartIdx] const newEndVNode = newChildren[newEndIdx] const oldStartVNode = oldChildren[oldStartIdx] const oldEndVNode = oldChildren[oldEndIdx] if (newStartVNode.key === oldStartVNode.key) { patch(oldStartVNode, newStartVNode, container); newStartIdx++ oldStartIdx++ } else if (newEndVNode.key === oldEndVNode.key) { patch(oldEndVNode, newEndVNode, container); newEndIdx-- oldEndIdx-- } else if (newEndVNode.key === oldStartVNode.key) { patch(oldStartVNode, newEndVNode, container) insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling) newEndIdx-- oldStartIdx++ } else if (newStartVNode.key === oldEndVNode.key) { patch(oldEndVNode, newStartVNode, container) insert(oldStartVNode.el, container, oldStartVNode.el) newStartIdx++ oldEndIdx-- } } }
10.2. 双端diff比较的优势
- 简单Diff算法 是双层嵌套循环
- 双端Diff算法 是单层循环
- 且理想情况下 双端Diff算法 需要移动的 DOM 会更少
10.3. 非理想状况的处理方式
假设上述算法, 4步比较之后, 都没有可复用的节点,那么进行下面操作
- 拿
newStartVNode
的key 在oldChildren
中未处理的节点中寻找相同的key - 找到之后, 进行
patch
, 然后将该oldVNode
的el
移动到oldStartVNode
的前面 - 将
oldChildren
的该位置 置空 - newStartIdx + 1
如下:
function patchKeyChildren(n1, n2, container) { const oldChildren = n1.children const newChildren = n2.children let newStartIdx = 0 let oldStartIdx = 0 let newEndIdx = newChildren.length - 1 let oldEndIdx = oldChildren.length - 1 while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) { const newStartVNode = newChildren[newStartIdx] const newEndVNode = newChildren[newEndIdx] const oldStartVNode = oldChildren[oldStartIdx] const oldEndVNode = oldChildren[oldEndIdx] if (!oldStartVNode) { oldStartIdx++ } else if (!oldEndVNode) { oldEndIdx-- } else if (newStartVNode.key === oldStartVNode.key) { // } else if (newEndVNode.key === oldEndVNode.key) { // } else if (newEndVNode.key === oldStartVNode.key) { // } else if (newStartVNode.key === oldEndVNode.key) { // } else { for (let i = oldStartIdx + 1; i < oldEndIdx - 1; i++) { const oldVNode = oldChildren[i] if (oldVNode && oldVNode.key === newStartVNode.key) { patch(oldVNode, newStartVNode, container) insert(oldVNode.el, container, oldStartVNode.el) oldChildren[i] = null newStartIdx++ break } } } } }
10.4. 添加新元素
如上在 oldChildren 找不到复用的节点, 说明该节点是新增的,应该如下处理:
- 创建元素,将该元素 mount 到
oldStartVNode
前面 - newStartIdx + 1
function patchKeyChildren(n1, n2, container) { const oldChildren = n1.children const newChildren = n2.children let newStartIdx = 0 let oldStartIdx = 0 let newEndIdx = newChildren.length - 1 let oldEndIdx = oldChildren.length - 1 while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) { const newStartVNode = newChildren[newStartIdx] const newEndVNode = newChildren[newEndIdx] const oldStartVNode = oldChildren[oldStartIdx] const oldEndVNode = oldChildren[oldEndIdx] if (!oldStartVNode) { oldStartIdx++ } else if (!oldEndVNode) { oldEndIdx-- } else if (newStartVNode.key === oldStartVNode.key) { // } else if (newEndVNode.key === oldEndVNode.key) { // } else if (newEndVNode.key === oldStartVNode.key) { // } else if (newStartVNode.key === oldEndVNode.key) { // } else { let finded = false for (let i = oldStartIdx + 1; i < oldEndIdx - 1; i++) { const oldVNode = oldChildren[i] if (oldVNode && oldVNode.key === newStartVNode.key) { patch(oldVNode, newStartVNode, container) insert(oldVNode.el, container, oldStartVNode.el) oldChildren[i] = null finded = true break } } if (!finded) { patch(null, newStartVNode, container, oldStartVNode.el) } newStartIdx++ } } }
10.5. 移除不存在的元素
进行完上述操作后,将 oldChildren 中未复用的节点 unmount
根据算法逻辑, 对于 oldStartIdx 和 oldEndIdx, 如果进行了递增或递减操作, 说明之前的 oldVNode 是可复用的, 同时, 中间的节点如果可复用, 会被置空。 因此不存在的节点, 都位于 oldStartIdx 和 oldEndIdx 之间
function patchKeyChildren(n1, n2, container) { const oldChildren = n1.children const newChildren = n2.children let newStartIdx = 0 let oldStartIdx = 0 let newEndIdx = newChildren.length - 1 let oldEndIdx = oldChildren.length - 1 while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) { const newStartVNode = newChildren[newStartIdx] const newEndVNode = newChildren[newEndIdx] const oldStartVNode = oldChildren[oldStartIdx] const oldEndVNode = oldChildren[oldEndIdx] if (!oldStartVNode) { oldStartIdx++ } else if (!oldEndVNode) { oldEndIdx-- } else if (newStartVNode.key === oldStartVNode.key) { // } else if (newEndVNode.key === oldEndVNode.key) { // } else if (newEndVNode.key === oldStartVNode.key) { // } else if (newStartVNode.key === oldEndVNode.key) { // } else { // } } for (let i = oldStartIdx; i <= oldEndIdx; i++) { unmount(oldChildren[i]) } }
10.6. 总结
进行双端diff算法,需处理以下步骤
- 对于新旧 children 的两端进行两两比较,寻找可复用节点并对于移动
- 处理两端可复用节点不在两端的情况
- 处理新增元素的情况
- unmount 不存在的元素
算法总览如下
function patchKeyChildren(n1, n2, container) { const oldChildren = n1.children const newChildren = n2.children let oldStartIdx = 0 let newStartIdx = 0 let oldEndIdx = oldChildren.length - 1 let newEndIdx = newChildren.length - 1 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { const oldStartVNode = oldChildren[oldStartIdx] const newStartVNode = newChildren[newStartIdx] const oldEndVNode = oldChildren[oldEndIdx] const newEndVNode = oldChildren[newEndIdx] if (!oldStartVNode) { oldStartIdx++ } else if (!oldEndVNode) { oldEndIdx-- } else if (newStartVNode.key === oldStartVNode.key) { patch(oldStartVNode, newStartVNode, container) oldStartIdx++ newStartIdx++ } else if (newEndVNode.key === oldEndVNode.key) { patch(oldEndVNode, newEndVNode, container) oldEndIdx-- newEndIdx-- } else if (newEndVNode.key === oldStartVNode.key) { patch(oldStartVNode, newEndVNode, container) insert(oldStartVNode.el, container, oldEndVNode.el.nextSilbing) newEndIdx-- oldStartIdx++ } else if (newStartVNode.key === oldEndVNode.key) { patch(oldEndVNode, newStartVNode, container) insert(oldEndVNode.el, container, oldStartVNode.el) newStartIdx++ oldEndIdx-- } else { let finded = false for (let i = oldStartIdx + 1; i < oldEndIdx; i++) { const oldVNode = oldChildren[i] if (oldVNode?.key === newStartVNode.key) { patch(oldVNode, newStartVNode, container) insert(oldVNode.el, container, oldStartVNode.el) finded = true break } } if (!finded) { patch(null, newStartVNode, container, oldStartVNode.el) } newStartIdx++ } } for (let i = oldStartIdx; i <= oldEndIdx; i++) { unmount(oldChildren[i]) } }
11. 快速Diff算法
11.1. 相同的前置元素和后置元素
- 对于新旧 children 两端 key 重叠的元素,可以不用进行移动操作
- 处理完前置与后置后, 如果新 children 都处理完了, 说明 oldChildren 的剩余节点都是要被移除的
- 处理完前置与后置后, 如果就 children 都处理完了, 说明 newChildren 的剩余节点都是要新增的
function patchKeyChildren(n1, n2, container) { const newChildren = n2.children const oldChildren = n1.children let j = 0; let newEnd = newChildren.length - 1; let oldEnd = oldChildren.length - 1; // 比较前置 let newVNode = newChildren[j] let oldVNode = oldChildren[j] while (oldVNode && newVNode && newVNode.key === oldVNode.key) { patch(oldVNode, newVNode, container) j++ newVNode = newChildren[j] oldVNode = oldChildren[j] } // 比较后置 newVNode = newChildren[newEnd] oldVNode = oldChildren[oldEnd] while (newEnd >= j && oldEnd >= j && newVNode.key === oldVNode.key) { patch(oldVNode, newVNode, container) newEnd-- oldEnd-- newVNode = newChildren[newEnd] oldVNode = oldChildren[oldEnd] } if (j > newEnd) { // newChildren 处理完毕, oldChildren 有多出来的节点 while (j <= oldEnd) { unmount(oldChildren[j++]) } } else if (j > oldEnd) { // oldChilren 处理完毕, newChildren 有多出来的节点 const anchor = newChildren[newEnd + 1]?.el while (j <= newEnd) { patch(null, newChildren[j++], container, anchor) } } else { // 两者之间都未处理的节点 } }
11.2. 判断是否需要进行DOM移动操作
对于上述尾部 else
的有以下特征:
- 满足
j <=
newEnd= 且j <=
oldEnd= - 新旧 children 中间的节点都还没有处理
处理方式是:
- 构建一个
source
数组, 索引为新节点相对于j
的位置, 值是对应复用的旧节点的位置 - 如果没有复用节点,那么值为 -1
- 如果都是 -1 , 说明不需要移动, 都是新增的节点
function patchKeyChildren(n1, n2, container) { const newChildren = n2.children const oldChildren = n1.children let j = 0; let newEnd = newChildren.length - 1; let oldEnd = oldChildren.length - 1; // 比较前置 // 比较后置 if (j > newEnd) { // newChildren 处理完毕, oldChildren 有多出来的节点 } else if (j > oldEnd) { // oldChilren 处理完毕, newChildren 有多出来的节点 } else { // 两者之间都未处理的节点 const count = newEnd - j + 1 const source = new Array(count) source.fill(-1) const newKeyMap = {} for (let i = j; i <= newEnd; i++) { newKeyMap[newChildren[i].key] = i } let lastIdx = j let shouldMoved = false let patched = 0 for (let i = j; i <= oldEnd; i++) { const oldVNode = oldChildren[i] if (patched >= count) { // 剩下的肯定不存在于 newChildren上 unmount(oldVNode) continue } const newIdx = newKeyMap[oldVNode.key] if (typeof newIdx !== 'undefined') { source[newIdx - j] = i patch(oldVNode, newChildren[newIdx], container) patched++ if (newIdx < lastIdx) { // 不是递增趋势 shouldMoved = true } else { lastIdx = newIdx } } else { // 不在新节点上 unmount(oldVNode) } } if (shouldMoved) { // 进行移动操作 } } }
11.3. 如何移动元素
上述的还遗留实际需要 move 的情景,进行以下处理:
- 构建
source
的最长子序列索引seq
(这个序列的节点都不需要移动) - 将
seq
与source
的尾部开始进行比对- 如果两者的尾部不重合(即
seq
的值 不是source
处理过的的最后一个索引),则将source
对应的该节点插入到尾部前面 - 如果重合, 两者都缩短 1 , 继续比对
- 其目的是, 移动或新增不在
seq
的节点,此时需要移动的节点数最少
- 如果两者的尾部不重合(即
function patchKeyChildren(n1, n2, container) { const newChildren = n2.children const oldChildren = n1.children let j = 0; let newEnd = newChildren.length - 1; let oldEnd = oldChildren.length - 1; // 比较前置 // 比较后置 if (j > newEnd) { // newChildren 处理完毕, oldChildren 有多出来的节点 } else if (j > oldEnd) { // oldChilren 处理完毕, newChildren 有多出来的节点 } else { // 两者之间都未处理的节点 const count = newEnd - j + 1 const source = new Array(count) source.fill(-1) const newKeyMap = {} for (let i = j; i <= newEnd; i++) { newKeyMap[newChildren[i].key] = i } let lastIdx = j let shouldMoved = false let patched = 0 for (let i = j; i <= oldEnd; i++) { const oldVNode = oldChildren[i] if (patched >= count) { // 剩下的肯定不存在于 newChildren上 unmount(oldVNode) continue } const newIdx = newKeyMap[oldVNode.key] if (typeof newIdx !== 'undefined') { source[newIdx - j] = i patch(oldVNode, newChildren[newIdx], container) patched++ if (newIdx < lastIdx) { // 不是递增趋势 shouldMoved = true } else { lastIdx = newIdx } } else { // 不在新节点上 unmount(oldVNode) } } if (shouldMoved) { // 进行移动操作 let seq = getSequence(source) let handledIdx = source.length - 1; let sEnd = seq.length - 1 for (; handledIdx >= 0; handledIdx--) { const anchor = newChildren[handledIdx + j + 1]?.el const newVNode = newChildren[handledIdx + j] if (source[handledIdx] === -1) { patch(null, newVNode, container, anchor) } else if (seq[sEnd] === handledIdx) { // 重合部分 sEnd-- } else { insert(newVNode.el, container, anchor) } } } } }
11.4. 总结
核心逻辑
- 处理新旧两端重合的部分, 该部分不用移动
- 如果第一步处理后, newChildren 都处理完了, 那么剩余的 oldChildren 是需要删除的
- 如果第一步处理后, oldChildren 都处理完了, 那么剩余的 newChildren 都是新增的节点
- 接下来比较两者中间的部分
- 构建
source
用来保存 newChildren 中在 oldChildren 可寻找到的复用的节点的索引 - 移除 oldChildren 中不再
source
的节点 - 如果
source
的值不是递增趋势, 说明有节点需要移动 - 移动节点时进行下面操作
- 构建最大递增子序列
seq
, 该序列的节点不需要进行移动 - 从尾部比较
seq
的值和source
尾部索引, 如果重合就 -1, 不重合,那么将该 索引对应的节点插到尾部
- 构建最大递增子序列
- 构建
function patchKeyChildren(n1, n2, container) { const oldChildren = n1.children const newChildren = n2.children let j = 0; let newEnd = newChildren.length - 1; let oldEnd = oldChildren.length - 1; // 比较前置 let newVNode = newChildren[j] let oldVNode = oldChildren[j] while (newVNode.key === oldVNode.key) { patch(oldVNode, newVNode, container) j++ newVNode = newChildren[j] oldVNode = oldChildren[j] } // 比较后置 newVNode = newChildren[newEnd] oldVNode = oldChildren[oldEnd] while (newVNode.key === oldVNode.key) { patch(oldVNode, newVNode, container) newEnd-- oldEnd-- newVNode = newChildren[newEnd] oldVNode = oldChildren[oldEnd] } if (j > oldEnd) { // oldChilren 处理完 while (j <= newEnd) { patch(null, newChildren[j++], container, newChildren[newEnd + 1]?.el) } } else if (j > newEnd) { // newChildren 处理完 while (j <= oldEnd) { unmount(oldChildren[j++]) } } else { const newStart = j const oldStart = j const count = newEnd - newStart + 1 const source = new Array(count) source.fill(-1) const newKeyMap = {} for (let i = newStart; i <= newEnd; i++) { newKeyMap[newChildren[i].key] = i } let lastIdx = 0 let moved = false let patched = 0 for (let i = oldStart; i <= oldEnd; i++) { const oldVNode = oldChildren[i] if (patched >= count) { unmount(oldVNode) continue } const k = newKeyMap[oldVNode.key] if (typeof k === 'undefined') { unmount(oldVNode) } else { source[k - newStart] = i patched++ patch(oldVNode, newChildren[k], container) if (i < lastIdx) { moved = true } else { lastIdx = i } } } if (moved) { const seq = getSequence(source) let sEnd = seq.length - 1 for (let i = source.length - 1; i >= 0; i--) { const pos = i + newStart const anchor = newChildren[pos + 1]?.el const newVNode = newChildren[pos] if (source[i] === -1) { patch(null, newVNode, container, anchor) } else if (source[i] === seq[sEnd]) { // 重合 sEnd-- } else { insert(newVNode.el, container, anchor) } } } } }