每天閱讀一個 npm 模組(3)- mimic-fn

elvinnn發表於2018-08-28

系列文章:

  1. 每天閱讀一個 npm 模組(1)- username
  2. 每天閱讀一個 npm 模組(2)- mem

昨天閱讀 mem 的原始碼之後,提出了當引數為 RegExp 型別時,執行結果會存在問題。今天又仔細思考了一下,對於 Symbol 型別,也會存在同樣的問題。通過 mem - Issue #20 和作者 Sindre Sorhus 討論之後,已經得出了初步的解決方法,相信這個 bug 會在最近被 fix ?

一句話介紹

今天閱讀的 npm 模組是 mimic-fn,mimic 的意思是模仿,它通過對原函式的複製從而模仿原函式的行為,可以在不修改原函式的前提下,擴充函式的功能,當前版本為 1.2.0,周下載量約為 421 萬。

用法

const mimicFn = require('mimic-fn');

function foo() {}
foo.date = '2018-08-27';

function wrapper() {
	return foo() {};
}

console.log(wrapper.name);
//=> 'wrapper'

// 此處複製 foo 函式後,
// foo 擁有的功能,wrapper 均有
mimicFn(wrapper, foo);

console.log(wrapper.name);
//=> 'foo'

console.log(wrapper.date);
//=> '2018-08-27'
複製程式碼

原始碼學習

實現 mimic-fn 功能的難點在於如何獲得原函式所有的屬性並將其賦值給新函式。其實原始碼非常非常非常(重要的事情說三遍)短:

// 原始碼 3-1
module.exports = (to, from) => {
	for (const prop of Object.getOwnPropertyNames(from).concat(Object.getOwnPropertySymbols(from))) {
		Object.defineProperty(to, prop, Object.getOwnPropertyDescriptor(from, prop));
	}

	return to;
};
複製程式碼

雖然原始碼只有四五行,但是涉及 JavaScript 中非常核心基礎的內容 —— property descriptor(屬性描述符),還是值得好好研究一下的。

屬性描述符介紹

形如 const obj = {x: 1} 是最簡單的物件,xobj 的一個屬性。ES5 帶給了我們對屬性 x 進行定製化的能力。通過 Object.defineProperty(obj, 'x', descriptor) 可以實現一些有意思的效果:

不能被修改的屬性

const obj = {};

// 定於不能被修改的 x 屬性
Object.defineProperty(obj, 'x', {
   value: 1,
   writable: false,
});

console.log(obj.x);
// => 1

obj.x = 2;
console.log(obj.x);
// => 1
複製程式碼

不能被刪除的屬性

const obj = {};

// 定義不能被刪除的 y 屬性
Object.defineProperty(obj, 'y', {
    value: 1,
    configurable: false,
});

console.log(obj.y);
// => 1

console.log(delete obj.y);
// => false

console.log(obj.y);
// => 1
複製程式碼

不能被遍歷的屬性

const obj = {};

// 定義不能被遍歷的 z 屬性
Object.defineProperty(obj, 'z', {
    value: 1,
    enumerable: false,
});

console.log(obj, obj.z);
// => {}, 1

for (const key in obj) {
    console.log(key, obj[key]);
}
// => 沒有輸出
複製程式碼

輸入與輸出不同的屬性

const obj = {};

// 定義輸入與輸出不同的 u 屬性
Object.defineProperty(obj, 'u', {
    get: function() {
        return this._u * 2;
    },
    set: function(value) {
        this._u = value;
    },
});

obj.u = 1;
console.log(obj.u);
// => 2

複製程式碼

從上面的例子中可以瞭解到通過屬性描述符的 value | writable | configurable | enumerable | set | get 欄位可以實現神奇的效果,相信它們的含義大家也能猜出來,下面的介紹摘自 MDN - Object.defineProperty()

  • configurable:當且僅當該屬性的 configurable 為 true 時,該屬性描述符才能夠被改變,同時該屬性也能從對應的物件上被刪除。預設為 false。
  • enumerable:當且僅當該屬性的 enumerable 為 true 時,該屬性才能夠出現在物件的列舉屬性中。預設為 false。
  • value:該屬性對應的值。可以是任何有效的 JavaScript 值(數值,物件,函式等)。預設為 undefined。
  • writable:當且僅當該屬性的 writable 為 true 時,value 才能被賦值運算子改變。預設為 false。
  • get:一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined
  • set:一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。當屬性值修改時,觸發執行該方法。該方法將接受唯一引數,即該屬性新的引數值。

需要注意的是,屬性描述符分為兩類:

  • 資料描述符(data descriptor):可設定 configurable | enumerable |value | writable。
  • 儲存描述符(access descriptor):可設定 configurable | enumerable | get | set。

可以看出,一個屬性不可能同時設定 value 和 get 或者同時設定 writable 和 set 等。

對於我們最常用的物件自變數 const obj = {x: 1} 的屬性 x,其屬性描述符的值為:

{ 
	value: 1,
	writable: true,
	enumerable: true,
	configurable: true,
}
複製程式碼

函式的屬性描述符

眾所周知在 JavaScript 中一切皆物件,所以函式也有自己的屬性描述符,通過 Object.getOwnPropertyDescriptors() 來看看對於一個已定義的函式,其具有哪些屬性:

function foo(x) { 
    console.log('foo..'); 
}

console.log(Object.getOwnPropertyDescriptors(foo));

{ 
   length:
   { value: 1,
     writable: false,
     enumerable: false,
     configurable: true },
  name:
   { value: 'foo',
     writable: false,
     enumerable: false,
     configurable: true },
  arguments:
   { value: null,
     writable: false,
     enumerable: false,
     configurable: false },
  caller:
   { value: null,
     writable: false,
     enumerable: false,
     configurable: false },
  prototype:
   { value: foo {},
     writable: true,
     enumerable: false,
     configurable: false }
}
複製程式碼

從上面的程式碼中可以看出函式一共有 5 個屬性,分別為:

  1. length:函式定義的引數個數。

  2. name:函式名,注意其 writable 為 false,所以直接改變函式名 foo.name = bar 是不起作用的。

  3. arguments:函式執行時的引數,是一個類陣列,在 'use strict' 嚴格模式下無法使用。對於 ES6+,可以通過 Rest Parameters 實現同樣的功能,而且在嚴格模式下仍能使用。

    function foo(x) { 
        console.log('foo..', arguments);
    }
    
    function bar(...rest) {
        console.log('bar..', rest) 
    }
    
    foo(); bar();
    // => foo.. [Arguments]
    // => bar.. []
    
    foo(1); bar(1);
    // => foo.. [Arguments] { '0': 1 }
    // => bar.. [ 1 ]
    
    foo(1, 2); bar(1, 2);
    // => foo.. [Arguments] { '0': 1, '1': 2 }
    // => bar.. [ 1, 2 ]
    複製程式碼
  4. caller:指向函式的呼叫者,在 'use strict' 嚴格模式下無法使用:

    
    function foo() { console.log(foo.caller) }
    
    function bar() { foo() }
    
    bar();
    // => [Function: bar]
    複製程式碼
  5. prototype:指向函式的原型,與 JavaScript 中的原型鏈相關,這裡不做展開。

屬性描述符操作

知道了屬性描述符的欄位和作用,那麼當然要嘗試對其進行修改,在 JavaScript 中有四種方法可以對其進行修改,分別為:

  • Object.defineProperty(obj, prop, descriptor):當屬性的 configurable 為 true 時,可以對已有的屬性的描述符進行變更。
  • Object.preventExtensions(obj):阻止 obj 被新增新的屬性。
  • Object.seal(obj):阻止 obj 被新增新的屬性或者刪除已有的屬性。
  • Object.freeze(obj):阻止 obj 被新增新的屬性、刪除已有的屬性或者更新已有的屬性。

通過這些函式可以實現一些有意思的功能,例如阻止陣列新添或刪除元素:

const arr = [ 1 ];

arr.push(2);
// => TypeError: Cannot add property 1, object is not extensible

arr.pop();
// => TypeError: Cannot delete property '0' of [object Array]
複製程式碼

三種方式對比

回到原始碼

現在再來看 mimic-fn 的原始碼就十分簡單了,其實它只做了兩件事情:

  1. 讀取原函式的屬性。
  2. 將原函式的屬性設定到新函式上。
// 原始碼 3-1
module.exports = (to, from) => {
	for (const prop of Object.getOwnPropertyNames(from).concat(Object.getOwnPropertySymbols(from))) {
		Object.defineProperty(to, prop, Object.getOwnPropertyDescriptor(from, prop));
	}

	return to;
};
複製程式碼

這段程式碼只有一個地方需要解釋一下:當物件的屬性為 Symbol 型別時,getOwnPropertyNames 無法獲得,需要再通過 getOwnPropertySymbols 獲得之後訪問:

const obj= {
   x: 1,
   [Symbol('elvin')]: 2,
};

console.log(Object.getOwnPropertyNames(obj));
// => [ 'x' ]

console.log(Object.getOwnPropertySymbols(obj));
// => [ Symbol(elvin) ]

console.log(Reflect.ownKeys(obj));
// => [ 'x', Symbol(elvin) ]
複製程式碼

可以看到 Object.getOwnPropertyNames() 只能獲得 x,而 Object.getOwnPropertySymbols(obj) 只能獲得 Symbol('elvin'),兩者一起使用的話則可以獲得物件所有的屬性。

另外對於 Node.js >= 6.0,可以通過 Reflect.ownKeys(obj) 的方式來實現同樣的功能,而且程式碼更加的簡潔,所以我嘗試做了如下的更改:

module.exports = (to, from) => {
	for (const prop of Reflect.ownKeys(from)) {
		Object.defineProperty(to, prop, Object.getOwnPropertyDescriptor(from, prop));
	}

	return to;
};
複製程式碼

上述程式碼目前已被合進最新的 master 分支,詳情可檢視 mimic-fn PR#9

寫在最後

今天所寫的內容在平時工作中其實幾乎不會用到,所以假如大家要問了解這個有什麼用的話?

瞭解這個沒用,看完忘記了也沒問題,開心就好,權當對 JavaScript 內部機制多了一些瞭解。

關於我:畢業於華科,工作在騰訊,elvin 的部落格 歡迎來訪 ^_^

相關文章