开发模式是如何工作的?
August 4, 2019
如果你的 JavaScript 代码库稍微有点复杂,你很可能已经有办法在开发和生产环境中分别打包并运行不同的代码。
在开发和生产环境中分别打包并运行不同的代码非常强大。在开发模式下,React 会包含许多警告,帮助你在问题变成 bug 之前发现它们。然而,这些用于检测错误的代码通常会增加包的体积,并让应用运行变慢。
在开发环境下,变慢是可以接受的。实际上,开发环境下运行速度变慢甚至可能是有益的,因为这在一定程度上弥补了开发者高性能机器与普通用户设备之间的性能差异。
但在生产环境中,我们不希望为此付出任何代价。因此,这些检查会在生产环境中被省略。那么这是如何实现的呢?让我们来看看。
在开发环境下运行不同代码的具体方式,取决于你的 JavaScript 构建流程(以及你是否有这样的流程)。在 Facebook,通常是这样的:
if (__DEV__) {
doSomethingDev();
} else {
doSomethingProd();
}
这里的 __DEV__
并不是真正的变量。它是一个常量,在模块被拼接到浏览器时会被替换。最终结果如下:
// 开发环境下:
if (true) {
doSomethingDev(); // 👈
} else {
doSomethingProd();
}
// 生产环境下:
if (false) {
doSomethingDev();
} else {
doSomethingProd(); // 👈
}
在生产环境下,你还会对代码运行一个压缩工具(比如 terser)。大多数 JavaScript 压缩工具都会做有限的无用代码消除,比如移除 if (false)
分支。所以在生产环境下你只会看到:
// 生产环境下(压缩后):
doSomethingProd();
(注意,主流 JavaScript 工具在无用代码消除方面有明显的局限性,但这是另一个话题。)
即使你没有用 __DEV__
这种魔法常量,如果你用的是像 webpack 这样的主流 JavaScript 打包工具,也有类似的约定可用。例如,常见的写法如下:
if (process.env.NODE_ENV !== 'production') {
doSomethingDev();
} else {
doSomethingProd();
}
这正是像 React 和 Vue 这样的库在你通过 npm 并使用打包工具导入时所采用的模式。(单文件 <script>
标签构建则分别提供开发版 .js
和生产版 .min.js
文件。)
这种约定最初来自 Node.js。在 Node.js 中,有一个全局的 process
变量,它通过 process.env
对象暴露了系统的环境变量。然而,当你在前端代码中看到这种模式时,通常并没有真正的 process
变量。🤯
实际上,整个 process.env.NODE_ENV
表达式会在构建时被替换为字符串字面量,就像我们的魔法变量 __DEV__
一样:
// 开发环境下:
if ('development' !== 'production') { // true
doSomethingDev(); // 👈
} else {
doSomethingProd();
}
// 生产环境下:
if ('production' !== 'production') { // false
doSomethingDev();
} else {
doSomethingProd(); // 👈
}
因为整个表达式是常量('production' !== 'production'
保证为 false
),压缩工具也可以移除另一分支。
// 生产环境下(压缩后):
doSomethingProd();
大功告成。
需要注意的是,这种方式在更复杂的表达式下并不起作用:
let mode = 'production';
if (mode !== 'production') {
// 🔴 不保证会被消除
}
由于 JavaScript 的动态特性,静态分析工具并不太智能。当它们遇到像 mode
这样的变量,而不是像 false
或 'production' !== 'production'
这样的静态表达式时,通常就“放弃”了。
同样地,当你在模块顶层用 import
语句时,JavaScript 的无用代码消除通常也无法跨模块边界生效:
// 🔴 不保证会被消除
import {someFunc} from 'some-module';
if (false) {
someFunc();
}
因此,你需要以非常“机械化”的方式编写代码,使条件绝对静态,并确保所有你想要消除的代码都包含在其中。
要让这一切生效,你的打包工具需要负责替换 process.env.NODE_ENV
,并且需要知道你希望以哪种模式构建项目。
几年前,忘记配置环境变量是很常见的。经常会看到开发模式下的项目被部署到生产环境。
这很糟糕,因为会让网站加载和运行变慢。
在过去两年里,这种情况有了很大改善。例如,webpack 添加了一个简单的 mode
选项,无需手动配置 process.env.NODE_ENV
的替换。React DevTools 现在也会在开发模式下的网站显示红色图标,让你很容易发现并且举报。
像 Create React App、Next/Nuxt、Vue CLI、Gatsby 等带有明确约定的工具,通过将开发构建和生产构建分为两个独立命令,让你更难犯错。(比如 npm start
和 npm run build
。)通常,只有生产构建可以被部署,这样开发者就不会再犯错了。
总有人争论,也许生产模式应该是默认的,开发模式应该是可选开启的。就我个人而言,我并不认同这种观点。最需要开发模式警告的人,往往是刚接触这个库的新手。他们不会知道要去开启它,从而错过了许多本可以提前发现的 bug。
没错,性能问题很糟糕。但把有 bug 的体验交付给最终用户同样糟糕。例如,React 的 key 警告 能帮助你避免把消息发给错误的人或者买错商品这样的 bug。关闭警告进行开发,对你和你的用户来说都是很大的风险。如果默认关闭,等你发现并开启时,警告已经堆积如山,大多数人只会再关掉它。这就是为什么它必须从一开始就开启,而不是后期再打开。
最后,即使开发警告是可选开启的,并且开发者知道要在开发早期就打开,我们还是会回到最初的问题。总会有人在部署到生产时忘记关掉它们!
一切又回到原点。
我个人相信,工具应该根据你是在调试还是在部署,自动显示和使用正确的模式。几乎所有其他环境(无论是移动端、桌面端还是服务器端),几十年来都能区分加载开发和生产构建,只有 Web 浏览器例外。
或许,与其让各个库各自发明和依赖这些约定,不如让 JavaScript 环境把这种区分作为一等公民来对待。
哲学讨论到此为止!
让我们再看看这段代码:
if (process.env.NODE_ENV !== 'production') {
doSomethingDev();
} else {
doSomethingProd();
}
你可能会疑惑:既然前端代码里没有真正的 process
对象,为什么像 React 和 Vue 这样的库在 npm 构建中还要依赖它?
(再澄清一下:你可以直接在浏览器用 <script>
标签加载的 React 和 Vue,都不依赖这个。你需要手动选择开发版 .js
还是生产版 .min.js
文件。下面的内容只讨论通过 npm import
并用打包工具使用 React 或 Vue 的情况。)
像编程中许多事情一样,这个约定主要是历史原因。我们还在用它,是因为现在已经被各种工具广泛采用。切换到别的方式成本很高,而且收效甚微。
那么它的历史是什么?
在 import
和 export
语法标准化之前,有好几种不同的模块关系表达方式。Node.js 推广了 require()
和 module.exports
,也就是 CommonJS。
早期发布到 npm 的代码都是为 Node.js 编写的。Express 曾经(现在可能还是?)是最流行的 Node.js 服务器框架,它用 NODE_ENV
环境变量 来启用生产模式。其他一些 npm 包也采用了同样的约定。
早期的 JavaScript 打包工具如 browserify,想让前端项目也能用 npm 上的代码。(没错,那时 几乎没人用 npm 做前端!你能想象吗?)所以它们把 Node.js 生态已有的约定扩展到了前端代码。
最初的 “envify” 转换器是在 2013 年发布的。React 在那时开源,npm + browserify 似乎是当时打包前端 CommonJS 代码的最佳方案。
React 从一开始就提供了 npm 构建(除了 <script>
标签构建)。随着 React 流行起来,模块化 JavaScript 和通过 npm 发布前端代码的做法也流行起来。
React 需要在生产模式下移除仅开发用的代码。browserify 已经提供了解决方案,所以 React 也采用了用 process.env.NODE_ENV
的约定。随着时间推移,webpack、Vue 等其他工具和库也都采用了这一约定。
到了 2019 年,browserify 已经不再主流。然而,在构建步骤中用 'development'
或 'production'
替换 process.env.NODE_ENV
的做法依然非常流行。
(很有意思的是,未来如果 ES Modules 作为分发格式而不仅仅是编写格式被广泛采用,会不会改变这一格局。欢迎在 Twitter 上和我讨论!)
还有一件事可能让你困惑:在 React 的 源码(GitHub 上)你会看到用 __DEV__
这个魔法变量。但在 npm 上的 React 代码里却用的是 process.env.NODE_ENV
。这是怎么做到的?
历史上,我们在源码中用 __DEV__
是为了和 Facebook 的源码保持一致。很长一段时间里,React 是直接拷贝进 Facebook 代码库的,所以必须遵循相同规则。对于 npm,我们有一个构建步骤,会在发布前把 __DEV__
检查直接替换为 process.env.NODE_ENV !== 'production'
。
这有时会带来问题。有些依赖 Node.js 约定的代码模式在 npm 上没问题,但在 Facebook 内部却坏掉了,反之亦然。
自 React 16 起,我们改变了做法。现在我们为每个环境编译一个 bundle(包括 <script>
标签、npm 和 Facebook 内部代码库)。所以即使是 npm 的 CommonJS 代码,也会提前编译出开发和生产两个 bundle。
这意味着,虽然 React 源码里写的是 if (__DEV__)
,但我们实际上会为每个包生成两个 bundle。一个已经预编译为 __DEV__ = true
,另一个为 __DEV__ = false
。npm 上每个包的入口会“决定”导出哪一个。
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}
这也是你打包工具唯一会把 'development'
或 'production'
作为字符串插入的地方,也是压缩工具会移除仅开发用 require
的地方。
react.production.min.js
和 react.development.js
都不再包含任何 process.env.NODE_ENV
检查。这很好,因为在真正运行于 Node.js 时,访问 process.env
其实有点慢。提前为两种模式编译 bundle 还能让我们无论你用什么打包或压缩工具,都能更一致地优化文件体积。
这就是它真正的工作原理!
我希望有一种更加一等的方式来实现,而不是依赖这些约定,但现实就是如此。如果所有 JavaScript 环境都能把模式作为一等概念,并且浏览器能有办法提示某些代码在不该开发模式下运行,那就太好了。
另一方面,一个项目中的约定能传播到整个生态,这也很有意思。EXPRESS_ENV
在 2010 年变成了 NODE_ENV
,2013 年又传到了前端。也许这个方案并不完美,但对每个项目来说,采用它的成本都比说服所有人用别的方案低。这也给我们关于自上而下和自下而上采纳的宝贵启示。理解这种动态,是区分标准化成功与失败的关键。
区分开发和生产模式是非常有用的技术。我建议你在库和应用代码中都用它来做那些在生产环境下太昂贵、但在开发环境下很有价值(甚至至关重要!)的检查。
和所有强大的特性一样,也有一些误用的方式。下篇文章我会专门讨论这个话题!
Pay what you like