源码位置:
vue-next/packages/runtime-core/src/component.ts
本文只实现了 Composition API,没有实现 Options API
随着前端业务越来越复杂,也就自然而然的出现了现代化的需求,如模块化、组件化、自动化、规范化,前文的 render 和 patch 都留好了坑,本文主要来实现组件的渲染
用用看
根据官网文档,定义如下全局组件(省略部分无关代码)
app.component('my-comp', {
props: ['className'],
setup() {
return { text: 'this is a text' };
},
template: `
<div>
<div :class="className" :id="id">{{ text }}</div>
</div>
`,
});
<my-comp class-name="aaa" id="cid" />
渲染到页面中的结构如下图,页面效果就不放了,就一句话嘛
其实在最开始接触 vue3 乃至最开始接触 vue 的时候我就有好奇过这么几个问题
- 组件的本质是什么?
props是什么?- 为什么
tamplate中能拿到props和setup中的数据? - 为什么能够渲染出
template中的结构? - 为什么此处的
id='cid'会挂载到外层的div上?
上述问题暂时先不予解答,看完本篇都会懂的,先接着往下看
用用我写的
用了官方版本,再来看看我的乞丐版本,如下
const MyComp = {
props: ['className'],
setup() {
return { text: 'this is a text' };
},
render(ctx) {
return h('div', null, [
h('div', { class: ctx.className, id: ctx.id }, ctx.text),
]);
},
};
render(
h(
MyComp,
{
className: 'aaa',
id: 'cid',
},
null
),
document.body
);
渲染效果和上面的例子是一样的
那么从这两个对比中,就可以大概的看出一些东西
- 此处用了一个对象来保存组件的一些属性和方法,即
MyComp - 此处用
render实现了template的功能 MyComp.render函数中接收了一个ctx来获取props和setup中的数据- 此处调用
h函数生成了 "一个MyComp类型的元素",并且把它渲染到页面就是我们想要的组件
虽然还不能窥其全貌,但已经可以初见端倪,我们可以把此处的 MyComp 对象理解为组件的一个配置对象,其中包含了一些组件的相关信息,而渲染组件时会根据这个配置对象来生成节点树并渲染到页面
在正式开始写之前
经过上述分析,如果你觉得已经可以开始敲代码了,那就大错特错了,此处要先解释一下 props 这个东西,其实就是 property。实际上,property 是可以在组件上注册的一些自定义属性(attribute),这句话不是我说的,是尤大说的
那么 attribute 又是什么呢,我暂时的理解就是 attribute 是元素标签的属性,有大量的 attribute,如 class、id、style,而 property 则是一些自定义的属性。再深入一点说,我的理解是这样的
attribute是元素标签的属性,用于视图层property是元素对象的属性,用于逻辑层
此处的定义并不太准确,但大概就那么个意思,而这样的理解的话,两者区别就非常明了了,比如我们需要给某个节点定义类名用于指定样式,那显然它是 attribute,而这个节点需要一个 isShow 来控制是否该显示,那么这必定是个 property,但这两者从本质上说其实都是 attribute
写之前再分析一下
前面说了那么多,我们到底该如何在组件范围内实现数据的流通呢,比如 render 中用到了 setup 暴露的数据,该如何拿到?
其实这里的做法非常简单,只需要把数据全部保存在一个对象里面就好了,而这个对象就是组件对应的实例对象,以下称为 instance,这不是我说的,vue3 就是这么做的。那么我们要做的事情也就非常清晰了
- 使用一个实例对象保存组件的相关数据
- 渲染时使用实例对象上
render函数暴露的节点树
而问题也就来到了 instance 上该保存哪些属性,首先必须得有的 property、attribute、context,那么 context 用来保存上下文环境的话,其中应该包括什么呢?
从上面例子中可以看出,组件渲染时可以使用的数据有 props 中声明接收的,setup 函数暴露出来的,这些都应该包含在上下文环境 context 中,那么又有问题了,我们该怎么拿到 props、setup 呢?
如上文所说,组件配置对象在 h 函数创建 VNode 的时候充当 type 的角色,那么我们就可以通过这个组件配置对象来获取其中的 props 和 setup
现在就确实是可以开始写代码了
终于要写了
废话了那么多终于开始写了
初始化 instance
首先就应该写挂载组件的函数了,也是把之前的坑填上,经过上面漫长的铺垫,我们的前几步要做的事情清晰又明了
- 获取组件配置对象
- 创建
instance - 初始化属性
- 运行
setup - 初始化上下文
const mountComponent = (vnode, container) => {
const { type: Component } = vnode;
const instance = {
props: null,
attrs: null,
setupState: null,
ctx: null,
}
// 初始化 props
// 后面实现
initProps(instance, vnode);
const { setup } = Component;
if (setup) {
// 这里偷懒了,其实 setupContext 还有 slots 和 emits
const setupContext = { attrs: instance.attrs };
const setupResult = setup(instance.props, setupContext);
instance.setupState = setupResult;
// 源码中通过 proxyRefs 代理 setup 函数返回的对象
// 意味着在 render 里面不需要通过 .value 的方式获取响应式数据的值
// 但我偷懒了 :)
// 源码中长这样
// handleSetupResult(instance, setupResult);
}
instance.ctx = {
...instance.props,
...instance.setupState,
}
// 未完待续...
}
初始化 props
来写初始化 props 的函数,其实这个初始化非常简单,节点的属性会定义在 vnode.props 中,只需要将 vnode.props 中声明接收的放 instance.props 中,没声明接收的放到 instance.attrs 中即可,不过有个需要注意的就是 props 的改变会引起组件的更新,那么很自然的就是想到用 reactive 封装一下,代码如下
const initProps = (instance, vnode) => {
const { type: Component, props: vnodeProps } = vnode;
// 初始化
const props = (instance.props = {});
const attrs = (instance.attrs = {});
for (const key in vnodeProps) {
if (Component.props && Component.props.includes(key)) {
props[key] = vnodeProps[key];
} else {
attrs[key] = vnodeProps[key];
}
}
// 代理一下
instance.props = reactive(instance.props);
}
组件挂载 & 更新
组件的挂载更新操作就很有说法了,因为要进行更新必定是要走 patch 流程,那么其中的 n1 和 n2 又要从哪来呢
其实这个问题的答案也非常简单,n1 和 n2 实际上就是 VNode,而组件的 VNode,源码中称为 subTree,这东西是组件配置对象内部的 render 方法返回的,只需要存在 instance 里面就好了
而前面用 reactive 代理了 props,要实现组件的响应式更新的话,只需要用 effect 进行监听即可
以下开始写代码,首先需要拓展 instance,如下
// 其他代码省略
const instance = {
props: null,
attrs: null,
setupState: null,
ctx: null,
// 拓展以下属性
subTree: null,
update: null,
isMounted: false,
}
而如上所说,组件的挂载更新方法都需要用 effect 进行监听,因此会变成下面这样
instance.update = effect(() => {
if (!instance.isMounted) {
// mount...
} else {
// update...
}
});
以下再来进行细说
挂载
组件的挂载操作,就拿到组件配置对象中的 render 函数返回的 subTree,直接挂载就可以了,需要注意的就是 render 需要传入一个上下文环境,如下
if (!instance.isMounted) {
const subTree = (instance.subTree = Component.render(instance.ctx));
patch(null, subTree, container);
vnode.el = subTree.el;
instance.isMounted = true;
} else {
// update...
}
更新
更新操作的话只要拿到原先的 subTree,再重新渲染一次 subTree,两个直接拿去 patch 就可以了
if (!instance.isMounted) {
// mount...
} else {
const prevSubTree = instance.subTree;
const nextSubTree = (instance.subTree = Component.render(instance.ctx));
patch(prevSubTree, nextSubTree, container);
vnode.el = subTree.el;
}
process & update
完成以上逻辑,剩下的都是小事情,之前留的坑这波是填上了。而 processComponent 中和之前写过的其他 processxxx 逻辑是一样的,都是根据 n1 是否存在来判断该挂载还是更新
const processComponent = (n1, n2, container) => {
if (n1) {
// 源码中有 shouldUpdateComponent 判断是否该更新组件
// 这里偷懒了,每次都更新
updateComponent(n1, n2);
} else {
mountComponent(n2, container);
}
}
更新的逻辑只要能复用 n1 的组件实例然后调用 instance 上的 update 即可,那么如何在 updateComponent 中拿到组件实例 instance 呢
这里的处理非常简单,mountComponent 和 updateComponent 产生联系的就是 vnode,只要保存在之前留的坑 vnode.component 里即可,那么 mountComponent 中我们就还需要将 instance 保存一下
const mountComponent = (vnode, container) => {
// ...
const instance = (vnode.component = {
// ...
});
// ...
}
上述处理之后,我们的 updateComponent 中就可以直接这样处理
const updateComponent = (n1, n2) => {
n2.component = n1.component;
n2.component.update();
}
unmountComponent
组件的卸载也也也是非常想当然,直接把组件的 VNode,也就是 subTree 卸载即可
const unmountComponent = vnode => {
// 源码里没这么简单
// 因为还要处理生命周期之类的
// 但我偷懒了 :)
unmount(vnode.component.subTree);
}
还有一件事
前面提到一个比较细节的问题,为什么下图中的 id='cid' 会挂载到外层 div 上,此处也是可以进行解答了
app.component('my-comp', {
props: ['className'],
setup() {
return { text: 'this is a text' };
},
template: `
<div>
<div :class="className" :id="id">{{ text }}</div>
</div>
`,
});
<my-comp class-name="aaa" id="cid" />
相信认真看过 文档 的兄弟们都知道这么一个概念就是 non-prop attributes,也就是 attribute 的继承问题,名字比较拗口,以下简称为 我是一个没有被prop接收的attribute,那么先来看看文档中的说法
图中的意思就是,你给组件绑定一个属性,但他没有被 props 接收的话,这个属性会默认应用在组件的根节点上
在上面上面的例子中体现为,我给 my-comp 组件绑定了一个 id 属性,但我并没有在 props 中声明接收,因此就会把 id 属性挂载到 my-comp 的根组件下,当然这一切的前提是我的 my-comp 组件只有一个根节点
那么按照这个说法,我只要在 props 中声明接收 id,就可以正常的挂载到内部的 div 上,代码如下
app.component('my-comp', {
props: ['className', 'id'], // 此处声明接收了 id
setup() {
return { text: 'this is a text' };
},
template: `
<div>
<div :class="className" :id="id">{{ text }}</div>
</div>
`,
});
<my-comp class-name="aaa" id="cid" />
效果如下所示,尤大诚不欺我
那么这个 我是一个没有被prop接收的attribute 该怎么实现呢?
inheritAttrs 实现
这里的实现有点绕,但逻辑上来说是简单的,需要区分好 VNode.props 和 instance.props
- 首先这两者其实是一个父子集的关系,
VNode.props通过initProps过滤后产生了instance.props VNode.props是挂载在节点上的一些属性,是通过patchProps挂载的,比如onClick、style、class等instance.props保存了组件内部的一部分上下文,虽然来源是VNode.props,但我个人的理解是到这里的时候,instance.props是作为执行上下文的存在,而不只是节点属性,因为那些挂载在元素标签上的属性都到instance.attrs里去了
那么这里就只需要将 instance.attrs 添加进 VNode.props 让他挂载就可以了,代码如下
const inheritAttrs = (instance, subTree) => {
const { attrs } = instance;
const { props } = subTree;
if (attrs) {
subTree.props = {
...props,
...attrs,
};
}
}
而进行 attributes 继承的时机当然也就是在组件的挂载和更新时,如下
if (!instance.isMounted) {
const subTree = (instance.subTree = Component.render(instance.ctx));
// 继承
inheritAttrs(instance, subTree);
patch(null, subTree, container);
vnode.el = subTree.el;
instance.isMounted = true;
} else {
const prevSubTree = instance.subTree;
const nextSubTree = (instance.subTree = Component.render(instance.ctx));
// 继承
inheritAttrs(instance, nextSubTree);
patch(prevSubTree, nextSubTree, container);
vnode.el = subTree.el;
}
详细回答一下之前的问题
顺便当是 Q&A 环节,老规矩,都是个人见解,错了就喷我
Q: 组件的本质是什么?
A: 组件实际上是一些元素的集合体,从各种角度上来说都是如此。从页面的角度来说,组件是一棵节点树 subTree,而从逻辑的角度来说,组件实际上是一个实例对象 instance。不过话虽如此,我可以渲染一堆标签说这是组件么,可以,但没意义,组件和一堆标签之间最本质的区别我个人认为是数据的流通,组件有自己的上下文环境,其中的数据可以在这棵节点树上的任何一个节点中使用。这个说法也同样可以用于逻辑角度,我创建一堆 VNode 说这是个组件可以么,可以,但没意义,因为没有数据的流通,即没有在同一个独立的上下文环境内。以此为基础,以下是我的结论,组件是在同一个上下文环境内且有数据流通的复数元素集合体
Q: props 是什么?
A: 引用文档的说法,props 是可以在组件上注册的一些自定义属性,我斗胆给这句话拓展一下,instance.props 是组件内部上下文环境的一部分,可以用于接收一些自定义的属性数据,最终可以在组件内部使用。而此处对这个问题再拓展一下, props 和 attrs 最大的差别不应该集中在"是否被接收",而是"是否自定义",自定义的是 props,非自定义的是 attrs。当然也可以像文中例子那样接收一个 class、id 什么的来在组件内部使用,但我只是举例子图方便,正常开发没人这么写,我寻思没有十年脑血栓真的写不出这代码,肯定会有比这更好的处理方式
Q: 为什么 tamplate 中能拿到 props 和 setup 中的数据?
A: 此处剧透一下,相信大家也都多少有点了解,vue 的 template 语法其实是自己写了一个编译器来处理,最终也是会编译成很多的 render 和 h,因此 template 中能拿到 props 和 setup 中的数据是因为 instance 实例对象成为一个桥梁,模板中可以直接通过 instance.ctx 来获取其中的数据
Q: 为什么能够渲染出 template 中的结构?
A: 正文中一直提到的"组件配置对象"(我确实不知道该叫什么)在 h 函数中充当 type 的角色,因此就可以像上文那样把 type 解构出来,再以此来获取其中的 render 方法,以此就可以获取 template 中的节点树 subTree 了
Q: 为什么例子中的 id='cid' 会挂载到外层的 div 上?
A: 正文已经详细解释过了 我是一个没有被prop接收的attribute 的定义,谜底就在谜面上,因为例子中 id 没有被 props 接收,因此默认它是一个 attrs,则直接挂载在了组件的根节点上,至于为什么会挂载在根节点上而不是其他什么节点,建议回去再好好看看我的 render 实现和 patch 实现
Q: 可以从源码的角度解释一下为什么 setup 函数中不能使用 this 么?
A: 可以,首先按照文档中的说法,setup 不会指向当前实例对象,也不会指向组件配置对象,其实是会指向 window,具体原因可以仔细看一下 setup 函数的调用,不过想要在 setup 中使用 this 其实很简单,只需要像下面这样大逆不道的改一下就可以让 setup 中的 this 指向 instance 实例对象
const setupResult = setup.call(instance, instance.props, setupContext);
但说老实话,这完全没意义,或者说目前在 setup 里没有使用 this 的机会和场景,所有会用到的上下文环境都在 setup 的两个参数里,分别是 props 和 ctx(我偷懒只写了个 attrs 的那个),而其他的生命周期钩子也都有相应的 setup 版本。而回到问题本身,setup 函数的调用时机是在 props 初始化之后,此时 instance 实例对象虽然已经存在了,而在源码中,其他的 optionsAPI 此时还没开始解析,也就是说 this 的指向与其他 optionsAPI 中的指向不同,因此就没有进行处理,而是换了其他思路,将上下文环境保存在 setup 的两个参数中给你用,也就不需要使用 this 了
Q: 我不会 compositionAPI 还不能用你写的 beggar-vue 了还??
A: 我这个 beggar-vue 根本就不是做来生产的。以上
总结
个人感觉需要理解组件的本质,在实现中也可以看到基本上所有代码都是围绕 instance 在转,而组件这个东西在逻辑的角度来说确实就只是一个对象而已。此外就是需要思考清楚各个属性的作用和关系,这个真的非常重要,比如 props 里是什么,attrs 里是什么,他们有什么关系,区分清楚了,这篇文章你就基本都能看懂了
而其他具体的实现步骤在上面已经很详细了,就不多做赘述了,最后放个完整 mountComponent
const mountComponent = (vnode, container) => {
const { type: Component } = vnode;
const instance = (vnode.component = {
props: null,
attrs: null,
setupState: null,
ctx: null,
subTree: null,
update: null,
isMounted: false,
});
initProps(instance, vnode);
const { setup } = Component;
if (setup) {
const setupContext = { attrs: instance.attrs };
const setupResult = setup(instance.props, setupContext);
instance.setupState = setupResult;
}
instance.ctx = {
...instance.props,
...instance.setupState,
}
instance.update = effect(() => {
if (!instance.isMounted) {
const subTree = (instance.subTree = Component.render(instance.ctx));
inheritAttrs(instance, subTree);
patch(null, subTree, container);
vnode.el = subTree.el;
instance.isMounted = true;
} else {
const prevSubTree = instance.subTree;
const nextSubTree = (instance.subTree = Component.render(instance.ctx));
inheritAttrs(instance, nextSubTree);
patch(prevSubTree, nextSubTree, container);
vnode.el = subTree.el;
}
});
}
const initProps = (instance, vnode) => {
const { type: Component, props: vnodeProps } = vnode;
const props = (instance.props = {});
const attrs = (instance.attrs = {});
for (const key in vnodeProps) {
if (Component.props && Component.props.includes(key)) {
props[key] = vnodeProps[key];
} else {
attrs[key] = vnodeProps[key];
}
}
instance.props = reactive(instance.props);
}
const inheritAttrs = (instance, subTree) => {
const { attrs } = instance;
const { props } = subTree;
if (attrs) {
subTree.props = {
...props,
...attrs,
};
}
}