跳到主要内容

模块与包 — 代码的分而治之

这一章讲三件事: 为什么代码会长成「大泥球」,模块怎么救; JS 两套模块系统的机制差异(以及迁移为什么慢); 从 NPM 装包时,版本号、node_modules、打包压缩到底在发生什么。 读完你能看懂任何项目的 import 语句和 package.json

1. 先看现象:大泥球

理想中,程序结构清晰、各司其职;实际上程序有机生长——哪缺补哪。放任不管的结局有两条罪状1:

  1. 没法孤立理解:什么都能碰到什么,看任何一块都得把整个系统装进脑子;
  2. 没法复用:想拿走一小块功能,把它从语境里解出来的功夫,可能比重写还大;

这种程序有个形象的名字:大泥球(big ball of mud)——什么黏着什么,一挑就散,只剩一手的泥2

模块(module)= 一段声明了「我依赖谁」和「我提供什么(接口)」的程序。接口概念与对象的接口同源;但模块系统还要求另一半:依赖(dependency)必须写在模块自己身上——依赖明确了,机器才能算出「用这个模块还得装哪些」,进而自动加载。书里的比喻一句话:依赖显式的系统像乐高,接口就是拼接件;泥球里什么都黏什么3

2. ES modules:语言原生的模块

最初 JavaScript 没有模块概念:所有脚本共享一个全局作用域,跨脚本「调用」靠的是碰全局绑定——重名互踩的事故于是成了日常4。2015 年起语言分出两种程序:script(老样子,全局作用域)与 module(独立作用域,拥有 import/export 这两个 script 里不存在的关键字)5

// dayname.mjs —— 接口:dayName/dayNumber;依赖:无
const names = ["Sunday",];
export function dayName(number) { return names[number]; }

// main.mjs
import {dayName as nomDeJour} from "./dayname.js";

四条机制要点6:

  • export 放在声明前,宣布「这是我的接口」;import {名字} from "模块名" 引入;
  • import/export 只能写在模块顶层,在加载时立即解析,与代码执行顺序无关;
  • 模块名如何落地看平台:浏览器当 URL,Node 解析成文件;
  • 变体:export default(整个模块只荐一个值,导入时省花括号);import * as xxx(整包挂到一个名字下);as 改名。

3. CommonJS:语言没给,社区自造

2015 年之前大家已经在大 JS 里讨生活,于是自造模块:用函数造局部作用域,用对象当接口。最简形态是立即执行函数(IIFE)包住整个模块,把接口挂到单个全局变量上——有隔离,但不声明依赖,还是靠全局碰头7

事实标准最终是 CommonJS:模块里多两个绑定——require("名字") 加载依赖并返回其接口对象;exports 是自己的接口对象,往上挂属性即可。Node 起家就用它,NPM 上大量包仍是它8

书里给了 20 行的迷你实现,机制值得看穿一层:require 查缓存(取过的留一份备查) → 读代码 → 用 Function 构造器把代码包进一个函数,把 require 和 exports 作为参数传进去 → 调用。Function 能把字符串变成程序——强大,也危险:谁能让你的程序执行一段他给的字符串,谁就能让你的程序为所欲为9

两套系统的机制分野,一句话版10:

ES modulesCommonJS
解析时机import 在模块代码运行前解析完毕require 是普通函数,运行中调用
依赖名只能是引号字符串字面量可以是任何表达式
能否在函数内不能(必须顶层)

社区的迁移「缓慢而粗糙」;作者的结论:如今大多数流行的 NPM 包已提供 ES modules,没有真正的理由再用 CommonJS 写新程序——但你一定会读到它11

4. 主走查:一个模块被 require 的一生

拿书里那份 20 行的迷你实现9,装载一个具体的模块,逐帧看每份状态的内容。被装载的模块是上一节那个 formatDate(它自己又依赖 NPM 上的 ordinal 与 date-names 两个包)12:

初始状态 require.cache = Object.create(null) ← 空表
① 调用 require("./format-date.js")
缓存里没有这个名字 → 读入源码文本
② 先占座 require.cache["./format-date.js"] = {}
← 一个空对象先进入缓存,它就是本模块的 exports[^25]
③ 包壳 Function("require, exports", 源码文本)
→ 得到一个函数;源码里的 require/exports
从此是这两个参数,不是全局变量
④ 执行函数壳 源码第 1 行:const ordinal = require("ordinal")
→ 递归走一遍 ①-④:ordinal 的 exports
{ordinal: fn} 入缓存并被返回
源码第 3 行:exports.formatDate = function (…)
→ 挂到 ② 占座的那只空对象上
⑤ 返回 require.cache["./format-date.js"]
现在是 {formatDate: fn},原样交给调用者
⑥ 再次 require("./format-date.js")
缓存命中 → 直接返回,源码不再执行第二遍

这份走查顺手解释了三件前面只说结论的事:导出发生在执行期(exports 是运行中被逐个挂上去的对象,所以 require 必须在运行中调用);同一模块的代码只执行一次(缓存先占座,第二次直接取);以及循环依赖为什么能活——若 ordinal 反过来 require format-date,它拿到的就是 ② 那只还没填完的空对象,只要不在加载完成前碰它的接口,就不出事(书末练习的原话:加载完成前互不访问接口即可)13

5. 包与 NPM:复用的基础设施

模块是「项目内」的组织;包(package)= 可独立分发的一块代码,含一或多个模块、自己的依赖清单和说明文档。为什么不复制粘贴?书里的场景很真实:把 parseINI 抄进新项目,后来发现 Bug,修了手上这份,忘了另一份——复制一多,人生就耗在「搬运与追版本」上14

这套玩法需要基础设施,JS 世界的答案是 NPM:一个在线仓库(写作时300 多万个包,「相当大一部分是垃圾」)+ 一个随 Node 附带的命令行工具15npm install ini 后,当前目录出现 node_modules/ini/;装在当前目录而非系统中央是刻意的——每个应用完全掌控自己的包,版本管理和清理都简单16。项目根的 package.json 记录名字、版本和 dependencies;不带参数的 npm install 会按清单装齐17

语义化版本(semantic versioning) 是包世界的契约:版本号三段,如 2.3.0——加新功能动中段;破坏兼容动首段package.json 里的 ^2.3.0 意思是:允许 ≥2.3.0 且 <3.0.0 的任何版本——兼容段内自动跟进,跨大版本绝不自动18。另外,任何人都能向 NPM 发布包,但不能占用别人已用的包名——否则就能替别人发新版了19

6. 打包与压缩:你发的从来不是你写的

两件你可能没意识到的加工:

  • 打包(bundling):模块化程序动辄 200 个文件,浏览器每个要一次网络往返——书里按「单文件 50 毫秒」估算:全程序要 10 秒。于是先把拆好的模块重新合并成一个大文件再发布20;
  • 压缩(minification):删注释与空白、改短绑定名、用更短的等价代码,程序等义而体积骤减21

所以 NPM 包里或网页上的代码,往往经过了「新语法→旧语法、多文件→单文件、人话→紧凑」多轮变换。作者的态度:工具多且流行榜常换,知道存在、用到再查即可22

7. 模块设计:四条裁决

本章最有长期价值的一节。结构设计没有标准答案,但书里给了四条硬准则23:

  1. 好用压倒一切:接口要简单可预测。正面教材 ini 包——模仿 JSON 的 parse/stringify 命名,用过一次就忘不掉;
  2. 专注一件事、算出值:那种「顺手把读文件也做了」的 INI 解析器,在浏览器里直接废掉(没有文件系统);读文件应该留给调用者组合进去;
  3. 能是函数就别造对象:书里点名批评某类接口——「先 new 一个对象、再把文件装进去、再用专门方法取结果」的状态机仪式,原话是 terrible:一次调用能完的事,凭什么让用户陪对象走完它的心路历程?还让所有调用方都得认识这个类型,凭空多出纠缠。数组够用就用数组;
  4. 想被组合,就跟着别人的数据结构走:书里的实例——第 07 章的图用「节点→邻居数组」表示,而 NPM 上的寻路包(dijkstrajs)全都要带权边;想用它们,就得把图转成 {节点: {邻居: 权重}} 的形状(所有边填 1)。不同包用不同结构描述同类事物,是可组合性的最大路障24

8. 作者的判断与证据

  • 「大泥球两大罪状」:来自实践的归纳,配了机制(纠缠→无法孤立理解)1;
  • 「Function 危险」:机制层面成立(字符串→任意代码)9;
  • 「300 万包、大半垃圾」:作者写作时的观察,自带口吻说明;
  • 「探索期别急着组织」:问题还没摸透时,保持整洁本身就是干扰;等东西站稳了,退一步再整理——对流程的判断25

判断(我们的,不是书里的): 本章与第 06 章「继承制造纠缠」是同一条原则的两次落地—— 纠缠是复杂度的主载体,接口是把纠缠圈进围栏的手段。 判断一个模块好不好, 便宜的办法是问:不用它的人需要知道它内部什么?答案越接近「什么都不用」,模块越好。 如果错,会错在: 极小的项目里,模块边界的维护成本可能超过它省下的理解成本——书里自己也说了「小应用可以不做」。

9. 边界与局限

  • 循环依赖只出现在练习:CommonJS 容忍有限循环(加载完之前互不摸对方的接口),ES modules 的处理书里没展开;
  • 打包工具的具体名字(webpack、Vite 一类)刻意不提——「流行榜常换」;
  • Node 怎么实际解析模块路径,留到第 14 章;
  • monorepo、workspace 等多包工程形态不在本书射程。

10. 可带走的

  1. 模块=接口+依赖声明;依赖显式才配得上「乐高」这个词;
  2. import/export 只能在顶层,加载时立即解析;
  3. import 先于运行解析;require 运行中调用——这是两套系统一切差异的根;
  4. Function("参数", "代码体") 能从字符串造函数:强大且危险;
  5. npm 装进当前目录的 node_modules;package.json 是依赖的账本;
  6. 语义化版本:中段加功能、首段破兼容;^2.3.0 = ≥2.3.0 且 <3.0.0;
  7. 你发的代码经过打包与压缩——合并是网络往返太慢逼的,压缩是带宽(每秒能运多少数据)逼的;
  8. 接口模仿知名惯例(JSON 的 parse/stringify)就是最好的文档;
  9. 能函数不对象、能数组不造类;状态机仪式是 terrible;
  10. 想被组合:专注、算值、用别人都在用的数据结构

11. 原文地图

主题原书章原文位置
纠缠的两大罪状Modulestext/13-fm-modules.txt:13(搜「two practical issues」)
大泥球同上text/13-fm-modules.txt:15(搜「big ball of mud」)
模块=接口+依赖同上text/13-fm-modules.txt:19(搜「module」) · :23(搜「dependencies」)
乐高 vs 泥同上text/13-fm-modules.txt:25(搜「LEGO」)
无模块时代的互踩同上text/13-fm-modules.txt:29(搜「same scope」)
script 与 module 分野同上text/13-fm-modules.txt:31(搜「ES modules」)
import/export 顶层解析同上text/13-fm-modules.txt:58(搜「immediately resolved」)
default / import *同上text/13-fm-modules.txt:66(搜「default」) · :74(搜「import *」)
复制代码修两份同上text/13-fm-modules.txt:84(搜「parseINI」)
NPM 双重身份与包数同上text/13-fm-modules.txt:92(搜「two things」) · :94(搜「million」)
许可证文化同上text/13-fm-modules.txt:102(搜「copyright」) · :104(搜「keep the license」)
IIFE 造模块同上text/13-fm-modules.txt:122(搜「weekDay」)
CommonJS 机制同上text/13-fm-modules.txt:138(搜「require」)
require 迷你实现同上text/13-fm-modules.txt:173(搜「require.cache」)
Function 的危险同上text/13-fm-modules.txt:183(搜「Function is」)
两种解析时机同上text/13-fm-modules.txt:191(搜「normal function」)
迁移的结论同上text/13-fm-modules.txt:193(搜「slow and somewhat rough」)
打包的 10 秒账同上text/13-fm-modules.txt:199(搜「10 seconds」)
压缩与多轮变换同上text/13-fm-modules.txt:201(搜「minifiers」) · :203(搜「multiple stages」)
ini 模仿 JSON 惯例同上text/13-fm-modules.txt:213(搜「parse and stringify」)
顺手读文件的代价同上text/13-fm-modules.txt:215(搜「file from the hard disk」)
有状态仪式 terrible同上text/13-fm-modules.txt:219(搜「ritual」)
数组够用就用数组同上text/13-fm-modules.txt:221(搜「use an array」)
图格式不合与适配同上text/13-fm-modules.txt:227(搜「dijkstrajs」) · :244(搜「barrier to composition」)
探索期别急组织同上text/13-fm-modules.txt:246(搜「step back」)

Footnotes

  1. 出处:「Modules」第 13 段(text/13-fm-modules.txt:13,搜「two practical issues」)。 2

  2. 出处:「Modules」第 15 段(text/13-fm-modules.txt:15,搜「big ball of mud」)。

  3. 出处:「Modules」第 19 段(text/13-fm-modules.txt:19,搜「interface」)、第 23 段(text/13-fm-modules.txt:23,搜「dependencies」)与第 25 段(text/13-fm-modules.txt:25,搜「LEGO」)。

  4. 出处:「Modules」第 29 段(text/13-fm-modules.txt:29,搜「same scope」)。

  5. 出处:「Modules」第 31 段(text/13-fm-modules.txt:31,搜「ES modules」)。

  6. 出处:「Modules」第 54 段(text/13-fm-modules.txt:54,搜「import」)、第 56 段(text/13-fm-modules.txt:56,搜「web addresses」)、第 58 段(text/13-fm-modules.txt:58,搜「immediately resolved」)与第 66 段(text/13-fm-modules.txt:66,搜「default」)。

  7. 出处:「Modules」第 117 段(text/13-fm-modules.txt:117,搜「improvised」)与第 134 段(text/13-fm-modules.txt:134,搜「does not declare dependencies」)。

  8. 出处:「Modules」第 136 段(text/13-fm-modules.txt:136,搜「CommonJS」)与第 138 段(text/13-fm-modules.txt:138,搜「require」)。

  9. 出处:「Modules」第 176 段(text/13-fm-modules.txt:176,搜「Function(」)与第 183 段(text/13-fm-modules.txt:183,搜「dangerous」)。 2 3

  10. 出处:「Modules」第 191 段(text/13-fm-modules.txt:191,搜「normal function」)。

  11. 出处:「Modules」第 193 段(text/13-fm-modules.txt:193,搜「no real reason」)。

  12. 出处:「Modules」第 140 段(text/13-fm-modules.txt:140,搜「ordinal」)与第 162 段(text/13-fm-modules.txt:162,搜「format-date」)。

  13. 出处:「Modules」第 284 段(text/13-fm-modules.txt:284,搜「circular dependency」)与第 286 段(text/13-fm-modules.txt:286,搜「until after they finish loading」)。

  14. 出处:「Modules」第 84 段(text/13-fm-modules.txt:84,搜「fix it in whichever」)与第 86 段(text/13-fm-modules.txt:86,搜「package」)。

  15. 出处:「Modules」第 92 段(text/13-fm-modules.txt:92,搜「two things」)与第 94 段(text/13-fm-modules.txt:94,搜「rubbish」)。

  16. 出处:「Node.js」第 106 段(text/23-fm-node-js.txt:106,搜「full control」)。

  17. 出处:「Node.js」第 110 段(text/23-fm-node-js.txt:110,搜「package.json」)与第 127 段(text/23-fm-node-js.txt:127,搜「dependencies listed」)。

  18. 出处:「Node.js」第 133 段(text/23-fm-node-js.txt:133,搜「semantic versioning」)与第 135 段(text/23-fm-node-js.txt:135,搜「caret character」)。

  19. 出处:「Modules」第 102 段(text/13-fm-modules.txt:102,搜「publish」)。

  20. 出处:「Modules」第 199 段(text/13-fm-modules.txt:199,搜「10 seconds」)。

  21. 出处:「Modules」第 201 段(text/13-fm-modules.txt:201,搜「minifiers」)。

  22. 出处:「Modules」第 203 段(text/13-fm-modules.txt:203,搜「multiple stages」)。

  23. 出处:「Modules」第 213 段(text/13-fm-modules.txt:213,搜「parse and stringify」)、第 217 段(text/13-fm-modules.txt:217,搜「composed」)与第 221 段(text/13-fm-modules.txt:221,搜「use an array」)。

  24. 出处:「Modules」第 227 段(text/13-fm-modules.txt:227,搜「dijkstrajs」)与第 244 段(text/13-fm-modules.txt:244,搜「barrier to composition」)。

  25. 出处:「Modules」第 246 段(text/13-fm-modules.txt:246,搜「step back」)。