记入职半个月时在公司做的一次前端工程化的分享
theme: orange
highlight: an-old-hope
一. 代码书写/git提交规范(变量/类型命名, 组件拆分规则)。
1.1 类型命名
react组件
import React, { memo, useMemo } from 'react'
interface ITitleProps {
title: string
}
const Title: React.FC<ITitleProps> = props => {
const { title } = props
return (
<h2>{title}</h2>
)
}
export default memo(Title)
ITitleProps 以I为开头代表类型,中间为语义化Title,后面Props为类型
定义接口
例1: 登录接口
import { request } from '@/utils/request'
/** 公共的接口响应范型 */
export interface HttpSuccessResponse<T> {
code: number
message: string
data: T
}
/** 登录接口参数 */
export interface ILoginParams {
username: string
password: string
}
/** 登录接口响应 */
export interface ILoginData {
token: string
}
export type ILoginApi = (params: ILoginParams) => Promise<HttpSuccessResponse<ILoginData>>
/* 用户登录接口 */
export const loginApi: ILoginApi = params => {
return request.post('/distribute/school/login', params)
}
例2: 获取学生列表
import request from '@/utils/request'
/** 公共的分页接口响应范型 */
export type PageSuccessResponse<T> = HttpSuccessResponse<{
current: number
pages: number
records: T[]
searchCount: true
size: number
total: number
}>
/** 获取学生列表接口参数 */
export interface IStudentListParams {
pageSize: number
pageNumber: number
keyword?: string
}
/** 获取学生列表接口响应 */
export interface IStudentListItem {
name: string
age: number
sex: '男' | '女'
}
export type IStudengListApi = (params: IStudentListParams) => Promise<PageSuccessResponse<IStudentListItem[]>>
/* 获取学生列表接口 */
export const studengListApi: IStudengListApi = params => {
return request.get('/distribute/school/studengt/list', params)
}
export const studengListApi: IStudengListApi = params => {
return request.post('/distribute/school/studengt/list', params)
}
// 原先的
// export const studengListApi: IStudengListApi = params => {
// const options = {
// method: "GET",
// }
// const query = serializeObject(params)
// return request(`/distribute/school/studengt/list${query}`, options)
// }
1.2 事件
以on开头代表事件
1.3 其他很多规范
...
二. 代码规范
- vscode:统一前端编辑器。
- editorconfig: 统一团队vscode编辑器默认配置。
- prettier: 保存文件自动格式化代码。
- eslint: 检测代码语法规范和错误。
- stylelint: 检测和格式化样式文件语法
三. git提交规范
- husky:可以监听githooks执行,在对应hook执行阶段做一些处理的操作。
- lint-staged: 只检测暂存区文件代码,优化eslint检测速度。
- pre-commit:githooks之一, 在commit提交前使用tsc和eslint对语法进行检测。
- commit-msg:githooks之一,在commit提交前对commit备注信息进行检测。
- commitlint:在githooks的pre-commit阶段对commit备注信息进行检测。
- commitizen:git的规范化提交工具,辅助填写commit信息。
四. 项目目录规范
文件目录组织现在常用的有两种方式
4.1 按功能类型来划分
├─src # 项目目录
│ ├─api # 数据请求
│ │ └─Home # 首页页面api
│ │ └─Kind # 分类页面api
│ ├─assets # 资源
│ │ ├─css # css资源
│ │ └─images # 图片资源
│ ├─config # 配置
│ ├─components # 组件
│ │ ├─common # 公共组件
│ │ └─Home # 首页页面组件
│ │ └─Kind # 分类页面组件
│ ├─layout # 布局
│ ├─hooks # 自定义hooks组件
│ ├─routes # 路由
│ ├─store # 状态管理
│ │ └─Home # 首页页面公共的状态
│ │ └─Kind # 分类页面公共的状态
│ ├─pages # 页面
│ │ └─Home # 首页页面
│ │ └─Kind # 分类页面
│ ├─utils # 工具
│ └─main.ts # 入口文件
4.2 按领域模型划分
├─src # 项目目录
│ ├─assets # 资源
│ │ ├─css # css资源
│ │ └─images # 图片资源
│ ├─config # 配置
│ ├─components # 公共组件
│ ├─layout # 布局
│ ├─hooks # 自定义hooks组件
│ ├─routes # 路由
│ ├─store # 全局状态管理
│ ├─pages # 页面
│ │ └─Home # 首页页面
│ │ └─components # Home页面组件
│ │ ├─api # Home页面api
│ │ ├─store # Home页面状态管理器
│ │ ├─index.tsx # Home页面
│ │ └─Kind # 分类页面
│ ├─utils # 工具
│ └─main.ts # 入口文件
五. 状态管理器优化和统一
5.1 优化了下公司封装的状态管理
// createStore.ts
import React, { createContext, useContext, ComponentType, ComponentProps } from 'react'
/** 创建context组合useState状态Store */
function createStore<T>(store: () => T) {
// eslint-disable-next-line
const ModelContext: any = {}
/** 使用model */
function useModel<K extends keyof T>(key: K) {
return useContext(ModelContext[key]) as T[K]
}
/** 当前的状态 */
let currentStore: T
/** 上一次的状态 */
let prevStore: T
/** 创建状态注入组件 */
function StoreProvider(props: { children: React.ReactNode }) {
currentStore = store()
/** 如果有上次的context状态,做一下浅对比,
* 如果状态没变,就复用上一次context的value指针,避免context重新渲染
*/
if(!!prevStore) {
for(let key in prevStore) {
if(Shallow(prevStore[key], currentStore[key])) {
currentStore[key] = prevStore[key]
}
}
}
prevStore = currentStore
// eslint-disable-next-line
let keys: any[] = Object.keys(currentStore)
let i = 0, length = keys.length
/** 遍历状态,递归形成多层级嵌套Context */
function getContext<T, K extends keyof T>(key: K, val: T, children: React.ReactNode): JSX.Element {
let Context = ModelContext[key] || (ModelContext[key] = createContext(val[key]))
const currentIndex = ++i
/** 返回嵌套的Context */
return React.createElement(Context.Provider, {
value: val[key],
},
currentIndex < length ? getContext(keys[currentIndex], val, children) : children
)
}
return getContext(keys[i], currentStore, props.children)
}
/** 获取当前状态, 方便在组件外部使用,同时避免只使用方法的组件因为用到了context引起页面渲染 */
function getModel<K extends keyof T>(key: K): T[K] {
return currentStore[key]
}
/** 连接Model注入到组件中 */
function connectModel<Selected, K extends keyof T>(key: K, selector: (state: T[K]) => Selected) {
return function<P, C extends ComponentType<any>>(WarpComponent: C): ComponentType<Omit<ComponentProps<C>, keyof Selected>>{
function Connect(props: P) {
const val = useModel(key)
const state = selector(val)
/** @ts-ignore */
return React.createElement(WarpComponent, {
...props,
...state
})
}
return Connect as unknown as ComponentType<Omit<ComponentProps<C>, keyof Selected>>
}
}
return {
useModel,
connectModel,
StoreProvider,
getModel,
}
}
export default createStore
/** 浅对比对象 */
function Shallow<T>(obj1: T, obj2: T) {
if(obj1 === obj2) return true
if(Object.keys(obj1).length !== Object.keys(obj2).length) return false
for(let key in obj1) {
if(obj1[key] !== obj2[key]) return false
}
return true
}
5.2 store目录结构
├─src # 项目目录
│ ├─store # 全局状态管理
│ │ └─modules # 状态modules
│ │ └─user.ts # 用户信息状态
│ │ ├─other.ts # 其他全局状态
│ │ ├─createStore.ts # 封装的状态管理器
│ │ └─index.ts # store入口页面
5.3 定义状态管理器
5.3.1 在store/index.ts中引入
import { useState } from 'react'
/** 1. 引入createStore.ts */
import createStore from './createStore'
/** 2. 定义各个状态 */
// user
const userModel = () => {
const [ userInfo, setUserInfo ] = useState<{ name: string }>({ name: 'name' })
return { userInfo, setUserInfo }
}
// other
const otherModel = () => {
const [ other, setOther ] = useState<number>(20)
return { other, setOther }
}
/** 3. 组合所有状态 */
const store = createStore(() => ({
user: userModel(),
other: otherModel(),
}))
/** 向外暴露useModel, StoreProvider, getModel, connectModel */
export const { useModel, StoreProvider, getModel, connectModel } = store
5.3.2 在顶层通过StoreProvider注入状态
// src/main.ts
import React from 'react'
import ReactDOM from 'react-dom'
import App from '@/App'
// 1. 引入StoreProvider
import { StoreProvider } from '@/store'
// 2. 使用StoreProvider包裹App组件
ReactDOM.render(
<StoreProvider>
<App />
</StoreProvider>,
document.getElementById('root')
)
5.4 使用状态管理器
5.4.1 在函数组件中使用,借助useModel
import React from 'react'
import { useModel } from '@/store'
function FunctionDemo() {
/** 通过useModel取出user状态 */
const { userInfo, setUserInfo } = useModel('user')
/** 在点击事件中调用setUserInfo改变状态 */
const onChangeUser = () => {
setUserInfo({
name: userInfo.name + '1',
})
}
// 展示userInfo.name
return (
<button onClick={onChangeUser}>{userInfo.name}--改变user中的状态</button>
)
}
export default FunctionDemo
5.4.2 在class组件中使用,借助connectModel
import React, { Component } from 'react'
import { connectModel } from '@/store'
// 定义class组件props
interface IClassDemoProps {
setOther: React.Dispatch<React.SetStateAction<string>>
other: number
}
class ClassDemo extends Component<IClassDemoProps> {
// 通过this.props获取到方法修改状态
onChange = () => {
this.props.setOther(this.props.other + 1)
}
render() {
// 通过this.props获取到状态进行展示
return <button onClick={this.onChange}>{this.props.other}</button>
}
}
// 通过高阶组件connectModel把other状态中的属性和方法注入到类组件中
export default connectModel('other',state => ({
other: state.other,
setOther: state.setOther
}))(ClassDemo)
5.4.3 在组件外使用, 借助getModel
也可以在组件内读取修改状态方法,不回引起更新
import { getModel } from '@/store'
export const onChangeUser = () => {
// 通过getModel读取usel状态,进行操作
const user = getModel('user')
user.setUserInfo({
name: user.userInfo.name + '1'
})
}
// 1秒后执行onChangeUser方法
setTimeout(onChangeUser, 1000)
六. 封装请求统一,和项目解耦。
6.1 现有的封装
项目现用的请求封装和项目业务逻辑耦合在一块,不方便直接复用,使用上比较麻烦,每次需要传GET和POST类型,GET参数要每次单独做处理,参数类型限制弱。
import { message } from 'antd'
import { tokenStorage } from '@/common/storage'
enum HttpMethod {
GET = 'GET',
POST = 'POST',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function request(url: string, option: any): any {
const options = {
...option,
}
options.headers = {
// no-cors: 该模式用于跨域请求但是服务器不带CORS响应头,也就是服务端不支持CORS;这也是fetch的特殊跨域请求方式
mode: 'no-cors',
// 为了让浏览器发送包含凭据的请求(即使是跨域源),fetch 方法的init对象
credentials: 'include',
}
// 处理请求头信息
if (options.method === HttpMethod.GET || options.method === HttpMethod.POST) {
options.headers = {
// Accept 用来告知(服务器)客户端可以处理的内容类型
Accept: 'application/json',
...options.headers,
}
if (options.body && !(options.body instanceof FormData)) {
// 客户端告诉服务器实际发送的数据类型
options.headers['Content-Type'] = 'application/json; charset=utf-8'
options.body = JSON.stringify(options.body)
}
}
// 设置域名
options.headers.domain = 'CMS'
// 请求携带token
if (tokenStorage.getItem()) {
options.headers.Authorization = tokenStorage.getItem()
}
return (
fetch(url, options)
.then(async response => {
// 获取token
const authToken: string | null = response.headers.get('Authorization')
if (authToken) {
tokenStorage.setItem(authToken)
}
const isFile = response.headers.get('Content-Disposition')
if (isFile && response) {
const fileName = decodeURI(isFile.split('filename=')[1])
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
return response.text()
}
return response.json()
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then((response: any) => {
if (response.code === 0) {
return response
}
if (response.code === 401) {
if (tokenStorage.getItem()) {
message.destroy()
message.error(response.message)
}
if (window.location.href.indexOf('login') === -1) {
window.location.hash = '/login'
}
return response
}
if (tokenStorage.getItem()) {
message.destroy()
message.error(response.message)
}
return response
})
.catch(err => {
if (!String(err).includes('body stream already read')) {
message.destroy()
message.error('请求异常,请检查网络!')
}
return { code: 500, message: String(err) }
})
)
}
export default request
6.2 推荐使用
推荐直接使用axios,或者借鉴axios封装一个简版的axios。项目中基于次做二次封装,只关注和项目有关的逻辑,不关注请求的实现逻辑。
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import { tokenStorage } from '@/common/storage'
/** 封装axios的实例,方便多个url时的封装 */
export const createAxiosIntance = (baseURL: string): AxiosInstance => {
const request = axios.create({ baseURL })
// 请求拦截器器
request.interceptors.request.use((config: AxiosRequestConfig) => {
config.headers['Authorization'] = tokenStorage.getItem()
return config
})
// 响应拦截器
request.interceptors.response.use(
response => {
const code = response.data.code
switch (code) {
case 0:
return response.data
case 401:
goLogin()
return response.data || {}
default:
return response.data || {}
}
},
error => {
// 接口请求报错时,也返回对象,这样使用async/await就不需要加try/catch
// code为0为请求正常,不为0为请求异常,使用message提示
return { message: onErrorReason(error.message) }
}
)
return request
}
function goLogin() {
// 退出登录逻辑
}
/** 解析http层面请求异常原因 */
function onErrorReason(message: string): string {
switch (message) {
case 'Request failed with status code 401':
return '登录过期,请重新登录!'
case 'Network Error':
return '网络异常,请检查网络情况!'
}
if (message.includes('timeout')) {
return '请求超时,请重试!'
}
return '服务异常,请重试!'
}
export const request = createAxiosIntance('https://xiongmaooshi.com')
6.3 使用
使用上面代码命名定义接口类型的loginApi例子
/** 登录 */
const onLogin = async () => {
const res = await loginApi(params)
if(res.code === 0) {
// 处理登录正常逻辑
} else message.error(res.message)
}
七. api接口管理统一。
看上面1.1代码命名定义接口类型的loginApi例子
八. 通用hooks抽离复用。
├─src # 项目目录
│ ├─hooks # 自定义hooks
│ │ └─index.ts # hooks入口文件
│ │ ├─useBooleanState.ts # 布尔值hook
│ │ ├─useDebounce.ts # 防抖hook
│ │ ├─useMemoizedFn.ts # 缓存方法引用hook
│ │ ├─useSafeState.ts # 安全状态hook
│ │ ├─useThrottle.ts # 节流hook
useMemoizedFn.ts
import { useMemo, useRef } from 'react'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type noop = (this: any, ...args: any[]) => any
type PickFunction<T extends noop> = (
this: ThisParameterType<T>,
...args: Parameters<T>
) => ReturnType<T>
/** 替代useCallback,保存函数引用,不用关心依赖,每次都能访问到最新的值 */
function useMemoizedFn<T extends noop>(fn: T) {
const fnRef = useRef<T>(fn)
fnRef.current = useMemo(() => fn, [fn])
const memoizedFn = useRef<PickFunction<T>>()
if (!memoizedFn.current) {
// eslint-disable-next-line func-names
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args)
}
}
return memoizedFn.current as T
}
export default useMemoizedFn
useBooleanState.ts
import { useCallback, useState } from 'react'
/** 设置布尔值状态,适合model弹窗,抽屉等控制显示隐藏 */
function useBooleanState(initialState: boolean): [boolean, () => void, () => void] {
const [state, setState] = useState(initialState)
const onSetTrue = useCallback(() => setState(true), [])
const onSetFalse = useCallback(() => setState(false), [])
return [state, onSetTrue, onSetFalse]
}
export default useBooleanState
useSafeState.ts
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
function useSafeState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>]
function useSafeState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>]
/** 安全的state, 组件卸载后阻止setState */
function useSafeState<S>(initialState?: S | (() => S)) {
const unmountedRef = useRef<boolean>(false)
const [state, setState] = useState(initialState)
const setCurrentState = useCallback(currentState => {
if (unmountedRef.current) return
setState(currentState)
}, [])
useEffect(() => {
unmountedRef.current = false
return () => {
unmountedRef.current = true
}
}, [])
return [state, setCurrentState] as const
}
export default useSafeState
其他hooks
参考ahook
八. 通用组件抽离复用, UI/业务/功能组件。
- UI组件
- 业务组件
- 功能组件:上拉刷新,滚动到底部加载更多,虚拟滚动,拖拽排序,图片懒加载..
封装成npm包或者放单独文件夹使用
九. 本地存储统一管理
统一管理可以避免后期本地存储混乱不好维护问题。
export const localStorageKey = 'com.drpanda.school.'
interface IStorage<T> {
key: string
defaultValue: T
}
/** 封装获取,修改,删除本地存储 */
export class SchoolStorage<T> implements IStorage<T> {
key: string
defaultValue: T
constructor(key, defaultValue) {
this.key = localStorageKey + key
this.defaultValue = defaultValue
}
/** 设置值 */
setItem(value: T) {
sessionStorage.setItem(this.key, window.btoa(encodeURIComponent(JSON.stringify(value))))
}
/** 获取值 */
getItem(): T {
const value =
sessionStorage[this.key] && decodeURIComponent(window.atob(sessionStorage.getItem(this.key)))
if (value === undefined) return this.defaultValue
try {
return value && value !== 'null' && value !== 'undefined'
? (JSON.parse(value) as T)
: this.defaultValue
} catch (error) {
return value && value !== 'null' && value !== 'undefined'
? (value as unknown as T)
: this.defaultValue
}
}
/** 删除值 */
removeItem() {
sessionStorage.removeItem(this.key)
}
}
/** 管理本地存储token */
export const tokenStorage = new SchoolStorage<string>('token', '')
/** 管理其他本地存储 */
export const otherStorage = new SchoolStorage<string>('other', '')
/** 只清除当前项目所属的本地存储 */
export const clearSessionStorage = () => {
for (const key in sessionStorage) {
if (key.includes(localStorageKey)) {
sessionStorage.removeItem(key)
}
}
}
十. css超集和css模块化方案统一
css超集:使用less或者scss,看项目具体情况,能全项目统一就统一。
css模块化: vue使用style scoped, react使用css-module方案。
十一. 引入immer来优化性能和简化写法
Immer 是 mobx 的作者写的一个 immutable 库,核心实现是利用 ES6 的 Proxy(不支持Proxy的环境会自动使用Object.defineProperty来实现),几乎以最小的成本实现了 js 的不可变数据结构,简单易用、体量小巧、设计巧妙,满足了我们对js不可变数据结构的需求。
简单使用
1. 优化性能
修改用户信息
const [ userInfo, setUserInfo ] = useState({ name: 'immer', info: { age: 6 } })
const onChange = (age: number) => {
setUserInfo({...userInfo, info: {
...userinfo.info,
age: age
}})
}
上面某次修改age没有变,但setUserInfo时每次都生成了一个新对象,更新前后引用变化了,组件就会刷新。
使用immer后,age没变时不会生成新的引用,同时语法也更简洁
import produce from 'immer'
const [ userInfo, setUserInfo ] = useState({ name: 'immer', age: 5 })
const onChange = (age: number) => {
setUserInfo(darft => {
darft.age = age
})
}
2.简化写法
react遵循不可变数据流的理念,每次修改状态都要新生成一个引用,不能在原先的引用上进行修改,所以在对引用类型对象或者数组做操作时,总要浅拷贝一下,再来做处理,当修改的状态层级比较深的时候,写法会更复杂。
以数组为例,修改购物车某个商品的数量
import produce from 'immer'
const [ list, setList ] = useState([{ price: 100, num: 1 }, { price: 200, num: 1 }])
// 不使用用immer
const onAdd = useMemoizedFn((index: number) => {
/** 不使用immer */
// const item = { ...list[index] }
// item.num++
// list[index] = item
// setList([...list])
/** 使用immer */
setList(
produce(darft => {
darft[index].num++
}),
)
})
十三. 搭建npm私服管理可复用的依赖。
公司前端项目不推荐使用太多第三方包,可以自己搭建公司npm私服,来托管公司自己封装的状态管理库,请求库,组件库,以及脚手架cli,sdk等npm包,方便复用和管理。推荐使用verdaccio,搭建环境和使用都比较简单。
十四. 各类型项目通用模版封装。
- 通用后台管理系统基础模版封装
- 通用小程序基础模版封装
- 通用h5端基础模版封装
- 通用node端基础模版封装
- 其他类型的项目默认模版封装,减少重复工作。
十五. 搭建cli脚手架下载模版。
搭建类似vue-cli, vite, create-react-app类的cli命令行脚手架来快速选择和下载所需版本,比git拉代码要方便。
十六. 规范和使用文档输出文档站点,方便查阅。
代码规范和git提交规范以及各个封装的库使用说明要输出成文档部署到线上,方便新同事快速熟悉和使用。