Slot 和 SlotScope 是怎么实现的

技术 8月 13, 2020

废弃的 API 不在文章内容范围内。

基本用法

<!-- 组件定义 -->
<template>
	<div id="slot-component">
    <slot></slot>
    <slot name="hasNameSlot"></slot>
    <slot name="hasNameAndScopeSlot" :text="text"></slot>
  </div>
</template>

<!-- 使用插槽 -->
<template>
	<slot-component>
    <span>我会去默认插槽</span>
    <tempalte #hasNameSlot>我会去第一个具名插槽</tempalte>
    <template #hasNameAndScopeSlot="{text}">{{text}}</template>
  </slot-component>
</template>

原理解析

AST


插槽 与 其他正常的写法组件,最大的区别的起点实际是在 生成 AST 阶段 开始的,我们可以查看一下上述示例中 使用插槽 部分生成 AST 内容(忽略了没有意义的内容)。

主要特点 是,两个明确为具名插槽的节点,并不在 slot-componentchildren 里面,而是在其 scopedSlots 内以 key-value 的形式存储着。
原因 是解析器会在每个节点词法分析完后,会对其进行语法分析,其中有一个步骤就是对节点有可能有的插槽相关属性进行分析。具体执行栈大约是这样的:baseCompile -> parse -> parseHTML -> options.start -> options.end -> closeElement -> processSlotContent

{
    "tag": "slot-component",
    "children": [
        {
            "type": 1,
            "tag": "div",
            "children": [
                {
                    "type": 3,
                    "text": "我会去默认插槽"
                }
            ],
        }
    ],
    "scopedSlots": {
        "\"hasNameSlot\"": {
            "tag": "template",
            "attrsMap": {
                "#hasNameSlot": ""
            },
            "children": [
                {
                    "type": 1,
                    "tag": "div",
                    "children": [
                        {
                            "type": 3,
                            "text": "我会去第一个具名插槽"
                        }
                    ],
                }
            ],
            "slotScope": "_empty_",
            "slotTarget": "\"hasNameSlot\"",
            "slotTargetDynamic": false
        },
        "\"hasNameAndScopeSlot\"": {
            "tag": "template",
            "attrsMap": {
                "#hasNameAndScopeSlot": "{ text }"
            },
            "children": [
                {
                    "tag": "div",
                    "children": [
                        {
                            "expression": "_s(text)",
                            "tokens": [
                                {
                                    "@binding": "text"
                                }
                            ],
                            "text": "{{ text }}"
                        }
                    ],
                    "plain": true
                }
            ],
            "slotScope": "{ text }",
            "slotTarget": "\"hasNameAndScopeSlot\"",
            "slotTargetDynamic": false
        }
    },
}

Code generate


AST 生成后紧接着就是 code generate,生成是当前 AST 对应的 render 函数,让我们看看插槽部分和其他节点有什么不同。
从结构上来说,大体与 AST 保持一致,插槽的内容并没有到 children 部分,二是跑到了 **节点属性 **上,同时插槽内的内容变成的 函数式组件,相应的我们可以发现,作用域插槽就是通过调用这个 函数式组件,并 **传入参数 **而完成的。由于作用域的关系,函数内的变量会临时覆盖 this 上相同 key 的值,所以保证的语法的一致性。
具体的调用栈大约是这样的:baseCompiler -> generate -> genElement -> genData -> genScopedSlots

with (this) {
  return _c(
      "slot-component",
      {
        scopedSlots: _u([
          {
            key: "hasnameslot",
            fn: function () {
              return [_c("div", [_v("我会去第一个具名插槽")])];
            },
            proxy: true,
          },
          {
            key: "hasnameandscopeslot",
            fn: function ({ text }) {
              return [_c("div", [_v(_s(text))])];
            },
          },
        ]),
      },
      [_c("div", [_v("我会去默认插槽")])]
  )
}

Render


接下来一步是 Vue 是如何生成对应的虚拟 DOM 的呢,我们先需要去尝试解读一下生成的 render 函数里面的比较重要的函数 _c 、 _u 

_c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

https://github.com/vuejs/vue/blob/5255841aaff441d275122b4abfb099b881de7cb5/src/core/vdom/create-element.js#L28

// 入参格式化
export function createElement (context, tag, data, children, normalizationType, alwaysNormalize) {
  // 函数重载处理,如果 data 为数组或者基本数据类型,则视 data 实际缺省
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  // 开发者手动写的 render 函数才会为 true,此参数决定以什么模式格式化内容
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

export function _createElement ( context, tag, data, children, normalizationType) {
  // 如果 data(属性)为响应式的数据则抛出错误,返回空的虚拟 DOM
  if (isDef(data) && isDef((data: any).__ob__)) {
    process.env.NODE_ENV !== 'production' && warn(
      `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
      'Always create fresh vnode data objects in each render!',
      context
    )
    return createEmptyVNode()
  }
  // component is 的写法处理
  if (isDef(data) && isDef(data.is)) {
    tag = data.is
  }
  // 如果 tag 为空则返回空的虚拟 DOM
  if (!tag) {
    return createEmptyVNode()
  }
  
  // 如果 key 值不是基本数据类型则抛出错误
  if (process.env.NODE_ENV !== 'production' &&
    isDef(data) && isDef(data.key) && !isPrimitive(data.key)
  ) {
    if (!__WEEX__ || !('@binding' in data.key)) {
      warn(
        'Avoid using non-primitive value as key, ' +
        'use string/number value instead.',
        context
      )
    }
  }
  
  // 如果有 children 并且第一个是函数的话,则将第一个 child 转移到 scopedSlots.default 中,并清空 children
  if (Array.isArray(children) &&
    typeof children[0] === 'function'
  ) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }
  
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children) // 复杂的规范化处理,因为 render 是开发者写的
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children) // 简单的规范化处理(拍平可能出现的嵌套数组 children)
  }
  
  
  let vnode, ns
  if (typeof tag === 'string') {
    // 标签为 string 时
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // 如果是原生标签
      // 则进行 v-on 的错误判断
      if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
        warn(
          `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
          context
        )
      }
      // 生成平台相对应的虚拟 DOM
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // 如果不是原生标签同时相应的标签名称在 components 中定义了,则视为组件,并传入对应的构造函数,创建函数 VNode
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // 未知的标签,不管三七二十一直接用 tag 生成
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // 不是字符串则视为组件的构造函数等,直接创建函数 VNode
    vnode = createComponent(tag, data, context, children)
  }
  
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

_u = resolveScopedSlots

https://github.com/vuejs/vue/blob/5255841aaff441d275122b4abfb099b881de7cb5/src/core/instance/render-helpers/resolve-scoped-slots.js#L3

// 将数值的 scopedSlots 转换成 key-value 的形式,同时加上一些渲染优化相关的属性
// 参数内容由 codegen 时的 genScopedSlots 来决定
// fns,就是插槽内容集合
// res,正常都是初始值都为空,只不过递归处理的时候需要传递下去
// hasDynamicKeys,通常情况下为 false(稳定的),如果相关节点有动态属性或者内容则为 true,这意味着需要在父节点更新的时候需要强制更新
// contentHashKey,与第三个参数互斥,如果祖父组件有 v-if,则会有 key 值
export function resolveScopedSlots (fns, res, hasDynamicKeys, contentHashKey) {
  // 初始化时根据 hasDynamicKeys 来判断是否是稳定的
  res = res || { $stable: !hasDynamicKeys } 
  
  // 遍历 scopedSlots 里的内容
  for (let i = 0; i < fns.length; i++) {
    const slot = fns[i]
    if (Array.isArray(slot)) {
    	// 如果还是数组则递归处理
      resolveScopedSlots(slot, res, hasDynamicKeys)
    } else if (slot) {
      // 如果是动态的则在渲染函数上也添加相关静态属性
      if (slot.proxy) {
        slot.fn.proxy = true
      }
      res[slot.key] = slot.fn
    }
  }
  // 有则加
  if (contentHashKey) {
    (res: any).$key = contentHashKey
  }
  return res
}

VNode

基于上述讲解生成的虚拟DOM

{
  tag: 'vue-component-1-slot-component',
  componentOptions: {
  	Ctor: f VueComponent(options),
  	children: [divVNode],
		tag: 'slot-component',
		listeners: undefined,
		propsData: undefined
  },
  context: {...VueInstance},
  data: {
    hook: {...VNodeHooks},
    on: undefined,
    scopedSlots: {
    	$stable: true,
      hasnameandscopeslot: f({ text }),
      hasnameslot: f()
    }
  },
  ...otherKeys,
}

组件如何处理父组件传下来的插槽内容


经过上面的讲解,我们可以直接跳过 AST,查看 render 函数

with (this) {
  return _c(
    "div",
    { attrs: { id: "slot-component" } },
    [
      _t("default"),
      _v(" "),
      _t("hasNameSlot"),
      _v(" "),
      _t("hasNameAndScopeSlot", null, { text: text }),
    ],
    2
  );
}

很明显关键点就在 _t

_t = renderSlot

export function renderSlot (name, fallback, props, bindObject) {
  // this 指向的就是上面生成的组件的实例,$scopedSlots 指向的就是我们上面折腾半天的内容
  // 先查看有没有传递下来相关的插槽
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes
  if (scopedSlotFn) {
    // 如果有对应插槽
    // props 便是作用域内容
    props = props || {}
    // 如果绑定的是个对象则抛出错误并合并
    if (bindObject) {
      if (process.env.NODE_ENV !== 'production' && !isObject(bindObject)) {
        warn(
          'slot v-bind without argument expects an Object',
          this
        )
      }
      props = extend(extend({}, bindObject), props)
    }
    // 调用渲染函数,传入参数,得到虚拟 VNode
    nodes = scopedSlotFn(props) || fallback
  } else {
    // 退而求其次去组件的插槽找
    nodes = this.$slots[name] || fallback
  }

  // 嵌套插槽处理
  const target = props && props.slot
  if (target) {
    return this.$createElement('template', { slot: target }, nodes)
  } else {
    return nodes
  }
}

默认插槽是如何处理的


根据上面的分析,之后其实最大的问题就是,children 如何加入到 $scopeSlots 中,这个其实分两步走

  • 第一步:实例化 slot-component 组件阶段
  • 第二步:slot-component 生成 VNode 之前的准备工作时
    • Vue.proptotype._render 回先判断有没有父级节点,如果有则初始化 $scopeSlots
    • $scopeSlots 的初始化是通过 normalizeScopedSlots 函数将 _parentVnode.data.scopedSlotsthis.$slotthis.$scopedSlots(一般为空) 合并
    • 这个时候 this.$scopedSlots 就会有 default 指向父节点的 children,以及父组件的获得的具名插槽


在初始化完完成后,通过获取 this.$scopedSlots.default 就可以获取到默认插槽的内容啦!

参考

Pengsha Ying

逝者如斯,故不舍昼夜