Skip to content

每日一报

C++ Addon 与沙盒环境的根本性冲突

C++ addon 之所以在 JavaScript 生态系统中广受欢迎,主要是因为它们能够提供普通 JavaScript 代码无法实现的能力。开发者选择使用 C++ addon 通常是为了获取对底层系统资源的直接访问权限,例如 文件系统网络接口硬件设备操作系统 API。这种直接访问允许开发者绕过 JavaScript 引擎的限制,执行 高性能计算,直接 管理内存,或者利用现有的 C/C++ 库生态系统。对于许多性能密集型应用程序,如 图像处理机器学习加密操作C++ addon 提供的性能提升可能是数量级的。

然而,现代沙盒环境的 核心设计理念C++ addon 的这些优势直接冲突。浏览器边缘计算平台安全优先的运行时(如 Deno) 都建立在 强隔离 原则之上。这些环境故意 限制对底层系统资源的访问,以防止恶意代码危害用户系统或获取敏感数据。 例如,浏览器完全禁止直接执行原生代码,所有资源访问都必须通过严格控制的 API 进行。Cloudflare WorkersVercel Edge Functions 等边缘计算环境运行在高度受限的 V8 隔离环境中,禁止直接文件系统访问和许多其他系统调用。这些限制正是沙盒环境的价值所在 --- 保护用户免受潜在的安全威胁

尝试通过 兼容层 来解决 C++ addon 和沙盒环境之间的冲突 面临着根本性的障碍。这不仅仅是技术实现问题,而是 设计理念的根本冲突C++ addon 的价值在于 突破 JavaScript 的限制,而沙盒环境的价值则在于强制实施这些限制来确保安全。即使 WebAssembly 作为一种潜在的桥梁技术,它仍然受到与 JavaScript 相同的沙盒限制,无法提供原生 C++ addon 相同的系统访问能力。尽管 WebAssembly 提高了计算性能,但它不能绕过沙盒对系统资源访问的核心限制。

对于开发者来说,这种根本性冲突导致了现代 JavaScript 生态系统的分化。当构建面向多环境的应用程序时,开发者必须权衡 性能兼容性。服务器端应用可以自由使用 C++ addon 来优化性能,而面向浏览器或边缘环境的代码则必须使用纯 JavaScript 实现。这对构建 同构应用(即既在服务器上运行又在客户端浏览器中运行的应用)提出了特殊挑战,因为相同的代码需要在两种截然不同的环境中工作。

许多现代库通过提供双重实现来应对这一挑战:一个基于 C++ addon 的高性能版本用于 Node.js 等非受限环境,以及一个纯 JavaScript 实现用于浏览器和其他沙盒环境。例如,Node.js 环境下的 sharp(图像处理库)使用 libvips 提供高性能实现,而在浏览器中只能依赖 JavaScriptWebAssembly 的替代方案。许多加密库(如 node-forgenode-sodium)提供优化的原生实现和纯 JavaScript 后备实现。然而,这种方法增加了维护负担,因为开发者需要维护两套代码,确保功能一致性,并处理不同环境中的边缘情况。

随着边缘计算和服务器功能的普及,这种 环境分化 变得更加复杂。应用程序的不同部分可能在具有不同能力和限制的各种环境中运行。例如,同一应用程序的组件可能需要在传统 Node.js 服务器、无服务器函数、边缘计算节点和浏览器中工作。在这种复杂的部署场景中,依赖 C++ addon 的库可能会成为障碍,限制代码的 可移植性部署选项

正因如此,我们看到越来越多的库,特别是面向广泛使用的 通用库,优先选择纯 JavaScript 实现,即使这意味着在某些场景下 牺牲一些性能。这种趋势反映了 JavaScript 生态系统的演变,从主要面向服务器端应用程序转向更广泛的 多环境兼容性。对于开发者来说,了解这种根本性冲突至关重要,因为它会影响技术选择、架构决策和长期维护策略。在选择库和框架时,不仅要考虑当前 性能需求,还要考虑未来的 部署灵活性生态系统兼容性

补充扩展

Lynx 跨端框架 限制 了依赖包不能依赖 C++ addonNodeJS 内置模块的库。

TypeScript 中的私有化设计决策:编译时 private 与运行时 # 的并存理念

TypeScript 团队对 private 关键字和 # 私有字段的处理体现了语言设计中的精心平衡。他们明确选择同时支持这两种不同的私有化机制,各自保持其独特的语义和行为模式。private 关键字提供编译时的类型检查而不施加运行时限制,而 # 语法则实现了 ECMAScript 规范中的真正运行时私有性。这一决策主要基于技术实现的根本限制——将 private 编译为 # 需要类型引导的代码生成,这违反了 TypeScript 的类型擦除原则。

更重要的是,如果 TypeScript 实现将 private 关键字编译为 ECMAScript# 私有字段,这将导致大量严重的破坏性变更。以下是具体场景示例:

  • 反射与元编程失效
typescript
class User {
  private id: string;

  constructor(id: string) {
    this.id = id;
  }
}

// 转换前:运行时可以访问
const user = new User('123');
console.log(Object.keys(user)); // ['id']
console.log(user['id']); // "123"

// 转换后:完全无法访问
const user = new User('123');
console.log(Object.keys(user)); // []
console.log(user['id']); // undefined
// user.#id 在类外部尝试访问会导致语法错误
  • 序列化行为变化
typescript
class Product {
  private price: number;
  public name: string;

  constructor(name: string, price: number) {
    this.name = name;
    this.price = price;
  }
}

// 转换前:
const product = new Product('Phone', 999);
console.log(JSON.stringify(product)); // {"name":"Phone","price":999}

// 转换后:
const product = new Product('Phone', 999);
console.log(JSON.stringify(product)); // {"name":"Phone"}
// 价格信息完全丢失!
  • 单元测试失效

private 字段可以被单元测试直接访问,这在测试私有实现细节时非常有用。虽然可能违背了封装原则,但这种灵活性在实际开发中经常被证明是有价值的。

typescript
class Calculator {
  private total: number = 0;

  add(value: number): void {
    this.total += value;
  }

  getTotal(): number {
    return this.total;
  }
}

// 转换前:测试可以直接验证内部状态
test('Calculator internal state', () => {
  const calc = new Calculator();
  calc.add(5);
  // 直接访问私有字段验证内部实现
  expect(calc['total']).toBe(5);
});

// 转换后:测试失败
test('Calculator internal state', () => {
  const calc = new Calculator();
  calc.add(5);
  expect(calc['total']).toBe(5); // 失败! calc['total'] 为 undefined
});
  • 库和框架集成问题
typescript
class Component {
  private state: object = {};

  constructor(initialState: object) {
    this.state = initialState;
  }
}

// 转换前:框架能够通过反射读取状态
const framework = {
  extractState(component: any): object {
    return component.state;
  }
};

const comp = new Component({ count: 0 });
console.log(framework.extractState(comp)); // { count: 0 }

// 转换后:框架无法访问状态
const comp = new Component({ count: 0 });
console.log(framework.extractState(comp)); // undefined
  • Proxy 和拦截器失效
typescript
class Database {
  private connection: any;

  connect(config: any): void {
    this.connection = config;
  }
}

// 转换前:可以使用 Proxy 拦截所有属性访问
const db = new Database();
const trackedDb = new Proxy(db, {
  get(target, prop) {
    console.log(`访问属性: ${String(prop)}`);
    return target[prop];
  },
  set(target, prop, value) {
    console.log(`设置属性: ${String(prop)} = ${value}`);
    target[prop] = value;
    return true;
  }
});

trackedDb.connect('mongodb://localhost');
// 输出: 设置属性: connection = mongodb://localhost

// 转换后:私有字段访问完全无法拦截
// Proxy 不会捕获到 #connection 的设置
// 无输出
  • 继承和混合模式问题
typescript
class Parent {
  private sharedState: any = {};
}

class Child extends Parent {
  method() {
    // 转换前:能够从父类访问私有字段
    console.log(this['sharedState']);

    // 转换后:完全无法访问
    // this.#sharedState 无法从子类访问,因为 #sharedState
    // 只在定义它的实际类中可见
  }
}
  • 与动态类型系统交互
typescript
class Config {
  private settings: Record<string, any> = {};

  setSetting(key: string, value: any): void {
    this.settings[key] = value;
  }
}

// 转换前:外部库能通过动态方式修改配置
function extendConfig(config: any): void {
  config.settings = { ...config.settings, theme: 'dark' };
}

// 转换后:完全无法工作
function extendConfig(config: any): void {
  // config.settings 是 undefined
  config.settings = { ...config.settings, theme: 'dark' }; // TypeError
}
  • 调试体验劣化

转换前,开发者能在调试器中直接查看私有字段的值:

bash
> instance
< {name: "example", private_data: "sensitive"}

转换后,私有字段在调试器中不可见:

bash
> instance
< {name: "example"}

这种差异将导致依赖于现有行为的代码出现意外失效,因此 TypeScript 团队决定不支持将 private 编译为 #,保持向后兼容性,将选择权交付给了开发者。

另外,在需要兼容较旧 JavaScript 运行环境的场景中,编译时 private 提供了更好的运行时性能表现,因为它不需要额外的运行时机制来实现私有性。在不支持 WeakMap 的运行环境中实现严格的运行时私有性缺乏良好的降级方案。

通过维持两种清晰分离的私有化机制,TypeScript 实现了多重目标:保持了与已有代码库的兼容性,完全支持了 JavaScript 的最新标准特性,并为开发者提供了根据具体需求选择合适工具的灵活性。正如 Ryan Cavanaugh 所说:"# 表示运行时私有,private 表示编译时私有"——这种明确的区分比试图通过配置选项统一它们更有利于代码的可理解性和可维护性。最终,TypeScriptJavaScript 超集的定位中找到了平衡点:既扩展了语言能力,又尊重并完整支持 JavaScript 本身的发展方向。

支持状态与兼容性

  • 主流浏览器支持:Chrome 74+, Firefox 90+, Safari 14.1+, Edge 79+。
  • Node.js:12.0.0+ (部分支持), 14.6.0+ (完全支持)。
  • 对于需要支持较旧环境的项目,可通过 Babel 配置 @babel/plugin-proposal-class-properties 进行转译。

Playground

js
class User {
  #id = null;

  constructor(id) {
    this.#id = id;
  }
}
js
function _classPrivateFieldInitSpec(obj, privateMap, value) {
  _checkPrivateRedeclaration(obj, privateMap);
  privateMap.set(obj, value);
}

function _checkPrivateRedeclaration(obj, privateCollection) {
  if (privateCollection.has(obj)) {
    throw new TypeError(
      'Cannot initialize the same private elements twice on an object'
    );
  }
}

function _classPrivateFieldSet(receiver, privateMap, value) {
  var descriptor = _classExtractFieldDescriptor(
    receiver,
    privateMap,
    'set'
  );
  _classApplyDescriptorSet(receiver, descriptor, value);
  return value;
}

function _classExtractFieldDescriptor(receiver, privateMap, action) {
  if (!privateMap.has(receiver)) {
    throw new TypeError(
      'attempted to ' + action + ' private field on non-instance'
    );
  }
  return privateMap.get(receiver);
}

function _classApplyDescriptorSet(receiver, descriptor, value) {
  if (descriptor.set) {
    descriptor.set.call(receiver, value);
  } else {
    if (!descriptor.writable) {
      throw new TypeError('attempted to set read only private field');
    }
    descriptor.value = value;
  }
}

var _id = /*#__PURE__*/ new WeakMap();

class User {
  constructor(id) {
    _classPrivateFieldInitSpec(this, _id, {
      writable: true,
      value: null
    });
    _classPrivateFieldSet(this, _id, id);
  }
}
特性私有字段 (#)命名约定 (_)SymbolWeakMap
真正私有
性能
内存消耗
语法简洁性
TypeScript支持完善一般完善完善

isolatedModules 配置项

typescriptisolatedModules 配置选项本质上是为了解决现代前端工具链中的一个 核心矛盾typescript 编译器与单文件转译工具之间的工作模式差异。当我们设置 isolatedModules: true 时,typescript 会强制执行一系列约束,确保每个源文件都能作为 独立单元 被处理,而无需依赖全局类型系统或跨文件类型推断。这一机制的根本目的在于支持 rollupwebpackesbuildbabelswc 等转译工具的工作模式,因为这些工具在 转译过程 中采用了与 typescript 编译器截然不同的处理策略。

typescript 编译器(tsc)通常会 先加载整个程序构建完整的类型系统,使其能够执行 全局类型检查分析;而 打包工具转译器 则采用从 入口文件 开始的递归处理模式,在处理单个模块时 无法获取完整依赖图的上下文信息。这种根本性的架构差异导致了某些 typescript 特性在单文件转译环境中无法可靠工作,例如隐式类型引用常量枚举仅类型导出 以及 命名空间合并 等功能,他们都依赖于编译器对整个(部分)程序的上下文理解。

isolatedModules 的引入解决了一个实际的工程痛点:通过类型限制,确保在编译阶段就能意识到哪些代码(类型)结构可能在构建过程中导致问题,而不是等到构建阶段才发现因上下文不足而无法转译。这种前置验证机制尤其重要,因为在复杂的前端项目中,typescript 类型检查模块转译 通常是由不同工具执行的,类型检查typescript 负责,而 模块转译 则交给打包工具(如 rollupwebpackesbuild)或转译工具(如 babelswc)处理,这就创造了潜在的不一致风险。

typescriptisolatedModules 选项通过 限制需要跨文件类型分析的语言特性,实现了模块的独立编译能力,从而显著提升了构建性能。该选项的核心价值在于将 代码转译类型检查 进行了 关注点分离使转译过程能够完全并行化,而不必等待其他模块的类型信息

尽管在严格的类型检查阶段仍然存在依赖链,但现代构建工具如 esbuildswcrollup 采取了 转译优先 的策略,将 代码转译类型验证 分开处理,同时前者能够充分利用多核处理器进行并行处理,显著提升编译速度,后者则可以作为可选的单独步骤(使用增量编译和声明文件缓存等技术,有效地减轻了依赖链带来的性能瓶颈)。

这种 关注点分离 的策略使即使具有复杂类型依赖的大型项目也能实现高效构建,比如 esbuildswc 等工具可以在保证类型安全的同时,将编译速度提升 10 ~ 100 倍。在实际工程中,isolatedModules 的价值不仅体现在理论上的并行优化,更反映在开发体验的显著改善上,特别是在大型 typescript 项目的快速迭代场景中。

命名空间解析中的权衡

namespaces 确实是 typescript 类型系统中难以 并行处理 的特性,有以下几点原因:

  1. 多模块合并

    namespaces 可以在多个模块声明中间进行类型合并,因此对于编译器来说需要收集完所有模块的类型信息获得完整的类型上下文后才能确定 namespaces 的结构,这本质上是一个顺序依赖的过程。

    ts
    // eslint-disable-next-line @typescript-eslint/no-namespace
    namespace Utils {
      export const world = 'world!';
    
      const t = () => {
        return 'hello' + Utils.world;
      };
      export { t };
    }
    ts
    // eslint-disable-next-line @typescript-eslint/no-namespace
    namespace Utils {
      export const world = 'world!';
    }
  2. 内部状态共享

    namespaces 的内部状态共享,导致在编译过程中需要全局扫描所有模块的类型信息,这使得编译器在处理 namespaces 时需要更多的内存和时间。

  3. 跨文件引用

    namespaces 的跨文件引用,导致在编译过程中需要全局扫描所有模块的类型信息,这使得编译器在处理 namespaces 时需要更多的内存和时间。

namespaces 可以在多个模块声明中间进行类型合并,因此对于编译器来说需要收集完所有模块的类型信息获得完整的类型上下文后才能确定 namespaces 的结构,这本质上是一个顺序依赖的过程。

强制启用 isolatedModules: true 是一种合理的折衷方案。这个选项告诉 typescript 编译器将每个文件视为 独立模块,禁用需跨模块分析的类型特性,为 并行处理 创造了条件。

第三方包(如 @types/node) 的类型定义可能还使用 命名空间 特性,这使得 命名空间 的兼容性问题尤为突出。为解决这个问题,采用单线程编译通道来专门处理 命名空间 特性,而配置了 isolatedModules: true 管辖的应用代码可以保持 并行编译 的优势。这种 分而治之 的方法可以在不破坏兼容性的前提下提升大部分代码的编译性能。

命名空间在 typescript 中是一个 遗留特性,从长远来看,这种策略可视为向纯 esm 迁移的过渡措施。typescript 社区已经 明确推荐 使用 esm 替代命名空间,主流工具链也朝这个方向发展。许多类型定义包正在逐步迁移到模块化方案,尽管这个过程可能需要时间。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)