Back

记入职半个月时在公司做的一次前端工程化的分享


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 其他很多规范

...

二. 代码规范

  1. vscode:统一前端编辑器。
  2. editorconfig: 统一团队vscode编辑器默认配置。
  3. prettier: 保存文件自动格式化代码。
  4. eslint: 检测代码语法规范和错误。
  5. stylelint: 检测和格式化样式文件语法

三. git提交规范

  1. husky:可以监听githooks执行,在对应hook执行阶段做一些处理的操作。
  2. lint-staged: 只检测暂存区文件代码,优化eslint检测速度。
  3. pre-commit:githooks之一, 在commit提交前使用tsc和eslint对语法进行检测。
  4. commit-msg:githooks之一,在commit提交前对commit备注信息进行检测。
  5. commitlint:在githooks的pre-commit阶段对commit备注信息进行检测。
  6. 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/业务/功能组件。

  1. UI组件
  2. 业务组件
  3. 功能组件:上拉刷新,滚动到底部加载更多,虚拟滚动,拖拽排序,图片懒加载..

封装成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,搭建环境和使用都比较简单。

十四. 各类型项目通用模版封装。

  1. 通用后台管理系统基础模版封装
  2. 通用小程序基础模版封装
  3. 通用h5端基础模版封装
  4. 通用node端基础模版封装
  5. 其他类型的项目默认模版封装,减少重复工作。

十五. 搭建cli脚手架下载模版。

搭建类似vue-cli, vite, create-react-app类的cli命令行脚手架来快速选择和下载所需版本,比git拉代码要方便。

十六. 规范和使用文档输出文档站点,方便查阅。

代码规范和git提交规范以及各个封装的库使用说明要输出成文档部署到线上,方便新同事快速熟悉和使用。

郭炯韦个人博客 备案号: 豫ICP备17048833号-1
Top