Vue3 → 插槽slot原理与实现

597 阅读4分钟

Vue.js 进阶技巧 - 插槽/内容分发/具名插槽

slot实现

原理解析

template中的内容最终会被compilerender函数,render函数内部会调用h函数转换成vnode

使用slot的地方是this.slots,内部有配置的插槽额名称,如果使用者没有传递配置,则是通过default进行默认值配置的;
组件的插槽指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入;

实现组件的默认插槽

  • 功能点包括
    • 在组件的render函数中通过this.$slots获取到父组件传入的默认插槽的内容,并在子组件对应的默认插槽中渲染出来
    • 支持插槽传入数组
  • 案例场景搭建
// App.js
import { h, createTextVNode } from "../../lib/guide-mini-vue.esm.js";
import { Foo } from "./Foo.js";
export const App = {
  setup() {
    return {};
  },
  render() {
    const foo = h(Foo, {}, h("p", {}, "default slot"));
    const foo1 = h(Foo, {}, h("p", {}, {
        header:h("p",{},"header"),
        footer:h("p",{},"footer")
    }));
    const foo2 = h("h1", {}, "normal slot");
    return h("div", {}, [foo,foo1, foo2]);
  },
};
// Foo.js
import { h, renderSlots } from "../../lib/guide-mini-vue.esm.js";
export const Foo = {
  setup() {
    return {};
  },
  render() {
    const foo = h("p", {}, "foo");
    const foo2 = h("h2", {}, "foo2");
    return h("div", {}, [foo, foo2]);
  },
};
  • 渲染结果

image.png 实现逻辑

  • 添加组件公共属性 - $slots

    • 实现思路:在子组件Foo中可以通过this.$slots获取到父组件传入的slots插槽的内容,然后放入到h函数中进行渲染
    // Foo.js
    import { h, renderSlots } from "../../lib/guide-mini-vue.esm.js";
    export const Foo = {
      setup() {
        return {};
      },
      render() {
        console.log(this.$slots,'this.$slots=========')
        const foo = h("p", {}, "foo");
        const foo2 = h("h2", {}, "foo2");
        // 获取到Foo组件中的虚拟节点.vnode children->this.$slots 然后将其渲染出来
        // 方式一:将传入的数组结构转换成一个个vnode 不建议 简单粗暴
        return h("div", {}, [foo, foo2, this.$slots]);
      },
    };
    
    • 存在的问题:需要将slots以属性的方式挂载到组件实例上,然后可以在组件公共实例中通过$slots获取到
    // src/runtime-core/component.ts
    // 备注:内部调用逻辑 render -> patch -> processComponent(挂载组件) -> mountComponent(通过虚拟节点创建组件实例) -> createComponentInstance(初始化组件实例属性配置)
    export function createComponentInstance(vnode,parent) {
      const component = {
        vnode,
        type: vnode.type,
        props: {},
        slots: {},
        isMounted: false, // 标识是否是初次加载还是后续依赖数据的更新操作
        subTree: null,
        emit: ()=>{},
        provides:parent?parent.provides:{},
        parent,
        render: vnode.render,
        setupState: {},
      };
      component.emit = emit.bind(null,component) as any
      return component;
    }
    
    // src/runtime-core/componentPublicInstance.ts
    import { hasOwn } from "../shared/index";
    
    const publicPropertiesMap = {
      $el: (i) => i.vnode.el,
      $slots: (i) => i.slots
    };
    export const PublicInstanceProxyHandlers = {
      get({ _: instance }, key) {
        const { setupState,props } = instance;
        // if (key in setupState) {
        //   return setupState[key];
        // }
    
        if(hasOwn(setupState,key)){
          return setupState[key]
        } else if(hasOwn(props,key)){
          return props[key]
        }
    
        // if(key === '$el'){
        //     return instance.vnode.el
        // }
        const publicGetter = publicPropertiesMap[key];
        if (publicGetter) {
          return publicGetter(instance);
        }
      },
    };
    
    • 初始化Slots - initSlots
      • 此时slots就是vnode.children,挂载需要对组件实例instance进行slots挂载
    // src/runtime-core/componentSlots.ts
    export function initSlots(instance, children) {
      instance.slots = children;
    }
    
    // src/runtime-core/component.ts
    export function setupComponent(instance) {
      // 处理setup的信息 初始化props  初始化Slots等
      initProps(instance,instance.vnode.props),
      initSlots(instance,instance.vnode.children),
      setupStatefulComponent(instance);
    }
    
    • 此时打印this.$slots结果是
    // Foo.js
    import { h, renderSlots } from "../../lib/guide-mini-vue.esm.js";
    export const Foo = {
      setup() {
        return {};
      },
      render() {
        console.log(this.$slots,'this.$slots=========')
        const foo = h("p", {}, "foo");
        const foo2 = h("h2", {}, "foo2");
        // 获取到Foo组件中的虚拟节点.vnode children->this.$slots 然后将其渲染出来
        // 方式一:将传入的数组结构转换成一个个vnode 不建议 简单粗暴
        return h("div", {}, [foo, foo2, this.$slots]);
      },
    };
    

image.png

插入数组到插槽中
  • 内部逻辑

    • 当前实现了在子组件插槽中插入一个Element类型的vnode,但实际上应该支持多个vnode的渲染
    • 存在问题:此时this.$slots是个数组类型,数组内部的元素是vnode,而在上述的实现中,子组件是直接通过将this.$slots传入一个数组中,变成了嵌套数组,而h函数是不支持嵌套数组类型的处理的,需要特殊处理传入的slots
  • 实现思路:可以简单的将传入的this.$slots进行h函数包裹进行展示嵌套数组格式的内容

    import { h, renderSlots } from "../../lib/guide-mini-vue.esm.js";
    export const Foo = {
      setup() {
        return {};
      },
      render() {
        console.log(this.$slots,'this.$slots=========')
        const foo = h("p", {}, "foo");
        const foo2 = h("h2", {}, "foo2");
        return h("div", {}, [foo, foo2,h("div",{},this.$slots)]);
      },
    };
    
    • 渲染结果 image.png
  • 存在的问题:此时mountElement只支持数组和TEXT_CHILDREN类型,而传入单个vnode到插槽中时是不兼容的,需要将单个vnode封装成一个数组

    export function initSlots(instance, children){
        // 处理传入的children节点时单节点或者是数组的场景
        instance.slots = Array.isArray(children)?children:[children]
    }
    

image.png

具名插槽与作用域插槽的实现

实现逻辑

具名插槽就是:除了可以有多个,且除了有default外,还可以加入其他名字;
作用域插槽:每个slot里面可以传入数据,数据只在当前的slot中有效;父组件进行this指向控制,从而可以在父组件中访问到子组件中的数据-渲染的插槽元素来自于父组件

  • 需求分析
    • 具名插槽

      • renderSlot传入第二个参数,可以获取对应的slots

        // 最后还需要使用renderSlot函数
      export function renderSlots(slots, name = 'default') {
          const slot = slots[name]
          if (slot) {
             return createVNode('div', {}, slot)
           }
      }
      
      
    • 作用域插槽

      • 传入插槽的时候,传入一个函数,函数可以拿到子组件传过来的参数
        • 只需要在传入插槽的时候进行判断,当slot是一个函数时,执行函数且传入参数
      • renderSlots可以传入第三个参数props进行数据接收,用于接收子组件往父组件里传入的参数
        • 同样做传入内容的判断,是函数时进行传入参数处理
  • 案例分析
// example/componentSlot/App.js
import { h, createTextVNode } from "../../lib/guide-mini-vue.esm.js";
import { Foo } from "./Foo.js";
export const App = {
  setup() {
    return {};
  },
  render() {
    const foo = h(Foo, {}, h("p", {}, "default slot"));
    const foo1 = h(Foo, {}, h("p", {}, {
        header:h("p",{},"header"),
        footer:h("p",{},"footer")
    }));
    const foo3 = h(Foo,{},[h("p",{},"123"),h("p",{},"567")])
    const foo2 = h("h1", {}, "normal slot");
    return h("div", {}, [foo,foo1, foo2,foo3]);
  },
};
// example/componentSlot/Foo.js
export const Foo = {
  setup() {
    return {};
  },
  render() {
    console.log(this.$slots,'this.$slots=========')
    const foo = h("p", {}, "foo");
    const foo2 = h("h2", {}, "foo2");
    return h("div", {}, [foo, foo2,h("div",{},this.$slots)]);
  },
};
  • 渲染结果 - 主看log

image.png

  • 实现思路

主要是在子组件中进行slot定向展示时,当遇到具名插槽时就调用单独封装的renderSlots函数进行特殊处理,其他类型的slot是不变的,不调用该方法;
此时子组件调用该方法传入renderSlots的slot就是一个函数了,参数是插槽的集合、具名插槽的名称、作用域中需要暴漏访问的数据

  • 案例分析
export const Foo = {
    render(){
        const foo = h("P",{},"foo")
        console.log(this.$slots,'this.$slots=========')
        // 获取到Foo组件中的虚拟节点.vnode children->this.$slots 然后将其渲染出来

        // 在渲染节点时  内部的children必须是一个VNode 而下例却是一个数组
        // return h("div",{},[foo,renderSlots(this.$slots)])
            // 方式一:将传入的数组结构转换成一个个vnode
                // h("div",{},[foo,h("div",{},this.$slots)]) - 不建议 粗暴
            // 方式二:封装到统一的函数中 由内部导出 renderSlots
                // h("div",{},[foo,renderSlots(this.$slots)])

        // 需求三 将数组内的组件渲染到指定的位置 - 具名插槽
            // 1、获取到要渲染的元素
            // 2、获取到要渲染的位置
        const age = 12
        return h("div",{},[
            renderSlots(this.$slots,'header',{
                age
            }),
            foo,
            renderSlots(this.$slots,'footer')
        ])
        // return h("div",{},[foo])
    },
    setup(){
        return {}
    }
}
import { createVNode, Fragment } from "../vnode";

export function renderSlots(slots,name,props){
    // return createVNode("div",{},slots)
    const slot = slots[name]
    if(slot){
        // 在进行传参的时候 slot 就变成了函数 - 作用域插槽
        if(typeof slot === 'function'){
            // children 是不可以有 Array 的
            // return createVNode("div",{},slot(props))

            console.log(slot,props,'slot,props=========')
            return createVNode(Fragment,{},slot(props))
        } 
        return createVNode(Fragment,{},slot)
    }
    
}
// src/runtime-core/componentSlots.ts
import { ShapeFlags } from "../shared/shapeFlags";

export function initSlots(instance, children){
    // 处理传入的children节点时单节点或者是数组的场景
    // instance.slots = Array.isArray(children)?children:[children]

    // 处理传入的children是一个对象

    // const slots = {}
    // for (const key in children) {
    //     if (Object.prototype.hasOwnProperty.call(children, key)) {
    //         const value = children[key];
    //         slots[key] = normalizeSlotValue(value)
    //     }
    // };
    // instance.slots = slots
    const { vnode } = instance
    if(vnode.shapeFlag && ShapeFlags.SLOT_COMPONENT){
        normalizeObjectSlots(children,instance.slots)
    }
    
}
function normalizeObjectSlots(children,slots:any){
    // const slots = {}
    for (const key in children) {
        if (Object.prototype.hasOwnProperty.call(children, key)) {
            const value = children[key];
            // slots[key] = normalizeSlotValue(value)

            // 作用域插槽
            slots[key] = typeof slot !== 'function' ? normalizeSlotValue(value(props)): ((props) => Array.isArray(value(props))?value[props]:[value(props)])
        }
    };
    // instance.slots = slots
}
function normalizeSlotValue(value){
    return Array.isArray(value)?value:[value]
}
  • sodo

image.png