source-file-plugin
一、认识
SourceFilePlugin
是一个自定义的 Webpack
插件。收集 Entry 依赖的所有构建产物, 包括入口自身构建产物、SplitChunk 拆分后的产物、通过 import() 动态加载的产物, 并整理每个入口模块相关的 CSS
和 JS
资源,并根据配置调整资源加载的优先级, 将这些信息记录到 source.json, 生成的 source.json
文件会被写入到每个入口模块的文件夹中。这样,每个应用可以根据 source.json
中的资源列表,确定需要加载哪些资源,以及这些资源的加载顺序。SourceFilePlugin
可以与 HtmlWebpackPlugin
的 inject
配置结合使用,inject
控制是否自动注入资源文件。当将 inject
设置为 false
时,HTML
文件不会自动注入任何资源。此时,我们可以通过 Node.js
模版分发服务,根据 source.json
中已经排序好的 JS
和 CSS
资源列表,手动插入 <script>
和 <link>
标签,确保每个应用只加载所需资源,并避免资源冲突或重复加载。例如,CSS
文件的加载顺序可以根据优先级进行控制,避免样式覆盖;同时,子应用的资源可以按需加载,确保性能优化并减少冗余加载。通过这种方式,我们也能够灵活控制微前端架构中资源的加载顺序和管理,确保每个应用的资源按需加载,避免重复加载,提升了资源管理的效率和应用性能。具体逻辑如下:
-
apply(compiler) 方法中, 注册 compiler.hooks.afterEmit 钩子, compiler.hooks.afterEmit 是在所有文件已经写入输出目录后触发的钩子。它通常用于在文件写入完成后执行一些操作,例如生成额外的文件或执行一些后处理任务。所以,我们在 compiler.hooks.afterEmit 钩子中, 获取 compilation 对象
-
通过 compilation.entrypoints.get(entry) 获取当前 Entry 的模块对象
-
通过 entryModule.chunks 获取 entryModule 所有 chunks
-
接下来获取通过 SplitChunks 拆分的产物路径, SplitChunks 拆分出来的产物我们认为是所有 Entry 共有的, 因此, 需要为每一个 Entry 都获取。通过 Webpack 配置中的 splitChunks.cacheGroups 获取拆分的 chunk 配置, 遍历每个 splitChunks.cacheGroup,从 Object.keys(compilation.assets) 中查找与该拆分 chunk 相关的文件(根据文件名匹配),查找出来的产物就是 SplitChunks 拆分的产物路径(其实, 正常情况下, entryModule.getFiles() 是包含 SplitChunks 产物的, 但是在多页面下,引用场景很复杂, A 和 B 都引入 Common, 此时, A 中有, B 就没有, 所以还是要处理下)
-
最后就是找出 Entry 依赖的 通过 import() 动态加载的产物, Webpack 中没有提供直接 API, 所以, 通过 entryModule.chunks 获取 entryModule 所有 chunks, 遍历这些 chunks, 通过 entryChunk.getAllAsyncChunks() 获取所有动态加载的产物 chunks(注意: getAllAsyncChunks 获取的结果不是当前 Entry 的,是所有 Entry 的), 然后在遍历所有动态加载的产物 chunks, 每一个动态加载的 chunk 有一个 groupsIterable 属性, 对每个异步 chunk,通过 groupsIterable 获取它的所有 chunk group, 递归查找 chunkGroup.getParents 对应的父级依赖, 并从中提取每个父级的 id 或 name, 将每个父级的 id 或 name 添加到 entrypoints 集合中,如果当前父级没有 id 或 name,则继续向上查找父级。 父级就是每个 Entry。所以, 我们可以通过判断 Entry 是否包含在 entrypoints 来判断当前的 asyncChunk 是否为当前 Entry 的依赖。
扩展知识点、UMU 输入 URL 之后的过程: 请求到达 Node BFF 层, 获取用户数据、页面所需数据, 获取当前页面指定静态资源存放位置, 该目录下有 .tpl 模版文件, source.json 文件, lang.json 文件, 读取 .tpl 格式的模版(.tpl 模版由前端代码来维护), 注入数据, 读取 source.json 文件, 将里面的 js、css 拼接为 <script>/<link>
, 注入到模版, 然后渲染为 .html
二、实现
const fs = require("fs");
const path = require("path");
const PLUGIN_NAME = "SourceFilePlugin";
class SourceFilePlugin {
constructor(options) {
this.options = options;
}
writeFile(compilation, entry, data) {
const outputPath = compilation.compiler.outputPath;
const dir = path.join(outputPath, entry);
try {
fs.accessSync(dir);
} catch (e) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(
path.join(outputPath, entry, `source.json`),
JSON.stringify(data, null, 2),
{
flag: "w",
}
);
}
getChunks(entryModule) {
return entryModule.chunks.map((chunk) => ({
name: chunk.name,
files: chunk.files,
}));
}
getCssChunksOfPriority(chunks, cssChunkPriorityMap) {
if (!cssChunkPriorityMap) {
return [...chunks];
}
const cssChunksOfPriority = chunks.map((chunk) => {
const item = { ...chunk };
item.priority = cssChunkPriorityMap[chunk.name] || 0;
return item;
});
cssChunksOfPriority.sort((a, b) => a.priority - b.priority);
return cssChunksOfPriority;
}
findEntrypoints(chunkGroup) {
const entrypoints = new Set();
const stack = [chunkGroup];
while (stack.length) {
const group = stack.pop();
const parents = group.getParents ? group.getParents() : [];
for (const parent of parents) {
const parentId = parent?.id || parent?.options?.id;
const parentName = parent?.name || parent?.options?.name;
if (parentName || parentId) {
entrypoints.add(parentName || parentId);
} else {
stack.push(parent);
}
}
}
return [...entrypoints];
}
getEntryAsyncChunks(entry, entryModule) {
const entryAsyncChunks = [];
const entryChunks = entryModule.chunks;
entryChunks.forEach((entryChunk) => {
const asyncChunks = entryChunk.getAllAsyncChunks();
asyncChunks.forEach((asyncChunk) => {
let flag = false;
asyncChunk.groupsIterable.forEach((chunkGroup) => {
const entryNames = this.findEntrypoints(chunkGroup);
flag = entryNames.includes(entry);
});
if (flag) {
entryAsyncChunks.push({
id: asyncChunk.id,
files: [...asyncChunk.files.values()],
});
}
});
});
return entryAsyncChunks;
}
apply(compiler) {
const { keys = [], entry, cssChunkPriorityMap = {} } = this.options;
compiler.hooks.afterEmit.tapPromise(PLUGIN_NAME, async (compilation) => {
const entryModule = compilation.entrypoints.get(entry);
if (!entryModule) {
return;
}
const chunks = this.getChunks(entryModule);
const lazyChunks = this.getEntryAsyncChunks(entry, entryModule);
const cssChunksOfPriority = this.getCssChunksOfPriority(
chunks,
cssChunkPriorityMap
);
await this.writeFile(compilation, entry, {
data: {
i18n: {
scope: [],
keys: keys,
name: entry,
},
headers: {
css: cssChunksOfPriority.map((chunk) => ({
name: chunk.name,
files: [...chunk.files.values()].filter((path) =>
/\.css$/.test(path)
),
})),
},
footers: {
js: chunks.map((chunk) => ({
name: chunk.name,
files: [...chunk.files.values()].filter((path) =>
/\.js$/.test(path)
),
})),
},
lazyChunks,
public: compilation.outputOptions.publicPath,
},
public: compilation.outputOptions.publicPath,
});
});
}
}
module.exports = SourceFilePlugin;
三、配置
const { merge } = require("webpack-merge");
const common = require("./rspack.config.common");
const SourceFilePlugin = require("./plugin/sourceFilePlugin");
module.exports = merge(common, {
mode: "production",
devtool: "source-map",
plugins: [
new SourceFilePlugin({ entry: "pageA" }),
new SourceFilePlugin({ entry: "pageB" }),
new SourceFilePlugin({ entry: "pageC" }),
],
});