阅读 236

Development模式是如何运作的?

原文:overreacted.io/how-does-th…
译者:前端技术小哥

如果您的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年扩展到前端。也许这个解决方案并不完美,但对于每个项目,采用它的成本要低于说服其他人做不同的事情的成本。这教授了关于采用自上而下和自下而上的宝贵经验。理解这种动态的运行方式可以区分成功的标准化尝试和失败。

分离开发模式和生产模式是一种非常有用的技巧。我建议在您的库和应用程序代码中使用它,用于那些在生产环境中执行开销太大,但在开发中执行却很有价值(而且常常很关键!)的检查。对于任何强大的特性,都有一些方法会误用它。

希望本文能帮助到您! 看之后

点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
关注公众号「新前端社区」,享受文章首发体验!
每周重点攻克一个前端技术难点。