手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉
我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情
hey🖐! 我是pino😊😊。一枚小透明,期待关注➕ 点赞,共同成长~
写在前面
本文的目标是实现一个基本的 vue3
的虚拟DOM的节点渲染与更新,包含最基础的情况的处理,本文是系列文章,本系列已全面使用vue3组合式语法,如果你对 vue3
基础语法及响应式相关逻辑还不了解,那么请移步:
更新!更新!实现vue3虚拟DOM更新&diff算法优化🎉 🎉
本文只是整个vue3渲染器的上篇内容,包含vnode
的首次渲染过程,组件及节点的更新,更新优化,diff算法的内容将会放在下一篇内容。
食用提醒!必看
由于整个渲染过程中的函数实现以及流程过长,有很多函数的实现内容在相关的章节并不会全部展示,并且存在大量的伪代码,相关章节只会关注当前功能代码的显示和实现。
但是!为了便于理解,我在github
上上传了每章节的具体实现。(请把贴心打在评论区 😂😂)把每一章节的实现都存放在了单独的文件夹:
只使用了单纯的html
和js
来实现功能,只需要在index.html
中替换相关章节的文件路径(替换以下三个文件),在浏览器中打开,就可以自行调试和查看代码。见下图:
地址在这里,欢迎star!
mini-vue3的正式项目地址在这里!目前只实现了响应式和渲染器部分!
👉 k-vue
欢迎star!
本文你将学到
- 虚拟DOM
- 一个基础的虚拟DOM转换为真实DOM的实现
- 初始化
component
主流程 - 初始化
element
主流程 - 实现代理对象
- 实现
props
、emit
及slot
功能 - 实现
provide/inject
功能
故事的开始,先来看一下vue3中初始绑定html
的写法,首先是index.html
这个文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<!-- 避免使用打包工具打包,降低流程,便于调试,这里直接将逻辑放在render.js中,使用script标签进行引入 -->
<script src="./process/render.js"></script>
</body>
</html>
那么html
中的dom内容是如何与vue3中的逻辑代码进行绑定的呢?相信这段代码大家也一定非常熟悉,通常位于vue项目工程的main.js
文件中:
import App from './App.vue'
// 获取真实dom节点
const dom = document.querySelector("#app")
// 处理vue中的组件逻辑内容,然后挂载到真实的dom节点上
createApp(App).mount(dom)
上面这段代码也是每个vue项目工程必要的一段代码,它的功能是创建一个应用实例,将vue组件处理后挂载到真实的DOM节点上。
至于createApp
和mount
函数现在可以不必关心,后文会实现这两个函数,现在只需要了解vue是如何将逻辑与真实DOM节点如何产生关联的即可。 至于createApp
接受的参数App
,则是又一个我们非常熟悉的文件,就是通常在vue项目工程主文件的App.js
中,通常也是vue项目的主入口:
export default {
setup() {
return {}
}
}
<template>
<div>
hello pino!
</div>
</template>
这也是我们每天都在使用的.vue
文件的语法,但是目前我们并不能支持解析.vue
文件,所以本文将直接使用创建对象的形式来代表一个组件,此外vue3也提供一个render
和h
函数,用于编程式地创建组件虚拟 DOM 树的函数,解析和创建vnode
节点,关于render
和h
函数,目前也只需要了解它的功能,下文中也会对其进行实现。
所以本文中的组件将会以这种形式来进行使用,以下的两种方式是等价的:
// 第一种方式
export default {
name: 'App',
setup() {
const count = ref(0)
return {
count
}
}
}
<template>
<div id="box">
hello pino!
</div>
</template>
// 第二种方式
const App = {
setup() {
const count = ref(0)
return {
count
}
},
render() {
return h('div', { id: 'box' }, 'hello pino!')
}
}
在本文的实现中,每个功能涉及的函数是非常多的,而且流程长了之后会非常难以理解,我会在每个功能实现的开头加入函数的功能说明和功能的整体流程图便于加强理解,如果在程序的流程处理时感觉有点混乱,建议回头看一下每个函数代表什么意思,再配合流程图,重新捋一下思路。
为了更加准确,下文中将组件称为component
,节点元素称为element
一. 初始化 component 主流程
首先先来定义一个组件,我们的目标就是将这个组件最终转换成真实DOM渲染到页面上:
const dom = document.querySelector("#app")
createApp(App).mount(dom)
// 定义组件,使用对象的方式进行定义
const App = {
setup() {},
// 使用render函数来定义需要渲染的dom节点
render() {
return h('div', {}, 'hello pino')
},
}
上文说过,vue3中的组件都是通过createApp
这个函数来挂载到html
中的,所以先来实现createApp
以及mount
函数:
// 接收根组件
const createApp = function(rootComponent) {
// 返回mount挂载函数
return {
// container即为真实的dom节点,所有虚拟dom生成的内容均挂载到此节点上
mount(container) {
// 创建vnode
const vnode = createVNode(rootComponent)
// 触发渲染函数
render(vnode, container)
}
}
}
createApp
函数的主要工作就是根据根组件来创建vnode
,并且触发render
函数来进行解析渲染。
1. createVNode
createVNode
函数主要是创建vnode
也就是虚拟DOM,虚拟DOM是一个在面试中久经不衰的问题,那么什么是vnode
呢?其实很简单,它其实只是一个用于描述dom
节点的对象而已,比如:
const vnode = {
type,
props,
children
}
上面的对象中,使用type
来用于描述节点对象,props
用于描述属性,children
用于描述子节点。 所以createVNode
也只是返回一个包装后的对象:
const createVNode = function(type, props, children) {
const vnode = {
type,
props,
children
}
return vnode
}
此时被处理后的vnode
2. render
render
函数为处理与渲染vnode
的入口函数:
// 调用patch函数
const render = function(vnode, container) {
patch(vnode, container)
}
3. patch
patch
函数为处理节点类型的中转站,此时只增加component
的逻辑:
const patch = function(vnode, container) {
// 调用processComponent函数,对component的vnode进行处理
processComponent(vnode, container)
}
4. processComponent
调用mountComponent
函数对component
进行解析:
const processComponent = function(vnode, container) {
mountComponent(vnode, container)
}
5. mountComponent
mountComponent
函数主要是对组件进行"拆包",回想一下我们组件中都包含什么?
const App = {
setup() {},
render() {},
}
所以mountComponent
函数的任务就很明确了,首先要执行setup
,因为setup
的返回值是我们在dom中所需要触发,渲染和使用的数据,然后就是对render
函数中h
函数返回的vnode
进行处理和渲染。
const mountComponent = function(vnode, container) {
// 创建组件实例
const instance = createComponentInstance(vnode)
// 处理setup
setupComponent(instance)
// 处理render
setupRenderEffect(instance, container)
}
------ 分割线 下面为一系列的componet
处理函数 ------
5.1 createComponentInstance
createComponentInstance
函数用于创建和包装一个component
对象,挂载属性,便于后续处理:
const createComponentInstance = function(vnode) {
const component = {
vnode,
type: vnode.type,
// 用于保存setup函数执行结果
setupState: {}
}
return component
}
5.2 setupComponent
调用setupStatefulComponent
函数对setup函数进行执行
:
const setupComponent = function(instance) {
setupStatefulComponent(instance)
}
5.3 setupStatefulComponent
调用setup
函数,保存执行结果
const setupStatefulComponent = function(instance) {
// 如果使用component来初始化vnode,整个的组件对象会保存与type中
// 这一点是和element不同的地方
const component = instance.type
const { setup } = component
if(setup) {
// 执行setup
const setupResult = setup();
// 调用handleSetupResult进行后续处理
handleSetupResult(instance, setupResult)
}
}
需要注意的是,关于在什么地方获取setup
,如果使用component
来初始化vnode
,整个的组件对象会保存在vnode
的type
中,这一点是和element
有区别的。可以看一下createVNode
函数的实现和调用逻辑。
5.4 handleSetupResult
保存setup
函数的执行结果,便于后续使用:
const handleSetupResult = function(instance, setupResult) {
if(typeof setupResult === 'object') {
// 将执行结果设置到component实例中
instance.setupState = setupResult
}
// 取得vnode中的render函数,保存到component实例中,相当于解构,少一层访问
instance.render = instance.type.render;
}
------ 分割线 component
类型调用结束 ------
6. setupRenderEffect
开始调用vnode
中的render
函数,处理vnode
节点
const setupRenderEffect = function(instance, container) {
// 执行render函数
const subTree = instance.render()
// 由于执行render后返回依然是一个vnode对象,继续递归调用patch进行处理
patch(subTree, container)
}
注意由于我们还没有实现h
函数,所以在调用render
函数时是会报错的,切莫惊慌,在下文中会继续实现后续逻辑。
以上整个component
的流程基本上完成了,这时候肯定会有人要问了,创建那么多函数干啥,明明正经的逻辑没有几行,其他全是在创建函数,其实这样实现先把所要经过的流程走了一遍,上文中看似只是简单调用的函数在后文中都会进行一步步的完善和补充,到那时大体的流程不会有太大变化了。
二. 初始化 element 主流程
上文中我们已经实现了component
类型的基本处理流程,然后执行到了h
函数,先来看一下vue3中的h
函数是如何使用的:
1. h
h('div', {}, 'hello pino')
h
函数接受三个参数,分别为元素类型,属性,子节点,其中子节点还可以传入数组,代表多个子节点,例如:
h('div', { class: 'box' }, [
h('span', {}, 'c1'),
h('p', {}, 'c2')
])
接下来是实现h
函数:
const h = function(type, props, children) {
return createVNode(type, props, children)
}
可以看到h
函数依旧调用createVNode
函数生成vnode
对象。 上文中setupRenderEffect
函数中,调用render
函数生成vnode
后依然由patch
函数进行对比。
2. vnode对比
截止到目前,执行了两次patch
函数,分别是初始化component
实例后调用patch
函数对组件对象进行拆解,然后分别执行setup
和render
,当执行render
函数后生成了element
类型的vnode
,再次调用了patch
函数。但是值得注意的是,这两次调用传入的vnode
是截然不同的,在patch
函数内打印一下vnode
对象也可以印证这一点:
可以看到,当第一次初始化根组件时,传入的vnode
是component
类型的,所生成的type
是组件对象,而props
与children
皆为undefined
。
而第二次在render
函数中生成的vnode
是element
类型,type
,props
,children
都有对应的参数传入,这也是我们在patch
中判断所处理的vnode
为组件或是节点的重要依据。 接下来就可以根据type
来改造一下patch
函数:
const patch = function(vnode, container) {
// 根据type进行区分
if(typeof vnode.type === 'string') {
// 处理element
processElement(vnode, container)
} else {
// 处理component
processComponent(vnode, container)
}
}
3. processElement
processElement
函数主要用于调用mountElement
函数对element
进行处理:
const processElement = function(vnode, container) {
mountElement(vnode, container)
}
4. mountElement
对节点的处理,主要包括:根据type
来创建DOM节点,根据props
来创建节点属性,根据children
来生成子节点,当children
属性为数组时,则需要递归创建子节点。
const mountElement = function(vnode, container) {
// 创建dom节点
const el = document.createElement(vnode.type)
const { children, props } = vnode
// children
if(typeof children === 'string') {
// 如果children属性为字符串,则只需要设置文本节点即可
el.textContent = children
} else {
// 调用mountChildren处理子节点
mountChildren(children, el)
}
// props
// 设置节点属性
for(const key in props) {
const prop = props[key]
el.setAttribute(key, prop)
}
// 挂载dom
container.append(el)
}
5. mountChildren
如果vnode
得children
属性为数组,那么需要进行遍历,使用patch
进行递归对比:
const mountChildren = function(children, container) {
children.forEach(v=>{
patch(v, container)
})
}
至此,component
和element
最基本的渲染流程就完成了,我们使用上文中的例子来试一下:
// 根组件
const App = {
setup() {},
render() {
return h('div', { id: 'box' }, 'hello pino!')
},
}
<style>
#box {
color: red;
font-weight:600
}
</style>
可以看到,已经正确显示到了html
中。
三. 实现组件代理对象及位运算应用
1. 代理对象
接下来要实现组件代理对象,因为我们不可能一直在render
中使用固定的数据,上面的例子我们只是将固定的字符串传入了h
函数,看下面的例子:
const App = {
setup() {
return {
msg: 'one',
}
},
render() {
// 使用setup函数结果进行拼接
return h('div', { id: 'box' }, 'we are ' + this.msg + '!')
},
}
在这个案例中,我们想要实现在执行h
函数来创建vnode
时,使用setup
函数返回的变量,这也是我们在日常开发中的用法,上面的例子中,我们想要的效果是在页面中显示 "we are one"。 那么如何实现呢,可以看到,在h
函数中使用this
来访问setup
函数返回的变量,那么绑定this
的方法js
原生已经为我们提供了,也就是说,在执行用户传入的render
函数时,将this
指向绑定到setup
函数所返回的对象上,就可以实现想要的结果。
但是还有一个问题,vue3
中的this.$el
是如何实现的呢?vue
内部为我们定义了一些默认的变量,用户获取指定的信息,比如this.$el
就代表$el
用于获取 vue
实例挂载的 DOM
元素。我们并没有在setup
中定义$el
相关的变量,那么是如何被注入到我们的this
上的呢?
这就用到在响应式中大显神威的Proxy
了,试想,如果我们对对象进行拦截,当访问$el
等内置的属性时,就可以对this
进行注入不同的逻辑处理。
const setupStatefulComponent = function(instance) {
const component = instance.type
// 在instance中加入代理对象
// 将instance赋值给_
instance.proxy = new Proxy({ _: instance }, PublicInstanceProxyHandlers); // 新增
const { setup } = component
if(setup) {
const setupResult = setup();
handleSetupResult(instance, setupResult)
}
}
接下来设置代理对象:
// 定义字典,便于扩展
const publicPropertiesMap = {
$el: i => i.vnode.el
}
// { _: instance }为解构赋值,更换属性名
const PublicInstanceProxyHandlers = {
get({ _: instance }, key) {
const { setupState } = instance
if(key in setupState) {
return setupState[key]
}
// 如果不存在于setup函数的结果对象,则根据key返回默认的属性
const publicGetter = publicPropertiesMap[key];
if (publicGetter) {
return publicGetter(instance);
}
}
}
但是目前我们的vnode
上并没有el
,所以在赋值的时候,将dom
元素进行保存:
const mountElement = function(vnode, container) {
const el = (vnode.el = document.createElement(vnode.type)); // 修改
// 省略...
}
对el
进行更新:
const mountComponent = function(vnode, container) {
// 省略...
setupRenderEffect(instance, vnode, container) // 修改
}
const setupRenderEffect = function(instance, vnode, container) { // 修改
const { proxy } = instance
const subTree = instance.render.call(proxy) // 修改
patch(subTree, container)
// 对el进行更新
vnode.el = subTree.el; // 新增
}
最后在执行render
函数时,将this
绑定到代理对象上:
const setupRenderEffect = function(instance, container) {
const { proxy } = instance // 新增
const subTree = instance.render.call(proxy) // 修改
patch(subTree, container)
vnode.el = subTree.el
}
可以看到页面已经发生了变化。
2. 位运算应用
接下来将渲染流程中的一些判断使用位运算的方式进行改造一下,那么为啥要使用位运算呢? 其实目的就是可以对流程判断进行统一管理,使整个节点的判断更加的清晰易用。 那么什么是位运算呢?移位运算就是对二进制进行有规律低移位。其实可能在日常的开发中并不会经常接触二进制的数据,但是对于二进制js
也已经进行了支持,比如toString
方法:
(4).toString(2) // 100
为toString
方法传入参数2,就代表将某个数字转换为二进制数据。 JavaScript 中的按位操作符有:
下面举几个例子,主要看下 AND
和 OR
:
# 例子1
A = 10001001
B = 10010000
A | B = 10011001
# 例子2
A = 10001001
C = 10001000
A | C = 10001001
# 例子1
A = 10001001
B = 10010000
A & B = 10000000
# 例子2
A = 10001001
C = 10001000
A & C = 10001000
位运算结合 按位与 、按位或 在权限系统中有非常巧妙的应用。可以使用|
(按位与) 可以用来赋予权限,&
(按位与) 可以用来校验权限。例如:
添加权限
let a= 100
let b = 010
let c = 001
// 给用户赋全部权限(使用前面讲的 | 操作)
// 拥有了全部权限
let user = r | w | x // 111
校验权限
let a = 100
let b = 010
let c = 001
// 给用户赋 a b 两个权限
let user = a | b // 110
console.log((user & a) === a) // true 有 a 权限
console.log((user & b) === b) // true 有 b 权限
console.log((user & c) === c) // false 没有 c 权限
接下来就将位运算的思想应用于流程判断中,首先先创建一个权限集合ShapeFlags
,用于定义所有的“权限”:
const ShapeFlags = {
ELEMENT: 1, // 0001
STATEFUL_COMPONENT: 1 << 1, // 0010
TEXT_CHILDREN: 1 << 2, // 0100
ARRAY_CHILDREN: 1 << 3 // 1000
}
将每个权限依次进行移位,在移位运算过程中,符号位始终保持不变如果右侧空出位置,则自动填充为 0。 在创建vnode
的时候进行初始化:
const createVNode = function (type, props, children) {
const vnode = {
type,
props,
children,
// 初始化
shapeFlag: getShapeFlag(type),
el: null,
}
// 判断children是否为数组,也就是是否有子组件
if (typeof children === 'string') {
// 此处的 |= 相当于 vnode.shapeFlag = vnode.shapeFlag | ShapeFlags.TEXT_CHILDREN
// 此处可以理解为“增加权限”的操作
vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
} else if (Array.isArray(children)) {
vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
}
return vnode
}
const getShapeFlag = function (type) {
return typeof type === 'string' ? ShapeFlags.ELEMENT : ShapeFlags.STATEFUL_COMPONENT
}
接下来就可以在patch
函数与mountElement
函数中分别进行判断:
patch
const patch = function (vnode, container) {
const { shapeFlag } = vnode
// patch修改位运算
// if(typeof vnode.type === 'string') {
// processElement(vnode, container)
// } else {
// processComponent(vnode, container)
// }
if (shapeFlag & ShapeFlags.ELEMENT) { // 修改
processElement(vnode, container)
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) { // 修改
processComponent(vnode, container)
}
}
mountElement
const mountElement = function (vnode, container) {
const el = (vnode.el = document.createElement(vnode.type));
const { children, props, shapeFlag } = vnode
// children
// children修改位运算
// if(typeof children === 'string') {
// el.textContent = children
// } else {
// mountChildren(children, el)
// }
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 修改
el.textContent = children
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 修改
mountChildren(children, el)
}
for (const key in props) {
const prop = props[key]
el.setAttribute(key, prop)
}
container.append(el)
}
四. 实现事件注册
实现以下示例:
const App = {
setup() {
console.log(this.$el);
return {
msg: 'one',
}
},
render() {
return h('div', {
id: 'box',
// 注册点击事件
onClick() {
console.log("触发了click~~");
},
}, 'we are ' + this.msg + '!')
},
}
实现事件的注册逻辑很简单,因为事件的注册都是以on
开头的,例如:onAdd
,onChange
等。所以只需要在设置属性的时候对事件进行区分:
const mountElement = function (vnode, container) {
const el = (vnode.el = document.createElement(vnode.type));
// 省略...
// props
for (const key in props) {
const prop = props[key]
const isOn = key => /^on[A-Z]/.test(key) // 新增
// 使用on进行绑定事件
if(isOn(key)) { // 新增
const event = key.slice(2).toLowerCase() // 新增
el.addEventListener(event, prop) // 新增
} else { // 新增
el.setAttribute(key, prop)
}
}
container.append(el)
}
利用正则匹配来验证属性的key
是否以on
开头,如果是的话就需要注册事件,使用addEventListener
进行事件注册。
事件已经正确触发。
五. 实现组件的 props
props
通常用于在父组件中给子组件传值,这里其实有一个需要注意的点,如果不注意的话,可能会走到误区里,component
类型的vnode
与element
类型的vnode
中处理props
是不同的,element
中的props
是用于设置DOM的属性,而component
中的props
是用于作为参数传递给setup
函数给用户使用的,这两者的处理逻辑是不同的,是两条不会相交的逻辑处理。
认识到这一点,其实实现props
就非常简单了。 先来创建两个组件嵌套:
const App = {
setup() {},
render() {
return h('div', {
id: 'box',
}, [
h('div', {}, 'i am a'),
h(Son, { name: 'good man' })
])
},
}
const Son = {
setup(props) {
// 需打印
console.log(props);
},
render() {
return h("div", {}, this.name);
},
}
如上所示,我们创建了两个组件,App
作为父组件,Son
为子组件,子组件中传递了props
为name
,最后期望能够输出i am a good man
,并且在控制台打印name
。 首先我们要做的就是保存props
,因为props
是定义在vnode
上的,为了调用方便,我们在调用setup
函数之前,将它挂载到组件实例上:
const setupComponent = function (instance) {
// 挂载到组件实例
instance.props = instance.vnode.props || {} //新增
setupStatefulComponent(instance)
}
相应的,在初始化组件实例时,也应该初始化props
:
const createComponentInstance = function (vnode) {
const component = {
vnode,
type: vnode.type,
props: {}, // 新增
setupState: {}
}
return component
}
接下来在执行setup
函数时,将props
当作参数传入setup
:
const setupStatefulComponent = function (instance) {
// 省略...
if (setup) {
// const setupResult = setup();
const setupResult = setup(shallowReadonly(instance.props)); // 修改
handleSetupResult(instance, setupResult)
}
}
加入shallowReadonly
是为了禁止子组件修改父组件传递过来的值,此内容为响应式部分,有需要可移步响应式文章。 最后还有一步,因为我们支持在vnode
中使用this
来调用属性,所以还需要在代理对象中设置转发:
const PublicInstanceProxyHandlers = {
get({ _: instance }, key) {
const { setupState, props } = instance // 修改
const hasOwn = (val, key) =>
Object.prototype.hasOwnProperty.call(val, key); // 新增
if (hasOwn(setupState, key)) {
return setupState[key];
} else if (hasOwn(props, key)) { //新增
return props[key]; // 新增
}
const publicGetter = publicPropertiesMap[key];
if (publicGetter) {
console.log(instance);
return publicGetter(instance);
}
}
}
实现用Object.prototype.hasOwnProperty
判断所访问属性的来源,然后设置转发,返回setup
函数执行结果或props
的数据。
可以看到页面已经更新。
六. 实现组件 emit 功能
vue中的emit
是用于在子组件内触发父组件传递的函数,在vue3
版本中可以在setup
函数中的第二个参数解构获取,emit
是一个函数,用于指定函数名进行触发并传递参数。例如:
const App = {
setup(props, { emit }) {
emit('add', 1, 2)
}
}
由此,我们可以写出一个示例:
const App = {
setup() {},
render() {
return h('div', { class: 'box' }, [
h('p', {}, 'App component'),
// 向子组件传递函数Add, AddFoo
h(Son, {
onAdd(a, b) {
console.log('this is Add', a, b);
},
onAddFoo(c) {
console.log('this is AddFoo', c);
}
})
])
}
}
const Son = {
setup(props, { emit }) {
// 触发emit,执行父组件传递的函数
const changeEmit = () => {
emit('add', 1, 2)
emit('add-foo', 3)
console.log('emit change!');
}
return {
changeEmit
}
},
render() {
const son = h('p', {}, 'Son component')
// 点击按钮,触发emit
const btn = h('button', {
onClick: this.changeEmit
}, '触发emit')
return h('div', { class: 'son' }, [son, btn])
}
}
我们在App
根组件中引入了Son
组件,然后向子组件传递了Add
和AddFoo
两个函数,我们需要实现的效果是,当点击子组件的触发emit按钮时,能够通过emit
来触发Add
和AddFoo
这两个函数。
其实和props
传递数据的思路是一样的,我们可以在执行setup
函数时,将emit
函数作为setup
函数的参数进行传递。而emit
主要是什么功能呢,其实功能也很简单,先看一下父组件中传递的函数名与子组件中的调用名:
add -> onAdd
add-foo -> onAddFoo
其实到这里就很明朗了,只要我们把子组件中调用的函数名进行修改,与父组件传入的函数名一致不就可以取到函数体了吗,还有一个问题,怎么获取父组件传递的函数呢?当然是props
中啊,由于props
已经被保存到component
实例中了,所以很容易获取。
所以整个核心逻辑就是修改属性名与父组件的事件名匹配,然后将其作为key,在props中获取事件函数并执行。
当然修改属性名也是有一定的规则的,可以讲字符串转换为大驼峰然后前面加入'on'进行拼接,即可变更为事件名。同样也支持使用(-)短横线的方式调用,将短横线后面的第一个字符转换为大写,然后进行拼接,有了转换规则,就可以实现逻辑了:
首先在初始化component
时对emit
函数进行初始化:
const createComponentInstance = function (vnode) {
const component = {
vnode,
type: vnode.type,
props: {},
setupState: {},
emit: () => { } // 增加
}
component.emit = emit.bind(null, component) // 增加
return component
}
这里有一个需要注意的地方,为啥要使用bind
进行绑定呢?其实这里的目的并不是绑定this
,而是传递参数,试想,我们需要将component
实例进行传递便于获取props
,但是也需要在使用的时候支持用户自定义传入参数,这样显然是很难取舍的,而使用bind
就可以完美的解决这个问题,bind
绑定是不会执行的,主要的作用就是默认传递第一个参数。
在执行setup
是,传入emit
函数:
const setupStatefulComponent = function (instance) {
const component = instance.type
instance.proxy = new Proxy({ _: instance }, PublicInstanceProxyHandlers);
const { setup } = component
if (setup) {
const setupResult = setup(instance.props, { // 修改
emit: instance.emit
});
handleSetupResult(instance, setupResult)
}
}
接下来实现emit
函数:
const emit = function (instance, event, ...args) {
// 从component实例中获取props
const { props } = instance
// 获取处理后的属性名
const handlerName = toHandlerKey(camelize(event))
// 执行事件函数
const handler = props[handlerName]
handler && handler(...args)
}
接下来是处理属性名的操作:
// Add -> onAdd
const toHandlerKey = function (str) {
return str ? 'on' + capitalize(str) : ''
}
// add -> Add
const capitalize = function(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
// add-foo -> addFoo
const camelize = function(str) {
return str.replace(/-(\w)/g, (_, c) => {
return c ? c.toUpperCase() : ''
})
}
接下来返回页面,点击触发emit
按钮,可以看到函数均已正确执行。
七. 实现组件 slots 功能
slots
(插槽)也是vue中非常常用的功能之一,一般我们是这样使用的:
// 父组件
<template>
<div>
<slot></slot>
</div>
</template>
<script>
export default {
name: 'children'
}
</script>
// 使用children组件
<children>代替slot的内容</children>
渲染后的结果
<template>
<div>
代替slot的内容
</div>
</template>
但是我们目前并不能实现模版功能,所以只能在h
函数中进行使用,例如:
// 父组件
// 具名插槽
const son = h(Son, {}, {
// name为子组件传递参数
header: ({ name }) => h("p", {}, "header " + name),
footer: () => h("p", {}, "footer"),
})
在父组件中,我们使用h
函数的第三个参数为对象的形式来表示插槽,key
为名称。value
为函数,子组件可以调用时传参。
在子组件中,通过renderSlots
函数来进行渲染。
// 子组件
h('div', {}, [
// 通过this.$slots来获取slots列表,功能同this.$el
// 第二个参数为指定渲染slots,需同父组件的名城对应
// 第三个参数为参数传递
renderSlots(this.$slots, "header", {
name,
}),
son,
renderSlots(this.$slots, "footer"),
])
根据以上在h
函数的用法,我们先来写一个案例:
// 父组件
const App = {
setup() {},
render() {
const app = h('p', {}, 'App')
const son = h(Son, {}, {
// 定义slots
header: ({ name }) => h("p", {}, "header " + name),
footer: () => h("p", {}, "footer"),
})
return h('div', {}, [app, son])
}
}
// 子组件
const Son = {
setup() {},
render() {
const name = 'pino'
const son = h("p", {}, "Son");
return h('div', {}, [
// 传递变量name,在header这个slots中渲染出来
renderSlots(this.$slots, "header", {
name,
}),
son,
renderSlots(this.$slots, "footer"),
])
}
}
我们想要的效果是最后将"header pino"、"footer"全部显示出来。
根据上面的例子可以看出,我们至少需要实现:renderSlots
函数(用于渲染slots)、this.$slots
(用于获取slots列表)、变量传递等功能。
实现思路就是:首先在子组件渲染时,判断children
是否为对象,如果为对象,说明需要渲染slots
,接下来将children
中所有的值进行遍历,使用函数包裹(便于处理传参),挂载到component
实例的slots
属性中,在用户调用renderSlots
函数时,根据名称将所属的slots
取出来,使用第三个参数作为参数传入调用。最后调用createVNode
函数创建vnode
即可。
接下来进行实现,首先第一步需要添加“权限”,因为children
所属类型又增加了一种形式:对象。
const ShapeFlags = {
ELEMENT: 1,
STATEFUL_COMPONENT: 1 << 1,
TEXT_CHILDREN: 1 << 2,
ARRAY_CHILDREN: 1 << 3,
SLOT_CHILDREN: 1 << 4 //新增
}
const createVNode = function (type, props, children) {
// 省略...
// 为slots类型增加“权限”
if(vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) { // 新增
// 根绝children是否为对象来判断
if(typeof children === 'object') { // 新增
vnode.shapeFlag |= ShapeFlags.SLOT_CHILDREN // 新增
}
}
return vnode
}
接下来在初始化component
实例时增加slots
属性:
const createComponentInstance = function (vnode) {
const component = {
vnode,
type: vnode.type,
props: {},
setupState: {},
slots: {}, // 新增
emit: () => {}
}
component.emit = emit.bind(null, component)
return component
}
// 用于使用this进行访问slots
const publicPropertiesMap = {
$el: i => i.vnode.el,
$slots: i => i.slots, // 新增
}
在component
实例上挂载slots
列表:
const setupComponent = function (instance) {
const { vnode, slots } = instance
// 初始化slots
// 判断是否具有slots权限
if(vnode.shapeFlag & ShapeFlags.SLOT_CHILDREN) { // 新增
normalizeObjectSlots(vnode.children, slots) // 新增
} // 新增
// 初始化props
instance.props = vnode.props || {}
setupStatefulComponent(instance)
}
normalizeObjectSlots
方法主要是将children
中的值挂载到componet
实例的slots
中:
const normalizeObjectSlots = function(children, slots) { // 新增
for(let key in children) { // 新增
const value = children[key] // 新增
// 使用函数进行包裹
// 因为我们要为slots传递参数
// 将执行结果包裹为数组
slots[key] = props => value(props) // 新增
} // 新增
}
将slots
函数进行包裹,便于传参。
最后返回slots
函数的执行结果,相当于对比普通的渲染方式多了一层转换。
const renderSlots = function(slots, name, props) { // 新增
const slot = slots[name] // 新增
if(slot) { // 新增
if(typeof slot === 'function') { // 新增
return slot(props) // 新增
} // 新增
}
}
如果我们拿前面的例子来对比一下,相当于:
header: ({ name }) => h("p", {}, "header " + name),
return h("p", {}, "header " + 'pino') // 新增
页面已经发生改变:
八. 实现 Fragment 和 Text 类型节点
有时候,我们可能并不想创建那么多节点的包裹嵌套,按照现在的创建节点的方式,我们必须要在外层先创建父节点进行包裹,比如:
const App = {
setup() {},
render() {
return h('div', {}, [
h('span', {}, 'span'),
h('p', {}, 'App')
])
}
}
上面这段代码渲染在页面中的DOM结构是这样的:
我们的本意只是想渲染span
和p
标签,但是由于目前的实现,必须要使用一个父标签进行包裹,也就是图中的div
,这就多了一层根本没有必要的嵌套。而且有时候可能只是想渲染一个纯文本节点,也没有必要嵌套一个标签。
接下来首先我们新增一个Fragment
类型,用于标识只包裹children
,而不产生实际的标签。新增Text
类型和createTextVNode
函数来标识生成纯文本节点。所以改写一下上面的案例:
const App = {
setup() {},
render() {
return h(Fragment, { id: 'box' }, [
h('span', {}, 'span'),
createTextVNode('App')
])
}
}
使用Fragment
进行包裹,createTextVNode
创建一个纯文本节点。
首先先改写一下patch
函数,因为在处理vnode
时,我们在patch
函数中根据类型的不同进行不同的处理:
// 定义唯一值Fragment和Text
const Fragment = Symbol("Fragment"); // 新增
const Text = Symbol("Text"); // 新增
const patch = function (vnode, container) {
// 获取type,根据Text判断
const { type, shapeFlag } = vnode // 修改
// if (shapeFlag & ShapeFlags.ELEMENT) {
// processElement(vnode, container)
// } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
// processComponent(vnode, container)
// }
switch(type) {
case Fragment:
// 处理Fragment
processFragment(vnode, container);
break
case Text:
// 处理Text
processText(vnode, container);
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(vnode, container)
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(vnode, container)
}
break
}
}
我们使用Symbol
来生成唯一值Fragment
和Text
,来避免重复。接下来根据type
值来对不同的vnode
类型进行处理。
const processFragment = function(vnode, container) { // 新增
mountChildren(vnode.children, container) // 新增
} // 新增
如果type
为Fragment
类型,代表当前节点只是一个包裹的作用,那么只需要调用mountChildren
处理children
即可。
const processText = function(vnode, container) { // 新增
const { children } = vnode // 新增
// 直接创建文本节点并挂载
const textVNode = (vnode.el = document.createTextNode(children)) // 新增
container.append(textVNode) // 新增
} // 新增
如果type
为Text
类型,那么直接生成文本节点挂载,而不需要再继续进行处理了。
如果在h
函数内使用createTextVNode
,就代表直接创建文本节点,直接使用createVNode
创建一个文本节点类型的vnode
返回。
const createTextVNode = function(text) { // 新增
return createVNode(Text, {}, text) // 新增
} // 新增
此时,页面中就没有多余的标签了。
九. 实现 provide/inject 功能
vue中的provide/inject
可以更便捷的进行数据共享,通常,当我们需要从父组件向子组件传递数据时,我们使用 props
进行传递。但是想象一下这样的结构:有一些深度嵌套的组件,而深层的子组件只需要父组件的部分内容。在这种情况下,如果仍然将 prop 沿着组件链逐级传递下去,可能会很麻烦。
对于这种情况,我们可以使用一对 provide
和 inject
。无论组件层次结构有多深,父组件都可以作为其所有子组件的依赖提供者。这个特性有两个部分:父组件有一个 provide
选项来提供数据,子组件有一个 inject
选项来开始使用这些数据。
在vue3中通常是这样使用的:
父组件:
// 伪代码
setup() {
// 注入数据
provide('name', 'pino')
provide('age', 18)
}
子组件:
// 伪代码
setup() {
// 消费数据
const name = inject('name')
const age = inject('age')
}
在实现此功能之前,先来实现一个小功能,getCurrentInstance
用于获取当前的component
的实例,这个方法可以由用户在setup
函数中进行调用,所以在执行setup
函数之前进行赋值:
// 设置全局变量currentInstance
let currentInstance = null;
const setupStatefulComponent = function (instance) {
// 省略...
if (setup) {
// 设置currentInstance
setCurrentInstance(instance)
const setupResult = setup(instance.props, {
emit: instance.emit
});
setCurrentInstance(null)
handleSetupResult(instance, setupResult)
}
}
// 获取当前执行的component实例
const getCurrentInstance = function() {
return currentInstance;
}
// 设置currentInstance
const setCurrentInstance = function(instance) {
currentInstance = instance;
}
接下来先实现一个案例:
// 根组件
const App = {
name: 'App',
setup() {
// 注入数据
provide('name', 'pino')
provide('age', 18)
},
render() {
return h('div', {}, [
h('p', {}, 'App'),
h(Son)
])
}
}
// 子组件
const Son = {
name: 'Son',
setup() {
// 注入数据
provide('name', 'momo')
// 消费数据
const name = inject('name')
return {
name
}
},
render() {
return h('div', {}, [
h('p', {}, `Son -> name: ${this.name}`),
h(Grandson)
])
}
}
// 孙组件
const Grandson = {
name: 'Grandson',
setup() {
// 消费数据
const name = inject('name')
const age = inject('age')
return {
name,
age
}
},
render() {
return h('div', {}, `Grandson -> name:${this.name} age:${this.age}`)
}
}
在这个案例中,我们在根组件App
中使用provide
注入数据,然后在孙组件Grandson
使用inject
对name
和age
进行使用,并在页面中渲染出来。
其实只看provide/inject
的使用方式,很容易就可以想得到实现方案,就是在使用provide
的时候对数据进行存储,当使用inject
的时候取出数据,问题的关键就是在哪里保存数据呢,怎么能保证使用provide
存储数据后,可以跨层级的使用数据呢?
在js
原生的方法中,在创建对象时可以使用**Object.create()
** ,Object.create()
方法用于创建一个新对象,使用现有的对象来作为新创建对象的原型(prototype
)。所以我们就可以根据原型链来解决这个问题,为创建的component实例上挂载provide对象,初始化的时候将父级的provide作为当前component实例的prototype,根据DOM层级,形成一个原型链调用链条。这样当访问属性的时候,首先会查找自身的provide对象,如果没有找到,会沿着原型链向上查找。 这样就可以解决跨层级获取数据的问题。
const createComponentInstance = function (vnode, parent) { // 修改
const component = {
vnode,
type: vnode.type,
props: {},
setupState: {},
// 添加provides
provides: parent ? parent.provides : {}, // 修改
// 添加parent,用于保存父级component实例
parent: parent ? parent : {}, // 修改
slots: {},
emit: () => {}
}
component.emit = emit.bind(null, component)
return component
}
那么parent
父级实例是在哪里被传入的呢?试想一下,一个vnode
的children
是在哪里被返回的?一定是在执行用户传入的render
函数被调用的地方,于是在setupRenderEffect
函数传入父级实例:
const setupRenderEffect = function (instance, vnode, container) {
const { proxy } = instance
const subTree = instance.render.call(proxy)
// 此时的subTree已经是子级的vnode,而instance为父级
patch(subTree, container, instance) // 修改
vnode.el = subTree.el;
}
父级的实例已经获取到并且传入了,但是并没有直接传入到createComponentInstance
函数中直接进行生成component
实例,这中间还需要在所有调用patch
及相关函数的位置进行传递参数的修改(由于大部分函数只是增加参数,下面列举仅列出所需增加参数的函数):
const render = function (vnode, container) {
// render函数调用时为根组件初始化,直接传入null即可
patch(vnode, container, null) // 修改
}
// patch函数增加第三个参数parentComponent,用于表示父级实例
const patch = function (vnode, container, parentComponent) { // 修改
// 省略...
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(vnode, container, parentComponent) // 修改
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(vnode, container, parentComponent) // 修改
}
break
}
}
const processFragment = function(vnode, container, parentComponent) { // 修改
mountChildren(vnode.children, container, parentComponent) // 修改
}
const processElement = function (vnode, container, parentComponent) { // 修改
mountElement(vnode, container, parentComponent) // 修改
}
const mountElement = function (vnode, container, parentComponent) { // 修改
const el = (vnode.el = document.createElement(vnode.type));
const { children, props, shapeFlag } = vnode
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
el.textContent = children
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(children, el, parentComponent) // 修改
}
// 省略...
}
const mountChildren = function (children, container, parentComponent) { // 修改
children.forEach(v => {
patch(v, container, parentComponent) // 修改
})
}
const processComponent = function (vnode, container, parentComponent) { // 修改
mountComponent(vnode, container, parentComponent) // 修改
}
const mountComponent = function (vnode, container, parentComponent) { // 修改
// 创建组件实例
const instance = createComponentInstance(vnode, parentComponent) // 修改
setupComponent(instance)
setupRenderEffect(instance, vnode, container)
}
详细代码可参见:
然后可以实现provide
函数,用于同父级provide
对象建立连接,保存数据。
const provide = function(key, value) {
// 获取当前component实例
const currentInstance = getCurrentInstance();
if (currentInstance) {
let { provides } = currentInstance;
// 获取父级provide对象
const parentProvides = currentInstance.parent.provides;
// 第一次执行provide时,设置当前实例provide的prototype为父级的provide
if (provides === parentProvides) {
provides = currentInstance.provides = Object.create(parentProvides);
}
// 保存数据
provides[key] = value;
}
}
provide
函数的实现中,使用provides === parentProvides
来判断是否为第一次执行provide
,为什么会成立呢?因为我们初始化当前的component
实例的provide
对象时,是直接使用父级的provide
进行初始化的:
而后续保存数据后,再次执行provide
函数,两个对象不再相等。
而inject
函数则是根据key
进行取值就可以了:
const inject = function(key) {
// 获取当前实例
const currentInstance = getCurrentInstance();
if (currentInstance) {
const parentProvides = currentInstance.parent.provides;
// 判断key是否存在于parentProvides中
if (key in parentProvides) {
return parentProvides[key];
}
}
}
我们上面的案例就可以正常显示了:
如果想要为key
增加默认值,就像这样:
const Grandson = {
name: 'Grandson',
setup() {
const name = inject('name')
const age = inject('age')
// 增加addr,设置默认值为函数,返回“火星”
const addr = inject("addr", () => "火星");
return {
name,
age,
addr
}
},
render() {
return h('div', {}, `Grandson -> name:${this.name} age:${this.age} addr:${this.addr}`)
}
}
实现这个功能,只需要在inject
函数中增加第二个参数defaultValue
的判断,来返回不同的结果。
// 增加默认值参数defaultValue
const inject = function(key, defaultValue) {
const currentInstance = getCurrentInstance();
if (currentInstance) {
const parentProvides = currentInstance.parent.provides;
if (key in parentProvides) {
return parentProvides[key];
// 如果不存在,则判断是否存在defaultValue
}else if(defaultValue){
// 如果为函数,执行返回结果
if(typeof defaultValue === "function"){
return defaultValue()
}
return defaultValue
}
}
}
支持两种默认值方式,函数和值的形式,根据默认值类型的不同来进行不同的返回。
本文只实现了首次渲染的过程,因为篇幅过长,更新过程将会发布在下一篇文章。
写在最后 ⛳
未来可能会更新实现mini-vue3
和javascript
基础知识系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳
参考文章
转载自:https://juejin.cn/post/7142694451448643591