前端面试知识点

Posted 仙凌阁

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了前端面试知识点相关的知识,希望对你有一定的参考价值。

1、简单描述一下 Babel 的编译过程?
Babel 是一个源到源的转换编译器(Transpiler),它的主要作用是将 javascript 的高版本语法(例如 ES6)转换成低版本语法(例如 ES5),从而可以适配浏览器的兼容性。

温馨提示:如果某种高级语言或者应用语言(例如用于人工智能的计算机设计语言)转换的目标语言不是特定计算机的汇编语言,而是面向另一种高级程序语言(很多研究性的编译器将 C 作为目标语言),那么还需要将目标高级程序语言再进行一次额外的编译才能得到最终的目标程序,这种编译器可称为源到源的转换器。

从上图可知,Babel 的编译过程主要可以分为三个阶段:

解析(Parse):包括词法分析和语法分析。词法分析主要把字符流源代码(Char Stream)转换成令牌流( Token Stream),语法分析主要是将令牌流转换成抽象语法树(Abstract Syntax Tree,AST)。
转换(Transform):通过 Babel 的插件能力,将高版本语法的 AST 转换成支持低版本语法的 AST。当然在此过程中也可以对 AST 的 Node 节点进行优化操作,比如添加、更新以及移除节点等。
生成(Generate):将 AST 转换成字符串形式的低版本代码,同时也能创建 Source Map 映射。
具体的流程如下所示:

举个栗子,如果要将 TypeScript 语法转换成 ES5 语法:

// 源代码
let a: string = 1;
// 目标代码
var a = 1;
复制代码

1.1 解析(Parser)
Babel 的解析过程(源码到 AST 的转换)可以使用 @babel/parser,它的主要特点如下:

支持解析最新的 ES2020
支持解析 JSX、Flow & TypeScript
支持解析实验性的语法提案(支持任何 Stage 0 的 PRS)
@babel/parser 主要是基于输入的字符串流(源代码)进行解析,最后转换成规范(基于 ESTree 进行调整)的 AST,如下所示:

import { parse } from '@babel/parser';
const source = `let a: string = 1;`;

enum ParseSourceTypeEnum {
  Module = 'module',
  Script = 'script',
  Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
  Flow = 'flow',
  FlowComments = 'flowComments',
  TypeScript = 'typescript',
  Jsx = 'jsx',
  V8intrinsic = 'v8intrinsic',
}

// 解析(Parser)阶段
const ast = parse(source, {
  // 严格模式下解析并且允许模块定义
  sourceType: ParseSourceTypeEnum.Module,
  // 支持解析 TypeScript 语法(注意,这里只是支持解析,并不是转换 TypeScript)
  plugins: [ParsePluginEnum.TypeScript],
});
复制代码

需要注意,在 Parser 阶段主要是进行词法和语法分析,如果词法或者语法分析错误,那么会在该阶段被检测出来。如果检测正确,则可以进入语法的转换阶段。

1.2 转换(Transform)
Babel 的转换过程(AST 到 AST 的转换)主要使用 @babel/traverse,该库包可以通过访问者模式自动遍历并访问 AST 树的每一个 Node 节点信息,从而实现节点的替换、移除和添加操作,如下所示:

import { parse } from '@babel/parser';
import traverse from '@babel/traverse';

enum ParseSourceTypeEnum {
  Module = 'module',
  Script = 'script',
  Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
  Flow = 'flow',
  FlowComments = 'flowComments',
  TypeScript = 'typescript',
  Jsx = 'jsx',
  V8intrinsic = 'v8intrinsic',
}

const source = `let a: string = 1;`;

// 解析(Parser)阶段
const ast = parse(source, {
  // 严格模式下解析并且允许模块定义
  sourceType: ParseSourceTypeEnum.Module,
  // 支持解析 TypeScript 语法(注意,这里只是可以解析,并不是转换 TypeScript)
  plugins: [ParsePluginEnum.TypeScript],
});

// 转换(Transform) 阶段
traverse(ast, {
  // 访问变量声明标识符
  VariableDeclaration(path) {
    //  const  let 转换为 var
    path.node.kind = 'var';
  },
  // 访问 TypeScript 类型声明标识符
  TSTypeAnnotation(path) {
    // 移除 TypeScript 的声明类型
    path.remove();
  },
});
复制代码

关于 Babel 中的访问器 API,这里不再过多说明,如果想了解更多信息,可以查看 Babel 插件手册。除此之外,你可能已经注意到这里的转换逻辑其实可以理解为实现一个简单的 Babel 插件,只是没有封装成 Npm 包。当然,在真正的插件开发开发中,还可以配合 @babel/types 工具包进行节点信息的判断处理。

温馨提示:这里只是简单的一个 Demo 示例,在真正转换 let、const 等变量声明的过程中,还会遇到处理暂时性死区(Temporal Dead Zone, TDZ)的情况,更多详细信息可以查看官方的插件 babel-plugin-transform-block-scoping。

1.3 生成(Generate)
Babel 的代码生成过程(AST 到目标代码的转换)主要使用 @babel/generator,如下所示:

import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';

enum ParseSourceTypeEnum {
  Module = 'module',
  Script = 'script',
  Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
  Flow = 'flow',
  FlowComments = 'flowComments',
  TypeScript = 'typescript',
  Jsx = 'jsx',
  V8intrinsic = 'v8intrinsic',
}
const source = `let a: string = 1;`;

// 解析(Parser)阶段
const ast = parse(source, {
  // 严格模式下解析并且允许模块定义
  sourceType: ParseSourceTypeEnum.Module,
  // 支持解析 TypeScript 语法(注意,这里只是可以解析,并不是转换 TypeScript)
  plugins: [ParsePluginEnum.TypeScript],
});

// 转换(Transform) 阶段
traverse(ast, {
  // 访问词法规则
  VariableDeclaration(path) {
    path.node.kind = 'var';
  },
	
  // 访问词法规则
  TSTypeAnnotation(path) {
    // 移除 TypeScript 的声明类型
    path.remove();
  },
});

// 生成(Generate)阶段
const { code } = generate(ast);
// code:  var a = 1;
console.log('code: ', code);
复制代码

如果你想了解上述输入源对应的 AST 数据或者尝试自己编译,可以使用工具 AST Explorer (也可以使用 Babel 官网自带的 Try It Out ),具体如下所示:

温馨提示:上述第三个框是以插件的 API 形式进行调用,如果想了解 Babel 的插件开发,可以查看 Babel 插件手册 / 编写你的第一个 Babel 插件。

如果你觉得 Babel 的编译过程太过于简单,你可以尝试更高阶的玩法,比如自己设计词法和语法规则从而实现一个简单的编译器(Babel 内置了这些规则),你完全可以不只是做出一个源到源的转换编译器,而是实现一个真正的从 JavaScript (TypeScript) 到机器代码的完整编译器,包括实现中间代码 IR 以及提供机器的运行环境等,这里给出一个可以尝试这种高阶玩法的库包 antlr4ts(可以配合交叉编译工具链 riscv-gnu-toolchain,gcc编译工具的制作还是非常耗时的)。

阅读链接: Babel 用户手册、Babel 插件手册

2、ES6 Module 相对于 CommonJS 的优势是什么?
温馨提示:以下 ES Module 的代码只在 Node.js 环境中进行了测试,感兴趣的同学可以使用浏览器进行再测试。对不同规范模块的代码编译选择了 Webpack,感兴趣的同学也可以采用 Rollup 进行编译测试。

关于 ES Module 和 CommonJS 的规范以及语法,两者的主要区别如下所示:

类型 ES Module CommonJS
加载方式 编译时 运行时
引入性质 引用 / 只读 浅拷贝 / 可读写
模块作用域 this this / __filename / __dirname…

2.1 加载方式

加载方式是 ES Module 和 CommonJS 的最主要区别,这使得两者在编译时和运行时上各有优劣。首先来看一下 ES Module 在加载方式上的特性,如下所示:

// 编译时:VS Code 鼠标 hover  b 时可以显示出 b 的类型信息
import { b } from './b';

const a = 1;
// WARNING: 具有逻辑
if(a === 1) {
   // 编译时:ESLint: Parsing error: 'import' and 'export' may only appear at the top level
   // 运行时:SyntaxError: Unexpected token '{'
   // TIPS: 这里可以使用 import() 进行动态导入
   import { b } from './b';
}

const c = 'b';
// WARNING: 含有变量
// 编译时:ESLint:Parsing error: Unexpected token `
// 运行时:SyntaxError: Unexpected template string
import { d } from `./${c}`;
复制代码

CommonJS 相对于 ES Module 在加载方式上的特性如下所示:

const a = 1;

if(a === 1) {
  // VS Code 鼠标 hover  b 时,无法显示出 b 的类型信息
  const b = require('./b');
}

const c = 'b';
const d = require(`./${c}`);

复制代码

大家可能知道上述语法的差异性,接下来通过理论知识重点讲解一下两者产生差异的主要原因。如下图所示:

ES Module 是采用静态的加载方式,也就是模块中导入导出的依赖关系可以在代码编译时就确定下来。如上图所示,代码在编译的过程中可以做的事情包含词法和语法分析、类型检查以及代码优化等等。因此采用 ES Module 进行代码设计时可以在编译时通过 ESLint 快速定位出模块的词法语法错误以及类型信息等。ES Module 中会产生一些错误的加载方式,是因为这些加载方式含有逻辑和变量的运行时判断,只有在代码的运行时阶段才能确定导入导出的依赖关系,这明显和 ES Module 的加载机制不相符。 ​

CommonJS 相对于 ES Module 在加载模块的方式上存在明显差异,是因为 CommonJS 在运行时进行加载方式的动态解析,在运行时阶段才能确定的导入导出关系,因此无法进行静态编译优化和类型检查。 ​

温馨提示:注意 import 语法和 import() 的区别,import() 是 tc39 中的一种提案,该提案允许你可以使用类似于 import(${path}/foo.js) 的导入语句(估计是借鉴了 CommonJS 可以动态加载模块的特性),因此也允许你在运行时进行条件加载,也就是所谓的懒加载。除此之外,import 和 import() 还存在其他一些重要的区别,大家还是自行谷歌一下。

2.2 编译优化
由于 ES Module 是在编译时就能确定模块之间的依赖关系,因此可以在编译的过程中进行代码优化。例如:

// hello.js 
export function a() {
  console.log('a');
}

export function b() {
  console.log('b');
}

// index.js
// TIPS: Webpack 编译入口文件
// 这里不引入 function b
import { a } from './hello';
console.log(a);
复制代码

使用 Webpack 5.47.1 (Webpack Cli 4.7.2)进行代码编译,生成的编译产物如下所示:

(()=>{"use strict";console.log((function(){console.log("a")}))})();
复制代码

可以发现编译生成的产物没有 function b 的代码,这是在编译阶段对代码进行了优化,移除了未使用的代码(Dead Code),这种优化的术语被叫做 Tree Shaking。

温馨提示:你可以将应用程序想象成一棵树。绿色表示实际用到的 Source Code(源码)和 Library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。

温馨提示:在 ES Module 中可能会因为代码具有副作用(例如操作原型方法以及添加全局对象的属性等)导致优化失败,如果想深入了解 Tree Shaking 的更多优化注意事项。

为了对比 ES Module 的编译优化能力,同样采用 CommonJS 规范进行模块导入: ​

// hello.js
exports.a = function () {
  console.log('a');
};

exports.b = function () {
  console.log('b');
};

// index.js
// TIPS: Webpack 编译入口文件
const { a } = require('./hello');
console.log(a);
复制代码

使用 Webpack 进行代码编译,生成的编译产物如下所示:

(() => {
  var o = {
      418: (o, n) => {
        (n.a = function () {
          console.log('a');
        }),
          // function b 的代码并没有被去除
          (n.b = function () {
            console.log('b');
          });
      },
    },
    n = {};
  function r(t) {
    var e = n[t];
    if (void 0 !== e) return e.exports;
    var s = (n[t] = { exports: {} });
    return o[t](s, s.exports, r), s.exports;
  }
  (() => {
    const { a: o } = r(418);
    console.log(o);
  })();
})();
复制代码

可以发现在 CommonJS 模块中,尽管没有使用 function b,但是代码仍然会被打包编译,正是因为 CommonJS 模块只有在运行时才能进行同步导入,因此无法在编译时确定是否 function b 是一个 Dead Code。

温馨提示:在 Node.js 环境中一般不需要编译 CommonJS 模块代码,除非你使用了当前 Node 版本所不能兼容的一些新语法特性。

大家可能会注意到一个新的问题,当我们在制作工具库或者组件库的时候,通常会将库包编译成 ES5 语法,这样尽管 Babel 以及 Webpack 默认会忽略 node_modules 里的模块,我们的项目在编译时引入的这些模块仍然能够做到兼容。在这个过程中,如果你制作的库包体积非常大,你又不提供非常细粒度的按需引入的加载方式,那么你可以编译你的源码使得编译产物可以支持 ES Module 的导入导出模式(注意只支持 ES6 中模块的语法,其他的语法仍然需要被编译成 ES5),当项目真正引入这些库包时可以通过 Tree Shaking 的特性在编译时去除未引入的代码(Dead Code)。

温馨提示:如果你想了解如何使发布的 Npm 库包支持 Tree Shaking 特性,可以查看 defense-of-dot-js / Typical Usage、 Webpack / Final Steps、pgk.module 以及 rollup.js / Tree Shaki…。 ​

Webpack 对于 module 字段的支持的描述提示:The module property should point to a script that utilizes ES2015 module syntax but no other syntax features that aren’t yet supported by browsers or node. This enables webpack to parse the module syntax itself, allowing for lighter bundles via tree shaking if users are only consuming certain parts of the library.

2.3 加载原理 & 引入性质
温馨提示:下述理论部分以及图片内容均出自于 2018 年的文章 ES modules: A cartoon deep-dive,如果想要了解更多原理信息可以查看 TC39 的 16.2 Modules。

在 ES Module 中使用模块进行开发,其实是在编译时构建模块之间的依赖关系图。在浏览器或者服务的文件系统中运行 ES6 代码时,需要解析所有的模块文件,然后将模块转换成 Module Record 数据结构,具体如下图所示:

事实上, ES Module 的加载过程主要分为如下三个阶段: ​

构建(Construction):主要分为查找、加载(在浏览器中是下载文件,在本地文件系统中是加载文件)、然后把文件解析成 Module Record。
实例化(Instantiation):给所有的 Module Record 分配内存空间(此刻还没有填充值),并根据导入导出关系确定各自之间的引用关系,确定引用关系的过程称为链接(Linking)。
运行(Evaluation):运行代码,给内存地址填充运行时的模块数据。

温馨提示:import 的上述三个阶段其实在 import() 中体现的更加直观(尽管 import 已经被多数浏览器支持,但是我们在真正开发和运行的过程中仍然会使用编译后的代码运行,而不是采用浏览器 script 标签的远程地址的动态异步加载方式),而 import() 事实上如果要实现懒加载优化(例如 Vue 里的路由懒加载,更多的是在浏览器的宿主环境而不是 Node.js 环境,这里不展开更多编译后实现方式的细节问题),大概率要完整经历上述三个阶段的异步加载过程,具体再次查看 tc39 动态提案:This proposal adds an import(specifier) syntactic form, which acts in many ways like a function (but see below). It returns a promise for the module namespace object of the requested module, which is created after fetching, instantiating, and evaluating all of the module’s dependencies, as well as the module itself.

ES Module 模块加载的三个阶段分别需要在编译时和运行时进行(可能有的同学会像我一样好奇实例化阶段到底是在编译时还是运行时进行,根据 tc39 动态加载提案里的描述可以得出你想要的答案:The existing syntactic forms for importing modules are static declarations. They accept a string literal as the module specifier, and introduce bindings into the local scope via a pre-runtime “linking” process.),而 CommonJS 规范中的模块是在运行时同步顺序执行,模块在加载的过程中不会被中断,具体如下图所示:

上图中 main.js 在运行加载 counter.js 时,会先等待 counter.js 运行完成后才能继续运行代码,因此在 CommonJS 中模块的加载是阻塞式的。CommonJS 采用同步阻塞式加载模块是因为它只需要从本地的文件系统中加载文件,耗费的性能和时间很少,而 ES Module 在浏览器(注意这里说的是浏览器)中运行的时候需要下载文件然后才能进行实例化和运行,如果这个过程是同步进行,那么会影响页面的加载性能。

从 ES Module 链接的过程可以发现模块之间的引用关系是内存的地址引用,如下所示:

// hello.js
export let a = 1;

setTimeout(() => {
  a++;
}, 1000);


// index.js
import { a } from './hello.js';

setTimeout(() => {
  console.log(a); // 2
}, 2000);
复制代码

在 Node (v14.15.4)环境中运行上述代码得到的执行结果是 2,对比一下 CommonJS 规范的执行:

// hello.js
exports.a = 1;

setTimeout(() => {
  exports.a++;
}, 1000);


// index.js
let { a } = require('./hello');

setTimeout(() => {
  console.log(a); // 1
}, 2000);

复制代码

可以发现打印的结果信息和 ES Module 的结果不一样,这里的执行结果为 1。产生上述差异的根本原因是实例化的方式不同,如下图所示:

ATG精准科技-前端面试题

前端面试CSS系列——DIV垂直水平居中

前端面试每日 3+1 —— 第692天

前端事件绑定知识点(面试常考)

前端面试每日 3+1 —— 第772天

前端面试每日 3+1 —— 第869天