重新审视前端模块的调用, 执行和加载之间的关系

9,324 阅读2分钟

好几个月没动笔了, 最近一直和团队在研究一些技术上的东西, 正好有一部分关于前端模块的内容可以和大家分享下.

在进入正题之前, 让我们先回顾下前端模块从无到有的一个简短历史

如果你有一定的工作经验, 并且经历过 jQuery 那样的年代, 应该了解早期的前端模块, 只是 window 上的一个局部变量.

在最初的时候前端工程师为了分享自己的代码, 往往会通过 window 来建立联系, 这种古老的做法至今还被很多人使用, 因为简单. 例如我们编写了一个脚本, 通常我们并不认为这是个模块, 但我们会习惯于将这个脚本包装成一个对象. 例如

window.myModule = {
    getName(name){
        return `hello ${name}`
    }
}

当其他人加载这个脚本后, 就可以便捷的通过 window.myModule 来调用 getName 方法.

早期的 JavaScript 脚本主要用于开发一些简单的表单和网页的交互功能, 那个年代的前端工程师数量也极少, 通过 window 来实现模块化并没有什么太大的问题.

直到 ajax 的出现, 将 web 逐步推动到了富客户端的阶段, 随着 spa 的兴起, 前端工程师发现使用 window 模块化代码越来越难以维护, 主要原因有 2 个

  1. 大量的模块加载污染了 window, 导致各种命名冲突和意外覆盖, 这些问题还很难定位.
  2. 模块和模块之间的交互越来越多, 为了保证调用顺序, 需要人为保障 script 标签的加载顺序

为了解决这个问题, 类似 require seajs 这样的模块 loader 被创造出来, 通过模块 loader, 大大缓解了上述的两个问题.

但前端技术和互联网发展的速度远超我们的想象, 随着网页越来越像一个真实的客户端, 这对前端的工程能力提出了极大的挑战, 仅靠单纯的脚本开发已经难以满足项目的需要, 于是 gulp 等用于前端工程管理的脚手架开始进入我们的视野, 不过在这个阶段, 模块 loader 和前端工程流之间尚未有机的结合.

直到 nodejs 问世, 前端拥有了自己的包管理工具 npm, 在此基础上 Webpack 进一步推动了前端工程流和模块之间的整合, 随后前端模块化的进程开始稳固下来, 一直保持至今.

从这个历史上去回顾, 前端模块化的整个进程包括 es6 关于 module 的标准都是一直围绕这个一个核心命题存在的.

无论是 require 还是 Webpack 在这个核心命题上并没有区别, 即前端模块遵循

加载 → 调用 → 执行 这样的一个逻辑关系. 因为模块必须先加载才能调用并执行, 模块加载器和构建工具就必须管理和分析应用中所有模块的依赖关系, 从而确定哪些模块可以拆分哪些可以合并, 以及模块的加载顺序.

但是随着时间的推移, 前端应用的模块越来越多, 应用越来越庞大, 我们的本地的 node_modules 几百兆起步, Webpack 虽然做了很多优化, 但是 rebuild 的时间在大型应用面前依然显得很慢.

今年 2 月份, Webpack 5 发布了他们的模块拆解方案, 模块联邦, 这个插件解决了 Webpack 构建的模块无法在多个工程中复用的问题.

早些时间 yarn 2.0 采用共享 node_moudles 的方法来解决本地模块大量冗余导致的性能问题. 包括 nodejs 作者在 deno 中放弃了 npm 改用网络化加载模块的方式等等.

可以看到社区已经意识到了先行的前端模块化机制再次面临瓶颈, 无论是性能还是维护成本都面临诸多挑战, 各个团队都在想办法开辟一个新的方向.

不过这些努力依然没有超越先行模块化机制中的核心命题, 即模块必须先加载, 后调用执行.

只要这个核心命题不变, 模块的依赖问题依然是无解的. 为此我们尝试提出了一种新的思路

模块为什么不能先调用, 后加载执行呢?

如果 A 模块调用 B 模块, 但并不需要 B 模块立即就绪, 这就意味着, 模块加载器可以不关心模块的依赖关系, 而致力于只解决模块加载的效率和性能问题.

同时对于构建工具来说, 如果 A 模块的执行并不基于 B 模块立即就绪这件事, 那么构建工具可以放心的将 A 和 B 模块拆成两个文件, 如果模块有很多, 就可以利用 http2 的并行加载能力, 大大提升模块的加载性能.

在我们的设想中, 一种新的模块加载方式是这样的

// remoteModule.js 这是一个发布到 cdn 的远程模块, 内部代码是这样

widnow.rdeco.create({
    name:'remote-module',
    exports:{
        getName(name, next){
            next(`hello ${name}`)
        }
    }
})

让我们先不加载这个模块, 而是直接先执行调用端的代码例如这样

window.rdeco 可以理解成类似 Webpack runtime 一样的存在, 不过 rdeco 是一个独立的库, 其功能远不止于此

// localModule.js 这个是本地的模块
window.rdeco.inject('remote-module').getName('world').then(fullName=>{
    console.log(fullName)
})

然后我们在 html 中先加载 localModule.js 后加载 remoteModule.js

<scirpt src="localModule.js"></script>
<scirpt src="remoteModule.js"></script>

正常理解, localModule.js 加载完之后会试图去调用 remote-module 的 getName 方法, 但此时 remoteModule 尚未加载, 按照先行的模块化机制, 这种调用会抛出异常. 为了避免这个问题

模块构建工具需要分析两个文件的代码, 从而发现 localModule.js 依赖 remoteModule.js, 然后保存这个依赖顺序, 同时通知模块加载器, 为了让代码正常执行, 必须先加载 remoteModule.js.

但如果模块可以先调用后加载, 那么这个复杂的过程就可以完全避免. 目前我们实现了这一机制, 可以看下这个 demo: codesandbox.io/s/tender-ar…

你可试着先点击 Call remote module's getName method 按钮,

此时文案不会变化只是显示 hello, 但代码并不会抛出异常, 然后你再点击 Load remote module 按钮, 开始加载 remoteModule, 等待加载完成, getName 才会真实执行, 此时文案变成了 hello world

上述例子是我们重新看待前端模块化, 代码拆分的一个缩影, 我们在实际的应用开发中, 大量采用了类似的思路, 目前相关的功能都被封装在 rdeco: github.com/kinop112365… 这个项目中, 如果你对我们的工作感兴趣, 欢迎 star 关注 👏🏻, 更欢迎加入我们, 下面留言即可