上個月,我在這篇文章《為什麼要停止使用 Grunt 和 Gulp》中建議大家使用 npm 作為替代方案,npm 的 scripts
配置可以實現這些構建工具的所有功能,而且更簡潔、更優雅和較少的模組依賴和維護開銷。本文第一稿大概有 6000 字,深入講解了如何將 npm 作為替代方案,但那篇文章主要在表達我的觀點,而不是作為一篇教程。然而,讀者的反饋卻很強烈,許多讀者告訴我 npm 並不能完全實現這些構建工具提供的特性,甚至有的讀者直接給我一個 Gruntfile
,然後反問我:“怎麼用 npm 來實現這樣的構建方案”?所以我決定進一步更新本文,將其作為一個新手入門教程,主要分享如何使用 npm 來完成一些常見的構建任務。
npm 是一個很好的工具,提供了一些奇特的功能,也是 NodeJS 的核心,包括我在內的很多人每天都在使用 npm,事實上在我的 Bash 歷史記錄中,npm 的使用頻率僅次於 git。npm 更新也很快,旨在使 npm 成為一個強大的模組管理工具。而且,npm 有一個功能子集,可以通過執行一些任務來維護模組的生命週期,換句話說,它也是一個強大的構建工具。
scripts 配置
首先,我們需要搞清楚如何使用 npm 來管理構建指令碼。作為核心命令之一的 npm run-script
命令(簡稱 npm run
)可以從 package.json
中解析出 scripts
物件,然後將該物件的鍵作為 npm run
的第一個引數,它會在作業系統的預設終端中執行該鍵對應的命令,請看下面的 package.json
檔案:
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "name": "myproject", "devDependencies": { "jshint": "latest", "browserify": "latest", "mocha": "latest" }, "scripts": { "lint": "jshint **.js", "test": "mocha test/" } } |
如果執行 npm run lint
,npm 將在終端中執行 jshint **.js
,如果執行 npm run test
,npm 將在終端中執行 mocha test/
。執行 npm run xxx
時會將 node_modules/.bin
加入終端的 PATH
環境變數中,這樣你就可以直接執行那些作為依賴安裝的二進位制模組,也就是說你不需要 "./node_modules/.bin/jshint **.js"
或 "$(npm bin)/jshint **.js"
這樣來指定命令的路徑。如果執行不帶引數的 npm run
命令,它將列舉出目前可執行的命令:
1 2 3 4 5 |
Available scripts in the user-service package: lint jshint **.js test mocha test/ |
快捷命令
npm 為一些命令提供了快捷方式:npm test
,npm start
和 npm stop
,例如 npm test
就是 npm run test
的快捷命令,快捷命令存在的原因有二:
- 這些是大多數專案都將使用的通用任務,所以不必每次都需要輸入如此之多字元。
- 更重要的是,這為測試、啟動和停止模組提供了對應的標準介面。一些持續整合工具(比如 Travis)就充分利用了這一特性,將
npm test
作為 NodeJS 模組的預設命令。這也可以使開發者加入一個新專案更加容易,他們不需要閱讀文件就知道可以執行像npm test
這樣的命令。
鉤子
另一個炫酷的特性是,可以在 scripts
中為任何可執行的命令指定 pre-
和 post-
鉤子。例如,當執行 npm run lint
時,即便是沒有在 scripts
中定義對應的 pre-
命令,npm 也會首先執行 npm run prelint
,接著才是 npm run lint
,最後是 npm run postlint
。
這個規則適用於所有命令,npm test
也一樣(npm run pretest
,npm run test
,npm run posttest
)。並且這些命令可以感知 exit-code
,也就是說如果 pretest
命令退出時返回了非零的 exit-code
,那麼後續的 test
和 posttest
命令都不會繼續執行。需要注意的是鉤子不能巢狀,比如 prepretest
這樣的命令將被忽略。
npm 也為一些內建命令(install
,uninstall
,publish
和 update
)提供了鉤子,使用者不能重寫這些內建命令的行為,但可以通過鉤子來影響這些命令的行為:
1 2 3 4 5 6 7 8 9 |
"scripts": { "lint": "jshint **.js", "build": "browserify index.js > myproject.min.js", "test": "mocha test/", "prepublish": "npm run build # also runs npm run prebuild", "prebuild": "npm run test # also runs npm run pretest", "pretest": "npm run lint" } |
傳遞引數
npm 2.0.0 之後可以為命令傳遞引數,請看下面例子:
1 2 3 4 |
"scripts": { "test": "mocha test/", "test:xunit": "npm run test -- --reporter xunit" } |
我們可以直接執行 npm run test
也就是 mocha test/
,我們還可以在命令後面加上 --
來傳遞自定義的引數,比如 npm run test -- anothertest.js
將執行 mocha test/ anothertest.js
,一個更實用的例子是 npm run test -- --grep parser
,將執行 mocha test/ --grep parser
。這可以讓我們將一些命令組合起來使用,並提供一些高階配置項。
自定義變數
可以在 package.json
檔案中的 config 中指定任意數量的變數,然後我們可以在 scripts
中像使用環境變數一樣來使用這些變數:
1 2 3 4 5 6 7 8 |
"name": "fooproject", "config": { "reporter": "xunit" }, "scripts": { "test": "mocha test/ --reporter $npm_package_config_reporter", "test:dev": "npm run test --fooproject:reporter=spec" } |
在 config
中的所有屬性都將加上 npm_package_config_
字首暴露到環境變數中,在上面的 config
物件中有一個值為 xunit
的 reporter
屬性,所以執行 npm run test
時,將執行 mocha test/ --reporter xunit
。
可以通過如下兩種方式來覆蓋變數的值:
- 和上例中的
test:dev
一樣,可以通過--fooproject:reporter=spec
將reporter
變數的值指定為spec
。具體使用時,你需要將fooproject
替換為你自己的專案名,同時將reporter
替換為你需要替換的變數名。 - 通過使用者配置來覆蓋,通過執行
npm config set fooproject:reporter spec
將會在~/.npmrc
檔案中新增fooproject:reporter=spec
項,執行 npm 時將動態讀取這些配置並且替換npm_package_config_reporter
變數的值,這意味著執行npm run test
將執行mocha test/ --reporter spec
。可以通過執行npm config delete fooproject:reporter
來刪除這些個人配置項。比較優雅的方式是在package.json
檔案中為變數指定一些預設值,同時使用者可以在~/.npmrc
檔案中自定義某些變數的值。
老實說,我並不喜歡對這種定義和使用變數的方式,而且還有一個缺陷,那就是在 Windows 中引用變數是通過 %
加變數名,如果 scripts
中定義的是 NodeJS 指令碼,並不會有什麼問題,然而對於 shell 指令碼卻不相容。
Windows 的問題
繼續深入之前,我們先聊一個題外話。npm 依賴作業系統的 shell 作為其指令碼執行的環境,Linux、Solaris、BSD 和 Mac OSX 都內建了 Bash 作為他們的預設 shell,而 Windows 卻沒有,在 Windows 中,npm 將使用 Windows 的命令列工具作為其執行環境。
但這也算不上什麼大問題,Bash 和 Windows 中的許多語法都一樣:
&&
連續執行多個命令,前面的命令執行成功後才執行後面的命令&
連續執行多個命令,不管前面命令執行成功沒有,後面的命令將繼續執行使命令從檔案讀入
>
把命令的輸出重定向到檔案中|
把命令的輸出重定向到下一個命令
最大的問題在於,某些命令的命名不同(cp
和 Windows 中的 COPY
)和變數的引用方式(Windows 中使用 %
引用變數,而 Bash 卻是使用 $
)。但這些問題都是可以解的:
- 對於某些特殊的命令,我們可以不使用系統內建的命令,而是使用具有相同功能的 npm 模組。例如我們可以使用 rimraf 這個模組來替代內建的
rm
命令。 - 只使用那些跨平臺相容的語法,即便是僅僅使用
&&
,>
,|
和這些語法就可以完成很多令人驚訝的功能。環境變數的引用只是冰山一角。
如何替換構建工具
現在我們迴歸正題,如果我們想要替換 Grunt 和 Gulp 這樣的構建工具,我們需要實現這些構建工具及其外掛的對等功能。我從各種專案和上篇文章的評論中收集了一些最流行的構建任務,下面我將演示如何通過 npm 來實現這些任務。
多檔案處理
在上一篇文章的評論中有幾個人提到:構建工具的一個優勢是可以使用 *.js
, *.min.css
或 assets/*/*
這樣的 globs 語法來進行多檔案處理。事實上這個特性的靈感來源於 Bash 中的 glob
命令。 Shell 會將命令引數(如 *.js
)中的星號解析為萬用字元,使用連續兩個星號表示跨目錄遞迴查詢。如果你正在使用 Mac 或 Linux,你可以在終端中玩一下,比如 ls *.js
。
現在的問題是,Windows 的命令列並不支援該特性。新運的是,Windows 會將引數(如 *.js
)逐字完整地傳遞給命令,這樣就可以為 Windows 安裝對應的相容庫就可以實現 glob
語法。在 npm 中有兩個最流行的 glob
包 minimatch 和 glob,已經被 1500
多個專案依賴,包括 JSHint,JSCS,Mocha,Jade,Stylus,Node-Sass…等等,而且這個數量還在增長。
這樣你就可以在 scripts
中直接使用 glob
語法了:
1 2 3 4 5 6 |
"devDependencies": { "jshint": "latest" }, "scripts": { "lint": "jshint *.js" } |
執行多工
在 Grunt 和 Gulp 中可以將一些任務組合起來成為一個新的命令,尤其是在構建或測試時非常實用。在 npm 中有兩種方式可以解這個問題:一是通過 pre-
和 post-
鉤子,如果在執行某個任務之前需要執行某個任務(如壓縮之前合併檔案),這是個不錯的選擇;另外你還可以實用 &&
這個命令連線符:
1 2 3 4 5 6 7 8 9 10 11 12 |
"devDependencies": { "jshint": "latest", "stylus": "latest", "browserify": "latest" }, "scripts": { "lint": "jshint **", "build:css": "stylus assets/styles/main.styl > dist/main.css", "build:js": "browserify assets/scripts/main.js > dist/main.js", "build": "npm run build:css && npm run build:js", "prebuild:js": "npm run lint" } |
上例中 build
包含了 build:css
和 build:js
兩個任務,並且在執行 build:js
前將先執行 lint
任務。獨立執行 build:css
或 build:js
也是可行的,單獨執行 build:js
前也會先執行 lint
。所以我們可以像這樣來組合我們的任務,並且這是 Windows 相容的。
使用資料流
Gulp 一個最大的特性是使用流將一個任務的輸出 pipe 到下一個任務(Grunt 需要頻繁地讀取和儲存檔案)。在 Bash 和 Windows 的命令列中都有 |
這個管道操作符,可以用來將一個命令的輸出(stdout
)作為下一個命令的輸入(stdin
)。比方說對一個 CSS 檔案,你想先通過 Autoprefixer 處理,然後 CSSMin,最後儲存到檔案:
1 2 3 4 5 6 7 |
"devDependencies": { "autoprefixer": "latest", "cssmin": "latest" }, "scripts": { "build:css": "autoprefixer -b 'last 2 versions' < assets/styles/main.css | cssmin > dist/main.css" } |
就像你看到的那樣,首先通過 autoprefixer
為我們的 CSS 新增瀏覽器廠商字首,然後將其輸出 pipe 到 cssmin
來壓縮我們的 CSS,最後將整個輸出儲存到 dist/main.css
檔案。絕大多數工具都支援 stdin
和 stdout
,而且上述程式碼可以在 Windows,Mac 和 Linux 平臺下完美相容。
版本號
版本號管理是 Grunt 和 Gulp 中的一個常見任務,可以方便地將 package.json
中的版本號加一,為專案打 Tag 和 Commit。
npm 的一個核心功能就是版本管理,執行 npm version patch
就可以增加修訂版本號:1.1.1 -> 1.1.2
,執行 npm version minor
可以增加次要版本號:1.1.1 -> 1.2.0
,執行 npm version major
可以增加主版本號:1.1.1 -> 2.0.0
,這幾個命令將自動為你的專案打 Tag 和 Commit,就剩下 git push
和 npm publish
了。
還可以自定義這幾個命令的行為。如果不想為專案打 Tag,你可以在命令後面加上 --git-tag-version=false
,或者通過 npm config set git-tag-version false
將其設定為預設項。如果想自定義提交資訊呢?可以這樣 npm version patch -m "Bumped to %s"
,或直接設定為預設項 npm config set message "Bumped to %s"
。甚至可以通過 --sign-git-tag=true
為 Tag 簽名,也可以通過 npm config set sign-git-tag true
將其設定為預設項。
清理
很多構建工具都會有一個 clean
任務,用來清理構建過程或構建後生成的檔案,在 Bash 中自帶了一個清理命令 rm
,在命令後面加上 -r
引數可以遞迴刪除目錄。這個命令再簡單不過了:
1 2 3 |
"scripts": { "clean": "rm -r dist/*" } |
如果想相容 Windows 可以使用 rimraf 這個平臺無關的相容模組:
1 2 3 4 5 6 |
"devDependencies": { "rimraf": "latest" }, "scripts": { "clean": "rimraf dist" } |
檔名 Hash 化
在 Grunt 和 Gulp 分別有 grunt-hash 和 gulp-hash 兩個外掛,用來根據檔案的內容生成一個 hash 化後的檔名。要用已有的命令來實現這個功能還是比較難,我搜尋了 npm 模組,也沒有找到具有相同功能的模組,所以最後我自己實現了一個 – hashmark。該支援流操作,可以作為某些 Grunt/Gulp 外掛的依賴項。繼續之前的例子,我們可以將構建結果 pipe 到一個具有 hash 值檔名的檔案中:
1 2 3 4 5 6 7 |
"devDependencies": { "autoprefixer": "latest", "cssmin": "latest" }, "scripts": { "build:css": "autoprefixer -b '> 5%' < assets/styles/main.css | cssmin | hashmark -l 8 'dist/main.#.css'" } |
現在執行 build:css
任務將得到一個類似 dist/main.3ecfca12.css
這樣的檔案。
Watch
這也是 Grunt/Gulp 備受歡迎的原因之一,很多構建工具都支援監視檔案系統的變化然後執行相應的構建或重新整理任務,這在開發過程中非常實用。這也是在上篇文章中許多開發者關注的問題之一,他們認為如果沒有 watch
類似的任務就黯然失色了。
好吧,其實很多工具自身就提供了這個選項,可以用於監聽複雜的檔案系統。比如 Mocha 就提供了 -w
選項,還有 Stylus、Node-Sass、Jade 和 Karma 等等。你可以這樣使用:
1 2 3 4 5 6 7 8 9 10 11 |
"devDependencies": { "mocha": "latest", "stylus": "latest" }, "scripts": { "test": "mocha test/", "test:watch": "npm run test -- -w", "css": "stylus assets/styles/main.styl > dist/main.css", "css:watch": "npm run css -- -w" } |
當然,並不是所有工具都提供了該選項,就算都有這個選項,有時候你還希望在檔案變化時觸發某個任務集合,不用擔心,有很多模組可以監視檔案變化,並在檔案變化是觸發某個命令,比如 watch、onchange、 dirwatch 這些模組,甚至可以用 nodemon:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
"devDependencies": { "stylus": "latest", "jade": "latest", "browserify": "latest", "watch": "latest", }, "scripts": { "build:js": "browserify assets/scripts/main.js > dist/main.js", "build:css": "stylus assets/styles/main.styl > dist/main.css", "build:html": "jade assets/html/index.jade > dist/index.html", "build": "npm run build:js && npm run build:css && npm run build:html", "build:watch": "watch 'npm run build' .", } |
就是這麼簡單,僅僅 13 行配置就可以監視整個專案檔案,當任何檔案改變時就自動執行構建 HTML、CSS 和 JS 的任務,直接執行 npm run build:watch
就可以開始無痛開發了。使用一個我寫的模組 Parallelshell,用於併發執行多個命令,我們還可以做一些優化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
"devDependencies": { "stylus": "latest", "jade": "latest", "browserify": "latest", "watch": "latest", "parallelshell": "latest" }, "scripts": { "build:js": "browserify assets/scripts/main.js > dist/main.js", "watch:js": "watch 'npm run build:js' assets/scripts/", "build:css": "stylus assets/styles/main.styl > dist/main.css", "watch:css": "watch 'npm run build:css' assets/styles/", "build:html": "jade index.jade > dist/index.html", "watch:html": "watch 'npm run build:html' assets/html", "build": "npm run build:js && npm run build:css && npm run build:html", "build:watch": "parallelshell 'npm run watch:js' 'npm run watch:css' 'npm run watch:html'", } |
執行 npm run build:watch
時將通過 Parallelshell 分別執行獨立的監視任務,如果只有 CSS 檔案發生了變化,那麼將只執行 CSS 構建任務。Parallelshell 將每個任務的輸出(stdout
和 stderr
)連線到主程式,並監聽了 exitCode
來確保構建任務的日誌輸出(這與 &
這個命令連線符不同)。
LiveReload
LiveReload 也是一個很受歡迎的特性:當檔案變化時自動重新整理瀏覽器中的頁面,live-reload 這個 npm 模組可以實現這個功能,看下面例子:
1 2 3 4 5 6 |
"devDependencies": { "live-reload": "latest", }, "scripts": { "livereload": "live-reload --port 9091 dist/", } |
1 2 |
<!-- In your HTML file --> <script src="//localhost:9091"></script> |
執行 npm run livereload
後,dist/
目錄下的任何改變都將通知到你訪問的 HTML 頁面,並觸發頁面自動重新整理。
自定義指令碼
那麼如果一個模組並沒有提供相應的命令列工具,如 favicon,該怎麼辦呢?我們可以自己寫一段 JavaScript 指令碼來執行相應的功能,這也正是 Grunt/Gulp 外掛所做的事情,還可以給模組維護者提交 PullRequest 讓他們提供一個命令列工具:
1 2 3 4 5 6 7 |
// scripts/favicon.js var favicons = require('favicons'); var path = require('path'); favicons({ source: path.resolve('../assets/images/logo.png'), dest: path.resolve('../dist/'), }); |
1 2 3 4 5 6 |
"devDependencies": { "favicons": "latest", }, "scripts": { "build:favicon": "node scripts/favicon.js", } |
一個相對複雜的例子
在上篇文章的評論中有些人說我忽視構建工具的關鍵點:構建工具不僅僅是用於執行單個任務,更重要的是它們可以將單個任務連線起來成為複雜的構建流程。所以這裡我就將上面演示過的例子組合起來成為一個複雜的構建任務,這和具有上百行程式碼的 Gruntfile
所做的事情一樣。在本例中我想完成以下構建任務:
- Lint、Test 和編譯 JS 檔案,生成對應的 sourcemap,hash 化檔名,最後上傳到 S3
- 將 Stylus 編譯為一個獨立的 Hash 化的 CSS 檔案,生成對應的 sourcemap,並上傳到 S3
- 為編譯後測試新增 watcher
- 啟動一個靜態伺服器,用於瀏覽和測試編譯結果
- 為 CSS 和 JS 檔案新增 livereload
- 設計一個與構建環境相關的總任務,將所有相關任務包括進來,這樣就可以執行這個簡單的命令來完成複雜的構建過程
- 自動開啟瀏覽器並訪問我們的測試頁面
我將本例的完整程式碼放在 npm-scripts-example 這個程式碼庫中,下面是我們最關注的部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
"scripts": { "clean": "rimraf dist/*", "prebuild": "npm run clean -s", "build": "npm run build:scripts -s && npm run build:styles -s && npm run build:markup -s", "build:scripts": "browserify -d assets/scripts/main.js -p [minifyify --compressPath . --map main.js.map --output dist/main.js.map] | hashmark -n dist/main.js -s -l 8 -m assets.json 'dist/{name}{hash}{ext}'", "build:styles": "stylus assets/styles/main.styl -m -o dist/ && hashmark -s -l 8 -m assets.json dist/main.css 'dist/{name}{hash}{ext}'", "build:markup": "jade assets/markup/index.jade --obj assets.json -o dist", "test": "karma start --singleRun", "watch": "parallelshell 'npm run watch:test -s' 'npm run watch:build -s'", "watch:test": "karma start", "watch:build": "nodemon -q -w assets/ --ext '.' --exec 'npm run build'", "open:prod": "opener http://example.com", "open:stage": "opener http://staging.example.internal", "open:dev": "opener http://localhost:9090", "deploy:prod": "s3-cli sync ./dist/ s3://example-com/prod-site/", "deploy:stage": "s3-cli sync ./dist/ s3://example-com/stage-site/", "serve": "http-server -p 9090 dist/", "live-reload": "live-reload --port 9091 dist/", "dev": "npm run open:dev -s & parallelshell 'npm run live-reload -s' 'npm run serve -s' 'npm run watch -s'" } |
上面的 -s
是禁止 npm 輸出任何日誌資訊,你可以嘗試刪除這個選項來看看有什麼不同。
如果用 Grunt 來完成相同的構建任務,則需要上百行的 Gruntfile
程式碼,並且還需要十多個額外的模組。就可讀性而言,npm 的 scripts 雖然表面上可讀性並不是那麼高,但就我而言我可以指令碼語言更加容易被理解,每個任務所做的事情也更加清楚。
總結
希望通過本文你瞭解到了 npm 在構建方面的能力,當需要構建一個專案時 Grunt/Gulp 並不一定是首選工具,或許 npm 就能滿足你的需求。