12.vue3中组件实现原理(下)之emit和slots

1.组件事件和emit的实现原理

我们再使用vue3的时候都知道,emit其实就是用来传递自定义事件,那emit是如何实现的呢?

那我们先看下面的代码

js 复制代码
   const MyComponent = {
        name: 'MyComponent',
        setup(props, { emit }) {
            emit('change', 1, 2)
            return () => {
                return //....
            }
        }
    }

当我们使用该组件的时候,我们可以监听由emit函数发射的自定义事件:

js 复制代码
<MyComponent @change="hanlder" />

那上面代码转换为对应的虚拟DOM为:

js 复制代码
  const CompVnode = {
      type: MyComponent,
      props: {
          onChange: hanlder
      }
  }

通过上面代码我们可以很清楚的看到,自定义事件由change被编译成了onChange的属性,并存储到了props中,这是一个约定的东西。

自定义事件的本质其实就是根据事件名称去props数据对象中寻找到对应的事件处理函数并执行,如下代码所示:

js 复制代码
  function mountComponent(vnode, container, anchor) {
    //省略部分代码
        const instance = {
            state,
            props: shallowReactive(props),
            isMounted: false,
            subTree: null
        }

        function emit(event, ...payload) {
            const eventName = `on${event[0].toUpperCase()}${event.slice(1)}`
            const handler = instance.props[eventName]
            if (handler) {
                handler(...payload)
            } else {
                console.warn('不存在该事件')
            }
        }
        const setupContext = { attrs, emit }
    }

整体实现其实并不复杂

  • 实现一个emit函数并添加到setupContext对象中,那么传参的时候就可以通过setupContext取得emit函数
  • 再emit函数调用的时候,我们会根据约定对时间名称转换,并再props数据中找到对应的函数处理
  • 最后,调用事件处理函数并传参即可

之前我们再解析props方法中,并没有对onXXX的参数进行处理,所以我们必须对上篇中resolveProps方法进行改造一下,代码如下:

js 复制代码
   function resolveProps(options, propsData) {
        const props = {}
        const attrs = {}
        for (const key in propsData) {
            if (key in options) {
                if (key in options || key.startsWith('on')) { //新增判断
                    props[key] = propsData[key]
                } else {
                    attrs[key] = propsData[key]
                }
            }
        }
        return [props, attrs]
    }

处理方法也很简单,通过检测propsData中的key是否以on为开头,如果是,则认为该属性是组件的自定义事件,那我们就完美的处理了这个问题了,那其实emit基本实现就完成了,是不是很简单?

2.插槽的工作原理和实现

插槽,我们再工作都用过,顾名思义,就是组件的插槽会预留一个槽位,该槽位具体渲染的内容都是由用户定义的,我们还是以MyComponent来举例,如下:

js 复制代码
<template>
   <header><slot name="header" /></header>
   <div>
     <slot name="body" />
   </div>
   <footer><slot name="footer" /></footer>
</template>

当再父组件中使用MyComponent组件时,可以根据插槽名来插入自定义的内容:

js 复制代码
 <MyComponent>
   <template #header>
     <h1>我是标题</h1>
   </template>
   <template #body>
     <section>我是内容</section>
   </template>
   <template #footer>
     <p>我是注脚</p>
   </template>
 </MyComponent>

上面的模版就会被编译成如下渲染函数

js 复制代码
 // 父组件的渲染函数
 function render() {
   return {
     type: MyComponent,
     // 组件的 children 会被编译成一个对象
     children: {
       header() {
         return { type: 'h1', children: '我是标题' }
       },
       body() {
         return { type: 'section', children: '我是内容' }
       },
       footer() {
         return { type: 'p', children: '我是注脚' }
       }
     }
   }
 }

我们可以很清楚的看到,组件模版中插槽的内容会背编译成插槽函数,而且插槽函数的返回值就是具体的插槽内容,那看到上面的结构,我们可以很自然的想法,把如果我们直接执行对应的插槽函数,是不是就可以把组件渲染出来了呢?

那我们可以看到MyComponent组件模版就会被编译成如下结果:

js 复制代码
 // MyComponent 组件模板的编译结果
 function render() {
   return [
     {
       type: 'header',
       children: [this.$slots.header()]
       },
     {
       type: 'body',
      children: [this.$slots.body()]
     },
     {
       type: 'footer',
       children: [this.$slots.footer()]
     }
   ]
 }

可以很清楚的看到,渲染插槽的内容的过程,就是调用插槽函数并渲染 ,所以我们只需要再对应的时机执行就可以了,插槽就依赖于setupContext中的slot对象,如下所示

js 复制代码
 function mountComponent(vnode, container, anchor) {
   // 省略部分代码
   // 直接使用编译好的 vnode.children 对象作为 slots 对象即可
   const slots = vnode.children || {}
   // 将 slots 对象添加到 setupContext 中
   const setupContext = { attrs, emit, slots }
 }

其实slot的实现是非常简单的,只需要将编译好的 node.children 作为 slots 对象,然后将 slots 对象添加到setupContext 对象中。为了在 render 函数内和生命周期钩子函数内能够通过 this. <math xmlns="http://www.w3.org/1998/Math/MathML"> s l o t s 来访问插槽内容,我们还需要在 r e n d e r C o n t e x t 中特殊对待 slots 来访问插槽内容,我们还需要在renderContext 中特殊对待 </math>slots来访问插槽内容,我们还需要在renderContext中特殊对待slots 属性,如下面的代码所示:

js 复制代码
 function mountComponent(vnode, container, anchor) {
   // 省略部分代码
   // 直接使用编译好的 vnode.children 对象作为 slots 对象即可
   const slots = vnode.children || {}
   // 将 slots 对象添加到 setupContext 中
   const setupContext = { attrs, emit, slots }

   const instance = {
     //省略其他 
     slots
   }
      const renderContext = new Proxy(instance,{
            get(t,k,r){
                const {state,props,slots} = t
                // 新增代码,当k等于slots时,直接返回组件实例上的slots
                 if(k === '$slot') return slots 
            },
            set(t,k,v,r){
          
            }
        })
 }

通过上面的简单处理,那我们就完成了通过this.$slots来访问插槽的内容了。

3.生命周期为何可以多次调用?

当我们再写组件的时候,比如onMountedonUpdated等,我们其实可以注册多个,这些函数会在组件被挂载之后执行,那问题再哪里呢?思考下下面的问题:

问题:A组件的setup函数中调用onMounted会将钩子函数注册再A上,B组件的钩子函数会注册再B组件上,那么是如何实现的呢?

那其实也很简单,我们只需要维护一个currentInstance变量,存储当前组件的实力,每当组件初始化值执行setup函数之前,我们将currentInstance设置为当前组件实例,再执行组件的setup函数,那可以通过onMounted注册的钩子函数与组件实例进行关联了。

那我们就可以进行如下改造:

js 复制代码
 // 全局变量,存储当前正在被初始化的组件实例
 let currentInstance = null
 // 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
 function setCurrentInstance(instance) {
   currentInstance = instance
 }

   function mountComponent(vnode, container, anchor) {
    //省略其他代码
    const instance = {
        mounted:[]
    }

    const setupContext = { attrs }
    setCurrentInstance(instance)
    const setupResult = setup(shallowReadonly(instance.props), setupContext) // 处理setup选项
    setCurrentInstance(null)
   }

我们再组件实例上添加mounted数组,当多次调用onMounted函数注册不同的生命周期时,这些生命周期都会存储到instance.mounted数组中,那我们接下来只需要实现onMouted函数就可以了:

js 复制代码
 function onMounted(fn) {
   if (currentInstance) {
     // 将生命周期函数添加到 instance.mounted 数组中
     currentInstance.mounted.push(fn)
   } else {
     console.error('onMounted 函数只能在 setup 中调用')
   }
 }

可以看到,整体实现非常简单直观。只需要通过 currentInstance 取得当前组件实例,并将生命周期钩子函数添加 到当前实例对象的 instance.mounted 数组中即可。另外,如果当前实例不存在,则说明用户没有在 setup 函数内调用 onMounted 函 数,这是错误的用法,因此我们应该抛出错误及其原因。

最后一步需要做的是,在合适的时机调用这些注册到 instance.mounted 数组中的生命周期钩子函数,如下面的代码所示:

js 复制代码
 function mountComponent(vnode, container, anchor) {
 // 省略部分代码
   effect(() => {
       const subTree = render.call(renderContext, renderContext)
       if (!instance.isMounted) {
         // 遍历 instance.mounted 数组并逐个执行即可
         instance.mounted && instance.mounted.forEach(hook =>hook.call(renderContext))
       }
       instance.subTree = subTree
   }, {
   scheduler: queueJob
   })
 }

可以看到,我们只需要再核实的时机,遍历instance.mounteds数组,并逐个执行该数组生命周期钩子函数即可。其他生命周期钩子函数,原理同上。

相关推荐
2401_8370885036 分钟前
CSS opacity
前端·css
Lysun00138 分钟前
(pnpm)引入 其他依赖失败,例如‘@element-plus/icons-vue‘失败
前端·javascript·npm·pnpm
花花鱼1 小时前
spring boot lunar 农历的三方库引用,获取日期的农历值
java·前端·spring boot
fly spider1 小时前
1.短信登录
前端·firefox
前端小巷子2 小时前
CSS渲染性能优化
前端·css·面试·性能优化
苦学编程的谢2 小时前
计算机是如何工作的
服务器·前端·javascript
蓉妹妹3 小时前
React+Taro选择日期组件封装
前端·react.js·前端框架
风口上的吱吱鼠3 小时前
记录 ubuntu 安装中文语言出现 software database is broken
linux·服务器·前端
whltaoin3 小时前
前端弹性布局:用Flexbox构建现代网页的魔法指南
前端·弹性布局
GISer_Jing4 小时前
前端工程化和性能优化问题详解
前端·性能优化