前言

本篇文章slots的知识点进行Vue的源码逻辑梳理,旨在理解slots背后的实现逻辑。

实际上本篇文章梳理slots相关的逻辑主要是2点:

Slot具体逻辑梳理

结合简单实例来梳理slots相关知识,实例如下:

  <div id="app">
    <element-block>测试</element-block>
  </div>

  <script>
    Vue.component('element-block', {
      template: '<div>Vue之slot <slot></slot></div>'
    });
    const vm = new Vue({
      el: '#app'
    });
  </script>

上面的简单实例涉及到两个组件:根组件和已定义组件element-block。

构建render时slot的处理

实际上这部分是在 Vue解析构建Render函数 文章中对解析流程中对于标签和文本有了一定的介绍,实际上这边处理的情况还有注释、标签结束等相关的处理都没有介绍到(之后会对这篇文章进行重写),这里就先看看这个步骤中对<slot>的处理吧。

实际上对于<slot>也是按照标签的处理方式,从上面文章提及的逻辑中实际上这边逻辑如下:

而在processElement函数中实际上是对几种特殊指令进行特殊处理,具体指令如下:

对于<slot>标签还是按照普通标签来解析,只是当解析到是标签就需要调用processElement,而在该函数中就需要对slot进行特殊的处理,具体的处理逻辑如下(这里只看slot的处理):

if (el.tag === 'slot') {
  // 获取slot中name属性值
  el.slotName = getBindingAttr(el, 'name');
}

整个解析完成完成之后element-block组件对应的ast结构如下:

{
    type: 1,
    tag: "div",
    plain: true,
    attrsList: [],
    attrsMap: {},
    parent: undefined,
    children:[
        {
            type: 3,
            text: 'Vue之slot '
        },
        {
            type: 1,
            tag: 'slot',
            ...
        }
    ]
}

可知,slot标签在解析的时候是按照标签的形式来解析的。而构建成render函数slot与普通的标签是存在区别的,而这个区别就是本文中第二个问题的源头。

稍微提及下构建render函数中构建函数体的处理generate函数的逻辑,逻辑脉络如下:

genSlot函数相关逻辑如下:

function genSlot (el, state) {
  // 默认名称的由来
  var slotName = el.slotName || '"default"';
  // 循环处理slot下的子组件
  var children = genChildren(el, state);
  // 注意这里的_t,这是核心
  var res = "_t(" + slotName + (children ? ("," + children) : '');
  // 其他属性相关的处理
  var attrs = el.attrs && ("{" + (el.attrs.map(function (a) { return ((camelize(a.name)) + ":" + (a.value)); }).join(',')) + "}");
  var bind$$1 = el.attrsMap['v-bind'];
  if ((attrs || bind$$1) && !children) {
    res += ",null";
  }
  if (attrs) {
    res += "," + attrs;
  }
  if (bind$$1) {
    res += (attrs ? '' : ',null') + "," + bind$$1;
  }
  return res + ')'
}

实际上需要关注的点是_t,在之前的文章涉及到了_c、_s、_v,这里就在汇总下:

而实例element-block自定义组件构建成的render函数的函数体如下:

with(this){return _c('div',[_v("Vue之slot "),_t("default")],2)}

_t是renderSlot函数,即本文第二点的具体逻辑了。

slot插槽数据合并

当render函数被解析创建出来之后,这边的逻辑就是在render函数被调用时触发,实际上这里也就是调用_t函数即renderSlot函数。该函数的主要逻辑如下(slot相关的):

function renderSlot (name, fallback, props, bindObject) {
  var slotNodes = this.$slots[name];
  if (slotNodes) {
    slotNodes._rendered = true;
  }
  // 其他作用域slot的逻辑暂不提及
  return slotNodes || fallback;
}

从上面逻辑中,可以得到关键信息:

那此时就需要确认slots中保存的slot相关数据了。全局搜索Vue源码发现对$slots属性进行赋值的操作不过就3处地方,列举如下:

这里需要排除下到底是触发哪个函数对slotsdebuggerslots就是在element-block自定义组件初始化过程中处理的,即initRender函数中处理。

而initRender函数中处理$slots逻辑如下:

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

这里需要注意的是renderContext是父VNode对象的context即Vue实例。

function resolveSlots (children, context) {
  var slots = {};
  if (!children) return slots
  for (var i = 0, l = children.length; i < l; i++) {
    var child = children[i];
    var data = child.data;
    // 带有名称的slot,data以及data.attrs都会存在slot属性
    if (data && data.attrs && data.attrs.slot) {
      delete data.attrs.slot;
    }
    // 处理带有名称的slot
    if ((child.context === context || child.fnContext === context) &&
      data && data.slot != null
    ) {
      var name = data.slot;
      var slot = (slots[name] || (slots[name] = []));
      // slot属性所在标签是template
      if (child.tag === 'template') {
        slot.push.apply(slot, child.children || []);
      } else {
        slot.push(child);
      }
    } else {
      // 默认slot
      (slots.default || (slots.default = [])).push(child);
    }
  }
  // 处理仅包含空格的vnode
  for (var name$1 in slots) {
    if (slots[name$1].every(isWhitespace)) {
      delete slots[name$1];
    }
  }
  return slots
}

从这边实际上从另一个角度更加深刻理解了官网下的这句话:

父组件模板的所有东西都会在父级作用域内编译;子组件模板的所有东西都会在子级作用域内编译。

上面这句话另一个解读就是:

正是element-block子组件的编译过程处理了和收集了当前Vue实例的$slots。通过_t函数(即renderSlot合并了插槽数据)。

作用域slot的特殊处理

父组件模板的所有东西都会在父级作用域内编译;子组件模板的所有东西都会在子级作用域内编译。而作用域slot就是将父组件数据传递到子组件中,以便子组件在编译时调用。

作用域slot整个流程梳理的实例如下:

<div id="app">
	<element-block>
        <span slot-scope="scope">{{scope.data}}</span>
    </element-block>
</div>

<script>
    Vue.component('element-block', {
      template: '<div><slot :data="text"></slot></div>',
      data() {
        return {
          text: 'scoped-slot'
        };
      }
    });
    const vm = new Vue({
      el: '#app'
    });
</script>

构建render函数作用域slot的处理

这边的主要逻辑还是跟普通的slot相同,主要的处理函数是processElement下processSlot,这里就具体看看processSlot针对作用域slot的处理逻辑,主要逻辑代码如下:

function processSlot (el) {
    var slotScope;
    // <template slot-scope>
    if (el.tag === 'template') {
      // 支持scope 和 slot-scope
      slotScope = getAndRemoveAttr(el, 'scope');
      el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope');
    } else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) {
      el.slotScope = slotScope;
    }
    // 判断是否存在slot元素, 这里Vue源码给了解释
    // 针对原生的shadow DOM保留slot属性, 但限于非范围的slot
    var slotTarget = getBindingAttr(el, 'slot');
    if (slotTarget) {
      el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget;
      if (el.tag !== 'template' && !el.slotScope) {
        addAttr(el, 'slot', slotTarget);
      }
    }
}

从上面主要逻辑可知,会存在slotScope属性,根据debugger发现slotScope的逻辑在parseHTML的start(看普通slot这边的提及)之后的处理逻辑也有涉及到,具体的逻辑是:

if (currentParent && !element.forbidden) {
    // 标签上存在v-if、v-else等指令的
    if (element.elseif || element.else) {
        processIfConditions(element, currentParent);
    } else if (element.slotScope) {
       	// 处理作用域slot
        currentParent.plain = false;
        // 默认和带名称的情况
        var name = element.slotTarget || '"default"';
        (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element;
    } else {
        currentParent.children.push(element);
        element.parent = currentParent;
    }
}

从上面可看出会在父元素对象上创建scopedSlots属性(slot-scope属性肯定在一个标签上,所以这里在父元素对象上注册scopedSlot属性)。

构建出了ast结构后,接下来的处理解释创建函数体了,这边的大体逻辑跟上面slot相同,这里需要注意的是逻辑是针对作用域slot的处理不同了:

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

可见作用域slot是调用genScopedSlots函数,而该函数的具体逻辑如下:

function genScopedSlots (slots, state) {
  // 构建scopedSlots: _u([...])的结构
  return ("scopedSlots:_u([" + (Object.keys(slots).map(function (key) {
      return genScopedSlot(key, slots[key], state)
    }).join(',')) + "])")
}

内部调用的genScopedSlot函数的具体处理如下:

function genScopedSlot (key, el, state) {
  if (el.for && !el.forProcessed) {
    return genForScopedSlot(key, el, state)
  }
  var fn = "function(" + (String(el.slotScope)) + "){" +
    "return " + (el.tag === 'template'
      ? el.if
        ? ((el.if) + "?" + (genChildren(el, state) || 'undefined') + ":undefined")
        : genChildren(el, state) || 'undefined'
      : genElement(el, state)) + "}";
  // {"key": "default", fn: function(scope) {return 函数体}}
  return ("{key:" + key + ",fn:" + fn + "}")
}

这里是比较重要的逻辑,这里会结合实例详细梳理的。首先通过上面的解析构建出来的ast的结构如下:

{
    type: 1,
    tag: "div",
    plain: false,
    parent: undefined,
    children:[
        {
            type: 1,
            tag: 'element-block',
            scopedSlots: {
                default: VNode对象
            },
            children: [
            	{
            		type: 1,
            		tag: 'span',
            		slotScope: 'scope'
        		}
            ]
        }
    ]
}

上面的ast就是实例解析出来的大概结构(还有其他属性这里就不具体了),由这个结构来看genElement的构建过程就比较具体了。

整个解析过程如下:

就实例而言,这边作用域slot会构建成:

{
    scopedSlots:_u([
        {
            key:"default",
            fn:function(scope){return _c('span',{},[_v(_s(scope.data))])}
        }
    ]),
}

那么构建出来的render函数的函数体就如下:

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

作用域slot插槽数据合并

在普通slot中,提及了是render函数执行时调用_t函数(renderSlot)来实现数据的合并,而$slots源在element-block组件initRender时被收集。

那么作用域slot的处理是不是有所区别呢?

作用域slot直接父组件render处理

主要的区别在于element-block所在的父组件的render处理。实际上主要点还是相同即render函数执行触发相关函数执行,只不过作用域slot是_u函数,通过源码可知该函数:

function installRenderHelpers (target) {
  target._u = resolveScopedSlots;
}

可见作用域slot是要调用resolveScopedSlots函数来处理,而该函数的具体处理逻辑如下:

function resolveScopedSlots (fns, res) {
  res = res || {};
  for (var i = 0; i < fns.length; i++) {
    if (Array.isArray(fns[i])) {
      resolveScopedSlots(fns[i], res);
    } else {
      // 收集作用域slot
      res[fns[i].key] = fns[i].fn;
    }
  }
  return res
}

通过_u函数的执行,实际上就是平铺所有的作用域slot构建成对象:

scopedSlots: {
    default: function(scope) {
        // 相关处理
    }
}
作用域slot自身的组件

在element-block初始化过程中实际上$scopedSlots就是一个冻结的空对象,而真正对其赋值的操作是在element-block对应的组件render函数执行时进行的,关键逻辑如下:

Vue.prototype._render = function () {
    // 其他处理

    if (_parentVnode) {
      vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject;
    }
}

而对应的element-block组件render的函数体实际与普通slot没有什么区别,都是调用_t函数,唯一的区别就是作用域slot会传参,就是子组件中调用的父组件的数据。_t函数就是renderSlot函数的执行,就看下该函数中scopedSlot相关处理逻辑:

function renderSlot (name, fallback, props, bindObject) {
  var scopedSlotFn = this.$scopedSlots[name];
  var nodes;
  // 作用域slot存在
  if (scopedSlotFn) {
    props = props || {};
    if (bindObject) {
      // bindObject应该是一个对象
      if ("development" !== 'production' && !isObject(bindObject)) {
        warn(
          'slot v-bind without argument expects an Object',
          this
        );
      }
      // 调用extend构建props, 实际上就是浅拷贝
      props = extend(extend({}, bindObject), props);
    }
    // 执行作用域slot构建的函数, 生成vnodes
    nodes = scopedSlotFn(props) || fallback;
  }
}

总结

本篇文章主要梳理了普通slot和作用域slot背后的处理逻辑,了解到:

  • initRender -> slots>render>render>renderSlot>slots -> 生成vnodes
  • initRender -> $scopedSlots -> 解析构建生成render函数 -> _render函数执行 -> scopedSlots>render>scopedSlots ->生成vnodes

实际上上面的主要流程是slot所在的组件的主要流程,就本文而言就是element-block组件自身初始化过程,scopedSlotsslots基本相同,不同的在于其父组件下对于两者的解析,不同点如下:

02-18 03:19