深刻剖析Vue源碼 - Vue插槽,你想了解的都在這裏!

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

10.1 普通插槽

插槽將<slot></slot>做爲子組件承載分發的載體,簡單的用法以下vue

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 組件掛載原理

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

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

接下來咱們對slot的分析將圍繞這四個具體的流程展開,對組件流程的詳細分析,能夠參考深刻剖析Vue源碼 - 組件基礎小節。git

10.1.3 父組件處理

回到組件實例流程中,父組件會優先於子組件進行實例的掛載,模板的解析和render函數的生成階段在處理上沒有特殊的差別,這裏就不展開分析。接下來是render函數生成Vnode的過程,在這個階段會遇到子的佔位符節點(即:child),所以會爲子組件建立子的VnodecreateComponent執行了建立子佔位節點Vnode的過程。咱們把重點放在最終Vnode代碼的生成。github

// 建立子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中,這是第二步的重點promise

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

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屬性的形式保存在子組件的實例中。app


隨後子組件也會走掛載的流程,一樣會經歷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對這一思想的應用。


相關文章
相關標籤/搜索