系列文章:
昨天閱讀 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}
是最簡單的物件,x
是 obj
的一個屬性。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 個屬性,分別為:
-
length:函式定義的引數個數。
-
name:函式名,注意其
writable
為 false,所以直接改變函式名foo.name = bar
是不起作用的。 -
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 ] 複製程式碼
-
caller:指向函式的呼叫者,在 'use strict' 嚴格模式下無法使用:
function foo() { console.log(foo.caller) } function bar() { foo() } bar(); // => [Function: bar] 複製程式碼
-
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 的原始碼就十分簡單了,其實它只做了兩件事情:
- 讀取原函式的屬性。
- 將原函式的屬性設定到新函式上。
// 原始碼 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 的部落格 歡迎來訪 ^_^