废弃的 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-component 的 children 里面,而是在其 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)
// 入参格式化export function createElement (context, tag, data, children, normalizationType, alwaysNormalize) {// 函数重载处理,如果 data 为数组或者基本数据类型,则视 data 实际缺省if (Array.isArray(data) || isPrimitive(data)) {normalizationType = childrenchildren = datadata = 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(属性)为响应式的数据则抛出错误,返回空的虚拟 DOMif (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 为空则返回空的虚拟 DOMif (!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 中,并清空 childrenif (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, nsif (typeof tag === 'string') {// 标签为 string 时let Ctorns = (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)}// 生成平台相对应的虚拟 DOMvnode = new VNode(config.parsePlatformTagName(tag), data, children,undefined, undefined, context)} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {// 如果不是原生标签同时相应的标签名称在 components 中定义了,则视为组件,并传入对应的构造函数,创建函数 VNodevnode = createComponent(Ctor, data, context, children, tag)} else {// 未知的标签,不管三七二十一直接用 tag 生成vnode = new VNode(tag, data, children,undefined, undefined, context)}} else {// 不是字符串则视为组件的构造函数等,直接创建函数 VNodevnode = 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
// 将数值的 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 nodesif (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)}// 调用渲染函数,传入参数,得到虚拟 VNodenodes = scopedSlotFn(props) || fallback} else {// 退而求其次去组件的插槽找nodes = this.$slots[name] || fallback}// 嵌套插槽处理const target = props && props.slotif (target) {return this.$createElement('template', { slot: target }, nodes)} else {return nodes}}
默认插槽是如何处理的
根据上面的分析,之后其实最大的问题就是,children 如何加入到 $scopeSlots 中,这个其实分两步走
- 第一步:实例化 slot-component 组件阶段
- 在 initRender 阶段通过 resloveSlots 初始化组件的 this.$slots
- 这个时候 this.$slots = {default: children}
- 第二步:slot-component 生成 VNode 之前的准备工作时
- 在 Vue.proptotype._render 回先判断有没有父级节点,如果有则初始化 $scopeSlots
- $scopeSlots 的初始化是通过 normalizeScopedSlots 函数将
_parentVnode.data.scopedSlots、this.$slot、this.$scopedSlots(一般为空)合并 - 这个时候 this.$scopedSlots 就会有 default 指向父节点的 children,以及父组件的获得的具名插槽
在初始化完完成后,通过获取 this.$scopedSlots.default 就可以获取到默认插槽的内容啦!
