10. Vue的插槽进化及其原理

2,499 阅读5分钟

插槽

在Vue 2.6.0 中,我们为具名插槽和作用域插槽引入了一个新的统一的语法(即v-slot指令)。它取代了 slot和slot-scope。

匿名和具名插槽slot

父子组件分别以

<app-layout>另一个主要段落</app-layout>

<div class="container"><slot></slot></div>

为例。 父组件生成的render函数为:

(function anonymous(
) {
with(this){return _c('div',{attrs:{"id":"app"}},[_c('app-layout',[_v("另一个主要段落")])],1)}
})

调用:

// Ctor为app-layout的子组件构造函数
vnode = createComponent(Ctor, data, context, children, tag);
// 创建Vnode
var vnode = new VNode(
      ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
      data, undefined, undefined, undefined, context,
      { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
      asyncFactory
);

app-layout组件生成的Vnode为:

{
    asyncFactory: undefined,
    asyncMeta: undefined,
    children: undefined,
    componentInstance: undefined,
    componentOptions: {
        Ctor: ƒ VueComponent(options)
        children: [VNode],
        listeners: undefined,
        propsData: undefined,
        tag: "app-layout"
    },
    context: Vue {_uid: 0, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: Vue, …},
    data: {on: undefined, hook: {…}},
    elm: undefined,
    fnContext: undefined,
    fnOptions: undefined,
    fnScopeId: undefined,
    isAsyncPlaceholder: false,
    isCloned: false,
    isComment: false,
    isOnce: false,
    isRootInsert: true,
    isStatic: false,
    key: undefined,
    ns: undefined,
    parent: undefined,
    raw: false,
    tag: "vue-component-1-app-layout",
    text: undefined,
    child: undefined
}

app-layout的子组件生成的render函数为:

(function anonymous(
) {
with(this){return _c('div',{staticClass:"container"},[_t("default")],2)}
})

this指向app-layout父组件的构造函数。在子组件初始化过程中的initRender会对slot进行如下处理:

vm.$slots = resolveSlots(options._renderChildren, renderContext);

其中options._renderChildren表示一个包含app-layout子组件VNode的数组,具体函数为

function resolveSlots (
    children,
    context
  ) {
    if (!children || !children.length) {
      return {}
    }
    var slots = {};
    for (var i = 0, l = children.length; i < l; i++) {
      var child = children[i];
      var data = child.data;
      // remove slot attribute if the node is resolved as a Vue slot node
      if (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
      ) {
        var name = data.slot;
        var slot = (slots[name] || (slots[name] = []));
        if (child.tag === 'template') {
          slot.push.apply(slot, child.children || []);
        } else {
          slot.push(child);
        }
      } else {
        (slots.default || (slots.default = [])).push(child);
      }
    }
    // ignore slots that contains only whitespace
    for (var name$1 in slots) {
      if (slots[name$1].every(isWhitespace)) {
        delete slots[name$1];
      }
    }
    return slots
}

返回{default: [VNode]},此时vm.$slot = {default: [VNode]}vm.$scopedSlots = emptyObject,接下来进入编译阶段,其中的默认插槽slot被genSlot函数解析成

_t("default")

在子组件中的_render函数中会格式化插槽的表示

vm.$scopedSlots = normalizeScopedSlots(
  _parentVnode.data.scopedSlots,
  vm.$slots,
  vm.$scopedSlots
);

生成

vm.$scopedSlots = {
  default: ƒ (),
  $hasNormal: true,
  $key: undefined,
  $stable: false
}

接着看_t函数:

// name1为default, 其他为undefined
function renderSlot (
    name,
    fallback,
    props,
    bindObject
  ) {
    var scopedSlotFn = this.$scopedSlots[name];
    var nodes;
    if (scopedSlotFn) { // scoped slot
      props = props || {};
      if (bindObject) {
        if (!isObject(bindObject)) {
          warn(
            'slot v-bind without argument expects an Object',
            this
          );
        }
        props = extend(extend({}, bindObject), props);
      }
      nodes = scopedSlotFn(props) || fallback;
    } else {
        // 从this.$slots中获取default值,是app-layout组件的text虚拟节点内容[VNode]
        nodes = this.$slots[name] || fallback;
    }

    var target = props && props.slot;
    if (target) {
      return this.$createElement('template', { slot: target }, nodes)
    } else {
      return nodes
    }
}

接着将子组件内容挂载到父级,然后挂载到body上,最后再删除初始标签内容。具名插槽同理,只是将default改成对应的名字xx。

作用域插槽slot-scope

父子组件分别以

<div id="app"><app-layout :items="items"><template slot-scope="list"><div>{{ list.data }}</div></template></app-layout></div>

<div><slot v-for="(item,index) in items" :data="item.text"></slot></div>`

为例。 在模板解析template尾标签时,执行closeElement会对template模板组件会作如下处理:

function processSlotContent (el) {
    if (el.tag === 'template') {
      slotScope = getAndRemoveAttr(el, 'scope');
      /* istanbul ignore if */
      if (slotScope) {
        warn$2(
          "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 = 'list'
      el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope');
    } 
}

此时会在element元素上挂载: { slotScope: "list" } 接着closeElement函数继续处理:

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;

生成:

{
    scopedSlots: {
        "default": {
            attrsList: []
            attrsMap: {slot-scope: "list"},
            children: [{…}],
            end: 106,
            parent: {type: 1, tag: "app-layout", attrsList: Array(1), attrsMap: {…}, rawAttrsMap: {…}, …},
            plain: false,
            rawAttrsMap: {
                slot-scope: {
                    end: 68,
                    name: "slot-scope",
                    start: 51,
                    value: "list"
                }
            },
            slotScope: "list",
            start: 41,
            tag: "template",
            type: 1
        }
    }
}

生成代码过程中经过genScopedSlots, genScopedSlot处理

function genScopedSlot (
    el,
    state
  ) {
    var 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)
    }
    var slotScope = el.slotScope === emptySlotScopeToken
      ? ""
      : String(el.slotScope);
    var 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.$slots
    var reverseProxy = slotScope ? "" : ",proxy:true";
    return ("{key:" + (el.slotTarget || "\"default\"") + ",fn:" + fn + reverseProxy + "}")
  }

生成

"{
  attrs:{"items":items},
  scopedSlots:_u([{
    key:"default",
    fn:function(list){
      return [_c('div',[_v(_s(list.data))])]}}]),"

完整的render函数为:

with(this){
    return _c('div',
    {
        attrs:{"id":"app"}
    },
    [
        _c('app-layout',{
            attrs:{"items":items},
            scopedSlots:_u([{key:"default", fn:function(list){
                return [_c('div',[_v(_s(list.data))])]
            }}])
        })
    ] ,1)
}

其中_u函数表示

function resolveScopedSlots (
    fns, // see flow/vnode
    res,
    // the following are added in 2.6
    hasDynamicKeys,
    contentHashKey
  ) {
    res = res || { $stable: !hasDynamicKeys };
    for (var i = 0; i < fns.length; i++) {
      var slot = fns[i];
      if (Array.isArray(slot)) {
        resolveScopedSlots(slot, res, hasDynamicKeys);
      } else if (slot) {
        // marker for reverse proxying v-slot without scope on this.$slots
        if (slot.proxy) {
          slot.fn.proxy = true;
        }
        res[slot.key] = slot.fn;
      }
    }
    if (contentHashKey) {
      (res).$key = contentHashKey;
    }
    return res
}

fns为[{fn: ƒ (list), key: "default"}],该函数返回

{
    $stable: true,
    default: ƒ (list)
}

app-layout生成的VNode为:

{
    asyncFactory: undefined,
    asyncMeta: undefined,
    children: undefined,
    componentInstance: undefined,
    componentOptions: {
        Ctor: ƒ VueComponent(options),
        children: undefined,
        listeners: undefined,
        propsData: {items: Array(1)},
        tag: "app-layout"
    },
    context: Vue {_uid: 0, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: Vue, …},
    data: {
        attrs: {}, 
        scopedSlots: {
            $stable: true,
            default: ƒ (list)
        }, 
        on: undefined, hook: {init: ƒ, prepatch: ƒ, insert: ƒ, destroy: ƒ}
    },
    elm: undefined,
    fnContext: undefined,
    fnOptions: undefined,
    fnScopeId: undefined,
    isAsyncPlaceholder: false,
    isCloned: false,
    isComment: false,
    isOnce: false,
    isRootInsert: true,
    isStatic: false,
    key: undefined,
    ns: undefined,
    parent: undefined,
    raw: false,
    tag: "vue-component-1-app-layout",
    text: undefined,
    child: undefined
}

layout子组件的render函数为:

with(this){
    return _c(
        'div',
        [
            _l((items),function(item, index){
                return _t("default", null,{"data": item.text})
            })
        ],
    2)
}

_l函数为:

function renderList (
    val,
    render
  ) {
    var ret, i, l, keys, key;
    if (Array.isArray(val) || typeof val === 'string') {
      ret = new Array(val.length);
      for (i = 0, l = val.length; i < l; i++) {
        ret[i] = render(val[i], i);
      }
    } else if (typeof val === 'number') {
      ret = new Array(val);
      for (i = 0; i < val; i++) {
        ret[i] = render(i + 1, i);
      }
    } else if (isObject(val)) {
      if (hasSymbol && val[Symbol.iterator]) {
        ret = [];
        var iterator = val[Symbol.iterator]();
        var result = iterator.next();
        while (!result.done) {
          ret.push(render(result.value, ret.length));
          result = iterator.next();
        }
      } else {
        keys = Object.keys(val);
        ret = new Array(keys.length);
        for (i = 0, l = keys.length; i < l; i++) {
          key = keys[i];
          ret[i] = render(val[key], key, i);
        }
      }
    }
    if (!isDef(ret)) {
      ret = [];
    }
    (ret)._isVList = true;
    return ret
}

对数组的每一个元素,执行:

return _t("default", null,{"data": item.text})

此过程会触发getter,添加依赖,_t函数为:

// name为default,props为{data: 'text1'}
function renderSlot (
    name,
    fallback,
    props,
    bindObject
  ) {
      // f函数
    var scopedSlotFn = this.$scopedSlots[name];
    var nodes;
    if (scopedSlotFn) { // scoped slot
      props = props || {};
      if (bindObject) {
        if (!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;
    }

    var target = props && props.slot;
    if (target) {
      return this.$createElement('template', { slot: target }, nodes)
    } else {
      return nodes
    }
}

接着将生成的VNode生成元素节点,并将子组件内容挂载到父级,然后挂载到body上,最后再删除初始标签内容。 现在我们来对比一下普通插槽和作用域插槽的区别:// 普通插槽

slots: {
  xxoo: h('div')
}
// 作用域插槽
scopedSlots: {
  xxoo: (scopedData) => h('div', scopedData.a)
}

作用域插槽和普通插槽的区别在于,子组件拿到它的时候它还是一个函数,只有你执行该函数,它才会返回要渲染的内容(即vnode),所以可以统一为:

// 普通插槽
slots: {
  xxoo: () => h('div')
}

这个也是Vue2.6.*中的改变之处。

v-slot原理

父子组件分别以

<div id="app"><app-layout :items="items"><template v-slot:default="list"><div>{{ list.data }}</div></template></app-layout></div>

<div><slot v-for="(item,index) in items" :data="item.text"></slot></div>`

为例。 父组件在模板解析阶段,编译闭合标签template时,closeElement函数中会执行processSlotContent,如下:

// 2.6 v-slot syntax
    {
      if (el.tag === 'template') {
        // v-slot on <template>
        // 作用在template上,slotRE = /^v-slot(:|$)|^#/
        var slotBinding = getAndRemoveAttrByRegex(el, slotRE);
        if (slotBinding) {
          var ref = getSlotName(slotBinding);
          var name = ref.name;
          var dynamic = ref.dynamic;
          el.slotTarget = name;
          el.slotTargetDynamic = dynamic;
          el.slotScope = slotBinding.value || emptySlotScopeToken; // force it into a scoped slot for perf
        }
      } else {
        // v-slot on component, denotes default slot
        var slotBinding$1 = getAndRemoveAttrByRegex(el, slotRE);
        if (slotBinding$1) {
          // add the component's children to its default slot
          var slots = el.scopedSlots || (el.scopedSlots = {});
          var ref$1 = getSlotName(slotBinding$1);
          var name$1 = ref$1.name;
          var dynamic$1 = ref$1.dynamic;
          var slotContainer = slots[name$1] = createASTElement('template', [], el);
          slotContainer.slotTarget = name$1;
          slotContainer.slotTargetDynamic = dynamic$1;
          slotContainer.children = el.children.filter(function (c) {
            if (!c.slotScope) {
              c.parent = slotContainer;
              return true
            }
          });
          slotContainer.slotScope = slotBinding$1.value || emptySlotScopeToken;
          // remove children as they are returned from scopedSlots now
          el.children = [];
          // mark el non-plain so data gets generated
          el.plain = false;
        }
    }
}

该函数在element元素中添加属性:

{
    ...
    slotScope: "list",
    slotTarget: ""default"",
    slotTargetDynamic: false,
    ...
}

然后在app-layout组件的closeElement函数中增加属性:

...
scopedSlots: {"default": {
    attrsList: [],
    attrsMap: {v-slot:default: "list"},
    children: [{
        attrsList: [],
        attrsMap: {},
        children: [{…}],
        end: 99,
        parent: {type: 1, tag: "template", attrsList: Array(0), attrsMap: {…}, rawAttrsMap: {…}, …},
        plain: true,
        rawAttrsMap: {},
        start: 73,
        tag: "div",
        type: 1}],
    end: 110,
    parent: {type: 1, tag: "app-layout", attrsList: Array(1), attrsMap: {…}, rawAttrsMap: {…}, …},
    plain: false,
    rawAttrsMap: {v-slot:default: {
        end: 72,
        name: "v-slot:default",
        start: 51,
        value: "list"
    }},
    slotScope: "list",
    slotTarget: ""default"",
    slotTargetDynamic: false,
    start: 41,
    tag: "template",
    type: 1
}}
...

接着在生成代码阶段,genElement中的genData$2函数会处理上面生成的scopedSlots属性值:

if (el.scopedSlots) {
    data += (genScopedSlots(el, el.scopedSlots, state)) + ",";
}

该函数具体为

function genScopedSlots (
    el,
    slots,
    state
  ) {
    var generatedSlots = Object.keys(slots)
      .map(function (key) { return genScopedSlot(slots[key], state); })
      .join(',');

    return ("scopedSlots:_u([" + generatedSlots + "]" + (needsForceUpdate ? ",null,true" : "") + (!needsForceUpdate && needsKey ? (",null,false," + (hash(generatedSlots))) : "") + ")")
}

genScopedSlot函数的作用是生成如下代码:

"{key:"default",fn:function(list){return [_c('div',[_v(_s(list.data))])]}}"

返回了

"{
    attrs:
    {
        "items":items
    },
    scopedSlots:_u([{
        key:"default",
        fn:function(list){
            return [_c('div',[_v(_s(list.data))])]
        }
    }]),"

生成完整的render函数为:

with(this){
    return _c('div',
    {attrs:{"id":"app"}},
    [_c('app-layout',{
        attrs:{"items":items},
        scopedSlots:_u([{
            key:"default",
            fn:function(list){return [_c('div',[_v(_s(list.data))])]}}])
        })
    ],1)
}

生成VNode时,_u即resolveScopedSlots函数处理

function resolveScopedSlots (
    fns, // see flow/vnode
    res,
    // the following are added in 2.6
    hasDynamicKeys,
    contentHashKey
  ) {
    res = res || { $stable: !hasDynamicKeys };
    for (var i = 0; i < fns.length; i++) {
      var slot = fns[i];
      if (Array.isArray(slot)) {
        resolveScopedSlots(slot, res, hasDynamicKeys);
      } else if (slot) {
        // marker for reverse proxying v-slot without scope on this.$slots
        if (slot.proxy) {
          slot.fn.proxy = true;
        }
        res[slot.key] = slot.fn;
      }
    }
    if (contentHashKey) {
      (res).$key = contentHashKey;
    }
    return res
}

其中函数参数fns=[{key: "default", fn: ƒ}]res={$stable: true}。返回值为 {$stable: true, default: ƒ (list)} 生成虚拟DOM节点的函数vnode = createComponent(Ctor, data, context, children, tag),其中data为

{
    attrs: {},
    hook: {init: ƒ, prepatch: ƒ, insert: ƒ, destroy: ƒ},
    on: undefined,
    scopedSlots: {
        $stable: true,
        default: ƒ (list)
    }
}

app-layout的VNode为:

{
    syncFactory: undefined,
    asyncMeta: undefined,
    children: undefined,
    componentInstance: undefined,
    componentOptions: {
        Ctor: ƒ VueComponent(options)
        children: undefined,
        listeners: undefined,
        propsData: {},
        tag: "app-layout",
    }
    context: Vue {_uid: 0, _isVue: true, $options: {…}, _renderProxy: Proxy, _self: Vue, …},
    data: {attrs: {}, scopedSlots: {
        $stable: true,
        default: ƒ (list)
    }, on: undefined, hook: {…}},
    elm: undefined,
    fnContext: undefined,
    fnOptions: undefined,
    fnScopeId: undefined,
    isAsyncPlaceholder: false,
    isCloned: false,
    isComment: false,
    isOnce: false,
    isRootInsert: true,
    isStatic: false,
    key: undefined,
    ns: undefined,
    parent: undefined,
    raw: false,
    tag: "vue-component-1-app-layout",
    text: undefined,
    child: undefined
}

子组件与slot-scope一致的render函数为:

with(this){return _c('div',[
    _l((items),function(item,index){
        return _t("default",null,{"data":item.text})
    })],2)
}

同样的经过_t函数处理

function renderSlot (
    name,
    fallback,
    props,
    bindObject
  ) {
    var scopedSlotFn = this.$scopedSlots[name];
    var nodes;
    if (scopedSlotFn) { // scoped slot
      props = props || {};
      if (bindObject) {
        if (!isObject(bindObject)) {
          warn(
            'slot v-bind without argument expects an Object',
            this
          );
        }
        props = extend(extend({}, bindObject), props);
      }
      nodes = scopedSlotFn(props) || fallback;
    } else {
      nodes = this.$slots[name] || fallback;
    }

    var target = props && props.slot;
    if (target) {
      return this.$createElement('template', { slot: target }, nodes)
    } else {
      return nodes
    }
}

this.$scopedSlots为:

{
    default: ƒ (),
    $hasNormal: false,
    $key: undefined,
    $stable: true
}

props为{data: "text1"},返回的nodes为子组件的[VNode],最后挂载子组件到父组件,并将父级组件挂载到body页面,再删除老的元素节点。此处理过程与slot-scope一致。 匿名插槽模板

<div id="app"><app-layout v-slot:default>另一个主要段落</app-layout></div>

会被编译为:

"_c('app-layout',{
    scopedSlots:_u([{key:"default",fn:function(){return [_v("另一个主要段落")]
    },proxy:true}])
    })"

其他处理过程一致。

总结

在Vue2.5.*之前,如果是普通插槽就直接访问的是VNode,而如果是作用域插槽,由于子组件需要在父组件访问子组件的数据,所以父组件下是一个未执行的函数(slotScope) => return h('div',slotScope.msg),接受子组件的slotProps参数,在子组件渲染实例时会调用该函数传入数据。在2.6之后,两者合并,普通插槽也变成一个函数,只是不接受参数了。