了解JS构建工具(第1部分:模块和软件包)
#javascript #网络开发人员 #初学者 #tooling

简介ð

首次学习前端开发时,您将学习一个简单的事实:在浏览器中运行JavaScript,您可以在HTML文件中添加<script>标签。您可以将src属性设置为链接到JS文件,或者只需在标签中写入JS,如果您不使用太多JavaScript,则可能不需要多才能运行它。

但是,如今,Web应用程序和站点倾向于使用JavaScript的 lot 。 JS在浏览器和服务器上也越来越多地责任。因此,我们还拥有一批技术和工具,可以帮助我们构建JS项目。这可能意味着要使用您不完全理解的构建工具进行大量搏斗,即使您是经验丰富的开发人员。

当然,复杂性或配置的每一层旨在改善开发人员或最终用户的体验。但是随着时间的流逝,它总结了很多技术知识。尽管我们有可以为您抽象这些细节的工具和框架,但它总是有必要知道真正发生的事情,因此您可以更好地控制您的项目。

在这本两部分的文章中,我会帮助您做到这一点。我们将揭开通常用于设置现代JS项目的关键方法和工具,研究它们的工作,为什么存在以及他们的利弊。最后,您应该能够就构建工具做出明智的决定,并有信心解决其配置。

在第1部分中,我们对JavaScript模块和包装有着深入的了解。

先决条件

  • 对HTML,JavaScript和JSON的基本理解
  • 对git的基本理解

模块ð

模块是现代前端发展的核心。这是因为我们项目的JavaScript可能会分布在不同的文件中。即使我们自己写所有JS,一个大文件也不是很好。如果我们使用其他人编写的代码,我们肯定需要其他文件。

作为起点,我们可以将JS文件分为单独的脚本。例如,网站或应用程序的每个页面也可能之一。但是,当您在HTML中添加多个<script>标签时,默认情况下,JavaScript运行,就好像它仍然在一个文件中一样。如果我声明函数或变量全球(不在函数内),则可以在以后使用它,即使在其他文件中也可以使用。

所以如果我有两个这样的脚本:

    <script src="./script1.js"></script>
    <script src="./script2.js"></script>

script1中具有这样的功能:

function sayHi() {
  alert("hi");
}

我可以在script2:
中使用该函数

sayHi();
// the "hi" alert shows in the browser

当JS是一种新颖性时,这种行为似乎还不错,可以在大多数静态网站上增加互动性。但是对于当今的复杂站点和应用而言,它远非理想。

首先,我们必须管理依赖顺序。因为script2取决于script1的函数,所以script2的<script>标签必须在html中 script1之后。这些脚本是按顺序运行的,并且在定义函数或变量之前无法使用脚本。这似乎没什么大不了的,但是问题随您拥有的脚本数量而越来越大。跟踪脚本需要去的是我们不想做的手动工作。

其次,我们代码部分之间的关​​系是隐式的,可以以意外的方式破坏我们的代码。如果Script1之后的某个地方有另一个sayHi函数,而我稍后将其称为脚本1版本,则实际上它将被另一个实现所覆盖。对于用var定义的函数声明和变量而发生的情况发生。对于使用constlet定义的任何变量(包括函数表达式)的任何变量,情况甚至更糟 - 如果另一个已经以相同名称存在的另一个变量,则会导致错误。对于使用许多库和框架的代码,这是不可行的。

最后,全局变量和函数创建了安全问题。运行代码的任何人都可以运行脚本以访问您的代码内部工作,甚至更改其所做的工作。

那么我们该如何处理呢?答案是模块。但是,在JavaScript中使用了适当的模块系统之前,有一种方法可以稍微降低全球范围的问题,有时也称为模块模式。

“模块模式”

在模块之前,图书馆作者将其代码包装在一个功能中,因此内部工作不在全局范围内,也不会与任何东西发生冲突。在该函数内部,他们会将所有库的公共API添加到一个对象(例如,在著名的jQuery示例中的$对象),并将其添加到窗口对象中,以使其在全球范围内访问。最后,他们将包装功能调用以使其正常工作。

这里的收获是,包装功能的名称仍将最终出现在全局范围上。这个问题用晦涩的技术避开了。如果JavaScript是 expression 的一部分,我们可以在JavaScript中无需定义函数(解决值的代码行)。通常,表达式被分配给一个变量,但它们不必是 - 仅编写诸如3之类的值是JavaScript的一个非常有效的行。

但是,未分配给变量的函数被JavaScript编译器视为函数声明,因此它将期望函数名称。将表达式放入括号内的表达式确信编译器这不是函数声明。然后,我们需要调用该函数,并且由于以后不能这样做(没有名称可以参考),因此我们必须立即执行此操作。因此,我们得到了这个奇怪的构造,称为立即调用函数表达式(iife):

(function() {
    const library = {}
    // my library code...
    window.library = library;
})();

这种模式在JavaScript代码中非常广泛使用,但绝对不是理想的。每个模块仍然将公共对象暴露于 其他脚本,我们仍然必须管理脚本的依赖性顺序。

,让我们看看模块系统如何为我们解决这些问题。

eCMAScript模块(ESM)

ecmascript规范中描述了一个模块语法(定义JavaScript)。它使用关键字importexport链接依赖项。

例如,在脚本1中,我可以:

export function sayHi() {
  alert("hi");
}

然后,在script2:

import { sayHi } from "./script1.js";

sayHi();
// the "hi" alert shows in the browser

我们可以立即查看这一点 - 现在明确定义了不同文件之间的关系。并且每个模块内部的代码都范围为模块, 全球范围内。因此,只能在导入的地方使用sayHi。如果我不导入,我会发现一个错误:

sayHi();
// Uncaught ReferenceError: sayHi is not defined

如果我愿意的话,我可以对sayHi有不同的定义,并且两者在各自的文件中都可以正常工作。如果我既导入sayHi 定义具有相同名称的新版本,则在运行程序时会遇到(不同的)错误,但是至少在文件中显而易见的名称冲突,而不是隐式。

import { sayHi } from "./script1.js";

function sayHi() {}

sayHi();
// Uncaught SyntaxError: Identifier 'sayHi' has already been declared

避免命名冲突的一种方法是使用模块对象语法,该语法将导入文件的任何导出添加到带有任何名称的对象中。

import * as Script1 from "./script1.js";

Script1.sayHi()
// the "hi" alert shows in the browser

在浏览器中使用模块

现在是复杂的位。 ESM已成为现代Web应用程序的标准,但是有一些历史。

首先,该系统仅在通常称为ES6(第六版)的2015年版本中添加到Ecmascript规范中。在此之前,浏览器根本不了解ESM语法或具有将脚本包装到孤立模块中的功能。

实际上是为运行JavaScript 外部创建的第一个JS模块系统,由Commonjs Project在2009年浏览器。然后在流行的JS运行时环境Node.js中实现。

commonjs模块使用与ESM不同的语法。例如,这是脚本1中的命名导出:

module.exports.sayHi = function sayHi() {
  console.log("hi");
}

这是脚本中的导入:

const { sayHi } = require('./script1')

sayHi()
// "hi" is logged to the console

该系统使服务器端和命令行JS项目的作者易于拆分其代码并使用其他人的模块。但是在浏览器中实现它是一个障碍。

在node.js中,可以将JS文件位于机器上并立即加载,因此代码可以同步运行逐线。浏览器环境不同。 HTML并未考虑模块的设计。您可以将浏览器交给一堆模块,然后以正确的顺序运行它们。您必须指定单个脚本标签,每个标签都是网络请求,需要时间才能完成。因此,除非我们要在等待每个脚本时阻止页面的渲染,否则这使得过程 asynchronous。

因此,开始使用工具将JavaScript模块转换为浏览器可以正确运行的代码的传统。早期的方法在执行时改变了代码。有一个流行的规范,称为异步模块定义(AMD),,您的脚本定义了一个函数,该函数包裹要执行的代码并列出了所需的依赖项,但没有调用该函数。在运行时,一个称为A script Loader的第三方库从DOM检索脚本元素,整理了所需的顺序并将模块代码添加到DOM中。

其他库可作为命令行构建工具提供。这些在之前汇总了commonjs code ,它已交付给浏览器。创建了一种称为Browserify的流行工具,以处理模块之间的依赖关系,并且 bundle 在单个脚本标签中的所有代码。后来,它被Webpack和其他可以处理ESM语法的捆绑包取代,并提供了我们在下一篇文章中讨论的其他功能。

不幸的是,在ESM上没有关于最佳模块方法的共识,因此库作者需要其代码才能使用 commonjs或amd的用户工作。这导致了通用模块定义(UMD),基本上使作者回到了使用IIFE,然后添加代码以在运行时使用的任何方式导出模块。

您需要在项目中使用这些方法,但是您会听到Bundler文档中提到的这些方法。 Bundler需要处理不同类型的进出口进口,因为许多流行的库仍未使用ESM导出(例如,React仍然导出CommonJS模块)。当我们捆绑浏览器时,我们通常会在IIFE中输出代码,以便它可以在较旧的浏览器中运行,同时仍与可能在页面上运行的任何其他JS隔离。

默认导出

先前模块系统的另一个遗产是导出包含所有库功能的单个对象非常普遍。 ESM的语法允许此称为默认导出

例如,让我说我有一个称为mylibrary.js的文件,而我要从它导出的只是一个名为MyLibrary的类。如果我用export default导出它,则可以在没有卷发括号的情况下导入它:

import MyLibrary from "./MyLibrary.js"

捆绑还是不捆绑?

好吧,我们完成了历史课,我们想使用ESM语法。但是,在模块击中浏览器之前,我们仍然有一个枪战,并且仅使用浏览器实现。如果要使用import语句而不捆绑语句,则每个模块的<script>标签必须将其type属性设置为“模块”。使用我们的示例脚本,看起来像这样:

<script type="module" src="./script1.js"></script>
<script type="module" src="./script2.js"></script>

当浏览器读取这些文件时,它会在执行任何代码之前弄清楚依赖项,因此我们不必担心该顺序。如果Script1从script2导入某些内容,则告诉浏览器首先执行script2,即使它在html稍后出现。

请注意,这使得导入 static- 他们在执行过程中不允许更改。因此,您不能在功能或条件中使用importexport语句 - 它必须位于“顶级”。 (但是,有一个称为动态导入的替代方案,我们将在第2部分中覆盖)。

所以现在存在于浏览器中,为什么要用构建工具来打扰?好吧,捆绑JavaScript变得如此受欢迎的原因有很多。一个是它削减了网络请求 - 当我们只能发送一个时,为什么要使浏览器请求多个文件?另一个是构建工具可以在输出代码之前处理其他有用的操作,我们将在第2部分中涵盖。

,但也许最大的原因是我们如何使用其他人的代码。

共享代码ð

我们经常想在我们的JS中使用其他人的库和框架,称为依赖项。最好的方法是什么?

you 可以 访问要使用并下载代码的库的网站或github存储库,然后将其添加到您的项目中。但这很慢,您还必须下载其依赖的所有库。由于如今的JS项目具有太多的依赖关系,因此这并不是真正可行的。这仅适用于图书馆的一个版本,不介意处理更新。

一种更常见的方法是将<script>标签中的链接到库代码(及其依赖项)的服务器的URL。为此,作者必须首先宣传此链接,并且服务器必须允许 cross-origin请求(来自其他URL的请求)。此外,为了使代码快速进入浏览器,它们很可能会使用内容输送网络(CDN)。

CDN链接

CDN是世界各地的服务器网络。 Cloudflare,Akamai,Amazon,Google,Microsoft等公司都提供了这些网络,代码作者为处理其资源服务,例如网站,应用程序或库。

作者通常将其代码放在自己的服务器上(称为 Origin Server ),但要么设置重定向或改变其URL以指向CDN。 CDN服务器从原点汲取该资源并缓存它,以便用户在任何地方,都应附近有一个副本,可以快速到达它们。定期地,CDN服务器请求原始服务器的资源以确保其最新,或者作者可以手动使缓存无效。

通过CDN使用库的优点是您不必自己下载代码或其依赖项,而且通常是快速且可靠的。另外,对于用户来说可能会更好,因为由于多个站点可以从同一cdn访问库,因此用户的浏览器可能已经使您链接到的资源已经缓存,在这种情况下,它不需要通过网络再次获取它。那么,为什么不将CDN链接用于所有内容呢?

首先,有一些小缺点可能是一个问题,例如无法离线工作或无法控制您的项目所需的资源。另一个简单的是,可能没有用于使用库的CDN链接。

,但也有更大的原因:

  • admin。在开发过程中,您需要在正确的位置添加所有<script>标签。然后,随着时间的流逝,您需要手动管理第三方软件包更新时需要的版本。
  • 性能。这是我们前面提到的同一问题 - 浏览器必须对每个<script>标签做出单独的请求(除非资源在浏览器缓存中),这将减慢生产中的负载时间。

对于一个很小的项目,这些弊端可能没有问题。但是对于其他一切,这就是为什么软件包经理如此受欢迎的原因。软件包经理提供了一个用于管理依赖关系的系统。所有代码都存储在开发人员的计算机上,因此可以在必要时离线开发应用程序。您可以使用捆绑器仅输出一个<script>标签(或者您喜欢的许多标签)。

包裹的缺点?它们来自节点。您必须在计算机上运行node.js,然后一个捆绑器将所有模块连接在一起。对于初级开发人员来说,这可能是一个障碍,并为开始JS项目的入门增加了一些复杂性。但是对于大多数JS开发人员而言,这并不重要 - 我们接受了设置节点的不便,并偶尔接受了Bundler配置,以换取其提供的好处。

然而,这确实意味着浏览器中的本地ESM在2015年到来时有些死亡。那。

所以让我们深入研究包装的工作方式。

软件包

一个软件包是代码库以及有关代码的信息,例如版本号。软件包管理器是可让您下载,管理并在必要时创建这些软件包的工具。它具有与注册表通信的命令行接口,该界面是用户发布的所有软件包的数据库。这些不是全部公开(例如,可以访问特定组织),但是许多是。

我们将寻找JS, npm 的最受欢迎的软件包经理。在NPM的早期,有些问题导致Facebook和其他地方的一些开发人员创建了类似的选择。但是,通常认为NPM陷入了主要的纱线改进之中。还有 pnpm ,可提供一些速度和磁盘空间的改进,但使用量较少。作为初学者,您应该适应可预见的NPM,因为其他人无论如何都相似。

要使用npm,您只需要从node.js网站下载node.js -npm即将包含在其中,因为如今它对JS开发非常重要。您可以在npm website上找到NPM软件包的列表。要安装软件包,请导航到您的项目目录并运行npm install,然后是软件包名称,例如npm install react。有内置的别名(同一命令的名称),例如npm i,如果您喜欢的话,您可以使用它们。如果您需要卸载包装,则可以使用npm uninstall,该npm uninstall也具有npm removenpm r等别名。

安装命令从注册表下载一个软件包。默认情况下,安装是 local - 您只能在安装中安装的目录中使用包装。这是如今的标准方法,当您需要为不同项目使用不同版本的软件包时,很有用。但是,如果需要,您可以使用全局 flag (option)-g,例如,例如。 npm install -g react。然后,它将安装到处可访问的地方(确切的位置取决于操作系统)。

软件包将在您的项目中下载到一个名为 node_modules,的文件夹NPM,并为每个软件包提供了子文件夹。大多数软件包依次都有自己的依赖关系( sub依赖性),这些依赖性)也已安装在node_modules文件夹中的计算机上。这意味着node_modules文件夹非常快地获取 非常大!您肯定不想将此大文件夹提交为版本控件,因此请将Node_modules文件夹添加到您的.gitignore文件中。

安全性和NPM

请注意,任何人都可以将代码上传到NPM,并且依靠用户报告来删除恶意代码。特别是有一种称为 typosquatting 的练习,其中恶意代码与流行的NPM软件包的名称略有不同,因此用户可能会偶然地安装它。因此,请小心您安装的内容,并在NPM网站上检查包装信息,包括每周下载的数字,以确定包裹是否合理且可靠。

使用软件包

安装软件包后,您可以在任何JS文件中导入它。您可能希望您需要在Node_modules中指定通往文件夹的路径,但是幸运的是,捆绑器允许 Bare Specifiers,,例如:

import React from "react"

当我们执行此操作时,Bundler(或插件)知道在Node_modules中寻找依赖性并从那里导入。

package.json

安装软件包时,将添加到一个名为package.json的文件中,如果您已经创建了NPM为您创建的条目。这列出了您在dependencies属性中的依赖项,并且文件应承诺进行版本控制。这样,当其他人克隆您的项目进行工作时,他们只能运行npm install,并且将为他们安装package.json中列出的所有依赖项。

软件包作者还使用了package.json来指定有关其软件包的信息。这包括软件包名称,描述,版本号和devDependencies,这是他们用于创建软件包的依赖项,但是包装用户不需要安装。还有peerDependencies,该插件作者应指定,他们在不直接使用该软件包的情况下将功能添加到另一个软件包。

您经常会看到包装用户安装应用程序代码中使用的包装,例如测试库,带有--save-dev标志,因此在devDependencies中列出了它们。这不是严格的必要条件,因为捆绑包通常会消除您的应用程序未导入的代码(在下一篇文章中介绍)。但是,它已成为一种标准做法,如果您喜欢的话,您可以遵循。

同伴依赖性也值得理解,因为自NPM 7以来,NPM默认会自动安装同行依赖项,这意味着您可能会看到与项目中安装的软件包的相互冲突版本有关的错误。 npm install --legacy-peer-deps可以忽略此错误,尽管从长远来看解决冲突更好。

最后,您可能会在package.json中看到一些自定义属性,这些属性未由NPM记录。这些可用于指定配置以构建工具,我们将在第2部分中覆盖。

包装版本

现在,我们可以找到包装管理人员的主要优点和并发症之一 - 管理软件包版本。

发布软件包时,它们会通过版本号进行分类。软件中的传统方法是,任何发行版都有三个方面,由三个数字表示,由点隔开:主要功能添加的“主要”发行版本(1.x.x),这是次要的次要版本版本功能添加(X.1.x)和用于小错误修复的“补丁”版本版本(x.x.1)。这些数字在必要时会增加,因此软件包可以从1.1.0版本直接跳到版本1.2.0,例如,如果所做的更改是较小的功能。

建议使用

npm软件包遵循这种称为语义版本的严格系统化解释(通常缩写为'semver')。这旨在使与依赖关系的合作更加可靠。主要规则是,次要和补丁版本不能包含破坏更改,即使用软件包中断代码的更改。

安装NPM软件包时,您可以使用@指定版本,例如可用,例如。 npm install react@16.14.0。但是,如果未指定版本,并且我们已经安装了软件包,则NPM将始终安装最新版本。在npm install react的示例中,在package.jsondependencies属性中可能看起来像这样:

"react": "^18.2.0"

好,因此安装了18.2.0版。但是,在这个数字之前那是什么有趣的事情?好吧,这就是一个人的看法,这使事情变得有些复杂。

从理论上讲,每个软件包用户和作者都可以始终指定包装的精确版本。可以通过运行npm config set save-exact=true或使用npm install --save-exact的特定软件包为所有安装配置这。然后,当您确定这样做是安全的时,您需要单独更新软件包(Minor和Patch Release art arth a t 假定可以破坏东西 - 这并不意味着他们不能!)。使用这种方法,包装版本是在没有商品的package.json中指定的。

有几个缺点:首先,您必须自己跟踪和管理依赖关系的所有版本。其次,请考虑您在项目中安装软件包A和软件包B,但是这些软件包取决于软件包C,并指定了略有不同的版本。 NPM将必须在您的Node_modules文件夹中安装两个版本的包C,这可能是浪费空间 - 包裹可能可以使用相同版本的软件包c。

可以正常工作。

因此,NPM允许一些灵活性 - 我们可以指定包装版本的可接受范围。默认值是可以容忍包装的新补丁或次要版本(因为它们不应破坏现有代码)。这就是package.json中的Caret所示。因此,我可以使用特定的软件包名称运行npm update(或npm install),并且该软件包将更新为最新的非Major版本。或者我可以在没有争论的情况下运行npm update即可一次更新我的所有软件包。

您也可能会在包装版本之前查看Tilde,例如"react": "~17.0.0"。这意味着仅允许更新的补丁版本,因此可以安装React 16.3.1,但不能安装React 16.4.0。可以使用npm config set save-prefix='~'配置。

锁定包版本

不幸的是,允许版本范围有副作用。说别人克隆我的存储库并运行npm install,在我自己安装包装以来的那段时间里,已经有一些次要或补丁发行。 package.json文件保持完全相同,但是其他用户的包装版本略有不同,这可能会导致不同的行为。

为了解决这个问题,NPM引入了另一个名为package-lock.json的文件,该文件也是您安装软件包时也会创建的。该文件有点像历史记录:它显示了使用npm install安装的所有依赖项和亚依赖性的确切版本,并且它应与package.json一起承诺

当另一个用户在您的项目中运行npm install时,NPM通常会安装package.lock-json中指定的确切版本,而不是根据package.json安装 的最新次要版本或补丁版本。这确保了包装安装的一致性,同时仍保持指定依赖关系范围的好处。

例如,您的package.json可能列出"react": "^18.1.0"没有 a package-lock.json,另一个用户可以安装React 18.2.0。但是,如果package-lock.json文件显示18.1.0,那就是其他用户会得到的。

有一个例外,在package.json的情况下,已经手动编辑了以指定其他版本。例如,如果被编辑为18.2.0,那么在运行npm install时,其他用户也会得到它,即使package-lock.json表示18.1.0。该安装使package-lock.json被更新到列表18.2.0。

注意:package-lock.json在这种情况下曾经总是赢得胜利,但是由于用户倾向于将package.json视为其包装版本的“真相来源”,因此NPM在2017年改变了行为。

初始评估

因此,现在您了解了软件包和模块捆绑的目的,但是您仍然不确定如何开始构建JS应用程序。一种方法是使用一种称为 initialiser的NPM软件包。

初始评估器旨在配置您的本地环境,以构建某种类型的应用程序,包括设置Bundler。这些软件包的名称始于创建,它们包含可以在命令行上运行的代码,以安装某些软件包并为您设置配置文件。

您可以使用NPM命令npx运行这些软件包,该软件包临时安装并运行在本地目录中指定的软件包,然后在安装完成后卸下。这使其非常适合初始评估,因为它们只需要一次项目中的一次。因此,一个例子是npx create-react-app。您也可以运行npm init react-app(无需在软件包名称中创建),这是一个较新的等效物(INIT也是混血来创建)。

这样的初始评估器使得通过项目启动并运行更快。然后,您可以参考他们的文档,以了解他们为您所做的事情以及如何继续。通常,您也可以在某种程度上自定义设置以适合您的需求。

一个缺点是,在启动之前,您需要对应用程序的要求有一个相当清晰的了解,除非您要花费一些额外的时间来解开配置。另一个是外包配置意味着您真的不需要理解它。通常,在开发中,这可以正常工作,直到您想要的东西无法正常工作为止,这会使您头痛。在本系列中,我们深入研究了某些人忽略的细节,因此您可以做出适当的知情决定,以确定如何设置项目,无论您选择使用初始评估器是否使用。

结论

我们在这里介绍了有关模块和NPM的大量信息,但是我们没有构建工具的工作方式。我会在下一篇文章中介绍这一点,因为这是一个重要的话题。首先,让我们回顾要点:

  • 模块是现代JS的关键,但是我们今天使用的系统受到HTML和“历史发展”的原始设计的影响很大。这就是为什么我们最终使用ESM,但不一定是浏览器实现的原因。
  • 通过node.js使用软件包之所以受欢迎,部分原因是它允许我们将代码捆绑在单个网络请求中获取,并更容易地管理依赖项的不同版本。但是,它并非没有自己的并发症和缺点。
  • 即使某些方法在JavaScript社区中变得非常流行,您也应该始终使用正确的工具来工作。这需要对工具有牢固的了解,这就是本系列的全部内容。

脚注:CDN和NPM

您可能听说过一个名为unpkg.com的开源项目,该项目使用CloudFlare为NPM上的每个包装提供了CDN链接。这是一项依靠赞助商的免费服务,并且不提供任何正常运行时间或支持保证。因此,项目作者不建议将其依靠对企业至关重要的任何事情。但是,它对于演示,个人项目和未设置本地环境的包裹可能非常有用。

来源

我尽可能地交流了我的来源。如果您认为本文中的某些信息不正确,请留下礼貌的评论,或给我发消息。

* =特别建议进一步研究