需求背景
- 内嵌vue3H5项目体积增大,需要针对业务拆包。
- 国际化词条26国,不针对业务过滤,每个子包有全量几千条词条。
- 降本增效
常用做法
- 根据子包需要的词条,单独生成langs文件,手动塞入
- 在打包层面,通过插件遍历代码str,找到词条key,再从全量词条中抓取生成对应json
方案选择
当然是选择方案二,存留业务量很大,拆包需求每个版本要求不一样,都是根据存量版本的业务走的,所以在打包层面进行自动化处理是最好的。 方案一,基本就是搬砖。项目有几十块业务几百个页面,有拆包需求就搬砖不现实,除了稳妥没有任何优点。
先讲思路
- 在打包层面去实现,最先想到的就是通过插件处理。
- 插件使用位置?最优先级,也就是plugins数组配置的第一个元素。
- 插件需要干什么?解析代码,识别关键字(这个主要就是国际化词条的写法)。解析代码分为解析js文件和解析vue文件以及ts文件,都需要转为ast再进行相关识别操作。
- 20多国语言的配置也要同步和做转换,写入相同词条得去重。
- 最后就是过滤后的词条单独存放在一个文件夹里面,不打入主包。这个一步考虑的是后续使用云端词条。
技术调研
- vite插件写法。
- 抽象语法树(ast)的转换以及遍历。
import { parse as babelParse } from '@babel/parser'
import _traverse from '@babel/traverse'
const traverse = _traverse.default
- i18n引入的写法统计
// js文件中
const t = i18n.global.t;
t('a.b');
const $t = i18n.global.t
$t('a.b');
i18n.global.t('a.b');
// 三元表达式 需要特殊处理
d ? t('a.b') : t('a.c')
// vue template中
{{ $t('a.b') }}
v-t
//三元表达式
{{ d ? t('a.b') : t('a.c') }}
4. 工具函数定义
//定义工具函数、变量
let usedKeys = new Set() //使用到的词条key集合
let processedFiles = new Set() // 处理过的文件集合
// 收集代码中用到的词条key,塞到usedKeys中
function extractKey(node, usedKeys) {}
// 特殊处理三元表达式中包含的所有词条写法
function extractKeysFromJsExpression(jsExpr, usedKeys) {}
// 针对vue文件处理template中的ast
function walkTemplateAst(node, usedKeys) {}
- node.js 关于读写文件的一些方法
整体实现
// vite-plugin-auto-filter-i18n.js
import { promises as fs } from 'fs'
import path from 'path'
import { parse as babelParse } from '@babel/parser'
import _traverse from '@babel/traverse'
const traverse = _traverse.default
const tranConfig = {
***
}
function extractKey(node, usedKeys) {
if (!node) return
if (node.type === 'StringLiteral' || (node.type === 4 && node.isStatic)) {
const value = node.type === 'StringLiteral' ? node.value : node.content
if (value) usedKeys.add(value.replace(/'/g, ''))
return
}
if (node.type === 'TemplateLiteral' || node.type === 8) {
const quasis = node.quasis || node.children
quasis.forEach((quasi) => {
const cooked = quasi.value?.cooked || quasi.content
if (cooked) usedKeys.add(cooked)
})
return
}
if (node.type === 'BinaryExpression' && node.operator === '+') {
extractKey(node.left, usedKeys)
extractKey(node.right, usedKeys)
}
}
function extractKeysFromJsExpression(jsExpr, usedKeys) {
try {
const ast = babelParse(jsExpr, {
sourceType: 'module',
plugins: ['jsx', 'typescript']
})
traverse(ast, {
CallExpression(path) {
const callee = path.node.callee
if (callee.type === 'Identifier' && (callee.name === '$t' || callee.name === 't')) {
extractKey(path.node.arguments[0], usedKeys)
}
}
})
} catch (e) {
console.warn('Failed to parse expression:', jsExpr)
}
}
function walkTemplateAst(node, usedKeys) {
if (!node) return
if (node.type === 5) {
const content = node.content
if (content.type === 14) {
const jsExpr = content.children
.map((c) => (typeof c === 'string' ? c : c.content || ''))
.join('')
extractKeysFromJsExpression(jsExpr, usedKeys)
} else if (content.type === 4) {
const exp = content.content
extractKeysFromJsExpression(exp, usedKeys)
}
}
if (node.props) {
node.props.forEach((prop) => {
if (prop.type === 7 && prop.exp) {
const exp = prop.exp.content
extractKeysFromJsExpression(exp, usedKeys)
} else if (prop.type === 6 && prop.value?.content) {
const value = prop.value.content
extractKeysFromJsExpression(value, usedKeys)
}
})
}
if (node.children) {
node.children.forEach((child) => walkTemplateAst(child, usedKeys))
}
}
export default function autoFilterI18n(options = {}) {
let usedKeys = new Set()
let processedFiles = new Set()
return {
name: 'vite-plugin-auto-filter-i18n',
enforce: 'pre',
async transform(code, id) {
if (
id.includes('node_modules') ||
id.includes('src/langs') ||
!/\.(vue|js|ts|jsx|tsx)$/.test(id) ||
processedFiles.has(id)
) {
return
}
processedFiles.add(id)
try {
if (id.endsWith('.vue')) {
const { parse: parseVue } = await import('@vue/compiler-sfc')
const { descriptor } = parseVue(code)
if (descriptor.template) {
const { parse: parseTemplate } = await import('@vue/compiler-dom')
const templateAst = parseTemplate(descriptor.template.content, {
comments: true,
onError: (err) => {
console.warn(`Vue template parse error in ${id}:`, err.message)
}
})
walkTemplateAst(templateAst, usedKeys)
}
const scriptCode = descriptor.script?.content || descriptor.scriptSetup?.content
if (scriptCode) {
const ast = babelParse(scriptCode, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
sourceFilename: path.basename(id)
})
traverse(ast, {
CallExpression(path) {
const callee = path.node.callee
if (
(callee.type === 'Identifier' && (callee.name === '$t' || callee.name === 't')) ||
(callee.type === 'MemberExpression' &&
((callee.object.type === 'ThisExpression' && callee.property.name === '$t') ||
(callee.object?.name === '$i18n' && callee.property.name === 't') ||
(callee.object?.type === 'MemberExpression' &&
callee.object.object?.name === 'i18n' &&
callee.object.property?.name === 'global' &&
callee.property.name === 't')))
) {
extractKey(path.node.arguments[0], usedKeys)
}
}
})
}
} else {
const ast = babelParse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
sourceFilename: path.basename(id)
})
traverse(ast, {
CallExpression(path) {
const callee = path.node.callee
if (
(callee.type === 'Identifier' && (callee.name === '$t' || callee.name === 't')) ||
(callee.type === 'MemberExpression' &&
((callee.object?.name === '$i18n' && callee.property.name === 't') ||
(callee.object?.type === 'MemberExpression' &&
callee.object.object?.name === 'i18n' &&
callee.object.property?.name === 'global' &&
callee.property.name === 't')))
) {
extractKey(path.node.arguments[0], usedKeys)
}
}
})
}
} catch (e) {
console.warn(`Error parsing ${id} for i18n keys:`, e.message)
}
return code
},
async writeBundle() {
const {
inputDir = path.join(process.cwd(), 'src/langs'),
outputDir = path.join(process.cwd(), 'dist/vpp/modern/langs'),
defaultLocale = 'en_US',
debug = false
} = options
try {
if (debug) {
console.log('Detected i18n keys:', Array.from(usedKeys))
}
await fs.mkdir(outputDir, { recursive: true })
await Promise.all(
Object.entries(tranConfig).map(async ([localeCode, fileName]) => {
const inputFile = path.join(inputDir, `${fileName}.json`)
try {
const fullContent = JSON.parse(await fs.readFile(inputFile, 'utf-8'))
const filteredContent = {}
Array.from(usedKeys).forEach((key) => {
const keys = key.split('.')
let current = fullContent
let exists = true
for (const part of keys) {
if (!current || typeof current !== 'object' || !(part in current)) {
exists = false
break
}
current = current[part]
}
if (exists) {
keys.reduce((acc, part, index) => {
if (!acc[part]) {
acc[part] = index === keys.length - 1 ? current : {}
}
return acc[part]
}, filteredContent)
} else if (localeCode === defaultLocale) {
console.warn(`Missing translation for key: ${key} in ${localeCode}`)
}
})
await fs.writeFile(
path.join(outputDir, `${fileName}.json`),
JSON.stringify(filteredContent, null, 2)
)
} catch (e) {
if (e.code === 'ENOENT') {
console.warn(`Language file not found: ${inputFile}`)
} else {
console.error(`Error processing ${localeCode}:`, e)
}
}
})
)
console.log(`Successfully generated filtered i18n files in ${outputDir}`)
} catch (e) {
console.error('Error generating i18n files:', e)
}
}
}
}