注册

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

背景

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

作者:字节跳动技术团队
来源:https://mp.weixin.qq.com/s/Qt6BL5pa7OJIBLH7Sl_WCA

0 个评论

要回复文章请先登录注册