[JS] 我所理解的 JavaScript (上)
接触 Javascript 这门编程语言,满打满算应该有将近 4 年的时间,我觉得不能说对这门语言理解的有多深刻,只是在看了众多的经典书籍和博客之后,也想把自己的理解和总结也分享出来。
闭包
很多编程语言(Golang, Python, Javascript)都有闭包,闭包是语言运行时当中的一个自然的结果,拿 Javascript 举例来说,下面是一个经典的闭包案例。
function out() {
const name = "cyan";
return function in() {
console.log(name);
}
}
const fn = out();
fn();
fn = null;
为什么会产生闭包?
拿 JS 来说,就是外层函数执行上下文的变量环境对象在内存中被内层函数的作用域链 [[scope]]
引用,而且根据 JS 垃圾回收机制并不会销毁这一引用,所以内层函数作用域才可以通过作用域链引用外层函数作用域的属性,所以才有了闭包这种自然结果。
闭包有什么意义?
给 JavaScript 函数增加高级特性,像函数式编程中 柯里化函数 | 组合函数 | pipe | partial
闭包的负面价值:内存泄露
内存泄露,内存泄露的本质就是代码执行完毕之后,内存当中的内层函数对象和外层函数执行上下文的变量对象存在引用,但是根据 JavaScript 垃圾回收机制,并不会删除所以占据内存。造成了内存泄露。我们可以利用 JavaScript 垃圾回收机制去解决闭包的内存泄露问题。我们可以手动销毁内层函数,外层的变量对象没有了引用也会被销毁。如下 fn = null;
原型
JavaScript 是基于原型的语言,JavaScript 实现继承,实例化的时候是将多个对象(实例对象,原型对象,普通对象)进行使用原型链进行关联或者说链接来实现的。
具体来说,JavaScript 当中的所有函数在初始化的时候都有一个 prototype 属性,该属性指向的是原型对象,也就是通过调用构造函数创建的实例对象的原型,好处是我们可以预定义属性,方法。这些属性和方法会被实例对象所共享。
原型链本质和设计
原型最核心的部分就是整个 JavaScript 整个语言的原型设计。通过 github 上的一张神图,我们可以按照显式原型和隐式原型两条线来进行全面梳理 JavaScript 语言的 原型设计。
显式原型路线是:无论是基本类型构造函数(Number, String, BigInt, Boolean, Symbol)还是除了 Object 引用类型构造函数(Array, Date, Function, RegExp,除了 Object),还是其他自定义构造函数,都遵守:
构造函数.prototype.__proto__
等于Object.prototype
Object.prototype.__proto__
等于顶层原型null
。
隐式原型路线是:无论是基本类型构造函数(Number, String, BigInt, Boolean, Symbol),还是引用类型构造函数(Array, Date, Function, RegExp,包括 Object),还是其他自定义构造函数,都遵守:
构造函数.__proto__ 等于 Function.prototype
构造函数.__proto__.__proto__ 等于 Object.prototype
作用域
作用域决定这些变量在程序的哪个区域可以使用。作用域也同时决定了变量在程序中的可见性。
在 JavaScript 中,有函数作用域/全局作用域/eval 作用域/块级作用域, 这些作用域的本质就是执行上下文环境或者说执行上下文对象。 举例来说,就是执行上下文环境中的 变量对象 VO/活动对象 AO(函数上下文使用 AO 活动对象来收集变量,函数和形参的)。
作用域链
作用域链本质上就是上下文对象中的作用域链一个属性,具体来说,初始化一个函数执行上下文对象的作用域链属性时,会将函数执行上下文对象的 AO,压入到已保存作用域链属性的最顶端。这样以来,就形成了作用域链,那查找属性的时候,先从自己的 AO 活动对象找,一直沿着作用域链找到下一个 AO,直到找到 GO 为止。这也就是作用域链的由来和本质。
执行上下文
我们知道JavaScript
是解释性语言,代码是一段一段执行的,再执行到一段可执行代码的时候,就会创建执行上下文/执行上下文环境/执行上下文对象,JavaScript
可执行代码有三种:全局代码,函数代码,eval
代码,所以对应的执行上下文对象也就有三种,分别是全局执行上下文,函数执行上下文,eval
执行上下文。这三种执行上下文对象都有三个属性,第一个是变量环境对象VO
,第二个是作用域链Scope
,第三个是this
。
可以用一段代码的执行来更好的解释执行上下文:
var a = 1;
function out () {
var name = "ryan";
function in () {
console.log(name);
}
return in()
}
out();
有这样的一段代码,var a = 1
,有一个外层函数out
,out
函数当中定义 var name = 2
,out
外层函数当中有一个内层函数in
,内层函数,打印外层函数作用域的变量name
,返回内层函数的调用。外层先调用out
,然后再调用in
。
首先,JavaScript
引擎会先创建GO
就是global object
全局对象,这个全局对象上,我们可以使用一些预定义的对象或者函数,比如:日期对象Date,数学对象 Math ……
开始执行后JavaScript
引擎会拿到他的第一段可执行代码,全局代码,所以会先创建全局执行上下文对象,并且将执行上下文 push 到调用栈当中,这时开始初始化全局上下文对象。初始化这个对象就是初始化三个属性:作用域链属性,VO
变量对象,绑定this
。
初始化作用域链:将JavaScript
刚刚创建的GO
保存为作用域链属性。
初始化VO
:这个VO
就是来收集定义的变量和函数的定义。 这里就是全局定义的a
属性 和定义的out
函数。
绑定this
:这个就是this
的绑定。
这时out
函数被创建,保存全局上下文对象的作用链作为out
函数的[[scope]]
属性。调用out
函数,然后创建out
函数上下文对象,入栈,开始初始化out
函数初始化上下文对象。
初始化AO
活动对象:函数上下文当中叫AO
(既可以收集定义的变量和函数,还能够收集函数的形参),全局当中我们是叫做VO
。这里就是 name变量
和 in
内部函数。
初始化作用域链:将out
函数的[[scope]]
属性赋值过来作为作用域链属性。再将out
函数的 AO 对象压入out
函数作用域链的最顶端。这样做的结果和目的就是,out
函数开始执行查找属性和函数的时候,先在out
函数的作用域的AO
对象上找属性和方法,然后去GO
上找属性和方法。
绑定this
:还是函数的this
绑定。
这时in
函数被创建,保存out
函数的作用链到in
函数的[[scope]]
属性,调用in
函数,然后创建in
函数上下文,入栈,开始初始化in
函数初始化上下文对象。
初始化AO
活动对象:这里没有初始化代码。
初始化作用域链:将in
函数的[[scope]]
属性赋值过来作为作用域链属性。再将in
函数的AO
对象压入in
函数作用域链的最顶端。这样做的结果就很明确,in
函数开始执行查找属性和函数的时候,先是自己的AO
,然后是out
函数的AO
,最后是全局上下文的GO
。
绑定this
:还是函数的this
绑定。
执行完in
之后出栈,out
出栈,全局上下文出栈,执行完毕。
垃圾回收
- JavaScript 不像 C/C++, 让程序员自己去调用函数来释放内存,go javascript Java 拥有自己的一套垃圾回收算法(GC)进行自动的内存管理。
垃圾回收有两种方法:标记清除、引用计数, 引用计数不太常用,标记清除较为常用。
引用计数算法
第一个就是早先的引用计数法,它的策略是跟踪记录每个变量值被使用的次数,当这个值的引用次数变为 0 的时候,说明没有其他对象引用他了,垃圾回收器会在运行的时候清理掉引用次数为 0 的垃圾对象。
缺点
循环引用的两个对象无法回收的问题,因为循环引用的两个对象的引用次数都不为 0。这样就会发生内存泄露
标记清除算法
第二个就是目前大多数浏览器的 JavaScript 引擎 都在采用标记清除算法,就像它的名字一样,此算法分为 标记 和 清除 两个阶段。
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设标记策略时全标记为 0。
- 然后从根对象开始遍历,把可达的对象也就是不是垃圾的节点改成 1。
- 清理所有标记为 0 的垃圾对象,销毁并回收它们所占用的内存空间
- 最后,把内存中对象的标记都修改为 0,等待下一轮垃圾回收。
优化垃圾清除算法的原因
如果单单是这样的算法 会造成很多问题
- JS 是单线程运行的,垃圾回收机制是微任务,这意味着一旦进入到垃圾回收,那么其它的运行的业务逻辑都要暂停; 另一方面垃圾回收其实是非常耗时间的操作
- 未进行优化的算法之前存在很多问题,比如内存碎片化的问题
标记清除算法优化
优化 1:标记整理(Mark-Compact)算法
- 垃圾清理之后,会存在大量不连续的内存碎片,所以在标记结束后,标记整理算法会将不需要清理的对象对向内存的一端移动。从而使得空闲内存块是连续的。
优化 2:分代式垃圾回收
- V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,两个区域采用不同的垃圾回收器也就是不同的策略管理垃圾回收 。
- 新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1 ~ 8M 的容量,而老生代的对象为存活时间较长或常驻内存的对象,简单来说就是经历过新生代垃圾回收后还存活下来的对象,容量通常比较大。
新生代垃圾回收
- 新生代垃圾回收所依赖的算法是
Scavenge算法
。 - 首先将新生代内存空间一分为二:分为使用区和空闲区,当进行垃圾回收时,V8 将 使用区 部分的对象检查一遍,如果是存活对象那么按照内存顺序排列并复制到 空闲区 内存中,如果是非存活对象直接回收即可。当所有的 使用区 中的存活对象按照顺序进入到 空闲区 内存之后,空闲区 和 使用区 两者的角色对调,空闲区 现在被闲置,使用区 为正在使用,如此循环。
老生代垃圾回收
- 老生代垃圾回收所依赖的算法是
标记清除算法
。 - 新生代中的变量如果经过多次回收之后依然存在,会采用对象晋升策略,放入到老生代内存中。
- 老生代当中就是先使用标记清除算法来进行垃圾回收,然后再通过标记整理来解决内存碎片化问题。
优化 3:增量标记
- 将垃圾回收任务分为很多小的部分完成,每做完一个小的部分 就让 JavaScript 应用逻辑执行一会儿,然后再做下面的部分,如此循环,直到任务完成。
事件循环
所有的任务都会被放到调用栈等待主线程执行,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务(将 DOM 操作,HTTP 请求,定时器等的回调函数)会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),通过轮询来读取异步任务到调用栈内等待主线程的执行。
从宏任务和微任务的角度来聊 JavaScript 事件循环?
- MacroTask(宏任务)
- script 全部代码(普通任务)、setTimeout、setInterval、setImmediate、I/O、UI Rendering。
- MicroTask(微任务)
- Process.nextTick(Node 独有)、Promise、MutationObserver、垃圾回收
在每一个宏任务中会定义一个微任务队列,当该宏任务执行完成,会检查其中的微任务队列,如果为空则直接执行下一个宏任务,如果不为空,则依次执行微任务
,执行完成才去执行下一个宏任务。
node 事件循环和浏览器事件循环的区别是什么?
两者最主要的区别在于浏览器中的微任务是在每个相应的宏任务
中执行的,(在执行下一个宏任务的时候会清空微任务队列)而 nodejs 中的微任务是在不同阶段之间
执行的。
- node 的初始化
- 初始化 node 环境。
- 执行输入代码。
- 执行 process.nextTick 回调。
- process.nextTick 是一个独立于 eventLoop 的任务队列。在每一个 eventLoop 阶段完成后会去检查这个队列,如果里面有任务,会让这部分任务
优先于微任务
执行。所以在 nodejs 事件循环的每一个子阶段退出之前都会按顺序执行如下过程:- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出当前阶段。
- 执行 microtasks。
- 进入 event-loop
- 进入 timers 阶段
- 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。这些定时器就是 setTimeout、setInterval
- 检查是否有 process.nextTick 任务,如果有,全部执行。
- 检查是否有 microtask,如果有,全部执行。
- 退出该阶段。
- 进入IO callbacks阶段。
- 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
- 检查是否有 process.nextTick 任务,如果有,全部执行。
- 检查是否有 microtask,如果有,全部执行。
- 退出该阶段。
- 进入 poll /轮询 阶段
- 首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。
- 第一种情况:
- 如果有可用回调(可用回调包含到期的定时器还有一些 IO 事件等),执行所有可用回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出该阶段。
- 第二种情况:
- 如果没有可用回调。
- 检查是否有 immediate 回调,如果有,退出 poll 阶段,进入 check 阶段。
- 所以 immediate 的回调的调用顺序是不一定的。
- 第一种情况:
- 如果不存在尚未完成的回调,退出 poll 阶段。
- 首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。
- 进入 check 阶段。
- 如果有 immediate 回调,则执行所有 immediate 回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出 check 阶段
- 进入 closing 阶段 / 关闭事件的回调阶段。
> 如果一个 socket 或句柄(handle 文件/设备/套接字)被突然关闭,例如 socket.destroy(), close 事件的回调就会在这个阶段执行。
- 如果有 immediate 回调,则执行所有 immediate 回调。
- 检查是否有 process.nextTick 回调,如果有,全部执行。
- 检查是否有 microtaks,如果有,全部执行。
- 退出 closing 阶段
- 进行下一轮事件循环
- 进入 timers 阶段
模块化
JavaScript 模块化历史
1. 刀耕火种
defer 与 async 的区别是:defer 要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async 一旦下载完,排版引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer 是“渲染完再执行”,async 是“下载完就执行”。另外,如果有多个 defer 脚本,会按照它们在页面出现的顺序加载,而多个 async 脚本是不能保证加载顺序的。
在原始 JavaScript
中,出现模块化规范之前,js
文件之间的通信基本上靠的是 window
对象。我们按照 js
文件之间依赖关系来按顺序引入 js
。一旦顺序没有按照依赖关系来引入,就会报错。
// 引入顺序颠倒就会报错
<body>
<script src="./a.js"></script>
<scrip src="./b.js"></script>
</body>
// a.js
var nameP = "ryan";
// b.js
console.log(nameP);
所以当业务变得复杂时,就会产生很多问题:
- 第一个问题:多而复杂的
js
文件不好开发和维护,需要考虑js
文件之间的依赖关系。 - 第二个问题:
window
上挂载的全局变量避免不了存在命名冲突、占用内存无法被回收、代码可读性低等诸多问题。 - 如果按照这么来开发前端,开发体验会非常糟糕。
所以尽管当时没有模块化的时候,但也涌现出了一些解决方案。
解决方案 1:函数
function m1() {
//...
}
function m2() {
//...
}
将不同的功能函数当成一个个模块,这样做虽然起到了一定的效果,但是还是避免不了存在命名冲突,最重要的是,这些函数被当作模块,我们看不出来模块之间的关系。
解决方案 2:对象
var module1 = new Object({
_count: 0,
m1: function () {
//...
},
m2: function () {
//...
},
});
将不同的功能对象当成一个个模块,这样做虽然起到了一定的效果,但是还是避免不了存在命名冲突,最重要的是,这样的写法会暴露所有的模块成员。模块成员可以被随意修改。
解决方案 3:IIFE(匿名立即执行函数)
// a.js
var moduleA = (function () {
return {
name: "ryan",
};
})();
//console.log(moduleA.name); ryan
随着前端业务增重,代码越来越复杂,前端急需一种清晰有效方案来处理功能模块之间的依赖关系。
2. AMD 和 CMD
node
服务器端编程出来的时候,模块系统就是参照 CommonJs
规范实现的,所以有了服务器端模块化规范,大家也想要客户端模块化开发,而且大家也希望两者可以相互兼容,但是 CommonJs
对客户端模块化来说,一个非常大的局限就是:同步加载模块的方式不适合浏览器环境,因为同步加载模块会导致浏览器卡死,阻塞渲染,所以在这样的背景下 AMD
这种异步加载模块化的方式出现了。
CMD
和 AMD
一样,都是 JS
的社区模块化规范,主要应用于浏览器端,可以异步加载模块。
我们只要按照规范的方式去书写,就可以被 require.js
, sea.js
正确解析。从而实现异步模块化。
- 比如
require.js
中的require
函数,define
函数 sea.js
的require
函数
AMD
例子来自于 yayu
- require.js
// main.js
require(["./add", "./square"], function (addModule, squareModule) {
console.log(addModule.add(1, 1));
console.log(squareModule.square(3));
});
// add.js
define(function() {
console.log('加载了 add 模块');
var add = function(x, y) { 
return x + y;
};
return {      
add: add
};
});
// multiply.js
define(function() {
console.log('加载了 multiply 模块')
var multiply = function(x, y) { 
return x * y;
};
return {      
multiply: multiply
};
});
// square.js
define(['./multiply'], function(multiplyModule) {
console.log('加载了 square 模块')
return {      
square: function(num) {
return multiplyModule.multiply(num, num)
}
};
});
// 加载了 add 模块
// 加载了 multiply 模块
// 加载了 square 模块
// 2
// 9
CMD
- sea.js
// main.js
define(function(require, exports, module) {
var addModule = require('./add');
console.log(addModule.add(1, 1))
var squareModule = require('./square');
console.log(squareModule.square(3))
});
// add.js
define(function(require, exports, module) {
console.log('加载了 add 模块')
var add = function(x, y) { 
return x + y;
};
module.exports = {      
add: add
};
});
// square.js
define(function(require, exports, module) {
console.log('加载了 square 模块')
var multiplyModule = require('./multiply');
module.exports = {      
square: function(num) {
return multiplyModule.multiply(num, num)
}
};
});
// multiply.js
define(function(require, exports, module) {
console.log('加载了 multiply 模块')
var multiply = function(x, y) { 
return x * y;
};
module.exports = {      
multiply: multiply
};
});
// 加载了 add 模块
// 2
// 加载了 square 模块
// 加载了 multiply 模块
// 9
AMD 与 CMD 的区别
yayu 总结的区别:
根据上面代码的打印结果,我们也可以更好的理解他们之间的区别
CMD
推崇依赖就近,AMD
推崇依赖前置。AMD
是将需要使用的模块全部加载完再执行代码,而CMD
是在require
的时候才去加载模块文件,加载完再接着执行。
CommonJS(CJS)
CommonJS 基本说明
随着 node
诞生,服务器端的模块规范 CommonJS
被创建出来。在 commonjs
中每一个 js 文件都是一个单独的模块,我们在控制台也可以打印出这个 module
对象。所有代码都运行在模块作用域,不会污染全局作用域。同时,CommonJS
是运行时同步加载模块,比较适合服务器端的模块加载,因为服务器的模块文件都在硬盘,即使是同步也非常快。
CJS 基本用法
导入
const a = require("./a.js");
// a是module.exports导出的对象。
导出
// 为module.exports对象上添加属性。
exports.name = "kobe";
exports.age = 18;
// 重置了module.exports对象引用。
module.exports = {};
// 导出module.exports对象
cjs 模块导入导出的基本原理
module.exports = {};
exports = module.exports;
require 引入细节
- 情况一:X 是一个
Node
核心模块,比如path
、http
。- 直接返回核心模块,并且停止查找。
- 情况二:X 是以 ./ 或 ../ 或 /(根目录)开头的。
- 第一步:将 X 当做一个文件在对应的目录下查找。
- 如果有后缀名,按照后缀名的格式查找对应的文件
- 如果没有后缀名,会按照如下顺序:按照
js
->json
->node
的顺序。
- 第二步:没有找到对应的文件,将 X 作为一个目录。
- 作为目录下查找
index.js
->.json
->.node
。
- 作为目录下查找
- 第一步:将 X 当做一个文件在对应的目录下查找。
ES6module(ESM)
AMD
、CMD
等都是在原有JS
语法的基础上二次封装的一些社区规范,ES6 module
是 JavaScript 语言层面的规范,ES6 module
编译时静态加载。编译时就能确定模块的依赖关系,以及输入和输出的变量。从而我们可以在编译时进行静态优化,而像 AMD, CMD 运行时加载,导致完全没办法在编译时做“静态优化”。
ES6module 与 CommonJS 的区别
CommonJS 模块
输出的是一个值的拷贝对于引用类型而言就是引用,ES6
模块输出的无论是什么类型都是值的引用。CommonJS /模块
是运行时加载,ES6module
是编译时就能确定模块的依赖关系和输入输出的接口。
ES6module 与 CommonJS 导出简单值
// 输出模块 counter.js -> 简单值
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
// 引入模块 main.js
var mod = require("./counter");
console.log(mod.counter); // 3
mod.incCounter();
console.log(mod.counter); // 3
ES6 module 模块输出的无论是什么类型都是值的引用。
// counter.js
export let counter = 3;
export function incCounter() {
counter++;
}
// main.js
import { counter, incCounter } from "./counter";
console.log(counter); // 3
incCounter();
console.log(counter); // 4
CommonJS 导出引用值
// 输出模块 counter.js -> 引用值
var counter = {
value: 3,
};
function incCounter() {
counter.value++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
// 引入模块 main.js
var mod = require("./counter.js");
console.log(mod.counter.value); // 3
mod.incCounter();
console.log(mod.counter.value); // 4
ES Module 的解析流程
阶段一: 构建(Construction),根据所有依赖关系去查找 js 文件,并且下载,将其解析成模块记录(Module Record)
阶段二: 实例化(Instantiation),对模块记录进行实例化为模块环境记录,这时 import 会自动提升到代码的顶层。开始解析模块的导入和导出语句。并且为 bindings 的值(也就是导出的变量)分配内存空间。
阶段三: 运行(Evaluation),运行代码,计算值,并且赋值到导出变量对应的内存地址中,以供导入消费。
导入的模块不可以修改值,因为在模块环境记录当中用的是 const,修改就会报错。
export 用法
export const name = 'why';
export const age = 18;
export { name, age, foo };
export { name as fName, age as fAge, foo as fFoo };
// 直接导出,无需先引入。
export { add, sub } from './math.js';
export { timeFormat, priceFormat } from './format.js';
export * from './math.js';
export * from './format.js';
// 默认导出
export default foo;
import 用法
import { name, age } from './foo.js';
import { name as fName, age as fAge, foo as fFoo } from './foo.js';
import * as foo from './foo.js';
// 默认导出的引入
import why from './foo.js';
// 动态引入
import('./foo.js').then((res) => {
console.log('res:', res.name);
});
CommonJS 模块的循环加载
CommonJS 模块的重要特性是加载时执行,即脚本代码在require
的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出,从而解决 cjs 模块的循环加载。
让我们来看,Node 官方文档里面的例子。脚本文件a.js
代码如下。
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');
上面代码之中,a.js
脚本先输出一个done
变量,然后加载另一个脚本文件b.js
。注意,此时a.js
代码就停在这里,等待b.js
执行完毕,再往下执行。
再看b.js
的代码。
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
上面代码之中,b.js
执行到第二行,就会去加载a.js
,这时,就发生了“循环加载”。系统会去a.js
模块对应对象的exports
属性取值,可是因为a.js
还没有执行完,从exports
属性只能取回已经执行的部分,而不是最后的值。
a.js
已经执行的部分,只有一行。
exports.done = false;
因此,对于b.js
来说,它从a.js
只输入一个变量done
,值为false
。
然后,b.js
接着往下执行,等到全部执行完毕,再把执行权交还给a.js
。于是,a.js
接着往下执行,直到执行完毕。我们写一个脚本main.js
,验证这个过程。
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);
执行main.js
,运行结果如下。
$ node main.js
在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true
上面的代码证明了两件事。一是,在b.js
之中,a.js
没有执行完毕,只执行了第一行。二是,main.js
执行到第二行时,不会再次执行b.js
,而是输出缓存的b.js
的执行结果,即它的第四行。
exports.done = true;
总之,CommonJS 输入的是被输出值的拷贝,不是引用。
另外,由于 CommonJS 模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,两者可能会有差异。所以,输入变量的时候,必须非常小心。
var a = require('a'); // 安全的写法
var foo = require('a').foo; // 危险的写法
exports.good = function (arg) {
return a.foo('good', arg); // 使用的是 a.foo 的最新值
};
exports.bad = function (arg) {
return foo('bad', arg); // 使用的是一个部分加载时的值
};
上面代码中,如果发生循环加载,require('a').foo
的值很可能后面会被改写,改用require('a')
会更保险一点。
ES6 模块的循环加载
ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import
从一个模块加载变量(即import foo from 'foo'
),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值,
请看下面这个例子。
// a.mjs
import { bar } from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';
// b.mjs
import { foo } from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';
上面代码中,a.mjs
加载b.mjs
,b.mjs
又加载a.mjs
,构成循环加载。执行a.mjs
,结果如下。
$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined
上面代码中,执行a.mjs
以后会报错,foo
变量未定义,这是为什么?
让我们一行行来看,ES6 循环加载是怎么处理的。首先,执行a.mjs
以后,引擎发现它加载了b.mjs
,因此会优先执行b.mjs
,然后再执行a.mjs
。接着,执行b.mjs
的时候,已知它从a.mjs
输入了foo
接口,这时不会去执行a.mjs
,而是认为这个接口已经存在了,继续往下执行。执行到第三行console.log(foo)
的时候,才发现这个接口根本没定义,因此报错。
解决这个问题的方法,就是让b.mjs
运行的时候,foo
已经有定义了。这可以通过将foo
写成函数来解决。
// a.mjs
import { bar } from './b';
console.log('a.mjs');
console.log(bar());
function foo() {
return 'foo';
}
export { foo };
// b.mjs
import { foo } from './a';
console.log('b.mjs');
console.log(foo());
function bar() {
return 'bar';
}
export { bar };
这时再执行a.mjs
就可以得到预期结果。
$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar
这是因为函数具有提升作用,在执行import {bar} from './b'
时,函数foo
就已经有定义了,所以b.mjs
加载的时候不会报错。这也意味着,如果把函数foo
改写成函数表达式,也会报错。
// a.mjs
import { bar } from './b';
console.log('a.mjs');
console.log(bar());
const foo = () => 'foo';
export { foo };
上面代码的第四行,改成了函数表达式,就不具有提升作用,执行就会报错。
我们再来看 ES6 模块加载器SystemJS给出的一个例子。
// even.js
import { odd } from './odd';
export var counter = 0;
export function even(n) {
counter++;
return n === 0 || odd(n - 1);
}
// odd.js
import { even } from './even';
export function odd(n) {
return n !== 0 && even(n - 1);
}
上面代码中,even.js
里面的函数even
有一个参数n
,只要不等于 0,就会减去 1,传入加载的odd()
。odd.js
也会做类似操作。
运行上面这段代码,结果如下。
$ babel-node
> import * as m from './even.js';
> m.even(10);
true
> m.counter
6
> m.even(20)
true
> m.counter
17
上面代码中,参数n
从 10 变为 0 的过程中,even()
一共会执行 6 次,所以变量counter
等于 6。第二次调用even()
时,参数n
从 20 变为 0,even()
一共会执行 11 次,加上前面的 6 次,所以变量counter
等于 17。
这个例子要是改写成 CommonJS,就根本无法执行,会报错。
// even.js
var odd = require('./odd');
var counter = 0;
exports.counter = counter;
exports.even = function (n) {
counter++;
return n == 0 || odd(n - 1);
};
// odd.js
var even = require('./even').even;
module.exports = function (n) {
return n != 0 && even(n - 1);
};
上面代码中,even.js
加载odd.js
,而odd.js
又去加载even.js
,形成“循环加载”。这时,执行引擎就会输出even.js
已经执行的部分(不存在任何结果),所以在odd.js
之中,变量even
等于undefined
,等到后面调用even(n - 1)
就会报错。
$ node
> var m = require('./even');
> m.even(10)
TypeError: even is not a function
类型
类型检测
typeof
typeof
一般检测简单数据类型, 返回值是简单数据类型的字符串如; "number"
, "boolean"
,
"string"
检测引用数据类型的时候除了函数类型正常返回"function"
, 其余都返回"object"
。
- typeof 可以检测返回 8 种数据类型也就是
typeof
可以返回 8 种数据类型的字符串:undefined
,boolean
,string
,number
,object
,function
,symbol
,bigint
。
console.log(typeof 1); // 'number'
console.log(typeof "1"); // 'string'
console.log(typeof function () {}); // 'function'
console.log(typeof true); // 'boolean'
console.log(typeof null); // 'object'
console.log(typeof undefined); // 'undefined'
console.log(typeof { name: "ryan" }); // 'object'
console.log(typeof new String("hello")); // 'object'
console.log(typeof new String(1)); // 'object'
console.log(typeof 120n); // 'bigint'
console.log(typeof Symbol()); // 'symbol'
// 由此可见typeof对于基本数据类型的检测是okay的。
typeof
检测null
的时候,也会返回'object'
, 出现这种情况的原因其实是JavaScript
最初是通过 32 位 bit 来存储值,值的低三位或者低一位被当作类型标签来识别数据类型(000:被识别为对象,1:被识别为 31 位的有符号位整数,110:被 识别位布尔值,110:被识别为字符串,010:被识别为双精度的浮点数)
,而恰巧对象和null
的低三位都是000
,且000
的类型标签是对象,所以 null 也被处理为对象。从技术层面来将,我认为还是挺合理的,因为特殊值null
一直被我们认为是一个空对象的引用。
instanceof
instanceof
一般检测引用数据类型,返回值是布尔值,用来判断构造函数的原型对象 prototype
是否存在于实例对象的原型链上。
对于基本数据类型不能准确判断:
111 instanceof Number -> false 同样的还有"xx"和true, 因为字面量值不是实例, 通过new构造函数构造出来的才是,new Number()才是
console.log('instanceof 系列');
console.log('instanceof 系列' instanceof String); // false
console.log([2, 3] instanceof Array); // true
console.log(1 instanceof Number); // false
console.log({ name: 'ryan' } instanceof Object); // true
console.log(function () {} instanceof Function); // true
console.log(true instanceof Boolean); // false
console.log(new String('hello') instanceof String); // true
// 由此可以看出instanceof检测简单数据类型都时候都返回false, 除非你将简单值进行类包装如: new String('')
Object.prototype.toString.call()
可以检测 14 个数据类型,检测数据类型的神器
function.call(thisArg, arg1, arg2, ...) // 第一个是可选参数。
console.log(Object.prototype.toString.call(new Number(1)));
console.log(Object.prototype.toString.call('hello'));
console.log(Object.prototype.toString.call(new String('hello')));
console.log(Object.prototype.toString.call(console.log));
console.log(Object.prototype.toString({}));
console.log(Object.prototype.toString.call([]));
console.log(Object.prototype.toString.call(new Date()));
console.log(Object.prototype.toString.call(Math));
console.log(Object.prototype.toString.call(JSON));
console.log(Object.prototype.toString.call(undefined))
console.log(Object.prototype.toString.call(null))
console.log(Object.prototype.toString(null))
console.log(Object.prototype.toString(undefined))
//[object Number]
//[object String]
//[object String]
//[object Function]
//[object Object]
//[object Array]
//[object Date]
//[object Math]
//[object JSON]
//[object Undefined]
//[object Null]
// [object Object]
//[object Object]
//如果非要挑一挑毛病,那就是方法返回的字符串可能没有像typeof一样
// 值得注意的是,Object.prototype.toString(null | undefined) 返回的是 [object Object]
constructor
constructor
有两个作用,一是判断数据的类型
,二是对象实例通过 constrcutor 对象
访问它的构造函数。
- 需要注意。如果创建一个对象来改变它的原型,
constructor
就不能用来判断数据类型了。(也非常好理解,因为实例上没有constructor属性,实例找该属性是通过实例的原型对象来找的,所以当原型对象改变了,找到的构造函数就改变了)
console.log((2).constructor === Number); // true
console.log((true).constructor === Boolean); // true
console.log(('str').constructor === String); // true
console.log(([]).constructor === Array); // true
console.log((function() {}).constructor === Function); // true
console.log(({}).constructor === Object); // true
类型转换
分为显示类型转换和隐式类型转换
原始值转换为布尔值
- 原始值转换为布尔值: 只有六种值会被转换为 false, 其他都是 true.
0
,NaN
,null
,''
,undefined
,false
原始值转换为数字
- 原始值转换为数字: Number()如果不传值默认返回 0, 如果传了值调用调用
ToNumber(value)
, ToNumber 是底层规范实现。
| 参数类型 | 结果 | | --- | --- | | undefined | NaN | | Null | +0 | | Boolean | 如果参数是 true,返回 1。参数为 false,返回 +0 | | Number | 返回与之相等的值 | | String | 如下段解释 | - 对于
Number(string)
会试着将包含数值字符, 加号,减号的字符串转换成浮点数或者是整数, 也可以识别 8 进制的 0 和 16 进制的 0x,会忽略前导的 0 进行转换, 如果有一个字符不是数字直接返回NaN
, 也就是不符合上述的情况直接返回NaN
.
console.log(Number(undefined)); // NaN
console.log(Number(null)); // 0
console.log(Number(1)); // 1
console.log(Number(true)); // 1
console.log(Number(NaN)); // NaN
console.log(Number("123j")); // NaN
- 鉴于这种严格的转换数字规则, 我们一般会使用更加灵活的
parseInt()
和parseFloat()
进行转换,他们更专注于字符串当中是否包含数值模式 parseInt(string, [radix])
; 只解析整数,parseFloat(string, [radix])
既可以解析整数也可以解析浮点数,parseInt
和parseFloat
都会跳过任意数量的前导空格,尽可能解析更多数值字符,并忽略后面的内容。如果第一个非空格字符是非法的数字直接量,将最终返回NaN
:parseFloat
会专注于浮点数的第一次出现, 第二次就会忽略浮点数
console.log(parseInt("1a2"));
console.log(parseInt("0001a2"));
console.log(parseInt("-0001a2"));
console.log(parseInt("-z0001a2"));
console.log(parseInt("+0.001a2"));
console.log(parseInt("1.001a2"));
console.log(parseFloat("1.001a2.30"));
console.log(parseInt(070));
// 1
// 1
// -1
// NaN
// 0
// 1
// 1.001
// 56
原始值转换为字符串
如果 String
函数不传参数,返回空字符串,如果有参数,调用 ToString(value)
,而 ToString
也给了一个对应的结果表。
参数类型 | 结果 |
---|---|
Undefined | "undefined" |
Null | "null" |
Boolean | 如果参数是 true,返回 "true"。参数为 false,返回 "false" |
Number | num.toString() 括号当中可以选择进制, 默认是 10 进制 |
String | 返回与之相等的值 |
null 和 undefined 没有 toString()方法, 所有我们会使用 String()方法, 如果有参数会调用 toString(), 如果参数是 null 和 undefined 返回字符串, 相当于是 toString()的增强版 -- 注意这里的 toString()和ToString(value)
不一样, 一个是对外暴露的, 一个是不对外暴露的顶层规范
- 如果
String(对象类型)
那么会调用toPrimitive(input, String)
方法,返回一个基本类型的值, 然后在通过ToString
对基本类型进行转换
原始值转换为对象
可以通过构造函数 String(), Boolean(), 来将原始值转换为包装对象
对象转化为字符串
console.log(Array.prototype.toString === [1, 1].toString, "布尔值"); // true
({ a: 1 }.toString === Object.prototype.toString); // true
每个类型在调用自己身上的 toString
方法时的一些规则
toString()
从上述代码我们可以看出来,当调用对象的 toString
方法的时候, 他会调用对应对象上的 toString
方法。
- 数组的
toString
方法将每个数组元素转换成一个字符串,并在元素之间添加逗号后合并成结果字符串。 - 函数的
toString
方法返回源代码字符串。 - 日期的
toString
方法返回一个可读的日期和时间字符串。 - RegExp 的
toString
方法返回一个表示正则表达式直接量的字符串。 - 如果
String(对象类型)
那么会调用toPrimitive(input, String)
方法,返回一个基本类型的值, 然后在通过ToString
对基本类型进行转换
console.log(function a() {}.toString());
console.log({ a: "ryan" }.toString());
console.log([1, 3, 34].toString());
console.log(new Date().toString());
console.log(/^a$/.toString());
//function a() {}
//[object Object]
//'1,3,34'
//Sat Jan 08 2022 12:52:09 GMT+0800 (中国标准时间)
//^a$/
valueOf
- 另一个转换对象的函数是 valueOf,表示对象的原始值。默认的 valueOf 方法返回这个对象本身,数组、函数、正则简单的继承了这个默认方法,也会返回对象本身。日期是一个例外,它会返回它的一个内容表示: 1970 年 1 月 1 日以来的毫秒数。
console.log(function a() {}.valueOf());
console.log({ a: "ryan" }.valueOf());
console.log([1, 3, 34].valueOf());
console.log(new Date().valueOf());
console.log(/^a$/.valueOf());
// [Function: a]
// { a: 'ryan' }
// [ 1, 3, 34 ]
// 1641620212097
// /^a$/
对象转换为数字
- 如果
Number(对象类型)
那么会调用toPrimitive(input, Number)
方法,返回一个基本类型的值, 然后在通过ToNumber
对基本类型进行转换
ToPrimitive()
ToPrimitive(input[, PreferredType]) []表示可选参数
- 第一个参数是
input
,表示要处理的输入值。 - 第二个参数是
PreferredType
,非必填,表示希望转换成的类型,有两个值可以选,Number
或者String
。 - 当不传入
PreferredType
时,如果input
是日期类型,相当于传入String
,否则,都相当于传入Number
。 - 如果传入的
input
是Undefined
、Null
、Boolean
、Number
、String
类型,直接返回该值。// 如果是原始值直接返回
ToPrimitive(obj, Number)
- 如果是 ToPrimitive(obj, Number),处理步骤如下:
- 如果 obj 为 基本类型,直接返回
- 否则,调用 valueOf 方法,如果返回一个原始值,则 JavaScript 将其返回。
- 否则,调用 toString 方法,如果返回一个原始值,则 JavaScript 将其返回。
- 否则,JavaScript 抛出一个类型错误异常。
ToPrimitive(obj, String)
- 如果是 ToPrimitive(obj, String),处理步骤如下:
- 如果 obj 为 基本类型,直接返回
- 否则,调用 toString 方法,如果返回一个原始值,则 JavaScript 将其返回。
- 否则,调用 valueOf 方法,如果返回一个原始值,则 JavaScript 将其返回。
- 否则,JavaScript 抛出一个类型错误异常。
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
JSON.toStringify()
- 处理基本类型时,与使用
toString
基本相同,结果都是字符串,除了undefined
- 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值。
undefined
、任意的函数以及symbol
值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)。
// 在对象当中和在数组当中的不同表现
JSON.stringify({ x: undefined, y: Object, z: Symbol("") });
// "{}"
JSON.stringify([undefined, Object, Symbol("")]);
// "[null,null,null]"
JSON.stringify
有第二个参数replacer
,它可以是数组或者函数,用来指定对象序列化过程中哪些属性应该被处理,哪些应该被排除。
//数组和函数当中的不同体现
function replacer(key, value) {
if (key == "name") {
return undefined;
}
return value;
}
JSON.toStringify({ name: "ryan", age: 18 }, replacer);
var foo = { foundation: "Mozilla", model: "box", week: 45, transport: "car", month: 7 };
console.log(JSON.stringify(foo, ["week", "month"]));
// {"week":45,"month":7}
- 如果一个被序列化的对象拥有
toJSON 方法
,那么该toJSON
方法就会覆盖该对象默认的序列化行为:不是那个对象被序列化,而是调用toJSON
方法后的返回值会被序列化,例如:
// toJSON返回值被序列化
var obj = {
foo: "foo",
toJSON: function () {
return "bar";
},
};
JSON.stringify(obj); // '"bar"'
JSON.stringify({ x: obj }); // '{"x":"bar"}'
隐式转换
一元操作符
- 当一元操作符 + 操作的是原始值 Number(原始值)
- 当一元操作符 + 操作的是引用类型 → ToPrimitive(引用值, Number) -> 根据 ToNumber 规范求值
console.log(+["1"]); // 1
console.log(+["1", "2", "3"]); // NaN
console.log(+{}); // NaN
console.log(+"1"); // 1
二元操作符
当计算 value1 + value2 时:
- 0lprim = ToPrimitive(value1)
- rprim = ToPrimitive(value2)
- 如果 lprim 是字符串或者 rprim 是字符串或者都是字符串,那么返回 ToString(lprim) 和 ToString(rprim)的拼接结果
- 要是不满足那么就返回 ToNumber(lprim) 和 ToNumber(rprim)的运算结果
console.log(null + 1); // 1
console.log([] + []); // ''
console.log([] + {}); // '[object Object]'
console.log(1 + true); // 2
console.log({} + {}); // '[object Object][object Object]'
console.log(new Date(2017, 04, 21) + 1); // 这个知道是数字还是字符串类型就行
x == y 相等
- 当 x 和 y 是同一类型的时候:
- x 和 y 是 null 和 undefined 时返回 true
- x 和 y 是字符串的时候, 只有字符串完全相同的时候返回 true, 否则返回 false
- x 和 y 是数字的时候, 两个 NaN 在比较的时候, 返回 false, 数字相等的时候返回 true, +0 0 -0 之间的比较是 true 其他是 false
- x 和 y 是对象的时候, 指向同一对象的时候返回 false
- x 和 y 是布尔值的时候, 都是 false 或者都是 true 的时候返回 true 其他返回 false
- 当 x 和 y 是不同的类型的时候
- x 和 y 一方是 null 另一方是 undefined 的时候返回 true
- x 和 y 有一方是字符串另一方是数字的时候, 会则会 ToNumber(字符串)来进行比较
- x 和 y 有一方是布尔值的时候, 将布尔值转换为数字进行比较
- x 和 y 有一方是数字或者是字符串的时候, 另一方是对象的时候. 会 Toprimitive(对象)进行比较
- 其他返回 false
console.log(false == "0");
console.log(false == 0);
console.log(false == "");
console.log("" == 0);
console.log("" == []);
console.log([] == 0);
console.log("" == [null]);
console.log(0 == "\n");
console.log([] == 0);
// 都是false要值得注意的是 [null | undefined].toString() 结果是 ''[null, null, null, undefined].toString() j结果是 ',,,'
对于<
和>
比较符
如果两边都是字符串,则比较字母表顺序:
'ca' < 'bd' // false
'a' < 'b' // true
其他情况下,转换为数字再比较:
'12' < 13 // true
false > -1 // true
而引用类型会被ToPrimitive(obj, Number)
转换为基本类型再进行转换:
var a = {}
a > 2 // false
NaN 和数字比,比不出来结果
类型相关面试题
为什么 0.1+0.2 ! == 0.3?如何解决?
计算机运算器在做加和的时候,是将 10 进制的 0.1 + 0.2 转换为 2 进制的 0.1 + 0.2 进行加和。由于 0.1 和 0.2 在转换为二进制的时候会无限循环 (0.1转换为二进制,乘以二发现永远都乘不到整数),且 JavaScript Number
类型的实现遵循 IEEE 754
标准:1 位存符号位 sign
,11 位存指数位 exponent
,52 位使用原码来存储尾数位Fraction
。所以 二进制的 0.1 和 0.2 在 52 位之后二进制位会被截掉,即在保存的时候就已经出现了精度的丢失。最后,精度丢失的两个二进制数经过对阶、尾数运算、规格化、舍入处理相加之后再转变为 10 进制也就出现了0.1 + 0.2 = 0.30000000000000004440892098500626的结果。
let n1 = 0.1, n2 = 0.2
console.log(n1 + n2) // 0.30000000000000004
(n1 + n2).toFixed(2) // 注意,toFixed为四舍五入
//利用误差属性
function numberepsilon(arg1,arg2){
return Math.abs(arg1 - arg2) < Number.EPSILON;
}
console.log(numberepsilon(0.1 + 0.2, 0.3)); // true
|| 和 && 和 ?? 操作符的返回值?
|| 和 && 首先会对第一个操作数执行条件判断,如果其不是布尔值就先强制转换为布尔类型,然后再执行条件判断。
- 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数的值,如果为 false 就返回第二个操作数的值。做变量赋值。
- && 则相反,如果条件判断结果为 true 就返回第二个操作数的值,如果为 false 就返回第一个操作数的值。做变量渲染。
- ?? 控制合并是一个逻辑运算符,当左侧的操作数为
null
或者undefined
时,返回其右侧操作数,否则返回左侧操作数。
|| 和 && 返回它们其中一个操作数的值,而非条件判断的结果
= 和 与 Object.is()的区别
- 对于
==
(双等号) 来说,在隐式转换当中的例子当中我们也可以看到,如果==
两边的类型不一致会先进行类型转换之后再比较,比如有布尔值就将布尔值转换为数字再进行比较,有字符串和数字就将字符串转换为数字再进行比较, - 对于
===
(三等号) 来说,如果出现两边类型不一致的情况直接返回 false. Object.is()
是再三等号判断的基础上进行增强和完善,处理了特殊情况,比如在Object.is()
中0
和+0
不相等,两个NaN
是相等的。这在三等号和双等号中0
和+0
相等,两个NaN
不相等。