[vue解析]插槽在vue中是如何编译和执行的?

1,215 阅读5分钟

前言

slotsvue的一个核心功能,它和keep-alive、transition组件都有关系,所以我们有必要对它做一个全面的了解。

<div id="app">
  <app-layout>
    <template v-slot:header>头部内容</template>
    <template v-slot>默认内容</template>
    <template v-slot:footer>底部内容</template>
  </app-layout>
  <button @click="change">change title</button>
</div>
<script>
  const AppLayout = {
    template:
      '<div class="container">' +
      '<header><slot name="header"></slot></header>' +
      '<main><slot></slot></main>' +
      '<footer><slot name="footer"></slot></footer>' +
      '</div>'
  }

  const vm = new Vue({
    el: '#app',
    components: {
      AppLayout
    }
  })
</script>

因为vue-next已经全面改成使用v-slot了,所以我们分析插槽只分析v-slot API

注意 当我们开始分析vue的指令的时候,我们需要分两步走:

  1. 编译阶段做了哪些工作,ASTrender方法是什么样子的?
  2. 初始化做了什么工作,执行的时候又做了什么?

插槽的编译

vue的编译分为三步

  1. parse:将html转换成AST
  2. opitimize: 将AST中不变的数据标识成static,优化的作用
  3. codegen: 将AST代码转成字符串,并通过new Function实例化为函数

parse

那么我们先看第一步,在parse方法里面vue调用了parseHTML方法。parseHTML方法传入两个参数,一个是html字符串, 在第二个参数里面传入了四个钩子函数start、end、chars、comment,这里要讲清楚的话比较麻烦,而我们通过查看执行栈的方式来看parse的执行过程。

首先我们知道我们现在的例子和slot有关,那么很显然vue在定义方法的时候肯定和slot有关系,我们查找到两个方法processSlotContentprocessSlotOutlet两个方法。 在第一个方法打上断点,查看执行栈

执行栈

这样就能清晰的看到代码的执行过程了。这里我们进入processSlotContent方法,开始分析

function processSlotContent (el) {
  // 2.6 v-slot syntax
  if (process.env.NEW_SLOT_SYNTAX) {
    if (el.tag === 'template') {
      // v-slot on <template>
      const slotBinding = getAndRemoveAttrByRegex(el, slotRE)
      if (slotBinding) {
        const { name, dynamic } = getSlotName(slotBinding)
        el.slotTarget = name
        el.slotTargetDynamic = dynamic
        el.slotScope = slotBinding.value || emptySlotScopeToken 
      }
    }
  }
}

这里我们先记录一下,el当前的对象中attrsList的值

el: {
  attrsList: [{
    end: 68,
    name: "v-slot:header",
    start: 52,
    value: ""
  }]
}

然后直接跳过getAndRemoveAttrByRegex方法查看新值和返回值,attrsList被清空了,并且我们拿到了里面的值

{
 end: 68,
 name: "v-slot:header",
 start: 52,
 value: ""
}

getSlotName方法拿到name值,这个值是header。并且这里也会判断是不是动态slot和新增特性相关。 最后vue赋值了三个属性

slotScope: "_empty_"
slotTarget: "\"header\""
slotTargetDynamic: false

这里我们还要去closeElement方法看一下

function closeElement (element) {

 if (!inVPre && !element.processed) {
   element = processElement(element, options)
 }
 if (currentParent && !element.forbidden) {
   if (element.elseif || element.else) {
     ...
   } else {
     if (element.slotScope) {
       const name = element.slotTarget || '"default"'
       ;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
     }
     currentParent.children.push(element)
     element.parent = currentParent
   }
 }

 element.children = element.children.filter(c => !c.slotScope)
}

在执行完processElement之后,我们看上面的代码,currentParentstart钩子函数中赋值,拿到是当前的element的父级。

这里我们看到将当前element元素放到了currentParentscopedSlotschildren

为什么要在scopedSlots中也放一份?

为了正确维护v-if的关系,看下面这段代码,过滤slotScope中不存在的数据

element.children = element.children.filter(c => !c.slotScope)

这样父组件内的第一个v-slot就解析完毕了。

好,我们看最终生成的AST,父组件是这样的

const ast = {
  children: [
    {
      tag: "app-layout",
      scopedSlots: {
        'default': {slotTarget: '"default"', slotScope: '_empty_', parent: 'parentAST'},
        'footer': {slotTarget: '"footer"', slotScope: '_empty_', parent: 'parentAST'},
        'header': {slotTarget: '"header"', slotScope: '_empty_', parent: 'parentAST'},
      }
    }
  ]
}

什么时候进入子组件?

只要看el.tag是否是app-layout就行。好,开始执行子组件解析的时候,我们进入processSlotOutlet

function processSlotOutlet (el) {
  if (el.tag === 'slot') {
    el.slotName = getBindingAttr(el, 'name')
  }
}

这里主要是赋值了slotName。这个就很简单,我们直接看子组件的AST

const childAst = {
  children: [
    {
      tag: 'header',
      children: [{slotName:'header', tag:'slot'}]
    }
    ...
  ]
}

codegen

拿到AST之后,我们就需要将它转换成字符串代码,在src/compiler/index.js文件中,我们可以看到 vue编译的三个步骤,那么我们现在就来看看generate(ast, options)。在该方法中,我们可以看到, 主要分两步

  1. 初始化options,拿到state
  2. 通过genElement拿到code

我们直接看genElement,对于父组件,我们看其中的genData

export function genData (el: ASTElement, state: CodegenState): string {
  let data = '{'
  if (el.scopedSlots) {
    data += `${genScopedSlots(el, el.scopedSlots, state)},`
  }
  return data
}

看上面父组件的AST,我们要拿到children才会走上面的逻辑,所以直接跳到子el。进入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)}` : ``
  })`
}

前面是关于在v-slots中使用v-if的优化。genScopedSlot就是每一个v-slot生成的过程,里面就不进去看了,也是很简单的判断和字符串拼接,我们直接看结果。

{scopedSlots:_u([
  {key:"header",fn:function(){return [_v("头部内容")]},proxy:true},
  {key:"default",fn:function(){return [_v("默认内容")]},proxy:true},
  {key:"footer",fn:function(){return [_v("底部内容")]},proxy:true}])
}

父组件返回的字符串结果如图,我们看子组件。在子组件中,它的AST关键在于el.tag=slot 所以我们看的是

export function genElement (el: ASTElement, state: CodegenState): string {
  if (el.parent) {
    el.pre = el.pre || el.parent.pre
  } else if (el.tag === 'slot') {
    return genSlot(el, state)
  }
  return code
}

所以我们就可以拿到它的code

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

执行

编译阶段结束,那么我们就能拿到匿名执行函数去执行,在之前的文章我曾经说过,我们在执行匿名函数的时候,其实就是在执行render-helpers里面定义的方法,那么_u就是resolveScopedSlots方法

export function resolveScopedSlots (
  fns: ScopedSlotsData, // see flow/vnode
  res?: Object,
  // the following are added in 2.6
  hasDynamicKeys?: boolean,
  contentHashKey?: number
): { [key: string]: Function, $stable: boolean } {
  res = res || { $stable: !hasDynamicKeys }
  for (let i = 0; i < fns.length; i++) {
    const slot = fns[i]
    if (Array.isArray(slot)) {
      resolveScopedSlots(slot, res, hasDynamicKeys)
    } else if (slot) {
      if (slot.proxy) {
        slot.fn.proxy = true
      }
      // 规整化为 插槽名称:fn
      res[slot.key] = slot.fn
    }
  }
  if (contentHashKey) {
    res.$key = contentHashKey
  }
  return res
}

看这个方法,fns就是我们上面通过_u([...])传入的数组,并且vueslots.fn.proxy=true。最终的返回是

res: {
  default: fn(),
  footer:fn(),
  header: fn(),
}

父组件处理完了,我们看子组件。看上面vue调用的_t方法。在render-helpers中是renderSlot方法,

export function renderSlot (
  name: string,
  fallbackRender: ?((() => Array<VNode>) | Array<VNode>),
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes
  if (scopedSlotFn) {
    // scoped slot
    props = props || {}
    nodes =
      scopedSlotFn(props) ||
      (typeof fallbackRender === 'function' ? fallbackRender() : fallbackRender)
  } 
  return nodes
}

唉,我们这里看到this.$scopedSlots里面好像存着,父组件上面解析好的slots这是为什么,哪里赋值的呢?

我们回过头来看一段代码,在Vue.prototype._render方法中

Vue.prototype._render = function (): VNode {
    const vm: Component = this
    // 通过规整化好后的 $options拿到 渲染函数
    const { render, _parentVnode } = vm.$options
    // 拿到$scopeSlots
    if (_parentVnode) {
      vm.$scopedSlots = normalizeScopedSlots(
        _parentVnode.data.scopedSlots,
        vm.$slots,
        vm.$scopedSlots
      )
    }
    return vnode
  }

在子组件中_parentVnode是肯定存在的,那么就会调用normalizeScopedSlots,它传的第一个参数就是父组件上的scopedSlots对象,那么很显然,当前的scopedSlots也就有值了。

回过头去看上面的方法,scopedSlotFn存在值,那么就被执行了,执行的是return [_v("头部内容")]。因为props不存在值,那么直接的就返回了nodes。这样插槽就会被渲染到子组件了

作用域插槽

先看例子

<div id="app">
 <app-layout>
   <template v-slot:header="props">头部内容 {{props.msg}}</template>
   <template v-slot="props">默认内容 {{props.msg}}</template>
   <template v-slot:footer="props">底部内容 {{props.msg}}</template>
 </app-layout>
</div>
<script>
 const AppLayout = {
   data() {
     return {
       msg1: 'header',
       msg2: 'default',
       msg3: 'footer'
     }
   },
   template:
     '<div class="container">' +
     '<header><slot name="header" :msg="msg1"></slot></header>' +
     '<main><slot :msg="msg2"></slot></main>' +
     '<footer><slot name="footer" :msg="msg3"></slot></footer>' +
     '</div>'
 }

 const vm = new Vue({
   el: '#app',
   components: {
     AppLayout
   }
 })
</script>

例子很简单,这次我们不一步步分析了,直接来看父组件和子组件的最终code

scopedSlots:_u([
  {key:"header",fn:function(props){return [_v("头部内容 "+_s(props.msg))]}},{key:"default",fn:function(props){return [_v("默认内容 "+_s(props.msg))]}},{key:"footer",fn:function(props){return [_v("底部内容 "+_s(props.msg))]}}
])

不同点在于,方法中传了参数,并且调用了_s

再看子组件

[_c('header',[_t("header",null,{"msg":msg1})],2),
_c('main',[_t("default",null,{"msg":msg2})],2),
_c('footer',[_t("footer",null,{"msg":msg3})],2)]

这里我们传入了多个参数,那么很简单,我们去看resolveScopedSlotsrenderSlot方法。 前者没什么不同,还是把数组形式转换成了对象形式。看renderSlot方法。

nodes = scopedSlotFn(props)

那么再来看子组件,首先在运行到msg1的时候,vue会触发依赖收集,这都是在子组件进行的。然后触发_t 这时候我们可以看到通过get拦截器vue拿到了值_t('footer', null, {msg: 'header'})。那么在方法中,就能正确的渲染props.msg

因为上面的依赖收集,当我们修改msg1的时候,就会触发子组件更新,这时候就会重新调用_t执行一遍

总结

插槽就是一段函数,可以看到,父组件里面的模板是父级作用域编译的,子组件通过拿到scopedSlots执行里面的方法,然后在子组件生成vnode渲染了真实dom。那么也就是官网中的话

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