与浏览器共享代码

Node.js 的主要卖点之一是它基于 JavaScript 并在 V8 上运行,V8 是一种 JavaScript 引擎,实际上为一些最流行的浏览器提供支持:Google Chrome 和 Microsoft Edge。 我们可能认为共享相同的 JavaScript 引擎足以使 Node.js 和浏览器之间共享代码成为一件容易的任务;但是,事实并非如此。 然而,正如我们将在本章中看到的,这并不总是正确的,除非我们只想共享简单的、独立的和通用的代码片段。

为客户端和服务器开发代码需要付出不可忽视的努力,以确保相同的代码可以在本质上不同的两个环境中正常运行。 例如,在 Node.js 中,我们没有 DOM 或长期存在的视图,而在浏览器上,我们肯定没有文件系统和许多其他接口来与底层操作系统交互。

另一个争论点是对现代 JavaScript 功能的支持程度。 当我们以 Node.js 为目标时,我们可以安全地采用现代语言功能,因为我们知道哪个 Node.js 版本在我们的服务器上运行。 例如,对于我们的服务器代码,如果我们知道它将在 Node.js 版本 8(或更新的版本)上运行,我们可以安全地决定采用 async/await。 不幸的是,当我们为浏览器编写 JavaScript 代码时,我们不能有同样的信心。

这是因为不同的用户将拥有不同的浏览器,这些浏览器对最新语言功能的兼容性程度也不同。 某些用户可能正在使用完全支持异步/等待的现代浏览器,而其他用户可能仍在使用旧设备和不支持异步/等待的旧浏览器。

因此,为这两个平台进行开发时所需的大部分努力是确保将这些差异减少到最低限度。 这可以借助抽象、模式和工具来完成,这些抽象、模式和工具使应用程序能够动态地或在构建时在浏览器兼容的代码和 Node.js 代码之间切换。

幸运的是,随着人们对这种令人兴奋的新可能性的兴趣日益浓厚,生态系统中的许多库和框架已经开始支持这两种环境。 这种演变还得到了越来越多支持这种新型工作流程的工具的支持,这些工具多年来已经得到提炼和完善。 这意味着,如果我们在 Node.js 上使用 npm 包,它很可能也能在浏览器上无缝运行。 然而,这通常不足以保证我们的应用程序可以在浏览器和 Node.js 上顺利运行。 正如我们将看到的,开发跨平台代码时始终需要仔细设计。

在本节中,我们将探讨在为 Node.js 和浏览器编写代码时可能遇到的基本问题,并且我们将提出一些工具和模式来帮助我们应对这一令人兴奋的新挑战。

跨平台上下文中的 JavaScript 模块

当我们想要在浏览器和服务器之间共享一些代码时,我们遇到的第一堵墙是 Node.js 使用的模块系统与浏览器上使用的模块系统的异构环境之间的不匹配。 另一个问题是,在浏览器上,我们没有 require() 函数或可以解析模块的文件系统。 大多数现代浏览器都支持导入和 ES 模块,但同样,访问我们网站的一些用户可能还没有采用这些现代浏览器之一。

除了这些问题之外,我们还必须考虑到服务器和浏览器分发代码的差异。 在服务器上,模块直接从文件系统加载。 这通常是一个高性能操作,因此鼓励开发人员将其代码分割成小模块,以保持不同的逻辑单元小而有组织。

在浏览器上,脚本加载模型完全不同。 该过程通常从浏览器从远程端点下载 HTML 页面开始。 HTML 代码由浏览器解析,浏览器可能会找到对需要下载和执行的脚本文件的引用。 如果我们正在处理大型应用程序,可能需要下载许多脚本,因此浏览器必须发出大量 HTTP 请求并下载和解析多个脚本文件,然后才能完全初始化应用程序。 脚本文件的数量越多,我们在浏览器上运行应用程序所付出的性能损失就越大,尤其是在慢速网络上。 尽管通过采用 HTTP/2 服务器推送 (nodejsdp.link/http2-server-push)、客户端缓存、预加载或类似技术可以减轻部分性能损失,但潜在的问题仍然存在:必须 接收和解析大量文件通常比处理一些优化文件更糟糕。

解决此问题的常见做法是为浏览器“构建”包(或捆绑包)。 典型的构建过程会将所有源文件整理成极少量的包(例如,每页一个 JavaScript 文件),以便浏览器不必为每个页面访问下载大量脚本。 构建过程不仅限于减少文件数量,事实上,它还可以执行其他有趣的优化。 另一个常见的优化是代码压缩,它允许我们在不改变功能的情况下将字符数量减少到最少。 这通常是通过删除注释、删除未使用的代码以及重命名函数和变量名称来完成的。

模块捆绑器

如果我们想要编写可以在服务器和浏览器上尽可能无缝地工作的大部分代码,我们需要一个工具来帮助我们在构建时将所有依赖项“捆绑”在一起。 这些工具通常称为模块捆绑器。 让我们通过一个示例来直观地了解如何使用模块捆绑器将共享代码加载到服务器和客户端:

image 2024 05 07 17 27 31 611
Figure 1. 图 10.1:在服务器和浏览器上加载共享模块(使用模块捆绑器)

通过查看图10.1,我们可以看到代码在服务器端和浏览器上的处理和加载方式有所不同:

  • 在服务器端:Node.js 可以直接执行我们的 serverApp.js,后者将导入 模块 moduleA.js、moduleB.js 和 moduleC.js。

  • 在浏览器上:我们有 browserApp.js,它还导入 moduleA.js、moduleB.js 和 moduleC.js。 如果我们的索引文件直接包含 browserApp.js,则在应用程序完全初始化之前,我们必须下载总共五个文件(index.html、browserApp.js 和三个依赖模块)。 模块捆绑器允许我们通过预处理 browserApp.js 及其所有依赖项并生成一个名为 main.js 的等效包,将文件总数减少到仅两个,然后由 index.html 引用并由浏览器加载。

总而言之,在浏览器上,我们通常必须处理两个逻辑阶段,构建和运行时,而在服务器上,我们通常不需要构建阶段,可以直接执行源代码。

在选择模块捆绑器时,最流行的选择可能是 webpack (nodejsdp.link/webpack)。 Webpack 是目前可用的最完整、最成熟的模块捆绑器之一,也是我们将在本章中使用的模块捆绑器。 但值得一提的是,有一个相当繁荣的生态系统,充满了替代方案,每个方案都有自己的优势。 如果您好奇,以下是一些最知名的 webpack 替代方案:

  • Parcel (nodejsdp.link/parcel):旨在快速运行,无需任何配置即可 “自动神奇地” 工作。

  • Rollup (nodejsdp.link/rollup):第一个完全支持 ESM 的模块捆绑器之一,并提供大量优化,例如树形抖动和死代码消除。

  • Browserify (nodejsdp.link/browserify):第一个支持 CommonJS 的模块捆绑器,至今仍被广泛采用。

其他趋势模块捆绑器包括 FuseBox (nodejsdp.link/fusebox)、Brunch (nodejsdp.link/brunch) 和 Microbundle (nodejsdp.link/microbundle)。

在下一节中,我们将更详细地讨论模块捆绑器的工作原理。

模块捆绑器的工作原理

我们可以将模块捆绑器定义为一种工具,它获取应用程序的源代码(以入口模块及其依赖项的形式)并生成一个或多个捆绑文件。 捆绑过程不会改变应用程序的业务逻辑; 它只是创建经过优化以在浏览器上运行的文件。 在某种程度上,我们可以将捆绑器视为浏览器的编译器。

在上一节中,我们了解了捆绑器如何帮助减少浏览器需要加载的文件总数,但实际上,捆绑器可以做的远不止这些。 例如,它可以使用像 Babel (nodejsdp.link/babel) 这样的转译器。 转译器是一种处理源代码并确保现代 JavaScript 语法转换为等效的 ECMAScript 5 语法的工具,以便各种浏览器(包括较旧的浏览器)可以正确运行应用程序。 一些模块捆绑器不仅允许我们预处理和优化 JavaScript 代码,还允许我们预处理和优化其他资产,例如图像和样式表。

在本节中,我们将提供一个简化视图,介绍模块捆绑器如何工作以及它如何导航给定应用程序的代码以生成针对浏览器优化的等效捆绑包。 模块捆绑器的工作可以分为两个步骤,我们称之为依赖解析和打包。

依赖解析

依赖关系解析步骤的目标是从主模块(也称为入口点)开始遍历代码库,并发现所有依赖关系。 捆绑器执行此操作的方法是将依赖关系表示为非循环直接图(称为依赖图)。

让我们通过一个示例来探讨这个概念:一个虚构的计算器应用程序。 实现故意不完整,因为我们只想关注模块结构、不同模块如何相互依赖,以及模块捆绑器如何构建此应用程序的依赖关系图:

// app.js (1)
import { calculator } from './calculator.js'
import { display } from './display.js'

display(calculator('2 + 2 / 4'))
// display.js (5)
export function display () {
    // ...
}

// calculator.js (2)
import { parser } from './parser.js'
import { resolver } from './resolver.js'
export function calculator (expr) {
    return resolver(parser(expr))
}

// parser.js (3)
export function parser (expr) {
    // ...
}

// resolver.js (4)
export function resolver (tokens) {
    // ...
}

让我们看看模块捆绑器如何遍历此代码以找出依赖关系图:

  1. 模块捆绑器从应用程序的入口点(模块 app.js)开始分析。 在此阶段,模块捆绑器将通过查看导入语句来发现依赖关系。 捆绑器开始扫描入口点的代码,它找到的第一个导入引用了 calculator.js 模块。 现在,捆绑器暂停对 app.js 的分析并立即跳转到 calculator.js。 捆绑器将密切关注打开的文件:它会记住 app.js 的第一行已经被扫描,这样当它最终重新开始处理该文件时,它将从第二行继续。

  2. 在 calculator.js中,捆绑器立即找到 parser.js 的新导入,以便中断 calculator.js 的处理并移至 parser.js。

  3. 在 parser.js 中,没有 import 语句,因此在完全扫描文件后,捆绑器会返回到calculator.js,其中下一个 import 语句引用 resolver.js。 同样,calculator.js 的分析被暂停,捆绑器立即跳转到 resolver.js。

  4. 模块 resolver.js 不包含任何导入,因此控制权返回到 calculator.js。 Calculator.js 模块不包含其他导入,因此控制权返回到 app.js。 在 app.js 中,下一个导入是 display.js,捆绑器会直接跳转到其中。

  5. display.js 不包含任何导入。 因此,控制权又回到了 app.js。 app.js 中不再有导入,因此代码已被充分探索,并且依赖关系图已完全构建。

每当模块捆绑器从一个文件跳转到另一个文件时,这意味着我们正在发现一个新的依赖项并向依赖关系图中添加一个新节点。 前面列表中描述的步骤的直观表示可以在图 10.2 中找到:

image 2024 05 07 17 34 33 842
Figure 2. 图 10.2:依赖关系图解析

这种解决依赖关系的方法也适用于循环依赖关系。 事实上,如果捆绑器第二次遇到相同的依赖关系,该依赖关系将被跳过,因为它已经存在于依赖关系图中。

摇树

值得注意的是,如果我们的项目模块中有从未导入的实体(例如函数、类或变量),那么它们不会出现在这个依赖图中,因此它们不会包含在最终的包中 。 更高级的模块捆绑器还可以跟踪从每个模块导入的实体以及在依赖关系图中找到的导出实体。 这允许捆绑包确定是否存在应用程序中从未使用过的导出功能,以便可以从最终捆绑包中删除它们。 这种优化技术称为树摇动(nodejsdp.link/tree-shaking)。

在依赖关系解析阶段,模块捆绑器构建一个称为模块映射的数据结构。 该数据结构是一个哈希映射,具有唯一的模块标识符(例如文件路径)作为键,并以模块源代码的表示形式作为值。 在我们的示例中,模块映射的简化表示可能如下所示:

{
    'app.js': (module, require) => {/* ... */},
    'calculator.js': (module, require) => {/* ... */},
    'display.js': (module, require) => {/* ... */},
    'parser.js': (module, require) => {/* ... */},
    'resolver.js': (module, require) => {/* ... */}
}

模块映射中的每个模块都是一个工厂函数,它接受两个参数:module 和 require。 我们将在下一节中更详细地了解这些论点。 现在需要理解的重要一点是,这里的每个模块都是原始源模块中代码的完整表示。 例如,如果我们采用 calculator.js 模块的代码,它可能表示如下:

(module, require) => {
    const { parser } = require('parser.js')
    const { resolver } = require('resolver.js')
    module.exports.calculator = function (expr) {
        return resolver(parser(expr))
    }
}

请注意 ESM 语法是如何转换为类似于 CommonJS 模块系统语法的。 请记住,浏览器不支持 CommonJS 并且这些变量不是全局的,因此这里不存在命名冲突的风险。 在这个简化的实现中,我们决定使用与 CommonJS 中完全相同的标识符(module、require 和 module.exports),以使与 CommonJS 的相似性看起来更加明显。 实际上,每个模块捆绑器都会使用自己的唯一标识符。 例如,webpack 使用 __webpack_require____webpack_exports__ 等标识符。

打包

模块映射是依赖解析阶段的最终输出。 在打包阶段,模块捆绑器获取模块映射并将其转换为可执行捆绑包:包含原始应用程序的所有业务逻辑的单个 JavaScript 文件。

这个想法很简单:我们已经在模块映射中拥有了应用程序原始代码库的表示; 我们必须找到一种方法将其转换为浏览器可以正确执行的内容并将其保存到生成的捆绑文件中。

考虑到模块映射的结构,实际上只需几行包装模块映射的代码即可完成此操作:

((modulesMap) => { // (1)
    const require = (name) => { // (2)
        const module = { exports: {} } // (3)
        modulesMap[name](module, require) // (4)
        return module.exports // (5)
    }
    require('app.js') // (6)
})(
    {
        'app.js': (module, require) => {/* ... */},
        'calculator.js': (module, require) => {/* ... */},
        'display.js': (module, require) => {/* ... */},
        'parser.js': (module, require) => {/* ... */},
        'resolver.js': (module, require) => {/* ... */},
    }
)

这不是很多代码,但这里发生了很多事情,所以让我们一起逐步完成它:

  1. 在此代码片段中,我们有一个立即调用函数表达式 (IIFE),它接收整个模块映射为 一个论点。

  2. 函数执行时,定义了自定义的 require 函数。 该函数接收模块名称作为输入,它将从 moduleMap 加载并执行相应的模块。

  3. 在 require 函数中,初始化一个模块对象。 这个对象只有一个属性,称为exports,它是一个没有属性的对象。

  4. 此时,给定模块的工厂函数被调用,我们将刚刚创建的模块对象和对 require 函数本身的引用传递给它。 请注意,这本质上是服务定位器模式的实现 (nodejsdp.link/service-locator-pattern)。 这里,工厂函数一旦执行,就会通过附加模块导出的功能来修改模块对象。 工厂函数还可以通过使用作为参数传递的 require 函数来递归地请求其他模块。

  5. 最后,require 函数返回 module.exports 对象,该对象由上一步中调用的工厂函数填充。

  6. 最后一步是要求依赖图的入口点,在我们的例子中是模块 app.js。 最后一步实际上是引导整个应用程序。 事实上,通过加载入口点,它将依次以正确的顺序加载并执行其所有依赖项,然后执行其自己的业务逻辑。

通过这个过程,我们基本上创建了一个自给自足的模块系统,能够加载已在同一文件中正确组织的模块。 换句话说,我们设法将最初组织在多个文件中的应用程序转换为等效应用程序,其中所有代码都已移至单个文件中。 这是生成的捆绑文件。

请注意,前面的代码已被有意简化,只是为了说明模块捆绑器的工作原理。 有许多我们没有考虑到的边缘情况。 例如,如果我们需要模块映射中不存在的模块,会发生什么?

使用 webpack

现在我们知道了模块捆绑器的工作原理,让我们构建一个可以在 Node.js 和浏览器上运行的简单应用程序。 在整个练习中,我们将学习如何编写一个简单的库,无需对浏览器应用程序和服务器应用程序进行更改即可使用该库。 我们将使用 webpack 来构建浏览器包。

为了简单起见,我们的应用程序现在只不过是一个简单的 “hello world”,但不用担心,我们将在本章后面的创建通用 JavaScript 应用程序部分中构建一个更实际的应用程序。

让我们首先在我们的系统中安装 webpack CLI:

npm install --global webpack-cli

现在让我们在新文件夹中初始化一个新项目:

npm init

引导项目初始化完成后,由于我们要在 Node.js 中使用 ESM,因此需要将属性 "type":"module" 添加到 package.json 中。

现在,我们可以运行:

webpack-cli init

此引导过程将在您的项目中安装 webpack,并帮助您自动生成 webpack 配置文件。 在撰写本文时,使用 webpack 4,引导过程并未意识到我们要在 Node.js 中使用 ESM,因此我们必须对生成的文件进行两个小更改:

  • 将 webpack.config.js 重命名为 webpack.config.cjs

  • 更改 package.json 中的以下 npm 脚本:

    "build": "webpack --config webpack.config.cjs"
    "start": "webpack-dev-server --config webpack.config.cjs"

现在,我们准备开始编写我们的应用程序。

我们首先在 src/say-hello.js 中编写我们要共享的模块:

import nunjucks from 'nunjucks'

const template = '<h1>Hello <i>{{ name }}</i></h1>'

export function sayHello (name) {
    return nunjucks.renderString(template, { name })
}

在此代码中,我们使用 nunjucks 模板库 (nodejsdp.link/nunjucks),它必须与 npm 一起安装。 该模块导出一个简单的 sayHello 函数,该函数接受名称作为唯一参数并使用它来构造 HTML 字符串。

现在让我们编写将使用此模块的浏览器应用程序 (src/index.js):

import { sayHello } from './say-hello.js'

const body = document.getElementsByTagName('body')[0]
body.innerHTML = sayHello('Browser')

此代码使用 sayHello 函数构建一个 HTML 片段,表示“Hello Browser”,然后将其插入到当前 HTML 页面的正文部分。

如果您想预览此应用程序,可以在终端中运行 npm start。 这应该会打开您的默认浏览器,并且您应该会看到应用程序正在运行。

如果您想生成应用程序的静态版本,可以运行:

npm run build

这将生成一个名为 dist 的文件夹,其中包含两个文件:index.html 和我们的捆绑文件(其名称类似于 main.12345678901234567890.js)。

捆绑包的文件名是使用文件内容的哈希值生成的。 这样,每次我们的源代码发生变化时,我们都会获得一个具有不同名称的新包。 这是一种有用的优化技术,称为缓存清除,webpack 默认采用,并且在将我们的资产部署到内容交付网络 (CDN) 时特别方便。 使用 CDN,覆盖地理上分布在多个服务器上并且已经缓存在多层(可能包括我们用户的浏览器)中的文件通常非常昂贵。 通过每次更改都会生成新文件,我们完全避免了缓存失效。

您可以使用浏览器打开 index.html 文件以查看应用程序的预览。

如果您好奇,可以查看生成的捆绑文件。 您会注意到它比我们在上一节中演示的示例包更加复杂和冗长。 但是,您应该能够识别该结构,并注意到整个 nunjucks 库以及我们的 sayHello 模块已嵌入到捆绑代码中。

现在,如果我们想要构建一个在 Node.js 中运行的等效应用程序怎么办? 例如,我们可以使用 sayHello 函数并在终端中显示结果代码:

// src/server.js
import { sayHello } from './say-hello.js'
console.log(sayHello('Node.js'))

就是这样!

如果我们运行此代码:

node src/server.js

我们将看到以下输出:

<h1>Hello <i>Node.js</i></h1>

是的,在终端中显示 HTML 并不是特别有用,但现在我们实现了能够从浏览器和服务器使用库的目标,而无需对库代码库进行任何更改。

在接下来的部分中,我们将讨论一些模式,如果我们想在浏览器或 Node.js 上提供更专业的行为,这些模式允许我们在必要时实际更改代码。