この記事は、ドワンゴ Advent Calendar 2018の3日目です。
TL; DR
- npm scriptsが太ってつらい。
- Makeを勉強して移してみたら管理しやすくなった。
- Windows・・・。WSLを使えば問題ないはずだ。
前提
タスクランナーとしてよく使われるnpm scriptsですが、buildツールやlintツールを使ったり、buildする対象が複数に渡ったりすると、JSONというあまり書き心地がいいわけではないファイルにコマンドをたくさん並べる事態になります。更にwatch系のコマンドが複数できて、それらを同時に実行したいとか、いろいろやりたくなってどんどん増えていくことがよくあります。
気にならないならいいのですが、いろいろなタスクをnpm scriptsに任せて20個30個並び始めると、メンテナンスしにくいし依存関係わかりにくいし、つらくなってきます。
Gulpなどでコマンドを実行することはできるのですが、そのためにGulpを入れるのは煩わしい感じがして、なにか手頃な方法はないかと思ったときにふと思い出して書いてみたのが、Makefileです。
npmでMakeを使ってみる
Make自体は機能がたくさんあって奥が深いので、タスクを指定したらコマンドを実行してくれるという単純な機能を主に見ていきたいと思います。
まずpackage.jsonと同じディレクトリにMakefileという名前でファイルを作り、簡単にタスクを定義しましょう。
基本形は
タスク名:
実行コマンド
実行コマンド
実行コマンド
...
です。
SHELL := /bin/bash
export PATH := ./node_modules/.bin:$(PATH)
.PHONY: build
build:
tsc -p .
lint:
tslint -p . -c .
こんな感じでタスクを並べていくと、
make build # -> TypeScriptでコンパイル
make lint # -> tslint実行
な風に、タスクランナーに使えます。
少し詳しく
タスク間の依存関係
上の例では独立したコマンドを定義しましたが、あるコマンドが別のコマンドを
.PHONY: build
build: build_server build_config build_webpack
build_server:
tsc -p server
build_config:
./scripts/build-config
build_webpack:
webpack -c webpack.config.js
こうすると、 make build
で下3つを実行できます。
並列実行
Makeは-J
オプションを付けることで複数プロセスを同時に実行できます。上の例でmake build -J
を実行すると、下3つが同時に実行されます。
同時実行で高速に、だけではなく、例えばwatch系のプロセスを複数同時に立ち上げたい場合もこんなことができます。
# TypeScriptをwatchしてコンパイルしつつ、変更があったらサーバーを再起動する
dev: watch_tsc watch_server
watch_tsc:
tsc -p server -w
watch_server:
onchange server -- node server/build/start.js
make dev -J # →2つのプロセスが同時に立ちあがってそれぞれwatchする
環境変数
Makeの中で環境変数を定義できます。Nodeで特に便利なのが、
export PATH := ./node_modules/.bin:$(PATH)
で、最初の方にこれを書いておけば、npm scriptsと同じようにローカルにあるnpmパッケージのコマンドが使えるようになります。
ローカル変数
Make内で有効な文字列の変数を定義し、メタプロのようなこともできます。
PRETTIER := prettier "**/*.{js,jsx,ts,tsx,yml,json}"
# prettier "**/*.{js,jsx,ts,tsx,yml,json}"
prettier:
$(PRETTIER)
# prettier "**/*.{js,jsx,ts,tsx,yml,json}" --write
format:
$(PRETTIER) --write
この$()
の部分は、実行時に定義された文字列に置き換わります。
ファイルを分ける
Makefile一つだけに書いていくと、それでも多くなりすぎる事があるかもしれません。そのときは
include foo.mk
# とか
include *.mk
# とか
このように書くと、別のファイルを取り込めます。
コメントを書ける
一応package.jsonにもコメントを書けますが、無理矢理感が半端ない。
Makefileなら#
以降がコメントになります。コマンドの説明など簡単にかけます。
# これはTypeScriptをJavaScriptにコンパイルします
.PHONY: build
build:
tsc -p .
つらいところ
ここまで機能豊富で便利そうなMakeですが悩ましいところももちろんあります。
Windowsで使えない
7と8系だと動かす方法は見つけられませんでした。Windows向けMakeなるものがあったが最終更新2006年・・・。
10ではWindows Subsystem for Linuxでは使えます。
文法、落とし穴
いろいろな機能や文法があるので、時々意図しない挙動をしたりします。あと、タブでインデントしないと動かないという、謎な決まりもあります。
Macでデフォルトで入っているMakeはそこそこ古く、変な挙動があったりするのでhomebrewなどでアップデートしてあげるほうが安心です。
.PHONY:
.PHONY:
.PHONY:
.PHONY:
...
上の例で所々つけている呪文.PHONY
、これをつけると「このタスクの名前はただのタスク名だよ」とMakeに言うことができます。逆に言うと、そうしないと別の解釈をされる可能性があるわけです。
特にタスク名と同じファイルやディレクトリがある/できる可能性がある場合は、つけておいたほうが無難です。
終わりに
とここまでMakeをNodeで使うための基礎をみてきました。個人のプロジェクトでそこそこヘビーに使っていますが、.PHONY
の煩わしさを除けば満足できています。npm scriptsにはnpmのライフサイクルフック、Makefileには具体的な仕事を書く、という分割ができて、全部ごちゃまぜのnpm scriptsより手を入れやすいです。
いきなり全部Makeは、、、という場合、たとえばCIで実行するタスクをMakefileにまとめておく、みたいに少しずつ導入することもできます。CIならWindowsを気にする必要もありません。
Makeはこのために習ったくらいで詳しくないので、間違っていることとかありましたら教えてください ><
(付録1) npmでMakeって昔あったよね?
僕自身がタイムリーに見ていたわけではないですが、npmでMakeを使う話は新しい話ではなく、終わったものとして捉えられているひともいるそうです。4年前にはこんな記事が書かれていました。
GruntからMakeへ、Makeからnpm-scriptsへ
2年前にはJavaScriptのエコシステムに詳しくない人が、同僚に相談したら複雑さに圧倒されて逆に混乱する寸劇が投稿されて話題になってました。
How it feels to learn JavaScript in 2016
その中でMakefileが「webpackに全て吸収されるまで使われていた」という形で話にあがっています。(本当にwebpackに全て吸収されたのかはおいておく)
ですが、
- ツールが多様化かつ細分化されて数が増え、npm scriptsじゃ耐えられないことはやはりある
- ツールはコマンドラインから実行するものが多く、コマンドをそのままかけるMakeは手軽
- Windowsでの互換性は、10のWindows Subsystem for Linuxで状況が変わりつつある
のでこれから生き返っても不思議ではないと思います。
過ぎたものでも、便利なら積極的に使っていきたい。
ちなみにたとえばbabel/babelなど、Makeが健在のJavaScriptのプロジェクトもあります。
(付録2) 本来のMake
GNU Makeは、もともとはC言語で書かれたソースファイルをコンパイルするときに、入力ファイル、出力ファイル、出力するための実行コマンドを関連づけて効率的にコンパイルするのが目的のツールらしいです。ただし言語に依存しないので何にでも使えます。
Cはほとんど書いたことがないですが。
出力ファイル: 入力ファイル
実行するコマンド
が基本形で、make 出力ファイル
でコマンドを実行し、ターゲットのファイルを生成する感じで書きます。「このファイルを作って」と命令するとみれば、make
という名前も理解できますね。
edit : main.o kbd.o command.o display.o
cc -o edit main.o kbd.o command.o display.o
main.o : main.c defs.h
cc -c main.c
kbd.o : kbd.c defs.h command.h
cc -c kbd.c
command.o : command.c defs.h command.h
cc -c command.c
display.o : display.c defs.h buffer.h
cc -c display.c
こんな感じでコンパイル対象を分けると、再コンパイルする際に、ファイルの更新があったコンパイルが必要なものだけがコンパイルされて嬉しい、というものらしいです。