My Little World

learn and share


  • 首页

  • 分类

  • 标签

  • 归档

  • 关于
My Little World

合理组织项目文件结构

发表于 2021-11-26

按领域组织文件夹结构

每增加一个新的功能,整个应用程序的复杂度不应该明显上升。这样才能保证我们的应用程序始终可扩展,可维护。

软件复杂度的根源完全来自复杂的依赖关系

从业务功能去理解,依赖可以分为两种。
第一种是硬依赖。如果功能 A 的实现必须基于功能 B,也就是说没有功能 B,功能 A 就是不可运行的,那么我们可以说 A 硬依赖于 B。
比如对于博客系统,评论功能肯定是基于文章功能的,因为评论的对象就是文章,脱离了文章,评论功能就没有意义。

1
2
3
4
5
6
7
8
9
import CommentList from './CommentList';
function ArticleView() {
return (
<div className="article-view">
<MainContent />
<CommentList />
</div>
);
}

第二种是软依赖。如果功能 B 扩展了功能 A,也就是说,没有功能 B,功能 A 自身也是可以独立工作的,只是缺少了某些能力。
同样对于博客应用,文章管理是主要的功能,而评论功能则可以认为是增强了文章的功能。
照此来看,即使没有评论功能,文章功能也是可以独立运行的。这样就可以认为文章功能软依赖于评论功能。
我们应该让文章功能相关的代码,不要硬依赖于评论功能的代码。

在业务功能上是一个软依赖,但是在代码实现层面,却往往做成了硬依赖。
这就导致随着功能的不断增加,整个应用变得越来越复杂,最终降低了整体的开发效率

让模块之间的交互不再通过硬依赖。
解决办法就是
扩展点机制:在任何可能产生单点复杂度的模块中,通过扩展点的方式,允许其它模块为其增加功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在需要扩展的地方定义扩展点
function ArticleView({ id }) {
const { article } = useArticle(id);
return (
<div className="article-view">
<MainContent article={article} />
{/* 定义了一个名为 article.footer 的扩展点 */}
<Extension name="article.footer" args={article} />
</div>
);
}

// 在被扩展的组件里,将自己挂到扩展点上,这样就算被扩展组件直接删除,对需要扩展的功能组件来说也毫无影响
extensionEngine.register('article.footer', article => {
return <CommentList article={article} />
});

软件复杂度产生的根源,来自复杂的依赖关系。
随着功能的增加,系统复杂度也在不断增加,那么整个项目就会到达一个不可维护的状态。
所以我们首先需要从项目结构层面,去对复杂度做物理上的隔离,确保业务模块相关的代码都能在独立的文件夹中。
其次,我们要妥善地处理业务模块之间的依赖关系。
不仅需要在业务上区分硬依赖和软依赖。
同时呢,在技术的实现层面也要能做到模块的松耦合。
当然,上面的所有介绍要落实到实际的项目,还有很多细节问题需要考虑,
比如如何避免在单点模块定义所有的路由,如何避免一个导航菜单组件包含了所有业务功能的导航逻辑,等等。
总结来说,这里是整个隔离复杂度的思路,可以根据实际场景进行有针对性的思考,进而解决复杂度的问题。
同时更为重要的是,在进行实际项目开发,尤其是大型项目的架构设计时,一定要时刻有管理系统复杂度的意识,
不能只考虑功能是否实现,而不管复杂度,那样终究会导致系统越来越复杂,不断降低开发和维护的效率,甚至导致项目失败。

一个扩展点引擎

My Little World

使用hooks封装自定义事件

发表于 2021-11-25

事件处理

1.原生事件

只要原生 DOM 有的事件,在 React 中基本都可以使用,只是写法上采用骆驼体就可以了

2.是不是所有的回调函数都需要用 useCallback 进行封装呢?是不是简单的回调函数就可以不用封装了呢?

其实是否需要 useCallback ,和函数的复杂度没有必然关系,而是和回调函数绑定到哪个组件有关。
这是为了避免因组件属性变化而导致不必要的重新渲染。
如果你的事件处理函数是传递给原生节点,那么不写 callback,也几乎不会有任何性能的影响。
但是如果你使用的是自定义组件,或者一些 UI 框架的组件,那么回调函数还都应该用 useCallback 进行封装。

React 原生事件的原理:合成事件(Synthetic Events)

由于虚拟 DOM 的存在,在 React 中即使绑定一个事件到原生的 DOM 节点,事件也并不是绑定在对应的节点上,而是所有的事件都是绑定在根节点上。
然后由 React 统一监听和管理,获取事件后再分发到具体的虚拟 DOM 节点上。

在 React 17 之前,所有的事件都是绑定在 document 上的,
而从 React 17 开始,所有的事件都绑定在整个 App 上的根节点上,这主要是为了以后页面上可能存在多版本 React 的考虑。

具体来说,React 这么做的原因主要有两个。

第一,虚拟 DOM render 的时候, DOM 很可能还没有真实地 render 到页面上,所以无法绑定事件。
第二,React 可以屏蔽底层事件的细节,避免浏览器的兼容性问题。
同时呢,对于 React Native 这种不是通过浏览器 render 的运行时,也能提供一致的 API。

浏览器原生事件机制是冒泡模型。
无论事件在哪个节点被触发, React 都可以通过事件的 srcElement 这个属性,知道它是从哪个节点开始发出的,
这样 React 就可以收集管理所有的事件,然后再以一致的 API 暴露出来。

自定义事件

虽然自定义事件和原生事件看上去类似,但是两者的机制是完全不一样的:

原生事件是浏览器的机制;

而自定义事件则是纯粹的组件自己的行为,本质是一种回调函数机制。

Hooks 具备绑定任何数据源的能力, 通过分析事件中用到的数据,将数据进行抽离,从hooks角度去处理事件
可以实现定义一次,然后在任何组件中重复使用的效果
用hooks的思维去简化事件处理逻辑。

比如封装键盘输入事件
只要把键盘按键看做是一个不断变化的数据源,这样,就可以去实时监听某个 DOM 节点上触发的键盘事件了。

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
import { useEffect, useState } from "react";

// 使用 document.body 作为默认的监听节点
const useKeyPress = (domNode = document.body) => {
const [key, setKey] = useState(null);
useEffect(() => {
const handleKeyPress = (evt) => {
setKey(evt.keyCode);
};
// 监听按键事件
domNode.addEventListener("keypress", handleKeyPress);
return () => {
// 接触监听按键事件
domNode.removeEventListener("keypress", handleKeyPress);
};
}, [domNode]);
return key;
};

// 使用


import useKeyPress from './useKeyPress';

function UseKeyPressExample() => {
const key = useKeyPress();
return (
<div>
<h1>UseKeyPress</h1>
<label>Key pressed: {key || "N/A"}</label>
</div>
);
};
My Little World

应对复杂条件渲染场景

发表于 2021-11-24

容器模式:实现按条件执行 Hooks

把条件判断的结果放到两个组件之中,确保真正 render UI 的组件收到的所有属性都是有值的。

1
2
3
4
5
6
7
8
9
10
11

// 定义一个容器组件用于封装真正的 UserInfoModal
export default function UserInfoModalWrapper({
visible,
...rest, // 使用 rest 获取除了 visible 之外的属性
}) {
// 如果对话框不显示,则不 render 任何内容
if (!visible) return null;
// 否则真正执行对话框的组件逻辑
return <UserInfoModal visible {...rest} />;
}

在容器模式中可以看到,条件的隔离对象是多个子组件,这就意味着它通常用于一些比较大块逻辑的隔离。
所以对于一些比较细节的控制,其实还有一种做法,就是把判断条件放到 Hooks 中去。

1
2
3
4
5
6
7
8
9
10
11

function useUser(id) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// 当 id 不存在,直接返回,不发送请求
if (!id) return
// 获取用户信息的逻辑
});
}

通过这样一个容器模式,我们把原来需要条件运行的 Hooks 拆分成子组件,
然后通过一个容器组件来进行实际的条件判断,从而渲染不同的组件,实现按条件渲染的目的。
这在一些复杂的场景之下,也能达到拆分复杂度,让每个组件更加精简的目的.

使用 render props 模式重用 UI 逻辑

render props 就是把一个 render 函数作为属性传递给某个组件,
由这个组件去执行这个函数从而 render 实际的内容

Hooks 是逻辑重用的第一选择。
不过在如今的函数组件情况下,Hooks 有一个局限,那就是只能用作数据逻辑的重用,
而一旦涉及 UI 表现逻辑的重用,就有些力不从心了,
而这正是 render props 擅长的地方。
所以,即使有了 Hooks,我们也要掌握 render props 这个设计模式的用法

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

import { Popover } from "antd";

function ListWithMore({ renderItem, data = [], max }) {
const elements = data.map((item, index) => renderItem(item, index, data));
const show = elements.slice(0, max);
const hide = elements.slice(max);
return (
<span className="exp-10-list-with-more">
{show}
{hide.length > 0 && (
<Popover content={<div style={{ maxWidth: 500 }}>{hide}</div>}>
<span className="more-items-wrapper">
and{" "}
<span className="more-items-trigger"> {hide.length} more...</span>
</span>
</Popover>
)}
</span>
);
}

// 这里用一个示例数据
import data from './data';

function ListWithMoreExample () => {
return (
<div className="exp-10-list-with-more">
<h1>User Names</h1>
<div className="user-names">
Liked by:{" "}
<ListWithMore
renderItem={(user) => {
return <span className="user-name">{user.name}</span>;
}}
data={data}
max={3}
/>
</div>
<br />
<br />
<h1>User List</h1>
<div className="user-list">
<div className="user-list-row user-list-row-head">
<span className="user-name-cell">Name</span>
<span>City</span>
<span>Job Title</span>
</div>
<ListWithMore
renderItem={(user) => {
return (
<div className="user-list-row">
<span className="user-name-cell">{user.name}</span>
<span>{user.city}</span>
<span>{user.job}</span>
</div>
);
}}
data={data}
max={5}
/>
</div>
</div>
);
};
My Little World

从 Hooks 的角度去组织异步请求

发表于 2021-11-23

要定义一个自己的 API Client

封装整个应用中异步请求的一些通过设置,以及统一处理,方便在 Hooks 中使用。

通常来说,会包括以下几个方面:

  1. 一些通用的 Header。比如 Authorization Token。
  2. 服务器地址的配置。前端在开发和运行时可能会连接不同的服务器,比如本地服务器或者测试服务器,此时这个 API Client 内部可以根据当前环境判断该连接哪个 URL。
  3. 请求未认证的处理。比如如果 Token 过期了,需要有一个统一的地方进行处理,这时就会弹出对话框提示用户重新登录。
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

import axios from "axios";

// 定义相关的 endpoint
const endPoints = {
test: "https://60b2643d62ab150017ae21de.mockapi.io/",
prod: "https://prod.myapi.io/",
staging: "https://staging.myapi.io/"
};

// 创建 axios 的实例
const instance = axios.create({
// 实际项目中根据当前环境设置 baseURL
baseURL: endPoints.test,
timeout: 30000,
// 为所有请求设置通用的 header
headers: { Authorization: "Bear mytoken" }
});

// 听过 axios 定义拦截器预处理所有请求
instance.interceptors.response.use(
(res) => {
// 可以假如请求成功的逻辑,比如 log
return res;
},
(err) => {
if (err.response.status === 403) {
// 统一处理未授权请求,跳转到登录界面
document.location = '/login';
}
return Promise.reject(err);
}
);

export default instance;

封装远程资源

将通过get方法获取的数据其实就是远程数据源,UI依赖远程数据源渲染

比起在组件内部直接发请求,只是把代码换了个地方,也就是写到了 Hook 里面

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

// useArticle
import { useState, useEffect } from "react";
import apiClient from "./apiClient";

// 将获取文章的 API 封装成一个远程资源 Hook
const useArticle = (id) => {
// 设置三个状态分别存储 data, error, loading
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// 重新获取数据时重置三个状态
setLoading(true);
setData(null);
setError(null);
apiClient
.get(`/posts/${id}`)
.then((res) => {
// 请求成功时设置返回数据到状态
setLoading(false);
setData(res.data);
})
.catch((err) => {
// 请求失败时设置错误状态
setLoading(false);
setError(err);
});
}, [id]); // 当 id 变化时重新获取数据

// 将三个状态作为 Hook 的返回值
return {
loading,
error,
data
};
};

// 使用


import useArticle from "./useArticle";

const ArticleView = ({ id }) => {
// 将 article 看成一个远程资源,有 data, loading, error 三个状态
const { data, loading, error } = useArticle(id);
if (error) return "Failed.";
if (!data || loading) return "Loading...";
return (
<div className="exp-09-article-view">
<h1>
{id}. {data.title}
</h1>
<p>{data.content}</p>
</div>
);
};

有了这样一个 Hook,React 的函数组件几乎不需要有任何业务的逻辑,而只是把数据映射到 JSX 并显示出来就可以了,在使用的时候非常方便。

在项目中,可以把每一个 Get 请求都做成这样一个 Hook。
数据请求和处理逻辑都放到 Hooks 中,从而实现 Model 和 View 的隔离,
不仅代码更加模块化,而且更易于测试和维护。

这样做是为了保证每个 Hook 自身足够简单。

一般来说,为了让服务器的返回数据满足 UI 上的展现要求,通常需要进一步处理。
而这个对于每个请求的处理逻辑可能都不一样,通过一定的代码重复,能够避免产生太复杂的逻辑。

同时呢,某个远程资源有可能是由多个请求组成的,那么 Hooks 中的逻辑就会不一样,因为要同时发出去多个请求,组成 UI 展现所需要的数据。
所以,将每个 Get 请求都封装成一个 Hook ,也是为了让逻辑更清楚。

这个模式仅适用于 Get 请求的逻辑,对于其它类型,可以使用 useAsync 这样一个自定义的 Hook,
同样也是用 Hook 的思想,把请求的不同状态封装成了一个数据源供组件使用。

处理并发或串行请求

从状态变化的角度去组织异步调用。

函数组件的每一次 render,其实都提供了我们根据状态变化执行不同操作的机会,
就是利用这个机制,通过不同的状态组合,来实现异步请求的逻辑

利用状态的组合变化来实现并发和串行请求

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
// useArticle 同上
// useUser
import { useState, useEffect } from "react";
import apiClient from "./apiClient";

export default (id) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// 当 id 不存在,直接返回,不发送请求
if (!id) return;
setLoading(true);
setData(null);
setError(null);
apiClient
.get(`/users/${id}`)
.then((res) => {
setLoading(false);
setData(res.data);
})
.catch((err) => {
setLoading(false);
setError(err);
});
}, [id]);
return {
loading,
error,
data
};
};

// 应用

import { useState } from "react";
import CommentList from "./CommentList";
import useArticle from "./useArticle";
import useUser from "./useUser";
import useComments from "./useComments";

const ArticleView = ({ id }) => {
const { data: article, loading, error } = useArticle(id);
const { data: comments } = useComments(id);
const { data: user } = useUser(article?.userId);
if (error) return "Failed.";
if (!article || loading) return "Loading...";
return (
<div className="exp-09-article-view">
<h1>
{id}. {article.title}
</h1>
{user && (
<div className="user-info">
<img src={user.avatar} height="40px" alt="user" />
<div>{user.name}</div>
<div>{article.createdAt}</div>
</div>
)}
<p>{article.content}</p>
<CommentList data={comments || []} />
</div>
);
};

export default () => {
const [id, setId] = useState(1);
return (
<div className="exp-09-article-view-wrapper">
<ul>
<li onClick={() => setId(1)}>Article 1</li>
<li onClick={() => setId(2)}>Article 2</li>
<li onClick={() => setId(3)}>Article 3</li>
<li onClick={() => setId(4)}>Article 4</li>
<li onClick={() => setId(5)}>Article 5</li>
</ul>
<ArticleView id={id} />
</div>
);
};

useArticle 和 useComments属于并发请求;useArticle 和 useUser属于串行请求,

之所以useUser能在article数据变化时重新执行,是因为在useUser的hook里面用useEffect做了依赖

或者换个角度想,把三个钩子的逻辑全部都写在应用代码里,不再封装起来,明显可以看到,
因为useEffect的原因,可以使获取user信息的请求,在拿到article数据后再执行。

My Little World

复杂状态管理

发表于 2021-11-22

React 的开发其实就是复杂应用程序状态的管理和开发

复杂状态管理的两个原则

原则一:保证状态最小化

在定义一个新的状态之前,都要再三拷问自己:
这个状态是必须的吗?是否能通过计算得到呢?
在得到肯定的回答后,再去定义新的状态,
就能避免大部分多余的状态定义问题,也就能在简化状态管理的同时,保证状态的一致性

比如根据关键字搜索的场景,搜索结果可以根据关键和原始数据计算得到,可以使用useMemo保存过滤结果,
而不是再为过滤结果声明一个state

原则二:避免中间状态,确保唯一数据源

比如在需要根据URL查询数据的场景,查询条件在url,同时页面有配置条件的交互
那么直接将 URL 作为唯一的数据来源,状态的读取和修改都是对 URL 直接进行操作,
而不是通过一个中间的状态保存条件
(URL 变化时,同步查询关键字到 State;State 变化时,同步查询关键字到输入框; 用户在输入框输入的时候,同步关键字到 URL 和 State),
这样就简化了状态的管理,保证了状态的一致性

My Little World

redux的基本使用

发表于 2021-11-17

Redux 的三个基本概念

Redux 引入的概念其实并不多,主要就是三个:State、Action 和 Reducer。

其中 State 即 Store,一般就是一个纯 JavaScript Object。
Action 也是一个 Object,用于描述发生的动作。
而 Reducer 则是一个函数,接收 Action 和 State 并作为参数,通过计算得到新的 Store。

如何在 React 中使用 Redux

如何建立 Redux 和 React 的联系

主要是两点:

  1. React 组件能够在依赖的 Store 的数据发生变化时,重新 Render;
  2. 在 React 组件中,能够在某些时机去 dispatch 一个 action,从而触发 Store 的更新

答案:
借助react-redux 这样一个工具库,工具库的作用就是建立一个桥梁,让 React 和 Redux 实现互通。

在 react-redux 的实现中,为了确保需要绑定的组件能够访问到全局唯一的 Redux Store,
利用了 React 的 Context 机制去存放 Store 的信息。
通常我们会将这个 Context 作为整个 React 应用程序的根节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from 'react'
import ReactDOM from 'react-dom'

import { Provider } from 'react-redux'
import store from './store'

import App from './App'

const rootElement = document.getElementById('root')
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
rootElement
)

Hooks 的本质就是提供了让 React 组件能够绑定到某个可变的数据源的能力。
在这里,当 Hooks 用到 Redux 时可变的对象就是 Store,而 useSelector 则让一个组件能够在 Store 的某些数据发生变化时重新 render.

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 from 'react'
import { useSelector, useDispatch } from 'react-redux'

export function Counter() {
// 从 state 中获取当前的计数值
const count = useSelector(state => state.value)

// 获得当前 store 的 dispatch 方法
const dispatch = useDispatch()

// 在按钮的 click 时间中去分发 action 来修改 store
return (
<div>
<button
onClick={() => dispatch({ type: 'counter/incremented' })}
>+</button>
<span>{count}</span>
<button
onClick={() => dispatch({ type: 'counter/decremented' })}
>-</button>
</div>
)
}

使用 Redux 处理异步逻辑

借助中间件机制

middleware 可以让你提供一个拦截器在 reducer 处理 action 之前被调用。
在这个拦截器中,你可以自由处理获得的 action。
无论是把这个 action 直接传递到 reducer,或者构建新的 action 发送到 reducer,都是可以的

Middleware 正是在 Action 真正到达 Reducer 之前提供的一个额外处理 Action 的机会

Redux 提供了 redux-thunk 这样一个中间件,它如果发现接受到的 action 是一个函数,那么就不会传递给 Reducer,
而是执行这个函数,并把 dispatch 作为参数传给这个函数,从而在这个函数中你可以自由决定何时,如何发送 Action。

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
// 在创建 Redux Store 时指定了 redux-thunk 这个中间件
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import rootReducer from './reducer'

const composedEnhancer = applyMiddleware(thunkMiddleware)
const store = createStore(rootReducer, composedEnhancer)

//dispatch action 时就可以 dispatch 一个函数用于来发送请求

function fetchData() {
return dispatch => {
dispatch({ type: 'FETCH_DATA_BEGIN' });
fetch('/some-url').then(res => {
dispatch({ type: 'FETCH_DATA_SUCCESS', data: res });
}).catch(err => {
dispatch({ type: 'FETCH_DATA_FAILURE', error: err });
})
}
}

// dispatch action 时就可以 dispatch 一个函数用于来发送请求
import fetchData from './fetchData';

function DataList() {
const dispatch = useDispatch();
// dispatch 了一个函数由 redux-thunk 中间件去执行
dispatch(fetchData());
}

通过这种方式,我们就实现了异步请求逻辑的重用。那么这一套结合 redux-thunk 中间件的机制,我们就称之为异步 Action.

dispatch时可以传入其他参数

1
2
3
4
5
6
7
8
9
10
11
12

function counterReducer(state = initialState, action) {
switch (action.type) {
case 'counter/incremented': return {value: state.value + action.payload}
case 'counter/decremented': return {value: state.value - action.payload}
default: return state
}
}
const incrementAction = { type: 'counter/incremented', payload: 10 };
store.dispatch(incrementAction); // 计数器加 10
const decrementAction = { type: 'counter/decremented', payload: 10 };
store.dispatch(decrementAction); // 计数器减 10
My Little World

hooks 的典型应用场景

发表于 2021-11-16

自定义hooks

声明一个名字以 use 开头的函数, 并在函数中用到其他hooks。
Hooks 和普通函数在语义上的区别,就在于函数中有没有用到其它 Hooks(自定义或者内置,能够让组件刷新,或者去产生副作用),没有用到就是普通函数。

典型的四个使用场景

  1. 抽取业务逻辑;
  2. 封装通用逻辑;
  3. 监听浏览器状态;
  4. 拆分复杂组件.

抽取业务逻辑

抽取具体业务逻辑到hooks中,暴露接口在组件中调用
一方面能让这个逻辑得到重用,另外一方面也能让代码更加语义化,并且易于理解和维护

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
// 实现计数器业务逻辑的拆分和重用
import { useState, useCallback }from 'react';

function useCounter() {
// 定义 count 这个 state 用于保存当前数值
const [count, setCount] = useState(0);
// 实现加 1 的操作
const increment = useCallback(() => setCount(count + 1), [count]);
// 实现减 1 的操作
const decrement = useCallback(() => setCount(count - 1), [count]);
// 重置计数器
const reset = useCallback(() => setCount(0), []);

// 将业务逻辑的操作 export 出去供调用者使用
return { count, increment, decrement, reset };
}

// 应用
import React from 'react';

function Counter() {
// 调用自定义 Hook
const { count, increment, decrement, reset } = useCounter();

// 渲染 UI
return (
<div>
<button onClick={decrement}> - </button>
<p>{count}</p>
<button onClick={increment}> + </button>
<button onClick={reset}> reset </button>
</div>
);
}

封装通用逻辑:useAsync

在每个需要异步请求的组件中,其实都需要重复相同的逻辑。
事实上,在处理这类请求的时候,模式都是类似的,通常都会遵循下面步骤:

  1. 创建 data,loading,error 这 3 个 state;
  2. 请求发出后,设置 loading state 为 true;
  3. 请求成功后,将返回的数据放到某个 state 中,并将 loading state 设为 false;
  4. 请求失败后,设置 error state 为 true,并将 loading state 设为 false。

最后,基于 data、loading、error 这 3 个 state 的数据,
UI 就可以正确地显示数据,或者 loading、error 这些反馈给用户了。
所以,通过创建一个自定义 Hook,可以很好地将这样的逻辑提取出来,成为一个可重用的模块。

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


import { useState } from 'react';
// asyncFunction 真正发出请求的函数
const useAsync = (asyncFunction) => {
// 设置三个异步逻辑相关的 state
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// 定义一个 callback 用于执行异步逻辑 相当于声明一个请求的函数,在需要请求数据的情况下调用
const execute = useCallback(() => {
// 请求开始时,设置 loading 为 true,清除已有数据和 error 状态
setLoading(true);
setData(null);
setError(null);
return asyncFunction()
.then((response) => {
// 请求成功时,将数据写进 state,设置 loading 为 false
setData(response);
setLoading(false);
})
.catch((error) => {
// 请求失败时,设置 loading 为 false,并设置错误状态
setError(error);
setLoading(false);
});
}, [asyncFunction]);

return { execute, loading, data, error };
};


// 应用

import React from "react";
import useAsync from './useAsync';

export default function UserList() {
// 通过 useAsync 这个函数,只需要提供异步逻辑的实现
const {
execute: fetchUsers, // fetchUsers可以在需要请求的地方去使用
data: users, // 返回的数据可以直接拿来渲染ui
loading,
error,
} = useAsync(async () => {
const res = await fetch("https://reqres.in/api/users/");
const json = await res.json();
return json.data;
});

return (
// 根据状态渲染 UI...
);
}

利用了 Hooks 能够管理 React 组件状态的能力,将一个组件中的某一部分状态独立出来,从而实现了通用逻辑的重用。

监听浏览器状态:useScroll

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

import { useState, useEffect } from 'react';

// 获取横向,纵向滚动条位置
const getPosition = () => {
return {
x: document.body.scrollLeft,
y: document.body.scrollTop,
};
};
const useScroll = () => {
// 定一个 position 这个 state 保存滚动条位置
const [position, setPosition] = useState(getPosition());
useEffect(() => {
const handler = () => {
setPosition(getPosition(document));
};
// 监听 scroll 事件,更新滚动条位置
document.addEventListener("scroll", handler);
return () => {
// 组件销毁时,取消事件监听
document.removeEventListener("scroll", handler);
};
}, []);
return position;
};

// 应用


import React, { useCallback } from 'react';
import useScroll from './useScroll';

function ScrollTop() {
const { y } = useScroll();

const goTop = useCallback(() => {
document.body.scrollTop = 0;
}, []);

const style = {
position: "fixed",
right: "10px",
bottom: "10px",
};
// 当滚动条位置纵向超过 300 时,显示返回顶部按钮
if (y > 300) {
return (
<button onClick={goTop} style={style}>
Back to Top
</button>
);
}
// 否则不 render 任何 UI
return null;
}

拆分复杂组件

拆分逻辑的目的不一定是为了重用,而可以是仅仅为了业务逻辑的隔离。

把 Hooks 就看成普通的函数,能隔离的尽量去做隔离

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
110
111
112
113
114
115


import React, { useEffect, useCallback, useMemo, useState } from "react";
import { Select, Table } from "antd";
import _ from "lodash";
import useAsync from "./useAsync";

const endpoint = "https://myserver.com/api/";
const useArticles = () => {
// 使用上面创建的 useAsync 获取文章列表
const { execute, data, loading, error } = useAsync(
useCallback(async () => {
const res = await fetch(`${endpoint}/posts`);
return await res.json();
}, []),
);
// 执行异步调用
useEffect(() => execute(), [execute]);
// 返回语义化的数据结构
return {
articles: data,
articlesLoading: loading,
articlesError: error,
};
};
const useCategories = () => {
// 使用上面创建的 useAsync 获取分类列表
const { execute, data, loading, error } = useAsync(
useCallback(async () => {
const res = await fetch(`${endpoint}/categories`);
return await res.json();
}, []),
);
// 执行异步调用
useEffect(() => execute(), [execute]);

// 返回语义化的数据结构
return {
categories: data,
categoriesLoading: loading,
categoriesError: error,
};
};
const useCombinedArticles = (articles, categories) => {
// 将文章数据和分类数据组合到一起
return useMemo(() => {
// 如果没有文章或者分类数据则返回 null
if (!articles || !categories) return null;
return articles.map((article) => {
return {
...article,
category: categories.find(
(c) => String(c.id) === String(article.categoryId),
),
};
});
}, [articles, categories]);
};
const useFilteredArticles = (articles, selectedCategory) => {
// 实现按照分类过滤
return useMemo(() => {
if (!articles) return null;
if (!selectedCategory) return articles;
return articles.filter((article) => {
console.log("filter: ", article.categoryId, selectedCategory);
return String(article?.category?.name) === String(selectedCategory);
});
}, [articles, selectedCategory]);
};

const columns = [
{ dataIndex: "title", title: "Title" },
{ dataIndex: ["category", "name"], title: "Category" },
];

export default function BlogList() {
const [selectedCategory, setSelectedCategory] = useState(null);
// 获取文章列表
const { articles, articlesError } = useArticles();
// 获取分类列表
const { categories, categoriesError } = useCategories();
// 组合数据
const combined = useCombinedArticles(articles, categories);
// 实现过滤
const result = useFilteredArticles(combined, selectedCategory);

// 分类下拉框选项用于过滤
const options = useMemo(() => {
const arr = _.uniqBy(categories, (c) => c.name).map((c) => ({
value: c.name,
label: c.name,
}));
arr.unshift({ value: null, label: "All" });
return arr;
}, [categories]);

// 如果出错,简单返回 Failed
if (articlesError || categoriesError) return "Failed";

// 如果没有结果,说明正在加载
if (!result) return "Loading...";

return (
<div>
<Select
value={selectedCategory}
onChange={(value) => setSelectedCategory(value)}
options={options}
style={{ width: "200px" }}
placeholder="Select a category"
/>
<Table dataSource={result} columns={columns} />
</div>
);
}
My Little World

hooks 与 生命周期

发表于 2021-11-15

单次执行函数

利用 useRef 这个 Hook,我们可以实现一个 useSingleton 这样的一次性执行某段代码的自定义 Hook

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


import { useRef } from 'react';

// 创建一个自定义 Hook 用于执行一次性代码
function useSingleton(callback) {
// 用一个 called ref 标记 callback 是否执行过
const called = useRef(false);
// 如果已经执行过,则直接返回
if (called.current) return;
// 第一次调用时直接执行
callBack();
// 设置标记为已执行过
called.current = true;
}



import useSingleton from './useSingleton';

const MyComp = () => {
// 使用自定义 Hook
useSingleton(() => {
console.log('这段代码只执行一次');
});

return (
<div>My Component</div>
);
};

useEffect与class生命周期方法联系

1
2
3
4
5
6
7
8
9

useEffect(() => {
// componentDidMount + componentDidUpdate
console.log('这里基本等价于 componentDidMount + componentDidUpdate');
return () => {
// componentWillUnmount
console.log('这里基本等价于 componentWillUnmount');
}
}, [deps])

这个写法并没有完全等价于传统的这几个生命周期方法。主要有两个原因。

一方面,useEffect(callback) 这个 Hook 接收的 callback,只有在依赖项变化时才被执行。
而传统的 componentDidUpdate 则一定会执行。
这样来看,Hook 的机制其实更具有语义化,
因为过去在 componentDidUpdate 中,我们通常都需要手动判断某个状态是否发生变化,然后再执行特定的逻辑。

另一方面,
callback 返回的函数(一般用于清理工作)在下一次依赖项发生变化以及组件销毁之前执行,
而传统的 componentWillUnmount 只在组件销毁时才会执行。

My Little World

react 自带的hooks

发表于 2021-11-12

useCallback:缓存回调函数

在 React 函数组件中,每一次 UI 的变化,都是通过重新执行整个函数来完成的

如果组件中依赖的处理函数没有被useCallback做缓存处理,那么每次重新执行时都会被重新创建
那么依赖处理函数的组件就会因为处理函数更新进行重现渲染

为避免频繁的重新渲染组件,useCallback的作用就是将函数缓存起来,
只有当函数逻辑中依赖的状态发生变化才会去重新生成,然后引起组件更新,
避免了依赖没有变化时的无效更新

useCallback 可以减少不必要的渲染,主要体现在将回调函数作为属性传给某个组件。
如果每次都不一样就会造成组件的重新渲染。
但是如果确定子组件多次渲染也没有太大问题,特别是原生的组件,比如 button,
那么不用 useCallback 也问题不大。所以这和子组件的实现相关,和函数是否轻量无关。

useMemo:缓存计算的结果

同处理函数类似,如果组件中用到的数据A通过其他状态值计算得到,
在依赖值没有发生变化情况下,其实就不用重新计算,
即
如果某个数据是通过其它数据计算得到的,那么只有当用到的数据,也就是依赖的数据发生变化的时候,才应该需要重新计算
因此当组件因为其他原因刷新,进而重新执行函数,引起的重新计算是没有必要的

可以实现这个功能的hooks就是useMemo,useMemo就是只有在依赖的状态值发生变化时才会去重新执行计算的过程
避免不必要的重新计算过程
同时,对于依赖数据A的组件来说,没有重新计算产生的新值,可以在一定程度上避免子组件重复渲染。

如果将函数也看做一个状态值/变量的话,其实,useMemo可以实现useCallback的功能

1
2
3
4
5
6
const myEventHandler = useMemo(() => {
// 返回一个函数作为缓存结果
return () => {
// 在这里进行事件处理
}
}, [dep1, dep2]);

二者的本质就是
建立了一个绑定某个结果到依赖数据的关系。只有当依赖变了,这个结果才需要被重新得到。

useRef:在多次渲染之间共享数据

可以把 useRef 看作是在函数组件之外创建的一个容器空间
在这个容器上,我们可以通过唯一的 current 属设置一个值,从而在函数组件的多次渲染之间共享这个值

使用 useRef 保存的数据一般是和 UI 的渲染无关的,因此当 ref 的值发生变化时,是不会触发组件的重新渲染的,
这也是 useRef 区别于 useState 的地方。
比如保存定时器句柄,在回调函数中关掉定时器,然后将ref.current 设置为null, 这个句柄没有在组件中被用到,所以不会引起重新渲染。
但如果用useState去保存句柄,将定时器关掉时,同时将state设置为null,即使没有被组件用到,也会引起重新组件渲染。

useRef 还有一个重要的功能,就是保存某个 DOM 节点的引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14

function TextInputWithFocusButton() {
const inputEl = useRef(null);
const onButtonClick = () => {
// current 属性指向了真实的 input 这个 DOM 节点,从而可以调用 focus 方法
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}

可以看到 ref 这个属性提供了获得 DOM 节点的能力,并利用 useRef 保存了这个节点的应用

useContext:定义全局状态

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

const MyContext = React.createContext(initialValue);

const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};
// 创建一个 Theme 的 Context

const ThemeContext = React.createContext(themes.light);
function App() {
// 整个应用使用 ThemeContext.Provider 作为根组件
return (
// 使用 themes.dark 作为当前 Context
<ThemeContext.Provider value={themes.dark}>
<Toolbar />
</ThemeContext.Provider>
);
}

// 在 Toolbar 组件中使用一个会使用 Theme 的 Button
function Toolbar(props) {
return (
<div>
<ThemedButton />
</div>
);
}

// 在 Theme Button 中使用 useContext 来获取当前的主题
function ThemedButton() {
const theme = useContext(ThemeContext);
return (
<button style={{
background: theme.background,
color: theme.foreground
}}>
I am styled by theme context!
</button>
);
}

Context 提供了一个方便在多个组件之间共享数据的机制。
不过需要注意的是,它的灵活性也是一柄双刃剑。
你或许已经发现,Context 相当于提供了一个定义 React 世界中全局变量的机制,
而全局变量则意味着两点:

  1. 会让调试变得困难,因为你很难跟踪某个 Context 的变化究竟是如何产生的。
  2. 让组件的复用变得困难,因为一个组件如果使用了某个 Context,它就必须确保被用到的地方一定有这个 Context 的 Provider 在其父组件的路径上。

需要再三强调的是,Context 更多的是提供了一个强大的机制, 让 React 应用具备定义全局的响应式数据的能力。

My Little World

如何保存组件状态和使用生命周期

发表于 2021-11-11

那么什么样的值应该保存在 state 中呢?

在一个函数组件的多次渲染之间,这个 state 是共享的。
这是日常开发中需要经常思考的问题。通常来说,我们要遵循的一个原则就是:state 中永远不要保存可以通过计算得到的值。
比如说:

  1. 从 props 传递过来的值。有时候 props 传递过来的值无法直接使用,而是要通过一定的计算后再在 UI 上展示,比如说排序。
    那么我们要做的就是每次用的时候,都重新排序一下,或者利用某些 cache 机制,而不是将结果直接放到 state 里。
  2. 从 URL 中读到的值。比如有时需要读取 URL 中的参数,把它作为组件的一部分状态。那么我们可以在每次需要用的时候从 URL 中读取,而不是读出来直接放到 state 里。
  3. 从 cookie、localStorage 中读取的值。通常来说,也是每次要用的时候直接去读取,而不是读出来后放到 state 里。

useEffect执行

useEffect 让我们能够在下面四种时机去执行一个回调函数产生副作用:

  1. 每次 render 后执行:不提供第二个依赖项参数。比如useEffect(() => {})。
  2. 仅第一次 render 后执行:提供一个空数组作为依赖项。比如useEffect(() => {}, [])。
  3. 第一次以及依赖项发生变化后执行:提供依赖项数组。比如useEffect(() => {}, [deps])。
  4. 组件 unmount 后执行:返回一个回调函数。比如useEffect() => { return () => {} }, [])。

定义依赖项注意点

那么在定义依赖项时,我们需要注意以下三点:

  1. 依赖项中定义的变量一定是会在回调函数中用到的,否则声明依赖项其实是没有意义的。
  2. 依赖项一般是一个常量数组,而不是一个变量。因为一般在创建 callback 的时候,你其实非常清楚其中要用到哪些依赖项了。
  3. React 会使用浅比较来对比依赖项是否发生了变化,所以要特别注意数组或者对象类型。
    如果你是每次创建一个新对象,即使和之前的值是等价的,也会被认为是依赖项发生了变化。这是一个刚开始使用 Hooks 时很容易导致 Bug 的地方。

Hooks 的使用规则

只能在函数组件的顶级作用域使用

第一,所有 Hook 必须要被执行到。第二,必须按顺序执行。

所谓顶层作用域,就是 Hooks 不能在循环、条件判断或者嵌套函数内执行,而必须是在顶层。
同时 Hooks 在组件的多次渲染之间,必须按顺序被执行。
因为在 React 组件内部,其实是维护了一个对应组件的固定 Hooks 执行列表的,以便在多次渲染之间保持 Hooks 的状态,并做对比。
也不能在return语句后执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

function MyComp() {
const [count, setCount] = useState(0);
if (count > 10) {
// 错误:不能将 Hook 用在条件判断里
useEffect(() => {
// ...
}, [count])
}

// 这里可能提前返回组件渲染结果,后面就不能再用 Hooks 了
if (count === 0) {
return 'No content';
}

// 错误:不能将 Hook 放在可能的 return 之后
const [loading, setLoading] = useState(false);

//...
return <div>{count}</div>
}

Hooks 只能在函数组件或者其它 Hooks 中使用

如果一定要在 Class 组件中使用,那应该如何做呢?
其实有一个通用的机制,那就是利用高阶组件的模式,将 Hooks 封装成高阶组件,从而让类组件使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
a.
import React from 'react';
import { useWindowSize } from '../hooks/useWindowSize';

export const withWindowSize = (Comp) => {
return props => {
const windowSize = useWindowSize();
return <Comp windowSize={windowSize} {...props} />;
};
};

b.
import React from 'react';
import { withWindowSize } from './withWindowSize';

class MyComp {
render() {
const { windowSize } = this.props;
// ...
}
}

// 通过 withWindowSize 高阶组件给 MyComp 添加 windowSize 属性
export default withWindowSize(MyComp);

小结
hooks使用时应包括这么三点:

  1. 在 useEffect 的回调函数中使用的变量,都必须在依赖项中声明;
  2. Hooks 不能出现在条件语句或者循环中,也不能出现在 return 之后;
  3. Hooks 只能在函数组件或者自定义 Hooks 中使用。
1…567…26
YooHannah

YooHannah

260 日志
1 分类
23 标签
RSS
© 2025 YooHannah
由 Hexo 强力驱动
主题 - NexT.Pisces