首頁 > web前端 > js教程 > 你所不知道的JavaScript

你所不知道的JavaScript

coldplay.xixi
發布: 2020-11-17 16:30:35
轉載
2473 人瀏覽過

JavaScript欄位介紹一些必會操作。

你所不知道的JavaScript

和其他“圈子”裡的同學們不一樣,前端圈子裡的同學們都很熱衷於“手寫xxx方法”,基本上每天在掘金裡都可以看到類似的文章。但是,很多文章(不代表全部,無意冒犯)大都是囫圇吞棗、依葫蘆畫瓢,經不起推敲和考究,很容易誤導那些對JavaScript剛入門的新同學。

有鑑於此,本文將基於《你不知道的JavaScript》(小黃書)裡一些典型的知識點,結合一些經典的、高頻的被「手寫」的方法來逐一地原理和實現結合,和同學一起在搞懂原理的基礎上再去手寫程式碼。

一、運算子new

在講解它之前我們首先需要澄清一個非常常見的關於JavaScript 中函數和物件的誤解:

在傳統的物件導向的語言中,「建構子」是類別中的一些特殊方法,使用new 初始化類別時會呼叫類別中的建構子。通常的形式是這樣的:

something = new MyClass(..);复制代码
登入後複製

JavaScript 也有一個new 運算子,使用方法看起來也和那些以類別為導向的語言一樣,絕大多數開發者都認為JavaScript 中new 的機制也和那些語言一樣。然而,JavaScript 中 new 的機制其實和物件導向的語言完全不同。

首先我們重新定義一下 JavaScript 中的「建構子」。在 JavaScript 中,建構函式只是一些使用 new 運算子時被呼叫的函數。它們並不會屬於某個類,也不會實例化一個類別。實際上,它們甚至不能說是一種特殊的函數類型,它們只是被 new 運算子呼叫的普通函數而已。

實際上並不存在所謂的“建構函數”,只有對於函數的“構造呼叫”。 使用new 來呼叫函數,或者說發生建構函數呼叫時,會自動執行下面的操作:

  1. 建立(或說建構子)一個全新的物件;
  2. 這個新物件會被執行[[ 原型]] 連接;
  3. 這個新物件會綁定到函數呼叫的this ;
  4. 如果函數沒有傳回其他對象,那麼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()方法搞清楚它是如何做到的。

二、運算子instanceof

在相當長的一段時間裡,JavaScript 只有一些近似類別的語法元素,如newinstanceof ,不過在後來的ES6 中新增了一些元素,像是class 關鍵字。

在不考慮class的前提下,newinstanceof之間的關係「曖昧不清」。之所以會出現newinstanceof這些運算子,其主要目的就是為了向「物件導向程式設計」靠攏。

因此,我們既然搞懂了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()

Object.create()方法建立一個新對象,使用現有的對象來提供新建立的對象的__proto__

在《你不知道的JavaScript》中,多次用到了Object.create()这个方法去模仿传统面向对象编程中的“继承”,其中也包括上面讲到了new操作符的实现过程。在MDN中对它的介绍也很简短,主要内容大都在描述可选参数propertiesObject的用法。

简单起见,为了和newinstanceof的知识串联起来,我们只着重关注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__属性的方式,因为每个浏览器的实现不尽相同,而且在规范中也没有明确该属性名)。

四、Function的原型方法:call、apply和bind

作为最经典的手写“劳模”们,callapplybind已经被手写了无数遍。也许本文中手写的版本是无数个前辈们写过的某个版本,但是有一点不同的是,本文会告诉你为什么要这样写,让你搞懂了再写。

在《你不知道的JavaScript上卷》第二部分的第1章和第2章,用了2章斤30页的篇幅中详细地介绍了this的内容,已经充分说明了this的重要性和应用场景的复杂性。

而我们要实现的callapplybind最为人所知的功能就是使用指定的thisArg去调用函数,使得函数可以使用我们指定的thisArg作为它运行时的上下文。

《你不知道的JavaScript》总结了四条规则来判断一个运行中函数的this到底是绑定到哪里:

  1. new 调用?绑定到新创建的对象。
  2. call 或者 apply (或者 bind )调用?绑定到指定的对象。
  3. 由上下文对象调用?绑定到那个上下文对象。
  4. 默认:在严格模式下绑定到 undefined ,否则绑定到全局对象。

更具体一点,可以描述为:

  1. 函数是否在 new 中调用( new 绑定)?如果是的话 this 绑定的是新创建的对象:
var bar = new foo()复制代码
登入後複製
  1. 函数是否通过 callapply (显式绑定)或者硬绑定(bind)调用?如果是的话, this 绑定的是指定的对象:
var bar = foo.call(obj2)复制代码
登入後複製
  1. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话, this 绑定的是那个上下文对象:
var bar = obj1.foo()复制代码
登入後複製
  1. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined ,否则绑定到全局对象:
var bar = foo()复制代码
登入後複製

就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。

至此,你已经搞明白了this的全部绑定规则,而我们要去手写实现的callapplybind只是其中的一条规则(第2条),因此,我们可以在另外3条规则的基础上很容易地组织代码实现。

4.1 call和apply

实现callapply的通常做法是使用“隐式绑定”的规则,只需要绑定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)作为键名是为了避免和上下文对象的其他键名冲突,从而导致覆盖了原有的属性键值对。

4.2 bind

在《你不知道的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函数的xy两个参数变成了先用一个函数接收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中文網其他相關文章!

相關標籤:
來源:juejin.im
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
作者最新文章
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板