万字长文,深入JS核心语法(下篇)(this指向 v8垃圾回收与闭包)

我心飞翔 分类:javascript

目录

前言
  一、this指向
    - 一般情况
    - 手动改变this指向
    - 原生js实现call、apply、bind
  二、内存管理机制
    - 垃圾回收
      - 引用计数
      - 标记清除
      - V8中的垃圾回收算法
  三、闭包
    - 闭包造成的问题
    - 闭包的应用
后记
 

前言

本文为原创文章,万字长文,建议先收藏后阅读。书接上回 万字长文 深入JS核心语法(上篇),继续深入 JavaScript。如果本文对你有所帮助,欢迎收藏、点赞、评论,转载请注明出处。

一、this 指向

一般情况

大多数情况下,this 是在运行时进行绑定的,所以 this 指向取决于 调用方式。

大致有以下三种情况:

1. 全局
在浏览器中,全局环境 里直接访问 this,this 始终指向 window。

2. 函数内部
在函数里,this 的指向取决于谁调用了该方法就指向谁。

来看下面这个例子

function func() {
  return this;
}
console.log(func() === window); // true

const obj = {
  func,
};
console.log(obj.func() === obj); // true
 

在这个例子中,func 直接返回 this

  • 在全局作用域下直接执行 func 函数,此时的 this 指向 window。
  • 当我们通过 obj 调用 func 函数时,此时的 this 指向的则是调用方,也就是这里的 obj。

在全局作用域中,直接调用函数时,函数内的 this 指向,在 严格模式 与 非严格模式 下是不同的。

上代码:

"use strict";
function strictFunc() {
  return this;
}

console.log(strictFunc() === window); // false
console.log(strictFunc() === undefined); // false
 
  • 在严格模式下,strictFunc 中的 this 指向的并不是 window,而是 undefined。
  • 而非严格模式下,就如上一个例子中的 func 函数,this 始终指向 window。

注:箭头函数 不会创建自己的 this,而是会继承作用域链上一层的 this。

3. 类
类中的 this 指向与函数内的 this 指向基本一致,唯一区别就是 类中的静态属性不会被添加到 this 上。

以下是 MDN 上的一个小栗子

class Example {
  constructor() {
    const proto = Object.getPrototypeOf(this);
    console.log(Object.getOwnPropertyNames(proto)); // ["constructor", "first", "second"]
  }
  first() {
    return this;
  }
  second() {}
  static third() {}
}

const instance = new Example();
console.log(instance.first() === instance); // true
 

手动改变 this 指向

想要人为修改 this 的指向,通常做法是使用 call、apply、bind 方法。

相同点:

  • 都是用来改变 this 指向
  • 第一个参数都是 this 要指向的对象,也就是想指定的上下文
  • 都可以利用后续参数传参

不同点:

  • call 传参时需将目标函数的入参逐个传入,并立即执行目标函数
  • apply 传参时是以数组形式传入,同样会立即执行目标函数
  • bind 传参也是逐个传入,但只修改 this 指向,返回一个新函数,不会执行目标函数
var saying = "hello world";
function say() {
  console.log(`say ${this.saying}`);
}

const dog = {
  saying: "bark",
};

const bull = {
  saying: "moo",
};

say(); // say hello world
say.call(dog); // say bark
say.apply(bull); // say moo

const dogSay = say.bind(dog);
dogSay(); // say bark
 

一个小栗子简单了解一下

原生 js 实现 call、apply、bind

通过上一小节,我们知道了 call、apply、bind 的用法与区别,下面我们来试着自己实现一下三个方法

首先,三个方法都是通过函数直接调用的,所以三个方法必然是挂载在 Function 的 prototype 上的。

Function.prototype.call = function () {};
Function.prototype.apply = function () {};
Function.prototype.bind = function () {};
 

然后,三个方法第一个入参都是需要指定 this 指向的对象,再改造一下

Function.prototype.call = function (thisArg) {
  thisArg.func = this;
};
Function.prototype.apply = function (thisArg) {
  thisArg.func = this;
};
Function.prototype.bind = function (thisArg) {
  thisArg.func = this;
};
 

我们在 thisArg 内自定义了一个 func 方法,指向目标函数

在接下来就是三个方法的差异实现了
1. call
call 通过逐个传参的方式实现入参,这里我们采用 扩展运算符 实现

Function.prototype.call = function (thisArg, ...args) {
  thisArg.func = this;
  thisArg.func(...args);
  // 执行完毕后销毁
  delete thisArg.func;
};
 

2. apply
apply 则是通过数组的方式完成入参,这个就简单了

Function.prototype.apply = function (thisArg, args = []) {
  thisArg.func = this;
  thisArg.func(args);
  // 执行完毕后销毁
  delete thisArg.func;
};
 

3. bind
bind 入参与 call 一致,但是与 call、apply 不同的是,bind 不直接执行目标函数,而是提供一个新函数供外界调用

Function.prototype.bind = function (thisArg, ...args) {
  thisArg.func = this;
  return function () {
    thisArg.func(...args);
    delete thisArg.func;
  };
};
 

二、内存管理机制

无论什么语言,内存的生命周期都是一致

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放\归还

在 js 中,定义变量时就完成了内存分配,当程序发现这些变量不再被使用的时候,会自动释放变量的内存。

js 中内存空间分为 栈、堆

  • :空间小,存放 执行上下文,执行上下文中又包含了 基本数据类型 和 复杂数据类型的引用
  • :空间大,存放复杂的数据类型
    (注:基本数据类型的复制是完全复制,复杂数据类型的复制只是复制了引用地址)

万字长文,深入JS核心语法(下篇)(this指向 v8垃圾回收与闭包)

对于栈中的内存,操作系统会 自动分配 和 自动释放。
对于堆中的内存,由于每一块的大小都是不固定的,所以操作系统无法完成自动释放,需要 js 引擎手动释放。

js 引擎要做的就是找到已经不再需要的内存,并释放它们。这里就涉及到了 js 引擎的 垃圾回收机制。

垃圾回收

每隔一段时间,JS 的垃圾收集器就会对内存做 “巡检”。当它判断一块内存空间不再被需要之后,它就会把这块内存空间给释放掉,这个过程叫做垃圾回收

如何找到 不被需要的内存空间 就成了垃圾回收机制的重点,常见的垃圾回收算法有两种:

  1. 引用计数法
  2. 标记清除法

引用计数

引用计数 顾名思义就是通过计算内存区域的引用数量,为 0 时则表示这块内存区域 不被需求 可以被回收。

“引用” 仅仅用来描述引用类型的内存地址。

在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。
这里 “对象” 的概念不仅特指 JavaScript 对象,还包括 函数作用域(或者全局词法作用域)

通过一个小栗子理解一下

const a = { name: "MelonField", author: "HLianfa" };
 

在 JavaScript 中,赋值表达式是从右向左读的。首先会开辟一块内存,存放当前右侧这个对象;之后 a 变量指向它;这就创建了一个指向该对象的 “引用”。这时这个对象的 引用计数 就等于 1。

a = null;
 

当我们把 a 变量指向 null 时,{ name: 'MelonField', author: 'HLianfa' } 这个对象的 引用计数 就变为 0,也就是它 “不被需要” 了,在下一次 垃圾收集器 “巡检” 时,就会释放其所占用的这一块内存空间。

引用计数的缺陷

引用计数 算是最初级的垃圾回收算法,现在基本被淘汰了,原因是 引用计数 算法有个致命的缺陷:“无法处理循环引用的事例”。可能导致占用的内存将永远不会被释放,造成 内存泄漏。

function run() {
  var a = {};
  var b = {};
  a.link = b;
  b.link = a;
}
run();
 

在这个例子中,我们最后运行了一下 run;按理说函数执行完毕后,函数内的变量所占用的内存都可以被释放了。
但是,如果在使用 引用计数 算法作为垃圾回收算法的环境中,a、b 变量所占内存将无法被释放。

万字长文,深入JS核心语法(下篇)(this指向 v8垃圾回收与闭包)

如图所示,a、b 两个变量卷起来了,它们互相引用,而 “循环引用” 又是 引用计数 算法无法处理的,所以它们会一直在内存中无法被释放。

标记清除法

因为 引用计数 的“缺陷”,2012 年起,所有主流浏览器都改用 标记清除算法。

标记清除算法,顾名思义就是要先标记出来,再清除。

  1. 标记:从初始的 根对象 也就是全局对象(浏览器:window,Node:global)的指针开始,向下搜索其子节点,被搜索到的 子节点 打上“可达”(不是可达鸭,是可抵达)标记。

  2. 清除:在所有子节点都被遍历后,那些没被打上标记的节点,就是没有被任何地方引用,可以被回收。

V8 中的垃圾回收算法

兜兜转转又回到了 V8,没办法,全靠谷歌爸爸赏饭吃。
V8 为了提高回收效率,把 堆 进一步分为了 新生代 和 老生代。

  • 新生代:存放的是 生存时间短 的对象,采用 Scavenge
  • 老生代:存放的 生存时间长 的对象,采用 Mark-Sweep & Mark-Compact

运行机制:

  • Scavenge 会进一步将新生代划分为 from-space 和 to-space,也是先标记,之后将 from-space 中的“可抵达”对象复制到 to-space,并在 to-space 中有序排列,释放 from-space 中剩余的对象后,再把 to-space 中的对象复制过来
  • 老生代垃圾回收机制算法有两种:
    • Mark-Sweep:常规的 标记-清除,标记出“可达”对象后,直接把“不可达”对象清除
    • Mark-Compact:相比 Mark-Sweep,增加了对象整理阶段,将所有“可达”对象往一端移动,移动完成后,直接清理掉边界外的内存

三、闭包

首先,我们要搞清楚什么是 闭包?

来康康 MDN 上的定义:

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)

通俗的说就是 函数 以及 声明该函数的词法环境 组合形成了 闭包。

(词法环境忘了的建议翻到上面 执行上下文 再熟悉一下)

最常见的一种闭包就是 函数嵌套

function makeFunc() {
  var name = "Mozilla";
  function displayName() {
    console.log(name);
  }
  return displayName;
}
var myFunc = makeFunc();
myFunc(); // Mozilla
 

在这个例子中,当 makeFunc 执行完毕后,name 依然可以被输出。

至于原因,网络上大多数文章都会告诉你,因为 js 用的是 词法作用域。
这个说法也没错,但是用 闭包 来解释这个现象会更合理一点。

  • 首先,myFunc 是 displayName 函数实例的引用。
  • 其次是 displayName 的实例也维持了一个对 声明它的词法环境 的引用。
  • 而这个声明 displayName 的词法环境中包含了 name 变量。
  • 所以,当 myFunc 被调用时,变量 name 仍然可用。

闭包造成的问题

内存泄漏?

每当提起 闭包,很多人都会条件反射的想到 内存泄漏。

然而,闭包并不会引起内存泄漏
造成内存泄漏的原因,都是代码书写不规范导致的。

这种说法多半是源于早期浏览器的 垃圾回收机制 仍使用 引用计数 导致的,形成了 循环引用。

性能

闭包虽然不会造成内存泄漏,但是用在错误的地方还是会影响性能的。

function animal(something) {
  this.say = function () {
    console.log(`say ${something}`);
  };
}

var dog = new animal("bark");
var bull = new animal("moo");

dog.say(); // say bark
bull.say(); // say moo
 

在这个例子中,dog 和 bull 是 animal 的实例;创建 dog 时,say 方法会被赋值,创建 bull 时,say 方法又会被赋值一次。
每次新建一个 animal 实例,say 都被赋值一次,显然是不合理的。
在这个例子中,闭包 不但没有带来便利,反而让性能受影响。

理想的方式如下

function animal(something) {
  this.saying = something;
}

animal.prototype.say = function () {
  console.log(`say ${this.saying}`);
};

var dog = new animal("bark");
var bull = new animal("moo");

dog.say(); // say bark
bull.say(); // say moo
 

闭包的应用

1. 防抖与节流

防抖 与 节流 对频繁调用的函数,限制调用次数,达到优化前端性能与体验的目的。

  • 防抖:在短时间内多次触发同一个函数,只执行最后一次,或是只在开始时执行
  • 节流:一段时间内只允许函数执行一次
// 防抖
function debounce(fn, delay) {
  let timer = null;

  return function () {
    const context = this;
    const args = arguments;

    clearTimeout(timer);
    if (!timer) {
      timer = setTimeout(function () {
        fn.apply(context, args);
      }, delay);
    }
  };
}
 

👆 这个例子简单实现了防抖,fn 就是实际需要执行的函数,debounce 每次被调用都会清除 定时器,最后一次触发的定时器被保留,并在 delay 毫秒后执行 fn。

// 节流
function throttle(fn, delay) {
  let timer = null;

  return function () {
    const context = this;
    const args = arguments;

    if (!timer) {
      timer = setTimeout(function () {
        fn.apply(context, args);
        timer = null;
      }, delay);
    }
  };
}
 

👆 这个例子同样是利用定时器,实现了节流;fn 是实际需要执行的函数,第一次调用时,fn 不会立即被执行,而是等待 delay 毫秒后执行,之后每过 delay 毫秒执行一次 fn。

2. 模拟私有属性

在大多数编程语言中都支持将类中的 变量 方法 私有化。而在 js 中无论是通过构造函数实现的 类 还是通过 class 语法糖实现的 类,都无法直接定义 私有变量,但是通过闭包就可以轻松实现。

const Animal = (function () {
  let _saying = "";
  class Animal {
    constructor(saying) {
      _saying = saying;
    }
    say() {
      console.log(`say ${_saying}`);
    }
  }
  return Animal;
})();

const dog = new Animal("bark");
dog.say(); // say bark
console.log(dog._saying); // undefined
 

👆 这个例子中,我们使用一个 立即执行函数 包裹了 Animal 类并返回,当我们在 Animal 之外访问 _saying 时,会得到 undefined,成功将 _saying 私有化。
(chrome 74+ 的版本,实现了 ES2020 实验草案 通过#号定义私有属性,这里就不展开了)

3. 偏函数/柯里化
  • 柯里化 一句话概括就是:将接受多个参数的函数变成只接受一个参数的函数。
  • 偏函数 不像 柯里化 那么严格,允许你先接受几个参数,返回一个新函数,再去接受剩余的参数。
function animal(name, color, size, age) {
  // do something
}

animal("dog", "yellow", "big", "2");
animal("dog", "yellow", "big", "5");
animal("dog", "yellow", "big", "3");
 

👆 在这个例子中,我们都是对 “大黄狗” 做相关操作,它们唯一的区别的就是年龄,因此,我们可以通过 柯里化 锁定“大黄狗”。

function animal(name) {
  return function (color) {
    return function (size) {
      return function (age) {
        // do something
      };
    };
  };
}

const dog = animal("dog"); // 调用一次,锁定 狗子
const yellowDog = dog("yellow"); // 锁定 黄色的狗子
const bigYellowDog = yellowDog("big"); // 锁定 大黄狗

// 简写:const bigYellowDog = animal('dog')('yellow')('big');

bigYellowDog("2");
bigYellowDog("5");
bigYellowDog("3");
 

再来看看 偏函数改造后的 animal。

function animal(name, color, size) {
  return function (age) {
    // do something
  };
}

const bigYellowDog = animal("dog", "yellow", "big");

bigYellowDog("2");
bigYellowDog("5");
bigYellowDog("3");
 

孰优孰劣视具体业务而定了,担心嵌套太多层,可以通过 工厂模式 简化。

后记

如有其它意见,欢迎评论区讨论。文章同时发在个人公众号,欢迎关注 MelonField
深入JS核心语法(下篇)

参考

  • developer.mozilla.org/zh-CN/docs/…
  • developer.mozilla.org/zh-CN/docs/…
  • github.com/yacan8/blog…

作者:掘金-万字长文,深入JS核心语法(下篇)(this指向 v8垃圾回收与闭包)

回复

我来回复
  • 暂无回复内容