压缩11000条 key 减少 7.2M,飞书如何实现 i18n 前端体积优化

438次阅读  |  发布于2年以前

背景

在推进国际化的进程中,涌现出很多方案可以帮大家实现国际化文案定义以及使用。在飞书前端架构中,国际化文案已经做到了按需引入及按需加载,只不过随着业务的发展,国际化文案数量逐渐增多。再来看代码中的文案部分,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。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8