本文首发于我的博客 ahabhgk's blog
我们会一起写一个简易的 runtime,对于 Vue 如何运行的有一个大致的了解,当然我们实现的会和源码本身有一些不同,会简化很多,主要学习思想
本篇文章并不是为了深入 Vue3 源码,而是对 Vue3 核心 VDOM 和新特性的简单了解,适合作为深入 Vue3 源码的入门文章
👀 Vue3 Entry
我们先看一下 Vue3 的 JSX 组件怎么写,因为我们只是造一个 runtime,所以不会涉及到 Vue 的模版编译,直接用 JSX 就很方便
import { createApp, ref } from 'vue';
const Displayer = {
props: { count: Number },
setup(props) {
return () => <div>{props.count}</div>;
},
};
const App = {
setup(props) {
const count = ref(0);
const inc = () => count.value++;
return () => (
<div>
<Displayer count={count.value} />
<button onClick={inc}> + </button>
</div>
);
},
};
createApp(App).mount('#app');
我们这里直接用的对象形式的组件,一般会使用 defineComponent,它做的只是多了处理传入一个函数的情况,返回一个有 setup 方法的对象,并没有更多其他的处理了,至于为什么设计出一个 defineComponent 方法而不直接写对象,大概是为了在 API 设计层面和 defineAsyncComponent 一致吧
先看入口 createApp,翻翻源码可以看出做的事是根据 rendererOptions 创建 renderer,然后创建 app 对象,最后调用 app.mount 进行渲染,mount 里也是调用的 render
我们写简单一点,去掉 app 的创建,因为创建 app 其实类似于一个作用域,app 的插件和指令等只对该 app 下的组件起作用
// runtime-core/renderer.js
export function createRenderer(renderOptions) {
return {
render(rootVNode, container) {
},
}
}
通过 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
export function h(type, props, ...children) {
props = props ?? {}
const key = props.key ?? null
delete props.key
// 注意 props.children 和 children 的不同
// props.children 因为子组件会使用所以是没有处理过的
// children 是为了维持内部的 VNode 树结构而创建的,类型是一个 VNode 数组
if (children.length === 1) {
props.children = children[0]
} else if (children.length > 1) {
props.children = children
}
return {
type,
props,
key, // key diff 用的
node: null, // 宿主环境的元素(dom node……),组件 VNode 为 null
instance: null, // 组件实例,只有组件 VNode 会有,其他 VNode 为 null
parent: null, // parent VNode
children: null, // VNode[],建立内部 VNode 树结构
}
}
Vue3 的 JSX 语法已经跟 React 很像了,除了 props.children 是通过 Slots 实现以外,基本都一样,这里我们并不打算实现 Slots,因为 Slots 实现的 children 也是一种 props,是一段 JSX 而已,并不算特殊,毕竟你随便写个 props 不叫 children 然后传 JSX 也是可以的。Vue 专门弄一个 Slots 是为了兼容它的 template 语法
☄️ patchElement & patchText
// runtime-core/renderer.js
export function createRenderer(renderOptions) {
return {
render(vnode, container) {
if (vnode == null) {
if (container.vnode) {
unmount(container.vnode)
}
} else {
patch(container.vnode ?? null, vnode, container)
}
container.vnode = vnode
},
}
}
我们补全 render 方法的实现,这里不直接写 patch(null, vnode, container) 的原因是 render 有可能多次调用,并不一定每次调用都是 mount
// shared/index.js
export const isObject = (value) => typeof value === 'object' && value !== null
export const isString = (value) => typeof value === 'string'
export const isNumber = (value) => typeof value === 'number'
export const isText = (v) => isString(v) || isNumber(v)
export const isArray = Array.isArray
// runtime-core/component.js
import { isObject } from '../shared'
export const TextType = Symbol('TextType')
export const isTextType = (v) => v === TextType
export const isSetupComponent = (c) => isObject(c) && 'setup' in c
// runtime-core/vnode.js
// ...
export const isSameVNodeType = (n1, n2) => n1.type === n2.type && n1.key === n2.key
// runtime-core/renderer.js
import { isString, isArray, isText } from '../shared'
import { TextType, isTextType, isSetupComponent } from './component'
import { isSameVNodeType, h } from './vnode'
export function createRenderer(renderOptions) {
const patch = (n1, n2, container) => {
if (n1 && !isSameVNodeType(n1, n2)) {
unmount(n1)
n1 = null
}
const { type } = n2
if (isSetupComponent(type)) {
processComponent(n1, n2, container)
} else if (isString(type)) {
processElement(n1, n2, container)
} else if (isTextType(type)) {
processText(n1, n2, container)
} else {
type.patch(/* ... */)
}
}
// ...
}
patch(也就是 diff)在 type 判断最后加一个“后门”,我们可以用它来实现一些深度定制的组件,比如 setupComponent 就可以放到这里实现,或者还可以实现 Hooks(抄 Preact 的,Preact Compat 很多实现都是拿到组件实例 this 去 hack this 上的一些方法,或者再拿内部的一些方法去处理,比如 diff、diffChildren……),这里我们甚至可以实现一套 Preact Component……
diff 最主要的就是对于 Element 和 Text 的 diff,对应元素节点和文本节点,所以我们先实现这两个方法
// runtime-core/renderer.js
const processText = (n1, n2, container) => {
if (n1 == null) {
const node = n2.node = document.createTextNode(n2.props.nodeValue)
container.appendChild(node)
} else {
const node = n2.node = n1.node
if (node.nodeValue !== n2.props.nodeValue) {
node.nodeValue !== n2.props.nodeValue
}
}
}
const processElement = (n1, n2, container) => {
if (n1 == null) {
const node = n2.node = document.createElement(n2.type)
mountChildren(n2, node)
patchProps(null, n2.props, node)
container.appendChild(node)
} else {
const node = n2.node = n1.node
patchChildren(n1, n2, node)
patchProps(n1.props, n2.props, node)
}
}
const mountChildren = (vnode, container) => {
let children = vnode.props.children
children = isArray(children) ? children : [children]
vnode.children = []
for (let i = 0; i < children.length; i++) {
let child = children[i]
if (child == null) continue
child = isText(child) ? h(TextType, { nodeValue: child }) : child
vnode.children[i] = child
patch(null, child, container)
}
}
可以看到对于 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
const patchProps = (oldProps, newProps, node) => {
oldProps = oldProps ?? {}
newProps = newProps ?? {}
// remove old props
Object.keys(oldProps).forEach((propName) => {
if (propName !== 'children' && propName !== 'key' && !(propName in newProps)) {
setProperty(node, propName, null, oldProps[propName]);
}
});
// update old props
Object.keys(newProps).forEach((propName) => {
if (propName !== 'children' && propName !== 'key' && oldProps[propName] !== newProps[propName]) {
setProperty(node, propName, newProps[propName], oldProps[propName]);
}
});
}
const setProperty = (node, propName, newValue, oldValue) => {
if (propName[0] === 'o' && propName[1] === 'n') {
const eventType = propName.toLowerCase().slice(2);
if (!node.listeners) node.listeners = {};
node.listeners[eventType] = newValue;
if (newValue) {
if (!oldValue) {
node.addEventListener(eventType, eventProxy);
}
} else {
node.removeEventListener(eventType, eventProxy);
}
} else if (newValue !== oldValue) {
if (propName in node) {
node[propName] = newValue == null ? '' : newValue
} else if (newValue == null || newValue === false) {
node.removeAttribute(propName)
} else {
node.setAttribute(propName, newValue)
}
}
}
function eventProxy(e) {
// this: dom node
this.listeners[e.type](e)
}
值得注意的是第 35 行对于 newValue === false 的处理,是直接 removeAttribute 的,这是为了表单的一些属性。还有对于事件的监听,我们通过一个 eventProxy 代理,这样不仅方便移除事件监听,还减少了与 DOM 的通信,修改了事件监听方法直接修改代理即可,不至于与 DOM 通信移除旧的事件再添加新的事件
接下来看 diff 算法的核心:patchChildren,我们先实现一个简易版的 key diff,不考虑节点的移动,后面会有完整的 key diff
// runtime-core/renderer.js
const patchChildren = (n1, n2, container) => {
const oldChildren = n1.children // 拿到旧的 VNode[]
let newChildren = n2.props.children // 新的 children
newChildren = isArray(newChildren) ? newChildren : [newChildren]
n2.children = [] // 新的 VNode[]
for (let i = 0; i < newChildren.length; i++) {
if (newChildren[i] == null) continue
let newChild = newChildren[i]
// 处理 Text,Text 也会建立 VNode,Text 不直接暴露给开发者,而是在内部处理
newChild = isText(newChild) ? h(TextType, { nodeValue: newChild }) : newChild
n2.children[i] = newChild
newChild.parent = n2 // 与 n2.children 建立内部 VNode Tree
let oldChild = null
for (let j = 0; j < oldChildren.length; j++) { // key diff
if (oldChildren[j] == null) continue
if (isSameVNodeType(oldChildren[j], newChild)) { // 找到 key 和 type 一样的 VNode
oldChild = oldChildren[j]
oldChildren[j] = null // 找到的就变为 null,最后不是 null 的就是需要移除的,全部 unmount 即可
break
}
}
patch(oldChild, newChild, container)
if (newChild.node) container.appendChild(newChild.node) // 有 node 就添加到 DOM 中,因为 component 没有 node
}
for (let oldChild of oldChildren) {
if (oldChild != null) unmount(oldChild)
}
}
我们并没有考虑移动节点的情况,而且是根据顺序 diff 的 newVNode,如果之前 node 在 container 中,appendChild 会先移除之前的 node,然后添加到末尾,所以是没问题的
// runtime-core/renderer.js
const unmount = (vnode) => {
const child = vnode.node
const parent = child.parentNode
parent && parent.removeChild(child)
}
然后实现 unmount,因为目前只考虑 Element 和 Text 的 diff,unmount 就没有对 Component 的 unmount 进行处理,后面我们会加上,现在可以写个 demo 看看效果了
/** @jsx h */
import { createRenderer, h } from '../../packages/runtime-core'
const renderer = createRenderer() // 这里我们还没有分离平台操作,可以先这样写
const $root = document.querySelector('#root')
const arr = [1, 2, 3]
setInterval(() => {
arr.unshift(arr.pop())
renderer.render(
<div>
{arr.map(e => <li key={e}>{e}</li>)}
</div>,
$root,
)
}, 300)
💥 patchComponent
下面实现 Component 的 patch
// runtime-core/renderer.js
const processComponent = (n1, n2, container) => {
if (n1 == null) {
const instance = n2.instance = {
props: reactive(n2.props), // initProps
render: null,
update: null,
subTree: null,
vnode: n2,
}
const render = instance.render = n2.type.setup(instance.props)
instance.update = effect(() => { // component update 的入口,n2 是更新的根组件的 newVNode
const renderResult = render()
n2.children = [renderResult]
renderResult.parent = n2
patch(instance.subTree, renderResult, container)
instance.subTree = renderResult
})
} else {
// update...
}
}
首先是 mount Component,需要在 VNode 上建立一个组件实例,用来存一些组件的东西,props 需要 reactive 一下,后面写 update Component 的时候就知道为什么了,然后获取 setup 返回的 render 函数,这里非常巧妙的就是组件的 update 方法是一个 effect 函数,这样对应他的状态和 props 改变时就可以自动去更新
我们来看组件的 update
// runtime-core/renderer.js
const processComponent = (n1, n2, container) => {
if (n1 == null) {
// mount...
} else {
const instance = n2.instance = n1.instance
instance.vnode = n2
// updateProps, 根据 vnode.props 修改 instance.props
Object.keys(n2.props).forEach(key => {
const newValue = n2.props[key]
const oldValue = instance.props[key]
if (newValue !== oldValue) {
instance.props[key] = newValue
}
})
}
}
这里类似 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
const remove = (child) => {
const parent = child.parentNode
if (parent) parent.removeChild(child)
}
const unmount = (vnode, doRemove = true) => {
const { type } = vnode
if (isSetupComponent(type)) {
vnode.children.forEach(c => unmount(c, doRemove))
} else if (isString(type)) {
vnode.children.forEach(c => unmount(c, false))
if (doRemove) remove(vnode.node)
} else if (isTextType(type)) {
if (doRemove) remove(vnode.node)
} else {
type.unmount(/* ... */)
}
}
类似于 patch,针对不同 type 进行 unmount,由于组件的 node 是 null,就直接将子节点进行 unmount
注意这里的 deRemove 参数的作用,Element 的子节点可以不直接从 DOM 上移除,直接将该 Element 移除即可,但是 Element 子节点中可能有 Component,所以还是需要递归调用 unmount,触发 Component 的清理副作用(后面讲)和生命周期,解决方案就是加一个 deRemove 参数,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
const unmount = (vnode, doRemove = true) => {
const { type } = vnode
if (isSetupComponent(type)) {
const instance = { vnode }
instance.effects.forEach(stop)
stop(instance.update)
vnode.children.forEach(c => unmount(c, doRemove))
} // ...
}
// runtime-core/component.js
let currentInstance
export const getCurrentInstance = () => currentInstance
export const setCurrentInstance = (instance) => currentInstance = instance
export const recordInstanceBoundEffect = (effect) => {
if (currentInstance) currentInstance.effects.push(effect)
}
// reactivity/renderer.js
const processComponent = (n1, n2, container) => {
if (n1 == null) {
const instance = n2.instance = {
props: reactive(n2.props), // initProps
render: null,
update: null,
subTree: null,
vnode: n2,
effects: [], // 用来存 setup 中调用 watchEffect 和 computed 的 effect
}
setCurrentInstance(instance)
const render = instance.render = n2.type.setup(instance.props)
setCurrentInstance(null)
// update effect...
} else {
// update...
}
}
组件的 setup 只会调用一次,所以在这里调用 setCurrentInstance 即可,这是与 React.FC 的主要区别之一
// reactivity/api-watch.js
import { effect, stop } from '../reactivity'
import { recordInstanceBoundEffect, getCurrentInstance } from './component'
export const watchEffect = (cb, { onTrack, onTrigger } = {}) => {
let cleanup
const onInvalidate = (fn) => cleanup = e.options.onStop = fn
const getter = () => {
if (cleanup) {
cleanup()
}
return cb(onInvalidate)
}
const e = effect(getter, {
onTrack,
onTrigger,
// 这里我们写成 lazy 主要是为了 onInvalidate 正常运行
// 不 lazy 的话 onInvalidate 会在 e 定义好之前运行,onInvalidate 中有使用了 e,就会报错
lazy: true,
})
e()
recordInstanceBoundEffect(e)
const instance = getCurrentInstance()
return () => {
stop(e)
if (instance) {
const { effects } = instance
const i = effects.indexOf(e)
if (i > -1) effects.splice(i, 1) // 清除 effect 时也要把 instance 上的去掉
}
}
}
watchEffect 的回调函数还可以传入一个 onInvalidate 方法用于注册失效时的回调,执行时机是副作用即将重新执行时和侦听器被停止(如果在 setup() 中使用了 watchEffect, 则在卸载组件时),相当于 React.useEffect 返回的 cleanup 函数,至于为什么不设计成与 React.useEffect 一样返回 cleanup,是因为 watchEffect 被设计成支持参数传入异步函数的
const useLogger = () => {
let id
return {
logger: (v, time = 2000) => new Promise(resolve => {
id = setTimeout(() => {
console.log(v)
resolve()
}, time)
}),
cancel: () => {
clearTimeout(id)
id = null
},
}
}
const App = {
setup(props) {
const count = ref(0)
const { logger, cancel } = useLogger()
watchEffect(async (onInvalidate) => {
onInvalidate(cancel) // 异步调用之前就注册失效时的回调
await logger(count.value)
})
return () => <button onClick={() => count.value++}>log</button>
}
}
继续看 computed 怎么绑定 effect
// reactivity/api-computed.js
import { stop, computed as _computed } from '../reactivity'
import { recordInstanceBoundEffect } from './component'
export const computed = (options) => {
const computedRef = _computed(options)
recordInstanceBoundEffect(computedRef.effect) // computed 内部实现也用到了 effect 哦
return computedRef
}
就是通过在 setup 调用时设置 currentInstance,然后把 setup 中的 effect 放到 currentInstance.effects 上,最后 unmount 时一一 stop
最后我们再实现组件和 watchEffect 的异步调用
// reactivity/scheduler.js
const resolvedPromise = Promise.resolve()
const queue = [] // 相对于 DOM 更新是同步的
export const queueJob = (job) => {
queue.push(job)
resolvedPromise.then(() => { // syncQueue 中的 callbacks 还是会加入到微任务中执行
const deduped = [...new Set(queue)]
queue.length = 0
deduped.forEach(job => job())
})
}
const processComponent = (n1, n2, container) => {
// createInstance, setup...
instance.update = effect(() => {
// patch...
}, { scheduler: queueJob }) // 没有 lazy,mount 时没必要通过异步调用
// ...
}
// reactivity/api-watch.js
import { queueJob } from './scheduler'
const afterPaint = requestAnimationFrame
export const watchEffect = (cb, { onTrack, onTrigger } = {}) => {
// onInvalidate...
const scheduler = (job) => queueJob(() => afterPaint(job))
const e = effect(getter, {
lazy: true,
onTrack,
onTrigger,
scheduler,
})
scheduler(e) // init run, run by scheduler (effect 的 lazy 为 false 时,即使有 scheduler 它的 init run 也不会通过 schduler 运行)
// bind effect on instance, return cleanup...
}
这里 watchEffect 进入微任务中又加到 afterPaint 是模仿了 React.useEffect 的调用时机,源码中并不是这样的,源码中实现了
flush: 'pre' | 'sync' | 'post'这三种模式,我们这里为了简单做了一些修改
其实就是创建一个队列,然后把更新和 watchEffect 的回调函数放到队列中,之后队列中的函数会通过 promise.then 放到微任务队列中去执行,实现异步更新
现在终于完成了!写一个 demo 看看效果~
/** @jsx h */
import { ref } from '../../packages/reactivity'
import { h, createRenderer, watchEffect } from '../../packages/runtime-core'
const Displayer = {
setup(props) {
return () => (
<div>{props.children}</div>
)
}
}
const App = {
setup(props) {
const count = ref(0)
const inc = () => count.value++
watchEffect(() => console.log(count.value))
return () => (
<div>
<button onClick={inc}> + </button>
{count.value % 2 ? <Displayer>{count.value}</Displayer> : null}
</div>
)
}
}
createRenderer().render(<App />, document.querySelector('#root'))
⚡️ key diff
这里我们只给出简单版的实现(React 使用的 key diff,相比 Vue 使用的少了些优化,但是简单易懂),具体讲解可以看这篇渲染器的核心 Diff 算法,是一位 Vue Team Member 写的,应该没有文章讲的比这篇更清晰易懂了
// runtime-core/renderer.js
const patchChildren = (n1, n2, container) => {
const oldChildren = n1.children
let newChildren = n2.props.children
newChildren = isArray(newChildren) ? newChildren : [newChildren]
n2.children = []
let lastIndex = 0 // 存上一次 j 的值
for (let i = 0; i < newChildren.length; i++) {
if (newChildren[i] == null) continue
let newChild = newChildren[i]
newChild = isText(newChild) ? h(TextType, { nodeValue: newChild }) : newChild
n2.children[i] = newChild
newChild.parent = n2
let find = false
for (let j = 0; j < oldChildren.length; j++) {
if (oldChildren[j] == null) continue
if (isSameVNodeType(oldChildren[j], newChild)) { // update
const oldChild = oldChildren[j]
oldChildren[j] = null
find = true
patch(oldChild, newChild, container)
if (j < lastIndex) { // j 在上一次 j 之前,需要移动
// 1. 目前组件的 VNode.node 为 null,后面我们会 fix
// 2. newChildren[i - 1] 因为在上一轮已经 patch 过了,所以 node 不为 null
const refNode = getNextSibling(newChildren[i - 1])
move(oldChild, container, refNode)
} else { // no need to move
lastIndex = j
}
break
}
}
// mount
if (!find) {
const refNode = i - 1 < 0
? getNode(oldChildren[0])
: getNextSibling(newChildren[i - 1])
patch(null, newChild, container, refNode)
}
}
for (let oldChild of oldChildren) {
if (oldChild != null) unmount(oldChild)
}
}
之前是不涉及节点移动的,不管有没有节点一律 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
const patch = (n1, n2, container, anchor = null) => { // insertBefore(node, null) 就相当于 appendChild(node)
// unmount...
const { type } = n2
if (isSetupComponent(type)) {
processComponent(n1, n2, container, anchor)
} else if (isString(type)) {
processElement(n1, n2, container, anchor)
} else if (isTextType(type)) {
processText(n1, n2, container, anchor)
} else {
type.patch(/* ... */)
}
}
const getNode = (vnode) => { // patchChildren 在插入新 VNode 时调用 getNode(oldChildren[0])
if (!vnode) return null // oldChildren[0] 为 null 是返回 null 相当于 appendChild
const { type } = vnode
if (isSetupComponent(type)) return getNode(vnode.instance.subTree)
if (isString(type) || isTextType(type)) return vnode.node
return type.getNode(internals, { vnode })
}
const getNextSibling = (vnode) => { // patchChildren 在进行移动 VNode 前获得 refNode 调用
const { type } = vnode
if (isSetupComponent(type)) return getNextSibling(vnode.instance.subTree)
if (isString(type) || isTextType(type)) return hostNextSibling(vnode.node)
return type.getNextSibling(internals, { vnode })
}
const move = (vnode, container, anchor) => { // patchChildren 中用于移动 VNode
const { type } = vnode
if (isSetupComponent(type)) {
move(vnode.instance.subTree, container, anchor)
} else if (isString(type) || isTextType(type)) {
hostInsert(vnode.node, container, anchor)
} else {
type.move(internals, { vnode, container, anchor })
}
}
const processComponent = (n1, n2, container, anchor) => {
if (n1 == null) {
// ...
patch(instance.subTree, renderResult, container, anchor)
// ...
} else {
// ...
}
}
const processElement = (n1, n2, container, anchor) => {
if (n1 == null) {
// ...
container.insertBefore(node, anchor)
} else {
// ...
}
}
const processText = (n1, n2, container, anchor) => {
if (n1 == null) {
// ...
container.insertBefore(node, anchor)
} else {
// ...
}
}
const mountChildren = (vnode, container, isSVG, anchor) => {
// ...
for (/* ... */) {
// ...
patch(null, child, container, isSVG, anchor)
}
}
🎨 Renderer
现在我们的 runtime 基本完成了,之前为了写起来方便并没有抽离出来平台操作,现在我们抽离出来,然后把原来的从传入的 renderOptions 引入即可
// runtime-dom/index.js
import { createRenderer, h } from '../runtime-core'
const nodeOps = {
querySelector: (sel) => document.querySelector(sel),
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor ?? null)
},
remove: child => {
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
},
createElement: (tag) => document.createElement(tag),
createText: text => document.createTextNode(text),
nextSibling: node => node.nextSibling,
setProperty: (node, propName, newValue, oldValue) => {
if (propName[0] === 'o' && propName[1] === 'n') {
const eventType = propName.toLowerCase().slice(2);
if (!node.listeners) node.listeners = {};
node.listeners[eventType] = newValue;
if (newValue) {
if (!oldValue) {
node.addEventListener(eventType, eventProxy);
}
} else {
node.removeEventListener(eventType, eventProxy);
}
} else if (newValue !== oldValue) {
if (propName in node) {
node[propName] = newValue == null ? '' : newValue
} else if (newValue == null || newValue === false) {
node.removeAttribute(propName)
} else {
node.setAttribute(propName, newValue)
}
}
},
}
function eventProxy(e) {
// this: node
this.listeners[e.type](e)
}
export const createApp = (rootComponent) => ({
mount: (rootSel) =>
createRenderer(nodeOps).render(h(rootComponent), nodeOps.querySelector(rootSel))
})
// runtime-core/renderer.js
export function createRenderer(renderOptions) {
const {
createText: hostCreateText,
createElement: hostCreateElement,
insert: hostInsert,
nextSibling: hostNextSibling,
setProperty: hostSetProperty,
remove: hostRemove,
} = renderOptions
// ...
}
😃 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,但是也很简单,这篇写的时候改了很多次,感觉已经写的很复杂了,所以在有的地方做了简化,更完整的可以看这个仓库