Event Loop
一、进程与线程
1)运行以后的程序叫做进程
2)一个进程可以有多个线程
3)线程是最小的执行单元,进程至少由一个线程组成
4)线程与进程的调度由操作系统决定
5)单核CPU一次只能执行一个进程
6)有的内存是共享的;有的被加了互斥锁;有的只能给限定个数的线程使用(信号量)
参考资料
二、浏览器与进程
以下以chrome为例
1)chrome是多进程的,每个tab标签代表一个进程。
2)进程的隔离性,可以防止一个进程挂断后,整个浏览器都挂掉。
参考资料
三、Stack。栈和堆
1)数据结构(栈,后进先出)
2)代码运行方式(调用栈)
3)内存区域(stack栈,heap堆)
stack有三种不同的含义,需要根据不同的情况进行理解。这里重点记录下内存区域。
- 系统会划分两种不同的内存空间,一种叫栈,一种叫堆。栈是有结构的,区块的大小是确定的。堆是没有结构的,数据任意存放。栈的寻址速度要快于堆。
- JS中原始数据类型(Undefined、Null、Boolean、Number、String)存放在栈中;引用数据类型由于占据空间大小不固定,存放在堆中,在栈中存储了指针,指针指向堆中该实体的起始地址。
- 一般每个线程分配一个stack,每个进程分配一个heap,stack是线程独占的,heap是线程共享的。
- JS的方法运行完成后,方法中的局部变量会被回收,即相关栈会被清空,但是堆中的对象数据继续存在,直到系统垃圾清理机制将这块内存回收。一般内存泄露发生在堆,由于某些原因,无用的数据没有被回收。
参考资料
四、Event Loop
1)JS是单线程的:单线程可以避免一个线程在修改DOM,另一个线程在删除这个DOM
2)Web Worker可以创建多个线程,但子线程受主线程控制且不得操作DOM
3)任务分为:同步任务与异步任务
同步任务:在主线程上执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
异步任务:不进入主线程进入“任务队列(task queue)”的任务,只能“任务队列”通知主线程这个任务可以执行了,该任务才会进入主线程执行。
“任务队列”是【先进先出】的数据结构。
执行过程
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个“任务队列”(task queue)。只要异步任务有了运行结果,就在“任务队列”之中放置一个事件。
- 一旦“执行栈”中的本轮循环中所有同步任务执行完毕,系统就会读取“任务队列”,看看里面有哪些事件。哪些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
因为主线程从“任务队列”读取事件,这个过程是循环不断的,所以这种运行机制又称为Event Loop(事件循环)。
上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在“任务队列”中加入了各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取“任务队列”,依次执行那些事件对应的回调函数。
定时器
setTimeout()只是将事件插入了“任务队列”,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,很可能要等很久,所以并没办法保证,回调函数一定会在setTimeout()指定的时间执行。
process.nextTick和setImmediate
- process.nextTick在当前执行栈的尾部执行,即下一次Event Loop前执行,即它指定的任务总是发生在所有异步任务之前。
- setImmediate和setTimout类似,在下一个Event Loop执行。
- 多个process.nextTick语句总是在当前"执行栈"一次执行完,多个setImmediate可能则需要多次loop才能执行完。
process.nextTick(function A() {
console.log(1);
process.nextTick(function B(){console.log(2);});
});
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0)
// 1
// 2
// TIMEOUT FIRED
如果有多个process.nextTick语句(不管它们是否嵌套),将全部在当前"执行栈"执行。
setImmediate(function A() {
console.log(1);
setImmediate(function B(){console.log(2);});
});
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0);
运行结果可能是1–TIMEOUT FIRED–2,也可能是TIMEOUT FIRED–1–2。
setImmediate(function A() {
console.log(1);
setImmediate(function B(){console.log(2);});
});
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0);
process.nextTick(function foo() {
console.log(10);
});
// 10
// TIMEOUT FIRED
// 1
// 2
如果在setTimeout和setImmediate中加入微任务,那么setTimeout或先于setImmediate执行。
setImmediate(function (){
setImmediate(function A() {
console.log(1);
setImmediate(function B(){console.log(2);});
});
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0);
});
// 1
// TIMEOUT FIRED
// 2
上面代码中,setImmediate和setTimeout被封装在一个setImmediate里面,它的运行结果总是1–TIMEOUT FIRED–2,这时函数A一定在timeout前面触发。
node-v14.16.0 经测试,这段代码的运行结果也是不固定的可能是1–TIMEOUT FIRED–2,也可能是TIMEOUT FIRED–1–2
参考资料
JavaScript 运行机制详解:再谈Event Loop
五、宏任务与微任务
宏任务
(macro)task,可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task执行开始前,对页面进行重新渲染:
(macro)task -> 渲染 -> (macro)task -> …
即渲染发生在两次事件循环之间,而每次事件循环又包含执行栈的执行及执行栈从任务队列中拿去回调事件进行执行这两个过程
宏任务有哪些
- script(整体代码)
- setTimeout
- setInterval
- I/O
- UI交互事件
- postMessage
- MessageChannel
- setImmediate(Node.js 环境)
微任务
microtask,可以理解是当前task执行结束后立即执行的任务,即任务队列里面的任务。
在某个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。这也解释了JavaScript 运行机制详解:再谈Event Loop中写道的【“递归调用process.nextTick,将会没完没了”】。因为需要将宏任务执行期间产生的微任务都执行完,而微任务process.nextTick又产生了微任务process.nextTick,所以微任务就会一直被执行,而无法执行下一个宏任务。
微任务有哪些
- Promise.then
- Object.observe
- MutationObserver
- process.nextTick(Node.js 环境)
微任务process.nextTick的优先级是所有微任务中最高的(也就是说,它指定的任务总是发生在所有异步任务之前【JavaScript 运行机制详解:再谈Event Loop】)。
再者,当Promise有多个回调的时候,要先执行完所有的回调,才能执行process.nextTick。可以理解为微任务中,Promise有自己的任务队列,process.nextTick也有自己的任务队列,只有当当前的任务队列执行完后,才能执行其他的任务队列。【About Promise & process.nextTick】
任务队列里都是执行完的异步任务,不是注册异步任务到任务队列里。
运行机制
在事件循环中,每进行一次循环操作称为tick,每次tick的任务处理模型是比较复杂的,但关键步骤如下:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
为什么微任务优先于宏任务
<script>
setTimeout(() => {
console.log(1)
}, 0);
new Promise((rej)=>rej(2)).then((data)=>
console.log(data)
)
</script>
script(整体代码)本身就是一个宏任务,setTimeout也是一个宏任务,Promise.then是一个微任务。当一个宏任务执行结束后,就会去执行微任务。
script(整体代码)执行完毕后,当前的执行栈就空了,这时就会去执行能执行的微任务Promise.then,所有微任务执行完后,就会进入下一个事件循环,执行下一个宏任务setTimeout。
这就解释了为什么微任务先于宏任务执行,其实本质上还是宏任务先执行,只是script(整体代码)这个宏任务被忽略了。
参考资料
js中的宏任务与微任务
【JS】深入理解事件循环,这一篇就够了!(必看)
为什么js的微任务优先于宏任务?
微任务、宏任务与Event-Loop
六、关于async/await函数
setTimeout(_ => console.log(4))
async function main() {
console.log(1)
await Promise.resolve()
console.log(3)
}
main()
console.log(2)
- await关键字与Promise.then效果类似。
- async函数在await之前的代码都是同步执行的,可以理解为await之前的代码属于new Promise时传入的代码,await之后的所有代码都是在Promise.then中的回调。
七、Event Loop测试题
setTimeout(_ => console.log(4))
new Promise(resolve => {
resolve()
console.log(1)
}).then(_ => {
console.log(3)
Promise.resolve().then(_ => {
console.log('before timeout')
}).then(_ => {
console.log(7)
Promise.resolve().then(_ => {
console.log('also before timeout')
})
})
new Promise(resolve => {
console.log(5)
resolve()
}).then(function() {
console.log(6)
})
})
console.log(2)
运行结果:
1
2
3
5
before timeout
6
7
also before timeout
4
结果分析:
- 执行栈执行script这个宏任务
- 将宏任务setTimeout加入执行栈
- 执行new Promise中的console.log(1),【打印 1】
- 将第一个Promise.then加入任务队列
- 执行console.log(2),打印【2】
- 这是script这一个宏任务执行完毕了,这时候本次事件循环的执行栈就空了,此时就会去拉去任务队列中的回调事件到执行栈中执行了
- 执行第一个Promise.then,打印【3】
- 将Promise.resolve().then这个微任务加入任务队列
- 执行第二个new Promise中console.log(5),打印【5】
- 将第二个Promise.then加入任务队列
- 这时任务队列中有Promise.resolve().then、第二个Promise.then,且这两个微任务是因为script这个宏任务执行产生的,因此要先将这两个微任务执行完
- 执行Promise.resolve().then,打印【before timeout】
- 将Promise.resolve().then().then加入任务队列
- 执行第二个Promise.then,打印【6】
- 这时任务队列有Promise.resolve().then().then,依然要把它执行完
- 执行Promise.resolve().then().then,打印【7】
- 将第二个Promise.resolve().then这个微任务加入任务队列
- 这时任务队列中还剩第二个Promise.resolve().then,依然要执行完
- 执行第二个Promise.resolve().then,打印【also before timeout】
- 这是script这个宏任务产生的微任务都执行完了,可以进行下一个事件循环,执行下一个宏任务了。此时执行栈中还有setTimeout这个宏任务
- 执行setTimeout,产生一个匿名函数微任务
- setTimeout这个宏任务执行结束后,该去任务队列里,拉去微任务到执行栈中执行了,发现任务队列执行一个匿名函数
- 执行该匿名函数,打印【4】
加大难度加入node中的setImmediate和process.nextTick
setTimeout(function(){console.log(4)},0)
setImmediate(function B() {
console.log(11);
});
new Promise(function(resolve) {
resolve()
console.log(1)
}).then(function() {
console.log(3)
Promise.resolve().then(function() {
console.log('before timeout')
}).then(function() {
console.log(7)
Promise.resolve().then(function() {
console.log('also before timeout')
})
})
setImmediate(function A() {
console.log(8);
});
new Promise(function(resolve) {
console.log(5)
process.nextTick(function foo2() {
console.log(9);
});
resolve()
}).then(function() {
console.log(6)
})
})
process.nextTick(function foo() {
console.log(10);
});
console.log(2)
运行结果:
1
2
10
3
5
before timeout
6
7
also before timeout
9
4
11
8
结果分析:
- 执行script这个宏任务
- 执行第一个Promise,打印【1】
- 执行console.log(2),打印【2】
- 这时script宏任务执行完毕,准备执行微任务,任务队列中此时有第一个Promise.then和第一个process.nextTick
- process.nextTick执行优先级高于Promise.then,先执行process.nextTick(process.nextTick会先入任务队列),打印【10】
- 接着执行任务队列中的第一个Promise.then,打印【3】
- 执行第二个Promise,打印【5】
- 第二个process.nextTick此时不会执行,虽然process.nextTick执行的优先级大于Promise.then,但是目前第一个Promise.then的任务队列还没执行完毕,因此第二个process.nextTick要等第一个Promise.then的任务队列执行完毕才能执行。
- 这时任务队列里接下来要执行的有第一个Promise.resolve().then、第二个Promise.then
- 执行第一个Promise.resolve().then,打印【before timeout】
- 执行第二个Promise.then,打印【6】
- 执行第一个Promise.resolve().then().then,打印【7】
- 执行第二个第一个Promise.resolve().then,打印【also before timeout】
- 这时第一个Promise.then的任务队列都执行完毕了,可以执行第二个process.nextTick,打印【9】
- 所有的微任务都执行完毕了,接下来进行下一轮事件循环
- 这时主线程中有setTimeout、setImmediateB,setImmediateA
- 如果代码中只有setTimeout、setImmediateB,而没有其他代码,那么setTimeout和setImmediateB执行的顺序是不固定的,而有其他代码之后,setTimeout可能会先于setImmediateB执行,因为执行其他代码需要时间,这时候setTimeout设定的时间如果已经到了,就会先执行setTimeout,而setImmediate会在本次event loop的最后执行。
- 执行setTimeout,打印【4】
- 执行setImmediateB,打印【11】
- 执行setImmediate,打印【8】
八、面试题:什么是Event Loop?
JS是单线程的语言,它在同一个时间点只能做一件事情,如果所有的代码都按照顺序执行,要是前面有很复杂的耗时代码要执行,后面的代码就要一直等待,这不利于代码的执行,因此JS就通过Event Loop来解决这类情况。
JS的任务分为宏任务与微任务且JS只有一个主线程,宏任务在主线程(可以理解为宏任务的任务队列)执行,宏任务执行过程中会产生微任务,微任务执行完后产生的回调事件会进入任务队列中,当一次宏任务执行完之后,主线程就回去微任务的任务队列中,拉取可执行的回调事件,只有当此次宏任务产生的微任务都执行完毕后,主线程才会进入下一轮的宏任务,重复上述步骤继续执行。
常见的宏任务:script整体代码,setTimeout,setInterval,setImmediate(nodejs),I/O
常见微任务:Promise.then,await,process.nextTick(nodejs)
另外每个微任务也有自己的任务队列,只有当一个微任务的任务队列执行完才会执行另一个微任务的任务队列。
同层级微任务中,process.nextTick会被优先执行。
同层级宏任务中,setImmediate会被放在本轮Event Loop的末尾执行,setTimeout可以先于setImmediate执行,也可能晚于setImmediate,具体要看其他代码执行的时间是否大于等于setTimeout的等待时间,如果大于先执行setTimeout,如果小于先执行setImmediate。
JS模块化
CommonJS, AMD, CMD都是JS模块化的规范。
CommonJS是服务器端js模块化的规范,NodeJS是这种规范的实现。
AMD(异步模块定义)和CMD(通用模块定义)都是浏览器端js模块化的规范。RequireJS 遵循的是 AMD,SeaJS 遵循的是 CMD。
一、CommonJS
根据CommonJS规范,一个单独的文件就是一个模块。加载模块使用require方法,该方法读取一个文件并执行,最后返回文件内部的exports对象。所以,定义一个模块就是写一个新的js文件,但是最后要将文件的内容exports出来。接下来我们看一下如何定义模块和加载模块。
//定义一个module.js文件
var A = function() {
console.log('我是定义的模块');
}
//导出这个模块
//1.第一种返回方式 module.exports = A;
//2.第二种返回方式 module.exports.test = A
//3.第三种返回方式 exports.test = A;
exports.test = A;
//再写一个test.js文件,去调用刚才定义好的模块,这两个文件在同一个目录下
var module = require("./module"); //加载这个模块
//调用这个模块,不同的返回方式用不同的方式调用
//1.第一种调用方式 module();
//2.第二种调用方式 module.test();
//3.第三种调用方式 module.test();
module.test();
//接下来我们去执行这个文件,前提是你本地要安装node.js,不多说了,自己百度安装。
//首先打开cmd, cd到这两个文件所在的目录下,执行: node test.js
node test.js
//输出结果:我是定义的模块
当我们执行 node test.js 的时候,根据 var module = require(“./module”); 会加载同一目录下的module.js文件,并将这个文件的exports对象返回,赋值给module,所以我们调用 module.test(); 就相当于执行了module.js文件。
CommonJs 主要针对服务端,模块输出的是一个值的拷贝。
以上就是CommonJS规范下的模块定义与加载的形式。
CommonJS 加载模块是同步的,所以只有加载完成才能执行后面的操作。像Node.js主要用于服务器的编程,加载的模块文件一般都已经存在本地硬盘,所以加载起来比较快,不用考虑异步加载的方式,所以CommonJS规范比较适用。但如果是浏览器环境,要从服务器加载模块,这是就必须采用异步模式。所以就有了 AMD CMD 解决方案。
二、AMD(Asynchronous Module Definition) 异步模块定义
AMD规范通过define方法去定义模块,通过require方法去加载模块。RequireJS实现了这种规范。
AMD只有一个接口:define(id?,dependencies?,factory); 它要在声明模块的时候制定所有的依赖(dep),并且还要当做形参传到factory中。要是没什么依赖,就定义简单的模块(或者叫独立的模块),下面我们看代码实现:
// 编写一个module1.js文件
// 定义独立的模块
define({
methodA: function() {
console.log('我是module1的methodA');
},
methodB: function() {
console.log('我是module1的methodB');
}
});
//编写一个module2.js文件
//另一种定义独立模块的方式
define(function () {
return {
methodA: function() {
console.log('我是module2的methodA');
},
methodB: function() {
console.log('我是module2的methodB');
}
};
});
//编写一个module3.js文件
//定义非独立的模块(这个模块依赖其他模块)
define(['module1', 'module2'], function(m1, m2) {
return {
methodC: function() {
m1.methodA();
m2.methodB();
}
};
});
//再定义一个main.js,去加载这些个模块
require(['module3'], function(m3){
m3.methodC();
});
//我们在一个html文件中去通过RequireJS加载这个main.js
//等号右边的main指的main.js
<script data-main="main" src="require.js"></script>
//浏览器控制台输出结果
我是module1的methodA
我是module2的methodB
以上就是AMD规范的模块定义和加载的形式,ReauireJS实现了这种规范。所以我们的例子也借助RequireJS去实现。
三、CMD(Common Module Definition) 通用模块定义
CMD是SeaJS 在推广过程中对模块定义的规范化产出。
AMD和CMD的区别:
-
对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。不过 RequireJS 从 2.0 开始,也改成可以延迟执行(根据写法不同,处理方式不同)。CMD 推崇 as lazy as possible(尽可能的懒加载,也称为延迟加载,即在需要的时候才加载)。
-
CMD 推崇依赖就近,AMD 推崇依赖前置。
-
AMD 的 API 默认是一个当多个用,CMD 的 API 严格区分,推崇职责单一。比如 AMD 里,require 分全局 require 和局部 require,都叫 require。CMD 里,没有全局 require,而是根据模块系统的完备性,提供 seajs.use 来实现模块系统的加载启动。CMD 里,每个 API 都简单纯粹。
虽然 AMD 也支持 CMD 的写法,同时还支持将 require 作为依赖项传递,但 RequireJS 的作者默认是最喜欢下面的写法,也是官方文档里默认的模块定义写法。看代码:
// CMD
define(function(require, exports, module) {
var a = require('./a');
a.doSomething();
// 此处略去 100 行
var b = require('./b'); // 依赖可以就近书写
b.doSomething();
// ...
})
// AMD 默认推荐的是
define(['./a', './b'], function(a, b) { // 依赖必须一开始就写好
a.doSomething();
// 此处略去 100 行
b.doSomething();
//...
})
四、UMD(Universal Module Definition) 通用模块定义
严格上说,umd不能算是一种模块规范,因为它没有模块定义和调用,这是AMD和CommonJS(服务端模块化规范)的结合体,保证模块可以被amd和commonjs调用。
它兼容AMD和commonJS规范的同时,还兼容全局引用的方式
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
//AMD
define(['jquery'], factory);
} else if (typeof exports === 'object') {
//Node, CommonJS之类的
module.exports = factory(require('jquery'));
} else {
//浏览器全局变量(root 即 window)
root.returnExports = factory(root.jQuery);
}
}(this, function ($) {
//方法
function myFunc(){};
//暴露公共方法
return myFunc;
}));
五、ES6 Module
ES6 Module可以将大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。它的思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入输出的变量。
export的用法
export命令规定的是对外的接口,必须与模块内部的变量建立一一对应的关系,即如果不使用大括号包裹,export要与变量声明在同一行,不能直接导出变量。
export 和 import 可以出现在模块的任何位置,只要处于顶层就可以,不能处于块级作用域内,不然就没法做静态分析了。即两者都不能进行动态变量的导入和导出。
export default 默认输出一个叫default的变量或方法,导出的值会赋值给default变量,所以不能export default后面声明变量,再者export default可以直接输出一个值。
// 报错
export 1;
// 报错
var m = 1;
export m;
// 正确
export var m = 1;
export { m };
export { m as cc };
// 报错
function f() {}
export f;
// 正确
export function f() {};
export { f };
以下罗列下export的使用方式:
// 写法一
export var m = 1;
// 写法二
var m = 1;
export { m };
// 写法三
var n = 1;
export { n as m };
// 写法四,一个模块只能有一个默认输出
function f() {}
export default f;
// 写法五
export { foo, bar } from 'my_module';
// 可以简单理解为
import { foo, bar } from 'my_module';
export { foo, bar };
export和import语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,foo和bar实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用foo和bar。
import的用法
export的输出名 和 import的输入名要相同。
import命令输入的变量都是只读的,不能重写变量,但可以改写变量的属性,但不建议改写。
form可以接相对路径也可以接绝对路径,但是如果接模块名,需要配置模块名的路径。
import命令具有提升效果,会提升到整个模块的头部,首先执行。它编译阶段执行的,在代码运行之前。函数的声明会比import优先执行,可以用来解决循环调用的问题。
import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。
import语句是单例模式,多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。
可以使用星号(*)加载模块中的所有输出值,但是会忽略export default。
以下罗列下import的使用方式:
// 写法一
import { area, circumference } from './circle';
// 写法二
import * as circle from './circle';
// 写法三,用于export default,import不使用大括号
import customName from './export-default';
ES6 模块的特征:
- 它能进行静态分析,在编译的时候就能确定模块的依赖关系,以及输入输出的变量;而commonJS、AMD和CMD模块,只能在运行时确定这些东西
- ES6 Module可以在编译时就完成模块加载,效率要比commonJS模块的加载方式高,且它可以进行静态分析。
- ES6 Module 自动采用严格模式,具有严格模式的若干限制。严格模式中顶层this指向undefined。
- 一个模块就是一个独立的文件。
- ES6 Module export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值;commonJS模块输出的是值的缓存,不存在动态更新。
参考资料
CommonJS, AMD, CMD是什么及区别
前端模块化:AMD、CMD、CommonJS、ES6
JS模块规范:AMD、UMD、CMD、commonJS、ES6 module
六、面试题:JS模块化有哪些?
JS模块化主要有四种实现方式:AMD、CMD、commonJS、ES6 Module。
AMD的实现者是require.js,它推崇模块加载前置,即当所有依赖的模块加载完毕后再执行回调函数。
CMD的实现者是sea.js,它推崇就近加载,即需要用到哪个模块的时候再去加载哪个模块。
commonJS的实现者是nodejs,它主要用于服务端,是一种同步加载的方式。
ES6 Module,通过import和export实现模块化,不同于前三者的是它能进行静态分析,即在编辑阶段就能确定模块间的依赖关系,而前三者需要在运行时,才能知道。但这也导致一个问题就是ES6 Module无法进行条件加载。再者import是一个单例模式,多个import只会执行一次,而且import是模块的引用,可以实时获取模块的值,而commonJS输出的是值的拷贝,不能实时获取模块的值。
JS单例模式
一、定义
一个类只能有一个实例,即使多次实例化该类,也只返回第一次实例化后的实例对象。单例模式可以减少内存的开支和全局变量的冲突。
二、面试题:什么是单例模式?
单例模式即一个类只能有一个实例,多次调用该类,返回的也是第一次调用生成的实例。单例模式可以节省内存的开销,减少全局命名的冲突。JS中可以使用对象字面量创建一个单例模式,或者使用构造函数,ES6的类。
JS单例模式可用于工具类的封装,模块的管理等。
参考资料
从ES6重新认识JavaScript设计模式(一): 单例模式
JS防抖和节流
一、防抖(debounce)
在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。
二、节流(throttle)
规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。
三、面试题:什么是防抖和节流?
我们从字面上理解,防抖即防止抖动,就是在它不抖动的时候才执行,如果一个事件在n秒内被不断触发,那么它的回调函数不会执行,计时器会重新计时。只有当事件被触发n秒后,没有再触发,该事件的回调函数才会被触发。
节流即节约流量,流量就像河流一样不断流淌,如果用抽水机不停抽,水可能会被抽干,如果我们用瓢,每隔一段时间舀一勺,水就不容易干涸。如果一个事件连续触发,它只会每隔一段时间生效一次。
防抖适用于当用户停止操作才触发的场景,如搜索。节流用于在用户连续操作中,每隔一段时间触发一次,如下拉加载。
下拉加载不用防抖是因为如果要等到用户不下拉才请求接口,这会造成较长的时间等待,形成卡顿感,影响用户体验,而且用户的下拉操作可能是疯狂下拉。虽然节流可以优化体验,但相比于防抖资源开销较大。
参考资料
CSS盒模型
一、面试题:谈谈你对CSS盒模型的理解?
CSS盒模型由content、padding、border、margin组成。CSS盒模型分为IE盒模型盒W3C盒模型两种。
IE盒模型的宽(width)高(height)属性由content+padding+border组成,通过box-sizing:border-box;可以设置为IE盒模型。
W3C盒模型宽(width)高(height)属性仅由content组成,通过box-sizing:content-box;可以设置为W3C盒模型。
#box {
width: 200px;
height: 200px;
background-color: pink;
padding:20px;
}
IE盒模型:
content+padding+border = 200px; content < 200px;
W3C盒模型:
content+padding+border > 200px; content = 200px;
参考资料
JS 基本包装类型
一、面试题:什么是JS基本包装类型?
JS中的String、Number、Boolean这三类基本类型都有自己的包装对象。包装对象也是对象,当进行字面量声明的时候,后台其实对它进行了对象声明,赋值结束后该对象会被消除,下一次对该字面变量进行操作的时候,又会new一个新的对象。
如果是采用对象进行变量声明的时候,再次对该变量进行操作就不会再new一个新的对象。
// 我们平常写程序的过程:
var str = 'hello'; //string 基本类型
var s2 = str.charAt(0); //在执行到这一句的时候 后台会自动完成以下动作 :
// 后台偷偷发生的
(
var _str = new String('hello'); // 1.创建String类型的一个实例
var s2 = _str.chaAt(0); // 2 在实例上调用指定的方法,并且返回结给s2
_str = null; // 3.销毁这个实例
)
// 使用字面变量声明:
var str = 'string';
str.pro = 'hello';
console.log(str.pro); // undefined
// 使用对象声明:
var str = new String('string');
str.pro = 'hello';
console.log(str.pro); // hello
参考资料
变量声明var、let、const
一、var
- var 存在变量提升的情况。
- 另外,函数声明语句function fn(){},也存在变量提升的情况。
console.log(i);
var i = 20;
// 可以看成
var i;
console.log(i); // undefined
i = 20;
var tmp = new Date();
function f() {
console.log(tmp);
if (false) {
var tmp = 'hello world';
}
}
f(); // undefined
-------------------------------------
// 存在变量提升
var tmp = new Date();
function f() { // 函数有自己的作用域
var tmp;
console.log(tmp);
if (false) {
tmp = 'hello world';
}
}
f(); // undefined
二、let
- let 不会变量提升。
- let 声明的变量,会绑定在它声明的作用域,只在它所在的代码块有效。
// 例1:
var tmp = 123;
if (true) {
tmp = 'abc'; // ReferenceError 暂时性死区
let tmp;
}
// 例2:
function f1() {
let n = 5;
if (true) {
let n = 10;
}
console.log(n); // 5
}
f1();
- 需先声明再使用。
- for 循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。
for (let i = 0; i < 3; i++) {
console.log(i); // 暂时性死区
let i = 'abc';
console.log(i);
}
// ()是一个作用域,{}也是一个作用域,因为如果()和{}是同一个作用域,声明“i”变量就会报错。{}中存在使用let声明变量,因此第一个console.log打印“i”时,这时候“i”还没声明就会报错。
- ES6 规定,块级作用域之中,函数声明语句的行为类似于let,在块级作用域之外不可引用。ES6中,函数声明的语句类似var的行为,存在变量提示,应该避免在块级作用域内声明函数,如果必须在块级作用域内声明,应写成函数表达式的形式,而不是函数声明的语句。
三、const
- const声明一个只读的常量。一旦声明,常量的值就不能改变。const声明的变量不得改变值,这意味着,const一旦声明变量,就必须立即初始化,不能留到以后赋值。
- 作用域与let相同。
- const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单数据类型,它的值存在栈中,即变量指向的那个内存地址,因此等于常量;对于对象,它的数据存储与堆中,栈中存储的是指向堆的指针,const只能保证栈中的指针不变,不能保证堆中的数据结构是否改变。如果真的想将对象冻结,应该使用Object.freeze方法。
四、变量声明
- ES6中存在6种声明变量的方式,var、function、let、const、import、class。
- ES6中,var命令和function命令声明的全局变量,依旧是顶层对象的属性;let命令、const命令、class命令声明的全局变量,不属于顶层对象的属性。也就是说,从 ES6 开始,全局变量将逐步与顶层对象的属性脱钩。
五、面试题:var、let、const的区别
var存在变量提升的情况,let、const不存在变量提升的情况。这导致var可以使用在声明前;而let和const必须先声明再使用。
let和const有自己的块级作用域,同一变量不能在同一块级作用域中声明两次;同一变量可以在不同作用域内声明,且不受其他块级作用域的影响。
const声明的时候必需赋值,不能先声明在赋值。
构造函数
一、面试题:new 操作符执行的步骤
- 1)创建一个新对象;
- 2)将新对象的[[原型]]指向函数的原型对象;
- 3)将构造函数的作用域赋给新对象(因此this就指向这个新对象);
- 4)执行构造函数中的代码(为这个新对象添加属性);
- 5)返回新对象。
步骤如下:
function Person(name,sex){
this.name = name;
this.sex = sex;
};
Person.prototype.go = function(){
return this.name;
}
var p = new Person('金文','男');
1)创建一个新对象
var obj = {}
2)新对象被执行[[原型]]链接(这点是你不知到的JS中描述的,其他为小红书描述内容)
obj.__proto__ = Person.prototype;
3)将构造函数的作用域赋给新对象(因此this就指向这个新对象)
Person.bind(obj); // 大概是这样,不确定实际情况是怎样
4)执行构造函数中的代码(为这个新对象添加属性)
obj.name = '金文';
obj.sex = '男';
obj.go = function(){return '金文';}
5)返回新对象
var obj = {
name:'金文',
sex:'男',
go:function(){
return '金文';
}
}
参考资料
this的指向问题
一、基本原理
- this是在运行时进行绑定的,不是在编写时绑定,它的上下文取决于函数调用时的各种条件,且只取决于函数的调用方式。
function foo(){
this.a = 2; // 此时是不知道this的指向的
}
var fn = foo; // 此时是不知道this的指向的
foo(); // 这时可确定this的指向,指向window
// 相当于 window.foo();
var obj = {
fn: {
foo: foo
}
}
// 对象属性引用链中只有最顶层或者说最后一层会影响调用位置。
obj.fn.foo(); // this指向fn。即 {foo: foo}
// 因为是obj的fn调用了foo这个方法,可以把 obj.fn 设为 x,即 x.foo()
- 隐式丢失,使用默认绑定
// 第一种情况
function foo(){
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
}
var bar = obj.foo; // 这里相当于将foo赋值给bar
var a = 'hello world';
bar(); // hello world。相当于执行了foo();
--------------------------------------
// 第二种情况
function foo(){
console.log(this.a);
}
// 函数的参数传递是一种隐式赋值, fn = obj.foo
function doFoo(fn){
fn(); // 这里相当于执行了 foo();
}
var obj = {
a: 2,
foo: foo
}
var a = "hello world";
doFoo(obj.foo); // hello world
- 把null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。更安全的做法是使用一个空对象来占位。
fn.call(Object.create(null), 2);
二、this指向判断顺序
this的判断顺序:new,显示绑定call、apply、bind,隐式绑定、默认绑定。
bind是硬绑定,会返回硬绑定的新函数,绑定后,方法中的this指向就不能再改变了,但是new可以修改该this的指向。
- 函数是否在new中调用(new)绑定?如果是的话this绑定的是新创建的对象。
var bar = new foo();
- 函数是否通过call、apply(显示绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。
var bar = foo.call(obj2);
- 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是哪个上下文对象。
var bar = obj1.foo();
- 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。
var bar = foo();
- 特殊:箭头函数 —— 箭头函数没有this,它的this继承上层作用域。类似self = this;且new 无法修改箭头函数的this
function foo(){
setTimeout(() => {
// 这里的this在此法上继承自foo()
console.log(this.a);
}, 100);
}
var obj = {
a: 2
}
foo.call(obj); // 2
三、面试题:谈谈对this的理解
this的指向,只有在函数调用的时候才能确定,需要依顺序考虑以下几点:
1)是否使用new进行调用
2)是否使用call、apply、bind进行显式调用
3)是否使用对象等进行隐式调用
4)是否使用默认绑定
5)是否是在箭头函数中
另外,使用new进行函数构造调用时,可以改变bind硬绑定的this指向,但无法改变箭头函数中的this指向。
Unicode与UTF-8、UTF-16、UTF-32
一、计算机单位
- 位(bit):计算机中最小的数据单位,每一位的状态只能是0或1。
- 字节(Byte):1B = 8 位
- 字(Word):8位计算机,1字 = 1B;16位计算机,1字 = 2B
- KB:1KB = 1024B
- MB:1MB = 1024KB
- GB:1GB = 1024MB
- TB:1TB = 1024GB
二、Unicode
1、Unicode源于一个很简单的想法:将全世界所有的字符包含在一个集合里,计算机只要支持这一个字符集,就能显示所有的字符,再也不会有乱码了。
2、Unicode只规定了每个字符的码点,未规定用什么样的字节序表示这个码点。
- 码点相当于一个符号,一个标记。未规定如何存储。
- Unicode是符号集合,而UTF-8,UTF-16和UTF-32是Unicode的实现方式。
三、UTF-32
1、固定用4个字节表示一个字符(1字节8位,共32位)
2、优点:规则简单,查询效率高
3、缺点:浪费空间;HTML 5标准就明文规定,网页不得编码成UTF-32。
四、UTF-8
1、UTF-8是一种变长的编码方法,字符长度从1个字节到4个字节不等。
2、越是常用的字符,字节越短,最前面的128个字符,只使用1个字节表示,与ASCII码完全相同。
五、UTF-16
基本平面的字符占用2个字节,辅助平面的字符占用4个字节。也就是说,UTF-16的编码长度要么是2个字节(U+0000到U+FFFF),要么是4个字节(U+010000到U+10FFFF)。
六、关于JavaScript
1、JavaScript只能处理UCS-2编码,造成所有字符在这门语言中都是2个字节,如果是4个字节的字符,会当作两个双字节的字符处理。类似的问题存在于所有的JavaScript字符操作函数。
2、ES6可以自动识别4字节的码点。
参考资料
0.1 + 0.2 !== 0.3
一、一些注意点
1、js最大安全数是Math.pow(2,53) - 1
虽然js中存储数字的位数只有52位。但是科学计数法,表示为1.xxx…因此会有一位默认的隐藏位,即js数字存储位有53位(52+1)。因此它能表示的最大数字是111…(共53个1,二进制),转为十进制1x20 + 1x21 … + 1x252,再根据等比数列的求和公式可得js最大安全数是Math.pow(2,53) - 1。
2、那为什么 x=0.1 能得到 0.1?
实际上x并不等于0.1, 通过:
0.1.toPrecision(21) // 0.100000000000000005551
我们可以发现x其实还有很多的尾数未完全显示,因为Math.pow(2,53) - 1等于9007199254740991,最大位是16位,因此x其实是做了舍去操作,可以看成:
0.1.toPrecision(16) // 0.10000000000000000
二、面试题:为什么0.1 + 0.2 !== 0.3
JS的数字采用IEEE754 64位的标准进行存储,其中0-51位存储数字(分数)部分(共52位),52-62位(共11位)存储指数部分,63位存储符号位。
运算步骤如下:
1)十进制转为二进制
2)采用科学计数法表示二进制
3)将指数转为二进制
4)对阶运算
5)将运算结果转为十进制
在步骤1)中,二进制可能超出52位,超出的部分要进行“0舍1入”的操作;步骤4)中,也可能存在移位和舍入的操作。这些过程都会使原来的数产生误差,因此最后的计算结果也会与原来的不同。
参考资料
0.1 + 0.2不等于0.3?为什么JavaScript有这种“骚”操作?
为什么0.1+0.2不等于0.3?原来编程语言是这么算的……
0.1 + 0.2为什么不等于0.3?
VUE2
一、观察者模式与发布订阅模式
对象:Subject: 被观察者;Observers: 观察者
观察者模式存在被观察者和观察者两个角色,观察者是各种单一的功能接口,当被观察者发现事件变化时,它会调用对应功能的接口实现数据更新。这种设计模式是松耦合
二、面试题:VUE双向绑定的原理
vue的双向绑定原理基于发布订阅模式+Object.defineProperty实现。
1)发布者会遍历data数据对象并通过Object.defineProperty中的get和set方法对数据进行处理。
2)解析模块会对页面模板进行解析并初始化,解析成功的数据会根据不同情况实例化观察者。
3)观察者会触发get方法将数据统一注册到一个数据队列中(经理人)
4)当数据发生变化时,会触发set方法,数组队列会找到对应的观察者,通知它更新数据,视图就会发生变化。