构建包设计

构建配置抽离成 npm 包的意义

通用性

  • 业务开发者无需关注构建配置
  • 统一团队构建脚本

可维护性

  • 构建配置合理的拆分
  • README 文档、ChangeLog 文档等

质量

  • 冒烟测试、单元测试、测试覆盖率
  • 持续集成

构建配置管理的可选方案

  1. 通过多个配置文件管理不同环境的构建,webpack —config 参数进行控制。
  2. 将构建配置设计成一个库,比如:hjs-webpack、Neutrino、webpack-blocks 。
  3. 抽成一个工具进行管理,比如:create-react-app, kyt, nwb 。
  4. 将所有的配置放在一个文件,通过 —env 参数控制分支选择。

下面介绍的是 1、2 两种方案。适用于规模 10 - 20 人左右的团队。

构建配置包设计

通过多个配置文件管理不同环境的 webpack 配置

  • 基础配置:webpack.base.js
  • 开发环境:webpack.dev.js
  • 生产环境:webpack.prod.js
  • SSR环境:webpack.ssr.js
    。。。。。。

抽离成一个 npm 包统一管理

  • 规范:Git commit日志、README、ESLint 规范、Semver 规范
  • 质量:冒烟测试、单元测试、测试覆盖率和 CI

通过 webpack-merge 组合配置

合并配置。

  1. module.exports = merge(baseConfig, devConfig);

功能模块设计和目录结构

功能模块设计

design.png

目录结构设计

lib 放置源代码。

test 放置测试代码。

folder_design.png

配置

npm i webpack-merge -D

webpack.base.js

const path = require('path');
const glob = require('glob');

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const FriendlyErrorsWebpaclPlugin = require('friendly-errors-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const projectRoot = process.cwd();

const setMPA = () => {
  const entry = {};
  const htmlWebpackPlugins = [];

  const entryFiles = glob.sync(path.join(projectRoot, './src/pages/*/index.js'));

  entryFiles.map((entryFile) => {
    const match = entryFile.match(/src\/pages\/(.*)\/index\.js/);
    const pageName = match && match[1];

    entry[pageName] = entryFile;

    return htmlWebpackPlugins.push(
      new HtmlWebpackPlugin({
        template: path.resolve(projectRoot, `src/pages/${pageName}/index.html`),
        filename: `${pageName}.html`,
        chunks: [pageName],
        excludeChunks: ['node_modules'],
        inject: true,
        minify: {
          html5: true,
          collapseWhitespace: true,
          preserveLineBreaks: false,
          minifyCSS: true,
          minifyJS: true,
          removeComments: false,
        },
      }),
    );
  });

  return {
    entry,
    htmlWebpackPlugins,
  };
};

const { entry, htmlWebpackPlugins } = setMPA();

module.exports = {
  entry,
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          'babel-loader',
        ],
      },
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
        ],
      },
      {
        test: /\.less$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'less-loader',
          'postcss-loader',
          {
            loader: 'px2rem-loader',
            options: {
              remUnit: 75, // 1rem = 75px、适合 750 设计稿
              remPrecesion: 8, // px => rem 小数点的位数
            },
          },
        ],
      },
      {
        test: /\.(png|jpg|gif|jpeg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              name: '[name]_[hash:8].[ext]',
              limit: 10240,
            },
          },
        ],
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: '[name]_[hash:8].[ext]',
            },
          },
        ],
      },
    ],
  },
  stats: 'errors-only',
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css',
    }),
    new CleanWebpackPlugin(),
    new FriendlyErrorsWebpaclPlugin(),
    function errorPlugin() {
      this.hooks.done.tap('done', (stats) => {
        if (
          stats.compilation.errors
          && stats.compilation.errors.length
          && process.argv.indexOf('--watch') === -1
        ) {
          console.log(stats.compilation.errors); // eslint-disable-line
          process.exit(1);
        }
      });
    },
  ].concat(htmlWebpackPlugins),
};

webpack.dev.js

const path = require('path');

const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base');

const projectRoot = process.cwd();

const devConfig = {
  mode: 'devlopment',
  output: {
    filename: '[name].js',
    path: path.resolve(projectRoot, 'dist'),
  },
  devServer: {
    contentBase: './dist',
    hot: true,
    stats: 'errors-only',
  },
  devtool: 'source-map',
};

module.exports = merge(baseConfig, devConfig);

webpack.prod.js

const path = require('path');
const glob = require('glob');

const { merge } = require('webpack-merge');
const cssnano = require('cssnano');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');
const baseConfig = require('./webpack.base');

const projectRoot = process.cwd();

const setMPA = () => {
  const htmlWebpackExternalsPlugins = [];

  const entryFiles = glob.sync(path.join(projectRoot, './src/pages/*/index.js'));

  entryFiles.map((entryFile) => {
    const match = entryFile.match(/src\/pages\/(.*)\/index\.js/);
    const pageName = match && match[1];

    return htmlWebpackExternalsPlugins.push(
      new HtmlWebpackExternalsPlugin({
        externals: [
          {
            module: 'react',
            entry: 'https://11.url.cn/now/lib/16.2.0/react.min.js',
            global: 'React',
          },
          {
            module: 'react-dom',
            entry: 'https://11.url.cn/now/lib/16.2.0/react-dom.min.js',
            global: 'ReactDOM',
          },
        ],
        files: [`${pageName}.html`],
      }),
    );
  });

  return {
    htmlWebpackExternalsPlugins,
  };
};

const { htmlWebpackExternalsPlugins } = setMPA();

const prodConfig = {
  mode: 'production',
  output: {
    filename: '[name]_[chunkhash:8].js',
    path: path.resolve(projectRoot, 'dist'),
  },
  optimization: {
    splitChunks: {
      minSize: 0,
      cacheGroups: {
        commons: {
          name: 'commons',
          chunks: 'all',
          minChunks: 2,
        },
      },
    },
  },
  plugins: [
    new OptimizeCSSAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: cssnano,
    }),
  ].concat(htmlWebpackExternalsPlugins),
};

module.exports = merge(baseConfig, prodConfig);

webpack.ssr.js

const path = require('path');
const glob = require('glob');

const { merge } = require('webpack-merge');
const cssnano = require('cssnano');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');
const baseConfig = require('./webpack.base');

const projectRoot = process.cwd();

const setMPA = () => {
  const htmlWebpackExternalsPlugins = [];

  const entryFiles = glob.sync(path.join(projectRoot, './src/pages/*/index.js'));

  entryFiles.map((entryFile) => {
    const match = entryFile.match(/src\/pages\/(.*)\/index\.js/);
    const pageName = match && match[1];

    return htmlWebpackExternalsPlugins.push(
      new HtmlWebpackExternalsPlugin({
        externals: [
          {
            module: 'react',
            entry: 'https://11.url.cn/now/lib/16.2.0/react.min.js',
            global: 'React',
          },
          {
            module: 'react-dom',
            entry: 'https://11.url.cn/now/lib/16.2.0/react-dom.min.js',
            global: 'ReactDOM',
          },
        ],
        files: [`${pageName}.html`],
      }),
    );
  });

  return {
    htmlWebpackExternalsPlugins,
  };
};

const { htmlWebpackExternalsPlugins } = setMPA();

const prodConfig = {
  mode: 'production',
  output: {
    filename: '[name]_[chunkhash:8].js',
    path: path.resolve(projectRoot, 'dist'),
  },
  module: {
    rules: [
      {
        test: /\.(css|less)$/,
        use: 'ignore-loader',
      },
    ],
  },
  optimization: {
    splitChunks: {
      minSize: 0,
      cacheGroups: {
        commons: {
          name: 'commons',
          chunks: 'all',
          minChunks: 2,
        },
      },
    },
  },
  plugins: [
    new OptimizeCSSAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: cssnano,
    }),
  ].concat(htmlWebpackExternalsPlugins),
};

module.exports = merge(baseConfig, prodConfig);

.postcss.config.js

module.exports = {
  plugins: [
    require('autoprefixer')({
      "overrideBrowserslist": [
        "defaults",
        "not ie < 11",
        "last 2 versions",
        "> 1%",
        "iOS 7",
        "last 3 iOS versions"
      ]
    })
  ]
}

.gitignore

node_modules/
logs/
dist/

使用 ESLint 规范构建脚本

使用 eslint-config-airhnb-base。

eslint —fix 可以自动处理空格。

npm i eslint eslint-plugin-import babel-eslint eslint-config-airbnb-base -D

.eslintrc.js

module.exports = {
  "parser": "babel-eslint",
  "extends": "airbnb-base",
  "env": {
    "browser": true,
    "node": true
  }
}

package.json

{
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "eslint": "eslint ./lib --fix"
  },
  "devDependencies": {
    "babel-eslint": "^10.1.0",
    "eslint": "^7.21.0",
    "eslint-config-airbnb-base": "^14.2.1",
    "eslint-plugin-import": "^2.22.1"
  },
  "dependencies": {
    "@babel/core": "^7.12.17",
    "@babel/plugin-syntax-dynamic-import": "^7.8.3",
    "@babel/preset-env": "^7.12.17",
    "@babel/preset-react": "^7.12.13",
    "autoprefixer": "^10.2.4",
    "babel-loader": "^8.2.2",
    "clean-webpack-plugin": "^3.0.0",
    "css-loader": "^5.0.2",
    "cssnano": "^4.1.10",
    "express": "^4.17.1",
    "file-loader": "^6.2.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "glob": "^7.1.6",
    "html-webpack-externals-plugin": "^3.8.0",
    "html-webpack-plugin": "^5.2.0",
    "less": "^4.1.1",
    "less-loader": "^8.0.0",
    "mini-css-extract-plugin": "^1.3.8",
    "optimize-css-assets-webpack-plugin": "^5.0.4",
    "postcss-loader": "^5.0.0",
    "px2rem-loader": "^0.1.9",
    "raw-loader": "^0.5.1",
    "lib-flexible": "^0.3.2",
    "style-loader": "^2.0.0",
    "url-loader": "^4.1.1",
    "webpack": "^5.24.2",
    "webpack-cli": "^4.5.0",
    "webpack-merge": "^5.7.3"
  }
}

冒烟测试和实际运用

冒烟测试 (smoke testing) 是指对提交测试的软件在进行详细深入的测试之前而进行的预测试。

这种预测试的主要目的是暴露导致软件需重新发布的基本功能失效等严重问题。

冒烟测试执行

构建是否成功 。

每次构建完成 build 目录是否有内容输出 :

  • 是否有 JS、CSS 等静态资源文件
  • 是否有 HTML 文件

判断构建是否成功

npm i rimraf -D
const path = require('path');
const webpack = require('webpack');
const rimraf = require('rimraf');

process.chdir(path.join(__dirname, 'template'));

rimraf('./dist', () => {
  const prodConfig = require('../../lib/webpack.prod');

  webpack(prodConfig, (err, stats) => {
    if (err) {
      console.error(err);
      process.exit(2);
    }

    console.log(stats.toString({
      colors: true,
      modules: false,
      children: false
    }));
  });
});

判断基本功能是否正常

npm i mocha -D
npm i glob-all -D

编写 mocha 测试用例 :

  • 是否有 JS、CSS 等静态资源文件
  • 是否有 HTML 文件
const glob = require('glob-all');

describe('Checking generated css files', () => {
  it ('should generate css js files', (done) => {
    const files = glob.sync([
      './dist/index_*.js',
      './dist/index_*.css',
      './dist/search_*.js',
      './dist/search_*.css'
    ]);

    if (files.length > 0) {
      done();
    } else {
      throw new Error('no css js files generated');
    }
  });
});

describe('Checking generated html files', () => {
  it ('should generate html files', (done) => {
    const files = glob.sync([
      './dist/index.html',
      './dist/search.html'
    ]);

    if (files.length > 0) {
      done();
    } else {
      throw new Error('no html files generated');
    }
  });
});
const path = require('path');
const webpack = require('webpack');
const rimraf = require('rimraf');
const Mocha = require('mocha');

const mocha = new Mocha({
  timeout: '10000ms'
});

process.chdir(path.join(__dirname, 'template'));

rimraf('./dist', () => {
  const prodConfig = require('../../lib/webpack.prod');

  webpack(prodConfig, (err, stats) => {
    if (err) {
      console.error(err);
      process.exit(2);
    }

    console.log(stats.toString({
      colors: true,
      modules: false,
      children: false
    }));

    console.log('Webpack build success, begin run test.');

    mocha.addFile(path.join(__dirname, 'html-test.js'));
    mocha.addFile(path.join(__dirname, 'css-js-test.js'));

    mocha.run();
  });
});

测试

node .\test\smoke\index.js

单元测试和覆盖率

unit-test.png

编写单元测试用例

技术选型:Mocha + Chai。

测试代码:describe, it, except。

测试命令:mocha add.test.js。

单元测试接入

npm i mocha -D

安装断言库支持

npm i assert -D

安装测试覆盖率支持

npm i nyc -D

新建 test 目录,并增加 xxx.test.js 测试文件

test/index.js

const path = require('path');

process.chdir(path.join(__dirname, 'smoke/template'));

describe('webpack-desigin test case', () => {
  require('./unit/webpack-base-test');
  require('./unit/webpack-prod-test');
});

test/unit/webpack-base-test.js

const assert = require('assert');

describe('webpack.base.js test case', () => {
  const baseConfig = require('../../lib/webpack.base');

  console.log(baseConfig);

  it ('enrty', () => {
    assert.deepStrictEqual(baseConfig.entry.index, 'F:/js/review/webpack/webpack_tencent/webpack-design/test/smoke/template/src/pages/index/index.js');
    assert.deepStrictEqual(baseConfig.entry.search, 'F:/js/review/webpack/webpack_tencent/webpack-design/test/smoke/template/src/pages/search/index.js');
  });
});

在 package.json 中的 scripts 字段增加 test 命令

"scripts": {
  "test": "./node_modules/.bin/_mocha",
  "test:cover": "nyc ./node_modules/bin/_mocha",
}

单元测试命令

npm run test

测试覆盖率命令

npm run test:cover

持续集成和 Travis CI

持续集成的作用

  • 快速发现错误。
  • 防止分支大幅偏离主干。

代码集成到主干之前,必须通过自动化测试。只有有一个测试用例失败,就不能集成。

GitHub 最流行的 CI

github_ci.png

接入 Travis CI

  1. https://travis-ci.org/ 使用 GitHub 账号登录
  2. https://travis-ci.org/account/repositories 为项目开启
  3. 项目根目录下新增 .travis.yml

travis.yml 文件内容

install 安装项目依赖 。

script 运行测试用例。

language: node_js

sudo: false

cache:
  apt: true
  directories:
    - node_modules

node_js: stable

install:
  - npm install -D
  - cd ./test/smoke/template
  - npm install -D
  - cd ../../../

scripts:
  - npm test

发布到 npm

添加用户: npm adduser

升级版本

  • 升级补丁版本号:npm version patch
  • 升级小版本号:npm version minor
  • 升级大版本号:npm version major

发布版本:npm publish

Git Commit 规范和 changelog 生成

良好的 Git commit 规范优势:

  • 加快 Code Review 的流程
  • 根据 Git Commit 的元数据生成 Changelog
  • 后续维护者可以知道 Feature 被修改的原因

技术方案

git.png

提交格式要求

commit_rule.png

本地开发阶段增加 precommit 钩子

安装 husky

npm i husky -D

通过 commitmsg 钩子校验信息

"scripts": {
  "commitmsg": "validate-commit-msg",
  "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0"
},
"devDependencies": {
  "valiate-commit-msg": "^2.11.1",
  "conventional-changelog-cli": "^1.2.0",
  "husky": "^0.13.1"
}

语义化版本(Semantic Versioning)规范格式

开源项目版本信息案例。

软件的版本通常由三位组成,形如:X.Y.Z 。

版本是严格递增的,此处是:16.2.0 -> 16.3.0 -> 16.3.1 。

在发布重要版本时,可以发布alpha, rc 等先行版本 。

alpha和rc等修饰版本的关键字后面可以带上次数和meta信息 。

react_version.png

遵守 semver 规范的优势

react_semver.png

语义化版本(Semantic Versioning)规范格式

主版本号:当你做了不兼容的 API 修改。

次版本号:当你做了向下兼容的功能性新增。

修订号:当你做了向下兼容的问题修正。

先行版本号

先行版本号可以作为发布正式版之前的版本,格式是在修订版本号后面加上一个连接号(-),再加上一连串以点(.)分割的标识符,标识符可以由英文、数字和连接号([0-9A-Za-z-])组成。

  • alpha:是内部测试版,一般不向外部发布,会有很多 Bug。一般只有测试人员使用。
  • beta:也是测试版,这个阶段的版本会一直加入新的功能。在 Alpha 版之后推出。
  • rc:Release Candidate) 系统平台上就是发行候选版本。RC 版不会再加入新的功能了,主
    要着重于除错。