备战前端面试—esnext篇
ECMA 262 标准
模块化
考点1:模块化规范
ES6 模块化如何使用?开发环境如何打包?
模块化语法:
export
能按需导入,export default
不行。export
可以有多个,export default
仅有一个。export
能直接导出变量表达式,export default
不行。export
方式导出,在导入时要加{}
,export default
则不需要。
// 导出单个特性 export let name1, name2, …, nameN; // also var, const export let name1 = …, name2 = …, …, nameN; // also var, const export function FunctionName(){...} export class ClassName {...} // 导出列表 export { name1, name2, …, nameN }; // 重命名导出 export { variable1 as name1, variable2 as name2, …, nameN }; // 解构导出并重命名 export const { name1, name2: bar } = o; // 默认导出 export default expression; export default function (…) { … } // also class, function* export default function name1(…) { … } // also class, function* export { name1 as default, … }; // 导出模块合集 export * from …; // does not set the default export export * as name1 from …; // Draft ECMAScript® 2O21 export { name1, name2, …, nameN } from …; export { import1 as name1, import2 as name2, …, nameN } from …; export { default } from …;
编译:
目前大部分浏览器暂不支持 es6 新语法,因此需要使用 js 编译器将 es6 及以上的代码转化为 es5 代码。想要在开发环境中使用 es6 新语法,需要借助一些工具链,比如 babel。如果使用了 es6 的模块化语法,babel 会将其转化为 CommonJS 规范。
打包:
将散乱的模块打包在一起,并处理模块间的依赖关系。可以使用模块打包器完成打包,比如 webpack、rollup。
ES6 模块化导出和 CommonJS 导出的区别?
CommonJS 模块输出的是一个值的拷贝
// lib.js var counter = 3; function incCounter() { counter++; } module.exports = { counter: counter, incCounter: incCounter, }; // main.js var mod = require('./lib'); console.log(mod.counter); // 3 mod.incCounter(); console.log(mod.counter); // 3
ES6 模块输出的是值的引用
// lib.js export let counter = 3; export function incCounter() { counter++; } // main.js import { counter, incCounter } from './lib'; console.log(counter); // 3 incCounter(); console.log(counter); // 4
CommonJS 模块是运行时加载,而且只会加载一次,之后从缓存读取。
ES6 模块是编译时输出接口,对外接口只是一种静态定义。
CommonJS 是单个值导出,ES6 Module 可以导出多个。
CommonJS 是动态语法可以写在判断里,ES6 Module 静态语法只能写在顶层。
CommonJS 的 this 是当前模块,ES6 Module 的 this 是 undefined。
前端模块化的历史
在
ES6 Module
以前,JavaScript
中是没有模块化的概念的。基于实际开发的需要,众多先驱在模块化实现的探索过程中历经了艰辛。经历了函数封装、对象封装、立即执行函数的失败后,由社区统一的前端模块化规范出现了。随着CommonJS
的兴起,属于前端的模块化时代来临了。CommonJS
规范是NodeJS
的模块化标准。由于CommonJS
的同步加载机制,在服务端能正常工作,而在浏览器加载会带来非常大的网络I/0
开销,而且天然异步会产生时序上的错误。在这一问题之上,
CommonJS
社区产生了重大分歧。异步加载模块规范AMD
诞生了。RequireJS 给出了具体实现。RequireJS采用提前加载的方式,破坏了就近声明的原则,并引入了新全局函数define
,与CommonJS
渐行渐远。在众多开发者的呼声下, RequireJS 最终妥协,在语法上兼容CommonJS
,但并没有解决延迟加载的问题。国内阿里巴巴集团的前端大佬玉伯在向RequireJS提出建议无果后,选择自己开发模块加载器。这就是 SeaJS。玉伯在此基础上提出了
CMD
规范,是CommonJS
和AMD
的结合。CMD
保留了CommonJS
最核心的延迟加载、就近声明特性。AMD
和CMD
的区别?- 模块定义时对依赖的处理不同。
AMD
要求依赖前置,在定义模块时就要声明所有使用到的模块,CMD
保留了依赖就近声明的特性,在使用到某个模块时再require
。 - 对依赖模块的执行时机处理不同。
AMD
延迟执行,CMD
同步执行。AMD
加载一个模块执行一个模块,加载完所有模块后才进入require
的回调函数处理主逻辑。CMD
加载完模块后不执行,在加载完所有模块后进入主逻辑,遇到require
执行相应模块。
- 模块定义时对依赖的处理不同。
Class
考点1:Class 的本质
Class 和 普通构造函数的区别?
Class 是一个语法糖,其本质仍是函数,只不过在形式上改变为更贴近面向对象的写法。
class A {} typeof A // "function" A === A.prototype.constructor // true
通过 Class 创建的函数具有特殊的内部属性标记
[[IsClassConstructor]]: true
。因此,它与手动创建并不完全相同。Class 的构造函数必须使用 new 来调用,而普通构造函数可以当做普通的函数调用。Class 总是使用
use strict
。 在类构造中的所有代码都将自动进入严格模式。在继承上,Class 必须先调用 super 获取 this。允许继承原生构造函数定义子类。
普通构造函数的子类会先创建一个空对象赋值给 this,再以 call、apply(this) 调用父类的方法 。原生构造函数不能被继承(原生构造函数会忽略 call、apply 方法传入的 this)。
let、const、var
考点1:变量提升
什么是变量提升?
变量提升是在执行上下文入栈后,代码实际执行之前的预编译过程,把当前环境内的所有变量标识符进行提前声明(部分变量是定义),这种预先处理的机制是变量提升。
变量提升阶段,function 直接定义,var、let、const、class 等只声明(其中 var 绑定初始值 undefined)。
变量提升会遮蔽外部同名变量。
考点2:暂时死区
什么是暂时死区?
对于 let、const、class 声明的变量,在声明之前不能被访问,从预编译完毕到声明语句执行的这一段时间称为暂时死区(TDZ)。
实质是由于 ECMA262 中的一项规定:在初始化之前不允许以任何方式访问变量。所有引擎都实现了这一规范,在声明前使用会抛出一个未捕获引用错误:
Uncaught ReferenceError: Cannot access before initialization
考点3:只读
const 声明的变量可以重新赋值吗?
不可以。使用 const 声明的常量,其值是只读的,不能以任何方式修改。
如果其值是引用类型,值所指向的地址不可变,而地址所指向的值是可变的。
考点4:解构赋值
解构赋值有哪几种方式?
数组解构,模式匹配。等号右边必须是可迭代对象。
let [,b,...c] = [1,2,3,4,5] b//2 c//3,4,5 let [d,e] = [6,7,8] d//6 e//7
对象解构,同名属性赋值。
let { bar, foo } = { foo: 'aaa', bar: 'bbb' }; foo // "aaa" bar // "bbb"
实际形式:
找同名属性,赋值给对应变量。
let { width: w, height: h = 200, title } = { title: "Menu", width: 100}; console.log(w);//100 console.log(h);//200 console.log(title);//title
函数参数解构。
let options = { title: "My menu", items: ["Item1", "Item2"] }; function showMenu({ title = "Untitled", width: w = 100, // width goes to w height: h = 200, // height goes to h items: [item1, item2] // items first element goes to item1, second to item2 }) { alert( `${title} ${w} ${h}` ); // My Menu 100 200 alert( item1 ); // Item1 alert( item2 ); // Item2 } showMenu(options);
实际形式:
function({ incomingProperty: varName = defaultValue ... })//没有赋值到的变量为默认值/undefined,所以支持不完全匹配
模块加载。
const { SourceMapConsumer, SourceNode } = require("source-map");
异步
考点1:setTimeOut
经典老题
for (var i = 1; i <= 5; i++) { setTimeout( function timer() { console.log(i); }, i * 1000 ); } //要求改动上述代码,使其依次输出1、2、3、4、5(方法越多越好)
考察的主要是事件循环和作用域的知识。
因为setTimeout为宏任务,由于JS中单线程eventLoop机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后setTimeout中的回调才依次执行,但输出i的时候当前作用域没有,往上一级再找,发现了i,此时循环已经结束,i变成了6。因此会全部输出6。
5个定时器所打印出来的是同一个 i 变量,所以想要实现输出不同的数字,就需要把每个定时器所访问的变量独立起来,这就用到了JavaScript的闭包。
for (var i = 1; i <= 5; i++) { (function(i){ //利用IIFE(立即执行函数表达式)当每次for循环时,把此时的i变量传递到定时器中 setTimeout( function timer() { console.log(i); }, i * 1000 ); })(i); }
for (var i = 1; i <= 5; i++) { (function(){ var s = i;//把i赋值给另外一个变量 setTimeout( function timer() { console.log(s); }, s * 1000 ); })(); }
for (var i = 1; i <= 5; i++) { setTimeout( function timer(j) {//给定时器传入第三个参数, 作为timer函数的第一个函数参数 console.log(j); }, i * 1000, i ); }
for (let i = 1; i <= 5; i++) { // let 具有独立作用域 setTimeout( function timer() { console.log(i); }, i * 1000 ); }
考点2:Promise
什么是回调地狱?Promise 怎么消除回调地狱?
回调地狱,就是回调函数的复杂嵌套。每种任务都包含两种可能的结果(成功或失败),那么在每次回调结束后都必须处理这两种情况。
例子:
fs.readdir(source, function (err, files) { if (err) { console.log('Error finding files: ' + err) } else { files.forEach(function (filename, fileIndex) { console.log(filename) gm(source + filename).size(function (err, values) { if (err) { console.log('Error identifying file size: ' + err) } else { console.log(filename + ' : ' + values) aspect = (values.width / values.height) widths.forEach(function (width, widthIndex) { height = Math.round(width / aspect) console.log('resizing ' + filename + 'to ' + height + 'x' + height) this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) { if (err) console.log('Error writing file: ' + err) }) }.bind(this)) } }) }) } })
Promise 利用了三大技术手段来解决
回调地狱
:- 回调函数延迟绑定。
- 返回值穿透。
- 错误冒泡。
let readFilePromise = (filename) => { fs.readFile(filename, (err, data) => { if(err) { reject(err); }else { resolve(data); } }) } readFilePromise('1.json').then(data => { return readFilePromise('2.json') });
回调函数不是直接声明的,而是在通过后面的 then 方法传入的,即延迟传入。这就是
回调函数延迟绑定
。let x = readFilePromise('1.json').then(data => { return readFilePromise('2.json')//这是返回的Promise }); x.then(/* 内部逻辑省略 */)
根据 then 中传入的回调函数创建不同的 Promise,然后把返回的 Promise 穿透到外层,以供后续的调用。这里的 x 指的就是内部返回的 Promise,然后在 x 后面可以依次完成链式调用。
这便是
返回值穿透
的效果。两种技术一起使用,形成了链式调用的效果:
readFilePromise('1.json').then(data => { return readFilePromise('2.json'); }).then(data => { return readFilePromise('3.json'); }).then(data => { return readFilePromise('4.json'); });
到这里解决的是嵌套的问题。
另一个问题是如何解决每次任务执行结束后
分别处理成功和失败
的情况。Promise 采用了
错误冒泡
的方式:readFilePromise('1.json').then(data => { return readFilePromise('2.json'); }).then(data => { return readFilePromise('3.json'); }).then(data => { return readFilePromise('4.json'); }).catch(err => { // xxx })
这样前面产生的错误会一直向后传递,直到被第一个 catch 接收到,就不用频繁地检查错误了。
综上,Promise 通过回调函数延迟绑定和返回值穿透实现了链式调用,并利用错误冒泡机制,解决每次任务中判断错误、代码混乱的问题。
为什么Promise要引入微任务?
Promise 的构造函数是同步执行的,而里面也可以存在异步操作,在异步操作执行完后,调用 resolve 回调,或者中途遇到错误调用 reject 回调,这两者都是作为微任务进入到 EventLoop 中。
问题本身,其实就是如何处理回调的问题。总结起来有三种方式:
- 使用同步回调,等待
异步任务
执行完后,再进行后面的任务。 - 使用异步回调,将回调函数执行放在
宏任务队列
的队尾。 - 使用异步回调,将回调函数执行放在
当前宏任务
的末尾。
第一种方式显然是不可取的,问题在于会使脚本阻塞,当前任务等待,后面的所有任务都无法执行,而这部分等待的时间是可以用来做其他事情的,导致 CPU 利用率非常低。而且同步的回调函数不能实现延迟绑定的效果。
如果采用第二种方式,那么执行回调(
resolve/reject
)的时机应该是在前面所有的宏任务
完成之后,倘若现在的任务队列非常长,那么回调迟迟得不到执行,造成应用卡顿
。为了解决上述方案的问题,另外也考虑到
延迟绑定
的需求,Promise 采取第三种方式, 即引入微任务
, 即把 resolve(reject) 回调的执行放在当前宏任务的末尾。这样,利用
微任务
解决了两大痛点:- 采用异步回调替代同步回调解决了浪费 CPU 性能的问题。
- 放到当前宏任务最后执行,解决了回调执行的实时性问题。
- 使用同步回调,等待
如何实现 Promise.resolve?
返回一个以给定值解析后的 Promise。
Promise.resolve()
方法实际上调用的是new Promise()
并调用resolve方法。实现 resolve 静态方法有三个要点:
- 传参为一个 Promise, 则直接返回它。
- 传参为一个 thenable 对象,返回的 Promise 会跟随这个对象,
采用它的最终状态
作为自己的状态
。 - 其他情况,直接返回以该值为成功状态的promise对象。
Promise.resolve = function (value) { if (value instanceof Promise) return value; if (value === null) return new Promise((resolve, reject) => resolve(null)); if (typeof value === "object" || typeof value === "function") { try { let then = value.then; if (typeof then === "function") { return new Promise(then.bind(value)); } } catch (e) { return new Promise((resolve, reject) => reject(e)); } } return new Promise((resolve, reject) => resolve(value)); };
如何实现 Promise.reject?
Promise.reject 中传入的参数会作为一个 reason 原封不动地往下传, 实现如下:
Promise.reject = function (reason) { return new Promise((resolve, reject) => { reject(reason); }); }
如何实现 Promise.finally?
Promise.finally 返回一个 Promise,无论其状态是 fulfilled 还是 rejected,都会执行指定的回调函数。并且将值原封不动的往下传。
Promise.prototype.finally = function (f) { return this.then(function (value) { return Promise.resolve(f()).then(function () { return value; }); }, function (err) { return Promise.resolve(f()).then(function () { throw err; }); }); };
如何实现 Promise.all?
对于 all 方法,需要完成以下几点功能:
- 如果传入一个空的可迭代对象,那么将其直接 resolve。
- 如果参数中的 Promise 有一个失败,那么 all 返回的 Promise 失败。
- 在任何情况下,all 都返回一个 Promise,其完成状态的结果是一个数组。
Promise.all = (promises) => { return new Promise((resolve, reject) => { if ( !promises || typeof promises !== "object" || typeof promises[Symbol.iterator] !== "function" ) { reject(TypeError()); } let args = [...promises]; if (args.length === 0) return resolve([]); let len = args.length; let result = []; let promiseCount = 0; for (let i = 0; i < len; i++) { Promise.resolve(args[i]).then( (value) => { promiseCount++; result[i] = value; if (promiseCount === len) { resolve(result); } }, (error) => { reject(error); } ); } }); };
如何实现 Promise.race?
与 all 方法类似,返回一个 Promise,其状态与结果与其中最先改变状态的 Promise 相同。
Promise.race = (promises) => { return new Promise((resolve, reject) => { if ( !promises || typeof promises !== "object" || typeof promises[Symbol.iterator] !== "function" ) { throw TypeError(); } let args = [...promises]; if (args.length === 0) { return; } let len = args.length; for (let i = 0; i < len; i++) { return Promise.resolve(args[i]).then( (value) => resolve(value), (error) => reject(error) ); } }); };
Promise.allSettled 和 Promise.all 有什么区别?
all 方法返回一个 Promise 对象。如果 all 方法的参数中每一个 Promise 都 fulfilled,才执行 resolve 回调,参数是这些 Promise 的回调结果组成的数组。如果其中任意一个 Promise 被 rejected,就执行 reject 回调,参数是第一个被 rejected 的 Promise 的回调结果。
allSettled 方法等待参数中所有 Promise 回调执行完后,返回一个对象数组,每个对象表示对应的 Promise 结果。
考点3:async/await
解释一下async/await的运行机制
async 是一个通过异步执行并隐式返回一个 Promise 的函数:
async function func() { return 100; } console.log(func()); // Promise {<resolved>: 100}
async的实现就是将 Generator 函数和自动执行器,包装在一个函数里:
async function func(args) { // ... } // 等同于 function func(args) { return spawn(function* () { // ... }); }
spawn 函数就是自动执行器:
function spawn(genF){ return new Promise((resolve, reject)=>{ const gen = genF() // 先将Generator函数执行下,拿到遍历器对象 function step(nextF) { let next try { next = nextF() } catch(e){ return reject(e) } if(next.done){ return resolve(next.value) } Promise.resolve(next.value).then((v)=>{ step(()=>{return gen.next(v)}) }, (e)=>{ step(()=>{return gen.throw(e)}) }) } step(()=> {return gen.next(undefined)}) }) }
await 被JS 引擎转换成一个 Promise:
await 100; let promise = new Promise((resolve,reject) => { resolve(100); })
实际运行时:
async function test() { console.log(100) let x = await 200 console.log(x) console.log(200) } console.log(0) test() console.log(300) //0 //100 //300 //200 //200
- 首先代码同步运行,打印
0
。 - 进入 test 函数执行上下文,执行同步代码,打印
100
。 - 遇到 await,将其加入微任务队列,JS 引擎将暂停当前协程的运行,把线程的执行权交给
父协程
。 - 回到父协程,先对
await
返回的Promise
调用then
,来监听这个 Promise 的状态改变 ,然后往下执行,打印出300
。 - 根据
EventLoop
机制,当前主线程的宏任务完成,检查微任务队列
,执行微任务,then
中传入的回调函数执行。 - 线程的执行权交给
test
协程,并把value
值传递给test
协程。 - 执行权到了
test协程
手上,test
接收到传来的200
,赋值给 x ,然后依次执行后面的语句,打印200
、200
。
- 首先代码同步运行,打印
forEach 中用 await 会产生什么问题?怎么解决这个问题?
问题:对于异步代码,forEach 并不能保证按顺序执行。(forEach 底层的实现是调用 call 立刻执行)
解决:利用
for...of
就能轻松解决。async function test() { let arr = [4, 2, 1] for(const item of arr) { const res = await handle(item) console.log(res) } console.log('结束') }
解决原理——
Iterator
:其实,
for...of
并不像forEach
那么简单粗暴遍历执行,而是采用一种特别的手段——迭代器
。原生具有
[Symbol.iterator]
属性数据类型为可迭代数据类型。如数组、类数组(如 arguments、NodeList)、Set 和 Map。let arr = [4, 2, 1]; // 这就是迭代器 let iterator = arr[Symbol.iterator](); console.log(iterator.next()); console.log(iterator.next()); console.log(iterator.next()); console.log(iterator.next()); // {value: 4, done: false} // {value: 2, done: false} // {value: 1, done: false} // {value: undefined, done: true}
async/await 的底层实现是生成器,并且生成器本身就是一个迭代器。
生成器和协程
考点1:Generator
什么是 Generator?它是怎么执行的?
Generator 生成器是一个带
*
号的“函数”(并不是真正的函数),可以通过 yield 暂停执行和恢复执行。function* gen() { console.log("enter"); let a = yield 1; let b = yield (function () {return 2})(); return 3; } var g = gen() // 阻塞住,不会执行任何语句 console.log(typeof g) // object 不是"function" console.log(g.next()) console.log(g.next()) console.log(g.next()) console.log(g.next()) // enter // { value: 1, done: false } // { value: 2, done: false } // { value: 3, done: true } // { value: undefined, done: true }
调用
Generator
后,程序会阻塞住,不执行任何语句,调用next()
后,程序继续执行,直到遇到 yield 程序暂停。next 方法返回一个对象,有两个属性: value 和 done。value 为当前 yield 后面的结果,done 表示是否执行完,遇到了 return 后,done 会由 false 变为 true。如何让 Generator 的异步代码按顺序执行完毕?
借助
thunk 函数
。thunk 函数本质是一种偏函数,可以从形式上将函数的执行部分和回调部分分开。比如:fn(a, callback) => thunkify(fn)(a)(callback)
那么,我们可以让 thunk 函数处理异步:
const readFileThunk = (filename) => { return (callback) => { fs.readFile(filename, callback); } }
异步操作核心的一环就是绑定回调函数,而
thunk函数
可以帮我们做到。首先传入文件名,然后生成一个针对某个文件的定制化函数。这个函数中传入回调,这个回调就会成为异步操作的回调。这样就让Generator
和异步
关联起来了。采用
co 库结合 Promise
。使用起来非常简单:
const co = require('co'); let g = gen(); co(g).then(res =>{ console.log(res); })
考点2:协程
什么是协程?
协程是比线程更轻量的存在。在一个线程中可以有多个协程。协程运行在线程之上,当一个协程执行完之后,可以主动让出,让另一个协程运行在当前线程之上。协程没有增加线程的数量,只是在线程的基础之上利用分时复用。而且协程的切换的代价比线程小很多。不像进程和线程,协程并不受操作系统的管理,而是被具体的应用程序代码所控制。
协程的运作机制?
一个线程一次只能执行一个协程。想要切换任务,就必须要将当前的控制权转交给另外一个协程。之后,该协程将暂停。
function* A() { console.log("我是A"); yield B(); // A停住,在这里转交线程执行权给B console.log("结束了"); } function B() { console.log("我是B"); return 100;// 返回,并且将线程执行权还给A } let gen = A(); gen.next(); gen.next(); // 我是A // 我是B // 结束了
在这个过程中,A 将执行权交给 B,也就是
A 启动 B
,我们也称 A 是 B 的父协程。因此 B 当中最后return 100
其实是将 100 传给了父协程。需要强调的是,对于协程来说,它并不受操作系统的控制,完全由用户自定义切换,因此并没有进程/线程
上下文切换
的开销,这是高性能
的重要原因。
- Post link: https://blog.sticla.top/2021/08/15/front-end-interview-review-es/
- Copyright Notice: All articles in this blog are licensed under unless otherwise stated.
GitHub Issues