保姆级·从零搭建一个现代化前端工程

1,612 阅读16分钟

这是我的第一篇掘金博客,开启掘金写作之路。
大家好,我是刚加入掘金的"有两只臭猫"(公众号:咪仔和汤圆,欢迎关注~)

未经授权,禁止转载~

开篇

这次和大家探讨如何真正的从零开始搭建一个现代化前端工程,总结其中的要点。整个过程中,会用到很多工具、库、框架,也会有很多需要自己书写的地方。在文章中,更多的是偏向于如何搭建以及如何运作,所以某一些工具的详细配置,可能就不是那么精细了,还需要自己去探索。

适合有一些前端入门知识的人,如果是新手朋友,当不知道某一项是做什么的时候,就查资料,查到自己明白了他的作用为止(也花不了多少时间)~

再强调一遍,如果不知道,就去查资料,very very重要~

文中使用技术均为当前时间段(2022年1月)下的最新版,如果时间比较久了配置可能会有出入。

本文可能比较长,还请心平气和慢慢看(大侠别翻篇),最好实操一遍

再次谢谢大家~

(掘金的这个编辑器图片我明明设置了大小的,好像不生效哇~ 后面图片看着不太对,还请忽略)

目录和关键技术

首先把整个要用到的技术大致列出来一下,也供想了解的朋友先看一下:

HTML、CSS、JS、Webpack、Babel、TS、Less、Postcss、React、Redux、Axios、Eslint、Husky、Git

大致上是这些了~

好,那我们进入正题:joy:

一、工程运行原理

首先需要明白一个项目或者一个工程的本质,目前前端工程的本质运行逻辑还是没有变化的:读取HTMLCSS文件进行解析和渲染,加载其中的JS进行执行。所以搭建工程的目的就是万年不变的整出来这老三样,放在UserAgent中运行。

目前流行的项目管理方式就是node的包管理机制,目前有很多种管理机制:npmcnpmyarnpnpm等,我们就选用目前最熟知的npm就行了。

任何项目前提都是新建文件夹,首先我们新建一个文件夹,初始化为npm项目:

mkdir demo && cd demo
npm init

初始化会要求你填入一些信息,这里我们选填一些工程的属性就可以了,里面具体的字段含义如果不懂可以查一下node文档。初始化以后,项目就形成了,记住这个时候我们就已经是一个项目了。在mkdir demo创建文件夹以后我们就是一个项目了,可以在里面写html、css、js文件来运行。后面初始化为npm包的形式只是为了跟上现代前端的步伐。

初始化工程.png 整篇完

开个玩笑~

初始化为工程以后,做什么?回想一下一个普通的项目,当我们运行npm run start的时候,会启动一个本地服务器拉起我们写的代码,并且我们写的代码是承载在一个HTML模板上的。其中做了以下的工作:

  1. node通过npm运行package.json中的命令,这个命令通常是脚手架自定义命令或者自己配置的打包工具命令。
  2. 添加的打包工具或者脚手架接收到命令后,读取打包工具配置,进行打包生成产物。
  3. 打包的产物完以后会以一个入口的形式挂在在我们的模板文件下,这个时候我们就可以浏览器预览了。
  4. 加个本地服务器,用本地域名拉起我们的模板文件,给你营造家的感觉,起飞🛫。

二、实操 先跑起来

在新建的demo文件夹中,新建一个index.html,随便写一点内容。安装一个打包工具,并且按照这个打包工具的规则进行一些配置。我们选用常用的打包工具webpack,直接打开文档的指南webpack(这个文档相对官网是更新最好的,翻译也比较ok,建议看这个。):

npm install webpack webpack-cli --save-dev

当安装完webpack以后,如果不是很熟悉,可以按照官方给的文档走一遍。webpack本质就是处理项目中用到的各种资源,打包压缩后将产物挂在html模板上。因为我们是刚开始搭建工程,这里我们就手动先将产物挂载到模板文件上。

挂载模板文件.png

这个/dist/main.js就是我们打包出来的东西,这里贴一下目前的工程目录:

工程目录-初始化时.png

几个比较重要的文件(这里吐槽一下掘金的编辑器太难用了,太难用了,2202年了还有这么难用的~):

注意📢:后面贴代码的部分,可能只会贴有改动的地方。

webpack.config.js是我们打包工具的配置,这里我们只添加最基础的配置:

// webpack.config.js
// webpack最新的版本你甚至可以不写配置,有默认配置了
const path = require('path');
module.exports = {  
  mode: 'development',  
  entry: path.join(__dirname, 'src', 'index'),
  output: {
    path: path.join(__dirname, 'dist'),
  }
};

index.html就是我们的入口文件了,我们的逻辑也很简单,手动挂载js,然后一个点击按钮,触发我们挂在的js中的方法:

<!DOCTYPE html>
<html>
  <head>    
    <meta charset="utf-8">  
    <meta http-equiv="X-UA-Compatible" content="IE=edge">  
    <title>从零开始一个工程</title>   
    <meta name="description" content="">  
    <meta name="viewport" content="width=device-width, initial-scale=1">  
    <link rel="stylesheet" href=""> 
  </head>  
  <body>    
    <script src="./dist/main.js"></script>  
    <h1>从零开始的一个工程</h1>  
    <button onclick="change()">点击</button>  
    <div id="app"></div> 
  </body>
</html>

src下的index.js,相当于我们的业务文件,逻辑就一个函数:

function say(){
  console.log("say hello")
}

function change(){ 
  console.log("调用了change")  
  const app = document.getElementById('app') 
  app.innerHTML = "改变App文案"
}

package.json,我们安装完webpack以后,只需要在scripts下添加一个打包命令来启动webpack,而webpack的打包命令就是webpack

{  
  "name": "demo", 
  "version": "1.0.0", 
  "description": "一个示例工程",  
  "main": "index.js", 
  "scripts": {   
    "start": "webpack" 
   }, 
  "author": "awefeng", 
  "license": "MIT",  
  "devDependencies": {    
    "webpack": "^4.29.0",   
    "webpack-cli": "^3.2.1"
  }
}

三、配置环境和改变入口

这一步骤比较简单但是还是需要做。

虽然webpack支持直接传--env,但考虑到后续工程里面的环境判断,我们还是在scripts命令传入环境变量。增加个依赖包cross-env来抵消掉不同平台环境(windowslinuxmacOS等)运行命令时的问题:

npm install --save-dev cross-env
// package.json
"scripts": {   
  "start": "cross-env ENV=dev webpack",   
  "build:test": "cross-env ENV=test webpack",  
  "build": "cross-env ENV=prod webpack" 
},

webpack里面配置mode为运行命令传入的环境参数,webpack会将配置文件中mode的值自动设置为process.env.NODE_ENV的值。

设置webpack的打包入口,注意这里我们把入口名改为了app

entry: { 
  app: resolve(__dirname, '../src', 'app'// multi: xxxx 多个入口类似
},

四、添加CSS、图片等处理配置

首先我们添加css预处理器,这里选用less(最好不用选sass,国内的垃圾网络+sass自身的实现问题+sass的版本兼容问题...):

npm install --save-dev style-loader css-loader less-loader less

css-loader:会对 @import 和 url() 进行处理,就像 js 解析 import/require() 一样,默认生成一个字符串数组存放存放处理后的样式字符串,并将其导出。

style-loader:作用就是把 CSS 插入到 DOM 中,就是处理css-loader导出的模块数组,然后将样式通过style标签或者其他形式插入到DOM中。

less-loaderless出的针对webpackloaderless-loader中会用到核心库less

webpackloader是实行的链式流水线调用,并且是从右向左,所以我们这么配置:

module: {    
  rules: [     
    {      
      test: /.less$/i,   
      use: [        
        'style-loader',         
        'css-loader',      
        'less-loader',    
      ] 
    }
  ] 
}

到这里简单的webpack处理less的配置就完成了,我们在工程中写入一个less文件,在index.js中进行引入,然后在我们的函数运用:

// src/index.js
import './index.less'

function change(){ 
  const app = document.getElementById('app')
  app.innerHTML = "改变App文案" 
  app.classList.add("container")
}
change()

// index.less
.container{  width: 100px;  height: 100px;  border: 1px solid black;}

这时候我们运行npm run start命令,等待打包完成以后,浏览器打开html

image.png

006APoFYly8gsuul1wviqg306o06on6b.gif

进行优化:

  1. 添加css module
  2. cssjs分开(生产环境)
  3. 压缩css
  4. css进行按需引入
  5. 浏览器兼容css

这里就简单的先把每一项的方案、解决办法说一下,然后在最后贴上配置文件,相关的详细配置还需要自己去详细研究。

添加CSS Module

两种方案:

  1. 全部lesscss文件都模块化
  2. 部分文件(比如只有.module.(le/c)ss后缀的)才模块化

因为在css module中可以添加:global来对样式全局化,所以我们采用第一种方案。

将CSS和JS分开

webpack打包生成的bundle,是将cssjs混合在一起的。我们需要添加webpack的插件mini-css-extract-plugin来处理打包后的bundle,将cssjs分开。

mini-css-extract-pluginstyle-loader不能共用,在dev模式下style-loader更具优势。所以这里判断下环境。在dev模式下使用style-loader;在prod环境下使用mini-css-extract-plugin。入口文件的css引用需要我们用html-webpack-plugin去自动引入,加入了html-wbpack-plugin以后,需要指定这个插件从对应的模板html生成新的index.html

mini-css-extract-plugin支持css的按需加载并且无需配置,这里额外安装了clean-webpack-plugin来清除每次打包前,上一次打包遗留的bundle

npm install --save-dev mini-css-extract-plugin html-wbpack-plugin clean-webpack-plugin

压缩CSS

npm install css-minimizer-webpack-plugin --save-dev

浏览器兼容CSS

配置浏览器兼容需要提供.browserslistrc文件,这个文件主要是提供浏览器基准信息(查一下资料),像postcssbabel这类工具都需要参考这些浏览器信息。

npm install --save-dev postcss-loader postcss postcss-preset-env

说完less、css的处理,接着说图片的处理,图片的处理我们直接使用webpack的asset module功能(查一下资料)。

整个配置

image.png

<!-- index.html --> 
<!doctype html>
<html>
  <head>
    <meta charset="utf-8"> 
    <title>从0开始搭建一个工程</title> 
    <meta name="viewport" content="width=device-width,initial-scale=1">
  </head>
  <body> 
    <!-- 添加一个节点 --> 
    <!-- 不需要再手动引入js 因为我们使用了html-webpack-plugin --> 
    <div id="app"></div>
  </body>
</html>
// index.js
import styles from './index.less' // css module
import temp from './temp.css'   // 测试CSS文件是否被postcss处理
import tans from './asset/image/tans.jpeg' //asset module

function change(){ 
  const app = document.getElementById('app')
  app.innerHTML = "改变App文案"
  app.classList.add(styles.container) 
  app.classList.add(temp.temp)
  
  const img = document.createElement('img') 
  img.src = tans
  app.parentElement.append(img)
}

change()
// index.less
.container{ 
  width: 100px;  
  height: 100px; 
  border: 1px solid black; 
  background-image: url("./asset/image/tans.jpeg");  
  background-size: cover; 
  user-select: none;
}
// webpack.config.js
const {CleanWebpackPlugin} = require('clean-webpack-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const path = require('path')
const {ProgressPlugin} = require('webpack')
const config = require("./config/index")

/** 
 * 获取当前环境的枚举 
 * @returns development test production 
 */
function getEnv() {  return config.ENV_ENUM[process.env.ENV]}

const isProd = getEnv() === config.ENV_ENUM.prod

// webpack插件
const plugins = [  
  // 进度条  
  new ProgressPlugin(),  
  // 打包之前清理dist  
  new CleanWebpackPlugin(), 
  // 从模板自动生成html 
  new HtmlWebpackPlugin({template: 'index.html'}), 
  // 提取CSS文件 按需加载CSS  
  new MiniCssExtractPlugin({   
    filename: "./css/[name].[contenthash].css", 
  }),
]

module.exports = { 
  // 环境配置  
  mode: isProd ? config.ENV_ENUM.prod : config.ENV_ENUM.dev,  
  // 入口  
  entry: {   
    app: path.join(__dirname, 'src', 'index') 
  }, 
  // 输出 
  output: {  
    filename: "[name].[hash].js",
    path: path.join(__dirname, 'dist'),
    }, 
  module: { 
    rules: [    
      // less css 文件的处理    
      {     
        test: /.(le|c)ss$/i,     
        exclude: /node_modules/,   
        use: [        
          // 生产环境进行压缩  本地dev直接使用style-loader      
          isProd ?  MiniCssExtractPlugin.loader : 'style-loader',  
          {        
            loader: "css-loader",    
            /**            
             * 开启css module   
             * css文件处理之前先加载上一个loader 即postcss-loader 来处理兼容问题             
             */
            options: {        
              importLoaders: 1,      
              modules: true        
            }        
          },  
          {           
            loader: 'postcss-loader',    
            options: {         
              postcssOptions: {    
                // 自动添加不同浏览器浅醉 并处理新特性  
                plugins: ['postcss-preset-env']     
              } 
            }
          },     
          "less-loader"    
        ],
      },   
      {     
        test: /.(png|svg|jpg|jpeg|gif|woff|woff2|eot|ttf|otf)$/i,   
        type: 'asset/resource',   
        generator: {        
          filename: "[file]" 
        } 
      },  
    ] 
  }, 
  plugins,  
  optimization: { 
    minimizer: [  
      // 压缩css    
      new CssMinimizerPlugin(),   
    ],
  },
}

五、搭建开发环境

首先开启source-map,可选值太多了,上网搜一下。推荐dev环境eval-cheap-module-source-map,生产环境nosources-source-map,在webpack中配置devtool:

module.exports = {
    devtool: isProd ? "nosources-source-map" : "eval-cheap-module-source-map",
}

每次运行的时候手动npm run start,再去刷新,很麻烦。所以我们添加上开发工具(本地服务器),webpack上用的比较多的是web-dev-server

npm install --save-dev webpack-dev-server
module.exports = {
  devServer: {
    static: resolve(__dirname, '../dist'),
    compress: true,
    hot: true,
    port: 8080  
 },
}
// 修改下package.json中的启动命令
"start": "cross-env ENV=dev webpack serve --open",

重新npm run start,项目会自动拉起,修改代码浏览器会自动刷新。同时我们debugger的时候,看到的是我们源码(因为我们配置的devtool)。

image.png

六、配置HMR

webpack-dev-server是带有模块热替换的,我们要做的就是开启它。开启方法在devServer配置中添加hot: true即可。这个时候我们新建一个js文件,随便写点东西在里面,然后在index.js中去引用。index.js中引用的模块更新了,需要用module.hot.accpet去获取,也就是需要手动去写个钩子。

//添加一个 student.js
export function studentSay(){ 
  console.log('学生', "说", "不想考试")
}
// index.js 
import {studentSay} from './student.js'

if (module.hot) {    
  module.hot.accept('./student.js', function() {
    console.log('Accepting the updated student module!');   
    studentSay() 
  }) 
}

项目npm run start以后,随便更改下student.js,就会看见热更新了。

image.png

至于为什么需要手动写钩子,可以参考下这个大佬文章,不过好在后面我们要用框架,框架实现了loader来处理,所以不用担心。

七、Tree Shaking

tree shaking就是“摇树”,把树上多余枯叶摇下来。也就是把工程里面用不到的代码剔除,让打包后的bundle体积更小。工作原理是静态结构分析,所以只能用于ESM。对于全局引用的文件,要注意是否有副作用,比如全局css、全局js、一些polyfillIIFE文件等,这些就是有副作用的。webpack需要在设置中排除掉这些文件。

webpack默认开启了tree shaking,如果你的文件没有副作用,tree shaking就会生效,删除不要的代码。但是如果你的文件中只要有一个地方被webpack检测到认为有副作用,这个时候就不会对这个文件进行tree shaking。他不会去删除没用的代码,也不符合逻辑和事实。因此,如果你能明确知道哪些文件有副作用,哪些文件没有副作用,可以手动去设置,这也就是官方说的:

image.png

因为是默认开启,所以我们暂时不用配置,我们首先去还原下有副作用的场景:新建math-effect.jsmath-no-effect.js,一个表示有副作用,一个表示没有副作用:

// math-effect.js
// console.log 万一打印打印的这句话是业务需求的呢
// 所以这句话在webpack看来引起了副作用

console.log("aaa")
export function addNum(num1, num2){  return num1 + num2}
// math-no-effect.js
export const name = 'awe'
export function calcNameLen(){  return name.length }
// index.js 中只引用 不进行任何调用
import "./math-effect"
import './math-no-effect'

这个时候我们npm run start,浏览器里找到app.xxxx.js,会发现没有删除这两个文件中的任何代码,这是因为开发环境下不会做tree shaking

运行npm run build,检查生成的生产环境的dist里的app.xxx.js,会发现只打入了math-effect.js,这时候就是tree shaking对没有副作用的文件生效了。

image.png

实际项目情况中,会存在全局css、全局jspolyfillIIFE等情况,webpack提供了一个配置,可以在package.json中显式的配置副作用: sideEffects: true(所有文件都有)`false(所有都没有)\['xxx.js, *.css'](符合配置项的才有副作用),因为我们知道我们写的math-effect.js`其实是没有业务副作用的,我们配置个空的:

// package.json
{
  "sideEffects": [],
}

重新打包查看生成的dist会发现也没有打包math-effect.js了。

需要注意的是这样写就表示所有文件都没有副作用,需要你很明确知道项目其他文件真的没有副作用。

还有一种配置方法是在loader里面进行配置,这个查下资料就知道了。

一般来说在项目里面不用去特殊处理,因为第一个是全局css、全局jspolyfillIIFE等有副作用的文件不会占比很多,第二个是webpack默认会去tree shaking那些没有副作用的文件。 其实到这就差不多了,官方的旧版本文档建议再安装个压缩插件,应该算性能优化了。

npm install terser-webpack-plugin --save-dev
// webpack.config.js
module.exports = {
  optimization: { 
    minimize: true, 
    minimizer: [new TerserPlugin()]
  },
}

八、webpack配置文件优化

因为我们是把所有的webpack配置都写在webpack.config.js中的,但是有些配置在某些环境下我并不想要(比如我不想开发环境还在压缩我的代码),所以我们把不同环境的配置分成不同文件,然后用webpack-merge来合并通用配置就行了。

npm install --save-dev webpack-merge

创建webpack.prod.js和webpack.dev.js就够了,test环境只是需要在webpack.dev.js里面做代理的区分,改造后的文件:

image.png

// webpack.config.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
const { ProgressPlugin} = require('webpack')
const config = require("./index")
const { merge} = require('webpack-merge')
const devConfig = require("./webpack.dev")
const prodConfig = require("./webpack.prod")

/**
 * 获取当前环境的枚举
 * @returns development test production
 */
function getEnv() {  return config.ENV_ENUM[process.env.ENV] }
const isProd = getEnv() === config.ENV_ENUM.prod

const commonConfig = { 
  // 入口  
  entry: {  
    app: path.join(__dirname, '../src', 'index') 
  }, 
  // 输出  
  output: { 
    filename: "[name].[hash].js", 
    path: path.join(__dirname, '../dist'),
  }, 
  module: {   
    rules: [    
      {
        test: /.(png|svg|jpg|jpeg|gif|woff|woff2|eot|ttf|otf)$/i,  
        type: 'asset/resource', 
        generator: {       
          filename: "[file]"     
        } 
      }, 
    ] 
  }, 
  plugins: [  
    new ProgressPlugin(),  
    // 打包之前清理dist  
    new CleanWebpackPlugin(),
    // 从模板自动生成html   
    new HtmlWebpackPlugin({ template: 'index.html' }) 
  ]
}
module.exports = () =>{
  if(isProd){ 
    return merge(commonConfig, prodConfig)  
  } 
  return merge(commonConfig, devConfig)
}
// webpack.dev.js
const config = require('./index')
const { HotModuleReplacementPlugin } = require('webpack')
const path = require('path')
const { merge } = require('webpack-merge')
const isTestEnv = config.ENV_ENUM[process.env.ENV] === config.ENV_ENUM.test
const devConfig = {
  mode: config.ENV_ENUM.dev,
  devtool: 'eval-source-map',
  module: {
    rules: [
      // less css 文件的处理
      {
        test: /\.(le|c)ss$/i,
        exclude: /node_modules/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
              modules: {
                localIdentName: '[path][name]__[local]'
              }
            }
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: ['postcss-preset-env']
              }
            }
          },
          'less-loader'
        ]
      }
    ]
  },
  plugins: [new HotModuleReplacementPlugin()]
}
const getConfig = () => {
  if (isTestEnv) return devConfig
  return merge(devConfig, {
    devServer: {
      static: path.join(__dirname, '../dist'),
      compress: true,
      hot: true,
      port: 8080
    }
  })
}

module.exports = getConfig()
// webpack.prod.js
const config = require('./index')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  mode: config.ENV_ENUM.prod,
  devtool: 'nosources-source-map',
  module: {
    rules: [
      // less css 文件的处理
      {
        test: /\.(le|c)ss$/i,
        exclude: /node_modules/,
        use: [
          // 生产环境进行压缩  本地dev直接使用style-loader
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            /**
             * 开启css module
             * css文件处理之前先加载上一个loader 即postcss-loader 来处理兼容问题
             */
            options: {
              importLoaders: 1,
              modules: {
                localIdentName: '[hash:base64]'
              }
            }
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                // 自动添加不同浏览器前缀 并处理新特性
                plugins: ['postcss-preset-env']
              }
            }
          },
          'less-loader'
        ]
      }
    ]
  },
  optimization: {
    minimize: true,
    minimizer: [
      // 压缩css
      new CssMinimizerPlugin(),
      // 压缩JS
      new TerserPlugin()
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: './css/[name].[contenthash].css'
    })
  ]
}
// package.json中命令修改"
scripts": {  
  "start": "cross-env ENV=dev webpack serve --open --config ./config/webpack.config.js",  
  "build:test": "cross-env ENV=test webpack --config ./config/webpack.config.js", 
  "build": "cross-env ENV=prod webpack --config ./config/webpack.config.js" 
}

九、配置bundle分析

通常打包后需要知道哪些资源占用比较多,这里我们配置一条分析命令,对生产环境打出来的包进行分析,我们采用webpack-bundle-analyzer这个插件来完成这个工作。

config下面新增一个webpack.analyzer.js的配置,这个配置在prod的配置基础上增加一个插件配置就可以了:

// scripts中添加一条命令来启动analyzer
// 之所以不在webpack.config里添加这个插件是因为这个加了这个插件以后就会运行一个devserver来显示你的bundle统计
"scripts": {   
  "analyzer": "cross-env ENV=prod webpack --config ./config/webpack.analyzer.js"  
},
//webpack.analyzer.js
const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer')
const { merge} = require('webpack-merge')
const getConfig = require("./webpack.config")

const analyzerConfig = { 
  plugins: [   
    new BundleAnalyzerPlugin() 
  ]
}

// 注意我们优化以后webpack.config是导出的一个函数
module.exports = merge(getConfig(), analyzerConfig)

这个时候我们npm run analyzer,就会启动一个界面来分析打包后的bundle的情况,更多关于此插件的信息查看这个地址

十、配置代码分离

webpack打包生成的产物叫bundle,我们写的代码是叫module,而从modulebundle的这个过程中,webpack会生成一个中间产物叫chunk,整个打包过程是modulechunkbundle的过程。chunk意思是块,就好比是造房子用的砖一样,代码的某处逻辑用到了“砖”,只去加载这部分就行了。把代码拆分成比较小的chunk,有需要用到某一个chunk的时候才去按需引用,这就是代码分离的由来,代码分离使得我们能获得更小更细的bundle,然后可以按需加载或者并行加载。

代码分离有三种方式:动态引入、入口起点、防止重复等方式。webpack动态引入需要反人类书写代码,不太能让人读懂;入口起点分离只是增多了入口,防止不了重复引入的问题;所以我们采用防止重复这种方式,去抽取公共依赖为一个bundle就可以了。

防止重复里面又可以分两种:一种是入口依赖的方式,在每个入口下面指定依赖的项,然后配置依赖的项的具体内容,详见这个地址;第二种是通过SplitChunksPlugin插件来完成,这个插件被集成在webpack里了,能够拆分一些重复的依赖,但是能够自动拆分的也不是很多:

image.png 我们这里就配置一个很简单的就行了,要搞得够好,需要去深入研究。

// webpack.prod.js 
module.exports = {
  optimization: { 
    splitChunks: {  
      chunks: 'all',  
    },
  },
}

十一、修改为TS项目

TypeScript主要有两个功能:一是提供类型扩展、进行静态检查(强类型语言前端真的该去写写,比如java),这个功能我们只需要开发依赖中安装上typescript包就可以了;二是提供编译能力(tsc),将高版本语法编译为低版本语法以兼容不同时代浏览器或者其他运行环境。因此第二个能力也就和babel形成了竞品。

babel相对于tsc来说,生态要丰富一些,编译产物也更细腻一些,可以去查些资料对比一下(简单甩一篇文章

所以我们只用TS的静态检查和类型扩展,对于编译我们还是用babel,后面会做详细配置。

npm i --save-dev typescript @types/node

因为我们想把webpack的文件配置也改为TS,所以我们还需要装上webpack相关的types包。并且我们要直接运行TSwebpack.config.ts等),所以还需要要装上ts-node

npm install --save-dev ts-node @types/webpack @types/webpack-dev-server

添加tsconfig.json

第一步我们在项目中添加上tsconfig.json,这样从定义的角度上,项目就已经是TS项目了。再去改造我们原来写的文件(只注释了几个比较重要的,没用过TS的建议还是先去看看)。

{
  "compilerOptions": {
    "noEmit": true,     // 只做静态类型 不编译内容
    "module": "esnext",
    "target": "es5",
    "lib": [
      "dom",
      "esnext"
    ],
    "baseUrl": ".",
    "sourceMap": true, // ts中启用source-map必须配置
    "allowJs": true, // 也允许写js文件
    "checkJs": true, // 允许对js文件做检查
    "noImplicitAny": false,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "incremental": true,
    "isolatedModules": true
  },
  "ts-node": { // ts-node对module的重载 后面会讲到
    "compilerOptions": {
      "module": "CommonJS"
    }
  },
  // 起作用的范围
  "include": [
    "src/**/*",
    "config/**/*",
  ],
  // 排除哪些
  "exclude": [
    "node_modules",
    "build",
    "dist",
    "scripts",
  ]
}

webpack添加处理ts

第二步改造我们的文件,包括一些业务配置文件、环境配置文件、webpack配置文件等。

需要注意的两个地方:

我们项目变成TS项目以后,有些自定义变量比如process.env.ENV这种,是检查不到定义的,以及像.less.css.png等资源文件,从定义上TS不认为是模块。所以我们需要写个.d.ts来扩展(一般是在src下写个typing.d.ts, crasrc/react-app-env.d.ts)。

因为我们把webpack的配置文件改为了.ts,所以需要用ts-node去运行他。ts-node只是个node上能运行.ts的扩展,并且node环境是CommonJS规范。在tsconfig.json中我们指定的moduleesnext,所以需要对ts-node重载一个配置(见上面tsconfig.jsonts-node的配置),如果不做这一步,ts-node看见配置里写的import {xxx} from 'xxx'语法是识别不出来到底啥意思的,根本不知道啥玩意。

这里我把webpack配置也顺便调整了一下。

image.png

// src/typing.d.ts
// 图片上的typing.d.ts代码有一些问题
// 下面是修正后的
declare module '*.less'
declare module '*.css'
declare module '*.png'
declare module '*.svg'
declare module '*.jpg'
declare module '*.jpeg'
declare module '*.gif'
declare module '*.bmp'
declare module '*.tiff'
declare namespace NodeJS {
  interface ProcessEnv {     
    ENV: "dev" | "test" | "prod";  
  } 
}
// config/index.ts
// 环境枚举
export const ENV_LIST = {
  DEV: "dev",
  TEST: "test",
  PROD: "prod"
}
// config/webpack.analyzer.ts
import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer'
import { merge } from 'webpack-merge'
import {Configuration} from 'webpack'
import prodConfig from './webpack.prod'

const analyzerConfig: Configuration = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

export default merge<Configuration>(prodConfig,  analyzerConfig)
// config/webpack.common.ts
import {CleanWebpackPlugin} from 'clean-webpack-plugin'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import { join} from 'path'
import {Configuration, ProgressPlugin} from 'webpack'

const commonConfig: Configuration = {
  // 入口
  entry: {
    app: join(__dirname, '../src', 'index')
  },
  // 输出
  output: {
    filename: "[name].[chunkhash].js",
    path: join(__dirname, '../dist'),
  },
  module: {
    rules: [
      {
        test: /\.(png|svg|jpg|jpeg|gif|woff|woff2|eot|ttf|otf)$/i,
        type: 'asset/resource',
        generator: {
          filename: "[file]"
        }
      },
    ]
  },
  plugins: [
    new ProgressPlugin(),
    // 打包之前清理dist
    new CleanWebpackPlugin(),
    // 从模板自动生成html
    new HtmlWebpackPlugin({ template: 'index.html' })
  ]
}

export default commonConfig
// webpack.dev.ts
import { HotModuleReplacementPlugin, Configuration } from 'webpack'
import { join } from 'path'
import { merge } from 'webpack-merge'
import commonConfig from './webpack.common'
// in case you run into any typescript error when configuring `devServer`
import 'webpack-dev-server'

//https://github.com/DefinitelyTyped/DefinitelyTyped/issues/27570
const devConfig: Configuration & { devServer: { [key: string]: any } } = {
  mode: 'development',
  devtool: 'eval-source-map',
  devServer: {
    static: join(__dirname, '../dist'),
    compress: true,
    hot: true,
    port: 8080
  },
  module: {
    rules: [
      // less css 文件的处理
      {
        test: /\.(le|c)ss$/i,
        exclude: /node_modules/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
              modules: {
                localIdentName: '[path][name]__[local]'
              }
            }
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: ['postcss-preset-env']
              }
            }
          },
          'less-loader'
        ]
      }
    ]
  },
  plugins: [new HotModuleReplacementPlugin()]
}

export default merge<Configuration>(commonConfig, devConfig)
// webpack.prod.ts
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'
import TerserPlugin from 'terser-webpack-plugin'
import { Configuration } from 'webpack'
import commonConfig from './webpack.common'
import { merge } from 'webpack-merge'

const prodConfig: Configuration = {
  mode: 'production',
  devtool: 'nosources-source-map',
  module: {
    rules: [
      // less css 文件的处理
      {
        test: /\.(le|c)ss$/i,
        exclude: /node_modules/,
        use: [
          // 生产环境进行压缩  本地dev直接使用style-loader
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            /**
             * 开启css module
             * css文件处理之前先加载上一个loader 即postcss-loader 来处理兼容问题
             */
            options: {
              importLoaders: 1,
              modules: {
                localIdentName: '[hash:base64]'
              }
            }
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                // 自动添加不同浏览器前缀 并处理新特性
                plugins: ['postcss-preset-env']
              }
            }
          },
          'less-loader'
        ]
      }
    ]
  },
  optimization: {
    splitChunks: {
      chunks: 'all'
    },
    minimize: true,
    minimizer: [
      // 压缩css
      new CssMinimizerPlugin(),
      // 压缩JS
      new TerserPlugin()
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: './css/[name].[contenthash].css'
    })
  ]
}

export default merge<Configuration>(commonConfig, prodConfig)
"scripts": { 
  "start": "cross-env ENV=dev webpack serve --open --config ./config/webpack.dev.ts",     "start:prod": "cross-env ENV=prod webpack serve --open --config ./config/webpack.dev.ts",  
  "build:test": "cross-env ENV=test  webpack --config ./config/webpack.prod.ts",   
  "build": "cross-env ENV=prod webpack --config ./config/webpack.prod.ts",   
  "analyzer": "cross-env ENV=prod webpack --config ./config/webpack.analyzer.ts"  
},

其他.js文件也挨着改为.ts就好了,给这些文件添加一些ts的类型,不要让.ts文件只改一个后缀名为.js就能继续运行(后面会用来验证,这一步需要做)。一些配置文件,比如根目录下以.js结尾的配置文件是不能更改的。

这个时候我们运行npm run start,但是发现项目是跑不起来的:

image.png 项目找不到src下的index,因为webpack默认不支持TypeScript,他不认识.ts。在官方的模块和模块解析(这里要去看下)也有说明。

image.png 所以这个时候我们首先要做两个步骤:

第一个步骤是添加解析的扩展名,让webpack能够在找我们引用的模块的路径时,能够找到,所以我们在webpack.common.ts中加上配置:

resolve: {  extensions: [ '.tsx', '.ts', '.jsx', '.js', '.less', '.css' ],},

这个时候如果我们在引用的地方去加上.ts后缀名,而不是省略后缀名,会报个错误:

image.png 这是一个很有意思的问题,谷歌搜索TS 2691,你会发现官方开摆这里,这个没有去修,意思就是让我们省略后缀名。行吧,那就摆烂吧~

006qOO1Xly1grdi7it9zeg308c07iwlr.gif

项目到这里以后npm run start发现还是跑步起来,因为webpack认识.ts了,但是不知道怎么处理,上一步骤只是加了个解析规则而已,因此有了我们第二步。

第二个步骤是我们需要让webpack知道怎么去处理.ts。回顾webpack的原理可以知道,我们需要添加loaderwebpack能处理自己不认识的资源,所以我们需要添加处理.tsloader。前面也说了,有tscbabel两个选择,我们选择的是babel,下一节我们就配置babel-loader

十二、添加babel

最开始的时候,babel只是处理js,没有处理.ts的能力,所以那个阶段很难受,混用babelTS,然后增加各种配置。先使用tscts转为js,再用babel处理成低版本js。到babel7以后,解决了这个问题,解决办法就是babel直接抹去所有的ts,还是按照js那样编译。babel本身做编译,所以他们的思想是你TS做好你自己的类型定义、静态检查就完事了,到编译代码这一步的时候,我也不去管你的类型啊什么的,全部去掉后按js来编译就行了。

npm install --save-dev babel-loader @babel/core @babel/preset-env @babel/preset-typescript

项目根目录中添加babel.config.json

// babel.config.json
// preset-typescript 是官方针对ts的一个推荐集合
// preset-env 是利用.browserslistrc针对目标环境来生成对应的低版本代码  可以查下资料
{  "presets": ["@babel/preset-typescript", "@babel/preset-env"]}

这个时候我们npm run start,项目再一次跑起来了,当然你可以再装一些babelplugin,来继续优化。

十三、添加React

安装React依赖包,React把核心和渲染环境进行了区分,核心包为react(叫react-core可能语义上更好一点),渲染环境包为react-domreact-native等,我们这个demo是开发web,所以我们使用react-dom

npm i react react-dom
npm i -D @types/react @types/react-dom

然后我们改造项目,将根目录上的index.ts改为app.tsxindex.less改为global.less,去除掉前面验证功能后留下的冗余文件,增加views视图文件夹并写一个界面welcome,目录结构:

这里着重强调下.tsx扩展语法和vue那种模板写法的区别,请明白这一点tsx是扩展语法,语法。这里搞清楚了后面就会很自然。

image.png

//app.tsx
import ReactDOM from 'react-dom'
import React from 'react'
import './global.less'
import Welcome from './views/welcome'

ReactDOM.render(
  <React.StrictMode>
    <Welcome />
  </React.StrictMode>,
  document.getElementById('app')
)
// src/welcome/index.tsx
import React, { FC } from 'react'
import styles from './index.less'

// index.less中随便写样式
const Welcome: FC = () => {
  return <div className={styles.welcome}>欢迎 主页</div>
}

export default Welcome

配置tsconfig.jsonbabel.config.json支持React

//tsconfig.json 在compilerOptions添加jsx转换
{  
  "compilerOptions": { 
    "jsx": "react-jsx",
  }
}
// 添加react的babel集
npm install --save-dev @babel/preset-react

// 配置babel.config.json
{ 
  "presets": ["@babel/preset-react", "@babel/preset-typescript", "@babel/preset-env"]
}

修改webpack的配置支持tsxjsx,修改入口为app.tsx

// 入口 修改为app
entry: {
  app: join(__dirname, '../src', 'app')
},
// 添加.tsx .jsx扩展
resolve: {
  extensions: [ '.ts', '.tsx' , '.js','.jsx', '.less', '.css' ],
},
module: {
  rules: [
    {
    // 添加扩展tsx jsx扩展 添加presets
      test: /\.(tsx?|jsx?)$/,
      exclude: /node_modules/,
      use: {
        loader: "babel-loader",
        options: {
          presets: ['@babel/preset-react', '@babel/preset-env', '@babel/preset-typescript']
        }
      }
    },
  ]
},

十四、添加UI库

UI库根据自身选择就可以了,我们拿用的比较多的antd来举例,打开他们官网的指导文档

注意的是有两个问题:一是样式文件,antd说是直接引用,但是我们在配置webpacklesscss文件处理的时候主动忽略了node_modules,所以需要在webpack中单独处理;二是antd这种组件库的样式文件都是编译好了的,不适用于我们配置的css module,所以单独配置的时候要去掉css module:

//webpack.dev.ts 和webpack.prod.ts中修改一下less-loader配置
{
  loader: "less-loader",
  options: {
    lessOptions: {
      javascriptEnabled: true
    }
  }
}
// wepack.common.ts中单独处理node_modules中的样式文件
{
  test: /\.(le|c)ss$/i,
  include: /node_modules/,
  use: [
    'style-loader',
    {
      loader: "css-loader",
      options: {
        importLoaders: 1,
      }
    },
    {
      loader: "less-loader",
      options: {
        lessOptions: {
          javascriptEnabled: true
        }
      }
    }
  ]
},
// app.tsx中引入antd的样式文件
import 'antd/dist/antd.less';

十五、添加状态管理

这里我们就以redux为例,当然你有其他喜欢的状态管理库也可以添加其他的。

添加reduxreact-redux@reduxjs/toolkitredux是状态管理库,可以用于react,可以用于vue,可以用于原生js获者其他框架。react-redux是为了方便在react中使用redux,而开发的一个中间件。又因为redux写法有点累赘,遂开发了@reduxjs/toolkit(这东西有点像dva,人称小dva)来简化写法,就是这么回事。

这里提一个以前说的,如果包是用ts写的,就会自带类型文件,如果不是,再去下载@types对应的包。

npm i redux react-redux @reduxjs/toolkit

安装好以后我们在src下新建文件夹store,里面新建index.tsrootReducer.ts和我们的业务模块state,这里我们就举个用户模块例子(注意这里需要一定的react-redux@reduxjs/toolkit基础,如果写着费劲,就先去简单看一下。)

image.png

// state/index.ts
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';

const store = configureStore({
  reducer: rootReducer,
});

export type AppDispatch = typeof store.dispatch;
export const dispatch = store.dispatch;
export default store;
// state/rootReducer.ts
import { combineReducers } from '@reduxjs/toolkit';
import userReducer from './user';

const rootReducer = combineReducers({
  user: userReducer,
});

export type RootState = ReturnType<typeof rootReducer>;

declare module 'react-redux' {
  // 为了方便在业务中能直接读到对应业务模块state类型
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
  interface DefaultRootState extends RootState {}
}

export default rootReducer;
// state/user.ts
import type { PayloadAction } from '@reduxjs/toolkit'
import { createSlice } from '@reduxjs/toolkit'

export interface UserStateProps {
  userId: string
  name: string
  phone: string
}
// 初始化的值
const initState = (): UserStateProps => {
  return {
    userId: '',
    name: '',
    phone: ''
  }
}
const userSlice = createSlice({
  name: 'user',
  initialState: initState(),
  reducers: {
    init(state) {
      const initS = initState()

      Object.keys(initS).forEach((key) => {
        state[key] = initS[key]
      })
    },
    setUserInfo(state, action: PayloadAction<Partial<UserStateProps>>) {
      const inputState = action.payload

      Object.keys(inputState).forEach((key) => {
        state[key] = inputState[key]
      })
    }
  }
})

export const { init, setUserInfo } = userSlice.actions

export default userSlice.reducer

然后在React中挂在store,改造下app.tsx

// src/app.tsx
import ReactDOM from 'react-dom'
import React from 'react'
import store from './store'
import './global.less'
import 'antd/dist/antd.less'
import Welcome from './views/welcome'
import { Provider } from 'react-redux'

ReactDOM.render(
  <React.StrictMode>
    // 挂载store
    <Provider store={store}>
      <Welcome />
    </Provider>
  </React.StrictMode>,
  document.getElementById('app')
)

业务组件上我们测试一下:

import { Button, message } from 'antd'
import React, { FC, Fragment } from 'react'
import styles from './index.less'
import { useDispatch, useSelector } from 'react-redux'
import { setUserInfo } from '../../store/user'

const Welcome: FC = () => {
  const dispath = useDispatch()
  const { name } = useSelector((state) => state.user)

  return (
    <Fragment>
      <Button
        type='primary'
        danger
        onClick={() => {
          dispath(setUserInfo({ name: 'awefeng' }))
          message.success('登录成功')
        }}
      >
        登录 awefeng
      </Button>
      <div className={styles.welcome}>欢迎: {name}</div>
    </Fragment>
  )
}

export default Welcome

Jan-19-2022 17-00-59.gif

可以看到设置读取都木得问题,并且reduxdev也能看见过程。

十六、添加Router

前端路由在我公众号里面以前已经说过一次,react-ruoter也分为了核心和运行环境,因为我们主要是在浏览器中使用,例子中我们就安装react-router-corereact-router-dom。但是今天我不准备说这里,因为路由版块比较大,涉及到配置式书写、权限控制、图标、layout菜单、懒加载等。所以后面单独开一个系列写项目中的router设计,到时候就用react-router,所以在这里先埋个坑。

react-router的基础使用也比较简单,可以看看他们官网。

十七、添加请求处理文件

添加一个统一请求文件用来处理请求,一般会选取某一个库然后结合业务,写入一定的处理逻辑,这里我们选用axios来写一个简单的例子:

npm install axios

src目录下新建文件夹request,新建index.ts。我这里就直接贴书写步骤,然后给个简单的例子文件。

  1. 首先写一个大体的空心架子,请求的时候我们填入url、填入请求方法、查询参数、数据、其他配置等。
  2. 然后我们去完善传入的axios的配置,这个时候我们也需要加上类型。
  3. 根据前后端对齐的API规范文档去处理axois的返回。
  4. 继续完善config传入的参数,对请求进行完善(包括鉴权、headers、超时、拦截、取消、并发等)。
  5. 最后根据不同的环境(开发、测试、预发、线上)抽离出不同的配置文件。
  6. 优化点是可以抽出多个axios实例,来供不同的场景调用。
// src/request/index.ts
// 一个简单的例子  不一定准确
import axios from 'axios';
import type { Method, AxiosRequestConfig, AxiosResponse, } from 'axios';
import { notification } from 'antd';

const fetch = <R>(
  url: string,
  {
    method,
    baseUrl = '',
    params = undefined,
    data = undefined,
    then = undefined,
    ...others
  }: { method: Method; baseUrl?: string; params?: Object; data?: any; then: Function, [key: string]: any },
): Promise<R> => {

  /**
   * 1. 是否有自定义的header 或者其他从config传入的配置
   * 从这里进行完善
   */
  let headers = {
    'Content-Type': 'application/json',
    //TODO 鉴权?
  };

  if (others.headers) {
    headers = Object.assign(headers, others.headers);
  }
  // 2. 定义请求的Config
  const requestConfig: AxiosRequestConfig = {
    method,
    url,
    timeout: 10000,
    data,
    headers,
    ...others,
  };

  // 3. 根据环境和传参决定baseurl
  // 优化点 抽出不同的axios实例
  if (baseUrl) {
    requestConfig.baseURL = baseUrl;
  } else {
    // 开发环境根据 devServer proxy来替换
    // 其他环境从配置读取
    if (process.env.ENV !== ENV.DEV) {
      requestConfig.baseURL = config.baseUrl;
    }
  }

  // 如果是自己传入了后续处理方法 则用后续的处理方法
  if (then) {
    return axios(requestConfig).then(then);
  }

  return axios(requestConfig)
    .then((res) => {
      return new Promise((resolve, reject) => {
        // 根据API规范文档进行处理
        const { code } = res.data;
        if (code === 0 ) {
          resolve(res.data as R);
        } else {
          reject(res);
        }
      });
    })
    .catch(async (res: AxiosResponse) => {
      // 统一的错误请求处理
      // TODO 登录过期 API规范文档规定的报错code 错误兜底 等
      const { status, data } = res;
      if (status === 401) {
        await new Promise<void>((resolve) => {
          notification.error({ message: data?.msg ?? '鉴权过期,请重新登录!' });
          setTimeout(() => {
            resolve();
          }, 2000);
        }).then(logOut);
      } else {
        // TODO  其他情况
        // 抛出业务错误信息
        throw new Error(data?.msg ?? '请求错误~,服务器开小差了');
      }
    });
};

export default fetch;

然后我们请求的时候在src下建好api文件夹统一归档,写一个请求例子:

// src/api/user.ts
import fetch from '../request/index'

// 获取用户信息
export function getUserInfo(
  userId: string
): Promise<{ data: { userId: string; name: string; blabala: any } }> {
  return fetch('/user/info', { method: 'GET', params: { userId } })
}

十八、统一npm源、版本

统一npm源很简单,工程目录下添加.npmrc,考虑到CI/CDrunner打包以及墙的原因等,建议还是用taobao镜像:

.npmrcregistry=https://registry.npm.taobao.org/
@company:registry=https://registry.npm.company.com/

node的统一是很有必要的,node的更新,不管大小,可能都会有改动;某一些依赖包,可能会要求node版本,为了保证项目在每个人(甚至服务器)上的一致,所以保持node统一、npm版本统一是很有必要的。关系到安装依赖的规则,可以查一下package-lock.jsonnpm版本的高低,package-lock.json的内容可能是不一样的(是不是经常出现这种情况:为什么同事安装依赖跑得好好的,到你电脑上就不行😶)。 至于怎么统一,你可以写脚本约束,可以强制规定(或者你就摆烂,不管这些,能跑就行),nvm管理起来。

十九、路径别名

这个其实应该在webpack配置那一节就顺便做了,在webpack的解析中配置好别名以后还需要在tsconfig.json中也配置好解析路径:

// config/webpack.common.ts
resolve: {
  alias: {
    "@": resolve(__dirname, '../src'),
    "#": resolve(__dirname, '../config')
  },
  extensions: [ '.ts', '.tsx' , '.js','.jsx', '.less', '.css' ],
},

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": [
        "./src/*"
      ],
      "#/*": [
        "./config/*"
      ]
    }
  }
}

这个时候我们就可在代码中使用了:

// 举个例子
import { setUserInfo } from "@/store/user";
import {ENV_LIST} from "#/index"

二十、添加.editorconfig和.gitignore

.editorconfig配置文件是对IDE的编码约束,并且需要IDE的支持(支持的IDE以及需要装插件才能支持的IDE,在官网有详细的展示)。比如我使用的是VS Code,需要在插件中安装EditorConfig for VS Code插件,才能使用此配置。

// 根目录下 .editorconfig

# see http://editorconfig.org
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

[Makefile]
indent_style = tab

git的忽略文件,配置工程中需要在提交的时候忽略的文件,git官方针对每种不同的开发环境推荐了不同的配置,可以参考下文档

# see https://github.com/github/gitignore/blob/main/Node.gitignore
/dist
.DS_Store
*.lock

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*

# Dependency directories
node_modules/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional stylelint cache
.stylelintcache

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

二十一、添加eslint、prettier和stylelint

eslint主要用来规范代码质量,需要我们自己安装包

npm i --save-dev eslint

先写一个初始配置放在根目录上

// .eslintrc.js
module.exports = {
  // 设定当前目录为eslint根目录
  root: true,
  // 设定eslint的env
  env: {
    es6: true,
    browser: true,
    node: true
  },
  parserOptions: {
    ecmaVersion: 6,
    sourceType: "module",
    jsx: true
  },
  extends: ["eslint:recommended"],
  rules: {
    //暂时不写
  }
};

我使用的IDEVS Code,需要安装插件ESLint,在控制台查看eslint server是否启动成功和配置文件是否报错:

image.png

这个时候我们在根目录下新建一个测试用的js文件,写一点代码,eslint是能检测到的(这里有个坑是以.开头的文件是默认忽略了,和node_modules一样,但是我没在官方找到说明,有兴趣的查一查)。

接着继续修改上面的配置,以支持Reactts,给两个参考链接:

Eslint-TS

Eslint-React

npm i -D @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react

rules中添加一些自定义规则,修改后的配置如下:

module.exports = {
  // 设定当前目录为eslint根目录
  root: true,
  // 设定eslint的env
  env: {
    es2021: true,
    browser: true,
    node: true
  },
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true
    }
  },
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint', 'react'],
  extends: [
    'eslint:recommended',
    'plugin:react/recommended',
    // 工程里面用了最新react-jsx模式 所以要加这个
    'plugin:react/jsx-runtime',
    'plugin:@typescript-eslint/recommended'
  ],
  settings: {
    react: {
      pragma: 'React',
      version: 'detect'
    }
  },
  rules: {
    // 规则具体含义看下官网
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-inner-declarations': 'warn',
    'block-scoped-var': 'error',
    'default-case': 'error',
    'no-caller': 'error',
    'no-eval': 'error',
    'array-element-newline': ['warn', { multiline: true, minItems: 4 }],
    'comma-dangle': ['error', 'never'],
    'max-len': [
      'error',
      {
        code: 100,
        tabWidth: 2,
        comments: 80
      }
    ],
    semi: ['error', 'never'],
    'padding-line-between-statements': [
      'error',
      // 给代码加上必要的空行风格
      {
        blankLine: 'always',
        prev: ['const', 'let', 'var', 'block', 'block-like'],
        next: '*'
      },
      {
        blankLine: 'always',
        prev: ['import'],
        next: ['const', 'let', 'var', 'block', 'block-like', 'expression']
      },
      { blankLine: 'never', prev: ['import'], next: ['import'] },
      { blankLine: 'never', prev: ['const'], next: ['const'] }
    ]
  }
}

配置好忽略文件:

# .eslintignore
# 目前就三个就可以了 
# 也可以不写node_modules eslint会默认忽略
node_modules
/dist
/src/asset

prettier前面也说了作用了,我们需要在eslint中融入prettier,并且让和prettier冲突的都以prettier为准,给个参考链接:

Eslint-Prettier

npm i -D eslint-plugin-prettier prettier eslint-config-prettier

改造eslint支持prettier

// 只贴改动部分
module.exports = {
  // 插件新增prettier
  plugins: ["@typescript-eslint", "react", "prettier"],
  // 按官方推荐的新的方式配置 
  // 使用 "plugin:prettier/recommended"
  extends: [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:react/jsx-runtime",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
  ]
}

新建.prettier.js,来配置prettier,配置选项见官方文档

// .prettier.js
module.exports = {
  printWidth: 80, //一行的字符数,如果超过会进行换行,默认为80
  tabWidth: 2, //一个tab代表几个空格数,默认为80
  useTabs: false, //是否使用tab进行缩进,默认为false,表示用空格进行缩减
  singleQuote: true, //字符串是否使用单引号,默认为false,使用双引号
  jsxSingleQuote: true, // 在JSX中是否使用单引号
  semi: false, //行位是否使用分号,默认为true
  trailingComma: 'none', //是否使用尾逗号,有三个可选值"<none|es5|all>"
  bracketSpacing: true, //对象大括号直接是否有空格,默认为true,效果:{ foo: bar }
  parser: 'typescript' //代码的解析引擎
}

同样配置好忽略文件:

# .prettierignore

package.json
/dist
.DS_Store
.eslintignore
*.png
.editorconfig
.gitignore
.prettierignore

添加styleint这个留给你们自己去做了~(写不动了~)

二十二、格式化

配置格式化命令

完成上面的工作后,工程上代码规范有了,但是只是提示,更改的时候需要自己运行命令,因此我们在package.json中添加一条命令,用来修复eslint检查出的问题:

"scripts": {  
  "lint": "eslint --fix --ext .js,.jsx,.ts,.tsx ."
 },

这里有个坑就是eslint对于node的版本是有要求的,如果你运行这条命令出错的话,先看看node版本对不对,这也是上面我们说要控制版本的原因之一。

运行完命令以后,你会发现有些地方可能还是没有修复,这个时候鼠标指着报错处查看原因。有一种情况是prettier的规则和我们自己在rules写的eslint规则冲突了。

// 比如这条规则 和prettier是冲突
'array-element-newline': ['warn', { multiline: true, minItems: 4 }]

原因是这样的:prettier的某一些配置和我们自己写的配置冲突,然后prettier是不会以我们自己写的优先的(也就是大家说的prettier有偏见),目前官方给的解决办法就是你自己在上面加个忽略(开摆:啊对对对,这个可以查一下,我记得有人github提了issue)。所以,如果真正的要做到自己或者团队定制化,prettier只可以作为个参考。

IDE自动格式化

这个就需要基于不同的IDE进行不同的配置了。有些IDE可能默认支持,不需要配置,检测到你有eslint配置就会按照配置进行格式化,有些IDE可能还是需要你写个工作区配置文件。我拿VS Code来举例,VS Code是需要配置的,工作区配置有两种方法:第一种是根目录下新建.vscode,添加settings.json手动编写;第二种是菜单-code-首选项-设置-工作区,进行勾选设置。

// .vscode/settings.json
{
  "eslint.alwaysShowStatus": true,
  "editor.codeActionsOnSave": {
    "source.fixAll": true
  }
}

这个时候我们在写完代码保存的时候,就会自动格式化了。

还有个优化点是,每次提交的时候不需要再把全部文件去格式化一下,只需要格式化我们有改动的文件即可,这一部分留个作业自己去做(lint-staged)。

二十三、Git约束

如果团队成员的IDE需要手动配置或者不支持,或者团队成员也没有配置。这个时候他写的代码可能还没有经过格式化,就推到远端仓库去了。为了避免这种情况出现,需要在git提交之前运行我们的命令来格式化代码,并且能够做到规范提交信息是最好的。基于这两点需求,我们召唤“哈士奇”,husky是一个git钩子库,可以在提交前用来lint代码,规范提交信息、跑测试等。husky在7.0版本以后采用的是配置文件脚本方式,我们用官方的安装指引

npx husky-init

修改pre-commit钩子里的内容,需要在pre-commit里完成重新格式化并且重新提交工作,所以pre-commit脚本改造下,对应的我们的执行命令也改造下:

// .husky/pre-commit   简单例子仅供参考

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

echo '1. 执行eslint'
npm run lint
echo '2. 执行prettier'
npx prettier --write
echo '执行完成,重新添加文件'
git add -A
// package.json
{
  "scripts": {
    "lint": "npm run lint:js && npm run lint:style",
    "lint:js":"eslint --fix --ext .js,.jsx,.ts,.tsx .",
    "lint:style": "echo '写不动了,此处配置styleling命令去格式化less css'",
  }
}

这时候我们走一遍提交流程:

image.png 第一个工作格式化完成后,第二个工作我们需要检测提交信息,防止乱写,保持统一。同样的,按照官方给的示例添加一个commit-msg钩子:

npx husky add .husky/commit-msg

然后我们这里编辑commit-msg脚本,去检测提交信息是否符合我们的需求:

// /husky/commit-msg
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# 本人bash脚本不太熟 
# 所以用node运行我们写的限制脚本
node ./commit-msg.js $1
// 根目录 commit-msg.js
/** 
 * 根目录下 commonjs环境
 * chalk5是ESM commonjs中需要使用chalk4.x
 * npm i -D chalk@4.1.2
 * 记得eslint下忽略这个文件
 * 
 * 我抄的umijs fabric的检测 
 * 他也是抄的别人的
 */

 const chalk = require("chalk")
 const msgPath = process.argv[2]
 
 const msg = require('fs').readFileSync(msgPath, 'utf-8').trim()
 
 const commitRE =
   /^(((\ud83c[\udf00-\udfff])|(\ud83d[\udc00-\ude4f\ude80-\udeff])|[\u2600-\u2B55]) )?(revert: )?(feat|fix|docs|UI|refactor|⚡perf|workflow|build|CI|typos|chore|tests|types|wip|release|dep|locale): .{1,50}/
 
 if (!commitRE.test(msg)) {
   console.error(
     `  ${chalk.bgRed.white(' ERROR ')} ${chalk.red(`提交日志不符合规范`)}\n\n${chalk.red(
       `  合法的提交日志格式如下(emoji 选填):\n\n`,
     )}
     ${chalk.green(`💥 feat: 添加了个很棒的功能`)}
     ${chalk.green(`🐛 fix: 修复了一些 bug`)}
     ${chalk.green(`📝 docs: 更新了一下文档`)}
     ${chalk.green(`🌷 UI: 修改了一下样式`)}
     ${chalk.green(`🏰 chore: 对脚手架做了些更改`)}
     ${chalk.green(`🌐 locale: 为国际化做了微小的贡献`)}
     ${chalk.red(`更多提交前缀查看commit-msg.js\n`)}`
   )
   process.exit(1)
 }

总结

到这里我们搭建的就差不多了,圆润圆润就可以使用了,至此整个从零搭建工程就搞定了,整个系列内容可能有出入、有错误、有未配置、疏漏的地方,请海涵、指出,谢谢。

完整的工程目录:

image.png

总结下大致步骤和思路:

  1. 新建文件夹并初始化为npm项目
  2. 引入打包工具并写一个最最基础的配置文件
  3. 配置环境以及对应的打包脚本命令
  4. 不同环境的打包配置文件区分开
  5. 打包配置文件中添加资源文件处理
  6. 搭建开发环境(devServer)
  7. 配置HMR、Tree Shaking、Code Splitting等
  8. 增加打包后的产物分析(webpack-bundle-analyzer)
  9. 项目修改为ts项目
  10. 添加babel-loader处理ts
  11. 添加react,修改配置以让工程支持tsx、jsx
  12. 添加UI库
  13. 添加状态管理
  14. 添加路由
  15. 添加请求处理
  16. 添加工程约束文件
  17. 重新整理打包配置文件,进行提取优化,抽离配置文件等
  18. 添加自动构建/自动部署
  19. 添加一个工程说明文件(工程干什么的、用了哪些技术、怎么启动怎么玩的、提交检查、工作流程、部署说明等等)

这些配置一点都不难,只是需要你知道每种技术是干什么的,然后去找相应的配置就行了。整个从零构建一个工程,除了路由部分,应该都是完成了。我放了个demogithub上,大家可以参考一下:

github.com/awefeng/fe-…

欢迎star(多点一点⭐️吧,各位老爷)

欢迎关注我的公众号:咪仔和汤圆

我是”有两只臭猫“,一个养了两只猫,和大家一起学习前端的朋友~

006qOO1Xly1grdi7it9zeg308c07iwlr.gif

ceeb653ely8gqq84fia6aj20gk0gkmyp.jpeg