前端利器躬行記(7)——自製腳手架
在學習了Webpack基礎後,查看別人寫好的腳手架總是會一頭霧水,後面就上網查各種資料,一邊參考一邊修改,整出了一套簡易的腳手架(已上傳至GiuHub和npm上),借鑒了Create React App(CRA)的目錄結構(如下所示),並做成了命令行工具(已上傳至GiuHub和npm上)。
├── pwu --------------------------------------- 腳手架示例 │ ├── config -------------------------------- webpack配置目錄 │ ├── ├── jest ------------------------------ Jest測試的配置目錄 │ ├── ├── webpack.base.config.js ------------ 通用配置 │ ├── ├── webpack.dev.config.js ------------- 開發環境配置 │ ├── ├── webpack.prod.config.js ------------ 生產環境配置 │ ├── bin ----------------------------------- 命令行工具 │ ├── ├── pwu.js ---------------------------- 命令文件 │ ├── dist ---------------------------------- 輸出目錄 │ ├── ├── css ------------------------------- 樣式 │ ├── ├── img ------------------------------- 圖像 │ ├── ├── js -------------------------------- 腳本 │ ├── public -------------------------------- 模板目錄 │ ├── ├── index.html ------------------------ 模板頁面 │ ├── src ----------------------------------- 源文件目錄 │ ├── ├── __tests__ ------------------------- 測試目錄 │ ├── ├── component ------------------------- 組件目錄 │ ├── ├── font ------------------------------ 字體目錄 │ ├── ├── img ------------------------------- 圖像目錄 │ ├── ├── index.js -------------------------- 入口文件 │ ├── ├── index.scss ------------------------ 全局樣式 │ ├── package.json -------------------------- 管理依賴的包 │ ├── package-lock.json --------------------- 管理包的版本號和來源 │ ├── postcss.config.js --------------------- 後處理器配置文件 │ ├── tsconfig.json ------------------------- TypeScript配置文件 │ ├── .eslintrc ----------------------------- ESLint配置文件 │ ├── .eslintignore ------------------------- ESLint忽略的文件和目錄 │ ├── .gitignore ---------------------------- Git忽略的文件和目錄
一、通用配置
1)入口和出口
在通用配置中包含兩個環境都需要的參數,例如入口和出口,如下所示。path是Node.js中的路徑模塊path.resolve()用於解析絕對路徑,__dirname可讀取當前模塊的目錄名。
const path = require("path"); module.exports = { entry: { index: "./src/index.js" }, output: { path: path.resolve(__dirname, "../dist"), publicPath: "/" } };
publicPath指定靜態資源的基礎路徑,公式如下。
靜態資源最終路徑 = output.publicPath + 加載器或插件的配置路徑。
假設html元素的背景是一條相對路徑,那麼最後生成的路徑將會是「/img/lake.png」,其中配置的輸出目錄是「img」。
html { background: url("../../../public/img/lake.png") no-repeat; } /* 生成的背景路徑 */ html { background: url("/img/lake.png") no-repeat; }
在CRA的webpack.config.js配置文件中,也有對publicPath的配置,如下所示,生產和開發環境會有對應的值。
const publicPath = isEnvProduction ? paths.servedPath : isEnvDevelopment && '/';
2)加載器
在加載器中,會添加腳本(babel-loader)、樣式(css-loader、postcss-loader和sass-loader)、圖像(url-loader)以及字體(file-loader)。
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); module.exports = { module: { rules: [ { test: /\.(js|jsx)$/, use: "babel-loader", exclude: /node_modules/ }, { test: /\.(sass|scss)$/, use: [ MiniCssExtractPlugin.loader, "css-loader", "postcss-loader", "sass-loader" ] }, { test: /\.(jpg|png|gif)$/, use: { loader: "url-loader", options: { name: "[name].[ext]", outputPath: "img/", limit: 8192 } } }, { test: /\.(eot|ttf|svg|woff|woff2)$/, use: { loader: "file-loader", options: { name: "[name]_[hash].[ext]", outputPath: "font/" } } } ] } };
在解析樣式的配置中,使用了四個加載器,後聲明的先執行。Babel的配置信息寫到了package.json文件中,新建一個babel字段,useBuiltIns的值為usage,表示自動加載源碼所需的Polyfill。
"babel": { "presets": [ [ "@babel/preset-env", { "targets": { "ie": 11, "chrome": 49 }, "corejs": "2", "useBuiltIns": "usage" } ], "@babel/preset-react" ] }
postcss-loader又稱為CSS後處理器,常用來提升瀏覽器兼容性,它有許多配套插件(例如autofix),這些插件的配置被放在單獨的postcss.config.js文件中,如下所示。
module.exports = { plugins: [require("autoprefixer")()] };
在執行時,postcss-loader會建議將瀏覽器的信息放在package.json中,新建一個browserslist字段,如下所示。
"browserslist": [ "last 5 version", ">1%", "ie >=8" ]
MiniCssExtractPlugin.loader引用的是mini-css-extract-plugin插件的加載器,該插件能從JS文件中提取CSS樣式,保存到單獨的CSS文件中。
url-loader和file-loader中的outputPath屬性用於配置輸出目錄。圖像中的limit屬性的值是8192,以位元組為單位,相當於8kb,如果圖像尺寸小於該值,那就將其轉換成Base64格式,嵌入到文件中,減少HTTP請求。字體文件的名稱還會加上唯一標識的hash值,生成的名稱如下所示。
iconfont_7346d960c4ad96f1ea8d5a8834fab00f.ttf
3)插件
MiniCssExtractPlugin插件的作用前面已提過,其中chunkFilename參數會在動態導入時用到。
plugins: [ new MiniCssExtractPlugin({ filename: "css/[name].[hash].css", chunkFilename: "css/[id].[hash].css" }) ]
二、開發環境配置
在開發環境中,需要引入通用配置,再利用webpack-merge合併,如下所示。mode字段用於告知webpack使用相應模式的優化。輸出的文件名稱也包含hash,但只會提取前8個字符。
const base = require('./webpack.base.config.js'); const merge = require('webpack-merge'); module.exports = merge(base, { mode: "development", output: { filename: "js/[name].[hash:8].bundle.js" } });
1)webpack-dev-server
開啟基於Node.js的本地服務器:webpack-dev-server。
devServer: { contentBase: path.resolve(__dirname, "../dist"), open: true, //自動打開瀏覽器 port: 4000, //端口號 compress: true, //啟用gzip壓縮: useLocalIp: true, //使用本機IP hot: true //開啟熱更新 }
2)Source Map
通過Source Map追蹤錯誤或警告在源文件中的原始位置,以便調試,可配置devtool實現,如下所示。
devtool: "source-map"
再添加webpack的HotModuleReplacementPlugin插件,如下所示。
const webpack = require('webpack'); module.exports = merge(base, { plugins: [ new webpack.HotModuleReplacementPlugin() ] });
3)HtmlWebpackPlugin
HtmlWebpackPlugin插件能根據模板生成一個HTML文件,還能自動引入所需的bundle文件。模板文件被放置在public目錄中,如下所示。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"/> <title>腳手架示例</title> </head> <body> <div id="root"></div> </body> </html>
具體配置如下,inject參數用於指定腳本注入位置,例如body元素的底部。
const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = merge(base, { plugins: [ new HtmlWebpackPlugin({ template: path.resolve(__dirname, "../public/index.html"), inject: "body" }) ] });
4)腳本命令
在package.js文件的scripts字段中,聲明了start命令,開啟本地服務器,並實時重載腳本。
{ "scripts": { "start": "webpack-dev-server --config ./config/webpack.dev.config.js" } }
三、生產環境配置
生產環境比較注重性能,因此需要做很多優化配置,例如壓縮、代碼分離等,mode採用production優化模式,如下所示。
module.exports = merge(base, { mode: "production", output: { filename: 'js/[name].[chunkhash:8].bundle.js' } }
1)optimization
首先優化的是代碼分離,也就是將穩定不變的模塊(例如react、react-dom等)抽取成一個單獨的文件,splitChunks參數的配置可參考SplitChunksPlugin插件。
module.exports = merge(base, { optimization: { splitChunks: { chunks: "all", minSize: 30000, maxSize: 0, minChunks: 1, cacheGroups: { vendors: { test: /node_modules/, name: "vendor", enforce: true } } } } });
cacheGroups是優化的關鍵,它是一個緩存組(屬性如下所示),vendors會篩選從node_modules目錄下引入的模塊。
(1)test:一個字符串、正則或函數,模塊的匹配條件。
(2)name:拆分出的chunk(塊)的名字。
(3)enforce:當為true時,可忽略minSize、minChunks、maxAsyncRequests和maxInitialRequests選項。
(4)priority:打包的優先級。
接下來優化的是壓縮,配置到minimizer選項中。UglifyjsWebpackPlugin插件會使用使用UglifyJS去壓縮JavaScript代碼。OptimizeCssAssetsPlugin插件用於壓縮CSS文件。
const UglifyjsWebpackPlugin = require("uglifyjs-webpack-plugin"); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); module.exports = merge(base, { optimization: { minimizer: [ new UglifyjsWebpackPlugin(), new OptimizeCssAssetsPlugin({ assetNameRegExp: /\.css$/g, cssProcessor: require("cssnano"), cssProcessorPluginOptions: { preset: ["default", { discardComments: { removeAll: true } }] }, canPrint: true }) ] } });
2)插件
生產環境也需要模板插件,只不過要配置minify選項,如下所示,去除注釋和空格。
new HtmlWebpackPlugin({ template: path.resolve(__dirname, "../public/index.html"), inject: "body", minify: { removeComments: true, collapseWhitespace: true } })
CleanWebpackPlugin插件可清除輸出目錄中的文件。
const { CleanWebpackPlugin } = require("clean-webpack-plugin"); module.exports = merge(base, { plugins: [ new CleanWebpackPlugin() ] });
偶爾會出現圖45中的錯誤,目前還沒找出原因。
圖 45
3)腳本命令
在package.js文件的scripts字段中,新增build命令,可在本地構建項目。
{ "scripts": { "start": "webpack-dev-server --config ./config/webpack.dev.config.js", "build": "webpack --config ./config/webpack.prod.config.js" } }
四、TypeScript
若要支持TypeScript,那麼必須安裝相應的模塊以及加載器,命令如下。
npm install --save-dev typescript ts-loader
在webpack的通用配置中,添加如下字段,resolve的extensions屬性能夠在引入模塊時不帶擴展。
module.exports = { resolve: { extensions: [".tsx", ".ts", ".js"] }, module: { rules: [ { test: /\.tsx?$/, use: [ 'ts-loader' ], exclude: /node_modules/ } ] } };
還得要添加tsconfig.json配置文件,如下所示,具體的字段說明可以參考官方文檔。
{ "compilerOptions": { "outDir": "./dist/", "noImplicitAny": true, "module": "es6", "target": "es5", "jsx": "react", "allowJs": true } }
由於要使用react和react-dom,因此還需要安裝它們的聲明文件:@types/react和@types/react-dom。並且使用了html-webpack-plugin插件,它的聲明文件(@types/html-webpack-plugin)也得安裝。
都安裝好後,就能在tsx文件中使用JSX語法了,如下所示。
import * as React from 'react'; import * as ReactDOM from 'react-dom'; import App from './component/app/app'; function init() { ReactDOM.render(React.createElement(App, null), document.getElementById('root')); } init();
在webpack中的通用配置中,可添加新的入口文件,如下所示。
module.exports = { entry: { index: "./src/index.ts", index2: "./src/index.tsx" } }
五、ESLint
ESLint是目前流行的靜態代碼檢測工具,它能建立一套代碼規範,保證代碼的一致性,並且還能避免不必要的錯誤。
1)基礎配置
首先需要安裝ESLint和ESLint的加載器,命令如下所示。
npm install --save-dev eslint eslint-loader
然後在通用配置中添加eslint-loader,如下所示。
module.exports = { module: { rules: [ { test: /\.(js|jsx)$/, use: [ 'babel-loader', 'eslint-loader'] , exclude: /node_modules/ }, { test: /\.tsx?$/, use: [ 'ts-loader', 'eslint-loader' ], exclude: /node_modules/ } ] } };
接着在根目錄中創建.eslintrc配置文件,如下所示,rules字段中可記錄各種規則。
{ "rules": { } }
2)規則
現在運行腳手架會報錯(如下所示),因為ESLint不能識別ES6語法。
1:1 error Parsing error: The keyword 'import' is reserved
為了避免該錯誤,需要安裝babel-eslint,並且修改.eslintrc文件。
{ "parser": "babel-eslint", "rules": { } }
下面添加一條簡單的max-len規則(其它規則可參考官方文檔),一行最長200,4個Tab字符的寬度,忽略尾部注釋和行內注釋。
{ "rules": { "max-len": ["warn", 200, 4, { "ignoreComments": true }] } }
當超過該限制時,會顯示下面的警告。
7:1 warning This line has a length of 292. Maximum allowed is 200 max-len
由於使用了React,因此還可以添加React的規則,安裝eslint-plugin-react,並修改.eslintrc文件。
{ "plugins": [ "react" ] }
如果不想自己定義規則,那麼可以直接使用網上開源的規則,例如Airbnb的JavaScript編碼規範。注意,Airbnb的標準包會依賴eslint-plugin-import、eslint-plugin-react和eslint-plugin-jsx-a11y等插件。安裝成功後,再次修改.eslintrc文件。
{ "extends": "airbnb" }
重新運行腳手架,馬上就會出現一大堆錯誤和警告,修改加載器(如下所示),使用–fix參數可以將它們減少很多。
module.exports = { module: { rules: [ { test: /\.(js|jsx)$/, use: [ 'babel-loader', {loader: 'eslint-loader', options: {fix: true}} ], exclude: /node_modules/ }, { test: /\.tsx?$/, use: [ 'ts-loader', {loader: 'eslint-loader', options: {fix: true}} ], exclude: /node_modules/ } ] } };
3)pre-commit
如果使用的版本控制系統是Git,那麼可以在每次提交前檢測ESLint的規則。當檢測失敗時,就能阻止提交。
husky是一個Git鉤子工具,可以防止不良的git commit、git push等操作。lint-staged可對暫存的Git文件執行指定的任務。注意,husky對Node和Git的版本有要求,前者要大於10,後者要大於2.13。
接下來修改package.json文件,添加husky和lint-staged字段,在lint-staged中配置ESLint檢測以及需要檢測的文件後綴。當檢測失敗時,會得到圖46中的提示。
"husky": { "hooks": { "pre-commit": "lint-staged" } }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ "eslint" ] }
圖 46
六、Jest
Jest是Facebook開源的一個測試框架,曾經寫過一篇入門的教程。要將Jest集成到Webpack中,首先得安裝Jest,安裝完成後在package.json文件中添加一條腳本命令(如下所示),執行Jest並打印測試覆蓋率。注意,生成的測試覆蓋率信息默認會保存到coverage目錄中。
"scripts": { "test": "jest --coverage" }
現在執行「npm test」,不會有任何結果,因為還沒寫測試腳本。Jest默認會測試__tests__目錄和名稱中包含spec或test的腳本文件(包括TypeScript文件),並且默認還會忽略node_modules目錄中的文件,配置項如下所示。
testMatch: [ '**/__tests__/**/*.js?(x)', '**/?(*.)(spec|test).js?(x)' ]
testPathIgnorePatterns: ["node_modules"]
在src目錄中新增__tests__目錄,並新建app.js,其代碼如下所示,添加了一個用於演示的測試用例。
describe("my test case", () => { test("one plus one is two", () => { expect(1 + 1).toBe(2); }); });
當在測試用例中使用ES6語法時(例如像下面這樣引入組件),會提示錯誤,此時需要引入babel-jest。而babel-jest在安裝Jest時已經自動下載,因此不必再單獨安裝。
import { App } from '../component/app/app';
在package.json文件定義jest字段,並聲明transform選項,添加下面這條規則,就能避免報錯。
"jest": { "transform": { "^.+\\.js$": "babel-jest" } }
Jest還有一些其它配置,在測試時能發揮重大作用。例如在使用樣式對象時,將所有的className原樣返回(例如styles.container === ‘container’),這會便於快照測試。要實現這個功能,得安裝identity-obj-proxy,並修改moduleNameMapper選項,如下所示。
"jest": { "moduleNameMapper": { "\\.(css|scss)$": "identity-obj-proxy" } }
當moduleNameMapper不能滿足需求時,可以使用transform選項設定轉換規則,如下所示。
"jest": { "transform": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)$": "<rootDir>/config/jest/fileTransformer.js" } }
fileTransformer.js文件位於配置目錄的jest目錄中,其作用就是返迴文件的名稱(如下代碼所示),例如require(‘avatar.png’)返回「avatar.png」。
const path = require('path'); module.exports = { process(src, filename, config, options) { return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';'; } };
注意,之前使用了ESLint檢測代碼,因此測試用例也會被檢測。如果不想執行ESLint,那麼可以添加.eslintignore文件,內容如下所示,其中配置目錄也被忽略了。
src/__tests__
config
七、命令行工具
之前曾寫過一篇命令行工具的簡易教程。目前的設想是將命令行工具從腳手架中分離出來,通過命令下載腳手架。
首先安裝ora、chalk、commander和download-git-repo四個包,安裝命令如下所示。
npm install --save ora chalk commander download-git-repo
ora是一個優雅的終端旋轉器,chalk可為終端中的文字添加顏色,commander是一個編輯命令的工具,download-git-repo可下載GitHub上的倉庫代碼。下面是具體的命令,命令(pwu-cli)已上傳到npm中,安裝成功後,可以執行「pwu create demo」創建demo目錄(如圖47所示),並自動下載pwu倉庫中的腳手架代碼。
#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const ora = require('ora'); const chalk = require('chalk'); const program = require('commander'); const download = require('download-git-repo'); program .version('1.0.0', '-v, --version', '版本'); program .command('create <name>') .description('create a repository') .action(name => { const spinner = ora('開始下載腳手架'); spinner.start(); const destination = path.join(process.cwd(), name); if(fs.existsSync(destination)) { console.log(chalk.red('腳手架已存在')); return; } download('github:pwstrick/pwu', destination, (err) => { spinner.stop(); console.log(chalk.green('腳手架下載成功')); }); }); program.parse(process.argv)
圖 47
在發佈到npm時,npm可根據.gitignore文件中的內容進行過濾,這樣就能避免上傳依賴的模塊。
參考資料:
What is the Purpose of chunkFilename of mini-css-extract-plugin Module?
webpack 4 Code Splitting 的 splitChunks 配置探索
在React+Babel+Webpack環境中使用ESLint
使用 husky+lint-staged+prettier 優化代碼格式
husky和lint-staged實現git commit前自動跑lint