前端工程化實戰 - 自定義 React 腳手架 & CLI 升級

⚠️ 本文爲掘金社區首發簽約文章,未獲受權禁止轉載css

前言

上一篇企業級 CLI 開發中,已經針對構建這塊的流程作了一個初級的 CLI,但對於工程化體系的建設僅僅也只是邁出了第一步。html

開發者日常最多的仍是在開發業務代碼,僅僅依靠 CLI 從 devops 末端去約束是遠遠不夠的,因此通常的小團隊也會從腳手架入手。node

本篇將以 React 爲例定製一套自定義腳手架以及對以前的 CLI 進行升級。react

自定義 React 腳手架

腳手架設計通常分爲兩塊,一塊是基礎架構,一塊是業務架構。webpack

基礎架構決定腳手架的技術選型、構建工具選型以及開發優化、構建優化、環境配置、代碼約束、提交規範等。ios

業務架構則是針對業務模塊劃分、請求封裝、權限設計等等於與業務耦合度更高的模塊設計。git

搭建基礎架構

跟 CLI 同樣都是從 0 搭建這個腳手架,因此起手仍是初始化項目與 ts 配置。github

npm init
tsx --init
複製代碼

如上先將 package.josntsconfig.json 生成出來,tsconfig.json 的配置項能夠直接使用下面的配置或者根據本身需求從新定義。web

{
  "include": [
    "src"
  ],
  "compilerOptions": {
    "module": "CommonJS",
    "target": "es2018",
    "outDir": "dist",
    "noEmit": true,
    "jsx": "react-jsx",
    "esModuleInterop": true,
    "moduleResolution": "node",
    "strict": true,
    "noUnusedLocals": false,
    "noFallthroughCasesInSwitch": true,
    "baseUrl": "./",
    "keyofStringsOnly": true,
    "skipLibCheck": true,
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  }
}
複製代碼

下面是 package.josn 的依賴與一些其餘的配置,也一塊兒附上,這裏再也不針對每一個依賴包作單獨說明,若是對哪一個模塊有不理解的地方,能夠在留言區評論諮詢。typescript

{
  "name": "react-tpl",
  "version": "1.0.0",
  "description": "a react tpl",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "cross-env NODE_ENV=development webpack-dev-server --config ./script/webpack.config.js",
  },
  "author": "cookieboty",
  "license": "ISC",
  "dependencies": {
    "@babel/cli": "^7.14.5",
    "@babel/core": "^7.14.6",
    "@babel/preset-env": "^7.14.7",
    "@babel/preset-react": "^7.14.5",
    "@babel/preset-typescript": "^7.14.5",
    "babel-loader": "^8.2.2",
    "clean-webpack-plugin": "^4.0.0-alpha.0",
    "cross-env": "^7.0.3",
    "css-loader": "^6.1.0",
    "file-loader": "^6.2.0",
    "html-webpack-plugin": "^5.3.2",
    "less": "^4.1.1",
    "less-loader": "^10.0.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "style-loader": "^3.1.0",
    "typescript": "^4.3.5",
    "webpack": "^5.45.1",
    "webpack-cli": "3.3.12",
    "webpack-dev-server": "^3.11.2"
  },
  "devDependencies": {
    "@types/react": "^17.0.14",
    "@types/react-dom": "^17.0.9"
  }
}
複製代碼

配置 webpack

新建 script/webpack.config.js 複製下述配置。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

module.exports = {
  mode: "development",
  entry: "./src/index.tsx",
  devServer: {
    contentBase: path.resolve(__dirname, "dist"),
    hot: true,
    historyApiFallback: true,
    compress: true,
  },
  resolve: {
    alias: {
      '@': path.resolve('src')
    },
    extensions: ['.ts', '.tsx', '.js', '.json']
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx|ts|tsx)$/,
        use: {
          loader: require.resolve('babel-loader')
        },
        exclude: [/node_modules/],
      },
      {
        test: /\.(css|less)$/,
        use: [
          {
            loader: "style-loader",
          },
          {
            loader: "css-loader",
            options: {
              importLoaders: 1,
            },
          },
        ],
      },
      {
        test: /\.(png|svg|jpg|gif|jpeg)$/,
        loader: 'file-loader'
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        loader: 'file-loader'
      }
    ],
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template: 'tpl/index.html'
    }),
  ]
};
複製代碼

這裏有個須要注意的點是 webpack-cliwebpack-dev-server版本須要保持一致,都是用 3.0 的版本便可,若是版本不一致的話,會致使報錯。

配置 React 相關

新建 tpl/index.html 文件(html 模板),複製下述代碼

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
複製代碼

新建 src/index.tsx 文件(入口文件),複製下述代碼

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(
  <App />,
  document.getElementById("root")
);
複製代碼

新建 .babelrc 文件(babel 解析配置),複製下述代碼

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react",
    [
      "@babel/preset-typescript",
      {
        "isTSX": true,
        "allExtensions": true
      }
    ]
  ]
}
複製代碼

完成上述一系列配置以後,同時安裝完依賴以後,運行 yarn start,此時應該是可以正常運行項目以下圖所示

image.png

瀏覽器打開 http://localhost:8081/,便可看到寫出來的展現的頁面

image.png

至此,已經完成了一個初步的腳手架搭建,可是針對於業務來講,仍是有不少的細節須要完善。接下來,咱們一塊兒針對日常開發須要使用到的模塊對項目進行進一步的配置。

篇幅所致,本文並不會對 Webpack、Babel、React 的配置項作過多的說明,僅僅提供一個完整實例,能夠根據步驟完成一個基礎框架的搭建,若是有同窗想了解更多相關的細節,建議直接搭建完畢以後閱讀文檔,而後根據文檔說明來配置本身想要的功能,多思考、多動手。

優化 Webpck Dev 配置

簡化 server 信息輸出

前面的配圖能夠看出 webpack-dev-server 輸出的信息很亂,可使用 Stats 配置字段對輸出信息進行過濾。

通常咱們只須要看到 error 信息便可,能夠添加以下參數:

devServer: {
    stats: 'errors-only', // 過濾信息輸出
    contentBase: path.resolve(__dirname, "dist"),
    hot: true,
    historyApiFallback: true,
    compress: true,
},
複製代碼

添加構建信息輸出

image.png

ProgressPlugin 能夠監控各個 hook 執行的進度 percentage,輸出各個 hook 的名稱和描述。

使用也很是簡單,按照以下引用以後,就能夠正常輸出如圖標紅的構建進度。

const { ProgressPlugin } = require('webpack')
plugins: [
    ...
    new ProgressPlugin(),
]
複製代碼

優化業務模塊

先將項目目錄劃分好,約定好每一個目錄的文件的做用與功能。

這裏的規範並非必定的,具體要看各個團隊本身的開發規範來定製,例若有的團隊喜歡將公共的資源放在 public 目錄等。

├── dist/                          // 默認的 build 輸出目錄
└── src/                           // 源碼目錄
    ├── assets/                    // 靜態資源目錄
    ├── config                     
        ├── config.js              // 項目內部業務相關基礎配置
    ├── components/                // 公共組件目錄
    ├── service/                   // 業務請求管理
    ├── store/                     // 共享 store 管理目錄
    ├── util/                      // 工具函數目錄
    ├── pages/                     // 頁面目錄
    ├── router/                    // 路由配置目錄
    ├── .index.tsx                 // 依賴主入口
└── package.json
複製代碼

配置路由

收斂路由的好處是能夠在一個路由配置文件查看到當前項目的一個大概狀況,便於維護管理,固然也可使用約定式路由,即讀取 pages 下文件名,根據文件命名規則來自動生成路由。但這種約束性我感受仍是不太方便,我的仍是習慣本身配置路由規則。

首先改造 index.tsx 入口文件,代碼以下:

import React from 'react'
import ReactDOM from 'react-dom'
import { HashRouter, Route, Switch } from 'react-router-dom'
import routerConfig from './router/index'
import './base.less'

ReactDOM.render(
  <React.StrictMode> <HashRouter> <Switch> { routerConfig.routes.map((route) => { return ( <Route key={route.path} {...route} /> ) }) } </Switch> </HashRouter> </React.StrictMode>,
  document.getElementById('root')
)
複製代碼

router/index.ts 文件配置,代碼以下:

import BlogsList from '@/pages/blogs/index'
import BlogsDetail from '@/pages/blogs/detail'

export default {
  routes: [
    { exact: true, path: '/', component: BlogsList },
    { exact: true, path: '/blogs/detail/:article_id', component: BlogsDetail },
  ],
}

複製代碼

Service 管理

跟收斂路由是同樣的意思,收斂接口也能夠統一修改、管理這些請求,若是有複用接口修改能夠從源頭處理。

全部項目請求都放入 service 目錄,建議每一個模塊都有對應的文件管理,以下所示:

import * as information from './information'
import * as base from './base'

export {
  information,
  base
}
複製代碼

這樣能夠方便管理請求,base.ts 做爲業務請求類,能夠在這裏處理一些業務特殊處理。

import { request } from '../until/request'

const prefix = '/api'

export const getAllInfoGzip = () => {
  return request({
    url: `${prefix}/apis/random`,
    method: 'GET'
  })
}

複製代碼

util/request 做爲統一引入的請求方法,能夠自行替換成 fetch、axios 等請求庫,同時能夠在此方法內封裝通用攔截邏輯。

import qs from 'qs'
import axios from "axios";

interface IRequest {
    url: string
    params?: SVGForeignObjectElement
    query?: object
    header?: object
    method?: "POST" | "OPTIONS" | "GET" | "HEAD" | "PUT" | "DELETE" | undefined
}

interface IResponse {
    count: number
    errorMsg: string
    classify: string
    data: any
    detail?: any
    img?: object
}

export const request = ({ url, params, query, header, method = 'POST' }: IRequest): Promise<IResponse> => {
    return new Promise((resolve, reject) => {
        axios(query ? `${url}/?${qs.stringify(query)}` : url, {
            data: params,
            headers: header,
            method: method,
        })
            .then(res => {
                resolve(res.data)
            })
            .catch(error => {
                reject(error)
            })
    })
}
複製代碼

具體通用攔截,請參考 axios 配置,或者本身改寫便可,須要符合自身的業務需求。

在具體業務開發使用的時候能夠按照模塊名引入,容易查找對應的接口模塊。

import { information } from "@/service/index";

const { data } = await information.getAllInfoGzip({ id });
複製代碼

這套規則一樣能夠適用於 store、router、utils 等能夠拆開模塊的地方,有利於項目維護。

上述是針對項目作了一些業務開發上的配置與約定,各位同窗能夠根據本身團隊中的規定與喜愛行修改。

CLI 升級改造

在上述自定義 React 腳手架搭建完畢以後,咱們若是直接用使用上一篇搭建出來的 CLI 來構建項目是不會構建成功的,還有印象的同窗,應該記得以前的 CLI 的入口文件是 src/index.js,html 模板使用的是 public/index.html

很明顯能夠看出,此時的 CLI 是遠遠達不到要求的,咱們並不能在每一次開發的時候都須要對 CLI 進行更新,這樣是違背 CLI 的通用性原則。

那麼該如何解決這個問題呢?

自定義配置文件

根目錄新建 cli.config.json 文件,此文件將是須要讀取配置的文件。

將此項目的自義定配置寫入文件,供給 CLI 讀取。

{
  "entry": {
    "app": "./src/index.tsx"
  },
  "output": {
    "filename": "build.js",
    "path": "./dist"
  },
  "template": "tpl/index.html"
}
複製代碼

CLI 同步進行改造,代碼以下:

require('module-alias/register')
import webpack from 'webpack';
import { getCwdPath, loggerTiming, loggerError } from '@/util'
import { loadFile } from '@/util/file'
import { getProConfig } from './webpack.pro.config'
import ora from "ora";

export const buildWebpack = () => {

  const spinner = ora('Webpack building...')

  const rewriteConfig = loadFile(getCwdPath('./cli.config.json')) // 讀取腳手架配置文件

  const compiler = webpack(getProConfig(rewriteConfig));

  return new Promise((resolve, reject) => {
    loggerTiming('WEBPACK BUILD');
    spinner.start();
    compiler.run((err: any, stats: any) => {
      console.log(err)
      if (err) {
        if (!err.message) {
          spinner.fail('WEBPACK BUILD FAILED!');
          loggerError(err);
          return reject(err);
        }
      }
    });

    spinner.succeed('WEBPACK BUILD Successful!');
    loggerTiming('WEBPACK BUILD', false);
  })
}
複製代碼

webpack.pro.config.ts 代碼以下:

import getBaseConfig from './webpack.base.config'
import { getCwdPath, } from '@/util'

interface IWebpackConfig {
  entry: {
    app: string
  }
  output: {
    filename: string,
    path: string
  }
  template: string
}

export const getProConfig = (config: IWebpackConfig) => {
  const { entry: { app }, template, output: { filename, path }, ...rest } = config

  return {
    ...getBaseConfig({
      mode: 'production',
      entry: {
        app: getCwdPath(app || './src/index.js')
      },
      output: {
        filename: filename || 'build.js',
        path: getCwdPath(path || './dist'), // 打包好以後的輸出路徑
      },
      template: getCwdPath(template || 'public/index.html')
    }),
    ...rest
  }
}
複製代碼

經過 loadFile 函數,讀取腳手架自定義配置項,替換初始值,再進行項目構建,構建結果以下:

image.png

這個自定義配置只是初步的,後期能夠自定義添加更多的內容,例如自定義的 babel 插件、webpack 插件、公共路徑、反向代理請求等等。

接管 dev 流程

與接管構建流程相似,在咱們進行自定義腳手架構建以後,能夠以此爲基礎將項目的 dev 流程也接管,避免項目由於開發與構建的依賴不一樣而致使構建失敗,從源頭管理項目的規範與質量。

在前面腳手架中配置的 webpack-dev-server 是基於 webpack-cli 來使用的。

既然使用 CLI 接管 dev 環境,那麼也就不須要將 webpack-dev-server 做爲 webpack 的插件使用,而是直接調用 webpack-dev-serverNode Api

將剛剛的腳手架的 webpack-dev-server 配置抽離,相關配置放入 CLI 中。

const WebpackDevServer = require('webpack-dev-server/lib/Server')

export const devWebpack = () => {
  const spinner = ora('Webpack running dev ...')

  const rewriteConfig = loadFile(getCwdPath('./cli.config.json'))
  const webpackConfig = getDevConfig(rewriteConfig)

  const compiler = webpack(webpackConfig);

  const devServerOptions = {
    contentBase: 'dist',
    hot: true,
    historyApiFallback: true,
    compress: true,
    open: true
  };
  
  const server = new WebpackDevServer(compiler, devServerOptions);

  server.listen(8000, '127.0.0.1', () => {
    console.log('Starting server on http://localhost:8000');
  });
}
複製代碼

而後在腳手架的 package.json scripts 添加對應的命令就能夠完成對 dev 環境的接管,命令以下:

"scripts": {
     "dev": "cross-env NODE_ENV=development fe-cli webpack",
     "build": "cross-env NODE_ENV=production fe-cli webpack"
 }
複製代碼

運行對應的命令便可運行或者打包當前腳手架內容。

優化 webpack 構建配置

上一篇就已經介紹過了,目前的構建產物結果很明顯並非咱們想要的,也不符合普通的項目規範,因此須要將構建的配置再優化一下。

mini-css-extract-plugin

mini-css-extract-plugin 是一款樣式抽離插件,能夠將 css 單獨抽離,單獨打包成一個文件,它爲每一個包含 css 的 js 文件都建立一個 css 文件。也支持 css 和 sourceMaps 的按需加載。配置代碼以下:

{
    rules: [
        test: /\.(css|less)$/,
            use: [MiniCssExtractPlugin.loader],
          }
    ]
}
  
plugins: [
      new MiniCssExtractPlugin({
        filename: '[name].[contenthash].css',
        chunkFilename: '[id].[contenthash].css',
        ignoreOrder: true,
      })
    ]
複製代碼

提取公共模塊

咱們可使用 webpack 提供的 splitChunks 功能,提取 node_modules 的公共模塊出來,在 webpack 配置項中添加以下配置便可。

optimization: {
      splitChunks: {
        cacheGroups: {
          commons: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
          },
        },
      },
},
複製代碼

image.png

如圖,如今構建出來的產物是否是瞬間清晰多了。

優化構建產物路徑

上述的構建產物雖然已經優化過了,可是目錄依然還不夠清晰,咱們能夠對比下圖的 cra 構建產物,而後進行引用路徑的優化。

image.png

其實很簡單,將全部構建產物的路徑前面統一添加 static/js,這樣在進行構建獲得的產物就以下圖所示。

image.png

配置增量構建(持久化緩存)

這是 webpack 5 的新特性,在 webpack 4 的時候,咱們經常使用優化構建的手段是使用 hard-source-webpack-plugin 這個插件將模塊依賴緩存起來,再第二次構建的時候會直接讀取緩存,加快構建速度。

這個過程在 webpack 5 裏面被 cache 替代了,官方直接內置了持久化緩存的功能,配置起來也很是方便,添加以下代碼便可:

import { getCwdPath } from '@/util'

export default {
  cache: {
    type: 'filesystem',  // 'memory' | 'filesystem'
    cacheDirectory: getCwdPath('./temp_cache'), // 默認將緩存存儲在 當前運行路徑/.cache/webpack
    // 緩存依賴,當緩存依賴修改時,緩存失效
    buildDependencies: {
      // 將你的配置添加依賴,更改配置時,使得緩存失效
      config: [__filename]
    },
    allowCollectingMemory: true,
    profile: true,
  },
}
複製代碼

而後在運行構建或者開發的時候,會在當前運行目錄生產緩存文件以下:

image.png

如今讓咱們一塊兒來看看,構建速度的提高有多少:

image.png

能夠很明顯看出,第一構建速度比以前要慢 2s 左右,可是第二次構建速度明顯提高,畢竟腳手架目前的內容太少了,初次構建使用增量的時候會比普通編譯多了存儲緩存的過程。

這裏有個須要注意的點,由於咱們是調用 webpack 的 Node Api 來構建,因此須要顯示關閉 compiler 才能正常生產緩存文件。

const compiler = webpack(webpackConfig);

  try {
    compiler.run((err: any, stats: any) => {

      if (err) {
        loggerError(err);
      } else {
        loggerSuccess('WEBPACK SUCCESS!');
      }
      compiler.close(() => {
        loggerInfo('WEBPACK GENERATE CACHE'); // 顯示調用 compiler 關閉,生成緩存
      });
      loggerTiming('WEBPACK BUILD', false);
    });
  } catch (error) {
    loggerError(error)
  }
複製代碼

有興趣的同窗能夠試試 dev 環境,啓動速度同樣會縮短到秒開級別。

特別鳴謝

image.png

這是上一篇的讀者留言,此處@琦玉,感謝這位同窗的建議,後面的系列博文除了介紹思路以外,coding 與步驟會更加詳細,也會及時提供項目 demo 供給參考,其餘同窗更好的建議也能夠在評論區反饋。但願除了能將這個系列寫完以外,還能寫得更好,讓我能和更多的同窗一塊兒互相學習、共同成長。

寫在最後

CLI 工具到此爲止,總算是有個大概可用的雛形了,可是做爲企業級的 CLI 目標,咱們還差很長的一段路要走,僅僅構建這塊能優化的點就很是多,包括但不限於構建配置的約束、拓展、提交約束等等細節性的優化。

全部的項目代碼已經上傳至項目地址,有興趣的同窗能夠拉取參考,後續全部專欄的相關的代碼都會統一放在 BOTY DESIGN 中。

相關文章
相關標籤/搜索