方案背景与要解决的问题
一、业务场景
低代码平台允许用户在页面中自由编写 JavaScript 代码块(如按钮点击逻辑、数据处理函数等),这些代码块作为 DSL(领域特定语言)的一部分被保存和分发。
二、核心矛盾
DSL体积 ∝ (代码块数量 × 每个代码块的polyfill体积)
平台面临两难选择:
| 策略 | 问题 |
|---|---|
| 不做polyfill | 用户编写的现代 ES 语法(如可选链 ?.、空值合并 ??、async/await)在旧版浏览器中直接报错,页面功能异常 |
| 保存时注入polyfill | 每个代码块独立编译并内嵌 polyfill,DSL 体积随代码块数量线性膨胀,严重影响页面加载性能 |
三、根本原因
- 用户代码不可控:平台无法限制用户使用哪些 ES 语法特性,而不同用户的浏览器版本差异巨大
- 传统方案成本高:若采用全量 polyfill 或全员转译,会显著增加 DSL 体积和运行时开销
- 重复编译浪费:多个代码块可能依赖相同的 polyfill,独立编译导致大量冗余代码
四、解决目标
在满足以下约束的前提下,实现 DSL 体积可控 + 浏览器兼容:
- 不限制用户编写现代 JavaScript 语法
- 不显著增加 DSL 存储和传输体积
- 不严重拖慢页面加载和执行速度
- 尽量降低平台自身的实现复杂度
解决方案(激进)
优点:成本低,不用搭建动态polyfill服务,适用于公司内部使用的低代码平台。
重要假设
本方案基于一个核心假设:
- 假设用户写的代码都是可控的,即代码是正确的并且已经处理了异常。
1. 前置语法检测,避免首次执行必然失败
优化思路:在保存代码块时检测主流浏览器尚未原生支持的语法特性,提示开发者使用替代语法。
function detectUnsupportedFeatures(code: string): string[] {
const unsupportedFeatures = [];
// 检测空值合并 ??
if (code.includes('??')) {
unsupportedFeatures.push('nullish-coalescing');
}
// 检测可选链 ?.
if (code.includes('?.')) {
unssupportedFeatures.push('optional-chaining');
}
// 继续检测 async/await、class 等其他特性...
return unsupportedFeatures;
}
效果:在保存阶段给出兼容性提示,引导开发者使用更通用的语法。
2. Polyfill 去重 + 预编译
当前问题:每个代码块独立编译生成polyfill,存在大量重复代码。
优化思路:收集所有动态代码,在页面发布/保存时统一预编译。
代码收集
function collectAllCode(dsl): string {
const { codeBlocks = {} } = dsl;
return Object.keys(codeBlocks).reduce((accumulator, key) => {
return accumulator + `var ${key} = ${codeBlocks[key].content}\n`;
}, '');
}
统一预编译
// 页面保存/发布时执行
function preprocessCodeBlock(rawCode: string): string {
const target = config.browserTarget; // 如 '> 0.5%, last 2 versions'
const compiled = swc.transform(rawCode, {
env: { targets: target, mode: 'entry' },
minify: true
});
return compiled.code;
}
const legacyCode = preprocessCodeBlock(rawCode);
// 上传至CDN
await ossService.ossPut(legacyCode, 'application/javascript', `/legacy/${pageId}.js`);
3. 动态加载Polyfill
一个代码块运行时若发生语法错误(意味着其他代码块大概率也会报错),动态加载当前页面预编译的polyfill脚本后重试执行。
let polyfillLoaded = false;
async function sandbox(content: string, params: unknown, polyfillName?: string): Promise<any> {
// 已加载polyfill时,包装为带polyfill的函数调用
if (polyfillLoaded && typeof content === 'string' && polyfillName) {
content = `(data) => ${polyfillName}(data)`;
}
try {
const fn = eval(`(${content})`);
return fn(params);
} catch (error) {
if (polyfillLoaded) {
throw error; // 已加载仍失败,说明是代码本身的问题
}
await asyncLoadJs(`/legacy/${pageId}.js`);
polyfillLoaded = true;
return sandbox(content, params, polyfillName);
}
}
4. 最终流程
graph TD
A[用户保存代码块] --> B[语法检测]
B --> C{是否包含不兼容特性}
C -->|是| D[提示开发者修改]
C -->|否| E[保存原始代码]
E --> F[发布时统一预编译]
F --> G[上传至CDN]
H[运行时执行] --> I{首次执行}
I --> J[try执行原始代码]
J --> K{是否语法错误}
K -->|是| L[动态加载polyfill脚本]
L --> M[重试执行]
K -->|否| N[正常执行]
解决方案(保守)
若认为"代码都是可控的"假设过于理想化,可采用以下更稳健的方案,但需要搭建动态polyfill服务成本比较高。
1. 全局Polyfill Registry
避免每个代码块独立加载polyfill造成的重复请求。
class PolyfillRegistry {
private loaded = new Set<string>();
private pending = new Map<string, Promise<void>>();
async ensure(features: string[]): Promise<void> {
const missing = features.filter(f => !this.loaded.has(f));
if (missing.length === 0) return;
const key = missing.sort().join(',');
if (this.pending.has(key)) return this.pending.get(key);
const promise = this.load(missing);
this.pending.set(key, promise);
await promise;
missing.forEach(f => this.loaded.add(f));
this.pending.delete(key);
}
private async load(features: string[]): Promise<void> {
// 组合成单一bundle,避免多次请求
const url = `/polyfill/bundle?features=${features.join(',')}`;
await import(url);
}
}
2. 代码块预编译 + Metadata提取
在页面发布/保存时预编译,并将依赖的polyfill信息存入DSL元数据。
function preprocessCodeBlock(rawCode: string): ProcessedBlock {
const target = config.browserTarget;
const compiled = await swc.transform(rawCode, {
env: { targets: target, mode: 'usage' },
minify: true
});
const polyfills = extractPolyfillImports(compiled);
return {
original: rawCode,
compiled: compiled.code,
polyfills,
size: compiled.code.length
};
}
DSL结构示例:
{
"codeBlocks": {
"code_xxx": {
"content": "async function test() {}",
"compiled": "var _ref; (_ref = window.data) === null || _ref === void 0 ? void 0 : _ref.value",
"polyfills": ["optional-chaining", "nullish-coalescing"]
}
}
}
运行时调用:
await polyfillRegistry.ensure(codeBlock.polyfills);
const fn = new Function(codeBlock.compiled);
fn(params);
3. 最终流程
graph TD
A[用户保存代码块] --> B[语法检测]
B --> C{是否包含不兼容特性}
C -->|是| D[提示开发者修改]
C -->|否| E[保存原始代码]
E --> F[发布时统一预编译]
F --> G[上传至CDN]
H[运行时执行] --> I[首次执行]
I --> J[try执行原始代码]
J --> K{是否语法错误}
K -->|是| L[动态加载polyfill脚本]
L --> M[重试执行]
K -->|否| N[正常执行]
方案对比
| 方案 | DSL体积 | 首次执行延迟 | 实现复杂度 |
|---|---|---|---|
| 激进 | 极小 | 低 | 低 |
| 保守 | 小(仅metadata) | 中(按需加载) | 中 |
推荐策略
- 低成本场景:激进方案中的"失败重试"足够应对内部系统或可控浏览器环境
- 一般场景:采用保守方案中的"预编译 + Polyfill Registry"组合,在DSL体积和运行时性能之间取得最佳平衡
- 高要求场景:两者结合——保存时预检测提示,运行时按需动态加载polyfill