和其他“圈子”裡的同學們不一樣,前端圈子裡的同學們都很熱衷於“手寫xxx方法”,基本上每天在掘金裡都可以看到類似的文章。但是,很多文章(不代表全部,無意冒犯)大都是囫圇吞棗、依葫蘆畫瓢,經不起推敲和考究,很容易誤導那些對JavaScript剛入門的新同學。
有鑑於此,本文將基於《你不知道的JavaScript》(小黃書)裡一些典型的知識點,結合一些經典的、高頻的被「手寫」的方法來逐一地原理和實現結合,和同學一起在搞懂原理的基礎上再去手寫程式碼。
在講解它之前我們首先需要澄清一個非常常見的關於JavaScript 中函數和物件的誤解:
在傳統的物件導向的語言中,「建構子」是類別中的一些特殊方法,使用new
初始化類別時會呼叫類別中的建構子。通常的形式是這樣的:
something = new MyClass(..);复制代码
JavaScript 也有一個new
運算子,使用方法看起來也和那些以類別為導向的語言一樣,絕大多數開發者都認為JavaScript 中new
的機制也和那些語言一樣。然而,JavaScript 中 new
的機制其實和物件導向的語言完全不同。
首先我們重新定義一下 JavaScript 中的「建構子」。在 JavaScript 中,建構函式只是一些使用 new
運算子時被呼叫的函數。它們並不會屬於某個類,也不會實例化一個類別。實際上,它們甚至不能說是一種特殊的函數類型,它們只是被 new
運算子呼叫的普通函數而已。
實際上並不存在所謂的“建構函數”,只有對於函數的“構造呼叫”。
使用new
來呼叫函數,或者說發生建構函數呼叫時,會自動執行下面的操作:
因此,如果我們要想寫出一個合乎理論的new
,就必須嚴格按照上面的步驟,落實到程式碼上就是:
/** * @param {fn} Function(any) 构造函数 * @param {arg1, arg2, ...} 指定的参数列表 */ function myNew (fn, ...args) { // 创建一个新对象,并把它的原型链(__proto__)指向构造函数的原型对象 const instance = Object.create(fn.prototype) // 把新对象作为thisArgs和参数列表一起使用call或apply调用构造函数 const result = fn.apply(instance, args) 如果构造函数的执行结果返回了对象类型的数据(排除null),则返回该对象,否则返新对象 return (result && typeof instance === 'object') ? result : instance } 复制代码
範例程式碼中,我們使用
Object.create(fn.prototype)
建立空對象,使其的原型鏈__proto__
指向建構子的原型物件fn. prototype
,後面我們也會自己手寫一個Object.create()
方法搞清楚它是如何做到的。
在相當長的一段時間裡,JavaScript 只有一些近似類別的語法元素,如new
和instanceof
,不過在後來的ES6 中新增了一些元素,像是class
關鍵字。
在不考慮class
的前提下,new
和instanceof
之間的關係「曖昧不清」。之所以會出現new
和instanceof
這些運算子,其主要目的就是為了向「物件導向程式設計」靠攏。
因此,我們既然搞懂了new
,就沒有理由不去搞清楚instanceof
。引用MDN上對於instanceof
的描述:“instanceof
運算子用於檢測建構子的prototype
屬性是否出現在某個實例物件的原型鏈上” 。
看到這裡,基本上明白了,instanceof
的實作需要考驗你對原型鍊和prototype
的理解。在JavaScript中關於原型和原型鏈的內容需要大篇幅的內容才能講述得清楚,而網上也有一些不錯的總結博文,其中幫你徹底搞懂JS中的prototype、__proto__與constructor(圖解)就是一篇難得的精品文章,通透得整理並總結了它們之間的關係和連結。
《你不知道的JavaScript上卷》第二部分-第5章則更基礎、更全面地得介紹了原型相關的內容,值得一讀。
以下instanceof
程式碼的實現,雖然很簡單,但是需要建立在你對原型和原型鏈有所了解的基礎之上,建議你先把以上的博文或文章看懂了再繼續。
/** * @param {left} Object 实例对象 * @param {right} Function 构造函数 */ function myInstanceof (left, right) { // 保证运算符右侧是一个构造函数 if (typeof right !== 'function') { throw new Error('运算符右侧必须是一个构造函数') return } // 如果运算符左侧是一个null或者基本数据类型的值,直接返回false if (left === null || !['function', 'object'].includes(typeof left)) { return false } // 只要该构造函数的原型对象出现在实例对象的原型链上,则返回true,否则返回false let proto = Object.getPrototypeOf(left) while (true) { // 遍历完了目标对象的原型链都没找到那就是没有,即到了Object.prototype if (proto === null) return false // 找到了 if (proto === right.prototype) return true // 沿着原型链继续向上找 proto = Object.getPrototypeOf(proto) } }复制代码
Object.create()
方法建立一個新對象,使用現有的對象來提供新建立的對象的__proto__
。
在《你不知道的JavaScript》中,多次用到了Object.create()
这个方法去模仿传统面向对象编程中的“继承”,其中也包括上面讲到了new
操作符的实现过程。在MDN中对它的介绍也很简短,主要内容大都在描述可选参数propertiesObject
的用法。
简单起见,为了和new
、instanceof
的知识串联起来,我们只着重关注Object.create()
的第一个参数proto
,咱不讨论propertiesObject
的实现和具体特性。
/** * 基础版本 * @param {Object} proto * */ Object.prototype.create = function (proto) { // 利用new操作符的特性:创建一个对象,其原型链(__proto__)指向构造函数的原型对象 function F () {} F.prototype = proto return new F() } /** * 改良版本 * @param {Object} proto * */ Object.prototype.createX = function (proto) { const obj = {} // 一步到位,把一个空对象的原型链(__proto__)指向指定原型对象即可 Object.setPrototypeOf(obj, proto) return obj }复制代码
我们可以看到,Object.create(proto)
做的事情转换成其他方法实现很简单,就是创建一个空对象,再把这个对象的原型链属性(Object.setPrototype(obj, proto)
)指向指定的原型对象proto
就可以了(不要采用直接赋值__proto__
属性的方式,因为每个浏览器的实现不尽相同,而且在规范中也没有明确该属性名)。
作为最经典的手写“劳模”们,call
、apply
和bind
已经被手写了无数遍。也许本文中手写的版本是无数个前辈们写过的某个版本,但是有一点不同的是,本文会告诉你为什么要这样写,让你搞懂了再写。
在《你不知道的JavaScript上卷》第二部分的第1章和第2章,用了2章斤30页的篇幅中详细地介绍了this
的内容,已经充分说明了this
的重要性和应用场景的复杂性。
而我们要实现的call
、apply
和bind
最为人所知的功能就是使用指定的thisArg
去调用函数,使得函数可以使用我们指定的thisArg
作为它运行时的上下文。
《你不知道的JavaScript》总结了四条规则来判断一个运行中函数的this
到底是绑定到哪里:
new
调用?绑定到新创建的对象。call
或者 apply
(或者 bind
)调用?绑定到指定的对象。undefined
,否则绑定到全局对象。更具体一点,可以描述为:
new
中调用( new
绑定)?如果是的话 this
绑定的是新创建的对象:var bar = new foo()复制代码
call
、 apply
(显式绑定)或者硬绑定(bind
)调用?如果是的话, this
绑定的是指定的对象:var bar = foo.call(obj2)复制代码
this
绑定的是那个上下文对象:var bar = obj1.foo()复制代码
undefined
,否则绑定到全局对象:var bar = foo()复制代码
就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this
的绑定原理了。
至此,你已经搞明白了this
的全部绑定规则,而我们要去手写实现的call
、apply
和bind
只是其中的一条规则(第2条),因此,我们可以在另外3条规则的基础上很容易地组织代码实现。
实现call
和apply
的通常做法是使用“隐式绑定”的规则,只需要绑定thisArg
对象到指定的对象就好了,即:使得函数可以在指定的上下文对象中调用:
const context = { name: 'ZhangSan' } function sayName () { console.log(this.name) } context.sayName = sayName context.sayName() // ZhangSan复制代码
这样,我们就完成了“隐式绑定”。落实到具体的代码实现上:
/** * @param {context} Object * @param {arg1, arg2, ...} 指定的参数列表 */ Function.prototype.call = function (context, ...args) { // 指定为 null 或 undefined 时会自动替换为指向全局对象,原始值会被包装 if (context === null || context === undefined) { context = window } else if (typeof context !== 'object') { context = new context.constructor(context) } else { context = context } const func = this const fn = Symbol('fn') context[fn] = func const result = context[fn](...args) delete context[fn] return result } /** * @param {context} * @param {args} Array 参数数组 */ Function.prototype.apply = function (context, args) { // 和call一样的原理 if (context === null || context === undefined) { context = window } else if (typeof context !== 'object') { context = new context.constructor(context) } else { context = context } const fn = Symbol('fn') const func = this context[fn] = func const result = context[fn](...args) delete context[fn] return result }复制代码
细看下来,大家都那么聪明,肯定一眼就看到了它们的精髓所在:
const fn = Symbol('fn') const func = this context[fn] = func复制代码
在这里,我们使用Symbol('fn')
作为上下文对象的键,对应的值指向我们想要绑定上下文的函数this
(因为我们的方法是声明在Function.prototype
上的),而使用Symbol(fn)
作为键名是为了避免和上下文对象的其他键名冲突,从而导致覆盖了原有的属性键值对。
在《你不知道的JavaScript》中,手动实现了一个简单版本的bind
函数,它称之为“硬绑定”:
function bind(fn, obj) { return function() { return fn.apply( obj, arguments ); }; }复制代码
硬绑定的典型应用场景就是创建一个包裹函数,传入所有的参数并返回接收到的所有值。
由于硬绑定是一种非常常用的模式,所以在 ES5 中提供了内置的方法 Function.prototype.bind
,它的用法如下:
function foo(something) { console.log( this.a, something ) return this.a + something; } var obj = { a:2 } var bar = foo.bind( obj ) var b = bar( 3 ); // 2 3 console.log( b ); // 5复制代码
bind(..)
会返回一个硬编码的新函数,它会把参数设置为 this
的上下文并调用原始函数。
MDN是这样描述
bind
方法的:bind()
方法创建一个新的函数,在bind()
被调用时,这个新函数的this
被指定为bind()
的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
因此,我们可以在此基础上实现我们的bind
方法:
/** * @param {context} Object 指定为 null 或 undefined 时会自动替换为指向全局对象,原始值会被包装 * * @param {arg1, arg2, ...} 指定的参数列表 * * 如果 bind 函数的参数列表为空,或者thisArg是null或undefined,执行作用域的 this 将被视为新函数的 thisArg */ Function.prototype.bind = function (context, ...args) { if (typeof this !== 'function') { throw new TypeError('必须使用函数调用此方法'); } const _self = this // fNOP存在的意义: // 1. 判断返回的fBound是否被new调用了,如果是被new调用了,那么fNOP.prototype自然是fBound()中this的原型 // 2. 使用包装函数(_self)的原型对象覆盖自身的原型对象,然后使用new操作符构造出一个实例对象作为fBound的原型对象,从而实现继承包装函数的原型对象 const fNOP = function () {} const fBound = function (...args2) { // fNOP.prototype.isPrototypeOf(this) 为true说明当前结果是被使用new操作符调用的,则忽略context return _self.apply(fNOP.prototype.isPrototypeOf(this) && context ? this : context, [...args, ...args2]) } // 绑定原型对象 fNOP.prototype = this.prototype fBound.prototype = new fNOP() return fBound }复制代码
具体的实现细节都标注了对应的注释,涉及到的原理都有在上面的内容中讲到,也算是一个总结和回顾吧。
维基百科:柯里化,英语:Currying,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术 。
看这个解释有一点抽象,我们就拿被做了无数次示例的add
函数,来做一个简单的实现:
// 普通的add函数 function add(x, y) { return x + y } // Currying后 function curryingAdd(x) { return function (y) { return x + y } } add(1, 2) // 3 curryingAdd(1)(2) // 3复制代码
实际上就是把add
函数的x
,y
两个参数变成了先用一个函数接收x
然后返回一个函数去处理y
参数。现在思路应该就比较清晰了,就是只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
函数柯里化在一定场景下,有很多好处,如:参数复用、提前确认和延迟运行等,具体内容可以拜读下这篇文章,个人觉得受益匪浅。
最简单的实现函数柯里化的方式就是使用Function.prototype.bind
,即:
function curry(fn, ...args) { return fn.length <= args.length ? fn(...args) : curry.bind(null, fn, ...args); }复制代码
如果用ES5代码实现的话,会比较麻烦些,但是核心思想是不变的,就是在传递的参数满足调用函数之前始终返回一个需要传参剩余参数的函数:
// 函数柯里化指的是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。 function curry(fn, args) { args = args || [] // 获取函数需要的参数长度 let length = fn.length return function() { let subArgs = args.slice(0) // 拼接得到现有的所有参数 for (let i = 0; i < arguments.length; i++) { subArgs.push(arguments[i]) } // 判断参数的长度是否已经满足函数所需参数的长度 if (subArgs.length >= length) { // 如果满足,执行函数 return fn.apply(this, subArgs) } else { // 如果不满足,递归返回科里化的函数,等待参数的传入 return curry.call(this, fn, subArgs) } }; }复制代码
在本文中,我们熟悉了new
的底层执行原理、instanceof
和原型链直接的密切关系、Object.create()
是如何实现的原型链指定以及JavaScript中最复杂最难搞定的this
的绑定问题。
当然,在《你不知道的JavaScript》中还有很多精妙的见解和知识内容,如"JavaScript是需要先编译
再执行的"、"面相对象编程只是JavaScript中的一种设计模式"等等独到的见解和观点。墙裂推荐大家多读几遍,每一遍都会有不同的收获。
相关免费学习推荐:JavaScript(视频)
以上是你所不知道的JavaScript的詳細內容。更多資訊請關注PHP中文網其他相關文章!