◆

JavaScript 模块编译时加载与静态分析

2023-02-02 01:370

首先了解什么是静态分析

程序静态分析是指在不运行代码的方式下,通过词法分析、语法分析、控制流、数据流分析等技术对程序代码进行扫描,验证代码是否满足规范性、安全性、可靠性、可维护性等指标的一种代码分析技术。 静态分析技术向模拟执行的技术发展以能够发现更多传统意义上动态测试才能发现的缺陷,例如符号执行、抽象解释、值依赖分析等等并采用数学约束求解工具进行路径约减或者可达性分析以减少误报增加效率。

运行时加载:CommonJS

CommonJS 的 require() 是一个普通函数调用,它在代码执行到那一行时才去读文件、执行模块、返回 module.exports 这个对象。整个过程发生在运行时,所以有几个直接后果:

// a.js
const utils = require('./utils');
// 路径可以是变量,可以拼接,可以放在 if 里
const mod = require(condition ? './a' : './b');
if (needHeavy) {
  const heavy = require('./heavy');
}

require 拿到的是整个导出对象,想用其中一个方法也得先把整个对象加载出来:

// 即使只用 add,math.js 里的所有代码都执行了,所有导出都在内存里
const { add } = require('./math');

因为路径可以是任意表达式,工具在不执行代码的前提下无法确定依赖关系。静态分析在这里就失效了——分析器看到 require(someVar) 只能放弃。

编译时加载:ES Module

import 不是函数,是语法结构。规范要求它满足几个约束:

  1. 只能出现在模块顶层,不能放在 if、函数体、try 里。
  2. 模块路径必须是字符串字面量,不能是变量或拼接表达式。
  3. 声明会被提升,位置写在文件哪里都一样。
// 合法
import { add } from './math.js';

// 全部报语法错误
if (x) {
  import { add } from './math.js';
}
import { add } from './math' + suffix;

这三条约束就是「编译时加载」的含义:JS 引擎在解析阶段(生成 AST 之后、执行任何代码之前)就能把整张依赖图画出来,知道每个模块从哪儿来、导出了什么、谁引用了谁。原文讲的静态分析技术,在这里才有施展空间。

import 拿到的也不是一个对象,而是对导出变量的绑定(binding)。这个区别可以直接测出来:

// counter.js
export let count = 0;
export function inc() {
  count++;
}
// main.mjs
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 —— 值跟着变了

换成 CommonJS 写法,const { count } = require('./counter') 拿到的是导出那一刻的值拷贝,调用 inc() 之后再打印仍然是 0。这就是常说的「CommonJS 输出值的拷贝,ESM 输出值的引用」。

也正因为是绑定而不是赋值,导入的变量是只读的,count = 5 会抛 TypeError: Assignment to constant variable。

静态分析换来了什么

tree shaking。 打包器能看出 math.js 导出了十个函数但只有 add 被引用,其余九个的代码可以整块删掉。CommonJS 做不到,因为 module.exports 是一个运行时可以任意改写的对象,删掉任何一项都可能改变语义。

// math.js
export const add = (a, b) => a + b;
export const mul = (a, b) => a * b; // 没人用,会被摇掉

// main.js
import { add } from './math.js';

循环依赖不再是死结。 ESM 采用「先构建模块记录并链接所有导出绑定,再执行模块体」的两阶段模型,所以循环引用时至少变量名都是存在的(虽然可能是未初始化的 TDZ 状态)。CommonJS 遇到循环会拿到一个执行到一半的、残缺的 exports 对象。

编译期报错。 导入一个不存在的导出名,ESM 在链接阶段就报 does not provide an export named 'xxx',根本不会开始执行。CommonJS 只会给你一个 undefined,等调用的时候才炸成 xxx is not a function。

顶层 await 可用。 因为模块系统知道整张依赖图,可以把异步模块的完成状态往上传播。CommonJS 的同步 require 语义上不可能支持这个。

需要动态加载时用 import()

静态 import 的约束有时确实碍事——按路由懒加载、按条件加载 polyfill,路径就是要在运行时才定。这时用动态 import():

// 返回 Promise,resolve 出来的是模块的命名空间对象
const { render } = await import(`./locales/${lang}.js`);

// 路由懒加载
const routes = [
  { path: '/about', component: () => import('./views/About.vue') },
];

import() 是运行时行为,看起来像是回到了 require 的老路,但它和 require 有两个关键差别:返回 Promise 而非同步返回;打包器仍会把它识别为一个分包点,为它单独产出一个 chunk。

不过路径里带变量时,打包器只能靠猜。Vite / Rollup 的处理方式是把 ./locales/${lang}.js 里的变量部分当通配符,把 ./locales/ 下所有 .js 都打进去。所以路径前缀要尽可能具体,写成 import(path) 这种纯变量形式打包器会直接放弃并给出警告。

两套系统混用的注意点

Node 里判断一个文件按哪套规则解析,看的是扩展名和最近的 package.json 的 type 字段:.mjs 恒为 ESM,.cjs 恒为 CommonJS,.js 则跟随 type("module" 为 ESM,缺省或 "commonjs" 为 CJS)。

混用时的规则:

  • ESM 里可以 import CJS 模块,但只能拿到默认导出(整个 module.exports),具名导入靠 Node 的静态分析尽力而为,复杂的动态导出会失败。
  • CJS 里不能 require 一个 ESM 模块,因为 ESM 可能有顶层 await,无法同步完成。要加载只能用 await import()。
  • ESM 里没有 __dirname、__filename、require,替代品是 import.meta.url 和 import.meta.dirname(Node 20.11+)。
// ESM 里拿当前目录
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEJavaScript 模块编译时加载与静态分析加载中