1. 程式人生 > 實用技巧 >挖掘隱藏在原始碼中的Vue技巧!

挖掘隱藏在原始碼中的Vue技巧!

前言

最近關於Vue的技巧文章大熱,我自己也寫過一篇(vue開發中的"騷操作"),但這篇文章的技巧是能在Vue的文件中找到蛛絲馬跡的,而有些文章說的技巧在Vue文件中根本找不到蹤跡!這是為什麼呢?

當我開始閱讀原始碼的時候,我才發現,其實這些所謂的技巧就是對原始碼的理解而已。

下面我分享一下我的收穫。

隱藏在原始碼中的技巧

我們知道,在使用Vue時,要使用new關鍵字進行呼叫,這就說明Vue是一個建構函式。所以源頭就是定義Vue建構函式的地方!

src/core/instance/index.js中找到了這個建構函式

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this
instanceof Vue) ) { warn('Vue is a constructor and should be called with the `new` keyword') } this._init(options) } initMixin(Vue) stateMixin(Vue) eventsMixin(Vue) lifecycleMixin(Vue) renderMixin(Vue)

在建構函式中,只做一件事——執行this._init(options)

_init()函式是在initMixin(Vue)中定義的

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init 
= function (options?: Object) { // ... _init 方法的函式體,此處省略 } }

以此為主線,來看看在這過程中有什麼好玩的技巧。

解構賦值子元件data的引數

按照官方文件,我們一般是這樣寫子元件data選項的:

props: ['parentData'],
data () {
  return {
    childData: this.parentData
  }
}

但你知道嗎,也是可以這麼寫:

data (vm) {
  return {
    childData: vm.parentData
  }
}
// 或者使用解構賦值
data ({ parentData }) { return { childData: parentData } }

通過解構賦值的方式將props裡的變數傳給data函式中,也就是說data函式的引數就是當前例項物件。

這是因為data函式的執行是用call()方法強制綁定了當前例項物件。這發生在data合併的階段,接下來去看看,說不定還有一些別的收穫!

_init()函式中主要是執行一系列的初始化,其中options選項的合併是初始化的基礎。

vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
)

Vue例項上添加了$options屬性,在那些初始化方法中,無一例外的都使用到了例項的$options屬性,即vm.$options

其中合併data就是在mergeOption中進行的。

strats.data = function (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    if (childVal && typeof childVal !== 'function') {
      process.env.NODE_ENV !== 'production' && warn(
        'The "data" option should be a function ' +
        'that returns a per-instance value in component ' +
        'definitions.',
        vm
      )

      return parentVal
    }
    return mergeDataOrFn(parentVal, childVal)
  }

  return mergeDataOrFn(parentVal, childVal, vm)
}

上面程式碼是data選項的合併策略函式,首先通過判斷是否存在vm,來判斷是否為父子元件,存在vm則為父元件。不管怎麼,最後都是返回mergeDataOrFn的執行結果。區別在於處理父元件時,透傳vm

接下來看看mergeDataOrFn函式。

export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    // in a Vue.extend merge, both should be functions
    if (!childVal) {
      return parentVal
    }
    if (!parentVal) {
      return childVal
    }
    // when parentVal & childVal are both present,
    // we need to return a function that returns the
    // merged result of both functions... no need to
    // check if parentVal is a function here because
    // it has to be a function to pass previous merges.
    return function mergedDataFn () {
      return mergeData(
        typeof childVal === 'function' ? childVal.call(this, this) : childVal,
        typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
      )
    }
  } else {
    return function mergedInstanceDataFn () {
      // instance merge
      const instanceData = typeof childVal === 'function'
        ? childVal.call(vm, vm)
        : childVal
      const defaultData = typeof parentVal === 'function'
        ? parentVal.call(vm, vm)
        : parentVal
      if (instanceData) {
        return mergeData(instanceData, defaultData)
      } else {
        return defaultData
      }
    }
  }
}

函式整體是由if判斷分支語句塊組成,對vm進行判斷,也使得mergeDataOrFn也能區分父子元件。

return function mergedDataFn () {
  return mergeData(
    typeof childVal === 'function' ? childVal.call(this, this) : childVal,
    typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
  )
}

來看這一段,當父子元件的data選項同時存在,那麼就返回mergedDataFn函式。mergedDataFn函式又返回mergeData函式。

在mergeData函式中,執行父子元件的data選項函式,注意這裡的childVal.call(this, this)parentVal.call(this, this),關鍵在於call(this, this),可以看到,第一個this指定了data函式的作用域,而第二個this就是傳遞給data函式的引數。這就是開頭能用解構賦值的原理。

接著往下看!

注意因為函式已經返回了(return),所以mergedDataFn函式還沒有執行。

以上就是處理子元件的data選項時所做的事,可以發現在處理子元件選項時返回的總是一個函式。

說完了處理子元件選項的情況,再看看處理非子元件選項的情況,也就是使用new操作符建立例項時的情況。

if (!vm) {
  ...
} else {
  return function mergedInstanceDataFn () {
    // instance merge
    const instanceData = typeof childVal === 'function'
      ? childVal.call(vm, vm)
      : childVal
    const defaultData = typeof parentVal === 'function'
      ? parentVal.call(vm, vm)
      : parentVal
    if (instanceData) {
      return mergeData(instanceData, defaultData)
    } else {
      return defaultData
    }
  }
}

如果走else分支的話那麼就直接返回mergedInstanceDataFn函式。其中父子元件data選項函式的執行也是用了call(vm, vm)方法,強制綁定當前例項物件。

const instanceData = typeof childVal === 'function'
  ? childVal.call(vm, vm)
  : childVal
const defaultData = typeof parentVal === 'function'
  ? parentVal.call(vm, vm)
  : parentVal

注意此時的mergedInstanceDataFn函式同樣還沒有執行。所以mergeDataFn函式永遠返回一個函式。

為什麼這麼強調返回的是一個函式呢?也就是說strats.data最終結果是一個函式?

這是因為,通過函式返回的資料物件,保證了每個元件例項都要有一個唯一的資料副本,避免了元件間資料互相影響。

這個mergeDataFn就是後面的初始化階段處理執行的。mergeDataFn返回是mergeData(childVal, parentVal)的執行結果才是真正合並父子元件的data選項。也就是到了初始化階段才是真正合並,這是因為propsinject這兩個選項的初始化是先於data選項的,這就保證了能夠使用props初始化data中的資料。

這才能在data選項中呼叫props或者inject的值!

生命週期鉤子可以寫成陣列形式

生命週期鉤子可以寫成陣列形式,不信你可以試試!

created: [
    function () {
      console.log('first')
    },
    function () {
      console.log('second')
    },
    function () {
      console.log('third')
    }
]

這啥能這麼寫?來看看生命週期鉤子的合併處理!

mergeHook是用於合併生命週期鉤子。

/**
 * Hooks and props are merged as arrays.
 */
function mergeHook (
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  return childVal
    ? parentVal
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal
}

LIFECYCLE_HOOKS.forEach(hook => {
  strats[hook] = mergeHook
})

其實從註釋中也能發現Hooks and props are merged as arrays.

使用forEach遍歷LIFECYCLE_HOOKS常量,說明LIFECYCLE_HOOKS是一個數組。LIFECYCLE_HOOKS來自於shared/constants.js檔案。

export const LIFECYCLE_HOOKS = [
  'beforeCreate',
  'created',
  'beforeMount',
  'mounted',
  'beforeUpdate',
  'updated',
  'beforeDestroy',
  'destroyed',
  'activated',
  'deactivated',
  'errorCaptured'
]

所以那段forEach語句,它的作用就是在strats策略物件上新增用來合併各個生命週期鉤子選項的函式。

return childVal
    ? parentVal
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal

函式體由三組三目運算子組成,在經過mergeHook函式處理之後,元件選項的生命週期鉤子函式被合併成一個數組。

在第一個三目運算子中,首先判斷是否有childVal,即元件的選項是否寫了生命週期鉤子函式,如果沒有則直接返回了parentVal,這裡有一個預設的假定,就是如果有parentVal那麼一定是個陣列,如果沒有parentVal那麼strats[hooks]函式根本不會執行。以created生命週期鉤子函式為例:

new Vue({
    created: function () {
        console.log('created')
    }
})

對於strats.created策略函式來講,childVal就是例子中的created選項,它是一個函式。parentVal應該是Vue.options.created,但Vue.options.created是不存在的,所以最終經過strats.created函式的處理將返回一個數組:

options.created = [
  function () {
    console.log('created')
  }  
]

再看下面的例子:

const Parent = Vue.extend({
  created: function () {
    console.log('parentVal')
  }
})

const Child = new Parent({
  created: function () {
    console.log('childVal')
  }
})

其中Child是使用new Parent生成的,所以對於Child來講,childVal是:

created: function () {
  console.log('childVal')
}

parentVal已經不是Vue.options.created了,而是Parent.options.created,那麼Parent.options.created是什麼呢?它其實是通過Vue.extend函式內部的mergeOptions處理過的,所以它應該是這樣的:

Parent.options.created = [
  created: function () {
    console.log('parentVal')
  }
]

經過mergeHook函式處理,關鍵在那句:parentVal.concat(childVal),將parentValchildVal合併成一個數組。所以最終結果如下:

[
  created: function () {
    console.log('parentVal')
  },
  created: function () {
    console.log('childVal')
  }
]

另外注意第三個三目運算子:

: Array.isArray(childVal)
  ? childVal
  : [childVal]

它判斷了childVal是不是陣列,這說明了生命週期鉤子是可以寫成陣列的。這就是開頭所說的原理!

生命週期鉤子的事件偵聽器

大家可能不知道什麼叫做「生命週期鉤子的事件偵聽器」?,其實Vue元件是可以這麼寫的:

<child
  @hook:created="childCreated"
  @hook:mounted="childMounted"
 />

在初始化中,使用callhook(vm, 'created')函式執行created生命週期函式,接下來瞧一瞧callhook()的實現方法:

export function callHook (vm: Component, hook: string) {
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget()
  const handlers = vm.$options[hook]
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      try {
        handlers[i].call(vm)
      } catch (e) {
        handleError(e, vm, `${hook} hook`)
      }
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
  popTarget()
}

callhook()函式接收兩個引數:

  • 例項物件;
  • 要呼叫的生命週期鉤子的名稱;

首先快取生命週期函式:

const handlers = vm.$options[hook]

如果執行callHook(vm, created),那麼就相當於:

const handlers = vm.$options.created

剛剛介紹過,對於生命週期鉤子選項最終會被合併處理成一個數組,所以得到的handlers就是一個生命週期鉤子的陣列。接著執行的是這段程式碼:

if (handlers) {
  for (let i = 0, j = handlers.length; i < j; i++) {
    try {
      handlers[i].call(vm)
    } catch (e) {
      handleError(e, vm, `${hook} hook`)
    }
  }
}

最後注意到callHook函式的最後有這樣一段程式碼:

if (vm._hasHookEvent) {
  vm.$emit('hook:' + hook)
}

其中vm._hasHookEvent是在initEvents函式中定義的,它的作用是判斷是否存在「生命週期鉤子的事件偵聽器」,初始化值為false代表沒有,當元件檢測到存在生命週期鉤子的事件偵聽器時,會將vm._hasHookEvent設定為true

生命週期鉤子的事件偵聽器,就是開頭說的:

<child
  @hook:created="childCreated"
  @hook:mounted="childMounted"
 />

使用hook:加生命週期鉤子名稱的方式來監聽元件相應的生命週期鉤子。

總結

1、子元件data選項函式是有引數的,而且是當前的例項物件;

2、生命週期鉤子是可以寫成陣列形式,按順序執行;

3、可以使用生命週期鉤子的事件偵聽器來註冊生命週期函式

「不過沒在官方文件中寫明的方法,不建議使用」。

作者: zhangwinwin
連結:挖掘隱藏在原始碼中的Vue技巧!
來源:github