likes
comments
collection

我帮一朋友重构了点代码,他直呼牛批,但基操勿六

作者站长头像
站长
· 阅读数 14

故事的后续

首先事情是这样的

我一朋友,用 react 开发前端时间不长,一些简单的功能和页面没啥大问题。前不久React 18 发布了,他就用 create-react-app 创建了一个新项目,合计练练手,但谁成想遇到了种种问题,让我帮看看,于是就有了接下来要聊的一些看似简单,但是对新手却很绊脚的小问题。

react都 18 了,但为啥还是 ReactDom.render

create-react-app新创建的项目,还是用的ReactDom.render,如下:

import React from 'react'
import ReactDOM from 'react-dom' //《----------react 17使用的ReactDOM
import App from './App'
import './index.css'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

语义大体上:ReactDOMrender 函数,把 JSX Elements 组件,渲染到 id 为'root'的 dom 节点上。

那么用react 18的新写法改造一下

react 18改了

//index.tsx
import React from 'react'
import { createRoot } from 'react-dom/client' //《----------react 18使用的ReactDOM/client中的createRoot
import App from './App'
import './index.css'

function render() {
  const root = createRoot(document.getElementById('root')!)
  root.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  )
}

语义大体上:react-dom/clientcreateRoot 函数,把 id 为'root'的 dom 节点做成了一个渲染器,然后用Render函数把JSX Elements渲染出来。

用策略模式啊,别写大段的switch或if了

我朋友跟我说: 我帮一朋友重构了点代码,他直呼牛批,但基操勿六

我看了看项目,发现了有好多大段的switch或者if,如:

我帮一朋友重构了点代码,他直呼牛批,但基操勿六

这样显然不优雅,于是我用策略模式重构一下:

const infoMap = {
  pending:"error",
  fullyPayed:"success",
  ready:"success"
}

const orderStatusIndicator = (
  orderStatus
) => {
  return infoMap[orderStatus]||'default'
};

嗯,策略模式就是好用,这看着多舒服。

函数组件useState的更新没有回调函数,我怎么同步获得最新state?

由于我朋友用习惯了class组件,经常会设置状态时传入一个回调函数,从而用同步的写法,获得刷新后的最新状态,如:

this.setState({
    test:"新数据",
    ()=>{
        consoloe.log(this.state.test)
    }
})

但是在函数组件中,这种开发习惯就不行了,因为useState的set函数是不具备回调参数的,如果想在设置后获得最新的状态,那就得借助useEffect配合一下:

const [test,setTest] = useState("0");
useEffect(()=>{
    setTest("1")
    console.log(test) // 当前的数据依然是旧的数据:0
},[])
useEffect(()=>{
    console.log(test) // 当前的数据才是新的数据:1
},[test])

批处理?

ok,你会说因为这是react的批处理机制,只要我们通过react管不到的函数中写,就能够通过获得了,如:

const [test,setTest] = useState("0");
useEffect(()=>{
    setTimeout(()=>{
        setTest("1")
        console.log(test) // 虽然确实跳出了批处理,直接修改了state,但这里你还是拿不到最新的test值,依然还是会打印旧的值。
    })
},[])

首先就算跳出批处理,也是无法同步的获得最新状态值,而且你要知道这可是老黄历了,现在的React 18可强大到没有法外之地了:

我帮一朋友重构了点代码,他直呼牛批,但基操勿六

官方相关文章

React 18也提供了flushSync来跳出批处理,不要再setTimeout了。

所以,就算跳出批处理也无法同步获得最新状态,那该怎么实现我朋友的愿望呢?

写一个强化版的useState

import { useRef, useState, useEffect } from "react";

const useStateWithCall = (initValue)=>{
  const ref = useRef(0)
  const callFRef = useRef()
  const setFuncRef = useRef()
  let [state,setState] = useState(initValue)
  if(!ref.current){
    ref.current = 1;
    setFuncRef.current = (newData,callF)=>{
      callFRef.current = callF;
      setState(newData)
      return Promise.resolve(newData)
    }
  }
  useEffect(()=>{
    callFRef.current?.(state)
  },[state])
  return [state,setFuncRef.current]
}

export default useStateWithCall;

然后在代码中使用,用法跟useState大体一致:

  • 初始化hook:
    const [test,setTest] = useStateWithCall(-1);
    
  • 设置最新状态:
    • 回调的方式
      // 设置新数据 
      setTest(1,(newState)=>{
          console.log("新值"+newState)
      })
      
    • .then的方式
      setTest(type).then((newState)=>{
          console.log("新值"+newState)
      })
      

这样就基本实现了类似class组件setState那样的回调获得最新state了。

react 都 18 了,React-router 得 v6 啊,但变化好大,咋用啊?

React-router v6可谓是变化着实不小,之前v5组织路由是这样的:

React-router v5

//app.tsx
import React from 'react'
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
export default () => {
  return (
      <Router>
        <Switch>
          <Route path="/" component={Login} />
          <Route path="/pageCenter" component={PageCenter} />
        </Switch>
      </Router>
    )
  }
)

PageCenter就是我们的页面组件,一般都会在这里实现嵌套路由,如:

//PageCenter.tsx
import React from 'react'
import NestRoute from ‘./nestRoute’
import { Route, Switch } from "react-router-dom";
export default () => {
  return (
      <>
        <Switch>
          <Route path="/pageCenter/nestRoute" component={NestRoute} />
        </Switch>
      <>
    )
  }
)

em ~~~,跟 app.tsx 中实现的顶层路由很像,一脉相承。

评价一波 v5 路由的组织方式吧

  • tsx 文件大臃肿:每配置一个路由,就写一个 Route 组件,我个人是不喜欢的,我不希望我的 tsx 的代码太多,这是我的喜好,为了阅读起来容易,清晰。
  • 项目的文件夹结构复杂嵌套:顶层路由和嵌套子路由配置分离,直接影响了工程项目中对项目的文件夹结构的编排。因为不能够很直接理清页面组件间的组织关系,不理清会很混乱,维护难度加大,所以理清关系就落在了项目的文件夹结构设计了,这就会导致项目的文件夹结构随着v5 路由的组织方式的复杂而复杂。

React-router v6

可能是因为 v5 的种种原因,才导致 v6 的变化那么大,最突出便是:

  • v6 痛快的推出了配置式路由:一个简单的配置对象,充分描述出了路由的样子和组织关系,痛快~~~。
  • 简洁的路由嵌套方式:仅仅在配置了嵌套路由组件中,使用新推出的标签就搞定了,优雅~~~。

不过~~~,也有一些破坏性的改变,让我措手不及,比如:

  • 路由拦截无了!!!:拦截啊可是,怎么没有了,这。。。
  • withRouter无了!!!:函数组件我能用hook搞搞,类组件咋办,这。。。

em ~~~没事 repect,毕竟进步嘛,怎么会没代价呢,没有咱就自己搞被,不坐车就不会走了么?

我为此写了一个库r6helper,尽可能的弥补了升级 v6 带来的影响

  • 拦截,安排上了。 我帮一朋友重构了点代码,他直呼牛批,但基操勿六
  • withRouter,安排上了。

路由好了,那么路由懒加载得有吧,怎么搞?

方式还是依然是通过 React.lazy配合import的动态引入,代码如下。

const Login = React.lazy(() => import('./login'))

然后还要在通过React.Suspense包裹一下这个懒加载组件,否则的话会报错,这个问题我的那个朋友可是卡住了很久。。。,原因就是忘记了要在懒加载组件外包裹一层React.Suspense

<React.Suspense fallback={<>...</>}>{<Login />}</React.Suspense>

但是,朋友又跟我讲,每加一个页面,就写个lazy引入组件和Suspense包裹,那么页面一多,代码就会变成这样:

const Login = React.lazy(() => import('./pages/login'))
const PageCenter = React.lazy(() => import('./pages/pageCenter'))
const Page1 = React.lazy(() => import('./pages/page1'))
const Page2 = React.lazy(() => import('./pages/page2'))
const Page3 = React.lazy(() => import('./pages/page3'))
const Page4 = React.lazy(() => import('./pages/page4'))
const Page5 = React.lazy(() => import('./pages/page5'))
...
export default () => {
  return useRoutes([
    {
      path: '/',
      element: <React.Suspense fallback={<>...</>}>{<Login />}</React.Suspense>,
      children: [
        { path: "codePLay", element: <CodePLay /> },
      ]
    },
    {
      path: '/pageCenter',
      element: <React.Suspense fallback={<>...</>}>{<Login />}</React.Suspense>,
      children: [
        {
          path: '/page1',
          element: <PageCenter />
        },
        {
          path: '/page2',
          element: <React.Suspense fallback={<>...</>}>{<Page2 />}</React.Suspense>
        },
        {
          path: '/page3',
          element: <React.Suspense fallback={<>...</>}>{<Page3 />}</React.Suspense>
        },
        {
          path: '/page4',
          element: <React.Suspense fallback={<>...</>}>{<Page4 />}</React.Suspense>
        },
        {
          path: '/page5',
          element: <React.Suspense fallback={<>...</>}>{<Page5 />}</React.Suspense>
        },
      ]
    },
    {
      path: '/404',
      element: <div>not found</div>
    },
  ])
}

嵌套路由

    //PageCenter.tsx
    import React from 'react'
    export default () => {
      return (
          <div>
              <Outlet />
          </div>
        )
      }
    )

这样看起来就非常的冗余,很多重复的代码,希望我能帮他优化一下,em ~~~没问题,开整。

优化代码

主要从两个方面入手:

  • 组件lazy引入上
  • 然后Suspense包裹上

统一入口

首先页面组件都放在了pages路径下,然后再定向导入,我们加个indexpages文件夹下,进行统一管理。

// 文件:pages/index.ts
export Login = React.lazy(() => import('./pages/login'))
export Page1 = React.lazy(() => import('./pages/page1'))
export Page2 = React.lazy(() => import('./pages/page2'))
export Page3 = React.lazy(() => import('./pages/page3'))
export Page4 = React.lazy(() => import('./pages/page4'))
export Page5 = React.lazy(() => import('./pages/page5'))

然后我们重构一下之前的引入代码:

const { Login, Page1, Page2, Page3, Page4, Page5 } from './pages'

封装包装组件,支持多类型

写一个能够包装多类型的组件,都可以包装:

  • 组件,包括:函数组件类组件
  • lazy 组件
  • jsx element

那么代码如下:

// 加载异步组件的loading
type ChildT = React.LazyExoticComponent<() => JSX.Element> | React.FC
export const wrapper = (Child: ChildT, cutonFallBack?: CutonFallBackT) => {
  // 判断jsx
  if (Child.type && !Child._init && !Child._payload) {
    return Child
  } else {
    // 判断是否为clas和function组件
    if (typeof Child === 'function') {
      return <Child></Child>
    } else {
      // 判断是否为lazy组件
      return (
        <React.Suspense fallback={cutonFallBack || <>...</>}>
          {<Child></Child>}
        </React.Suspense>
      )
    }
  }
}

注:React.Suspense完全可以不用多次包裹直接顶层包裹一次即可,我这么写,仅仅是突出表达如何实现一个可以包装多种类型节点的Hoc

当然还有更优雅的方式,就是react-is

import * as reactIs from 'react-is';
...

type ChildT = React.LazyExoticComponent<() => JSX.Element> | React.FC
export const wrapper = (Child: ChildT, cutonFallBack?: CutonFallBackT) => {
  // 判断jsx
  if (reactIs.isElement(Child)) {
    return Child
  } else {
    // 判断是否为clas和function组件
    if (reactIs.isValidElementType(Child)) {
      return <Child></Child>
    } else {
      // 判断是否为lazy组件
      return (
        <React.Suspense fallback={cutonFallBack || <>...</>}>
          {<Child></Child>}
        </React.Suspense>
      )
    }
  }
}

那么这样整体重构后的代码,就大体变成了

const { Login,PageCenter, Page1, Page2, Page3, Page4, Page5 } from './pages'
...
export default () => {
  return useRoutes([
    {
      path: '/',
      element:wrapper(Login),
    },
    {
      path: '/pageCenter',
      children: [
        {
          path: '/page1',
          element: wrapper(Page1)
        },
        {
          path: '/page2',
          element: wrapper(Page2)
        },
        {
          path: '/page3',
          element: wrapper(Page3)
        },
        {
          path: '/page4',
          element: wrapper(Page4)
        },
        {
          path: '/page5',
          element: wrapper(Page5)
        },
      ]
    },
    {
      path: '/404',
      element: wrapper(<div>not found</div>)
    },
  ])
}

em ~~~朴实无华,但是代码看起来舒服不少,朋友感叹学到不少干货,我感觉这就是基本操作,233333。

但是这样就够了么?

随着项目的发展,会不断的创建新的页面,进而就会出现越来越复杂的路由结构,如下: 我帮一朋友重构了点代码,他直呼牛批,但基操勿六

这配置数据的嵌套的层级太深了,简直“嵌套地狱”,要继续想办法优化

继续分解演化

我们把路由的配置数据按照作用分解,即把“组织关系”和“路由个体”拆开。

拆开的过程简单说:就是通过“组织关系”数据和“路由个体”数据,组合生成树状嵌套的路由结构数据(也就是useRoutes要求的数据结构)。

首先“一”分“二”,同时还有二者之间有联系,那么就得用“键值”关联。

const r_loginRoute = Symbol(),
  r_pageCenter = Symbol(),
  r_page1 = Symbol(),
  r_page2 = Symbol(),
  r_page3 = Symbol(),
  r_page4 = Symbol(),
  r_page5 = Symbol(),
  r_page3_1 = Symbol(),
  r_page5_1 = Symbol(),
  r_page5_1_1 = Symbol(),
  r_page5_1_2 = Symbol(),
  r_page5_1_2_1 = Symbol()

借助Symbol,让代码简洁,当然也可以这样:

const r_loginRoute = "r_loginRoute",
  r_pageCenter = "r_pageCenter",
  r_page1 = "r_page1",
  r_page2 = "r_page2",
  r_page3 = "r_page3",
  r_page4 = "r_page4",
  r_page5 = "r_page5",
  r_page3_1 = "r_page3_1",
  r_page5_1 = "r_page5_1",
  r_page5_1_1 = "r_page5_1_1",
  r_page5_1_2 = "r_page5_1_2",
  r_page5_1_2_1 = "r_page5_1_2_1"

虽然也行,但相比Symbol,其字符值并没有什么实际意义,仅仅能够“独一无二”,可以用来作区分即可,所以Symbol就完全能胜任了。

“二”中之一,便是“路由个体”数据,数据结构是个字典对象

let routesMap = {
  [r_loginRoute]: {
    path: '/',
    element: Login
  },
  [r_pageCenter]: {
    path: '/pageCenter',
    element: PageCenter
  },
  [r_page1]: {
    path: '/page1',
    element: Page1
  },
  [r_page2]: {
    path: '/page2',
    element: Page2
  },
  [r_page3]: {
    path: '/page3',
    element: Page3
  },
  [r_page4]: {
    path: '/page4',
    element: Page4
  },
  [r_page5]: {
    path: '/page5',
    element: Page5
  },
  [r_page3_1]: {
    path: '/page3_1',
    element: Page3_1
  },
  [r_page5_1]: {
    path: '/page5_1',
    element: Page5_1
  },
  [r_page5_1_1]: {
    path: '/page5_1_1',
    element: Page5_1_1
  },
  [r_page5_1_2]: {
    path: '/page5_1_2',
    element: Page5_1_2
  },
  [r_page5_1_2_1]: {
    path: '/page5_1_2_1',
    element: Page5_1_2_1
  }
}

每个路由个体都是路由结构数据去除children的部分,完整沿用了react router v6useRoutes的配置数据的api

最后二中之一,便是“组织关系”数据,其就是一个扁平的数组,仅仅描述的是关联关系,即结构

let relation = [
  {
    id: r_loginRoute,
    parentId: ''
  },
  {
    id: r_pageCenter,
    parentId: ''
  },
  {
    id: r_page1,
    parentId: r_pageCenter
  },
  {
    id: r_page2,
    parentId: r_pageCenter
  },
  {
    id: r_page3,
    parentId: r_pageCenter
  },
  {
    id: r_page4,
    parentId: r_pageCenter
  },
  {
    id: r_page5,
    parentId: r_pageCenter
  },
  {
    id: r_page3_1,
    parentId: r_page3
  },
  {
    id: r_page5_1,
    parentId: r_page5
  },
  {
    id: r_page5_1_1,
    parentId: r_page5_1
  },
  {
    id: r_page5_1_2,
    parentId: r_page5_1
  },
  {
    id: r_page5_1_2_1,
    parentId: r_page5_1_2
  }
]

仅仅两个属性:

  • id:自己的id。
  • parentId:父节点的id。

最后实现让两者合而为一的“魔法”

const createRoutesData = ({ relation, routesMap }) => {
  // 首先遍历一下“组织关系”数据,作用:
  // 1 深拷贝一下“组织关系”数据,不污染和篡改原数据。
  // 2 记录一下索引,优化效率。
  let relationCopy = []
  let ids = {}
  relation.forEach((item, index) => {
    const { id } = item
    ids[id] = index
    relationCopy.push({ ...item })
  })

  // 工具函数,简化逻辑,让代码清晰。
  // 初始化数据
  const initData = (arr, key, def) => {
    if (!(key in arr)) {
      arr[key] = def
    }
    return arr[key]
  }
  // 加工RouteItem
  const processRouteItem = data => {
    let temp = { ...data }
    temp.element = wrapper(temp.element)
    return temp
  }

  // 目标结果数据
  let results = []
  // 然后遍历一下数据,融合
  relationCopy.forEach(item => {
    const { id, parentId } = item
    Object.assign(item, processRouteItem(routesMap[id]))
    if (!parentId) {
      if (!(id in routesMap)) {
        throw `routesMap未配置该id:${id}的数据个体`
      }
      results.push(item)
    } else {
      let pIndex = ids[parentId]
      let routesData = relationCopy[pIndex]
      let routeChildren = initData(routesData, 'children', [])
      routeChildren.push(item)
    }
  })
  return results
}
console.log(
  createRoutesData({
    relation,
    routesMap
  })
)

以上我写的“lowlow的算法”,亲测没问题,思路也都写在了备注里,有问题的话,可以在评论区讨论。

那么经过以上进一步的重构,代码如下:

const { Login,PageCenter, Page1, Page2, Page3, Page4, Page5, page3_1, page5_1_1, page5_1_2, page5_1_2_1 } from './pages'

const r_loginRoute = Symbol(),
  r_pageCenter = Symbol(),
  r_page1 = Symbol(),
  r_page2 = Symbol(),
  r_page3 = Symbol(),
  r_page4 = Symbol(),
  r_page5 = Symbol(),
  r_page3_1 = Symbol(),
  r_page5_1 = Symbol(),
  r_page5_1_1 = Symbol(),
  r_page5_1_2 = Symbol(),
  r_page5_1_2_1 = Symbol()
  
let relation = [
  {
    id: r_loginRoute,
    parentId: ''
  },
  {
    id: r_pageCenter,
    parentId: ''
  },
  {
    id: r_page1,
    parentId: r_pageCenter
  },
  {
    id: r_page2,
    parentId: r_pageCenter
  },
  {
    id: r_page3,
    parentId: r_pageCenter
  },
  {
    id: r_page4,
    parentId: r_pageCenter
  },
  {
    id: r_page5,
    parentId: r_pageCenter
  },
  {
    id: r_page3_1,
    parentId: r_page3
  },
  {
    id: r_page5_1,
    parentId: r_page5
  },
  {
    id: r_page5_1_1,
    parentId: r_page5_1
  },
  {
    id: r_page5_1_2,
    parentId: r_page5_1
  },
  {
    id: r_page5_1_2_1,
    parentId: r_page5_1_2
  }
]

let routesMap = {
  [r_loginRoute]: {
    path: '/',
    element: Login
  },
  [r_pageCenter]: {
    path: '/pageCenter',
    element: PageCenter
  },
  [r_page1]: {
    path: '/page1',
    element: Page1
  },
  [r_page2]: {
    path: '/page2',
    element: Page2
  },
  [r_page3]: {
    path: '/page3',
    element: Page3
  },
  [r_page4]: {
    path: '/page4',
    element: Page4
  },
  [r_page5]: {
    path: '/page5',
    element: Page5
  },
  [r_page3_1]: {
    path: '/page3_1',
    element: Page3_1
  },
  [r_page5_1]: {
    path: '/page5_1',
    element: Page5_1
  },
  [r_page5_1_1]: {
    path: '/page5_1_1',
    element: Page5_1_1
  },
  [r_page5_1_2]: {
    path: '/page5_1_2',
    element: Page5_1_2
  },
  [r_page5_1_2_1]: {
    path: '/page5_1_2_1',
    element: Page5_1_2_1
  }
}

export default () => {
  return useRoutes(createRoutesData({
    relation,
    routesMap
  }))
}

扁平化配置带来的好处

最直观的好处就是,调整路由组件的组织关系变得简单

对于v5,怎么改组织关系?

方式:上文提到,由于v5没有配置数据,那么体现组织关系的重任落到了项目的文件夹结构设计上,那么如果调整路由页面间的关系,那就得修改项目的文件夹结构

弊端:修改项目的文件夹结构,会有很大的可能引发错误,因为路径改变依赖关系就会改变,这样的重构风险极大,成本很高。

对于v6,怎么改组织关系?

方式:由于v6有了配置数据,那么直接调整配置数据即可,即调整树状数据中各节点数据的从属关系。

弊端:嵌套地狱啊,一堆路由嵌套过于复杂,至少对于我,看着比较乱,修改起来比较麻烦,从臃肿的树状数据中找到子节点,然后剪切,再找到目标父节点,然后剪切进去,不太方便。

基于v6,实现扁平化配置,完美的解决以上弊端

仅仅修改组织关系的id即可,比如:

let relation = [
  {
    id: "A",
    parentId: ""
  },
  {
    id: "B",
    parentId: "A"
  },
  {
    id: "C",
    parentId: "A"
  }
]

把C节点调整为B的子节点,那么仅仅修改parentId即可,如:

let relation = [
  {
    id: "A",
    parentId: ""
  },
  {
    id: "B",
    parentId: "A"
  },
  {
    id: "C",
    parentId: "B"
  }
]

搞定~~~

扁平化和树状结构是搭档

我并没有否定树状结构,相反我非常喜欢,我使用扁平化,不单单出于维护方便,更多的是为了推动事物发展,让“树”不单单是数据,而是一棵能够被看到的“树”,并能够进一步通过可视化拖拽来调整组织关系。如:

我帮一朋友重构了点代码,他直呼牛批,但基操勿六

这样一条清晰演化过程,便浮现了出来:

工程项目结构⇢配置树状数据⇢扁平化配置⇢可视化配置

结尾

至此,整个代码虽然变多,但是扩展的能力大大加强,未来无论是:

  • 配合后台做权限控制
  • 实现低代码的可视化创建

都大大降低了开发难度和维护成本。

如果有问题,可以随时咨询我,我没事就喜欢水群,结交了一帮喜欢交流技术的伙伴,并整个了个群,欢迎加入一起交流,一起学。

我帮一朋友重构了点代码,他直呼牛批,但基操勿六