React-Webpack5-TypeScript打造工程化多页面应用
多页面应用打包
日常工作中大部分场景下我们都是在使用webpack
构建传统单页面spa
应用。
所谓的单页面应用也就是说打包后的代码仅仅生成一份html
文件,基于前端路由js
去控制渲染不同的页面。
当然所谓的多页面应用简单来说也就是打包后生成多个html
文件。
这篇文章中我们来重点介绍多页面应用,文章中涉及的内容纯干货。我们废话不多说,一篇文章让你彻底搞懂所谓工程化的多页面应用构建。
文章中涉及的模板配置可以点击这里查看戳这里👇。
不要忘记给一个star
呀大佬们(祈求脸.jpg)
前边部分是基于基础配置从零开始搭建一个React+TypeScript+Webpack
的讲解部分,如果这块你已经足够了解了,可以直接跳到 切入多页面应用 去查看动态多页面部分的配置。
最终我们达到的效果如下👇:
原谅我实在是不会gif...
初始化目录结构
让我们来先初始化最基础的工程目录:
让我们先来安装webpack
以及React
:
yarn add -D webpack webpack-cli
yarn add react react-dom
webpack-cli
是webpack
的命令行工具,用于在命令行中使用webpack
。
接下来让我们去分别创建不同的页面目录,假设我们存在两个多页面应用。
一个editor
编辑器页面,一个home
主页。
安装完成之后让我来改变改变目录文件:
创建的项目配置如下,我们分别先来讲讲这两个基础文件夹
containers
文件夹中存放不同项目中的业务逻辑packages
文件夹中存放不同项目中的入口文件
这两个文件中的内容我们先不关心,仅建立好最基础的文件目录。
配置react
支持
接下来让我们的项目先支持最原始的jsx
文件,让项目支持react
的jsx
。
支持jsx
需要额外配置babel
去处理jsx
文件,将jsx
转译成为浏览器可以识别的js
。
这里我们需要用到如下几个库:
babel-loader
@babel/core
@babel/preset-env
@babel/plugin-transform-runtime
@babel/preset-react
我们来稍微梳理一下这几个babel
的作用,具体babel
原理这里我不进行过分深究。
babel-loader
首先对于我们项目中的jsx
文件我们需要通过一个"转译器"将项目中的jsx
文件转化成js
文件,babel-loader
在这里充当的就是这个转译器。
@babel/core
但是babel-loader
仅仅识别出了jsx
文件,内部核心转译功能需要@babel/core
这个核心库,@babel/core
模块就是负责内部核心转译实现的。
@babel/preset-env
@babel/prest-env
是babel
转译过程中的一些预设,它负责将一些基础的es 6+
语法,比如const/let...
转译成为浏览器可以识别的低级别兼容性语法。
这里需要注意的是
@babel/prest-ent
并不会对于一些es6+
并没有内置一些高版本语法的实现比如Promise
等polyfill
,你可以将它理解为语法层面的转化不包含高级别模块(polyfill
)的实现。
@babel/plugin-transform-runtime
@babel/plugin-transform-runtime
,上边我们提到了对于一些高版本内置模块,比如Promise/Generate
等等@babel/preset-env
并不会转化,所以@babel/plugin-transform-runtime
就是帮助我们来实现这样的效果的,他会在我们项目中如果使用到了Promise
之类的模块之后去实现一个低版本浏览器的polyfill
。
其实与
@babel/plugin-transform-runtime
达到相同的效果还可以直接安装引入@babel/polyfill
,不过相比之下这种方式不被推荐,他存在污染全局作用域,全量引入造成提及过大以及模块之间重复注入等缺点。
此时这几个插件我们已经可以实现将es6+
代码进行编译成为浏览器可以识别的低版本兼容性良好的js
代码了,不过我们还缺少最重要一点。
目前这些插件处理的都是js
文件,我们得让她能够识别并处理jsx
文件。
@babel/preset-react
此时就引入了我们至关重要的@babel/preset-react
这个插件,在jsx
中我们使用的jsx
标签实质上最终会被编译成为:
有兴趣的朋友可以看看我之前的这篇文章
React
中的jsx
原理解析。
最终我们希望将.jsx
文件转化为js
文件同时将jsx
标签转化为React.createElement
的形式,此时我们就需要额外使用babel
的另一个插件-@babel/preset-react
。
@babel/preset-react
是一组预设,所谓预设就是内置了一系列babel plugin
去转化jsx
代码成为我们想要的js
代码。
babel
所需要的配置这里我们已经讲完了需要用到的包和对应的作用,因为babel
涉及的编译原理部分的直接特别多所以我们这里仅仅了解如何配置就可以了,有兴趣的朋友可以移步babel
官网去详细查看。
项目babel
配置
接下来让我们来安装这5个插件,并且在webpack
中进行配置:
yarn add -D @babel/core @babel/preset-env babel-loader @babel/plugin-transform-runtime @babel/preset-react
创建基础webpack
配置
当我们安装完成上边的编译工具后,我们就来创建一个基础的webpack.config.js
来使用它来转译我们的jsx
文件:
我们来在项目跟目录下创建一个scripts/webpack.base.js
文件。
关于webpack
对于代码的转译,所谓转译直白来讲也就是webpack
默认只能处理基于js json
的内容。
如果我们想让webpack
处理我们的jsx
内容,就需要配置loader
告诉它,
"嘿,webpack
碰到.jsx
后缀的文件使用这个loader
来处理。"
我们来编写基础的babel-loader
配置:
webpack.base.js
// scripts/webpack.base.js
const path = require('path');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.jsx'),
},
module: {
rules: [
{
test: /\.jsx?$/,
use: 'babel-loader',
},
],
},
};
此时我们已经告诉webpack
,如果遇到jsx
或者js
代码,我们需要使用babel-loader
进行处理,通过baebel
将项目中的js/jsx
文件处理成为低版本浏览器可以识别的代码。
.babelrc
上边我们讲到了babel-loader
仅仅是一个桥梁,真正需要转译作用的其他的插件。接下来就让我们来使用它:
babel-loader
提供了两种配置方式,一种是直接在webpack
配置文件中编写options
,另一个是官方推荐的在项目目录下建立.babelrc
文件单独配置babel
。
这里我们采用第二种推荐的配置:
// .babelrc
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"regenerator": true
}
]
]
}
packages/home
创建入口文件index.jsx
接下来让我们在packages/home
目录下创建home
业务的入口文件
// packages/home/index.jsx
import ReactDom from 'react-dom'
const Element = <div>hello</div>
ReactDom.render(<Element />,document.getElementById('root'))
到这一步相关基础配置文件已经初具模型了,我们需要做的就是当我们调用webpack
打包我们项目的时候使用我们刚才书写的webpack.base.js
这个配置文件。
pacakge.json
增加build
脚本
我们来稍微修改一下pacakge.json
:
{
"name": "pages",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "webpack --config ./scripts/webpack.base.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.15.5",
"@babel/plugin-transform-runtime": "^7.15.0",
"@babel/preset-env": "^7.15.6",
"@babel/preset-react": "^7.14.5",
"babel-loader": "^8.2.2",
"webpack": "^5.53.0",
"webpack-cli": "^4.8.0"
},
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
这里我们删除了初始化的
test
脚本,增加了build
命令。
接下来让我们运行npm run build
:
这一步我们已经成功让webpack
识别jsx
代码并且支持将高版本js
转化为低版本javascript
代码了。
如果有疑问的话,可以停留下来在想一想大概流程。主要就是:
- 创建
babel
配置转译jsx/js
内容。 - 创建入口文件。
webpack
中对于jsx/js
内容使用babel-loader
调用babel
配置好的预设和插件进行转译。
接下来让我们继续来支持TypeScript
吧!
配置TypeScript
支持
针对TypeScript
代码的支持其实业内存在两种编译方式:
- 直接通过
TypeScript
去编译ts/tsx
代码。 - 通过
babel
进行转译。
其实这两种方式都是可以达到编译TypeScript
代码成为JavaScript
并且兼容低版本浏览器代码的。
有兴趣的朋友可以自行搜索这两种方式的差异,平常在一些类库的打包时我会直接使用tsc
结合tsconfig.js
编译ts
代码。
日常工作中,大部分情况我个人还是会使用babel
进行转译,因为涉及到业务往往是需要css
等静态资源与ts
代码一起打包,那么使用babel + webpack
其实可以很好的一次性囊括了所有的资源编译过程。
babel
支持Typescirpt
babel
内置了一组预设去转译TypeScript
代码 --@babel/preset-typescript
。
接下来让我们来使用@babel/preset-typescript
预设来支持TypeScript
语法吧。
npm install --save-dev @babel/preset-typescript
安装完成之后让我们一步一步来修改之前的配置:
首先我们先来修改之前.babelrc
配置文件,让babel
支持转译的ts
文件:
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
+ "@babel/preset-typescript"
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"regenerator": true
}
]
]
}
这里我们在presets
添加了@babel/preset-typescrpt
预设去让babel
支持typescript
语法。
此时我们的babel
已经可以识别TypeScript
语法了
webpack
支持ts/tsx
文件
不要忘记同时修改我们的webpack
中babel-loader
的匹配规则:
// webpack.base.jf
const path = require('path');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
// 这里修改`jsx`为`tsx`
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
module: {
rules: [
{
// 同时认识ts jsx js tsx 文件
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
],
},
};
这里我们将ts,js,tsx,jsx
文件都交给babel-loader
处理。
初始化tsconfig.json
现在,我们项目中已经可以支持tsx
文件的编写,同时也支持编译ts
文件为低版本js
文件了。
在使用Ts
时,通常我们需要配置typescript
的配置文件,没错就是tsconfig.json
。
也许你已经见到过很多次tsconfig.json
了,接下来让我们去安装typescript
并且初始化吧~
项目内安装Ts
:
yarn add -D typescript
调用tsc --init
命令初始化tsconfig.json
:
npx tsc --init
关于
npx
命令和npm
的关系,之后我会在另一篇文章中细细讲述。了解他在这里的用途:调用当前项目内node_modules/typescript/bin
的可执行文件执行init
命令就可以了。
现在我们的目录应该是这样的:
虽然说我们使用
babel
进行的编译,tsconfig.json
并不会在编译时生效。但是tsconfig.json
中的配置非常影响我们的开发体验,接下来我们就来稍微修改一下一下它吧。
配置tsconfig.json
首先我们来找到对应的jsx
选项:
他的作用是指定jsx
的生成什么样的代码,简单来说也就是jsx
代码将被转化成为什么。
这里我们将它修改为react
。
接下来我们来修改一下ts
中的模块解析规则,将它修改为node
:
"moduleResolution": "node",
这里暂时我们先修改这两个配置,后续配置我们会在后边的讲解中渐进式的进行配置。
推荐一本开源的电子书,这里罗列了大部分
tsconfig.json
配置信息
处理报错
我们已经在项目中完美支持了typescript
,接下里让我们把pacakges/home/index.jsx
改为packages/home/index.tsx
吧.
修改完成后缀后我们再来看看我们想项目文件:
我们来一个一个解决这些报错:
首先我们引用第三方包在TypeScript
文件时,简单来说它会寻找对应包的package.json
中的type
字段查找对应的类型定义文件。
react
和react-dom
这两个包代码中都不存在对应的类型声明,所以我们需要单独安装他们对应的类型声明文件:
yarn add -D @types/react-dom @types/react
大多数额外的类型定义包,你可在这里找到
安装完成之后,我们重新再看看当前的index.tsx
文件:
此时,仅剩下一个报错了。让我们来仔细定位一下错误。ts
告诉我们ReactDom.render
方法中传入的参数类型不兼容。嗯,本质上是我们react
语法写错了。修改后的代码如下:
此时我们的项目已经可以完成支持typescript
和react
了。
webpack
配置静态资源支持
一个成熟的项目只能有ts
怎么能够呢? 毕竟一个成熟的业务仔怎么脱离css
的魔抓呢😂
也许你之前接触过webpack5
之前的静态资源处理,file-loader,url-loader,row-loader
这些loader
是不是听起来特别熟悉。
webpack
默认是不支持非js
文件的,所以在webpack5
之前我们通过loader
的方式返回可执行的js
脚本文件,内部将处理这些webpack
不认识的文件。
在webpack 5+
版本之后,这些loader
的作用都已经被内置了~
接下来我们来看看应该如何配置,具体对应的作用可以查看webpack
资源模块
处理图片,文件资源文件
资源模块类型(asset module type),通过添加 4 种新的模块类型,来替换所有这些 loader:
asset/resource
发送一个单独的文件并导出 URL。之前通过使用file-loader
实现。asset/inline
导出一个资源的 data URI。之前通过使用url-loader
实现。asset/source
导出资源的源代码。之前通过使用raw-loader
实现。asset
在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用url-loader
,并且配置资源体积限制实现。
当在 webpack 5
中使用旧的 assets loader
(如 file-loader
/url-loader
/raw-loader
等)和 asset 模块时,你可能想停止当前 asset 模块的处理,并再次启动处理,这可能会导致 asset 重复,你可以通过将 asset 模块的类型设置为 'javascript/auto'
来解决。
关于配置
type:'asset'
后,webpack 将按照默认条件,自动地在resource
和inline
之间进行选择:小于 8kb 的文件,将会视为inline
模块类型,否则会被视为resource
模块类型。
我们可以通过设置 Rule.parser.dataUrlCondition.maxSize
选项来修改此条件
其实
maxSize
就相当于url-loader
中的limit
属性,资源大小在maxSize
之内使用行内asset/inline
处理,超过之后就使用resource
导出资源。当然这个配置也支持导出一个函数自定义配置实现。
了解了assets
模块的用途之后,我们来试着配置它来处理静态资源:
const path = require('path');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
module: {
rules: [
{
// 同时认识ts jsx js tsx 文件
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type:'asset/inline'
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
};
这一步我们已经关于图片和字体文件配置已经配置完毕了,接下来我们来修改一下目录结构验证一下我们的配置是否生效:
验证配置效果
修改packages
首先让我们先来修改packages
,packages
文件夹之前讲过是存放多页面应用中每个页面的入口文件的。让我们在home
文件夹下先新建一个app.tsx
:
// src/packages/home/app.tsx
import React from 'react'
import ReactDom from 'react-dom'
// 这里App仅接着就会降到 它就是就是一个React FunctionComponent
import { App } from '../../containers/home/app.tsx'
ReactDom.render(<App />, document.getElementById('root'))
修改containers
containers
文件夹中的内容是存放每个页面应用不同的业务逻辑的部分。
让我们在containers
中新建app.tsx
作为跟应用,以及同级目录下新建assets
,styles
,views
三个目录:
assets
存放当前模块相关静态资源,比如图片,字体文件等。styles
存在当前模块相关样式文件,我们还没有配置相关样式文件的处理,之后会详细介绍。views
存放当前模块下相关页面逻辑页面拆分
我们给assets
中新建一个images
文件夹,放入一张logo
,图片。
修改app.tsx
内容如下:
import React from 'react'
import Banner from './assets/image/banner.png'
const App: React.FC = () => {
return <div>
<p>Hello,This is pages!</p>
<img src={Banner} alt="" />
</div>
}
export {
App
}
yarn build
我们当前的目录结构如下:
基本的目录结构我们已经搭建成功,接下来让我们运行yarn build
。
首先根据webpack
中的入口文件会去寻找packages/home/index.tsx
,我们在index.tsx
中引入了对应的containers/app.tsx
,webpack
会基于我们的import
语法去处理模块依赖关系构建模块。
同时因为我们在app.tsx
中引入了图片
// webpack.base.js
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline'
},
此时type:'assets/inline'
针对图片的处理就会生效了!
让我们来看看build
之后的文件:
asset/inline
会讲资源文件内置成为base64
和url-loader
是相同的作用。
当前目前
webpack
的asset/inline
模块会将所有资源转化为base64
在行内迁入,如果要达到url-loader
中limit
的配置需要禁用asset/inline
使用url-loader
处理或者使用type:'asset'
来处理.
解决报错
细心的你可能已经发现了,目前我们项目中存在两个问题:
ts
文件中针对image
的引入,ts
并不能正确的识别。
解决这个问题的方式其实很简单,我们定义一个image.d.ts
在根目录下:
declare module '*.svg';
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.gif';
declare module '*.bmp';
declare module '*.tiff';
declare
为声明语法,意思为声明ts
全局模块,这样我们就可以正常引入对应的资源了。
- 我们在
index.tsx
中引入了对应的app.tsx
,当存在后缀时ts
会进行报错提示:
接下来让我们来解决这个问题吧。其实无法就是引入文件时默认后缀名的问题:
- 目前
webpack
不支持默认后缀名.tsx
- 而
tsconfig.json
中是支持后缀名.tsx
,所以显示声明会提示错误。
我们来统一这两个配置:
别名统一
修改webpack
别名配置
// webpack.base.js
const path = require('path');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
resolve: {
alias: {
'@src': path.resolve(__dirname, '../src'),
'@packages': path.resolve(__dirname, '../src/packages'),
'@containers': path.resolve(__dirname, '../src/containers'),
},
mainFiles: ['index', 'main'],
extensions: ['.ts', '.tsx', '.scss', 'json', '.js'],
},
module: {
rules: [
{
// 同时认识ts jsx js tsx 文件
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline'
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
};
这里我们添加了resolve
的参数,配置了别名@src
,@package
,@container
。
以及当我们不书写文件后缀时,默认的解析规则extensions
规则。
同时还配置了mainFiles
,解析文件夹路径~
这个三个配置都比较基础,就不过多深入了哈。如果仍然还是不是很懂,用到的时候多翻翻慢慢也就记住啦!
让我们来尝试修改index.tsx
,使用别名来引入:
此时我们发现并没有路径提示,这个!是真的无法接受!
原因是我们是基于typescript
开发,所以ts
文件中并不知道我们在webpack
中配置的别名路径。
所以我们需要做的就是同步修改tsconfig.json
,让tsconfig.json
也支持别名以及刚才的配置,达到最佳的开发体验。
修改tsconfig.json
别名配置
我们来修改tsconfig.json
,让ts
同步支持引入:
// tsconfig.json
...
"baseUrl": "./",
/* Specify the base directory to resolve non-relative module names. */
"paths": {
"@src/*": ["./src/*"],
"@packages/*": ["./src/packages/*"],
"@containers/*": ["./src/containers/*"],
},
如果要配置paths
那么一定是要配置baseUrl
的,所谓baseUrl
就是我们的paths
是相对于那个路径开始的。
所以我们在paths
中添加对应的别名路径就可以完成配置,让ts
也可以合理解析出我们的类型别名。
此时我们再来看看:
已经可以正确出现路径提示了,是不是非常nice
。
针对路径/文件目录解析规则配置我们目前就告一段落了~
配置css/sass
接下来我们给项目添加一些样式来美化它。
这里其实React
项目有太多有关css
的争吵了,但是无论如何我们是都要在webpack
中针对css
进行处理的。
这里我选择使用
sass
预处理器进行演示,其他less
等都是同理。
针对于sass
文件,同样是webpack
不认识的文件。咱们同样是需要loader
去处理。
这里用到的loader
如下:
sass-loader
resolve-url-loader
postcss-loader
css-loader
MiniCssExtractPlugin.loader
我们来一个一个来分析这些loader
的作用的对应的配置:
sass-loader
针对于sass
文件我们首先一定是要使用sass
编译成为css
的,所以我们首先需要对.scss
结尾的文件进行编译成为css
文件。
这里我们需要安装:
yarn add -D sass-loader sass
sass-loader
需要预先安装 Dart Sass 或 Node Sass(可以在这两个链接中找到更多的资料)。这可以控制所有依赖的版本, 并自由的选择使用的 Sass 实现。
sass-loader
的作用就类似我们之前讲到过的babel-loader
,可以将它理解成为一个桥梁,sass
转译成为css
的核心是由node-sass
或者dart-sass
去进行编译工作的。
resolve-url-loader
上一步我们已经讲到过sass-loader
将sass
文件转化为css
文件。
但是这里有一个致命的问题,就是关于webpack
对scss
文件中
由于 Saass 的实现没有提供 url 重写的功能,所以相关的资源都必须是相对于输出文件(ouput
)而言的。
- 如果生成的 CSS 传递给了
css-loader
,则所有的 url 规则都必须是相对于入口文件的(例如:main.scss
)。 - 如果仅仅生成了 CSS 文件,没有将其传递给
css-loader
,那么所有的 url 都是相对于网站的根目录的。
所以针对于sass
编译后的css
文件中的路径是不正确的,并不是我们想要的相对路径模式。
想要解决路径引入的问题业内有很多现成的办法,比如通过
- 路径变量定义引入路径
- 定义别名,
sass
中使用别名引入路径 resolve-url-loader
这里我们采用
resolve-url-loader
来处理文件引入路径问题。
不要忘记
yarn add -D resolve-url-loader
postcss-loader
PostCSS是什么?或许,你会认为它是预处理器、或者后处理器等等。其实,它什么都不是。它可以理解为一种插件系统。
针对于postcss
其实我这里并不打算深入去讲解,它是babel
一样都是两个庞然大物。拥有自己独立的体系,在这里你需要清楚的是我们使用postcss-loader
处理生成的css
。
第一步首先安装post-css
对应的内容:
yarn add -D postcss-loader postcss
postcss-loader
同时支持直接在loader
中配置规则选项,也支持单独建立文件配置,这里我们选择单独使用文件进行配置:
我们在项目根目录下新建一个postcss.config.js
的文件:
module.exports = {
plugins: [
require('autoprefixer'),
require('cssnano')({
preset: 'default',
}),
],
}
这里我们使用到了两个postcss
的插件:
autoprefixer
插件的作用是为我们的css
内容添加浏览器厂商前缀兼容。cssnano
的作用是尽可能小的压缩我们的css
代码。
接下来我们去安装这两个插件:
yarn add -D cssnano autoprefixer@latest
css-loader
css-loader
是解析我们css
文件中的@import/require
语句分析的.
yarn add -D css-loader
MiniCssExtractPlugin.loader
这个插件将 CSS 提取到单独的文件中。它为每个包含CSS
的 JS
文件创建一个 CSS
文件。它支持按需加载 CSS 和 SourceMaps
。
这里需要提一下他和style-loader
的区别,这里我们使用了MiniCssExtractPlugin
代替了style-loader
。
style-loader
会将生成的css
添加到html
的header
标签内形成内敛样式,这显然不是我们想要的。所以这里我们使用MiniCssExtractPlugin.loader
的作用就是拆分生成的css
成为独立的css
文件。
yarn add -D mini-css-extract-plugin
生成sass
最终配置文件
接下来我们来生成sass
文件的最终配置文件:
// webapck.base.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
resolve: {
alias: {
'@src': path.resolve(__dirname, '../src'),
'@packages': path.resolve(__dirname, '../src/packages'),
'@containers': path.resolve(__dirname, '../src/containers'),
},
mainFiles: ['index', 'main'],
extensions: ['.ts', '.tsx', '.scss', 'json', '.js'],
},
module: {
rules: [
{
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(sa|sc)ss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
'css-loader',
'postcss-loader',
{
loader: 'resolve-url-loader',
options: {
keepQuery: true,
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true
},
},
],
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline'
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'assets/[name].css',
}),
]
};
// postcss.config.js
module.exports = {
plugins: [
require('autoprefixer'),
require('cssnano')({
preset: 'default',
}),
],
}
完成这些配置之后,同时我们在containers/src/home/styles
目录中新建一个sass
文件index.scss
:
让我们来写一些简单的样式文件:
.body {
background-color: red;
}
这之后,让我们重新运行yarn build
检查生成的dist
文件我们发现,我们的sass
被成功的编译成为了css
文件并且删除了多于空格(进行了压缩)
这一步我们scss
的基础配置也已经完成了!
配置html
页面
当前我们所有涉及的都是针对单页面应用的配置,此时我们迫切需要一个html
展示页面。
此时就引入我们的主角,我们后续的多页面应用也需要机遇这个插件生成html
页面
html-webpack-plugin,其实看到这里我相信大家对这个插件原本就已经耳熟能详了。
简单介绍一下它的作用: 这个插件为我们生成 HTML 文件,同时可以支持自定义
html
模板。
多页面应用主要基于它的
chunks
这个属性配置,我们这里先买个关子。
让我们来使用一下这个插件:
yarn add --dev html-webpack-plugin
我们在项目根目录下创建一个public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id='root'></div>
</body>
</html>
我们使用这个文件作为插件的模板文件,同时与入口文件中的ReactDom.reander(...,document.getElementById('root'))
进行呼应,在页面创建一个id=root
的div
作为渲染节点。
// webpack.base.js
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const htmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
// 入口文件,这里之后会着重强调
entry: {
main: path.resolve(__dirname, '../src/packages/home/index.tsx'),
},
resolve: {
alias: {
'@src': path.resolve(__dirname, '../src'),
'@packages': path.resolve(__dirname, '../src/packages'),
'@containers': path.resolve(__dirname, '../src/containers'),
},
mainFiles: ['index', 'main'],
extensions: ['.ts', '.tsx', '.scss', 'json', '.js'],
},
module: {
rules: [
{
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(sa|sc)ss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
'css-loader',
'postcss-loader',
{
loader: 'resolve-url-loader',
options: {
keepQuery: true,
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
},
},
],
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline',
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'assets/[name].css',
}),
// 生成html名称为index.html
// 生成使用的模板为public/index.html
new htmlWebpackPlugin({
filename: 'index.html',
template: path.resolve(__dirname, '../public/index.html'),
}),
],
};
此时,当我们再次运行yarn build
时,我们生成的dist
目录下会多出一个html
文件,这个html
文件会注入我们打包生成后的的js
和css
内容。
打开index.html
,就会展示出我们代码中书写的页面啦~
配置开发环境预览
上边的长篇大论已经能满足一个SPA
单页面应用的构建了,但是我们总不能每次修改代码都需要执行一次打包命令在预览吧。
这样的话也太过于麻烦了,别担心webpack
为我们提供了devServer
配置,支持我们每次更新代码热重载。
我们来使用一下这个功能吧~
首先让我们在scripts
目录下新建一个webpack.dev.js
文件,表示专门用于开发环境下的打包预览:
虽然
devServer
已经内置了hot:true
达到热重载,但是我们仍然需要安装webpack-dev-server
。
// webpack.dev.js
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base');
const path = require('path');
const devConfig = {
mode: 'development',
devServer: {
// static允许我们在DevServer下访问该目录的静态资源
// 简单理解来说 当我们启动DevServer时相当于启动了一个本地服务器
// 这个服务器会同时以static-directory目录作为跟路径启动
// 这样的话就可以访问到static/directory下的资源了
static: {
directory: path.join(__dirname, '../public'),
},
// 默认为true
hot: true,
// 是否开启代码压缩
compress: true,
// 启动的端口
port: 9000,
},
};
module.exports = merge(devConfig, baseConfig);
关于devServer
的基础配置在代码中进行了注释讲解,当然还存在一些其他的proxy
,onlistening
等配置需要的小朋友可以去这里查阅。
这里需要提到的是
webpack-merge
这个插件是基于webpack
配置合并的,这里我们基于webpack.base.js
和webpack.dev.js
合并导出了一个配置对象。
接下里再让我们修改一下pacakge.json
下的scripts
命令。
devServer
需要使用webpack serve
启动。
...
"scripts": {
+ "dev": "webpack serve --config ./scripts/webpack.dev.js",
"build": "webpack --config ./scripts/webpack.base.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
接下来让我们运行yarn dev
就可以在localhost:9000
访问到我们刚才需要的页面并且实时支持热重载了。
支持端口被占用启动
这里有一个小tip
当我们的devServer
端口被占用的时候我们再次启动项目会因为相同的端口被占用而报错
解决端口占用的问题我们需要借助一个第三方库node-portfinder
它的用法很简答有兴趣的同学可以点击上方库名移步官网来查看用法:
首先先让我们来给scripts/utils/constant.js
中添加一个常量为固定的启动端口:
// scripts/utils/constant.js
...
// 固定端口
const BASE_PROT = 9000
...
module.exports = {
MAIN_FILE,
log,
separator,
BASE_PROT
}
接下来让我们来修改一下上边的webpack.dev.js
:
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const portfinder = require('portfinder')
const path = require('path')
const { BASE_PROT } = require('./utils/constant')
portfinder.basePort = BASE_PROT
const devConfig = {
mode: 'development',
devServer: {
// static允许我们在DevServer下访问该目录的静态资源
// 简单理解来说 当我们启动DevServer时相当于启动了一个本地服务器
// 这个服务器会同时以static-directory目录作为跟路径启动
// 这样的话就可以访问到static/directory下的资源了
static: {
directory: path.join(__dirname, '../public'),
},
// 默认为true
hot: true,
// 是否开启代码压缩
compress: true,
// 启动的端口
port: BASE_PROT,
},
}
module.exports = async function () {
try {
// 端口被占用时候 portfinder.getPortPromise 返回一个新的端口(往上叠加)
const port = await portfinder.getPortPromise()
devConfig.devServer.port = port
return merge(devConfig, baseConfig)
} catch (e) {
throw new Error(e)
}
}
我们把导出从对象变成了导出一个函数,webpack
中配置的导出支持一个对象的同时也支持一个函数~
然后函数中调用portfinder.getPortPromise()
判断当前端口是否占用,如果占用portfinder
会返回一个新的端口,此时我们修改devConfig
中的端口并且返回最新的配置进行启动就可以了!
让我们重新运行yarn dev
~
切入多页面应用
接下来正式进入我们的多入口文件部分讲解。
原理
拆分js
所谓基于webpack
实现多页面应用,简单来将就是基于多个entry
,将js
代码分为多个entry
入口文件。
比如我在src/packages/editor
新建一个入口文件index.tsx
,同时修改webpack
中的entry
配置为两个入口文件,webpack
就会基于入口文件的个数自动进行不同chunk
之间的拆分。
简单来说就是
webapck
会基于入口文件去拆分生成的js
代码成为一个一个chunk
。
上边的配置我们运行yarn build
之后生成的dist
目录如下:
我们可以看到根据不同的入口文件生成了两份js
文件,一份是main.js
一份是editor.js
。
我们第一步已经完成了,基于不同的入口文件打包生成不同的js
。
拆分html
但是现在我们现在拆分出来的js
还是在同一个index.html
中进行引入,我们想要达到的效果是main.js
在main.html
中引入成为一个页面。
editor.js
在editor.html
中引入成为一个单独的页面。
要实现这种功能,我们需要在html-webpack-plugin
上做手脚。
不知道大家还记不记得我们之前留下的chunks
这个关子。
所谓的
chunks
配置指的是生成的html
文件仅仅包含指定的chunks
块。
这不正是我们想要的嘛!
现在我们打包生成了两份js
文件分别是editor.js
和main.js
,现在我们想生成两份单独的html
文件,两个html
文件中分别引入不同的editor.js
和main.js
。
此时我们每次打包只需要调用两次htmlWebpackPlugin
,一份包含editor
这个chunk
,一份包含main
这个chunk
不就可以了嘛。
让我们来试一试:
接下来我们运行yarn build
来看一看生成的文件:
来看看我们生成的html
和js
结构,大功告成没一点问题!这样的确能解决,这也是基于webpack
打包多页面应用的原理。
可是如果我们存在很多个页面的话,首先我们每次都需要手动修改入口文件然后在进行添加一个htmlWebpackPlugin
这显然是不人性的。
同时如果这个项目下有很多个多页应用,但是我每次开发仅仅关心某一个应用进行开发,比如我负责的是home
模块,我并不想使用和关心editor
模块。那么每次我还需要在dev
环境下进行打包吗?显然是不需要的。
接下来就让我们尝试来修改这些配置将它变成自动化且按需打包的工程化配置吧。
工程化多页配置
工程化原理
我们之前已经讲清楚了webpack
中的原理了,接下来我们需要实现的过程是:
- 每次打包通过
node
脚本去执行打包命令。 - 每次打包通过命令行交互命令,读取
pacakges
下的目录让用户选择需要打包的页面。 - 当用户选中对应需要打包的目录后,通过环境变量注入的方式动态进行打包不同的页面。
这里我们需要额外用到一下几个库,还不太清楚的同学可以点击去查看一下文档:
这个库改进了node
的源生模块child_process
,用于开启一个node
子进程。
inquirer
提供一些列api
用于nodejs
中和命令行的交互。
chalk
为我们的打印带上丰富的颜色.
yarn add -D chalk inquirer execa
实现代码
首先让我们在scripts
下创建一个utils
的文件夹。
utils/constant.js
constant.js
中存放我们关于调用脚本声明的一些常量:
// 规定固定的入口文件名 packages/**/index.tsx
const MAIN_FILE = 'index.tsx'
const chalk = require('chalk')
// 打印时颜色
const error = chalk.bold.red
const warning = chalk.hex('#FFA500')
const success = chalk.green
const maps = {
success,
warning,
error,
}
// 因为环境变量的注入是通过字符串方式进行注入的
// 所以当 打包多个文件时 我们通过*进行连接 比如 home和editor 注入的环境变量为home*editor
// 注入多个包环境变量时的分隔符
const separator = '*'
const log = (message, types) => {
console.log(maps[types](message))
}
module.exports = {
MAIN_FILE,
log,
separator,
BASE_PORT,
}
utils/helper.js
const path = require('path')
const fs = require('fs')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { MAIN_FILE } = require('./constant')
// 获取多页面入口文件夹中的路径
const dirPath = path.resolve(__dirname, '../../src/packages')
// 用于保存入口文件的Map对象
const entry = Object.create(null)
// 读取dirPath中的文件夹个数
// 同时保存到entry中 key为文件夹名称 value为文件夹路径
fs.readdirSync(dirPath).filter(file => {
const entryPath = path.join(dirPath, file)
if (fs.statSync(entryPath)) {
entry[file] = path.join(entryPath, MAIN_FILE)
}
})
// 根据入口文件list生成对应的htmlWebpackPlugin
// 同时返回对应wepback需要的入口和htmlWebpackPlugin
const getEntryTemplate = packages => {
const entry = Object.create(null)
const htmlPlugins = []
packages.forEach(packageName => {
entry[packageName] = path.join(dirPath, packageName, MAIN_FILE)
htmlPlugins.push(
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../../public/index.html'),
filename: `${packageName}.html`,
chunks: ['manifest', 'vendors', packageName],
})
)
})
return { entry, htmlPlugins }
}
module.exports = {
entry,
getEntryTemplate,
}
helper.js
中其实主要导出的getEntryTemplate
方法,这个方法输入一个package
的数组,同时返回对应webpack
需要的entry
和html-wepback-plugin
组成的数组。
utils/dev.js
当我们定义好两个辅助文件之后,接下来我们就要实现和"用户"交互的部分了,也就是当用户调用我们这个脚本之后。
- 首先动态读取
packages
下的目录,获取当前项目下所有的页面文件。 - 通过命令交互罗列当前所有页面,提供给用户选择。
- 用户选中后,通过
execa
调用webpack
命令同时注入环境变量进行根据用户选中内容打包。
const inquirer = require('inquirer')
const execa = require('execa')
const { log, separator } = require('./constant')
const { entry } = require('./helper')
// 获取packages下的所有文件
const packagesList = [...Object.keys(entry)]
// 至少保证一个
if (!packagesList.length) {
log('不合法目录,请检查src/packages/*/main.tsx', 'warning')
return
}
// 同时添加一个全选
const allPackagesList = [...packagesList, 'all']
// 调用inquirer和用户交互
inquirer
.prompt([
{
type: 'checkbox',
message: '请选择需要启动的项目:',
name: 'devLists',
choices: allPackagesList, // 选项
// 校验最少选中一个
validate(value) {
return !value.length ? new Error('至少选择一个项目进行启动') : true
},
// 当选中all选项时候 返回所有packagesList这个数组
filter(value) {
if (value.includes('all')) {
return packagesList
}
return value
},
},
])
.then(res => {
const message = `当前选中Package: ${res.devLists.join(' , ')}`
// 控制台输入提示用户当前选中的包
log(message, 'success')
runParallel(res.devLists)
})
// 调用打包命令
async function runParallel(packages) {
// 当前所有入口文件
const message = `开始启动: ${packages.join('-')}`
log(message, 'success')
log('\nplease waiting some times...', 'success')
await build(packages)
}
// 真正打包函数
async function build(buildLists) {
// 将选中的包通过separator分割
const stringLists = buildLists.join(separator)
// 调用通过execa调用webapck命令
// 同时注意路径是相对 执行node命令的cwd的路径
// 这里我们最终会在package.json中用node来执行这个脚本
await execa('webpack', ['server', '--config', './scripts/webpack.dev.js'], {
stdio: 'inherit',
env: {
packages: stringLists,
},
})
}
dev.js
中的逻辑其实很简单,就是通过命令行和用户交互获得用户想要启动的项目之后通过用户选中的packages
然后通过execa
执行webpack
命令同时动态注入一个环境变量。
注入的环境变量是*
分割的包名。比如用户如果选中app
和editor
那么就会注入一个packages
的环境变量为app*editor
。
修改webpack.base.js
我们已经可以在命令行中和用户交互,并且获得用户选中的pacakge
。
此时我们就基于wepback.base.js
来修改,达到读取用户环境变量进行动态打包的效果:
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const htmlWebpackPlugin = require('html-webpack-plugin');
const { separator } = require('./utils/constant')
const { getEntryTemplate } = require('./utils/helper')
// 将packages拆分成为数组 ['editor','home']
const packages = process.env.packages.split(separator)
// 调用getEntryTemplate 获得对应的entry和htmlPlugins
const { entry, htmlPlugins } = getEntryTemplate(packages)
module.exports = {
// 动态替换entry
entry,
resolve: {
alias: {
'@src': path.resolve(__dirname, '../src'),
'@packages': path.resolve(__dirname, '../src/packages'),
'@containers': path.resolve(__dirname, '../src/containers'),
},
mainFiles: ['index', 'main'],
extensions: ['.ts', '.tsx', '.scss', 'json', '.js'],
},
module: {
rules: [
{
test: /\.(t|j)sx?$/,
use: 'babel-loader',
},
{
test: /\.(sa|sc)ss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
'css-loader',
'postcss-loader',
{
loader: 'resolve-url-loader',
options: {
keepQuery: true,
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
},
},
],
},
{
test: /\.(png|jpe?g|svg|gif)$/,
type: 'asset/inline',
},
{
test: /\.(eot|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[hash][ext][query]',
},
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'assets/[name].css',
}),
// 同时动态生成对应的htmlPlugins
...htmlPlugins
],
};
因为我们的目录结构是固定的,所以通过环境变量中的packages
传入getEntryTemplate
方法可以获得对应的入口文件,以及生成对应的htmlWebpackPlugin
。
到这一步其实我们已经实现了动态打包的所有逻辑了。
接下来让我们来替换一下package.json
中的脚本
修改package.json
"scripts": {
- "dev": "webpack serve --config ./scripts/webpack.dev.js",
+ "dev": "node ./scripts/utils/dev.js",
"build": "webpack --config ./scripts/webpack.base.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
我们将dev
命令替换成为执行scripts/utils/dev.js
。
接下来我们来试一下运行yarn dev
:
我们选中home
进行打包:
可以看到这次生成的打包结果就真的只有home.html
和home.js
相关的内容了,让我们打开localhost:9000/home.html
可以看到页面上出现了我们想要的内容~
此时尝试去访问http://localhost:9000/editor.html
会得到Cannot GET /editor.html
。
这一步我们大功告成啦~但是当前只有dev
环境下,让我们接下来来改造production
环境下的配置。
改造production
环境
production
环境的代码和dev
环境的流程思路是一摸一样的,只是针对于webpack
配置有所不同。
所以我们在scripts
新建一个webpack.prod.js
文件:
// 这里我使用了默认的`webpack`production下的配置
// 如果你需要额外的配置,可以额外添加配置。
// 这里提供动态多页应用的流程 具体压缩/优化插件和配置 各位小哥可以去官网查看配置~
// 之后我也会在文章开头的github仓库中提供不同branch去实践最佳js代码压缩优化
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const prodConfig = {
mode: 'production',
devtool: 'source-map',
output: {
filename: 'js/[name].js',
path: path.resolve(__dirname, '../dist'),
},
plugins: [
new CleanWebpackPlugin(),
new FriendlyErrorsWebpackPlugin()
]
}
module.exports = merge(prodConfig, baseConfig)
scripts/build.js
,这里和dev
下的思路是一模一样的,公用代码逻辑其实可以拆分~
const inquirer = require('inquirer')
const execa = require('execa')
const { log, separator } = require('./constant')
const { entry } = require('./helper')
const packagesList = [...Object.keys(entry)]
if (!packagesList.length) {
log('不合法目录,请检查src/packages/*/main.tsx', 'warning')
return
}
const allPackagesList = [...packagesList, 'all']
inquirer
.prompt([
{
type: 'checkbox',
message: '请选择需要打包的项目:',
name: 'buildLists',
choices: allPackagesList, // 选项
validate(value) {
return !value.length ? new Error('至少选择一个内容进行打包') : true
},
filter(value) {
if (value.includes('all')) {
return packagesList
}
return value
},
},
])
.then(res => {
// 拿到所有结果进行打包
const message = `当前选中Package: ${res.buildLists.join(' , ')}`
log(message, 'success')
runParallel(res.buildLists)
})
function runParallel(packages) {
const message = `开始打包: ${packages.join('-')}`
log(message, 'warning')
build(packages)
}
async function build(buildLists) {
const stringLists = buildLists.join(separator)
await execa('webpack', ['--config', './scripts/webpack.prod.js'], {
stdio: 'inherit',
env: {
packages: stringLists,
},
})
}
这一步其实我们已经完成了动态多页应用的实现了,原理部分之前也已经讲清楚了。
其实核心就是把我环境变量通过execa
和inquirer
进行命令行交互动态注入环境变量打包对应选中文件。
如果对
webpack
中环境变量还是不太熟悉的同学可以点击这篇文章,Wepback中环境变量的各种姿势。
Eslint
& prettier
完成了核心的应用流程打包代码,接下来我们来聊一些轻松的代码检查。
一份良好的工程架构代码规范检查是必不可少的配置。
prettier
yarn add --dev --exact prettier
安装完成之后我们在项目根目录下:
echo {}> .prettierrc.js
我们来个这个js
内容添加一些基础配置
module.exports = {
printWidth: 100, // 代码宽度建议不超过100字符
tabWidth: 2, // tab缩进2个空格
semi: false, // 末尾分号
singleQuote: true, // 单引号
jsxSingleQuote: true, // jsx中使用单引号
trailingComma: 'es5', // 尾随逗号
arrowParens: 'avoid', // 箭头函数仅在必要时使用()
htmlWhitespaceSensitivity: 'css', // html空格敏感度
}
我们再来添加一份.prettierignore
让prettier
忽略检查一些文件:
//.prettierignore
**/*.min.js
**/*.min.css
.idea/
node_modules/
dist/
build/
同时让我们为我们的代码基于husky
和lint-staged
添加git hook
具体配置可以参照这里husky&list-staged
安装完成后,在我们每次commit
时候都会触发lit-staged
自动修复我们匹配的文件:
因为我们项目中是ts
文件,所以要稍微修改一下他支持的后缀文件:
// package.json
...
"lint-staged": {
"*.{js,css,md,ts,tsx,jsx}": "prettier --write"
}
...
ESlint
Eslint
其实就不用多说了,大名鼎鼎嘛。
yarn add eslint --dev
初始化eslint
npx eslint --init
eslint
回和我们进行一些列的交互提示,按照提示进行选择我们需要的配置就可以了:
当
prettier
和eslint
共同工作时,他们可能会冲突。我们需要安装yarn add -D eslint-config-prettie
插件并且覆盖eslint
部分规则。
安装完成之后,我们稍微修改一下eslint
的配置文件,让冲突时,优先使用prettier
覆盖eslint
规则:
// .eslint.js
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
// 添加`prettier`拓展 用于和`prettier`冲突时覆盖`eslint`规则
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint"
],
"rules": {
}
};
同时我们来添加.eslintignore
忽略掉一些我们的非ts
目录文件,比如构建的一些脚本文件。
*.d.ts
scripts/**
写在最后
关于 Pages ,欢迎大家在留言区留下自己的意见指出对文章中的不足。
相信文章中的代码还有很多优化的点,这里提供给大家的主要是一个流程。
希望大家在评论区留下对于代码中存在的不足,或者可以持续优化点的。我们共同探讨😊!
后续如果有时间我会将Pages 持续完善然后集成到我cli
中去,期待和大家分享cli
部分的pages
~
转载自:https://juejin.cn/post/7011128931533193230