没有什么营养的序:写代码就像种一颗树,你不管它,它通常也能自然生长,但你时不时修剪修剪,它能长的更好一点。但它又不同于种树,因为你把它头插土里,它也可以自然生长。 文章中的代码都是盲打的,不保证正确性。
背景
随着项目的不断迭代,当前的前端底层设计很难满足需求的持续迭代,问题如下
- 代码难以拆分,需重新思考代码模块的组织问题
- 根组件状态越来越多,逻辑也越来越多,不同功能间状态很难维护对,需要消除功能之间对彼此影响
为什么根组件状态越来越多呢,对现在根组件下状态进行分类如下
简单总结下该状态特征
- 多个域数据(关注点开始增多)
- 用户界面状态过多,用于去控制大范围元素是否渲染(一个信号,说明该组件太杂了)
- 应用程序状态过多,说明该界面用户行为很多
- 很难给这个组件一个明确主题,生成页?编辑页?列表页?
那么组件不断增长,该如何优化呢
- 技术手段:尽可能解耦代码、模块化代码
- 沟通手段:这个需求能不能不做?
基本优化
超级类(组件)存在的危害
- 可读性、可维护性差,我可能只需要改其中的 5 行,但你强制我看了 1000 行
- 可测试性差,这种代码的测试通常都很难编写
- 指数级膨胀(破窗效应)
模块化具备以下优势:
- 这有利于将关注点和功能分离,有助于项目的可视化、理解和组织。
- 当项目被清晰地构建和细分之后,就更容易维护也更不容易出错。
- 如果项目被细分为许多不同的部分,每个部分可以单独进行处理和修改,这样更利于软件开发。
该如何优化(分离关注点、提高复用性):
- 思路一:分层
- 思路二:组合
相关思考方式
- 模块里有没有业务无关通用逻辑,可以抽成 utils/oop
- 模块里有没有复杂对象创建、复杂值计算逻辑,可以抽成 utils/oop
- 有没有复杂的表达式,可以考虑抽成中间变量或函数
- 你发现几个属性总是和特定的几个方法打交道,也许你可以把它们组合成新类
- React 组件同理,某些状态总是服务于某段 HTML 结构,考虑将他们抽成独立的组件
反复推敲代码:看一个作家的水平,不是看他发表了多少文字,而要看他的废纸篓里扔掉了多少
Generic Patterns
代码评价指标
最常用、最重要的评价指标
简单概括
- 可维护性:不破坏原有设计、不引入新的 BUG 的情况下,能够快速的修改和添加代码
- 分层清晰、模块化好、高内聚低耦合
- 代码量的多少,业务复杂程度,用到的技术复杂程度
- 可读性:编码规范、命名、注释
- 可扩展性:代码应对未来需求变化的能力
- 不修改或少量修改原有代码的情况下,通过扩展的方式添加新的功能代码
- 开闭原则
- 灵活性
- 实现新功能,原有代码已经预留好了扩展点,只需要在扩展点添加新的代码即可
- 原有代码中,已经抽象了很多底层可以复用的模块、类等
- 使用某组接口时,这组接口可以应对各种场景
- 简洁性:KISS 原则
- 可复用性:减少重复代码编写,复用已有代码
- 继承、多态
- 单一职责
- 解耦、模块化、高内聚
- 可测试性:可测试性差的代码,比较难写单元测试
圈复杂度
如何判断你的代码是不是该修剪了呢,了解一个指标:圈复杂度
- 基础复杂度为 1
- 碰到如下语句就 +1
- if、case、catch
- for、while
- and、or
- 三元运算符 通常当圈复杂度达到 5,就很不易读了,达到 10 的方法存在很大的出错风险。
如何优化圈复杂度
- 提炼函数:将一个方法中的代码提炼成多个方法,把高内聚的代码块提炼出来
- 卫语句:对分支条件,先做检查,快速返回。比如如果 A 条件极其罕见,则应该在条件 A 为真时,立刻返回
- 合并条件表达式:如果对多重错误的处理是一致的,就可以泛化这类错误,合并处理
- 以多态取代条件表达式
- 分解条件表达式:把表达式分解成独立的方法,不但可以突出条件逻辑,而且可以更清楚的表达每个分支的作用
- 替换算法:复杂算法会导致 bug 的增加,如果函数对性能要求不高,建议使用简单的算法
- 移除控制标记:通过 return 语句减少控制标记
- 策略模式
设计模式目的
设计模式要干的事情就是解耦
- 创建型模式是将创建和使用代码解耦,如工厂模式
- 结构型模式是将不同功能代码解耦,如代理模式
- 行为型模式是将不同行为代码解耦,如观察者模式
React Patterns
基础知识
掌握 React 核心,一个公式 + 一张图
- UI = render(data);
- Props down, events up.
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 呢
有点抽象,可以看下如下图示,某个列表页,点击某一项,弹出编辑面板,一顿编辑,此时我点击新的项目,这时候会……(可发散式的思考下如何解决)
尝试给每个页面定一个明确的主题,而从限定其边界范围
OOP embrace React
回到最初的公式 UI = render(data),以及上面各种 React Patterns,难道还不够你拆分代码吗?对于传统的复杂度不高 Web 项目的确足够了,但我们项目要更复杂一点,所以下面首先解释一下React 为什么要拥抱 OOP 呢?如图示:
我们项目复杂的地方在于场景,我们会涉及到很多图形渲染、绘制、编辑、交互相关的逻辑,且都是在手动操作。在 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
所以你今天想找照顾哪颗树呢?
彩蛋
setState 是同步还是异步?
- Concurrent 模式下均表现为异步
- Legacy 模式下,则要看场景,合成事件中表现为异步,但异步代码中,如 Promise、setTimeout 等则会表现为同步!切需要特别注意,异步代码中,多次 setState 的情况,可能会导致意料之外的 bug。
React batch update:对多次 setState 做批量更新,表现为异步
下面是三种情形的调用情况(异步、同步、多次同步):