this指针、作用域&闭包 - 04

183 阅读9分钟

this

类型 及其 极端情况

默认绑定(函数直接调用)

非严格模式下:

  function fn () {
    console.log(this); // window
  }

  fn();

严格模式下:

  function fn () {
    'use strict'
    console.log(this); // undefined
  }

  fn();

Tip1 ➡️ 非严格模式下,默认绑定指向全局 (node 中是 global)

面试题:

  var a = 1;
  function fn() {
    var a = 2;
    console.log(this.a); 
  }
  fn(); // 1

  // ------------- 变体 -------------
  // 1. 把最外层 var a = 1 -> let a = 1 ??
  let a = 1;
  function fn() {
    var a = 2;
    console.log(this.a); // undefined
  }
  fn(); 

var 声明时会绑定在全局,而let 不会

  // ------------- 变体 -------------
  // 2. 多层
  var b = 1;
  function outer () {
    var b = 2;
    function inner () {
      console.log(this.b) // 1
    }
    inner();
  }

  outer(); 

函数 直接 执行,不管几层,都会指向window

  // ------------- 变体 -------------
  // 3. 对象属性
  const obj = {
    a: 1,
    fn: function () {
      console.log(this.a);
    }
  }

  obj.fn() // 1
  const f = obj.fn
  f() // undefined

只要是赋值执行,绑定会丢失,f 直接执行了,所以this 指向 window

总结:无论函数如何变化,只要函数直接调用,this就会指向全局

隐式绑定(属性访问调用)

  function fn () {
    console.log(this.a);
  }

  const obj = {
    a: 1
  }

  obj.fn = fn;
  obj.fn() // 1

Tip2 ➡️ 隐式绑定的 this 指的是调用堆栈的上一级(.前面的那一个)

  function fn () {
    console.log(this.a);
  }

  const obj1 = {
    a: 1,
    fn
  }

  const obj2 = {
    a: 2,
    obj1
  }

  obj2.obj1.fn(); // 1

面试题:一般会问一些边界case,比如隐士绑定失效(列举部分):

  1. 前面提到过的情况
  const obj1 = {
    a: 1,
    fn: function() {
      console.log(this.a);
    }
  }

  const fn1 = obj1.fn; // undefined
  fn1(); 

** const fn1 = obj1.fn 将引用给了fn1,等同于写了 function fn1() {console.log(this.a)}; fn1() 这里其实已经变成了默认绑定规则,该函数 fn1 执行的环境就是全局环境**

  1. setTimeout
  setTimeout(obj1.fn, 1000);

执行环境同样是全局

  1. 函数作为参数传递
  function run(fn) {
    fn();
  }
  run(obj1.fn); //undefined

这里传进去的是一个引用,内部还是fn直接调用

  1. 一般匿名函数也会指向全局的
  var name = 'The Window';
  var obj = {
    name: 'My obj',
    getName: function() {
      return function() { // 这是一个匿名函数
        console.log(this.name);
      }
    }
  }

  obj.getName()(); // The Window
  1. **函数赋值或有运算符,包括 ,**也会改变 this 指向,下边练习题会有 (练习 2)case, react 时间处理函数为什么要用 bind 一下的原因

  2. IIFE

显示绑定(call, bind, apply)

通过显示的一些方法去强行的绑定 this 的上下文

  function fn() {
    console.log(this.a);
  }

  const obj = {
    a: 100
  }

  fn.call(obj); // 100

Tip3 ➡️ 这种根本还是取决于第一个参数 但第一个参数为 null 的时候还是绑定到全局的

bind,面试常考

  function fn () {
    console.log(this);
  }

  // 为什么可以绑定基本类型?
  fn.bind(1).bind(2)() // 1 

bind 中两个基本点:

  1. 绑定之后会返回新的函数,this 指向谁?
  2. 为什么可以绑定 基本类型
  3. 连续执行后又回绑定什么?
  1. 基本类型可以被装箱(boxing) -> 1 --- Number(1) --> Number {1}
  2. bind 只看第一个 bind (堆栈的上下文,上一个,写的顺序来看就是第一个)

MDN 实现 bind

  //  Yes, it does work with `new (funcA.bind(thisArg, args))`
  if (!Function.prototype.bind) (function(){
    var ArrayPrototypeSlice = Array.prototype.slice; // 为了绑定 this
    Function.prototype.bind = function(otherThis) {
      // 调用者必须是函数,这里的 this 指向调用者:fn.bind(ctx, ...args) / fn

      if (typeof this !== 'function') {
        // closest thing possible to the ECMAScript 5
        // internal IsCallable function
        throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
      }

      var baseArgs= ArrayPrototypeSlice.call(arguments, 1), // 取余下的参数
          baseArgsLength = baseArgs.length,
          fToBind = this, // 调用者
          fNOP    = function() {}, // 寄生组合集成需要一个中间函数,避免两次构造
          fBound  = function() {
            // const newFn = fn.bind(ctx, 1); newFn(2) -> arguments: [1, 2]
            baseArgs.length = baseArgsLength; // reset to default base arguments
            baseArgs.push.apply(baseArgs, arguments); // 参数收集
            return fToBind.apply( // apply 显示绑定 this
              // 判断是不是 new 调用的情况,这里也说明了后边要讲的优先级问题      
              fNOP.prototype.isPrototypeOf(this) ? this : otherThis, baseArgs
            );
          };
      // 下边是为了实现原型继承
      if (this.prototype) { // 函数的原型指向其构造函数,构造函数的原型指向函数
        // Function.prototype doesn't have a prototype property
        fNOP.prototype = this.prototype; // 就是让中间函数的构造函数指向调用者的构造
      }
      fBound.prototype = new fNOP(); // 继承中间函数,其实这里也继承了调用者了

      return fBound; // new fn()
    };
  })();

new 比 bind 优先级高

new

实现new

  // new 关键字会进行如下的操作:

  // 1. 创建一个空的简单JavaScript对象(即{});
  // 2. 链接该对象(设置该对象的constructor)到另一个对象 ;
  // 3. 将步骤1新创建的对象作为this的上下文 ;// 🔥
  // 4. 如果该函数没有返回对象,则返回this。

  // 我们来模拟实现一个 new
  // new Fn(); 
  // myNew(Fn, ...args);
  import _ from 'lodash';

  function myNew(fn, ...args) {
    // fn 必须是一个函数
    if (typeof fn !== 'function') throw new Error('fn must be a function.')
    // es6 new.target
    myNew.target = fn
    // 原型继承
    const temp = Object.create(fn.prototype) // 步骤 1. 2.
    // fn执行绑定 this 环境
    const res = fn.apply(temp, ...args) // 步骤 3.
    // 如果该函数没有返回对象,则返回this。
    return _.isObject(res) ? res : temp
  }

Tip4 ➡️ 如果函数 constructor 里没有返回对象的话,this 指向的是 new 之后得到的实例

  function foo(a) {
    this.a = a;
  }

  const f = new foo(2)
  f.a // 2 this 指向 f

返回对象

  function bar(a) {
    this.a = a;
    return {
      a: 100
    }
  }

  const b = new bar(3)
  b.a // 100

箭头函数

箭头函数比较特殊,编译期间确定上下文,不会被改变,哪怕new,指向就是上一层的上下文

Tip5 ➡️ 箭头函数本身是没有 this 的,继承的是最外层的

  function fn () {
    return {
      b: () => {
        console.log(this)
      }
    }
  }

  fn().b() // window
  fn().b.bind(1)() // window
  fn.bind(2)().b.bind(3)() // 2

优先级

  1. 隐式 vs 默认 --> 结论:隐式 > 默认
  function fn() {
    console.log(this);
  }

  const obj = {
    a: 1,
    fn
  }

  obj.fn() // {a: 1, fn: ƒ}
  1. 显式 vs 隐式 --> 结论:显式 > 隐式
  obj.fn.bind(5)(); // Number {5};
  1. new vs 显式 --> 结论:new > 显式
  function foo(a) {
    this.a = a;
  }

  const obj1 = {};

  var bar = foo.bind(obj1);
  bar(2);
  console.log(obj1.a); // 2

  // new
  var baz = new bar(3);
  console.log(obj1.a) // 2
  console.log(baz.a) // 3
  1. 箭头函数 没有 this,没有比较意义

Tip6 ➡️ 优先级 「new 绑定」 > 「显式」 > 「隐式」 > > 「默认绑定」

实战题目

  // 1.
  function foo() {
    console.log( this.a ) // 2
  }
  var a = 2;
  (function(){
    "use strict" // 迷惑大家的
    foo(); // 直接执行,指向window
  })();

  // 2.
  var name="the window"

  var object={
    name:"My Object", 
    getName: function(){ 
      return this.name
    } 
  }
  object.getName() // My Object
  (object.getName)() // My Object
  (object.getName = object.getName)() // the window
  (object.getName, object.getName)() // the window

  // 3.
  var x = 3
  var obj3 = {
    x: 1,
    getX: function() {
      var x = 5
      return function() {
        return this.x
      }(); // ⚠️
    }
  }
  console.log(obj3.getX()) // 3 立即执行表达式 指向 window

  // 4. 
  function a(x){
    this.x = x
    return this
  }
  var x = a(5) 
  var y = a(6) // 替换为 let 再试试 // 再换回 var,但是去掉 y 的情况,再试试

  console.log(x.x) // undefined
  console.log(y.x) // 6

  // 等价于
  var x = a(5) 
  window.x = 5;
  window.x = window;

  var y = a(6)
  window.x = 6;
  window.y = window;

  console.log(x.x) // 其实执行 Number(6).x 
  console.log(y.x) // 6

替换为 let 再试试

  function a(x){
    this.x = x
    return this
  }
  let x = a(5) 
  let y = a(6)

  console.log(x.x) // 6
  console.log(y.x) // 6

  // 等价于
  let x = a(5)
  window.x = 5;
  window.x = window;

  let y = a(6)
  window.x = 6;
  window.y = window;

  console.log(x.x) // 6

作用域和闭包

储存空间、执行上下文

  1. 数据是怎么存的?
  const a = 100;

  fn(a);

本质是将数据映射成 0 1,然后通过触发器储存这类信息(电信号)

  1. 栈 和 堆 / 静态内存分配 和 动态内存分配 堆栈这里指的是存储数据解构,当然本身可以是一种数据解构的概念(二叉堆、栈)

静态内存分配:

  • 编译期知道所需内存空间大小
  • 编译期执行
  • 申请到栈空间
  • FILO (先进后出)

动态内存分配:

  • 编译期不知道所需内存空间大小
  • 运行期执行
  • 申请到堆空间
  • 没有特定顺序
  1. 执行上下文 和 可执行代码

当控制器转到一段可执行代码的时候就会进入到一个执行上下文。执行上下文是一个堆栈结构(先进后出), 栈底部永远是全局上下文,栈顶是当前活动的上下文。其余都是在等待的状态,这也印证了JS中函数执行的原子性

可执行代码与执行上下文是相对的,某些时刻二者等价

可执行代码(大致可以这么划分):

  • 全局代码
  • 函数
  • eval

递归 & 尾调用优化 [gEdBIU.png

递归爆栈:每次递归会往栈中推一个新的fn执行上下文,每次调用推一个,直到装不下 尾调用优化:返回一个新的函数,会有一个新的函数,前一个函数被认为执行完成,自动出栈。

执行上下文(简称 EC)中主要分为三部分内容:

  • VO / AO 变量对象
  • 作用域链
  • This

所以这个流程可以梳理出来:

  1. 遇到可执行代码

  2. 创建一个执行上下文 (可执行代码的生命周期:编译、运行)

    2.1 初始化 VO

    2.2 建立作用域链

    2.3 确定 This 上下文

  3. 可执行代码执行阶段

    3.1 参数、变量赋值、提升

    3.2 函数引用

    3.3 ...

  4. 出栈,内存回收

问题:为什么函数执行完毕之后会出栈,闭包还能够访问呢?

闭包存在堆中

作用域链

每一个执行上下文都与一个作用域链相关联。作用域链是一个对象组成的链表,求值标识符的时候会搜索它。当控制进入执行上下文时,就根据代码类型创建一个作用域链,并用初始化对象(VO/AO)填充。执行一个上下文的时候,其作用域链只会被 with 声明和 catch 语句所影响

体会一下:

  var a = 20;
  function foo(){
      var b = 100;
      alert( a + b );
  }
  foo();

  // 两个阶段:创建 - 执行

  // --------------------------- 创建 ------------------------------

  // 模拟 VO/AO 对象
  AO(foo) {
    b: void 0
  }

  // [[scope]] 不是作用域链,只是函数的一个属性(规范里的,不是实际实现)
  // 在函数创建时被存储,静态(不变的),永远永远,直到函数被销毁
  foo.[[scope]]: [VO(global)]

  VO(global) {
    a: void 0,
    foo: Reference<'foo'>
  }
    
  // --------------------------- 调用 ------------------------------
    
  // 可以这么去理解,近似的用一个 concant 模拟,就是将当前的活动对象放作用域链最前边
  Scope = [AO|VO].concat([[Scope]])
    
    
  // ---------------------------- 执行时 EC --------------------------------
  EC(global) {
    VO(global) {
      a: void 0,
      foo: Reference<'foo'>
    },
    Scope: [VO(global)],
    // this
  }
    
  EC(foo) {
    AO(foo) { // 声明的变量,参数
      b: void 0
    },
    Scope: [AO(foo), VO(global)] // 查找顺序 -> RHS LHS  
  }

特殊情况:Function 构造的函数 [[scope]] 里只有全局的变量对象

  // 证明
  var a = 10;

  function foo(){
    var b = 20;
    // 函数声明
    function f1(){ // EC(f1) { Scope: [AO(f1), VO(foo), VO(g)] }
      console.log(a, b);
    }

    // 函数表达式
    var f2 = function(){
      console.log(a, b);
    }

    var f3 = Function('console.log(a,b)')

    f1(); // 10, 20
    f2(); // 10, 20
    f3(); // 10, b is not defined
  }

  foo();
  • with & catch & eval

本质上 eval 之类的恐怖之处是可以很方便的修改作用域链,执行完后又回归最初状态

  // 这样好理解
  Scope = [ withObj|catchObj ].concat( [ AO|VO ].concat( [[ scope ]] ) )
  // 初始状态 [VO(foo), VO(global)]
  // with 一下:[VO(with)❓,在本身的作用域链前塞一个with的 VO(foo), VO(global)]
  // with 完事儿了,还要恢复 👈

题目:

  var a = 15, b = 15;

  with( { a: 10 } ){
    var a = 30, b = 30;
    alert(a); // 30
    alert(b); // 30
  }

  alert(a); // ? answer: 15
  alert(b); // 30

  // with 会塞入当前执行作用域上下文的最前面,with 执行完之后,把代码reset 一下,这里 b 没有reset 因此 是 30

闭包

函数拥有对其词法作用域的访问,哪怕是在当前作用域之外执行

对于现代浏览器机制来说,闭包其实就是 逃逸分析

逃逸分析:通过浏览器的逃逸分析机制判断哪些变量使用了,哪些变量不会再用,哪些变量未来可能还会用;不会再用的就回收,还可能用的就hold住

闭包一定不会存在栈当中,因此使用完了之后就会被GC,只能存在堆当中,存一个指针。