再谈模块化:Node中ESM与CJS的解析策略
本文讨论了Node.js中ESM与CJS的模块解析策略,强调了tsconfig.json中的module和moduleResolution字段对模块解析的影响。通过分析Node对两种模块标准的支持,指出了配置不匹配导致的错误,并提供了解决方案,包括将moduleResolution修改为Node16以确保正确的解析算法。最后,强调了在使用ESM时必须在导入语句中包含文件扩展名。
TypeScript 的类型声明文件 .d.ts 提供了 JavaScript 代码的附加元信息,支持类型提示和自动补全。使用 DefinitelyTyped 仓库可以为没有类型声明的 npm 包提供类型支持,类型定义可通过 type 和 interface 进行,declare 语法用于为已有的 JS 变量或函数添加类型。三斜杠指令用于引入额外的文件依赖。
我入坑前端的时候,Typescript 已经成为了事实 npm 包的事实标准,很多 npm 包都带有 .d.ts
的类型,让我能享受即时的类型提示和自动补全。
但是,在自己写工程的时候,又不常会接触到 .d.ts
文件,一般都是为某个缺失的 module 手动声明一个类型,或者直接使用脚手架生成的类型文件,几乎没有其他了解。因此,在遇到一些问题时,比如「为什么 import
后就不能为全局声明类型」,就没有办法解决了。
浏览器和 NodeJS 都只能运行 Javascript 代码(本质上是 V8 只认 JS),但 JS 不携带类型等元信息, d.ts
就是一种「附加元信息」,可以理解为 JS 代码的一种「特殊注释」,Typescript 的语言服务器会读取这些信息来给 IDE 提供类型提示和自动补全,而这些类型信息并不会被 runtime 读取来影响运行进程
dts
的出现让类型声明和实际运行的代码可以分离开,使类型的引入变得无痛,很快就成为了 npm 包的事实标准
但是,早期的 npm 包都是纯 JS 编写的,不可能全部用 TS 重写一次,只能用一些类似注释的方法为其添加类型说明,为此 DefinitelyTyped 被引入了。
DefinitelyTyped 是一个专门存储类型定义的仓库,专门用于那些没有自带类型声明文件的 npm 包。
tsconfig.json
读取该文件在 Typescript 2.0 之后,在 npm 上DefinitelyTyped 会把类型声明发布到 @types
这个域下,在本地 TS 语言服务器会默认读取 node_modules/@types
文件夹下的类型声明,因此不再需要安装独立的包管理器就可以安装类型声明;使用 tsconfig.json
可以定义类型加载行为:
typeRoots
默认包含了 node_modules/@types
这个目录,手动定义会覆盖此默认设置@types
下的包都会被加载,但如果定义了 types
字段,则只会加载指定的类型d.ts
文件的仓库;d.ts
是独立单位,是真正存储类型信息的底层机制,上面的都只是一种分发模式。后面许多 npm 包原生使用 TS 编写,在发布前会运行一次转译过程,将 ts
源文件编译为用于运行的 js
文件和用于提供类型信息的 d.ts
文件。
对于一些本地的、需要补充类型或者添加全局类型的场景,如:
window
和 global
)里添加了某些成员,为了避免 TS 报错手动添加成员类型d.ts
文件本质上是一种特殊的 ts
文件,专注于「类型声明」这一领域,支持以下场景和用例:
type
或 interface
)类型定义部分与普通 .ts
文件表现一致,只是在模块化策略上有区别:
ts
文件都被视为模块d.ts
文件默认都是全局的,除非使用了 import
和 export
才会被视为模块有两种定义类型(变量形状)的方法: type
和 interface
,它们在大部分情况下没有区别。
type
定义能做的事type MyType = string
interface
定义能做的事implements
和 extends
interface
以合并类型interface
和 type
?无关优劣,只看语义,我的个人习惯如下:
interface
type
declare
语法decalre
是 Typescript 中定义类型的一种方法,一般用于给已有的 JS 变量或函数添加类型。
需要注意的是:
decalre
声明变量类型,而不能定义具体变量;但 declare
可以在所有 ts 源文件中使用,不限制在 d.ts 中使用type
和 interface
不需要使用 decalre
,但是 declare type
是合法的,可以用于重新声明某个类型或添加全局decalre
语句的作用和应用场景declare namespace Obj {}
声明一个全局命名空间
// jquery.d.ts
declare namespace $ {
interface JQuery {
// 定义 jQuery 方法
html(content: string): this; // 设置或获取 HTML 内容
css(property: string, value?: string): this; // 设置或获取 CSS 属性
on(event: string, handler: (event: Event) => void): this; // 绑定事件处理程序
// 其他 jQuery 方法...
}
// 定义全局 jQuery 函数
function (selector: string): JQuery;
}
declare function fn(): Type
声明或重载函数类型
declare var foo: number
声明全局变量类型declare class MyClass {}
声明全局 class
的类型
class
的区别: declare class
只提供类型定义,不提供具体实现declare module 'example-package' {}
在全局范围内补充声明某个模块的类型定义declare interface
和 declare type
重新声明已有的类型或接口,表明其存在或修改定义
与直接使用 type
和 interface
的区别:
declare
通常用于声明已有的接口或类型,直接使用往往会定义一个新的declare
往往没有区别Typescript 和 Javascript 处理模块化的逻辑是一致的,没有任何顶层 export
和 import
或顶层 await
语句的代码文件都会被视为全局脚本而非模块。
d.ts
只是一种特殊的、只包含类型定义的 ts
文件,模块判定方式与普通 ts
文件一致,因此默认情况下, d.ts
都是全局的,但是出现了模块化关键字(如 import
和 exports
等)后,就会被视为模块。
关于如何为模块添加类型定义,通常有以下两种方式:
查找模块类型定义 import a from "a"
的来源,合并所有定义
declare module "a"
a.ts
, a.d.ts
中的类型定义包引用:
package.json
中的 types
定义@types
包下的类型声明.d.ts
文件declare module
语法,在全局类型声明中添加;对于为模块创建类型声明(包内携带类型或发布 @types
包)的场景,应该使用 export
或 exports
语法,创建模块化的类型声明文件;
假设我们处于一个编写 npm 包的场景中,我们使用 Typescript 编写源代码,使用 tsc
将源代码转译为 js 和 d.ts 文件,根据输出的模块类型不同,我们使用不同的类型声明语法:
export =
语法,等价于 module.exports =
export
和 export default
语法,与正常 TS 文件无异esModuleInterop
属性import * as
)以获得最佳兼容性;对于任何 TS 项目,都应该开启 esModuleInterop
属性以无害地获得 CJS 互操作性(如果不使用 tsc 编译,请额外留意构建工具行为)导入 CJS 模块时,Typescript 提供了多种语法支持,具体由 tsconfig.json
中的 esModuleInterop
属性开关决定
关闭时:严格检验 ESM 模块规范,对于 CommonJS 模块必须使用以下语法
import a = require('a')
:NodeJS CJS 引用语法(不推荐)import * as fs from "fs"
:命名空间导出语法,将整个模块作为一个对象引入(对于 ESM 模块也使用此语法)开启后:允许使用 ESM 格式导入 CommonJS 模块,开启后 TS 编译器会对 CJS 模块做一些预处理以支持 ESM 导入
import React from "react"
那么, esModuleInterop
做了什么工作呢?其实非常简单。
下面的例子中 tsc 的 target 都是 commonjs
default
中取值还是直接从对象中取值,参考下面的代码(未开启 esModuleInterop
)// 编译前
import fs from 'fs';
import * as fsBak from 'fs';
fs.copyFile('', '', () => {});
fsBak.copyFile('', '', () => {});
// 编译后:CJS
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = require("fs");
const fsBak = require("fs");
fs_1.default.copyFile('', '', () => { });
fsBak.copyFile('', '', () => { });
问题在于 CJS 中没有 default
导出,那么就在运行时加上一段代码来手动添加即可,这就是 esModuleInterop
属性开启后 tsc 完成的工作。开启后,对 CJS 的命名空间导入和默认导入行为都有改变,即将整个导出对象赋值给 default
属性
import * as React from 'react'
,会生成 __importStar
辅助函数,为 CJS 引入对象添加 default
属性,ESM 对象则不做处理,具体示例如下:// 编译前
import * as fsBak from 'fs';
fsBak.copyFile('', '', () => {});
// 编译后
"use strict";
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function (o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function (o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const fsBak = __importStar(require("fs"));
fsBak.copyFile('', '', () => { });
import React from 'react
,会生成 __importDefault
辅助函数,将 CJS 导出整个赋值给 default
属性,ESM 则不做处理,与上面的区别在于返回对象是仅包含 default
属性的新对象// 编译前
import fs from 'fs';
fs.copyFile('', '', () => {});
// 编译后
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = __importDefault(require("fs"));
fs_1.default.copyFile('', '', () => { });
三斜线指令是包含单个XML标签的单行注释。 注释的内容会做为编译器指令使用。这些指令只有在文件顶部时才有效,作用是告诉在编译器为该文件编译时要引入的额外的文件,主要应用场景是在 d.ts (特别是全局)中声明文件依赖关系(毕竟在 ts 里我为什么不直接 import
呢)
这一预处理过程类似于 C 语言里的宏展开,在编译的输入过程中按顺序将指令引用的文件额外添加到编译过程中,使用 DFS 顺序遍历文件依赖。
下面是一些常用的三斜杠指令:
/// <reference path="..." />
最常用的指令,在编译或语法分析时将指定文件添加到上下文中/// <reference types="..." />
:与 path
引用类似,不过仅用于引入 @types
下的包类型,一般只在手写 d.ts 时才会用到,日常开发时可以直接修改 tsconfig 中的 types
属性/// <reference no-default-lib="true"/
需要注意的是,只有在编译时才会展开,IDE 类型推断并不会展开。
和 /// <reference>
指令无关,不要使用顶级 import
即可不变成模块,保留全局属性,如下:
// src/interface/a.ts
interface ITest {
a: string;
}
// src/types/global.d.ts, 需要`typeRoots`包含`src/types`目录
type GlobalITest = import('src/interface/a').ITest;
// src/index.ts
const obj: GlobalITest = { a: "test" }; // 无需引入类型
作者: ChlorineC
创建于: 2025-01-03 19:12:00
更新于: 2025-01-05 09:03:00
版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
本文讨论了Node.js中ESM与CJS的模块解析策略,强调了tsconfig.json中的module和moduleResolution字段对模块解析的影响。通过分析Node对两种模块标准的支持,指出了配置不匹配导致的错误,并提供了解决方案,包括将moduleResolution修改为Node16以确保正确的解析算法。最后,强调了在使用ESM时必须在导入语句中包含文件扩展名。
在React中,useRef和useEffect常用于管理组件的可变值和副作用,但在响应DOM元素挂载时,使用useCallback作为ref的回调函数更为有效。useCallback确保在元素挂载和卸载时被调用,避免了因useRef导致的生命周期回调未触发的问题。文中还介绍了其他几种优化Node操作的方法,如使用useState、useStateRef和useRefWithCallback,以提高性能和管理复杂性。
本文深入探讨了React中的useMemo和useCallback的使用场景和性能影响。useMemo用于缓存计算结果以减少不必要的计算,适合在计算开销较大的情况下使用,而useCallback用于缓存函数定义以避免不必要的组件刷新。作者强调,过度使用这两个Hook可能导致性能下降,建议在实际需要时再使用,并提出了其他优化方案,如使用useReducer和合理组织组件逻辑。
本文介绍了前端包管理器Yarn的不同版本,特别是Yarn v2及其PnP(Plug'n'Play)机制。Yarn v1与npm相比具有离线模式和lock文件等优点,而Yarn v2引入了monorepo支持和零依赖安装。PnP机制替代了传统的node_modules目录,通过优化依赖解析过程,提高了安装速度并解决了依赖版本冲突和幽灵依赖问题。此外,文中还讨论了Yarn的插件化设计、兼容性问题及其解决方案。