Skip to content

23npmcript:打造一体化的构建和部署流程 ​

之前我们提到过,一个顺畅的基建流程离不开 npm scripts。npm scripts 将工程化的各个环节串联起来,相信任何一个现代化的项目都有自己的 npm scripts 设计。那么作为架构师或资深开发者,我们如何设计并实现项目配套的 npm scripts 呢?关于 npm scripts 我们如何进行封装抽象,做到复用或基建统一呢?

这一讲,我们就围绕如何使用 npm scripts,打造一体化的构建和部署流程展开。

npm scripts 原理介绍 ​

这一部分,我们将对 npm scripts 是什么,以及其核心原理进行讲解。

npm scripts 是什么 ​

我们先来系统地了解一下 npm scripts。Node.js 在设计 npm 之初,允许开发者在 package.json 文件中,通过 scripts 字段来自定义项目的脚本。比如我们可以在 package.json 中这样使用:

java
{
	// ...
  "scripts": {
    "build": "node build.js",
    "dev": "node dev.js",
    "test": "node test.js",
  }
  // ...
}

对应上述代码,我们在项目中可以使用命令行执行相关的脚本:

java
$ npm run build
$ npm run dev
$ npm run test

其中build.js、dev.js、test.js三个 Node.js 模块分别对应上面三个命令行执行命令。这样的设计,可以方便我们统计和集中维护项目工程化或基建相关的所有脚本/命令,也可以利用 npm 很多辅助功能,例如下面几个功能。

  • 使用 npm 钩子,比如pre、post,对应命令npm run build的钩子命令就是:prebuild和postbuild。

  • 开发者使用npm run build时,会默认自动先执行npm run prebuild再执行npm run build,最后执行npm run postbuild,对此我们可以自定义:

java
    {
    	// ...
      "scripts": {
        "prebuild": "node prebuild.js",
        "build": "node build.js",
        "postbuild": "node postbuild.js",
      }
      // ...
    }
  • 使用 npm 提供的process.env.npm_lifecycle_event等环境变量。通过process.env.npm_lifecycle_event,可以在相关 npm scripts 脚本中获得当前运行的脚本名称。

  • 使用 npm 提供的npm_package_能力,获取 package.json 中的相关字段,比如下面代码:

java
  // 获取 package.json 中的 name 字段值
  console.log(process.env.npm_package_name)

  // 获取 package.json 中的 version 字段值
  console.log(process.env.npm_package_version)

更多 npm 为 npm scripts 提供的"黑魔法",我们不再一一列举了。你可以前往 https://docs.npmjs.com/ 进行了解。

npm scripts 原理 ​

其实,npm scripts 原理比较简单。我们依靠npm run xxx来执行一个 npm scripts,那么核心奥秘就在于npm run了。npm run会自动创建一个 Shell(实际使用的 Shell 会根据系统平台而不同,类 UNIX 系统里,如 macOS 或 Linux 中指代的是 /bin/sh, 在 Windows 中使用的是 cmd.exe),我们的 npm scripts 脚本就在这个新创建的 Shell 中被运行。这样一来,我们可以得出几个关键结论:

  • 只要是 Shell 可以运行的命令,都可以作为 npm scripts 脚本;

  • npm 脚本的退出码,也自然遵守 Shell 脚本规则;

  • 如果我们的系统里安装了 Python,可以将 Python 脚本作为 npm scripts;

  • npm scripts 脚本可以使用 Shell 通配符等常规能力。

比如这样的代码:

java
  {
  	// ...
    "scripts": {
      "lint": "eslint **/*.js",
    }
    // ...
  }

*表示任意文件名,**表示任意一层子目录,在执行npm run lint后,就可以对当前目录下,任意一层子目录的 js 文件进行 lint 审查。

另外,请你思考:npm run创建出来的 Shell 有什么特别之处呢?

我们知道,node_modules/.bin子目录中的所有脚本都可以直接以脚本名的形式调用,而不必写出完整路径,比如下面代码:

java
{
	// ...
  "scripts": {
    "build": "webpack",
  }
  // ...
}

在 package.json 中直接写webpack即可,而不需要写成:

java
{
	// ...
  "scripts": {
    "build": "./node_modules/.bin/webpack",
  }
  // ...
}

的形式。这是为什么呢?

实际上,npm run创建出来的 Shell 需要将当前目录的 node_modules/.bin子目录加入PATH 变量中,在 npm scripts 执行完成后,再将 PATH 变量恢复。

npm scripts 使用技巧 ​

这里我们简单讲解两个常见场景,以此介绍 npm scripts 的关键使用技巧。

传递参数

任何命令脚本,都需要进行参数传递。在 npm scripts 中,可以使用--标记参数。比如下面代码:

java
$ webpack --profile --json > stats.json

另外一种传参的方式是通过 package.json,比如下面代码:

java
{
	// ...
  "scripts": {
    "build": "webpack --profile --json > stats.json",
  }
  // ...
}

串行/并行执行脚本

在一个项目中,任意 npm scripts 可能彼此之间都有会依赖关系,我们可以通过&&符号来串行执行脚本。比如下面代码:

java
$ npm run pre.js && npm run post.js

如果需要并行执行,可以使用&符号,如下代码:

java
npm run scriptA.js & npm run scriptB.js

这两种串行/并行执行方式其实是 Bash 的能力,社区里也封装了很多串行/并行执行脚本的公共包供开发者选用,比如:npm-run-all 就是一个常用的例子。

最后的提醒

最后,特别强调两点注意事项。

首先,npm scripts 可以和 git-hooks 相结合 ,为项目提供更顺畅、自然的能力。比如 pre-commit、husky、lint-staged 这类工具,支持 Git Hooks 各种种类,在必要的 git 操作节点,执行我们的 npm scripts。

同时需要注意的是,我们编写的 npm scripts 应该考虑不同操作系统上兼容性的问题 ,因为 npm scripts 理论上在任何系统都应该 just work。社区为我们提供了很多跨平台的方案,比如 un-script-os 允许我们针对不同平台进行不同的定制化脚本,如下代码:

java
{
  // ...
  "scripts": {
    // ...
    "test": "run-script-os",
    "test:win32": "echo 'del whatever you want in Windows 32/64'",
    "test:darwin:linux": "echo 'You can combine OS tags and rm all the things!'",
    "test:default": "echo 'This will run on any platform that does not have its own script'"
    // ...
  },
  // ...
}

再比如,更加常见的https://www.npmjs.com/package/cross-env,可以为我们自动在不同的平台设置环境变量。

好了,接下来我们从一个实例出发,打造一个 lucas-scripts,实践操作 npm scripts,同时丰富我们的工程化经验。

打造一个 lucas-scripts ​

lucas-scripts 其实是我设想的一个 npm scripts 插件集合,通过 Monorepo 风格的项目,借助 npm 抽象"自己常用的"npm scripts 脚本,以在多个项目中达到复用的目的。

其设计思想其实源于 Kent C.Dodds(https://kentcdodds.com/blog)的:Tools without config 思想。事实上,在 PayPal 公司内部,有一个 paypal-scripts(未开源),借助 paypal-scripts 的设计思路,就有了 lucas-scripts。我们先从设计思想上分析,不管是 paypal-scripts 还是 lucas-scripts,它们主要解决了哪类问题。

谈到前端开发,各种工具配置着实令人头大,而对于一个企业级团队来说,维护统一的企业级工具配置或设计,对工程效率的提升至关重要。这些工具包括但不限于:

  • 测试工具及方案

  • Client 端打包工具及方案

  • Linting 工具及方案

  • Babel 工具及方案

等等,这些工具及方案的背后往往是烦琐的配置,同时,这些配置的设计却至关重要。比如我们的 Webpack 可以工作,但是它的配置设计却经常经不起推敲;Linters 经常过时,跟不上语言的发展,使得我们的构建流程无比脆弱而容易中断。

在此背景下,lucas-scripts 负责维护和掌管工程基建中的种种工具及方案,同时它的使命不仅仅是 Bootstrap 一个项目,而是长期维护基建方案,可以随时升级,随时插拔。

这很类似我们熟悉的 create-react-app,create-react-app 可以帮助 React 开发者迅速启动一个项目,它以黑盒的方式维护了 Webpack 构建以及 Jest 测试、Eslint 等能力。开发者只需要使用 react-scripts 就能够满足构建和测试等需求,开发者只需要关心业务开发。lucas-scripts 的理念相同:开发者只需要使用 lucas-scripts,就可以使用开箱即用的各类型 npm scripts 插件,npm scripts 插件提供基础工具的配置和方案设计。

但需要注意的是,create-react-app 官方并不允许开发者自定义这些工具的配置及方案设计,而我们的 lucas-scripts 理应实现更灵活的配置能力。如何做到开发者自定义配置的能力呢?设计上,我们支持开发者在项目中添加 .babelrc或在项目的 package.json 中添加相应的 babel 配置项,lucas-scripts 在运行时读取这些信息,并采用开发者自定义的配置即可。

比如,我们支持项目中 package.json 配置:

java
{
  "babel": {
    "presets": ["lucas-scripts/babel"],
    "plugins": ["glamorous-displayname"]
  }
}

上述代码可以做到使用 lucas-scripts 定义的 Babel 预设,同时支持开发者使用名为 glamorous-displayname 的 Babel 插件。

下面,我们就以 lucas-scripts 中封装的 Babel 配置进行详细讲解。

在使用 lucas-scripts 的 Babel 方案时,我们提供了默认的一套 Babel 设计方案,具体代码如下:

java
// 使用 browserslist 包进行降级目标设置
const browserslist = require('browserslist')
const semver = require('semver')
// 几个工具包,这里不再一一展开
const {
  ifDep,
  ifAnyDep,
  ifTypescript,
  parseEnv,
  appDirectory,
  pkg,
} = require('../utils')
// 获取环境变量
const {BABEL_ENV, NODE_ENV, BUILD_FORMAT} = process.env
// 几个关键变量的判断
const isTest = (BABEL_ENV || NODE_ENV) === 'test'
const isPreact = parseEnv('BUILD_PREACT', false)
const isRollup = parseEnv('BUILD_ROLLUP', false)
const isUMD = BUILD_FORMAT === 'umd'
const isCJS = BUILD_FORMAT === 'cjs'
const isWebpack = parseEnv('BUILD_WEBPACK', false)
const isMinify = parseEnv('BUILD_MINIFY', false)
const treeshake = parseEnv('BUILD_TREESHAKE', isRollup || isWebpack)
const alias = parseEnv('BUILD_ALIAS', isPreact ? {react: 'preact'} : null)
// 是否使用 @babel/runtime
const hasBabelRuntimeDep = Boolean(
  pkg.dependencies && pkg.dependencies['@babel/runtime'],
)
const RUNTIME_HELPERS_WARN =
  'You should add @babel/runtime as dependency to your package. It will allow reusing "babel helpers" from node_modules rather than bundling their copies into your files.'
// 强制使用 @babel/runtime,以减少编译后代码体积等
if (!treeshake && !hasBabelRuntimeDep && !isTest) {
  throw new Error(RUNTIME_HELPERS_WARN)
} else if (treeshake && !isUMD && !hasBabelRuntimeDep) {
  console.warn(RUNTIME_HELPERS_WARN)
}
// 获取用户的 browserslist 配置,默认给一个 ie 10 和 ios 7 配置
const browsersConfig = browserslist.loadConfig({path: appDirectory}) || [
  'ie 10',
  'ios 7',
]
// 获取 envTargets
const envTargets = isTest
  ? {node: 'current'}
  : isWebpack || isRollup
  ? {browsers: browsersConfig}
  : {node: getNodeVersion(pkg)}

// @babel/preset-env 配置,默认使用以下配置项
const envOptions = {modules: false, loose: true, targets: envTargets}
// babel 默认方案
module.exports = () => ({
  presets: [
    [require.resolve('@babel/preset-env'), envOptions],
    // 如果存在 react 或 preact 依赖,则补充 @babel/preset-react
    ifAnyDep(
      ['react', 'preact'],
      [
        require.resolve('@babel/preset-react'),
        {pragma: isPreact ? ifDep('react', 'React.h', 'h') : undefined},
      ],
    ),
    // 如果使用 Typescript,则补充 @babel/preset-typescript
    ifTypescript([require.resolve('@babel/preset-typescript')]),
  ].filter(Boolean),
  plugins: [
    [
    	// 强制使用 @babel/plugin-transform-runtime 
      require.resolve('@babel/plugin-transform-runtime'),
      {useESModules: treeshake && !isCJS},
    ],
    // 使用 babel-plugin-macros
    require.resolve('babel-plugin-macros'),
    // 别名配置
    alias
      ? [
          require.resolve('babel-plugin-module-resolver'),
          {root: ['./src'], alias},
        ]
      : null,
    // 是否编译为 UMD 规范
    isUMD
      ? require.resolve('babel-plugin-transform-inline-environment-variables')
      : null,
    // 强制使用 @babel/plugin-proposal-class-properties
    [require.resolve('@babel/plugin-proposal-class-properties'), {loose: true}],
    // 是否进行压缩
    isMinify
      ? require.resolve('babel-plugin-minify-dead-code-elimination')
      : null,
    treeshake
      ? null
      : require.resolve('@babel/plugin-transform-modules-commonjs'),
  ].filter(Boolean),
})
// 获取 node 版本
function getNodeVersion({engines: {node: nodeVersion = '10.13'} = {}}) {
  const oldestVersion = semver
    .validRange(nodeVersion)
    .replace(/[>=<|]/g, ' ')
    .split(' ')
    .filter(Boolean)
    .sort(semver.compare)[0]
  if (!oldestVersion) {
    throw new Error(
      `Unable to determine the oldest version in the range in your package.json at engines.node: "${nodeVersion}". Please attempt to make it less ambiguous.`,
    )
  }
  return oldestVersion
}

通过上面代码,我们将 Babel 方案强制使用了一些最佳实践,比如使用了特定 loose、moudles 设置的 @babel/preset-env 配置项,使用了 @babel/plugin-transform-runtime,使用了 @babel/plugin-proposal-class-properties,各种原理我们已经在 07 讲《梳理混乱的 Babel,不再被编译报错困扰》中有所涉及。

了解了 Babel 的设计方案,我们在使用 lucas-scripts 时是如何调用设计方案并执行 Babel 编译的呢?我们看看相关逻辑源码,如下:

java
const path = require('path')
// 支持使用 DEFAULT_EXTENSIONS,具体见:https://www.babeljs.cn/docs/babel-core#default_extensions
const {DEFAULT_EXTENSIONS} = require('@babel/core')
const spawn = require('cross-spawn')
const yargsParser = require('yargs-parser')
const rimraf = require('rimraf')
const glob = require('glob')
// 工具方法
const {
  hasPkgProp,
  fromRoot,
  resolveBin,
  hasFile,
  hasTypescript,
  generateTypeDefs,
} = require('../../utils')
let args = process.argv.slice(2)
const here = p => path.join(__dirname, p)
// 解析命令行参数
const parsedArgs = yargsParser(args)
// 是否使用 lucas-scripts 提供的默认 babel 方案
const useBuiltinConfig =
  !args.includes('--presets') &&
  !hasFile('.babelrc') &&
  !hasFile('.babelrc.js') &&
  !hasFile('babel.config.js') &&
  !hasPkgProp('babel')

// 使用 lucas-scripts 提供的默认 babel 方案,读取相关配置
const config = useBuiltinConfig
  ? ['--presets', here('../../config/babelrc.js')]
  : []
// 是否使用 babel-core 所提供的 DEFAULT_EXTENSIONS 能力
const extensions =
  args.includes('--extensions') || args.includes('--x')
    ? []
    : ['--extensions', [...DEFAULT_EXTENSIONS, '.ts', '.tsx']]
// 忽略某些文件夹,不进行编译
const builtInIgnore = '**/__tests__/**,**/__mocks__/**'
const ignore = args.includes('--ignore') ? [] : ['--ignore', builtInIgnore]
// 是否复制文件
const copyFiles = args.includes('--no-copy-files') ? [] : ['--copy-files']
// 是否使用特定的 output 文件夹
const useSpecifiedOutDir = args.includes('--out-dir')
// 默认的 output 文件夹名为 dist
const builtInOutDir = 'dist'
const outDir = useSpecifiedOutDir ? [] : ['--out-dir', builtInOutDir]
const noTypeDefinitions = args.includes('--no-ts-defs')
// 编译开始前,是否先清理 output 文件夹
if (!useSpecifiedOutDir && !args.includes('--no-clean')) {
  rimraf.sync(fromRoot('dist'))
} else {
  args = args.filter(a => a !== '--no-clean')
}
if (noTypeDefinitions) {
  args = args.filter(a => a !== '--no-ts-defs')
}
// 入口编译流程
function go() {
	// 使用 spawn.sync 方式,调用 @babel/cli 
  let result = spawn.sync(
    resolveBin('@babel/cli', {executable: 'babel'}),
    [
      ...outDir,
      ...copyFiles,
      ...ignore,
      ...extensions,
      ...config,
      'src',
    ].concat(args),
    {stdio: 'inherit'},
  )
  // 如果 status 不为 0,返回编译状态
  if (result.status !== 0) return result.status
  const pathToOutDir = fromRoot(parsedArgs.outDir || builtInOutDir)
	// 使用 Typescript,并产出 type 类型
  if (hasTypescript && !noTypeDefinitions) {
    console.log('Generating TypeScript definitions')
    result = generateTypeDefs(pathToOutDir)
    console.log('TypeScript definitions generated')
    if (result.status !== 0) return result.status
  }
  // 因为 babel 目前仍然会拷贝一份需要忽略不进行编译的文件,所以我们将这些文件手动进行清理
  const ignoredPatterns = (parsedArgs.ignore || builtInIgnore)
    .split(',')
    .map(pattern => path.join(pathToOutDir, pattern))
  const ignoredFiles = ignoredPatterns.reduce(
    (all, pattern) => [...all, ...glob.sync(pattern)],
    [],
  )
  ignoredFiles.forEach(ignoredFile => {
    rimraf.sync(ignoredFile)
  })
  return result.status
}
process.exit(go())

通过上面代码,我们就可以将 lucas-script 的 Babel 方案融会贯通了。

整体设计思路我 fork 了 https://github.com/kentcdodds/kcd-scripts,并进行部分优化和改动,你可以在https://github.com/HOUCe/kcd-scripts中进一步学习。

总结 ​

这一讲我们先介绍了 npm scripts 的重要性,接着分析了 npm scripts 的原理;后半部分,从实践出发,分析了 lucas-scripts 的设计理念,以此进一步巩固 npm scripts 相关知识。

本讲内容总结如下:

说到底,npm scripts 就是一个 Shell,我们以前端开发者所熟悉的 Node.js 来实现 npm scripts,当然这还不够。事实上,npm scripts 的背后是对一整套工程化体系的理解,比如我们需要通过 npm scripts 来抽象 Babel 方案、抽象 Rollup 方案等。相信通过这一讲的学习,你会有所收获。

下一讲,我们将深入工程化体系的一个重点细节------自动化代码检查,并反过来使用 lucas-scripts 再实现一套智能的代码 Lint 脚本,请你继续学习。