三千文字,也沒寫好 Function.prototype.call

前言

Function.prototype.call,手寫系列,萬文面試系列,必會系列必包含的內容,足見其在前端的份量。
本文基於MDNECMA 標準,和你們一塊兒重新認識calljavascript

涉及知識點:前端

  1. undefined
  2. void 一元運算符
  3. 嚴格模式和非嚴格模式
  4. 瀏覽器和nodejs環境識別
  5. 函數反作用 (純函數)
  6. eval
  7. Content-Security-Policy
  8. delete
  9. new Function
  10. Object.freeze
  11. 對象屬性檢查
  12. 面試現場
  13. ECMA規範和瀏覽器廠商之間的愛恨情仇

掘金流行的版本

面試官的問題:
麻煩你手寫一下Function.prototype.calljava

基於ES6的拓展運算符版本

Function.prototype.call = function(context) {
    context = context || window;
    context["fn"] = this;
    let arg = [...arguments].slice(1); 
    context["fn"](...arg);
    delete context["fn"];
}
複製代碼

這個版本,應該不是面試官想要的真正答案。不作太多解析。node

基於eval的版本

Function.prototype.call = function (context) {
  context = (context == null || context == undefined) ? window : new  Object(context);
  context.fn = this;
  var arr = [];
  for (var i = 1; i < arguments.length; i++) {
    arr.push('arguments[' + i + ']');
  }
  var r = eval('context.fn(' + arr + ')');
  delete context.fn;
  return r;
}
複製代碼

這個版本值得完善的地方面試

  1. this 是否是函數沒有進行判斷
  2. 使用undefined進行判斷,安全不安全
    undefined 可能被改寫,(高版本瀏覽器已作限制)。
  3. 直接使用window做爲默認上下文,過於武斷。
    腳本運行環境,瀏覽器? nodejs?
    函數運行模式,嚴格模式,非嚴格模式?
  4. eval 必定會被容許執行嗎
  5. delete context.fn 有沒有產生反作用
    context上要是原來有fn屬性呢

在咱們真正開始寫Function.prototype.call以前,仍是先來看看MDN和 ECMA是怎麼定義她的。算法

MDN call 的說明

語法

function.call(thisArg, arg1, arg2, ...)
複製代碼

參數

thisArg編程

可選的。在 function 函數運行時使用的 this 值。請注意,this可能不是該方法看到的實際值:若是這個函數處於非嚴格模式下,則指定爲 null 或 undefined 時會自動替換爲指向全局對象原始值會被包裝
arg1, arg2, ...
指定的參數列表。小程序

透露的信息

這裏透露了幾個信息,我已經加粗標註:瀏覽器

  1. 非嚴格模式,對應的有嚴格模式
  2. 這裏說的是指向 全局對象,沒有說是window。固然MDN這裏說是window也沒太大問題。我想補充的是 nodejs 也實現了 ES標準。因此咱們實現的時候,是否是要考慮到 nodejs環境呢。
  3. 原始值會被包裝。怎麼個包裝呢,Object(val),即完成了對原始值val的包裝。

ES標準

Function.prototype.call() - JavaScript | MDN的底部羅列了ES規範版本,每一個版本都有call實現的說明。安全

咱們實現的,是要基於ES的某個版原本實現的。

由於ES的版本不一樣,實現的細節可能不同,實現的環境也不同。

規範版本 狀態 說明
ECMAScript 1st Edition (ECMA-262) Standard 初始定義。在 JavaScript 1.3 中實現。
ECMAScript 5.1 (ECMA-262)
Function.prototype.call
Standard
ECMAScript 2015 (6th Edition, ECMA-262)
Function.prototype.call
Standard
ECMAScript (ECMA-262)
Function.prototype.call
Living Standard

在ES3標準中關於call的規範說明在11.2.3 Function Calls, 直接搜索就能查到。

咱們今天主要是基於2009年ES5標準下來實現Function.prototype.call,有人可能會說,你這,爲嘛不在 ES3標準下實現,由於ES5下能涉及更多的知識點。

不可靠的undefined

(context == null || context == undefined) ? window : new Object(context)

上面代碼的 undefined 不必定是可靠的。

引用一段MDN的話:

在現代瀏覽器(JavaScript 1.8.5/Firefox 4+),自ECMAscript5標準以來undefined是一個不能被配置(non-configurable),不能被重寫(non-writable)的屬性。即使事實並不是如此,也要避免去重寫它。

在沒有交代上下文的狀況使用 void 0 比直接使用 undefined 更爲安全。

有些同窗可能沒見過undefined被改寫的狀況,沒事,來一張圖:

image.png

void 這個一元運算法除了這個 準備返回 undefined外, 還有另外兩件常見的用途:

  1. a標籤的href,就是什麼都不作
    <a href="javascript:void(0);">

  2. IIFE當即執行

;void function(msg){
    console.log(msg)
}("你好啊");

複製代碼

固然更直接的方式是:

;(function(msg){
    console.log(msg)
})("你好啊");
複製代碼

瀏覽器和nodejs環境識別

瀏覽器環境:

typeof self == 'object' && self.self === self
複製代碼

nodejs環境:

typeof global == 'object' && global.global === global
複製代碼

如今已經有 globalThis, 在高版本瀏覽器和nodejs裏面都支持。

顯然,在咱們的這個場景下,還不能用,可是其思想能夠借鑑:

var getGlobal = function () {
  if (typeof self !== 'undefined') { return self; }
  if (typeof window !== 'undefined') { return window; }
  if (typeof global !== 'undefined') { return global; }
  throw new Error('unable to locate global object');
};
複製代碼

嚴格模式

是否支持嚴格模式

Strict mode 嚴格模式,是ES5引入的特性。那咱們怎麼驗證你的環境是否是支持嚴格模式呢?

var hasStrictMode = (function(){ 
 "use strict";
 return this == undefined;
}());
複製代碼

正常狀況都會返回true,放到IE8裏面執行:

image.png

在非嚴格模式下,函數的調用上下文(this的值)是全局對象。在嚴格模式下,調用上下文是undefined。

是否處於嚴格模式下

知道是否是支持嚴格模式,還不夠,咱們還要知道咱們是否是處於嚴格模式下。

以下的代碼能夠檢測,是否是處於嚴格模式:

var isStrict = (function(){
  return this === undefined;
}());
複製代碼

這段代碼在支持嚴格模式的瀏覽器下和nodejs環境下都是工做的。

函數反作用

var r = eval('context.fn(' + arr + ')');
  delete context.fn;
複製代碼

如上的代碼直接刪除了context上的fn屬性,若是原來的context上有fn屬性,那會不會丟失呢?

咱們採用eval版本的call, 執行下面的代碼

var context = {
  fn: "i am fn",
  msg: "i am msg"
}

log.call(context);  // i am msg

console.log("msg:", context.msg); // i am msg
console.log("fn:", context.fn); // fn: undedined
複製代碼

能夠看到context的fn屬性已經被幹掉了,是破壞了入參,產生了不應產生的反作用。
與反作用對應的是函數式編程中的 純函數

對應的咱們要採起行動,基本兩種思路:

  1. 造一個不會重名的屬性
  2. 保留現場而後還原現場

均可以,不過以爲 方案2更簡單和容易實現:
基本代碼以下:

var ctx = new Object(context);

var propertyName = "__fn__";
var originVal;
var hasOriginVal = ctx.hasOwnProperty(propertyName)
if(hasOriginVal){
    originVal = ctx[propertyName]
}

...... // 其餘代碼

if(hasOriginVal){
    ctx[propertyName] = originVal;
}
  
複製代碼

基於eval的實現,基本以下

基於標準ECMAScript 5.1 (ECMA-262) Function.prototype.call

When the call method is called on an object func with argument thisArg and optional arguments arg1, arg2 etc, the following steps are taken:

1. If IsCallable(func) is false, then throw a TypeError exception.

2. Let argList be an empty List.

3. If this method was called with more than one argument then in left to right 
order starting with arg1 append each argument as the last element of argList

4. Return the result of calling the [[Call]] internal method of func, providing 
thisArg as the this value and argList as the list of arguments.
The length property of the call method is 1.

NOTE The thisArg value is passed without modification as the this value. This is a 
change from Edition 3, where a undefined or null thisArg is replaced with the  
global object and ToObject is applied to all other values and that result is passed 
as the this value.
複製代碼

對咱們比較重要的是 1Note:

看看咱們的基礎實現

var hasStrictMode = (function () {
 "use strict";
    return this == undefined;
}());

var isStrictMode = function () {
    return this === undefined;
};

var getGlobal = function () {
    if (typeof self !== 'undefined') { return self; }
    if (typeof window !== 'undefined') { return window; }
    if (typeof global !== 'undefined') { return global; }
    throw new Error('unable to locate global object');
};

function isFunction(fn){
    return typeof fn === "function";
}

function getContext(context) {

    var isStrict = isStrictMode();

    if (!hasStrictMode || (hasStrictMode && !isStrict)) {
        return (context === null || context === void 0) ? getGlobal() : Object(context);
    }

    // 嚴格模式下, 妥協方案
    return Object(context);
}

Function.prototype.call = function (context) {

    // 不能夠被調用
    if (typeof this !== 'function') {
        throw new TypeError(this + ' is not a function');
    }
    
    // 獲取上下文
    var ctx = getContext(context);

    // 更爲穩妥的是建立惟一ID, 以及檢查是否有重名
    var propertyName = "__fn__" + Math.random() + "_" + new Date().getTime();
    var originVal;
    var hasOriginVal = isFunction(ctx.hasOwnProperty) ? ctx.hasOwnProperty(propertyName) : false;
    if (hasOriginVal) {
        originVal = ctx[propertyName]
    }
    
    ctx[propertyName] = this;    

    // 採用string拼接
    var argStr = '';
    var len = arguments.length;
    for (var i = 1; i < len; i++) {
        argStr += (i === len - 1) ? 'arguments[' + i + ']' : 'arguments[' + i + '],'
    }
    var r = eval('ctx["' + propertyName + '"](' + argStr + ')');

    // 還原現場
    if (hasOriginVal) {
        ctx[propertyName] = originVal;
    } else {
        delete ctx[propertyName]
    }

    return r;
}

複製代碼

當前版依舊存在問題,

  1. 嚴格模式下,咱們用依然用Obeject進行了封裝。

會致使嚴格模式下傳遞非對象的時候,this的指向是不許的, 不得以的妥協。 哪位同窗有更好的方案,敬請指導。

  1. 雖然說咱們把臨時的屬性名變得難以重名,可是若是重名,而函數調用中真調用了此方法,可能會致使異常行爲。

因此完美的解決方法,就是產生一個UID.

  1. eval的執行,可能會被 Content-Security-Policy 阻止

大體的提示信息以下:

[Report Only] Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an   
allowed source of script in the following Content Security Policy directive: "script-src ......... 複製代碼

image.png

前面兩條都應該還能接受,至於第三條,咱們不能妥協。

這就得請出下一位嘉賓, new Function

new Function

new Function ([arg1[, arg2[, ...argN]],] functionBody)

其基本格式如上,最後一個爲函數體。

舉個簡單的例子:

const sum = new Function('a', 'b', 'return a + b');

console.log(sum(2, 6));
// expected output: 8
複製代碼

咱們call的參數個數是不固定,思路就是從arguments動態獲取。

這裏咱們的實現借用面試官問:可否模擬實現JS的call和apply方法 實現方法:

function generateFunctionCode(argsArrayLength){
    var code = 'return arguments[0][arguments[1]](';
    for(var i = 0; i < argsArrayLength; i++){
        if(i > 0){
            code += ',';
        }
        code += 'arguments[2][' + i + ']';
    }
    code += ')';
    // return arguments[0][arguments[1]](arg1, arg2, arg3...)
    return code;
}

複製代碼

基於 new Function的實現

var hasStrictMode = (function () {
 "use strict";
    return this == undefined;
}());

var isStrictMode = function () {
    return this === undefined;
};

var getGlobal = function () {
    if (typeof self !== 'undefined') { return self; }
    if (typeof window !== 'undefined') { return window; }
    if (typeof global !== 'undefined') { return global; }
    throw new Error('unable to locate global object');
};

function isFunction(fn){
    return typeof fn === "function";
}

function getContext(context) {
    var isStrict = isStrictMode();

    if (!hasStrictMode || (hasStrictMode && !isStrict)) {
        return (context === null || context === void 0) ? getGlobal() : Object(context);
    }
    // 嚴格模式下, 妥協方案
    return Object(context);
}


function generateFunctionCode(argsLength){
    var code = 'return arguments[0][arguments[1]](';
    for(var i = 0; i < argsLength; i++){
        if(i > 0){
            code += ',';
        }
        code += 'arguments[2][' + i + ']';
    }
    code += ')';
    // return arguments[0][arguments[1]](arg1, arg2, arg3...)
    return code;
}


Function.prototype.call = function (context) {

    // 不能夠被調用
    if (typeof this !== 'function') {
        throw new TypeError(this + ' is not a function');
    }

    // 獲取上下文
    var ctx = getContext(context);

    // 更爲穩妥的是建立惟一ID, 以及檢查是否有重名
    var propertyName = "__fn__" + Math.random() + "_" + new Date().getTime();
    var originVal;
    var hasOriginVal = isFunction(ctx.hasOwnProperty) ? ctx.hasOwnProperty(propertyName) : false;
    if (hasOriginVal) {
        originVal = ctx[propertyName]
    }

    ctx[propertyName] = this;
 
    var argArr = [];
    var len = arguments.length;
    for (var i = 1; i < len; i++) {
        argArr[i - 1] = arguments[i];
    }

    var r = new Function(generateFunctionCode(argArr.length))(ctx, propertyName, argArr);

    // 還原現場
    if (hasOriginVal) {
        ctx[propertyName] = originVal;
    } else {
        delete ctx[propertyName]
    }

    return r;
}

複製代碼

評論區問題收集

評論區最精彩:

  1. 爲何不用 Symbol

由於是基於ES5的標準來寫,若是使用Symbol,那拓展運算符也可使用。 考察的知識面天然少不少。

  1. 支付寶小程序evel、new Function都是不給用的

這樣子的話,可能真的無能爲力了。

  1. Object.freeze後的對象是不能夠添加屬性的

感謝虛鯤菜菜子的指正,其文章手寫 call 與 原生 Function.prototype.call 的區別 推薦你們細讀。

以下的代碼,嚴格模式下會報錯,非嚴格模式複製不成功:

"use strict";
var context = {
    a: 1,
    log(msg){
        console.log("msg:", msg)
    }
};

Object.freeze(context);
context.fn = function(){

};

console.log(context.fn);

VM111 call:12 Uncaught TypeError: Cannot add property fn, object is not extensible
    at VM49 call:12
複製代碼

這種狀況怎麼辦呢,我能想到的是兩種方式:

  1. 複製對象
  2. Obect.create

這也算是一種妥協方法,畢竟鏈路仍是變長了。

"use strict";
var context = {
    a: 1,
    log(msg){
        console.log("msg:", msg)
    }
};

Object.freeze(context);

var ctx =  Object.create(context);

ctx.fn = function(){

}

console.log("fn:", typeof ctx.fn);  // fn: function
console.log("ctx.a", ctx.a);  // ctx.a 1
console.log("ctx.fn", ctx.fn); // ctx.fn ƒ (){}
複製代碼

小結

回顧一下依舊存在的問題

  1. 嚴格模式下,咱們用依然須要用Object進行了封裝基礎數據類型

會致使嚴格模式下傳遞非對象的時候,this的指向是不許的, 不得以的妥協。 哪位同窗有更好的方案,敬請指導。

  1. 雖然說咱們把臨時的屬性名變得難以重名,可是若是重名,而函數調用中真調用了此方法,可能會致使異常行爲

  2. 小程序等環境可能禁止使用evalnew Function

  3. 對象被凍結,call執行函數中的this不是真正傳入的上下文對象。

因此,我仍是修改標題爲三千文字,也沒寫好 Function.prototype.call

面試現場

一個手寫call涉及到很多的知識點,本人水平有限,若有遺漏,敬請諒解和補充。

當面試官問題的時候,你要清楚本身面試的崗位,是P6,P7仍是P8。
是高級開發仍是前端組長,抑或是前端負責人。
崗位不同,面試官固然指望的答案也不同。

寫在最後

寫做不易,您的支持就是我前行的最大動力。

參考和引用

Function.prototype.call() - JavaScript | MDN
Strict mode - JavaScript | MDN
ECMAScript 5 Strict Mode
ES合集
手寫call、apply、bind實現及詳解
call、apply、bind實現原理
面試官問:可否模擬實現JS的call和apply方法

相關文章
相關標籤/搜索