12. 组件的实现原理
12.1 渲染组件
从用户的角度来看,一个有状态组件就是一个选项对象,但是,如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟 DOM 节点。实际上,组件本身是对页面内容的封装,它用来描述页面内容的一部分
const MyComponent = {
// 组件名称,可选
name: "MyComponent",
// 组件的渲染函数,其返回值必须为虚拟 DOM
render() {
// 返回虚拟 DOM
return {
type: "div",
children: `我是文本内容`,
};
},
};
// 用来描述组件的 VNode 对象,type 属性值为组件的选项对象
const CompVNode = {
type: MyComponent,
};
// 调用渲染器来渲染组件
renderer.render(CompVNode, document.querySelector("#app"));
12.2 组件状态与自更新
const MyComponent = {
name: "MyComponent",
// 用 data 函数来定义组件自身的状态
data() {
return {
foo: "hello world",
};
},
render() {
return {
type: "div",
children: `foo 的值是: ${this.foo}`, // 在渲染函数内使用组件状态
};
},
};
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type;
const { render, data } = componentOptions;
// 调用 data 函数得到原始数据,并调用 reactive 函数将其包装为响应式数据
const state = reactive(data());
// 将组件的 render 函数调用包装到 effect 内
effect(
() => {
// 调用 render 函数时,将其 this 设置为 state,
// 从而 render 函数内部可以通过 this 访问组件自身状态数据
const subTree = render.call(state, state);
patch(null, subTree, container, anchor);
},
{
// 指定该副作用函数的调度器为 queueJob 即可
scheduler: queueJob,
}
);
}
// 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重
const queue = new Set();
// 一个标志,代表是否正在刷新任务队列
let isFlushing = false;
// 创建一个立即 resolve 的 Promise 实例
const p = Promise.resolve();
// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {
// 将 job 添加到任务队列 queue 中
queue.add(job);
// 如果还没有开始刷新队列,则刷新之
if (!isFlushing) {
// 将该标志设置为 true 以避免重复刷新
isFlushing = true;
// 在微任务中刷新缓冲队列
p.then(() => {
try {
// 执行任务队列中的任务
queue.forEach((job) => job());
} finally {
// 重置状态
isFlushing = false;
queue.clear = 0;
}
});
}
}
12.3 组件实例与组件的生命周期
组件实例本质上就是一个状态集合(或一个对象),它维护着组件运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树(subTree
)、组件是否已经被挂载、组件自身的状态(data
),等等。
实际上,我们可以在需要的时候,任意地在组件实例 instance 上添加需要的属性。但需要注意的是,我们应该尽可能保持组件实例轻量,以减少内存占用。
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type;
// 从组件选项对象中取得组件的生命周期函数
const {
render,
data,
beforeCreate,
created,
beforeMount,
mounted,
beforeUpdate,
updated,
} = componentOptions;
// 在这里调用 beforeCreate 钩子
beforeCreate && beforeCreate();
const state = reactive(data());
const instance = {
// 组件自身的状态数据,即 data
state,
// 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
isMounted: false,
// 组件所渲染的内容,即子树(subTree)
subTree: null,
};
// 将组件实例设置到 vnode 上,用于后续更新
vnode.component = instance;
// 在这里调用 created 钩子
created && created.call(state);
effect(
() => {
// 调用组件的渲染函数,获得子树
const subTree = render.call(state, state);
// 检查组件是否已经被挂载
if (!instance.isMounted) {
// 在这里调用 beforeMount 钩子
beforeMount && beforeMount.call(state);
// 初次挂载,调用 patch 函数第一个参数传递 null
patch(null, subTree, container, anchor);
// 重点:将组件实例的 isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作,
// 而是会执行更新
instance.isMounted = true;
// 在这里调用 mounted 钩子
mounted && mounted.call(state);
} else {
// 在这里调用 beforeUpdate 钩子
beforeUpdate && beforeUpdate.call(state);
// 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
// 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
// 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
patch(instance.subTree, subTree, container, anchor);
// 在这里调用 updated 钩子
updated && updated.call(state);
}
// 更新组件实例的子树
instance.subTree = subTree;
},
{ scheduler: queueJob }
);
}
12.4 props 与组件的被动更新
对于一个组件来说,只需要关心为组件传递的 props
数据和组件选项对象中定义的 props
选项这两部分
<MyComponent title="A Big Title" :other="val" />
const vnode = {
type: MyComponent,
props: {
title: "A big Title",
other: this.val,
},
};
const MyComponent = {
name: "MyComponent",
// 组件接收名为 title 的 props,并且该 props 的类型为 String
props: {
title: String,
},
render() {
return {
type: "div",
children: `count is: ${this.title}`, // 访问 props 数据
};
},
};
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type;
// 从组件选项对象中取出 props 定义,即 propsOption
const { render, data, props: propsOption /* 其他省略 */ } = componentOptions;
beforeCreate && beforeCreate();
const state = reactive(data());
// 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
const [props, attrs] = resolveProps(propsOption, vnode.props);
const instance = {
state,
// 将解析出的 props 数据包装为 shallowReactive 并定义到组件实例上
props: shallowReactive(props),
isMounted: false,
subTree: null,
};
vnode.component = instance;
// 省略部分代码
}
// resolveProps 函数用于解析组件 props 和 attrs 数据
function resolveProps(options, propsData) {
const props = {};
const attrs = {};
// 遍历为组件传递的 props 数据
for (const key in propsData) {
if (key in options) {
// 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,则将其视为合法的 props
props[key] = propsData[key];
} else {
// 否则将其作为 attrs
attrs[key] = propsData[key];
}
}
// 最后返回 props 与 attrs 数据
return [props, attrs];
}
props
本质上是父组件的数据,当 props
发生变化时,会触发父组件重新渲染,在更新过程中,渲染器发现父组件的 subTree
包含组件类型的虚拟节点,所以会调用 patchComponent
函数完成子组件的更新
由父组件自更新引起的子组件更新叫作子组件的被动更新。当子组件发生被动更新时,需要:
- 检测子组件是否真的需要更新,因为子组件的
props
可能是不变的; - 如果需要更新,则更新子组件的
props
、slots
等内容。
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (typeof type === "string") {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
} else if (typeof type === "object") {
// vnode.type 的值是选项对象,作为组件来处理
if (!n1) {
mountComponent(n2, container, anchor);
} else {
// 更新组件
patchComponent(n1, n2, anchor);
}
}
}
function patchComponent(n1, n2, anchor) {
// 获取组件实例,即 n1.component,同时让新的组件虚拟节点 n2.component 也指向组件实例
const instance = (n2.component = n1.component);
// 获取当前的 props 数据
const { props } = instance;
// 调用 hasPropsChanged 检测为子组件传递的 props 是否发生变化,如果没有变化,则不需要更新
if (hasPropsChanged(n1.props, n2.props)) {
// 调用 resolveProps 函数重新获取 props 数据
const [nextProps] = resolveProps(n2.type.props, n2.props);
// 更新 props
for (const k in nextProps) {
props[k] = nextProps[k];
}
// 删除不存在的 props
for (const k in props) {
if (!(k in nextProps)) delete props[k];
}
}
}
function hasPropsChanged(prevProps, nextProps) {
const nextKeys = Object.keys(nextProps);
// 如果新旧 props 的数量变了,则说明有变化
if (nextKeys.length !== Object.keys(prevProps).length) {
return true;
}
for (let i = 0; i < nextKeys.length; i++) {
const key = nextKeys[i];
// 有不相等的 props,则说明有变化
if (nextProps[key] !== prevProps[key]) return true;
}
return false;
}
12.5 setup 函数的作用与实现
在组件的整个生命周期中,setup 函数只会在被挂载时执行一次,它的返回值可以有两种情况
- 返回一个函数,该函数将作为组件的 render 函数
const Comp = {
setup() {
// setup 函数可以返回一个函数,该函数将作为组件的渲染函数
return () => {
return { type: "div", children: "hello" };
};
},
};
- 返回一个对象,该对象中包含的数据将暴露给模板使用
const Comp = {
setup() {
const count = ref(0);
// 返回一个对象,对象中的数据会暴露到渲染函数中
return {
count,
};
},
render() {
// 通过 this 可以访问 setup 暴露出来的响应式数据
return { type: "div", children: `count is: ${this.count}` };
},
};
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type;
// 从组件选项中取出 setup 函数
let { render, data, setup /* 省略其他选项 */ } = componentOptions;
beforeCreate && beforeCreate();
const state = data ? reactive(data()) : null;
const [props, attrs] = resolveProps(propsOption, vnode.props);
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
};
// 暂时只要 attrs
const setupContext = { attrs };
// 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值,
// 将 setupContext 作为第二个参数传递
const setupResult = setup(shallowReadonly(instance.props), setupContext);
// setupState 用来存储由 setup 返回的数据
let setupState = null;
// 如果 setup 函数的返回值是函数,则将其作为渲染函数
if (typeof setupResult === "function") {
// 报告冲突
if (render) console.error("setup 函数返回渲染函数,render 选项将被忽略");
// 将 setupResult 作为渲染函数
render = setupResult;
} else {
// 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
setupState = setupResult;
}
vnode.component = instance;
// 创建渲染上下文对象,本质上是组件实例的代理
const renderContext = new Proxy(instance, {
get(t, k, r) {
// 取得组件自身状态与 props 数据
const { state, props } = t;
if (state && k in state) {
return state[k];
} else if (k in props) {
return props[k];
} else if (setupState && k in setupState) {
// 渲染上下文需要增加对 setupState 的支持
return setupState[k];
} else {
console.error("不存在");
}
},
set(t, k, v, r) {
const { state, props } = t;
if (state && k in state) {
state[k] = v;
} else if (k in props) {
console.warn(`Attempting to mutate prop "${k}". Props are readonly.`);
} else if (setupState && k in setupState) {
// 渲染上下文需要增加对 setupState 的支持
setupState[k] = v;
} else {
console.error("不存在");
}
},
});
// 省略部分代码
}
12.6 组件事件与 emit 的实现
<MyComponent @change="handler" />
const CompVNode = {
type: MyComponent,
props: {
onChange: handler,
},
};
在具体的实现上,发射自定义事件的本质就是根据事件名称去 props
数据对象中寻找对应的事件处理函数并执行
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
};
// 定义 emit 函数,它接收两个参数
// event: 事件名称
// payload: 传递给事件处理函数的参数
function emit(event, ...payload) {
// 根据约定对事件名称进行处理,例如 change --> onChange
const eventName = `on${event[0].toUpperCase() + event.slice(1)}`;
// 根据处理后的事件名称去 props 中寻找对应的事件处理函数
const handler = instance.props[eventName];
if (handler) {
// 调用事件处理函数并传递参数
handler(...payload);
} else {
console.error("事件不存在");
}
}
// 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
const setupContext = { attrs, emit };
// 省略部分代码
}
由于没有显式地声明为 props
的属性都会存储到 attrs 中。所以在解析 props
数据的时候对事件类型的 props
需要做特殊处理
function resolveProps(options, propsData) {
const props = {};
const attrs = {};
for (const key in propsData) {
// 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
if (key in options || key.startsWith("on")) {
props[key] = propsData[key];
} else {
attrs[key] = propsData[key];
}
}
return [props, attrs];
}
12.7 插槽的工作原理与实现
组件的插槽指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入
MyComponent 组件
<template>
<header><slot name="header" /></header>
<div>
<slot name="body" />
</div>
<footer><slot name="footer" /></footer>
</template>
// MyComponent 组件模板的编译结果
function render() {
return [
{
type: "header",
children: [this.$slots.header()],
},
{
type: "body",
children: [this.$slots.body()],
},
{
type: "footer",
children: [this.$slots.footer()],
},
];
}
父组件模板
<MyComponent>
<template #header>
<h1>我是标题</h1>
</template>
<template #body>
<section>我是内容</section>
</template>
<template #footer>
<p>我是注脚</p>
</template>
</MyComponent>
// 父组件的渲染函数
function render() {
return {
type: MyComponent,
// 组件的 children 会被编译成一个对象
children: {
header() {
return { type: "h1", children: "我是标题" };
},
body() {
return { type: "section", children: "我是内容" };
},
footer() {
return { type: "p", children: "我是注脚" };
},
},
};
}
在运行时的实现上,插槽则依赖于 setupContext
中的 slots
对象。但为了在 render
函数内和生命周期钩子函数内能够通过 this.$slots
来访问插槽内容,还需要在 renderContext
中特殊对待 $slots
属性
function mountComponent(vnode, container, anchor) {
// 省略部分代码
// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {};
// 将 slots 对象添加到 setupContext 中
const setupContext = { attrs, emit, slots };
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
// 将插槽添加到组件实例上
slots,
};
// 省略部分代码
const renderContext = new Proxy(instance, {
get(t, k, r) {
const { state, props, slots } = t;
// 当 k 的值为 $slots 时,直接返回组件实例上的 slots
if (k === "$slots") return slots;
// 省略部分代码
},
set(t, k, v, r) {
// 省略部分代码
},
});
// 省略部分代码
}
12.8 注册生命周期
每当初始化组件并执行组件的 setup
函数之前,先将 currentInstance
设置为当前组件实例,再执行组件的 setup
函数,这样就可以通过 currentInstance
来获取当前正在被初始化的组件实例,从而将那些通过 onMounted
函数注册的钩子函数与组件实例进行关联
// 全局变量,存储当前正在被初始化的组件实例
let currentInstance = null;
// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
currentInstance = instance;
}
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
slots,
// 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
mounted: [],
};
// 省略部分代码
// setup
const setupContext = { attrs, emit, slots };
// 在调用 setup 函数之前,设置当前组件实例
setCurrentInstance(instance);
// 执行 setup 函数
const setupResult = setup(shallowReadonly(instance.props), setupContext);
// 在 setup 函数执行完毕之后,重置当前组件实例
setCurrentInstance(null);
// 省略部分代码
}
function onMounted(fn) {
if (currentInstance) {
// 将生命周期函数添加到 instance.mounted 数组中
currentInstance.mounted.push(fn);
} else {
console.error("onMounted 函数只能在 setup 中调用");
}
}
最后在合适的时机调用这些注册到 instance.mounted 数组中的生命周期钩子函数
function mountComponent(vnode, container, anchor) {
// 省略部分代码
effect(
() => {
const subTree = render.call(renderContext, renderContext);
if (!instance.isMounted) {
// 省略部分代码
// 遍历 instance.mounted 数组并逐个执行即可
instance.mounted &&
instance.mounted.forEach((hook) => hook.call(renderContext));
} else {
// 省略部分代码
}
instance.subTree = subTree;
},
{
scheduler: queueJob,
}
);
}