阅读 2281

[2.7w字]我是这样搭建 React+Typescript项目环境的(下)

哎,分篇是真的难受~不过咱们继续!

支持 React

终于来到我们 React 的支持环节了,美好的开始就是安装 react 和 react-dom :

npm install react react-dom -S
复制代码

-S 相当于 --save , -D 相当于 --save-dev 。

其实安装了这两个包就已经能使用 jsx 语法了,我们在 src/index.js 中输入以下代码:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(<App />, document.querySelector('#root'))
复制代码

src/app.js 中输入以下示例代码:

import React from 'react'

function App() {
  return <div className='App'>Hello World</div>
}

export default App
复制代码

然后修改 webpack.common.js 中 entry 字段,修改入口文件为 index.js :

module.exports = {
  entry: {
+   app: resolve(PROJECT_PATH, './src/index.js'),
-   app: resolve(PROJECT_PATH, './src/app.js'),
  },
}
复制代码

如果这时候,你无论尝试 npm run start 还是 npm run build ,结果都会报错:

诶!为啥啊,我不是都安装了 react 了吗,咋还不行啊? 因为 webpack 根本识别不了 jsx 语法,那怎么办?使用 babel-loader 对文件进行预处理。

在此,强烈建议大家先阅读一篇关于 babel 写的很好的文章:不容错过的 Babel7 知识,绝对的收获满满,我知道在自己文章中插入一个链接,让读者去阅读再回来接着读这种行为挺让人反感的,我看别人文章时也有这种感觉,但是在这里我真的不得不推荐,一定要读!一定要读!一定要读!

好了,安装该有的包:

npm install babel-loader @babel/core @babel/preset-react -D
复制代码

babel-loader 使用 babel 解析文件;@babel/core 是 babel 的核心模块;@babel/preset-react 转译 jsx 语法。

在根目录下新建 .babelrc 文件,输入以下代码:

{
  "presets": ["@babel/preset-react"]
}
复制代码

presets 是一些列插件集合。比如 @babel/preset-react 一般情况下会包含 @babel/plugin-syntax-jsx 、 @babel/plugin-transform-react-jsx 、 @babel/plugin-transform-react-display-name 这几个 babel 插件。

接下来打开我们的 webpack.common.js 文件,增加以下代码:

module.exports = {
	// other...
  module: {
    rules: [
      {
        test: /\.(tsx?|js)$/,
        loader: 'babel-loader',
        options: { cacheDirectory: true },
        exclude: /node_modules/,
      },
      // other...
    ]
  },
  plugins: [ //... ],
}

复制代码

注意,我们匹配的文件后缀只有 .tsx 、.ts 、 .js ,我把 .jsx 的格式排除在外了,因为我不可能在 ts 环境下建 .jsx 文件,实在要用 jsx 语法的时候,用 .js 不香吗?

babel-loader 在执行的时候,可能会产生一些运行期间重复的公共文件,造成代码体积大冗余,同时也会减慢编译效率,所以我们开启 cacheDirectory 将这些公共文件缓存起来,下次编译就会加快很多。

建议给 loader 指定 include 或是 exclude,指定其中一个即可,因为 node_modules 目录不需要我们去编译,排除后,有效提升编译效率。

现在,我们可以 npm run start 看看效果了!其实 babel 还有一些其他重要的配置,我们先把 TS 支持了再一起搞!

支持 TypeScript

webpack 模块系统只能识别 js 文件及其语法,遇到 jsx 语法、tsx 语法、文件、图片、字体等就需要相应的 loader 对其进行预处理,像图片、字体这种我们上面已经配置了,为了支持 React,我们使用了 babel-loader 以及对应的插件,现在如果要支持 TypeScript 我们也需要对应的插件。

1. 安装对应 babel 插件

@babel/preset-typescript 是 babel 的一个 preset,它编译 ts 的过程很粗暴,它直接去掉 ts 的类型声明,然后再用其他 babel 插件进行编译,所以它很快。

废话补多少,先来安装它:

npm install @babel/preset-typescript -D
复制代码

注意:我们之前因为 Eslint 的配置地方需要先安装 Typescript,所以之前安装过的就不用再安装一次了。

然后修改 .babelrc :

{
  "presets": ["@babel/preset-react", "@babel/preset-typescript"]
}
复制代码

presets 的执行顺序是从后到前的。根据以上代码的 babel 配置,会先执行 @babel/preset-typescript ,然后再执行 @babel/preset-react 。

2. tsx 语法测试

src/ 有以下两个 .tsx 文件,代码分别如下:

index.tsx :

import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'

ReactDOM.render(
  <App name='vortesnail' age={25} />,
  document.querySelector('#root')
)
复制代码

app.tsx :

import React from 'react'

interface IProps {
  name: string
  age: number
}

function App(props: IProps) {
  const { name, age } = props
  return (
    <div className='app'>
      <span>{`Hello! I'm ${name}, ${age} years old.`}</span>
    </div>
  )
}

export default App
复制代码

很简单的代码,在 <App /> 中输入属性时因为 ts 有了良好的智能提示,比如你不输入 name 和 age ,那么就会报错,因为在 <App /> 组件中,这两个属性时必须值!

但是这个时候如果你 npm run start ,发现是编译有错误的,我们修改 webpack.common.js 文件:

module.exports = {
  entry: {
    app: resolve(PROJECT_PATH, './src/index.tsx'),
  },
  output: {//...},
  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.json'],
  },
}
复制代码

一来修改了 entry 中的入口文件后缀,变为 .tsx 。

二来新增了 resolve 属性,在 extensions 中定义好文件后缀名后,在 import 某个文件的时候,比如上面代码:

import App from './app'
复制代码

就可以不加文件后缀名了。webpack 会按照定义的后缀名的顺序依次处理文件,比如上文配置 ['.tsx', '.ts', '.js', '.json'] ,webpack 会先尝试加上 .tsx 后缀,看找得到文件不,如果找不到就依次尝试进行查找,所以我们在配置时尽量把最常用到的后缀放到最前面,可以缩短查找时间。

这个时候再进行 npm run start ,页面就能正确输出了。

既然都用上了 Typescript,那 React 的类型声明自然不能少,安装它们:

npm install @types/react @types/react-dom -D
复制代码

3. tsconfig.json 详解

每个 Typescript 都会有一个 tsconfig.json 文件,其作用简单来说就是:

  • 编译指定的文件
  • 定义了编译选项

一般都会把 tsconfig.json 文件放在项目根目录下。在控制台输入以下代码来生成此文件:

npx tsc --init
复制代码

打开生成的 tsconfig.json ,有很多注释和几个配置,有点点乱,我们就将这个文件的内容删掉吧,重新输入我们自己的配置。

此文件中现在的代码为:

{
  "compilerOptions": {
    // 基本配置
    "target": "ES5",                          // 编译成哪个版本的 es
    "module": "ESNext",                       // 指定生成哪个模块系统代码
    "lib": ["dom", "dom.iterable", "esnext"], // 编译过程中需要引入的库文件的列表
    "allowJs": true,                          // 允许编译 js 文件
    "jsx": "react",                           // 在 .tsx 文件里支持 JSX
    "isolatedModules": true,
    "strict": true,                           // 启用所有严格类型检查选项

    // 模块解析选项
    "moduleResolution": "node",               // 指定模块解析策略
    "esModuleInterop": true,                  // 支持 CommonJS 和 ES 模块之间的互操作性
    "resolveJsonModule": true,                // 支持导入 json 模块
    "baseUrl": "./",                          // 根路径
    "paths": {																// 路径映射,与 baseUrl 关联
      "Src/*": ["src/*"],
      "Components/*": ["src/components/*"],
      "Utils/*": ["src/utils/*"]
    },

    // 实验性选项
    "experimentalDecorators": true,           // 启用实验性的ES装饰器
    "emitDecoratorMetadata": true,            // 给源码里的装饰器声明加上设计类型元数据

    // 其他选项
    "forceConsistentCasingInFileNames": true, // 禁止对同一个文件的不一致的引用
    "skipLibCheck": true,                     // 忽略所有的声明文件( *.d.ts)的类型检查
    "allowSyntheticDefaultImports": true,     // 允许从没有设置默认导出的模块中默认导入
    "noEmit": true														// 只想使用tsc的类型检查作为函数时(当其他工具(例如Babel实际编译)时)使用它
  },
  "exclude": ["node_modules"]
}
复制代码

compilerOptions 用来配置编译选项,其完整的可配置的字段从这里可查询到; exclude 指定了不需要编译的文件,我们这里是只要是 node_modules 下面的我们都不进行编译,当然,你也可以使用 include 去指定需要编译的文件,两个用一个就行。

接下来对 compilerOptions 重要配置做一下简单的解释:

  • target 和 module :这两个参数实际上没有用,它是通过 tsc 命令执行才能生成对应的 es5 版本的 js 语法,但是实际上我们已经使用 babel 去编译我们的 ts 语法了,根本不会使用 tsc 命令,所以它们在此的作用就是让编辑器提供错误提示。

  • isolatedModules :可以提供额外的一些语法检查。

比如不能重复 export :

import { add } from './utils'
add()

export { add } // 会报错
复制代码

比如每个文件必须是作为独立的模块:

const print = (str: string) => { console.log(str) } // 会报错,没有模块导出

// 必须有 export
export print = (str: string) => { 
  console.log(str) 
}
复制代码
  • esModuleInterop :允许我们导入符合 es6 模块规范的 CommonJS 模块,下面做简单说明。

比如某个包为 test.js :

// node_modules/test/index.js
exports = test
复制代码

使用此包:

// 我们项目中的 app.tsx
import * as test from 'test'
test()
复制代码

开启 esModuleInterop 后,直接可如下使用:

import test from 'test'
test()
复制代码

接下来我们着重讲下 baseUrl 和 paths ,这两个配置真的是提升开发效率的利器啊!它的作用就是快速定位某个文件,防止多层 ../../../ 这种写法找某个模块!比如我现在的 src/ 下有这么几个文件:

我在 app.js 中要引入 src/components 下的 Header 组件,以往的方式是:

import Header from './components/Header'
复制代码

大家可能觉得,蛮好的啊,没毛病。但是我这里是因为 app.tsx 和 components 是同级的,试想一下如果你在某个层级很深的文件里要用 components ,那就是疯狂 ../../../.. 了,所以我们要学会使用它,并结合 webpack 的 resolve.alias 使用更香。

但是想用它麻烦还蛮多的,咱一步步拆解它。

首先 baseUrl 一定要设置正确,我们的 tsconfig.json 是放在项目根目录的,那么 baseUrl 设为 ./ 就代表了项目根路径。于是, paths 中的每一项路径映射,比如 ["src/*"] 其实就是相对根路径。

如果大家像上面一样配置了,并自己尝试用以下方式开始进行模块的引入:

import Header from 'Components/Header'
复制代码

因为 eslint 的原因,是会报错的:

这个时候需要改 .eslintrc.js 文件的配置了,首先得安装 eslint-import-resolver-typescript

npm install eslint-import-resolver-typescript -D
复制代码

然后在 .eslintrc.js 文件的 setting 字段修改为以下代码:

settings: {
  'import/resolver': {
    node: {
      extensions: ['.tsx', '.ts', '.js', '.json'],
    },
    typescript: {},
  },
},
复制代码

是的,只需要添加 typescript: {} 即可,这时候再去看已经没有报错了。 但是上面我们完成的工作仅仅是对于编辑器来说可识别这个路径映射,我们需要在 webpack.common.js 中的 resolve.alias 添加相同的映射规则配置:

module.exports = {
  // other...
  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.json'],
    alias: {
      'Src': resolve(PROJECT_PATH, './src'),
      'Components': resolve(PROJECT_PATH, './src/components'),
      'Utils': resolve(PROJECT_PATH, './src/utils'),
    }
  },
  module: {//...},
  plugins: [//...],
}

复制代码

现在,两者一致就可以正常开发和打包了!可能有的小伙伴会疑惑,我只配置 webpack 中的 alias 不就行了吗?虽然开发时会有报红,但并不会影响到代码的正确,毕竟打包或开发时 webpack 都会进行路径映射替换。是的,的确是这样,但是在 tsconfig.json 中配置,会给我们增加智能提示,比如我打字打到 Com ,编辑器就会给我们提示正确的 Components ,而且其下面的文件还会继续提示。

如果你参与过比较庞大、文件层级会很深的项目你就能明白智能提示真的很香。

更多 babel 配置

之前我们已经使用 babel 去解析 react 语法和 typescript 语法了,但是目前我们所做的也仅仅如此,你在代码中用到的 ES6+ 语法编译之后依然全部保留,然而不是所有浏览器都能支持 ES6+ 语法的,这时候就需要@babel/preset-env 来做这个苦力活了,它会根据设置的目标浏览器环境(browserslist)找出所需的插件去转译 ES6+ 语法。比如 const 或 let 转译为 var 。

但是遇到 Promise 或 .includes 这种新的 es 特性,是没办法转译到 es5 的,除非我们把这中新的语言特性的实现注入到打包后的文件中,不就行了吗?我们借助 @babel/plugin-transform-runtime 这个插件,它和 @babel/preset-env 一样都能提供 ES 新API 的垫片,都可实现按需加载,但前者不会污染原型链。

另外,babel 在编译每一个模块的时候在需要的时候会插入一些辅助函数例如 _extend ,每一个需要的模块都会生成这个辅助函数,显而易见这会增加代码的冗余,@babel/plugin-transform-runtime 这个插件会将所有的辅助函数都从 @babel/runtime-corejs3 导入(我们下面使用 corejs3),从而减少冗余性。

安装它们:

npm install @babel/preset-env @babel/plugin-transform-runtime -D
npm install @babel/runtime-corejs3 -S
复制代码

注意: @babel/runtime-corejs3 的安装为生产依赖。

修改 .babelre 如下:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        // 防止babel将任何模块类型都转译成CommonJS类型,导致tree-shaking失效问题
        "modules": false
      }
    ],
    "@babel/preset-react",
    "@babel/preset-typescript"
  ],
  "plungins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "corejs": {
          "version": 3,
          "proposals": true
        },
        "useESModules": true
      }
    ]
  ]
}
复制代码

ok,搞定!

到此为止,我们的 react+typescript 项目开发环境已经可行了,就是说现在已经可以正常进行开发了,但是针对开发环境和生产环境,我们能做的优化还有很多,大家继续加油!

公共(common)环境优化

这部分主要针对无论开发环境还是生产环境都需要的公共配置优化。

1. 拷贝公共静态资源

大家有没有注意到,到目前为止,我们的开发页面还是没有 icon 的,就下面这个东西:

create-react-app 一样,我们将 .ico 文件放到 public/ 目录下,比如我就复制了一个 cra 的 favicon.ico 文件,然后在我们的 index.html 文件中加入以下标签:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
+   <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React+Typescript 快速开发脚手架</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

复制代码

这时候你 npm run build 打个包,我们看到 dist 目录下是没有 favicon.ico 文件的,那么 html 文件中的引入肯定就无法起效了。于是我们希望有一个手段,在打包时能把 public/ 文件夹下的静态资源复制到我们打包后生成的 dist 目录中,除非你想每次打包完手动复制,不然就借助 copy-webpack-plugin 吧!

安装它:

npm install copy-webpack-plugin -D
复制代码

修改 webpack.common.js 文件,增加以下代码:

const CopyPlugin = require('copy-webpack-plugin')

module.exports = {
	plugins: [
    // 其它 plugin...
  	new CopyPlugin({
      patterns: [
        {
          context: resolve(PROJECT_PATH, './public'),
          from: '*',
          to: resolve(PROJECT_PATH, './dist'),
          toType: 'dir',
        },
      ],
    }),
  ]
}
复制代码

然后你重新 npm run start ,再清下页面缓存,你会看到我们的小图标就出来了,现在你可以替换成你自己喜欢的图标了。

同样地,其它的静态资源文件,大家只要往 public/ 目录下丢,打包之后都会自动复制到 dist/ 目录下。

特别注意⚠️:在讲基础配置配置 html-webpack-plugin 时,注释中特别强调过要配置 cache: false ,如果不加的话,你代码修改之后刷新页面,html 文件不会引入任何打包出来的 js 文件,自然也没有执行任何 js 代码,特别可怕,我搞了好久,查了 copy-webpack-plugin 官方 issue 才找到的解决方案。

2. 显示编译进度

我们现在执行 npm run start 或 npm run build 之后,控制台没有任何信息能告诉我们现在编译的进度怎么样,在大型项目中,编译打包的速度往往需要很久,如果不是熟悉此项目尿性的人,基本都会认为是不是卡住了,从而极大地增强了焦虑感。。。所以,显示打包的进度是非常重要的,这是对开发者积极的正向反馈。

在我看来,人活着,心中希望真的很重要。

我们可以借助 webpackbar 来完成此项任务,安装它:

npm install webpackbar -D
复制代码

webpack.common.js 增加以下代码:

const WebpackBar = require('webpackbar')

module.exports = {
	plugins: [
    // 其它 plugin...
  	new WebpackBar({
      name: isDev ? '正在启动' : '正在打包',
      color: '#fa8c16',
    }),
  ]
}
复制代码

现在我们本地起服务还是打包都有进度展示了,是不是特别舒心呢?我真的很喜欢这个插件。

3. 编译时的 Typescirpt 类型检查

我们之前配置 babel 的时候说过,为了编译速度,babel 编译 ts 时直接将类型去除,并不会对 ts 的类型做检查,来看一个例子,大家看我之前创建的 src/app.tsx 文件下,我故意解构出一个事先没有声明的类型:

如上所示,我尝试解构的 wrong 是没有在我们的 IProps 中声明的,在编辑器中肯定会报错的,但是重点是,在某一刻某一个人某种情况下就是犯了这样的错误,而它没有去处理这个问题,我们接手这个项目之后,并不知道有这么个问题,然后本地开发或打包时,依然可以正常进行,这完全丧失了 typescript 类型声明所带来的优势以及带来了重大的隐性 bug!

所以,我们需要借助 fork-ts-checker-webpack-plugin ,在我们打包或启动本地服务时给予错误提示,那就安装它吧:

npm install fork-ts-checker-webpack-plugin -D
复制代码

webpack.common.js 中增加以下代码:

const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')

module.exports = {
	plugins: [
    // 其它 plugin...
  	new ForkTsCheckerWebpackPlugin({
      typescript: {
        configFile: resolve(PROJECT_PATH, './tsconfig.json'),
      },
    }),
  ]
}
复制代码

现在,我们执行 npm run build 看看,会有以下错误提示:

发现问题之后我们就可以去解决它了,而不是啥都不知道任由其隐性 bug 存在。

4. 加快二次编译速度

这里所说的“二次”意思为首次构建之后的每一次构建。

有一个神器能大大提高二次编译速度,它为程序中的模块(如 lodash)提供了一个中间缓存,放到本项目 node_modules/.cache/hard-source 下,就是 hard-source-webpack-plugin ,首次编译时会耗费稍微比原来多一点的时间,因为它要进行一个缓存工作,但是再之后的每一次构建都会变得快很多!我们先来安装它:

npm install hard-source-webpack-plugin -D
复制代码

webpack.common.js 中增加以下代码:

const HardSourceWebpackPlugin = require('hard-source-webpack-plugin')

module.exports = {
	plugins: [
    // 其它 plugin...
  	new HardSourceWebpackPlugin(),
  ]
}
复制代码

这时候我们执行两次 npm run start 或 npm run build ,看看花费时间对比图:

随着项目变大,这个速度差距会更明显。

5. external 减少打包体积

到目前为止,我们无论是开发还是生产,都要先经过 webpack 将 react、react-dom 的代码打进我们最终生成的代码中,试想一下,当这种第三方包变得越来也多的时候,最后打出的文件将会很大,用户每次进入页面需要下载一个那么大的文件,带来的就是白屏时间变长,将会严重影响用户体验,所以我们将这种第三方包剥离出去或者采用 CDN 链接形式。

修改 webpack.common.js ,增加以下代码:

module.exports = {
	plugins: [
    // 其它 plugin...
  ],
  externals: {
    react: 'React',
    'react-dom': 'ReactDOM',
  },
}
复制代码

在开发时,我们是这样使用 react 和 react-dom 的:

import React from 'react'
import ReactDOM from 'react-dom'
复制代码

那么,我们最终打完的包已经不注入这两个包的代码了,肯定得有另外的方式将其引入,不然程序都无法正确运行了,于是我们打开 public/index.html ,增加以下 CDN 链接:

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="root"></div>
+   <script crossorigin src="https://unpkg.com/react@16.13.1/umd/react.production.min.js"></script>
+   <script crossorigin src="https://unpkg.com/react-dom@16.13.1/umd/react-dom.production.min.js"></script>
  </body>
</html>
复制代码

它们各自的版本可在 package.json 去确定!

然后我们对比一下添加 externals 前后的打包体积会发现相差很多。

这个时候大家就疑惑了,我无论添不添加 externals,最终需要下载的文件大小其实并没有变啊,只不过一个是一次性下载一个文件,另一个是一次性下载三个文件,大小都不变,时间应该也不变啊?其实它有以下优势:

  • http 缓存:当用户第一次下载后,之后每次进入页面,根据浏览器的缓存策略,都不需要再重新下载 react 和 react-dom。
  • webpack 编译时间减少:因为少了一步打包编译 react 和 react-dom 的工作,因此速度会提升。

跟大家说明下,关于 externals 的配置,如果是用在自己的项目里,这样配完全没问题,但是如果用该脚手架开发 react 组件,并需要发布到 npm 上的,那如果你把 react 这种依赖没有打进最终输出的包里,那么别人下载了你这个包就需要 npm install react@16.3.1 -S ,这其实是有问题的,你无法保证别人的 react 版本和你一致,这个问题我们之后会再说,现在先提个醒~

6. 抽离公共代码

我们先来讲一下ES6中的懒加载

懒加载是优化网页首屏速度的利器,下面演示一个简单的例子,让大家明白有什么好处。

一般情况下,我们引入某个工具函数是这样的:

import { add } from './math.js';
复制代码

如果这样引入,在打包之后, math.js 这个文件中的代码就会打进最终的包里,**即使这个 **add **方法不一定在首屏就会使用!**那么带来的坏处显而易见,我都不需要在首屏使用它,却要承担下载这个目前的多余代码的响应速度变慢的后果!

但是,如果现在我们以下面的方式进行引入:

import("./math").then(math => {
  console.log(math.add(16, 26))
})
复制代码

webpack 就会自动解析这个语法,进行代码分割,打包出来之后, math.js 中的代码会被自动打成一个独立的 chunk 文件,只有我们在页面交互时调用了这个方法,页面才会下载这个文件,并执行调用的方法。

同理,我们也可以对 React 组件进行这样的懒加载,只需借助 React.lazy 和 React.Suspense 即可,下面做个简单的演示:

src/app.tsx :

import React, { Suspense, useState } from 'react'

const ComputedOne = React.lazy(() => import('Components/ComputedOne'))
const ComputedTwo = React.lazy(() => import('Components/ComputedTwo'))

function App() {
  const [showTwo, setShowTwo] = useState<boolean>(false)

  return (
    <div className='app'>
      <Suspense fallback={<div>Loading...</div>}>
        <ComputedOne a={1} b={2} />
        {showTwo && <ComputedTwo a={3} b={4} />}
        <button type='button' onClick={() => setShowTwo(true)}>
          显示Two
        </button>
      </Suspense>
    </div>
  )
}

export default App
复制代码

src/components/ComputedOne/index.tsx :

import React from 'react'
import './index.scss'
import { add } from 'Utils/math'

interface IProps {
  a: number
  b: number
}

function ComputedOne(props: IProps) {
  const { a, b } = props
  const sum = add(a, b)

  return <p className='computed-one'>{`Hi, I'm computed one, my sum is ${sum}.`}</p>
}

export default ComputedOne
复制代码

ComputedTwo 组件代码与 ComputedOne 组件代码相似, math.ts 是简单的求和函数,就不贴代码了。

接下来,我们 npm run start ,并打开控制台的 Network,会发现以下动态加载 chunk 文件:

以上演示便是实现了组件的懒加载方式。接下来,执行一下 npm run build 看看打包出来了以下文件:

红线框住的文件就是两个组件( ComputedOne 和 ComputedTwo )的代码,这样带来的好处很明显:

  • 若通过懒加载引入的组件,若该组件代码不变,打出的包名也不会变,部署到生产环境后,因为浏览器缓存原因,用户不需要再次下载该文件,缩短了网页交互时间。
  • 防止把所有组件打进一个包,降低了页面首屏时间。

懒加载带来的优势不可小觑,我们沿着这个思维模式向外延伸思考,如果我们能把一些引用的第三方包也打成单独的 chunk,是否也会具有同样的优势呢?

答案是肯定的,因为第三方依赖包只要版本锁定,代码是不会有变化的,那么每一次项目代码的迭代,都不会影响到依赖包 chunk 文件的文件名,那么就会同样具有以上优势!

其实 webpack4 默认就开启该功能,所以以上演示的懒加载才会打出独立 chunk 文件,但是要将第三方依赖也打出来独立 chunk,我们需要在 webpack.common.js 中增加以下代码:

module.exports = {
	// other...
  externals: {//...},
  optimization: {
    splitChunks: {
      chunks: 'all',
      name: true,
    },
  },
}
复制代码

这个时候我们 npm run build ,就会发现多了这么一个包:

这个 chunk 里放了一些我们没有通过 externals 剔除的第三方包的代码,若大家不想通过 cdn 形式引入 react 和 react-dom ,这里也可以进行相应的配置将它们单独抽离出来;另一方面,若是多页应用,还需要配置把公共模块也抽离出来,这里因为我们是搭建单页应用开发环境,就不演示了。

给大家推荐两个学习 splitChunks 配置的地方:1. webpack官方介绍;2. 理解webpack4.splitChunks

开发(dev)环境优化

这部分主要针对无论开发环境还是开发环境都需要的公共配置优化。

1. 热更新

如果你开发时忍受过稍微改一下代码,页面就会重新刷新的痛苦,那么热更新一定得学会了!可能小项目你觉得没什么,都一样快,但是项目大了每一次编译都是直击内心的痛!

所谓的热更新其实就是,页面只会对你改动的地方进行“局部刷新”,这个说法可能不严谨,但是想必大家能理解什么意思。打开 webpack.dev.js ,执行以下三个步骤即可使用:

第一步:将 devServer 下的 hot 属性设为 true 。

第二步:新增 webpack.HotModuleReplacementPlugin 插件:

const webpack = require('webpack')

module.exports = merge(common, {
  devServer: {//...},
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
  ]
})

复制代码

这个时候,你 npm run start 并尝试改变局部的代码,保存后发现整个页面还是会进行刷新,如果你希望得到上面所说的“局部刷新”,需要在项目入口文件加以下代码。

第三步:修改入口文件,比如我就选择 src/index.js 作为我的入口文件:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'

if (module && module.hot) {
  module.hot.accept()
}

ReactDOM.render(<App />, document.querySelector('#root'))
复制代码

这时候因为 ts 的原因会报错:

我们只需要安装 @types/webpack-env 即可:

npm install @types/webpack-env -D
复制代码

现在,我们在重新 npm run start ,在页面上随便修改个代码看看,是不是不会整体刷新了?舒服~

2. 跨域请求

一般来说,利用 devServer 本来就有的 proxy 字段就能配置接口代理进行跨域请求,但是为了使构建环境的代码与业务代码分离,我们需要将配置文件独立出来,可以这样做:

第一步:在 src/ 下新建一个 setProxy.js 文件,并写入以下代码:

const proxySettings = {
  // 接口代理1
  '/api/': {
    target: 'http://198.168.111.111:3001',
    changeOrigin: true,
  },
  // 接口代理2
  '/api-2/': {
    target: 'http://198.168.111.111:3002',
    changeOrigin: true,
    pathRewrite: {
      '^/api-2': '',
    },
  },
  // .....
}

module.exports = proxySettings
复制代码

配置完成,我们要在 webpack.dev.js 中要引入,并正确放大 devServer 的 proxy 字段。

第二步:简单的引入及解构下就行:

const proxySetting = require('../../src/setProxy.js')

module.exports = merge(common, {
  devServer: {
    //...
    proxy: { ...proxySetting }
  },
})

复制代码

可以了!就这么简单!接下来安装我们最常用的请求发送库 axios :

npm install axios -S
复制代码

src/app.tsx 中简单发个请求,就可以自己测试了,这里大家要找测试接口的话可以找下 github 的公用 api,这里我就直接蹭公司的了~

生产(prod)环境优化

这部分主要针对无论开发环境还是生产环境都需要的公共配置优化。

1. 抽离出 css 样式

抽离出单独的 chunk 文件的优势在上面“抽离公共代码”一节已经简单描述过,现在我们写的所有样式打包后都打进了 js 文件中,如果这样放任下去,该文件会变得越来越大,抽离出样式文件势在必行!

借助 mini-css-extract-plugin 进行 css 样式拆分,先安装它:

npm install mini-css-extract-plugin -D
复制代码

webpack.common.js 文件(注意⚠️,是 common 文件)中增加和修改以下代码:

const MiniCssExtractPlugin = require('mini-css-extract-plugin')

const getCssLoaders = (importLoaders) => [
  isDev ? 'style-loader' : MiniCssExtractPlugin.loader,
  // ....
]

module.exports = {
	plugins: [
    // 其它 plugin...
    !isDev && new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].css',
      ignoreOrder: false,
    }),
  ]
}
复制代码

我们修改了 getCssLoaders 这个方法,原来无论在什么环境我们使用的都是 style-loader ,因为在开发环境我们不需要抽离,于是做了个判断,在生产环境下使用 MiniCssExtractPlugin.loader

我们随便写点样式,然后执行以下 npm run build ,再到 dist 目录下看看:

可以看到成功拆出来了样式 chunk 文件,享用了至尊级待遇!

2. 去除无用样式

我在样式文件中故意为某个不会用到的类名加了个样式:

结果我执行打包,找到这个分离出的样式文件点进去一看:

它默认还是保留这个样式了,这显然是无意义的代码,所以我们要想办法去除它,所幸有 purgecss-webpack-plugin 这个利器,让我们先安装它及路径查找利器 node-glob

npm install purgecss-webpack-plugin glob -D
复制代码

然后在 webpack.prod.js 中增加以下代码:

const { resolve } = require('path')
const glob = require('glob')
const PurgeCSSPlugin = require('purgecss-webpack-plugin')
const { PROJECT_PATH } = require('../constants')

module.exports = merge(common, {
	// ...
  plugins: [
    new PurgeCSSPlugin({
      paths: glob.sync(`${resolve(PROJECT_PATH, './src')}/**/*.{tsx,scss,less,css}`, { nodir: true }),
      whitelist: ['html', 'body']
    }),
  ],
})

复制代码

简单解释下上面的配置: glob 是用来查找文件路径的,我们同步找到 src 下面的后缀为 .tsx 、 .(sc|c|le)ss 的文件路径并以数组形式返给 paths ,然后该插件就会去解析每一个路径对应的文件,将无用样式去除; nodir 即去除文件夹的路径,加快处理速度。为了直观给大家看下路径数组,打印出来是这个样子:

[
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/app.scss',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/app.tsx',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/components/ComputedOne/index.scss',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/components/ComputedOne/index.tsx',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/components/ComputedTwo/index.scss',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/components/ComputedTwo/index.tsx',
  '/Users/RMBP/Desktop/react-ts-quick-starter/src/index.tsx'
]
复制代码

大家要注意⚠️:一定也要把引入样式的 tsx 文件的路径也给到,不然无法解析你没有哪个样式类名,自然也无法正确剔除无用样式了。

现在再看看我们打包出来的样式文件,已经没有了那个多余的代码,简直舒服!

3. 压缩 js 和 css 代码

在生产环境,压缩代码是必须要做的工作,其打包出的文件体积能减少一大半呢!

js 代码压缩

webpack4 中 js 代码压缩神器 terser-webpack-plugin 可谓是无人不知了吧?它支持对 ES6 语法的压缩,且在 mode 为 production 时默认开启,是的,webpack4 完全内置,不过我们为了能对它进行一些额外的配置,还是需要先安装它的:

npm install terser-webpack-plugin -D
复制代码

webpack.common.js 文件中的 optimization 增加以下配置:

module.exports = {
	// other...
  externals: {//...},
  optimization: {
    minimize: !isDev,
    minimizer: [
      !isDev && new TerserPlugin({
        extractComments: false,
        terserOptions: {
          compress: { pure_funcs: ['console.log'] },
        }
      })
    ].filter(Boolean),
    splitChunks: {//...},
  },
}
复制代码

首先增加了 minimize ,它可以指定压缩器,如果我们设为 true ,就默认使用 terser-webpack-plugin ,设为 false 即不压缩代码。接下来在 minimize 中判断如果是生产环境,就开启压缩。

  • extractComments 设为 false 意味着去除所有注释,除了有特殊标记的注释,比如 @preserve 标记,后面我们会利另一个插件来生成我们的自定义注释。
  • pure_funcs 可以设置我们想要去除的函数,比如我就将代码中所有 console.log 去除。

css 代码压缩

同样也是耳熟能详的 css 压缩插件 optimize-css-assets-webpack-plugin ,直接安装它:

npm install optimize-css-assets-webpack-plugin -D
复制代码

在我们上面配置过的 minimizer 新增一段代码即可:

module.exports = {
  optimization: {
    minimizer: [
      // terser
      !isDev && new OptimizeCssAssetsPlugin()
    ].filter(Boolean),
  },
}
复制代码

4. 添加包注释

上面我们配置 terser 时说过,打包时会把代码中所有注释去除,除了一些有特殊标记的比如 @preserve 这种就会保留。我们希望别人在使用我们开发的包时,可以看到我们自己写好的声明注释(比如 react 就有),就可以使用 webpack 内置的 BannerPlugin ,无需安装!

webpack.prod.js 文件中增加以下代码,并写入自己想要的声明注释即可:

const webpack = require('webpack')

module.exports = merge(common, {
  plugins: [
    // ...
    new webpack.BannerPlugin({
      raw: true,
      banner: '/** @preserve Powered by react-ts-quick-starter (https://github.com/vortesnail/react-ts-quick-starter) */',
    }),
  ],
})
复制代码

这时候打个包去 dist 目录下看看出口文件:

5. tree-shaking

tree-shaking 是 webpack 内置的打包代码优化神器,在生产环境下,即 mode 设置为 production 时,打包后会将通过 ES6 语法 import 引入的未使用的代码去除。下面我们简单举个例子:

src/utils/math.ts 中写入以下代码:

export function add(a: number, b: number) {
  console.info('I am add func')
  return a + b
}

export function minus(a: number, b: number) {
  console.info('I am minus func')
  return a - b
}
复制代码

回到我们的 src/app.tsx 中,清除以前的内容,写入以下代码:

import React from 'react'
import { add, minus } from 'Utils/math'

function App() {
  return <div className='app'>{add(5, 6)}</div>
}

export default App
复制代码

可以看到,我们同时引入来 add 和 minus 方法,但是实际使用时只使用了 add 方法,这时候我们 build 一下,打开打包后的文件搜索 console.info('I am minus func') 是搜不到的,但却搜到了 console.info('I am add func') 意味着这个方法因为没有被使用导致被删除,这就是 tree-shaking 的作用!

在我开发的项目时,我不会去 package.json 中配置 sideEffects: false ,因为我写的模块我能保证没有副作用。

这里大家有必要回忆一下,在 .babelrc 中我们在 @babel/preset-env 下配置了 module: false ,目的在于不要将 import 和 export 关键字处理成 commonJS 的模块导出引入方式,比如 require ,这样的话才能支持 tree-shaking,因为我们上面说了,在 ES6 模块导入方式下才会有效。

6. 打包分析

有时候我们想知道打出的包都有哪些,具体多大,只需借助 webpack-bundle-analyzer 即可,我们安装它:

npm install webpack-bundle-analyzer -D
复制代码

打开 webpack.prod.js 增加以下 plugin 即可:

const webpack = require('webpack')

module.exports = merge(common, {
  plugins: [
    // ...
    new BundleAnalyzerPlugin({
      analyzerMode: 'server',					// 开一个本地服务查看报告
      analyzerHost: '127.0.0.1',			// host 设置
      analyzerPort: 8888,							// 端口号设置
    }),
  ],
})
复制代码

这时候我们 npm run build 完成后,就会打开默认浏览器,出现一下 bundle 分析页面:

尽情想用吧!~

前半部分结语

大家跟着读到这里,或者跟着做到这里,相信大家感觉一定不虚此行了吧?现在完成的配置已经是可以进行正常的开发了,至于项目中经常用到的 react-router-dom 、 react-redux 、 mobx 等更多的库大家就按照正常开发时安装使用就可以。

接下来后半部分我想以两个案例讲解使用现用我们搭出来的架子开发 React 组件和常规工具并发布至 npm 的全流程,内容分别如下:

  • 利用 rollup 和 tsc 打包工具包并发布至 npm 全流程
  • 利用 rollup 和 tsc 打包开发的 react 组件并发布至 npm 全流程

这一部分能讲的东西也是满多的,我会新起另一篇文章讲解,大家敬请期待吧!这篇文章我前后花了大概一个月时间,都是利用工作之余时间写的,希望大家能给予一点小小的鼓励,只需要给我的github/blog一个小小的 star✨ 即可让我元气满满!球球了🙏!!!