theydy / notebook

记录读书笔记 + 知识整理,vuepress 迁移中 https://theydy.github.io/notebook/
0 stars 0 forks source link

slot 原理 #34

Open theydy opened 3 years ago

theydy commented 3 years ago

普通插槽

基础用法

var A = {
  template: `<div class="A"><slot /></div>`
}
var vm = new Vue({
  el: '#app',
  components: {
    A
  },
  template: `<div id="app"><A>普通插槽</A></div>`
})

// 最终渲染结果
<div class="A">普通插槽</div>

父组件处理

首先 vm new Vue 根实例后,进入 vm 的挂载阶段,首先需要执行 render 函数生成 vnode,然后 patch 生成真实 DOM,此时的 render 函数如下:

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c("div", { attrs: { id: "app" } }, [_c("A", [_vm._v("普通插槽")])], 1)
}

可以看到 vm 最后 _c 执行前需要先执行 childern 的 vnode 生成(其实应该是 A 组件的占位 vnode) _c("A", [_vm._v("普通插槽")])_c_h 函数都是对于 createElement 函数的封装,在 initRender 中都通过闭包保存了当前的 vm 实例做为上下文。

function initRender (vm) {
    // ...
    vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };
    // normalization is always applied for the public version, used in
    // user-written render functions.
    vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };

    // ...
  }

接着进入 A 组件占位 vnode 生成流程,在 createElement 函数中,vnode = createComponent(Ctor, data, context, children, tag);

function createComponent (
    Ctor, // A options
    data, // undefined
    context, // vm 实例
    children,  // [{text: "普通插槽"}]
    tag // 'A'
  ) {
  // ...
  // 这里生成 A 组件的构造函数
  // ...

  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
  );

  return vnode
}

这里暂时只需要知到传入的 Ctor、propsData 这些都放在 componentOptions 即可。

{
  // ...
  componentOptions: {
    Ctor,  // 构造器
    children,  // children
    listeners, // 监听器
    propsData, // props 数据
    tag, // 占位符
  },
  context, // 父组件实例
  // ...
}

到此为止,父组件的处理完成。

子组件处理

在父组件 patch 的过程中,碰到子组件占位符 vnode,createComponent → init 钩子

var child = vnode.componentInstance = createComponentInstanceForVnode(
  vnode,
  activeInstance
);
child.$mount(hydrating ? vnode.elm : undefined, hydrating);

进入子组件初始化、挂载阶段。

_init 函数中,因为是子组件,所以会执行 initInternalComponent 方法,拿到父组件拥有的相关配置信息,并赋值给子组件自身的配置选项。

    Vue.prototype._init = function (options) {
      var vm = this;

      // merge options
      if (options && options._isComponent) {
        initInternalComponent(vm, options);
      } 
      // ... 
      initLifecycle(vm);
      initEvents(vm);
      initRender(vm);
      callHook(vm, 'beforeCreate');
      initInjections(vm); // resolve injections before data/props
      initState(vm);
      initProvide(vm); // resolve provide after data/props
      callHook(vm, 'created');

      if (vm.$options.el) {
        vm.$mount(vm.$options.el);
      }
    };

  function initInternalComponent (vm, options) {
    var opts = vm.$options = Object.create(vm.constructor.options);
    // doing this because it's faster than dynamic enumeration.
    var parentVnode = options._parentVnode;
    opts.parent = options.parent;
    opts._parentVnode = parentVnode;

    var vnodeComponentOptions = parentVnode.componentOptions;
    opts.propsData = vnodeComponentOptions.propsData;
    opts._parentListeners = vnodeComponentOptions.listeners;
    opts._renderChildren = vnodeComponentOptions.children;
    opts._componentTag = vnodeComponentOptions.tag;

    if (options.render) {
      opts.render = options.render;
      opts.staticRenderFns = options.staticRenderFns;
    }
  }

可以看到,最终占位 vnode 上的 componentOptions 都被放进子组件实例的 $options 中了。

children 放在 opts._renderChildren 上。

继续 _init 函数的流程,进入 initRender 方法,在这个过程中会将配置的 _renderChildren 属性做规范化处理,并将它赋值给子组件实例上的 $slot 属性。

  function initRender (vm) {
    // ...
    var options = vm.$options;
    // ...
    // 这里的 renderContext 是父组件实例
    var renderContext = parentVnode && parentVnode.context;
    vm.$slots = resolveSlots(options._renderChildren, renderContext);
    // ...
  }

  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 {
        /**
         * 默认直接放进 default 中,$slot.default
         */
        (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
  }

随后继续子组件的挂载流程,先生成 render 函数,这里会对 slot 标签做处理,使用 _t 函数包裹。

// slot render 部分处理函数
  function genSlot (el, state) {
    var slotName = el.slotName || '"default"';
    var children = genChildren(el, state);
    var res = "_t(" + slotName + (children ? ("," + children) : '');
    var attrs = el.attrs || el.dynamicAttrs
      ? genProps((el.attrs || []).concat(el.dynamicAttrs || []).map(function (attr) { return ({
          // slot props are camelized
          name: camelize(attr.name),
          value: attr.value,
          dynamic: attr.dynamic
        }); }))
      : null;
    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 + ')'
  }

// 最终 render 函数。
var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c("div", { staticClass: "A" }, [_vm._t("default")], 2)
}

_t 其实是 renderSlot 函数的缩写,对于普通插槽,renderSlot 其实就是通过 default 取到 vnode 返回即可。

  function renderSlot (
    name,
    fallback, // 插槽默认内容
    props,
    bindObject
  ) {
      // ...
      let nodes = this.$slots[name] || fallback;
      return nodes
    }
  }

然后就是正常的子组件 patch 过程。

普通插槽使用默认内容

var A = {
  template: `<div class="A"><slot>普通插槽默认内容</slot></div>`
}
var vm = new Vue({
  el: '#app',
  components: {
    A
  },
  template: `<div id="app"><A /></div>`
})

// 最终渲染结果
<div class="A">普通插槽默认内容</div>

区别在与子组件的 render 函数现在变成这样

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c(
    "div",
    { staticClass: "A" },
    [_vm._t("default", [_vm._v("普通插槽默认内容")])],
    2
  )
}

多了 [_vm._v("普通插槽默认内容")] 这段。对应在 renderSlot 的第二个参数就是 fallback

如果插槽中使用了父组件的响应式属性

var A = {
  template: `<div class="A"><slot /></div>`
}
var vm = new Vue({
  el: '#app',
  components: {
    A
  },
  data () {
    return { message: 'hello world' }
  } 
  template: `<div id="app"><A>{{ message }}</A></div>`
})

// 最终渲染结果
<div class="A">hello world</div>

此时 vm 的构造函数是这样:

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c(
    "div",
    { attrs: { id: "app" } },
    [_c("A", [_vm._v(_vm._s(_vm.message))])],
    1
  )
}

可以看到 [_vm._v(_vm._s(_vm.message))] 这里通过 _vm.message 取到了父组件的属性值。

父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。

父组件模板的内容在父组件编译阶段就确定了,并且保存在 componentOptions 属性中,而子组件有自身初始化 init 的过程,这个过程同样会进行子作用域的模板编译,因此两部分内容是相对独立的。

theydy commented 3 years ago

具名插槽

基础用法

var B = {
  template: `<div class="B"><slot name="b-slot">BBB</slot></div>`
}
var vm = new Vue({
  el: '#app',
  components: {
    B
  },
  data () {
    return { message: 'hello world' }
  } 
  template: `<div id="app"><B><template #b-slot>{{ message }}</template></B></div>`
})

// 最终渲染结果
<div class="B">hello world</div>

父组件处理的不同

此时 vm 构造函数如下,可以看到具名插槽分发的内容不是做为 vnode 的 children,而是放在 vnode 的 data.scopedSlots 属性下,具体的内容通过一个 fn 函数返回。

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c(
    "div",
    { attrs: { id: "app" } },
    [
      _c("B", {
        scopedSlots: _vm._u([
          {
            key: "b-slot",
            fn: function() {
              return [_vm._v(_vm._s(_vm.message))]
            },
            proxy: true
          }
        ])
      })
    ],
    1
  )
}

通过 _u 函数即 resolveScopedSlots 处理后,返回的 scopedSlots 格式如下:

{
  scopedSlots: {
    $stable: true
    'b-slot': function() {
      return [_vm._v(_vm._s(_vm.message))]
    }
  }
}

最后在子组件占位 vnode 中 data.scopedSlots 就是这样,这里和普通插槽不同的是,普通插槽父组件分发的内容最后存在 vnode.componentOptions.children 下,而具名插槽是放在 vnode.data.scopedSlots

子组件处理的不同

vnode 上的 scopedSlots 最后经过处理会保存在子组件实例的 $scopedSlots 上。

子组件的渲染函数:

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c(
    "div",
    { staticClass: "B" },
    [_vm._t("b-slot", [_vm._v("BBBBBB")])],
    2
  )
}

_trenderSlotscopedSlotFn = this.$scopedSlots[name]; 通过 b-slot 这个 key 找到对应父组件分发的插槽函数,最后返回的 nodes 就是函数的返回值即 [_vm._v(_vm._s(_vm.message))]

  function renderSlot (
    name,
    fallback, // slot 的默认内容
    props,
    bindObject
  ) {
    // ... 
    var scopedSlotFn = this.$scopedSlots[name];
    var nodes;
    if (scopedSlotFn) { // scoped slot
      // ...
      nodes = scopedSlotFn(props) || fallback;
    }
      // ...
    return nodes
  }

后续和普通插槽 patch 相同。

theydy commented 3 years ago

作用域插槽

基础用法

var C = {
  template: `<div class="C"><slot name="msg" :message="message" /></div>`
  data () {
    return {
      message: 'CCCC'
    };
  }
}
var vm = new Vue({
  el: '#app',
  components: {
    C
  },
  data () {
    return { message: 'hello world' }
  } 
  template: `<div id="app"><C><template #msg="slotProps">{{ slotProps.message }}</template></C></div>`
})

// 最终渲染结果
<div class="C">CCCC</div>

作用域插槽在 2.6 后和具名插槽原理类似。

父组件处理

vm render 函数如下,可以看到和具名插槽差不多,只是在 fn 函数多了个参数。

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c(
    "div",
    { attrs: { id: "app" } },
    [
      _c("C", {
        scopedSlots: _vm._u([
          {
            key: "msg",
            fn: function(slotProps) {
              return [_vm._v(_vm._s(slotProps.message))]
            }
          }
        ])
      })
    ],
    1
  )
}

子组件处理

子组件的 render 函数如下,可以看到 _trenderSlot 多了第三个参数 props,里面记录的就是我们在子组件中传入的数据。

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c(
    "div",
    { staticClass: "C" },
    [_vm._t("msg", null, { message: _vm.message })],
    2
  )
}

接着进入 renderSlot ,前面贴的都是简化后的 renderSlot 逻辑,现在来看下完整的 renderSlot 函数代码。

  function renderSlot (
    name,
    fallback, // slot 的默认内容
    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
    }
  }

首先通过 render 函数可以知道此时 name 的值是 msgfallbacknull 因为我们没有默认插槽内容,props 是 { message: _vm.message }bindObjectundefined

最重要的就是这两句。

var scopedSlotFn = this.$scopedSlots[name];
nodes = scopedSlotFn(props)

把 props 做为参数传给 scopedSlotFn,所以父组件中可以访问子组件的作用域。

theydy commented 3 years ago

2.6 后其实可以不看 $slots 只看 $scopedSlots。

所有的 $slots 现在都会作为函数暴露在 $scopedSlots 中。如果你在使用渲染函数,不论当前插槽是否带有作用域,我们都推荐始终通过 $scopedSlots 访问它们。这不仅仅使得在未来添加作用域变得简单,也可以让你最终轻松迁移到所有插槽都是函数的 Vue 3。