柚木

[翻译] Makefile - 失落的艺术

译者吐槽:尽管 Makefile 似乎与前端渐行渐远,不过还是有很多神奇海螺一般的魔法值得我们去入门,本文与前端的内容相结合进行了一番介绍和示例科普,适合所有对 Makefile 一脸懵逼的小伙伴。
原文:http://www.olioapps.com/blog/the-lost-art-of-the-makefile/

我做过许多 JavaScript 项目。JavaScript 目前的潮流是使用用 JavaScript 书写和配置的构建工具像 Gulp 或者 Webpack。我想要讨论的是 Make 的长处(尤其是 GNU Make)。

Make 是一种通用的构建工具,它自40年前推出以来一直在不断完善和改进。 Make 非常擅长简洁的表现构建步骤,此外它并不特定用于 JavaScript 项目。 它非常适合增量构建,在更改大型项目中的一个或两个文件后重新构建时,Make 可以节省大量时间。

Make 已经存在了足够长的时间来解决那些新出现的构建工具现在刚刚发现的问题。

尽管标题上称之为失落的艺术,但实际上 Make 仍然被广泛的使用着。不过我认为它在 JavaScript 中代表性仍显不足。你会更常见于 C/C++ 之类的的项目。

我的猜测是,JavaScript 社区中的大部分并没有一个 Unix 编程的背景,也一直没有一个很好的机会去了解 Make 有哪些功能。

在这里,我将会提供一个简单的入门。我会用我自己的 JavaScript 项目来介绍 Makefile 的内容。

你可以在这里找到完整的文件。

什么时候需要坚持使用 Webpack

Webpack 所做的工作非常的专业化。如果你正在编写一个前端应用,并且你需要打包代码,你的确需要 Webpack(或者相似的 Parcel)。

另一方面,如果你的需求很一般时,Make 是一个非常好的工具。我在编写客户端或者服务端库、Node App 时使用 Make。在那些情况下,我没有从 Webpack 的特殊功能中受益。

为什么 JavaScript 需要一个构建的步骤

让我们快速解决一下这个问题:为什么有些人希望在打包代码的过程中加入构建这个环节。

我希望能够编写同时针对浏览器和最新稳定版 Node 代码的 Stage 4 ECMAScript。我也希望我的代码中能包含 Flow 类型注释,我希望对我的代码进行处理,并且可以转换为纯 JavaScript。所以我使用 Make 来调用 Babel 转换代码。

介绍 Makefile

Make 会在当前文件夹寻找一个叫做 Makefile 的文件。一个 Makefile 是一系列像下述的列表:

target_file: prerequisite_file1 prerequisite_file2
    shell command to build target_file (必须用 tab 缩进而不是空格)
    another shell command (这些命令被称为菜单)

除非你另行制定,Make 会假定目标(在这个例子中是 target_file)和条件(prerequisite_file1prerequisite_file2)是文件或者目录。你可以要求 Make 由命令行构建一个目标:

$ make target_file

如果 target_file 不存在,或者 prerequisite_file1prerequisite_file2target_file 最近一次构建后被修改了,Make 将会执行给定的 shell 脚本。不过在此之前 Make 将会先检查在 Makefile 中是不是有针对 prerequisite_file1prerequisite_file2 的内容,并且按需构建或重新构建。

Makefile 规则的一个实际例子

一个最小的项目可能有一个叫做 src/index.js 的文件。我们需要一个规则来告诉 Make 转换该文件并将结果写入 lib/index.js。但是 Make会以相反的方式看待这件事:Make 希望被告知需要的结果,然后使用规则来指定如何产生这个结果。所以我们编写这个 Makefile 的规则的目标是 lib/index.js,条件是 src/index.js

lib/index.js: src/index.js
    mkdir -p $(dir $@)
    babel $< --out-file $@ --source-maps

这个菜单通过 babel 转换 src/index.js 生成 lib/index.js。Makefile 的菜单中的 shell 命令与你在 bash 中输入的内容几乎完全相同——但是请注意,Make 替换变量和在命令前以 $ 开头表达式将会被执行。你可以通过加倍 $ 来转义(比如说 cd $$HOME)。在上面的菜单中有两个特殊的变量 $< 是对条件列表的简称(本例中为 src/index.js),$@ 是目标的简称(本例中为 lib/index.js)。我们会在瞬间明白为什么这些变量是不可或缺的。

mkdir -p 行在 lib/ 目录不存在的情况下创建行。函数 dir 从文件路径中提取目录部分,因此 $(dir $@) 读作「包含被 $@ 引用的文件的目录路径」。

广义规则

当我们向项目中添加更多的文件时,为每个 JavaScript 文件编写一个 Makefile 目标将非常的繁琐,目标和条件可以使用通配符来创建一个模式:

lib/%: src/%
    mkdir -p $(dir $@)
    babel $< --out-file $@ --source-maps

这告诉 Make 任何的由 lib/ 开头的文件路径可以用给定的步骤来构建,并且目标依赖于 src/ 下的相对应的路径。无论什么字符串在 Make 中的目标中用 % 表示,他会替换条件中的相同位置的 % 字符串。现在我们更清楚为什么变量 $<$@ 是必须的了:在规则调用之前,我们并不知道这些变量的值。

为什么分别为每个源文件调用 Babel

通过一次调用,Babel 就可以传输目标树中的所有文件。但上面的规则会为 src 下的每个文件运行 babel,每次运行 babel 时都会有一些启动时间的开销,所以在进行一次提交中多次调用 babel 会更慢。但是,由于 Make 的增量构建能力,它将跳过 lib/ 下已经有最新结果的文件。我们运行增量构建的次数远远多于完整构建,所以我非常欣赏这种加速。

编辑:Hacker News 中的几位评论者(falcolas, Jtsummers, jlg23, nzoschke)指出 Make 可以并行的执行任务。因为 Make 规则中明确的列出了每个目标的依赖关系,所以知道哪些任务可以安全的并行运行。使用 make --jobs=4 命令可以一次执行最多 4 个 Babel 实例,这可以抵消为每个源文件运行单独的 Babel 实例的性能损失。

定位 Babel

我在 Makefile 的上述的规则中做了一点微小的改动:

babel := node_modules/.bin/babel

lib/%: src/%
    mkdir -p $(dir $@)
    $(babel) $< --out-file $@ --source-maps

babel 可执行文件由 babel-cli 这个 npm 包提供。我更喜欢将 babel-cli 安装作为项目的 dev dependency,这会导致 babel 的可执行文件安装在路径 node_modules/.bin/babel 上。这样,任何想要构建我项目的人都不必采用特殊步骤去安装一个全局的 babel-cli,但是大多数机器上,babel 并不会配置在可执行的 $PATH 中,为了避免输入可执行文件的路径,我将 babel 的位置分配给 Makefile 中的变量(babel := node_modules/.bin/babel),并且使用 Make 的变量替换将该路径作为菜单中的命令。

(专业提示:您可以将 node_modules/.bin 添加到您的 shell 的 $PATH 中,像这样:PATH="node_modules/.bin:$PATH"。这样可以很容易的运行当前目录中项目依赖项安装的可执行文件。与项目一起安装的优先级会高于全局安装的可执行文件,当你运行 npm 脚本时,npm 会自动进行这个 $PATH 优先级得陶正。不过我总是在 Makefile 中声明 babel 的 path,因为我不想假设任何人做了同样的设置,而且我也并不是总想从 npm 脚本中运行 make。)

转换整个项目

用如下的规则来转换 JavaScript 源文件:

$ make lib/index.js  # outputs lib/index.js and lib/index.js.map

Make 在 Makefile(lib/%) 中找到匹配的项目,展开通配符,通过展开 src/% 来找到匹配的源文件,并运行 babel。但是你可能不想为每个源文件手动运行 make。你想要的只是输入 make 并让它转换所有的源文件。记住 Make 需要被告知你想要的结果。为此,首先需要计算一份所有源文件的列表,并且将其分配给一个变量:

src_files := $(shell find src/ -name '*.js')

在这个任务右侧的表达式使用了 Make 内置的 shell 函数来运行外部de shell 命令。在这种情况下,我们应该使用 find 命令来递归的列出 src/ 下面所有扩展名为 .js 的文件。您可以使用另一个命令,比如 [fd][]——不过 find 更可能已经被安装在您同事的工作站和你的 CI 服务器中。

这下我们拿到了我们所有的文件列表。但是我们需要告诉 Make 我们需要的文件。对于每一个在 src 下面的文件,我们都希望在 lib 下面有一个对应的转换后的文件。我们可以通过将 Make 的 patsubst 函数应用到每个源文件中来计算该列表:

transpiled_files := $(patsubst src/%,lib/%,$(src_files))

替换表达式使用 % 作为通配符,与我们之前编写的规则相同,写起来更容易了。

现在我们可以定义一个列出我们想要的文件作为条件的目标。当我们请求该目标时,Make 会自动为每个源文件建立一个转义的结果:

all: $(transpiled_files)

目标名 all 是特殊的:当你在运行时没有指定 make 的目标时,他会执行 all 作为默认值。这是当目标不是文件或者目录时的特殊情况——all 只是一个标签。你需要像这样在你的 Makefile 中声明非文件的目标,这样 Make 就不会浪费时间,也不会为了在你的项目中找到匹配的文件而感到困惑。

.PHONY: all clean

哦,是的!或许你想要一种方法来删除已经构建的内容,以便你可以开始干净的构建,有了这个目标你可以运行 make clean 来达到这个效果:

clean:
    rm -rf lib

在 package.json 改变时自动安装 node modules

Make 非常强大,足以完成任何你可以想象的任务。你是否曾经提交更新给一个项目,并且在进行一些 debug 后发现你是忘了运行 yarn install 来更新你的依赖关系?你可以通过 Make 来做到!当你运行 yarn install 时,node_modules 目录将会创建或者更新。你可以在 Make 中添加一条规则将 node_modules 作为目标来更新 node_modules。

node_modules 的状态取决于 package.json 和 yarn.lock 的内容,所以这些文件应该作为条件被列出:

node_modules: package.json yarn.lock
    yarn install  # could be replaced with `npm install` if you prefer

这个针对 all 目标的变更添加了 node_modules 作为条件。

现在,当且仅当自上一次构建后 package.json 或者 yarn.lock 发生了变化时,Make 才会运行 yarn install。我在 $(transpiled_files) 之前放上了 node_modules,以防万一新的依赖可能会包含 babel 模块的更新,这样会影响到项目文件的构建。

监听文件并且重构更新

每个构建工具都应该有适用于快速开发的监听文件变更的选项。你可以通过 Make 与通用文件监听工具的组合来达到这个效果:

$ yarn global add watch
$ watch make src/

记得注意你没有监听 lib/ 目录,否则的话你会陷入一个无尽的构建循环中。

使用 Make 来分配 Flow 类型定义

我在上文中提到我也经常使用 flow 来检查我的项目,我想使用纯 JavaScript,但我也希望任何使用我的库的人使用 Flow 来从我的类型注释中获益。Flow 支持查找一个使用了 .js.flow 的文件扩展名。比如,当你导入一个名为 User 的模块时,JavaScript 运行库将查找名为 User.js 的文件,而 Flow 将在同一目录中另外查找名为 User.js.flow 的文件。该文件应该是具有类型注释的原始源文件。我的 Makefile 将 src/ 下的每个文件复制到 lib/ 的响应路径,并根据此规则添加 .flow 扩展名。

lib/%.js.flow: src/%.js
    mkdir -p $(dir $@)
    cp $< $@

为了确保 Flow 中所有的源文件按照此步骤运行,我计算了 .flow 的文件列表,我期望得到与我们预期相一致的结果:

flow_files := $(patsubst %.js,%.js.flow,$(transpiled_files))

另外,我在所有的任务的条件中包含了 flow_files

all: node_modules $(flow_files) $(transpiled_files)

进阶

Make 还有很多我在这里没有提及的功能。例如,Make 支持可以为特别复杂的用例计算规则的宏,用 Makefile 可以委托目标给其他 Makefile。这在分发 Make 库中非常有用。也可以用于构建过程中涉及到构建多个子项目组合在一起的多层项目,GNU Make Manual 可以找到更多有用的信息。