跨平台开发的基础知识
在针对不同平台进行开发时,我们面临的最常见问题是如何重用尽可能多的代码,同时为特定于平台的细节提供专门的实现。 我们现在将探讨面对这一挑战时使用的一些原则和模式,例如代码分支和模块交换。
运行时代码分支
基于主机平台提供不同实现的最简单直观的技术是动态分支我们的代码。 这就要求我们有一种机制可以在运行时识别主机平台,然后用 if…else 语句动态切换实现。 一些通用方法涉及检查仅在 Node.js 或浏览器上可用的全局变量。
例如,我们可以检查 window 全局变量是否存在。 让我们修改 say-hello.js 模块以使用此技术来提供略有不同的功能,具体取决于该模块是在浏览器上运行还是在服务器上运行:
import nunjucks from 'nunjucks'
const template = '<h1>Hello <i>{{ name }}</i></h1>'
export function sayHello (name) {
if (typeof window !== 'undefined' && window.document) {
// client-side code
return nunjucks.renderString(template, { name })
}
// Node.js code
return `Hello \u001b[1m${name}\u001b[0m`
}
转义序列 \u001b[1m 是一种特殊的终端格式指示符,可将文本设置为粗体。 序列 \u001b[0m 相反将格式重置为正常。 如果您想了解有关转义序列及其历史的更多信息,请查看 ANSI 转义序列:nodejsdp.link/ansi-escape-sequences。 |
再次尝试在 Node.js 和浏览器上运行我们的应用程序,看看差异! 如果这样做,则在运行 Node.js 应用程序时,您将不会在终端上看到 HTML 代码。 相反,您将看到一个具有正确终端格式的字符串。 浏览器上的前端应用程序保持不变。
运行时代码分支的挑战
使用运行时分支方法在 Node.js 和浏览器之间切换绝对是我们可以用于此目的的最直观、最简单的模式; 然而,也存在一些不便之处:
-
两个平台的代码都包含在同一模块中,因此包含在最终的捆绑包中。 这会增加包的大小,添加无法访问和不必要的代码。 无法访问的代码也可能包含不应发送到用户浏览器的敏感信息,例如加密密钥或 API 密钥。 在这种情况下,这种方法也可能会引起重大的安全问题。
-
如果使用得太广泛,它会大大降低代码的可读性,因为业务逻辑将与仅用于增加跨平台兼容性的逻辑混合在一起。
-
根据平台,使用动态分支加载不同的模块将导致所有模块都添加到最终包中,无论其目标平台如何。 例如,如果我们考虑以下代码片段,则 clientModule 和 serverModule 都将包含在使用 webpack 生成的包中,除非我们明确从构建中排除其中之一:
import { clientFunctionality } from 'clientModule' import { serverFunctionality } from 'serverModule' if (typeof window !== 'undefined' && window.document) { clientFunctionality() } else { serverFunctionality() }
造成最后这种不便的原因如下:
-
捆绑程序无法确定构建时知道运行时变量的值(除非该变量是常量),因此,在前面的示例中,if…else 语句的两个分支始终包含在最终捆绑包中 ,尽管很明显浏览器总是只执行其中之一。
-
ES 模块导入始终在文件顶部以声明方式定义,我们没有办法根据当前环境过滤导入。 捆绑器不会尝试了解您是否有条件地仅使用导入功能的子集,并且无论如何它都会包含所有导入的代码。
最后一个属性的结果是使用变量动态导入的模块不包含在捆绑包中。 例如,从以下代码来看,不会捆绑任何模块:
moduleList.forEach(function(module) {
import(module)
})
值得强调的是,webpack 克服了其中一些限制,并且在某些特定情况下,它能够猜测动态需求的所有可能值。 例如,如果您有如下代码片段:
function getControllerModule (controllerName) {
return import(`./controller/${controllerName}`)
}
Webpack 将在最终包中包含控制器文件夹中可用的所有模块。
强烈建议您查看官方文档以了解所有支持的案例(nodejsdp.link/webpack-dynamic-imports)。
构建时代码分支
在本节中,我们将了解如何使用 webpack 插件在构建时删除我们只想在服务器上运行的所有代码部分。 这使我们能够获得更轻的捆绑文件,并避免意外暴露包含仅应存在于服务器上的敏感信息(例如秘密、密码或 API 密钥)的代码。
Webpack 提供对插件的支持,这使我们能够扩展 webpack 的功能并添加可用于生成捆绑文件的新处理步骤。 为了执行构建时代码分支,我们可以利用名为 DefinePlugin 的内置插件和名为 terser-webpack-plugin (nodejsdp.link/terser-webpack) 的第三方插件。
DefinePlugin 可用于用自定义代码或变量替换源文件中出现的特定代码。 terser-webpack-plugin 允许我们压缩生成的代码并删除无法访问的语句(消除死代码)。
让我们首先重写 say-hello.js 模块来探索这些概念:
import nunjucks from 'nunjucks'
export function sayHello (name) {
if (typeof __BROWSER__ !== 'undefined') {
// client-side code
const template = '<h1>Hello <i>{{ name }}</i></h1>'
return nunjucks.renderString(template, { name })
}
// Node.js code
return `Hello \u001b[1m${name}\u001b[0m`
}
请注意,我们正在检查是否存在名为 __BROWSER__
的通用变量来启用浏览器代码。 这是我们将在构建时使用 DefinePlugin 替换的变量。
现在,让我们安装 terser-webpack-plugin:
npm install --save-dev terser-webpack-plugin
最后,让我们更新 webpack.config.cjs 文件:
// ...
const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
mode: 'production',
// ...
plugins: [
// ...
new webpack.DefinePlugin({
__BROWSER__: true
})
],
// ...
optimization: {
// ...
minimize: true,
minimizer: [new TerserPlugin()]
}
}
这里的第一个更改是将选项模式设置为生产。 此选项将启用诸如代码缩减(或最小化)之类的优化。 优化选项在专用优化对象中定义。 在这里,我们通过将最小化设置为 true 来启用缩小,并提供一个新的 terser-webpack-plugin 实例作为最小化器。 最后,我们还添加了 webpack.DefinePlugin 并将其配置为将字符串 __BROWSER__
替换为值 true。
DefinePlugin 配置对象中的每个值都代表一段代码,webpack 将在构建时对其进行评估,然后用于替换当前匹配的代码片段。 这允许我们添加外部动态值,例如包含环境变量的内容、当前时间戳或最后一次 git 提交到包的哈希值。
通过此配置,当我们构建新包时,每次出现的 __BROWSER__
都会被替换为 true。 第一个 if 语句在内部看起来像 if (true !== 'undefined'),但 webpack 足够聪明,可以理解这个表达式将始终被评估为 true,因此它将结果代码再次转换为 if (true)。
一旦 webpack 处理完所有代码,它将调用 terser-webpack-plugin 以最小化生成的代码。 terser-webpack-plugin 是 Terser (nodejsdp.link/terser) 的包装器,Terser 是一个现代 JavaScript 压缩器。 Terser 能够作为其最小化算法的一部分删除死代码,因此,在这个阶段,我们的代码将如下所示:
if (true) {
const template = '<h1>Hello <i>{{ name }}</i></h1>'
return nunjucks.renderString(template, { name })
}
return `Hello \u001b[1m${name}\u001b[0m`
Terser 会将其简化为:
const template = '<h1>Hello <i>{{ name }}</i></h1>'
return nunjucks.renderString(template, { name })
这样,我们就摆脱了浏览器包中的所有服务器端代码。
即使构建时代码分支比运行时代码分支好得多,因为它生成更精简的捆绑文件,但在滥用时它仍然会使我们的源代码变得麻烦。 事实上,如果过度使用这种技术,您最终会得到包含太多 if 语句的代码,这将难以理解和调试。
发生这种情况时,通常最好将所有特定于平台的代码移至专用模块中。 我们将在下一节中讨论这种替代方法。
模块交换
大多数时候,我们在构建时就已经知道哪些代码必须包含在客户端包中,哪些不应该包含在客户端包中。 这意味着我们可以预先做出这个决定,并指示捆绑器在构建时替换整个模块的实现。 这通常会产生更精简的捆绑包,因为我们排除了不必要的模块,并且产生了更可读的代码,因为我们没有运行时和构建时分支所需的所有 if…else 语句。
让我们通过更新示例来了解如何使用 webpack 进行模块交换。
主要思想是我们希望有两种单独的 sayHello 功能实现:一种针对服务器优化 (say-hello.js),另一种针对浏览器优化 (say-hello-browser.js)。 然后,我们将告诉 webpack 将 say-hello.js 的任何导入替换为 say-hello-browser.js。 让我们看看我们的新实现现在是什么样子:
// src/say-hello.js
import chalk from 'chalk'
export function sayHello (name) {
return `Hello ${chalk.green(name)}`
}
// src/say-hello-browser.js
import nunjucks from 'nunjucks'
const template = '<h1>Hello <i>{{ name }}</i></h1>'
export function sayHello (name) {
return nunjucks.renderString(template, { name })
}
请注意,在服务器端版本中,我们引入了一个新的依赖项 chalk (nodejsdp.link/chalk),这是一个实用程序库,允许我们为终端设置文本格式。 这是为了展示这种方法的主要优点之一。 现在我们已经将服务器端代码与客户端代码分开,我们可以引入新的功能和库,而不必担心它们可能对仅前端包产生的影响。 此时,为了告诉 webpack 在构建时交换模块,我们必须在 webpack.config.cjs 中用新插件替换 webpack.DefinePlugin,如下所示:
plugins: [
// ...
new webpack.NormalModuleReplacementPlugin(
/src\/say-hello\.js$/,
path.resolve(__dirname, 'src', 'say-hello-browser.js')
)
]
我们使用 webpack.NormalModuleReplacementPlugin,它接受两个参数。 第一个参数是正则表达式,第二个参数是表示资源路径的字符串。 在构建时,如果模块路径与给定的正则表达式匹配,则会将其替换为第二个参数中提供的路径。
请注意,此技术不仅限于我们的内部模块,还可以与我们的 node_modules 文件夹中的外部库一起使用。
借助 webpack 和模块替换插件,我们可以轻松处理平台之间的结构差异。 我们可以专注于编写旨在提供特定于平台的代码的单独模块,然后可以在最终捆绑包中将仅限 Node.js 的模块与特定于浏览器的模块交换。
跨平台开发的设计模式
现在让我们修改一下前面几章中讨论的一些设计模式,看看如何利用这些模式进行跨平台开发:
-
策略和模板:这两种模式可能是与浏览器共享代码时最有用的模式。 事实上,他们的目的是定义算法的通用步骤,允许替换其某些部分,这正是我们所需要的! 在跨平台开发中,这些模式允许我们共享组件中与平台无关的部分,同时允许使用不同的策略或模板方法来更改特定于平台的部分(可以使用代码分支(运行时或构建时)进行更改) -时间)或模块交换)。
-
适配器:当我们需要交换整个组件时,此模式可能最有用。 我们已经在第 8 章“结构设计模式”中看到了几个例子。 如果您的服务器应用程序使用像 SQLite 这样的数据库,您可以使用适配器模式来提供在浏览器中工作的替代数据存储实现。 例如,您可以使用 localStorage API (nodejsdp.link/localstorage) 或 IndexedDB API (nodejsdp.link/indexdb)。
-
代理:当本应在服务器上运行的代码在浏览器上运行时,我们通常需要在服务器上使用的功能在浏览器上也可用。 这就是远程代理模式有用的地方。 想象一下,如果我们想从浏览器访问服务器的文件系统:我们可以考虑在客户端创建一个 fs 对象,它代理对服务器上 fs 模块的每次调用,使用 Ajax 或 WebSockets 作为交换命令的方式 返回值。
-
依赖项注入和服务定位器:依赖项注入和服务定位器都可用于在注入时替换模块的实现。 当我们在打包部分介绍模块映射的概念时,我们还看到了模块捆绑器本质上如何使用服务定位器模式将来自不同模块的所有代码整理到一个文件中。
正如我们所看到的,我们可以使用的模式库非常强大,但最强大的武器仍然是开发人员选择最佳方法并使其适应当前具体问题的能力。
现在我们了解了模块捆绑器的基础知识,并且已经学习了一些编写跨平台代码的有用模式,我们准备好进入本章的第二部分,在这里我们将学习 React 并编写我们的第一个通用 JavaScript 应用。