备战前端面试—esnext篇

ECMA 262 标准

模块化

考点1:模块化规范

  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。

  2. 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。

  3. 前端模块化的历史

    在 ES6 Module 以前,JavaScript 中是没有模块化的概念的。基于实际开发的需要,众多先驱在模块化实现的探索过程中历经了艰辛。经历了函数封装、对象封装、立即执行函数的失败后,由社区统一的前端模块化规范出现了。随着CommonJS 的兴起,属于前端的模块化时代来临了。

    CommonJS 规范是 NodeJS 的模块化标准。由于 CommonJS 的同步加载机制,在服务端能正常工作,而在浏览器加载会带来非常大的网络 I/0 开销,而且天然异步会产生时序上的错误。

    在这一问题之上,CommonJS 社区产生了重大分歧。异步加载模块规范 AMD 诞生了。RequireJS 给出了具体实现。RequireJS采用提前加载的方式,破坏了就近声明的原则,并引入了新全局函数 define,与CommonJS 渐行渐远。在众多开发者的呼声下, RequireJS 最终妥协,在语法上兼容 CommonJS,但并没有解决延迟加载的问题。

    国内阿里巴巴集团的前端大佬玉伯在向RequireJS提出建议无果后,选择自己开发模块加载器。这就是 SeaJS。玉伯在此基础上提出了 CMD 规范,是 CommonJS和 AMD的结合。CMD保留了 CommonJS最核心的延迟加载、就近声明特性。

  4. AMD 和 CMD 的区别?

    • 模块定义时对依赖的处理不同。AMD 要求依赖前置,在定义模块时就要声明所有使用到的模块,CMD保留了依赖就近声明的特性,在使用到某个模块时再 require。
    • 对依赖模块的执行时机处理不同。AMD延迟执行,CMD同步执行。AMD加载一个模块执行一个模块,加载完所有模块后才进入 require 的回调函数处理主逻辑。CMD加载完模块后不执行,在加载完所有模块后进入主逻辑,遇到 require 执行相应模块。

Class

考点1:Class 的本质

  1. 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:变量提升

  1. 什么是变量提升?

    变量提升是在执行上下文入栈后,代码实际执行之前的预编译过程,把当前环境内的所有变量标识符进行提前声明(部分变量是定义),这种预先处理的机制是变量提升。

    变量提升阶段,function 直接定义,var、let、const、class 等只声明(其中 var 绑定初始值 undefined)。

    变量提升会遮蔽外部同名变量。

考点2:暂时死区

  1. 什么是暂时死区?

    对于 let、const、class 声明的变量,在声明之前不能被访问,从预编译完毕到声明语句执行的这一段时间称为暂时死区(TDZ)。

    实质是由于 ECMA262 中的一项规定:在初始化之前不允许以任何方式访问变量。所有引擎都实现了这一规范,在声明前使用会抛出一个未捕获引用错误:Uncaught ReferenceError: Cannot access before initialization

考点3:只读

  1. const 声明的变量可以重新赋值吗?

    不可以。使用 const 声明的常量,其值是只读的,不能以任何方式修改。

    如果其值是引用类型,值所指向的地址不可变,而地址所指向的值是可变的。

考点4:解构赋值

  1. 解构赋值有哪几种方式?

    1. 数组解构,模式匹配。等号右边必须是可迭代对象。

      let [,b,...c] = [1,2,3,4,5]
      b//2
      c//3,4,5
      let [d,e] = [6,7,8]
      d//6
      e//7
    2. 对象解构,同名属性赋值。

      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
    3. 函数参数解构。

      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,所以支持不完全匹配
    4. 模块加载。

      const { SourceMapConsumer, SourceNode } = require("source-map");

异步

考点1:setTimeOut

  1. 经典老题

    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

  1. 什么是回调地狱?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 通过回调函数延迟绑定和返回值穿透实现了链式调用,并利用错误冒泡机制,解决每次任务中判断错误、代码混乱的问题。

  2. 为什么Promise要引入微任务?

    Promise 的构造函数是同步执行的,而里面也可以存在异步操作,在异步操作执行完后,调用 resolve 回调,或者中途遇到错误调用 reject 回调,这两者都是作为微任务进入到 EventLoop 中。

    问题本身,其实就是如何处理回调的问题。总结起来有三种方式:

    1. 使用同步回调,等待异步任务执行完后,再进行后面的任务。
    2. 使用异步回调,将回调函数执行放在宏任务队列的队尾。
    3. 使用异步回调,将回调函数执行放在当前宏任务的末尾。

    第一种方式显然是不可取的,问题在于会使脚本阻塞,当前任务等待,后面的所有任务都无法执行,而这部分等待的时间是可以用来做其他事情的,导致 CPU 利用率非常低。而且同步的回调函数不能实现延迟绑定的效果。

    如果采用第二种方式,那么执行回调(resolve/reject)的时机应该是在前面所有的宏任务完成之后,倘若现在的任务队列非常长,那么回调迟迟得不到执行,造成应用卡顿。

    为了解决上述方案的问题,另外也考虑到延迟绑定的需求,Promise 采取第三种方式, 即引入微任务, 即把 resolve(reject) 回调的执行放在当前宏任务的末尾。

    这样,利用微任务解决了两大痛点:

    • 采用异步回调替代同步回调解决了浪费 CPU 性能的问题。
    • 放到当前宏任务最后执行,解决了回调执行的实时性问题。
  3. 如何实现 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));
    };
  4. 如何实现 Promise.reject?

    Promise.reject 中传入的参数会作为一个 reason 原封不动地往下传, 实现如下:

    Promise.reject = function (reason) {
        return new Promise((resolve, reject) => {
            reject(reason);
        });
    }
  5. 如何实现 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;
        });
      });
    };
  6. 如何实现 Promise.all?

    对于 all 方法,需要完成以下几点功能:

    1. 如果传入一个空的可迭代对象,那么将其直接 resolve。
    2. 如果参数中的 Promise 有一个失败,那么 all 返回的 Promise 失败。
    3. 在任何情况下,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);
            }
          );
        }
      });
    };
  7. 如何实现 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)
          );
        }
      });
    };
  8. Promise.allSettled 和 Promise.all 有什么区别?

    all 方法返回一个 Promise 对象。如果 all 方法的参数中每一个 Promise 都 fulfilled,才执行 resolve 回调,参数是这些 Promise 的回调结果组成的数组。如果其中任意一个 Promise 被 rejected,就执行 reject 回调,参数是第一个被 rejected 的 Promise 的回调结果。

    allSettled 方法等待参数中所有 Promise 回调执行完后,返回一个对象数组,每个对象表示对应的 Promise 结果。

考点3:async/await

  1. 解释一下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
    1. 首先代码同步运行,打印 0。
    2. 进入 test 函数执行上下文,执行同步代码,打印 100。
    3. 遇到 await,将其加入微任务队列,JS 引擎将暂停当前协程的运行,把线程的执行权交给父协程。
    4. 回到父协程,先对await返回的Promise调用then,来监听这个 Promise 的状态改变 ,然后往下执行,打印出300。
    5. 根据EventLoop机制,当前主线程的宏任务完成,检查微任务队列,执行微任务,then 中传入的回调函数执行。
    6. 线程的执行权交给 test 协程,并把 value 值传递给 test 协程。
    7. 执行权到了test协程手上,test 接收到传来的 200,赋值给 x ,然后依次执行后面的语句,打印200、200。
  2. 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

  1. 什么是 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。

  2. 如何让 Generator 的异步代码按顺序执行完毕?

  3. 借助 thunk 函数。thunk 函数本质是一种偏函数,可以从形式上将函数的执行部分和回调部分分开。比如:

    fn(a, callback) => thunkify(fn)(a)(callback)

    那么,我们可以让 thunk 函数处理异步:

    const readFileThunk = (filename) => {
      return (callback) => {
        fs.readFile(filename, callback);
      }
    }

    异步操作核心的一环就是绑定回调函数,而thunk函数可以帮我们做到。首先传入文件名,然后生成一个针对某个文件的定制化函数。这个函数中传入回调,这个回调就会成为异步操作的回调。这样就让 Generator 和异步关联起来了。

  4. 采用 co 库结合 Promise。

    使用起来非常简单:

    const co = require('co');
    let g = gen();
    co(g).then(res =>{
      console.log(res);
    })

考点2:协程

  1. 什么是协程?

    协程是比线程更轻量的存在。在一个线程中可以有多个协程。协程运行在线程之上,当一个协程执行完之后,可以主动让出,让另一个协程运行在当前线程之上。协程没有增加线程的数量,只是在线程的基础之上利用分时复用。而且协程的切换的代价比线程小很多。不像进程和线程,协程并不受操作系统的管理,而是被具体的应用程序代码所控制。

  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 传给了父协程。

    需要强调的是,对于协程来说,它并不受操作系统的控制,完全由用户自定义切换,因此并没有进程/线程上下文切换的开销,这是高性能的重要原因。