30 秒速记
- 实例保存状态、输入和最近渲染子树
- 挂载先建立上下文,再执行渲染
- 插槽是由提供方定义的界面生成能力
- 外部组件节点与内部子树分别关联
- 更新与卸载要保持引用、事件和资源一致
有状态组件需要一个跨更新保存信息的上下文,让每次渲染仍属于同一个组件。 挂载时建立状态、输入和插槽,再执行渲染得到内部子树。父级传来的组件节点与内部渲染结果要分别记录,更新时才能知道是输入变了还是子树需要替换。插槽负责让使用方提供界面内容,组件可以传入必要数据,但不会因此接管提供方全部作用域。
版本校准: 本文出现 Object.defineProperty、Dep、Watcher 和双端 Diff 时,主要描述 Vue 2 实现;Vue 3 使用 Proxy、effect 与新的渲染器路径。Vue 2 已于 2023-12-31 结束维护,新项目应以 Vue 3 为基线,旧项目参考 Vue 2 EOL 官方说明 制定迁移与安全策略。
假设我们有如下模板:
<MyComponent>
<div></div>
</MyComponent>
由这段模板可知,我们为 MyComponent 组件提供了一个空的 div 标签作为默认插槽内容,从DOM结构上看 <MyComponent> 标签有一个 div 标签作为子节点,通常我们可以将其编译为如下 VNode:
const compVNode = {
flags: VNodeFlags.COMPONENT_STATEFUL_NORMAL,
tag: MyComponent,
children: {
flags: VNodeFlags.ELEMENT,
tag: 'div'
}
}
这其实没什么问题,但是我们更倾向于新建一个 slots 属性来存储这些子节点,这在语义上更加贴切,所以我们希望将模板编译为如下 VNode:
const compVNode = {
flags: VNodeFlags.COMPONENT,
tag: MyComponent,
children: null,
slots: {
// 默认插槽
default: {
flags: VNodeFlags.ELEMENT,
tag: 'div'
}
}
}
可以看到,如上 VNode 的 children 属性值为 null。当我们使用 mountComponent 函数挂载如上 VNode 时,我们可以在组件实例化之后并且在组件的渲染函数执行之前将 compVNode.slots 添加到组件实例对象上,这样当组件的渲染函数执行的时候,就可以访问插槽数据:
function mountComponent(vnode, container) {
// 创建组件实例
const instance = new vnode.tag()
// 设置 slots
instance.$slots = vnode.slots
// 渲染
instance.$vnode = instance.render()
// 挂载
mountElement(instance.$vnode, container)
vnode.ref && vnode.ref(instance)
}
在 MyComponent 组件的 render 函数内,我们就可以通过组件实例访问 slots 数据:
class MyComponent {
render() {
return {
flags: VNodeFlags.ELEMENT,
tag: 'h1'
children: this.$slots.default
}
}
}
实际上,这就是普通插槽的实现原理,至于作用域插槽(scopedSlots),与普通插槽并没有什么本质的区别,我们知道作用域插槽可以访问子组件的数据,在实现上来看其实就是函数传参:
class MyComponent {
render() {
return {
flags: VNodeFlags.ELEMENT,
tag: 'h1'
// 插槽变成了函数,可以传递参数
children: this.$slots.default(1)
}
}
}
如上代码所示,只要 this.$slots.default 是函数即可实现,所以在模板编译时,我们最终需要得到如下 VNode:
const compVNode = {
flags: VNodeFlags.COMPONENT,
tag: MyComponent,
children: null,
slots: {
// 作用域插槽,可以接受组件传递过来的数据
default: (arg) => {
const tag = arg === 1 ? 'div' : 'h1'
return {
flags: VNodeFlags.ELEMENT,
tag
}
}
}
}
现在你应该明白为什么普通插槽和作用域插槽本质上并没有区别了,因为普通插槽也可以是函数,只是不接收参数罢了。这么看的话其实普通插槽是作用域插槽的子集,那为什么不将它们合并呢?没错从 Vue2.6 起已经将之合并,所有插槽在 VNode 中都是函数,一个返回 VNode 的函数。
TIP
用过 React 的朋友,这让你想起 Render Prop 了吗!
# key 和 ref
key 就像 VNode 的唯一标识,用于 diff 算法的优化,它可以是数字也可以是字符串:
{
flags: VNodeFlags.ELEMENT_HTML,
tag: 'li',
key: 'li_0'
}
ref 的设计是为了提供一种能够拿到真实DOM的方式,当然如果将 ref 应用到组件上,那么拿到的就是组件实例,我们通常会把 ref 设计成一个函数,假设我们有如下模板:
<div :ref="el => elRef = el"></div>
我们可以把这段模板编译为如下 VNode:
const elementVNode = {
flags: VNodeFlags.ELEMENT_HTML,
tag: 'div',
ref: el => elRef = el
}
在使用 mountElement 函数挂载如上 VNode 时,可以轻松的实现 ref 功能:
function mountElement(vnode, container) {
const el = document.createElement(vnode.tag)
container.appendChild(el)
vnode.ref && vnode.ref(el)
}
如果挂载的是组件而非普通标签,那么只需要将组件实例传递给 vnode.ref 函数即可:
function mountComponent(vnode, container) {
// 创建组件实例
const instance = new vnode.tag()
// 渲染
instance.$vnode = instance.render()
// 挂载
mountElement(instance.$vnode, container)
vnode.ref && vnode.ref(instance)
}
# parentVNode 以及它的作用
与 VNode 的 slots 属性相同,parentVNode 属性也是给组件的 VNode 准备的,组件的 VNode 为什么需要这两个属性呢?它俩的作用又是什么呢?想弄清楚这些,我们至少要先弄明白:一个组件所涉及的 VNode 都有哪些。什么意思呢?看如下模板思考一个问题:
<template>
<div>
<MyComponent />
</div>
</template>
从这个模板来看 MyComponent 组件至少涉及到两个 VNode,第一个 VNode 是标签 <MyComponent /> 的描述,其次 MyComponent 组件本身也有要渲染的内容,这就是第二个 VNode。
- 第一个
VNode用来描述<MyComponent />标签:
{
// 省略...
tag: MyComponent
}
- 第二个
VNode是组件渲染内容的描述,即组件的render函数产出的VNode:
class MyComponent {
render () {
return {/* .. */} // 产出的 VNode
}
}
组件实例的 $vnode 属性值就是组件 render 函数产出的 VNode,这通过如下代码可以一目了然:
function mountComponent(vnode, container) {
// 创建组件实例
const instance = new vnode.tag()
// 渲染,$vnode 的值就是组件 render 函数产出的 VNode
instance.$vnode = instance.render()
// 挂载
mountElement