wulang8353/DO-THE-JS-BETTER

通过分析call()原理实现bind的polyfill来考验下基础吧

Opened this issue · 0 comments

通过分析call()原理实现bind的polyfill来考验下基础吧

每日一话:我做事三分钟热度,却也爱你那么久

前言

最近在看文章的时候多次看到改变上下文环境中使用了bind方法,突然想写一下其polyfill方法,结果憋不出来,虽说没有什么实际意义,但后来学习后也算是对基础知识又一次的加固。

文字叙述不多,更多都在代码的注释中。

call、apply、bind的用法这里就不再赘述,其作用都是在指定的上下文环境中调用函数。

  • bind返回函数,而call、apply 返回函数并立即执行

  • bind涉及到函数科里化,实参可以少于形参; 而call、apply实参必须大于等于形参

  • apply接收两个参数,上下文环境和数组参数;而call第二个参数得是参数序列,就好像打电话一样,总得一个个数字拨打过去吧

还是简单的看个Demo吧:

let profile = {
    name: "walter",
    info: function (age) {
         console.log("I am ", this.name + " and " + age + " years old");
     }
};

let judegment = {
    name: "handsome Boy",
};

profile.info(25); // ​​​​​I am  walter and 25 years old​​​​​

然后使用apply以及call方法

// 数组形式
profile.info.apply(judegment, [25]); // ​​​​​I am  handsome Boy and 25 years old​​​​​

// 参数序列形式
profile.info.call(judegment, 25);  // ​​​​​I am  handsome Boy and 25 years old​​​​​

call和apply的原理分析

不论是call还是apply其实本质上来讲做了两件事:

1、绑定上下文,也就是绑定了this的指向
2、profile.info()执行

通过下面这张图可以知道,f.call(o)函数的实现原理是先通过o.m=f将f作为对象o.m的属性值,执行后再删除该属性m。

apply

根据这个原理重新分析下刚刚profile.info.call(judegment, 25)的执行过程:

1、judement.fn = profile.info 
2、judement.fn()
3、delete judegment.fn

基于以上原理来逐步实现apply方法

apply的实现步骤

第一步:实现功能

Function.prototype.applyMe = function(context){

  // 获取被调用的函数,假想context对象预先不存在名为fn的属性
  // 根据传入的实参循序,context是对象judegment
  context.fn = this;

  // 获取参数,使用ES6扩展运算和解构赋值,args = [25]
  let [args] = [...(Array.from(arguments).slice(1))]

  // 执行被调用的函数
  context.fn(...args);

  //删除临时属性fn
  delete context.fn;
}

let profile = {
    name: "walter",
    info: function (age) {
         console.log("I am ", this.name + " and " + age + " years old");
     }
};

let judegment = {
    name: "handsome Boy",
};

profile.info.applyMe(judegment,[25]); // ​​​​​I am handsome Boy and 25 years old​​​​​

缺点:

1、边界问题:this可以传null或者不传,this就指向window

let hi = 'hi';
function sayHi() {
    console.log(this.hi);
}

sayHi.apply(); // 'hi'

2、函数直接调用,要有返回值

var obj = {
    name: 'walter'
}
function sayHi(age) {
    return {
        name: this.name,
        age: age
    }
}
console.log(sayHi.apply(obj,[25]));// {name: "walter", age: 24}

第二步:修改边界问题和返回值

Function.prototype.applyMe = function(context){

  // 增加边界值校验
  var context = context || window

  // 获取被调用的函数,假想context对象预先不存在名为fn的属性
  // 根据传入的实参循序,context是对象judegment
  context.fn = this;

  // 获取参数,使用ES6扩展运算和解构赋值,args = [25]
  let [args] = [...(Array.from(arguments).slice(1))]

  // 执行被调用的函数
  var returnValue = context.fn(...args);

  //删除临时属性fn
  delete context.fn;

  return returnValue
}

var obj = {
  name: 'walter'
}

function sayHi(age,height) {
  return {
      name: this.name,
      age: age,
      height:height
  }
}

console.log(sayHi.applyMe(obj,[24,185])); // ​​​​​{ name: 'walter', age: 24, height: 185 }​​​​​

缺点:

1、属性名的唯一性:假想context对象预先不存在名为fn的属性,但若是context对象本来有fn这个属性则会重写,无法保证唯一性。

第三步:保证唯一性

ES6中提供了除 Undefined、Null、Boolean、String、Number以外的第六种基本数据类型 Symbol,它用来避免属性名的冲突,确保了唯一性。Symbol函数可以接受一个字符串作为参数,表示Symbol实例。由于其唯一性,所以不能与其他类型的值进行计算

// ES5
var p = {name: 'tim'}
p.name = gary; // 重写了name属性

// ES6 没有参数的情况
var s1 = Symbol();
var s2 = Symbol();
s1 === s2 // false

// ES6 有参数的情况
var s1 = Symbol("foo");
var s2 = Symbol("foo");
s1 === s2 // false

// Symbol值作为对象属性名时,不能用点运算符
var mySymbol = Symbol();

// 第一种写法
var a = {};
a[mySymbol] = 'Hello!';

// 第二种写法
var a = {
  [mySymbol]: 'Hello!'
};

// 第三种写法
var a = {};
Object.defineProperty(a, mySymbol, { value: 'Hello!' });

// 以上写法都得到同样结果
a[mySymbol] // "Hello!"

通过Symbol()方法接收字符串,就能确保了属性名的唯一性了。

Function.prototype.applyMe = function(context){

  // 增加边界值校验
  var context = context || window

  // 确保fn的唯一性
  var fn = Symbol()

  // 获取被调用的函数,假想context对象预先不存在名为fn的属性
  // 根据传入的实参循序,context是对象judegment
  context.fn = this;

  // 获取参数,使用ES6扩展运算和解构赋值,args = [25]
  let [args] = [...(Array.from(arguments).slice(1))]

  // 执行被调用的函数
  var returnValue = context.fn(...args);

  //删除临时属性fn
  delete context.fn;

  return returnValue
}

var obj = {
  name: 'walter'
}

function sayHi(age,height) {
  return {
      name: this.name,
      age: age,
      height:height
  }
}

console.log(sayHi.applyMe(obj,[24,185])); // ​​​​​{ name: 'walter', age: 24, height: 185 }​​​​​

bind函数使用场景

bind函数的语法是这样样子的:fun.bind(thisArg[, arg1[, arg2[, ...]]])

虽然长得和call方法的语法差不多,但它们实质是不一样的。就像,老婆饼就一定是老婆做的么? 如果有,那也是别人的老婆~

bind() 方法会创建一个新函数,当这个新函数被调用时,它的 this 值是传递给 bind() 的第一个参数, 它的参数是 bind() 的其他参数和其原函数的参数合并之后的。
bind() 返回的绑定函数也能使用new操作符创建对象:这种行为就像把原函数当成构造器。提供的this值被忽略,同时调用时的参数被提供给模拟函数。

传入bind方法的第一个参数作为this,传入第二个参数以及函数自带的参数按照顺序作为新的参数。可以看到bind函数并不是立即执行的,而是绑定上下文环境后再手动执行。

// 数组形式 
profile.info.bind(judegment)([25]); // ​​​​​I am  handsome Boy and 25 years old​​​​​

// 参数序列形式
profile.info.bind(judegment)(25);  // ​​​​​I am  handsome Boy and 25 years old​​​​​

bind函数使用的场景的主要特点就是绑定this但不要像call、apply那样立即执行

var name = 'window'
function Person(name){
 this.nickname = name;
 this.sayHi = function() {
   setTimeout(function(){
     console.log("Hello, my name is " + this.nickname);
   }, 500);
 }
}
 
var p = new Person('walter');
p.sayHi(); //Hello, my name is window

这个时候输出的this.nickname是window,原因是this指向是在运行函数时确定的,而不是定义函数时候确定的,而setTimeout在全局环境下执行,所以this指向setTimeout的上下文:window。所以问题的关键之处就是要将setTimeout函数的上下文环境绑定在Person的实例上

function Person(name){
  this.nickname = name;
  this.sayHi = function() {
    setTimeout(function(){
      console.log("Hello, my name is " + this.nickname);
    }.bind(this), 500); // 将this指向了Person构造函数,函数实例化后也就thsi指向了该实例
  }
}
 
var p = new Person('walter');
p.sayHi(); // "Hello, my name is walter"

新手经常犯的一个错误是将一个方法从对象中拿出来赋值给一个变量,然后再调用该变量,这样做一般会将this不能继续指向原来的对象:

this.x = 1; 
var obj = {
  x: 2,
  getNum: function() { return this.x; }
};
 
obj.getNum(); // 2
 
var newObj = obj.getNum;
newObj(); // 1, 此时的this指向全局对象
 
// 通过bind将this重新指向obj
var bound = newObj.bind(obj);
bound(); // 2

bind函数Polyfill

上面讲完了bind方法的常见场景,现在来实现一下bind函数Polyfill

// 兼容处理:嗅探下原生有没有bind方法
Function.prototype.bind = Function.prototype.bind || function() {
  var func = this; // 需要绑定的原函数
  var args = [].slice.call(arguments); // 获取bind()中的参数
  var context = args.shift(); // 拿到this,此时args数组中只有需要传入的参数
  return function() {
      // 将bind()中的参数与 func中的参数合并
      return func.apply(context, args.concat([].slice.call(arguments)));
  }
};

兼容构造函数

// 兼容处理:嗅探下原生有没有bind方法
Function.prototype.bind = Function.prototype.bind || function() {
  
  var func = this; // 需要绑定的原函数
  var args = [].slice.call(arguments); // 获取bind()中的参数
  var context = args.shift(); // 拿到this,此时args数组中只有需要传入的参数

  var F = function () {};

  var bound = function() {
      // 将bind()中的参数与 func中的参数合并
      // 判断this是不是函数,为了用于继承原型
      return func.apply(this instanceof F ? this : context, args.concat([].slice.call(arguments)));
  }

  // 寄生模式继承原函数的原型
  F.prototype = this.prototype;
  bound.prototype = new F();
  return bound;
};

最终版本

// 兼容处理:嗅探下原生有没有bind方法
Function.prototype.bind = Function.prototype.bind || function() {

  // 调用bind方法的一定得是个函数
  if (typeof this !== "function") {
    throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
  }
  
  var func = this; // 需要绑定的原函数
  var args = [].slice.call(arguments); // 获取bind()中的参数
  var context = args.shift(); // 拿到this,此时args数组中只有参数

  var F = function () {};

  var bound = function() {
      // 将bind()中的参数与 func中的参数合并
      // 这里的this是func所在的上下文环境,也就是window对象且并非函数的实例,该情况下this = context
      // args.concat([].slice.call(arguments)))来实现上面提到的bind的科里化
      return func.apply(this instanceof F ? this : context, args.concat([].slice.call(arguments)));
  }

  // 寄生模式继承原函数的原型
  F.prototype = this.prototype;
  bound.prototype = new F();

  return bound;
};

// 测试下
function foo(){
  this.b = 100;
  return this.a
}

var func = foo.bind({a:1})

func(); // this.a = 1
🎉🎉🎉 自己发现以后还是要多增加一些文字叙述。有时候想当然地觉得代码能说明一切,但如果能用语言精练出来,相当于进一步的提炼概念,去其糟粕
:trollface::trollface::trollface: 就好像谈恋爱一样,光想是没有用,要得会说。不说,就算心里波涛汹涌也难激起水花。单身,倒还可以修炼这项技能,真好

参考文章

不用call和apply方法模拟实现ES5的bind方法

前端基础进阶(八):深入详解函数的柯里化

ECMAScript 6 入门