Skip to content

AHABHGK

Let's build a Vue3 runtime

SourceCode, Front End Framework3 min read

Table of Contents

我们会一起写一个简易的 runtime,对于 Vue 如何运行的有一个大致的了解,当然我们实现的会和源码本身有一些不同,会简化很多,主要学习思想

本篇文章并不是为了深入 Vue3 源码,而是对 Vue3 核心 VDOM 和新特性的简单了解,适合作为深入 Vue3 源码的入门文章

👀 Vue3 Entry

我们先看一下 Vue3 的 JSX 组件怎么写,因为我们只是造一个 runtime,所以不会涉及到 Vue 的模版编译,直接用 JSX 就很方便

1import { createApp, ref } from 'vue';
2
3const Displayer = {
4 props: { count: Number },
5 setup(props) {
6 return () => <div>{props.count}</div>;
7 },
8};
9
10const App = {
11 setup(props) {
12 const count = ref(0);
13 const inc = () => count.value++;
14
15 return () => (
16 <div>
17 <Displayer count={count.value} />
18 <button onClick={inc}> + </button>
19 </div>
20 );
21 },
22};
23
24createApp(App).mount('#app');

我们这里直接用的对象形式的组件,一般会使用 defineComponent,它做的只是多了处理传入一个函数的情况,返回一个有 setup 方法的对象,并没有更多其他的处理了,至于为什么设计出一个 defineComponent 方法而不直接写对象,大概是为了在 API 设计层面和 defineAsyncComponent 一致吧

先看入口 createApp,翻翻源码可以看出做的事是根据 rendererOptions 创建 renderer,然后创建 app 对象,最后调用 app.mount 进行渲染,mount 里也是调用的 render

我们写简单一点,去掉 app 的创建,因为创建 app 其实类似于一个作用域,app 的插件和指令等只对该 app 下的组件起作用

runtime-core/renderer.js
1export function createRenderer(renderOptions) {
2
3 return {
4 render(rootVNode, container) {
5
6 },
7 }
8}

通过 createRenderer(nodeOps).render(<App />, document.querySelector('root')) 调用,没错我就是抄 React 的,但是与 React 不同的在于 React 中调用 <App /> 返回的是一个 ReactElement,这里我们直接返回 VNode,ReactElement 其实就是 Partial<Fiber>,React 中是通过 ReactElement 对 Fiber(VNode)进行 diff,我们直接 VNode 对比 VNode 也是可以的(实际上 Vue 和 Preact 都是这么做的)

🌟 VNode Design

接下来我们来设计 VNode,因为 VNode 很大程度上决定了内部 runtime 如何去 diff

runtime-core/vnode.js
1export function h(type, props, ...children) {
2 props = props ?? {}
3
4 const key = props.key ?? null
5 delete props.key
6
7 // 注意 props.children 和 children 的不同
8 // props.children 因为子组件会使用所以是没有处理过的
9 // children 是为了维持内部的 VNode 树结构而创建的,类型是一个 VNode 数组
10 if (children.length === 1) {
11 props.children = children[0]
12 } else if (children.length > 1) {
13 props.children = children
14 }
15
16 return {
17 type,
18 props,
19 key, // key diff 用的
20 node: null, // 宿主环境的元素(dom node……),组件 VNode 为 null
21 instance: null, // 组件实例,只有组件 VNode 会有,其他 VNode 为 null
22 parent: null, // parent VNode
23 children: null, // VNode[],建立内部 VNode 树结构
24 }
25}

Vue3 的 JSX 语法已经跟 React 很像了,除了 props.children 是通过 Slots 实现以外,基本都一样,这里我们并不打算实现 Slots,因为 Slots 实现的 children 也是一种 props,是一段 JSX 而已,并不算特殊,毕竟你随便写个 props 不叫 children 然后传 JSX 也是可以的。Vue 专门弄一个 Slots 是为了兼容它的 template 语法

☄️ patchElement & patchText

runtime-core/renderer.js
1export function createRenderer(renderOptions) {
2 return {
3 render(vnode, container) {
4 if (vnode == null) {
5 if (container.vnode) {
6 unmount(container.vnode)
7 }
8 } else {
9 patch(container.vnode ?? null, vnode, container)
10 }
11 container.vnode = vnode
12 },
13 }
14}

我们补全 render 方法的实现,这里不直接写 patch(null, vnode, container) 的原因是 render 有可能多次调用,并不一定每次调用都是 mount

shared/index.js
1export const isObject = (value) => typeof value === 'object' && value !== null
2export const isString = (value) => typeof value === 'string'
3export const isNumber = (value) => typeof value === 'number'
4export const isText = (v) => isString(v) || isNumber(v)
5export const isArray = Array.isArray
runtime-core/component.js
1import { isObject } from '../shared'
2
3export const TextType = Symbol('TextType')
4export const isTextType = (v) => v === TextType
5
6export const isSetupComponent = (c) => isObject(c) && 'setup' in c
runtime-core/vnode.js
1// ...
2export const isSameVNodeType = (n1, n2) => n1.type === n2.type && n1.key === n2.key
runtime-core/renderer.js
1import { isString, isArray, isText } from '../shared'
2import { TextType, isTextType, isSetupComponent } from './component'
3import { isSameVNodeType, h } from './vnode'
4
5export function createRenderer(renderOptions) {
6 const patch = (n1, n2, container) => {
7 if (n1 && !isSameVNodeType(n1, n2)) {
8 unmount(n1)
9 n1 = null
10 }
11
12 const { type } = n2
13 if (isSetupComponent(type)) {
14 processComponent(n1, n2, container)
15 } else if (isString(type)) {
16 processElement(n1, n2, container)
17 } else if (isTextType(type)) {
18 processText(n1, n2, container)
19 } else {
20 type.patch(/* ... */)
21 }
22 }
23
24 // ...
25}

patch(也就是 diff)在 type 判断最后加一个“后门”,我们可以用它来实现一些深度定制的组件,比如 setupComponent 就可以放到这里实现,或者还可以实现 Hooks(抄 Preact 的,Preact Compat 很多实现都是拿到组件实例 this 去 hack this 上的一些方法,或者再拿内部的一些方法去处理,比如 diff、diffChildren……),这里我们甚至可以实现一套 Preact Component……

diff 最主要的就是对于 Element 和 Text 的 diff,对应元素节点和文本节点,所以我们先实现这两个方法

runtime-core/renderer.js
1const processText = (n1, n2, container) => {
2 if (n1 == null) {
3 const node = n2.node = document.createTextNode(n2.props.nodeValue)
4 container.appendChild(node)
5 } else {
6 const node = n2.node = n1.node
7 if (node.nodeValue !== n2.props.nodeValue) {
8 node.nodeValue = n2.props.nodeValue
9 }
10 }
11}
12
13const processElement = (n1, n2, container) => {
14 if (n1 == null) {
15 const node = n2.node = document.createElement(n2.type)
16 mountChildren(n2, node)
17 patchProps(null, n2.props, node)
18 container.appendChild(node)
19 } else {
20 const node = n2.node = n1.node
21 patchChildren(n1, n2, node)
22 patchProps(n1.props, n2.props, node)
23 }
24}
25
26const mountChildren = (vnode, container) => {
27 let children = vnode.props.children
28 children = isArray(children) ? children : [children]
29 vnode.children = []
30 for (let i = 0; i < children.length; i++) {
31 let child = children[i]
32 if (child == null) continue
33 child = isText(child) ? h(TextType, { nodeValue: child }) : child
34 vnode.children[i] = child
35 patch(null, child, container)
36 }
37}

可以看到对于 DOM 平台的操作是直接写上去的,并没有通过 renderOptions 传入,我们先这样耦合起来,后面再分离到 renderOptions 中

processText 的逻辑很简单,processElement 与 processText 类似,只不过多了 patchChildren / mountChildren 和 patchProps

patchProps 一看就知道是用来更新 props 的

mountChildren 就是对子节点处理下 Text 然后一一 patch

patchChildren 就是对于两个 VNode 的子节点的 diff,它与 patch 的不同在于 patchChildren 可以处理子节点是 VNode 数组的情况,对于子节点如何 patch 做了处理(指 key diff),而 patch 就是简简单单对于两个 VNode 节点的 diff

所以对于 Element 的子节点会调用 patchChildren / mountChildren 处理,因为 Element 子节点可以是多个的,而对于 Component 的子节点会调用 patch 处理,因为 Component 子节点都仅有一个(Fragment 是有多个子节点的,对于它我们可以通过 compat 处理),当然 Component 的子节点也可以调用 patchChildren 处理,Preact 就是这样做的,这样 Preact 就不用对 Fragment 单独处理了(这里关键不在于怎样处理,而在于设计的 Component 子节点可不可以是多的,做对应处理即可)

接下来我们看一下 patchProps

runtime-core/renderer.js
1const patchProps = (oldProps, newProps, node) => {
2 oldProps = oldProps ?? {}
3 newProps = newProps ?? {}
4 // remove old props
5 Object.keys(oldProps).forEach((propName) => {
6 if (propName !== 'children' && propName !== 'key' && !(propName in newProps)) {
7 setProperty(node, propName, null, oldProps[propName]);
8 }
9 });
10 // update old props
11 Object.keys(newProps).forEach((propName) => {
12 if (propName !== 'children' && propName !== 'key' && oldProps[propName] !== newProps[propName]) {
13 setProperty(node, propName, newProps[propName], oldProps[propName]);
14 }
15 });
16}
17
18const setProperty = (node, propName, newValue, oldValue) => {
19 if (propName[0] === 'o' && propName[1] === 'n') {
20 const eventType = propName.toLowerCase().slice(2);
21
22 if (!node.listeners) node.listeners = {};
23 node.listeners[eventType] = newValue;
24
25 if (newValue) {
26 if (!oldValue) {
27 node.addEventListener(eventType, eventProxy);
28 }
29 } else {
30 node.removeEventListener(eventType, eventProxy);
31 }
32 } else if (newValue !== oldValue) {
33 if (propName in node) {
34 node[propName] = newValue == null ? '' : newValue
35 } else if (newValue == null || newValue === false) {
36 node.removeAttribute(propName)
37 } else {
38 node.setAttribute(propName, newValue)
39 }
40 }
41}
42
43function eventProxy(e) {
44 // this: dom node
45 this.listeners[e.type](e)
46}

值得注意的是第 35 行对于 newValue === false 的处理,是直接 removeAttribute 的,这是为了表单的一些属性。还有对于事件的监听,我们通过一个 eventProxy 代理,这样不仅方便移除事件监听,还减少了与 DOM 的通信,修改了事件监听方法直接修改代理即可,不至于与 DOM 通信移除旧的事件再添加新的事件

接下来看 diff 算法的核心:patchChildren,我们先实现一个简易版的 key diff,不考虑节点的移动,后面会有完整的 key diff

runtime-core/renderer.js
1const patchChildren = (n1, n2, container) => {
2 const oldChildren = n1.children // 拿到旧的 VNode[]
3 let newChildren = n2.props.children // 新的 children
4 newChildren = isArray(newChildren) ? newChildren : [newChildren]
5 n2.children = [] // 新的 VNode[]
6
7 for (let i = 0; i < newChildren.length; i++) {
8 if (newChildren[i] == null) continue
9 let newChild = newChildren[i]
10 // 处理 Text,Text 也会建立 VNode,Text 不直接暴露给开发者,而是在内部处理
11 newChild = isText(newChild) ? h(TextType, { nodeValue: newChild }) : newChild
12 n2.children[i] = newChild
13 newChild.parent = n2 // 与 n2.children 建立内部 VNode Tree
14
15 let oldChild = null
16 for (let j = 0; j < oldChildren.length; j++) { // key diff
17 if (oldChildren[j] == null) continue
18 if (isSameVNodeType(oldChildren[j], newChild)) { // 找到 key 和 type 一样的 VNode
19 oldChild = oldChildren[j]
20 oldChildren[j] = null // 找到的就变为 null,最后不是 null 的就是需要移除的,全部 unmount 即可
21 break
22 }
23 }
24 patch(oldChild, newChild, container)
25 if (newChild.node) container.appendChild(newChild.node) // 有 node 就添加到 DOM 中,因为 component 没有 node
26 }
27
28 for (let oldChild of oldChildren) {
29 if (oldChild != null) unmount(oldChild)
30 }
31}

我们并没有考虑移动节点的情况,而且是根据顺序 diff 的 newVNode,如果之前 node 在 container 中,appendChild 会先移除之前的 node,然后添加到末尾,所以是没问题的

runtime-core/renderer.js
1const unmount = (vnode) => {
2 const child = vnode.node
3 const parent = child.parentNode
4 parent && parent.removeChild(child)
5}

然后实现 unmount,因为目前只考虑 Element 和 Text 的 diff,unmount 就没有对 Component 的 unmount 进行处理,后面我们会加上,现在可以写个 demo 看看效果了

1/** @jsx h */
2import { createRenderer, h } from '../../packages/runtime-core'
3
4const renderer = createRenderer() // 这里我们还没有分离平台操作,可以先这样写
5const $root = document.querySelector('#root')
6const arr = [1, 2, 3]
7
8setInterval(() => {
9 arr.unshift(arr.pop())
10 renderer.render(
11 <div>
12 {arr.map(e => <li key={e}>{e}</li>)}
13 </div>,
14 $root,
15 )
16}, 300)

💥 patchComponent

下面实现 Component 的 patch

runtime-core/renderer.js
1const processComponent = (n1, n2, container) => {
2 if (n1 == null) {
3 const instance = n2.instance = {
4 props: reactive(n2.props), // initProps
5 render: null,
6 update: null,
7 subTree: null,
8 vnode: n2,
9 }
10 const render = instance.render = n2.type.setup(instance.props)
11 instance.update = effect(() => { // component update 的入口,n2 是更新的根组件的 newVNode
12 const renderResult = render()
13 n2.children = [renderResult]
14 renderResult.parent = n2
15 patch(instance.subTree, renderResult, container)
16 instance.subTree = renderResult
17 })
18 } else {
19 // update...
20 }
21}

首先是 mount Component,需要在 VNode 上建立一个组件实例,用来存一些组件的东西,props 需要 reactive 一下,后面写 update Component 的时候就知道为什么了,然后获取 setup 返回的 render 函数,这里非常巧妙的就是组件的 update 方法是一个 effect 函数,这样对应他的状态和 props 改变时就可以自动去更新

我们来看组件的 update

runtime-core/renderer.js
1const processComponent = (n1, n2, container) => {
2 if (n1 == null) {
3 // mount...
4 } else {
5 const instance = n2.instance = n1.instance
6 instance.vnode = n2
7 // updateProps, 根据 vnode.props 修改 instance.props
8 Object.keys(n2.props).forEach(key => {
9 const newValue = n2.props[key]
10 const oldValue = instance.props[key]
11 if (newValue !== oldValue) {
12 instance.props[key] = newValue
13 }
14 })
15 }
16}

这里类似 const node = n2.node = n1.node 获取 instance,然后去 updateProps,这里就体现了之前 reactive(props) 的作用了,render 函数调用 JSX 得到的 props 每次都是新的,跟之前的 instance.props 并无关联,要是想 props 改变时也能使组件更新,就需要 JSX 的 props 和 instance.props 响应式的 props 进行关联,所以这里通过 updateProps 把 props 更新到 instance.props 上

我们再来看 updateProps,只涉及到了 instance.props 第一层的更新,相当于是做了层浅比较,内部实现了 React 的 PureComponent,阻断与更新无关子节点的更新,同时这里使用 shallowReactive 即可,得到更好一点的性能,但是之前我们没有实现 shallowReactive,这里就先用 reactive 替代

不要忘了我们的 unmount 还只能 unmount Element,我们来完善 Component 的 unmount

runtime-core/renderer.js
1const remove = (child) => {
2 const parent = child.parentNode
3 if (parent) parent.removeChild(child)
4}
5
6const unmount = (vnode, doRemove = true) => {
7 const { type } = vnode
8 if (isSetupComponent(type)) {
9 vnode.children.forEach(c => unmount(c, doRemove))
10 } else if (isString(type)) {
11 vnode.children.forEach(c => unmount(c, false))
12 if (doRemove) remove(vnode.node)
13 } else if (isTextType(type)) {
14 if (doRemove) remove(vnode.node)
15 } else {
16 type.unmount(/* ... */)
17 }
18}

类似于 patch,针对不同 type 进行 unmount,由于组件的 node 是 null,就直接将子节点进行 unmount

注意这里的 doRemove 参数的作用,Element 的子节点可以不直接从 DOM 上移除,直接将该 Element 移除即可,但是 Element 子节点中可能有 Component,所以还是需要递归调用 unmount,触发 Component 的清理副作用(后面讲)和生命周期,解决方案就是加一个 doRemove 参数,Element unmount 时 doRemove 为 true,之后子节点的 doRemove 为 false

最后还有清理副作用,生命周期就不提了,React 已经证明生命周期是可以不需要的,组件添加的 effect 在组件 unmount 后仍然存在,还没有清除,所以我们还需要在 unmount 中拿到组件所有的 effect,然后一一 stop,这时 stop 很简单,但如何拿到组件的 effect 就比较难

💫 Scheduler

其实 Vue 中并不会直接使用 Vue Reactivity 中的 API,从 Vue 中导出的 computed、watch、watchEffect 会把 effect 挂载到当前的组件实例上,用以之后清除 effect,我们只实现 computed 和简易的 watchEffect(不考虑 flush 为 post 和 pre 的情况)

update 的 effect 在 Vue 中通过 scheduler 实现了异步更新,watchEffect 的回调函数执行时机 flush 也是通过 scheduler 实现,简单来说就是 scheduler 创建了三个队列,分别存 pre Callbacks、sync Callbacks 和 post Callbacks,这三个队列中任务的执行都是通过 promise.then 放到微任务队列中,都是异步执行的,组件的 update 放在 sync 队列中,sync 指的是同步 DOM 更新(Vue 中 VNode 更新和 DOM 更新是同步的),pre 指的是在 DOM 更新之前,post 指的是在 DOM 更新之后,所以 pre 得不到更新后的 DOM 信息,而 post 可以得到

runtime-core/renderer.js
1const unmount = (vnode, doRemove = true) => {
2 const { type } = vnode
3 if (isSetupComponent(type)) {
4 const instance = { vnode }
5 instance.effects.forEach(stop)
6 stop(instance.update)
7 vnode.children.forEach(c => unmount(c, doRemove))
8 } // ...
9}
runtime-core/component.js
1let currentInstance
2export const getCurrentInstance = () => currentInstance
3export const setCurrentInstance = (instance) => currentInstance = instance
4
5export const recordInstanceBoundEffect = (effect) => {
6 if (currentInstance) currentInstance.effects.push(effect)
7}
reactivity/renderer.js
1const processComponent = (n1, n2, container) => {
2 if (n1 == null) {
3 const instance = n2.instance = {
4 props: reactive(n2.props), // initProps
5 render: null,
6 update: null,
7 subTree: null,
8 vnode: n2,
9 effects: [], // 用来存 setup 中调用 watchEffect 和 computed 的 effect
10 }
11 setCurrentInstance(instance)
12 const render = instance.render = n2.type.setup(instance.props)
13 setCurrentInstance(null)
14 // update effect...
15 } else {
16 // update...
17 }
18}

组件的 setup 只会调用一次,所以在这里调用 setCurrentInstance 即可,这是与 React.FC 的主要区别之一

reactivity/api-watch.js
1import { effect, stop } from '../reactivity'
2import { recordInstanceBoundEffect, getCurrentInstance } from './component'
3
4export const watchEffect = (cb, { onTrack, onTrigger } = {}) => {
5 let cleanup
6 const onInvalidate = (fn) => cleanup = e.options.onStop = fn
7 const getter = () => {
8 if (cleanup) {
9 cleanup()
10 }
11 return cb(onInvalidate)
12 }
13
14 const e = effect(getter, {
15 onTrack,
16 onTrigger,
17 // 这里我们写成 lazy 主要是为了 onInvalidate 正常运行
18 // 不 lazy 的话 onInvalidate 会在 e 定义好之前运行,onInvalidate 中有使用了 e,就会报错
19 lazy: true,
20 })
21 e()
22
23 recordInstanceBoundEffect(e)
24 const instance = getCurrentInstance()
25
26 return () => {
27 stop(e)
28 if (instance) {
29 const { effects } = instance
30 const i = effects.indexOf(e)
31 if (i > -1) effects.splice(i, 1) // 清除 effect 时也要把 instance 上的去掉
32 }
33 }
34}

watchEffect 的回调函数还可以传入一个 onInvalidate 方法用于注册失效时的回调,执行时机是副作用即将重新执行时和侦听器被停止(如果在 setup() 中使用了 watchEffect, 则在卸载组件时),相当于 React.useEffect 返回的 cleanup 函数,至于为什么不设计成与 React.useEffect 一样返回 cleanup,是因为 watchEffect 被设计成支持参数传入异步函数的

1const useLogger = () => {
2 let id
3 return {
4 logger: (v, time = 2000) => new Promise(resolve => {
5 id = setTimeout(() => {
6 console.log(v)
7 resolve()
8 }, time)
9 }),
10 cancel: () => {
11 clearTimeout(id)
12 id = null
13 },
14 }
15}
16
17const App = {
18 setup(props) {
19 const count = ref(0)
20 const { logger, cancel } = useLogger()
21
22 watchEffect(async (onInvalidate) => {
23 onInvalidate(cancel) // 异步调用之前就注册失效时的回调
24 await logger(count.value)
25 })
26
27 return () => <button onClick={() => count.value++}>log</button>
28 }
29}

继续看 computed 怎么绑定 effect

reactivity/api-computed.js
1import { stop, computed as _computed } from '../reactivity'
2import { recordInstanceBoundEffect } from './component'
3
4export const computed = (options) => {
5 const computedRef = _computed(options)
6 recordInstanceBoundEffect(computedRef.effect) // computed 内部实现也用到了 effect 哦
7 return computedRef
8}

就是通过在 setup 调用时设置 currentInstance,然后把 setup 中的 effect 放到 currentInstance.effects 上,最后 unmount 时一一 stop

最后我们再实现组件和 watchEffect 的异步调用

reactivity/scheduler.js
1const resolvedPromise = Promise.resolve()
2const queue = [] // 相对于 DOM 更新是同步的
3
4export const queueJob = (job) => {
5 queue.push(job)
6 resolvedPromise.then(() => { // syncQueue 中的 callbacks 还是会加入到微任务中执行
7 const deduped = [...new Set(queue)]
8 queue.length = 0
9 deduped.forEach(job => job())
10 })
11}
reactivity/renderer.js
1const processComponent = (n1, n2, container) => {
2 // createInstance, setup...
3 instance.update = effect(() => {
4 // patch...
5 }, { scheduler: queueJob }) // 没有 lazy,mount 时没必要通过异步调用
6 // ...
7}
reactivity/api-watch.js
1import { queueJob } from './scheduler'
2
3const afterPaint = requestAnimationFrame
4export const watchEffect = (cb, { onTrack, onTrigger } = {}) => {
5 // onInvalidate...
6 const scheduler = (job) => queueJob(() => afterPaint(job))
7 const e = effect(getter, {
8 lazy: true,
9 onTrack,
10 onTrigger,
11 scheduler,
12 })
13 scheduler(e) // init run, run by scheduler (effect 的 lazy 为 false 时,即使有 scheduler 它的 init run 也不会通过 schduler 运行)
14 // bind effect on instance, return cleanup...
15}

这里 watchEffect 进入微任务中又加到 afterPaint 是模仿了 React.useEffect 的调用时机,源码中并不是这样的,源码中实现了 flush: 'pre' | 'sync' | 'post' 这三种模式,我们这里为了简单做了一些修改

其实就是创建一个队列,然后把更新和 watchEffect 的回调函数放到队列中,之后队列中的函数会通过 promise.then 放到微任务队列中去执行,实现异步更新

现在终于完成了!写一个 demo 看看效果~

1/** @jsx h */
2import { ref } from '../../packages/reactivity'
3import { h, createRenderer, watchEffect } from '../../packages/runtime-core'
4
5const Displayer = {
6 setup(props) {
7 return () => (
8 <div>{props.children}</div>
9 )
10 }
11}
12
13const App = {
14 setup(props) {
15 const count = ref(0)
16 const inc = () => count.value++
17
18 watchEffect(() => console.log(count.value))
19
20 return () => (
21 <div>
22 <button onClick={inc}> + </button>
23 {count.value % 2 ? <Displayer>{count.value}</Displayer> : null}
24 </div>
25 )
26 }
27}
28
29createRenderer().render(<App />, document.querySelector('#root'))

⚡️ key diff

这里我们只给出简单版的实现(React 使用的 key diff,相比 Vue 使用的少了些优化,但是简单易懂),具体讲解可以看这篇渲染器的核心 Diff 算法,是一位 Vue Team Member 写的,应该没有文章讲的比这篇更清晰易懂了

runtime-core/renderer.js
1const patchChildren = (n1, n2, container) => {
2 const oldChildren = n1.children
3 let newChildren = n2.props.children
4 newChildren = isArray(newChildren) ? newChildren : [newChildren]
5 n2.children = []
6
7 let lastIndex = 0 // 存上一次 j 的值
8 for (let i = 0; i < newChildren.length; i++) {
9 if (newChildren[i] == null) continue
10 let newChild = newChildren[i]
11 newChild = isText(newChild) ? h(TextType, { nodeValue: newChild }) : newChild
12 n2.children[i] = newChild
13 newChild.parent = n2
14
15 let find = false
16 for (let j = 0; j < oldChildren.length; j++) {
17 if (oldChildren[j] == null) continue
18 if (isSameVNodeType(oldChildren[j], newChild)) { // update
19 const oldChild = oldChildren[j]
20 oldChildren[j] = null
21 find = true
22
23 patch(oldChild, newChild, container)
24
25 if (j < lastIndex) { // j 在上一次 j 之前,需要移动
26 // 1. 目前组件的 VNode.node 为 null,后面我们会 fix
27 // 2. newChildren[i - 1] 因为在上一轮已经 patch 过了,所以 node 不为 null
28 const refNode = getNextSibling(newChildren[i - 1])
29 move(oldChild, container, refNode)
30 } else { // no need to move
31 lastIndex = j
32 }
33 break
34 }
35 }
36 // mount
37 if (!find) {
38 const refNode = i - 1 < 0
39 ? getNode(oldChildren[0])
40 : getNextSibling(newChildren[i - 1])
41 patch(null, newChild, container, refNode)
42 }
43 }
44
45 for (let oldChild of oldChildren) {
46 if (oldChild != null) unmount(oldChild)
47 }
48}

之前是不涉及节点移动的,不管有没有节点一律 appendChild,现在需要加上节点移动的情况,就需要处理没有节点时新添加节点的 mount,对于移动的节点需要找到要移动到的位置(refNode 前面)

现在 mount 新节点时进行插入需要向 patch 传入 refNode,需要相应的更改之前的 patch,同时取 refNode 和 move 时会根据 type 不同操作也不同,我们这里将这几个操作进行封装

现在根据 type 不同封装出的操作有这些,patch 用来进入 VNode 更新,getNode 用于插入新 VNode 时取 oldChildren[0] 的 node,getNextSibling 用于取移动 VNode 时取 nextSibling,move 用来移动节点,unmount 用来移除 VNode,这些操作都是在该 diff 算法下会根据 type 不同有不同操作的一个封装,此外再算上 mountChildren、patchChildren 和 renderOptions,作为 internals 传入 type 的这五个方法中(剩余的方法可以通过以上方法调用到,所以不用暴露出去),用于深度定制组件,下一篇会详细讲 Vue3 Compat,表示 Vue3 中周边组件和一些其他新特性的实现原理,作为本篇的补充

runtime-core/renderer.js
1const patch = (n1, n2, container, anchor = null) => { // insertBefore(node, null) 就相当于 appendChild(node)
2 // unmount...
3
4 const { type } = n2
5 if (isSetupComponent(type)) {
6 processComponent(n1, n2, container, anchor)
7 } else if (isString(type)) {
8 processElement(n1, n2, container, anchor)
9 } else if (isTextType(type)) {
10 processText(n1, n2, container, anchor)
11 } else {
12 type.patch(/* ... */)
13 }
14}
15
16const getNode = (vnode) => { // patchChildren 在插入新 VNode 时调用 getNode(oldChildren[0])
17 if (!vnode) return null // oldChildren[0] 为 null 是返回 null 相当于 appendChild
18 const { type } = vnode
19 if (isSetupComponent(type)) return getNode(vnode.instance.subTree)
20 if (isString(type) || isTextType(type)) return vnode.node
21 return type.getNode(internals, { vnode })
22}
23
24const getNextSibling = (vnode) => { // patchChildren 在进行移动 VNode 前获得 refNode 调用
25 const { type } = vnode
26 if (isSetupComponent(type)) return getNextSibling(vnode.instance.subTree)
27 if (isString(type) || isTextType(type)) return hostNextSibling(vnode.node)
28 return type.getNextSibling(internals, { vnode })
29}
30
31const move = (vnode, container, anchor) => { // patchChildren 中用于移动 VNode
32 const { type } = vnode
33 if (isSetupComponent(type)) {
34 move(vnode.instance.subTree, container, anchor)
35 } else if (isString(type) || isTextType(type)) {
36 hostInsert(vnode.node, container, anchor)
37 } else {
38 type.move(internals, { vnode, container, anchor })
39 }
40}
41
42const processComponent = (n1, n2, container, anchor) => {
43 if (n1 == null) {
44 // ...
45 patch(instance.subTree, renderResult, container, anchor)
46 // ...
47 } else {
48 // ...
49 }
50}
51
52const processElement = (n1, n2, container, anchor) => {
53 if (n1 == null) {
54 // ...
55 container.insertBefore(node, anchor)
56 } else {
57 // ...
58 }
59}
60
61const processText = (n1, n2, container, anchor) => {
62 if (n1 == null) {
63 // ...
64 container.insertBefore(node, anchor)
65 } else {
66 // ...
67 }
68}
69
70const mountChildren = (vnode, container, isSVG, anchor) => {
71 // ...
72 for (/* ... */) {
73 // ...
74 patch(null, child, container, isSVG, anchor)
75 }
76}

🎨 Renderer

现在我们的 runtime 基本完成了,之前为了写起来方便并没有抽离出来平台操作,现在我们抽离出来,然后把原来的从传入的 renderOptions 引入即可

runtime-dom/index.js
1import { createRenderer, h } from '../runtime-core'
2
3const nodeOps = {
4 querySelector: (sel) => document.querySelector(sel),
5
6 insert: (child, parent, anchor) => {
7 parent.insertBefore(child, anchor ?? null)
8 },
9
10 remove: child => {
11 const parent = child.parentNode
12 if (parent) {
13 parent.removeChild(child)
14 }
15 },
16
17 createElement: (tag) => document.createElement(tag),
18
19 createText: text => document.createTextNode(text),
20
21 nextSibling: node => node.nextSibling,
22
23 setProperty: (node, propName, newValue, oldValue) => {
24 if (propName[0] === 'o' && propName[1] === 'n') {
25 const eventType = propName.toLowerCase().slice(2);
26
27 if (!node.listeners) node.listeners = {};
28 node.listeners[eventType] = newValue;
29
30 if (newValue) {
31 if (!oldValue) {
32 node.addEventListener(eventType, eventProxy);
33 }
34 } else {
35 node.removeEventListener(eventType, eventProxy);
36 }
37 } else if (newValue !== oldValue) {
38 if (propName in node) {
39 node[propName] = newValue == null ? '' : newValue
40 } else if (newValue == null || newValue === false) {
41 node.removeAttribute(propName)
42 } else {
43 node.setAttribute(propName, newValue)
44 }
45 }
46 },
47}
48
49function eventProxy(e) {
50 // this: node
51 this.listeners[e.type](e)
52}
53
54export const createApp = (rootComponent) => ({
55 mount: (rootSel) =>
56 createRenderer(nodeOps).render(h(rootComponent), nodeOps.querySelector(rootSel))
57})
runtime-core/renderer.js
1export function createRenderer(renderOptions) {
2 const {
3 createText: hostCreateText,
4 createElement: hostCreateElement,
5 insert: hostInsert,
6 nextSibling: hostNextSibling,
7 setProperty: hostSetProperty,
8 remove: hostRemove,
9 } = renderOptions
10 // ...
11}

😃 ramble

  1. 之前 Vue2 的时候一直对 Vue 不太感兴趣,觉得没 React 精简好用,而且那时候 React 已经有 Hooks 了,后来 Vue Reactivity 和 Composition API 出现后,同时越发觉得 Hooks 有很重的心智负担,才逐渐想去深入了解 Vue,从之前写 Reactivity 解析到现在写 runtime,发现 Vue3 的心智负担并没有想象中的那么少,但还是抵挡不住它的简单好用

  2. 对 Vue 的越来越深入也让我越发觉得 Vue 和 React 很多地方是一样的,也发现了它们核心部分的不同,Vue 就是 Proxy 实现的响应式 + VDOM runtime + 模版 complier,React 因为是一遍一遍的刷新,所以是偏向函数式的 Hooks + VDOM runtime (Fiber) + Scheduler,所以总结来说一个前端框架的核心就是数据层(reactivity、hooks、ng service)和视图连接层(VDOM、complier)

  3. 没有处理 svg,但是也很简单,这篇写的时候改了很多次,感觉已经写的很复杂了,所以在有的地方做了简化,更完整的可以看这个仓库

simple-vue/runtime-core 实现完整代码