实现组件 vnode 的识别、实例创建、首次渲染和响应式更新
前面已经实现了 createApp。
现在可以这样使用:
import { createApp, h, ref } from '../dist/vue.esm.js'
const App = {
setup() {
const count = ref(0)
return {
count
}
},
render() {
return h('div', this.count)
}
}
createApp(App).mount('#app')
但目前页面上还不会显示内容。
原因是 createApp(App).mount('#app') 最终会等价成:
const vnode = h(App)
render(vnode, container)
把组件对象传给 h,创建一个组件类型的 vnode,再交给 render。
之前的渲染流程只处理过普通元素,像 div、span,还没有处理组件,所以来补上组件的首次挂载。
识别组件 vnode
在创建 vnode 时,需要根据 type 区分它是普通元素还是组件。
如果 type 是字符串,说明它是普通元素:
h('div')
如果 type 是对象,说明它是一个组件:
h({
setup() {
},
render() {
}
})
所以在 createVNode 中补上组件类型的判断:
export function createVNode(type, props?, children = null) {
let shapeFlag = 0
if (isString(type)) {
shapeFlag = ShapeFlags.ELEMENT
}
else if (isObject(type)) {
shapeFlag = ShapeFlags.STATEFUL_COMPONENT
}
const vnode = {
type,
props,
children,
shapeFlag
}
return vnode
}
patch 分发组件
有了组件类型之后,接下来需要让 patch 能识别组件。
组件和元素一样,也分为挂载和更新两个阶段,所以单独抽一个 processComponent 来处理:
function patch(n1, n2, container, anchor = null) {
const { shapeFlag, type } = n2
switch (type) {
case Text:
processText(n1, n2, container, anchor)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, anchor)
}
else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container, anchor)
}
}
}
processComponent 里先处理首次挂载:
function processComponent(n1, n2, container, anchor) {
if (n1 == null) {
mountComponent(n2, container, anchor)
}
else {
// 更新组件
}
}
挂载组件
组件挂载大概分为三步:
- 创建组件实例
- 初始化组件状态
- 执行组件
render,拿到subTree后继续走patch
function mountComponent(vnode, container, anchor) {
const instance = createComponentInstance(vnode)
setupComponent(instance)
const subTree = instance.render.call(instance.setupState)
patch(null, subTree, container, anchor)
}
这里有一个关键点:组件的 render 返回的不是实际 DOM,而是另一个 vnode。
比如:
const App = {
render() {
return h('div', 'hello')
}
}
调用 render 后拿到的是:
h('div', 'hello')
这个返回值叫做 subTree,表示组件内部渲染出来的子树。
组件本身不直接创建真实节点,而是通过 patch(null, subTree, container, anchor) 把这棵子树挂载到页面上。
组件实例
组件相关逻辑继续放在 component.ts 中,避免 renderer.ts 越写越重。
实现 createComponentInstance:
// packages/runtime-core/src/component.ts
export function createComponentInstance(vnode) {
const { type } = vnode
const instance = {
type,
vnode,
render: null,
setupState: null,
props: {},
attrs: {},
subTree: null,
isMounted: false
}
return instance
}
这个实例是组件运行时的上下文,后面组件更新、props、插槽、生命周期等能力都会挂在它上面。
当前只需要关注两个字段:
setupState:保存setup返回的状态render:保存组件的渲染函数
实现 setupComponent:
// packages/runtime-core/src/component.ts
import { proxyRefs } from '@vue/reactivity'
export function setupComponent(instance) {
const { type } = instance
const setupState = proxyRefs(type.setup())
instance.setupState = setupState
instance.render = type.render
}
setup 返回的对象里可能包含 ref:
setup()
{
const count = ref(0)
return {
count
}
}
如果直接在 render 中使用,就需要写成:
this.count.value
但在组件里通常希望直接写:
this.count
所以这里用 proxyRefs 包一层,让 render 访问 setupState 时可以自动解包 ref。
render 的 this
最后再看一下这行代码:
const subTree = instance.render.call(instance.setupState)
它的作用是让 render 函数里的 this 指向 setupState。
因此组件中可以这样读取状态:
const App = {
setup() {
const count = ref(0)
return {
count
}
},
render() {
return h('div', this.count)
}
}
执行过程是:
const setupState = proxyRefs({
count: ref(0)
})
render.call(setupState)
所以 this.count 最终可以拿到 count.value。
组件更新
现在组件已经可以完成首次挂载了,但响应式更新还没有接上。
比如组件里有一个 count:
import { createApp, h, ref } from '../dist/vue.esm.js'
const Com = {
setup() {
const count = ref(0)
btn.onclick = () => {
count.value++
}
return {
count
}
},
render() {
return h('div', this.count)
}
}
createApp(Com).mount('#app')
点击按钮时,count.value 确实变了,但页面不会跟着更新。
原因是:响应式数据变化后,需要通过 effect 重新执行依赖它的渲染逻辑。组件的 render 正好就是这段需要重新执行的逻辑。
所以可以把组件渲染封装成一个 componentUpdateFn,再交给 ReactiveEffect 执行:
import { ReactiveEffect } from '@vue/reactivity'
function mountComponent(vnode, container, anchor) {
const instance = createComponentInstance(vnode)
setupComponent(instance)
const componentUpdateFn = () => {
const subTree = instance.render.call(instance.setupState)
patch(null, subTree, container, anchor)
}
const effect = new ReactiveEffect(componentUpdateFn)
effect.run()
}
这样首次执行时可以正常渲染,后续 count.value++ 时也会再次执行 componentUpdateFn。
但这时会出现一个新问题:每次更新都会往页面里追加一个新的节点。
0
1
2
3
原因是每次执行时都在调用:
patch(null, subTree, container, anchor)
第一个参数一直是 null,对 patch 来说,这就永远是一次全新的挂载,而不是更新。
所以组件实例上需要保存上一次渲染出来的 subTree,下一次更新时再把旧 subTree 和新 subTree 一起交给 patch:
function mountComponent(vnode, container, anchor) {
const instance = createComponentInstance(vnode)
setupComponent(instance)
const componentUpdateFn = () => {
if (!instance.isMounted) {
const subTree = instance.render.call(instance.setupState)
patch(null, subTree, container, anchor)
instance.subTree = subTree
instance.isMounted = true
}
else {
const prevTree = instance.subTree
const subTree = instance.render.call(instance.setupState)
patch(prevTree, subTree, container, anchor)
instance.subTree = subTree
}
}
const effect = new ReactiveEffect(componentUpdateFn)
effect.run()
}
通过 isMounted 把组件渲染分成两种情况:
- 首次执行:没有旧节点,调用
patch(null, subTree, container, anchor)完成挂载 - 后续执行:已经有旧节点,调用
patch(prevTree, subTree, container, anchor)完成更新
组件更新本身并不直接操作 DOM,它只是重新执行 render 得到新的 subTree,真正的差异更新仍然交给前面已经实现过的
patch、patchElement 和 patchChildren。
总结
h(App)创建组件vnodecreateVNode通过对象类型标记组件shapeFlagpatch根据shapeFlag分发到processComponentmountComponent创建实例并初始化组件状态- 执行组件
render得到subTree - 用
ReactiveEffect包住组件渲染逻辑,让响应式数据变化时可以重新执行 - 首次渲染时调用
patch(null, subTree, container, anchor)挂载组件子树 - 更新时调用
patch(prevTree, subTree, container, anchor)复用旧节点并完成 diff