前端工程化实战 - 自定义 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 中。

相关文章
相关标签/搜索