你真的了解 npm scripts 吗?

1,427 阅读7分钟

现如今无论是 node 还是前端开发都离不开 npm,而脚本功能是 npm 最强大、最常用的功能之一。

关于 npm 脚本(npm scripts)的原理及用途你真的了解吗?

本文就带大家一起深入浅出 npm scripts

1. 什么是 npm scripts?

1.1 如何定义 npm scripts

npm 允许在package.json文件里面,使用scripts字段定义脚本命令。

{
  "scripts": {
    "build": "vue-cli-service build"// vue 项目编译命令(前端领域)
    "start": "node server.js" // nodejs 服务启动命令 (后端领域)
  }
}

上面代码是package.json文件的一个片段。其中,scripts字段的值为一个对象。其对象的每一个属性,对应了一段脚本。

1.2 如何使用 npm scripts

在命令行中执行npm run ${script name}命令,就可以执行对应的脚本命令。例如:

$ npm run build
# 等同于执行
$ vue-cli-service build

$ npm run start
# 等同于执行
$ node server.js

1.3 如何查看 npm scripts

在命令行中,直接使用不带任何参数的npm run命令,可查看当前项目下的所有 npm 脚本命令。例如:

$ npm run
# 上述命令的返回的结果如下:
Scripts available in test-project@1.0.0 via `npm run-script`:
 dev
   vue-cli-service serve
 lint
   eslint --ext .js,.vue src
 build
   vue-cli-service build
 test:unit
   jest --clearCache && vue-cli-service test:unit
 test:ci
   npm run lint && npm run test:unit

1.4 npm scripts 有哪些特点

  • 方便指令的集中管理:项目下的脚本均放置在scripts对象下进行集中管理。
  • 方便指令的集成:通过&& & ||shell相关的指令可轻松完成指令的集成。例如:npm run lint && npm run test:unit,可按顺序执行npm run lintnpm run test:unit指令(前一个指令执行成功后才会执行后续指令)
  • 提供了丰富的辅助功能:可以使用 npm 提供的很多辅助功能,例如脚本的hooks、环境变量等功能。

2 npm scripts 的底层原理

2.1 npm scripts执行

npm 脚本的原理非常简单。每执行一次npm run,就会自动新建一个 Shell,并在这个 Shell 里面执行指定的脚本命令。因此,只要是 Shell(通常是 Bash)可以运行的命令,就可以写在 npm 脚本里面。例如:

{
  "scripts": {
    "linux:ls": "ls -al"// linux ls命令
    "script:js": "filePath/jsfile.js" //  nodejs 脚本文件(filePath表示文件路径,也可以直接通过node xxx.js执行)
    "script:shell": "filePath/shellfile.sh" // bash shell脚本文件(filePath表示文件路径)
  }
}
  • 直接执行linux命令
$ npm run linux:ls

# 上述命令返回结果如下:
> test-project@1.0.0 linux:ls
> ls -al

total 248
drwxr-xr-x    27 username  staff    864  1 10 19:28 .
drwxr-xr-x    10 username  staff    320  1 10 15:45 ..
-rw-r--r--     1 username  staff   3101  1 10 15:54 .cz-config.js
-rw-r--r--     1 username  staff    575  1 10 15:54 .editorconfig
-rw-r--r--     1 username  staff     43  1  7 10:39 .eslintignore
 
  • 执行 js 脚本
#!/usr/bin/env node

// jsfile.js文件

const process = require('process') // 引用nodejs内置的process模块

console.log(process.env.PATH) // 获取环境变量PATH值

上述脚本中:

  1. #!/usr/bin/env node 表示采用 node 作为当前脚本的解释执行器。其中#!称为shebang,该行的其余部分是解释器的路径,用于指定解释执行器在操作系统中的位置。
  2. #!开头的声明必须放置到脚本的第一行且与文件后缀无关,因此有了该声明后,js脚本不一定非要以.js作为后缀,可以为.sh或.py等任意后缀(不推荐这样操作)。
  3. 当执行 env node 时,它其实会去 env | grep PATH 的路径里去依次查找名为 node 的可执行文件。
  4. 可以通过which node命令来找到你本地的node安装路径,并更改/usr/bin/env node为实际值,例如我本地的node路径#!/Users/username/.nvm/versions/node/v16.13.1/bin/node
$ npm run script:js

# 上述命令返回结果如下:
> test-project@1.0.0 script:js
> ./jsfile.js

/Users/username/Documents/workspace/project/iotgz-integration-api/node_modules/.bin:/Users/username/Documents/workspace/project/node_modules/.bin:/Users/username/Documents/workspace/node_modules/.bin:/Users/username/Documents/node_modules/.bin:/Users/username/node_modules/.bin:/Users/node_modules/.bin:/node_modules/.bin
  • 执行 shell 脚本
#!/usr/bin/env bash

echo $PATH;

其中,#!/usr/bin/env bash 表示采用 bash 作为当前脚本的解释执行器,也可以采用 zsh 等其它执行器。

$ npm run script:shell

> test-project@1.0.0 script:shell
> ./shellfile.sh

/Users/username/Documents/workspace/project/iotgz-integration-api/node_modules/.bin:/Users/username/Documents/workspace/project/node_modules/.bin:/Users/username/Documents/workspace/node_modules/.bin:/Users/username/Documents/node_modules/.bin:/Users/username/node_modules/.bin:/Users/node_modules/.bin:/node_modules/.bin

可以看出,上述两个脚本文件都是输出了环境变量 PATH 的值,由于都是通过npm scripts方式执行的,因此输出的值的内容是一致的。

2.2 环境变量注入

  1. 环境变量注入:npm run新建的这个 Shell,会递归地将当前目录下、上层目录下、上上层目录下的node_modules/.bin路径加入到PATH变量,直到到达根目录/为止(这样做的目的是为了尽可能的找到目标可执行文件)。执行npm run xxx后,你的 PATH 环境变量可能为:/Users/username/test-project/node_modules/.bin:/Users/username/node_modules/.bin:/Users/node_modules/.bin:/node_modules/.bin
  2. 环境变量恢复:执行结束后,PATH变量将恢复为原样。这是因为该环境变量类型为shell环境变量,非用户环境变量系统环境变量,因此在脚本执行完毕后,将被清除。

这意味着,当前目录或上层目录的node_modules/.bin子目录里面的所有脚本,都可以直接用脚本名调用,而不必加上路径。比如,当前项目的依赖里面有 Mocha,只要直接写mocha test就可以了。

"test": "mocha test"

而不用写成下面这样。

"test": "./node_modules/.bin/mocha test"

2.3 .bin目录可执行文件

node_modules/.bin中存放着可执行脚本,由于 npm 脚本的唯一要求就是可以在 Shell 执行,因此它不一定是 Node 脚本,任何可执行文件都可以写在里面。注意:.bin目录下的脚本实际上为系统软连接

例如:

$ ls -al
total 0
drwxr-xr-x    19 username  staff    608  1 10 17:06 .
drwxr-xr-x  2058 username  staff  65856  1 10 17:06 ..
lrwxr-xr-x     1 username  staff     46  1 10 17:06 commitizen -> ../_commitizen@4.2.4@commitizen/bin/commitizen
lrwxr-xr-x     1 username  staff     49  1 10 17:06 commitlint -> ../_@commitlint_cli@15.0.0@@commitlint/cli/cli.js

2.4 脚本退出

npm 脚本的退出码,也遵守 Shell 脚本规则。如果退出码不是0,npm 就认为这个脚本执行失败。

3 通配符

由于 npm 脚本就是 Shell 脚本,因为可以使用 Shell 通配符。需要注意的是,通配符和JS正则表达式不是同一个东西,他们的区别可以查看:通配符和JS中的正则区别


"lint": "jshint *.js"
"lint": "jshint **/*.js"

上面代码中,*表示任意文件名,**表示任意一层子目录。

如果要将通配符传入原始命令,防止被 Shell 转义,要将星号转义。


"test": "tap test/*.js"

4 传参

向 npm 脚本传入参数,要使用--标明。


"lint": "jshint **.js"

向上面的npm run lint命令传入参数,必须写成下面这样。


$ npm run lint --  --reporter checkstyle > checkstyle.xml

也可以在package.json里面再封装一个命令。


"lint": "jshint **.js",
"lint:checkstyle": "npm run lint -- --reporter checkstyle > checkstyle.xml"

5 默认值

一般来说,npm 脚本由用户提供。但是,npm 对两个脚本提供了默认值。也就是说,这两个脚本不用定义,就可以直接使用


"start": "node server.js""install": "node-gyp rebuild"

上面代码中,npm run start的默认值是node server.js,前提是项目根目录下有server.js这个脚本;npm run install的默认值是node-gyp rebuild,前提是项目根目录下有binding.gyp文件。

6 钩子

npm 脚本有prepost两个钩子。举例来说,build脚本命令的钩子就是prebuildpostbuild


"prebuild": "echo I run before the build script",
"build": "cross-env NODE_ENV=production webpack",
"postbuild": "echo I run after the build script"

用户执行npm run build的时候,会自动按照下面的顺序执行。


npm run prebuild && npm run build && npm run postbuild

因此,可以在这两个钩子里面,完成一些准备工作和清理工作。下面是一个例子。


"clean": "rimraf ./dist && mkdir dist",
"prebuild": "npm run clean",
"build": "cross-env NODE_ENV=production webpack"

npm 默认提供下面这些钩子。

  • prepublish,postpublish
  • preinstall,postinstall
  • preuninstall,postuninstall
  • preversion,postversion
  • pretest,posttest
  • prestop,poststop
  • prestart,poststart
  • prerestart,postrestart

自定义的脚本命令也可以加上prepost钩子。比如,myscript这个脚本命令,也有premyscriptpostmyscript钩子。不过,双重的prepost无效,比如prepretestpostposttest是无效的。

npm 提供一个npm_lifecycle_event环境变量,返回当前正在运行的脚本名称。比如pretesttestposttest等等。所以,可以利用这个变量,在同一个JS脚本文件里面,为不同的npm scripts命令编写代码。请看下面的例子。


const TARGET = process.env.npm_lifecycle_event;

if (TARGET === 'test') {
  console.log(`Running the test task!`);
}

if (TARGET === 'pretest') {
  console.log(`Running the pretest task!`);
}

if (TARGET === 'posttest') {
  console.log(`Running the posttest task!`);
}

注意,prepublish这个钩子不仅会在npm publish命令之前运行,还会在npm install(不带任何参数)命令之前运行。这种行为很容易让用户感到困惑,所以 npm 4 引入了一个新的钩子prepare,行为等同于prepublish,而从 npm 5 开始,prepublish将只在npm publish命令之前运行。

7 简写形式

四个常用的 npm 脚本有简写形式。

  • npm startnpm run start
  • npm stopnpm run stop的简写
  • npm testnpm run test的简写
  • npm restartnpm run stop && npm run restart && npm run start的简写

npm startnpm stopnpm restart都比较好理解,而npm restart是一个复合命令,实际上会执行三个脚本命令:stoprestartstart。具体的执行顺序如下。

  1. prerestart
  2. prestop
  3. stop
  4. poststop
  5. restart
  6. prestart
  7. start
  8. poststart
  9. postrestart

8 环境变量

npm 脚本有一个非常强大的功能,就是可以使用 npm 注入到shell的环境变量。

  1. 首先,通过npm_package_前缀,npm 脚本可以通过环境变量拿到package.json里面的字段。比如,下面是一个package.json

{
  "name": "foo", 
  "version": "1.2.5",
  "scripts": {
    "view": "node view.js"
  }
}

那么,变量npm_package_name返回foo,变量npm_package_version返回1.2.5


// view.js
console.log(process.env.npm_package_name); // foo
console.log(process.env.npm_package_version); // 1.2.5

上面代码中,我们通过环境变量process.env对象,拿到package.json的字段值。如果是 Bash 脚本,可以用$npm_package_name$npm_package_version取到这两个值。(如果是JS脚本,则可以直接通过require('package.json')获取,因为nodejs模块系统内置支持.js/.json/.node等后缀文件)

  1. 其次,npm 脚本还可以通过npm_config_前缀,拿到 npm 的配置变量,即npm config get xxx命令返回的值。比如,当前模块的发行标签,可以通过npm_config_tag取到。
{
  "name": "test-project", 
  "version": "1.0.0",
  "scripts": {
    "view": "env | grep -E ^npm_config"
  }
}

运行 npm run view后得到的结果如下:

$ npm run view

> test-project@1.0.0 view
> env | grep -e ^npm_config

npm_config_metrics_registry=https://registry.npmjs.org/
npm_config_global_prefix=/Users/username/.nvm/versions/node/v16.13.1
npm_config_noproxy=
npm_config_local_prefix=/Users/username/Documents/workspace/project/iotgz-integration-api
npm_config_globalconfig=/Users/username/.nvm/versions/node/v16.13.1/etc/npmrc
npm_config_userconfig=/Users/username/.npmrc
npm_config_init_module=/Users/username/.npm-init.js
npm_config_node_gyp=/Users/username/.nvm/versions/node/v16.13.1/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js
npm_config_cache=/Users/username/.npm
npm_config_user_agent=npm/8.3.0 node/v16.13.1 darwin arm64 workspaces/false
npm_config_prefix=/Users/username/.nvm/versions/node/v16.13.1

  1. 需要注意的是,package.json里面的config对象,可以被npm config set命令覆盖的。

{ 
  "name" : "test-project",
  "version" : "1.0.0",
  "config" : { "port" : "8080" },
  "scripts" : { "start" : "node server.js" }
}

上面代码中,npm_package_config_port环境变量返回的值是8080。这个值可以用下面的方法覆盖:


$ npm config set foo:port 80

9 常用脚本示例


// 删除目录
"clean": "rimraf dist/*",

// 本地搭建一个 HTTP 服务
"serve": "http-server -p 9090 dist/",

// 打开浏览器
"open:dev": "opener http://localhost:9090",

// 实时刷新
 "livereload": "live-reload --port 9091 dist/",

// 构建 HTML 文件
"build:html": "jade index.jade > dist/index.html",

// 只要 CSS 文件有变动,就重新执行构建
"watch:css": "watch 'npm run build:css' assets/styles/",

// 只要 HTML 文件有变动,就重新执行构建
"watch:html": "watch 'npm run build:html' assets/html",

// 部署到 Amazon S3
"deploy:prod": "s3-cli sync ./dist/ s3://example-com/prod-site/",

// 构建 favicon
"build:favicon": "node scripts/favicon.js",

10 参考链接

阮一峰 npm scripts 使用指南

官网 npm scripts 使用手册