阅读 10500

前端工程化实战 - 自定义 React 脚手架 & CLI 升级

⚠️ 本文为掘金社区首发签约文章,未获授权禁止转载

前言

上一篇企业级 CLI 开发中,已经针对构建这块的流程做了一个初级的 CLI,但对于工程化体系的建设仅仅也只是迈出了第一步。

开发者平常最多的还是在开发业务代码,仅仅依靠 CLI 从 devops 末端去约束是远远不够的,所以一般的小团队也会从脚手架入手。

本篇将以 React 为例定制一套自定义脚手架以及对之前的 CLI 进行升级。

自定义 React 脚手架

脚手架设计一般分为两块,一块是基础架构,一块是业务架构。

基础架构决定脚手架的技术选型、构建工具选型以及开发优化、构建优化、环境配置、代码约束、提交规范等。

业务架构则是针对业务模块划分、请求封装、权限设计等等于与业务耦合度更高的模块设计。

搭建基础架构

跟 CLI 一样都是从 0 搭建这个脚手架,所以起手还是初始化项目与 ts 配置。

npm init
tsx --init
复制代码

如上先将 package.josntsconfig.json 生成出来,tsconfig.json 的配置项可以直接使用下面的配置或者根据自己需求重新定义。

{
  "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 的依赖与一些其他的配置,也一起附上,这里不再针对每个依赖包做单独说明,如果对哪个模块有不理解的地方,可以在留言区评论咨询。

{
  "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 中。

文章分类
前端