前端利器躬行記(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-loaderpostcss-loadersass-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"
  ]
}

  如果不想自己定義規則,那麼可以直接使用網上開源的規則,例如AirbnbJavaScript編碼規範。注意,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

七、命令行工具

  之前曾寫過一篇命令行工具的簡易教程。目前的設想是將命令行工具從腳手架中分離出來,通過命令下載腳手架。

  首先安裝orachalkcommanderdownload-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文件中的內容進行過濾,這樣就能避免上傳依賴的模塊。

 

參考資料:

React & Webpack

Webpack中publicPath詳解

mini-css-extract-plugin插件快速入門

從零配置webpack 4+react腳手架

What is the Purpose of chunkFilename of mini-css-extract-plugin Module?

sass圖片地址

webpack 4 Code Splitting 的 splitChunks 配置探索

webpack SplitChunksPlugin實用指南

自製前端腳手架

使用 Node.js 開發簡單的腳手架工具

在React+Babel+Webpack環境中使用ESLint

使用 husky+lint-staged+prettier 優化代碼格式

husky和lint-staged實現git commit前自動跑lint

jest 和 Webpack 一起使用

jest教程