欢迎来到 CoderStan 的手写 Mini-Vue3 专栏,和我一起手写实现自己的 Mini-Vue3。这一章中将会简单实现 runtime-core 模块中的 Fragment 和 Text 的处理以及 props
、emit
、插槽和 Provide/Inject。(感谢 阿崔cxr 的 mini-vue)
有不足的地方欢迎大家评论留下意见或建议,如果觉得还不错还请点赞支持一下,想看其他部分的文章可以关注我或者关注我的手写 Mini-Vue3专栏,想看逐行注释的源码欢迎访问 GitHub 仓库,也请顺便点个 star 支持一下。
4. 实现 runtime-core
4.7 实现props
① happy path
props
是setup
的第一个参数,用于向一个组件中传入 prop,与使用选项式 API 时的this.$props
类似,该props
对象将仅包含显性声明的 prop,并且,所有声明了的 prop,不管父组件是否向其传递了,都将出现在props
对象中,其中未被传入的可选的 prop 的值会是undefined
。同时还要注意,props
对象是只读,但不是深度只读的。
在实现props
之前,首先在example
目录下创建Component-props
文件夹,在其中放置props
的测试相关文件,包括四个文件:index.html
、main.js
、App.js
和Foo.js
,其中index.html
和main.js
文件中的内容与第一个测试相同,App.js
和Foo.js
文件中的内容如下:
/* App.js */
export const App = {
render() {
return h(
'div',
{
id: 'root'
},
[
h('div', {}, 'hello, ' + this.name),
// 创建 Foo 组件,向其中传入 count prop
h(Foo, { count: 1 })
]
)
},
setup() {
return {
name: 'mini-vue3'
}
}
}
复制代码
/* Foo.js */
// Foo 组件选项对象
export const Foo = {
// props 对象是 setup 的第一个参数
setup(props) {
console.log(props)
// props 对象是只读的,但不是深度只读的
props.count++
console.log(props.count)
},
render() {
// 在 render 函数中通过 this 获取 props 对象的 property
return h('div', {}, 'foo: ' + this.count)
}
}
复制代码
② 实现
实现props
就是在src/runtime-core
目录下的component.ts
文件中的setupStatefulComponent
函数中调用setup
时传入props
对象的 shallowReadonly 响应式副本,而在这之前首先要在setupComponent
函数中初始化 props,也就是完成实现 Component 初始化主流程时留下的调用initProps
函数的 TODO。initProps
函数用于将props
对象挂载到组件实例对象上。
首先完善src/runtime-core
目录下的component.ts
文件中的createComponentInstance
函数,在组件实例对象中加入 props property:
/* component.ts */
export function createComponentInstance(vnode) {
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {}
}
return component
}
复制代码
然后在src/runtime-core
目录下创建componentProps.ts
文件,在其中实现并导出initProps
函数:
/* componentProps.ts */
// 用于将 props 对象挂载到组件实例对象上
export function initProps(instance, rawProps) {
instance.props = rawProps || {}
}
复制代码
在src/reactivity/src
目录下的index.ts
文件中将shallowReactive
导出:
/* src/reactivity/src/index.ts */
export { shallowReadonly } from './reactive'
复制代码
接下来完善src/runtime-core
目录下的component.ts
文件中的setupComponent
函数和setupStatefulComponent
函数:
/* component.ts */
export function setupComponent(instance) {
// 将组件对应 VNode 的 props property 挂载到组件实例对象上
initProps(instance, instance.vnode.props)
// TODO: 调用 initSlots
setupStatefulComponent(instance)
}
function setupStatefulComponent(instance) {
/* 其他代码 */
if (setup) {
// 调用 setup 传入 props 对象的 shallowReactive 响应式副本并获取其返回值
const setupResult = setup(shallowReadonly(instance.props))
// 处理 setup 的返回值
handleSetupResult(instance, setupResult)
}
}
复制代码
最后再来完善组件实例对象 proxy property 对应的 handlers,也就是src/runtime-core
目录下的componentPublicInstance.ts
文件中的PublicInstanceHandlers
:
/* componentPublicInstance.ts */
export const PublicInstanceHandlers = {
get({ _: instance }, key) {
// 通过解构赋值获取组件实例对象的 setupState property 和 props property
const { setupState, props } = instance
// 若 setupState property 或 props property 上有该 property 则返回其值
if (key in setupState) {
return setupState[key]
} else if (key in props) {
return props[key]
}
/* 其他代码 */
}
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/Component-props
目录下的index.html
文件,可以看到根组件和 Foo 组件选项对象中的内容被渲染到了页面上,在控制台中输出了对应内容,这样就成功实现了props
。
③ 优化代码
成功实现之后再来对代码做一些优化,在src/shared
目录下的index.ts
文件中声明并导出hasOwn
函数:
/* src/shared/index.ts */
// 用于判断对象中是否有某个 property
export const hasOwn = (val, key) =>
Object.prototype.hasOwnProperty.call(val, key)
export * from './ShapeFlags'
复制代码
再利用hasOwn
函数对src/runtime-core
目录下的componentPublicInstance.ts
文件中的PublicInstanceHandlers
进行重构:
/* componentPublicInstance.ts */
export const PublicInstanceHandlers = {
get({ _: instance }, key) {
const { setupState, props } = instance
if (hasOwn(setupState, key)) {
return setupState[key]
} else if (hasOwn(props, key)) {
return props[key]
}
/* 其他代码 */
}
复制代码
4.8 实现emit
① happy path
传递给setup
的第二个参数是context
。context
是一个普通的 JavaScript 对象,暴露了其他可能在setup
中有用的值,包括attrs
、slots
、emit
和expose
,而emit
用于触发使用该组件时在props
对象中声明的方法。例如在使用 Foo 组件时在props
对象中声明了onBar
方法和onBarBaz
,则在setup
中可通过emit('bar')
和emit('bar-baz')
触发该方法。
在实现emit
之前,首先在example
目录下创建Component-emit
文件夹,在其中放置emit
的测试相关文件,同样包括四个文件:index.html
、main.js
、App.js
和Foo.js
,其中index.html
和main.js
文件中的内容与之前的测试相同,App.js
和Foo.js
文件中的内容如下:
/* App.js */
export const App = {
render() {
return h('div', {}, [
h('div', {}, 'App'),
h(
Foo,
// 使用 Foo 组件时在 props 对象中声明 onBar 方法和 onBarBaz 方法
{
onBar(a, b) {
console.log('onBar', a, b)
},
onBarBaz(c, d) {
console.log('onBarBaz', c, d)
}
}
)
])
},
setup() {
return {}
}
}
复制代码
/* Foo.js */
// Foo 组件选项对象
export const Foo = {
setup(props, { emit }) {
const emitBar = () => {
console.log('emit bar')
// 通过 emit 触发使用 Foo 组件时在 props 对象中声明的 onBar 方法
emit('bar', 1, 2)
}
const emitBarBaz = () => {
console.log('emit bar baz')
// 通过 emit 触发使用 Foo 组件时在 props 对象中声明的 onBarBaz 方法
emit('bar-baz', 3, 4)
}
return {
emitBar,
emitBarBaz
}
},
render() {
const btnBar = h(
'button',
{
// 在 render 函数中通过 this 获取 setup 返回对象的方法
onClick: this.emitBar
},
'emitBar'
)
const btnBaz = h(
'button',
{
onClick: this.emitBarBaz
},
'emitBarBaz'
)
return h('div', {}, [btnBar, btnBaz])
}
}
复制代码
② 实现
实现emit
就是在src/runtime-core
目录下的component.ts
文件中的setupStatefulComponent
函数中调用setup
时传入一个包含 emit 方法的对象作为第二个参数,而 emit 方法就是组件实例对象的 emit 方法,用于调用props
对象中的指定方法并传入参数。
首先完善src/runtime-core
目录下的component.ts
文件中的createComponentInstance
函数,在组件实例对象中加入 emit 方法:
/* component.ts */
export function createComponentInstance(vnode) {
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
emit: () => {}
}
return component
}
复制代码
在src/runtime-core
目录下创建componentEmit.ts
文件, 在其中实现并导出emit
函数。这里用到了 TPP 的开发思路,即先针对一个特定行为进行编码,再对代码进行重构以适用于通用行为,比如这里就将调用组件时在props
对象中声明的方法指定为onBar
方法:
/* componentEmit.ts */
// 用于调用 props 对象中的指定方法
export function emit(instance, event, ...args) {
// 通过解构赋值获取组件实例对象的 props property
const { props } = instance
const handler = props['onBar']
handler && handler(...args)
}
复制代码
再通过Function.prototype.bind()
将emit
函数第一个参数指定为组件实例对象,将新函数挂载到组件实例对象上:
/* component.ts */
export function createComponentInstance(vnode) {
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
emit: () => {}
}
// 通过 Function.prototype.bind() 将 emit 函数第一个参数指定为组件实例对象,将新函数挂载到组件实例对象上
component.emit = emit.bind(null, component) as any
return component
}
复制代码
接下来完善src/runtime-core
目录下的component.ts
文件中的setupStatefulComponent
函数:
/* component.ts */
function setupStatefulComponent(instance) {
/* 其他代码 */
if (setup) {
// 调用 setup 传入 props 对象的 shallowReactive 响应式副本和包含 emit 方法的对象并获取其返回值
const setupResult = setup(shallowReadonly(instance.props), {
emit: instance.emit
})
handleSetupResult(instance, setupResult)
}
}
复制代码
然后在src/shared
目录下的index.ts
文件中实现并导出camelize
函数、capitalize
函数和toHandlerKey
函数:
/* src/shared/index.ts */
// 用于将带连字符的字符串转换为驼峰式
export const camelize = (str: string) => {
return str.replace(/-(\w)/g, (_, c: string) => {
return c ? c.toUpperCase() : ''
})
}
// 用于将字符串首字母转换为大写
export const capitalize = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1)
}
// 用于在字符串之前加上 on
export const toHandlerKey = (str: string) => {
return str ? 'on' + capitalize(str) : ''
}
复制代码
最后再来重构src/runtime-core
目录下的componentEmit.ts
文件中的emit
函数:
export function emit(instance, event, ...args) {
const { props } = instance
const handlerName = toHandlerKey(camelize(event))
const handler = props[handlerName]
handler && handler(...args)
}
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/Component-emit
目录下的index.html
文件,可以看到根组件和 Foo 组件选项对象中的内容被渲染到了页面上,点击两个按钮在控制台中分别输出了对应内容,这样就成功实现了emit
。
4.9 实现插槽
① happy path
在render
函数中可以通过this.$slots
访问静态插槽的内容,每个插槽都是一个 VNode 数组。插槽以函数的形式传递,允许子组件控制每个插槽内容的创建。例如,在父组件中将插槽传递给子组件:
render() {
// 等价于 <div><child v-slot="props"><span>{{ props.text }}</span></child></div>
return h('div', [
h(child, {}, {
default: props => h('span', props.text)
})
])
}
复制代码
在实现插槽之前,首先在example
目录下创建Component-slots
文件夹,在其中放置插槽的测试相关文件,同样包括四个文件:index.html
、main.js
、App.js
和Foo.js
,其中index.html
和main.js
文件中的内容与之前的测试相同,App.js
和Foo.js
文件中的内容如下:
/* App.js */
export const App = {
name: 'App',
setup() {
return {}
},
render() {
// 传入一个 VNode 作为插槽
return h(Foo, {}, h('p', {}, 'a slot'))
// 传入一个 VNode 数组,数组中每一项为一个插槽
// return h(Foo, {}, [h('p', {}, 'a slot'), h('p', {}, 'another slot')])
}
}
复制代码
/* Foo.js */
// Foo 组件选项对象
export const Foo = {
name: 'Foo',
setup() {
return {}
},
render() {
// 通过 this.$slots 获取父组件传递的插槽
return h('div', {}, [h('p', {}, 'Foo component'), this.$slots])
}
}
复制代码
其中App.js
中包括两种情况,即首先是传入一个 VNode 作为插槽,其次是传入一个数组,数组中的每一项为一个插槽。
② 最基本的实现
插槽就是在render
函数中通过 this 的 $slots property 获取父组件传入的 children,并对其进行渲染。
插槽的实现与组件代理对象类似,首先需要完善组件实例对象的 proxy property,在获取 $slots property 时返回组件的 children。而在这之前首先要在setupComponent
函数中初始化 slots,也就是完成实现 Component 初始化主流程时留下的调用initSlots
函数的 TODO。initSlots
函数用于将 children 赋值给组件实例对象的 slots property。
首先完善src/runtime-core
目录下的component.ts
文件中的createComponentInstance
函数,在组件实例对象中增加 slots property:
/* component.ts */
export function createComponentInstance(vnode) {
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
slots: {},
emit: () => {}
}
/* 其他代码 */
}
复制代码
然后在src/runtime-core
目录下创建componentSlots.ts
文件,在其中实现并导出initSlots
函数:
/* componentSlots.ts */
// 用于将 children 赋值给组件实例对象的 slots property
export function initSlots(instance, children) {
instance.slots = children
}
复制代码
接下来完善src/runtime-core
目录下的component.ts
文件中的setupComponent
函数:
/* component.ts */
export function setupComponent(instance) {
initProps(instance, instance.vnode.props)
// 将 children 挂载到组件实例对象的 slots property 上
initSlots(instance, instance.vnode.children)
setupStatefulComponent(instance)
}
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/Component-slots
目录下的index.html
文件,可以看到 Foo 组件选项对象中的内容和插槽都被渲染到了页面上。
这样就完成了针对第一种情况的实现,即当父组件传入一个 VNode 作为插槽时能够正常渲染,而此时第二种情况还无法正常渲染。针对第二种情况,可以创建一个 VNode,在其中用一个 div 对数组中的多个插槽进行包裹,也就是对example/Component-slots
目录下的Foo.js
做如下修改:
export const Foo = {
/* 其他代码 */
render() {
return h('div', {}, [h('p', {}, 'Foo component'), h('div', {}, this.$slots)])
}
}
复制代码
对以上处理进行封装,在src/runtime-core/helpers
目录下创建renderSlots.ts
文件,在其中实现并导出renderSlots
函数:
/* helpers/renderSlots.ts */
// 用于利用 div 对插槽进行包裹
export function renderSlots(slots) {
return createVNode('div', {}, slots)
}
复制代码
并在src/runtime-core
目录下的index.ts
文件中将renderSlots
函数导出:
/* index.ts */
export { renderSlots } from './helpers/renderSlots'
复制代码
再结合renderSlots
函数对example/Component-slots
目录下的Foo.js
做如下修改:
export const Foo = {
/* 其他代码 */
render() {
return h('div', {}, [h('p', {}, 'Foo component'), renderSlots(this.$slots)])
}
}
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/Component-slots
目录下的index.html
文件,可以看到 Foo 组件选项对象中的内容和插槽都被渲染到了页面上。
这样就完成了针对第二种情况的实现,但是此时第一种情况就无法正常渲染,为了同时包括两种情况,可以在初始化 slots 时进行处理,若 children 是一个 VNode 则将其转为数组,完善src/runtime-core
目录下的componentSlots.ts
文件中的initSlots
函数:
/* componentSlots.ts */
export function initSlots(instance, children) {
instance.slots = Array.isArray(children) ? children : [children]
}
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/Component-slots
目录下的index.html
文件,可以看到两种情况均能正常渲染,这样就完成了插槽最基本的实现。
③ 具名插槽的实现
具名插槽指的是,在由父组件向子组件传入插槽时传入一个对象,其中 key 为插槽的 name,用于指定插槽的位置,value 为插槽,而在子组件中将插槽的 name 作为第二个参数传入renderSlots
函数来指定该位置要渲染的插槽。
先来完善具名插槽的测试,在example/Component-slots
目录下创建Bar.js
文件,其中的内容如下:
/* Bar.js */
// Bar 组件选项对象
export const Bar = {
/* 其他代码 */
render() {
return h('div', {}, [
// 通过在调用 renderSlots 时传入第二个参数指定在此位置渲染的插槽
renderSlots(this.$slots, 'header'),
h('p', {}, 'bar component'),
renderSlots(this.$slots, 'footer')
])
}
}
复制代码
对example/Component-slots
目录下的App.js
文件做相应修改:
/* App.js */
export const App = {
/* 其他代码 */
render() {
// 传入一个对象,对象中每个 property 为一个插槽
return h(
Bar,
{},
{
header: h('p', {}, 'header slot'),
footer: h('p', {}, 'footer slot')
}
)
}
}
复制代码
要实现具名插槽,首先完善src/runtime-core/helpers
目录下的renderSlots.ts
文件中的renderSlots
函数:
/* helpers/renderSlots.ts */
export function renderSlots(slots, name) {
// 通过 name 获取相应的插槽
const slot = slots[name]
if (slot) {
return createVNode('div', {}, slot)
}
}
复制代码
再来完善src/runtime-core
目录下的componentSlots.ts
文件中的initSlots
函数,在其中对 children 进行遍历,将其 property 对应的 VNode 数组挂载到组件实例对象的 slots property 上:
/* componentSlots.ts */
// 用于将插槽挂载到组件实例对象的 slots property 上
export function initSlots(instance, children) {
const slots = {}
// 遍历 children,将其 property 对应的 VNode 数组挂载到 slots 对象上
for (const key in children) {
const value = children[key]
slots[key] = Array.isArray(value) ? value : [value]
}
instance.slots = slots
}
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/Component-slots
目录下的index.html
文件,可以看到 Bar 组件选项对象中的内容和插槽都被按顺序渲染到了页面上,这样就成功实现了具名插槽。
最后对initSlots
函数进行重构,将其中逻辑抽离为normalizeObjectSlots
函数和normalizeSlotValue
函数:
/* componentSlots.ts */
export function initSlots(instance, children) {
normalizeObjectSlots(children, instance.slots)
}
// 用于遍历 children,将其 property 对应的 VNode 数组挂载到组件实例对象的 slots property 上
function normalizeObjectSlots(children, slots) {
for (const key in children) {
const value = children[key]
slots[key] = normalizeSlotValue(value)
}
}
// 用于将一个 VNode 转为数组
function normalizeSlotValue(value) {
return Array.isArray(value) ? value : [value]
}
复制代码
④ 作用域插槽的实现
作用域插槽指的是,在由父组件向子组件传入插槽时传入一个对象,其中方法名为插槽的 name,方法用于创建插槽,接受一个对象作为参数,该对象的 property 为要传入插槽的参数,而在子组件中将包含要传入插槽参数的对象作为第三个参数传入renderSlots
函数中。
先来完善作用域插槽的测试,在example/Component-slots
目录下创建Baz.js
文件,其中的内容如下:
/* Baz.js */
// Baz 组件选项对象
export const Baz = {
name: 'Baz',
setup() {
return {}
},
render() {
const msg = 'this is a slot'
// 通过在调用 renderSlots 函数时传入第三个参数指定传入插槽函数的参数
return h(
'div',
{},
this.$slots.content({
msg
})
)
}
}
复制代码
对example/Component-slots
目录下的App.js
文件做相应修改:
/* App.js */
export const App = {
/* 其他代码 */
render() {
// 传入一个对象,对象中的每个方法为一个创建插槽的函数
return h(
Baz,
{},
{
content: props => h('p', {}, 'content: ' + props.msg)
}
)
}
}
复制代码
要实现作用域插槽,首先完善src/runtime-core/helpers
目录下的renderSlots.ts
文件中的renderSlots
函数:
/* helpers/renderSlots.ts */
export function renderSlots(slots, name, props) {
// 通过 name 获取创建相应插槽的方法
const slot = slots[name]
if (slot) {
if (typeof slot === 'function') {
return createVNode('div', {}, slot(props))
}
}
}
复制代码
再来完善src/runtime-core
目录下的componentSlots.ts
文件中的normalizeObjectSlots
函数,在其中对 children 进行遍历,将创建插槽对应的 VNode 数组的函数挂载到组件实例对象的 slots property 上:
/* componentSlots.ts */
// 用于遍历 children,将创建插槽对应的 VNode 数组的函数挂载到组件实例对象的 slots property 上
function normalizeObjectSlots(children, slots) {
for (const key in children) {
const value = children[key]
slots[key] = props => normalizeSlotValue(value(props))
}
}
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/Component-slots
目录下的index.html
文件,可以看到 Baz 组件选项对象中的内容和插槽以及传入的参数都被渲染到了页面上,这样就成功实现了作用域插槽。
最后利用 shapeFlag 完善initSlots
函数,增加对 children 的判断,只有在 children 为插槽时才进行处理。首先在src/shared
目录下的shapeFlags.ts
文件中的枚举变量ShapeFlags
中增加一项 SLOTS_CHILDREN,用于判断 children 是否为插槽:
export const enum ShapeFlags {
/* 其他代码 */
// 用于判断 children 是否是插槽
SLOTS_CHILDREN = 1 << 4 // 10000
}
复制代码
然后完善src/runtime-core
目录下的vnode.ts
文件中的createVNode
函数,若 VNode 类型为 Component 同时 children 类型为对象,则 children 为插槽,设置 shapeFlag 对应的位:
/* render.ts */
export function createVNode(type, props?, children?) {
/* 其他代码 */
// 若 VNode 类型为 Component 同时 children 类型为对象,则 children 为插槽,设置 shapeFlag 对应的位
if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
if (typeof children === 'object') {
vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN
}
}
return vnode
}
复制代码
最后完善src/runtime-core
目录下的componentSlots.ts
文件中的initSlots
函数:
/* componentSlots.ts */
export function initSlots(instance, children) {
// 通过解构赋值获得组件对应的 VNode
const { vnode } = instance
// 若 children 是插槽则进行处理
if (vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
normalizeObjectSlots(children, instance.slots)
}
}
复制代码
4.10 处理 Fragment 和 Text
在实现插槽的过程中,为了解决多个插槽同时渲染而 children 中不能包含数组的矛盾,采取了最简单的处理方式,利用一个特殊的 Element 即 div 对插槽进行了包裹,但是这样的处理其实是不合理的。例如在作用域插槽的测试中若采用renderSlots
函数则会导致多了一层 div。
return h(
'div',
{},
[
renderSlots(this.$slots, 'content', {
msg
})
]
)
复制代码
更合理的方式利用 Fragment 对插槽进行包裹,而处理 Fragment 时直接将其对应的 VNode 当作 children 调用mountChildren
函数进行处理,在src/runtime-core
目录下的renderer.ts
文件完善patch
方法,并实现processFragment
函数:
/* renderer.ts */
function patch(vnode, container) {
// 根据 VNode 类型的不同调用不同的函数
const { type, shapeFlag } = vnode
// 通过 VNode 的 type property 判断 VNode 类型是 Fragment 或其他
switch (type) {
case 'Fragment':
processFragment(vnode, container)
break
default:
// 通过 VNode 的 shapeFlag property 与枚举变量 ShapeFlags 进行与运算来判断 VNode 类型是 Element 或 Component
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(vnode, container)
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(vnode, container)
}
break
}
}
// 用于处理 Fragment
function processFragment(vnode, container) {
mountChildren(vnode, container)
}
复制代码
相应地完善src/runtime-core/helpers
目录下的renderSlots.ts
文件中的renderSlots
函数:
/* helpers/renderSlots.ts */
// 用于利用 Fragment 对插槽进行包裹
export function renderSlots(slots, name, props) {
// 通过 name 获取创建相应插槽的方法
const slot = slots[name]
if (slot) {
if (typeof slot === 'function') {
// 将创建插槽方法的执行结果作为 children 传入
return createVNode('Fragment', {}, slot(props))
}
}
}
复制代码
接下来对代码进行优化,在src/runtime-core
目录下的vnode.ts
文件中创建并导出 Symbol 类型变量Fragment
,用于代替字符串"Fragment":
/* vnode.ts */
export const Fragment = Symbol('Fragment')
复制代码
对src/runtime-core
目录下的renderer.ts
文件中的patch
方法和src/runtime-core/helpers
目录下的renderSlots.ts
文件中的renderSlots
函数做相应修改:
/* renderer.ts */
function patch(vnode, container) {
const { type, shapeFlag } = vnode
switch (type) {
case Fragment:
processFragment(vnode, container)
break
/* 其他代码 */
}
}
复制代码
/* helpers/renderSlots.ts */
export function renderSlots(slots, name, props) {
/* 其他代码 */
if (slot) {
if (typeof slot === 'function') {
return createVNode(Fragment, {}, slot(props))
}
}
}
复制代码
在作用域插槽的测试中采用renderSlots
函数就不会有出的一层 div。
借用作用域插槽的测试作为处理 Text 的测试,创建插槽的函数返回一个数组,将其中的第二项字符串作为文本节点渲染,对example/Component-slots
目录下的App.js
文件做如下修改:
export const App = {
/* 其他代码 */
render() {
return h(
Baz,
{},
{
content: props => [h('p', {}, 'content: ' + props.msg), 'a text node']
}
)
}
}
复制代码
在src/runtime-core
目录下的vnode.ts
文件中创建并导出 Symbol 类型变量Fragment
,同时实现并导出createTextVNode
函数:
/* vnode.ts */
export const Text = Symbol('Text')
// 用于创建 Text 类型的 VNode
export function createTextVNode(text: string) {
return createVNode(Text, {}, text)
}
复制代码
并在src/runtime-core
目录下的index.ts
文件中将createTextVNode
函数导出:
/* index.ts */
export { createTextVNode } from './vnode'
复制代码
在处理 Text 时,其 children 就是文本节点内容,利用document.createTextNode()
创建文本节点,再利用Element.append()
将该节点添加到根容器/其父元素中。在src/runtime-core
目录下的renderer.ts
文件中完善patch
方法并实现processText
函数:
function patch(vnode, container) {
const { type, shapeFlag } = vnode
// 通过 VNode 的 type property 判断 VNode 类型
switch (type) {
case Fragment:
processFragment(vnode, container)
break
case Text:
processText(vnode, container)
break
/* 其他代码 */
}
}
// 用于处理 Text
function processText(vnode, container) {
// 通过解构赋值获取 Text 对应 VNode 的 children,即文本内容
const { children } = vnode
// 利用 document.createTextNode() 创建文本节点
const textNode = document.createTextNode(children)
// 利用 Element.append() 将该节点添加到根容器/其父元素中
container.append(textNode)
}
复制代码
最后对example/Component-slots
目录下的App.js
文件做相应修改,利用createTextVNode
函数创建文本节点:
export const App = {
/* 其他代码 */
render() {
return h(
Baz,
{},
{
content: props => [
h('p', {}, 'content: ' + props.msg),
createTextVNode('a text node')
]
}
)
}
}
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/Component-slots
目录下的index.html
文件,可以看到文本节点被渲染到了页面上,这样就完成了 Text 的处理。
4.11 实现getCurrentInstance
查看 Vue3 API 文档中的组合式 API 部分,找到getCurrentInstance
的介绍。
getCurrentInstance
getCurrentInstance
支持访问内部组件实例。
import { getCurrentInstance } from 'vue' const MyComponent = { setup() { const internalInstance = getCurrentInstance() internalInstance.appContext.config.globalProperties // 访问 globalProperties } } 复制代码
getCurrentInstance
只能在setup
或生命周期钩子中调用。
① happy path
在实现getCurrentInstance
之前,首先在example
目录下创建getCurrentInstance
文件夹,在其中放置插槽的测试相关文件,同样包括四个文件:index.html
、main.js
、App.js
和Foo.js
,其中index.html
和main.js
文件中的内容与之前的测试相同,App.js
和Foo.js
文件中的内容如下:
/* App.js */
export const App = {
name: 'App',
setup() {
// 获取当前组件实例对象
const instance = getCurrentInstance()
console.log('App:', instance)
return {}
},
render() {
return h(Foo)
}
}
复制代码
/* Foo.js */
// Foo 组件选型对象
export const Foo = {
name: 'Foo',
setup() {
// 获取当前组件实例对象
const instance = getCurrentInstance()
console.log('Foo:', instance)
return {}
},
render() {
return h('p', {}, 'Foo component')
}
}
复制代码
② 实现
实现getCurrentInstance
就是声明一个全局变量currentInstance
用于保存当前组件实例对象,在setupStatefulComponent
函数中调用setup
前调用setCurrentInstance
函数将该全局变量赋值为当前组件实例对象,而在调用后再setCurrentInstance
函数将该全局变量赋值为 null。
在src/runtime-core
目录下的component.ts
文件中声明全局变量currentInstance
、实现getCurrentInstance
并导出、实现setCurrentInstance
函数同时完善setupStatefulComponent
函数:
/* component.ts */
// 用于保存当前组件实例对象
let currentInstance = null
// 用于获取当前组件的实例对象
export function getCurrentInstance() {
return currentInstance
}
// 用于给全局变量 currentInstance 赋值
function setCurrentInstance(instance) {
currentInstance = instance
}
function setupStatefulComponent(instance) {
/* 其他代码 */
if (setup) {
// 将全局变量 currentInstance 赋值为当前组件实例对象
setCurrentInstance(instance)
const setupResult = setup(shallowReadonly(instance.props), {
emit: instance.emit
})
// 将全局变量 currentInstance 赋值为 null
setCurrentInstance(null)
handleSetupResult(instance, setupResult)
}
}
复制代码
最后在src/runtime-core
目录下的index.ts
文件中将getCurrentInstance
导出:
/* index.ts */
export { getCurrentInstance } from './component'
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/getCurrentInstance
目录下的index.html
文件,在控制台中输出了对应的内容,这样就成功实现了getCurrentInstance
。
4.12 实现 Provide / Inject
查看 Vue3 官方文档中的可复用&组合部分,找到 Provide / Inject 的介绍。
两者都只能在当前活动实例的setup
中调用。在setup
中使用provide
函数和inject
函数时首先显示导入,然后调用provide
函数注入依赖,provide
函数接受两个参数,分别为 name 和 value,再调用inject
函数引入依赖。
// MyMap
export default {
components: {
MyMarker
},
setup() {
provide('location', 'North Pole')
provide('geolocation', {
longitude: 90,
latitude: 135
})
}
}
// MyMarker
export default {
setup() {
const userLocation = inject('location', 'The Universe')
const userGeolocation = inject('geolocation')
return {
userLocation,
userGeolocation
}
}
}
复制代码
① 父子组件间 Provide / Inject
在实现之前,首先在example
目录下创建provide-inject
文件夹,在其中放置父子组件间 Provide / Inject 的测试相关文件,包括三个文件:index.html
、main.js
和App.js
,其中index.html
和main.js
文件中的内容与之前的测试相同,App.js
文件中的内容如下:
// 父组件选项对象
const Provider = {
name: 'Provider',
setup() {
// 通过 provide 注入 foo
provide('foo', 'FooFromProvider')
},
render() {
return h('div', {}, [h('p', {}, 'Provider'), h(Consumer)])
}
}
// 子组件选项对象
const Consumer = {
name: 'Consumer',
setup() {
// 通过 inject 引入 foo
const foo = inject('foo')
return {
foo
}
},
render() {
return h('div', {}, [h('p', {}, `Consumer: inject ${this.foo}`)])
}
}
export default {
name: 'App',
setup() {},
render() {
return h('div', {}, [h('p', {}, 'provide-inject'), h(Provider)])
}
}
复制代码
实现父子组件间 Provide / Inject 就是在组件实例对象加入 provides property,用于保存该组件通过provide
函数注入的依赖,同时加入 parent property,用于保存其父组件实例对象,而在setup
中通过inject
函数引入依赖时则获取其父组件实例对象的 provides property 中的相应 property。
首先完善src/runtime-core
目录下的component.ts
文件中的createComponentInstance
函数,在组件实例对象中加入 provides property 和 parent property,同时接受父组件实例对象作为第二个参数:
/* component.ts */
export function createComponentInstance(vnode, parent) {
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
slots: {},
provides: {},
parent,
emit: () => {}
}
/* 其他代码 */
}
复制代码
然后完善src/runtime-core
目录下的renderer.ts
文件中所有和组件实例对象相关的函数,解决报错,其中在render
函数中调用patch
方法对根组件对应 VNode 进行处理时传入的第三个参数为 null,而在setupRenderEffect
函数中调用patch
方法递归地处理 VNode 树时传入的第三个参数为组件选项对象:
/* renderer.ts */
export function render(vnode, container) {
patch(vnode, container, null)
}
function setupRenderEffect(instance, vnode, container) {
/* 其他代码 */
patch(subTree, container, instance)
vnode.el = subTree.el
}
复制代码
接下来在src/runtime-core
目录下创建apiInject.ts
文件,在其中实现并导出provide
函数和inject
函数,其中provide
函数用于将依赖挂载到当前组件实例对象的 provides property 上,inject
函数用于获取父组件实例对象的 provides property 上的相应 property:
/* apiInject.ts */
// 用于注入依赖
export function provide(key, value) {
// 获取当前组件实例对象
const currentInstance: any = getCurrentInstance()
if (currentInstance) {
// 通过解构赋值获取当前组件实例对象的 provides property
const { provides } = currentInstance
// 将依赖挂载到当前组件实例对象的 provides property 上
provides[key] = value
}
}
// 用于引入依赖
export function inject(key) {
// 获取当前组件实例对象
const currentInstance: any = getCurrentInstance()
if (currentInstance) {
// 通过解构赋值获取当前组件实例对象的 parent property,即其父组件实例对象
const { parent } = currentInstance
// 返回父组件实例对象的 provides property 上的相应 property
return parent.provides[key]
}
}
复制代码
最后在src/runtime-core
目录下的index.ts
文件中将provide
函数和inject
函数导出:
/* index.ts */
export { provide, inject } from './apiInject'
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/provide-inject
目录下的index.html
文件,可以看到父子组件选项对象中的内容和依赖都被渲染到了页面上,这样就成功实现了父子组件间的 Provide / Inject。
② 跨层次组件间 Provide / Inject
在实现之前先对example/provide-inject
目录下的App.js
文件做如下修改,增加一个组件,使组件层次变成三层:
// 第一级组件
const Provider_I = {
name: 'Provider_I',
setup() {
// 通过 provide 注入 foo 和 bar
provide('foo', 'FooFromI')
provide('bar', 'BarFromI')
},
render() {
return h('div', {}, [h('p', {}, 'Provider_I'), h(Provider_II)])
}
}
// 第二级组件
const Provider_II = {
name: 'Provider_II',
setup() {
// 通过 provide 注入 foo
provide('foo', 'FooFromII')
},
render() {
return h('div', {}, [h('p', {}, 'Provider_II'), h(Consumer)])
}
}
// 第三级组件
const Consumer = {
name: 'Consumer',
setup() {
// 通过 inject 引入 foo 和 bar
const foo = inject('foo') // => FooFromII
const bar = inject('bar') // => BarFromI
// 通过 inject 引入 baz,同时传入默认值或默认值函数
const baz1 = inject('baz', 'defaultBaz1') // => defaultBaz1
const baz2 = inject('baz', () => 'defaultBaz2') // => defaultBaz2
return {
foo,
bar,
baz1,
baz2
}
},
render() {
return h('div', {}, [
h(
'p',
{},
`Consumer: inject ${this.foo}, ${this.bar}, ${this.baz1}, and ${this.baz2}`
)
])
}
}
export default {
name: 'App',
setup() {},
render() {
return h('div', {}, [h('p', {}, 'provide-inject'), h(Provider_I)])
}
}
复制代码
实现跨层次组件间 Provide / Inject 就是在当前组件存在父组件时,将当前组件实例对象的 provides property 赋值为父组件实例对象的 provides property,而在当前组件的setup
中第一次调用provide
函数时,将当前组件实例对象的 provides property 赋值为以父组件实例对象的 provides property 为原型的空对象,再将依赖挂载到其上,之后再调用时则直接将依赖挂载到当前组件实例对象的 provides property 上。
首先完善src/runtime-core
目录下的component.ts
文件中的createComponentInstance
函数,根据是否存在父组件为 provides property 赋值:
/* component.ts */
export function createComponentInstance(vnode, parent) {
const component = {
vnode,
type: vnode.type,
setupState: {},
props: {},
slots: {},
// 若存在父组件则赋值为 父组件实例对象的 provides property,否则为空对象
provides: parent ? parent.provides : {},
parent,
emit: () => {}
}
/* 其他代码 */
}
复制代码
接下来完善src/runtime-core
目录下的apiInject.ts
文件中的provide
函数,若当前组件实例对象和父组件实例对象的 provides property 相等,则是在当前组件setup
中第一次调用provide
函数:
/* apiInject.ts */
export function provide(key, value) {
/* 其他代码 */
if (currentInstance) {
// 通过解构赋值获取当前组件实例对象的 provides property
let { provides } = currentInstance
// 获取父组件实例对象的 provides property
const parentProvides = currentInstance.parent.provides
// 若判断当前组件实例对象和父组件实例对象的 provides property 相等,则是在当前组件 setup 中第一次调用 provide 函数
if (provides === parentProvides) {
// 利用 Object.create() 创建一个以父组件实例对象的 provides property 为原型的空对象,将其赋值给当前组件实例对象的 provides property
provides = currentInstance.provides = Object.create(parentProvides)
}
// 将依赖挂载到当前组件实例对象的 provides property 上
provides[key] = value
}
}
复制代码
最后完善src/runtime-core
目录下的apiInject.ts
文件中的inject
函数,接受一个默认值或默认值函数作为第二个参数:
/* apiInject.ts */
export function inject(key, defaultValue) {
// 获取当前组件实例对象
const currentInstance: any = getCurrentInstance()
if (currentInstance) {
// 获取父组件实例对象的 parent property
const parentProvides = currentInstance.parent.provides
// 若父组件实例对象的 provides property 上有相应的 property 则直接返回
if (key in parentProvides) {
return parentProvides[key]
}
// 否则,若传入了默认值或默认值函数则返回默认值或默认值函数的返回值
else if (defaultValue) {
if (typeof defaultValue === 'function') {
return defaultValue()
}
return defaultValue
}
}
}
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example/provide-inject
目录下的index.html
文件,可以看到三个层次的组件选项对象中的内容和依赖都被渲染到了页面上,这样就成功实现了跨层次组件间的 Provide / Inject。
4.13 实现自定义渲染器
在之前实现 Element 初始化主流程和注册事件功能以及处理 Text 时,在src/runtime-core
目录下的renderer.ts
文件中的processText
函数和mountElement
函数中使用了 DOM 的 API 来创建文本节点、创建元素、将 props 对象中的 property 或方法挂载到元素上以及将元素添加到根容器/父元素中:
/* renderer.ts */
function processText(vnode, container) {
const { children } = vnode
// document.createTextNode()
const textNode = document.createTextNode(children)
// Element.append()
container.append(textNode)
}
function mountElement(vnode, container) {
// document.createElement()
const el = (vnode.el = document.createElement(vnode.type))
const { props, shapeFlag, children } = vnode
for (const key in props) {
const val = props[key]
const isOn = (key: string) => /^on[A-Z]/.test(key)
if (isOn(key)) {
const event = key.slice(2).toLowerCase()
// Element.addEventListener()
el.addEventListener(event, val)
} else {
// Element.setAttribute()
el.setAttribute(key, val)
}
}
// Element.append()
container.append(textNode)
}
复制代码
这样实现的 runtime-core 就是针对浏览器平台的,而 runtime-core 应该是与平台无关的,并且在使用时可以根据需求通过createRenderer
函数传入相应的 API。
实现自定义渲染器就是利用createRenderer
函数对src/runtime-core
目录下的renderer.ts
文件中的函数进行封装,createRenderer
函数接受一个包含所需 API 的 options 对象作为参数,在其中首先获取相应的 API,再在src/runtime-core
目录下的renderer.ts
文件中的processText
函数和mountElement
函数中,利用传入的createText
函数、createElement
函数、patchProp
函数和insert
函数完成相应操作。同时createApp
是依赖render
函数的,因此再利用createAppAPI
函数对createApp
进行封装,createAppAPI
函数接受render
函数作为参数并返回createApp
,createRenderer
函数返回一个包含createApp
方法的对象,方法具体为调用createAppAPI
函数并传入render
函数。
首先在src/runtime-core
目录下的createApp.ts
文件中实现并导出createAppAPI
函数,对createApp
进行封装:
/* createApp.ts */
// 用于返回 createApp
export function createAppAPI(render) {
return function createApp(rootComponent) {}
}
复制代码
接下来在src/runtime-core
目录下的renderer.ts
文件中实现并导出createRenderer
函数,对src/runtime-core
目录下的renderer.ts
文件中的函数进行封装,并完善processText
函数和mountElement
函数,利用传入的 API 完成相应操作:
/* renderer.ts */
export function createRenderer(options) {
// 通过解构赋值获取 createText 函数、createElement 函数、patchProp 函数和 insert 函数
const {
createText: hostCreateText,
createElement: hostCreateElement,
patchProp: hostPatchProp,
insert: hostInsert
} = options
function render(vnode, container) {}
/* patch */
/* processFragment */
function processText(n1, n2, container) {
const { children } = n2
// createText 函数
const textNode = hostCreateText(children)
// insert 函数
hostInsert(textNode, container)
}
/* processElement */
function mountElement(vnode, container, parentComponent) {
// createElement 函数
const el = (vnode.el = hostCreateElement(vnode.type))
const { props, shapeFlag, children } = vnode
// 遍历 props,将其中的 property 或方法挂载到新元素上
for (const key in props) {
const val = props[key]
// patchProp 函数
hostPatchProp(el, key, val)
}
/* 其他代码 */
// insert 函数
hostInsert(el, container)
}
/* mountChildren */
/* processComponent */
/* mountComponent */
/* setupRenderEffect */
// 返回一个包含 createApp 方法的对象,方法具体为调用 createAppAPI 函数并传入 render 函数
return {
createApp: createAppAPI(render)
}
复制代码
然后对src/runtime-core
目录下的index.ts
文件做如下修改,导出createRenderer
函数而不再导出createApp
:
/* index.ts */
export { h } from './h'
export { renderSlots } from './helpers/renderSlots'
export { createTextVNode } from './vnode'
export { getCurrentInstance } from './component'
export { provide, inject } from './apiInject'
export { createRenderer } from './renderer'
复制代码
再来实现针对浏览器平台的 runtime-dom 的最简单的功能。在src/runtime-dom
目录下创建index.ts
文件,在其中首先实现并导出createText
函数、createElement
函数、patchProp
函数和insert
函数,然后调用createRenderer
函数并传入包含以上三个函数的对象,接下来实现并导出createApp
函数,主要是调用createRenderer
函数返回对象的 createApp 方法,最后导出 runtime-core:
/* src/runtime-dom/index.ts */
// 用于创建元素
function createElement(type) {
// 利用 document.createElement() 创建 DOM 元素
return document.createElement(type)
}
// 用于将 props 对象中的 property 或方法挂载到元素上
function patchProp(el, key, val) {
// 用于通过正则判断该 property 的 key 是否以 on 开头,是则为注册事件,否则为 attribute 或 property
const isOn = (key: string) => /^on[A-Z]/.test(key)
// 若为注册事件
if (isOn(key)) {
const event = key.slice(2).toLowerCase()
// 利用 Element.addEventListener() 将方法挂载到元素上
el.addEventListener(event, val)
}
// 否则
else {
// 利用 Element.setAttribute() 将 property 挂载到元素上
el.setAttribute(key, val)
}
}
// 用于将元素添加到根容器/父元素中
function insert(el, parent) {
// 利用 Element.append() 将元素添加到根容器/父元素中
parent.append(el)
}
// 用于创建文本节点
function createText(text) {
// 利用 document.createTextNode() 创建文本节点
return document.createTextNode(text)
}
// 调用 createRenderer 函数,并传入包含 createText 函数、createElement 函数、patchProp 函数和 insert 函数的对象
const renderer: any = createRenderer({
createElement,
patchProp,
insert
})
// 用于创建应用实例
export function createApp(...args) {
// 调用 createRenderer 函数返回对象的 createApp 方法
return renderer.createApp(...args)
}
export * from '../runtime-core'
复制代码
最后对src
目录下的index.ts
文件做如下修改,导出 runtime-dom 而不再导出 runtime-core:
/* src/index.ts */
export * from './runtime-dom'
复制代码
在项目文件夹下执行yarn build
命令进行打包,打包完成后通过 live server 插件打开example
目录下的所有文件夹中的index.html
,可以看到所有测试均通过,这样就成功实现了自定义渲染器。