likes
comments
collection
share

vue中的v-slot(源码分析)

作者站长头像
站长
· 阅读数 30

高频面试题:vue中的v-slot

答案:v-shot产生的主要目的是,在组件的使用过程中可以让父组件有修改子组件内容的能力,就像在子组件里面放了个插槽,让父组件往插槽内塞入父组件中的楔子;并且,父组件在子组件中嵌入的楔子也可以访问子组件中的数据。v-slot的产生让组件的应用更加灵活。

一、具名插槽

// 在main.js文件中
let baseLayout = {
  template: `<div class="container">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>`,
  data() {
    return {
      url: ""
    };
  }
};

new Vue({
  el: "#app",
  template: `<base-layout>
    <template v-slot:header>
      <h1>title-txt</h1>
    </template>
    <p>paragraph-1-txt</p>
    <p>paragraph-2-txt</p>
    <template v-slot:footer>
      <p>foot-txt</p>
    </template>
  </base-layout>`,
  components: {
    baseLayout
  }
});

当前例子渲染成真实的页面需要经历编译,虚拟DOM的获取和渲染过程。

1、编译部分

(1)ast

ast的创建过程中,当执行到闭合标签时,会进行闭合标签的属性处理closeElement(element)

function closeElement (element) {
    trimEndingWhitespace(element)
    if (!inVPre && !element.processed) {
      element = processElement(element, options)
    }
    // tree management
    if (!stack.length && element !== root) {
      // allow root elements with v-if, v-else-if and v-else
      if (root.if && (element.elseif || element.else)) {
        if (process.env.NODE_ENV !== 'production') {
          checkRootConstraints(element)
        }
        addIfCondition(root, {
          exp: element.elseif,
          block: element
        })
      } else if (process.env.NODE_ENV !== 'production') {
        warnOnce(
          `Component template should contain exactly one root element. ` +
          `If you are using v-if on multiple elements, ` +
          `use v-else-if to chain them instead.`,
          { start: element.start }
        )
      }
    }
    if (currentParent && !element.forbidden) {
      if (element.elseif || element.else) {
        processIfConditions(element, currentParent)
      } else {
        if (element.slotScope) {
          // 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
      }
    }

    // final children cleanup
    // filter out scoped slots
    element.children = element.children.filter(c => !(c: any).slotScope)
    // remove trailing whitespace node again
    trimEndingWhitespace(element)

    // check pre state
    if (element.pre) {
      inVPre = false
    }
    if (platformIsPreTag(element.tag)) {
      inPre = false
    }
    // apply post-transforms
    for (let i = 0; i < postTransforms.length; i++) {
      postTransforms[i](element, options)
    }
  }

其中的 element = processElement(element, options)逻辑中会执行到slot相关的处理逻辑,这里主要看版本号大于2.6+的情况:

// 2.6 v-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 = name
    el.slotTargetDynamic = dynamic
    el.slotScope = slotBinding.value || emptySlotScopeToken // force it into a scoped slot for perf
  }
}

通过const slotBinding = getAndRemoveAttrByRegex(el, slotRE)获取到包含namevalue信息的当前的slot描述对象,并删除el.attrsList中关于v-slot的属性信息。再获取到elslotTargetslotTargetDynamicslotScope的信息。

closeElement(element)过程中,如果存在element.slotScope,还会将当前element映射到当前父级的currentParent.scopedSlots中:

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

在当前例子中,在最后</base-layout>闭合过程中,会存在带有slotScope属性的节点:

vue中的v-slot(源码分析) 通过element.children = element.children.filter(c => !(c: any).slotScope)进行过滤。

最后执行结果为:

vue中的v-slot(源码分析)

(2)generate

generate阶段,会执行到genData函数拼接code字符串,针对scopedSlots会有逻辑:

export function genData (el: ASTElement, state: CodegenState): string {
    // ...
    // scoped slots
    if (el.scopedSlots) {
        data += (genScopedSlots(el, el.scopedSlots, state)) + ",";
    }
    // ...
}

其中的genScopedSlots中有主要逻辑:

function genScopedSlots (
  el: ASTElement,
  slots: { [key: string]: ASTElement },
  state: CodegenState
): string {
  // ...
  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)
  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.$slots
  const reverseProxy = slotScope ? `` : `,proxy:true`
  return `{key:${el.slotTarget || `"default"`},fn:${fn}${reverseProxy}}`
}

通过Object.keys(slots)获取到包含headerfooter的数组,通过遍历执行genScopedSlot的逻辑并进行join(',')拼接。genScopedSlot中获取到包含子节点创建的fn函数,如果当前el.slotScope"_empty_",在其后拼接",proxy:true"

执行完遍历后会拼接"scopedSlots:_u([,最终的获得的render为:

with(this) {
    return _c('base-layout', {
        scopedSlots: _u([{
            key: "header",
            fn: function () {
                return [_c('h1', [_v("title-txt")])]
            },
            proxy: true
        }, {
            key: "footer",
            fn: function () {
                return [_c('p', [_v("foot-txt")])]
            },
            proxy: true
        }])
    }, [_v(" "), _c('p', [_v("paragraph-1-txt")]), _v(" "), _c('p', [_v("paragraph-2-txt")])])
}

2、init钩子函数

父节点base-layoutpatch的过程中,会执行到钩子函数init

// inline hooks to be invoked on component VNodes during patch
var componentVNodeHooks = {
  init: function init (vnode, hydrating) {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      var mountedNode = vnode; // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode);
    } else {
      var child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      );
      child.$mount(hydrating ? vnode.elm : undefined, hydrating);
    }
  },
  // ...
}

(1)resolveSlots

其中在createComponentInstanceForVnode时会执行子组件的构造函数,并执行从Vue继承的this._init初始化方法,其中initRender中有处理slot中默认插槽的逻辑:

/**
 * Runtime helper for resolving raw children VNodes into a slot object.
 */
function resolveSlots (
  children,
  context
) {
  var slots = {};
  for (var i = 0, l = children.length; i < l; i++) {
    var child = children[i];
    // ...
    (slots.default || (slots.default = [])).push(child);
    // ...
  }
  return slots
}

当前例子中将children中的子节点都pushslots.default中,这里就获取到了defalut部分的vNode列表,下面再看获取headerfooter的相关逻辑。

(2)normalizeScopedSlots

child.$mount(hydrating ? vnode.elm : undefined, hydrating)的过程中会执行到_render逻辑中的:

Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options

    if (_parentVnode) {
      vm.$scopedSlots = normalizeScopedSlots(
        _parentVnode.data.scopedSlots,
        vm.$slots,
        vm.$scopedSlots
      )
    }
    // ...
}

这里通过normalizeScopedSlots的方式去合并包含headerfooter_parentVnode.data.scopedSlots和默认defaultvm.$slots:

export function normalizeScopedSlots (
  slots: { [key: string]: Function } | void,
  normalSlots: { [key: string]: Array<VNode> },
  prevSlots?: { [key: string]: Function } | void
): any {
  let res
  const hasNormalSlots = Object.keys(normalSlots).length > 0
  const isStable = slots ? !!slots.$stable : !hasNormalSlots
  const key = slots && slots.$key
  if (!slots) {
    res = {}
  } else if (slots._normalized) {
    // fast path 1: child component re-render only, parent did not change
    return slots._normalized
  } else if (
    isStable &&
    prevSlots &&
    prevSlots !== emptyObject &&
    key === prevSlots.$key &&
    !hasNormalSlots &&
    !prevSlots.$hasNormal
  ) {
    // fast path 2: stable scoped slots w/ no normal slots to proxy,
    // only need to normalize once
    return prevSlots
  } else {
    res = {}
    for (const key in slots) {
      if (slots[key] && key[0] !== '$') {
        res[key] = normalizeScopedSlot(normalSlots, key, slots[key])
      }
    }
  }
  // expose normal slots on scopedSlots
  for (const key in normalSlots) {
    if (!(key in res)) {
      res[key] = proxyNormalSlot(normalSlots, key)
    }
  }
  // avoriaz seems to mock a non-extensible $scopedSlots object
  // and when that is passed down this would cause an error
  if (slots && Object.isExtensible(slots)) {
    (slots: any)._normalized = res
  }
  def(res, '$stable', isStable)
  def(res, '$key', key)
  def(res, '$hasNormal', hasNormalSlots)
  return res
}

function normalizeScopedSlot(normalSlots, key, fn) {
  const normalized = function () {
    let res = arguments.length ? fn.apply(null, arguments) : fn({})
    res = res && typeof res === 'object' && !Array.isArray(res)
      ? [res] // single vnode
      : normalizeChildren(res)
    return res && (
      res.length === 0 ||
      (res.length === 1 && res[0].isComment) // #9658
    ) ? undefined
      : res
  }
  // this is a slot using the new v-slot syntax without scope. although it is
  // compiled as a scoped slot, render fn users would expect it to be present
  // on this.$slots because the usage is semantically a normal slot.
  if (fn.proxy) {
    Object.defineProperty(normalSlots, key, {
      get: normalized,
      enumerable: true,
      configurable: true
    })
  }
  return normalized
}

通过Object.definePropertynormalSlots定义属性key的时候,会执行normalized函数,这里完成fn的执行,即获取到fn函数对应的vNode,例如:footer对应的ƒ (){return [_c('p',[_v("foot-txt")])]}会转换成包含内容为foot-txt文本vNodevNode。经过normalizeScopedSlot的执行,使得vm.$slots中除了包含defaultvNode列表外还包含headerfooter的列表。

至此,vm.$scopedSlots也包含了defaultheaderfooter的可获取到vNode列表的函数。

(3)_render的执行

子组件的render函数为:

with(this) {
    return _c('div', {
        staticClass: "container"
    }, [_c('header', [_t("header")], 2), _v(" "), _c('main', [_t("default")], 2), _v(" "), _c(
        'footer', [_t("footer")], 2)])
}

当执行到子组件的_render时,会执行到t(header),即renderSlot

function renderSlot (
  name,
  fallback,
  props,
  bindObject
) {
  var scopedSlotFn = this.$scopedSlots[name];
  var nodes;
  if (scopedSlotFn) { // scoped slot
    props = 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) || fallback;
  } else {
    nodes = this.$slots[name] || fallback;
  }

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

这里通过var scopedSlotFn = this.$scopedSlots[name]获取到header获取vNode的函数,然后执行nodes = scopedSlotFn(props) || fallback的方式执行header对应函数的执行获取vNode

二、 作用域插槽

let currentUser = {
  template: `<span>
    <slot name="user" v-bind:userData="childData">{{childData.firstName}}</slot>
  </span>`,
  data() {
    return {
      childData: {
        firstName: "first",
        lastName: "last"
      }
    };
  }
};

new Vue({
  el: "#app",
  template: `<current-user>
    <template v-slot:user="slotProps">{{slotProps.userData.lastName}}</template>
  </current-user>`,
  components: {
    currentUser
  }
});

1、编译过程

(1)ast

父组件编译后的ast为:

vue中的v-slot(源码分析)

(2)generate

generate阶段,不同的是在作用域插槽中通过const slotScope = el.slotScope === emptySlotScopeToken ? `` : String(el.slotScope)获取到了slotScope的值。最终获得render结果为:

with(this) {
    return _c('current-user', {
        scopedSlots: _u([{
            key: "user",
            fn: function (slotProps) {
                return [_v(_s(slotProps.userData.lastName))]
            }
        }])
    })
}

2、init阶段

当前例子中子组件currentUserrender为:

with(this) {
    return _c('span', [_t("user", [_v(_s(childData.firstName))], {
        "userData": childData
    })], 2)
}

其中的_trenderSlot:

/**
 * Runtime helper for rendering <slot>
 */
export function renderSlot (
  name: string,
  fallback: ?Array<VNode>,
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes
  if (scopedSlotFn) { // scoped slot
    props = 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) || fallback
  } else {
    nodes = this.$slots[name] || fallback
  }

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

作用域插槽不同的是在执行renderSlot中的nodes = scopedSlotFn(props) || fallback时,会将props作为参数传入,在当前例子中就是{"userData": childData}。然后通过const scopedSlotFn = this.$scopedSlots[name]获取到当前slot对应的回调函数this.$scopedSlots[name],在当前例子中是function (slotProps) { return [_v(_s(slotProps.userData.lastName))] }

在当前例子中执行完回调函数后,就实现了<slot name="user" v-bind:userData="childData">{{childData.firstName}}</slot>childData作为回调参数传入,{{childData.firstName}}被父组件中内容{{slotProps.userData.lastName}}替换的目的。

总结:

v-slot实际上是通过回调函数传参的形式进行了父组件内容替换子组件中插槽的内容。

后记

如有纰漏,请贵手留言~ 如有帮助,点赞收藏吆~