经过一段时间的使用,我决定将 Makefile 加入到我的必备技术列表。这篇文章将向你介绍 Makefile 的基本编写方法,以及一个在现代前端项目中的使用样例。相信你在看完之后,就会像我一样爱上 Makefile,虽然某些时候它不是最好的方案,但在你没有更好的方案时,它一定不会让你失望。
make 有很多种实现,在撰写本文时,参考的是 GNU make 的相关文档。 |
配方
Makefile 中最基本的构成元素就是配方(Recipe),配方的作用就是用来说明如何构建软件某个产物(Target),每个配方都由三个元素组成:target、prerequisites、commands,一个简单的配方如下:
dist: node_modules # target: prerequisites
npm run build # commands
npm run tsc
这个配方描述了构建 dist
目录(target)的方式:在构建之前,make 需要确保它的依赖 node_modules
(prerequisites)已经被构建且更新了,然后再执行两个 npm 命令(commands)来构建产物。
配方的 target 也可以不是事实存在于硬盘上的文件,例如:
test: node_modules
npm run test
这个配方描述了执行测试的方式:先更新 node_moidules
,然后执行测试脚本,整过程不会产生并不会产生名为 test
的文件。
配方的 prerequisites 也跟 target 类似,除了可以是实际存在的文件之外,还可以是其他的 target,例如:
dist: node_modules test
npm run test
新的用来构建 dist
的配方将会同时依赖 node_modules
跟 test
,这样在我们执行 make dist
之前,测试脚本也会被执行了。
配方之间的依赖关系
有了 target 跟 prerequisites,make 就可以分析各个配方之间的依赖关系,进而可以确定不同配方的执行先后顺序,更进一步,如果你的 target、prerequisites 是文件的话,make 可以通过文件的最后修改时间来找出已经过时的产物,从而实现比较精准的增量构建。
通常,我会这么来编写一个现代的前端项目 Makefile:
node_modules: package.json yarn.lock
yarn install --fronzen-lockfile
test: node_modules tests
yarn test
types: src node_modules
yarn tsc
dist: src node_modules test types
yarn build
同样是执行 make dist
,对于初次上手的开发者来说,make 会为他还原好 node_modules,并且自动执行测试,测试通过后再构建 dist
目录。而对于经常在这个项目中摸爬滚打的熟手,只要没有更新项目的依赖,make 就会为他自动的跳过还原 node_modules
的步骤,而要在 npm scripts 中实现类似的效果是非常困难的。
变量
Makefile 中可以声明变量,变量可以使用在配方中的任何地方:
foo := 123 (1)
$(foo): (2)
echo $(foo) (3)
1 | 声明变量 foo |
2 | 使用变量 foo 的值作为 target |
3 | 在命令中使用 foo 变量的值 |
make 在解析配方之前,会将 Makefile 中形如 $(var)
的文本进行变量展开,所以与上面命令等价的是 shell 脚本是 echo 123
而不是 echo $foo
。因为 make 的这个特点,当我们想要在配方中使用 $
时,需要用 $$
来替换。
环境变量
make 默认会继承当前系统的全部环境变量,并将这些变量载入到 Makefile 中,例如:
build:
echo $(PATH) (1)
1 | $(PATH) 将会被替换为当前的系统环境变量 PATH 的值 |
$ make
echo /usr/local/sbin:/usr/local/bin:/usr/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl
/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl
而在 Makefile 中声明的自定义变量则不会在命令中以环境变量的形式存在:
foo := hello
build:
echo $$foo
$ make
echo $foo
如果想要在命令中通过环境变量的方式来访问自定义变量,可以在变量声明前加上 `export `。
export foo := hello
build:
echo $$foo
$ make
echo $foo
hello
变量覆盖
在执行 make 命令时,我们可以使用下面的语法通过命令行来覆盖自定义变量的值,一个常见的使用场景是这样的:
ENV := prod build: yarn build --$(ENV)
$ make
yarn build --prod
$make ENV=dev (1)
yarn build --dev
1 | 通过命令行参数来覆盖 Makefile 中定义的 ENV 变量。 |
通过命令行设置的变量同样也可以作为环境变量在配方的命令中使用。 |
书写命令
我们已经大致了解了配方的编写方法,现在让我们更进一步地来看看 make 执行命令的方式,make 会根据以下的变量来解析执行配方中的命令:
变量名 | 说明 | 默认值 |
---|---|---|
.RECIPEPREFIX |
单个字符,使用这个字符开头的行将被识别为配方的命令 |
Tab |
SHELL |
命令解释器的路径,用来执行配方中的命令 |
/bin/sh |
.SHELLFLAGS |
命令解释器的参数 |
|
make 将所有以 .RECIPEPREFIX
开头的行识别为命令,接着逐行调用 SHELL
来执行,当执行出错的时候,make 就会中断配方的执行。
make 在执行命令之前会输出将要执行的语句,如果你不希望 make 打印命令语句,可以在命令之前加上 @
。
build:
echo hello
@echo world
$ make echo hello hello world
build:
foo=123
echo $$foo
与 @
类似的记号还有 -
,表示允许这行命令出错。
build:
echo hello
-exit 23
echo world
$ make echo hello hello exit 23 make: [Makefile:3: build] Error 23 (ignored) echo world world
执行多行命令的脚本
make 逐行解释并执行的命令的方式在大多数情况下还是很方便的,但如果你准备使用 sh 以外的命令解释器,例如,Python、nodejs,你就需要让 make 能够一次性解释并执行多行命令,这时你只需要声明一个名为 .ONESHELL
的 target 即可。
.ONESHELL:
SHELL = /bin/sh
.SHELLFLAGS = -ec (1)
build:
foo=32
echo $$foo (2)
1 | 配方中的脚本不再被逐行解释执行,所以需要设置 sh 在遇到命令错误的时候自动退出 |
2 | 现在可以在命令块中声明并使用 shell 变量了,因为这些语句将在同一个 sh 调用中执行 |
foo=32 echo $foo 32
设置 .ONESHELL 之后,@ 、— 修饰符只能出现在整个命令块的开头
|
一些特殊的内建配方
到目前为止,关于 Makefile 的基本知识已经展现我们的眼前,除了这些基本通用的语法规则之外,GNU make 还有一些内置的配方,可以很方便的帮助我们实现常用的功能:
名称 | 功能 |
---|---|
PHONY |
将其 prerequisites 标记为伪目标,make 将始终重新构建这些目标,无论其是否需要更新 |
IGNORE |
当 make 在构建其 prerequisites 的时候,忽略构建错误 |