My Little World

learn and share


  • 首页

  • 分类

  • 标签

  • 归档

  • 关于
My Little World

重新认识路由

发表于 2021-11-28

路由管理,就是让你的页面能够根据 URL 的变化进行页面的切换,这是前端应用中一个非常重要的机制

路由的核心逻辑就是根据 URL 路径这个状态,来决定在主内容区域显示什么组件。

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
// 自定义路由
const MyRouter = ({ children }) => {
const routes = _.keyBy(
children.map((c) => c.props),
"path",
);
const [hash] = useHash();
const Page = routes[hash.replace("#", "")]?.component;
// 如果路由不存在就返回 Not found.
return Page ? <Page /> : "Not found.";
};


const Route = () => null;

// 应用
function SamplePages {
return (
<div className="sample-pages">
{/* 定义了侧边导航栏 */}
<div className="sider">
<a href="#page1">Page 1</a>
<a href="#page2">Page 2</a>
<a href="#page3">Page 3</a>
<a href="#page4">Page 4</a>
</div>
<div className="exp-15-page-container">
{/* 定义路由配置 */}
<MyRouter>
<Route path="page1" component={Page1} />
<Route path="page2" component={Page2} />
<Route path="page3" component={Page3} />
<Route path="page4" component={Page4} />
</MyRouter>
</div>
</>
);
};

使用React Router

简单应用

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
// 从 react-router-dom 引入路由框架提供的一些组件
import { BrowserRouter, Switch, Route, Link } from "react-router-dom";
// 引入了两个课程示例页面
import Counter from "./01/Counter";
import UserList from "./01/UserList";


// 使用数组定义了页面组件和导航的标题,从而方便下面的渲染逻辑
const routes = [
["01 Counter", Counter],
["01 UserList", UserList],
];
function App() {
return (
<BrowserRouter>
<div className="app">
<ul className="sider">
{routes.map(([label]) => (
<li>
<Link to={`/${label.replace(" ", "/")}`}>{label}</Link>
</li>
))}
</ul>
<div id="pageContainer" className="page-container">
<Switch>
{routes.map(([label, Component]) => (
<Route key={label} path={`/${label.replace(" ", "/")}`}>
<Component />
</Route>
))}
{/* 定义一个默认的路由 */}
<Route path="/" exact>
<h1>Welcome!</h1>
</Route>
<Route path="*">Page not found.</Route>
</Switch>
</div>
</div>
</BrowserRouter>
);
}

相关组件作用

BrowserRouter:标识用标准的 URL 路径去管理路由,比如 /my-page1 这样的标准 URL 路径。
除此之外,还有 MemoryRouter,表示通过内存管理路由;
HashRouter,标识通过 hash 管理路由。
自己实现的例子其实就是用的 hash 来实现路由。

Link:定义一个导航链接,点击时可以无刷新地改变页面 URL,从而实现 React Router 控制的导航。

Route: 定义一条路由规则,可以指定匹配的路径、要渲染的内容等等。

Switch:在默认情况下,所有匹配的 Route 节点都会被展示,但是 Switch 标记可以保证只有第一个匹配到的路由才会被渲染。

react router

使用嵌套路由:实现二级导航页面

需要路由框架具备两个能力:

能够模糊匹配。比如 /page1/general 、/page1/profile 这样两个路由,需要都能匹配到 Page1 这样一个组件。
然后 Page1 内部呢,再根据 general 和 profile 这两个子路由决定展示哪个具体的页面。

Route 能够嵌套使用。在我们自定义 Route 的例子中,Route 组件仅用于收集路由定义的信息,不渲染任何内容。
如果需要路由能嵌套使用,那就意味着需要在 Route 下还能嵌套使用 Route。而这在 React Router 是提供支持的。

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

import { BrowserRouter, Route, Link } from "react-router-dom";


const Page1 = () => {
return (
<div className="exp-15-page1">
<div className="exp-15-page1-header">
<Link to="/page1/general">General</Link>
<Link to="/page1/profile">Profile</Link>
<Link to="/page1/settings">Settings</Link>
</div>
<div className="exp-15-page1-content">
<Route path="/page1/general">General Page</Route>
<Route path="/page1/profile">Profile Page</Route>
<Route path="/page1/settings">Settings Page</Route>
</div>
</div>
);
};
const Page2 = () => "Page 2";
const Page3 = () => "Page 3";


function NestedRouting() {
return (
<BrowserRouter>
<h1>Nested Routing</h1>
<div className="exp-15-nested-routing">
<div className="exp-15-sider">
<Link to="/page1">Page 1</Link>
<Link to="/page2">Page 2</Link>
<Link to="/page3">Page 3</Link>
</div>
<div className="exp-15-page-container">
<Route path="/page1"><Page1 /></Route>
<Route path="/page2"><Page2 /></Route>
<Route path="/page3"><Page3 /></Route>
</div>
</div>
</BrowserRouter>
);
}

在 URL 中保存页面状态(页面参数)

在url携带页面相关信息,将页面的一些状态存放到 URL 中,
一方面可以提升用户体验,另一方面也可以简化页面之间的交互。

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

<Route path="/tabs-page/:activeTab" component={TabsPage} />

import { useCallback } from "react";
import { Tabs, Table } from "antd";
import { useHistory, useParams } from "react-router-dom";
import data from "../10/data";
import { useSearchParam } from "react-use";


const { TabPane } = Tabs;


export default () => {
// 通过 React Router 的 API 获取 activeTab 这个参数信息
const { activeTab = "users" } = useParams();
// 通过查询字符串获取当前的页码信息
const page = parseInt(useSearchParam("page"), 10) || 1;

// 通过 React Router 提供的 history 对象来操作 URL
const history = useHistory();
const handleTabChange = useCallback(
(tab) => history.push(`/15/TabsPage/${tab}`),
[history],
);
// 定义表格的翻页功能
const pagination = {
pageSize: 3,
current: page,
onChange: (p) => {
history.push(`/15/TabsPage/${activeTab}?page=${p}`);
},
};
return (
<div>
<h1>Tabs Page</h1>
<Tabs activeKey={activeTab} onChange={handleTabChange}>
<TabPane tab="Users" key="users">
<Table
dataSource={data}
columns={[
{ dataIndex: "name", title: "User Name" },
{ dataIndex: "city", title: "City" },
]}
pagination={pagination}
/>
</TabPane>
<TabPane tab="Jobs" key="jobs">
<Table
dataSource={data}
columns={[{ dataIndex: "job", title: "Job Title" }]}
pagination={pagination}
/>
</TabPane>
</Tabs>
</div>
);
}

注意

这里遵循了唯一数据源的原则,避免定义中间状态去存储 tab 和页码的信息,而是直接去操作 URL,这样可以让代码逻辑更加清晰和直观。

路由层面实现权限控制

利用前端路由的动态特性。路由是通过 JSX 以声明式的方式去定义的,这就意味着路由的定义规则是可以根据条件进行变化的,也就是所谓的动态路由。

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 { useState } from "react";
import { Button } from "antd";
import { Route, Link } from "react-router-dom";


// 定义了两个示例页面组件
const Page1 = () => "Page 1";
const Page2 = () => "Page 2";


// 定义了一个组件用户展示未登录状态
const UnauthedPage = () => (
<span style={{ color: "red" }}>Unauthorized, please log in first.</span>
);
export default () => {
// 模拟用户是否登录的状态,通过一个按钮进行切换
const [loggedIn, setLoggedIn] = useState(false);

// 定义了两套路由,一套用于登录后,一套用于未登录状态
const routes = loggedIn
? [
{
path: "/15/RouterAuth",
component: Page1,
},
{
path: "/15/RouterAuth/page1",
component: Page1,
},
{
path: "/15/RouterAuth/page2",
component: Page2,
},
]
// 如果未登录,那么对于所有 /15/RouterAuth 开头的路径,显示未授权页面
: [{ path: "/15/RouterAuth", component: UnauthedPage }];


return (
<div>
<h1>Router Auth</h1>
<Button
type={loggedIn ? "primary" : ""}
onClick={() => setLoggedIn((v) => !v)}
>
{loggedIn ? "Log Out" : "Log In"}
</Button>


<div className="exp-15-router-auth">
<div className="exp-15-sider">
<Link to="/15/RouterAuth/page1">Page 1</Link>
<Link to="/15/RouterAuth/page2">Page 2</Link>
</div>
<div className="exp-15-page-container">
{/* */}
{routes.map((r) => (
<Route path={r.path} component={r.component} />
))}
</div>
</div>
</div>
);

代码中核心的机制就在于我们根据登录状态,创建了不同的路由规则,这样就能在源头上对权限进行集中控制,避免用户未经授权就访问某些受保护的页面。

同时呢,因为在相同的 URL 下进行了信息提示,那么也就更容易实现用户登录后还能返回原页面的功能。

My Little World

Hooks 在forms上的使用

发表于 2021-11-27

应用思想就是在这个 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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// 包含验证功能validators 的hooks, 处理表单状态管理
const useForm = (initialValues = {}, validators) => {
const [values, setValues] = useState(initialValues);
// 定义了 errors 状态
const [errors, setErrors] = useState({});

const setFieldValue = useCallback(
(name, value) => {
setValues((values) => ({
...values,
[name]: value,
}));

// 如果存在验证函数,则调用验证用户输入
if (validators[name]) {
const errMsg = validators[name](value);
setErrors((errors) => ({
...errors,
// 如果返回错误信息,则将其设置到 errors 状态,否则清空错误状态
[name]: errMsg || null,
}));
}
},
[validators],
);
// 将 errors 状态也返回给调用者
return { values, errors, setFieldValue };
};

// 应用

import { useCallback } from "react";
import useForm from './useForm';

export default () => {
const validators = useMemo(() => {
return {
name: (value) => {
// 要求 name 的长度不得小于 2
if (value.length < 2) return "Name length should be no less than 2.";
return null;
},
email: (value) => {
// 简单的实现一个 email 验证逻辑:必须包含 @ 符号。
if (!value.includes("@")) return "Invalid email address";
return null;
},
};
}, []);
// 使用 useForm 得到表单的状态管理逻辑
const { values, errors, setFieldValue } = useForm({}, validators);
// 处理表单的提交事件
const handleSubmit = useCallback(
(evt) => {
// 使用 preventDefault() 防止页面被刷新
evt.preventDefault();
console.log(values);
},
[values],
);
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name: </label>
<input
value={values.name || null}
onChange={(evt) => setFieldValue("name", evt.target.value)}
/>
</div>

<div>
<label>Email:</label>
<input
value={values.email || null}
onChange={(evt) => setFieldValue("email", evt.target.value)}
/>
</div>
<button type="submit">Submit</button>
</form>
);
};

把表单的状态管理单独提取出来,成为一个可重用的 Hook。
这样在表单的实现组件中,就只需要更多地去关心 UI 的渲染,而无需关心状态是如何存储和管理的,从而方便表单组件的开发。

Form 最为核心的机制就是我们将表单元素的所有状态提取出来,
这样表单就可以分为状态逻辑和 UI 展现逻辑,从而实现数据层和表现层的分离。

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 只在组件销毁时才会执行。

1…567…27
YooHannah

YooHannah

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