Let's build a Vue3 runtime
— SourceCode, Front End Framework — 3 min read
Table of Contents
序
我们会一起写一个简易的 runtime,对于 Vue 如何运行的有一个大致的了解,当然我们实现的会和源码本身有一些不同,会简化很多,主要学习思想
本篇文章并不是为了深入 Vue3 源码,而是对 Vue3 核心 VDOM 和新特性的简单了解,适合作为深入 Vue3 源码的入门文章
👀 Vue3 Entry
我们先看一下 Vue3 的 JSX 组件怎么写,因为我们只是造一个 runtime,所以不会涉及到 Vue 的模版编译,直接用 JSX 就很方便
1import { createApp, ref } from 'vue';23const Displayer = {4 props: { count: Number },5 setup(props) {6 return () => <div>{props.count}</div>;7 },8};910const App = {11 setup(props) {12 const count = ref(0);13 const inc = () => count.value++;1415 return () => (16 <div>17 <Displayer count={count.value} />18 <button onClick={inc}> + </button>19 </div>20 );21 },22};2324createApp(App).mount('#app');
我们这里直接用的对象形式的组件,一般会使用 defineComponent,它做的只是多了处理传入一个函数的情况,返回一个有 setup 方法的对象,并没有更多其他的处理了,至于为什么设计出一个 defineComponent
方法而不直接写对象,大概是为了在 API 设计层面和 defineAsyncComponent
一致吧
先看入口 createApp,翻翻源码可以看出做的事是根据 rendererOptions 创建 renderer,然后创建 app 对象,最后调用 app.mount 进行渲染,mount 里也是调用的 render
我们写简单一点,去掉 app 的创建,因为创建 app 其实类似于一个作用域,app 的插件和指令等只对该 app 下的组件起作用
1export function createRenderer(renderOptions) {23 return {4 render(rootVNode, container) {56 },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
1export function h(type, props, ...children) {2 props = props ?? {}34 const key = props.key ?? null5 delete props.key67 // 注意 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 = children14 }1516 return {17 type,18 props,19 key, // key diff 用的20 node: null, // 宿主环境的元素(dom node……),组件 VNode 为 null21 instance: null, // 组件实例,只有组件 VNode 会有,其他 VNode 为 null22 parent: null, // parent VNode23 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
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 = vnode12 },13 }14}
我们补全 render 方法的实现,这里不直接写 patch(null, vnode, container)
的原因是 render 有可能多次调用,并不一定每次调用都是 mount
1export const isObject = (value) => typeof value === 'object' && value !== null2export 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
1import { isObject } from '../shared'23export const TextType = Symbol('TextType')4export const isTextType = (v) => v === TextType56export const isSetupComponent = (c) => isObject(c) && 'setup' in c
1// ...2export const isSameVNodeType = (n1, n2) => n1.type === n2.type && n1.key === n2.key
1import { isString, isArray, isText } from '../shared'2import { TextType, isTextType, isSetupComponent } from './component'3import { isSameVNodeType, h } from './vnode'45export function createRenderer(renderOptions) {6 const patch = (n1, n2, container) => {7 if (n1 && !isSameVNodeType(n1, n2)) {8 unmount(n1)9 n1 = null10 }1112 const { type } = n213 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 }2324 // ...25}
patch(也就是 diff)在 type 判断最后加一个“后门”,我们可以用它来实现一些深度定制的组件,比如 setupComponent 就可以放到这里实现,或者还可以实现 Hooks(抄 Preact 的,Preact Compat 很多实现都是拿到组件实例 this 去 hack this 上的一些方法,或者再拿内部的一些方法去处理,比如 diff、diffChildren……),这里我们甚至可以实现一套 Preact Component……
diff 最主要的就是对于 Element 和 Text 的 diff,对应元素节点和文本节点,所以我们先实现这两个方法
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.node7 if (node.nodeValue !== n2.props.nodeValue) {8 node.nodeValue = n2.props.nodeValue9 }10 }11}1213const 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.node21 patchChildren(n1, n2, node)22 patchProps(n1.props, n2.props, node)23 }24}2526const mountChildren = (vnode, container) => {27 let children = vnode.props.children28 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) continue33 child = isText(child) ? h(TextType, { nodeValue: child }) : child34 vnode.children[i] = child35 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
1const patchProps = (oldProps, newProps, node) => {2 oldProps = oldProps ?? {}3 newProps = newProps ?? {}4 // remove old props5 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 props11 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}1718const setProperty = (node, propName, newValue, oldValue) => {19 if (propName[0] === 'o' && propName[1] === 'n') {20 const eventType = propName.toLowerCase().slice(2);2122 if (!node.listeners) node.listeners = {};23 node.listeners[eventType] = newValue;2425 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 ? '' : newValue35 } else if (newValue == null || newValue === false) {36 node.removeAttribute(propName)37 } else {38 node.setAttribute(propName, newValue)39 }40 }41}4243function eventProxy(e) {44 // this: dom node45 this.listeners[e.type](e)46}
值得注意的是第 35 行对于 newValue === false
的处理,是直接 removeAttribute 的,这是为了表单的一些属性。还有对于事件的监听,我们通过一个 eventProxy 代理,这样不仅方便移除事件监听,还减少了与 DOM 的通信,修改了事件监听方法直接修改代理即可,不至于与 DOM 通信移除旧的事件再添加新的事件
接下来看 diff 算法的核心:patchChildren,我们先实现一个简易版的 key diff,不考虑节点的移动,后面会有完整的 key diff
1const patchChildren = (n1, n2, container) => {2 const oldChildren = n1.children // 拿到旧的 VNode[]3 let newChildren = n2.props.children // 新的 children4 newChildren = isArray(newChildren) ? newChildren : [newChildren]5 n2.children = [] // 新的 VNode[]67 for (let i = 0; i < newChildren.length; i++) {8 if (newChildren[i] == null) continue9 let newChild = newChildren[i]10 // 处理 Text,Text 也会建立 VNode,Text 不直接暴露给开发者,而是在内部处理11 newChild = isText(newChild) ? h(TextType, { nodeValue: newChild }) : newChild12 n2.children[i] = newChild13 newChild.parent = n2 // 与 n2.children 建立内部 VNode Tree1415 let oldChild = null16 for (let j = 0; j < oldChildren.length; j++) { // key diff17 if (oldChildren[j] == null) continue18 if (isSameVNodeType(oldChildren[j], newChild)) { // 找到 key 和 type 一样的 VNode19 oldChild = oldChildren[j]20 oldChildren[j] = null // 找到的就变为 null,最后不是 null 的就是需要移除的,全部 unmount 即可21 break22 }23 }24 patch(oldChild, newChild, container)25 if (newChild.node) container.appendChild(newChild.node) // 有 node 就添加到 DOM 中,因为 component 没有 node26 }2728 for (let oldChild of oldChildren) {29 if (oldChild != null) unmount(oldChild)30 }31}
我们并没有考虑移动节点的情况,而且是根据顺序 diff 的 newVNode,如果之前 node 在 container 中,appendChild 会先移除之前的 node,然后添加到末尾,所以是没问题的
1const unmount = (vnode) => {2 const child = vnode.node3 const parent = child.parentNode4 parent && parent.removeChild(child)5}
然后实现 unmount,因为目前只考虑 Element 和 Text 的 diff,unmount 就没有对 Component 的 unmount 进行处理,后面我们会加上,现在可以写个 demo 看看效果了
1/** @jsx h */2import { createRenderer, h } from '../../packages/runtime-core'34const renderer = createRenderer() // 这里我们还没有分离平台操作,可以先这样写5const $root = document.querySelector('#root')6const arr = [1, 2, 3]78setInterval(() => {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
1const processComponent = (n1, n2, container) => {2 if (n1 == null) {3 const instance = n2.instance = {4 props: reactive(n2.props), // initProps5 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 是更新的根组件的 newVNode12 const renderResult = render()13 n2.children = [renderResult]14 renderResult.parent = n215 patch(instance.subTree, renderResult, container)16 instance.subTree = renderResult17 })18 } else {19 // update...20 }21}
首先是 mount Component,需要在 VNode 上建立一个组件实例,用来存一些组件的东西,props 需要 reactive 一下,后面写 update Component 的时候就知道为什么了,然后获取 setup 返回的 render 函数,这里非常巧妙的就是组件的 update 方法是一个 effect 函数,这样对应他的状态和 props 改变时就可以自动去更新
我们来看组件的 update
1const processComponent = (n1, n2, container) => {2 if (n1 == null) {3 // mount...4 } else {5 const instance = n2.instance = n1.instance6 instance.vnode = n27 // updateProps, 根据 vnode.props 修改 instance.props8 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] = newValue13 }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
1const remove = (child) => {2 const parent = child.parentNode3 if (parent) parent.removeChild(child)4}56const unmount = (vnode, doRemove = true) => {7 const { type } = vnode8 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 可以得到
1const unmount = (vnode, doRemove = true) => {2 const { type } = vnode3 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}
1let currentInstance2export const getCurrentInstance = () => currentInstance3export const setCurrentInstance = (instance) => currentInstance = instance45export const recordInstanceBoundEffect = (effect) => {6 if (currentInstance) currentInstance.effects.push(effect)7}
1const processComponent = (n1, n2, container) => {2 if (n1 == null) {3 const instance = n2.instance = {4 props: reactive(n2.props), // initProps5 render: null,6 update: null,7 subTree: null,8 vnode: n2,9 effects: [], // 用来存 setup 中调用 watchEffect 和 computed 的 effect10 }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 的主要区别之一
1import { effect, stop } from '../reactivity'2import { recordInstanceBoundEffect, getCurrentInstance } from './component'34export const watchEffect = (cb, { onTrack, onTrigger } = {}) => {5 let cleanup6 const onInvalidate = (fn) => cleanup = e.options.onStop = fn7 const getter = () => {8 if (cleanup) {9 cleanup()10 }11 return cb(onInvalidate)12 }1314 const e = effect(getter, {15 onTrack,16 onTrigger,17 // 这里我们写成 lazy 主要是为了 onInvalidate 正常运行18 // 不 lazy 的话 onInvalidate 会在 e 定义好之前运行,onInvalidate 中有使用了 e,就会报错19 lazy: true,20 })21 e()2223 recordInstanceBoundEffect(e)24 const instance = getCurrentInstance()2526 return () => {27 stop(e)28 if (instance) {29 const { effects } = instance30 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 id3 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 = null13 },14 }15}1617const App = {18 setup(props) {19 const count = ref(0)20 const { logger, cancel } = useLogger()2122 watchEffect(async (onInvalidate) => {23 onInvalidate(cancel) // 异步调用之前就注册失效时的回调24 await logger(count.value)25 })2627 return () => <button onClick={() => count.value++}>log</button>28 }29}
继续看 computed 怎么绑定 effect
1import { stop, computed as _computed } from '../reactivity'2import { recordInstanceBoundEffect } from './component'34export const computed = (options) => {5 const computedRef = _computed(options)6 recordInstanceBoundEffect(computedRef.effect) // computed 内部实现也用到了 effect 哦7 return computedRef8}
就是通过在 setup 调用时设置 currentInstance,然后把 setup 中的 effect 放到 currentInstance.effects 上,最后 unmount 时一一 stop
最后我们再实现组件和 watchEffect 的异步调用
1const resolvedPromise = Promise.resolve()2const queue = [] // 相对于 DOM 更新是同步的34export const queueJob = (job) => {5 queue.push(job)6 resolvedPromise.then(() => { // syncQueue 中的 callbacks 还是会加入到微任务中执行7 const deduped = [...new Set(queue)]8 queue.length = 09 deduped.forEach(job => job())10 })11}
1const processComponent = (n1, n2, container) => {2 // createInstance, setup...3 instance.update = effect(() => {4 // patch...5 }, { scheduler: queueJob }) // 没有 lazy,mount 时没必要通过异步调用6 // ...7}
1import { queueJob } from './scheduler'23const afterPaint = requestAnimationFrame4export 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'45const Displayer = {6 setup(props) {7 return () => (8 <div>{props.children}</div>9 )10 }11}1213const App = {14 setup(props) {15 const count = ref(0)16 const inc = () => count.value++1718 watchEffect(() => console.log(count.value))1920 return () => (21 <div>22 <button onClick={inc}> + </button>23 {count.value % 2 ? <Displayer>{count.value}</Displayer> : null}24 </div>25 )26 }27}2829createRenderer().render(<App />, document.querySelector('#root'))
⚡️ key diff
这里我们只给出简单版的实现(React 使用的 key diff,相比 Vue 使用的少了些优化,但是简单易懂),具体讲解可以看这篇渲染器的核心 Diff 算法,是一位 Vue Team Member 写的,应该没有文章讲的比这篇更清晰易懂了
1const patchChildren = (n1, n2, container) => {2 const oldChildren = n1.children3 let newChildren = n2.props.children4 newChildren = isArray(newChildren) ? newChildren : [newChildren]5 n2.children = []67 let lastIndex = 0 // 存上一次 j 的值8 for (let i = 0; i < newChildren.length; i++) {9 if (newChildren[i] == null) continue10 let newChild = newChildren[i]11 newChild = isText(newChild) ? h(TextType, { nodeValue: newChild }) : newChild12 n2.children[i] = newChild13 newChild.parent = n21415 let find = false16 for (let j = 0; j < oldChildren.length; j++) {17 if (oldChildren[j] == null) continue18 if (isSameVNodeType(oldChildren[j], newChild)) { // update19 const oldChild = oldChildren[j]20 oldChildren[j] = null21 find = true2223 patch(oldChild, newChild, container)2425 if (j < lastIndex) { // j 在上一次 j 之前,需要移动26 // 1. 目前组件的 VNode.node 为 null,后面我们会 fix27 // 2. newChildren[i - 1] 因为在上一轮已经 patch 过了,所以 node 不为 null28 const refNode = getNextSibling(newChildren[i - 1])29 move(oldChild, container, refNode)30 } else { // no need to move31 lastIndex = j32 }33 break34 }35 }36 // mount37 if (!find) {38 const refNode = i - 1 < 039 ? getNode(oldChildren[0])40 : getNextSibling(newChildren[i - 1])41 patch(null, newChild, container, refNode)42 }43 }4445 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 中周边组件和一些其他新特性的实现原理,作为本篇的补充
1const patch = (n1, n2, container, anchor = null) => { // insertBefore(node, null) 就相当于 appendChild(node)2 // unmount...34 const { type } = n25 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}1516const getNode = (vnode) => { // patchChildren 在插入新 VNode 时调用 getNode(oldChildren[0])17 if (!vnode) return null // oldChildren[0] 为 null 是返回 null 相当于 appendChild18 const { type } = vnode19 if (isSetupComponent(type)) return getNode(vnode.instance.subTree)20 if (isString(type) || isTextType(type)) return vnode.node21 return type.getNode(internals, { vnode })22}2324const getNextSibling = (vnode) => { // patchChildren 在进行移动 VNode 前获得 refNode 调用25 const { type } = vnode26 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}3031const move = (vnode, container, anchor) => { // patchChildren 中用于移动 VNode32 const { type } = vnode33 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}4142const processComponent = (n1, n2, container, anchor) => {43 if (n1 == null) {44 // ...45 patch(instance.subTree, renderResult, container, anchor)46 // ...47 } else {48 // ...49 }50}5152const processElement = (n1, n2, container, anchor) => {53 if (n1 == null) {54 // ...55 container.insertBefore(node, anchor)56 } else {57 // ...58 }59}6061const processText = (n1, n2, container, anchor) => {62 if (n1 == null) {63 // ...64 container.insertBefore(node, anchor)65 } else {66 // ...67 }68}6970const mountChildren = (vnode, container, isSVG, anchor) => {71 // ...72 for (/* ... */) {73 // ...74 patch(null, child, container, isSVG, anchor)75 }76}
🎨 Renderer
现在我们的 runtime 基本完成了,之前为了写起来方便并没有抽离出来平台操作,现在我们抽离出来,然后把原来的从传入的 renderOptions 引入即可
1import { createRenderer, h } from '../runtime-core'23const nodeOps = {4 querySelector: (sel) => document.querySelector(sel),56 insert: (child, parent, anchor) => {7 parent.insertBefore(child, anchor ?? null)8 },910 remove: child => {11 const parent = child.parentNode12 if (parent) {13 parent.removeChild(child)14 }15 },1617 createElement: (tag) => document.createElement(tag),1819 createText: text => document.createTextNode(text),2021 nextSibling: node => node.nextSibling,2223 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 ? '' : newValue40 } else if (newValue == null || newValue === false) {41 node.removeAttribute(propName)42 } else {43 node.setAttribute(propName, newValue)44 }45 }46 },47}4849function eventProxy(e) {50 // this: node51 this.listeners[e.type](e)52}5354export const createApp = (rootComponent) => ({55 mount: (rootSel) =>56 createRenderer(nodeOps).render(h(rootComponent), nodeOps.querySelector(rootSel))57})
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 } = renderOptions10 // ...11}
😃 ramble
之前 Vue2 的时候一直对 Vue 不太感兴趣,觉得没 React 精简好用,而且那时候 React 已经有 Hooks 了,后来 Vue Reactivity 和 Composition API 出现后,同时越发觉得 Hooks 有很重的心智负担,才逐渐想去深入了解 Vue,从之前写 Reactivity 解析到现在写 runtime,发现 Vue3 的心智负担并没有想象中的那么少,但还是抵挡不住它的简单好用
对 Vue 的越来越深入也让我越发觉得 Vue 和 React 很多地方是一样的,也发现了它们核心部分的不同,Vue 就是 Proxy 实现的响应式 + VDOM runtime + 模版 complier,React 因为是一遍一遍的刷新,所以是偏向函数式的 Hooks + VDOM runtime (Fiber) + Scheduler,所以总结来说一个前端框架的核心就是数据层(reactivity、hooks、ng service)和视图连接层(VDOM、complier)
没有处理 svg,但是也很简单,这篇写的时候改了很多次,感觉已经写的很复杂了,所以在有的地方做了简化,更完整的可以看这个仓库