深入剖析Vue原始碼 - Vue插槽,你想了解的都在這裡!

不做祖國的韭菜發表於2019-08-29

Vue元件的另一個重要概念是插槽,它允許你以一種不同於嚴格的父子關係的方式組合元件。插槽為你提供了一個將內容放置到新位置或使元件更通用的出口。這一節將圍繞官網對插槽內容的介紹思路,按照普通插槽,具名插槽,再到作用域插槽的思路,逐步深入內在的實現原理,有對插槽使用不熟悉的,可以先參考官網對插槽的介紹。

10.1 普通插槽

插槽將<slot></slot>作為子元件承載分發的載體,簡單的用法如下

10.1.1 基礎用法
var child = {
  template: `<div class="child"><slot></slot></div>`
}
var vm = new Vue({
  el: '#app',
  components: {
    child
  },
  template: `<div id="app"><child>test</child></div>`
})
// 最終渲染結果
<div class="child">test</div>
複製程式碼
10.1.2 元件掛載原理

插槽的原理,貫穿了整個元件系統編譯到渲染的過程,所以首先需要回顧一下對元件相關編譯渲染流程,簡單總結一下幾點:

  1. 從根例項入手進行例項的掛載,如果有手寫的render函式,則直接進入$mount掛載流程。
  2. 只有template模板則需要對模板進行解析,這裡分為兩個階段,一個是將模板解析為AST樹,另一個是根據不同平臺生成執行程式碼,例如render函式。
  3. $mount流程也分為兩步,第一步是將render函式生成Vnode樹,子元件會以vue-componet-tag標記,另一步是把Vnode渲染成真正的DOM節點。
  4. 建立真實節點過程中,如果遇到子的佔位符元件會進行子元件的例項化過程,這個過程又將回到流程的第一步。

接下來我們對slot的分析將圍繞這四個具體的流程展開,對元件流程的詳細分析,可以參考深入剖析Vue原始碼 - 元件基礎小節。

10.1.3 父元件處理

回到元件例項流程中,父元件會優先於子元件進行例項的掛載,模板的解析和render函式的生成階段在處理上沒有特殊的差異,這裡就不展開分析。接下來是render函式生成Vnode的過程,在這個階段會遇到子的佔位符節點(即:child),因此會為子元件建立子的VnodecreateComponent執行了建立子佔位節點Vnode的過程。我們把重點放在最終Vnode程式碼的生成。

// 建立子Vnode過程
  function createComponent (
    Ctor, // 子類構造器
    data,
    context, // vm例項
    children, // 父元件需要分發的內容
    tag // 子元件佔位符
  ){
    ···
    // 建立子vnode,其中父保留的children屬性會以選項的形式傳遞給Vnode
    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
    );
  }
// Vnode構造器
var VNode = function VNode (tag,data,children,text,elm,context,componentOptions,asyncFactory) {
  ···
  this.componentOptions = componentOptions; // 子元件的選項相關
}
複製程式碼

createComponent函式接收的第四個引數children就是父元件需要分發的內容。在建立子Vnode過程中,會以會componentOptions配置傳入Vnode構造器中。最終Vnode中父元件需要分發的內容以componentOptions屬性的形式存在,這是插槽分析的第一步

10.1.4 子元件流程

父元件的最後一個階段是將Vnode渲染為真正的DOM節點,在這個過程中如果遇到子Vnode會優先例項化子元件並進行一系列子元件的渲染流程。子元件初始化會先呼叫init方法,並且和父元件不同的是,子元件會呼叫initInternalComponent方法拿到父元件擁有的相關配置資訊,並賦值給子元件自身的配置選項。

// 子元件的初始化
Vue.prototype._init = function(options) {
  if (options && options._isComponent) {
    initInternalComponent(vm, options);
  }
  initRender(vm)
}
function initInternalComponent (vm, options) {
    var opts = vm.$options = Object.create(vm.constructor.options);
    var parentVnode = options._parentVnode;
    opts.parent = options.parent;
    opts._parentVnode = parentVnode;
    // componentOptions為子vnode記錄的相關資訊
    var vnodeComponentOptions = parentVnode.componentOptions;
    opts.propsData = vnodeComponentOptions.propsData;
    opts._parentListeners = vnodeComponentOptions.listeners;
    // 父元件需要分發的內容賦值給子選項配置的_renderChildren
    opts._renderChildren = vnodeComponentOptions.children;
    opts._componentTag = vnodeComponentOptions.tag;

    if (options.render) {
      opts.render = options.render;
      opts.staticRenderFns = options.staticRenderFns;
    }
  }
複製程式碼

最終在子元件例項的配置中拿到了父元件儲存的分發內容,記錄在元件例項$options._renderChildren中,這是第二步的重點

接下來是initRender階段,在這個過程會將配置的_renderChildren屬性做規範化處理,並將他賦值給子例項上的$slot屬性,這是第三步的重點

function initRender(vm) {
  ···
  vm.$slots = resolveSlots(options._renderChildren, renderContext);// $slots拿到了子佔位符節點的_renderchildren(即需要分發的內容),保留作為子例項的屬性
}

function resolveSlots (children,context) {
    // children是父元件需要分發到子元件的Vnode節點,如果不存在,則沒有分發內容
    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.
      // 分支1為具名插槽的邏輯,放後分析
      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: [children] }物件返回
        (slots.default || (slots.default = [])).push(child);
      }
    }
    return slots
  }
複製程式碼

其中普通插槽的處理邏輯核心在(slots.default || (slots.default = [])).push(child);,即以陣列的形式賦值給default屬性,並以$slot屬性的形式儲存在子元件的例項中。


隨後子元件也會走掛載的流程,同樣會經歷template模板到render函式,再到Vnode,最後渲染真實DOM的過程。解析AST階段,slot標籤和其他普通標籤處理相同,不同之處在於AST生成render函式階段,對slot標籤的處理,會使用_t函式進行包裹。這是關鍵步驟的第四步

子元件渲染的大致流程簡單梳理如下

// ast 生成 render函式
var code = generate(ast, options);
// generate實現
function generate(ast, options) {
  var state = new CodegenState(options);
  var code = ast ? genElement(ast, state) : '_c("div")';
  return {
    render: ("with(this){return " + code + "}"),
    staticRenderFns: state.staticRenderFns
  }
}
// genElement實現
function genElement(el, state) {
  // 針對slot標籤的處理走```genSlot```分支
  if (el.tag === 'slot') {
    return genSlot(el, state)
  }
}
// 核心genSlot原理
function genSlot (el, state) {
    // slotName記錄著插槽的唯一標誌名,預設為default
    var slotName = el.slotName || '"default"';
    // 如果子元件的插槽還有子元素,則會遞迴調執行子元素的建立過程
    var children = genChildren(el, state);
    // 通過_t函式包裹
    var res = "_t(" + slotName + (children ? ("," + children) : '');
    // 具名插槽的其他處理
    ···    
    return res + ')'
  }
複製程式碼

最終子元件的render函式為: "with(this){return _c('div',{staticClass:"child"},[_t("default")],2)}"

第五步到了子元件渲染為Vnode的過程。render函式執行階段會執行_t()函式,_t函式是renderSlot函式簡寫,它會在Vnode樹中進行分發內容的替換,具體看看實現邏輯。


// target._t = renderSlot;

// render函式渲染Vnode函式
Vue.prototype._render = function() {
  var _parentVnode = ref._parentVnode;
  if (_parentVnode) {
    // slots的規範化處理並賦值給$scopedSlots屬性。
    vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots, // 記錄父元件的插槽內容
      vm.$scopedSlots
    );
  }
}
複製程式碼

normalizeScopedSlots的邏輯較長,但並不是本節的重點。拿到$scopedSlots屬性後會執行真正的render函式,其中_t的執行邏輯如下:

// 渲染slot元件內容
  function renderSlot (
    name,
    fallback, // slot插槽後備內容(針對後備內容)
    props, // 子傳給父的值(作用域插槽)
    bindObject
  ) {
    // scopedSlotFn拿到父元件插槽的執行函式,預設slotname為default
    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);
      }
      // 執行時將子元件傳遞給父元件的值傳入fn
      nodes = scopedSlotFn(props) || fallback;
    } else {
      // 如果父佔位符元件沒有插槽內容,this.$slots不會有值,此時vnode節點為後備內容節點。
      nodes = this.$slots[name] || fallback;
    }

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

renderSlot執行過程會拿到父元件需要分發的內容,最終Vnode樹將父元素的插槽替換掉子元件的slot元件。

最後一步就是子元件真實節點的渲染了,這點沒有什麼特別點,和以往介紹的流程一致

至此,一個完整且簡單的插槽流程分析完畢。接下來看插槽深層次的用法。

10.2 具有後備內容的插槽

有時為一個插槽設定具體的後備 (也就是預設的) 內容是很有用的,它只會在沒有提供內容的時候被渲染。檢視原始碼發現後備內容插槽的邏輯也很好理解。

var child = {
  template: `<div class="child"><slot>後備內容</slot></div>`
}
var vm = new Vue({
  el: '#app',
  components: {
    child
  },
  template: `<div id="app"><child></child></div>`
})
// 父沒有插槽內容,子的slot會渲染後備內容
<div class="child">後備內容</div>
複製程式碼

父元件沒有需要分發的內容,子元件會預設顯示插槽裡面的內容。原始碼中的不同體現在下面的幾點。

  1. 父元件渲染過程由於沒有需要分發的子節點,所以不再需要擁有componentOptions.children屬性來記錄內容。
  2. 因此子元件也拿不到$slot屬性的內容.
  3. 子元件的render函式最後在_t函式引數會攜帶第二個引數,該引數以陣列的形式傳入slot插槽的後備內容。例with(this){return _c('div',{staticClass:"child"},[_t("default",[_v("test")])],2)}
  4. 渲染子Vnode會執行renderSlot(_t)函式時,第二個引數fallback有值,且this.$slots沒值,vnode會直接返回後備內容作為渲染物件。
function renderSlot (
    name,
    fallback, // slot插槽後備內容(針對後備內容)
    props, // 子傳給父的值(作用域插槽)
    bindObject
){
    if() {
      ···
    }else{
      //fallback為後備內容
      // 如果父佔位符元件沒有插槽內容,this.$slots不會有值,此時vnode節點為後備內容節點。
      nodes = this.$slots[name] || fallback;
    }
}
    
複製程式碼

最終,在父元件沒有提供內容時,slot的後備內容被渲染。


有了這些基礎,我們再來看官網給的一條規則。

父級模板裡的所有內容都是在父級作用域中編譯的;子模板裡的所有內容都是在子作用域中編譯的。

父元件模板的內容在父元件編譯階段就確定了,並且儲存在componentOptions屬性中,而子元件有自身初始化init的過程,這個過程同樣會進行子作用域的模板編譯,因此兩部分內容是相對獨立的。

10.3 具名插槽

往往我們需要靈活的使用插槽進行通用元件的開發,要求父元件每個模板對應子元件中每個插槽,這時我們可以使用<slot>name屬性,同樣舉個簡單的例子。

var child = {
  template: `<div class="child"><slot name="header"></slot><slot name="footer"></slot></div>`,
}
var vm = new Vue({
  el: '#app',
  components: {
    child
  },
  template: `<div id="app"><child><template v-slot:header><span>頭部</span></template><template v-slot:footer><span>底部</span></template></child></div>`,
})
複製程式碼

渲染結果:

<div class="child"><span>頭部</span><span>底部</span></div>
複製程式碼

接下來我們在普通插槽的基礎上,看看原始碼在具名插槽實現上的區別。

10.3.1 模板編譯的差別

父元件在編譯AST階段和普通節點的過程不同,具名插槽一般會在template模板中用v-slot:來標註指定插槽,這一階段會在編譯階段特殊處理。最終的AST樹會攜帶scopedSlots用來記錄具名插槽的內容

{
  scopedSlots: {
    footer: { ··· },
    header: { ··· }
  }
}
複製程式碼

AST生成render函式的過程也不詳細分析了,我們只分析父元件最終返回的結果(如果對parse, generate感興趣的同學,可以直接看原始碼分析,編譯階段冗長且難以講解,跳過這部分分析)

with(this){return _c('div',{attrs:{"id":"app"}},[_c('child',{scopedSlots:_u([{key:"header",fn:function(){return [_c('span',[_v("頭部")])]},proxy:true},{key:"footer",fn:function(){return [_c('span',[_v("底部")])]},proxy:true}])})],1)}
複製程式碼

很明顯,父元件的插槽內容用_u函式封裝成陣列的形式,並賦值到scopedSlots屬性中,而每一個插槽以物件形式描述,key代表插槽名,fn是一個返回執行結果的函式。

10.3.2 父元件vnode生成階段

照例進入父元件生成Vnode階段,其中_u函式的原形是resolveScopedSlots,其中第一個引數就是插槽陣列。

// vnode生成階段針對具名插槽的處理 _u      (target._u = resolveScopedSlots)
  function resolveScopedSlots (fns,res,hasDynamicKeys,contentHashKey) {
    res = res || { $stable: !hasDynamicKeys };
    for (var i = 0; i < fns.length; i++) {
      var slot = fns[i];
      // fn是陣列需要遞迴處理。
      if (Array.isArray(slot)) {
        resolveScopedSlots(slot, res, hasDynamicKeys);
      } else if (slot) {
        // marker for reverse proxying v-slot without scope on this.$slots
        if (slot.proxy) { //  針對proxy的處理
          slot.fn.proxy = true;
        }
        // 最終返回一個物件,物件以slotname作為屬性,以fn作為值
        res[slot.key] = slot.fn;
      }
    }
    if (contentHashKey) {
      (res).$key = contentHashKey;
    }
    return res
  }
複製程式碼

最終父元件的vnode節點的data屬性上多了scopedSlots陣列。回顧一下,具名插槽和普通插槽實現上有明顯的不同,普通插槽是以componentOptions.child的形式保留在父元件中,而具名插槽是以scopedSlots屬性的形式儲存到data屬性中。

// vnode
{
  scopedSlots: [{
    'header': fn,
    'footer': fn
  }]
}
複製程式碼
10.3.3 子元件渲染Vnode過程

子元件在解析成AST樹階段的不同,在於對slot標籤的name屬性的解析,而在render生成Vnode過程中,slot的規範化處理針對具名插槽會進行特殊的處理,回到normalizeScopedSlots的程式碼

vm.$scopedSlots = normalizeScopedSlots(
  _parentVnode.data.scopedSlots, // 此時的第一個引數會拿到父元件插槽相關的資料
  vm.$slots, // 記錄父元件的插槽內容
  vm.$scopedSlots
);

複製程式碼

最終子元件例項上的$scopedSlots屬性會攜帶父元件插槽相關的內容。

// 子元件Vnode
{
  $scopedSlots: [{
    'header': f,
    'footer': f
  }]
}
複製程式碼
10.3.4 子元件渲染真實dom

和普通插槽類似,子元件渲染真實節點的過程會執行子render函式中的_t方法,這部分的原始碼會和普通插槽走不同的分支,其中this.$scopedSlots根據上面分析會記錄著父元件插槽內容相關的資料,所以會和普通插槽走不同的分支。而最終的核心是執行nodes = scopedSlotFn(props),也就是執行function(){return [_c('span',[_v("頭部")])]},具名插槽之所以是函式的形式執行而不是直接返回結果,我們在後面揭曉。

function renderSlot (
    name,
    fallback, // slot插槽後備內容
    props, // 子傳給父的值
    bindObject
  ){
    var scopedSlotFn = this.$scopedSlots[name];
    var nodes;
    // 針對具名插槽,特點是$scopedSlots有值
    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);
      }
      // 執行時將子元件傳遞給父元件的值傳入fn
      nodes = scopedSlotFn(props) || fallback;
    }···
  }
複製程式碼

至此子元件通過slotName找到了對應父元件的插槽內容。

10.4 作用域插槽

最後說說作用域插槽,我們可以利用作用域插槽讓父元件的插槽內容訪問到子元件的資料,具體的用法是在子元件中以屬性的方式記錄在子元件中,父元件通過v-slot:[name]=[props]的形式拿到子元件傳遞的值。子元件<slot>元素上的特性稱為插槽Props,另外,vue2.6以後的版本已經棄用了slot-scoped,採用v-slot代替。

var child = {
  template: `<div><slot :user="user"></div>`,
  data() {
    return {
      user: {
        firstname: 'test'
      }
    }
  }
}
var vm = new Vue({
  el: '#app',
  components: {
    child
  },
  template: `<div id="app"><child><template v-slot:default="slotProps">{{slotProps.user.firstname}}</template></child></div>`
})
複製程式碼

作用域插槽和具名插槽的原理類似,我們接著往下看。

10.4.1 父元件編譯階段

作用域插槽和具名插槽在父元件的用法基本相同,區別在於v-slot定義了一個插槽props的名字,參考對於具名插槽的分析,生成render函式階段fn函式會攜帶props引數傳入。即: with(this){return _c('div',{attrs:{"id":"app"}},[_c('child',{scopedSlots:_u([{key:"default",fn:function(slotProps){return [_v(_s(slotProps.user.firstname))]}}])})],1)}

10.4.2 子元件渲染

在子元件編譯階段,:user="user"會以屬性的形式解析,最終在render函式生成階段以物件引數的形式傳遞_t函式。 with(this){return _c('div',[_t("default",null,{"user":user})],2)}

子元件渲染Vnode階段,根據前面分析會執行renderSlot函式,這個函式前面分析過,對於作用域插槽的處理,集中體現在函式傳入的第三個引數。

// 渲染slot元件vnode
function renderSlot(
  name,
  fallback,
  props, // 子傳給父的值 { user: user }
  bindObject
) {
    // scopedSlotFn拿到父元件插槽的執行函式,預設slotname為default
    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
        props = extend(extend({}, bindObject), props);
      }
      // 執行時將子元件傳遞給父元件的值傳入fn
      nodes = scopedSlotFn(props) || fallback;
    }
複製程式碼

最終將子元件的插槽props作為引數傳遞給執行函式執行。回過頭看看為什麼具名插槽是函式的形式執行而不是直接返回結果。學完作用域插槽我們發現這就是設計巧妙的地方,函式的形式讓執行過程更加靈活,作用域插槽只需要以引數的形式將插槽props傳入便可以得到想要的結果。

10.4.3 思考

作用域插槽這個概念一開始我很難理解,單純從定義和原始碼的結論上看,父元件的插槽內容可以訪問到子元件的資料,這不是明顯的子父之間的資訊通訊嗎,在事件章節我們知道,子父元件之間的通訊完全可以通過事件$emit,$on的形式來完成,那麼為什麼還需要增加一個插槽props的概念呢。 我們看看作者的解釋。

插槽 prop 允許我們將插槽轉換為可複用的模板,這些模板可以基於輸入的 prop 渲染出不同的內容

從我自身的角度理解,作用域插槽提供了一種方式,當你需要封裝一個通用,可複用的邏輯模組,並且這個模組給外部使用者提供了一個便利,允許你在使用元件時自定義部分佈局,這時候作用域插槽就派上大用場了,再到具體的思想,我們可以看看幾個工具庫Vue Virtual Scroller Vue Promised對這一思想的應用。


相關文章