原文出处:WebAssembly模块化与动态链接

1. 前言

模块化编程(modular programming)是一种软件设计模式,它将软件分解为若干独立的、可替换的、具有预定功能的模块,每个模块实现一个功能,各模块通过接口(输入输出部分)组合在一起形成最终程序。当下流行的JavaScript、Python、Rust、Java 等语言都有具有模块(包)管理,甚至 C++20 开始都引入了模块化系统。

模块化编程从 1980 年代开始广泛传播,是 SoC (Separation of concerns)[1] 原则的理想目标,主要有如下特点:

模块可以理解为是一个实现特定功能的独立且通用的代码单元,意在解决代码拆分、作用域隔离、功能依赖耦合等问题,提升代码的可维护性。然而,由于模块化往往基于代码的分离而构建独立出代码单元,单一的模块往往无法作为一个单独运行单元,因此在运行过程中不可避免的需要引入动态链接的机制。

WebAssembly 作为可移植且兼容 Web 的全新格式,基于模块化的 WebAssembly 动态链接机制,可以将WebAssembly 应用程序的核心逻辑分离出来,可以更容易地共享,从而消除重复逻辑;功能独立的小模块具有更高的分发、下载、加载效率,并且可以做到按需加载,从未使用的模块永远不会被下载;此外,WebAssembly 可链接模块还可以进行并行流式编译、进行模块缓存、运行期动态链接,从而进一步提升加载和启动效率。

接下来,本文会从 WebAssembly 的模块化演进入手,介绍其模块化和动态链接的关键设计和实现,以及当前面临的挑战和未来的发展趋势。

2. JS 和 asm.js 模块化和动态链接

课程的第 1 章已经对 WebAssembly 的演进历史做了介绍,从其历史发展路径我们可以看出,WebAssembly 很多关键技术与 JavaScript 发展趋势和方向有很强的关联性,因此,本文先从 JavaScript 模块及其动态链接入手,进而分析 WebAssembly 多模块及动态链接相关设计和关键实现。

2.1 JavaScript 模块与动态链接

众所周知,JavaScript 有 CMJ(CommonJS)[2],AMD(Asynchronous Module Definition)[3],UMD(Universal Module Definition)[4],ESM(ECMAScript modules)[5]等多种主要的模块化规范。由于CommonJS 接受度较高并且与 WebAssembly 设计比较接近,本文中的示例将基于 CommonJS 模块化规范进行定义。

在 CommonJS 模块化规范中,单个独立的 JavaScript 文件可以被作为一个模块,模块中默认的定义仅在模块内部可见,文件对外接口和对象需通过module.exports 对外暴露;此外,JavaScript 文件可以通过 require() 接口来获取对应模块导出的对象,从而完成所需接口和对象的链接过程。在下图 1 的示例中,square.js 中定义了 Square 类用于计算矩形区域面积方法area();为了计算 square 的面积大小,calculator.js 可以通过调用require(square.js) 来加载 squre.js 模块并导入 Square 类,而不需要重新实现计算 square 面积的方法,从而实现代码复用和共享。

图 1. JavaScript 模块链接示意图

从上图 1 的示例中,我们可以发现,JavaScript 通过 require 方法来进行 JavaScript 模块的加载和符号的动态链接,那最终符号的动态链接过程又是如何实现的呢?接下来,我们将对 NodeJS CommonJS 模块的加载和动态链接过程进行分析,从而展示 JavaScript 模块的动态链接原理及运行机制[6]。

// eslint-disable-next-line func-style  
let wrap = function(script) {  
  return Module.wrapper[0] + script + Module.wrapper[1];  
};  

const wrapper = [  
  '(function (exports, require, module, __filename, __dirname) { ',  
  '\n});',  
];

在上面的源代码中,wrap 是 NodeJS 中模块的包装器函数,wrapper 是模块的包装模板。在 JavaScript (.js, .mjs) 的加载过程中,如果该文件作为一个独立的模块时,NodeJS Loader 会首先通过 "模块封装器"函数 wrap 将 JavaScript 源代码包装成匿名的函数表达式对象,如下面的源代码所示。匿名函数表达式将 var、const 或 let 定义的顶级变量作用域范围限制在模块中而不是全局对象中,函数表达式参数 "module" 和 "exports" 可以用于从模块中导出值,"require" 参数用于导入外部模块,外部模块除了 JavaScript 文件,还可以是 json 文件以及本地 node 文件,__filename 和 __dirname 参数包含模块的绝对文件名和目录路径。

(function(exports, require, module, __filename, __dirname) {  
   // 模块代码实际存在于此处  
});

当 NodeJS 将模块文件包装为运行时环境中的匿名函数对象后,当 JavaScript 通过 require(path) 函数尝试加载模块文件时,实际执行 NodeJS 加载器 loader.js[6] 中的 Module.prototype.require 函数,进而调用 Module._load 执行模块加载和链接,如下列的源代码所示。

/*  
 * source link:  
 * https://github.com/nodejs/node/blob/8822f40b2d48841c6d4fb4c04266a5703bdf33e9/lib/internal/modules/cjs/loader.js#L1095  
 */  
// Loads a module at the given file path. Returns that module's  
// `exports` property.  
Module.prototype.require = function(id) {  
  /* ... skip irrelevant code */  
  requireDepth++;  
  try {  
    return Module._load(id, this, /* isMain */ false);  
  } finally {  
    requireDepth--;  
  }  
};

Module._load 为了提升模块加载的效率定义了 Module._cache 用于缓存已经加载的模块并返回 module.exports 对象。如果模块已经完成加载则直接返回,否则,为文件创建一个 Module 实例后放入模块缓存中,进而调用 Module 实例的 load 函数执行模块的加载和链接过程,如下列源代码所示。

/*  
 * source link:  
 * https://github.com/nodejs/node/blob/8822f40b2d48841c6d4fb4c04266a5703bdf33e9/lib/internal/modules/cjs/loader.js#L844  
 */  
// Check the cache for the requested file.  
// 1. If a module already exists in the cache: return its exports object.  
// 2. If the module is native: call  
//    `BuiltinModule.prototype.compileForPublicLoader()` and return the exports.  
// 3. Otherwise, create a new module for the file and save it to the cache.  
//    Then have it load  the file contents before returning its exports  
//    object.  
Module._load = function(request, parent, isMain) {  
  /* ... skip irrelevant code */  
  // Don't call updateChildren(), Module constructor already does.  
  const module = cachedModule || new Module(filename, parent);  
  /* ... skip irrelevant code */  
  Module._cache[filename] = module; 

  /* ... skip irrelevant code */  
  let threw = true;  
  try {  
    module.load(filename);  
    threw = false;  
  } finally {  
    /* ... skip irrelevant code */  
  }  

  return module.exports;  
};

由于 require 方法可以加载多种类型的文件 (.json, .node, *.js), Module.prototype.load函数首先根据不同的文件扩展类型获取对应文件的处理函数。当加载 JavaScript 模块时, Module.prototype.load 函数实际调用Module._extensions['.js'] 处理函数完成 JavaScript 模块的编译和执行逻辑,如下列源代码所示。

/*  
 * source link:  
 * https://github.com/nodejs/node/blob/8822f40b2d48841c6d4fb4c04266a5703bdf33e9/lib/internal/modules/cjs/loader.js#L1068  
 */  
// Given a file name, pass it to the proper extension handler.  
Module.prototype.load = function(filename) {  
  /* ... skip irrelevant code */  
  // return the filename extension  
  const extension = findLongestRegisteredExtension(filename);  

  /* ... skip irrelevant code */  
  // invoke the extension handler, where *.js will  
  // invoke Module._extensions['.js'] handler  
  Module._extensions[extension](this, filename);  
  this.loaded = true;  

  /* ... skip irrelevant code */  
};

NodeJS 中 Module._extensions['.js'] 处理函数首先通过 fs 加载文件内容,然后调用模块实例的Module.prototype._compile 函数完成模块文件内容的编译,如下列源代码所示。

/*  
 * source link:  
 * https://github.com/nodejs/node/blob/8822f40b2d48841c6d4fb4c04266a5703bdf33e9/lib/internal/modules/cjs/loader.js#L1226  
 */  
// Native extension for .js  
Module._extensions['.js'] = function(module, filename) {  
  // If already analyzed the source, then it will be cached.  
  const cached = cjsParseCache.get(module);  
  let content;  
  if (cached?.source) {  
    content = cached.source;  
    cached.source = undefined;  
  } else {  
    content = fs.readFileSync(filename, 'utf8');  
  }  
  /* ... skip non-critical code */  
  module._compile(content, filename);  
};

模块实例的 Module.prototype._compile 函数会在模块上下文中执行 "模块包装器" 生成的匿名函数对象,并传递正确的requiremoduleexports 参数对象,如下列源代码所示。

/**  
 * source code links  
 * https://github.com/nodejs/node/blob/8822f40b2d48841c6d4fb4c04266a5703bdf33e9/lib/internal/modules/cjs/loader.js#L1169  
 */  
// Run the file contents in the correct scope or sandbox. Expose  
// the correct helper variables (require, module, exports) to  
// the file.  
// Returns exception, if any.  
Module.prototype._compile = function(content, filename) {  
  /* ... skip non-critical code */  
  // content will be wrapped as follows by the wrapper  
  // (function(exports, require, module, __filename, __dirname) {  
  //    content  
  // });  

  const compiledWrapper = wrapSafe(filename, content, this); 

  /* ... skip non-critical code */  

  // prepare the arguments for the wrapped function  
  const dirname = path.dirname(filename);  
  const require = makeRequireFunction(this, redirects);  
  let result;  
  const exports = this.exports;  
  const thisValue = exports;  
  const module = this;  

  /* ... skip non-critical code */  

  // invoke the wrapped function with arguments prepared  
  // (function(exports, require, module, __filename, __dirname) {  
  //    content  
  //    module.exports = {}  
  // });  

  result = ReflectApply(compiledWrapper, thisValue,  
                        [exports, require, module, filename, dirname]);  

  /* ... skip non-critical code */  

  return result;  
};

从以上的分析过程,可以发现 require 方法执行过程完成了 CommonJS 模块的动态加载和链接过程。exportsrequiremodule 等函数参数是导入模块和导出模块的共享对象,基于这些共享对象,JavaScript 模块才得以在动态加载的过程中完成符号的动态链接过程。

2.2 "asm.js" 模块与动态链接

asm.js 是 WebAssembly 的前身,是一种可用于编译期的低层级的、高效的 JavaScript 的一个严格子集,它可以通过AOT (Ahead-Of-Time,静态编译)策略来编译优化代码,因此,其模块化和对外交互特性(链接)显得尤为重要。

下面给出的是一个标准 asm.js 模块的基本结构,通过这种模块化的结构,asm.js 可以保证模块内部的所有代码都遵循自己独有的标准和语法规则,即所有模块内部使用到的变量都保证已经通过 Annotation 的方式进行了强制类型声明。除此之外,asm.js 模块作为一个整体也在代码层面与原始的 JavaScript 代码进行了隔离,同时其内部还可以通过暴露出的接口与标准 JavaScript 代码进行交互,如下列代码所示。

/* MyAsmModule module definition */  
function MyAsmModule(stdlib, foreign, heap) {  
    /* asm.js module declare */  
    "use asm";  

    /* varaible defintion */  
    var variable = 0;  

    /* funtion defintion */  
    function add($0, $1) {  
      $0 = $0 | 0; // annotate $0 is integer  
      $1 = $1 | 0; // annotate $1 is integer  
      $2 = (($1) + ($0) | 0);   
      return ($2 | 0); // annotate $2 is integer  
    }  

    /* module body... */  

    /* functions exporting */  
    return {  
      add: add,  
      export_func2: f2,  
      /* other functions */  
    };  
} 

/* MyAsmModule module user */  
const buffer_size = 0x10000;  

/* import function defitinion */  
const inport_funcs = {  
    import_func1: fa  
    /* other functions */  
};  

/* shared memory definition */  
var heap = new ArrayBuffer(buffer_size);  

/* create and initialize MyAsmModule */  
var asModule = MyAsmModule(window, inport_funcs, heap);  

/* invoke asm.js exported function */  
var sum = asModule.add(2,3);

从整体上看,asm.js 模块是一个标准的 JavaScript 函数,函数内部的第一行使用 “use asm” 标记来对模块进行声明。一个完整的asm.js 模块其内部被分为三个部分: 变量定义、函数定义和函数导出。模块导出的函数可以被其他 asm.js 模块引用,或者直接在 JavaScript 环境下通过 JavaScript 代码来调用运行。

一个 asm.js 模块最多可以接受三个可选参数,提供对外部 JavaScript 代码和数据的访问:

asm.js 模块的参数使得模块可以调用外部 JavaScript,并与外部 JavaScript 共享其 ArrayBuffer 堆缓冲区;相反,从模块返回的导出对象允许外部 JavaScript 调用 asm.js;asm.js 对象的交互和绑定过程我们称之为 asm.js 模块链接。asm.js 模块化和链接的底层机制基本沿袭了 JavaScript 模块和链接的设计,并在一定程度上被 WebAssembly 继承并发展,虽然 asm.js 标准自 2014 年发布至今已经鲜有人关注了,但可以为我们深入理解 WebAssembly 模块及链接机制提供帮助。

接下来,就让我们一起对 WebAssembly 的模块化和链接机制进行分析和理解。

3. WebAssembly 模块及动态链接

WebAssembly 在 asm.js 的基础上对模块和链接机制进行了扩展,它定义了 import 和 export 段来声明与外界环境交互的关键对象和组件;WebAssembly 模块介绍请参见课程的第 4 章内容。

asm.js 模块通过输入参数来引入外界环境的变量,为了提供统一入口来导入多种不同的类型,WebAssembly 定义了 Import 段来声明需要使用到的外界环境变量。Import 段会声明模块所使用的所有类型的导入对象,包括 Fucntion 对象、Table 对象、 Memroy 对象或 Global 对象。Import 的设计初衷是使模块可以共享代码和数据,同时支持模块独立编译和模块缓存;在模块进行实例化阶段,这些导入对象将由宿主环境或者三方模块提供。

与 asm.js 通过返回值来实现模块的内部函数导出给外部环境使用不同,WebAssembly 采用了类似 CommonJS 的更友好和灵活的方式,它通过与 module.exports 相似的 Export 段方式来导出不同类型的内部变量。Export 段声明了一个对象的列表,其中包含了模块实例化后外部环境可用的各种类型模块内部定义的对象,这些对象可以是 Function、 Table、Memory 或 Global 中的任意类型。

因此,WebAssembly 作为可移植性,语言和平台无关的发布产物,可以嵌入在众多平台上运行;它的运行时加载和宿主的链接过程主要通过 importexport 两个段中的内容来完成;其中,包含了 WebAssembly 的4种类型的关键对象,他们分别是 Function,Global,Memory,Table。

图 2. WebAssembly 模块链接示意图

WebAssembly 可以嵌入众多的宿主及不同的语言,并仍在不断的在扩展现有边界;上图 2 展示了 WebAssembly 与外部环境对象链接,内存共享和交互的主要场景;其中 JavaScript 是 WebAssembly 相对成熟的宿主环境和语言,因此,接下来,本文将以 JavaScript 作为宿主环境,通过如下三个关键的场景来深入分析 WebAssembly 与 JavaScript 环境的运行期动态链接原理及其可能的实现。

3.1 WebAssembly exports -> JavaScript imports

上图 2 所示的 WebAssembly 链接场景中,shared-module.wasm 提供了一个共享的 WebAssembly 模块,定义并导出了 WebAssembly 核心类型的对象,包括 Global 栈指针变量 stack_pointer,Table 对象 indrect_funtion_table,memory 对象以及全局函数 fibdistance。JavaScript 宿主在运行期可以动态加载 shared-module.wasm 文件,并通过 WebAssembly 执行环境创建 shared-module 模块实例,最后为 WebAssembly 执行环境中的 exports 对象在 JavaScript 环境中创建对应的 JavaScript 对象实例,从而完成 shared-module.wasm 模块的动态加载和链接过程;此后,在运行环境中就可以按照 JavaScript 访问方式来范围和调用 WebAssembly exports 的多种类型的对象实例,如下列代码所示。

/* source link:  
 * https://github.com/yaozhongxiao/webassembly_tech/tree/master/samples/module-linking/js-shared-module-linking.js  
 */   
JSModule = {};  
/* ... skip non-critical code */  
let instance = wasmLoad(__dirname + "/lib/shared-module.wasm", JSModule);  
/* exports memory */  
let memory = instance.exports.memory;  
/* exports global */  
let sp = instance.exports.stack_pointer;  
/* exports func */  
let fn_fib = instance.exports.fib;  
let fn_distance = instance.exports.distance;  
/* exports table */  
let tbl = instance.exports.indirect_function_table;

3.2 JavaScript exports -> WebAssembly imports

上图 2 所示的 WebAssembly 链接场景中,user-module.wasm 定义了一个 WebAssembly 应用程序,其依赖于宿主环境提供的应用所需要的 WebAssembly 核心类型的对象,包括 Global 栈指针变量stack_pointer,Table 对象 indrect_funtion_table,memory 对象以及全局函数 fibdistance。为了 WebAssembly 执行环境在加载和实例化 user-module.wasm模块时能够解析模块中的未定义符号,JavaScript 宿主需提供满足链接需求的 JSModule 对象,WebAssembly 虚拟机会在执行环境中创建与 JSModule 对应的 WebAssembly 实例对象,并将 JSModule 与 WebAssembly 实例对象绑定,从而完成未定义符号解析和链接。此后,WebAssembly 执行环境中就可以按照其原生对象访问方式来访问 JSModule 对象,如下列代码所示。

/* source link:  
 * https://github.com/yaozhongxiao/webassembly_tech/tree/master/samples/module-linking/js-user-module-linking.js  
 */   
/* ... skip non-critical code */  
/* ############## 1. define the JSModule ############## */  
/* define S.funcs */  
function fib(num) {  
  if (num == 1 || num == 0) {  
    return num;  
  }  
  return fib(num - 1) + fib(num - 2);  
}  

function distance(n1, n2) {  
  let ret = Math.abs(n1 - n2);  
  console.log("invoke distance(" + n1 + ", " + n2 + ") = " + ret);  
  return ret;  
} 

/* define S.table */  
const fn_table = new WebAssembly.Table({  
  initial: 2,  
  maximum: 2,  
  element: "anyfunc",  
}); 

/* define S.memory */  
const importMemory = new WebAssembly.Memory({  
  initial: 256,  
  maximum: 32768,  
}); 

const sp = new WebAssembly.Global({ value: "i32", mutable: true }, 5243920);  

JSModule = {  
  share_ctx: {  
    stack_pointer: sp,  
    fib: fib,  
    distance: distance,  
    indirect_function_table: fn_table,  
    memory: importMemory  
  },  
  env: {  
    print: console.log.bind(console),  
  },  
};  

/* ######## 2. user-module load and link with JSDepModule ######### */  
let instance = wasmLoad(__dirname + "/lib/user-module.wasm", JSModule);  
/* ... skip non-critical code */

3.3 WebAssembly exports -> WebAssembly imports

现有的 WebAssembly 引擎中并没有标准化的 WebAssembly 模块间链接实现,虽然 wasm-micro-runtime[12]中有多模块和加载时链接机制,但使用场景相对有限而且并非是遵循标准化规范的实现。在 JavaScript 环境中,我们可以通过前2种方式的组合来实现 WebAssembly exports -> JavaScript re-exports -> WebAssembly imports 的模式,从而间接实现 WebAssembly 模块间的动态链接机制。JavaScript 运行环境首先加载并实例化 shared-module,并将 WebAssembly 实例对象的 exports 导出变量绑定到 JSModule 对象上,这些绑定到 JSModule 上的 WebAssembly 实例对象的 exports 导出变量,都将作为不可变的绑定提供给其他 WebAssembly 模块。因此,shared-module 导出对象被包装到一个 JSModule 对象,包括 Global 栈指针变量 stack_pointer,Table 对象 indrect_funtion_table,memory 对象以及全局函数 fibdistance。WebAssembly 虚拟机在加载和实例化 user-module.wasm 模块时,将 JSModule 中 shared-module 对应的实例绑定导入到 user-module 模块中的 WebAssembly imports 中,从而完成 user-module 与 shared-moudule 导入导出符号的动态链接,如下列代码所示。

/* source link:  
 * https://github.com/yaozhongxiao/webassembly_tech/tree/master/samples/module-linking/js-user-module-linking.js  
 */   
 /* ... skip non-critical code */  
 /* ############## 1. wasmLoad shared-module ############## */  
let sharedModule = wasmLoad(__dirname + "/lib/shared-module.wasm", {});  

/* ############## 2. Initialize JSModule with sharedModule ############## */  
JSModule = {  
  share_ctx: {  
    stack_pointer: sharedModule.exports.stack_pointer,  
    fib: sharedModule.exports.fib,  
    distance: sharedModule.exports.distance,  
    indirect_function_table: sharedModule.exports.indirect_function_table,  
    memory: sharedModule.exports.memory,  
  },  
  env: {  
    print: console.log.bind(console),  
  },  
};  

/* ######## 3. load user-module and link with JSDepModule ######### */  
let instance = wasmLoad(__dirname + "/lib/user-module.wasm", JSModule);  

/* ... skip non-critical code */

在本小节中,我们构建了 WebAssembly 与 JavaScript 环境的运行期动态链接的三种核心使用场景示例,并深入分析了模块链接的原理及其可能的实现;由于篇幅有限,我们仅在文中展示了示例程序的核心实现代码,本节所涉及到的示例代码及完整实现请参见webassembly_tech[11]仓库,读者可以方便的按照仓库中的指引快速重建和运行本节中所有的示例程序,

4. WebAssembly 动态链接发展趋势

使用动态链接技术,不仅能够减小 WebAssembly 应用的二进制文件大小;以此同时,将公共代码提取出来也会在一定程度上减少公共资源在内存中副本数量,从而节省宝贵的内存资源。WebAssembly 模块化和动态链接机制在 JavaScript 环境下可以通过标准的 JavaScript API 来实现大部分需求,但这种机制是建立在宿主和语言的特定约定和依赖的的基础上,对不同的宿主和语言环境没有普适性和标准化;例如,对于 Rust 环境需要采用自身语言相关的 wasm-bindgen[13] 来实现,而无法与 JavaScript 的模块化机制兼容和共享。此外,即使 JavaScript 可以在一定程度上实现 WebAssembly 的动态绑定,但 WebAssembly 仍然无法融入 JavaScript 模块系统中,导致中间有很多的交互操作和成本。CG 和 WG 有多个提案来尝试解决 WebAssembly 模块的实例化和动态链行为;接下来,我们尝试对提案的原理做简要的介绍,以便更好地了解和把握 WebAssembly 模块化和动态链接的趋势,从而最大程度的利用具有高移植性、独立于宿主和语言的可组合 WebAssembly 模块生态系统。

4.1 WebAssembly/ES Module Integration

在阐述 WebAssembly 在 JavaScript 环境中的动态链接时,我们通过 W3C 定义的标准 JavaScript API 实例化 WebAssembly 模块,该过程中需要用户手动、显示地获取模块文件,链接导入导出函数,并调用 WebAssembly.instantiateWebAssembly.instantiateStreaming 进行模块实例化,如下列代码所示。

let req = fetch("./shared-module.wasm");  
let imports = {  
    env: {  
        print  
    }  
 };  

 WebAssembly.instantiateStreaming(req, imports)  
 .then(obj => obj.instance.exports.fib());

从工程学上来说,WebAssembly 模块的显示实例化方式是非常不友好和优雅的解决方案。虽然,我们也尝试在原型实现中提供 wasm-loader.js[14] 库来复用 WebAssembly 实例化过程,但 WebAssembly 宿主和语言环境的巨大差异不可避免地导致它们需要实现各自的库,这亟需WebAssembly 社区统一规范,从而实现进行标准化。由于 WebAssembly 和 JavaScript 的渊源以及 ES Module 组件的标准化进程,ECMAScript Module Integration [15] 提案尝试添加声明式 API 来隐藏 WebAssembly 文件请求、加载、实例化和链接过程,如下列代码所示。

import {fib } from "./shared-module.wasm"

不仅如此,WebAssembly Module 也可以通过 import 来直接使用 JavaScript Module 的导出对象,从而不再依赖 "协调者" 来完成 WebAssembly 与 JS Module 的链接过程,如下列代码所示。

/* counter.js  */  
let count = 42;  

function getCount() {  
    return count;  
}  
export {getCount};  

;; main.wat --> main.wasm  
(module  
  (import "./counter.js" "getCount" (func $getCount (func (result i32))))  
)

此外,为了使 JavaScript 开发人员可以轻松地组合来自 WebAssembly 模块和 JavaScript 模块的功能。ECMAScriptModule Integration [5][15][16] 提案意图实现 WebAssembly 模块与 JavaScript 模块的融合,使得 WebAssembly 可以参与 JavaScript 模块图,从而使得 JavaScript 和 WebAssembly Module 在 ESM 模块上做到统一,如下图 3 所示。

图 3. WebAssembly 与 JavaScript 模块集成示意图

4.2 Module Linking Proposal

WebAssembly 起源于 Web,但又不局限于 Web 的应用范畴。在前面小节中,结合 WebAssembly 模块的历史演进,深入解析了其模块和动态链接的设计。虽然在特定的宿主和语言环境下,我们可以通过特殊的约定来实现部分模块动态链接能力;但随着 WebAssembly 应用场景和语言环境的不断丰富,这种方式不可避免的会阻碍了模块化和代码重用,导致生产中的代码重复和语言间的隔离。为了解决 WebAssembly 独立定义实例化和动态链行为的困境,Module Linking[17] 曾作为 WebAssembly Proposal 在 CG 被提出,它希望建立一个可移植的、独立于宿主和语言的可组合 WebAssembly 模块生态系统。

在当前的标准中,WebAssembly 只允许导入已实例化模块的导出对象,而 Module Linking 提案的中心思想是允许主模块将其依赖项作为模块导入;即,通过扩展 WebAssembly 模块规范,将模块及其依赖关系在 WebAssembly 模块和二进制格式中进行标准化,从而实现依赖项作为模块导入,并由主模块控制依赖项的实例化 (提供导入) 和 链接 (公开导出)。

Module Linking 提案避免了对任何类型的运行时加载程序 (如 ld.so) 的依赖,相反,它让 WebAssembly 运行时最终完成所有工作。Module Linking 提案在 WebAssembly 二进制格式中添加了三个新的 Section 以及两个新的索引空间,他们分别是 模块索引空间和实例索引空间,其中:

Module Linking 意在保证模块在加载之前的独立性,以便多个程序可以共享公共模块。在下图 4 中包含 zipper 和 imgmgk 两个程序,libc、libzip 和 libimg 三个共享模块;在模块加载之前,各模块间形成了图 4 上半部分所示的模块静态依赖关系图;基于 Module Linking 的动态链接机制,使得 WebAssembly 运行时在实例化过程中,可以根据模块静态依赖关系图创建动态链接的实例图,而不再依赖宿主的动态加载能力,如下图 4 所示。

图 4. WebAssembly 动态链接概念示意图

然而,模块动态链接机制最初在 Module Linking 提案中首先提出,但是该提案暂时处于 "inactive" 状态,而相关的工作转到了 "Component Model " 提案中继续推进[17];基于此,在接下来的小节中,我们将对组建模型提案做下简要的介绍。

4.3 Component Model Proposal

ESM-Integrate [15] 和 Module-Link[17] 提案尝试"自下而上"的从已知问题入手,通过修改 WebAssembly 规范,针对性的进行改良;而 "Component Model"[18] 提案则希望"自上而下"的基于模块化(组件化)模型,制定 WebAssembly 的下一代标准。Component Model 提案的核心内容已基本标覆盖了 Module-Link 提案的关键内容,其中,涉及到 WebAssembly 模块化和动态链接的目标,概括起来主要有如下几个方面:

Component Model 提案既制定了宏伟的目标,又提出了实施方针;即,从初始用例集出发,增量式进行的完善。为了做到既兼容 WebAssembly 的核心规范,又完成组件模型这个复杂系统的设计和实现,Component Model 提案也采用了计算机科学领域的通用解决方案 (All problems in computer science can be solved by another level of indirection)[17],即,以 Module Linking 为中心,结合 interface types 等相关特性,为 WebAssembly 核心规范增加一个间接中间层 "Module Linking Layer"[18],如下图 5 所示。由于 Component Model 还处于非常初期的 Feature Proposal 阶段,本文仅仅作为一个引子,相关的详细设计和演变还需要读者时刻关注和研究。

图 5. WebAssembly 分层组件化概念模型示意图

5. 总结

本文基于 JavaScript 的模块及其链接方式,探讨了利用 WebAssembly 模块的导入导出机制实现动态链接的方式及存在的问题,并进一步结合最新提案,介绍其模块化和动态链接的关键设计和实现,以及当前面临的挑战和未来的发展趋势。

本文涉及到的示例代码,原型实现等相关资料,请访问参考文献 module-linking[11] 所在的 webassembly_tech 资源库进行查阅和获取。

6. 参考文献

  1. Separation of concerns
  2. CommonJS
  3. AMD
  4. Universal Module Definition
  5. ECMAScript modules
  6. node/loader.js
  7. ES moduless
  8. asm.js Working Draft
  9. asm.js: closing the gap between JavaScript and native
  10. WebAssembly Spec
  11. webassembly_tech
  12. multi-module
  13. wasm-bindgen
  14. wasm-loader.js
  15. ECMAScript module integration (Phase 2 - Proposed Spec Text Available(CG + WG))
  16. WebAssembly ES module integration
  17. Module Linking(Inactive Proposals)
  18. Component Model (Phase 1 - Feature Proposal (CG))
  19. All problems in computer science can be solved by another level of indirection)
  20. Scoping and Layering the Module Linking and Interface Types proposals