前言

本篇文章slots的知识点进行Vue的源码逻辑梳理,旨在理解slots背后的实现逻辑。实际上本篇文章梳理slots相关的逻辑主要是2点:

  • 构建成render函数的过程中slot是如何处理的?
  • slot插槽内容填充逻辑
  • 作用域slot的特殊处理

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的处理逻辑如下:

parseStartTag -> handleStartTag -> options.start ->processElement

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

  • processKey():标签中存在key属性
  • processRef():标签中存在ref属性
  • processSlot():处理标签是slot
  • processComponent():处理标签是组件的

对于<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',
            ...
        }
    ]
}

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

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

generate -> genElement -> genData$2 -> 处理各种情况的标签,其中就包括slot标签,调用genSlot函数

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,这里就在汇总下:

_c:是指createElement函数,即创建元素

_s:toString函数

_v:createTextVnode函数,创建文本节点

_t:renderSlot函数,关于slot的,具体的逻辑下面会讲

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

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

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

slot插槽内容填充

在render函数被调用时,对于插槽slot实际上这里就是调用_t函数即renderSlot函数,该函数的主要逻辑如下(slot相关的):

function renderSlot (name, fallback, props, bindObject) {
  var slotNodes = this.$slots[name];
  if (slotNodes) {
    slotNodes._rendered = true;
  }
  
  ...
  
  return slotNodes || fallback;
}

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

renderSlot函数中关于slot的处理会调用Vue实例的$slots,而该属性是slot的注册中心

从上面逻辑就可以知道,插槽内容的替换来源于$slots。$slots内容是什么时候注册的呢?全局搜索Vue源码发现对$slots属性进行赋值的操作不过就3处地方,列举如下:

  • initRender:创建Vue实例时调用
  • FunctionalRenderContext:函数式组件的处理逻辑,以当前实例来说基本可以排除
  • updateChildComponent:更新子组件,patch阶段调用

依据本文的实例debugger,发现$slots就是在element-block自定义组件初始化过程中处理的,即initRender函数中处理。而initRender函数中处理$slots逻辑如下:

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
}


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

从上面代码逻辑实际上可以看出$slots中注册的就是_renderChildren数组的内容,这是slot内容的来源。

options._renderChildren

这实际上涉及到Vue实例创建的处理,在Vue内部实际上对于options是分为两种情况:

if (options && options._isComponent) {
	// optimize internal component instantiation
    // since dynamic options merging is pretty slow, and none of the
    // internal component options needs special treatment.
    initInternalComponent(vm, options);
} else {
   vm.$options = mergeOptions(
   	resolveConstructorOptions(vm.constructor),
    options || {},
    vm
   );
}

通过_isComponent区分了两种场景,而_isComponent就有调用createComponentInstanceForVnode函数才会被创建,而该函数在虚拟DOM的init生命周期被调用。

VNode的生命周期函数都是在patch阶段被调用,实际上所有组件实例都是通过createComponentInstanceForVnode来创建(除了根组件的)

而_renderChildren实际上就是对应VNode的children的内容,而children就是template解析成render函数时确认的。

作用域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'
        		}
            ]
        }
    ]
}

由这个结构来看genElement的构建过程就比较具体了,之后就是生成render函数的过程。就实例而言,这边作用域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函数构成,可以看出作用域slot是调用_u函数即调用resolveScopedSlots函数来处理,而该函数的具体处理逻辑如下:

function installRenderHelpers (target) {
  target._u = 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) {
        // 相关处理
    }
}

通过_u函数的处理此时scopedSlots就是对象,其内容是在哪里调用的呢?

以实例来说,element-block组件自身的template会被解析成render函数,而scopedSlots的内容就是在其render函数执行时调用

以element-block组件为例,其render函数被构建成:

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

其render函数中存在_t函数,就是执行renderSlot中的逻辑了,具体逻辑如下:

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的内容填充来源于$scopedSlots。在组件初始化过程中(即initRender中),$scopedSlots就是一个冻结的空对象,而真正对其赋值的操作是在对应的组件render函数执行时进行的,关键逻辑如下:

Vue.prototype._render = function () {
    // 其他处理
    
    if (_parentVnode) {
      vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject;
    }
}

总结

本篇文章主要梳理了普通slot和作用域slot背后的处理逻辑,存在插槽的组件背后执行逻辑总结如下:

  • initRender -> $slots定义 -> 解析构建生成render函数 -> 视图渲染执行render实例方法 -> template对应的render函数执行 -> renderSlot执行 -> 调用$slots -> 生成vnodes
  • initRender -> $scopedSlots定义此时还是空对象 -> 解析构建生成render函数 -> 视图渲染执行render实例方法 -> $scopedSlots赋值 -> template对应的render函数执行 -> renderSlot执行 -> 调用$scopedSlots ->生成vnodes

实际上通过上面两者整个流程,就可以知道$scopedSlots与$slots不同的处理逻辑,主要有如下几点:

普通slot

  • 使用slot的组件的父组件的render不需要特殊的构建
  • 承载name属性的标签下的所有子标签都是直接解析的
  • 在组件初始化时机initRender时就会向$slots中注册相关内容,用于之后的内容填充

作用域slot

  • 使用作用域slot的组件需要额外的slot-scope属性传递数据所以存在特殊的构建内容scopedSlots
  • 其render函数体中相关使用_u来处理承载slot-scope属性的标签
  • 承载slot-scope属性的标签下的所有子标签都是间接解析的,即实际上通过构建了一层函数来处理的
  • 在组件初始化时机initRender时其$scopedSlots属性为空对象,在组件render函数执行前才会进行赋值
Logo

前往低代码交流专区

更多推荐