likes
comments
collection
share

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

作者站长头像
站长
· 阅读数 59

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

hey🖐! 我是pino😊😊。一枚小透明,期待关注➕ 点赞,共同成长~

写在前面

本文的目标是实现一个基本的 vue3 的虚拟DOM的节点渲染与更新,包含最基础的情况的处理,本文是系列文章,本系列已全面使用vue3组合式语法,如果你对 vue3基础语法及响应式相关逻辑还不了解,那么请移步:

超详细整理vue3基础知识💥

狂肝半个月!1.3万字深度剖析vue3响应式(附脑图)🎉🎉

更新!更新!实现vue3虚拟DOM更新&diff算法优化🎉 🎉

大结局!实现vue3模版编译功能🎉 🎉

本文只是整个vue3渲染器的上篇内容,包含vnode的首次渲染过程,组件及节点的更新,更新优化,diff算法的内容将会放在下一篇内容。

食用提醒!必看

由于整个渲染过程中的函数实现以及流程过长,有很多函数的实现内容在相关的章节并不会全部展示,并且存在大量的伪代码,相关章节只会关注当前功能代码的显示和实现。

但是!为了便于理解,我在github上上传了每章节的具体实现。(请把贴心打在评论区 😂😂)把每一章节的实现都存放在了单独的文件夹:

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

只使用了单纯的htmljs来实现功能,只需要在index.html中替换相关章节的文件路径(替换以下三个文件),在浏览器中打开,就可以自行调试和查看代码。见下图:

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

地址在这里,欢迎star!

👉 vue3-analysis(component)

mini-vue3的正式项目地址在这里!目前只实现了响应式和渲染器部分!

👉 k-vue

欢迎star!

本文你将学到

  • 虚拟DOM
  • 一个基础的虚拟DOM转换为真实DOM的实现
  • 初始化component主流程
  • 初始化element主流程
  • 实现代理对象
  • 实现 propsemitslot 功能
  • 实现 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节点上。

至于createAppmount函数现在可以不必关心,后文会实现这两个函数,现在只需要了解vue是如何将逻辑与真实DOM节点如何产生关联的即可。 至于createApp接受的参数App,则是又一个我们非常熟悉的文件,就是通常在vue项目工程主文件的App.js中,通常也是vue项目的主入口:

 export default {
   setup() {
     return {}
   }
 }
 
 <template>
   <div>
   hello pino!
     </div>
 </template>

这也是我们每天都在使用的.vue文件的语法,但是目前我们并不能支持解析.vue文件,所以本文将直接使用创建对象的形式来代表一个组件,此外vue3也提供一个renderh函数,用于编程式地创建组件虚拟 DOM 树的函数,解析和创建vnode节点,关于renderh函数,目前也只需要了解它的功能,下文中也会对其进行实现。

所以本文中的组件将会以这种形式来进行使用,以下的两种方式是等价的:

 // 第一种方式
 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 主流程

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

首先先来定义一个组件,我们的目标就是将这个组件最终转换成真实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
 }

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

此时被处理后的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,整个的组件对象会保存在vnodetype中,这一点是和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 主流程

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

上文中我们已经实现了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函数对组件对象进行拆解,然后分别执行setuprender,当执行render函数后生成了element类型的vnode,再次调用了patch函数。但是值得注意的是,这两次调用传入的vnode是截然不同的,在patch函数内打印一下vnode对象也可以印证这一点:

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

可以看到,当第一次初始化根组件时,传入的vnodecomponent类型的,所生成的type是组件对象,而propschildren皆为undefined

而第二次在render函数中生成的vnodeelement类型,typepropschildren都有对应的参数传入,这也是我们在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

如果vnodechildren属性为数组,那么需要进行遍历,使用patch进行递归对比:

 const mountChildren = function(children, container) {
   children.forEach(v=>{
     patch(v, container)
   })
 }

至此,componentelement最基本的渲染流程就完成了,我们使用上文中的例子来试一下:

 // 根组件
 const App = {
   setup() {},
   render() {
     return h('div', { id: 'box' }, 'hello pino!')
   },
 }
 
 <style>
       #box {
         color: red;
         font-weight:600
       }
 </style>

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

可以看到,已经正确显示到了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
 }

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

可以看到页面已经发生了变化。

2. 位运算应用

接下来将渲染流程中的一些判断使用位运算的方式进行改造一下,那么为啥要使用位运算呢? 其实目的就是可以对流程判断进行统一管理,使整个节点的判断更加的清晰易用。 那么什么是位运算呢?移位运算就是对二进制进行有规律低移位。其实可能在日常的开发中并不会经常接触二进制的数据,但是对于二进制js也已经进行了支持,比如toString方法:

 (4).toString(2) // 100

toString方法传入参数2,就代表将某个数字转换为二进制数据。 JavaScript 中的按位操作符有:

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

下面举几个例子,主要看下 ANDOR

 # 例子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开头的,例如:onAddonChange等。所以只需要在设置属性的时候对事件进行区分:

 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进行事件注册。

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

事件已经正确触发。

五. 实现组件的 props

props通常用于在父组件中给子组件传值,这里其实有一个需要注意的点,如果不注意的话,可能会走到误区里,component类型的vnodeelement类型的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为子组件,子组件中传递了propsname,最后期望能够输出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的数据。

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

可以看到页面已经更新。

六. 实现组件 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组件,然后向子组件传递了AddAddFoo两个函数,我们需要实现的效果是,当点击子组件的触发emit按钮时,能够通过emit来触发AddAddFoo这两个函数。

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

其实和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按钮,可以看到函数均已正确执行。

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

七. 实现组件 slots 功能

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

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') // 新增

页面已经发生改变:

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

八. 实现 Fragment 和 Text 类型节点

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

有时候,我们可能并不想创建那么多节点的包裹嵌套,按照现在的创建节点的方式,我们必须要在外层先创建父节点进行包裹,比如:

 const App = {
   setup() {},
   render() {
     return h('div', {}, [
       h('span', {}, 'span'),
       h('p', {}, 'App')
     ])
   }
 }

上面这段代码渲染在页面中的DOM结构是这样的:

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

我们的本意只是想渲染spanp标签,但是由于目前的实现,必须要使用一个父标签进行包裹,也就是图中的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来生成唯一值FragmentText,来避免重复。接下来根据type值来对不同的vnode类型进行处理。

 const processFragment = function(vnode, container) { // 新增
   mountChildren(vnode.children, container) // 新增
 } // 新增

如果typeFragment类型,代表当前节点只是一个包裹的作用,那么只需要调用mountChildren处理children即可。

 const processText = function(vnode, container) { // 新增
   const { children } = vnode // 新增
   // 直接创建文本节点并挂载
   const textVNode = (vnode.el = document.createTextNode(children)) // 新增
   container.append(textVNode) // 新增 
 } // 新增

如果typeText类型,那么直接生成文本节点挂载,而不需要再继续进行处理了。

如果在h函数内使用createTextVNode,就代表直接创建文本节点,直接使用createVNode创建一个文本节点类型的vnode返回。

 const createTextVNode = function(text) { // 新增
   return createVNode(Text, {}, text) // 新增
 } // 新增

此时,页面中就没有多余的标签了。

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

九. 实现 provide/inject 功能

vue中的provide/inject可以更便捷的进行数据共享,通常,当我们需要从父组件向子组件传递数据时,我们使用 props进行传递。但是想象一下这样的结构:有一些深度嵌套的组件,而深层的子组件只需要父组件的部分内容。在这种情况下,如果仍然将 prop 沿着组件链逐级传递下去,可能会很麻烦。

对于这种情况,我们可以使用一对 provideinject。无论组件层次结构有多深,父组件都可以作为其所有子组件的依赖提供者。这个特性有两个部分:父组件有一个 provide 选项来提供数据,子组件有一个 inject 选项来开始使用这些数据。

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

在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使用injectnameage进行使用,并在页面中渲染出来。

其实只看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父级实例是在哪里被传入的呢?试想一下,一个vnodechildren是在哪里被返回的?一定是在执行用户传入的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)
 }

详细代码可参见:

github.com/konvyi/vue3…

然后可以实现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进行初始化的:

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

而后续保存数据后,再次执行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];
     }
   }
 }

我们上面的案例就可以正常显示了:

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

如果想要为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第三弹!万字实现渲染器首次渲染流程 🎉 🎉

本文只实现了首次渲染的过程,因为篇幅过长,更新过程将会发布在下一篇文章。

写在最后 ⛳

未来可能会更新实现mini-vue3javascript基础知识系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳

参考文章

juejin.cn/post/684490… segmentfault.com/a/119000001…

www.jianshu.com/p/5ea5bda40…

转载自:https://juejin.cn/post/7142694451448643591
评论
请登录