理解Vue
总览
Vue3 的基本结构
采用 Monorepo 模式(多组件放在一个 Repo 中), 在 /packages/
中存储所有的模块.
模块分为几类:
- 编译时(
/package/compiler-*
)compiler-core
: 与平台无关的编译器核心compiler-dom
: 基于compiler-core
解析<template>
标签并编译为 render 函数compiler-sfc
: 基于compiler-dom
与compiler-core
解析 SFC (单文件组件, 通俗理解就是.vue
文件) 编译为浏览器可执行的 JavaScriptcompiler-ssr
: 服务端渲染的编译模块
- 运行时(
/package/runtime-*
)reactivity
: 实现响应式runtime-core
: 基于reactivity
实现运行时核心runtime-dom
: 基于runtime-core
实现针对浏览器的运行时. 包括DOM API, 属性, 事件处理等
- 其他
template-explorer
: 用于调试编译器输出的开发工具shared
: 多个包之间共享的内容vue
: 完整版本,包括运行时和编译器
依赖关系
+---------------------+
| |
| @vue/compiler-sfc |
| |
+-----+--------+------+
| |
v v
+---------------------+ +----------------------+
| | | |
+------------>| @vue/compiler-dom +--->| @vue/compiler-core |
| | | | |
+----+----+ +---------------------+ +----------------------+
| |
| vue |
| |
+----+----+ +---------------------+ +----------------------+ +-------------------+
| | | | | | |
+------------>| @vue/runtime-dom +--->| @vue/runtime-core +--->| @vue/reactivity |
| | | | | |
+---------------------+ +----------------------+ +-------------------+
学习路线
根据模块依赖关系, 路线为: reactivity
-> runtime-core
-> runtime-dom
-> compiler
. 重点是 runtime-*
代码分析步骤:
- 查看单元测试(位于
packages/**/__tests__/
) - 根据单元测试了解模块实现的功能
- 跟着单元测试的了解模块功能, 了解模块功能时: 先看导出(模块是什么), 再看模块被谁导入(为什么被需要), 最后看导出部分对应的实现(怎么样实现)
参考 repo
- cuixiaorui/mini-vue: 用来学习
- vuejs/core: 用来验证
Reactivity 的基本流程
Reactivity
模块是运行时的最底层, 负责实现响应式, 位于: mini-vue/packages/reactivity
reactive
的基本流程
reactive
是 Reactivity
的基础. 负责实现对象的响应式, 并向上提供调用时方法. 基本思想就是借助 ES6 的 Proxy
自定义 get & set
转到
mini-vue/../__tests__/reactive.spec.ts
, 发现测试的主要目的是看reactive
构造方法.转到
mini-vue/../src/reactive.ts
, 发现定义了reactive
,readonly
等方法, 这些方法都交由createReactiveObject
处理.观察
createReactiveObject
, 可以得到三个调用参数意义:target
: 要被代理的值proxyMap
: 不同类型的工厂函数有不同的全局proxyMap
, 这意味着该变量可能会存储所有代理的某类型变量. 根据const existingProxy = proxyMap.get(target); if (existingProxy) { return existingProxy; }
可以验证想法, 其在
createReactiveObject
的目的就是持久化Proxy
防止重复创建代理baseHandlers
: 根据const proxy = new Proxy(target, baseHandlers);
得出该方法就是 Proxy(MDN) 的
get & set
对象. 不同类型的 Proxy 有不同的baseHandlers
转到
mini-vue/../src/baseHandlers.ts
发现模块主要是提供不同的get & set
而这些都是由两个create
函数实现的, 尝试理解createGetter
应该返回一个handler.get
(MDN) 实现. 可以看到这个函数上有一堆类型判断的方法, 然后做了两步- 通过
Reflect.get
(MDN) 获取属性 - 通过
track
进行依赖收集, 这部分后面再看
最后返回获取结果. 整个
get
感觉和原生方法相比就是多了个类型判断和track
, 大部分的响应式都是依赖这个track
实现的- 通过
createSetter
更加简单, 看起来就是在实现handler.set
(MDN) 的基础上多了个trigger
到目前位置这个只有
track
和trigger
是不清楚的, 这两个函数在effect
等部分做依赖收集的, 可以先不管. 其他部分就是原生功能调用与权限管理
effect
的基本流程
如果让我实现 effect
我会怎么实现呢? 我先想到的是利用编译原理等魔法对代码做静态分析, 找到所用响应式对象, 在响应式对象的 set
上挂上函数. 但是, JavaScript 是个动态语言, 这完全没法挂啊! 只能在运行时动态解析.
Vue 的实现就比较流畅. 既然我 effect
要立即执行一遍函数, 那为啥不在执行前后做下 Flag, 一旦 Proxy 的 get
被调用, 让 get
检查一下是不是在 effect
执行阶段, 若是就把函数注册到这个响应式对象上😎
转到
mini-vue/../__tests__/reactive.spec.ts
看到effect
的主要功能是立即执行函数并在响应式数据发生改变时, 去执行effect
注册的函数转到
mini-vue/../src/effect.ts
看effect
函数的实现. 看到这里有熟悉的effect
,track
,trigger
effect
函数将传入函数包装为ReactiveEffect
对象, 合并配置, 执行run
函数, 构造runner
并返回(用于后期调用)ReactiveEffect
类active
: 根据run
,stop
函数和测试文件中的it("stop")
断言可以推出其是用来开关effect
功能的deps
: 根据track
与trigger
对其调用可以判断其是用来记录函数对应依赖的run
: 对effect
注册函数的包装, 在执行函数前后打入shouldTrack
标记, 并将activeEffect
标记为要执行的ReactiveEffect
好让get
知道哪个effect
在跑
track
函数: 在reactive
的get
中调用track(target, "get", key);
track
发现自己处于effect
阶段时会先检查自己所在对象有没有创建attribute
-effect
函数heap 的map
, 如果每就创建, 然后看map
上有没有记录当前属性, 如果没有, 就建立依赖的set
并交由trackEffects
加入并在ReactiveEffect
上也做记录.trigger
函数: 在reactive
的set
中调用先找到对应
attribute
的effect
依赖, 去重, 根据配置延迟或立即支持effect
总结
reactive
的流程: 传入对象, 持久化, 绑定baseHandlers
做权限管理与依赖收集effect
的流程: 将传入函数包装为对象, 立即执行函数并做好标记, 在执行时收集依赖. 每当reactive
被调用时就tigger
收集的effect
, 并二次收集依赖
问题
所有的依赖收集都是基于
get
, 这样的effect
存在问题it('should observe basic properties', () => { let dummy, flag = false; const counter = reactive({ num: 0 }); effect(() => { if (!flag) { dummy = -1; } else { dummy = counter.num; } }); expect(dummy).toBe(-1); flag = true; counter.num = 2; expect(dummy).toBe(2); // Except 2, Received -1 });
不只是
mini-vue
,vue/core
的单元测试也存在这个问题. 但是在Vue
代码中并不会出现无法追踪依赖的问题, 看来还有一些隐藏的优化没有找到
Runtime-core 的基本流程
runtime-core
依赖 Reactivity
为 runtime 提供服务. 可以通过观察 Vue 文件的运行观察 runtime-core
的基本流程
文件基本结构
转到
mini-vue/packages/vue/example/helloWorld/
的文件夹了解 vue 的基本工作流程转到
mini-vue/../helloWorld/index.html
, 只有个div#root
和script
转到
mini-vue/.../helloWorld/main.js
import { createApp } from '../../dist/mini-vue.esm-bundler.js'; import App from './App.js'; const rootContainer = document.querySelector('#root'); createApp(App).mount(rootContainer);
引入了创建根组件的
createApp
与根组件App
, 查找了 html 文件中声明的挂载点, 然后通过createApp(App)
打包根组件再将打包后结果挂载转到
mini-vue/../helloWorld/App.js
发现定义了两个 vue2 风格的组件对象{ name: 'App', // 组件名 setup() {}, // setup 方法 render() { // 渲染方法 return h('div', { tId: 1 }, [h('p', {}, '主页'), h(HelloWorld)]); }, };
前面有提到:
compiler-dom
将<template>
标签解析并编译为 render 函数. 在这里为了不追踪compiler-dom
的行为, 我们直接将render
给出h
为渲染函数, 参数分别是: 组件的ElementType
, 配置, 子组件数组, 可以看到, 这里第一个子组件是一个<p>
第二个是一个组件可以在对象中使用
render
, 也可以让setup
返回render
方法, 即{ name: 'App', setup() { return function() { return h('div', { tId: 1 }, [h('p', {}, '主页'), h(HelloWorld)]); } }, };.
createApp
调用关系比较复杂, 直接使用 dev-tools 观察执行过程. 打开一个 http 服务器并转到 dev-tools下, 找到createApp.js
并打下断点createApp
方法接受根组件配置对象App
直接包了个对象, 有_componment = App
mount
方法, 看语义, 这个方法接收挂载点, 将根组件创建为VNode
并挂载到挂载点(main.js
中的rootContainer
), 执行完后main.js
就结束了
我们需要继续分析的就是
VNode
的创建过程与render
的挂载过程
组件初始化过程
单步进入
createVNode
发现其声明了个vnode
.将传入对象(
rootComponent / App
) 作为vnode.type
在
vnode
上合并对象并配置shapeFlag
用于标记类型之后调用
normalizeChildren
并返回对象- 进入
normalizeChildren
看起来是作了slot
特判
- 进入
单步进入
render
, 其接收了处理后的vnode
与挂载点rootContainer
然后将参数直接交给patch
, 可以猜到patch
会是一个很通用的函数单步进入
patch
, 其接收n1 = null
,n2 = vnode
,container
.解构出了
n2
的type = App
与shapeFlag
,通过预定义的
Symbol
判断对象类型, 进入default
,通过位运算判断
shapeFlag
类型, 被识别为组件 (而不是像h('p', {}, '主页')
一样的 Element) 执行processComponent
单步进入
processComponent
,函数做了一个判断: 如果没有
n1
就认为n2
还没有被挂载就挂载n2
否则更新n2
单步进入
mountComponent
, 其接收了vnode
与挂载点将
vnode
转换为实例instance
, 执行setupComponent
处理instance
单步进入
setupComponent
发现其只是处理了prop
与slot
然后交给setupStatefulComponent
继续配置单步进入
setupStatefulComponent
, 其接收instance
将
instance.ctx
配置了PublicInstanceProxyHandlers
代理(后面分析)提取
Component = APP
,setup = APP.setup
如果
setup
不存在就直接finishComponentSetup
否则用
setCurrentInstance
打标记, 为setup
传入参数并获取执行结果, 执行handleSetupResult
处理结果单步进入
handleSetupResult
该函数对setup
结果执行判断如果是
function
说明是导出了render
函数, 将render
赋值到instance.render
上否则导出的对象存入
isntance.setupState
最后执行
finishComponentSetup
与无setup
的情况汇合单步进入
finishComponentSetup
其接收instance
若
instance
上没有render
就尝试从template
编译结果上获取并存入instrance.render
单步进入
setupRenderEffect
发现其定义绑定了一个componentUpdateFn
函数打断点并进入
componentUpdateFn
函数如果组件没有被挂载, 获取子节点, 获取
instance
的 Proxy, 构建子节点subTree
并递归patch
, 当patch
到 Element 时调用processElement
挂载节点否则更新节点(后面分析)
组件更新过程
为组件创建响应式并将 reavtive
导出到全局
{
name: 'HelloWorld',
setup() {
const count = ref(10);
window.count = count;
return { count };
},
render() {
return h('div', { tId: 'helloWorld' }, `hello world: count: ${this.count}`);
},
};
在 dev-tools 中修改 count.value
根据输出来自 effect.ts
进入文件并为 run
函数打上断点, 再次修改值, 发现 run
函数实际上就是执行了当时的 componentUpdateFn
, 为 componentUpdateFn
中已挂载的判断部分打上断点
在断点处查看调用栈, 确定函数就是因为
ref
修改而引发的在执行修改前先判断有没有
nextTrick
需要执行获取新节点的
vnode
将老节点子树复制到新节点
触发生命周期函数
patch
新节点单步进入
patch
, 接受老节点n1
新节点n2
这次更新的是一个 Element 于是进入ShapeFlags.ELEMENT
, 进入processElement
- 单步进入
processElement
, 这次老节点已经挂载, 直接走更新程序- 单步进入
updateElement
该函数分别对比了props
与 子节点并更新
- 单步进入
- 单步进入
触发生命周期函数
总结
graph TB init((初始化组件)) --> createAPp[将App交给createApp, 将App包装为vnode] --- norm1[将vnode应用normalizeChildren配置, 交给render渲染] --> renderdispatch[render直接交给patch] --> check[patch检查类型] --为组件--> processComponent[交给processComponent判断状态] --为新节点--> mountComponent[执行mountComponent挂载vnode: 构造instance, 运行 setup, 获取 render] --> effect[注册render的effect] --> run[执行effect, 检测是否挂载] --没有--> patch2(递归patch子节点) update((reactive更新)) -.-> run[执行effect, 检测是否挂载] -.-挂载了-.-> newvnode[构造新vnode, diff检查, 复制属性] -.-> patch2 ==> check check ==为Element==> mountDir(直接修改DOM)
实现 Reactivity
环境搭建
目录结构
. ├── jest.config.js ├── package.json ├── packages │ └── reactivity │ ├── index.ts # 入口文件 │ └── __tests__ # 测试文件 │ └── index.spec.ts ├── README-EN.md ├── README.md └── tsconfig.json # tsc --init
依赖:
typescript
/@types/node
/jest
/ts-jest
/@types/jest
实现基本 effect
与 reactive
TDD
TDD(Test-Driven Development), 是敏捷开发中的一项核心实践和技术, 也是一种设计方法论. TDD的原理是在开发功能代码之前, 先编写单元测试用例代码, 测试代码确定需要编写什么产品代码. TDD虽是敏捷方法的核心实践.
实现基本的 reactive
需求: 最简单的 reactive
, 输入对象并输出对象的代理. 代理对象修改时原对象同步修改
- 测试
it('Should different', () => { const origin = { foo: 1 }; const observed = reactive(origin); // 输入对象并返回代理对象 expect(observed).not.toBe(origin); // observed 和原来的不是一个对象 observed.foo = 3; expect(observed.foo).toBe(3); // 两者同步修改 expect(origin.foo).toBe(3); });
- 实现 只需要为对象配置一个普通代理
唯一的难点就是export function reactive(origin) { // 就是给一个对象, 返回一个 new Proxy return new Proxy(origin, { // 语法见 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/get get(target, key, receiver) { return Reflect.get(target, key, receiver); }, // 语法见 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/set set(target, key, value, receiver) { return Reflect.set(target, key, value, receiver); }, }); }
Proxy
语法 - 重构: 无
实现基本的 effect
需求: 1. 输入函数, 执行函数, 当函数中被 [GET]
的响应式对象发生变化时重新执行函数 2. 返回一个函数 runner
, 当执行 runner
时执行 effect
传入的函数
需求分析: 1. 为什么是函数中被 [GET]
的响应式对象变化时重新执行函数, [SET]
不行吗? 不行, 响应式对象被 [SET]
后如果执行了函数, 响应式对象会被重新 [SET]
, 那么上一次 [SET]
就没用了. 同时如果函数中其他变量不变只有响应式对象被 [SET]
此时执行函数并不会使得函数中变量值发生变化(毕竟变化的响应式变量没有被 [GET]
), 不会产生 sideEffect. 2. 执行流程: 开始执行函数 -> [GET]
响应式对象 -> 结束执行函数 -> 当响应式对象被 [SET]
-> 执行函数 可以发现只需要让响应式对象知道当自己变化时哪些 effect
需要执行就可以了, 至于 effect
知不知道响应式对象是谁那无所谓. 可以在函数执行期间执行依赖收集, 为 [GET]
的响应式对象注册 Effect Function, 在响应式对象修改时执行其注册的 Effect Function.
- 测试:
describe('Effect test', () => { it('Should sync', () => { const origin = { foo: 1 }; const observed = reactive(origin); let bar; const runner = effect(() => { bar = observed.foo; }); expect(observed.foo).toBe(1); // origin -> observed expect(bar).toBe(1); // 立即执行 fn observed.foo = 2; // 修改有 [GET] 的响应式对象 expect(observed.foo).toBe(2); // 响应式对象变化 expect(origin.foo).toBe(2); // 原对象变化 expect(bar).toBe(2); // 执行函数 }); it('Should return runner', () => { const origin = { foo: 1 }; const observed = reactive(origin); console.info = jest.fn(); // 劫持 console.info let bar; const runner = effect(() => { console.info('I RUN'); bar = observed.foo; }); expect(console.info).toBeCalledTimes(1); // 立即执行 fn, console.info 被调用 1 次 observed.foo = 2; expect(console.info).toBeCalledTimes(2); // 响应式对象发生变化执行 fn, console.info 被调用 2 次 runner(); // 手动调用 runner, console.info 被调用 3 次 expect(console.info).toBeCalledTimes(3); }); });
- 实现 利用
targetMap
实现响应式对象 -> Key -> Effective Function 的映射. 导出track
与trigger
用于收集与触发依赖
在// target: Object => keyMap:(string=>Set) // keyMap: string => Set const targetMap: Map<any, Map<string, Set<EffectReactive>>> = new Map(); let activeEffectFn: any = undefined; class EffectReactive { runner: (...args: any[]) => any; constructor(public fn) { this.runner = this.run.bind(this); activeEffectFn = this; // 全局注册当前正在收集依赖的 Effect this.run(); // 执行函数 activeEffectFn = undefined; // 取消注册 } run() { this.fn(); } } export function effect(fn) { // 考虑到 effect 上动作很多, 我们将其抽离为 EffectFunction 函数 return new EffectReactive(fn).runner; } // 依赖收集函数, 由 `[GET]` 触发, 该函数检查是否有 active 的 Effect, 有就收集依赖 export function track(target, key) { if (!activeEffect) return; if (!targetMap.has(target)) targetMap.set(target, new Map()); const keyMap = targetMap.get(target)!; if (!keyMap.has(key)) keyMap.set(key, new Set()); const dependenceEffect = keyMap.get(key)!; dependenceEffect.add(activeEffect); } // 触发函数, 当响应式对象被 `[SET]` 时尝试触发其收集的所有 Effect export function trigger(target, key) { const keyMap = targetMap.get(target)!; if (!keyMap) return; const depSet = keyMap.get(key)!; if (!depSet) return; [...depSet].forEach((d) => d.run()); }
Proxy
上同步修改export function reactive(origin) { return new Proxy(origin, { get(target, key, receiver) { + track(target, key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { + // 这两行顺序反了就寄了 const res = Reflect.set(target, key, value, receiver); + trigger(target, key); return res; }, }); }
- 重构: 上面这个代码有点问题, 我们只在构造 EffectFunction 时收集了依赖, 但是并不能收集全
这个测试就无法通过, 因为it('dym track', () => { const origin1 = { foo: 1 }; const observe1 = reactive(origin1); const origin2 = { foo: 100 }; const observe2 = reactive(origin2); let cnt = 0, ob = 0; effect(() => { if (cnt == 0) { ob = observe1.foo; } else { ob = observe2.foo; } cnt++; }); expect(ob).toBe(1); observe1.foo = 2; expect(ob).toBe(100); observe2.foo = 200; expect(ob).toBe(200); });
observe2
理论上应该在observe1.set
调用run
的时候收集依赖, 所以应该修改构造函数和run
为
我们知道, 所有的依赖收集都是通过 fn 中对 reactive 的constructor(public fn, options: any) { // ... this.run(); } run() { activeEffect = this; const res = this.fn(); activeEffect = undefined; return res; }
[GET]
实现的, 我可以保证只要执行fn
在其前后都加入了依赖收集的 flag 就可以. 调用fn
只可能发生在 - 构造函数
- 手动执行
runner
- reactive 执行
[SET]
触发 trigger
这三部分要执行的都是 run
我们可以保证只要执行 run
就触发依赖收集
实现 effect
的 scheduler
选项 (watch
)
需求: 为 effect
传入第二个参数, 参数是一个对象, 其中包含 scheduler
函数, 当构造 Effect 时执行传入的第一个函数参数, 当响应式函数变化时执行 scheduler
函数. 这与 Vue 3 的 watch
类似
需求分析: 在构造 Effect 的时候传入配置并在触发的时候判断是否有 scheduler
函数
- 测试
it('Shound run scheduler', () => {
const origin = { foo: 1 };
const observed = reactive(origin);
let bar;
effect(
() => {
bar = observed.foo;
},
{ // 传入配置
scheduler() {
bar = -observed.foo;
},
}
);
expect(bar).toBe(1); // 第一次运行 fn 函数
observed.foo = 2;
expect(bar).toBe(-2); // 第二次运行 scheduler 函数
});
- 实现
- 修改 effect 函数, 加入配置项
export function effect(fn, options = {}) { return new EffectReactive(fn, options).runner; }
- 修改 EffectReactive 的构造函数加载配置项
class EffectReactive { runner: (...args: any[]) => any; scheduler: (...args: any[]) => any | undefined; constructor(public fn, options: any) { this.scheduler = options.scheduler; // ... } // ... }
- 修改触发函数
export function trigger(target, key) { // ... [...depSet].forEach((d) => (d.scheduler ? d.scheduler() : d.run())); }
- 重构: 无
什么时候尝试抽离函数 / 对象
- 函数上有很多动作
- 函数作用范围广, 语义差
实现 effect
的 stop
与 onStop
选项
需求: 1. 定义一个外部函数 stop
. 传入 runner
让 runner
不再被响应式对象 trigger 2. effect
中加入 onStop
配置, 在 stop
时调用
需求分析: 只需要将 EffectFunction 从响应式对象的依赖表中删除即可. 但是我们之前就没记录有哪些响应式对象将 EffectFunction 作为依赖..., 所以需要开一个 Set 记录这些响应式对象. 同时, 我们不需要记录依赖的对象是什么, 只需要记录 KeyMap 对应的 Set.
- 测试
it('Should stop trigger', () => { const origin = { foo: 1 }; const observed = reactive(origin); let bar; const runner = effect( () => { bar = observed.foo; // 立即执行 }, { onStop() { if (bar > 0) bar = 0; // 如果首次调用置 0 bar--; }, } ); expect(bar).toBe(1); // 立即执行 stop(runner); expect(bar).toBe(-1); // 停止后第一次执行为 -1 observed.foo = 2; expect(bar).toBe(-1); // reactive 变化也不调用 fn stop(runner) expect(bar).toBe(-1); // 反复 stop 不反复执行 onStop });
- 实现 修正 EffectReactive
修正依赖收集函数class EffectReactive { runner: { // effect 只返回 runner, stop 函数需要根据 runner 找到 EffectReactive, 所以要在函数上加一个属性记录一下 (...args: any[]): any; effect?: EffectReactive; }; onStop: (...args: any[]) => any | undefined; // stop 回调 deps: Set<Set<EffectReactive>>; // 收集了这个函数依赖的变量的依赖表集合 active: boolean; // EffectReactive 是否运行 (stop 时置 0) // ... constructor(public fn, options: any) { this.runner = this.run.bind(this); this.runner.effect = this; this.onStop = options.onStop; this.deps = new Set(); this.active = true; // ... } run() { // 如果用户手动执行 runner 那么只执行 fn, 不追踪依赖, 放置依赖追踪给已经解除依赖的元素再绑定上依赖 if (!this.active) return this.fn(); activeEffect = this; const res = this.fn(); activeEffect = undefined; return res; } }
实现export function track(target, key) { // dependenceEffect.add(activeEffect); // 为当前正在依赖收集的 effect 的依赖上加入这个 key 的依赖表 activeEffect.deps.add(dependenceEffect); }
stop
函数export function stop(runner) { // 不反复执行 if (!runner.effect.active) return; runner.effect.active = false; // 找到所有收集过 effect 的变量, 将 effect 从依赖表中删除 [...runner.effect.deps].forEach((d) => d.delete(runner.effect)); // 执行 onStop runner.effect.onStop && runner.effect.onStop(); }
- 重构: 无
实现 Proxy
的 Readonly
需求: readonly
与 reactive
类似, 不过不支持 set
需求分析: 一个元素不支持 set
也就不可能触发依赖, 所以也没有必要做依赖收集. 所以只需要精简一下 reactive
. 可以发现, 不同权限的变量只是在构造的时候采用不同的 [GET]
与 [SET]
策略. 可以将 [GET]
与 [SET]
抽离出来
- 测试
it('Happy path', () => { const origin = { foo: 1 }; const observed = readonly(origin); console.warn = jest.fn(); expect(observed.foo).toBe(1); // 将原始对象包装为只读对象 expect(console.warn).not.toHaveBeenCalled(); // 最开始不报错 observed.foo = 2; // 修改, 静默失效, 报 warning expect(console.warn).toBeCalledTimes(1); // warning 被调用一次 expect(observed.foo).toBe(1); // readOnly 静默失效 });
- 实现 抽离
[GET]
与[SET]
抽离对象创建函数// reactive 的 [GET] function get(target, key, receiver) { track(target, key); return Reflect.get(target, key, receiver); } // reactive 的 [SET] function set(target, key, value, receiver) { const res = Reflect.set(target, key, value, receiver); trigger(target, key); return res; } function getReadonly(target, key, receiver) { return Reflect.get(target, key, receiver); } function setReadonly(target, key, value, receiver) { console.warn('Can not set readonly'); // 要返回一下设置结果, 如果返回 false 会抛出异常, 而我们只希望静默失效 return true; } export const proxyConfig = { get, set, }; export const proxyReadonlyConfig = { get: getReadonly, set: setReadonly, };
重写function createReactiveObject(origin, readonly = false) { if (readonly) return new Proxy(origin, proxyReadonlyConfig); return new Proxy(origin, proxyConfig); }
reactive
实现readonly
export function reactive(origin) { return createReactiveObject(origin); } export function readonly(origin) { return createReactiveObject(origin, true); }
- 重构: 上面的就是重构后的代码
实现工具函数 isReadonly
, isReactive
, isProxy
需求: 实现工具函数, isReadonly
, isReactive
, isProxy
(前两个函数二选一).
需求分析: 只需要在 [GET]
上特判即可
- 测试
it('isReadonly test', () => { const origin = { foo: 1 }; const observed = readonly(origin); expect(isReadonly(observed)).toBe(true); expect(isReactive(observed)).toBe(false); }); it('isReactive test', () => { const origin = { foo: 1 }; const observed = reactive(origin); expect(isReadonly(observed)).toBe(false); expect(isReactive(observed)).toBe(true); }); it('isProxy test', () => { const origin = { foo: 1 }; const observed = readonly(origin); expect(isProxy(observed)).toBe(true); });
- 实现 构造个枚举
实现函数export const enum ReactiveFlag { IS_REACTIVE = '__v_isReactive', IS_READONLY = '__v_isReadonly', }
修改export function isReactive(value) { // 要转一下 Boolean 因为非 reactive 对象会返回 undefined return !!value[ReactiveFlag.IS_REACTIVE]; } export function isReadonly(value) { return !!value[ReactiveFlag.IS_READONLY]; } export function isProxy(value) { return isReactive(value) || isReadonly(value); }
[GET]
const reactiveFlags = { [ReactiveFlag.IS_REACTIVE]: true, [ReactiveFlag.IS_READONLY]: false, }; const readonlyFlags = { [ReactiveFlag.IS_REACTIVE]: false, [ReactiveFlag.IS_READONLY]: true, }; function get(target, key, receiver) { if (Object.keys(reactiveFlags).find(d=>d===key)) return reactiveFlags[key]; // ... } function getReadonly(target, key, receiver) { if (Object.keys(readonlyFlags).find(d=>d===key)) return readonlyFlags[key]; // ... }
- 重构: 无
实现 reactive
/ readonly
嵌套
需求: 若 reactive
/ readonly
内部 value 为对象, 那么该对象也应该是 reactive
/ readonly
需求分析: 我最开始的想法是在构造 reactive
的时候遍历所有属性, 然后为这些属性配置 reactive
. 然而, 这无法将动态添加的对象转为 reactive
. 考虑需求, 我们希望让内层对象支持 reactive, 实际上是希望让内层对象也支持依赖收集等 reactive
功能, 而这些功能都是在对象被 [GET]
的时候被激活的. 也就是说我们最晚可以在首次访问属性的将内层对象转换为 reactive
.
- 测试(只写了
reactive
的)it('Should nested track', () => { const origin = { foo: { a: 1 }, bar: [{ b: 2 }], }; const observe = reactive(origin); expect(isReactive(observe)).toBe(true); expect(isReactive(observe.foo)).toBe(true); expect(isReactive(observe.bar)).toBe(true); expect(isReactive(observe.bar[0])).toBe(true); });
- 实现 只需要在
[GET]
的时候判断属性是否是对象, 如果是对象那么返回包装后的reactive
function get(target, key, receiver) {
if (Object.keys(reactiveFlags).find((d) => d === key))
return reactiveFlags[key];
const res = Reflect.get(target, key, receiver); // 获取结果
if (isObject(res)) return reactive(res); // 如果结果是对象, 将其包装为 reactive
track(target, key);
return res;
}
function getReadonly(target, key, receiver) {
if (Object.keys(readonlyFlags).find((d) => d === key))
return readonlyFlags[key];
const res = Reflect.get(target, key, receiver); // 获取结果
if (isObject(res)) return readonly(res); // 如果结果是对象, 将其包装为 readonly
return res;
}
在 packages/share/index.ts
中构造工具函数判断输入是否是对象export function isObject(v) {
return v !== null && typeof v === 'object';
}
3. 改进: 我们并没有实现内层 reactive 的持久化, 也就是说每次 reactive 的结果是不同的... 实现内层对象持久化const reactiveMap = new Map();
const readonlyMap = new Map();
export function reactive(origin) {
if (!reactiveMap.has(origin))
reactiveMap.set(origin, createReactiveObject(origin));
return reactiveMap.get(origin)!;
}
export function readonly(origin) {
if (!readonlyMap.has(origin))
readonlyMap.set(origin, createReactiveObject(origin, true));
return readonlyMap.get(origin)!;
}
注意
- JS是动态语言, 不要尝试做静态代码分析: 我们在实现功能的时候应该考虑什么时候完成工作不晚, 不遗漏而不是相静态语言一样想什么时候可以操作数据
- 实现功能时想想这个功能希望我们对外表现为什么样子: 思考是什么而不是怎么做, 比如内层 reactive 的第一版代码并没有实现将对象转为 reactive 并附着在对象上, 而是考虑如果一个内层对象是 reactive, 那么我们应该在
[GET]
的时候表现的与原始对象不同. 这就启发我们只需要在[GET]
的时候处理数据就可以而不需要在构造对象的时候实现这一功能.
实现 shadowReadonly
需求: shadowReadonly
就是只对对象外层实现 readonly, 内部对象不管, 不 Proxy
需求分析: 实际上就是不支持嵌套的 readonly
- 测试
it('Happy path', () => { const origin = { foo: { bar: 2 } }; const observed = shadowReadonly(origin); console.warn = jest.fn(); expect(console.warn).not.toHaveBeenCalled(); observed.foo = 0; // 外层禁止修改 expect(console.warn).toBeCalledTimes(1); expect(observed.foo.bar).toBe(2); observed.foo.bar = 0; // 内部不管 expect(observed.foo.bar).toBe(0); expect(console.warn).toBeCalledTimes(1); expect(isProxy(observed.foo)).toBe(false); });
- 实现
实现 shadowReadonlyfunction getShadowReadonly(target, key, receiver) { if (Object.keys(readonlyFlags).find((d) => d === key)) return readonlyFlags[key]; // 其实就是不支持嵌套追踪的 readonly. shadowReadonly 的元素一定是非 reactive 对象, 所以直接返回 return Reflect.get(target, key, receiver); } export const proxyShadowReadonlyConfig = { get: getShadowReadonly, set: setReadonly, };
const shadowReadonlyMap = new Map(); function createReactiveObject(origin, readonly = false, shadow = false) { if (shadow && readonly) return new Proxy(origin, proxyShadowReadonlyConfig); if (readonly) return new Proxy(origin, proxyReadonlyConfig); return new Proxy(origin, proxyConfig); } export function shadowReadonly(origin) { if (!shadowReadonlyMap.has(origin)) shadowReadonlyMap.set(origin, createReactiveObject(origin, true, true)); return shadowReadonlyMap.get(origin)!; }
- 重构
实现 ref
需求: 实现 ref
- 如果 ref(value)
输入的是不是对象, 那么可以 - 通过 .value
访问值 - 通过 .value
更新值, 如果赋值时新值与旧值一样则什么都不做 - 支持类似 reactive
的依赖收集与触发 - 如果 ref(value)
输入的是对象, 那么可以 - 在上面的基础上对要求对象支持 reactive
需求分析:
- 我最开始想到的是
ref = (value) => reactive({value})
但是如果只是这么简单实现, 那么ref
的非value
属性也将变为reactive
. 同时可以预见这样实现的ref
性能不及标准ref
. ref
的特点是外层有且只有value
一个key
, 这意味我们在实现时- 不用使用全局
targetMap
(只有一个depSet) - 不用像
reactive
一样实现一个 Proxy, 可以只实现一个[GET]
&[SET]
.
- 不用使用全局
- 考虑到
ref
的输入可能是对象或非对象- 我们能不使用全局的
targetMap
, 否则两个值相同的ref
会被判定为同一个keyMap
- 若输入为对象, 在对比赋值时新值与旧值一样不能简单的比较
_value === newValue
. 若输入为对象, 那么reactive(obj) !== obj
. 我们还需要保存输入的原始值
- 我们能不使用全局的
测试
ref
非对象时it('should be reactive', () => { const a = ref(1); let dummy; let calls = 0; effect(() => { calls++; dummy = a.value; }); expect(calls).toBe(1); // 构造 EffectFunction 执行一次 expect(dummy).toBe(1); a.value = 2; // ref 也支持依赖收集与触发 expect(calls).toBe(2); expect(dummy).toBe(2); a.value = 2; // 同值不触发 expect(calls).toBe(2); expect(dummy).toBe(2); });
ref
对象时要把内层对象变为reactive
, 对象也可以变为非对象it('should convert to reactive', () => { const origin = { foo: 1 }; const a = ref(origin); let dummy; let calls = 0; effect(() => { calls++; dummy = a.value.foo ? a.value.foo : a.value; }); expect(calls).toBe(1); // 构造 EffectFunction 执行一次 expect(dummy).toBe(1); a.value.foo = 2; // ref 也支持依赖收集与触发 expect(calls).toBe(2); expect(dummy).toBe(2); a.value = origin; // 同值不触发 expect(calls).toBe(2); expect(dummy).toBe(2); a.value = { foo: 1 }; // 同值不触发 expect(calls).toBe(3); expect(dummy).toBe(1); a.value = 5; // 变为非对象 expect(calls).toBe(4); expect(dummy).toBe(5); });
实现
// 与 reactive 直接返回一个 Proxy 不同, 我们只有 value 一个属性, 所以要手动实现一个对象
class RefImpl {
// 这里我们不使用全局的 targetMap 原因是
// - 我们这里的 Key 可以不是对象, 两个值相同的 ref 会被判定为同一个 key
// - 只存在一个 Key: value, 所以没有必要使用两个 Map, 只需要一个 Set 就可以存储所有的 EffectReactive
private deps: Set<EffectReactive>;
private _value;
private rawValue;
constructor(value) {
this.deps = new Set();
this._value = isObject(value) ? reactive(value) : value;
this.rawValue = value;
}
// 只需要 value 的 [SET] [GET] 就可以实现
get value() {
trackEffect(this.deps); // 依赖追踪
return this._value;
}
set value(newValue) {
// 重复赋值不触发, 考虑两种情况
// - this._value 不是 Object, 直接比较
// - this._value 是 Object, 此时 this._value 是一个 reactive, reactive(obj) !== obj, 必须使用原始值比较
if (this.rawValue === newValue) return;
this.rawValue = newValue;
this._value = isObject(newValue) ? reactive(newValue) : newValue;
triggerEffect(this.deps); // 触发依赖
}
}
export function ref(value) {
return new RefImpl(value);
}
在这里, trackEffect
与 triggerEffect
相当于不需要查 Set
的 track
与 trigger
(因为只有一个 Set
). 我们可以将原来的 track
与 trigger
拆开export function track(target, key) {
if (!activeEffect) return;
if (!targetMap.has(target)) targetMap.set(target, new Map());
const keyMap = targetMap.get(target)!;
if (!keyMap.has(key)) keyMap.set(key, new Set());
// 抽成一个函数
trackEffect(keyMap.get(key)!);
}
export function trackEffect(dependenceEffect) {
// 本来只需要在 track 上判断 activeEffect 但是这个函数可能被 track 或者 RefImpl 调用, 所以还需要在判断一次
if (!activeEffect) return;
dependenceEffect.add(activeEffect);
activeEffect.deps.add(dependenceEffect);
}
export function trigger(target, key) {
const keyMap = targetMap.get(target)!;
if (!keyMap) return;
const depSet = keyMap.get(key)!;
if (!depSet) return;
// 抽成一个函数
triggerEffect(depSet);
}
export function triggerEffect(depSet) {
[...depSet].forEach((d) => (d.scheduler ? d.scheduler() : d.run()));
}
实现工具函数 isRef
& unRef
& proxyRefs
需求: - isRef
: 判断输入是不是 ref
- unRef
: 返回 ref
的 value
- proxyRefs
: 模拟 Vue3 的 setup 函数, 通过该函数返回的对象中的 ref
在模板字符串中无需 .value
即可访问与赋值. 简单来说就是输入对象, 在访问对象中浅层 ref
的 Key
时无需 .value
即可访问
需求分析: - isRef
: 加一个 flag 即可 - unRef
: 判断是不是 ref
, 是就返回 ref.value
- proxyRefs
: 构造一个代理, 在读写是判断读写目标是不是 ref
如果是就返回 ref.value
. 同时, 在 [SET]
时, 如果新旧值都是 ref
那么直接替换掉旧 ref
测试
it('isRef', () => { const origin1 = 1; const origin2 = { foo: 1 }; const observed1 = ref(origin1); const observed2 = ref(origin2); expect(isRef(origin1)).toBe(false); expect(isRef(origin2)).toBe(false); expect(isRef(observed1)).toBe(true); expect(isRef(observed2)).toBe(true); }); it('unRef', () => { const origin1 = 1; const origin2 = { foo: 1 }; const observed1 = ref(origin1); const observed2 = ref(origin2); expect(unRef(observed1)).toBe(origin1); expect(unRef(observed2)).not.toBe(origin2); expect(unRef(observed2)).toStrictEqual(origin2); expect(unRef(observed2)).toBe(reactive(origin2)); }); it('proxyRefs', () => { const user = { sampleRef: ref(10), sampleStr: 'demo', }; const proxyUser = proxyRefs(user); expect(user.sampleRef.value).toBe(10); expect(proxyUser.sampleRef).toBe(10); expect(proxyUser.sampleStr).toBe('demo'); (proxyUser as any).sampleRef = 20; expect(proxyUser.sampleRef).toBe(20); expect(user.sampleRef.value).toBe(20); proxyUser.sampleRef = ref(10); expect(proxyUser.sampleRef).toBe(10); expect(user.sampleRef.value).toBe(10); });
实现
打flag
class RefImpl {
public __v_isRef = true;
}
实现 isRef
与 unRef
export function unRef(ref) {
return isRef(ref) ? ref.value : ref;
}
export function isRef(value) {
return !!value?.__v_isRef;
}
实现 proxyRef
export function proxyRefs(origin) {
return new Proxy(origin, proxyProxyRefConfig);
}
function getProxyRef(target, key, receiver) {
// 不用这么麻烦
// if (isRef(target[key])) return target[key].value;
// return target[key];
return unRef(target[key]);
}
function setProxyRef(target, key, value, receiver) {
// 只特判 ref <- 普通值
if (isRef(target[key]) && !isRef(value)) return (target[key].value = value);
return Reflect.set(target, key, value, receiver);
}
export const proxyProxyRefConfig = {
get: getProxyRef,
set: setProxyRef,
};
3. 重构: 无实现 computed
需求: 1. 输入一个函数, 返回一个对象, 可以通过 .value
获取函数返回值, 当函数内部 reactive
变化时, 返回值也要变化. 2. 支持 Lazy, 即: 1. 在 computed
内部 reactive
变化时不触发 computed
传入函数 2. 在 [GET]
时才触发 computed
传入函数 3. 若内部 reactive
不变, 重复触发 [GET]
不重复触发传入函数 3. 返回值也是一个 reactive
对象, 即 .value
变化时要触发依赖
需求分析:
- 测试
it('should reactive', () => { let cnt = 0; const observed = reactive({ foo: 1 }); const bar = computed(() => { cnt++; return observed.foo + 1; }); expect(cnt).toBe(0); // Lazy expect(bar.value).toBe(2); expect(cnt).toBe(1); observed.foo = 2; expect(cnt).toBe(1); // Lazy expect(bar.value).toBe(3); expect(cnt).toBe(2); expect(bar.value).toBe(3); expect(bar.value).toBe(3); // Lazy expect(cnt).toBe(2); }); // 返回值也可以收集依赖 it('should trigger effect', () => { const value = reactive({}); const cValue = computed(() => value.foo); let dummy; effect(() => { dummy = cValue.value; }); expect(dummy).toBe(undefined); value.foo = 1; expect(dummy).toBe(1); });
- 实现
- 构造一个
old
, 当内部 reactive 变化时修改, 如果内部不变就直接使用原_value
- 类似构造
ref
的dep
收集.value
的依赖 - 我们希望在第一次
[GET]
的时候收集依赖, 这可以使用EffectReactive
实现, 但是为了实现 Lazy 我们又不希望每次内部reactive
变化都触发依赖. 我们可以采用scheduler
解决, 每次内部reactive
变化时候打下标记(old
), 并通知computed
要触发依赖了. 如果computed
没有依赖那这次就 Lazy 过去了, 如果有那在触发依赖时其他函数会调用计算属性的[GET]
此时完成刷新
import {
effect,
EffectReactive,
trackEffect,
triggerEffect,
} from './effect';
class ComputedImpl {
old: boolean;
fst: boolean;
_value: any;
dep: Set<EffectReactive>;
effect!: EffectReactive;
constructor(public fn) {
this.old = false;
this.fst = true;
this.dep = new Set();
}
get value() {
trackEffect(this.dep);
// 为啥人家的代码没 fst 呢? 因为人家的 EffectReactive 每在构造函数的时候 run. 人家可以在构造函数里面注册这个 effect
if (this.fst) {
this.fst = false;
this.effect = new EffectReactive(() => (this._value = this.fn()), {
scheduler: () => {
this.old = true;
triggerEffect(this.dep);
},
});
}
if (this.old) {
this.old = false;
this._value = this.effect.runner();
triggerEffect(this.dep);
}
return this._value;
}
set value(_) {
console.warn('Can not set computed value');
}
}
export function computed(origin) {
return new ComputedImpl(origin);
}
- 重构: 考虑修改 EffectReactive
构造函数小结
- 实现
Reactivity
的核心就是一个Proxy
. 通过修改[GET]
&[SET]
实现不同权限 - 时刻谨记 JavaScript 是动态语言, 对象上的属性随时在变化, 不要想在某一个对对象上的属性做特殊处理, 很容易遗漏, 我们可以想想什么时候外部需要我们特殊处理的特性, 在出口处"围追堵截"
- 注意我们应该在什么时候抽象函数
- 语义上可以抽象时候
- 功能重复时
- 当函数功能部分重叠时要敢于拆分函数
ref
相当于是一个整体功能弱化的reactive
, 所以我们没有使用全局targetMap
computed
的实现比较巧妙, 运用了一个 effect 的配置项, 我们在实现工具函数的时候也可以想想是否可以通过配置项将两个功能类似的类合并成一个类.
实现 runtime-core 的 mount 部分
搭建环境
runtime-core 直接参与页面实现, 我们需要利用打包工具打包代码. 在打包网页时一般使用 webpack, 而在打包模块时一般使用 rollup.js. 安装 rollup 及其 TypeScript 依赖
pnpm i -D rollup @rollup/plugin-typescript tslib rollup-plugin-sourcemaps
# ^ 本体 ^ typescript 支持 ^ TS 支持依赖
配置 rollup
- 创建
/package/index.ts
作为整个项目的出口 - 创建 rollup 配置文件
/package/rollup.config.js
import typescript from '@rollup/plugin-typescript'; import sourceMaps from 'rollup-plugin-sourcemaps'; export default { input: './packages/index.ts', // 入口文件 output: [ // 2种输出格式 { format: 'cjs', // 输出格式 file: './lib/micro-vue.cjs.js', // 输出路径 sourcemap: true, }, { format: 'es', file: './lib/micro-vue.esm.js', sourcemap: true, }, ], plugins: [typescript(), sourceMaps()], };
- 执行
rollup -c ./rollup.config.js
打包 - 根据提示将
tsconfig.json
中"module": "commonjs"
改为"module": "ESNext"
- 在
package.json
中注册包的入口文件,main
对应 commonjs 包,module
对应 ESM 包"main": "lib/micro-vue.cjs.js", "module": "lib/micro-vue.esm.js",
构造测试用例
我们构造一个简单的 Vue demo 并尝试实现 vue-runtime 主流程使其可以将我们的 demo 渲染出来, Vue 项目一般包含如下文件
index.html
: 至少包含一个挂载点index.js
: 引入根组件, 将根组件挂载到挂载点App.vue
: 定义根组件
SFC 需要 vue-loader 编译才能实现. 而 vue-loader 的作用是将 SFC 处理为 render
函数, 在此我们只能先将 App.vue
处理为 vue-loader 编译后函数. 定义
index.html
: 只构造一个挂载点并引入 JS<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>micro-vue runtime-core</title> </head> <body> <div id="app"></div> <script src="./index.js" type="model"></script> </body> </html>
index.js
: 先不管有没有这些函数, 平时咋写就咋写import { createApp } from '../../lib/micro-vue.esm'; import App from './App'; createApp(App).mount('#app');
App.js
:import { h } from '../../lib/micro-vue.esm.js'; export default { setup() {}, render() { return h('div', { class: 'title' }, [ h('span', {}, "111"), h('span', {}, "222"), ]); }, };
App.js
默认导出了一个配置对象, 该对象应该包含 SFC 中导出的 setup
与 vue-loader 编译得到的 render
函数. 其中
setup
函数的返回值可以是对象, 也可以是渲染函数- 在解析 SFC 文件时, 如果用户手动通过
setup
返回了渲染函数那么 vue-loader 就不编译模板, 如果没有返回则编译模板并构造渲染函数render
.render
函数描述了这个组件里面应该如何渲染 render
中的h
用于表述一个组件/元素, 语法为:h(type, attr, children)
.type
: 描述元素或组件类型, 如果希望将目标渲染为 Element 那么type
为标签名, 如果希望渲染为组件那么type
为 组件配置对象attr
: 描述元素或组件上的属性(例如:class
)children
:- 如果待渲染的是一个元素, 如果这个元素下面没有子元素或者子组件, 那么
children
为元素的innerText
, 如果下面还有子组件或子元素, 那么children
应该是一个h
函数返回值数组 - 如果待渲染的是一个组件,
children
属性将传入插槽而不是子元素, 这一点与模板设计是类似的
的<template> <div> <span>111</span> <span>222</span> </div> </template>
h
函数
对于组件h('div', {}, [h('span', {}, '111'), h('span', {}, '222')])
的<template> <Comp> <span>111</span> <span>222</span> </Comp> </template>
h
函数
设计上是统一的.h(Comp, {}, [h('span', {}, '111'), h('span', {}, '222')])
- 如果待渲染的是一个元素, 如果这个元素下面没有子元素或者子组件, 那么
上面这个例子描述了这样一个组件:
首先默认导出的是一个组件配置对象
这个组件被编译为了
render
函数,render
函数返回了一个h
.诶, 我要渲染一个组件, 为啥
h
的type
是div
而不是配置对象呢? 一定注意,render
描述的是组件里面应该如何渲染, 这里的h
是说, App 组件里面有一个div
, 如果我们这里写的是h(demoObj, {}, '111')
这个意思是 App 组件里面有一个 demo 组件, 这个 demo 组件里面啥也没有, 他的 innerText 是 '111'诶, 那我们在哪里定义了 App 的 h 函数呢? 我们没有用
h
函数定义 App (是利用 createApp 定义的) 至于这俩函数有什么联系后面再说诶, 那难道组件内部只能有一个一级子元素? 是的, 在 Vue2 中我们就规定
template
下最多只能有一个一级子元素, 在 Vue3 中我们用语法糖解除了这个限制. 你可能会想到对于 App 下的某个组件(如 demo), 我们通过这样的方式让这个组件有多个子元素// App.js 的 render render() { return h(demoConfig, { class: 'title' }, [ h('span', {}, "111"), h('span', {}, "222"), ]); }
这是错的, 数组将作为插槽传入 demo 组件, 组件的子元素是在组件自己的
render
中定义的.demoConfig = { render(){ return h('div', {}, [ h('span', {}, "111"), h('span', {}, "222"), ]) } } // App.js 的 render render() { return h(demoConfig, { class: 'title' }); }
其实我们的疑问就是到底是他妈的谁构造了根组件
App
的h
函数
App 是一个组件, 这个组件内部有一个
div
这个div
又有两个子span
, 内容分别是111
和222
构造主流程
vue-runtime
的主流程graph TB 根组件配置对象 --createApp--> 一种特殊的vNode --挂载根组件--> 根组件特殊使命结束,成为普通的组件 --渲染--> 进入patch函数 --目标是Element--> Element处理函数 --新Element--> 挂载Element --没有子Element --> 写入innerText 挂载Element --有子Element--> 每个子Element --渲染--> 进入patch函数 Element处理函数 --老Element--> 更新Element 进入patch函数 --目标是组件--> 组件处理函数 --新组件--> 新建组件 --> 应用配置 --> 运行render --> 每个子组件 --渲染--> 进入patch函数 组件处理函数 --老组件--> 更新组件
可以看到
createApp
输入配置对象,h
函数输入type
(可以是string可以是配置对象),props
,children
. 虽然两者输入不同, 但是他们都返回了 vNode.createApp
的输入可以看作是没有props
,children
的h
函数的组件输入, 而createApp
的输出可以看作是具有特殊功能的h
输出. 实际上createApp
与h
在底层都依赖了createVNode
函数.vue 渲染中对象发生了如下变化:
graph LR 组件/元素配置对象 --> 虚拟节点vNode --> 实例对象 --> DOM
- 组件配置对象包含了
render
,setup
vNode
在配置对象的基础上加入了部分属性- 实例对象又在
vNode
的基础上加入了属性 - 最后挂载为 DOM
- 组件配置对象包含了
vue-runtime
对外暴露函数只有 createApp
我们从这个函数入手
createApp
创建了 app 组件vNode
, 同时这个的vNode
还应该有mount
函数(唯一特殊的地方)// @packages/runtime-core/src/createApp.ts import { createVNode } from './vnode'; import { render } from './render'; export function createApp(rootComponent) { return { _component: rootComponent, mount(container) { const vNode = createVNode(rootComponent); render(vNode, document.querySelector(container)); }, }; }
createVnode
: 收到配置对象,props
,children
将他们作为一个对象存起来(API 与h
函数一样)// @packages/runtime-core/src/vnode.ts export function createVNode(component, props = {}, children = []) { return { type: component, props, children, }; }
render
负责渲染vNode
, 但是render
什么都没做, 只是调用了patch
. 这里多此一举是为了方便之后部署子元素时递归方便// @packages/runtime-core/src/render.ts export function render(vNode, container) { patch(null, vNode, container); // 第一次创建没有老元素 }
patch
函数收入更新前后节点与挂载点(新节点的挂载前节点为null
), 针对不同节点类型调用不同处理函数// @packages/runtime-core/src/render.ts export function patch(vNode1, vNode2, container) { if (isObject(vNode2.type)) processComponent(vNode1, vNode2, container); else processElement(vNode1, vNode2, container); }
processComponent
处理组件// @packages/runtime-core/src/render.ts function processComponent(vNode1, vNode2, container) { if (vNode1) return updateComponent(vNode1, vNode2, container); // 老元素就 update return mountComponent(vNode2, container); // 新元素就挂载 }
updateComponent
暂时没有必要实现mountComponent
挂载组件. 首先明确组件自己是没有 HTML 标签的, 挂载组件实际上是挂载组件中的子元素. 而组件存在的必要是其导出的 setup 函数中存在子元素需要的变量与函数.我们实现组件实例在上面记录组件需要的上下文
// @packages/runtime-core/src/render.ts function mountComponent(vNode, container) { const instance = createComponent(vNode); // 创建实例 setupComponent(instance); // 配置实例 setupRenderEffect(instance.render, container); // 部署实例 }
createComponent
用于创建组件实例, 为了方便我们将组件的 type 提到实例上// @packages/runtime-core/src/componment.ts export function createComponent(vNode) { return { vNode, type: vNode.type, // 图方便 render: null, }; }
setupComponent
用于创建实例, 配置实例, 包括初始化 props, slots, 处理 setup 导出的变量等. 这里我们先不处理 props, slot, 忽略 setup 导出的变量后的归属问题, 只解决- 如果有
setup
就执行setup
, 如果执行结果是对象就将导出对象绑定到 instance 上, 如果是函数就把他当成render
函数 - 如果没
render
就从vNode
的type
上读取render
// @packages/runtime-core/src/componment.ts export function setupComponent(instance) { // initProp // initSlot setupStatefulComponent(instance); finishComponentSetup(instance); } // 如果有 setup 就处理 setup 函数运行结果 function setupStatefulComponent(instance) { if (instance.type.setup) handleSetupResult(instance, instance.type.setup.call(instance)); finishComponentSetup; } // 处理 setup 函数运行结果 function handleSetupResult(instance, res) { if (isFunction(res)) instance.render = res; else { instance.setupResult = res; } finishComponentSetup(instance); } // 最后兜底获取 render function finishComponentSetup(instance) { instance.render = instance.render || instance.type.render; }
- 如果有
实现
instance
之后需要将中的子元素挂载出去, 递归patch
即可// @packages/runtime-core/src/componment.ts export function setupRenderEffect(render, container) { const subTree = render(); // render 只能返回 h 函数的结果, 所以一定是一个 vNode, 直接 patch 就行 // ! patch(null, subTree, container); }
类似的实现 Element 处理功能
// @packages/runtime-core/src/render.ts function processElement(vNode1, vNode2, container) { if (vNode1) return updateElement(vNode1, vNode2, container); return mountElement(vNode2, container); }
实现挂载 Element
// @packages/runtime-core/src/render.ts function mountElement(vNode, container) { const el = document.createElement(vNode.type) as HTMLElement; // 构造 DOM 元素 // 添加属性 Object.keys(vNode.props).forEach((k) => el.setAttribute(k, vNode.props[k])); // 有子元素 if (isObject(vNode.children)) { vNode.children.forEach((d) => { patch(null, d, el); // 递归挂载 }); } else el.textContent = vNode.children; // 没子元素 container.appendChild(el); }
最后写下
h
函数// @packages/runtime-core/src/h.ts import { createVNode } from "./vnode"; export const h = createVNode
实现组件实例 Proxy
我们想要让组件可以引用自己导出的变量
export default {
setup() {
return {
message: ref('micro-vue'),
};
},
render() {
return h('div', { class: 'title' }, 'hi ' + this.message);
},
};
但是因为我们直接调用了 render
函数
// @packages/runtime-core/src/component.ts
export function setupRenderEffect(render, container) {
const subTree = render();
// ...
}
所以 render
的 this
是 global
, 我们希望 render
的 this
包括 setup
导出的对象与 Vue 3 文档中的组件实例, 所以我们需要构造一个 Proxy 同时实现访问 setup 结果与组件对象
- 处理 setup 导出
// @packages/runtime-core/src/component.ts
function handleSetupResult(instance, res) {
// ...
instance.setupResult = proxyRefs(res);
// ...
}
- 在结束组件初始化时构造代理对象, 将代理对象作为一个属性插入实例
将// @packages/runtime-core/src/component.ts function finishComponentSetup(instance) { // 声明代理对象 instance.proxy = new Proxy({ instance }, publicInstanceProxy); instance.render = instance.render || instance.type.render; }
target
定义为{ instance }
看起来很怪, 为啥不直接用instance
呢? 因为在 DEV 模式下这个对象内部应该还有很多属性, 只不过我们没有考虑 - 定义代理
// @packages/runtime-core/src/publicInstanceProxy.ts const specialInstanceKeyMap = { $el: (instance) => instance.vNode.el, }; export const publicInstanceProxy = { get(target, key, receiver) { // 如果 setup 导出的对象上有就返回 if (Reflect.has(target.instance.setupResult, key)) return Reflect.get(target.instance.setupResult, key); // 从组件属性上导出属性 if (key in specialInstanceKeyMap) return specialInstanceKeyMap[key](target.instance); return target.instance[key]; }, };
- 实现
$el
有很多组件实例, 我们暂时只实现 $el
. 挂载点应该是 vNode
的属性, 所以我们将挂载点记录在 vNode
上
// @packages/runtime-core/src/vnode.ts
export function createVNode(component, props = {}, children = []) {
return {
// ...
el: null,
};
}
el
作为组件实例在组件挂载后在 vNode 上更新即可// @packages/runtime-core/src/publicInstanceProxy.ts
export function setupRenderEffect(instance, container) {
// ...
instance.vNode.el = container;
}
实现 shapeFlags
可以将组件类型判断抽出为一个变量, 通过位运算判断组件类型. 我们目前需要判断的有:
- 是否是
Element
- 是否是有
setup
的组件(也叫 stateful component) - 子节点是 string 还是数组
实现
- 修改
vNode
定义export function createVNode(component, props = {}, children = []) { return { shapeFlags: getShapeFlags(component, children), // ... }; }
- 判断函数
import { isObject } from '../../share/index'; export const enum ShapeFlags { ELEMENT = 1 << 0, STATEFUL_COMPONENT = 1 << 1, TEXT_CHILDREN = 1 << 2, ARRAY_CHILDREN = 1 << 3, } export function getShapeFlags(type, children) { let res = 0; // 注意, 这俩不是互斥的... if (!isObject(type)) res |= ShapeFlags.ELEMENT; else if (type.setup) res |= ShapeFlags.STATEFUL_COMPONENT; if (isObject(children)) res |= ShapeFlags.ARRAY_CHILDREN; else res |= ShapeFlags.TEXT_CHILDREN; return res; }
- 同步判断
function setupStatefulComponent(instance) { if (instance.vNode.shapeFlags & ShapeFlags.STATEFUL_COMPONENT) // ... } function mountElement(vNode, container) { const el = document.createElement(vNode.type) as HTMLElement; Object.keys(vNode.props).forEach((k) => el.setAttribute(k, vNode.props[k])); if (vNode.shapeFlags & ShapeFlags.ARRAY_CHILDREN) { // ... } // ... } export function patch(vNode1, vNode2, container) { if (vNode2.shapeFlags & ShapeFlags.ELEMENT) processElement(vNode1, vNode2, container); // ... }
实现事件注册
我们可以为 Element 传入 attribute, 但是无法传入绑定事件, 例如传入 { onClick: ()=>{} }
在渲染到 DOM 时可以发现渲染结果为
<div onclick="()=>{}"></div>
onClick
的小驼峰命名没了- value 应该是一个函数调用, 而这里只写了一个函数, 这样点击时候并不会执行函数只会右查询一下这个函数
所以我们要手动实现这样的功能: 在挂载 Element 时, 若传入的是事件, 手动绑定这个事件
function mountElement(vNode, container) {
const el = document.createElement(vNode.type) as HTMLElement;
Object.keys(vNode.props).forEach((k) => {
// 通过正则判断是否为事件绑定
if (/^on[A-Z]/.test(k))
el.addEventListener(
k.replace(/^on([A-Z].*)/, (_, e) => e[0].toLowerCase() + e.slice(1)),
vNode.props[k]
);
else el.setAttribute(k, vNode.props[k]);
});
// ...
}
实现 props
需求:
- 将 props 输入
setup
, 使得可以在setup
中通过props.属性名
调用, 同时props
为 shadowReadonly - 在
render
可以通过this.属性名
调用
实现:
在 setup 时构造
export function setupComponent(instance) { // ... initProps(instance); // ... }
通过第二点我们就知道我们需要将 props 加入 componentPublicProxy
export const publicInstanceProxy = { get(target, key, receiver) { // ... if (key in target.instance.props) return target.instance.props[key]; // ... }, };
参考 Vue 的API, 对于第一点需求我们只需要修改
handleSetupResult
的调用, 传入时加入 shadowReadonlyhandleSetupResult( instance, instance.type.setup.call(instance, shadowReadonly(instance.props)}) );
为 setup 传入参数即可
setup(props, { emit }) { props.foo++; // warn: readonly value }
我们为啥不把 shadowReadonly 写入 componentPublicProxy 呢? 这样岂不是可以保护
render
中调用不会修改原值? 没有必要, 我们只需要保证浅层 readOnly, 而 render 是直接拿属性名的, 不会修改 props 上的属性定义.
实现 emits
需求:
通过 props 传入一堆 onXxxXxx
函数在 setup
中可以通过 emit(xxxXxx)
调用函数. 其中emit
通过 setup(props, {emit})
的方式传入.
注意, 这里就是差一个 on
. 你说为啥他妈的你要差个 on
啊, 我写 Vue 的时候也没有差异啊, 这个应该是 vue-loader 为传入的 emit
名加上的 (如: <comp v-on:doSth='xxx'>
, 可能会被 vue-loader 转为 { onDoSth: xxx }
)
那么, 难道 props
上的 onDoSth
不会被注册成事件监听吗? 怎么会, 我们的事件监听是为 Element 绑定的!
实现:
实现 emit 函数
export function emit(instance, event, ...args) { let eventName = event; if (/-([a-z])/.test(eventName)) // 如果是 xxx-xxx 命名法, 将其转换为小驼峰 eventName = eventName.replace(/-([a-z])/, (_, lc) => lc.toUpperCase()); if (/[a-z].*/.test(eventName)) // 如果是小驼峰命名法, 将其转换为大驼峰 eventName = eventName[0].toUpperCase() + eventName.slice(1); eventName = 'on' + eventName; // 加入 on instance.vNode.props[eventName] && instance.vNode.props[eventName](args); }
将函数加入实例对象
$emit
const specialInstanceKeyMap = { $el: (instance) => instance.vNode.el, $emit: (instance) => emit.bind(null, instance), }; export const publicInstanceProxy = {/*...*/};
这里有个比较绕的点, Vue 要求
emit
调用方法为emit(名字, 函数调用参数)
, 我们这边多了一个instance
, 所以我们在定义$emit
时为函数 bind 第一个参数传入
setup
的调用参数handleSetupResult( instance, instance.type.setup.call(instance, shadowReadonly(instance.props), { emit: instance.proxy.$emit, }) );
实现 slot
之前已经梳理过组件的 children 存储的是 slot. Vue 有三种 slot
- 默认 slot: 直接作为子元素写入, 在子组件中会按顺序写入
- 具名 slot: 指定元素插入什么地方
- 作用域 slot: 为具名 slot 传入参数
先考虑组件的 children 应该传入什么样的数据类型 (h(comp, {}, children)
)
如果只支持默认 slot, 我们大可将数组传入 children 并将 render 函数写成下面这样
const HelloWorld = { render() { // 假设 $slot 表示父组件传入的插槽数组, 让子组件在渲染时直接解构上去 return h('div', {}, [h('span', {}, this.foo), ...$slot]); }, }; export default { render() { return h('div', { class: 'title' }, [ h('span', {}, 'APP'), h(HelloWorld, { foo: 'hi' }, [ h('div', {}, 'IM LEFT'), h('div', {}, 'IM RIGHT'), ]), ]); }, };
如果需要支持具名插槽, 我们可以传入数组, 并在每个元素上打上
name
. 但是这样每次放入元素都需要 \(O(n)\) 查找. 可以考虑将传入的children
做成对象, Key 为具名插槽名字, Value 可以是 vNode 数组也可以是 vNode.import { h, ref, renderSlots } from '../../lib/micro-vue.esm.js'; const HelloWorld = { render() { return h('div', {}, [ // 由于 this.$slots[key] 不知道是数组还是对象, 我们用一个函数辅助处理 renderSlots(this.$slots, 'left'), h('span', {}, this.foo), renderSlots(this.$slots, 'right'), h('span', {}, 'OK'), renderSlots(this.$slots), // 调用默认插槽 ]); }, }; export default { render() { return h('div', { class: 'title' }, [ h('span', {}, 'APP'), h(HelloWorld, { foo: 'hi' }, { left: h('div', {}, 'IM LEFT'), // left 插槽 right: h('div', {}, 'IM RIGHT'), // right 插槽 default: [h('div', {}, 'IM D1'),h('div', {}, 'IM D2')] // 默认插槽 }), ]); }, };
这里 Vue 引入了
renderSlots
函数, 我以为其作用就是找到插槽并转换为数组, 就像下面这样function renderSlots(slots, key = 'default') { let rSlots = slots[key] ? slots[key] : []; return isObject(slots[key]) ? [rSlots] : rSlots; }
但是实际上这个函数的返回值是一个 vNode, Vue 会直接将一个或多个 vNode 打包成一个 vNode 返回从而规避数组解构
// @packages/runtime-core/src/componentSlots.ts function renderSlots(slots, name = 'default') { let rSlots = name in slots ? slots[name] : []; rSlots = testAndTransArray(rSlots); return h('div', {}, rSlots); }
不知道为什么要这么做😟
继续考虑作用域插槽, 为了实现作用域变量传递, 我们需要将插槽定义为函数, 并在调用
renderSolts
时传入参数const HelloWorld = { render() { return h('div', {}, [ renderSlots(this.$slots, 'left'), h('span', {}, this.foo), renderSlots(this.$slots, 'right', 'wuhu'), // 作用域 slot 传入参数 h('span', {}, 'OK'), renderSlots(this.$slots, 'default', 'wula'), ]); }, }; export default { render() { return h('div', { class: 'title' }, [ h('span', {}, 'APP'), h( HelloWorld, { foo: 'hi' }, { left: () => h('div', {}, 'IM LEFT'), right: (foo) => h('div', {}, foo), default: (foo) => [h('div', {}, 'IM ' + foo), h('div', {}, 'IM D2')], } ), ]); }, };
只需要在
renderSlots
中判断 value 是对象还是函数并分类讨论即可.// @packages/runtime-core/src/componentSlots.ts function renderSlots(slots, name = 'default', ...args) { let rSlots = name in slots ? slots[name] : []; // 防止给无效 Key // 如果是对象 / 数组就不管, 函数就调用 rSlots = isObject(rSlots) ? rSlots : rSlots(...args); // 尝试转为数组 rSlots = testAndTransArray(rSlots); return h(typeSymbol.FragmentNode, {}, rSlots); } // @packages/share/index.ts export function testAndTransArray(v) { return Array.isArray(v) ? v : [v]; }
至此, 我们实现了插槽的渲染, 再实现一些外围方法
实现
initSlot
// @packages/runtime-core/src/componentSlots.ts export function initSlot(instance) { instance.slots = instance.vNode.children || {}; }
添加
$slot
定义// @packages/runtime-core/src/componentPublicInstance.ts const specialInstanceKeyMap = { $el: (instance) => instance.vNode.el, $emit: (instance) => emit.bind(null, instance), $slots: (instance) => instance.slots, };
实现 FragmentNode
在实现 renderSolts
时我们为将多个 vNode 打包成一个 vNode 采用 h('div', {}, rSlots)
将多个插槽放入了一个 div
下. 然而我们希望在 HTML 中不现实这个多余的 div
, 此时就需要 Fragment
标签, 它相当于 Vue 插槽中的 <template></template>
标签, 永不会被渲染. 其实现的原理就是在 mount 时不挂载父节点, 直接将子节点挂载到 container 上
先用 Symbol 定义标签名
// @packages/runtime-core/src/vnode.ts export const typeSymbol = { FragmentNode: Symbol('FragmentNode'), };
在
patch
时特判Fragment
(因为Fragment
与 component, Element 判断条件不同, 我们没法把他们放入用三个case
中)// @packages/runtime-core/src/render.ts export function patch(vNode1, vNode2, container) { switch (vNode2.type) { case typeSymbol.FragmentNode: processFragmentNode(vNode1, vNode2, container); // 特判 Fragment break; default: if (vNode2.shapeFlags & ShapeFlags.ELEMENT) processElement(vNode1, vNode2, container); else processComponent(vNode1, vNode2, container); } } function processFragmentNode(vNode1, vNode2, container) { if (vNode1) return; return mountFragmentNode(vNode2, container); } function mountFragmentNode(vNode, container) { // 不挂载父节点直接将子节点挂载到 container 上 vNode.children.forEach((d) => patch(null, d, container)); }
修改
renderSlots
// @packages/runtime-core/src/componentSlots.ts export function renderSlots(slots, name = 'default', ...args) { let rSlots = name in slots ? slots[name](...args) : []; rSlots = testAndTransArray(rSlots); return h(typeSymbol.FragmentNode, {}, rSlots); // ^ 小改一下 }
实现 TextNode
我们还希望在 HTML 中不使用 span
就写入文字, 就像
<div>
<span>我想写 span 就写 span</span>
想直接写就直接写
</div>
除非使用 FragmentNode
我们无法不渲染一段内容的标签, 但是 FragmentNode
标签的 children
也必须是 vNode, 所以我们还需要定义一个特殊标签, 它本身会渲染为 TextNode
定义类型
// @packages/runtime-core/src/vnode.ts export const typeSymbol = { FragmentNode: Symbol('FragmentNode'), TextNode: Symbol('TextNode'), };
特判类型
// @packages/runtime-core/src/render.ts export function patch(vNode1, vNode2, container) { switch (vNode2.type) { case typeSymbol.FragmentNode: processFragmentNode(vNode1, vNode2, container); break; case typeSymbol.TextNode: // 特判 TextNode processTextNode(vNode1, vNode2, container); break; default: if (vNode2.shapeFlags & ShapeFlags.ELEMENT) processElement(vNode1, vNode2, container); else processComponent(vNode1, vNode2, container); } } function processTextNode(vNode1, vNode2, container) { if (vNode1) return; return mountTextNode(vNode2, container); } function mountTextNode(vNode, container) { const elem = document.createTextNode(vNode.children); // 通过 createTextNode 创建 container.appendChild(elem); }
向外部暴露接口
// @packages/runtime-core/src/vnode.ts export function createTextVNode(text) { return createVNode(typeSymbol.TextNode, {}, text); }
使用
render() { return h('div', { class: 'title' }, [ createTextVNode('im text'), ]); },
实现工具函数 getCurrentInstance
该函数用于在 setup 中获取当前 setup 所在的 instance. 只须在全局变量上打个标记就可以实现
// @packages/runtime-core/src/component.ts
let currentInstance = undefined;
function setupStatefulComponent(instance) {
if (instance.vNode.shapeFlags & ShapeFlags.STATEFUL_COMPONENT) {
currentInstance = instance; // 打个标记再执行
handleSetupResult(
instance,
instance.type.setup(shadowReadonly(instance.props), {
emit: instance.proxy.$emit,
})
);
currentInstance = undefined; // 删除标记
}
finishComponentSetup(instance);
}
export function getCurrentInstance() {
return currentInstance;
}
实现 provide-inject
provide-inject
机制允许组件在 setup
函数中调用 provide(key, value)
设置一个变量. 在若干级儿组件中通过 inject(key)
获取值的信息传递机制. 同时该机制遵守类似内外层作用域同名时的内层变量保护机制, 例如有如下 provide 关系
graph TB A:provide-a=1 --> B:provide-a=2 --> C:inject-a=2 A:provide-a=1 --> D:provide-b=1 A:provide-a=1 --> E:inject-a=1 --> F:inject-b=undefined
也就是说当组件在父组件上无法 inject 属性时会向更上层 inject 属性.
我们可以在组件实例上定义组件的 provide 与该组件的父组件然后实现递归查找的 inject 函数. 但是 JavaScript 的原型链本身就支持递归查找, 我们可以指定通过指定某个组件 provide 的 __proto__
为父组件的 provide 实现.
在 instance 上加入 provide 属性记录该组件所 provide 的所有键值对并设置
provide.__proto__ = parent.provide
export function createComponent(vNode, parent) { return { // ... parent, // 父组件 provides: parent ? Object.create(parent.provides) : {}, // 组件的 provide }; }
我们要求在
createComponent
的时候提供parent
属性, 那么我们也要在调用该函数时加入该参数, 该函数依赖关系如下graph TB render -.-> patch --> processComponent --> mountComponent --> createComponent((createComponent)) -.-> renderEffect -.-> patch patch --> processElement --> mountElement --> patch
为了让
createComponent
有参数我们需要让其前置函数都带 parent 参数, 所有可能调用其前置函数的函数也要带参数. 其中存在两个特殊函数.render
: 该函数用来渲染根节点, 根节点的父节点是null
renderEffect
: 该函数是用来渲染组件instance
的子组件subTree
所以 parent 参数就是instance
实现 API
// @packages/runtime-core/src/apiInject.ts import { getCurrentInstance } from './component'; export function provide(k, v) { const currentInstance = getCurrentInstance() as any; currentInstance.provides[k] = v; } export function inject(key, defaultValue) { const currentInstance = getCurrentInstance() as any; return currentInstance && key in currentInstance.parent.provides ? currentInstance.parent.provides[key] : defaultValue; }
测试外层屏蔽与跨组件传递
// @App.js
const ProviderOne = {
setup() {
provide('foo', 'F1');
provide('bar', 'B1');
return () => h(ProviderTwo);
},
};
const ProviderTwo = {
setup() {
provide('foo', 'F2');
provide('baz', 'Z2');
const i_foo = inject('foo');
const i_bar = inject('bar');
return () =>
h('div', {}, [
h('span', {}, '@ provide 2:'),
h('span', {}, 'foo: ' + i_foo),
h('span', {}, 'bar: ' + i_bar),
h(Consumer),
]);
},
};
const Consumer = {
setup() {
const t = getCurrentInstance();
const i_foo = inject('foo');
const i_bar = inject('bar');
const i_baz = inject('baz');
return () =>
h('div', {}, [
h('span', {}, '@ consumer:'),
h('span', {}, 'foo: ' + i_foo),
h('span', {}, 'bar: ' + i_bar),
h('span', {}, 'baz: ' + i_baz),
]);
},
};
export default {
name: 'App',
setup() {
return () => h('div', {}, [h('p', {}, 'apiInject'), h(ProviderOne)]);
},
};
结果
apiInject
@ provide 2:foo: F1bar: B1
@ consumer:foo: F2bar: B1baz: Z2
小结
到目前为止我们完成了组件挂载部分. 那我们折腾了点什么呢? 我们就是实现了 h
函数的不同功能. 在实现 API 的时候我们也要牢记 API 是为谁实现的.
参数 | 对于组件 | 对于Element |
---|---|---|
type | 包含 render 与 setup 的对象 | 标签名 |
props | 组件实例的 props | 包含属性与事件的对象 |
children | slots | 子元素 / 子组件 |
可以发现, 这个 API 设计的非常对仗工整.
对于
type
: 分别传入对象与标签名, 无话可说对于
props
:- 对于 Element: 传入一堆 attribute. Element 是会被直接渲染的, 我们直接将 Key-Value 写入标签即可. 在实践中我们发现做事件绑定时, 由于 value 是函数名, 我们无法直接将
onXxx
写入标签. 所以需要手动处理事件调用 - 对于组件: 传入一堆 props 与 emits. 难道就没有类似
onClick
的事件监听或者类似style
的属性吗? 没有! 组件本身是不会被渲染的! 不可能向组件标签上绑定什么东西. 组件能传入的只有用于 setup / render 的属性与 emits 事件
- 对于 Element: 传入一堆 attribute. Element 是会被直接渲染的, 我们直接将 Key-Value 写入标签即可. 在实践中我们发现做事件绑定时, 由于 value 是函数名, 我们无法直接将
对于
children
:对于 Element: 传入一堆子元素 / 子组件, 挨个 patch 就行
对于组件: 传入 slots, 将 slots 在添加到元素上
为啥不让组件的子元素也写入 children 呢? 这样看的多工整!
组件的子元素在组件的 render 里面, 不在
children
为啥不让Element的子元素也写入 render 呢?
人家 Element 压根就没对象存子元素的
这尤雨溪懂个锤子 Vue, 设计的 API 咋还要分类讨论啊!
实际上这个 API 设计的很有趣, 看看我们在 template 中是怎么书写的(从前面再抄一遍)
对于 Element
<template> <div> <span>111</span> <span>222</span> </div> </template>
h
函数写法h('div', {}, [h('span', {}, '111'), h('span', {}, '222')])
对于组件
<template> <Comp> <span>111</span> <span>222</span> </Comp> </template>
h
函数写法h(Comp, {}, [h('span', {}, '111'), h('span', {}, '222')])
在结构上是对仗的. 这 API 设计的太伟大了
此外我们还实现了特殊的 Fragment
与 TextNode
这俩都是魔改 patch 实现的. 我们还实现了 provide-inject
API, 这里借助原型链实现功能也很有趣
实现 runtime-core 的 update 部分
在实现更新逻辑时我们也要对组件与 Element 分类讨论并谨记当前实现的是组件还是 Element
基本思想
update 事件的触发者是组件. 响应式对象修改后会触发函数, 这个函数一定是组件上的函数, Element 上存不了函数
响应式对象变化后最后应该触发的是依赖组件的
render
函数,render
函数重新执行并生成新的 subTree.我们不能直接将老的 subTree 删除掉替换为新的 subTree, 这样性能损耗太大, 我们希望尽可能对比新老 subTree, 根据两个 subTree 之间的变化刷新 DOM
我们对比的是 vNode, 存在 Element 与组件两种 vNode, 我们要分别对不同类型 vNode 讨论更新方法
如何判断两个 vNode 是 "同类型" 的
这里的 "同类型" 是指两个 vNode 可以通过修改对应 DOM 的子元素, 修改 props 实现更新, 而不需要卸载DOM.
type
不同的 vNode 一定不是同类型的oldVnode = h('div', {}, '呀哈哈') newVnode1 = h('span', {}, '克洛洛') newVnode2 = h(HelloWorld)
三个 vNode 的
type
不同这导致 DOM 标签名不同, 铁定无法不卸载元素更新props.key
不同一定不是同类型的在 Vue 中我们可以指定元素的 key 作为元素的 id, 不同 id 的元素一定是不同型的
综上我们可以通过
vNode1.type === vNode2.type && vNode1.props.key === vNode2.props.key
判断两个 vNode 是不是同类型的如果两个 vNode 是 "同类型" 的如何更新
vNode 是 Element 型的
Element 型 vNode 被实实在在的渲染到了 DOM 树上, 我们希望尽量不卸载挂载 DOM 元素, 而希望在原 Element 上做更新. 我们需要更新 DOM 的属性与 children
更新 props
更新 children
children 可以是字符串也可以是 vNode 数组, 我们需要分类讨论
Text 型到 Text 型:
oldVnode = h('div', {}, '呀哈哈') newVnode = h('div', {}, '克洛洛')
直接更新 textCont
Text 型到 Array 型:
oldVnode = h('div', {}, '呀哈哈') newVnode = h('div', {}, [h('div', {}, '克洛洛'), h('div', {}, '卓')])
删掉 DOM 的 textCont, patch 新 vNode 进去
Array 型到 Text 型:
oldVnode = h('div', {}, [h('div', {}, '克洛洛'), h('div', {}, '卓')]) newVnode = h('div', {}, '呀哈哈')
删掉 DOM 的所有 children, 写入 textContent
Array 型到 Array 型: 最麻烦的, 采用双端对比法, 找到节点发生变化的区间, 删除新 vNode 中不存在的节点, 加入新 vNode 中独有节点, 调整节点顺序
新 vNode 是组件型的
??
如果两个 vNode 不是 "同类型" 的如何更新
如果是这种情况我们就束手无策了
oldVnode1 = h('div', {}, '呀哈哈') newVnode1 = h('span', {}, '克洛洛')
但是这种情况是不存在的. 若两个不同类型 vNode 要求更新, 那么前面一定调用过
patch(vNode1, vNode2, ...)
, 在更新时哪些情况会调用vNode1 !== null
的 patch 呢? 组件更新, 同类型 Element 的 Array to Array. 不同类型的节点会被双端对比算法视为不同节点而被删除 / 增加掉. 所以不可能让不同类型节点patch
在一起.
更新 pipeline
让响应式对象支持依赖收集与触发依赖. 将整个 renderEffect 装入 effect, 这意味着每次响应式对象发生变化都会重复调用 renderEffect. 同时我们要区分是 mount 还是 update, 我们可以让 instance 记录更新前的 subTree 并默认为 null 实现这一功能
// @packages/runtime-core/src/component.ts
export function setupRenderEffect(instance, container) {
effect(() => {
const subTree = instance.render.call(instance.proxy);
patch(instance.subTree, subTree, container, instance);
// ^ 第一次是 null
instance.vNode.el = container;
instance.subTree = subTree; // 记录当前 subTree
});
}
同步修正一下 createComponent
export function createComponent(vNode, parent) {
return {
// ...
subTree: null,
};
}
之后需要实现:
- Element:
- 更新 props
- 更新 children
- 组件
- 更新??
Element 更新 props
给定更新前后的 vNode 与目标 DOM 对象, 实现 props 更新.
实现测试用例
export default { setup() { let cnt = 1; const attrValue = ref(`attrValue${cnt}`); window.test = function() { attrValue.value = `attrValue${++cnt}`; }; return { attrValue, htmlValue }; }, render() { return h('div', {attrValue: this.attrValue}, 'test') }, };
先实现
updateElement
函数由于
vNode2
没有被 mount 所以vNode2.el
不存在, 但是两个 vNode 对应的是同一个 DOM 对象, 我们可以将vNode1.el
直接给到vNode2.el
. 同时我们定义patchProps
用于实现功能function updateElement(vNode1, vNode2, container) { const elem = (vNode2.el = vNode1.el); patchProps(elem, vNode1?.props, vNode2.props); }
实现
patchProps
首先要明确我们需要 patch 什么样的 props.
- 空值当不存在: 如果 props 是
{key: undefined / null}
我们就不 patch 这个 key - value 可能是事件监听: 我们可以借助 mountElement 中的 props 实现
先实现辅助函数判断 key 是否在 props 上. 如果 value 是 null / undefined / NaN 也当 key 不存在
// @packages/share/index.ts export function isUNKey(k, obj) { return k in obj && obj[k] !== null && obj[k] !== undefined && !Number.isNaN(obj[k]) }
实现
patchProps
函数function patchProps(elem: HTMLElement, oldProps = {}, newProps = {}) { // 获取所有 props const props = [ ...new Set([...Object.keys(oldProps), ...Object.keys(newProps)]), ]; props.forEach((k) => { // 假设 key 是事件监听, 尝试将其转化为小驼峰 let ek = /^on[A-Z]/.test(k) ? k.replace(/^on([A-Z].*)/, (_, e) => e[0].toLowerCase() + e.slice(1)) : undefined; // 如果 key 在老 vNode 中存在, 在新 vNode 中不存在: removeAttribute // 如果 key 在老 vNode 中存在, 是事件监听: 解除 防止监听函数变化 // 如果 key 在老 vNode 中存在, 不是事件监听, 在新 vNode 也有: 不管 if (isUNKey(k, oldProps) && (!isUNKey(k, newProps) || ek)) ek ? elem.removeEventListener(ek, oldProps[k]) : elem.removeAttribute(k); // 不管老节点有没有, 新节点有: setAttribute / addEventListener else if (isUNKey(k, newProps)) ek ? elem.addEventListener(ek, newProps[k]) : elem.setAttribute(k, newProps[k]); }); }
- 空值当不存在: 如果 props 是
为什么叫
patchProps
不叫updateProps
?实际上这个函数也可以用于
mountElement
中 props 处理(令oldProps = {}
), 并不是updateElement
专用的. 修改function mountElement(vNode, container, parent) { const el = (vNode.el = document.createElement(vNode.type) as HTMLElement); - Object.keys(vNode.props).forEach((k) => { - if (/^on[A-Z]/.test(k)) - el.addEventListener( - k.replace(/^on([A-Z].*)/, (_, e) => e[0].toLowerCase() + e.slice(1)), - vNode.props[k] - ); - else el.setAttribute(k, vNode.props[k]); - }); + patchProps(el, {}, vNode.props); }
Element 更新 children 前三种情况
Text to Text
const T2T = { setup() { const ot = ref(false); window.test = () => { ot.value = true; }; return { ot }; }, render() { return this.ot ? h('div', {}, 'after') : h('div', {}, 'before'); }, }; export default { setup() {}, render() { return h(T2T); }, };
只需修改 DOM 内部 textContent, 如果内容不变就不修改
function updateChildren(vNode1, vNode2, container, parent) { if (vNode2.shapeFlags & ShapeFlags.TEXT_CHILDREN) { if (vNode1.shapeFlags & ShapeFlags.TEXT_CHILDREN) if (vNode2.children !== vNode1.children) container.textContent = vNode2.children; } }
Array to Text
const A2T = { setup() { const ot = ref(false); window.test = () => { ot.value = true; }; return { ot }; }, render() { return this.ot ? h('div', {}, 'after') : h('div', {}, [h('div', {}, 'before'), h('div', {}, 'before')]); }, };
将 DOM 中所有 Element 都删除, 写入 conteneText
function updateChildren(vNode1, vNode2, container, parent) { if (vNode2.shapeFlags & ShapeFlags.TEXT_CHILDREN) { if (vNode1.shapeFlags & ShapeFlags.ARRAY_CHILDREN) [...container.children].forEach((d) => d.remove()); // 这里合并了下代码, 如果是 Array to Text, 那么 Array !== string 一定成立 if (vNode2.children !== vNode1.children) container.textContent = vNode2.children; } }
Text to Array
const T2A = { setup() { const ot = ref(false); window.test = () => { ot.value = true; }; return { ot }; }, render() { return this.ot ? h('div', {}, [h('div', {}, 'after'), h('div', {}, 'after')]) : h('div', {}, 'before'); }, };
删除内部 textContent 插入 vNode 数组
function updateChildren(vNode1, vNode2, container, parent) { if (vNode2.shapeFlags & ShapeFlags.TEXT_CHILDREN) { if (vNode1.shapeFlags & ShapeFlags.ARRAY_CHILDREN) [...container.children].forEach((d) => d.remove()); if (vNode2.children !== vNode1.children) container.textContent = vNode2.children; } else { if (vNode1.shapeFlags & ShapeFlags.TEXT_CHILDREN) { container.textContent = ''; vNode2.children.forEach((element) => { patch(null, element, container, parent); }); } } }
为 Array to Array 预留函数调用
patchKeyedChildren
function updateChildren(vNode1, vNode2, container, parent) { if (vNode2.shapeFlags & ShapeFlags.TEXT_CHILDREN) { if (vNode1.shapeFlags & ShapeFlags.ARRAY_CHILDREN) [...container.children].forEach((d) => d.remove()); if (vNode2.children !== vNode1.children) container.textContent = vNode2.children; } else { if (vNode1.shapeFlags & ShapeFlags.TEXT_CHILDREN) { container.textContent = ''; vNode2.children.forEach((element) => { patch(null, element, container, parent); }); } else { patchKeyedChildren(vNode1.children, vNode2.children, container, parent); } } }
Element 更新 children 的双端对比法
基本思想
分别从左边右边对比元素, 找到发生变化的区间, 例如前后两个 Array 分别为
[ a b c d e f g h ]
[ a b e d i g h ]
从左边找找到只有 a b
是相同的
[ a b | c d e f g h ]
[ a b | e d i g h ]
从右边找找到只有 g h
是相同的
[ a b | c d e f | g h ]
[ a b | e d i | g h ]
最后我们找到变化区间([c d e f] -> [e d i]
)
将老 vNode 独有的子 vNode 移除([c f]
), 将新 vNode 独有的子 vNode patch上([i]
), 调整子 vNode 的顺序. 可以使用 insertBefore
调整顺序.
删除老 vNode 独有子元素
为新 vNode 变化区间上的元素编号, 建立
key -> index
的映射. 遍历老节点, 如果没有查到 key 就删除节点创建新 vNode 独有的子元素: 在调整位置时一并处理
调整位置
可以通过之前建立的
key -> index
映射一股脑的将 DOM 调整到正常位置, 但是调整 DOM 的代价太高了, 我们希望尽可能少的减少insertBefore
操作.[l1 l2 l3 ( a b c d e f g h i ) r1 r2 r3] [l1 l2 l3 ( i a b c d e f g h ) r1 r2 r3]
如果采用一般方法, 我们需要分别将
a - h
移动到r1
前面. 这明显不如将i
移动到a
前面划算.我们可以在原 vNode 的子 vNode 数组中定义一个稳定串, 保证稳定串中的元素相对位置符合新 vNode 设定, 我们只需要遍历新 vNode 的子元素, 如果该元素没有在老 vNode 中出现就创建并将其 patch 到指定位置, 如果在非稳定串中出现我们就将其
insetBefore
到指定位置. 考虑到我们只有insertBefore
没有insertAfter
函数, 我们还需要保证一个元素在insertBefore
前他后一个的元素已经就位了. 所以我们需要倒着遍历新 vNode.在上面的例子中, 可以将
a b c d e f g h
视作稳定串, 调整i
到a
前即可将 vNode patch 到指定位置
想要将 Element 调整到指定 Element 前面, 我们可以采用
container.insertBefore(elem, target)
, 如果target == null
就将元素调整到 container 尾部.但是如果想将新 vNode 调整到指定 Element 前面就需要调用 patch 了, 我们需要为 patch 加入一个锚点参数指定将 vNode patch 到哪里:
patch(vNode1, vNode2, container, parent = null, anchor = null)
寻找稳定串
寻找稳定串其实就是寻找相对位置正确的尽可能长的子串, 我们又已知道老节点对应的新节点有一个
key -> index
的映射, 在新节点中, index 严格递增, 所以可以获取每个老节点对应的 index 并查找 LIS. 例如oldIndex: 0 1 2 4 5 6 7 8 9 10 11 3 12 13 14 oldVNode: [l1 l2 l3 ( a b c d e f g h i ) r1 r2 r3] newVNode: [l1 l2 l3 ( i a b c d e f g h ) r1 r2 r3] newIndex: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
可以得到
lis = [a b c d e f g h]
特殊情况
可以针对部分特殊情况特殊处理避免计算 LIS
新 vNode 只在最右边加了一堆元素: 只需要 patch 多出来的部分
old: [a b c] new: [a b c d e f]
新 vNode 只在最左边加了一堆元素: 只需要 patch 多出来的部分
old: [ d e f] new: [a b c d e f]
新 vNode 只最右边少了一堆元素: remove 多余 vNode 的 DOM 元素. 注意这里 remove 的不能是 vNode, 这样不管子 vNode 是 Element 还是组件类型的都可以一键卸载(因为 ELement 或 组件类型的 vNode 对应的 DOM 树都一定只有一个根 Element)
old: [a b c d e f] new: [a b c]
新 vNode 只最左边少了一堆元素: remove 多余 vNode 的 DOM 元素
old: [a b c d e f] new: [ d e f]
我的部分实现
定义
c1, c2
: 更新前后 vNode 的 children 数组anchor
: 锚点i
: 变化区间的左边界(包括)e1, e2
: 变化区间对应c1, c2
的右边界(包括)
function patchKeyedChildren(c1, c2, container, parent, anchor) {
let i = 0,
e1 = c1.length - 1,
e2 = c2.length - 1;
// 从左往右看, 如果类型不同或者 key 不同就退出, 否则递归更新子节点
for (; i <= Math.min(e1, e2); i += 1) {
if (c1[i].type !== c2[i].type || c1[i].props.key !== c2[i].props.key) break;
else patch(c1[i], c2[i], container, parent, anchor);
}
// 从右往左看, 如果类型不同或者 key 不同就退出, 否则递归更新子节点
for (; e1 >= 0 && e2 >= 0; e1 -= 1, e2 -= 1)
if (c1[e1].type !== c2[e2].type || c1[e1].props.key !== c2[e2].props.key)
break;
else patch(c1[e1], c2[e2], container, parent, anchor);
// 右侧有新节点
if (i === c1.length)
c2.slice(i).forEach((d) =>
patch(null, d, container, parent, i >= c2.length ? null : c2[i].el)
);
// 右侧有老节点
// 传入的是 vNode 要加上 el 找到 DOM 对象
if (i === c2.length) c1.slice(i).forEach((d) => d.el.remove());
// 左侧有新节点
if (e1 === -1 && e2 !== -1)
c2.slice(0, e2 + 1).forEach((d) =>
patch(null, d, container, parent, c1[0].el)
);
// 左侧有老节点
if (e2 === -1 && e1 !== -1) c1.slice(0, e1).forEach((d) => d.el.remove());
// 中间
if (i <= Math.min(e1, e2)) {
const newRange = c2.slice(i, e2 + 1);
const oldRange = c1.slice(i, e1 + 1);
const k2iNew = new Map(newRange.map((d, idx) => [d.props.key, i + idx]));
const k2iOld = new Set(oldRange.map((d) => d.props.key));
oldRange.forEach((d) => {
// 新的有, 老的有 直接更新
if (k2iNew.has(d.props.key)) {
patch(
d,
c2[k2iNew.get(d.props.key) as number],
container,
parent,
anchor
);
// 新的没有, 老的有 直接删除
} else if (!k2iNew.has(d.props.key)) {
d.el.remove();
k2iOld.delete(d.props.key);
}
});
// 新的有老的没有 新建到问题区间的尾部
newRange.forEach((d) => {
if (!k2iOld.has(d.props.key)) {
patch(
null,
c2[k2iNew.get(d.props.key) as number],
container,
parent,
e2 + 1 < c2.length ? c2[e2 + 1].el : null
);
k2iOld.add(d.props.key);
}
});
// ... 调整位置
}
}
存在的问题
在中间对比时: 新的有老的没有的情况可以合并到调整位置上
为只用四个 if 考虑了特殊情况, 没有考虑特殊情况的的扩展
old = [a b c d e f] new = [a b e f]
既然从左往右看
[a b]
一样, 我们可以假装消掉[a b]
把[c d]
看成只有左侧有多于元素的情况此时直接消除[c d]
即可, 类似的还有old = [a b e f] new = [a b c d e f]
如果可以特判这种情况就爽死了
别个的实现
mini-vue 的实现和原版的差不多
function patchKeyedChildren( c1, c2, container, parentAnchor, parentComponent) {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1;
let e2 = l2 - 1;
const isSameVNodeType = (n1, n2) => {
return n1.type === n2.type && n1.key === n2.key;
};
// ... 求 i e1 e2
// 人家在这里是比较了 e1 e2 i 的关系, 这样变相的 "消除" 掉了前后置元素
if (i > e1 && i <= e2) {
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor;
while (i <= e2) {
patch(null, c2[i], container, anchor, parentComponent);
i++;
}
} else if (i > e2 && i <= e1) {
while (i <= e1) {
hostRemove(c1[i].el);
i++;
}
} else {
// ... 中间对比
}
}
这个明显不如四个 if 来的直观, 但是顺道处理了特殊情况的扩展情况. 有一说一 vuejs/core 在这一段中代码的注释中也没有提起这种情况, 但是通过这个泛泛的判断条件我们确实捕获到了这种情况. 看起来这是一个无意为之的优化?
最终实现
实现 diff
// @packages/runtime-core/src/render.ts function patchKeyedChildren(c1: any[], c2: any[], container, parent, anchor) { let i = 0, e1 = c1.length - 1, e2 = c2.length - 1; const isSameType = (v1, v2) => v1.type === v2.type && v1.props.key === v2.props.key; // 找到区间 for (; i <= Math.min(e1, e2); i += 1) if (!isSameType(c1[i], c2[i])) break; else patch(c1[i], c2[i], container, parent, anchor); for (; e1 >= 0 && e2 >= 0; e1 -= 1, e2 -= 1) if (!isSameType(c1[e1], c2[e2])) break; else patch(c1[e1], c2[e2], container, parent, anchor); // 特判 if (e2 < i && i <= e1) c1.slice(i, e1 + 1).forEach((d) => d.el.remove()); else if (e1 < i && i <= e2) c2.slice(i, e2 + 1).forEach((d) => patch( null, d, container, parent, e1 + 1 >= c1.length ? null : c1[e1 + 1].el ) ); // 中间 else if (i <= Math.min(e1, e2)) { const newRange = c2.slice(i, e2 + 1); const oldRange = c1.slice(i, e1 + 1); const new2oldIndex = new Map(); // 新节点 index -> 老节点 index const key2indexNew = new Map( // 新节点 key -> 新节点 index newRange.map((d, idx) => [d.props.key, i + idx]) ); // 如果老节点在新节点中存在 构造 新节点 index -> 老节点 index // 不存在 删除老节点 oldRange.forEach((vnode, idx) => { if (key2indexNew.has(vnode.props.key)) { new2oldIndex.set(key2indexNew.get(vnode.props.key), idx); } else vnode.el.remove(); }); const lis = LIS([...new2oldIndex.keys()]); // 构建稳定序列 newRange.reduceRight( (prev, cur, curIndex) => { const oldVnode = oldRange[new2oldIndex.get(curIndex + i)]; // 对应老节点(如果存在) if (lis.includes(curIndex + i)) // 处于稳定序列就只更新 return patch(oldVnode, cur, container, parent, prev?.el); if (new2oldIndex.has(curIndex + i)) { // 不在就移动节点 container.insertBefore(oldVnode.el, prev?.el); patch(oldVnode, cur, container, parent, prev?.el); } else patch(null, cur, container, parent, prev?.el); // 没有老节点就加入 return cur; }, e2 + 1 >= c2.length ? null : c2[e2 + 1] ); } }
实现 LIS
定义
low
数组,low[i]
表示长度为i
的LIS结尾元素的最小值. 对于一个上升子序列,显然其结尾元素越小, 越有利于在后面接其他的元素, 也就越可能变得更长. 因此, 我们只需要维护low
数组,对于每一个s[i]
,如果s[i] > low[当前最长的LIS长度]
,就把a[i]
接到当前最长的 LIS 后面,即low[++当前最长的LIS长度] = a[i]
对于每一个s[i]
,如果s[i]
能接到 LIS 后面,就接上去. 否则,就用s[i]
取更新low
数组。具体方法是, 在low
数组中找到第一个大于等于s[i]
的元素low[j]
, 用s[i]
去更新low[j]
. 如果从头到尾扫一遍low
数组的话,时间复杂度仍是 \(O(n^2)\). 我们注意到low
数组内部一定是单调不降的. 所有我们可以二分low
数组,找出第一个大于等于s[i]
的元素. 总时间复杂度为 \(O(n \log n)\)// @packages/share/index.ts export function LIS(s) { const low = [...s]; const res = [0]; const len = s.length; for (let i = 0, j; i < len; i++) { const cur = s[i]; if (cur !== 0) { j = res[res.length - 1]; if (s[j] < cur) { low[i] = j; res.push(i); continue; } let u = 0; let v = res.length - 1; while (u < v) { const c = (u + v) >> 1; if (s[res[c]] < cur) u = c + 1; else v = c; } if (cur < s[res[u]]) { if (u > 0) low[i] = res[u - 1]; res[u] = i; } } } let u = res.length; let v = res[u - 1]; while (u-- > 0) { res[u] = v; v = low[v]; } return res; }
为什么要用双端对比法
- 双端对比法的理论性能可能并不是最优秀的, 但是其用于前端 vNode list 的对比很优秀, 因为前端 DOM 的修改很少涉及全局修改, 一般都是一两个元素的增减调换, 双端对比法可以快速锁定修改区间, 忽略不变部分, LIS 可以保证在较少插入次数下实现位置调整
- 为什么要讨论特殊情况, 明明可以直接利用最后的通用算法求解. 首先这个特判会让单次 update 快很多, 同时考虑前端应用场景, update 单个头尾 / 中部元素比较频繁, 这个特判会被特别多次调用
组件更新
组件 vNode 更新时 setupRenderEffect
会触发 patch(组件vNode, ...)
最后 updateComponent
不管组件有多复杂我们更新的都是组件挂载的 DOM, 组件的 render 返回的是一个 h
也就是说组件最多有一个根 DOM, 我们可以直接更新这个 DOM.
我们更新的时候拿到的是 vNode, 但是为组件传入的 props 还在 instance 上, 我们需要为 vNode 绑定 instance (使用 .component
属性)
// @packages/runtime-core/src/render.ts
function updateComponent(vNode1, vNode2, container, parent, anchor) {
vNode2.el = vNode1.el; // 绑定 DOM
vNode2.component = vNode1.component; // 绑定 instance
// 判断 props 一不一样: 一样就不更新 (说明是父节点触发了, 递归到子节点)
// 不一样就重新渲染这个组件下的 vNode
if (isSameProps(vNode1.props, vNode2.props)) {
vNode1.component.vNode = vNode2;
} else {
// 我们要手动触发这个唯一的子 vNode 的render, 所以还需要保存每个 vNode 的 render 函数
// 保存在 `.runner`
// 同时记录 .next 为新 vNode
vNode1.component.next = vNode2;
// 调用老 vNode 的渲染函数
vNode1.component?.runner && vNode1.component.runner();
}
}
判断组件是否有必要更新(props
一不一样)
// @packages/runtime-core/src/component.ts
export function isSameProps(props1 = {}, props2 = {}) {
let res = true;
const props = [...new Set([...Object.keys(props1), ...Object.keys(props2)])];
props.forEach((k) => props1[k] !== props2[k] && (res = false));
return res;
}
在挂载组件时同步记录 instance
// @packages/runtime-core/src/render.ts
function mountComponent(vNode, container, parent, anchor) {
const instance = createComponent(vNode, parent);
+ vNode.component = instance;
setupComponent(instance);
setupRenderEffect(instance, container, anchor);
}
在重新渲染组件时迁移 props
// @packages/runtime-core/src/component.ts
export function setupRenderEffect(instance, container, anchor) {
+ instance.runner = effect(() => {
const subTree = instance.render.call(instance.proxy);
+ if (instance.next) {
+ instance.vNode = instance.next;
+ instance.props = instance.next.props;
+ instance.next = null;
+ }
patch(instance.subTree, subTree, container, instance, anchor);
instance.vNode.el = container;
instance.subTree = subTree;
});
}
实现 nextTrick
我们希望当 Reactivity 发生变化时组件与 DOM 是同步更新的, 这可能会带来不必要的资源消耗, 我们希望组件更新可以变成异步的
export default {
setup() {
let cnt = ref(1);
window.test = () => {
for (; cnt.value < 100; cnt.value++); // 这里会触发100次组件更新
};
return { cnt };
},
render() {
return h('div', {}, 'HTML Context:' + this.cnt);
},
};
如何完成异步更新呢? 我们可以将更新任务放入微任务这样只有在同步代码执行完成后微任务才会执行. 这也是 Vue 中 nextTrick
的实现原理
export function nextTick(e) {
return Promise.resolve().then(e);
}
更新实际上就是执行 renderEffect 的中 effect 的 runner. 可以利用 effect-scheduler 实现首次触发执行 runner 之后触发执行 scheduler. 在 scheduler 中我们可以将更新事件加入队列. 并注册更新队列的微事件
const jobs: Set<any> = new Set(); // 任务队列
function insertJob(instance) {
jobs.add(instance); // 不重复添加
if (jobs.size <= 1) // 确保之注册一个微任务, 防止创建不必要的 Promise.resolve()
nextTick(() => [...jobs].forEach((d) => jobs.delete(d) && d()));
}
export function setupRenderEffect(instance, container, anchor) {
instance.runner = effect(
() => componentUpdateFn(instance, container, anchor), // 将更新内容提出为函数
{
scheduler: () => {
insertJob(instance.runner);
},
}
);
// ...
}
当 Reactivity 发生变化时, 同步执行 insertJob 同步 add Set, 注册一个微任务用于在同步代码都执行完成后执行所有刷新函数
测试
setup() {
let cnt = ref(1);
const instance = getCurrentInstance();
window.test = () => {
for (; cnt.value < 100; cnt.value++);
console.log('1', instance.vNode.el.innerHTML); // 此时还没修改 还是 1
nextTick(() => {
console.log('2', instance.vNode.el.innerHTML); // 此时变为 100
});
};
return { cnt };
},
这也告诉我们, 如果在 Vue 中触发了组件变化, 如果还需要同时获取组件的状态应该使用 nextTrick
实现 runtime-dom
我们的 Vue 默认是渲染在 HTML 上面的, 如果我们向将组件渲染在 canvas 上(DOM 标签变为 canvas 上的一个元素)就需要重写所有 DOM API 相关的函数调用.
我们希望将这些 API 抽象出来 (例如 document.createElement
抽象为 create
函数). 当我们需要将 Vue 组件渲染在 HTML 时只需要为 runtime-core 传入 create
函数即可.
至此我们的组件依赖关系由 vue > runtime-core > reactivity
变为 vue > runtime-dom > runtime-core > reactivity
, runtime-dom 就是为 Vue 提供 HTML 渲染能力的组件
实现 runtime-dom API (将 runtime-core 中调用 DOM API 的地方全写出来)
import { createRenderer } from '../../runtime-core/src/render';
import { isUNKey } from '../../share';
export const createElement = (v) => document.createElement(v);
export const createText = (v) => document.createTextNode(v);
export const remove = (el) => el.parent && el.parent.remove(el);
export const insert = (el, parent, anchor) => parent.insertBefore(el, anchor);
export const setText = (el, text) => (el.nodeValue = text);
export const setElementText = (el, text) => (el.textContent = text);
export function patchProps(elem: HTMLElement, oldProps = {}, newProps = {}) {
const props = [
...new Set([...Object.keys(oldProps), ...Object.keys(newProps)]),
];
props.forEach((k) => {
let ek = /^on[A-Z]/.test(k)
? k.replace(/^on([A-Z].*)/, (_, e) => e[0].toLowerCase() + e.slice(1))
: undefined;
if (isUNKey(k, oldProps) && (!isUNKey(k, newProps) || ek))
ek ? elem.removeEventListener(ek, oldProps[k]) : elem.removeAttribute(k);
else if (isUNKey(k, newProps))
ek
? elem.addEventListener(ek, newProps[k])
: elem.setAttribute(k, newProps[k]);
});
}
我们还需要将 runtime-core 接入 runtime-dom, 因为我们要为 runtime-core 传入刚刚定义的渲染函数, 调用这些渲染函数的文件只有 render.ts
. 之前 render.ts
是通过导出函数向外暴露 API 的, 但是因为我们也要传入函数, 我们只能将 render.ts
外部包裹一个函数, 让该函数传入 runtime-dom 定义的渲染函数最后返回原本需要导出的函数
let renderer;
// 创建可用 DOM API 的 render
function ensureRenderer() {
return (
renderer || // 如果创建过了就不重复创建
(renderer = createRenderer({
createElement,
createText,
setText,
setElementText,
patchProps,
insert,
remove,
}))
);
}
// 创建可用 DOM API 的 createApp
export const createApp = (...args) => {
return ensureRenderer().createApp(...args);
};
export * from '../../runtime-core/src';
修改 render.ts
的构造方式
export function createRenderer({
createElement,
createText,
remove,
insert,
setText,
setElementText,
patchProps,
}) {
function render(vNode, container) {
// ... 将调用 DOM API 的地方改为调用传入的渲染函数
// ... 将原本所有 export 的函数改为 return {该函数}
return {
render,
createApp: createApp.bind(null, render),
};
}
createApp
函数需要 render, 但是我们的 render 也是动态构建的, 所以我们只能为 createApp 传入 render, 并在 render.ts
中先传入这一参数
export function createApp(render, rootComponent) {
return {
_component: rootComponent,
mount(container) {
const vNode = createVNode(rootComponent);
render(
vNode,
isObject(container)
? container
: document.querySelector(container)
);
},
};
}
最后修改导出
export * from './reactivity/src/index';
- export * from './runtime-core/src/index';
+ export * from './runtime-dom/src/index';
测试
实现一个 runtime-PIXI
, PIXI.js 是一个基于 canvas 的游戏库, 完成了对 canvas 的封装. 我们希望通过对 PIXI.js API 的二次封装实现利用 Vue 操作 canvas
我们希望实现执行 test
修改正方形位置
export default {
setup() {
const x = ref(0);
const y = ref(0);
window.test = (x1, y1) => {
x.value = x1;
y.value = y1;
};
return { x, y };
},
render() {
return h('div', { x: this.x, y: this.y });
},
};
为了将测试代码写在一起, 我们将 runtime-dom 完全引入了测试用例并重写 runtime-dom. 这样做可以免去将重新编译 Vue
// modify to export createRenderer (cause the export level is different from vue runtime-dom)
import { createRenderer } from '../../lib/micro-vue.esm.js';
export * from '../../lib/micro-vue.esm.js';
// 创建元素
export const createElement = (v) => {
const rect = new PIXI.Graphics();
rect.beginFill(0xff0000);
rect.drawRect(0, 0, 100, 100);
rect.endFill();
return rect;
};
// 插入
export const insert = (el, parent, anchor) => parent.addChild(el);
// 修改属性
export function patchProps(elem, oldProps = {}, newProps = {}) {
const props = [
...new Set([...Object.keys(oldProps), ...Object.keys(newProps)]),
];
props.forEach((k) => {
elem[k] = newProps[k];
});
}
let renderer;
// 与测试无关的 API 直接给 NULL
function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer({
createElement,
createText: null,
setText: null,
setElementText: null,
patchProps,
insert,
remove: null,
}))
);
}
export const createApp = (...args) => {
return ensureRenderer().createApp(...args);
};
// 创建 game
const game = new PIXI.Application({ width: 640, height: 360 });
// 挂载 canvas
document.body.append(game.view);
// 在 PIXI 中 canvas DOM 不用于挂载元素, 新元素是挂载到 game.stage 上的
export const el = game.stage;
实现 compiler-core
graph LR string(输入string) --> parse(parse模块) --> ast1(输出AST树) --> transform(transform模块对树CRUD) --> ast2(输出AST树) --> CodeGen(CodeGen模块) --> render(输出render)
构建相关模块
@packages/compiler-core
├── src
│ └── index.ts
└── __tests__
并导出模块
export * from './reactivity/src/index';
export * from './runtime-dom/src/index';
export * from './compiler-core/src/index';
实现 parse 模块的插值解析
需求
我们希望可以解析 {{message}}
为 AST 树, 插值语法的 AST 为
{
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'message',
},
}
实现
- 定义枚举
@packages/compiler-core/src/ast.ts export const enum NodeTypes { INTERPOLATION, SIMPLE_EXPRESSION, }
- 实现编译
@packages/compiler-core/src/parse.ts import { NodeTypes } from './ast'; // 构造上下文, 之后源码都从 source 里面取 function createParserContext(content) { return { source: content, }; } // 构造 Root 节点, 其只包含子节点属性 function createRoot(children) { return { children, }; } // 如果代码中包含 '{{' 就执行解析 function parseChildren(context) { const nodes = [] as any[]; let node = null as any; if (context.source.startsWith('{{')) node = parseInterpolation(context); node && nodes.push(node); return nodes; } // 找到一对最近的 {{ }}, 提取插值, 删除这个插值代码 function parseInterpolation(context) { const closeDelimiter = '}}'; const openDelimiter = '{{'; const closeIndex = context.source.indexOf( closeDelimiter, openDelimiter.length ); adviceBy(context, openDelimiter.length); const content = context.source .slice(0, closeIndex - openDelimiter.length) .trim(); adviceBy(context, closeIndex); return { type: NodeTypes.INTERPOLATION, content: { type: NodeTypes.SIMPLE_EXPRESSION, content, }, }; } // 推进操作(删除已解析代码) function adviceBy(context, length) { context.source = context.source.slice(length); } // 解析器入口 export function baseParse(content: string) { const context = createParserContext(content); return createRoot(parseChildren(context)); }
实现 parse 模块的 Element 解析
需求
识别 <xx></xx>
的代码并解析为 AST 树, 其结构为
{
type: NodeTypes.ELEMENT,
tag: 'div',
children: [],
}
实现
- 定义枚举
export const enum NodeTypes { INTERPOLATION, SIMPLE_EXPRESSION, ELEMENT, }
- 实现解析函数
// @packages/compiler-core/src/parse.ts // 识别条件 <字母模式, 这个定义看起来很宽松, 但是却是标准定义 if (/^<[a-zA-Z]/.test(context.source)) node = parseElement(context); function parseElement(context) { const tagMatch = context.source.match(/^<([a-zA-Z]*)>.*<\/\1>/); const tag = tagMatch[1]; adviceBy(context, tagMatch[0].length); return { type: NodeTypes.ELEMENT, tag, }; }
实现 parse 模块的 Text 解析
需求
将不满足两种规范的代码识别为 Text 并解析为 AST 树, 其结构为
{
type: NodeTypes.TEXT,
content: 'bulabula',
}
实现
- 定义枚举: 略
- 返回 AST
function parseText(context) { const content = context.source; adviceBy(context, content.length); return { type: NodeTypes.TEXT, content, }; }
同时解析三种类型
需求
将 <div>hi, {{message}}</div>
解析为
{
type: NodeTypes.ELEMENT,
tag: 'div',
children: [
{
type: NodeTypes.TEXT,
content: 'hi, ',
},
{
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'message',
},
},
],
}
实现
修正 Element 使之可以解析 Element 标签内部文本
function parseElement(context) { - const tagMatch = context.source.match(/^<([a-zA-Z]*)>.*<\/\1>/); + const tagMatch = context.source.match(/^<([a-zA-Z]*)>(.*)<\/\1>/); const tag = tagMatch[1]; adviceBy(context, tagMatch[0].length); return { type: NodeTypes.ELEMENT, tag, + children: parseChildren(createParserContext(tagMatch[2])), }; }
修正 Text 解析使之可以在遇到插值 / Element 前导时停止解析
function parseText(context) { + let content = context.source; + if (~content.indexOf('{{')) { + content = content.slice(0, content.indexOf('{{') ); + } else if (/<\/?[a-zA-Z].+/.test(content)) { + content = content.slice( + 0, + content.length - content.match(/<\/?[a-zA-Z].+/).length + ); + } adviceBy(context, content.length); return { type: NodeTypes.TEXT, content, }; }
在一次解析并推进完成后继续解析剩余代码
function parseChildren(context) { const nodes = [] as any[]; let node = null as any; + while (context.source) { if (context.source.startsWith('{{')) node = parseInterpolation(context); else if (/^<[a-zA-Z]/.test(context.source)) node = parseElement(context); else node = parseText(context); nodes.push(node); + } return nodes; }
实现 Transform 模块
希望为 transform 传入一个函数组, 对每个节点执行这些函数. 我们只需要做一个 DFS 即可
- DFS
import { NodeTypes } from './ast'; import { TO_DISPLAY_STRING } from './runtimeHelpers'; // 创建上下文并遍历 export function transform(root, options = {}) { const context = createTransformContext(root, options); traverseNode(root, context); root.helpers.push(...context.helpers.keys()); } // 创建上下文 function createTransformContext(root: any, options: any): any { const context = { root, nodeTransforms: options.nodeTransforms || [], helpers: new Map(), helper(key) { context.helpers.set(key, 1); }, }; return context; } // 遍历节点, 如果是插值节点就调用 helper, ROOT 与 Element 就遍历子节点 function traverseNode(node: any, context) { const exitFns: any = []; for (let i of context.nodeTransforms) { const onExit = i(node, context); onExit && exitFns.push(onExit); } switch (node.type) { case NodeTypes.INTERPOLATION: context.helper(TO_DISPLAY_STRING); break; case NodeTypes.ROOT: case NodeTypes.ELEMENT: traverseChildren(node, context); break; default: break; } let i = exitFns.length; while (i--) exitFns[i](); } // 遍历子节点 function traverseChildren(node: any, context: any) { for (let child of node.children) traverseNode(child, context); }
- 定义枚举
export const TO_DISPLAY_STRING = Symbol(`toDisplayString`); export const CREATE_ELEMENT_VNODE = Symbol("createElementVNode"); export const helperNameMap = { [TO_DISPLAY_STRING]: "toDisplayString", [CREATE_ELEMENT_VNODE]: "createElementVNode" };
- 为 ROOT 打上 tag
function createRoot(children) { return { + type: NodeTypes.ROOT, children, }; }
实现 compile 模块
需要将 AST 转换为代码字符串. 可以将 AST 分为四类
- Text
- 插值
- Element: children 只有一个元素 (e.g.
<div>as</div>
=>h('div', {}, 'as')
) - Element: children 有多个元素 (e.g.
<div>as {{her}}</div>
=>h('div', {}, 'as' + her)
), 称之为复杂类型
处理最后一种 AST 需要在内容中解析出的每个元素之间用 +
连接
加入枚举类型
export const enum NodeTypes { INTERPOLATION, SIMPLE_EXPRESSION, ELEMENT, TEXT, ROOT, + COMPOUND_EXPRESSION, }
代码生成
import { isString } from '../../share'; import { NodeTypes } from './ast'; import { CREATE_ELEMENT_VNODE, helperNameMap, TO_DISPLAY_STRING, } from './runtimeHelpers'; export function generate(ast) { const context = createCodegenContext(); // 创建上下文 genFunctionPreamble(ast, context); // 创建依赖引入 const functionName = 'render'; // 函数名 const args = ['_ctx', '_cache']; // 函数参数 const signature = args.join(', '); // 函数参数字符串 context.push(`function ${functionName}(${signature}){`); // 连接函数头 context.push('return '); genNode(ast.codegenNode, context); // 生成 AST 对应的内推 context.push('}'); return { code: context.code, }; } function genFunctionPreamble(ast, context) { const VueBinging = 'Vue'; // 引用自的变量 `import {...} from Vue` const aliasHelper = (s) => `${helperNameMap[s]}:_${helperNameMap[s]}`; // 引用代码生成函数 if (ast.helpers.length > 0) // 生成代码 context.push( `const { ${ast.helpers.map(aliasHelper).join(', ')} } = ${VueBinging}` ); context.push('\n'); context.push('return '); } function createCodegenContext(): any { const context = { code: '', push(source) { // 将拼接代码功能写入 context context.code += source; }, helper(key) { return `_${helperNameMap[key]}`; }, }; return context; } function genNode(node: any, context) { // 分类生成不同 AST 对应的代码 switch (node.type) { case NodeTypes.TEXT: genText(node, context); break; case NodeTypes.INTERPOLATION: genInterpolation(node, context); break; case NodeTypes.SIMPLE_EXPRESSION: genExpression(node, context); break; case NodeTypes.ELEMENT: genElement(node, context); break; case NodeTypes.COMPOUND_EXPRESSION: genCompoundExpression(node, context); break; default: break; } } function genCompoundExpression(node: any, context: any) { for (let child of node.children) if (isString(child)) context.push(child); else genNode(child, context); } function genElement(node: any, context: any) { context.push(`${context.helper(CREATE_ELEMENT_VNODE)}(`); genNodeList(genNullable([node.tag, node.props, node.children]), context); context.push(')'); } function genNodeList(nodes, context) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (isString(node)) context.push(node); else genNode(node, context); if (i < nodes.length - 1) context.push(', '); } } function genNullable(args: any) { return args.map((arg) => arg || 'null'); } function genExpression(node: any, context: any) { context.push(`${node.content}`); } function genInterpolation(node: any, context: any) { context.push(`${context.helper(TO_DISPLAY_STRING)}(`); genNode(node.content, context); context.push(')'); } function genText(node: any, context: any) { context.push(`'${node.content}'`); }
创建根节点的入口节点
function createRootCodegen(root: any, context: any) { const { children } = root; const child = children[0]; if (child.type === NodeTypes.ELEMENT && child.codegenNode) { const codegenNode = child.codegenNode; root.codegenNode = codegenNode; } else { root.codegenNode = child; } }
Element 生成函数
import { createVNodeCall, NodeTypes } from "../ast"; export function transformElement(node, context) { if (node.type === NodeTypes.ELEMENT) { return () => { // tag const vnodeTag = `'${node.tag}'`; // props let vnodeProps; // children const children = node.children; let vnodeChildren = children[0]; node.codegenNode = createVNodeCall( context, vnodeTag, vnodeProps, vnodeChildren ); }; } }
生成表达式
import { NodeTypes } from "../ast"; export function transformExpression(node) { if (node.type === NodeTypes.INTERPOLATION) { node.content = processExpression(node.content); } } function processExpression(node: any) { node.content = `_ctx.${node.content}`; return node; }
生成文本节点
import { NodeTypes } from '../ast'; import { isText } from '../utils'; export function transformText(node) { if (node.type === NodeTypes.ELEMENT) { return () => { const { children } = node; let currentContainer; for (let i = 0; i < children.length; i++) { const child = children[i]; if (isText(child)) { for (let j = i + 1; j < children.length; j++) { const next = children[j]; if (isText(next)) { if (!currentContainer) currentContainer = children[i] = { type: NodeTypes.COMPOUND_EXPRESSION, children: [child], }; currentContainer.children.push(' + '); currentContainer.children.push(next); children.splice(j, 1); j--; } else { currentContainer = undefined; break; } } } } }; } }
Text 节点与 string 判断函数
export const isString = (value) => typeof value === 'string'; export function isText(node) { return node.type === NodeTypes.INTERPOLATION || node.type === NodeTypes.TEXT; }
让 runtime-core 调用 compiler-core
runtime-core 会在确定 instance.render 时调用 compiler-core. 如果 setup 不返回函数, 组件没有自带 render 函数, runtime-core 会在有 template 时调用 compiler-core. compiler-core 会返回函数代码, 我们需要根据代码获得 render 函数
测试代码
import { h, ref } from '../../lib/micro-vue.esm.js';
export default {
setup() {
const message = ref('micro-vue');
window.test = () => (message.value = 'hihi');
return { message };
},
template: `<div>hi, {{message}}</div>`,
};
实现
构造 compiler-core 向外暴露的函数
import { generate } from './codegen'; import { baseParse } from './parse'; import { transform } from './transform'; import { transformExpression } from './transforms/transformExpression'; import { transformElement } from './transforms/transformElement'; import { transformText } from './transforms/transformText'; export function baseCompile(template, options = {}) { const ast = baseParse(template); transform( ast, Object.assign(options, { nodeTransforms: [transformElement, transformText, transformExpression], }) ); return generate(ast); }
在 runtime-core 中加入编译 template 功能
function finishComponentSetup(instance) { instance.render = instance.render || instance.type.render || compiler(instance.type.template); }
为 patchProps 加入兜底功能
export function patchProps(elem: HTMLElement, oldProps = {}, newProps = {}) { + oldProps ??= {}; + newProps ??= {}; // ... }
导出 createElementNode
export { createVNode as createElementVNode };
解决循环依赖问题: runtime-core 需要从 Vue 获取编译函数而 Vue 需要引用 runtime-core. 为了解决循环依赖, 可以让 runtime-core 暴露一个 SET 函数, 当文件加载完毕后调用祖册函数为 runtime-core 注册来自 compiler-core 的编译函数.
- 在 runtime-core 中暴露注册函数
let compiler; export function registerRuntimeCompiler(_compiler){ compiler = _compiler; }
- 在最外层引入
export * from './reactivity/src/index'; export * from './runtime-dom/src/index'; import { baseCompile } from './compiler-core/src'; import * as runtimeDom from './runtime-dom/src'; // 将文本转换为函数的函数 export function compileToFunction(template) { // 代码 const { code } = baseCompile(template); // compiler-core 编译的函数形如 // import { createElementNode as _createElementNode } from Vue // return _createElementVNode(...) // 我们希望获取 return 结果, 将这个代码段构造为函数 // function ff(Vue){ // import { createElementNode as _createElementNode } from Vue // return _createElementVNode(...) // } // 只需要传入 Vue 作为参数并获取函数运行结果即可 // 无需 eval, 用 Function 即可构造函数. 并传入 Vue 即可 return new Function('Vue', code)(runtimeDom); } // 将这个编译函数注入 runtime-core, runtime-core 即可拥有编译函数 runtimeDom.registerRuntimeCompiler(compileToFunction);
- 在 runtime-core 中暴露注册函数
编译后的函数需要两个参数, 传入相同值
function componentUpdateFn(instance, container, anchor, patch) { - const subTree = instance.render.call(instance.proxy); + const subTree = instance.render.call(instance.proxy, instance.proxy); if (instance.next) { instance.vNode = instance.next; instance.props = instance.next.props; // ... }