前端體積優化之 i18n key 壓縮

背景

在推進國際化的進程中,湧現出很多方案可以幫大家實現國際化文案定義以及使用。在飛書前端架構中,國際化文案已經做到了按需引入及按需加載,只不過隨着業務的發展,國際化文案數量逐漸增多。再來看代碼中的文案部分,key 長度越來越長,這部分都屬於無用代碼,如果能夠縮短,可以節省部分代碼體積,加快 js 在瀏覽器中運行的速度。

如何做?

通過壓縮 i18nkey 的方式,將 i18n 的 key 從字母壓縮爲短字符串。 目前業界中爲了提升 webpack 打包速度,發展出很多利用多進程進行 js 編譯的方案。飛書前端爲了提高 webpack 編譯速度,大量使用了thread-loader進行併發編譯,i18n 掃描則採用了 babel 插件進行掃描和統計,那如何在 babel 掃描的過程中將掃描結果收集起來,如何將運行時的 key 更換爲更短的 key,並且能夠按照文件歸類,實現按需加載呢?

思路

  1. 在 webpack 編譯之前,先拿到當前業務下載的文案列表,將列表中所有的 key 進行編碼,編碼後的長度應該越短越好

  2. 在 babel loader 掃描的過程中,將用到的文案上報,並將引入文案時使用的 key,替換爲短編碼

  3. 在掃描完成後,生成文案的部分,使用編碼後的短字符串,作爲文案的 key,打包進文案文件中

具體代碼

編碼方式

將下載的所有 i18n 的 key 進行一次編碼映射,通過 key 在數組中的 index,做一個 26 進制轉換,再把轉換後的字符串中的數字填充爲剩餘的未用到的字母,保證 key 中無數字,可獲得一個不超過 5 位的短 key。

  const NUMBER_MAP = {
    0: 'q',
    1: 'r',
    2: 's',
    3: 't',
    4: 'u',
    5: 'v',
    6: 'w',
    7: 'x',
    8: 'y',
    9: 'z',
  };
  const i18nKeys = Object.keys(resources['zh-CN']).reduce((all: object, key: string, index: number) ={
    // 將i18n的key重新編碼,編碼成26進制,然後用字母替換掉所有數字。
    // 因爲變量名稱不能用數字開頭,所以需要替換掉所有數字
    all[key] = index.toString(26).replace(/\d/g, (s) => NUMBER_MAP[s]);
    return all;
  }{});

最初的設想中如果有從某個 enum 中引入 key 的行爲,可以將 enum 的成員名字一起縮短,所以採用了替換所有數字的方式,保證短 key 不會以數字開頭,後來在開發過程中發現沒有這種用法,但是編碼方式還是保留下來了。

掃描方式

藉助 babel plugin 強大的 ast api,可以輕鬆完成 i18n key 的掃描和替換。

export default function babelI18nPlugin(options, args: {i18nKeys: {[key: string]: string}}) {
  const i18nKeys = args.i18nKeys;

  return {
    visitor: {
      StringLiteral: (tree, module) ={
        const { node, parentPath: {
          node: parent, scope, type
        } } = tree;
        const { filename } = module;
        if (!shouldAnalyse(filename)) {
          return;
        }
        const stringValue = node.value;
        if (stringValue && i18nKeys.hasOwnProperty(stringValue)) {
          if (
            /**
             * 飛書前端中使用了 __Text 和 _t 的全局方法來獲得對應的文案內容,所以在這裏限定了只有在全局方法
             * __Text 和 _t 中傳遞的第一個參數爲字符串時,纔將字符串修改爲短key
             */
            type === 'CallExpression' &&
            ['__t''__Text''__T'].includes(parent.callee.name) &&
            !scope.hasBinding(parent.callee.name)
          ) {
            node.value = i18nKeys[stringValue];
            /**
             * 通過在source中寫入一個特殊註釋的方式將key標記在代碼中,
             * 交給下一步的webpack來收集
             */
            tree.addComment('leading'`${COMMENT_PREFIX} ${i18nKeys[stringValue]}`);
          } else {
            /**
             * 當匹配到的字符串並不是通過 _t 和 __Text 使用的場景,依然上報長key,保證代碼穩定性
             */
            tree.addComment('leading'`${COMMENT_PREFIX} ${stringValue}`);
          }
        }
      },
      MemberExpression: (tree, { filename }) ={
        if (!shouldAnalyse(filename)) {
          return;
        }
        const { node } = tree;
        const memberName = node.property.name;
        if (memberName && i18nKeys.hasOwnProperty(memberName)) {
          tree.addComment('leading'`${COMMENT_PREFIX} ${memberName}`);
        }
      },
    }
  };
}

如果掃描到了 i18n 相關的字符串字段,將在原地添加一個註釋,用來標記當前模塊使用到的 key,這種方式可以讓掃描結果落在代碼中,使得掃描的操作可以被  cache-loader  緩存,進一步提升構建速度。

收集過程

通過 babel-loader 的模塊都會被標記上使用到的 i18n 的 key 和替換後的短 key,在 webpack 的 parse 階段只需要遍歷文件的所有註釋即可拿到模塊內用到的所有 i18n 的 key。

export default class ChunkI18nPlugin implements Plugin {
  static fileCache = new Map<string, Set<string>>();

  constructor(private i18nConfig: I18nBundleConfig) {
  }

  public apply(compiler: Compiler) {
    compiler.hooks.compilation.tap('ChunkI18nPlugin'(compilation, { normalModuleFactory }) ={

      const handler = (parser) ={
        // 在 parser 中 hook program 鉤子
        parser.hooks.program.tap('ChunkI18nPlugin'(ast, comments) ={
          const file = parser.state.module.resource;

          if (!ChunkI18nPlugin.fileCache.has(file)) {
            ChunkI18nPlugin.fileCache.set(file, new Set<string>());
          }
          const keySet = ChunkI18nPlugin.fileCache.get(file);

          // 拿到module的所有註釋,掃描其中包含的i18n信息,緩存到一個map中
          comments.forEach(({ value }{value: string}) ={
            const matcher = value.match(/\s*@i18n\s*(?<keys>.*)/);
            if (matcher?.groups?.keys) {
              const keys = matcher.groups?.keys?.split(' ');
              (keys || []).forEach(keySet.add.bind(keySet));
            }
          });
        });
      };

      // 監聽 normalModuleFactory 的 parser 的 hooks
      normalModuleFactory.hooks.parser
        .for('javascript/auto')
        .tap('DefinePlugin', handler);
      normalModuleFactory.hooks.parser
        .for('javascript/dynamic')
        .tap('DefinePlugin', handler);
      normalModuleFactory.hooks.parser
        .for('javascript/esm')
        .tap('DefinePlugin', handler);
    });
  }

  ...

}

有什麼不足?

按照模塊收集到的 key 是基於源文件掃描到的所有的 key。實際上我們可能存在一些較大的工具方法模塊,或者組件模塊,並不會用到全部的代碼(部分代碼會被 treeshaking 機制刪掉),後續優化方向可以探索如何只掃描用到的代碼中的 key,進一步壓縮打包後的總體積。

最終收益

在一段時間的灰度測試後,最終方案上線運行,飛書前端大約 11000 條 key 的情況下,所有單頁前端代碼體積總計下降 7.2MB。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/RGkgQ8A51C-ShsE9st2E4Q