前言
除了古老的 C/C++,几乎所有的编程语言都有模块系统,都有官方的包管理器。我们一般不自己实现所有的代码,实际应用开发过程中大量使用开源库和框架。这篇文章演示了如何把自己实现的库变成一个包,一个包就是你的应用,也是你的库。
随着程序越来越大,我们会将不同用途的代码放到不同的源文件。为了代码共享,我们会将部分代码提出来作为一个库。如果我们的项目越来越复杂的话,就会既有库又有可执行程序。如何组织项目的代码,如何理解一个复杂项目的代码结构。只需要掌握两点:
- 理解语言本身的模块或包的机制
- 理解包管理器或构建系统如何构建库/程序
JavaScript 语言本身没有包
JavaScript 的包不是一个语言层面的概念,是包管理器层面的概念。换句话说,JavaScript 语言本身没有包,包是 npm 的特性,让你构建、测试、分享 JS 模块。
JavaScript 只有模块,一个 .js
文件就是一个 JavaScript 模块。
JavaScript 的模块相当于是 Go 语言里面的包,只不过 Go 语言的包可以是单个目录下的多个 .go
文件。
Go语言的代码通过包(package)组织,包类似于其它语言里的库 (libraries)或者模块(modules)。一个包由位于单个目录下的一个或多个
.go
源代码文件组成, 目录定义包的作用。每个源文件都以一条package
声明语句开始,这个例子里就 是package main
, 表示该文件属于哪个包,紧跟着一系列导入(import)的包,之后是存储在 这个文件里的程序语句。
npm 包
BTW:Rust 也和 JavaScript 一样,Rust 语言本身没有包的概念,Rust 语言本身只有 Crate 和 Module。Rust 的 Module 是命名空间也是把代码分离到不同的源文件。Cargo 的包只能有一个 Library Crate,可以有多个 Binary Crate。rustc 一次考虑一个 crate。
node 一次考虑一个 JS 模块,node script.js
运行一个 JS 模块。npm 包只能有一个库,可以有多个可执行脚本。库的名字是 package.json
中的 name
,这也是包的名字,"main"
字段是这个库的入口。一个库也是一个包,package.json
描述了一个包。
我们来创建一个包,并使用它。
npm init
创建一个 JavaScript 的包,即创建 package.json
。创建 greeting
sh
mkdir greeting
cd greeting
npm init -y
我们要修改 package.json
,type: module
告诉 NodeJS 这个包的 JS 文件都是 ES 模块。greeting 的用户要使用 import 来导入包或者模块就必须做这个修改。
diff
--- i/package.json
+++ w/package.json
@@ -2,6 +2,7 @@
"name": "greeting",
"version": "1.0.0",
"main": "index.js",
+ "type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
main: index.js
是这个包的入口,我们要创建这个 index.js。index.js 是默认的 main,我们可以自定义 main。
// Filename: index.js
export function hello(name) {
return `Hi, ${name}. Welcome!`
}
这样我们就建立好了一个 JavaScript 的包,这个包提供一个 hello 函数。
创建一个名为 hello 的包,使用 greeting 这个包。
sh
mkdir hello
cd hello
npm init -y
创建 hello 包后目录结构如下
<home>/
├── greeting
│ ├── index.js
│ └── package.json
└── hello
└── package.json
使用 greeting,我们就要安装这个包,在 hello 文件夹下执行:
sh
npm i ../greeting
安装 greeting 包,npm 创建了一个 node_modules 目录,把 greeting 的代码放到了 node_modules。因为这是一个本地的包,npm 创建了一个符号链接,指向了 greeting 目录。
node_modules/
└── greeting -> ../../greeting
我们还没有在 hello 这个包里面写任何的代码,我们在 hello 这个包使用 greeting 提供的 hello 函数。
js
// Filename: hello/index.js
import { hello } from "greeting"
const message = hello("Mikami Yua")
console.log(message)
然后我们运行 hello 下的 index.js。
sh
$ node index.js
(node:2544) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///home/user/hello/index.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /home/user/hello/package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
Hi, Mikami Yua. Welcome!
index.js 就是我们 hello 程序的入口,NodeJS 会自动找到 JS 模块引用的其他 js 模块。
这里有一个警告,提示我们要消除这个警告就在 hello/package.json 中加上 "type": "module"
。NodeJS 默认使用 CommonJS,CommonJS 失败后会尝试 ES module。
npm 不仅仅是包管理器,也是包的构建工具的前端。npm build
构建这个项目,npm install
安装项目依赖。背后可能是调用 esbuild 或者其他的工具。
一个 package 就相当于是一个库,你可以引入库的某一个模块,所有的 JS 文件都是一个模块。你肯定不希望所有的 JS 文件都是公开的,有一部分代码是库内部使用的,不是 API,将来可能会改变文件的目录结构,甚至删除部分内部的函数。package.json
还有一个 "exports"
字段,显式声明这个包的哪些模块是公开的。
我们修改greeting包,使用 "exports"
,现代的 JS 项目推荐使用
diff
--- i/package.json
+++ w/package.json
@@ -1,11 +1,11 @@
{
"name": "greeting",
"version": "1.0.0",
- "main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
+ "exports": "./index.js",
"keywords": [],
"author": "",
"license": "ISC",
把 index.js 中的 hello 函数移到 hello.js 中去。
js
// Filename: greeting/index.js
// re-rexport hello
export { hello } from "./hello.js"
js
// Filename: greeting/hello.js
export function hello(name) {
return `Hi, ${name}. Welcome!`
}
我们改变了 greeting 包的结构,但是仍然提供 hello 函数,greeting 改动后 hello 包的代码不需要做任何改动,还是可以使用 node index.js
运行。
我们作为 greeting 库的作者,知道 hello 函数是 greeting/hello.js 提供的,我要直接从对应的 JS 模块导入 hello 函数。
diff
--- i/index.js
+++ w/index.js
@@ -1,5 +1,5 @@
// Filename: hello/index.js
-import { hello } from "greeting"
+import { hello } from "greeting/hello.js"
const message = hello("Mikami Yua")
console.log(message)
我们运行代码得到了 ERR_PACKAGE_PATH_NOT_EXPORTED 错误,"exports"
限定了有哪些模块是公开的。
$ node index.js
node:internal/modules/esm/resolve:314
return new ERR_PACKAGE_PATH_NOT_EXPORTED(
^
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './hello.js' is not defined by "exports" in /home/user/hello/node_modules/greeting/package.json imported from /home/user/hello/index.js
at exportsNotFound (node:internal/modules/esm/resolve:314:10)
at packageExportsResolve (node:internal/modules/esm/resolve:662:9)
at packageResolve (node:internal/modules/esm/resolve:842:14)
at moduleResolve (node:internal/modules/esm/resolve:926:18)
at defaultResolve (node:internal/modules/esm/resolve:1056:11)
at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:654:12)
at #cachedDefaultResolve (node:internal/modules/esm/loader:603:25)
at ModuleLoader.resolve (node:internal/modules/esm/loader:586:38)
at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:242:38)
at ModuleJob._link (node:internal/modules/esm/module_job:135:49) {
code: 'ERR_PACKAGE_PATH_NOT_EXPORTED'
}
Node.js v22.13.0
如果我们把 exports 从 package.json 移除,那我们就能根据包的目录结构从任意一个模块中导入。
NodeJS 的 ES Module
文档介绍了 Node.js 会把什么东西当作是 ES Module。package.json
的 type
是 "module"
, Node.js 把输入当作是 ES Moudle。.js
文件内使用了 ES6 Module 的语法,那就是一个 ES Module。
总结
Take away message : JavaScript 本身只有模块,包的概念是 npm 和 Node.js 建立的。package.json
定义了一个 JavaScript 的包。exports 字段指定了这个包的哪些模块是公开的,公开的模块可以被用户导入。
JavaScript 的包管理方式和 Rust 的包管理的方式非常相似,一个包倾向于只是一个库,或者提供多个可执行文件。
阅读材料: