前端基礎回顧之手寫題
前言
本文還是依然針對前端重點基礎知識點進行整體回顧系列的一篇,目標是幫助自己理解避免死記硬背。
下面針對new、Object.create、call、apply、new、bind 等基礎API,從用法到原理實現過一遍,期望看完之後大家實現時不是死記硬背而是根據理解記憶推導。
基礎準備
在探究上述內容原理之前,可以將上述API分為兩類。
一類是new、Object.create這兩者,涉及例項化物件的。
其對應的基礎內容部分和上篇前端面試基礎回顧之深入JS繼承的基礎部分相同。就是原型鏈和建構函式,這裡不再贅述。
剩下的就是關於this指向的修改。
這裡我們可以看下MDN中對this的描述。
this由呼叫時環境確定,簡單總結如下:
- 顯式指定:
new 例項化
this指向新構建的物件(new 顯式返回一個物件,則this指向該返回物件,否則指向該物件例項)
// 例如 var bar = new foo()
bind、call、apply ,指向繫結物件
var bar = foo.call( obj2 )
- 隱式指定:
函式作為物件屬性呼叫,即如object.func()形式,指向該物件。
//指向obj1 obj1.foo()
無指定
即不屬於以上情況,為預設繫結。在strict mode下,就是undefined,否則是global物件。var fun1 = obj1.foo // this指向全域性物件 fun1()
這裡順便把this指向也給過了一遍,以後遇到this指向,再複雜的都可以按照這個規律進行判斷。
既然call、apply、new、bind具備修改this指向的功能,那麼具體如何實現,就是下面要討論的內容。
手寫實現
new
用法
new 用法比較常見,舉個MDN例子:
function Car(make, model, year) { this.make = make; this.model = model; this.year = year; } const car1 = new Car('Eagle', 'Talon TSi', 1993); console.log(car1.make); // expected output: "Eagle"
這裡例項化了一個Car的例項物件car1,就不多說了。
分析
我們關注該方法功能是什麼,然後由此推如何手寫實現。
根據MDN的說法:
- 一個繼承自 Foo.prototype 的新物件被建立。
- 使用指定的引數呼叫建構函式 Foo,並將 this 繫結到新建立的物件。new Foo 等同於 new Foo(),也就是沒有指定引數列表,Foo 不帶任何引數呼叫的情況。
- 由建構函式返回的物件就是 new 表示式的結果。如果建構函式沒有顯式返回一個物件,則使用步驟1建立的物件。(一般情況下,建構函式不返回值,但是使用者可以選擇主動返回物件,來覆蓋正常的物件建立步驟)
我們要實現的點主要也有兩個:
- 實現一個新物件
- 繼承F的屬性
- 繫結this
如何實現上述兩點,就用到我們的基礎知識了。
- 新建物件,這個顯然都會。
- 繼承F的屬性
這裡說繼承可能不如說賦值更好理解一些。
對於一個建構函式來說,屬性包括兩部分例項屬性和原型屬性。
新物件要繼承其原型屬性,修改原型鏈指向即可。
繼承例項屬性,將建構函式F的this指向新物件,並執行一次就實現了對新物件的賦值。該過程順便還實現了this的繫結。
結合該思路一起來看看實現思路
實現
初版實現:
// 1.首先宣告函式my_new
function my_new(func){
// 2. 新建物件
var o = {}
// 3. 修改原型鏈
o._proto_ = func.prototype
// 示例屬性獲取,並修改this
func.call(o)
// 返回物件
return o
}
根據分析自然就實現了上面的程式碼。
不過new 還有個點分析時上面沒有提到,由建構函式返回的物件就是 new 表示式的結果。如果建構函式沒有顯式返回一個物件,則使用步驟1建立的物件。
假如建構函式返回了物件,那麼需要進行判斷func執行的結果是不是物件,不能直接返回執行結果。
// 1.首先宣告函式my_new
function my_new(func){
// 2. 新建物件
var o = {}
// 3. 修改原型鏈
o._proto_ = func.prototype
// 示例屬性獲取,並修改this
// 獲取建構函式執行結果,判斷是否有顯式返回。
var res = func.call(o)
// 視res型別決定返回物件
return typeof res === "object" ?res : o
}
到這裡new 的實現就完成了。
Object.create
用法
該方法建立一個新物件,使用現有的物件來提供新建立的物件的__proto__。即基於現有物件建立一個新的物件,直接看程式碼比較直接:
const person = {
isHuman: false,
printIntroduction: function () {
console.log(`My name is ${this.name}. Am I human? ${this.isHuman}`);
}
};
const me = Object.create(person);
me.name = "Matthew"; // "name" is a property set on "me", but not on "person"
me.isHuman = true; // inherited properties can be overwritten
me.printIntroduction();
// expected output: "My name is Matthew. Am I human? true"
分析
該方法的功能在於兩點:
- 一個新物件
- 帶有指定的原型物件和屬性
結合上述,倒序來分析:
- 帶有指定的原型物件和屬性
這裡比較特殊,因為原始物件不是建構函式,要繼承其所有屬性的話,還是要藉助建構函式來實現,即原始物件person,作為新建構函式F的原型物件,新物件me是F的例項。 一個新物件
新的物件可以是字面量宣告,也可以通過使用new來例項化。這裡就是後者了。這也是倒序分析的原因。實現
// 1. 宣告函式
function create(Obj){
// 2. 新建建構函式
function F() {}
// 3. 原型鏈修改
F.prototype = Obj
// 4.新建物件
return new F()
}
至於ES6正式規範中還是可以第二個引數的情況暫時不補充,我也沒有見到比較好的實現,大家可以補充。
call和apply
這兩者用法和實現差別不大,就放一起分析了。
用法
採用W3C的例子
//call 用法
var person = {
firstName:"Steve",
lastName: "Jobs",
fullName: function() {
return this.firstName + " " + this.lastName;
}
}
var person1 = {
firstName:"Bill",
lastName: "Gates",
}
person.fullName.call(person1); // "Bill Gates"
// apply 用法
person.fullName.apply(person1); // 將返回 "Bill Gates"
這裡沒有體現出兩者差別,差別在於傳參的不同。
call() 方法分別接受引數。
apply() 方法接受陣列形式的引數。
分析
call函式的功能有如下幾點:
- 改變函式中this指向
獲取後續引數則並執行
針對以上兩點,主要在於如何改變this指向。
- 回顧準備裡面的內容,改變this指向的方法,除去顯式的,我們也只剩下作為物件屬性呼叫了。
即將函式賦值給被呼叫物件,作為其屬性方法執行,至於引數執行時呼叫就好。
不過這裡有些點要注意
- 我們給被呼叫物件增加屬性,執行完畢之後還是要刪除的,避免與其他操作。
- 同樣增加屬性時,屬性名也要注意避免衝突,最好直接使用Symbol。
實現
call的實現:
// 函式
Function.prototype._call = function (ctx) {
// 1. 構造被呼叫物件,相容預設值
var obj = ctx || window
// 2. 獲取後續引數
var args = Array.from(arguments).slice(1)
// 3. 獲取唯一屬性名
var fun = Symbol()
// 4. 增加屬性方法,指向待呼叫函式
obj[fun] = this
var result = obj[fun](...args)
// 5. 執行完畢後,刪除該屬性
delete obj[fun]
return result
}
apply實現與call很類似只是引數處理有些差別。
Function.prototype._apply = function (ctx) {
var obj = ctx || window
var args = Array.from(arguments).slice(1)
var fun = Symbol()
// 引數處理
var result = obj[fun](args.join(','))
delete obj[fun]
return result
}
bind
用法
MDN描述:bind() 方法建立一個新的函式,在 bind() 被呼叫時,這個新函式的 this 被指定為 bind() 的第一個引數,而其餘引數將作為新函式的引數,供呼叫時使用。
使用方式如下:
const module = {
x: 42,
getX: function() {
return this.x;
}
}
const unboundGetX = module.getX;
console.log(unboundGetX()); // The function gets invoked at the global scope
// expected output: undefined
const boundGetX = unboundGetX.bind(module);
console.log(boundGetX());// expected output: 42
分析
其功能分為如下幾點:
- 修改this指向到指定物件
- 返回函式,可被後續執行
- 引數處理,較簡單
解決思路:
- this指向
因為call等的實現,這裡就可以偷懶了,使用apply來實現 - 返回一個函式
常見的閉包形式 - 引數處理
可能稍微複雜點的在於,執行時要考慮後續的引數拼接。
實現
初版實現:
Function.prototype._bind = function(ctx){
// 1. 相容判斷
var ctx = ctx || window
// 2. 保留當前獲取引數
var args = Array.from(arguments).slice(1)
var _this = this
// 3. 返回函式
return function F (arguments){
// 4. 繫結this指向,拼接新增引數
return _this.apply(ctx,args.concat(arguments))
}
}
上述完成了初版的功能要求,但是bind還有一種情況,返回的畢竟是個函式,就可以當做建構函式與new 結合使用。
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.toString = function () {
return this.x + ',' + this.y;
};
var emptyObj = {};
var YAxisPoint = Point.bind(emptyObj, 0/*x*/);
var axisPoint = new YAxisPoint(5);
axisPoint.toString(); // '0,5' 此時this指向當前示例物件,而非emptyObj
這種場景下,為什麼this指向了例項物件,主要是new 本身的功能體現。
而我們的api要支援new 的情況還是要結合new 的功能來看。
new 通過呼叫建構函式,產生了一個示例物件。主要是下面這段程式碼。
var res = func.call(o)
結合到我們的call中,此時func即為我們return 的F函式。
即此時函式中的this 為F的示例,由此可以區分兩種場景。
Function.prototype._bind = function (ctx) {
// 1. 相容判斷
var ctx = ctx || window
// 2. 保留當前獲取引數
var args = Array.from(arguments).slice(1)
var _this = this
// 3. 返回函式
return function F(arguments) {
// 4.判斷是否new 場景
if(this instanceof F){
// 5. 此時直接執行建構函式
return new _this(...args, ...arguments)
}else{
// 5. 常規場景,依然繫結this指向,拼接新增引數
return _this.apply(ctx, args.concat(arguments))
}
}
}
結束語
到這裡,幾個簡單的手寫題就總結完畢了,上面的例子多出自MDN。當然上面的程式碼都存在一個問題就是對於異常的處理。這裡就不列出了,大家可以自行補充。對於前面提到的js繼承的基礎,可以看我前面的文章。還是同樣一句話共勉你我,你若盛開蝴蝶自來