手动代码拆分
手动代码拆分是一项强大的功能,可作为 自动代码拆分 的补充。当你希望将应用拆分成更小、更易管理的部分,以优化加载性能时,它会非常有用。
阅读本指南前,你应该先了解 Rolldown 的 自动代码拆分 功能。本指南将说明手动代码拆分的工作原理,以及如何有效地使用它。
在深入细节之前,我们先澄清几点:
- 自动代码拆分与手动代码拆分并不冲突。使用手动代码拆分不代表禁用自动代码拆分。 根据配置,一个模块会由自动代码拆分或手动代码拆分处理,但不会同时由两者处理。如果某个模块未被手动代码拆分捕获,它仍会按照 自动代码拆分 指南中介绍的规则,被放入自动代码拆分创建的代码块中。
为什么使用手动代码拆分?
自动代码拆分不会考虑加载性能或缓存失效,只会根据模块的静态导入对它们进行分组。这可能产生不理想的代码块,例如创建体积过大的代码块,既不利于加载性能,又可能导致每次部署都使缓存失效。
如何使用手动代码拆分?
来看下面的示例:
// index.jsx
import * as ReactDom from 'react-dom';
import App from './App.jsx';
ReactDom.createRoot(document.getElementById('root')).render(<App />);
// App.jsx
import * as React from 'react';
import { Button } from 'ui-lib';
export default function App() {
return <Button onClick={() => alert('Button clicked!')} />;
}得到以下输出:
// node_modules/react/index.js
'React library code';
// node_modules/ui-lib/index.js
'UI library code';
// node_modules/react-dom/index.js
'ReactDOM library code';
// App.js
function App() {
return <Button onClick={() => alert('Button clicked!')} />;
}
// index.js
ReactDom.createRoot(document.getElementById('root')).render(<App />);在此示例中:
- 我们使用了 3 个库:
react、react-dom和ui-lib。 output-hash0.js是 Rolldown 生成的输出文件。hash0是输出文件的哈希值;文件内容变化时,该值也会变化。
减少缓存失效
先来讨论缓存失效。这里的缓存失效是指,当你部署应用的新版本时,浏览器需要下载新版文件。如果文件很大,就可能造成不佳的用户体验。
例如,如果你修改了 app.jsx 文件:
function App() {
return <Button onClick={() => alert('Button clicked!')} />;
return <Button onClick={() => alert('Button clicked!!!')} />;
}自然会得到一个 output-hash1.js 文件。除了 App 函数中的改动,它与 output-hash0.js 内容相同。
现在,如果部署这个新版本,浏览器需要下载整个 output-hash1.js 文件,尽管其中仅有一小部分发生了变化。这是因为文件的哈希值已改变,浏览器会将它视为新文件。
为解决这个问题,可以使用 codeSplitting 选项将输出中的库拆分到单独的代码块中,因为与应用代码相比,这些库通常不会频繁变化。
export default {
// ……其他配置
output: {
codeSplitting: {
groups: [
{
test: /node_modules/,
name: 'libs',
},
],
},
},
};使用上述 codeSplitting 选项后,输出如下:
import ... from './libs-hash0.js';
// App.js
function App() {
return <Button onClick={() => alert("Button clicked!")} />;
}
// index.js
ReactDom.createRoot(document.getElementById("root")).render(<App />);// node_modules/react/index.js
"React library code";
// node_modules/ui-lib/index.js
"UI library code";
// node_modules/react-dom/index.js
"ReactDOM library code";
export { ... };例如,修改 app.jsx 文件后:
function App() {
return <Button onClick={() => alert('Button clicked!')} />;
return <Button onClick={() => alert('Button clicked!!!')} />;
}将得到以下输出:
import ... from './libs-hash0.js';
// App.js
function App() {
return <Button onClick={() => alert("Button clicked!!!")} />;
}
// index.js
ReactDom.createRoot(document.getElementById("root")).render(<App />);// node_modules/react/index.js
"React library code";
// node_modules/ui-lib/index.js
"UI library code";
// node_modules/react-dom/index.js
"ReactDOM library code";
export { ... };libs-hash0.js文件没有变化,因此浏览器可以使用其缓存版本。output-hash1.js文件发生了变化,因此浏览器会下载新版本。
提升加载性能
手动代码拆分还可以将应用拆分为数量合理的代码块,利用浏览器的并行加载能力来提升加载性能。
在上一个示例中,我们将所有库放进了同一个代码块,这对加载性能而言并非最优。如果这些库体积过大,浏览器就要花费很长时间下载该代码块,进而造成不佳的用户体验。
为解决这个问题,可以使用 codeSplitting 选项将各个库拆分到单独的代码块中,让浏览器并行下载它们。
export default {
// ……其他配置
output: {
codeSplitting: {
groups: [
{
test: /node_modules\/react/,
name: 'react',
},
{
test: /node_modules\/react-dom/,
name: 'react-dom',
},
{
test: /node_modules\/ui-lib/,
name: 'ui-lib',
},
],
},
},
};使用上述 codeSplitting 选项后,输出如下:
import ... from './react-hash0.js';
import ... from './react-dom-hash0.js';
import ... from './ui-lib-hash0.js';
// App.js
function App() {
return <Button onClick={() => alert("Button clicked!")} />;
}
// index.js
ReactDom.createRoot(document.getElementById("root")).render(<App />);"React library code";
export { ... };"ReactDOM library code";
export { ... };"UI library code";
export { ... };现在,各个库已拆分到独立的代码块中,浏览器可以并行下载它们。这可以显著提升应用的加载性能,尤其是当这些库体积较大时。
限制
为什么总会有一个 runtime.js 代码块?
简而言之:如果通过分组使用手动代码拆分,Rolldown 会强制生成一个 runtime.js 代码块,确保运行时代码始终先于其他代码块执行。
runtime.js 是一个特殊代码块,只包含加载和执行应用所需的运行时代码。打包器会强制生成它,确保运行时代码始终先于其他代码块执行。
由于手动代码拆分允许在代码块之间移动模块,因此很容易在输出代码中产生循环导入。这可能导致运行时代码未能在其他代码块之前执行,进而使应用出错。
以下是一段存在循环导入的输出代码示例:
// first.js
import { __esm, __export, init_second, value$1 as value } from './second.js';
var first_exports = {};
__export(first_exports, { value: () => value$1 });
var value$1;
var init_first = __esm({
'first.js'() {
init_second();
// ...
},
});
export { first_exports, init_first, value$1 as value };
// main.js
import { first_exports, init_first } from './first.js';
import { __esm, init_second, second_exports } from './second.js';
var init_main = __esm({
'main.js'() {
init_first();
init_second();
// ...
},
});
init_main();
// second.js
import { init_first, value } from './first.js';
var __esm = '...';
var __export = '...';
var second_exports = {};
__export(second_exports, { value: () => value$1 });
var value$1;
var init_second = __esm({
'second.js'() {
init_first();
// ...
},
});
export { __esm, __export, init_second, second_exports, value$1 };运行 node ./main.js 时,模块的遍历顺序是 main.js -> first.js -> second.js,而模块执行顺序是 second.js -> first.js -> main.js。
second.js 尝试在 __esm 函数初始化之前调用它。这会导致运行时错误,因为代码实际上是在尝试将 undefined 作为函数调用。
通过强制生成 runtime.js,打包器可以确保任何依赖运行时代码的代码块都会先加载 runtime.js,再执行自身。这样能保证运行时代码始终先于其他代码块执行,从而避免循环导入问题。
为什么分组中包含不满足约束条件的模块?
当一个模块被某个分组捕获时,Rolldown 会尝试递归捕获它的依赖项,而不再考虑约束条件。这是因为 Rolldown 默认只允许改写非入口代码块的导出。
例如,有以下代码:
// entry.js
import { value } from './a.js';
console.log(value);
export const foo = 'foo';
// a.js
import { value as valueB } from './b.js';
export const value = 'a' + valueB;
// b.js
export const value = 'b';假设我们想将 a.js 模块移入单独的代码块,同时让 b.js 模块与 entry.js 留在同一代码块中,会得到:
import { value } from './a.js';
// b.js
const value = 'b';
// entry.js
const foo = 'foo';
console.log(value);
export { foo, value };import { value } from './entry.js';
// a.js
export const value = 'a' + value;可以看到,为了让 a.js 正常工作,我们必须修改入口代码块 entry.js 的导出签名,并额外导出 value。这完全违背了原代码只从 entry.js 导出 foo 的意图。
如果不希望出现这种行为,可以使用 codeSplitting.includeDependenciesRecursively: false 将其禁用。
注意事项
使用 includeDependenciesRecursively: false 时,分组依赖的模块可能会留在入口代码块中。从入口代码块导出非入口模块是无效的。为避免这种情况,如果没有显式设置 preserveEntrySignatures,Rolldown 会隐式将其设为 'allow-extension'。
includeDependenciesRecursively: false 会增加生成无效输出代码的可能性。如果遇到执行顺序或循环依赖造成的问题,可以考虑启用:
为什么代码块会大于 maxSize?
maxSize 是一个目标值,而不是严格限制。在以下情况下,代码块可能超过该值:
- 如果单个模块大于
maxSize,生成的代码块就会超出限制。Rolldown 目前不支持将单个模块拆分到多个代码块中。 - Rolldown 会优先考虑
minSize配置。如果拆分一个大代码块会使新代码块低于minSize阈值,Rolldown 会保留原代码块,不进行拆分,以免生成过小的文件。