Compressing i18n Keys to Reduce Bundle Size and Speed Up Frontend Builds
This article describes a method for shortening i18n keys in Feishu's frontend architecture by encoding them into compact strings, integrating the process into Babel and webpack pipelines, and demonstrates a 7.2 MB reduction in total page code size for over 11,000 keys.
Background
During the internationalization process, many solutions have emerged to define and use i18n texts. In Feishu's frontend architecture, i18n texts are already loaded on demand, but as the number of texts grows, the key strings become longer, adding unnecessary code size and slowing down JavaScript execution in browsers.
How to Do It?
Compress i18n keys by converting them from long alphabetic strings to short strings. To improve webpack build speed, Feishu's frontend heavily uses thread-loader for parallel compilation, and i18n scanning is performed with a Babel plugin. The challenge is to collect scan results during Babel processing, replace runtime keys with shorter ones, and organize them by file for on‑demand loading.
Idea
Before webpack compilation, obtain the list of i18n texts for the current business, encode all keys to the shortest possible strings.
During Babel loader scanning, report the used texts and replace the imported keys with the short encodings.
After scanning, generate the i18n bundle using the short strings as keys, and pack them into the i18n files.
Specific Code
Encoding Method
All downloaded i18n keys are mapped to short strings by converting their array index to a base‑26 representation and replacing digits with unused letters, ensuring no numeric characters appear and the result does not exceed five characters.
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, key, index) => {
// Encode i18n key to base‑26 and replace digits with letters.
// Variables cannot start with a digit, so replace all digits.
all[key] = index.toString(26).replace(/\d/g, s => NUMBER_MAP[s]);
return all;
}, {});Initially, we considered shortening enum member names as well, which required replacing all digits to avoid keys starting with numbers. Although that use‑case disappeared, the encoding method was retained.
Scanning Method
Using Babel's powerful AST API, i18n keys can be scanned and replaced easily.
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 (
/**
* Feishu frontend uses global methods __Text and _t to obtain i18n texts.
* Only when the first argument of these calls is a string do we replace it with a short key.
*/
type === 'CallExpression' &&
['__t', '__Text', '__T'].includes(parent.callee.name) &&
!scope.hasBinding(parent.callee.name)
) {
node.value = i18nKeys[stringValue];
/**
* Add a special comment to the source so that the next webpack step can collect it.
*/
tree.addComment('leading', `${COMMENT_PREFIX} ${i18nKeys[stringValue]}`);
} else {
/**
* If the string is not used in __t/__Text, still report the original long key to keep code stable.
*/
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}`);
}
},
}
};
}If an i18n‑related string field is found, a comment is added in place to mark the used key, allowing the result to be cached by cache-loader and further improve build speed.
Collection Process
Modules processed by babel-loader are all marked with the i18n keys they use and their short replacements. During webpack's parse phase, iterating over file comments retrieves every i18n key used in a module.
export default class ChunkI18nPlugin implements Plugin {
static fileCache = new Map
>();
constructor(private i18nConfig: I18nBundleConfig) {}
public apply(compiler: Compiler) {
compiler.hooks.compilation.tap('ChunkI18nPlugin', (compilation, { normalModuleFactory }) => {
const handler = (parser) => {
// Hook program stage in parser
parser.hooks.program.tap('ChunkI18nPlugin', (ast, comments) => {
const file = parser.state.module.resource;
if (!ChunkI18nPlugin.fileCache.has(file)) {
ChunkI18nPlugin.fileCache.set(file, new Set
());
}
const keySet = ChunkI18nPlugin.fileCache.get(file);
// Scan all comments for i18n info and cache them
comments.forEach(({ value }: { value: string }) => {
const matcher = value.match(/\s*@i18n\s*(?
.*)/);
if (matcher?.groups?.keys) {
const keys = matcher.groups?.keys?.split(' ');
(keys || []).forEach(keySet.add.bind(keySet));
}
});
});
};
// Listen to parser hooks for different javascript types
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);
});
}
...
}Limitations
The collected keys are based on a full source‑file scan. Large utility or component modules may contain keys that are never used after tree‑shaking, so future work could focus on scanning only the actually used code to further shrink the final bundle.
Final Benefits
After a period of gray‑testing, the solution was deployed to production. With roughly 11,000 i18n keys, the total size of all single‑page frontend code decreased by 7.2 MB.
ByteFE
Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.