深入理解构建
在 "基础使用" 的部分,我们已经知道可以通过 buildConfig
配置对项目的输出产物进行修改。buildConfig
不仅描述了产物的一些特性,同时还为构建产物提供了一些功能。
TIP
如果你还不了解 buildConfig
的作用,请先阅读 修改输出产物。
而在本章里我们将要深入理解某些构建配置的作用以及了解执行 modern build
命令的时候发生了什么。
bundle
/ bundleless
那么首先我们来了解一下 bundle 和 bundleless。
所谓 bundle 是指对构建产物进行打包,构建产物可能是一个文件,也有可能是基于一定的代码拆分策略得到的多个文件。
而 bundleless 则是指对每个源文件单独进行编译构建,但是并不将它们打包在一起。每一个产物文件都可以找到与之相对应的源码文件。bundleless 构建的过程,也可以理解为仅对源文件进行代码转换的过程。
它们有各自的好处:
- bundle 可以减少构建产物的体积,也可以对依赖预打包,减小安装依赖的体积。提前对库进行打包,可以加快应用项目构建的速度。
- bundleless 则是可以保持原有的文件结构,更有利于调试和 tree shaking。
WARNING
bundleless 是单文件编译模式,因此对于类型的引用和导出你需要加上 type
字段, 例如 import type { A } from './types
,背景参考 esbuild 文档。
在 buildConfig
中可以通过 buildConfig.buildType
来指定当前构建任务是 bundle 还是 bundleless。
input
/ sourceDir
buildConfig.input
用于指定读取源码的文件路径或者目录路径,其默认值在 bundle 和 bundleless 构建过程中有所不同:
- 当
buildType: 'bundle'
的时候,input
默认值为 src/index.(j|t)sx?
- 当
buildType: 'bundleless'
的时候,input
默认值为 ['src']
从默认值上我们可以知道:使用 bundle 模式构建时一般指定一个或多个文件作为构建的入口,而使用 bundleless 构建则是指定一个目录,将目录下所有文件作为入口。
sourceDir
用于指定源码目录,它只与以下两个内容有关系:
因此我们可以得到其最佳实践:
- 在 bundle 构建过程中,只能指定
input
。
- 一般情况下,bundleless 只需要指定
sourceDir
(此时 input
会与 sourceDir
保持一致)。
如果我们想要在 bundleless 里只对一部分文件进行转换,例如只需要转换 src/runtime
目录的文件,此时需要配置 input
:
modern.config.ts
import { defineConfig } from '@modern-js/module-tools';
export default defineConfig({
buildConfig: {
input: ['src/runtime'],
sourceDir: 'src',
},
});
使用 swc
在部分场景下,esbuild 不足以满足我们的需求,此时我们会使用 swc 来做代码转换。
从 2.36.0 版本开始,涉及到以下功能时,Modern.js Module 默认会使用 swc ,但不这意味着不使用 esbuild 了,其余功能还是使用 esbuild:
事实上,我们在 2.16.0 版本开始全量使用 swc 进行代码转换。不过 swc 同样也存在一些限制,为此我们添加了 sourceType 配置,当源码格式为 'commonjs' 时关闭 swc, 但这种方式并不符合用户直觉,另外,swc 格式化输出的 cjs 模式没有给每个导出名称添加注释,这在 node 中使用可能会带来一些问题。
因为我们废弃了此行为,回到了最初的设计 - 只在需要的场景下使用 swc 作为补充。
使用 Hook 介入构建流程
Modern.js Module 提供了 Hook 机制,允许我们在构建流程的不同阶段注入自定义逻辑。
Modern.js Module Hook 使用了 tapable 实现,扩展了 esbuild 的插件机制,若 esbuild plugins 已经满足了你的需求,建议直接使用它。
下面展开说明其用法:
Hook 类型
AsyncSeriesBailHook
串行执行的 hooks,如果某个 tapped function 返回非 undefined 结果,则后续其他的 tapped function 停止执行。
AsyncSeriesWaterFallHooks
串行执行的 hooks,其结果会传递给下一个 tapped function
Hook 顺序
Hook 的执行顺序和注册顺序保持一致,可以通过 applyAfterBuiltIn
来控制在 BuiltIn Hook 前或后注册。
Hook API
load
interface LoadArgs {
path: string;
namespace: string;
suffix: string;
}
type LoadResult =
| {
contents: string; // 模块内容
map?: SourceMap; // https://esbuild.github.io/api/#sourcemap
loader?: Loader; // https://esbuild.github.io/api/#loader
resolveDir?: string;
}
| undefined;
compiler.hooks.load.tapPromise('load content from memfs', async args => {
const contents = memfs.readFileSync(args.path);
return {
contents: contents,
loader: 'js',
};
});
transform
- AsyncSeriesWaterFallHooks
- 在 esbuild onLoad callbacks 触发,
将 load 阶段获取的模块内容进行转换
- 输入参数(返回参数)
export type Source = {
code: string;
map?: SourceMap;
path: string;
loader?: string;
};
compiler.hooks.transform.tapPromise('6to5', async args => {
const result = babelTransform(args.code, { presets: ['@babel/preset-env'] });
return {
code: result.code,
map: result.map,
};
});
renderChunk
- AsyncSeriesWaterFallHooks
- 在 esbuild onEnd callbacks 触发,
类似于 transform hook,但是作用在 esbuild 生成的产物
- 输入参数(返回参数)
export type AssetChunk = {
type: 'asset';
contents: string | Buffer;
entryPoint?: string;
/**
* absolute file path
*/
fileName: string;
originalFileName?: string;
};
export type JsChunk = {
type: 'chunk';
contents: string;
entryPoint?: string;
/**
* absolute file path
*/
fileName: string;
map?: SourceMap;
modules?: Record<string, any>;
originalFileName?: string;
};
export type Chunk = AssetChunk | JsChunk;
compiler.hooks.renderChunk.tapPromise('minify', async chunk => {
if (chunk.type === 'chunk') {
const code = chunk.contents.toString();
const result = await minify.call(compiler, code);
return {
...chunk,
contents: result.code,
map: result.map,
};
}
return chunk;
});
类型文件生成
buildConfig.dts
配置主要用于类型文件的生成。
关闭类型生成
默认情况下类型生成功能是开启的,如果需要关闭的话,可以按照如下配置:
modern.config.ts
import { defineConfig } from '@modern-js/module-tools';
export default defineConfig({
buildConfig: {
dts: false,
},
});
打包类型文件
在 buildType: 'bundleless'
的时候,类型文件的生成是使用项目的 tsc
命令来完成生产。
Modern.js Module 同时还支持对类型文件进行打包,不过使用该功能的时候需要注意:
- 对类型文件进行打包不会开启类型检查。
- 一些第三方依赖存在错误的语法会导致打包过程失败。因此对于这种情况,需要手动通过
buildConfig.externals
将这类第三方包排除,或者直接关闭dts.respectExternal从而不打包任何三方包类型。
- 对于第三方依赖的类型文件指向的是一个
.ts
文件的情况,目前无法处理。比如第三方依赖的 package.json
中存在这样的内容: {"types": "./src/index.ts"
。
对于上述问题,我们推荐的处理方式是首先使用 tsc
生成 d.ts 文件,然后将 index.d.ts 作为入口进行打包处理,并且关闭 dts.respectExternal
。在之后的演进我们也会逐渐向这种处理方式靠拢。
别名转换
在 bundleless 构建过程中,如果源代码中出现了别名,例如:
./src/index.ts
import utils from '@common/utils';
使用 tsc
生成的产物类型文件也会包含这些别名。不过 Modern.js Module 会对 tsc
生成的类型文件里的别名进行转换处理。
一些示例
import { defineConfig } from '@modern-js/module-tools';
export default defineConfig({
// 此时打包的类型文件输出路径为 `./dist/types`,并且将会读取项目下的 other-tsconfig.json 文件
buildConfig: {
buildType: 'bundle',
dts: {
tsconfigPath: './other-tsconfig.json',
distPath: './types',
},
outDir: './dist',
},
});
import { defineConfig } from '@modern-js/module-tools';
export default defineConfig({
// 此时类型文件没有进行打包,输出路径为 `./dist/types`
buildConfig: [
{
buildType: 'bundle',
dts: false,
outDir: './dist',
},
{
buildType: 'bundleless',
dts: {
only: true,
},
outDir: './dist/types',
},
],
});
构建过程
当执行 modern build
命令的时候,会发生
- 根据
buildConfig.outDir
清理产物目录。
- 编译
js/ts
源代码生成 bundle / bundleless 的 JS 构建产物。
- 使用
tsc
生成 bundle / bundleless 的类型文件。
- 处理
copy
任务。
构建报错
当发生构建报错的时候,基于以上了解到的信息,可以很容易的明白在终端出现的报错内容:
js 或者 ts 构建的报错:
error ModuleBuildError:
╭───────────────────────╮
│ bundle failed: │
│ - format is "cjs" │
│ - target is "esnext" │
╰───────────────────────╯
Detailed Information:
类型文件生成过程的报错:
error ModuleBuildError:
bundle DTS failed:
对于 js/ts
构建错误,我们可以从报错信息中知道:
- 报错的
buildType
- 报错的
format
- 报错的
target
- 其他具体报错信息
调试模式
从 2.36.0 版本开始,为了便于排查问题,Modern.js Module 提供了调试模式,你可以在执行构建时添加 DEBUG=module 环境变量来开启调试模式。
DEBUG=module modern build
调试模式下,你会看到 Shell 中输出更详细的构建日志,这主要以流程日志为主:
module run beforeBuildTask hooks +6ms
module run beforeBuildTask hooks done +0ms
module [DTS] Build Start +139ms
module [CJS] Build Start +1ms
另外,Module 还提供了调试内部工作流程的能力。你可以通过设置环境变量 DEBUG=module:*
来开启更详细的调试日志:
目前只支持了 DEBUG=module:resolve
,可以查看 Module 内部模块解析的详细日志:
module:resolve onResolve args: {
path: './src/hooks/misc.ts',
importer: '',
namespace: 'file',
resolveDir: '/Users/bytedance/modern.js/packages/solutions/module-tools',
kind: 'entry-point',
pluginData: undefined
} +0ms
module:resolve onResolve result: {
path: '/Users/bytedance/modern.js/packages/solutions/module-tools/src/hooks/misc.ts',
external: false,
namespace: 'file',
sideEffects: undefined,
suffix: ''
} +0ms