本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐
引言
Hello大家好,前段时间写了个Vue3的工程项目用起来还不错,其实老早前就想把它移植过来React
这边,奈何工作比较忙一直拖到现在,才陆陆续续把杂七杂八的模块补充好。
既然迁移过来了,也借着空闲时间给大家介绍下一个 Vite2
+ React
+ TypeScript
的项目中, 如何合理搭建和使用周边插件,以及让他们组合到整个工程中去,也欢迎大家阅览和补充更优想法。
接下来,为了让大家更好理解本项目工程化的思路,本文会按照以下关键词去逐步研读:
React
其实自react hook
诞生以来,网上两把声音对其褒贬不一,和传统class component写法比较的优缺点大概就下面这些:
hooks优点
1. 更容易复用代码:每份useHook都能生成独立状态,更易于组件抽离,工程解耦等;
2. 代码量更少:不需要定义繁琐的react component模板代码,状态的读写不需要在每个生命钩子中穿插使用,使代码结构变得浅层、简单;
hooks缺点
1. 副作用的性能开销:在监控某个状态变化时用的
useEffect
假如使用不当,很容易造成其他状态相互依赖而产生调用链,带来额外的性能开销;另外监听的global属性「如:location等...」,还有可能会造成全局污染;2. 异步的代码的处理:在多个状态有前后依赖时,很难处理他们的读写顺序;
本项目所有单文件组件都是React v16.8+ 的hooks写法,其考虑点主要在于本项目主要以工程框架介绍为主,hook写法能更好帮助组件的定义和抽离,呈现模块化结构,也更利于理解整个结构。
Typescript
近几年前端对 TypeScript的呼声越来越高,Typescript也成为了前端必备的技能。TypeScript 是 JS类型的超集,并支持了泛型、类型、命名空间、枚举等特性,弥补了 JS 在大型应用开发中的不足。
Vite
Vite是一种新型前端构建工具,能够显著提升前端开发体验。比起webpack,vite还是有它很独特的优势,这里推荐一篇文章《Vite 的好与坏》给大家参考下。
项目为什么选vite代替webpack,结合社区和个人考虑,有几点:(具体就不展开,推文已经分析的很细致了)
- Vite更加轻量,并且构建速度足够快
webpack是使用nodejs去实现,而viite使用 esbuild 预构建依赖。Esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快不是一个数量级。 - Vue官方出品,之前在vue项目实践过效果不错,另外vite也支持了react模板
- 发展势头迅猛,未来可期
当然事物都有两面性的,至目前为止,vite也有不少缺陷,例如:生态没有webpack成熟、生产环境下隐藏的不稳定因素等都是它如今要面临的问题。
但是,心怀梦想敢于向前,没有新势力的诞生,哪里来的技术发展?相比之下,vite更像一个青年,并逐步前行。
Redux Toolkit
React的状态管理库历来就是轮子重灾区,各种设计模式层出不穷,这里就不多介绍了。
项目不复杂,要求性能不高的直接用useContext、useReducer
就行,简单也容易实现;假如你追求优秀的设计模式并且适配项目结构,直接基于Redux
手写个轮子出来也行。
本项目选用Redux Toolkit
作为项目管理,一来,它在众多产品中算是比较优秀的一个框架,使用起来也简单、结构清晰;二来,它封装了immer,写起异步逻辑挺方便的,用起来也可以应对大多数情景。
工程化搭建
言归正传,我们通过以上技术,整合到一个项目中去。一般用于企业级生产的项目,要具备以下能力:
- 容错性、可拓展性强
- 组件高内聚,减少模块之间耦合度
- 清晰的项目执行总线,方便增加插槽逻辑
- 高度抽象的全局方法
- 资源压缩+性能优化等
对照这些指标,我们来逐步搭建一个初步的工程框架。
1. 技术栈
编程: React16.8+
+ Typescript
构建工具:Vite
路由 | 状态管理:react-router-dom v6
+ @reduxjs/toolkit
UI Element:Ant Design Mobile
2. 工程结构
. ├── README.md ├── index.html 项目入口 ├── mock mock目录 ├── package.json ├── public ├── src │ ├── App.tsx 主应用 │ ├── app.module.less │ ├── api 请求中心 │ ├── assets 资源目录(图片、less、css等) │ ├── components 项目组件 │ ├── constants 常量 │ └── vite-env.d.ts 全局声明 │ ├── main.tsx 主入口 │ ├── pages 页面目录 │ ├── routes 路由配置 │ ├── types ts类型定义 │ ├── store 状态管理 │ └── utils 基础工具包 ├── test 测试用例 ├── tsconfig.json ts配置 ├── .eslintrc.js eslint配置 ├── .prettierrc.json prettier配置 ├── .gitignore git忽略配置 └── vite.config.ts vite配置
其中,src/utils
里面放置全局方法,供整个工程范围的文件调用,当然工程初始化的事件总线也放在这里「下面会细述」。src/types
和src/constants
分别存放项目的类型定义和常量,以页面结构来划分目录。
3. 工程配置
搭建Vite + React项目
# npm 6.x npm init vite@latest my-vue-app --template react-ts # npm 7+, 需要额外的双横线: npm init vite@latest my-vue-app -- --template react-ts # yarn yarn create vite my-vue-app --template react-ts # pnpm pnpm create vite my-vue-app -- --template react-ts
然后按照提示操作即可!
Vite配置
/* eslint-disable no-extra-boolean-cast */ import { defineConfig, ConfigEnv } from 'vite'; import styleImport from 'vite-plugin-style-import'; import react from '@vitejs/plugin-react'; import { viteMockServe } from 'vite-plugin-mock'; import { visualizer } from 'rollup-plugin-visualizer'; import path from 'path'; // https://vitejs.dev/config/ export default defineConfig(({ command }: ConfigEnv) => { return { base: './', plugins: [ react(), // mock viteMockServe({ mockPath: 'mock', //mock文件地址 localEnabled: !!process.env.USE_MOCK, // 开发打包开关 prodEnabled: !!process.env.USE_CHUNK_MOCK, // 生产打包开关 logger: false, //是否在控制台显示请求日志 supportTs: true }), styleImport({ libs: [] }), !!process.env.REPORT ? visualizer({ open: true, gzipSize: true, filename: path.resolve(__dirname, 'dist/stats.html') }) : null ], resolve: { alias: [ { find: '@', replacement: '/src' } ] }, css: { // css预处理器 preprocessorOptions: { less: { javascriptEnabled: true, charset: false, additionalData: '@import "./src/assets/less/common.less";' } } }, build: { terserOptions: { compress: { drop_console: true } }, outDir: 'dist', //指定输出路径 assetsDir: 'assets' //指定生成静态资源的存放路径 } }; });
工程添加了mock模式供开发者在没有服务端情况下模拟数据请求,通过vite-plugin-mock
插件全局配置到vite中,mock接口返回在mock
目录下增加,mock模式启动命令:npm run dev:mock
。
FYI:
vite-plugin-mock
插件在vite脚手架下提供devtools network拦截能力,假如你要实现更多mock场景,请使用mockjs「项目已安装,直接可用」。
编码规范
事件总线
为了规范项目的初始化流程,方便在流程中插入自定义逻辑,在main.tsx
入口调用initialize(app)
方法,initialize代码如下:
import React from 'react'; import ReactDOM from 'react-dom'; import { Toast } from 'antd-mobile'; import App from './App'; import { initialize } from '@/utils/workflow'; // 初始化总线 initialize().then(flat => { if (!flat) { Toast.show({ icon: 'fail', content: '初始化失败' }); return; } ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') ); });
在方法里面,分别完成页面的rem自适应布局初始化等操作,另外initialize
支持异步逻辑注入,需要的自行添加并使用Promise包裹返回即可。
ps:
initialize
方法执行时机在主App挂载之前,请勿将dom操作逻辑放置此处
4. React Router
因为使用的是react-router-dom v6
,所以与之前的写法和hook有所区别,一个个来说。另外,v6版本还是有不少优势的,可参考官方团队解读。
tsx组件
// src/App.tsx import React from 'react'; import { BrowserRouter } from 'react-router-dom'; import { DotLoading } from 'antd-mobile'; import { Provider } from 'react-redux'; import RouterComponent from '@/routes'; import Header from '@/components/header'; import store from '@/store'; import style from './app.module.less'; const App = () => { return ( <Provider store={store}> <div className={style.appBody}> <React.Suspense fallback={<DotLoading />}> <BrowserRouter> <Header /> <RouterComponent /> </BrowserRouter> </React.Suspense> </div> </Provider> ); }; export default App;
RouterComponent
组件和Header
包裹在BrowserRouter
中,因为涉及到整个单页都会用到路由能力。下面我们再来看看RouterComponent的实现:
// src/routes/index.tsx import React, { FC, useEffect } from 'react'; import routes from './routesConfig'; import { Route, Routes, useNavigate, Navigate } from 'react-router-dom'; import { ErrorBlock } from 'antd-mobile'; import { IRoute } from '@/types/router'; import { isLogin } from '@/utils/userLogin'; // 路由装饰器 const RouteDecorator = (props: { route: IRoute }) => { const { route } = props; const navigate = useNavigate(); useEffect(() => { // 鉴权路由守卫 if (route.meta?.requireAuth) { if (!isLogin()) { navigate('/login', { state: { redirect: route.pathname } }); } } // 自定义路由守卫 route.beforeCreate && route.beforeCreate(route); return () => route.beforeDestroy && route.beforeDestroy(route); }, [route]); return <route.component />; }; const RouterComponent: FC = () => ( <Routes> <Route path="/" element={<Navigate to="/index" />} /> <Route path="*" element={<ErrorBlock fullPage />} /> {routes.map(route => ( <Route key={route.pathname} path={route.pathname} element={<RouteDecorator route={route} />} /> ))} </Routes> ); export default RouterComponent;
- 定义2个特殊路由:重定向和404;
- 定义1个
routesConfig
配置文件,记录每个路由页面的信息,类型定义如下:
export interface IRoute extends RouteProps { // 路径 pathname: string; // 名称 name: string; // 中文描述,可用于侧栏列表 title: string; // react组件函数 component: FC; // 页面组件创建时执行的hook beforeCreate: (route: IRoute) => void; // 页面组件销毁时执行的hook beforeDestroy: (route: IRoute) => void; // 属性 meta: { navigation: string; requireAuth: boolean; }; }
- 定义路由装饰器RouteDecorator:主要作用是路由守卫,另外执行每个路由页面创建时和销毁时的自定义hooks;
- 在config中,每个组件通过
react-lazily-component
插件懒加载,优化加载策略;
5. 请求中心
src/api
包含每个页面的异步请求,也是通过页面结构来划分目录。src/api/index.ts
是其入口文件,用来聚合每个请求模块,代码如下:
import { Request } from './request'; import box from './box'; import user from './user'; // 初始化axios Request.init(); export default { box, user // ...其他请求模块 };
这里的Request是请求中心的类对象,返回1个axios实例,src/api/request.ts
代码如下:
import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios'; import { Toast } from 'antd-mobile'; import { IRequestParams, IRequestResponse, TBackData } from '@/types/global/request'; interface MyAxiosInstance extends AxiosInstance { (config: AxiosRequestConfig): Promise<any>; (url: string, config?: AxiosRequestConfig): Promise<any>; } export class Request { public static axiosInstance: MyAxiosInstance; public static init() { // 创建axios实例 this.axiosInstance = axios.create({ baseURL: '/api', timeout: 10000 }); // 初始化拦截器 this.initInterceptors(); } // 初始化拦截器 public static initInterceptors() { // 设置post请求头 this.axiosInstance.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded'; /** * 请求拦截器 * 每次请求前,如果存在token则在请求头中携带token */ this.axiosInstance.interceptors.request.use( (config: IRequestParams) => { const token = localStorage.getItem('ACCESS_TOKEN'); if (token) { config.headers.Authorization = 'Bearer ' + token; } return config; }, (error: any) => { Toast.show({ icon: 'fail', content: error }); } ); // 响应拦截器 this.axiosInstance.interceptors.response.use( // 请求成功 (response: IRequestResponse): TBackData => { const { data: { code, message, data } } = response; if (response.status !== 200 || code !== 0) { Request.errorHandle(response, message); } return data; }, // 请求失败 (error: AxiosError): Promise<any> => { const { response } = error; if (response) { // 请求已发出,但是不在2xx的范围 Request.errorHandle(response); } else { Toast.show({ icon: 'fail', content: '网络连接异常,请稍后再试!' }); } return Promise.reject(response?.data); } ); } /** * http握手错误 * @param res 响应回调,根据不同响应进行不同操作 * @param message */ private static errorHandle(res: IRequestResponse, message?: string) { // 状态码判断 switch (res.status) { case 401: break; case 403: break; case 404: Toast.show({ icon: 'fail', content: '请求的资源不存在' }); break; default: // 错误信息判断 message && Toast.show({ icon: 'fail', content: message }); } } }
这里面做了几件事情:
- 配置axios实例,在拦截器设置请求和相应拦截操作,规整服务端返回的
retcode
和message
; - 改写
AxiosInstance
的ts类型(由AxiosPromise
→Promise
),矫正调用方能正确判断返回数据的类型; - 设置1个初始化函数
init()
,生成一个axios的实例供项目调用; - 配置
errorHandle
句柄,处理错误;
当然在第2步,你可以添加额外的请求拦截,例如RSA加密,本地缓存策略等,当逻辑过多时,建议通过函数引入。
至此,我们就能愉快使用axios去请求数据了。
// api模块→请求中心 import { Request } from './request'; userInfo: (options?: IRequestParams): Promise<TUser> => Request.axiosInstance({ url: '/userInfo', method: 'post', desc: '获取用户信息', isJSON: true, ...options }) // 业务模块→api模块 import request from '@/api/index'; request.user .userInfo({ data: { token } }) .then(res => { // do something... });
5. SSR
待补充...
性能测试
开发环境启动
图中可以看出,Vite在冷启动时对6项依赖进行Pre-Bundling后注入主应用中,整个项目启动时间只花了1463ms,性能相当快,这里不由感叹尤大对工程研究确实有一套😆。
构建后的资源包
分包策略是依据路由页面来切割,对js和css单独分离。
Lighthouse测试
以上为本地测试,首屏大约1000ms~1500ms,压力主要来源vendor.js的加载以及首屏图片资源拉取(首屏图片资源来源于网络)。其实通过模块分割加载后,首页的js包通过gzip压缩到4.3kb。
当然真实场景是,项目部署上云服务器后肯定达不到本地资源加载速度,但可以通过CDN来加速优化,其效果也比较显著。
Performance
参考文章
写在最后
感谢大家阅览并欢迎纠错,欢迎大家关注本人公众号「是马非马」,一起玩耍起来!🌹🌹