🤯vue3核心源码剖析(七)

994 阅读16分钟

🚀vue3 render 渲染函数的功能实现以及巧妙的 vnode 类型分类!

前言

上篇讲解了template到挂载元素的过程,这篇我们来对render实现一些细节上的功能,其中源码有一些开发思路还是很值得我们去学习的

本篇文章主要内容:

  1. 实现组件代理对象
  2. 实现 shapeFlags
  3. 实现注册事件功能
  4. 实现组件 props 功能
  5. 实现组件 emit 功能
  6. 实现组件 slots 功能
  7. 实现 Fragment 和 Text 类型节点

render 的 this 指向是如何实现的?

有时候我们的h()函数需要获取setup返回的对象里面的某个属性,比如this.name

先看一个例子:

export default {
  name: 'App',
  render() {
    // this指向通过proxy
    return h('div', {
      id: 'root',
      class: ['flex', 'container']
    }, this.name)
  },
  setup() {
    // 返回对象或者h()渲染函数
    return {
      name: 'hi my app'
    }
  }
}

可是这个this的指向分明就是指向的render()函数,如果不对this指向做处理,this.name将会得到undefined

其实this的指向并不重要,重要的是使用this可以获取setup的返回值、$el$slogs以及外部传递进来的props的属性

思考一下,哪里能获取到setup的返回值呢?(setupState)

前面我们不是实现过setupStatefulComponent()方法吗?这个函数执行了setup(),并把setup的返回值setupResult挂载在了instance.setupState上。

那我们怎么通过this访问到setupState呢?

改变this的指向我们有call、bind、apply。我们要改变instance.render的this指向,并且让render执行,返回子树的vnode,我们选用call

我们定位到instance.render执行逻辑所在的setupRenderEffect上:

function setupRenderEffect(instance: any, vnode: any, container: any) {
  const subTree = instance.render.call(instance.setupState)
  // 对子树进行拆箱操作 递归进去
  patch(subTree, container)
  // 代码到了这里,组件内的所有element已经挂在到document里面了
  vnode.el = subTree.el
}

直接在call的第一个参数填上instance.setupState当然没问题,但是我们后续会有props、$el$slots,这些参数都不在setup的返回值对象里面哦,所以我们需要new 一个 proxy对象,在get操作的时候判断key哪个对象的属性,并返回相应的键值。

function setupStatefulComponent(instance: any) {
  const Component = instance.type
  // 解决render返回的h()函数里面this的问题,指向setup函数
  instance.proxy = new Proxy({ _: instance } as Data, publicInstanceProxyHandlers)
  const { setup } = Component
  // 有时候用户并没有使用setup()
  if (setup) {

    const setupResult = setup()

    handleSetupResult(instance, setupResult)
  }
  finishComponentSetup(instance)
}
export const publicInstanceProxyHandlers: ProxyHandler<any> = {
  // 通过target吧instance传递给get操作
  get({ _: instance }, key: string) {
    const { setupState } = instance
    // 在setup的return中寻找key
    if (hasOwn(setupState, key)) {
      return setupState[key]
    }
    // 后续我们这里还可能会返回props、`$el`、`$slots`等等
  },
}

这个hasOwn的实现就是Object.property.hasOwnProperty.call()

再次更改上面的setupRenderEffect()

function setupRenderEffect(instance: any, vnode: any, container: any) {
  // 把instance.setupState 改成 instance.proxy
  const subTree = instance.render.call(instance.proxy)
  patch(subTree, container)
  vnode.el = subTree.el
}

巧妙的 vnode 类型分类

上一篇里我们是通过vnode.type判断是否是string类型,object类型来确当这个vnode到底是元素的vnode还是组件的vnode。

function patch(vnode: any, container: any) {
  if(typeof vnode.type === 'string'){
    processElement(vnode, container)
  }else if(isObject(vnode.type)){
    processComponent(vnode, container)
  }
}

这样写的话,代码不够优雅,后期vnode代表更多类型的时候,我们就需要在这个函数重新添加判断逻辑。为此我们需要定义一个标识位shapeFlag,每个vnode都有一个shapeFlag标识位,用于标识当前的vnode是什么类型。

那在vue源码里面vnode是怎么被标识的呢?我们可以在shared/src/shapeFlag.ts中找到答案。如下

export const enum ShapeFlags {
  ELEMENT = 1,                         // 0000000001
  FUNCTIONAL_COMPONENT = 1 << 1,       // 0000000010
  STATEFUL_COMPONENT = 1 << 2,         // 0000000100
  TEXT_CHILDREN = 1 << 3,              // 0000001000
  ARRAY_CHILDREN = 1 << 4,             // 0000010000
  SLOTS_CHILDREN = 1 << 5,             // 0000100000
  TELEPORT = 1 << 6,                   // 0001000000
  SUSPENSE = 1 << 7,                   // 0010000000
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,// 0100000000
  COMPONENT_KEPT_ALIVE = 1 << 9,       // 1000000000
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

vue源码并不是用对象去存储标识,映射。

// 为什么不直接用对象然后属性值是1,2,3,4,5.。。。主要还是性能问题,vue源码在可读性和性能这条分岔路口上,最终还是选择了性能。
export const ShapeFlags = {
  ElEMENT: 1,
  STATEFUL_COMPONENT: 2,
  TEXT_CHILDREN: 3,
  ARRAY_CHILDREN: 4,
}

而是通过二进制位运算的方式标识。例如:

1 的二进制 是 0000000001

左移1位

0000000010

左移2位

0000000100

按照vue源码的方法,

vnode类型标识位
element类型0000000001
组件类型0000000010
子级的文本类型0000000100

那如果一个vnode的类型是组件,并且子级的节点是文本类型,应该怎么表示?

我们是不是应该在二进制位的右边数起第三位把0改成1

0000000110

怎么得到这个值呢?这里需要用到逻辑或运算的知识(运算符两边为0,结果才为0),例如:

00=00 | 0 = 0

01=10 | 1 = 1

那么组件类型逻辑或运算子级文本类型就是:

00000000100000000100=00000001100000000010 | 0000000100 = 0000000110

如何判断这个vnode是组件类型呢?

简单的比较运算符肯定是不行的,0000000110 !== 0000000010,这里我们要用到逻辑与运算符(运算符两边都为1,结果才为1),例如:

1&1=11 \& 1 = 1

1&0=01 \& 0 = 0

那么判断vnode是不是组件类型将会这么表示:

0000000110&0000000010=00000000100000000110 \& 0000000010 = 0000000010

结果非0,说明该vnode是组件类型,否则不是组件类型。如下:

0000000101&0000000010=00000000000000000101 \& 0000000010 = 0000000000

0000000101是元素类型子级文本类型,我们通过0000000010判断它是否一个组件类型,结果为0,说明不是组件类型。

上面我们讲了如何判断vnode的类型和给vnode添加多种标识,那么我们来实际用起来吧。先在创建vnode的时候给vnode添加shapeFlag

export function createVNode(type: any, props?: any, children?: any) {
  const vnode = {
    type,
    props,
    children,
    shapeFlag: getShapeFlag(type), // getShapeFlag是添加标识
    el: null,
  }
  // 根据children的类型,给vnode添加额外的标识
  normalizeChildren(vnode, children)
  return vnode
}
// 根据vnode.type标志vnode类型
function getShapeFlag(type: any) {
  return isString(type) 
    ? ShapeFlags.ELEMENT
    : isObject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : 0
}
// 给vnode.shapeFlag追加标识
function normalizeChildren(vnode: any, children: any){
  // | 左右两边为0 则为0   可以用于给二进制指定的位数修改成1  例如:0100 | 0001 = 0101
  // 在这里相当于给vnode追加额外的标识
  if(isString(children)){
    vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
    // 子级是数组
  } else if(isArray(children)){
    vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
  }
  // vnode是组件
  if(vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT){
    // 子级是对象
    if(isObject(children) ){
      vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN
    }
  }
}

render 的事件是如何被注册的?

我们先来看一下绑定事件的用法:

// in app.js
export default {
  name: 'App',
  render() {
    return h('div', {
      id: 'root',
      class: ['flex', 'container-r'],
      onClick(){
        console.log('click event!')
      },
      onMouseDown(){
        console.log('mouse down!')
      }
    }, [
      h('p', {class: 'red'}, 'red'),
      h('p', {class: 'blue'}, 'blue')
    ])
  },
  setup() {
    return {
      name: 'hi my app'
    }
  }
}

可以看到h()函数里面绑定事件都是在事件名的第一个字母大写并且在前面添加上on前缀。

当vnode在mountElement的时候,循环vnode的props的时候,我们可以通过addEventListener来给元素注册事件,不过要辨别props到底是不是一个事件名以及,键值是否是一个函数哦。

export const isOn = (key: string) => /^on[A-Z]/.test(key)
function mountElement(vnode: any, container: any) {
  // 注意:这个vnode并非是组件的vnode,而是HTML元素的vnode
  console.log('mountElement', vnode)
  const el = (vnode.el = document.createElement(vnode.type) as HTMLElement)
  let { children, props } = vnode
  // 子节点是文本节点
  if (vnode.shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    el.textContent = children
    // 子节点是数组
  } else if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(vnode, el)
  }
  let val: any
  // 对vnode的props进行处理,把虚拟属性添加到el
  for (let key of Object.getOwnPropertyNames(props).values()) {
    val = props[key]
    if (Array.isArray(val)) {
      el.setAttribute(key, val.join(' '))
    } else if (isOn(key) && isFunction(val)) {
      // 添加事件
      el.addEventListener(key.slice(2).toLowerCase(), val)
    } else {
      el.setAttribute(key, val)
    }
  }
  container.append(el)
}

isOn的实现是用正则表达式匹配props的键名是否以on开头。并且键值是一个函数类型,那么就需要注册事件。slice(2).toLowerCase()是为了去掉on前缀,保留事件名。val直接作为函数体传入addEventListener的第二个参数。

render 是如何接收外部传入的 props?

先看例子:

export default {
  name: 'App',
  render() {
    return h('div', {}, [
      // 引入Foo组件
      h(Foo, {
        count: 1
      }, '')
    ])
  },
  setup() {
    // 返回对象或者h()渲染函数
    return {
      name: 'hi my app'
    }
  }
}

export default {
  name: 'Foo',
  render() {
    // 2. 能在render中通过this访问到props
    return h('div', {}, 'foo: ' + this.count)
  },
  setup(props) {
    // 1. 传入count
    console.log(props)
    // 3. shallow readonly
    props.count++
    console.log(props)
  }
}

实现目标:

  1. props从app.js中传给Foo组件,组件能在setup的第一个入参中访问到props
  2. Foo的render函数能通过this访问到props
  3. props具有shallowReadonly性质(浅层的readonly)

既然啊props写在Foo组件上,那么我们可以在setupComponent上,给instance挂载一个props的属性。先在createComponentInstance上为instance添加props属性。

export function createComponentInstance(vnode: any) {
  const type = vnode.type
  const instance = {
    vnode,
    type,
    render: null,
    setupState: {},
    props: {},
  }
  return instance
}
export function setupComponent(instance: any) {
  // 初始化组件外部传给组件的props
  initProps(instance, instance.vnode.props)
  setupStatefulComponent(instance)
}

这里的初始化props,比较简单,只需把instance的vnode下的props赋值给instance的props即可。

export function initProps(instance: any, rawProps: any) {
  instance.props = rawProps || {}
}

为了实现第一个目标,我们需要定位到调用setup函数的位置,并把instance.props作为第一个参数传递给setup

function setupStatefulComponent(instance: any) {
  const Component = instance.type
  // 解决render返回的h()函数里面this的问题,指向setup函数
  instance.proxy = new Proxy({ _: instance } as Data, publicInstanceProxyHandlers)
  const { setup } = Component
  if (setup) {
    const setupResult = setup(instance.props)

    handleSetupResult(instance, setupResult)
  }
  finishComponentSetup(instance)
}

前文讲到,render的this指向指向了instance.proxy了,为了能在this中访问到props,还记得我们在publicInstanceProxyHandlers中预留了位置吗,我说不要直接在new Proxy第二个参数直接写instance.setupState,现在已经体现了我的用意了吧。

export const publicInstanceProxyHandlers: ProxyHandler<any> = {
  get({ _: instance }, key: string) {
    const { setupState, props } = instance
    // 在setup的return中寻找key
    if (hasOwn(setupState, key)) {
      return setupState[key]
      // 在setup的参数props中寻找key
    } else if (hasOwn(props, key)) {
      return props[key]
    }
  },
}

第三个目标,props具有shallowReadonly性质,我们只需要把props用shallowReadonly包裹以下就好了,然后传递给setup函数

if(setup){
  const setupResult = setup(shallowReadonly(instance.props))
}

子组件定义自定义事件,父组件触发自定义事件

先看Foo内部:


export default {
  name: 'Foo',
  render() {
    
    return h('div', {}, [
      h('button', {
        onClick: this.onAdd
      }, '触发emit')
    ])
  },
  setup(props, {emit}) {
    
    function onAdd(){
      console.log('onAdd')
      emit('emitFooAddEvent', props.count)
    }
    
    return {
      onAdd
    }
  }
}

button上面有个click事件,事件里面触发emit,对外暴露了一个emitFooAddEvent自定义事件,并且有参数传递。其实这里button被点击的时候,onEmitFooAddEvent就会被执行。

父组件将会接收

export default {
  name: 'App',
  render() {
    return h('div', {
    }, [
      // 在Foo的props中寻找有没有on + emitFooAddEvent这个函数,有就执行
      h(Foo, {
        count: 1,
        onEmitFooAddEvent: this.takeEmitEvent
      }, '')
    ])
  },
  setup() {
    function takeEmitEvent(count){
      console.log('app take in count number:', count)
    }
    // 返回对象或者h()渲染函数
    return {
      takeEmitEvent
    }
  }
}

Foo组件通过将emitFooAddEvent首字母大写然后加上on,定义在Foo的props中,函数体是setup返回的takeEmitEvent函数,仔细看上面还接收一个参数count

实现思路如下:

当Foo组件内调用emit的时候,他会通过当前组件的instance.props找到对应的自定义事件名,然后执行对应的函数,这里的函数是takeEmitEvent,所以这里还会执行函数体this.takeEmitEvent

我们定义emit函数

import { camelCase, toHandlerKey } from "../shared"

export function emit(instance: any, event: string, ...args: unknown[]){
  const {props} = instance

  // 把kabobCase => camelCase
  const camelCase = (str: string) => {
    return str.replace(/-(\w)/g, (_, $1: string) => {
      return $1.toUpperCase()
    })
  }
  // 首字母大写
  const capitalize = (str: string) => {
    return str.charAt(0).toUpperCase() + str.slice(1)
  }
  // 事件前缀追加'on'
  const toHandlerKey = (eventName: string) => {
    return eventName ? 'on' + capitalize(eventName) : ''
  }

  const eventName = toHandlerKey(camelCase(event))
  // 在props中寻找eventName,并执行。
  const handler = props[eventName]
  handler && handler(...args)
}

camelCase解决的是自定义事件名称是烤肉串式的情况,例如:emit-foo-add-event。

capitalize解决的是自定义事件名称是小写的情况,例如:emitFooAddEvent。

toHandlerKey则为自定义事件名称添加on前缀。

之后在createComponentInstance中instance添加emit属性,并做初始化处理

export function createComponentInstance(vnode: any) {
  const type = vnode.type
  const instance = {
    vnode,
    type,
    render: null,
    setupState: {},
    props: {},
    emit: () => {},
  }
  instance.emit = emit.bind(null, instance) as any
  return instance
}

因为根据官网的用法,setup第二个参数ctx需要一个emit函数。所以setupStatefulComponent改写为:

function setupStatefulComponent(instance: any) {
  const Component = instance.type
  instance.proxy = new Proxy({ _: instance } as Data, publicInstanceProxyHandlers)
  const { setup } = Component
  if (setup) {

    const setupResult = setup(shallowReadonly(instance.props), {
      emit: instance.emit
    })

    handleSetupResult(instance, setupResult)
  }
  finishComponentSetup(instance)
}

这样三个目标就完成了!

render函数的slots插槽(作用域插槽、具名插槽)

我们的最终目标是实现以下用法:

你可以通过在h函数的第三个参数定义一个对象,default属性作为默认插槽,并接受一个返回值为h函数的h函数。

// 父组件
export default {
  name: 'App',
  render() {
    return h('div', {
      id: 'root',
      class: ['flex', 'container-r'],
    }, [
      h('p', {
        class: 'red'
      }, 'red'),
      h('p', {
        class: 'blue'
      }, this.name),
      h(Foo, {}, {
        default: () => h('p', {}, '我是slot1')
      })
    ])
  },
  setup() {
    return {
      name: 'hi my app',
    }
  }
}

子组件通过renderSlot解析出当前的Foo组件实例的插槽名称为default的slot插槽,renderSlot会返回vnode节点。

// 子组件
export default {
  name: 'Foo',
  render() {
    const foo = h('p', {}, '原本就在Foo里面的元素')
    return h('div', {}, [foo, renderSlot(this.$slots)])
  }
}

简单实现插槽

我们先来实现简单的,能把Foo组件的插槽渲染出来。

预期效果是:

// 父组件
export default {
  name: 'App',
  render() {
    return h('div', {
      id: 'root',
      class: ['flex', 'container-r'],
    }, [
      h('p', {
        class: 'red'
      }, 'red'),
      h('p', {
        class: 'blue'
      }, this.name),



      h(Foo, {}, h('p', {}, '我是slot1'))
    ])
  },
  setup() {
    return {
      name: 'hi my app',
    }
  }
}
// 子组件
export default {
  name: 'Foo',
  render() {
    const foo = h('p', {}, '原本就在Foo里面的元素')
    return h('div', {}, [foo, this.$slots])
  }
}

需要把App这个父组件的Foo组件的插槽h函数,传递给Foo组件的slots中,并且这个slotsvnode节点。为什么要vnode节点呢?再回忆一下啊,你们还记得mountElement的时候吗?他会识别children是否是一个数组,然后通过mountChildren把数组的每个vnode都遍历一遍,再次patch。就因为patch要传入vnode,所以slots中,并且这个slots是vnode节点。为什么要vnode节点呢?再回忆一下啊,你们还记得`mountElement`的时候吗?他会识别children是否是一个数组,然后通过`mountChildren`把数组的每个vnode都遍历一遍,再次patch。就因为patch要传入vnode,所以` slots`必须是一个vnode节点。

this.$slots这个this是Foo组件的instance,我们需要在createComponentInstance为instance添加新属性slots并初始化为{}。

export function createComponentInstance(vnode: any) {
  const type = vnode.type
  const instance = {
    vnode,
    type,
    render: null,
    setupState: {},
    props: {},
    emit: () => {},
    slots: {},   // 新增
  }
  instance.emit = emit.bind(null, instance) as any
  return instance
}

那这个slots在哪才能获得Foo的children呢?应该在setupComponent的时候,添加一个方法initSlots(),同时需要给这个方法传入instanceinstance.vnode.children

export function setupComponent(instance: any) {
  initProps(instance, instance.vnode.props)
  // 初始化组件的slots
  initSlots(instance, instance.vnode.children)
  setupStatefulComponent(instance)
}

这个instance.vnode.children就是h(Foo, {}, h('p', {}, '我是slot1'))的第三个参数h('p', {}, '我是slot1')

而 initSlots 的内部实现很简单。

export function initSlots(instance: any, children: any) {
  instance.slots = children
}

现在我们把children挂载在instance.slots上。还没完,接下来实现this如何访问$slots。

前文我们讲到this如何访问props,这次的$slots也是同样的,我们在publicInstanceProxyHandlers这个proxy对象的处理函数中返回 instance.slots。

export type PublicPropertiesMap = Record<string, (i: any) => any>
// 实例property
const publicPropertiesMap: PublicPropertiesMap = {
  $el: (i: any) => i.vnode.el,
  $slots: (i: any) => i.slots   // 新增
}
export const publicInstanceProxyHandlers: ProxyHandler<any> = {
  get({ _: instance }, key: string) {
    const { setupState, props } = instance
    // 在setup的return中寻找key
    if (hasOwn(setupState, key)) {
      return setupState[key]
      // 在setup的参数props中寻找key
    } else if (hasOwn(props, key)) {
      return props[key]
    }
    // 在publicPropertiesMap中寻找key,并调用,返回结果
    const publicGetter = publicPropertiesMap[key]
    if (publicGetter) {
      return publicGetter(instance)
    }
  },
}

现在插槽就实现了。 Obr5LR.md.png

🚀需求再次升级!Foo组件的children是数组?

Foo组件的slots位置如果需要传入多了个vnode呢?那该怎么办?我们通过数组的方式把这些vnode放到slots中。

Like this!👇

h(Foo, {}, [ h('p', {}, '我是slot1'), h('p', {}, '我是slot2') ])

为了兼容两种情况的写法,我们需要更改一下initSlots

  1. h(Foo, {}, h('p', {}, '我是slot1'))
  2. h(Foo, {}, [ h('p', {}, '我是slot1'), h('p', {}, '我是slot2') ])
export function initSlots(instance: any, children: any) {
  instance.slots = Array.isArray(children) ? children : [children]
}

h函数的第三个参数不是一个vnode节点了,而是一个数组,这时候this.$slots就是一个数组了,这相当于子组件return的h('div', {}, [foo, this.$slots]),到了mountChildren这一步 this.$slots 并不能patch。因为this.$slots并不是 vnode

解决方案是把this.$slots转成一个vnode,我们用createVNode()方法。

预期效果:

h('div', {}, [foo, resolveSlot(this.$slots)]),resolveSlot用于把this.$slots转成vnode。

❔这里解释一下createVNode的children遇到数组类型,内部会发生什么?因为slots是一个数组,createVNode内部把这个新创建的vnode的shapeFlag标记为子级数组vnode类型。在后来的mountElement的时候因为有shapeFlag的标记,所以走mountChildren去循环遍历slot数组。进而对数组里面的每一个vnode进行patch。

export function renderSlot(slots: any) {
  // 用一个div包裹
  return createVNode('div', {}, slots)
}

不过这样多了一层div,看着很不爽! OXK19P.md.png

摆脱 div 束缚的解决方案:Fragment 和 Text

为了不想把div渲染出来,又想把div的children渲染出来,怎么办呢?

那就把mountChildren单独拿出来使用吧,去除mountElement中的document.createElement操作。顺便定义一个vnode.type吧,记作Fragment,当patch的时候,vnode.type是Fragment的时候,执行mountChildren操作。

// 这里让Fragment具有唯一性,使用symbol
const Fragment = Symbol('Fragment')
function patch(vnode: any, container: any) {
  // 检查是什么类型的vnode
  const { type } = vnode
  switch (type) {
    case Fragment:
      processFragment(vnode, container)
      break
    ...
  }
}
function processFragment(vnode: any, container: any) {
  mountChildren(vnode, container)
}
function mountChildren(vnode: any, container: any) {
  vnode.children.forEach((vnode: any) => {
    patch(vnode, container)
  })
}

OXufl8.md.png

为Foo组件添加文本节点同样也无法被patch,要把文本节点转成vnode节点才行。

export default {
  name: 'Foo',
  render() {
    console.log('Foo--->', this.$slots)
    const foo = h('p', {}, '原本就在Foo里面的元素')
    // 结果就是'aaa'没有渲染出来
    return h('div', {}, [foo, renderSlot(this.$slots), 'aaa'])
  }
}

我们通过createVNode创建children为文本节点的vnode

因为patch并没有针对纯文本做处理,你只能通过div(或者其他html元素)包裹起来生成一个vnode才行,像这样:h('div',{},[Foo, h('div',{}, '我是文本')])

export function createTextVNode(text: string){
  return createVNode('div', {}, text)
}

要想不被div包裹,我们仿照Fragemnet的方法创建多一个vnode.type

export const Text = Symbol('Text')

function patch(vnode: any, container: any) {
  console.log('vnode', vnode.type)
  const { type } = vnode
  switch (type) {
    ...
    case Text:
      processText(vnode, container)
      break
    ...
  }
}
function processText(vnode: any, container: any) {
  mountText(vnode, container)
}
function mountText(vnode: any, container: any) {
  const { children } = vnode
  const textNode = (vnode.el = document.createTextNode(children))
  container.append(textNode)
}

这样文本节点就能被patch了。

export default {
  name: 'Foo',
  render() {
    console.log('Foo--->', this.$slots)
    const foo = h('p', {}, '原本就在Foo里面的元素')
    // 利用createTextVNode把文本节点转成vnode
    return h('div', {}, [foo, renderSlot(this.$slots), createTextVNode('aaa')])
  }
}

OjVkxe.md.png

具名插槽功能

具名插槽有什么用

有时一个自定义组件里面我们需要多个插槽。而怎么区分那个插槽插在哪个位置呢?我们需要一个name区分,它作为一个独立的ID决定什么内容应该渲染在什么地方。

Foo组件的children通过对象的形式,插槽名称name作为对象的属性名,对应的vnode作为对象的属性值。

export default {
  name: 'App',
  render() {
    return h('div', {
      id: 'root',
      class: ['flex', 'container-r'],
    }, [
      h('p', {
        class: 'red'
      }, 'red'),
      h('p', {
        class: 'blue'
      }, this.name),
      // 具名插槽 header和footer都是这个slot的名称
      h(Foo, {}, {
        header: h('p', {}, '我是header slot1'),
        footer: h('p', {}, '我是footer slot1')
      })
    ])
  },
  setup() {
    return {
      name: 'hi my app',
    }
  }
}

slot的name作为第二个参数传入renderSlot函数进行渲染指定的插槽到对应的位置。

export default {
  name: 'Foo',
  render() {
    // 这时候this.$slots是一个对象
    console.log('Foo--->', this.$slots)
    const foo = h('p', {}, '原本就在Foo里面的元素')
    return h('div', {}, [renderSlot(this.$slots, 'header'), foo, renderSlot(this.$slots, 'footer')])
  }
}

接下来我们讲解一下实现思路:

之前的initSlots函数的内部实现已经满足不了我们了

function initSlots(instance: any, children: any) {
  instance.slots = Array.isArray(children) ? children : [children]
}

children不单单是一个vnode那么简单了,children是一个对象。

{
  header: h('p', {}, '我是header slot1'),
  footer: h('p', {}, '我是footer slot1')
}

对象的属性值需要通过特殊处理,如果属性值不是数组,把它转成数组。只有转成数组,在mountChildren的时候才能遍历。

为了辨别组件的children是否是插槽,我们选择在创建vnode的时候给vnode添加额外的标识:

export function createVNode(type: any, props?: any, children?: any) {
  const vnode = {
    type,
    props,
    children,
    shapeFlag: getShapeFlag(type),
    el: null,
  }
  normalizeChildren(vnode, children)
  return vnode
}
// 给vnode.shapeFlag追加标识
function normalizeChildren(vnode: any, children: any){
  ...
  // vnode是组件
  if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
    // 子级是对象
    if (isObject(children)) {
      vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN
    }
  }
}

之后知道vnode的children是插槽的话,就可以对属性值进行数组化了。

// 如果children里面有slot,那么把slot挂载到instance上
export function initSlots(instance: any, children: any) {
  const { vnode } = instance
  if (vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
    normalizeObjectSlots(instance.slots, children)
  }
}
// 具名name作为instance.slots的属性名,属性值是vnode
function normalizeObjectSlots(slots: any, children: any) {
  console.log('slots children===>' ,children)
  // 遍历对象
  for (let key in children) {
    const value = children[key]
    // 通过转换成数组,之后mountChildren的时候才能遍历,然后patch。
    slots[key] = normalizeSlotValue(value)
  }
}
// 转成数组
function normalizeSlotValue(value: any) {
  return isArray(value) ? value : [value]
}

作用域插槽功能

作用:作用域插槽可以把组件的内部变量分享给插槽访问。

基于上述的作用描述,我们看看最终的结果:

export default {
  name: 'App',
  render() {
    return h('div', {
      id: 'root',
      class: ['flex', 'container-r'],
    }, [
      h('p', {
        class: 'red'
      }, 'red'),
      h('p', {
        class: 'blue'
      }, this.name),
      // 作用域插槽
      h(Foo, {}, {
        header: ({age})=>h('p', {}, '我是header slot1' + age),
        footer: ()=>h('p', {}, '我是footer slot1')
      })
    ])
  },
  setup() {
    return {
      name: 'hi my app',
    }
  }
}

子组件内部

export default {
  name: 'Foo',
  render() {
    const foo = h('p', {}, '原本就在Foo里面的元素')
    return h('div', {}, [renderSlot(this.$slots, 'header', {age: 18}), foo, renderSlot(this.$slots, 'footer')])
  }
}

可以看出,在Foo的每个插槽,都多了一步:返回vnode的函数作为属性值。并且renderSlot第三个参数传入要给外部访问的Foo内部变量。

另外如果函数需要返回多个vnode,可以返回一个数组,数组里包裹着多个vnode。

header: ({age})=>[h('p', {}, '我是header slot1' + age), h('p', {}, '我是header slot2' + age)]

下面是实现,修改了normalizeObjectSlots(),this.$slots对象每一个属性值都是函数

export function initSlots(instance: any, children: any) {
  const { vnode } = instance
  if (vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
    normalizeObjectSlots(instance.slots, children)
  }
}
// 具名name作为 instance.slots 的属性名,属性值是vnode
function normalizeObjectSlots(slots: any, children: any) {
  // 遍历对象
  for (let key in children) {
    // 假设key是header,那么
    // value是一个函数:({age})=>h('p', {}, '我是header slot1' + age)
    const value = children[key]
    slots[key] = (props: any) => normalizeSlotValue(value(props))
  }
}
// 转成数组
function normalizeSlotValue(value: any) {
  return isArray(value) ? value : [value]
}

加多一层if判断,以防用户输出错误的name值,获取不到slot

export function renderSlot(slots: any, name: string = 'default', props: any) {
  const slot = slots[name]
  if (slot) {
    if (typeof slot === 'function') {
      // slot是函数:(props: any) => normalizeSlotValue(value(props))
      // 执行slot之后,会返回vnode数组
      return createVNode(Fragment, {}, slot(props))
    }
  } else {
    return slots
  }
}

最后@肝血阅读,栓 Q

完整代码

本代码已经挂在stackblitz上了,大家进去之后,他会自动帮我们安装依赖。

  • 查看效果example/index.html,只需要在终端输入npm start
  • 打包runtime-core的代码可以在终端输入npm run build

完整代码