今天为大家分享一篇关于前端基建的文章,当团队达到3个人+的时候,前端的基础建设就显得更为重要了。良好的基础建设可以保证代码的规范性、可读性和可维护性,从而提高整体代码质量;共享的公共组件和函数库可以减少重复开发,前端工程模板和接口规范可以提高开发效率;统一的规范和标准可以减少潜在的错误和问题,提高项目的稳定性和可靠性。
如果恰巧你所在的公司前端都在各自为营,那你可以好好看看这篇文章,希望对你有所启发。
项目目录规范
文件目录组织现在常用的有两种方式,后面公司采用的第二种,更方便一些。两种方式没有最好的,只有更适合自己公司的,只要公司内部达成一致了,用哪一种都会很方便。
按功能类型来划分
按文件的功能类型来分,「比如api」,「组件」,「页面」,「路由」,「hooks」,「store」,不管是全局使用到的,还是单独页面局部使用到的,都按照功能类型放在「src」下面对应的目录里面统一管理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| ├─src │ ├─api │ │ └─Home │ │ └─Kind │ ├─assets │ │ ├─css │ │ └─images │ ├─config │ ├─components │ │ ├─common │ │ └─Home │ │ └─Kind │ ├─layout │ ├─hooks │ ├─routes │ ├─store │ │ └─Home │ │ └─Kind │ ├─pages │ │ └─Home │ │ └─Kind │ ├─utils │ └─main.ts
|
按领域模型划分
按照页面功能划分,全局会用到的「组件」,「api」等还是放到「src」下面全局管理,页面内部单独使用的「api」和「组件」放到对应页面的文件夹里面,使用的时候不用上下查找文件,在当前页面文件夹下就能找到,比较方便,功能也内聚一些。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| ├─src │ ├─assets │ │ ├─css │ │ └─images │ ├─config │ ├─components │ ├─layout │ ├─hooks │ ├─routes │ ├─store │ ├─pages │ │ └─Home │ │ └─components │ │ ├─api │ │ ├─store │ │ ├─index.tsx │ │ └─Kind │ ├─utils │ └─main.ts
|
代码书写规范
规范比较多,这里只简单列举一下基本的规范约束和使用工具来自动化规范代码。
组件结构
react组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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: 登录接口,定义好参数类型和响应数据类型,参数类型直接定义「params」的类型,响应数据放在「范型」里面,需要在封装的时候就处理好这个范型。
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 { 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 const loginApi = (params: ILoginApi) => { return request.post<ILoginData>('/xxx', params) }
|
事件
以「on」开头代表事件,这个只是规范,「on」要比「handle」短一点,哈哈。
1 2 3
| const onChange = () => {
}
|
工具约束代码规范
除了约定俗称的规范,我们也需要借助一些工具和插件来协助我们更好的完成规范这件事情。
代码规范
- 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」信息。
了解更多,详见
状态管理器优化和统一
优化状态管理
用「react」的「context」封装了一个简单的状态管理器,有完整的类型提升,支持在组件内和外部使用,也发布到npm(https://www.npmjs.com/package/reactx-atom-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 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
| import React, { createContext, useContext, ComponentType, ComponentProps } from 'react'
function createStore<T>(store: () => T) { const ModelContext: any = {};
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();
if (prevStore) { for (const key in prevStore) { if (shallow(prevStore[key], currentStore[key])) { currentStore[key] = prevStore[key]; } } } prevStore = currentStore; let keys: any[] = Object.keys(currentStore); let i = 0; const length = keys.length; function getContext<T, K extends keyof T>( key: K, val: T, children: React.ReactNode, ): JSX.Element { const Context = ModelContext[key] || (ModelContext[key] = createContext(val[key])); const currentIndex = ++i; return React.createElement( Context.Provider, { value: val[key], }, currentIndex < length ? getContext(keys[currentIndex], val, children) : children, ); } return getContext(keys[i], currentStore, props.children); }
function getModel<K extends keyof T>(key: K): T[K] { return currentStore[key]; }
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>> { const Connect = (props: P) => { const val = useModel(key); const state = selector(val); 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 }
|
store目录结构
1 2 3 4 5 6 7
| ├─src │ ├─store │ │ └─modules │ │ └─user.ts │ │ ├─other.ts │ │ ├─createStore.ts │ │ └─index.ts
|
定义状态管理器
在store/index.ts中引入
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 { useState } from 'react'
import createStore from './createStore'
const userModel = () => { const [ userInfo, setUserInfo ] = useState<{ name: string }>({ name: 'name' }) return { userInfo, setUserInfo } }
const otherModel = () => { const [ other, setOther ] = useState<number>(20) return { other, setOther } }
const store = createStore(() => ({ user: userModel(), other: otherModel(), }))
export const { useModel, StoreProvider, getModel, connectModel } = store
|
在顶层通过StoreProvider注入状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import React from 'react' import ReactDOM from 'react-dom' import App from '@/App'
import { StoreProvider } from '@/store'
ReactDOM.render( <StoreProvider> <App /> </StoreProvider>, document.getElementById('root') )
|
使用状态管理器
在函数组件中使用,借助useModel
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import React from 'react' import { useModel } from '@/store'
function FunctionDemo() {
const { userInfo, setUserInfo } = useModel('user')
const onChangeUser = () => { setUserInfo({ name: userInfo.name + '1', }) }
return ( <button onClick={onChangeUser}>{userInfo.name}--改变user中的状态</button> ) }
export default FunctionDemo
|
在class组件中使用,借助connectModel
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
| import React, { Component } from 'react' import { connectModel } from '@/store'
interface IClassDemoProps { setOther: React.Dispatch<React.SetStateAction<string>> other: number }
class ClassDemo extends Component<IClassDemoProps> { onChange = () => { this.props.setOther(this.props.other + 1) } render() { return <button onClick={this.onChange}>{this.props.other}</button> } }
export default connectModel('other',state => ({ other: state.other, setOther: state.setOther }))(ClassDemo)
|
在组件外使用, 借助getModel
也可以在组件内读取修改状态方法,不回引起更新
1 2 3 4 5 6 7 8 9 10 11 12
| import { getModel } from '@/store'
export const onChangeUser = () => { const user = getModel('user') user.setUserInfo({ name: user.userInfo.name + '1' }) }
setTimeout(onChangeUser, 1000)
|
本地存储统一管理
可以对「localStorage」和「sessionStorage」还有「cookie」简单封装一下,封装后使用的好处:
- 自动序列化,存储的时候转字符串,取得时候再转回来。
- 类型自动推断,在实例化的时候传入类型,在设置和获取值的时候都会自动类型推断。
- 可以统一管理,把本地存储都放在一个文件里面,避免后期本地存储混乱不好维护问题。
- 抹平平台差异,这个思路「web」,小程序,移动端,桌面端都适合。
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 27 28 29 30 31 32 33 34 35 36 37 38 39
| const prefix = 'xxx.'
interface IStorage<T> { key: string defaultValue: T } export class Storage<T> implements IStorage<T> { key: string defaultValue: T constructor(key, defaultValue) { this.key = prefix + key this.defaultValue = defaultValue } setItem(value: T) { localStorage.setItem(this.key, JSON.stringify(value)) } getItem(): T { const value = localStorage[this.key] && localStorage.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() { localStorage.removeItem(this.key) } }
export const tokenStorage = new Storage<string>('token', '')
|
封装请求统一和项目解耦
现有的封装
项目现用的请求封装和项目业务逻辑耦合在一块,不方便直接复用,使用上比较麻烦,每次需要传「GET」和「POST」类型,「GET」参数要每次单独做处理,参数类型限制弱。
推荐使用
推荐直接使用「fetch」封装或「axios」,项目中基于次做二次封装,只关注和项目有关的逻辑,不关注请求的实现逻辑。在请求异常的时候不返回「Promise.reject()**「,而是返回一个对象,只是」**code」**改为异常状态的「code」,这样在页面中使用时,不用用「try/catch」包裹,只用「if」判断「code」**是否正确就可以。
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import axios, { AxiosInstance, AxiosRequestConfig } from 'axios' import { tokenStorage } from '@/common/storage'
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: return response.data || {} default: return response.data || {} } }, error => { return { message: onErrorReason(error.message) } } ) return request }
function onErrorReason(message: string): string { if (message.includes('Network Error')) { return '网络异常,请检查网络情况!' } if (message.includes('timeout')) { return '请求超时,请重试!' } return '服务异常,请重试!' }
export const request = createAxiosIntance('https://xxx')
|
使用
使用上面代码命名定义接口类型的「loginApi」例子
1 2 3 4 5 6 7 8 9
| const onLogin = async () => { const res = await loginApi(params) if(res.code === 0) { } else { message.error(res.message) } }
|
api接口管理统一
文件夹路径
1 2 3 4 5
| ├─pages │ ├─Login │ │ └─api │ │ └─index.ts │ │ ├─types.ts
|
定义类型
1 2 3 4 5 6 7 8 9 10 11 12
|
export interface ILoginParams { username: string password: string }
export interface ILoginData { token: string }
|
定义请求接口
1 2 3 4 5 6 7
| import { request } from '@/utils/request' import { ILoginParams, ILoginData } from './types'
export const loginApi = (params: ILoginParams) => { return request.post<ILoginData>('/distribute/school/login', params) }
|
使用请求接口
使用上面代码命名定义接口类型的「loginApi」例子
1 2 3 4 5 6 7 8 9
| const onLogin = async () => { const res = await loginApi(params) if(res.code === 0) { } else { message.error(res.message) } }
|
函数库-通用方法抽离复用
把公司项目中常用的「方法」和「hooks」抽离出来组成「函数库」,方便在各个项目中使用,通过编写函数方法,写jest单元测试,也可以提升组内成员的整体水平。当时组内前端不管是实习生还是正式成员都在参与函数库的建设,很多就有了 「30+」 的函数和hooks,还在不断的增加。
是用了「dumi2」来开发的函数库。了解更多,详见
组件库-通用组件抽离复用
公司项目多了会有很多公共的组件,可以抽离出来,方便其他项目复用,一般可以分为以下几种组件:
- UI组件
- 业务组件
- 功能组件:上拉刷新,滚动到底部加载更多,虚拟滚动,拖拽排序,图片懒加载..
css超集和css模块化方案统一
css超集
使用「less」或者「scss」,看项目具体情况,能全项目统一就统一。
css模块化
「vue」使用自带的「style scoped」, 「react」使用「css-module」方案。
开启也简单,以「vite」为例,默认支持,可以修改「vite.config.ts」配置:
1 2 3 4 5 6 7 8 9 10 11 12
| export default defineConfig({ css: { modules: { localsConvention: 'camelCase', generateScopedName: '[local]-[hash:base64:5]', } }, })
|
「react」使用的时候,样式文件命名后缀需要加上 「.module」,例如index.module.less
:
1 2 3 4 5
| .title { font-size: 18px; color: yellow; }
|
组件里面使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import React, { memo, useMemo } from 'react' import styles from './index.module.less'
interface ITitleProps { title: string }
const Title: React.FC<ITitleProps> = props => { const { title } = props
return ( <h2 className={styles.title}>{title}</h2> ) }
export default memo(Title)
|
编译后类名会变成**title-[hash:5]**,可以有效避免样式冲突,减少起类名的痛苦。
引入immer来优化性能和简化写法
Immer(https://Fgithub.com/mweststrate/immer) 是 「mobx」 的作者写的一个 「immutable」 库,核心实现是利用 「ES6」 的 「Proxy」(不支持「Proxy」的环境会自动使用「Object.defineProperty」来实现),几乎以最小的成本实现了 「js」 的不可变数据结构,简单易用、体量小巧、设计巧妙,满足了我们对「js」不可变数据结构的需求。
优化性能
修改用户信息
1 2 3 4 5 6 7
| const [ userInfo, setUserInfo ] = useState({ name: 'immer', info: { age: 6 } }) const onChange = (age: number) => { setUserInfo({...userInfo, info: { ...userinfo.info, age: age }}) }
|
上面某次修改「age」没有变,但「setUserInfo」时每次都生成了一个新对象,更新前后引用变化了,组件就会刷新。
使用「immer」后,「age」没变时不会生成新的引用,同时语法也更简洁,可以优化性能。
1 2 3 4 5 6 7 8
| import produce from 'immer'
const [ userInfo, setUserInfo ] = useState({ name: 'immer', age: 5 }) const onChange = (age: number) => { setUserInfo(darft => { darft.age = age }) }
|
简化写法
「react」遵循不可变数据流的理念,每次修改状态都要新生成一个引用,不能在原先的引用上进行修改,所以在对引用类型对象或者数组做操作时,总要浅拷贝一下,再来做处理,当修改的状态层级比较深的时候,写法会更复杂。
以数组为例,修改购物车某个商品的数量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import produce from 'immer'
const [ list, setList ] = useState([{ price: 100, num: 1 }, { price: 200, num: 1 }])
const onAdd = (index: number) => {
setList( produce(darft => { darft[index].num++ }), ) }
|
1 2 3 4 5 6 7 8 9
| import useImmer from 'use-immer'
const [ list, setList ] = useImmer([{ price: 100, num: 1 }, { price: 200, num: 1 }])
const onAdd = (index: number) => { setList(darft => { darft[index].num++ }) }
|
搭建npm私服
公司前端项目不推荐使用太多第三方包,可以自己搭建公司「npm」私服,来托管公司自己封装的状态管理库,请求库,组件库,以及脚手架「cli」,「sdk」等「npm」包,方便复用和管理。
了解更多,详见:
【前端工程化】巧用阿里云oss服务打造前端npm私有仓库
【前端工程化】使用verdaccio搭建公司npm私有库完整流程和踩坑记录
各类型项目通用模版封装
可以提前根据公司的业务需求,封装出各个端对应通用开发模版,封装好项目目录结构,接口请求,状态管理,代码规范,git规范钩子,页面适配,权限,本地存储管理等等,来减少开发新项目时前期准备工作时间,也能更好的统一公司整体的代码规范。
- 通用后台管理系统基础模版封装
- 通用小程序基础模版封装
- 通用「h5」端基础模版封装
- 通用「node」端基础模版封装
- 其他类型的项目默认模版封装,减少重复工作。
搭建cli脚手架下载模版
搭建类似「vue-cli」, 「vite」, 「create-react-app」类的「cli命令行」脚手架来快速选择和下载封装好的模版,比「git」拉代码要方便。
了解更多,详见:从入门到精通,100行代码构建你的前端CLI脚手架之路
git操作规范
「git」操作规范也很重要,流程不规范很容易出现比较复杂的问题,要根据公司现有情况和业界比较好的实践方案制定一套适合自己公司的「git flow」开发规范,用各种限制方案来避免出现问题。
了解更多,详见
规范和使用文档输出文档站点
代码规范和git提交规范以及各个封装的库使用说明要输出成文档部署到线上,方便新同事快速熟悉和使用。
这个是很重要的,做了再多的基建和规范,如果没有一个公共的文档来查阅,就没办法快速熟悉,所以要一个线上的规范文档,把所有的规范都写进去。
相关链接
[1] 【前端工程化】配置React+ts企业级代码规范及样式格式和git提交规范