原文:https://overreacted.io/how-do…
译者:前端技术小哥
如果您的 JavaScript 代码库非常复杂,那么您可能会想办法在开发模式和生产模式中捆绑和运行不同代码。
在开发模式和生产模式中捆绑并运行不同的代码是非常强大的。在开发模式中,React 里有许多预警,可以帮助我们在导致 bug 之前找到问题。然而,检测此类错误所需的代码通常会增加 bundle 文件的大小,并使应用程序运行得更慢。
在开发模式中程序运行缓慢是可以接受的。事实上,在开发过程中减慢代码的运行速度甚至可能是有益的,因为它在一定程度上弥补了快速开发人员计算机和普通消费设备之间的差异。
在生产中,我们不想付出任何成本。因此,我们在生产中省略了这些检查。这是怎么回事? 让我们来看看。
在开发中运行不同代码的确切方法取决于 JavaScript 构建管道(以及是否有)。Facebook 是这样的:
if (__DEV__) {doSomethingDev();
} else {doSomethingProd();
}
这里,__DEV__不是一个真正的变量。它是一个常量,当浏览器的模块被拼接在一起时这个常量就被替换掉了。结果是这样的:
// In development:
if (true) {doSomethingDev(); // ????
} else {doSomethingProd();
}
// In production:
if (false) {doSomethingDev();
} else {doSomethingProd(); // ????
}
在生产中,我们还需要在代码上运行一个压缩器(例如,terser)。大多数 JavaScript 微引擎都会进行部分的死代码消除,例如删除 if(false)分支。所以在生产中你只会看到:
// In production (after minification):
doSomethingProd();
(请注意,主流 JavaScript 工具如何有效地消除死码是有诸多限制的,但又这是一个单独的问题了。)
虽然您可能没有使用一个神奇的__DEV__常量,但是如果您使用一个流行的 JavaScript 捆绑器(如 webpack),那么可能还有其他一些惯例可以遵循。例如,通常这样表示相同的模式:
if (process.env.NODE_ENV !== 'production') {doSomethingDev();
} else {doSomethingProd();
}
这正是使用捆绑器从 NPM 导入时 React 和 Vue 等库使用的模式。(单个文件(script)标签构建中将开发和生产版本作为单独的.js 和.min.js 文件提供。)
这一惯例最初来自 Node.js。在 Node.js 中,有一个全局 process 变量将系统的环境变量作为 process.env 对象的属性公开。然而,当您在前端代码库中看到这个模式时,通常不涉及任何实际的 process 变量。
相反,整个 process.env.NODE_ENV 表达式在构建时被字符串文字替换,就像我们的神奇的__DEV__变量:
// In development:
if ('development' !== 'production') { // true
doSomethingDev(); // ????} else {doSomethingProd();
}
// In production:
if ('production' !== 'production') { // false
doSomethingDev();} else {doSomethingProd(); // ????
}
因为整个表达式是常量(‘production’ !== ‘production’ 确保为 false),所以压缩器也可以删除其他分支。
// In production (after minification):
doSomethingProd();
这个麻烦就解决啦
注意,这对更复杂的表达式没用:
let mode = 'production';
if (mode !== 'production') {// ???? not guaranteed to be eliminated}
由于 JavaScript 语言的动态特性,JavaScript 静态分析工具不是很智能。当他们看到像 mode 这样的变量而不是像 false 或 ’production’ !== ‘production’ 这样的静态表达式时,他们通常会放弃。
同样,JavaScript 在我们使用顶级 import 语句时,死代码消除经常不能正常地跨模块边界运作:
// ???? not guaranteed to be eliminated
import {someFunc} from 'some-module';
if (false) {someFunc();
}
因此,我们需要以一种非常机械的方式编写代码,使条件绝对静态,并确保要消除的所有代码都在其中。
要使所有这些正常运作,我们的 bundler 需要执行 process.env.NODE_ENV 替换,并需要知道希望在哪种模式中构建项目。
几年前,我们常常会忘记配置环境。所以我们经常会看到一个处于开发模式的项目部署到生产模式中。这很糟糕,因为这会使网站加载和运行速度变慢。
在过去两年中,情况有了显著的改善。例如,webpack 添加了一个简单的 mode 选项,而不是手动配置 process.env.NODE_ENV 替换。React DevTools 现在还会在带有开发模式的站点上显示一个红色图标,这使得用户更容易发现以及报告。(此处需翻译图片中的文字)(此页面正在使用 React 开发构建模式。打开开发工具,React 键将会出现在右侧。注意:发构建模式并不适用于生产模式。确保在部署前使用生产构建模式)
像 Create React App、Next/Nuxt、Vue CLI、Gatsby 和其他一些固定设置,将开发构建和生产构建分离成两个单独的指令,这样就更不容易产生问题。(例如,npm start 和 npm run build)一般情况下我们只能部署生产构建,因此开发人员不会再犯这个错误。
总是有这样一种说法,即生产模式才应该被设置为默认的,而开发模式需要是手动切入。就我个人而言,我不认为这个论点有说服力。从开发模式的预警中获益最多的人通常是库的初学者。他们一般都不知道如何打开开发模式,并且会错过开发模式早就能给出的 bug 的高能预警。
是的,性能问题很糟糕。但向终端用户提供漏洞百出的体验也是如此。例如,React key 预警有助于防止犯错,比如向错误的人发送消息或购买错误的产品。在禁用预警时进行开发对您和您的用户都会带来重大风险。如果默认情况下它是关闭的,那么当我们找到切换键并打开它时,我们将会面对过量的预警并需要清除。所以大多数人会把它切换回去。这就是为什么需要从一开始就打开它,而不是稍后才启用它。
最后,即使选择切入开发预警,并且开发人员知道早早的时候就要打开它们,我们又回到最初的问题。有人可能会在部署到生产环境中时忘记关闭它们!我们又回到了出发点。
就我个人而言,我相信能够显示和使用正确模式的工具取决于我们是在调试还是部署。几十年来,除了 Web 浏览器之外,几乎所有其他环境(无论是移动、桌面还是服务器)都有能够加载和区分开发和生产构建。也许是时候让 JavaScript 环境将这种区别视为头等需求了,而不是由库提出并依赖于临时约定。
说了这么多的理论知识!让我们来看看实际操作:
if (process.env.NODE_ENV !== 'production') {doSomethingDev();
} else {doSomethingProd();
}
大家可能会好奇:如果前端代码中没有真正的 process 对象,为什么像 React 和 Vue 这样的库在 npm 构建中依赖它呢?(再次澄清一下:您可以在浏览器中加载的(script)标签,由 React 和 Vue 提供,不依赖于此。相反,我们必须自己在开发.js 和生产.min.js 之间作选择。下面部分提到的只是关于通过从 npm 导入它们将 React 或 Vue 与捆绑器一起使用。)
像编程中的许多东西一样,这种特定的惯例主要是历史原因。我们现在仍在使用它,因为现在它被不同的工具广泛采用。换成用其他东西是需要代价的,而且并没有太多意义。那背后的历史原因是什么呢?
在 import 和 export 语法标准化之前的很多年,存在着不止一种方式在竞争着来表达模块之间的关系。Node.js 推广了 require()和 module.exports,称为 CommonJS。早期在 npm 注册表上发布的代码是为 Node.js 编写的。Express 是(并且可能现在仍然是?)Node.js 最受欢迎的服务器端框架,它使用 NODE_ENV 环境变量来启用生产模式。其他一些 npm 包采用了相同的惯例。
像 browserify 这样的早期 JavaScript 捆绑包希望能够在前端项目中使用来自 npm 的代码。(是的,当时几乎没有人使用 npm 作为前端!大家能想象吗?)因此他们将 Node.js 生态系统中已经存在的相同惯例扩展到前端代码。
最初的“envify”转换是在 2013 年发布的。React 是在那个时候开源的,而且在那个时代使用 browserify 的 npm 似乎是捆绑前端 CommonJS 代码的最佳解决方案。从一开始 React 就开始提供 npm 构建(包括(script)标记构建)。随着 React 的流行,使用 CommonJS 模块编写模块化 JavaScript 并通过 npm 发送前端代码的做法也开始流行。
React 需要在生产模式中删除仅用于开发的代码。Browserify 已经为这个问题提供了解决方案,因此 React 也采用了将 process.env.NODE_ENV 用于其 npm 构建的惯例。随着时间的推移,许多其他工具和库,包括 webpack 和 Vue,都做了同样的事情。
到 2019 年,browserify 已经失去了相当多的市场占有率。但是,在构建步骤中用 ’development’ 或 ’production’ 替换 process.env.NODE_ENV 仍是一种流行的惯例。(看看如何采用 ES 模块作为分发格式,而不仅仅是创作格式,会改变方程式,这很有意思。)
还有一件事情可能仍然让大家感到困惑,在 GitHub 上的 React 源代码中,我们看到__DEV__被用作魔术变量。但是在 npm 的 React 代码中,它使用 process.env.NODE_ENV。这是怎么回事?
过去,我们在源代码中使用__DEV__来匹配 Facebook 源代码。很长一段时间,React 被直接复制到 Facebook 代码库中,所以它需要遵循相同的规则。对于 npm,我们有一个构建步骤,在发布之前用 process.env.NODE_ENV !== ‘production’ 直接替换__DEV__查验。
这有时会带来问题。有时,依赖于某些 Node.js 惯例的代码模式在 npm 上运行良好,但是在 Facebook 崩溃了,反之亦然。
自 React 16 以来,我们改变了我们的方法。相反,我们现在为每个环境编译一个捆绑包(包括(script)标签,npm 和 Facebook 内部代码库)。因此,即使是针对 npm 的 CommonJS 代码也会提前被编译为分开开发和生产捆绑包。
这意味着当 React 源代码说 if (__DEV__)时,我们实际上为每个包生成了两个捆绑包。一个已经预编译了__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 会有点慢。提前在两种模式下编译捆绑包还可以让我们更加一致地优化文件大小,无论您使用哪种捆绑器或压缩器。这就是它的工作原理!
我希望有一种更优秀的方式来做到这一点而不依赖于惯例,但我们现在就这样了。如果模式在所有 JavaScript 环境中都是一等的概念,并且如果浏览器有某种方式表明某些代码在开发模式下运行当它们其实不应该被运行的时候,那么它将是极好的。
另一方面,一个项目中的惯例如何能够在整个生态系统中传播是很有趣的。在 2010 年 EXPRESS_ENV 成为 NODE_ENV 并在 2013 年扩展到前端。也许这个解决方案并不完美,但对于每个项目,采用它的成本要低于说服其他人做不同的事情的成本。这教授了关于采用自上而下和自下而上的宝贵经验。理解这种动态的运行方式可以区分成功的标准化尝试和失败。
分离开发模式和生产模式是一种非常有用的技巧。我建议在您的库和应用程序代码中使用它,用于那些在生产环境中执行开销太大,但在开发中执行却很有价值 (而且常常很关键!) 的检查。对于任何强大的特性,都有一些方法会误用它。
希望本文能帮助到您!
看之后
点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
关注公众号「新前端社区」,享受文章首发体验!
每周重点攻克一个前端技术难点。