跳到主要内容

collect-lang-plugin

2025年02月19日
柏拉文
越努力,越幸运

一、认识


CollectLangPlugin 插件的收集 Entry 依赖的所有构建产物中的 lang 或者 langUtils 标签。包括入口自身构建产物、SplitChunk 拆分后的产物、通过 import() 动态加载的产物。其中难点在于如何收集 import() 动态加载的产物。以下是主要实现:

  1. apply(compiler) 方法中, 注册 compiler.hooks.compilation 钩子, compilation 钩子在 Compiler 创建完 compilation 对象之后执行

  2. 在 compiler.hooks.compilation 钩子中, 注册 compilation.hooks.processAssets 或者 compilation.hooks.afterProcessAssets 钩子, assets 是在资源 assets 处理过程中执行操作, 允许插件在资源生成和优化的不同阶段插入自定义逻辑。afterProcessAssets 钩子是在所有资源处理完成后触发的钩子, 它通常用于在资源处理完成后执行一些清理或最终操作。在资源处理过程中, 我们通过 compilation.entrypoints.get(entry) 获取当前 Entry 的模块对象。compilation.assets 表示构建输出的所有产物, 是一个对象, 通常需要 Object.keys(compilation.assets) 获取构建输出的所有产物路径。通过 entryModule.getFiles() 获取当前入口所有依赖的正常构建产物路径。接下来获取通过 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 的依赖。

  3. compiler.hooks.afterEmit 是在所有文件已经写入输出目录后触发的钩子。它通常用于在文件写入完成后执行一些操作,例如生成额外的文件或执行一些后处理任务。所以,我们在 compiler.hooks.afterEmit 钩子中, 可以得到当前 Entry 所有依赖的产物路径, 包括入口自身构建产物、SplitChunk 拆分后的产物、通过 import() 动态加载的产物。

  4. 找出 Entry 依赖的所有构建产物后, 通过 compilation.assets[构建产物路径].source 可以获取产物源码, 通过正则匹配来收集 lang 或者 langUtils 标签即可。

扩展知识点一、UMU 输入 URL 之后的过程: 请求到达 Node BFF 层, 获取用户数据、页面所需数据, 获取当前页面指定静态资源存放位置, 该目录下有 .tpl 模版文件, source.json 文件, lang.json 文件, 读取 .tpl 格式的模版(.tpl 模版由前端代码来维护), 注入数据, 读取 source.json 文件, 将里面的 js、css 拼接为 <script>/<link>, 并注入当前多语文件 zh.js/en.js 等, 注入到模版, 然后渲染为 .html

扩展知识点二、多语流程: 1. 使用__lang(文案) 的方式填充文案; 2. 将 __lang 的形式转化为 lang_id , 并通过 langUtil.lang, 转化过程中, 会将收集的 lang 多语存入 i18n cn.json 中; 3. i18n 是单独 git 来管理的, 提交新增多语改动 4. npm bun build 构建项目; 5. 收集每个 Entry 的 langUtil.lang 标签, 并将各自 lang.json 上传到 i18n 平台, 记录各个模块新增多语; 6. i18n 平台会新建一个翻译任务, 可以导出这个任务的待翻译文案指给译者, 等待翻译, 回填即可; 7. 生成 zh_cn.js 文件

二、实现


const fs = require("fs");
const path = require("path");
const pluginName = "CollectLangPlugin";
const REGEXP_MAP = {
js: /langUtils\n*\s*\.lang.*?\(['"`]?(lang_?[\d_\w]+|option_?[\d_\w]+|html_?[\d_\w]+|options_?[\d_\w]+|e_?lang[\d_\w]+|e_?option[\d_\w]+)['"`]?.*?\)/g,
html: /<%[=-]?\s?:?(lang_?[\d_]+|option_?[\d_\w]+|html_?[\d_]+|options_?[\d_]+|e_?lang[\d_]+|e_?option[\d_\w]+).*?%>/g,
tsx: /lang.*?\(['"`]?(lang_?[\d_\w]+|option_?[\d_\w]+|html_?[\d_\w]+|options_?[\d_\w]+|e_?lang[\d_\w]+|e_?option[\d_\w]+)['"`]?.*?\)/g,
ts: /langUtils\n*\s*\.lang.*?\(['"`]?(lang_?[\d_\w]+|option_?[\d_\w]+|html_?[\d_\w]+|options_?[\d_\w]+|e_?lang[\d_\w]+|e_?option[\d_\w]+)['"`]?.*?\)/g,
};

class CollectLangPlugin {
constructor(options = {}) {
this.options = options;
this.commonLangData = {
cn: {},
en: {},
es: {},
jp: {},
tw: {},
ko: {},
th: {},
};
this.init();
}

init() {
this.getAllI18nData();
}

getAllI18nData() {
this.commonLangData = {
cn: {
lang_23434: "嘻嘻哈哈",
"我是 PageA": "我是 PageA",
"我是 PageB": "我是 PageB",
"我是 PageC": "我是 PageC",
},
en: {},
es: {},
jp: {},
tw: {},
ko: {},
th: {},
};
}

collectLang(source, filepath) {
const ext = path.extname(filepath).slice(1);
const keysSet = new Set();
const fileRegexp = REGEXP_MAP[ext];
if (fileRegexp) {
source.replace(fileRegexp, (match, langKey) => {
keysSet.add(langKey);
return match;
});
}

return Array.from(keysSet);
}

writeFile(entry, langData, outputPath) {
const dir = path.join(outputPath, entry);

try {
fs.accessSync(dir);
} catch (e) {
fs.mkdirSync(dir, { recursive: true });
}

fs.writeFileSync(
path.join(outputPath, entry, `lang.json`),
JSON.stringify(langData, null, 2),
{
flag: "w",
}
);
}

getEntryLang(entry, keysSet, outputPath) {
const locales = Object.keys(this.commonLangData);

const langData = locales.reduce((outPrev, locale) => {
const allData = this.commonLangData[locale];
outPrev[locale] = Object.keys(allData).reduce((prev, langKey) => {
if (keysSet.has(langKey)) {
prev[langKey] = allData[langKey];
}
return prev;
}, {});
return outPrev;
}, {});

this.writeFile(entry, langData, outputPath);
}

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];
}

getEntryAsyncFiles(entry, entryModule, allFiles) {
const entryAsyncFiles = new Set();
const entryChunks = entryModule.chunks;

entryChunks.forEach((entryChunk) => {
const asyncChunks = entryChunk.getAllAsyncChunks();

asyncChunks.forEach((asyncChunk) => {
asyncChunk.groupsIterable.forEach((chunkGroup) => {
const entryNames = this.findEntrypoints(chunkGroup);
const isIncludesEntry = entryNames.includes(entry);
const asyncChunkId = asyncChunk.id;
const asyncFiles = allFiles.filter((allFile) =>
allFile.includes(asyncChunkId)
);

if (isIncludesEntry && asyncFiles?.length) {
asyncFiles.forEach((asyncFile) => entryAsyncFiles.add(asyncFile));
}
});
});
});

return entryAsyncFiles;
}

getSplitChunkFiles(compiler, allFiles) {
const splitChunksFiles = new Set();
const splitChunksCacheGroups = Object.keys(
compiler.options?.optimization?.splitChunks?.cacheGroups || {}
);

splitChunksCacheGroups.forEach((splitChunksCacheGroup) => {
const allFilesFilters = allFiles.filter((allFile) =>
allFile.includes(splitChunksCacheGroup)
);
allFilesFilters.forEach((allFilesFilter) =>
splitChunksFiles.add(allFilesFilter)
);
});

return splitChunksFiles;
}

apply(compiler) {
const { entry, entryKeys } = this.options;

compiler.hooks.compilation.tap(pluginName, (compilation) => {
compilation.hooks.afterProcessAssets.tap(pluginName, (assets) => {
const entryModule = compilation.entrypoints.get(entry);
if (!entryModule) {
return;
}

const allFiles = Object.keys(assets);
const entryNormalFiles = new Set([...entryModule.getFiles()]);
const splitChunksFiles = this.getSplitChunkFiles(compiler, allFiles);
const entryAsyncFiles = this.getEntryAsyncFiles(
entry,
entryModule,
allFiles
);
const entryFiles = new Set([
...entryAsyncFiles,
...entryNormalFiles,
...splitChunksFiles,
]);

this.keysSet = new Set();
entryFiles.forEach((entryFile) => {
const source = assets[entryFile].source();
const keys = this.collectLang(source, entryFile);
keys.forEach((key) => this.keysSet.add(key));
});

entryKeys?.forEach((unitKey) => this.keysSet.add(unitKey));
});
});

compiler.hooks.afterEmit.tap(pluginName, (compilation) => {
this.getEntryLang(entry, this.keysSet, compilation.compiler.outputPath);
});
}
}

module.exports = CollectLangPlugin;

三、配置


const { merge } = require("webpack-merge");
const common = require("./rspack.config.common");
const CollectLangPlugin = require("./plugin/collectLangPlugin");

module.exports = merge(common, {
mode: "production",
devtool: "source-map",
plugins: [
new CollectLangPlugin({ entry: "pageA", entryKeys: ["我是 PageA"] }),
new CollectLangPlugin({ entry: "pageB", entryKeys: ["我是 PageB"] }),
new CollectLangPlugin({ entry: "pageC", entryKeys: ["我是 PageC"] }),
],
});