阅读 9649

三千文字,也没写好 Function.prototype.call

前言

Function.prototype.call,手写系列,万文面试系列,必会系列必包含的内容,足见其在前端的分量。
本文基于MDNECMA 标准,和大家一起从新认识call

涉及知识点:

  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.call

基于ES6的拓展运算符版本

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

这个版本,应该不是面试官想要的真正答案。不做太多解析。

基于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方法

文章分类
前端
文章标签