Vue 的组件提供了一个非常有用的特性 —— slot 插槽,它让组件的实现变的更加灵活。平时在开发组件库的时候,为了让组件更加灵活可定制,经常用插槽的方式让用户可以自定义内容。插槽分为普通插槽和作用域插槽,它们可以解决不同的场景
普通插槽
例子
let AppLayout = {template: '<div class="container">' +'<header><slot name="header"></slot></header>' +'<main><slot>默认内容</slot></main>' +'<footer><slot name="footer"></slot></footer>' +'</div>'}let vm = new Vue({el: '#app',template: '<div>' +'<app-layout>' +'<h1 slot="header">{{title}}</h1>' +'<p>{{msg}}</p>' +'<p slot="footer">{{desc}}</p>' +'</app-layout>' +'</div>',data() {return {title: '我是标题',msg: '我是内容',desc: '其它信息'}},components: {AppLayout}})
定义了 AppLayout 子组件,它内部定义了 3 个插槽,2 个为具名插槽,一个 name 为 header,一个 name 为 footer,还有一个没有定义 name 的是默认插槽
父组件注册和引用了 AppLayout 的组件,并在组件内部定义了一些元素,用来替换插槽,那么它最终生成的 DOM 如下
<div><div class="container"><header><h1>我是标题</h1></header><main><p>我是内容</p></main><footer><p>其它信息</p></footer></div></div>
编译
编译是发生在调用 vm.$mount 的时候,所以编译的顺序是先编译父组件,再编译子组件
首先编译父组件,在 parse 阶段,会执行 processSlot 处理 slot
定义在 src/compiler/parser/index.js 中
// handle content being passed to a component as slot,// e.g. <template slot="xxx">, <div slot-scope="xxx">function processSlotContent (el) {let slotScope// <template slot="xxx">if (el.tag === 'template') {slotScope = getAndRemoveAttr(el, 'scope')/* istanbul ignore if */if (process.env.NODE_ENV !== 'production' && slotScope) {warn(`the "scope" attribute for scoped slots have been deprecated and ` +`replaced by "slot-scope" since 2.5. The new "slot-scope" attribute ` +`can also be used on plain elements in addition to <template> to ` +`denote scoped slots.`,el.rawAttrsMap['scope'],true)}el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope')} else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) { // <div slot-scope="xxx">/* istanbul ignore if */if (process.env.NODE_ENV !== 'production' && el.attrsMap['v-for']) {warn(`Ambiguous combined usage of slot-scope and v-for on <${el.tag}> ` +`(v-for takes higher priority). Use a wrapper <template> for the ` +`scoped slot to make it clearer.`,el.rawAttrsMap['slot-scope'],true)}el.slotScope = slotScope}// slot="xxx"// 当解析到标签上有slot属性的时候,会给对应的AST元素节点添加slotTarget属性const slotTarget = getBindingAttr(el, 'slot')if (slotTarget) {el.slotTarget = slotTarget === '""' ? '"default"' : slotTargetel.slotTargetDynamic = !!(el.attrsMap[':slot'] || el.attrsMap['v-bind:slot'])// preserve slot as an attribute for native shadow DOM compat// only for non-scoped slots.if (el.tag !== 'template' && !el.slotScope) {addAttr(el, 'slot', slotTarget, getRawBindingAttr(el, 'slot'))}}// 2.6 v-slot syntaxif (process.env.NEW_SLOT_SYNTAX) {if (el.tag === 'template') {// v-slot on <template>const slotBinding = getAndRemoveAttrByRegex(el, slotRE)if (slotBinding) {if (process.env.NODE_ENV !== 'production') {if (el.slotTarget || el.slotScope) {warn(`Unexpected mixed usage of different slot syntaxes.`,el)}if (el.parent && !maybeComponent(el.parent)) {warn(`<template v-slot> can only appear at the root level inside ` +`the receiving component`,el)}}const { name, dynamic } = getSlotName(slotBinding)el.slotTarget = nameel.slotTargetDynamic = dynamicel.slotScope = slotBinding.value || emptySlotScopeToken // force it into a scoped slot for perf}} else {// v-slot on component, denotes default slotconst slotBinding = getAndRemoveAttrByRegex(el, slotRE)if (slotBinding) {if (process.env.NODE_ENV !== 'production') {if (!maybeComponent(el)) {warn(`v-slot can only be used on components or <template>.`,slotBinding)}if (el.slotScope || el.slotTarget) {warn(`Unexpected mixed usage of different slot syntaxes.`,el)}if (el.scopedSlots) {warn(`To avoid scope ambiguity, the default slot should also use ` +`<template> syntax when there are other named slots.`,slotBinding)}}// add the component's children to its default slotconst slots = el.scopedSlots || (el.scopedSlots = {})const { name, dynamic } = getSlotName(slotBinding)const slotContainer = slots[name] = createASTElement('template', [], el)slotContainer.slotTarget = nameslotContainer.slotTargetDynamic = dynamicslotContainer.children = el.children.filter((c: any) => {if (!c.slotScope) {c.parent = slotContainerreturn true}})slotContainer.slotScope = slotBinding.value || emptySlotScopeToken// remove children as they are returned from scopedSlots nowel.children = []// mark el non-plain so data gets generatedel.plain = false}}}}
在codegen阶段genData中会处理slotTarget,相关代码在src/compiler/codegen/index.js中
// slot target// only for non-scoped slotTarget// 给data添加一个slot属性,并指向slotif (el.slotTarget && !el.slotScope) {data += `slot:${el.slotTarget},`}// scoped slotsif (el.scopedSlots) {data += `${genScopedSlots(el, el.scopedSlots, state)},`}
例子父组件最终生成代码如下
with(this){return _c('div',[_c('app-layout',[_c('h1',{attrs:{"slot":"header"},slot:"header"},[_v(_s(title))]),_c('p',[_v(_s(msg))]),_c('p',{attrs:{"slot":"footer"},slot:"footer"},[_v(_s(desc))])])],1)}
编译子组件,同样在parser阶段会执行processSlot处理函数
定义在src/compiler/codegen/index.js中
function genSlot (el: ASTElement, state: CodegenState): string {// 从AST元素节点对应的属性上取 默认是 defaultconst slotName = el.slotName || '"default"'// children对应的就是slot开始和闭合标签包裹的内容const children = genChildren(el, state)let res = `_t(${slotName}${children ? `,function(){return ${children}}` : ''}`const attrs = el.attrs || el.dynamicAttrs? genProps((el.attrs || []).concat(el.dynamicAttrs || []).map(attr => ({// slot props are camelizedname: camelize(attr.name),value: attr.value,dynamic: attr.dynamic}))): nullconst bind = el.attrsMap['v-bind']if ((attrs || bind) && !children) {res += `,null`}if (attrs) {res += `,${attrs}`}if (bind) {res += `${attrs ? '' : ',null'},${bind}`}return res + ')'}
最终子组件生成的代码如下
with(this) {return _c('div',{staticClass:"container"},[_c('header',[_t("header")],2),_c('main',[_t("default",[_v("默认内容")])],2),_c('footer',[_t("footer")],2)])}
在编译章节了解到,_t函数对应的就是renderSlot方法,它的定义在src/core/instance/render-heplpers/render-slot.js中
/*** Runtime helper for rendering <slot>*/export function renderSlot (name: string, // 插槽名称slotNamefallbackRender: ?((() => Array<VNode>) | Array<VNode>), // 插槽的默认内容生成的vnode数组props: ?Object,bindObject: ?Object): ?Array<VNode> {const scopedSlotFn = this.$scopedSlots[name]let nodesif (scopedSlotFn) {// scoped slotprops = 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)}nodes =scopedSlotFn(props) ||(typeof fallbackRender === 'function' ? fallbackRender() : fallbackRender)} else {// 如果this.$slot[name]有值,就返回它对应的vnode数组,否则返回fallbackRendernodes =this.$slots[name] ||(typeof fallbackRender === 'function' ? fallbackRender() : fallbackRender)}const target = props && props.slotif (target) {return this.$createElement('template', { slot: target }, nodes)} else {return nodes}}
子组件的 init 时机是在父组件执行 patch 过程的时候,那这个时候父组件已经编译完成了。并且子组件在 init 过程中会执行 initRender 函数,initRender 的时候获取到 vm.$slot,相关代码在 src/core/instance/render.js 中
export function initRender (vm: Component) {// ...const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent treeconst renderContext = parentVnode && parentVnode.contextvm.$slots = resolveSlots(options._renderChildren, renderContext)// ...}
resolveSlots定义在src/core/instance/render-helpers/resolve-slots.js中
/*** Runtime helper for resolving raw children VNodes into a slot object.*/export function resolveSlots (children: ?Array<VNode>, // 对应的是父vnode的childrencontext: ?Component // 父vnode的上下文,也就是父组件的vm实例): { [key: string]: Array<VNode> } {if (!children || !children.length) {return {}}const slots = {}// 遍历for (let i = 0, l = children.length; i < l; i++) {const child = children[i]// dataconst data = child.data// remove slot attribute if the node is resolved as a Vue slot nodeif (data && data.attrs && data.attrs.slot) {delete data.attrs.slot}// named slots should only be respected if the vnode was rendered in the// same context.if ((child.context === context || child.fnContext === context) &&data && data.slot != null) {// 插槽名称 编译父组件在codegen阶段设置的data.slotconst name = data.slot// 以name为key把child添加到slots中const slot = (slots[name] || (slots[name] = []))if (child.tag === 'template') {slot.push.apply(slot, child.children || [])} else {slot.push(child)}} else { // data.slot不存在则是默认插槽的内容(slots.default || (slots.default = [])).push(child)}}// ignore slots that contains only whitespacefor (const name in slots) {if (slots[name].every(isWhitespace)) {delete slots[name]}}return slots}
获取到整个slots,它是一个对象,key是插槽名称,value是一个vnode类型的数组,因为它可以有多个同名插槽
拿到了 vm.$slots 了,回到 renderSlot 函数
nodes = this.$slots[name] || (typeof fallbackRender === 'function' ? fallbackRender() : fallbackRender)
就能根据插槽名称获取到对应的 vnode 数组了,这个数组里的 vnode 都是在父组件创建的,这样就实现了在父组件替换子组件插槽的内容了
在普通插槽中,父组件应用到子组件插槽里的数据都是绑定到父组件的,因为它渲染成 vnode 的时机的上下文是父组件的实例
作用域插槽
例子
let Child = {template: '<div class="child">' +'<slot text="Hello" :msg="msg"></slot>' +'</div>',data() {return {msg: 'Vue'}}}let vm = new Vue({el: '#app',template: '<div>' +'<child>' +'<template slot-scope="props">' +'<p>Hello from parent</p>' +'<p>{{ props.text + props.msg}}</p>' +'</template>' +'</child>' +'</div>',components: {Child}})
最终生成的 DOM 结构如下
<div><div class="child"><p>Hello from parent</p><p>Hello Vue</p></div></div>
父组件实现插槽的部分多了一个 template 标签,以及 scope-slot 属性,其实在 Vue 2.5+ 版本,scoped-slot 可以作用在普通元素上
这些就是作用域插槽和普通插槽在写法上的差别
编译
在编译阶段,仍然是先编译父组件,同样是通过processSlotContent函数去处理 scoped-slot
定义在在 src/compiler/parser/index.js 中
// handle content being passed to a component as slot,// e.g. <template slot="xxx">, <div slot-scope="xxx">function processSlotContent (el) {let slotScopeif (el.tag === 'template') { // 读取scoped-slot属性并赋值给当前AST元素节点的slotName属性slotScope = getAndRemoveAttr(el, 'scope')/* istanbul ignore if */if (process.env.NODE_ENV !== 'production' && slotScope) {warn(`the "scope" attribute for scoped slots have been deprecated and ` +`replaced by "slot-scope" since 2.5. The new "slot-scope" attribute ` +`can also be used on plain elements in addition to <template> to ` +`denote scoped slots.`,el.rawAttrsMap['scope'],true)}el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope')} else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) {/* istanbul ignore if */if (process.env.NODE_ENV !== 'production' && el.attrsMap['v-for']) {warn(`Ambiguous combined usage of slot-scope and v-for on <${el.tag}> ` +`(v-for takes higher priority). Use a wrapper <template> for the ` +`scoped slot to make it clearer.`,el.rawAttrsMap['slot-scope'],true)}el.slotScope = slotScope}// ...}
接下来在构造 AST 树的时候,会执行以下逻辑
if (element.elseif || element.else) {processIfConditions(element, currentParent)} else {if (element.slotScope) { // 对于拥有 scopedSlot 属性的 AST 元素节点而言,是不会作为 children 添加到当前 AST 树中,而是存到父 AST 元素节点的 scopedSlots 属性上,它是一个对象,以插槽名称 name 为 key// scoped slot// keep it in the children list so that v-else(-if) conditions can// find it as the prev node.const name = element.slotTarget || '"default"';(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element}currentParent.children.push(element)element.parent = currentParent}
然后在 genData 的过程,会对 scopedSlots 做处理
// scoped slotsif (el.scopedSlots) {data += `${genScopedSlots(el, el.scopedSlots, state)},`}function genScopedSlots (el: ASTElement,slots: { [key: string]: ASTElement },state: CodegenState): string {// by default scoped slots are considered "stable", this allows child// components with only scoped slots to skip forced updates from parent.// but in some cases we have to bail-out of this optimization// for example if the slot contains dynamic names, has v-if or v-for on them...let needsForceUpdate = el.for || Object.keys(slots).some(key => {const slot = slots[key]return (slot.slotTargetDynamic ||slot.if ||slot.for ||containsSlotChild(slot) // is passing down slot from parent which may be dynamic)})// #9534: if a component with scoped slots is inside a conditional branch,// it's possible for the same component to be reused but with different// compiled slot content. To avoid that, we generate a unique key based on// the generated code of all the slot contents.let needsKey = !!el.if// OR when it is inside another scoped slot or v-for (the reactivity may be// disconnected due to the intermediate scope variable)// #9438, #9506// TODO: this can be further optimized by properly analyzing in-scope bindings// and skip force updating ones that do not actually use scope variables.if (!needsForceUpdate) {let parent = el.parentwhile (parent) {if ((parent.slotScope && parent.slotScope !== emptySlotScopeToken) ||parent.for) {needsForceUpdate = truebreak}if (parent.if) {needsKey = true}parent = parent.parent}}// 对scopedSlots对象遍历执行genScopedSlots,并把结果用逗号拼接const generatedSlots = Object.keys(slots).map(key => genScopedSlot(slots[key], state)).join(',')return `scopedSlots:_u([${generatedSlots}]${needsForceUpdate ? `,null,true` : ``}${!needsForceUpdate && needsKey ? `,null,false,${hash(generatedSlots)}` : ``})`}function genScopedSlot (el: ASTElement,state: CodegenState): string {const isLegacySyntax = el.attrsMap['slot-scope']if (el.if && !el.ifProcessed && !isLegacySyntax) {return genIf(el, state, genScopedSlot, `null`)}if (el.for && !el.forProcessed) {return genFor(el, state, genScopedSlot)}const slotScope = el.slotScope === emptySlotScopeToken? ``: String(el.slotScope)// 先生成一段函数代码,参数是slotScope(标签属性上的scoped-slot对应的值)const fn = `function(${slotScope}){` +`return ${el.tag === 'template'? el.if && isLegacySyntax? `(${el.if})?${genChildren(el, state) || 'undefined'}:undefined`: genChildren(el, state) || 'undefined': genElement(el, state)}}`// reverse proxy v-slot without scope on this.$slotsconst reverseProxy = slotScope ? `` : `,proxy:true`// 返回一个对象 key为插槽名称 fn为生成的函数代码return `{key:${el.slotTarget || `"default"`},fn:${fn}${reverseProxy}}`}
对于例子而言,父组件最终生成的代码如下
with(this){return _c('div',[_c('child',{scopedSlots:_u([{key: "default",fn: function(props) {return [_c('p',[_v("Hello from parent")]),_c('p',[_v(_s(props.text + props.msg))])]}}])})],1)}
可以看到它和普通插槽父组件编译结果的一个很明显的区别就是没有 children 了,data 部分多了一个对象,并且执行了 _u 方法
_u 函数对的就是 resolveScopedSlots 方法
定义在 src/core/instance/render-heplpers/resolve-scoped-slots.js 中
export function resolveScopedSlots (fns: ScopedSlotsData, // see flow/vnode 数组 每一个元素都有一个key和一个fn key对应插槽名称 fn对应一个函数res?: Object,// the following are added in 2.6hasDynamicKeys?: boolean,contentHashKey?: number): { [key: string]: Function, $stable: boolean } {res = res || { $stable: !hasDynamicKeys }// 遍历这个fns数组,生成一个对象,对象的key就是插槽名称,value就是函数for (let i = 0; i < fns.length; i++) {const slot = fns[i]if (Array.isArray(slot)) {resolveScopedSlots(slot, res, hasDynamicKeys)} else if (slot) {// marker for reverse proxying v-slot without scope on this.$slotsif (slot.proxy) {slot.fn.proxy = true}res[slot.key] = slot.fn}}if (contentHashKey) {(res: any).$key = contentHashKey}return res}
子组件的编译,和普通插槽的过程基本相同,唯一一点区别是在 genSlot 的时候
function genSlot (el: ASTElement, state: CodegenState): string {const slotName = el.slotName || '"default"'const children = genChildren(el, state)let res = `_t(${slotName}${children ? `,function(){return ${children}}` : ''}`const attrs = el.attrs || el.dynamicAttrs? genProps((el.attrs || []).concat(el.dynamicAttrs || []).map(attr => ({// slot props are camelizedname: camelize(attr.name),value: attr.value,dynamic: attr.dynamic}))): nullconst bind = el.attrsMap['v-bind']if ((attrs || bind) && !children) {res += `,null`}if (attrs) {res += `,${attrs}`}if (bind) {res += `${attrs ? '' : ',null'},${bind}`}return res + ')'}
它会对 attrs 和 v-bind 做处理,对应例子,最终生成的代码如下
with(this){return _c('div',{staticClass:"child"},[_t("default",null,{text:"Hello ",msg:msg})],2)}
_t 方法对应的是 renderSlot 方法
/*** Runtime helper for rendering <slot>*/export function renderSlot (name: string,fallbackRender: ?((() => Array<VNode>) | Array<VNode>),props: ?Object,bindObject: ?Object): ?Array<VNode> {const scopedSlotFn = this.$scopedSlots[name]let nodesif (scopedSlotFn) {// scoped slotprops = 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)}nodes =scopedSlotFn(props) ||(typeof fallbackRender === 'function' ? fallbackRender() : fallbackRender)} else {// ...}const target = props && props.slotif (target) {return this.$createElement('template', { slot: target }, nodes)} else {return nodes}}
这个 this.$scopedSlots 又是在什么地方定义的呢,原来在子组件的渲染函数执行前,在 vm_render 方法内,有这么一段逻辑,定义在 src/core/instance/render.js 中
if (_parentVnode) {vm.$scopedSlots = normalizeScopedSlots(_parentVnode.data.scopedSlots,vm.$slots,vm.$scopedSlots)}
这个 _parentVNode.data.scopedSlots 对应的就是父组件通过执行 resolveScopedSlots 返回的对象
所以回到 genSlot 函数,通过插槽的名称拿到对应的 scopedSlotFn,然后把相关的数据扩展到 props 上,作为函数的参数传入,函数这个时候执行,然后返回生成的 vnodes,为后续渲染节点用
了解普通插槽和作用域插槽的实现
它们有一个很大的差别是数据作用域,普通插槽是在父组件编译和渲染阶段生成 vnodes,所以数据的作用域是父组件实例,子组件渲染的时候直接拿到这些渲染好的 vnodes。而对于作用域插槽,父组件在编译和渲染阶段并不会直接生成 vnodes,而是在父节点 vnode 的 data 中保留一个 scopedSlots 对象,存储着不同名称的插槽以及它们对应的渲染函数,只有在编译和渲染子组件阶段才会执行这个渲染函数生成 vnodes,由于是在子组件环境执行的,所以对应的数据作用域是子组件实例
两种插槽的目的都是让子组件 slot 占位符生成的内容由父组件来决定,但数据的作用域会根据它们 vnodes 渲染时机不同而不同
