webpack 构建配置包
构建包设计
构建配置抽离成 npm 包的意义
通用性
- 业务开发者无需关注构建配置
- 统一团队构建脚本
可维护性
- 构建配置合理的拆分
- README 文档、ChangeLog 文档等
质量
- 冒烟测试、单元测试、测试覆盖率
- 持续集成
构建配置管理的可选方案
- 通过多个配置文件管理不同环境的构建,webpack --config 参数进行控制。
- 将构建配置设计成一个库,比如:hjs-webpack、Neutrino、webpack-blocks 。
- 抽成一个工具进行管理,比如:create-react-app, kyt, nwb 。
- 将所有的配置放在一个文件,通过 --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 组合配置
合并配置。
module.exports = merge(baseConfig, devConfig);
功能模块设计和目录结构
功能模块设计
目录结构设计
lib 放置源代码。
test 放置测试代码。
配置
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
单元测试和覆盖率
编写单元测试用例
技术选型: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
接入 Travis CI
- https://travis-ci.org/ 使用 GitHub 账号登录
- 在 https://travis-ci.org/account/repositories 为项目开启
- 项目根目录下新增 .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 被修改的原因
技术方案
提交格式要求
本地开发阶段增加 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信息 。
遵守 semver 规范的优势
语义化版本(Semantic Versioning)规范格式
主版本号:当你做了不兼容的 API 修改。
次版本号:当你做了向下兼容的功能性新增。
修订号:当你做了向下兼容的问题修正。
先行版本号
先行版本号可以作为发布正式版之前的版本,格式是在修订版本号后面加上一个连接号(-),再加上一连串以点(.)分割的标识符,标识符可以由英文、数字和连接号([0-9A-Za-z-])组成。
- alpha:是内部测试版,一般不向外部发布,会有很多 Bug。一般只有测试人员使用。
- beta:也是测试版,这个阶段的版本会一直加入新的功能。在 Alpha 版之后推出。
- rc:Release Candidate) 系统平台上就是发行候选版本。RC 版不会再加入新的功能了,主
要着重于除错。