实现一个自动过滤i18n词条的vite插件

199 阅读2分钟

需求背景

  1. 内嵌vue3H5项目体积增大,需要针对业务拆包。
  2. 国际化词条26国,不针对业务过滤,每个子包有全量几千条词条。
  3. 降本增效

常用做法

  1. 根据子包需要的词条,单独生成langs文件,手动塞入
  2. 在打包层面,通过插件遍历代码str,找到词条key,再从全量词条中抓取生成对应json

方案选择

当然是选择方案二,存留业务量很大,拆包需求每个版本要求不一样,都是根据存量版本的业务走的,所以在打包层面进行自动化处理是最好的。 方案一,基本就是搬砖。项目有几十块业务几百个页面,有拆包需求就搬砖不现实,除了稳妥没有任何优点。

先讲思路

  1. 在打包层面去实现,最先想到的就是通过插件处理。
  2. 插件使用位置?最优先级,也就是plugins数组配置的第一个元素。
  3. 插件需要干什么?解析代码,识别关键字(这个主要就是国际化词条的写法)。解析代码分为解析js文件和解析vue文件以及ts文件,都需要转为ast再进行相关识别操作。
  4. 20多国语言的配置也要同步和做转换,写入相同词条得去重。
  5. 最后就是过滤后的词条单独存放在一个文件夹里面,不打入主包。这个一步考虑的是后续使用云端词条。

技术调研

  1. vite插件写法。 image.png
  2. 抽象语法树(ast)的转换以及遍历。
    import { parse as babelParse } from '@babel/parser'
    import _traverse from '@babel/traverse'
    const traverse = _traverse.default
  1. 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) {}
    
  1. 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)
      }
    }
  }
}