前言:在之前的渲染器中讲了如何将VNode渲染成真实元素.虚拟DOM的
Type
属性决定了我们如何处理不同类型的节点,需要采用不同的处理方法来完成挂载和更新,例如处理HTML标签,只需要判断type是不是String
,如果是就进行以下操作
- 判断需要渲染的VNode是否为空(
代表卸载
),直接调用unmount
函数- 否则就代表挂载或更新,如果是挂载调用
mountElement
.如果是更新调用patchElement
- 在
patchElement
中,此时新旧节点type相同,先更新一下props
- 再进入
patchChildren
,这里需要根据新旧子节点的类型做出不同反应.- 如果新旧节点的children
都是数组
,需要进入核心diff
.
那么如果我们现在遇到了一个组件,应该怎么处理它呢.首先我们先来看看组件里有什么.
一个组件必须包含一个渲染函数
,即 render 函数,并且render的返回值应该是虚拟 DOM
。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口,
const MyComponent = {
// 组件名称,可选
name: 'MyComponent',
// 组件的渲染函数,其返回值必须为虚拟 DOM
render() {
// 返回虚拟 DOM
return {
type: 'div',
children: `我是文本内容`
}
}
}
其实对于上面的组件,对应的 type
是MyComponent,也就是一个 对象
在 patch
中如何处理组件呢,和处理普通标签类似.如果是挂载执行 mountComponent
,更新执行 patchComponent
.
先来看 mountComponent
函数,其具体实现如下所示.
其实就是拿到VNode.type
,即拿到了组件里相关的所有内容,执行其中的渲染函数获得VNode
,再用获取到的虚拟DOm当做patch的参数进行挂载
.
// 用来描述组件的 VNode 对象,type 属性值为组件的选项对象
const CompVNode = {
type: MyComponent
}
// 调用渲染器来渲染组件
renderer.render(CompVNode, document.querySelector('#app'))
function mountComponent(vnode, container, anchor) {
// 通过 vnode 获取组件的选项对象,即 vnode.type
const componentOptions = vnode.type
// 获取组件的渲染函数 render
const {
render } = componentOptions
// 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
const subTree = render()
// 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
patch(null, subTree, container, anchor)
}
我们还要为组件设计自身的状态,即使用 data
函数来定义组件自身的状态,同时可以在渲染函数中通过this
访问由 data 函数返回的状态数据
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 函数时,将其 this 设置为 state,
// 从而 render 函数内部可以通过 this 访问组件自身状态数据
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
}
也就是将data包装为响应式,再将render函数的指向修改为proxy后的data
当组件自身状态(data)发生变化时,我们需要有能力触发组件更新,即组件的自更新
。
为此,我们需要将整个渲染任务包装到一个 effect 中
,也就是执行mountComponent
时注册一个副作用,如果代理后的data发生了改变,会再次执行这个副作用函数
,也就是重新执行渲染函数
effect(() => {
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
})
但是如果多次修改响应式数据的值,将会导致渲染函数执行多次,这实际上是没有必要的。因此,我们需要实现一个
调度器
,当副作用函数需要重新执行时,我们不会立即执行它,而是将它缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行.之前讲响应式原理的时候有提过这里不再赘述
不过,上面这段代码存在缺陷。可以看到,我们在 effect 函数内调用 patch 函数完成渲染时,第一个参数总是 null。这意味着,每次更新发生时都会进行全新的挂载,而不会打补丁,这是不正确的。
与处理普通标签对比,mountElement
中会创建真实DOM,并把对应的引用保留到VNode中const el = vnode.el = createElement(vnode.type)
patchElement
会将这个引用传递下来const el = n2.el = n1.el
- 与之类似,在
mountComponent
中创建一个组件实例
,也就是一个对象.用来维护着组件运行过程中的所有信息
,例如组件渲染的子树(subTree)、组件是否已经被挂载、组件自身的状态 (data)、生命周期.再把组件实例设置到 vnode 上(不是组件里渲染函数返回的虚拟DOM) - 在
patchComponent
中也会把旧VNode的实例挂载到新VNode上 - 判断实例中的
isMounted
,为false代表挂载,patch第一个参数是null,执行之后修改isMounted,代表已挂载.true代表更新,patch第一个参数是实例instance中的subTree - 最后不管是挂载还是更新,都需要
更新实例中的subTree
对于组件更新,你暂时不需要知道新VNode是怎么生成的.反正你更新了就是会给我一个新虚拟Dom
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const {
render, data } = componentOptions
const state = reactive(data())
// 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信
const instance = {
// 组件自身的状态数据,即 data
state,
// 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
isMounted: false,
// 组件所渲染的内容,即子树(subTree)
subTree: null
}
// 将组件实例设置到 vnode 上,用于后续更新
vnode.component = instance
effect(() => {
// 调用组件的渲染函数,获得子树
const subTree = render.call(state, state)
// 检查组件是否已经被挂载
if (!instance.isMounted) {
// 初次挂载,调用 patch 函数第一个参数传递 null
patch(null, subTree, container, anchor)
// 重点:将组件实例的 isMounted 设置为 true,这样当更新发生时就不再次进行挂载操作,
// 而是会执行更新
instance.isMounted = true
} else {
// 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
// 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
// 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
patch(instance.subTree, subTree, container, anchor)
}
// 更新组件实例的子树
instance.subTree = subTree
}, {
scheduler: queueJob })
}
在上面的实现中,组件实例的 instance.isMounted
属性可以用来区分组件的挂载和更新
。因此,我们可以在合适的时机调用组件对应的生命周期钩子
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 = {
state,
isMounted: false,
subTree: null,
};
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, subTree, container, anchor);
instance.isMounted = true;
// 在这里调用 mounted 钩子
mounted && mounted.call(state);
} else {
// 在这里调用 beforeUpdate 钩子
beforeUpdate && beforeUpdate.call(state);
patch(instance.subTree, subTree, container, anchor);
// 在这里调用 updated 钩子
updated && updated.call(state);
}
instance.subTree = subTree;
},
{
scheduler: queueJob }
);
}
可以看到两个before执行的时机是在执行patch函数之前执行的.
对于组合式的写法:从type
中取得注册到组件上的生命周期函数
,然后在合适的时机调用
它们,这其实就是组件生命周期的实现原理
.
接下来就是对组件上props
的处理
对应的
<Demo title="title" :flag=flag />
VNode = {
type: Demo,
props: {
title: 'title',
flag: this.flag
}
}
拿父向子通信来说,将数据传递给子组件,在子组件中要声明props
选项,父组件传递的数据不在props选项中就会流到attrs
.VNode.props
也就是父组件传给子组件的数据,VNode.type.props
是子组件的props中声明的数据,由此我们很容易又可以得到attrs
.
并且把获取到的props
变成响应式存放到实例instance
中
处理完 props 数据后,我们再来讨论关于 props 数据变化
的问题。
props 本质上是父组件的数据,当 props 发生变化时,父组件会进行自更新。同时也会造成子组件的被动更新
,即调用 patchComponent
函数,它的作用是修改实例instance中的props
- 检测子组件是否真的需要更新,因为子组件的 props 可能是不变的
- 如果需要更新,则更新子组件的 props、slots 等内容。
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
}
这里要注意一下,子组件的更新是由本身数据发生改变更新还是父组件引起的更新
,处理方式有些不同
如果是主动更新,会触发mountComponent
注册的副作用函数,这里不再赘述.
如果是被动更新触发的是patchComponent
,会修改instance中的props
,它也是响应式的数据,所以也会触发mountComponent
注册的副作用函数
由于 props 数据与组件自身的状态数据都需要暴露到渲染函数中,并使得渲染函数能够通过 this 访问它们,因此我们需要封装一个渲染上下文对象
我们用proxy
拦截一下之前创建的实例instance
,里面含有state以及props,这样可以监听到对状态和props的读取和设置.
- 读取时,先读取组件自身状态,如果没有读取props.
- 设置时,只能设置组件自身状态,不能改变props.
- 生命周期函数,渲染函数调用时要
绑定渲染上下文对象
,也就是把它当成this.
并且使用proxy代理我们可以控制取消修改
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}
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) {
// 如果组件自身没有该数据,则尝试从props 中读取
return props[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}". Propsare readonly.`)
} else {
console.error('不存在')
}
}
})
// 生命周期函数调用时要绑定渲染上下文对象
created && created.call(renderContext)
// 省略部分代码
}
上面只是处理了选项式的写法,还有组合式的写法需要处理,这就不得不介绍setup函数了
举个例子:之前写选项式数据是从type
中解构出来的,但如果是组合式的写法数据是从setup函数的返回值
中获取,还有生命周期也不再是从type
中解构出来的,而是利用onMounted
等钩子在setup
中注册生命周期函数.
在组件的整个生命周期中, setup 函数只会在被挂载时执行一次
,它的返回值可以有两种情况
1.返回一个函数,该函数将作为组件的 render 函数
2.返回一个对象,该对象中包含的数据将暴露给模板使用
另外setup 函数接收两个参数。第一个参数是
props
数据对 象,第二个参数也是一个对象,通常称为setupContext
,其中保存着与组件接口相关的数据和方法,其中有slots:组件接收到的插槽
emit:一个函数,用来发射自定义事件。
attrs:当为组件传递 props 时,那些没有显式地声明为 props 的属性会存储到 attrs 对象中。
实现:
- 先从虚拟DOM的
type
属性中解构出setup
,props
.又可以得到attrs
. - 调用
setup
函数,将只读版本的props 作为第一个参数传递,第二个参数是一个对象里面包含attrs emit slot
. 可以理解为我们预先知道会给我们传递什么参数,函数体内也就可以使用参数,最后调用setup时他真的传递里参数过来. - 如果 setup 函数的返回值是函数,则将其作为渲染函数
- 如果 setup 的返回值不是函数,则作为数据状态赋值给
setupState
- 最后还要考虑return的数据怎么在render函数中使用,也就是要用到之前的渲染上下文
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,
};
// setupContext,由于我们还没有讲解 emit 和 slots,所以暂时只需要attrs;
const setupContext = {
attrs };
// 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值,
// 将 setupContext 作为第二个参数传递
const setupResult = setup(shallowReadonly(instance.props), tupContext);
// 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) {
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}". Propsare readonly.`);
} else if (setupState && k in setupState) {
// 渲染上下文需要增加对 setupState 的支持
setupState[k] = v;
} else {
console.error("不存在");
}
},
});
// 省略部分代码
}
补充一下setup返回的数据不是存放在instance的data
上,而是setupState
,也就是setup执行的返回结果
接下来是emit
的实现
父组件传递的props中不仅有数据还有方法.所以我们在mountComponent
函数中注册一个emit函数
它接收一个方法名和参数,我们只需要根据这个方法名在传递的props中找到对应的函数,并把它执行即可.之后将emit
函数添加到 setupContext
中
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() + ent.slice(1)}`;
// 根据处理后的事件名称去 props 中寻找对应的事件处理函数
const handler = instance.props[eventName];
if (handler) {
// 调用事件处理函数并传递参数
handler(...payload);
} else {
console.error("事件不存在");
}
}
// 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
const setupContext = {
attrs, emit };
// 省略部分代码
}
可以看到是在 instance.props
查找方法,但是之前说了对于没声明的props,都会放在attrs
.这也很容易解决:
- 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
插槽的实现
这里只介绍具名插槽
,先看如何使用
- 在子组件中声明插槽中对应节点的存放位置
<template> <header> <slot name="header" /> </header> <div> <slot name="body" /> </div> <footer> <slot name="footer" /> </footer> </template>;
- 把要传递的节点放在组件内,当成children
<MyComponent> <template #header> <h1>我是标题</h1> </template> <template #body> <section>我是内容</section> </template> <template #footer> <p>我是注脚</p> </template> </MyComponent>
- 父组件的VNode
{
type: MyComponent,
// 组件的 children 会被编译成一个对象
children: {
header() {
return {
type: 'h1', children: '我是标题' }
},
body() {
return {
type: 'section', children: '我是内容' }
},
footer() {
return {
type: 'p', children: '我是注脚' }
}
}
}
4.子组件的VNode
// MyComponent 组件模板的编译结果
function render() {
return [
{
type: 'header',
children: [this.$slots.header()]
},
{
type: 'body',
children: [this.$slots.body()]
},
{
type: 'footer',
children: [this.$slots.footer()]
}
]
}
所以VNode中的children即是slot
,在把它作为setup函数的参数,这样在setup函数中就能获取到插槽了.
function mountComponent(vnode, container, anchor) {
// 省略部分代码
// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {
}
// 将 slots 对象添加到 setupContext 中
const setupContext = {
attrs, emit, slots }
}
接下来要支持在生命周期或者渲染函数中使用this.$slot
可以拿到插槽内容,先将从type中解构出的slot挂载到instance
上
使用this.$slot
,this指向
已经绑定为renderContext
,它是对instance
的代理.当对renderContext
进行读取,判断key是否为$slot
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const slots = vnode.children || {
};
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) {
// 省略部分代码
},
});
// 省略部分代码
}
注册生命周期函数,例如onMounted、onUpdated
使用方法:
setup() {
onMounted(() => {
console.log('mounted')
onMounted(() => {
console.log('mounted')
})
}
有一个问题:例如我们在不同组件调用onMounted
注册生命周期函数,怎么保证能正确的注册到对应的组件呢
解决方法也很简单:定义一个全局变量currentInstance
,在setup执行之前,将currentInstance
设置为当前组件的实例,执行之后设为null.
并且不同于选项式的写法,执行生命周期函数,是从type中解构获得.在组合式中,要将onMounted
注册的生命周期函数保存到instance中,它是一个数组形式,因为可以多次调用onMounted
.
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), tupContext);
// 在 setup 函数执行完毕之后,重置当前组件实例
setCurrentInstance(null);
// 省略部分代码
}
对于onMounted
函数本身的实现也很简单,currentInstance
存放这当前要注册生命周期函数的实例,只需要 currentInstance.mounted.push(fn)
,这样就把生命周期保存在实例中了.
function onMounted(fn) {
if (currentInstance) {
// 将生命周期函数添加到 instance.mounted 数组中
currentInstance.mounted.push(fn)
} else {
console.error('onMounted 函数只能在 setup 中调用')
}
}
因为在执行setup中,也会执行onMounted
.此时实例上已经有注册的生命周期了.再执行mountComponent
中注册的副作用函数.
function mountComponent(vnode, container, anchor) {
// 省略部分代码
effect(
() => {
const subTree = render.call(renderContext, renderContext);
if (!instance.isMounted) {
// 省略部分代码
// 遍历 instance.mounted 数组并逐个执行即可
instance.mounted &&
instance.mounted.forEach((hook) => ok.call(renderContext));
} else {
// 省略部分代码
}
instance.subTree = subTree;
},
{
scheduler: queueJob,
}
);
}
对于除 mounted
以外的生命周期钩子函数,其原理同上