Better

Ethan的博客,欢迎访问交流

Writing your code is just like planting a tree

没有什么营养的序:写代码就像种一颗树,你不管它,它通常也能自然生长,但你时不时修剪修剪,它能长的更好一点。但它又不同于种树,因为你把它头插土里,它也可以自然生长。 文章中的代码都是盲打的,不保证正确性。

背景

随着项目的不断迭代,当前的前端底层设计很难满足需求的持续迭代,问题如下

  • 代码难以拆分,需重新思考代码模块的组织问题
  • 根组件状态越来越多,逻辑也越来越多,不同功能间状态很难维护对,需要消除功能之间对彼此影响

为什么根组件状态越来越多呢,对现在根组件下状态进行分类如下

1280X1280.png

简单总结下该状态特征

  • 多个域数据(关注点开始增多)
  • 用户界面状态过多,用于去控制大范围元素是否渲染(一个信号,说明该组件太杂了)
  • 应用程序状态过多,说明该界面用户行为很多
  • 很难给这个组件一个明确主题,生成页?编辑页?列表页?

那么组件不断增长,该如何优化呢

  • 技术手段:尽可能解耦代码、模块化代码
  • 沟通手段:这个需求能不能不做?

基本优化

超级类(组件)存在的危害

  • 可读性、可维护性差,我可能只需要改其中的 5 行,但你强制我看了 1000 行
  • 可测试性差,这种代码的测试通常都很难编写
  • 指数级膨胀(破窗效应)

模块化具备以下优势:

  • 这有利于将关注点和功能分离,有助于项目的可视化、理解和组织。
  • 当项目被清晰地构建和细分之后,就更容易维护也更不容易出错。
  • 如果项目被细分为许多不同的部分,每个部分可以单独进行处理和修改,这样更利于软件开发。

该如何优化(分离关注点、提高复用性):

  • 思路一:分层
  • 思路二:组合

3a5ab055-2485-44a8-bf24-965a8f3d138a.png

相关思考方式

  • 模块里有没有业务无关通用逻辑,可以抽成 utils/oop
  • 模块里有没有复杂对象创建、复杂值计算逻辑,可以抽成 utils/oop
  • 有没有复杂的表达式,可以考虑抽成中间变量或函数
  • 你发现几个属性总是和特定的几个方法打交道,也许你可以把它们组合成新类
  • React 组件同理,某些状态总是服务于某段 HTML 结构,考虑将他们抽成独立的组件

反复推敲代码:看一个作家的水平,不是看他发表了多少文字,而要看他的废纸篓里扔掉了多少

Generic Patterns

代码评价指标

最常用、最重要的评价指标

debaab03-aa33-476e-931e-222e4f509260.png

简单概括

  • 可维护性:不破坏原有设计、不引入新的 BUG 的情况下,能够快速的修改和添加代码
    • 分层清晰、模块化好、高内聚低耦合
    • 代码量的多少,业务复杂程度,用到的技术复杂程度
  • 可读性:编码规范、命名、注释
  • 可扩展性:代码应对未来需求变化的能力
    • 不修改或少量修改原有代码的情况下,通过扩展的方式添加新的功能代码
    • 开闭原则
  • 灵活性
    • 实现新功能,原有代码已经预留好了扩展点,只需要在扩展点添加新的代码即可
    • 原有代码中,已经抽象了很多底层可以复用的模块、类等
    • 使用某组接口时,这组接口可以应对各种场景
  • 简洁性:KISS 原则
  • 可复用性:减少重复代码编写,复用已有代码
    • 继承、多态
    • 单一职责
    • 解耦、模块化、高内聚
  • 可测试性:可测试性差的代码,比较难写单元测试

圈复杂度

如何判断你的代码是不是该修剪了呢,了解一个指标:圈复杂度

  1. 基础复杂度为 1
  2. 碰到如下语句就 +1
    1. if、case、catch
    2. for、while
    3. and、or
    4. 三元运算符 通常当圈复杂度达到 5,就很不易读了,达到 10 的方法存在很大的出错风险。

如何优化圈复杂度

  • 提炼函数:将一个方法中的代码提炼成多个方法,把高内聚的代码块提炼出来
  • 卫语句:对分支条件,先做检查,快速返回。比如如果 A 条件极其罕见,则应该在条件 A 为真时,立刻返回
  • 合并条件表达式:如果对多重错误的处理是一致的,就可以泛化这类错误,合并处理
  • 以多态取代条件表达式
  • 分解条件表达式:把表达式分解成独立的方法,不但可以突出条件逻辑,而且可以更清楚的表达每个分支的作用
  • 替换算法:复杂算法会导致 bug 的增加,如果函数对性能要求不高,建议使用简单的算法
  • 移除控制标记:通过 return 语句减少控制标记
  • 策略模式

设计模式目的

设计模式要干的事情就是解耦

  • 创建型模式是将创建和使用代码解耦,如工厂模式
  • 结构型模式是将不同功能代码解耦,如代理模式
  • 行为型模式是将不同行为代码解耦,如观察者模式

React Patterns

基础知识

掌握 React 核心,一个公式 + 一张图

  • UI = render(data);
  • Props down, events up.

1d5eed48-0ce8-4026-9333-9310986a532d.png

React 相关的模式

  • Container/Presentational Pattern
  • HOC
  • Render Props
  • Hooks
  • State Manager:Redux
  • Router
  • Compound Patterns
    • Provider
    • React.Children.map/React.createElement

Compound Patterns 指的是类似 Form 和 Form.Item 的组件组合设计,提高组件开发者的可设计能力,以及使用者的易用性,这里暂不展开讲。

Container/Presentational Patterns

两者之间的区别

  • 容器组件:主要负责的是 What 层面,比如给用户展示什么数据
  • 展示组件:主要负责的是 How 层面,比如怎么渲染给定的数组
    • 通用组件
    • 业务组件

展示组件可以维护一些上层不需要的状态,合理的输入下保证正确的输出即可,仅做必要的状态提升!

组件区分的意义

  • 关注点分离:拆分渲染逻辑和修改逻辑
  • 尽可能产出更多的展示组件,因为它就像一个纯函数,提高了复用性、可测试性

看一下代码的拆分

function App() {
  const [plans, setPlans] = useState([]);
  const [plan, setPlan] = useState(null);
  // fetch plan List
  return (
    <PlanList plans={plans} setPlan={setPlan} />
  )
}
interface PlanListProps {
  plans: Plan[];
  // bad prop name,onChange is recommended
  setPlan: (plan: Plan | null) => void;
}

HOC、Render Props、Hooks

HOC 和 Render Props 本质上是函数式编程思想在 React 上的应用,和 React 没有绑定关系。Hooks 是 React 16.8 推出逻辑复用方案,开发者可以通过自定义 hook 复用逻辑。通过一个场景讲述三者之间的区别。

你需要知道鼠标当前的坐标,不考虑复用你会这么做。

export default function App() {
  const [position, setPosition] = useState({x: 0, y: 0})
  useEffect(() => {
    const handleEvent = (event) => {
      setPosition({
        x: event.clientX,
        y: event.clientY
      });
    }
    document.addEventListener('mousemove', handleEvent);
    return () => {
      document.removeEventListener('mousemove', handleEvent);
    }
  }, [])
  return (
    <div>{position.x},{position.y}</div>
  )
}

如果我们要复用这部分代码,HOC 中这么做

// hoc
export function withMouse(Component) {
  return (props) => {
    const [position, setPosition] = useState({x: 0, y: 0})
    useEffect(() => {
      const handleEvent = (event) => {
        setPosition({
          x: event.clientX,
          y: event.clientY
        });
      }
      document.addEventListener('mousemove', handleEvent);
      return () => {
        document.removeEventListener('mousemove', handleEvent);
      }
    }, [])
    return <Component {...props} position={position} />
  }
}
// when use
Component1WithMouse = withMouse(Component1)
Component2WithMouse = withMouse(Component2)
export default function App() {
  return (
    <div>
      <Component1WithMouse />
    </div>
  )
}

Render-Props 中这么做

export default function Mouse({ children }) {
  const [position, setPosition] = useState({x: 0, y: 0})
  useEffect(() => {
    const handleEvent = (event) => {
      setPosition({
        x: event.clientX,
        y: event.clientY
      });
    }
    document.addEventListener('mousemove', handleEvent);
    return () => {
      document.removeEventListener('mousemove', handleEvent);
    }
  }, [])
  return children(position);
}
// when use
export default function App() {
  return (
    <div>
      <Mouse>
        {(position) => <Component position={position} />}
      </Mouse>
    </div>
  )
}

Hooks 中通过自定义 hook 做

export default function useMouse(){
  const [position, setPosition] = useState({x: 0, y: 0})
  useEffect(() => {
    const handleEvent = (event) => {
      setPosition({
        x: event.clientX,
        y: event.clientY
      });
    }
    document.addEventListener('mousemove', handleEvent);
    return () => {
      document.removeEventListener('mousemove', handleEvent);
    }
  }, [])
  return position
}
// when use
export function Component() {
  const position = useMouse();
  return (
    <div>{position.x},{position.y}</div>
  )
}

Hooks 的优点

  • 不会有组件树结构的修改
  • Hooks 逻辑更容易被复用和测试

有 Hooks 了,还需要 HOC 和 render props 吗

Hooks 在复用逻辑方面的确很优秀,且没有 HOC 和 render props 的缺点。但 Hooks 主要切入点是非视觉逻辑共享(Sharing non-visual logic),如果需要视觉元素的共享,则通常显得有些无能为力,此时可以考虑使用 HOC 和 render props 方案。

举个当前代码中的例子

function EditorHOC<TProps>(Component: ComponentType<TProps>) {
  return (props: TProps) => {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const [theme] = useTheme();
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const themeOptions = useMemo(() => getThemeConfig(theme), [theme]);
    return (
      <EditorProvider defaultKeyboardTrap={Application.keyboardTrap} themeOptions={themeOptions}>
        <Component {...props} />
      </EditorProvider>
    );
  };
}

再举一个例子,关于 NULL 的判断,在有些语言中 NULL 可以指任何对象,如果某个方法会返回 NULL 值,这就不得不要求调用方在使用时一定要记得(容易忘记)判断且处理 NULL,处理方式有可能是继续返回 NULL,这就导致它像病毒一样,在代码中野蛮生长,通常而言,我们需要在合适的地方尽早处理。

不要让对于 NULL 的判断,就像一个病毒一样,扩散在代码库的每个角落

在现在的项目代码中已经有很多类似判断逻辑了,比如

function App() {
  const [project, setProject] = useState(null);
  const doSomething = () => {
    if(!project) {
      return;
    }
    // do something
  }
  const doSomething2 = () => {
    if(!project) {
      return;
    }
    // do something
  }
  return <></>
}

如果某些组件需要在 project 有值的情况才能工作,那就想办法在组件外面处理掉,而不是把病毒递交给他。此时使用 HOC 可以这样做。

function withProject(Component) {
  return (props & { fallback: ReactNode }) => {
    // const project = from store、context、or even props
    if(!project) {
      return fallback;
    }
    return <Component {...props} project={project} />
  }
}

Redux

状态管理库需要解决的问题

  • 组件树中任何位置读取、写入存储状态,解决 Props Drilling 问题
  • 将逻辑从组件拆分出来,易于测试和扩展
  • 正确的优化重复渲染问题

不完整代码演示一下

function App() {
  const [plans, setPlans] = useState([]);
  const handleRemove = () => {};
  const handleAdd = () => {};
  const handleToggle = () => {};
}
// use redux(reducer)
function planReducer(state, action) {
  switch (action.type) {
    case 'ADD': {}
    case 'REMOVE': {}
    case 'TOGGLE': {}
    default: {}
  }
}
function App() {}
  const plans = useSelect(appState => appState.plans);
  const dispatch = useDispatch();
  return (
    <button onClick={() => dispatch({ type: 'ADD', payload: plan})}></button>
  )
}

你可能注意到,应用中大部分代码是用来更改数据的。使用状态管理后,操作状态的逻辑从组件中搬里出来了,这样的好处有

  • 操作和逻辑分开,关注点分离,组件只负责渲染
  • 拆分出来的 reducer 是一个纯函数,非常易于测试

以上代码只考虑导致将业务修改逻辑从组件中拆分出来,分离关注点做到了,但不存在复用的意义,其实它还可以进一步抽出其共性,以供类似场景复用,如提取自定义 hook useDynamicList

Router

首先第一个问题:Router 是必须的吗?Router 最开始是后端概念,后来前端吸纳。它本身就是工程师抽象的结果。假如我不用 router 编写后端接口(以下代码一定不能运行,因为是瞎写的)。

const handleGetUser = require('./getUser');
const handleGetProjects = require('./getProject');
http.createServer(function(request, response){
  const command = request.queryParams.get('command');
  if (command === 'getUser') {
    handleGetUser(request, response);      
  } else if (command === 'getProjects') {
    handleGetProjects(request, response);
  }
}).listen(8888);

这时候 localhost:8888?command=getUser/localhost:8888?command=getProjects 两个接口就完成了,聪明的你一定知道怎么优化这个代码,优化完之后 router 就出来了。

所以我认为 Router 是一种更顶层的模式,更宏观的角度去拆分代码,对于前端而言,它需要产品和开发协作去完成,如果不进行必要的路由拆分,你会很快陷入困境

  • 状态很快就膨胀起来,状态可能彼此还有影响,比如你修改 A,你必须同时修改 B 才能正常工作,关注点过多,就很容易导致 bug
  • 功能之间可能有冲突,你在操作 A 功能时,如果操作 B,则可能导致状态出错,如果只看结果的方式去解决这个问题,那就在使用 A 功能时,禁用 B 功能吧。嗯,很好,那么 C 和 D 呢

有点抽象,可以看下如下图示,某个列表页,点击某一项,弹出编辑面板,一顿编辑,此时我点击新的项目,这时候会……(可发散式的思考下如何解决)

ac0c432e-d58d-4dd6-93c3-c1ca8124cdee.png

尝试给每个页面定一个明确的主题,而从限定其边界范围

OOP embrace React

回到最初的公式 UI = render(data),以及上面各种 React Patterns,难道还不够你拆分代码吗?对于传统的复杂度不高 Web 项目的确足够了,但我们项目要更复杂一点,所以下面首先解释一下React 为什么要拥抱 OOP 呢?如图示:

8e6c4f55-af08-468d-b74e-824baff934f8.png

我们项目复杂的地方在于场景,我们会涉及到很多图形渲染、绘制、编辑、交互相关的逻辑,且都是在手动操作。在 React/Vue 框架出来之前,我们要更新 Web 页面,也是手动干的,这会导致将数据管理和页面渲染耦合在一起,导致代码大增。得益于 DOM 设计的简单以及 CSS 的灵活性,如今页面渲染的逻辑就被抽象出来,我们只需要管理好数据的正确性即可,数据更新,视图会自动更新。

React 操作的是 DOM 的渲染,它对于我们的 Canvas 视图是无能为力的,考虑到逻辑的下沉与封装、框架无关性、以及灵活性,场景相关的代码,尽可能通过 OOP 的方式进行封装。

那么问题来了,模块通过 OOP 类封装起来,假设有数据 A,React 组件需要用于渲染,OOP 类的逻辑也需要它,最直接的办法,React 组件持有 A,OOP 类同样持有A',当 A 更新时,调用 OOP 类方法更新内部 A',这还勉强可以接受。但如果 OOP 类内部也会更改 A' 呢,这该如何通知到 React 组件去更新 A 呢,通常这时候我们可以通过事件订阅方式解决。

但上述方式代码丑陋、繁琐,更重要的是容易出错,因为违背了 Single Source Of Truth

OOP 如何和 React 优雅结合呢?通常有两种方式

  • 类中暴露一个 useXXX 方法,直接作为一个 hook 使用,例如 useSubscription、useState,此时可以在类中使用 React 内置钩子,这种方式的缺点就是类和 React 耦合起来了
  • 将 React 的 update 函数作为参数传给类的 notify 函数,类中可选择暴露一个 public state 属性给 React 组件使用,类中修改 state 时调用 notify 函数通知更新

方式一代码示例

export default class User {
  state: State;

  private update: Dispatch<SetStateAction<State>> | undefined;

  useState(state: State) {
    const [value, setValue] = React.useState(state);
    this.state = value;
    this.update = setValue;
    return [value, setValue] as const;
  }

  setState(state: Partial<State>) {
    const newState = {
      ...this.state,
      ...state,
    };
    this.state = newState;
    this.update(newState);
  }

  setLoading(loading: boolean) {
    this.setState({ loading });
  }
}
// use in Component
export default App({ name }) => {
  const user = useCreation(() => new User());

  const [state, setState] = user.useState({
    loading: false,
  });

  return (
    <div>
      <h1
        onClick={() => {
          user.setLoading(true);
        }}
      >
        Hello {name}!
      </h1>
      {state.loading && <div>World</div>}
    </div>
  );
};

方式二代码示例

export default class UserList {
  state: State = {
    list: [],
    loading: false
  };
  setState(partialState: Partial<State> = {}) {
    this.state = {
      ...this.state,
      ...partialState,
    };
    this.subscribe();
  }
  setList(list: User[]) {
    this.setState({ list });
  }
}
// use in Component
export default function App() {
  const update = useUpdate();
  const userList = useCreation(() => new UserList(editor, copy, update), [editor]);

  return (
    <div>
      {userList.state.list.map(...)}
    </div>
  )
}

Examples

代码优化的实际例子。

示例一:圈复杂度高

function renderRotatedArea() {
  // ignore some code
  let delta = end - start;
  if (this.isAntiClock && delta < 0) {
    delta += Math.PI * 2;
  }
  if (!this.isAntiClock && delta > 0) {
    delta = Math.PI * 2 - delta;
  }
  if (!this.isAntiClock && delta < 0) {
    delta = -delta;
  }
  // ignore some code
}

示例二:不够语义化

function getAngleDelta(angle1: number, angle2: number) {
  if (angle2 >= angle1) {
    return this.fixAngle(angle2 - angle1);
  }
  return this.fixAngle(angle2 + 2 * Math.PI - angle1);
}

示例三:模块化不够,推荐策略模式+工厂模式,提高模块化、降低圈复杂度

function getSlope() {
  // ignore some code
  if (Math.abs(turnAngle - Math.PI) < precision) {
    // 反向
    left = this.calculateWhenReverse(entranceLeft, exitLeft, LineTypeEnum.L);
    right = this.calculateWhenReverse(entranceRight, exitRight, LineTypeEnum.R);
    middle = this.calculateWhenReverse(entranceOrigin, exitOrigin, LineTypeEnum.M);
  } else if (turnAngle < precision || Math.abs(turnAngle - Math.PI * 2) < precision) {
    // 同向
    left = this.calculateWhenSyntropy(entranceLeft, exitLeft, LineTypeEnum.L);
    right = this.calculateWhenSyntropy(entranceRight, exitRight, LineTypeEnum.R);
    middle = this.calculateWhenSyntropy(entranceOrigin, exitOrigin, LineTypeEnum.M);
  } else {
    left = this.calculate(entranceLeft, exitLeft, LineTypeEnum.L);
    right = this.calculate(entranceRight, exitRight, LineTypeEnum.R);
    middle = this.calculate(entranceOrigin, exitOrigin, LineTypeEnum.M);
  }
  // ignore some code
}

Callback

对照当前前端开发痛点与待优化项

  • 根组件状态过多,不同功能间状态难以维护对,因为你需要关注的点太多了
  • 修改场景样式(normal/hover/active/error)状态很恶心。当你专门做这件事时,你不会觉得恶心,但当你做其他事情时,你需要去管理这种东西,那就很恶心
  • 控制可选对象、可删对象、可编辑对象很恶心,原因同理,你内心当前并不想关心这些
  • 前端状态库选择、明确全局状态有哪些。通过提升状态,为可能的拆分提供支持

The end

所以你今天想找照顾哪颗树呢?

52e2329f-e9e6-4e6a-a941-602a8e3afd23.png

彩蛋

setState 是同步还是异步?

  • Concurrent 模式下均表现为异步
  • Legacy 模式下,则要看场景,合成事件中表现为异步,但异步代码中,如 Promise、setTimeout 等则会表现为同步!切需要特别注意,异步代码中,多次 setState 的情况,可能会导致意料之外的 bug。

React batch update:对多次 setState 做批量更新,表现为异步

iShot2023-04-01 10.54.22.png

下面是三种情形的调用情况(异步、同步、多次同步):

480d2fa3-07c0-4878-bb8b-84d24bedf0f9.png

Reference

Higher-Order Components in React Hooks era



留言