likes
comments
collection
share

从Vue3源码分析构建功能实现—渲染运行篇(万字长文解析)

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

前言

runtime-core 部分

实现最简单的虚拟DOM到真实DOM的流程

简单的配置:

我们创建一个简单的index.html

<style>
  .red {
    width: 100px;
    color: red;
    background-color: aqua;
  }
  .blue {
    width: 100px;
    color: blue;
    background-color: aqua;
  }
  .flex {
    display: flex;
  }
  .container-r {
    flex-flow: row nowrap;
    justify-content: space-between;
    align-items: center;
  }
</style>
<body>
  <div id="app"></div>
  <script src="./main.js" type="module"></script>
</body>

下面是我们在vuemain.js中常见的挂载到#app的配置:

// main.js
import App from './app.js'

const rootContainer = document.querySelector('#app')
createApp(App).mount(rootContainer)

下面实际就是最简单的.vue文件得到初始化后得到的内容:

  • h函数创造的是虚拟DOM对象,其中的参数实际上是来自编译后的内容
  • render函数实现的是真实的节点的挂载
// app.js
import { h } from '../../lib/vue3.esm.js'

export default {
  render() {
    return h('div', {
      id: 'root',
      class: ['flex', 'container-r']
    }, [
      h('p', {class: 'red'}, 'red'),
      h('p', {class: 'blue'}, 'blue')
    ])
  },
  setup() {
    return {
      name: 'hi my app' // 暴露的属性
    }
  }
}

简单实现以下createApp:

export function createApp(rootComponent: any) {
  // mount函数可接受 dom 实例对象或者 dom 的 ID 属性(string),指定挂载的位置
  const mount = (rootContainer: any) => {
    const vnode = createVNode(rootComponent)
    // 根组件的vnode
    render(vnode, rootContainer)
  }
  return {
    mount,
  }
}

下面实现一个最简单的vnode对象:

  • type : 组件对象或者元素 (通过编译得来的,可能是div/h1/span也可能是某组件Component)
  • props : 组件的内联属性
  • children : 组件的子组件或者子元素
export function createVNode(type: any, props?: any, children?: any) {
  const vnode = {
    type,
    props,
    children,
  }
  return vnode
}

按照官方文档来说h()函数的实现也是createVNode(),返回一个虚拟节点。

export function h(type: any, props?: any, children?: any) {
  return createVNode(type, props, children)
}

那么此时呢?会创造什么格式的?如下:

{
  type: { render() {...}, setup() {...} },
  props: undefined,
  children: undefined
}

这种vnode我们不妨称为“component vnode”,特点是type属性值为含有render和setup方法的对象,props就是组件的props,children用于其他节点的嵌套。

这边也一并介绍另一种vnode,即“element vnode”,这种就是比较常见的vnode,可以拿它渲染出真实dom。

{
  type: 'div',
  props: { class: 'red' },
  children: 'hello world' // 或者是数组
}

render()内部用于执行patch()(什么是patch,见下方经过 patch 拆箱

export function render(vnode: any, container: any) {
  // 做patch算法
  patch(vnode, container)
}

经过 patch 拆箱

patch会判断当前传入参数的vnode.type属性是什么类型。

  • 如果vnode.type是string类型,说明这个vnode是普通元素标签,patch内部会调用processElement进行对普通元素的vnode继续处理,processElement内部又用了mountElement()把vnode.type用createElement创建出dom元素。
  • 如果vnode.type是object类型,说明这个vnode是一个组件。再次调用vnode.type.render()可以得到子元素的vnode,用子元素的vnode再次调用patch()进行拆箱操作,直到vnode.type是普通元素标签为止。
// 传入vnode,递归对一个组件或者普通元素进行拆箱,在内部对vnode的type判断执行不同的处理函数
function patch(vnode: any, container: any) {
  // 检查是什么类型的vnode
  console.log('vnode', vnode.type)
  if(typeof vnode.type === 'string'){
    // 是一个普通元素?处理vnode是普通标签的情况
    processElement(vnode, container)
  }else if(isObject(vnode.type)){
    // 是一个组件?处理vnode是组件的情况
    processComponent(vnode, container)
  }
}

processComponent 处理组件

处理组件的事情大致分为三件事:

  1. 创建组件实例instance
  2. 把setup的返回值setupState挂载在组件实例instance上。
  3. 把render函数挂载在组件实例instance上,以便对render返回的vnode做patch()拆箱处理。

这些事情都在mountComponent里面完成

function processComponent(vnode: any, container: any) {
  mountComponent(vnode, container)
}
function mountComponent(vnode: any, container: any) {
  const instance = createComponentInstance(vnode)
  // 安装组件
  setupComponent(instance)

  //
  setupRenderEffect(instance, container)
}
// 创建组件实例
export function createComponentInstance(vnode: any) {
  const type = vnode.type
  const instance = {
    vnode,
    type,
  }
  return instance
}
function setupRenderEffect(instance: any, container: any) {
  console.log(instance)
  // 这个render()已经在finishComponentSetup处理过了,就是 instance.type.render() 特殊对象的render()
  const subTree = instance.render()
  // 对子树进行拆箱操作
  patch(subTree, container)
}
//
export function setupComponent(instance: any) {
  // initProps()
  // initSlots()
  setupStatefulComponent(instance)
}
// 初始化组件的状态
function setupStatefulComponent(instance: any) {
  const Component = instance.type
  const { setup } = Component
  // 有时候用户并没有使用setup()
  if (setup) {
    // 处理setup的返回值,如果返回的是对象,那么把对象里面的值注入到template上下文中
    // 如果是一个函数h(),那么直接render

    const setupResult = setup()

    handleSetupResult(instance, setupResult)
  }
  finishComponentSetup(instance)
}
// 处理组件的setup的返回值
function handleSetupResult(instance: any, setupResult: any) {
  if (isFunction(setupResult)) {
  // TODO handle function
  } else if (isObject(setupResult)) {
    // 把setup返回的对象挂载到setupState上
    instance.setupState = setupResult
  }
}
// 结束组件的安装
function finishComponentSetup(instance: any) {
  const Component = instance.type // 遇到h('div',{}, this.name)  这里Component将为'div'

  if (instance) {
    instance.render = Component.render
  }
}

processElement 处理元素

处理元素的事情大致分为三件事:

  1. 根据vnode.type创建HTML元素。
  2. 根据vnode.children的类型判断是string还是array,如果是string,那么说明children是文本节点,如果是array,我们并不知道每个元素到底是HTML元素还是组件,这点同样通过patch处理。
  3. 根据vnode.props对象,遍历来设置HTML元素的属性。
// 此时的vnode.type是一个string类型的HTML元素
function processElement(vnode: any, container: any) {
  mountElement(vnode, container)
}

生成真实 DOM 节点

生成真实的DOM节点,其实逻辑就在mountElement()里面。

mountElement()函数传入两个参数:vnodecontainer

因为此时调用mountElement的vnode.type已经被认定为是普通的HTMLElement,那么就能用document.createElement(vnode.type)创建dom节点,注意:这里的vnode.type是string类型。

除此之外,我们还必须处理vnode.props属性,它是包含着这个HTML元素的所有内联属性的对象,比如idclassstyle等等。如果class有多个类名,通过数组表示。处理props属性我们联想到了遍历props对象然后调用setAttribute()函数,把属性添加到dom节点上。

我们还得处理dom节点的子节点,这里我们分两种情况:

  1. vnode.children是一个string类型,说明子节点是文本节点。
  2. vnode.children是一个array类型,不清楚里面的子元素是文本节点还是组件,这时我们可以通过patch()做拆箱处理。

定义一个mountChildren()

里面的实现大致就是:通过遍历vnode.children数组,让每个子元素都执行patch()

function mountElement(vnode: any, container: any) {
  const el = document.createElement(vnode.type) as HTMLElement
  let { children, props } = vnode
  if (isString(children)) {
    el.textContent = children
  } else if (Array.isArray(children)) {
    mountChildren(vnode, el)
  }
  // 对vnode的props进行处理,把虚拟属性添加到el
  for (let key of Object.getOwnPropertyNames(props).values()) {
    if(Array.isArray(props[key])){
      el.setAttribute(key, props[key].join(' '))
    }else{
      el.setAttribute(key, props[key])
    }
  }
  container.append(el)
}

// 处理子节点
function mountChildren(vnode: any, container: any){
  vnode.children.forEach((vnode: any) => {
    patch(vnode, container)
  });
}

得到效果:

从Vue3源码分析构建功能实现—渲染运行篇(万字长文解析)

实现组件代理对象功能

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

先看一个例子:

export default {
  name: 'App',
  render() {
    // this指向通过proxy
    return h('div', {
      id: 'root',
      class: ['flex', 'container']
    }, this.name)
  },
  setup() {
    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
}

简单梳理一下:

  • 调用createApp(App).mount(rootContainer)首先将会进行vnode的构建,此时的vnode为component vnode,然后马上会进行调用render(vnode, rootContainer),开始进行vnode的处理
  • 进入patch,而此时为component vnode,进入的分支是[1]processComponent()->mountComponent()->createComponentInstance()进行处理
  • createComponentInstance()中进行创建instance实例,回忆一下:
{
  vnode,
  type: vnode.type, // 两种 vnode 的type,即组件对象或者标签名
  setupState: {}, // 用于组件代理,存放相应的data数据
  ...
};
  • 接着马上进行setupComponent() -> setupStatefulComponent() ,此时会进行代理对象的定义,对setup()返回的对象进行代理,为了通过this.来访问,关键一步instance.render.call(instance.proxy)来进行绑定,对于this.$el的获取则需要通过element vnode上的el来进行绑定到代理对象上,因为在编译时,进行el数据的设置是放在element vnode上的,在instance.vnode.el拿到dom
  • 进入setupRenderEffect()那么就是对于子树上的vnode进行patch(先会进行内部render()的调用)
  • 接下来一般就是进行element vnode的挂载,会进入processElement()分支,回看到[1]处的分支

巧妙的 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)
  }
}

这样写的话,后续对于虚拟DOM的维护的花销是很大的,我们需要有特定的flags来进行相应的处理:

const VNodeFlags = {
  // html 标签
  ELEMENT_HTML: 1,
  // SVG 标签
  ELEMENT_SVG: 1 << 1,

  // 普通有状态组件
  COMPONENT_STATEFUL_NORMAL: 1 << 2,
  // 需要被keepAlive的有状态组件
  COMPONENT_STATEFUL_SHOULD_KEEP_ALIVE: 1 << 3,
  // 已经被keepAlive的有状态组件
  COMPONENT_STATEFUL_KEPT_ALIVE: 1 << 4,
  // 函数式组件
  COMPONENT_FUNCTIONAL: 1 << 5,

  // 纯文本
  TEXT: 1 << 6,
  // Fragment
  FRAGMENT: 1 << 7,
  // Portal
  PORTAL: 1 << 8
}
// html 和 svg 都是标签元素,可以用 ELEMENT 表示
VNodeFlags.ELEMENT = VNodeFlags.ELEMENT_HTML | VNodeFlags.ELEMENT_SVG
// 普通有状态组件、需要被keepAlive的有状态组件、已经被keepAlice的有状态组件 都是“有状态组件”,统一用 COMPONENT_STATEFUL 表示
VNodeFlags.COMPONENT_STATEFUL =
  VNodeFlags.COMPONENT_STATEFUL_NORMAL |
  VNodeFlags.COMPONENT_STATEFUL_SHOULD_KEEP_ALIVE |
  VNodeFlags.COMPONENT_STATEFUL_KEPT_ALIVE
// 有状态组件 和  函数式组件都是“组件”,用 COMPONENT 表示
VNodeFlags.COMPONENT =
  VNodeFlags.COMPONENT_STATEFUL | VNodeFlags.COMPONENT_FUNCTIONAL

const ChildrenFlags = {
  // 未知的 children 类型
  UNKNOWN_CHILDREN: 0,
  // 没有 children
  NO_CHILDREN: 1,
  // children 是单个 VNode
  SINGLE_VNODE: 1 << 1,

  // children 是多个拥有 key 的 VNode
  KEYED_VNODES: 1 << 2,
  // children 是多个没有 key 的 VNode
  NONE_KEYED_VNODES: 1 << 3
}

ChildrenFlags.MULTIPLE_VNODES =
  ChildrenFlags.KEYED_VNODES | ChildrenFlags.NONE_KEYED_VNODES

export { VNodeFlags, ChildrenFlags }

从Vue3源码分析构建功能实现—渲染运行篇(万字长文解析)

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

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

// 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)) {
      // 添加事件 slice(2).toLowerCase()是为了去掉on前缀,保留事件名。
      el.addEventListener(key.slice(2).toLowerCase(), val)
    } else {
      el.setAttribute(key, val)
    }
  }
  container.append(el)
}

简单总结:

  • 注册事件通过遍历vnode的props的属性,发现以on开头且为函数,则进行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性质

既然把props写在Foo组件上,那么我们可以在setupComponent上,给instance挂载一个props的属性。先在createComponentInstance上为instance添加props属性,也方便后续的通过this访问到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 || {}
}

通过代理访问props[key]就返回拿到了值:


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))
}

简单总结:

  • 通过把props绑定到实例instance上
  • 增加代理对象的handler内容,使之this可以获取props的值
  • 通过传入当前有状态函数式组件的setup中,使得可以用props.来进行获取
  • 通过浅层可读代理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,并执行 [3]
  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 = { // [2]
    vnode,
    type,
    render: null,
    setupState: {},
    props: {},
    emit: () => {},
  }
  instance.emit = emit.bind(null, instance) as any // 进行bind绑定,在instance上增加
  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)
}

简单总结一下:

  • emit事件进行挂载在instance上,在setup()第二个入参中进行初始化,我们可以看到[2]处,instance的属性
  • Foo组件内调用emit的时候,实际上做的事情就是把参数传递给props上的事件的,可以查看[3]处,他会通过当前组件的instance.props找到对应的自定义事件名,然后执行对应的函数,这里的函数是takeEmitEvent,所以这里还会执行函数体this.takeEmitEvent进行参数的传递

slot

slot的实现有以下目标:

  • 实现基本的插槽
  • 具名插槽
  • 作用域插槽

基本的插槽

component vnode的children属性会作为插槽,所以应该先获取vnode的children。在instance上保存slots属性,在initSlots函数中,先简单实现为instance.slots = instance.vnode.children

现在,instance上已经保存了slots,this.$slots应当暴露给用户,这样用户在子组件Foo中就可以获取插槽中的结点,调用render生成vnode,最后被渲染出来。根据前面,此处需要给组件代理的map新增成员:

const publicPropertiesMap = {
  // 从component类型的vnode获取el
  $el: (i) => i.vnode.el,
  $slots: (i) => i.slots
};

现在,假设给子组件传的children是vnode类型(string属于文本结点),可以这样使用插槽:

  render() {
    const foo = h('p', {}, 'foo');
    const age = 18;

    return h('div', {}, [this.$slots, foo]);
  }

如果给子组件传的children是array类型,则不能成功渲染,可以再创建一个vnode,然后把插槽作为其children,children允许为array类型,但是这样写之后,children为vnode或string又不能成功渲染。

  render() {
    const foo = h('p', {}, 'foo');
    const age = 18;

    return h('div', {}, [h('div', {}, this.$slots), foo]);
  }

在initSlots中,如果children不是数组,instance.slots = [children]。从而保证this.$slots始终是数组。

至此,基本的插槽实现完毕。

具名插槽

具名插槽中,在拿到插槽vnode的基础上,还要考虑位置问题。刚才说给子组件传的children可以有array/string/vnode类型,现在修改为传一个对象。

父组件这样传:

    const app = h('div', {}, 'hi, ' + this.msg);
    const foo = h(
      Foo,
      {},
      {
        header: h('div', {}, 'header ' + age),
        footer: h('div', {}, 'footer')
      }
    );
    return h(
      'div',
      {
        id: 'root'
      },
      [app, foo]
    );
  },

子组件这样用:

    return h('div', {}, [
      renderSlots(this.$slots, 'header'),
      foo,
      renderSlots(this.$slots, 'footer')
    ]);

先来看看renderSlots的逻辑,核心是构造一个新的vnode,并把slots作为其children,但是由于是具名插槽,只会从this.$slots中取出名字相符的slot。

export function renderSlots(slots, name) {
  const slot = slots[name];

  if (slot) {
    return createVNode('div', {}, slot);
  }
}

之前,this.$slots是一个数组,现在能通过slots[name]的方式获取到slot,数组显然不合适,所以instance.slots也是一个对象

export function initSlots(instance, children) {
  const { vnode } = instance;
  
  // 新增ShapeFlags.SLOT_CHILDREN,判断有没有slot
  if (vnode.shapeFlag & ShapeFlags.SLOT_CHILDREN) {
    normalizeObjectSlots(instance.slots, children);
  }
}

function normalizeObjectSlots(slots, children) {
  for (const key in children) {
    const value = children[key];
    // instance.slots是一个对象,给每个key都初始化好slot
    slots[key] = normalizeSlotValue(value);
  }
}

function normalizeSlotValue(value) {
  // 上面说了slot要转为数组的原因.
  return Array.isArray(value) ? value : [value];
}

现在,renderSlots(this.$slots, 'header')就可以从instance.slots上拿到header对应的slot,生成vnode了。footer也同理,具名插槽实现完毕。

作用域插槽

所谓作用域插槽,指的是插槽中可以传入变量,比如父组件给子组件的插槽内使用了变量age,age要能渲染出来。

插槽对象内的成员改为函数,函数的返回值为vnode:

  render() {
    const app = h('div', {}, 'hi, ' + this.msg);
    const foo = h(
      Foo,
      {},
      {
        // 解构出age。为了拿到age,需要使用函数
        header: ({ age }) => h('div', {}, 'header ' + age),
        footer: () => h('div', {}, 'footer')
      }
    );
    return h(
      'div',
      {
        id: 'root'
      },
      [app, foo]
    );
  },

在子组件中,给renderSlots加入第三个参数,用于作用域插槽:

  render() {
    const foo = h('p', {}, 'foo');
    const age = 18;

    return h('div', {}, [
      // 第三个参数传一个对象,对象里是一些props
      renderSlots(this.$slots, 'header', { age }),
      foo,
      renderSlots(this.$slots, 'footer')
    ]);
  }

然后就是修改之前的逻辑,因为slot成为了函数的返回值,之前用到slot的地方,都需要改为slot(),并且将props传入。

function renderSlots(slots, name, props) {
  // slot从this.$slots拿到,也是函数
  const slot = slots[name];

  if (slot) {
    // slot(props)
    return createVNode('div', {}, slot(props));
  }
}

这里也需要修改:

function normalizeObjectSlots(slots, children) {
  for (const key in children) {
    const value = children[key]; // value就是slot(函数)
    // 修改原函数,让其返回值改为数组
    slots[key] = (props) => normalizeSlotValue(value(props));
  }
}

function normalizeSlotValue(value) {
  return Array.isArray(value) ? value : [value];
}

简单总结一下:

  • 于插槽而言,本质上就是一句话:函数的声明和调用

  • 当使用普通的插槽满足使用子组件再包裹其他组件或者标签时,对chrildren数组化,将其内部h函数进行创建vnode,所以在子组件里是呈现那样的数组。

  • 当我们使用具名插槽的时候,此时的this.$slot是为对象,而只有转成数组,在mountChildren的时候才能遍历chridren

  • 当我们使用作用域插槽的时候,会将变量以参数的形式传递,这样就可以了

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