如何懒加载Polyfill来避免低代码DSL体积爆炸

0 阅读4分钟

方案背景与要解决的问题

一、业务场景

低代码平台允许用户在页面中自由编写 JavaScript 代码块(如按钮点击逻辑、数据处理函数等),这些代码块作为 DSL(领域特定语言)的一部分被保存和分发。

二、核心矛盾

DSL体积 ∝ (代码块数量 × 每个代码块的polyfill体积)

平台面临两难选择:

策略问题
不做polyfill用户编写的现代 ES 语法(如可选链 ?.、空值合并 ??async/await)在旧版浏览器中直接报错,页面功能异常
保存时注入polyfill每个代码块独立编译并内嵌 polyfill,DSL 体积随代码块数量线性膨胀,严重影响页面加载性能

三、根本原因

  1. 用户代码不可控:平台无法限制用户使用哪些 ES 语法特性,而不同用户的浏览器版本差异巨大
  2. 传统方案成本高:若采用全量 polyfill 或全员转译,会显著增加 DSL 体积和运行时开销
  3. 重复编译浪费:多个代码块可能依赖相同的 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