前端工程化知识点梳理
前言
子奕大佬整理的这份知识点挺不错的,小伙伴们遇到有不明白的点,可以找找相关的文章,研究一下,横向提升自己知识面。
知识点
6、简单描述一下 Babel 的编译过程?
温馨提示:如果某种高级语言或者应用语言(例如用于人工智能的计算机设计语言)转换的目标语言不是特定计算机的汇编语言,而是面向另一种高级程序语言(很多研究性的编译器将 C 作为目标语言),那么还需要将目标高级程序语言再进行一次额外的编译才能得到最终的目标程序,这种编译器可称为源到源的转换器。
解析(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;
6.1 解析(Parser)
支持解析最新的 ES2020 支持解析 JSX、Flow & TypeScript 支持解析实验性的语法提案(支持任何 Stage 0 的 PRS)
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],
});
6.2 转换(Transform)
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();
},
});
温馨提示:这里只是简单的一个 Demo 示例,在真正转换 let、const 等变量声明的过程中,还会遇到处理暂时性死区(Temporal Dead Zone, TDZ)的情况,更多详细信息可以查看官方的插件 babel-plugin-transform-block-scoping。
6.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 插件手册
9、ES6 Module 相对于 CommonJS 的优势是什么?
温馨提示:如果你只是想知道本题的答案,那么直接进入传送门 16.8.2 Static module structure 。除此之外,以下 ES Module 的代码只在 Node.js 环境中进行了测试,感兴趣的同学可以使用浏览器进行再测试。对不同规范模块的代码编译选择了 Webpack,感兴趣的同学也可以采用 Rollup 进行编译测试。
类型 | ES Module | CommonJS |
---|---|---|
加载方式 | 编译时 | 运行时 |
引入性质 | 引用 / 只读 | 浅拷贝 / 可读写 |
模块作用域 | this | this / __filename / __dirname... |
9.1 加载方式
// 编译时: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}`);
温馨提示:注意 import 语法和 import() 的区别,import() 是 tc39 中的一种提案,该提案允许你可以使用类似于 import(`${path}/foo.js`) 的导入语句(估计是借鉴了 CommonJS 可以动态加载模块的特性),因此也允许你在运行时进行条件加载,也就是所谓的懒加载。除此之外,import 和 import() 还存在其他一些重要的区别,大家还是自行谷歌一下。
9.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 的更多优化注意事项,可以深入阅读你的 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);
})();
})();
温馨提示:在 Node.js 环境中一般不需要编译 CommonJS 模块代码,除非你使用了当前 Node 版本所不能兼容的一些新语法特性。
温馨提示:如果你想了解如何使发布的 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.
9.3 加载原理 & 引入性质
温馨提示:下述理论部分以及图片内容均出自于 2018 年的文章 ES modules: A cartoon deep-dive,如果想要了解更多原理信息可以查看 TC39 的 16.2 Modules。
事实上, 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.
具体如下图所示:上图中 main.js 在运行加载 counter.js 时,会先等待 counter.js 运行完成后才能继续运行代码,因此在 CommonJS 中模块的加载是阻塞式的。CommonJS 采用同步阻塞式加载模块是因为它只需要从本地的文件系统中加载文件,耗费的性能和时间很少,而 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。产生上述差异的根本原因是实例化的方式不同,如下图所示:
CommonJS 规范在导出时事实上导出的是值拷贝,如下图所示:
// hello.js
exports.a = {
value: 1,
};
setTimeout(() => {
exports.a.value++;
}, 1000);
// index.js
let { a } = require('./hello');
setTimeout(() => {
console.log(a.value); // 2
}, 2000);
接下来对比编译后的差异,将 ES Module 的源码进行编译(仍然使用 Webpack),编译之后的代码如下所示:
(() => {
'use strict';
let e = 1;
setTimeout(() => {
e++;
}, 1e3),
setTimeout(() => {
console.log(e);
}, 2e3);
})();
可以看出,将 ES Module 的代码进行编译后,使用的是同一个变量值,此时将 CommonJS 的代码进行编译:
(() => {
var e = {
418: (e, t) => {
// hello.js 中的模块代码
(t.a = 1),
setTimeout(() => {
t.a++;
}, 1e3);
},
},
t = {};
function o(r) {
// 开辟模块的缓存空间
var s = t[r];
// 获取缓存信息,每次返回相同的模块对象信息
if (void 0 !== s) return s.exports;
// 开辟模块对象的内存空间
var a = (t[r] = { exports: {} });
// 逗号运算符,先运行模块代码,赋值模块对象的值,然后返回模块信息
// 由于缓存,模块代码只会被执行一次
return e[r](a, a.exports, o), a.exports;
}
(() => {
// 浅拷贝
let { a: e } = o(418);
setTimeout(() => {
// 尽管 t.a ++,这里输出的仍然是 1
console.log(e);
}, 2e3);
})();
})();
可以发现 CommonJS 规范在编译后会缓存模块的信息,从而使得下一次将从缓存中直接获取模块数据。除此之外,缓存会使得模块代码只会被执行一次。查看 Node.js 官方文档对于 CommonJS 规范的缓存描述,发现 Webpack 的编译完全符合 CommonJS 规范的缓存机制。了解了这个机制以后,你会发现多次使用 require 进行模块加载不会导致代码被执行多次,这是解决无限循环依赖的一个重要特征。
除了引入的方式可能会有区别之外,引入的代码可能还存在一些区别,比如在 ES Module 中:
// hello.js
export function a() {
console.log('a this: ', this);
}
// index.js
import { a } from './hello.js';
// a = 1;
// TypeError: Assignment to constant variable.
// ...
// at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
// at async Loader.import (internal/modules/esm/loader.js:166:24)
// at async Object.loadESM (internal/process/esm_loader.js:68:5)
a = 1;
使用 Node.js 直接运行上述 ES Module 代码,是会产生报错的,因为导入的变量根据提示可以看出是只读变量,而如果采用 Webpack 进行编译后运行,则没有上述问题,除此之外 CommonJS 中导入的变量则可读可写。当然除此之外,你也可以尝试更多的其他方面,比如:
// hello.js
// 非严格模式
b = 1;
export function a() {
console.log('a this: ', this);
}
// index.js
import { a } from './hello.js';
console.log('a: ', a);
你会发现使用 Node.js 环境执行上述 ES Module 代码,会直接抛出下述错误信息:
ReferenceError: b is not defined
at file:///Users/ziyi/Desktop/Gitlab/Explore/module-example/esmodule/hello.js:1:3
at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
at async Loader.import (internal/modules/esm/loader.js:166:24)
at async Object.loadESM (internal/process/esm_loader.js:68:5)
9.4 模块作用域
// https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js#L206
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];
索性看到这个模块作用域的代码,我们就继续查看一下 require 的源码:
// https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L997
Module.prototype.require = function(id) {
validateString(id, 'id');
if (id === '') {
throw new ERR_INVALID_ARG_VALUE('id', id,
'must be a non-empty string');
}
requireDepth++;
try {
return Module._load(id, this, /* isMain */ false);
} finally {
requireDepth--;
}
};
// https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L757
// Check the cache for the requested file.
// 1. If a module already exists in the cache: return its exports object.
// 2. If the module is native: call
// `NativeModule.prototype.compileForPublicLoader()` and return the exports.
// 3. Otherwise, create a new module for the file and save it to the cache.
// Then have it load the file contents before returning its exports
// object.
Module._load = function(request, parent, isMain) {
let relResolveCacheIdentifier;
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
// Fast path for (lazy loaded) modules in the same directory. The indirect
// caching is required to allow cache invalidation without changing the old
// cache key names.
relResolveCacheIdentifier = `${parent.path}\x00${request}`;
const filename = relativeResolveCache[relResolveCacheIdentifier];
// 有缓存,则走缓存
if (filename !== undefined) {
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
return cachedModule.exports;
}
delete relativeResolveCache[relResolveCacheIdentifier];
}
}
// `node:` 用于检测核心模块,例如 fs、path 等
// Node.js 文档:http://nodejs.cn/api/modules.html#modules_core_modules
// 这里主要用于绕过 require 缓存
const filename = Module._resolveFilename(request, parent, isMain);
if (StringPrototypeStartsWith(filename, 'node:')) {
// Slice 'node:' prefix
const id = StringPrototypeSlice(filename, 5);
const module = loadNativeModule(id, request);
if (!module?.canBeRequiredByUsers) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(filename);
}
return module.exports;
}
// 缓存处理
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded) {
const parseCachedModule = cjsParseCache.get(cachedModule);
if (!parseCachedModule || parseCachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
parseCachedModule.loaded = true;
} else {
return cachedModule.exports;
}
}
const mod = loadNativeModule(filename, request);
if (mod?.canBeRequiredByUsers) return mod.exports;
// Don't call updateChildren(), Module constructor already does.
const module = cachedModule || new Module(filename, parent);
if (isMain) {
process.mainModule = module;
module.id = '.';
}
Module._cache[filename] = module;
if (parent !== undefined) {
relativeResolveCache[relResolveCacheIdentifier] = filename;
}
let threw = true;
try {
module.load(filename);
threw = false;
} finally {
if (threw) {
delete Module._cache[filename];
if (parent !== undefined) {
delete relativeResolveCache[relResolveCacheIdentifier];
const children = parent?.children;
if (ArrayIsArray(children)) {
const index = ArrayPrototypeIndexOf(children, module);
if (index !== -1) {
ArrayPrototypeSplice(children, index, 1);
}
}
}
} else if (module.exports &&
!isProxy(module.exports) &&
ObjectGetPrototypeOf(module.exports) ===
CircularRequirePrototypeWarningProxy) {
ObjectSetPrototypeOf(module.exports, ObjectPrototype);
}
}
return module.exports;
};
温馨提示:这里没有将 wrapper 和 _load 的联系说清楚(最后如何在 _load 中执行 wrapper),大家可以在 Node.js 源码中跟踪一下看一下上述代码是怎么被执行的,是否是 eval 呢?不说了,脑壳疼,想要了解更多信息,可以查看 Node.js / vm。除此之外,感兴趣的同学也了解一下 import 语法在 Node.js 中的底层实现,这里脑壳疼,就没有深入研究了。
温馨提示的温馨提示:比如你在源码中找不到上述代码的执行链路,那最简单的方式就是引入一个错误的模块,让错误信息将错误栈抛出来,比如如下所示,你会发现最底下执行了 wrapSafe,好了你又可以开始探索了,因为你对 safe 这样的字眼一定感到好奇,底下是不是执行的时候用了沙箱隔离呢?
SyntaxError: Cannot use import statement outside a module
at wrapSafe (internal/modules/cjs/loader.js:979:16)
at Module._compile (internal/modules/cjs/loader.js:1027:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
at Module.load (internal/modules/cjs/loader.js:928:32)
at Function.Module._load (internal/modules/cjs/loader.js:769:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
at internal/main/run_main_module.js:17:47
温馨提示:是不是以前经常有面试官询问 exports 和 module.exports 有什么关联,其实根本不用纠结这个问题,因为两者指向的是同一个引用地址,你如果对 exports 进行重新赋值,那么引用发生了改变,你新引用的部分当然就不会导出了,因为从源码里可以看出,我们这里导出的是 module.exports。
接下来主要是重点看下 this 执行上下文的差异(注意这里只测试 Node.js 环境,编译后的代码可能会有差异),首先执行 ES Module 模块的代码:
// hello.js
export function a() {
console.log('this: ', this); // undefined
}
// index.js
import { a } from './hello.js';
a();
我们接着执行 CommonJS 的代码:
// hello.js
exports.a = function () {
console.log('this: ', this);
};
// index.js
let { a } = require('./hello');
a();
你会发现 this 的上下文环境是有信息的,可能是当前模块的信息,具体没有深究:
温馨提示:Node.js 的调试还能在浏览器进行?可以查看一下 Node.js 调试,当然你也可以使用 VS Code 进行调试,需要进行一些额外的 launch 配置,当然如果你觉得 Node.js 自带的浏览器调试方式太难受了,也可以想想办法,如何通过 IP 端口在浏览器中进行调试,并且可以做到代码变动监听调试。
const Module = require('module');
const originalRequire = Module.prototype.require;
Module.prototype.require = function(id){
// 这里加入 path 的逻辑
return originalRequire.apply(this, id);
};