STAY HUNGRY , STAY FOOLISH.

求知若饥,虚心若愚。

       浏览:

从无到有搭建一套自己的UI库(一)

很久没更新技术类博客,记得去年8月写过《不一样的思维去封装Ant-Design(一)》后就不了了之。回忆去年,感觉又悲又喜,悲的是“已无独处,静下心去思考”的时间,(我有个习惯,如果周围不是安静的状态,我基本写不出什么干货);喜的是“每天有家人的陪伴,女儿活泼乖巧、老婆善解人意,妈妈身体健康,忙碌且幸福”。

拥有孩子后,每天都是“热热闹闹”的,想回到之前“平静”、“安逸”的生活基本不可能。感慨一下,快30岁的人看起来像20岁的大学生,拥有16岁的爱玩心态,还怀有3岁小孩的好奇心,就是现在的我。

最近在关注和学习如何做好资产配置什么是增额终身寿险等,尽管自己目前并没多少资产可言,也不知道学它们对自己到底有多大帮助,但是理解和接触下这些知识总是好的。


回到前端技术本身,当今各大浏览器引擎都支持原生JavaScript模块
一切前端框架都依赖原生JavaScript API,如:vue2.x通过Object.definePropertysetget监听来实现双向绑定;vue3.x通过Proxy(代理) & Reflect(反射)来实现双向绑定;react16使用requestIdleCallbackrequestAnimationFrame实现Fiber调度和性能优化…

2022年的今天,和前年比,前端框架也有很多的升级。
react从16升级到最新18,react-router从5升级到最新的6,mbox从4升级到最新的6。
构建工具从原来webpack、rollup、parcel构建工具到新一代构建工具的vite、esbuild、snowpack、wmr。
升级构建工具和框架到最新的好处:拥有更好的交互体验,解决框架自身遗留问题,开发者能用新的功能特性
升级构建工具和框架到最新的难处:老项目很难直接升级最新,兼容性问题难处理

最佳解决方案是,从无到有搭建一套属于自己的UI库。(目前全用最新版本,不需要关心如何兼容老项目,后续新项目能用就行。)

UI库可以理解成脚手架+UI组件,今天的主要内容是通过vite脚手架实现项目的基本结构。

  • 基于最新node版本v18.4.0环境开发
  • typescript:4.7.4,最新版本
  • react相关:react、react-dom:18.2.0,最新版本
  • 路由相关:react-router、react-router-dom:6.3.0,最新版本
  • 状态管理相关:mobx:6.6.1、 mobx-react-lite:3.4.0,最新版本
  • UI库相关:antd:4.21.4,最新版本

文章目录会按照以下顺序介绍和搭建:

  • 引入Vite(脚手架)
    • 1.Vite生产环境为什么选择Rollup做构建工具
    • 2.Vite为什么不用Rollup的热更新
    • 3.Vite为什么不用Webpack
    • 4.引入最新Vite(完成项目搭建)
  • 引入最新Mobx(状态管理)
  • 引入最新antd(UI库)
  • 引入最新react-router(路由)
  • 整合它们实现简单demo
    • 1.项目相对路径支持@别名
    • 2.引入antd库国际化
    • 3.初始化react-router
    • 4.初始化mbox,集成mobx-react-lite
    • 5.设置路由react-router

一.引入Vite(脚手架)

Vite是一个由原生ESM驱动的Web开发构建工具。开发环境下使用原生ESM imports,生产环境下使用Rollup打包

Vite可以理解成一个脚手架工具,Vite生成环境依赖的Rollup是构建工具,类似Webpack。

1.Vite生产环境为什么选择Rollup做构建工具?

Vite是一个由原生ESM驱动的Web开发构建工具。在选择构建工具的时候也最好可以选择基于ESM的工具。

Rollup是基于ES2015的JavaScript打包工具。它将小文件打包成一个大文件或者更复杂的库和应用,打包既可用于浏览器和Node.js使用。 Rollup最显著的地方就是能让打包文件体积很小。相比其他JavaScript打包工具,Rollup总能打出更小,更快的包。因为Rollup基于ES2015模块,比Webpack和Browserify使用的CommonJS模块机制更高效。


2.Vite为什么不用Rollup的热更新?

Vite开发模式单独实现了一套热更新(HMR - Hot Module Replacement),可是从Rollup Awesome中可以发现,Rollup有热更新插件nollup。为什么Vite不用Rollup的热更新呢?

从Vite的README,我们可以发现:

1
Vite was created to tackle native ESM-based HMR. When Vite was first released with working ESM-based HMR, there was no other project actively trying to bring native ESM based HMR to production.

也就是说Vite是第一个发布基于纯ESM的热更新。当时Rollup还没有纯ESM的热更新。


3.Vite为什么不用Webpack?

Webpack和Rollup功能差不多,以前有种说法是应用开发用Webpack,库开发用Rollup。但是现在Webpack也支持Tree shaking,Rollup也有热更新,而且都有强大的插件开发功能。二者的功能差异越来越模糊。
二者更多的区别是在写法上。
如下是Rollup的配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// rollup.config.js
import babel from 'rollup-plugin-babel';

export default {
input: './src/index.js',
output: {
file: './dist/bundle.rollup.js',
format: 'cjs'
},
plugins: [
babel({
presets: [
[
'es2015', {
modules: false
}
]
]
})
]
}

下面是webpack的配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// webpack.config.js
const path = require('path');
const webpack = require('webpack');

module.exports = {
entry: {
'index.webpack': path.resolve('./src/index.js')
},
output: {
libraryTarget: "umd",
filename: "bundle.webpack.js",
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
query: {
presets: ['es2015']
}
}
]
}
}

可以看出:

  • Rollup使用新的ESM,而Webpack用的是旧的CommonJS。
  • Rollup支持相对路径,webpack需要使用path模块。
    Rollup使用起来更简洁,并且Rollup打出更小体积的文件,所以Rollup更适合Vite

4.引入最新Vite(完成项目搭建)

兼容性注意Vite 需要 Node.js 版本 >= 14.18.0。然而,有些模板需要依赖更高的 Node 版本才能正常运行,当你的包管理器发出警告时,请注意升级你的 Node 版本。

因此先安装最新的node,当前最新node版本是v18.4.0:

1
nvm install v18.4.0

node升级后,安装vite:

1
npm create vite@latest

选择react-ts,后自动生成项目。安装node_modules,运行项目:

1
2
npm i
npm run dev

二、引入最新Mobx(状态管理)

MobX 有两种 React 绑定方式,其中mobx-react-lite仅支持函数组件,mobx-react 还支持基于类的组件。我选择mobx-react-lite

mobx6和mobx4主要区别是放弃使用装饰器@action@observable@computed等。主要原因是装饰器语法其实已经出来很久了,但一直未纳入ES标准,出于兼容性的考虑,建议使用makeObservable / makeAutoObservable代替。

decorators

1
npm install --save mobx mobx-react-lite

三、引入最新antd(UI库)

1
npm install --save antd

四、引入最新react-router(路由)

react-router6和react-router5主要区别是废弃老组件、hooks,使用新组件、hooks。如以前的SwitchRedirectuseHistory都不能使用,新的hooks如useNavigate

1
npm install react-router-dom@6 --save

五、整合它们实现简单demo

截止目前,react、ts、mobx、antd、react-router已经基本可实现项目的基本结构。

app.tsx:

1
2
3
<React.StrictMode>
<App />
</React.StrictMode>

补充一下:<React.StrictMode>包裹的组件包括其内所有的后代会被检查到,StrictMode的目的:

  • 识别具有不安全生命周期的组件
  • 有关旧式字符串ref用法的警告
  • 检测意外的副作用
  • 检测遗留 context API

1.项目相对路径支持@别名

更改index.html,id,个性化自己的,适合当前公司的名字:

1
<div id="root"></div>
1
<div id="bee-logistic"></div>

更改main.tsx,渲染的dom,id:

1
ReactDOM.createRoot(document.getElementById('root')!)...
1
ReactDOM.createRoot(document.getElementById('bee-logistic')!)...

加强tsconfig.json,配置参数demo如下:http://json.schemastore.org/tsconfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

compilerOptions里面新增baseUrlpaths

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

引入node模块类型的ts声明:

1
npm install @types/node --save-dev

引入node的path模块,更改vite.config.ts

1
2
3
4
5
6
7
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()]
})
1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { join } from "path";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': join(__dirname, "src"),
}
}
})

改好后,之前需要相对路径引入文件可以用@符号替换:

1
2
import App from './pages/App'
import logo from './../assets/images/logo.svg'
1
2
import App from '@/pages/App'
import logo from '@/assets/images/logo.svg'

2.引入antd库国际化

更改app.tsx:

1
2
3
<React.StrictMode>
<App />
</React.StrictMode>
1
2
3
4
5
6
7
8
import { ConfigProvider } from 'antd'
import zhCN from "antd/es/locale/zh_CN"

<React.StrictMode>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</React.StrictMode>

3.初始化react-router

更改app.tsx:

1
2
3
4
5
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</React.StrictMode>
1
2
3
4
5
6
7
8
9
import { BrowserRouter } from "react-router-dom"

<React.StrictMode>
<BrowserRouter>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</BrowserRouter>
</React.StrictMode>

4.初始化mbox,集成mobx-react-lite

选择集成轻量化的mobx-react-lite而非mobx-react是因为打算只用函数式组件去写代码,并不会用到类组件。
我相信未来也会是这个趋势,函数组件基本会替代类组件。

1.更改app.tsx:

1
2
3
4
5
6
7
<React.StrictMode>
<BrowserRouter>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</BrowserRouter>
</React.StrictMode>
1
2
3
4
5
6
7
8
9
10
11
import { StoreProvider } from "@/store/index"

<React.StrictMode>
<StoreProvider>
<BrowserRouter>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</BrowserRouter>
</StoreProvider>
</React.StrictMode>

2.实现StoreProvider:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { createContext, useContext } from "react"
import { useLocalObservable, } from "mobx-react-lite"
import createStore from "./store"

type StoreType = ReturnType<typeof createStore>

const storeContext = createContext<StoreType>(null)

export const StoreProvider = ({ children }: any) => {
const { Provider } = storeContext
const store = useLocalObservable(createStore)
return <Provider value={store}>{children}</Provider>
}

export const useStore = () => {
const store = useContext(storeContext)
if (!store) {
return useContext(storeContext)
}
return store
}

3.声明总store:

1
2
3
4
5
6
7
import testStore from "./workbench/test"

export default function createStore() {
return {
testStore,
}
}

4.声明子store:

1
2
3
4
5
6
7
8
9
10
11
import { makeAutoObservable } from "mobx"

class TestStore{
constructor() {
makeAutoObservable(this)
}
count = 0
increase(){this.count += 1}
}

export default new TestStore()

5.在子组件使用store:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { observer, } from "mobx-react-lite"
import { useStore } from '@/store'

function Header() {
const { testStore } = useStore()
return(
<header className="App-header">
<button type="button" onClick={() => testStore.increase()}>
count is: {testStore.count}
</button>
</header>
)
}

6.认识mbox核心:useLocalObservablemakeAutoObservableAPI

1
2
useLocalObservable 等价于
const [store] = useState(() => observable({ /* something */}))

makeAutoObservable
好处:自动注入注解,无需重复声明action、observable等。
不足:该类不能继承父类。
推断规则:
所有 自有属性都成为 observable
所有 getters 都成为 computed
所有 setters 都成为 action
所有 prototype 中的 functions 都成为 autoAction
所有 prototype 中的 generator functions 都成为 flow
overrides 参数中标记为 false 的成员将不会被添加注解。例如,将其用于像标识符这样的只读字段。

1
2
3
4
class类里面构造器加makeAutoObservable
constructor() {
makeAutoObservable(this)
}

5.设置路由react-router

最外层,已经设置过BrowserRouter。
1.在App.tsx中设置主路由和404

1
2
3
4
5
6
7
8
9
10
11
12
import { Routes, Route, } from 'react-router-dom'
import Header from '@/pages/Header'
import NotFound from '@/pages/layout/404'

function App() {
return (
<Routes>
<Route path="/" element={<div className="App"><Header/></div>} />
<Route path="*" element={<NotFound />} />
</Routes>
)
}

2.通过React.lazySuspense配合一起用,能够实现动态加载组件的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React, { lazy, Suspense } from 'react'
import { Spin } from 'antd'

const Header = lazy(() => import('@/pages/Header'))

function BSuspense({ children }) {
return (
<Suspense
fallback={
<div className="ui-layout-loading">
<Spin tip={"加载中..."} />
</div>
}>
{children}
</Suspense>
)
}

function App() {
return (
<Routes>
<Route path="/" element={<BSuspense><Header/></BSuspense>} />
<Route path="*" element={<NotFound/>} />
</Routes>
)
}

效果如下:
loading


3.结合store实现登录和工作台之间的切换(仿登录逻辑)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { observer } from "mobx-react-lite"
import { useStore } from '@/store'

const Header = lazy(() => import('@/pages/Header'))
const Workbench = lazy(() => import('@/pages/Workbench'))

function App() {
const { authStore } = useStore()
return (
<Routes>
<Route path="/" element={<BSuspense>{authStore.isLogin ? <Workbench/>: <Header/>}</BSuspense>} />
<Route path="*" element={<NotFound/>} />
</Routes>
)
}

声明的auth.ts:

1
2
3
4
5
6
7
8
9
10
11
import { makeAutoObservable } from "mobx"

class AuthStore{
constructor() {
makeAutoObservable(this)
}
isLogin = false
setLogin(login){this.isLogin = login}
}

export const authStore = new AuthStore()

声明的Header.tsx:(一个页面使用多个store的情况)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import logo from '@/assets/images/logo.svg'
import { observer, } from "mobx-react-lite"
import { useStore } from '@/store'

function Header() {
const { testStore, authStore, } = useStore()
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Hello Vite + React!</p>
<p>
<button type="button" onClick={() => testStore.increase()}>
count is: {testStore.count}
</button>
</p>
<p>
<button type="button" onClick={() => authStore.setLogin(true)}>login</button>
</p>
</header >
</div>
)
}
export default observer(Header)

声明的Workbench.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import logo from '@/assets/images/logo.svg'
import { observer, } from "mobx-react-lite"
import { useStore } from '@/store'

function Workbench() {
const { authStore, } = useStore()
return (
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>Workbench</p>
<p>
<button type="button" onClick={() => authStore.setLogin(false)}>back to home</button>
</p>
</header >
)
}
export default observer(Workbench)

效果如下:
login