再次梳理AMD、CMD、CommonJS、ES6 Module的区别

前言


回想起之前的一次面试,第一轮面试官问我 AMD 和 CMD 的区别,我只回答说 AMD 是提前加载,CMD 是按需加载。第二轮面试官又问了我 CommonJS 和 ES6 Module 的区别,emmm…,我大致回答说新的比旧的好~~,虽然面试官并没有说什么,不过显然这样的答案并不是有助于面试、有助于自己的技术积累的。


所以有必要进行一次梳理,以便更清晰地了解它们的特点及差异。

AMD


AMD 一开始是 CommonJS 规范中的一个草案,全称是 Asynchronous Module Definition,即异步模块加载机制。后来由该草案的作者以 RequireJS 实现了 AMD 规范,所以一般说 AMD 也是指 RequireJS。

RequireJS 的基本用法


通过define来定义一个模块,使用require可以导入定义的模块。

1
2
3
4
5
6
7
8
9
10
11
//a.js
//define可以传入三个参数,分别是字符串-模块名、数组-依赖模块、函数-回调函数
define(function(){
return 1;
})

// b.js
//数组中声明需要加载的模块,可以是模块名、js文件路径
require(['a'], function(a){
console.log(a);// 1
});

RequireJS 的特点


对于依赖的模块,AMD 推崇依赖前置,提前执行。也就是说,在define方法里传入的依赖模块 (数组),会在一开始就下载并执行。

CMD


CMD 是 SeaJS 在推广过程中生产的对模块定义的规范,在 Web 浏览器端的模块加载器中,SeaJS 与 RequireJS 并称,SeaJS 作者为阿里的玉伯。

SeaJS 的基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//a.js
/*
* define 接受 factory 参数,factory 可以是一个函数,也可以是一个对象或字符串,
* factory 为对象、字符串时,表示模块的接口就是该对象、字符串。
* define 也可以接受两个以上参数。字符串 id 表示模块标识,数组 deps 是模块依赖.
*/
define(function(require, exports, module) {
var $ = require('jquery');

exports.setColor = function() {
$('body').css('color','#333');
};
});

//b.js
//数组中声明需要加载的模块,可以是模块名、js文件路径
seajs.use(['a'], function(a) {
$('#el').click(a.setColor);
});

SeaJS 的特点


对于依赖的模块,CMD 推崇依赖就近,延迟执行。也就是说,只有到require时依赖模块才执行。

CommonJS


CommonJS 规范为 CommonJS 小组所提出,目的是弥补 JavaScript 在服务器端缺少模块化机制,NodeJS、webpack 都是基于该规范来实现的。

CommonJS 的基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//a.js
module.exports = function () {
console.log("hello world")
}

//b.js
var a = require('./a');

a();//"hello world"

//或者

//a2.js
exports.num = 1;
exports.obj = {xx: 2};

//b2.js
var a2 = require('./a2');

console.log(a2);//{ num: 1, obj: { xx: 2 } }

CommonJS 的特点

  • 所有代码都运行在模块作用域,不会污染全局作用域;
  • 模块是同步加载的,即只有加载完成,才能执行后面的操作;
  • 模块在首次执行后就会缓存,再次加载只返回缓存结果,如果想要再次执行,可清除缓存;
  • CommonJS 输出是值的拷贝 (即,require返回的值是被输出的值的拷贝,模块内部的变化也不会影响这个值)。

ES6 Module


ES6 Module 是 ES6 中规定的模块体系,相比上面提到的规范, ES6 Module 有更多的优势,有望成为浏览器和服务器通用的模块解决方案。

ES6 Module 的基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//a.js
var name = 'lin';
var age = 13;
var job = 'ninja';

export { name, age, job};

//b.js
import { name, age, job} from './a.js';

console.log(name, age, job);// lin 13 ninja

//或者

//a2.js
export default function () {
console.log('default ');
}

//b2.js
import customName from './a2.js';
customName(); // 'default'

ES6 Module 的特点 (对比 CommonJS)

  • CommonJS 模块是运行时加载,ES6 Module 是编译时输出接口;
  • CommonJS 加载的是整个模块,将所有的接口全部加载进来,ES6 Module 可以单独加载其中的某个接口;
  • CommonJS 输出是值的拷贝,ES6 Module 输出的是值的引用,被输出模块的内部的改变会影响引用的改变;
  • CommonJS this指向当前模块,ES6 Module this指向undefined;


目前浏览器对 ES6 Module 兼容还不太好,我们平时在 webpack 中使用的export/import,会被打包为exports/require

写在后面


这里比较宽泛地把 JavaScript 中的几大模块化规范列举出来,希望借此对 JavaScript 模块化有大致的认识,而未对细节展开具体分析,感兴趣的可以自行探索。


https://juejin.cn/post/6844903983987834888

私有npm仓库搭建与使用

NPM是随同NodeJS一起安装的包管理工具,能解决NodeJS代码部署上的很多问题,常见的使用场景有以下几种:

允许用户从NPM服务器下载别人编写的第三方包到本地使用。允许用户从NPM服务器下载并安装别人编写的命令行程序到本地使用。允许用户将自己编写的包或命令行程序上传到NPM服务器供别人使用。

一、为什么需要私有npm仓库

  • 安全:内部源码不能发布到npm上开源,避免业务代码公开
  • 免费:在npm上发布私有内容需要收费
  • 效率:统一管理积累内部开发资源,提高开发效率

二、技术选型

  • sinopia 不考虑,好几年没更新了
  • cnpmjs 太笨重了,使用的方式基于作用域,略显繁琐
  • verdaccio 轻量,使用简单,基于registry的概念,侵入少 👏👏👏👌

三、verdaccio搭建步骤-使用docker-安装在一组开发服务器上

1.下载镜像

docker pull verdaccio/verdaccio

image.png

2.容器启动配置

在 /alidata/www 目录下新建 npm 文件夹,并且创建 docker-compose.yml 配置文件

docker-compose是用来编排容器的,主要作用是将容器配置写在配置文件中,避免每次启动都需要手动运行 docker run 一堆东西。

cd /alidata/www
mkdir npm && touch npm/docker-compose.yml

写入以下配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
version: '2'

services:
verdaccio:
image: verdaccio/verdaccio
container_name: verdaccio
networks:
- node-network
environment:
- VERDACCIO_PORT=4873
ports:
- 4873:4873
volumes:
- ./storage:/verdaccio/storage
- ./conf:/verdaccio/conf
- ./plugins:/verdaccio/plugins
networks:
node-network:
driver: bridge

主要做了这些事情

  • 将 npm/storage 目录挂载到容器的 /verdaccio/storage
  • 将 npm/conf 目录挂载到容器的 /verdaccio/conf
  • 将 npm/plugins 目录挂载到容器的 /verdaccio/plugins
  • 容器和主机端口都设置为4873

/alidata/www/npm 文件夹下的内容可以定期git提交到云效,进行备份。主要包括用户资料,包代码。

3.verdaccio配置

配置说明文档 链接

在 /alidata/www/npm/conf 下新建一个文件

touch npm/conf/config.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
storage: /verdaccio/storage
auth:
htpasswd:
file: /verdaccio/conf/htpasswd
uplinks:
npmjs:
url: https://registry.npm.taobao.org/
packages:
# scope packages
'@scope/*':
access: $all
publish: $authenticated
proxy: npmjs
'**':
access: $all
proxy: npmjs
logs:
- {type: stdout, format: pretty, level: http}
  • htpasswd 存放的是 npm 用户及密码信息的文件。
  • uplinks 配置了npm替代地址,这里配置了淘宝源。如果在当前仓库找不相应软件包,就到uplinks配置的源获取。

4.启动容器

docker-compose up -d –build

image.png

5.nginx配置

先找到一组服务器的配置文件

ps aux|grep nginx

image.png

在 /rrzuji/nginx/conf/vhosts 下新建配置

1
2
3
4
5
6
7
8
server {
listen 80;
server_name npm.rruzji.net;
location / {
proxy_pass http://127.0.0.1:4873/;
proxy_set_header Host $host;
}
}

6.访问服务 npm.rrzuji.net

出现以下界面说明容器已经正常运行
image.png

7.设置访问权限

尝试新建用户,发现出现500错误
image.png
查看docker日志,提示 permission denied 显然,这是一个文件权限问题,容器无法访问宿主路径。

docker logs –tail 20 verdaccio

image.png
查看文档
image.png

执行下面代码,10001是容器内verdaccio使用的UID,65533是GID

sudo chown -R 10001:65533 /alidata/www/npm/conf/htpasswd
sudo chown -R 10001:65533 /alidata/www/npm/storage

测试功能是否正常
image.png

到这里安装已经结束。接下来介绍一下如何使用以及注意事项。

四、使用方法

1.安装nrm

没有安装nrm的可以先安装nrm,nrm是用来给npm快速换源的工具。安装好之后,新增一个名为 rnpm 的源。

npm i nrm -g
nrm add rnpm http://npm.rrzuji.net

image.png

http://npm.rrzuji.net 相当于私有源+淘宝源的组合,以下统称rnpm。

2.用户注册

image.png

3.用户登录

这个登录表示用户登录到rnpm,会在 userconfig 的文件里新增一条登录信息,不会影响到旧的npm用户授权认证记录。nrm use npm 切换后,就变成了npm用户,再 nrm use rnpm,就变成了 rnpm用户。
image.png
image.png

4.上传软件包

登录后,就可以上传软件包了,进入到软件包目录,执行

npm publish

注意,提交的软件包名需要为私有库的格式(@scope/*),同时也是为了和普通的包区别开来,否则会被拦截。

image.png
image.png

5.项目安装使用

安装私有包

npm install @scope/browser-version-tool-html

安装私有包和公共npm仓库的包,这是一个混用的例子

npm install @scope/browser-version-tool-html axios

image.png

6.删除软件包

npm unpublish

7.更新软件包

更新版本号后,重新发布进行更新

npm publish

经典题解-回溯法和动态规划

题目

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 **有效的 **括号组合。


示例:
输入:n = 3
输出:[
      “((()))”,
      “(()())”,
      “(())()”,
      “()(())”,
      “()()()”
    ]

解法

回溯法

判断回溯很简单,拿到一个问题,你感觉如果不穷举一下就没法知道答案,那就可以开始回溯了。


一般回溯的问题有三种:

  • Find a path to success 有没有解
  • Find all paths to success 求所有解
    • 求所有解的个数
    • 求所有解的具体信息
  • Find the best path to success 求最优解


回溯法是一个剪枝了的二叉树。我们要得到的结果是可以 good leaf,如果不满足 good leaf 就继续向下搜索,搜索的时候需要满足一定的条件。


image.png


从上面的图片中我们可以很明显的看到,最后五条画黑线的就是最终的结果,其中左分支都是添加左括号,右分支都是添加右括号。


那么我们在什么情况下添加左括号呢?很明显,最多能添加 n 个左括号,在递归调用的时候,在能传递到最底层的共用字符串中先添加 ”(“ ,然后 left-1,递归调用就可以。
那什么时候添加右括号呢?当左括号个数大于右括号的个数时添加右括号。


总之,向下搜索要满足两个条件:

  • 插入数量不超过n
  • 可以插入 ) 的前提是 ( 的数量大于 )


回溯法的代码套路是使用两个变量: res 和 path,res 表示最终的结果,path 保存已经走过的路径。如果搜到一个状态满足题目要求,就把 path 放到 res 中。


代码后面的判断条件都是 if,而不是 elif,因为是满足两个条件的任意一个就可以继续向下搜索,而不是同时只能满足其中的一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @param {number} n
* @return {string[]}
*/
var generateParenthesis = function(n) {
// 保存所有有效路径
let res = [];
dfs(res, n, n, '');
return res;
};

function dfs(res, left, right, path) {
if (right === 0) {
return res.push(path);
}
if (left > 0) {
dfs(res, left - 1, right , path + '(');
}
if (right > left) {
dfs(res, left, right - 1 , path + ')');
}
}

动态规划

什么是动态规划?在此题中,动态规划的思想类似于数学归纳法,当知道所有 i<n 的情况时,我们可以通过某种算法算出 i=n 的情况。

1
2
3
dp递推公式:
dp[i]="("+dp[m]+")"+dp[k]
其中m+k=i-1


本题最核心的思想是,考虑 i=n 时相比 n-1 组括号增加的那一组括号的位置。


当我们清楚所有 i<n 时括号的可能生成排列后,对与 i=n 的情况,我们考虑整个括号排列中最左边的括号。
它一定是一个左括号,那么它可以和它对应的右括号组成一组完整的括号 “( )”,我们认为这一组是相比 n-1 增加进来的括号。


那么,剩下 n-1 组括号有可能在哪呢?


剩下的括号要么在这一组新增的括号内部,要么在这一组新增括号的外部(右侧)。
既然知道了 i<n 的情况,那我们就可以对所有情况进行遍历:


“(“ + 【i=p时所有括号的排列组合】 + “)” + 【i=q时所有括号的排列组合】
其中 p + q = n-1,且 p q 均为非负整数。
事实上,当上述 p 从 0 取到 n-1,q 从 n-1 取到 0 后,所有情况就遍历完了。


注:上述遍历是没有重复情况出现的,即当 (p1,q1)≠(p2,q2) 时,按上述方式取的括号组合一定不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @param {number} n
* @return {string[]}
*/
var generateParenthesis = function (n) {
const cache = [];
cache[0] = [""];
cache[1] = ["()"];
for (let i = 2; i <= n; i++) {
const temp = [];
for (let j = 0; j < i; j++) {
const list1 = cache[j];
const list2 = cache[i - 1 - j];
for (let k1 of list1) {
for (let k2 of list2) {
temp.push("(" + k1 + ")" + k2);
}
}
}
cache.push(temp);
}
return cache[n];
};



原文链接:[https://leetcode-cn.com/problems/generate-parentheses/solution/zui-jian-dan-yi-dong-de-dong-tai-gui-hua-bu-lun-da/](https://leetcode-cn.com/problems/generate-parentheses/solution/zui-jian-dan-yi-dong-de-dong-tai-gui-hua-bu-lun-da/)
原文链接:[https://leetcode-cn.com/problems/generate-parentheses](https://leetcode-cn.com/problems/generate-parentheses)
原文链接:[https://leetcode-cn.com/problems/generate-parentheses/solution/ru-men-ji-bie-de-hui-su-fa-xue-hui-tao-lu-miao-don/](https://leetcode-cn.com/problems/generate-parentheses/solution/ru-men-ji-bie-de-hui-su-fa-xue-hui-tao-lu-miao-don/)

JavaScript 深入之头疼的类型转换(上)

在 JavaScript 中,有一部分内容,情况复杂,容易出错,饱受争议但又应用广泛,这便是类型转换。

前言

将值从一种类型转换为另一种类型通常称为类型转换。

ES6 前,JavaScript 共有六种数据类型:Undefined、Null、Boolean、Number、String、Object。

我们先捋一捋基本类型之间的转换。

原始值转布尔

我们使用 Boolean 函数将类型转换成布尔类型,在 JavaScript 中,只有 6 种值可以被转换成 false,其他都会被转换成 true。

1
2
3
4
5
6
7
8
9
10
console.log(Boolean()) // false

console.log(Boolean(false)) // false

console.log(Boolean(undefined)) // false
console.log(Boolean(null)) // false
console.log(Boolean(+0)) // false
console.log(Boolean(-0)) // false
console.log(Boolean(NaN)) // false
console.log(Boolean("")) // false

注意,当 Boolean 函数不传任何参数时,会返回 false。

原始值转数字

我们可以使用 Number 函数将类型转换成数字类型,如果参数无法被转换为数字,则返回 NaN。

在看例子之前,我们先看 ES5 规范 15.7.1.1 中关于 Number 的介绍:

根据规范,如果 Number 函数不传参数,返回 +0,如果有参数,调用 ToNumber(value)

注意这个 ToNumber 表示的是一个底层规范实现上的方法,并没有直接暴露出来。

ToNumber 则直接给了一个对应的结果表。表如下:

参数类型 结果
Undefined NaN
Null +0
Boolean 如果参数是 true,返回 1。参数为 false,返回 +0
Number 返回与之相等的值
String 这段比较复杂,看例子

让我们写几个例子验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
console.log(Number()) // +0

console.log(Number(undefined)) // NaN
console.log(Number(null)) // +0

console.log(Number(false)) // +0
console.log(Number(true)) // 1

console.log(Number("123")) // 123
console.log(Number("-123")) // -123
console.log(Number("1.2")) // 1.2
console.log(Number("000123")) // 123
console.log(Number("-000123")) // -123

console.log(Number("0x11")) // 17

console.log(Number("")) // 0
console.log(Number(" ")) // 0

console.log(Number("123 123")) // NaN
console.log(Number("foo")) // NaN
console.log(Number("100a")) // NaN

如果通过 Number 转换函数传入一个字符串,它会试图将其转换成一个整数或浮点数,而且会忽略所有前导的 0,如果有一个字符不是数字,结果都会返回 NaN,鉴于这种严格的判断,我们一般还会使用更加灵活的 parseInt 和 parseFloat 进行转换。

parseInt 只解析整数,parseFloat 则可以解析整数和浮点数,如果字符串前缀是 “0x” 或者 “0X”,parseInt 将其解释为十六进制数,parseInt 和 parseFloat 都会跳过任意数量的前导空格,尽可能解析更多数值字符,并忽略后面的内容。如果第一个非空格字符是非法的数字直接量,将最终返回 NaN:

1
2
3
4
5
6
console.log(parseInt("3 abc")) // 3
console.log(parseFloat("3.14 abc")) // 3.14
console.log(parseInt("-12.34")) // -12
console.log(parseInt("0xFF")) // 255
console.log(parseFloat(".1")) // 0.1
console.log(parseInt("0.1")) // 0

原始值转字符

我们使用 String 函数将类型转换成字符串类型,依然先看 规范 15.5.1.1中有关 String 函数的介绍:

如果 String 函数不传参数,返回空字符串,如果有参数,调用 ToString(value),而 ToString 也给了一个对应的结果表。表如下:

参数类型 结果
Undefined “undefined”
Null “null”
Boolean 如果参数是 true,返回 “true”。参数为 false,返回 “false”
Number 又是比较复杂,可以看例子
String 返回与之相等的值

让我们写几个例子验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
console.log(String()) // 空字符串

console.log(String(undefined)) // undefined
console.log(String(null)) // null

console.log(String(false)) // false
console.log(String(true)) // true

console.log(String(0)) // 0
console.log(String(-0)) // 0
console.log(String(NaN)) // NaN
console.log(String(Infinity)) // Infinity
console.log(String(-Infinity)) // -Infinity
console.log(String(1)) // 1

注意这里的 ToString 和上一节的 ToNumber 都是底层规范实现的方法,并没有直接暴露出来。

原始值转对象

原始值到对象的转换非常简单,原始值通过调用 String()、Number() 或者 Boolean() 构造函数,转换为它们各自的包装对象。

null 和 undefined 属于例外,当将它们用在期望是一个对象的地方都会造成一个类型错误 (TypeError) 异常,而不会执行正常的转换。

1
2
3
4
var a = 1;
console.log(typeof a); // number
var b = new Number(a);
console.log(typeof b); // object

对象转布尔值

对象到布尔值的转换非常简单:所有对象 (包括数组和函数) 都转换为 true。对于包装对象也是这样,举个例子:

1
console.log(Boolean(new Boolean(false))) // true

对象转字符串和数字

对象到字符串和对象到数字的转换都是通过调用待转换对象的一个方法来完成的。而 JavaScript 对象有两个不同的方法来执行转换,一个是 toString,一个是 valueOf。注意这个跟上面所说的 ToStringToNumber 是不同的,这两个方法是真实暴露出来的方法。

所有的对象除了 null 和 undefined 之外的任何值都具有 toString 方法,通常情况下,它和使用 String 方法返回的结果一致。toString 方法的作用在于返回一个反映这个对象的字符串,然而这才是情况复杂的开始。

《JavaScript 专题之类型判断 (上)》中讲到过 Object.prototype.toString 方法会根据这个对象的[[class]]内部属性,返回由 “[object “和 class 和”]” 三个部分组成的字符串。举个例子:

1
2
3
Object.prototype.toString.call({a: 1}) // "[object Object]"
({a: 1}).toString() // "[object Object]"
({a: 1}).toString === Object.prototype.toString // true

我们可以看出当调用对象的 toString 方法时,其实调用的是 Object.prototype 上的 toString 方法。

然而 JavaScript 下的很多类根据各自的特点,定义了更多版本的 toString 方法。例如:

  1. 数组的 toString 方法将每个数组元素转换成一个字符串,并在元素之间添加逗号后合并成结果字符串。
  2. 函数的 toString 方法返回源代码字符串。
  3. 日期的 toString 方法返回一个可读的日期和时间字符串。
  4. RegExp 的 toString 方法返回一个表示正则表达式直接量的字符串。

读文字太抽象?我们直接写例子:

1
2
3
4
5
6
7
8
console.log(({}).toString()) // [object Object]

console.log([].toString()) // ""
console.log([0].toString()) // 0
console.log([1, 2, 3].toString()) // 1,2,3
console.log((function(){var a = 1;}).toString()) // function (){var a = 1;}
console.log((/\d+/g).toString()) // /\d+/g
console.log((new Date(2010, 0, 1)).toString()) // Fri Jan 01 2010 00:00:00 GMT+0800 (CST)

而另一个转换对象的函数是 valueOf,表示对象的原始值。默认的 valueOf 方法返回这个对象本身,数组、函数、正则简单的继承了这个默认方法,也会返回对象本身。日期是一个例外,它会返回它的一个内容表示: 1970 年 1 月 1 日以来的毫秒数。

1
2
var date = new Date(2017, 4, 21);
console.log(date.valueOf()) // 1495296000000

对象接着转字符串和数字

了解了 toString 方法和 valueOf 方法,我们分析下从对象到字符串是如何转换的。看规范 ES5 9.8,其实就是 ToString 方法的对应表,只是这次我们加上 Object 的转换规则:

参数类型 结果
Object 1. primValue = ToPrimitive(input, String)
2. 返回 ToString(primValue).

所谓的 ToPrimitive 方法,其实就是输入一个值,然后返回一个一定是基本类型的值。

我们总结一下,当我们用 String 方法转化一个值的时候,如果是基本类型,就参照 “原始值转字符” 这一节的对应表,如果不是基本类型,我们会将调用一个 ToPrimitive 方法,将其转为基本类型,然后再参照 “原始值转字符” 这一节的对应表进行转换。

其实,从对象到数字的转换也是一样:

参数类型 结果
Object 1. primValue = ToPrimitive(input, Number)
2. 返回 ToNumber(primValue)。

虽然转换成基本值都会使用 ToPrimitive 方法,但传参有不同,最后的处理也有不同,转字符串调用的是 ToString,转数字调用 ToNumber

ToPrimitive

那接下来就要看看 ToPrimitive 了,在了解了 toString 和 valueOf 方法后,这个也很简单。

让我们看规范 9.1,函数语法表示如下:

1
ToPrimitive(input[, PreferredType])

第一个参数是 input,表示要处理的输入值。

第二个参数是 PreferredType,非必填,表示希望转换成的类型,有两个值可以选,Number 或者 String。

当不传入 PreferredType 时,如果 input 是日期类型,相当于传入 String,否则,都相当于传入 Number。

如果传入的 input 是 Undefined、Null、Boolean、Number、String 类型,直接返回该值。

如果是 ToPrimitive(obj, Number),处理步骤如下:

  1. 如果 obj 为 基本类型,直接返回
  2. 否则,调用 valueOf 方法,如果返回一个原始值,则 JavaScript 将其返回。
  3. 否则,调用 toString 方法,如果返回一个原始值,则 JavaScript 将其返回。
  4. 否则,JavaScript 抛出一个类型错误异常。

如果是 ToPrimitive(obj, String),处理步骤如下:

  1. 如果 obj 为 基本类型,直接返回
  2. 否则,调用 toString 方法,如果返回一个原始值,则 JavaScript 将其返回。
  3. 否则,调用 valueOf 方法,如果返回一个原始值,则 JavaScript 将其返回。
  4. 否则,JavaScript 抛出一个类型错误异常。

对象转字符串

所以总结下,对象转字符串 (就是 Number() 函数) 可以概括为:

  1. 如果对象具有 toString 方法,则调用这个方法。如果他返回一个原始值,JavaScript 将这个值转换为字符串,并返回这个字符串结果。
  2. 如果对象没有 toString 方法,或者这个方法并不返回一个原始值,那么 JavaScript 会调用 valueOf 方法。如果存在这个方法,则 JavaScript 调用它。如果返回值是原始值,JavaScript 将这个值转换为字符串,并返回这个字符串的结果。
  3. 否则,JavaScript 无法从 toString 或者 valueOf 获得一个原始值,这时它将抛出一个类型错误异常。

对象转数字

对象转数字的过程中,JavaScript 做了同样的事情,只是它会首先尝试 valueOf 方法

  1. 如果对象具有 valueOf 方法,且返回一个原始值,则 JavaScript 将这个原始值转换为数字并返回这个数字
  2. 否则,如果对象具有 toString 方法,且返回一个原始值,则 JavaScript 将其转换并返回。
  3. 否则,JavaScript 抛出一个类型错误异常。

举个例子:

1
2
3
4
5
6
7
8
9
10
console.log(Number({})) // NaN
console.log(Number({a : 1})) // NaN

console.log(Number([])) // 0
console.log(Number([0])) // 0
console.log(Number([1, 2, 3])) // NaN
console.log(Number(function(){var a = 1;})) // NaN
console.log(Number(/\d+/g)) // NaN
console.log(Number(new Date(2010, 0, 1))) // 1262275200000
console.log(Number(new Error('a'))) // NaN

注意,在这个例子中,[][0] 都返回了 0,而 [1, 2, 3] 却返回了一个 NaN。我们分析一下原因:

当我们 Number([]) 的时候,先调用 []valueOf 方法,此时返回 [],因为返回了一个对象而不是原始值,所以又调用了 toString 方法,此时返回一个空字符串,接下来调用 ToNumber 这个规范上的方法,参照对应表,转换为 0, 所以最后的结果为 0

而当我们 Number([1, 2, 3]) 的时候,先调用 [1, 2, 3]valueOf 方法,此时返回 [1, 2, 3],再调用 toString 方法,此时返回 1,2,3,接下来调用 ToNumber,参照对应表,因为无法转换为数字,所以最后的结果为 NaN

JSON.stringify

值得一提的是:JSON.stringify() 方法可以将一个 JavaScript 值转换为一个 JSON 字符串,实现上也是调用了 toString 方法,也算是一种类型转换的方法。下面讲一讲 JSON.stringify 的注意要点:

  1. 处理基本类型时,与使用 toString 基本相同,结果都是字符串,除了 undefined
1
2
3
4
5
console.log(JSON.stringify(null)) // null
console.log(JSON.stringify(undefined)) // undefined,注意这个undefined不是字符串的undefined
console.log(JSON.stringify(true)) // true
console.log(JSON.stringify(42)) // 42
console.log(JSON.stringify("42")) // "42"
  1. 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。
1
JSON.stringify([new Number(1), new String("false"), new Boolean(false)]); // "[1,"false",false]"

3.undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)。

1
2
3
4
5
JSON.stringify({x: undefined, y: Object, z: Symbol("")}); 
// "{}"

JSON.stringify([undefined, Object, Symbol("")]);
// "[null,null,null]"

4.JSON.stringify 有第二个参数 replacer,它可以是数组或者函数,用来指定对象序列化过程中哪些属性应该被处理,哪些应该被排除。

1
2
3
4
5
6
7
8
9
10
11
12
function replacer(key, value) {
if (typeof value === "string") {
return undefined;
}
return value;
}

var foo = {foundation: "Mozilla", model: "box", week: 45, transport: "car", month: 7};
var jsonString = JSON.stringify(foo, replacer);

console.log(jsonString)
// {"week":45,"month":7}
1
2
3
var foo = {foundation: "Mozilla", model: "box", week: 45, transport: "car", month: 7};
console.log(JSON.stringify(foo, ['week', 'month']));
// {"week":45,"month":7}
  1. 如果一个被序列化的对象拥有 toJSON 方法,那么该 toJSON 方法就会覆盖该对象默认的序列化行为:不是那个对象被序列化,而是调用 toJSON 方法后的返回值会被序列化,例如:
1
2
3
4
5
6
7
8
var obj = {
foo: 'foo',
toJSON: function () {
return 'bar';
}
};
JSON.stringify(obj); // '"bar"'
JSON.stringify({x: obj}); // '{"x":"bar"}'

JavaScript 深入之浮点数精度

前言

0.1 + 0.2 是否等于 0.3 作为一道经典的面试题,已经广外熟知,说起原因,大家能回答出这是浮点数精度问题导致,也能辩证的看待这并非是 ECMAScript 这门语言的问题,今天就是具体看一下背后的原因。

数字类型

ECMAScript 中的 Number 类型使用 IEEE754 标准来表示整数和浮点数值。所谓 IEEE754 标准,全称 IEEE 二进制浮点数算术标准,这个标准定义了表示浮点数的格式等内容。

在 IEEE754 中,规定了四种表示浮点数值的方式:单精确度(32 位)、双精确度(64 位)、延伸单精确度、与延伸双精确度。像 ECMAScript 采用的就是双精确度,也就是说,会用 64 位字节来储存一个浮点数。

浮点数转二进制

我们来看下 1020 用十进制的表示:

1020 = 1 * 10^3 + 0 * 10^2 + 2 * 10^1 + 0 * 10^0

所以 1020 用十进制表示就是 1020……(哈哈)

如果 1020 用二进制来表示呢?

1020 = 1 * 2^9 + 1 * 2^8 + 1 * 2^7 + 1 * 2^6 + 1 * 2^5 + 1 * 2^4 + 1 * 2^3 + 1 * 2^2 + 0 * 2^1 + 0 * 2^0

所以 1020 的二进制为 1111111100

那如果是 0.75 用二进制表示呢?同理应该是:

0.75 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4 + …

因为使用的是二进制,这里的 abcd…… 的值的要么是 0 要么是 1。

那怎么算出 abcd…… 的值呢,我们可以两边不停的乘以 2 算出来,解法如下:

0.75 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4…

两边同时乘以 2

1 + 0.5 = a * 2^0 + b * 2^-1 + c * 2^-2 + d * 2^-3… (所以 a = 1)

剩下的:

0.5 = b * 2^-1 + c * 2^-2 + d * 2^-3…

再同时乘以 2

1 + 0 = b * 2^0 + c * 2^-2 + d * 2^-3… (所以 b = 1)

所以 0.75 用二进制表示就是 0.ab,也就是 0.11

然而不是所有的数都像 0.75 这么好算,我们来算下 0.1:

1
2
3
4
5
6
7
8
9
10
11
0.1 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4 + ...

0 + 0.2 = a * 2^0 + b * 2^-1 + c * 2^-2 + ... (a = 0)
0 + 0.4 = b * 2^0 + c * 2^-1 + d * 2^-2 + ... (b = 0)
0 + 0.8 = c * 2^0 + d * 2^-1 + e * 2^-2 + ... (c = 0)
1 + 0.6 = d * 2^0 + e * 2^-1 + f * 2^-2 + ... (d = 1)
1 + 0.2 = e * 2^0 + f * 2^-1 + g * 2^-2 + ... (e = 1)
0 + 0.4 = f * 2^0 + g * 2^-1 + h * 2^-2 + ... (f = 0)
0 + 0.8 = g * 2^0 + h * 2^-1 + i * 2^-2 + ... (g = 0)
1 + 0.6 = h * 2^0 + i * 2^-1 + j * 2^-2 + ... (h = 1)
....

然后你就会发现,这个计算在不停的循环,所以 0.1 用二进制表示就是 0.00011001100110011……

浮点数的存储

虽然 0.1 转成二进制时是一个无限循环的数,但计算机总要储存吧,我们知道 ECMAScript 使用 64 位字节来储存一个浮点数,那具体是怎么储存的呢?这就要说回 IEEE754 这个标准了,毕竟是这个标准规定了存储的方式。

这个标准认为,一个浮点数 (Value) 可以这样表示:

Value = sign * exponent * fraction

看起来很抽象的样子,简单理解就是科学计数法……

比如 -1020,用科学计数法表示就是:

-1 * 10^3 * 1.02

sign 就是 -1,exponent 就是 10^3,fraction 就是 1.02

对于二进制也是一样,以 0.1 的二进制 0.00011001100110011…… 这个数来说:

可以表示为:

1 * 2^-4 * 1.1001100110011……

其中 sign 就是 1,exponent 就是 2^-4,fraction 就是 1.1001100110011……

而当只做二进制科学计数法的表示时,这个 Value 的表示可以再具体一点变成:

V = (-1)^S * (1 + Fraction) * 2^E

(如果所有的浮点数都可以这样表示,那么我们存储的时候就把这其中会变化的一些值存储起来就好了)

我们来一点点看:

(-1)^S 表示符号位,当 S = 0,V 为正数;当 S = 1,V 为负数。

再看 (1 + Fraction),这是因为所有的浮点数都可以表示为 1.xxxx * 2^xxx 的形式,前面的一定是 1.xxx,那干脆我们就不存储这个 1 了,直接存后面的 xxxxx 好了,这也就是 Fraction 的部分。

最后再看 2^E

如果是 1020.75,对应二进制数就是 1111111100.11,对应二进制科学计数法就是 1 * 1.11111110011 * 2^9,E 的值就是 9,而如果是 0.1 ,对应二进制是 1 * 1.1001100110011…… * 2^-4, E 的值就是 -4,也就是说,E 既可能是负数,又可能是正数,那问题就来了,那我们该怎么储存这个 E 呢?

我们这样解决,假如我们用 8 位字节来存储 E 这个数,如果只有正数的话,储存的值的范围是 0 ~ 254,而如果要储存正负数的话,值的范围就是 -127~127,我们在存储的时候,把要存储的数字加上 127,这样当我们存 -127 的时候,我们存 0,当存 127 的时候,存 254,这样就解决了存负数的问题。对应的,当取值的时候,我们再减去 127。

所以呢,真到实际存储的时候,我们并不会直接存储 E,而是会存储 E + bias,当用 8 个字节的时候,这个 bias 就是 127。

所以,如果要存储一个浮点数,我们存 S 和 Fraction 和 E + bias 这三个值就好了,那具体要分配多少个字节位来存储这些数呢?IEEE754 给出了标准:

在这个标准下:

我们会用 1 位存储 S,0 表示正数,1 表示负数。

用 11 位存储 E + bias,对于 11 位来说,bias 的值是 2^(11-1) - 1,也就是 1023。

用 52 位存储 Fraction。

举个例子,就拿 0.1 来看,对应二进制是 1 * 1.1001100110011…… * 2^-4, Sign 是 0,E + bias 是 -4 + 1023 = 1019,1019 用二进制表示是 1111111011,Fraction 是 1001100110011……

对应 64 个字节位的完整表示就是:

0 01111111011 1001100110011001100110011001100110011001100110011010

同理, 0.2 表示的完整表示是:

0 01111111100 1001100110011001100110011001100110011001100110011010

所以当 0.1 存下来的时候,就已经发生了精度丢失,当我们用浮点数进行运算的时候,使用的其实是精度丢失后的数。

浮点数的运算

关于浮点数的运算,一般由以下五个步骤完成:对阶、尾数运算、规格化、舍入处理、溢出判断。我们来简单看一下 0.1 和 0.2 的计算。

首先是对阶,所谓对阶,就是把阶码调整为相同,比如 0.1 是 1.1001100110011…… * 2^-4,阶码是 -4,而 0.2 就是 1.10011001100110...* 2^-3,阶码是 -3,两个阶码不同,所以先调整为相同的阶码再进行计算,调整原则是小阶对大阶,也就是 0.1 的 -4 调整为 -3,对应变成 0.11001100110011…… * 2^-3

接下来是尾数计算:

1
2
3
4
  0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
————————————————————————————————————————————————————————
10.0110011001100110011001100110011001100110011001100111

我们得到结果为 10.0110011001100110011001100110011001100110011001100111 * 2^-3

将这个结果处理一下,即结果规格化,变成 1.0011001100110011001100110011001100110011001100110011(1) * 2^-2

括号里的 1 意思是说计算后这个 1 超出了范围,所以要被舍弃了。

再然后是舍入,四舍五入对应到二进制中,就是 0 舍 1 入,因为我们要把括号里的 1 丢了,所以这里会进一,结果变成

1.0011001100110011001100110011001100110011001100110100 * 2^-2

本来还有一个溢出判断,因为这里不涉及,就不讲了。

所以最终的结果存成 64 位就是

0 01111111101 0011001100110011001100110011001100110011001100110100

将它转换为 10 进制数就得到 0.30000000000000004440892098500626

因为两次存储时的精度丢失加上一次运算时的精度丢失,最终导致了 0.1 + 0.2 !== 0.3

其他

1
2
3
4
5
6
7
8
9
10
11
12
13
// 十进制转二进制
parseFloat(0.1).toString(2);
=> "0.0001100110011001100110011001100110011001100110011001101"

// 二进制转十进制
parseInt(1100100,2)
=> 100

// 以指定的精度返回该数值对象的字符串表示
(0.1 + 0.2).toPrecision(21)
=> "0.300000000000000044409"
(0.3).toPrecision(21)
=> "0.299999999999999988898"

参考

  1. why is 0.1+0.2 not equal to 0.3 in most programming languages
  2. IEEE-754 标准与浮点数运算

深入系列

JavaScript 深入系列目录地址:https://github.com/mqyqingfeng/Blog。

JavaScript 深入系列预计写十五篇左右,旨在帮大家捋顺 JavaScript 底层知识,重点讲解如原型、作用域、执行上下文、变量对象、this、闭包、按值传递、call、apply、bind、new、继承等难点概念。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

JavaScript深入之bind的模拟实现

bind

一句话介绍 bind:

bind() 方法会创建一个新函数。当这个新函数被调用时,bind() 的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数。(来自于 MDN )

由此我们可以首先得出 bind 函数的两个特点:

  1. 返回一个函数
  2. 可以传入参数

返回函数的模拟实现

从第一个特点开始,我们举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
var foo = {
value: 1
};

function bar() {
console.log(this.value);
}

// 返回了一个函数
var bindFoo = bar.bind(foo);

bindFoo(); // 1

关于指定 this 的指向,我们可以使用 call 或者 apply 实现,关于 call 和 apply 的模拟实现,可以查看《JavaScript 深入之 call 和 apply 的模拟实现》。我们来写第一版的代码:

1
2
3
4
5
6
7
8
// 第一版
Function.prototype.bind2 = function (context) {
var self = this;
return function () {
return self.apply(context);
}

}

此外,之所以 return self.apply(context),是考虑到绑定函数可能是有返回值的,依然是这个例子:

1
2
3
4
5
6
7
8
9
10
11
var foo = {
value: 1
};

function bar() {
return this.value;
}

var bindFoo = bar.bind(foo);

console.log(bindFoo()); // 1

传参的模拟实现

接下来看第二点,可以传入参数。这个就有点让人费解了,我在 bind 的时候,是否可以传参呢?我在执行 bind 返回的函数的时候,可不可以传参呢?让我们看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var foo = {
value: 1
};

function bar(name, age) {
console.log(this.value);
console.log(name);
console.log(age);

}

var bindFoo = bar.bind(foo, 'daisy');
bindFoo('18');
// 1
// daisy
// 18

函数需要传 name 和 age 两个参数,竟然还可以在 bind 的时候,只传一个 name,在执行返回的函数的时候,再传另一个参数 age!

这可咋办?不急,我们用 arguments 进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 第二版
Function.prototype.bind2 = function (context) {

var self = this;
// 获取bind2函数从第二个参数到最后一个参数
var args = Array.prototype.slice.call(arguments, 1);

return function () {
// 这个时候的arguments是指bind返回的函数传入的参数
var bindArgs = Array.prototype.slice.call(arguments);
return self.apply(context, args.concat(bindArgs));
}

}

构造函数效果的模拟实现

完成了这两点,最难的部分到啦!因为 bind 还有一个特点,就是

一个绑定函数也能使用 new 操作符创建对象:这种行为就像把原函数当成构造器。提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。

也就是说当 bind 返回的函数作为构造函数的时候,bind 时指定的 this 值会失效,但传入的参数依然生效。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var value = 2;

var foo = {
value: 1
};

function bar(name, age) {
this.habit = 'shopping';
console.log(this.value);
console.log(name);
console.log(age);
}

bar.prototype.friend = 'kevin';

var bindFoo = bar.bind(foo, 'daisy');

var obj = new bindFoo('18');
// undefined
// daisy
// 18
console.log(obj.habit);
console.log(obj.friend);
// shopping
// kevin

注意:尽管在全局和 foo 中都声明了 value 值,最后依然返回了 undefind,说明绑定的 this 失效了,如果大家了解 new 的模拟实现,就会知道这个时候的 this 已经指向了 obj。

(哈哈,我这是为我的下一篇文章《JavaScript 深入系列之 new 的模拟实现》打广告)。

所以我们可以通过修改返回的函数的原型来实现,让我们写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 第三版
Function.prototype.bind2 = function (context) {
var self = this;
var args = Array.prototype.slice.call(arguments, 1);

var fBound = function () {
var bindArgs = Array.prototype.slice.call(arguments);
// 当作为构造函数时,this 指向实例,此时结果为 true,将绑定函数的 this 指向该实例,可以让实例获得来自绑定函数的值
// 以上面的是 demo 为例,如果改成 `this instanceof fBound ? null : context`,实例只是一个空对象,将 null 改成 this ,实例会具有 habit 属性
// 当作为普通函数时,this 指向 window,此时结果为 false,将绑定函数的 this 指向 context
return self.apply(this instanceof fBound ? this : context, args.concat(bindArgs));
}
// 修改返回函数的 prototype 为绑定函数的 prototype,实例就可以继承绑定函数的原型中的值
fBound.prototype = this.prototype;
return fBound;
}

如果对原型链稍有困惑,可以查看《JavaScript 深入之从原型到原型链》

构造函数效果的优化实现

但是在这个写法中,我们直接将 fBound.prototype = this.prototype,我们直接修改 fBound.prototype 的时候,也会直接修改绑定函数的 prototype。这个时候,我们可以通过一个空函数来进行中转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 第四版
Function.prototype.bind2 = function (context) {

var self = this;
var args = Array.prototype.slice.call(arguments, 1);

var fNOP = function () {};

var fBound = function () {
var bindArgs = Array.prototype.slice.call(arguments);
return self.apply(this instanceof fNOP ? this : context, args.concat(bindArgs));
}

fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
}

到此为止,大的问题都已经解决,给自己一个赞!o( ̄▽ ̄)d

三个小问题

接下来处理些小问题:

1.apply 这段代码跟 MDN 上的稍有不同

在 MDN 中文版讲 bind 的模拟实现时,apply 这里的代码是:

1
self.apply(this instanceof self ? this : context || this, args.concat(bindArgs))

多了一个关于 context 是否存在的判断,然而这个是错误的!

举个例子:

1
2
3
4
5
6
7
8
9
10
11
var value = 2;
var foo = {
value: 1,
bar: bar.bind(null)
};

function bar() {
console.log(this.value);
}

foo.bar() // 2

以上代码正常情况下会打印 2,如果换成了 context || this,这段代码就会打印 1!

所以这里不应该进行 context 的判断,大家查看 MDN 同样内容的英文版,就不存在这个判断!

(2018 年 3 月 27 日更新,中文版已经改了😀)

2. 调用 bind 的不是函数咋办?

不行,我们要报错!

1
2
3
if (typeof this !== "function") {
throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
}

3. 我要在线上用

那别忘了做个兼容:

1
2
3
Function.prototype.bind = Function.prototype.bind || function () {
……
};

当然最好是用 es5-shim 啦。

最终代码

所以最最后的代码就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Function.prototype.bind2 = function (context) {

if (typeof this !== "function") {
throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
}

var self = this;
var args = Array.prototype.slice.call(arguments, 1);

var fNOP = function () {};

var fBound = function () {
var bindArgs = Array.prototype.slice.call(arguments);
return self.apply(this instanceof fNOP ? this : context, args.concat(bindArgs));
}

fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
}

JavaScript深入之call和apply的模拟实现

call

一句话介绍 call:

call() 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法。

举个例子:

1
2
3
4
5
6
7
8
9
var foo = {
value: 1
};

function bar() {
console.log(this.value);
}

bar.call(foo); // 1

注意两点:

  1. call 改变了 this 的指向,指向到 foo
  2. bar 函数执行了

模拟实现第一步

那么我们该怎么模拟实现这两个效果呢?

试想当调用 call 的时候,把 foo 对象改造成如下:

1
2
3
4
5
6
7
8
var foo = {
value: 1,
bar: function() {
console.log(this.value)
}
};

foo.bar(); // 1

这个时候 this 就指向了 foo,是不是很简单呢?

但是这样却给 foo 对象本身添加了一个属性,这可不行呐!

不过也不用担心,我们用 delete 再删除它不就好了~

所以我们模拟的步骤可以分为:

  1. 将函数设为对象的属性
  2. 执行该函数
  3. 删除该函数

以上个例子为例,就是:

1
2
3
4
5
6
// 第一步
foo.fn = bar
// 第二步
foo.fn()
// 第三步
delete foo.fn

fn 是对象的属性名,反正最后也要删除它,所以起成什么都无所谓。

根据这个思路,我们可以尝试着去写第一版的 call2 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 第一版
Function.prototype.call2 = function(context) {
// 首先要获取调用call的函数,用this可以获取
context.fn = this;
context.fn();
delete context.fn;
}

// 测试一下
var foo = {
value: 1
};

function bar() {
console.log(this.value);
}

bar.call2(foo); // 1

正好可以打印 1 哎!是不是很开心!(~ ̄▽ ̄)~

模拟实现第二步

最一开始也讲了,call 函数还能给定参数执行函数。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var foo = {
value: 1
};

function bar(name, age) {
console.log(name)
console.log(age)
console.log(this.value);
}

bar.call(foo, 'kevin', 18);
// kevin
// 18
// 1

注意:传入的参数并不确定,这可咋办?

不急,我们可以从 Arguments 对象中取值,取出第二个到最后一个参数,然后放到一个数组里。

比如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 以上个例子为例,此时的arguments为:
// arguments = {
// 0: foo,
// 1: 'kevin',
// 2: 18,
// length: 3
// }
// 因为arguments是类数组对象,所以可以用for循环
var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
args.push('arguments[' + i + ']');
}

// 执行后 args为 ["arguments[1]", "arguments[2]", "arguments[3]"]

不定长的参数问题解决了,我们接着要把这个参数数组放到要执行的函数的参数里面去。

1
2
3
4
// 将数组里的元素作为多个参数放进函数的形参里
context.fn(args.join(','))
// (O_o)??
// 这个方法肯定是不行的啦!!!

也许有人想到用 ES6 的方法,不过 call 是 ES3 的方法,我们为了模拟实现一个 ES3 的方法,要用到 ES6 的方法,好像……,嗯,也可以啦。但是我们这次用 eval 方法拼成一个函数,类似于这样:

1
eval('context.fn(' + args +')')

这里 args 会自动调用 Array.toString() 这个方法。

所以我们的第二版克服了两个大问题,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 第二版
Function.prototype.call2 = function(context) {
context.fn = this;
var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
args.push('arguments[' + i + ']');
}
eval('context.fn(' + args +')');
delete context.fn;
}

// 测试一下
var foo = {
value: 1
};

function bar(name, age) {
console.log(name)
console.log(age)
console.log(this.value);
}

bar.call2(foo, 'kevin', 18);
// kevin
// 18
// 1

(๑•̀ㅂ•́)و✧

模拟实现第三步

模拟代码已经完成 80%,还有两个小点要注意:

1.this 参数可以传 null,当为 null 的时候,视为指向 window

举个例子:

1
2
3
4
5
6
7
var value = 1;

function bar() {
console.log(this.value);
}

bar.call(null); // 1

虽然这个例子本身不使用 call,结果依然一样。

2. 函数是可以有返回值的!

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var obj = {
value: 1
}

function bar(name, age) {
return {
value: this.value,
name: name,
age: age
}
}

console.log(bar.call(obj, 'kevin', 18));
// Object {
// value: 1,
// name: 'kevin',
// age: 18
// }

不过都很好解决,让我们直接看第三版也就是最后一版的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 第三版
Function.prototype.call2 = function (context) {
var context = context || window;
context.fn = this;

var args = [];
for(var i = 1, len = arguments.length; i < len; i++) {
args.push('arguments[' + i + ']');
}

var result = eval('context.fn(' + args +')');

delete context.fn
return result;
}

// 测试一下
var value = 2;

var obj = {
value: 1
}

function bar(name, age) {
console.log(this.value);
return {
value: this.value,
name: name,
age: age
}
}

bar.call2(null); // 2

console.log(bar.call2(obj, 'kevin', 18));
// 1
// Object {
// value: 1,
// name: 'kevin',
// age: 18
// }

到此,我们完成了 call 的模拟实现,给自己一个赞 b( ̄▽ ̄)d

apply 的模拟实现

apply 的实现跟 call 类似,在这里直接给代码,代码来自于知乎 @郑航的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Function.prototype.apply = function (context, arr) {
var context = Object(context) || window;
context.fn = this;

var result;
if (!arr) {
result = context.fn();
}
else {
var args = [];
for (var i = 0, len = arr.length; i < len; i++) {
args.push('arr[' + i + ']');
}
result = eval('context.fn(' + args + ')')
}

delete context.fn
return result;
}

ES2020(ES11)新特性

ECMAScript 2020 是 ECMAScript 语言规范的第11版,引入了以下新特性。

目录

  • String的matchAll方法
  • 动态导入语句 import()
  • import.meta
  • export * as ns from ‘module’
  • Promise.allSettled
  • 一种新的数据类型: BigInt
  • GlobalThis
  • Promise.allSettled
  • 一种新的数据类型: BigInt
  • Nullish coalescing Operator

matchAll

matchAll() 方法返回一个包含所有匹配正则表达式的结果的迭代器。使用 for…of 遍历或者使用操作符 … Array.from 将其转换成数组。

1
2
3
4
const reg =  /[0-3]/g;
const data = '2020';
console.log(data.matchAll(reg)); // data.matchAll 的返回值是一个迭代器
console.log([...data.matchAll(reg)]);


/*

  • 0: [“2”, index: 0, input: “2020”, groups: undefined]
  • 1: [“0”, index: 1, input: “2020”, groups: undefined]
  • 2: [“2”, index: 2, input: “2020”, groups: undefined]
  • 3: [“0”, index: 3, input: “2020”, groups: undefined]

*/


Dynamic import

标准用法的 import 导入的模块是静态的,会使所有被导入的模块,在加载时就被翻译(无法做到按需编译,降低首页加载速度)。有些场景中,你可能希望根据条件导入模块或者按需导入模块,这时你可以使用动态导入代替静态导入。


在 import() 之前,当我们需要根据条件导入模块时,不得不使用 require()。
如:

1
2
3
if(XXX){
const menu = require('./menu');
}


如今可以替换为:

1
2
3
if(XXX){
const menu = import('./menu');
}


@babel/preset-env 已经包含了 @babel/plugin-syntax-dynamic-import,因此如果要使用 import() 语法,只需要配置 @babel/preset-env 即可。

**提示:
请不要滥用动态导入(只有在必要情况下采用)。静态框架能更好的初始化依赖,而且更有利于静态分析工具和 tree shaking 发挥作用。


另外,import() 返回是一个 promise 对象,例如:

1
2
3
4
5
6
7
8
9
10
11
//menu.js
export default {
menu: 'menu'
}
//index.js
if(true) {
let menu = import('./menu');
console.log(menu); //Promise {<pending>
menu.then(data => console.log(data));//Module {default: {menu: "menu"}, __esModule: true, Symbol(Symbol.toStringTag): "Module"}
} else {
}

import.meta

import.meta 会返回一个对象,有一个 url 属性,返回当前模块的url路径,只能在模块内部使用。。

1
2
3
4
5
<script src='./main.js' type="module"></script>
//main.js
console.log(import.meta); //{url: "http://localhost:8080/main.js"} PS:使用了 http-s<script src='./main.js' type="module"></script>
//main.js
console.log(import.meta); //{url: "http://localhost:8080/main.js"} PS:使用了 http-server 启动


因为 import.meta 必须要在模块内部使用,如果不加 type=”module”,控制台会报错:Cannot use ‘import.meta’ outside a module。




export * as ns from ‘module’
ES2020新增了 export * as XX from ‘module’,和 import * as XX from ‘module’

1
2
//menu.js
export * as ns from './info';


可以理解为是下面两条语句合并为一句:

1
2
import * as ns from './info';
export { ns };


不过需要注意的是 export * as ns from ‘./info’ 并不会真的将导入模块,因此在该模块(menu.js)中,我们是获取不到 ns 的。


Promise.allSettled

Promise.all 或者 Promise.race 有的时候并不能满足我们的需求。比如,我们需要在所有的 Promise 都结束的时候做一些操作,而并不在乎它们是成功还是失败。在没有 Promise.allSettled之前,我们需要自已去写实现。


Promise.allSettled() 方法返回一个在所有给定的 promise 都已经 fulfilled 或 rejected 后的 promise,并带有一个对象数组,每个对象表示对应的 promise 结果。

1
2
3
4
5
6
7
8
9
10
11
const promise1 = Promise.resolve(100);
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'info'));
const promise3 = new Promise((resolve, reject) => setTimeout(resolve, 200, 'name'))
Promise.allSettled([promise1, promise2, promise3]).
then((results) => console.log(result));
/*
[
{ status: 'fulfilled', value: 100 },
{ status: 'rejected', reason: 'info' },
{ status: 'fulfilled', value: 'name' }
]


可以看到,Promise.allSettled() 的成功的结果是一个数组,该数组的每一项是一个对象,每个对象都有一个 status 属性,值为 fulfilled 或 rejected,如果 status 的值是 fulfilled,那么该对象还有一个 value 属性,其属性值是对应的 promise 成功的结果;如果 status 的值是 rejectedd,那么该对象有一个 reason 属性,其属性值是对应的 promise 失败的原因。


BigInt

BigInt 是一种数字类型的数据,它可以表示任意精度格式的整数。在此之前,JS中安全的最大数字是 9009199254740091,即2^53-1,在控制台中输入 Number.MAX_SAFE_INTEGER 即可查看。超过这个值,JS 没有办法精确表示。另外,大于或等于2的1024次方的数值,JS 无法表示,会返回 Infinity。


BigInt 即解决了这两个问题。BigInt 只用来表示整数,没有位数的限制,任何位数的整数都可以精确表示。为了和 Number 类型进行区分,BigInt类型的数据必须添加后缀 n。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Number类型在超过9009199254740991后,计算结果即出现问题
const num1 = 90091992547409910;
console.log(num1 + 1); //90091992547409900
//BigInt 计算结果争取
const num2 = 90091992547409910n;
console.log(num2 + 1n); //90091992547409911n
//Number 类型不能表示大于 2 的 1024 次方的数值
let num3 = 9999;
for(let i = 0; i < 10; i++) {
num3 = num3 * num3;
}
console.log(num3); //Infinity
//BigInt 类型可以表示任意位数的整数
let num4 = 9999n;
for(let i = 0n; i < 10n; i++) {
num4 = num4 * num4;
}
console.log(num4); //一串超级长的数字,这里就不贴了


我们还可以使用 BigInt 对象来初始化 BigInt 实例:

1
console.log(BigInt(999)); // 999n 注意:没有 new 关键字!!!


需要说明的是,BigInt 和 Number 是两种数据类型,不能直接进行四则运算,不过可以进行比较操作。

1
2
3
console.log(99n == 99); //true
console.log(99n === 99); //false
console.log(99n + 1);//TypeError: Cannot mix BigInt and other types, use explicit conversionss

GlobalThis

JS 中存在一个顶层对象,但是,顶层对象在各种实现里是不统一的。
从不同的 Javascript 环境中获取全局对象需要不同的语句,在 Web 中,可以通过 window、self取到全局对象,但是在 Web Workers 中,只有 self 可以。在 Node.js 中,它们都无法获取,必须使用 global。


在 globalThis 之前,我们这样去获取全局对象:

1
2
3
4
5
6
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');
};


ES2020 中引入 globalThis 作为顶层对象,在任何环境下,都可以简单的通过 globalThis 拿到顶层对象。


Nullish coalescing Operator

ES2020 新增了一个运算符 ??。当作侧的操作作数为 null 或者 undefined 时,返回其右侧操作数,否则返回左侧操作数。


使用 || 操作符,当左侧的操作数为0、null、undefined、NaN、false、’’时,都会使用右侧的操作符,如果使用 || 来为某些变量设置默认值,可能会遇到意料之外的行为。


例如:

1
2
3
jconst defaultValue = 100;
let value = someValue || defaultValue;
//当 someValue 转成 boolean 值为 false 时,value 的值都是 defaultValue

当 someValue 的值为 0 时,我们其实期望 value 值为 0,但是它却错误的分配成了100.


??操作符可以规避以上问题,它只有在左操作数是 null 或者是 undefined时,才会返回右侧操作数。

1
2
const defaultValue = 100;
let value = someValue ?? defaultValue;//someValue 为 0 ,value 的值是 0

Optional Chaining

可选链操作符?.允许读取于连接对象链深处的属性的值,而不必明确验证链中的每个引用时否有效。?.操作符的功能类似于 . 链式操作符,不同于之处在于,在引用为空 (nullish,即 null 或者 undefined) 的情况下不会引起错误,该表达式短路返回值是 undefined。


例如,我们要访问 info 对象的 animal 的 reptile 的 tortoise。但是我们不确定 animal,reptile是否存在,因此我们需要这样写:

1
const tortoise = info.animal && info.animal.reptile && info.animal.reptile.tortoise;


因为 null.reptile 或 undefined..reptile 会抛出错误:TypeError: Cannot read property ‘reptile’ of undefined 或 TypeError: Cannot read property ‘reptile’ of null,为了避免报错,如果我们需要访问的属性更深,那么这个代码会越来越长。


而有了可选链操作符?.,我们在访问 reptile 之前,不再需要校验 info.animal的值。同样,在访问 info.animal.reptile.tortoise之前,也不需要校验 info.animal.reptile的值。


上面代码简化为:

1
const tortoise = info.animal?.reptile?.tortoise;


JS在尝试访问 info.animal.reptile 之前,会隐式检查并确定 info.animal 的值不是 null 或 undefined,如果其值是 null 或 undefined,如果其值是 null 或 undefined,那么表达式短路计算直接返回 undefined。
可以看到可选链操作符 ?. 和空位合并操作符一样,都是针对的 null 和 undefined 这两个值。


JavaScript深入之从ECMAScript规范解读this

前言

在《JavaScript 深入之执行上下文栈》中讲到,当 JavaScript 代码执行一段可执行代码 (executable code) 时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性

  • 变量对象 (Variable object,VO)
  • 作用域链 (Scope chain)
  • this

今天重点讲讲 this,然而不好讲。

……

因为我们要从 ECMASciript5 规范开始讲起。

先奉上 ECMAScript 5.1 规范地址:

英文版:http://es5.github.io/#x15.1

中文版:http://yanhaijing.com/es5/#115

让我们开始了解规范吧!

Types

首先是第 8 章 Types:

Types are further subclassified into ECMAScript language types and specification types.

An ECMAScript language type corresponds to values that are directly manipulated by an ECMAScript programmer using the ECMAScript language. The ECMAScript language types are Undefined, Null, Boolean, String, Number, and Object.

A specification type corresponds to meta-values that are used within algorithms to describe the semantics of ECMAScript language constructs and ECMAScript language types. The specification types are Reference, List, Completion, Property Descriptor, Property Identifier, Lexical Environment, and Environment Record.

我们简单的翻译一下:

ECMAScript 的类型分为语言类型和规范类型。

ECMAScript 语言类型是开发者直接使用 ECMAScript 可以操作的。其实就是我们常说的 Undefined, Null, Boolean, String, Number, 和 Object。

而规范类型相当于 meta-values,是用来用算法描述 ECMAScript 语言结构和 ECMAScript 语言类型的。规范类型包括:Reference, List, Completion, Property Descriptor, Property Identifier, Lexical Environment, 和 Environment Record。

没懂?没关系,我们只要知道在 ECMAScript 规范中还有一种只存在于规范中的类型,它们的作用是用来描述语言底层行为逻辑。

今天我们要讲的重点是便是其中的 Reference 类型。它与 this 的指向有着密切的关联。

Reference

那什么又是 Reference ?

让我们看 8.7 章 The Reference Specification Type:

The Reference type is used to explain the behaviour of such operators as delete, typeof, and the assignment operators.

所以 Reference 类型就是用来解释诸如 delete、typeof 以及赋值等操作行为的。

抄袭尤雨溪大大的话,就是:

这里的 Reference 是一个 Specification Type,也就是 “只存在于规范里的抽象类型”。它们是为了更好地描述语言的底层行为逻辑才存在的,但并不存在于实际的 js 代码中。

再看接下来的这段具体介绍 Reference 的内容:

A Reference is a resolved name binding.

A Reference consists of three components, the base value, the referenced name and the Boolean valued strict reference flag.

The base value is either undefined, an Object, a Boolean, a String, a Number, or an environment record (10.2.1).

A base value of undefined indicates that the reference could not be resolved to a binding. The referenced name is a String.

这段讲述了 Reference 的构成,由三个组成部分,分别是:

  • base value
  • referenced name
  • strict reference

可是这些到底是什么呢?

我们简单的理解的话:

base value 就是属性所在的对象或者就是 EnvironmentRecord,它的值只可能是 undefined, an Object, a Boolean, a String, a Number, or an environment record 其中的一种。

referenced name 就是属性的名称。

举个例子:

1
2
3
4
5
6
7
8
var foo = 1;

// 对应的Reference是:
var fooReference = {
base: EnvironmentRecord,
name: 'foo',
strict: false
};

再举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var foo = {
bar: function () {
return this;
}
};

foo.bar(); // foo

// bar对应的Reference是:
var BarReference = {
base: foo,
propertyName: 'bar',
strict: false
};

而且规范中还提供了获取 Reference 组成部分的方法,比如 GetBase 和 IsPropertyReference。

这两个方法很简单,简单看一看:

1.GetBase

GetBase(V). Returns the base value component of the reference V.

返回 reference 的 base value。

2.IsPropertyReference

IsPropertyReference(V). Returns true if either the base value is an object or HasPrimitiveBase(V) is true; otherwise returns false.

简单的理解:如果 base value 是一个对象,就返回 true。

GetValue

除此之外,紧接着在 8.7.1 章规范中就讲了一个用于从 Reference 类型获取对应值的方法: GetValue。

简单模拟 GetValue 的使用:

1
2
3
4
5
6
7
8
9
var foo = 1;

var fooReference = {
base: EnvironmentRecord,
name: 'foo',
strict: false
};

GetValue(fooReference) // 1;

GetValue 返回对象属性真正的值,但是要注意:

调用 GetValue,返回的将是具体的值,而不再是一个 Reference

这个很重要,这个很重要,这个很重要。

如何确定 this 的值

关于 Reference 讲了那么多,为什么要讲 Reference 呢?到底 Reference 跟本文的主题 this 有哪些关联呢?如果你能耐心看完之前的内容,以下开始进入高能阶段:

看规范 11.2.3 Function Calls:

这里讲了当函数调用的时候,如何确定 this 的取值。

只看第一步、第六步、第七步:

1.Let ref be the result of evaluating MemberExpression.

6.If Type(ref) is Reference, then

1
a.If IsPropertyReference(ref) is true, then
1
i.Let thisValue be GetBase(ref).
1
b.Else, the base of ref is an Environment Record
1
i.Let thisValue be the result of calling the ImplicitThisValue concrete method of GetBase(ref).

7.Else, Type(ref) is not Reference.

1
a. Let thisValue be undefined.

让我们描述一下:

  1. 计算 MemberExpression 的结果赋值给 ref
  1. 判断 ref 是不是一个 Reference 类型
1
2
3
4
5
2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)

2.2 如果 ref 是 Reference,并且 base value 值是 Environment Record, 那么this的值为 ImplicitThisValue(ref)

2.3 如果 ref 不是 Reference,那么 this 的值为 undefined

具体分析

让我们一步一步看:

  1. 计算 MemberExpression 的结果赋值给 ref

什么是 MemberExpression?看规范 11.2 Left-Hand-Side Expressions:

MemberExpression :

  • PrimaryExpression // 原始表达式 可以参见《JavaScript 权威指南第四章》
  • FunctionExpression // 函数定义表达式
  • MemberExpression [ Expression ] // 属性访问表达式
  • MemberExpression . IdentifierName // 属性访问表达式
  • new MemberExpression Arguments // 对象创建表达式

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function foo() {
console.log(this)
}

foo(); // MemberExpression 是 foo

function foo() {
return function() {
console.log(this)
}
}

foo()(); // MemberExpression 是 foo()

var foo = {
bar: function () {
return this;
}
}

foo.bar(); // MemberExpression 是 foo.bar

所以简单理解 MemberExpression 其实就是 () 左边的部分。

  1. 判断 ref 是不是一个 Reference 类型。

关键就在于看规范是如何处理各种 MemberExpression,返回的结果是不是一个 Reference 类型。

举最后一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var value = 1;

var foo = {
value: 2,
bar: function () {
return this.value;
}
}

//示例1
console.log(foo.bar());
//示例2
console.log((foo.bar)());
//示例3
console.log((foo.bar = foo.bar)());
//示例4
console.log((false || foo.bar)());
//示例5
console.log((foo.bar, foo.bar)());

foo.bar()

在示例 1 中,MemberExpression 计算的结果是 foo.bar,那么 foo.bar 是不是一个 Reference 呢?

查看规范 11.2.1 Property Accessors,这里展示了一个计算的过程,什么都不管了,就看最后一步:

Return a value of type Reference whose base value is baseValue and whose referenced name is propertyNameString, and whose strict mode flag is strict.

我们得知该表达式返回了一个 Reference 类型!

根据之前的内容,我们知道该值为:

1
2
3
4
5
var Reference = {
base: foo,
name: 'bar',
strict: false
};

接下来按照 2.1 的判断流程走:

2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)

该值是 Reference 类型,那么 IsPropertyReference(ref) 的结果是多少呢?

前面我们已经铺垫了 IsPropertyReference 方法,如果 base value 是一个对象,结果返回 true。

base value 为 foo,是一个对象,所以 IsPropertyReference(ref) 结果为 true。

这个时候我们就可以确定 this 的值了:

GetBase 也已经铺垫了,获得 base value 值,这个例子中就是 foo,所以 this 的值就是 foo ,示例 1 的结果就是 2!

唉呀妈呀,为了证明 this 指向 foo,真是累死我了!但是知道了原理,剩下的就更快了。

(foo.bar)()

看示例 2:

1
console.log((foo.bar)());

foo.bar 被 () 包住,查看规范 11.1.6 The Grouping Operator

直接看结果部分:

Return the result of evaluating Expression. This may be of type Reference.

NOTE This algorithm does not apply GetValue to the result of evaluating Expression.

实际上 () 并没有对 MemberExpression 进行计算,所以其实跟示例 1 的结果是一样的。

(foo.bar = foo.bar)()

看示例 3,有赋值操作符,查看规范 11.13.1 Simple Assignment (=):

计算的第三步:

3.Let rval be GetValue(rref).

因为使用了 GetValue,所以返回的值不是 Reference 类型,

按照之前讲的判断逻辑:

2.3 如果 ref 不是 Reference,那么 this 的值为 undefined

this 为 undefined,非严格模式下,this 的值为 undefined 的时候,其值会被隐式转换为全局对象。

(false || foo.bar)()

看示例 4,逻辑与算法,查看规范 11.11 Binary Logical Operators:

计算第二步:

2.Let lval be GetValue(lref).

因为使用了 GetValue,所以返回的不是 Reference 类型,this 为 undefined

(foo.bar, foo.bar)()

看示例 5,逗号操作符,查看规范 11.14 Comma Operator (,)

计算第二步:

2.Call GetValue(lref).

因为使用了 GetValue,所以返回的不是 Reference 类型,this 为 undefined

揭晓结果

所以最后一个例子的结果是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var value = 1;

var foo = {
value: 2,
bar: function () {
return this.value;
}
}

//示例1
console.log(foo.bar()); // 2
//示例2
console.log((foo.bar)()); // 2
//示例3
console.log((foo.bar = foo.bar)()); // 1
//示例4
console.log((false || foo.bar)()); // 1
//示例5
console.log((foo.bar, foo.bar)()); // 1

注意:以上是在非严格模式下的结果,严格模式下因为 this 返回 undefined,所以示例 3 会报错。

补充

最最后,忘记了一个最最普通的情况:

1
2
3
4
5
function foo() {
console.log(this)
}

foo();

MemberExpression 是 foo,解析标识符,查看规范 10.3.1 Identifier Resolution,会返回一个 Reference 类型的值:

1
2
3
4
5
var fooReference = {
base: EnvironmentRecord,
name: 'foo',
strict: false
};

接下来进行判断:

2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)

因为 base value 是 EnvironmentRecord,并不是一个 Object 类型,还记得前面讲过的 base value 的取值可能吗? 只可能是 undefined, an Object, a Boolean, a String, a Number, 和 an environment record 中的一种。

IsPropertyReference(ref) 的结果为 false,进入下个判断:

2.2 如果 ref 是 Reference,并且 base value 值是 Environment Record, 那么 this 的值为 ImplicitThisValue(ref)

base value 正是 Environment Record,所以会调用 ImplicitThisValue(ref)

查看规范 10.2.1.1.6,ImplicitThisValue 方法的介绍:该函数始终返回 undefined。

所以最后 this 的值就是 undefined。

多说一句

尽管我们可以简单的理解 this 为调用函数的对象,如果是这样的话,如何解释下面这个例子呢?

1
2
3
4
5
6
7
8
9
var value = 1;

var foo = {
value: 2,
bar: function () {
return this.value;
}
}
console.log((false || foo.bar)()); // 1

此外,又如何确定调用函数的对象是谁呢?在写文章之初,我就面临着这些问题,最后还是放弃从多个情形下给大家讲解 this 指向的思路,而是追根溯源的从 ECMASciript 规范讲解 this 的指向,尽管从这个角度写起来和读起来都比较吃力,但是一旦多读几遍,明白原理,绝对会给你一个全新的视角看待 this 。而你也就能明白,尽管 foo() 和 (foo.bar = foo.bar)() 最后结果都指向了 undefined,但是两者从规范的角度上却有着本质的区别。

此篇讲解执行上下文的 this,即便不是很理解此篇的内容,依然不影响大家了解执行上下文这个主题下其他的内容。所以,依然可以安心的看下一篇文章。

JavaScript深入之从原型到原型链

构造函数创建对象

我们先使用构造函数创建一个对象:

1
2
3
4
5
6
function Person() {

}
var person = new Person();
person.name = 'Kevin';
console.log(person.name) // Kevin

在这个例子中,Person 就是一个构造函数,我们使用 new 创建了一个实例对象 person。

很简单吧,接下来进入正题:

prototype

每个函数都有一个 prototype 属性,就是我们经常在各种例子中看到的那个 prototype ,比如:

1
2
3
4
5
6
7
8
9
10
function Person() {

}
// 虽然写在注释里,但是你要注意:
// prototype是函数才会有的属性
Person.prototype.name = 'Kevin';
var person1 = new Person();
var person2 = new Person();
console.log(person1.name) // Kevin
console.log(person2.name) // Kevin

那这个函数的 prototype 属性到底指向的是什么呢?是这个函数的原型吗?

其实,函数的 prototype 属性指向了一个对象,这个对象正是调用该构造函数而创建的实例的原型,也就是这个例子中的 person1 和 person2 的原型。

那什么是原型呢?你可以这样理解:每一个 JavaScript 对象 (null 除外) 在创建的时候就会与之关联另一个对象,这个对象就是我们所说的原型,每一个对象都会从原型 “继承” 属性。

让我们用一张图表示构造函数和实例原型之间的关系:

在这张图中我们用 Object.prototype 表示实例原型。

那么我们该怎么表示实例与实例原型,也就是 person 和 Person.prototype 之间的关系呢,这时候我们就要讲到第二个属性:

proto

这是每一个 JavaScript 对象 (除了 null) 都具有的一个属性,叫__proto__,这个属性会指向该对象的原型。

为了证明这一点, 我们可以在火狐或者谷歌中输入:

1
2
3
4
5
function Person() {

}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true

于是我们更新下关系图:

既然实例对象和构造函数都可以指向原型,那么原型是否有属性指向构造函数或者实例呢?

constructor

指向实例倒是没有,因为一个构造函数可以生成多个实例,但是原型指向构造函数倒是有的,这就要讲到第三个属性:constructor,每个原型都有一个 constructor 属性指向关联的构造函数。

为了验证这一点,我们可以尝试:

1
2
3
4
function Person() {

}
console.log(Person === Person.prototype.constructor); // true

所以再更新下关系图:

综上我们已经得出:

1
2
3
4
5
6
7
8
9
10
function Person() {

}

var person = new Person();

console.log(person.__proto__ == Person.prototype) // true
console.log(Person.prototype.constructor == Person) // true
// 顺便学习一个ES5的方法,可以获得对象的原型
console.log(Object.getPrototypeOf(person) === Person.prototype) // true

了解了构造函数、实例原型、和实例之间的关系,接下来我们讲讲实例和原型的关系:

实例与原型

当读取实例的属性时,如果找不到,就会查找与对象关联的原型中的属性,如果还查不到,就去找原型的原型,一直找到最顶层为止。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person() {

}

Person.prototype.name = 'Kevin';

var person = new Person();

person.name = 'Daisy';
console.log(person.name) // Daisy

delete person.name;
console.log(person.name) // Kevin

在这个例子中,我们给实例对象 person 添加了 name 属性,当我们打印 person.name 的时候,结果自然为 Daisy。

但是当我们删除了 person 的 name 属性时,读取 person.name,从 person 对象中找不到 name 属性就会从 person 的原型也就是 person.proto ,也就是 Person.prototype 中查找,幸运的是我们找到了 name 属性,结果为 Kevin。

但是万一还没有找到呢?原型的原型又是什么呢?

原型的原型

在前面,我们已经讲了原型也是一个对象,既然是对象,我们就可以用最原始的方式创建它,那就是:

1
2
3
var obj = new Object();
obj.name = 'Kevin'
console.log(obj.name) // Kevin

其实原型对象就是通过 Object 构造函数生成的,结合之前所讲,实例的 proto 指向构造函数的 prototype ,所以我们再更新下关系图:

原型链

那 Object.prototype 的原型呢?

null,我们可以打印:

1
console.log(Object.prototype.__proto__ === null) // true

然而 null 究竟代表了什么呢?

引用阮一峰老师的 《undefined 与 null 的区别》 就是:

null 表示 “没有对象”,即该处不应该有值。

所以 Object.prototype.proto 的值为 null 跟 Object.prototype 没有原型,其实表达了一个意思。

所以查找属性的时候查到 Object.prototype 就可以停止查找了。

最后一张关系图也可以更新为:

顺便还要说一下,图中由相互关联的原型组成的链状结构就是原型链,也就是蓝色的这条线。

补充

最后,补充三点大家可能不会注意的地方:

constructor

首先是 constructor 属性,我们看个例子:

1
2
3
4
5
function Person() {

}
var person = new Person();
console.log(person.constructor === Person); // true

当获取 person.constructor 时,其实 person 中并没有 constructor 属性, 当不能读取到 constructor 属性时,会从 person 的原型也就是 Person.prototype 中读取,正好原型中有该属性,所以:

1
person.constructor === Person.prototype.constructor

proto

其次是 proto ,绝大部分浏览器都支持这个非标准的方法访问原型,然而它并不存在于 Person.prototype 中,实际上,它是来自于 Object.prototype ,与其说是一个属性,不如说是一个 getter/setter,当使用 obj.proto 时,可以理解成返回了 Object.getPrototypeOf(obj)。

真的是继承吗?

最后是关于继承,前面我们讲到 “每一个对象都会从原型‘继承’属性”,实际上,继承是一个十分具有迷惑性的说法,引用《你不知道的 JavaScript》中的话,就是:

继承意味着复制操作,然而 JavaScript 默认并不会复制对象的属性,相反,JavaScript 只是在两个对象之间创建一个关联,这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以与其叫继承,委托的说法反而更准确些。