最近工作比较忙,有想写的博客,但是一直没有下笔,想来也是有点懒了,还是要拔拔草。正值金三银四的,面试别人也以 Vue 源码居多,想要还是有必要学习一下插槽相关的内容。
这次的 bug 私以为还是 Vue 本身的问题,先看看一下代码:
// App.js
export default {
components: {
ChildContainer: ChildContainer,
},
data() {
return {
slotsData: {
a: ["a", "ab", "abc"],
default: [],
},
};
},
computed: {
slots() {
const scopedSlots = {};
const { slotsData } = this;
scopedSlots.a = (props) =>
slotsData.a.map((item) => <div {...props}>{item}</div>);
return scopedSlots;
},
},
render() {
const { slotsData } = this;
return (
<div id="app">
<child-container scopedSlots={this.slots}>
<div>default外的默认内容</div>
{slotsData.default.map((item) => item)}
</child-container>
</div>
);
},
};
// ChildContainer.vue
<template>
<div id="childContainer">
<slot></slot>
</div>
</template>;
可以看到上面的 App.js 采用 jsx 的写法,由于 ChildContainer.vue 只有一个默认的 slot,而 App.js 则同时通过 scopedSlots 和 children 的方式传入了插槽内容。首先子组件里面没有使用到 a 插槽,所对应的内容不会渲染出去,最后会渲染出默认的内容为 default外的默认内容。
此时表现一切都是正常的,this.slots 和 slotsData.default 各施其职,而且还充分利用了 computed 的缓存功能,避免重复的计算 slots。而当加载后,修改 slotsData.default 数据的发现,如 this.slotsData.default.push('slotsData的deflaut内容'),可以看到页面没有任何变化,难道是设置的姿势不对?这是再简单不过的,查看 slotsData.default 数据也是对的,只是为什么不渲染出来呢?于是换成 $set 来设置,已经是最完整的了,只是还是没有用。渲染函数确实再次执行了,但是输出的内容还是 default外的默认内容,问题出在哪里呢?
这个时候把 default外的默认内容 这一行代码注释掉,发现再次设置 slotsData.default 的时候,数据生效了,同时页面有渲染 slotsData的deflaut内容,岂不是奇了怪了。之前通过 Vue Dev Tool 还可以看到生成的 this.slots 这个 computed 内容多了个 _normalized 字段,而且其下面还有个 default 函数,当注释 default外的默认内容 这一行的时候,这个 _normalized 也没有了,什么时候多了这个字段呢?看来只能看看 Vue 的源码,之前看的时候一直避开 slot 部分的,完全是个黑盒。
组件输出的渲染函数的 slot 部分为 _vm._t("default"), 其中 _t 就是如下函数:
// 省略部分代码
function renderSlot(name, props) {
const scopedSlotFn = this.$scopedSlots[name];
let nodes;
if (scopedSlotFn) {
// scoped slot
props = props || {};
nodes = scopedSlotFn(props);
} else {
nodes = this.$slots[name];
}
const target = props && props.slot;
if (target) {
return this.$createElement("template", { slot: target }, nodes);
} else {
return nodes;
}
}
// _render 函数里面
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots,
vm.$slots,
vm.$scopedSlots
);
可以看到 renderSlot 的最终输出取决于 vm.$scopedSlots,没有的话再是 vm.$slots,而 $scopedSlots 的生成取决于
_parentVnode.data.scopedSlots 节点 vnode 数据的 scopedSlots 字段,也就是上文业务中的 this.slots;
vm.$slots 实例自身的生成的 $slots,一般是通过 resolveSlots 解析标签来匹配获得实例的 slots 节点;
vm.$scopedSlots 前一个 $scopedSlots;
在上面例子中,当更新 default 的数据的时候,_parentVnode.data.scopedSlots 和 default 数据没有关系所以不会更新,而 vm.$slots 则是包含了更新了的 default 插槽的数据,也就是包含了 default外的默认内容 以及 slotsData的deflaut内容 两个节点,只是在 debug 过程中发现最后生成的 vm.$scopedSlots 有大大的问题。
先看看 normalizeScopedSlots 方法
// 省略部分代码
function normalizeScopedSlots(slots, normalSlots, prevSlots) {
let res;
const hasNormalSlots = Object.keys(normalSlots).length > 0;
const isStable = slots ? !!slots.$stable : !hasNormalSlots;
const key = slots && slots.$key;
if (!slots) {
res = {};
} else if (slots._normalized) {
return slots._normalized;
} else {
res = {};
for (const key in slots) {
if (slots[key] && key[0] !== "$") {
res[key] = normalizeScopedSlot(normalSlots, key, slots[key]);
}
}
}
for (const key in normalSlots) {
if (!(key in res)) {
res[key] = proxyNormalSlot(normalSlots, key);
}
}
if (slots && Object.isExtensible(slots)) {
(slots: any)._normalized = res;
}
return res;
}
首次加载的时候,由于父节点的 scopedSlots 是一个 computed 返回的对象,最后会将生成的 res 赋值给 scopedSlots.__normalized,而这个 res 也包含了 vm.$slots 部分,也就是原本通过 computed 传入的对象是不包含 default 插槽的,但是 res 是全部的内容,也就会包含 default 内容,渲染内容为 default外的默认内容 的节点,最后会被挂载到 computed 输出值的 _normalized 字段。
首次渲染自然是没有问题的,因为 _normalized 也是最新的。当第二次执行 normalizeScopedSlots 的时候,由于 computed 缓存,这个 _normalized 字段也被缓存下来了,由于存在 _normalized,会返回上一次生成的 default 数据,不会包含最新数据的 vm.$slots 的数据返回,在后续的 renderSlot 一直是获取老的数据。
通过测试将 slots 从 computed 变成 methods,问题就解决了,那这应该是算 Vue 的 bug 了,没有设想到传入的 scopedSlots 是一个缓存值。只是要使用的话如何好呢,有两个方法:
- 彻底放弃在
jsx 组件里面嵌套插槽的写法,全部写在 jsx 的 scopedSlots 里面;这样每次更新插槽数据,都会重新触发 computed 从而更新 jsx 的 scopedSlots,只是这样一个插槽更新了,所有的插槽都要计算一次,效果还是稍微差了一点;
- 子组件为单文件组件,其采用
renderSlot 的渲染,那如果采用 jsx 指定插槽呢。比如 this.$slots.default 这样岂不是快哉,只是上面的还缺了点,还需要 $scopedSlots,所以应该是 this.$scopedSlots.default ? this.$scopedSlots.default(props) : this.$slots.default
scopedSlots 与 slots 如何区分?
scopedSlots 与 slots 是两个不同的部分,正如字面意思,前者是作用域插槽,后者是插槽,但是呢,具体区分更多的是按照 2.6 版本来的,比如如下写法:
<layout>
<template v-slot:name> name </template>
</layout>
表面是是没有看到作用域的,但是采用了 2.6 的新写法,最后通过编译会输出 scopedSlots 到 vnode:
// compile 阶段的生成AST树过程
// 省略部分代码
function processSlotContent(el) {
// slot="xxx"
const slotTarget = getBindingAttr(el, "slot");
if (slotTarget) {
el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget;
el.slotTargetDynamic = !!(
el.attrsMap[":slot"] || el.attrsMap["v-bind:slot"]
);
}
// 2.6 v-slot syntax
if (el.tag === "template") {
// v-slot on <template>
const slotBinding = getAndRemoveAttrByRegex(el, slotRE);
if (slotBinding) {
const { name, dynamic } = getSlotName(slotBinding);
el.slotTarget = name;
el.slotTargetDynamic = dynamic;
el.slotScope = slotBinding.value || emptySlotScopeToken; // 新的slot有slotScope
}
}
}
// compile 阶段的 ast 过程
// 省略部分代码
function closeElement(element) {
if (currentParent && !element.forbidden) {
if (element.elseif || element.else) {
processIfConditions(element, currentParent);
} else {
if (element.slotScope) {
// scoped slot
// keep it in the children list so that v-else(-if) conditions can
// find it as the prev node.
var name = element.slotTarget || '"default"';
(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[
name
] = element;
}
currentParent.children.push(element);
element.parent = currentParent;
}
}
}
// compile 阶段的 codegen 过程
// 省略部分代码
function genData(el, state) {
if (el.slotTarget && !el.slotScope) {
data += `slot:${el.slotTarget},`;
}
// scoped slots
if (el.scopedSlots) {
data += `${genScopedSlots(el, el.scopedSlots, state)},`;
}
}
可以看到 processSlotContent 里面上半部分是对 slot=name 判断,是对老的写法的处理,而下面部分则是对 2.6 版本的新写法 template 与 v-slot 组合的处理,新的部分最后输出包含了 slotScope 字段,而旧的版本没有。由于上面例子没有设置作用域,所以 slotScope 为 emptySlotScopeToken 也就是 _empty_ 字符串。在随后的 closeElement 里面,若有 slotScope,则会将其设置到父节点的 scopedSlots 里面,形成一个插槽对象。
到这里都是生成 AST 的过程,后面 codegen 阶段,会根据新老写法的不同,生成 slot 与 scopedSlots 数据,其中 genScopedSlots 返回的是编译好的渲染函数,而 slot 则不同,插槽内容,以 children 的形式存在与父节点中,只是其属性有 slot 而已。
生成的 scopedSlots 数据会传入到 vnode 里面,最后传入上面提到的 normalizeScopedSlots 返回给实例的 $scopedSlots,而 $slot 则会根据前面传入的 vnode 的 slot 数据生成。
上面是父组件里面生成 ChildContainer 的插槽信息,包括生成 scopedSlots 数据这些,而最后在 ChildContainer 编译阶段,会根据 slot 标签名的不同生成对应的 VNode,方法如下:
function renderSlot(name, fallback, props, bindObject) {
const scopedSlotFn = this.$scopedSlots[name];
let nodes;
if (scopedSlotFn) {
// scoped slot
props = props || {};
if (bindObject) {
props = extend(extend({}, bindObject), props);
}
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;
}
}
可以看到有 $scopedSlots 就会直接输出,没有会出采用 $slots 的数据,fallback 则是默认的插槽内容。最后返回的是 VNode 数据,给到子组件。
最后在 normalizeScopedSlots 可以发现,$slots 的也是会传入 $scopedSlots 里面的,所以项目中直接用 $scopedSlots 就可以了,同时 _parentVnode.data.scopedSlots 数据也会传给 $slots 里面的,某种程度说,是 $scopedSlots 和 $slots 区别不大的。
总结
跟着问题学习源码,还是很快的,只是觉得,自己还在看 Vue 源码的,有点不太行,一直想要突破到别的领域的,看来遥遥无期。
最近工作比较忙,有想写的博客,但是一直没有下笔,想来也是有点懒了,还是要拔拔草。正值金三银四的,面试别人也以
Vue源码居多,想要还是有必要学习一下插槽相关的内容。这次的
bug私以为还是Vue本身的问题,先看看一下代码:可以看到上面的
App.js采用jsx的写法,由于ChildContainer.vue只有一个默认的slot,而App.js则同时通过scopedSlots和children的方式传入了插槽内容。首先子组件里面没有使用到a插槽,所对应的内容不会渲染出去,最后会渲染出默认的内容为default外的默认内容。此时表现一切都是正常的,
this.slots和slotsData.default各施其职,而且还充分利用了computed的缓存功能,避免重复的计算slots。而当加载后,修改slotsData.default数据的发现,如this.slotsData.default.push('slotsData的deflaut内容'),可以看到页面没有任何变化,难道是设置的姿势不对?这是再简单不过的,查看slotsData.default数据也是对的,只是为什么不渲染出来呢?于是换成$set来设置,已经是最完整的了,只是还是没有用。渲染函数确实再次执行了,但是输出的内容还是default外的默认内容,问题出在哪里呢?这个时候把
default外的默认内容这一行代码注释掉,发现再次设置slotsData.default的时候,数据生效了,同时页面有渲染slotsData的deflaut内容,岂不是奇了怪了。之前通过Vue Dev Tool还可以看到生成的this.slots这个computed内容多了个_normalized字段,而且其下面还有个default函数,当注释default外的默认内容这一行的时候,这个_normalized也没有了,什么时候多了这个字段呢?看来只能看看Vue的源码,之前看的时候一直避开slot部分的,完全是个黑盒。组件输出的渲染函数的
slot部分为_vm._t("default"), 其中_t就是如下函数:可以看到
renderSlot的最终输出取决于vm.$scopedSlots,没有的话再是vm.$slots,而$scopedSlots的生成取决于_parentVnode.data.scopedSlots节点vnode数据的scopedSlots字段,也就是上文业务中的this.slots;vm.$slots实例自身的生成的$slots,一般是通过resolveSlots解析标签来匹配获得实例的slots节点;vm.$scopedSlots前一个$scopedSlots;在上面例子中,当更新
default的数据的时候,_parentVnode.data.scopedSlots和default数据没有关系所以不会更新,而vm.$slots则是包含了更新了的default插槽的数据,也就是包含了default外的默认内容以及slotsData的deflaut内容两个节点,只是在 debug 过程中发现最后生成的vm.$scopedSlots有大大的问题。先看看
normalizeScopedSlots方法首次加载的时候,由于父节点的
scopedSlots是一个computed返回的对象,最后会将生成的res赋值给scopedSlots.__normalized,而这个res也包含了vm.$slots部分,也就是原本通过computed传入的对象是不包含default插槽的,但是res是全部的内容,也就会包含default内容,渲染内容为default外的默认内容的节点,最后会被挂载到computed输出值的_normalized字段。首次渲染自然是没有问题的,因为
_normalized也是最新的。当第二次执行normalizeScopedSlots的时候,由于computed缓存,这个_normalized字段也被缓存下来了,由于存在_normalized,会返回上一次生成的default数据,不会包含最新数据的vm.$slots的数据返回,在后续的renderSlot一直是获取老的数据。通过测试将
slots从computed变成methods,问题就解决了,那这应该是算Vue的bug了,没有设想到传入的scopedSlots是一个缓存值。只是要使用的话如何好呢,有两个方法:jsx组件里面嵌套插槽的写法,全部写在jsx的scopedSlots里面;这样每次更新插槽数据,都会重新触发computed从而更新jsx的scopedSlots,只是这样一个插槽更新了,所有的插槽都要计算一次,效果还是稍微差了一点;renderSlot的渲染,那如果采用jsx指定插槽呢。比如this.$slots.default这样岂不是快哉,只是上面的还缺了点,还需要$scopedSlots,所以应该是this.$scopedSlots.default ? this.$scopedSlots.default(props) : this.$slots.defaultscopedSlots 与 slots 如何区分?
scopedSlots与slots是两个不同的部分,正如字面意思,前者是作用域插槽,后者是插槽,但是呢,具体区分更多的是按照 2.6 版本来的,比如如下写法:表面是是没有看到作用域的,但是采用了 2.6 的新写法,最后通过编译会输出
scopedSlots到vnode:可以看到
processSlotContent里面上半部分是对slot=name判断,是对老的写法的处理,而下面部分则是对 2.6 版本的新写法template与v-slot组合的处理,新的部分最后输出包含了slotScope字段,而旧的版本没有。由于上面例子没有设置作用域,所以slotScope为emptySlotScopeToken也就是_empty_字符串。在随后的closeElement里面,若有slotScope,则会将其设置到父节点的scopedSlots里面,形成一个插槽对象。到这里都是生成
AST的过程,后面codegen阶段,会根据新老写法的不同,生成slot与scopedSlots数据,其中genScopedSlots返回的是编译好的渲染函数,而slot则不同,插槽内容,以children的形式存在与父节点中,只是其属性有slot而已。生成的
scopedSlots数据会传入到vnode里面,最后传入上面提到的normalizeScopedSlots返回给实例的$scopedSlots,而$slot则会根据前面传入的vnode的slot数据生成。上面是父组件里面生成
ChildContainer的插槽信息,包括生成scopedSlots数据这些,而最后在ChildContainer编译阶段,会根据slot标签名的不同生成对应的VNode,方法如下:可以看到有
$scopedSlots就会直接输出,没有会出采用$slots的数据,fallback则是默认的插槽内容。最后返回的是VNode数据,给到子组件。最后在
normalizeScopedSlots可以发现,$slots的也是会传入$scopedSlots里面的,所以项目中直接用$scopedSlots就可以了,同时_parentVnode.data.scopedSlots数据也会传给$slots里面的,某种程度说,是$scopedSlots和$slots区别不大的。总结
跟着问题学习源码,还是很快的,只是觉得,自己还在看
Vue源码的,有点不太行,一直想要突破到别的领域的,看来遥遥无期。