@modern-js/main-doc 0.0.0-next-20230104054903 → 0.0.0-next-20230104130820

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ ---
2
+ title: 添加 Loader
3
+ ---
4
+
5
+ 上一章节中,我们学习了如何添加客户端路由。
6
+
7
+ 这一章节中,我们将会学习如何为**路由组件添加 Loader**。
8
+
9
+ 到目前为止,我们都是通过硬编码的方式,为组件提供数据。如果要从远端获取数据,通常情况下会使用 `useEffect` 来做。但在启用 SSR 的情况下,`useEffect` 是不会在服务端执行的,所以这种 SSR 只能渲染很有限的 UI。
10
+
11
+ Modern.js 为提供了 Data Loader 的能力,支持同构的在组件中获取数据,让 SSR 的价值最大化。
12
+
13
+ 下面我们演示如何为路由组件添加 Data Loader,并模拟远端数据获取。我们使用 faker 来 mock 需要的数据,首先安装依赖:
14
+
15
+ ```bash
16
+ pnpm add faker@5
17
+ pnpm add @types/faker@5 -D
18
+ ```
19
+
20
+ 修改 `src/routes/page.tsx`:
21
+
22
+ ```tsx
23
+ import { name, internet } from 'faker';
24
+
25
+ type LoaderData = {
26
+ code: number;
27
+ data: {
28
+ name: string;
29
+ avatar: string;
30
+ email: string;
31
+ }[];
32
+ };
33
+
34
+ export const loader = async (): Promise<LoaderData> => {
35
+ const data = new Array(20).fill(0).map(() => {
36
+ const firstName = name.firstName();
37
+ return {
38
+ name: firstName,
39
+ avatar: `https://avatars.dicebear.com/api/identicon/${firstName}.svg`,
40
+ email: internet.email(),
41
+ };
42
+ });
43
+
44
+ return {
45
+ code: 200,
46
+ data,
47
+ };
48
+ };
49
+ ```
50
+
51
+ :::note
52
+ Data Loader 并非只为 SSR 工作。在 CSR 项目中,Data Loader 也可以避免数据获取依赖 UI 渲染,解决请求瀑布流的问题。未来,Modern.js 也会为这一特性添加更多能力,例如预获取、数据缓存等。
53
+ :::
54
+
55
+ Modern.js 也提供了一个叫 `useLoaderData` 的 hooks API,我们修改 `src/routes/page.tsx` 导出的组件:
56
+
57
+ ```tsx {1,4,13}
58
+ import { useLoaderData } from '@modern-js/runtime/router';
59
+
60
+ function Index() {
61
+ const { data } = useLoaderData() as LoaderData;
62
+
63
+ return (
64
+ <div className="container lg mx-auto">
65
+ <Helmet>
66
+ <title>All</title>
67
+ </Helmet>
68
+ <List
69
+ dataSource={data}
70
+ renderItem={(info) => <Item key={info.name} info={info} />}
71
+ />
72
+ </div>
73
+ );
74
+ }
75
+
76
+ export default Index;
77
+ ```
78
+
79
+ <!-- Todo 重新截图,SSR 内容 -->
80
+ 重新执行 `pnpm run dev`,查看 `view-source:http://localhost:8080/`,或在 devtools 的 Network 面板里查看 HTML 请求的「 Preview 」,可以看到 SSR 渲染出来的 HTML 已经包含完整的 UI:
81
+
82
+ ![display6](https://lf3-static.bytednsdoc.com/obj/eden-cn/aphqeh7uhohpquloj/modern-js/docs/11/display6.png)
@@ -0,0 +1,260 @@
1
+ ---
2
+ title: 添加业务模型(状态管理)
3
+ ---
4
+
5
+ 上一章节中,我们把硬编码的 `mockData` 改成从 Data Loader 中加载。
6
+
7
+ 这一章节中,我们会进一步实现项目的功能,例如实现 **Archive** 按钮的功能,把联系人归档。
8
+
9
+ 因此会开始编写一些跟 UI 完全无关的业务逻辑,如果继续写在组件代码中,会产生越来越多的面条式代码。为此,我们引入了一种叫做 **业务模型(Model)** 的代码模块,将这些业务逻辑和 UI 解耦。
10
+
11
+ :::info 注
12
+ 使用 Model API,需要先设置 [runtime.state](/docs/configure/app/runtime/state) 以启用状态管理插件。
13
+ :::
14
+
15
+ ## 实现 Model
16
+
17
+ 创建一个完整的 Model 首先需要定义**状态(state)**,包括状态中数据的名称和初始值。
18
+
19
+ 我们使用 Model 来管理联系人列表的数据,因此定义如下数据状态:
20
+
21
+ ```js
22
+ const state = {
23
+ items: [],
24
+ };
25
+ ```
26
+
27
+ 使用 TS 语法,可以定义更完整的类型信息,比如 items 里每个对象都应该有 `name`、`email` 字段。为了实现归档功能,还需要创建 `archived` 字段保存这个联系人是否已被归档的状态。
28
+
29
+ 我们还需要一个字段用来访问所有已归档的联系人,可以定义 **computed** 类型的字段,对已有的数据做转换:
30
+
31
+ ```js
32
+ const computed = {
33
+ archived: ({ items }) => {
34
+ return items.filter(item => item.archived);
35
+ },
36
+ };
37
+ ```
38
+
39
+ `computed` 类型字段的定义方式是函数,但使用时可以像普通字段一样通过 state 访问。
40
+
41
+ :::info
42
+ Modern.js 集成了 [Immer](https://immerjs.github.io/immer/),能够像操作 JS 中常规的可变数据一样,去写这种状态转移的逻辑。
43
+ :::
44
+
45
+ 实现 Archive 按钮时,我们需要一个 `archive` 函数,负责修改指定联系人的 `archived` 字段,我们把这种函数都叫作 **action**:
46
+
47
+ ```js
48
+ const actions = {
49
+ archive(draft, payload) {
50
+ const target = draft.items.find(item => item.email === payload);
51
+ if (target) {
52
+ target.archived = true;
53
+ }
54
+ },
55
+ };
56
+ ```
57
+
58
+ action 函数是一种**纯函数**,确定的输入得到确定的输出(转移后的状态),不应该有任何副作用。
59
+
60
+ 函数的第一个参数是 Immer 提供的 Draft State,第二个参数是 action 被调用时传入的参数(后面会介绍怎么调用)。
61
+
62
+ 我们尝试完整实现它们:
63
+
64
+ ```js
65
+ const state = {
66
+ items: [],
67
+ pending: false,
68
+ error: null,
69
+ };
70
+
71
+ const computed = {
72
+ archived: ({ items }) => {
73
+ return items.filter(item => item.archived);
74
+ },
75
+ };
76
+
77
+ const actions = {
78
+ archive(draft, payload) {
79
+ const target = draft.items.find(item => item.email === payload);
80
+ if (target) {
81
+ target.archived = true;
82
+ }
83
+ },
84
+ };
85
+ ```
86
+
87
+ 接下来我们把上面的代码连起来,放在同一个 Model 文件里。首先执行以下命令,创建新的文件目录:
88
+
89
+ ```bash
90
+ mkdir -p src/models/
91
+ touch src/models/contacts.ts
92
+ ```
93
+
94
+ 添加 `src/models/contacts.ts` 的内容:
95
+
96
+ ```tsx
97
+ import { model } from '@modern-js/runtime/model';
98
+
99
+ type State = {
100
+ items: {
101
+ avatar: string;
102
+ name: string;
103
+ email: string;
104
+ archived?: boolean;
105
+ }[];
106
+ pending: boolean;
107
+ error: null | Error;
108
+ };
109
+
110
+ export default model<State>('contacts').define({
111
+ state: {
112
+ items: [],
113
+ pending: false,
114
+ error: null,
115
+ },
116
+ computed: {
117
+ archived: ({ items }: State) => items.filter(item => item.archived),
118
+ },
119
+ actions: {
120
+ archive(draft, payload) {
121
+ const target = draft.items.find(item => item.email === payload)!;
122
+ if (target) {
123
+ target.archived = true;
124
+ }
125
+ },
126
+ },
127
+ });
128
+ ```
129
+
130
+ 我们把一个包含 state,action 等要素的 plain object 称作 **Model Spec**,Modern.js 提供了 [Model API](/docs/apis/app/runtime/model/model_),可以根据 Model Spec 生成 **Model**。
131
+
132
+ ## 使用 Model
133
+
134
+ 现在我们直接使用这个 Model,把项目的逻辑补充起来。
135
+
136
+ 首先修改 `src/components/Item/index.tsx`,添加 **Archive 按钮**的 UI 和交互,内容如下:
137
+
138
+ ```tsx
139
+ import Avatar from '../Avatar';
140
+
141
+ type InfoProps = {
142
+ avatar: string;
143
+ name: string;
144
+ email: string;
145
+ archived?: boolean;
146
+ };
147
+
148
+ const Item = ({
149
+ info,
150
+ onArchive,
151
+ }: {
152
+ info: InfoProps;
153
+ onArchive?: () => void;
154
+ }) => {
155
+ const { avatar, name, email, archived } = info;
156
+ return (
157
+ <div className="flex p-4 items-center border-gray-200 border-b">
158
+ <Avatar src={avatar} />
159
+ <div className="ml-4 custom-text-gray flex-1 flex justify-between">
160
+ <div className="flex-1">
161
+ <p>{name}</p>
162
+ <p>{email}</p>
163
+ </div>
164
+ <button
165
+ type="button"
166
+ disabled={archived}
167
+ onClick={onArchive}
168
+ className={`text-white font-bold py-2 px-4 rounded-full ${
169
+ archived
170
+ ? 'bg-gray-400 cursor-default'
171
+ : 'bg-blue-500 hover:bg-blue-700'
172
+ }`}
173
+ >
174
+ {archived ? 'Archived' : 'Archive'}
175
+ </button>
176
+ </div>
177
+ </div>
178
+ );
179
+ };
180
+
181
+ export default Item;
182
+ ```
183
+
184
+ 接下来,我们修改 `src/routes/page.tsx`,为 `<Item>` 组件传递更多参数:
185
+
186
+ ```tsx
187
+ import { Helmet } from '@modern-js/runtime/head';
188
+ import { useModel } from '@modern-js/runtime/model';
189
+ import { useLoaderData } from '@modern-js/runtime/router';
190
+ import { List } from 'antd';
191
+ import { name, internet } from 'faker';
192
+ import Item from '../components/Item';
193
+ import contacts from '../models/contacts';
194
+
195
+ type LoaderData = {
196
+ code: number;
197
+ data: {
198
+ name: string;
199
+ avatar: string;
200
+ email: string;
201
+ }[];
202
+ };
203
+
204
+ export const loader = async (): Promise<LoaderData> => {
205
+ const data = new Array(20).fill(0).map(() => {
206
+ const firstName = name.firstName();
207
+ return {
208
+ name: firstName,
209
+ avatar: `https://avatars.dicebear.com/api/identicon/${firstName}.svg`,
210
+ email: internet.email(),
211
+ archived: false,
212
+ };
213
+ });
214
+
215
+ return {
216
+ code: 200,
217
+ data,
218
+ };
219
+ };
220
+
221
+ function Index() {
222
+ const { data } = useLoaderData() as LoaderData;
223
+ const [{ items }, { archive, setItems }] = useModel(contacts);
224
+ if (items.length === 0) {
225
+ setItems(data);
226
+ }
227
+
228
+ return (
229
+ <div className="container lg mx-auto">
230
+ <Helmet>
231
+ <title>All</title>
232
+ </Helmet>
233
+ <List
234
+ dataSource={items}
235
+ renderItem={info => (
236
+ <Item
237
+ key={info.name}
238
+ info={info}
239
+ onArchive={() => {
240
+ archive(info.email);
241
+ }}
242
+ />
243
+ )}
244
+ />
245
+ </div>
246
+ );
247
+ }
248
+
249
+ export default Index;
250
+ ```
251
+
252
+ `useModel` 是 Modern.js 提供的 hooks API。可以在组件中提供 Model 中定义的 state,或通过 actions 调用 Model 中定义的 side effect 与 action,从而改变 Model 的 state。
253
+
254
+ Model 是业务逻辑,是计算过程,本身不创建也不持有状态。只有在被组件用 hooks API 使用后,才在指定的地方创建状态。
255
+
256
+ 执行 `pnpm run dev`,点击 **Archive 按钮**,可以看到页面 UI 发生了变化。
257
+
258
+ :::note
259
+ 上述例子中,`useLoaderData` 其实在每次切换路由时都会执行。因为我们在 Data Loader 里使用了 fake 数据,每次返回的数据是不同的。但我们优先使用了 Model 中的数据,因此切换路由时数据没有发生改变。
260
+ :::
@@ -0,0 +1,283 @@
1
+ ---
2
+ title: 添加容器组件
3
+ ---
4
+
5
+ import Tabs from '@theme/Tabs';
6
+ import TabItem from '@theme/TabItem';
7
+
8
+ 上一章节中,我们初步引入**业务模型**,从 UI 组件中拆分出这部分逻辑。`page.tsx` 中不再包含 UI 无关的业务逻辑实现细节,只需要使用 Model,就能实现同样的功能。
9
+
10
+ 这一章节中,我们要进一步利用 Model 中实现的业务逻辑,让 `page.tsx` 和 `archived/page.tsx` 获取同一份数据。并实现 Archive 按钮,点击按钮能把联系人归档,只显示在 Archives 列表里,不显示在 All 列表里。
11
+
12
+ ## 使用完整 Model
13
+
14
+ 因为两个页面需要共用同一套状态(联系人列表数据、联系人是否被归档),都需要包含加载初始数据的逻辑,因此我们需要在更上一层完成数据获取。
15
+
16
+ Modern.js 支持在 `layout.tsx` 通过 Data Loader 获取数据,我们先数据获取这部分代码移动到 `src/routes/layout.tsx` 中:
17
+
18
+ ```tsx
19
+ import { name, internet } from 'faker';
20
+ import {
21
+ Outlet,
22
+ useLoaderData,
23
+ useLocation,
24
+ useNavigate,
25
+ } from '@modern-js/runtime/router';
26
+ import { useState } from 'react';
27
+ import { Radio, RadioChangeEvent } from 'antd';
28
+ import { useModel } from '@modern-js/runtime/model';
29
+ import contacts from '../models/contacts';
30
+ import 'tailwindcss/base.css';
31
+ import 'tailwindcss/components.css';
32
+ import 'tailwindcss/utilities.css';
33
+ import '../styles/utils.css';
34
+
35
+ type LoaderData = {
36
+ code: number;
37
+ data: {
38
+ name: string;
39
+ avatar: string;
40
+ email: string;
41
+ }[];
42
+ };
43
+
44
+ export const loader = async (): Promise<LoaderData> => {
45
+ const data = new Array(20).fill(0).map(() => {
46
+ const firstName = name.firstName();
47
+ return {
48
+ name: firstName,
49
+ avatar: `https://avatars.dicebear.com/api/identicon/${firstName}.svg`,
50
+ email: internet.email(),
51
+ };
52
+ });
53
+
54
+ return {
55
+ code: 200,
56
+ data,
57
+ };
58
+ };
59
+
60
+ export default function Layout() {
61
+ const { data } = useLoaderData() as LoaderData;
62
+ const [{ items }, { setItems }] = useModel(contacts);
63
+ if (items.length === 0) {
64
+ setItems(data);
65
+ }
66
+
67
+ const navigate = useNavigate();
68
+ ...
69
+ }
70
+ ```
71
+
72
+ 在 `src/routes/page.tsx` 中,直接使用 Model,获取数据:
73
+
74
+ ```tsx
75
+ import { Helmet } from '@modern-js/runtime/head';
76
+ import { useModel } from '@modern-js/runtime/model';
77
+ import { List } from 'antd';
78
+ import Item from '../components/Item';
79
+ import contacts from '../models/contacts';
80
+
81
+ function Index() {
82
+ const [{ items }, { archive }] = useModel(contacts);
83
+
84
+ return (
85
+ <div className="container lg mx-auto">
86
+ <Helmet>
87
+ <title>All</title>
88
+ </Helmet>
89
+ <List
90
+ dataSource={items}
91
+ renderItem={info => (
92
+ <Item
93
+ key={info.name}
94
+ info={info}
95
+ onArchive={() => {
96
+ archive(info.email);
97
+ }}
98
+ />
99
+ )}
100
+ />
101
+ </div>
102
+ );
103
+ }
104
+
105
+ export default Index;
106
+ ```
107
+
108
+ 同样在 `archived/page.tsx` 中,删除原本的 `mockData` 逻辑,使用 Model 中 computed 的 `archived` 值作为数据源:
109
+
110
+ ```tsx
111
+ import { Helmet } from '@modern-js/runtime/head';
112
+ import { useModel } from '@modern-js/runtime/model';
113
+ import { List } from 'antd';
114
+ import Item from '../../components/Item';
115
+ import contacts from '../../models/contacts';
116
+
117
+ function Index() {
118
+ const [{ archived }, { archive }] = useModel(contacts);
119
+
120
+ return (
121
+ <div className="container lg mx-auto">
122
+ <Helmet>
123
+ <title>Archives</title>
124
+ </Helmet>
125
+ <List
126
+ dataSource={archived}
127
+ renderItem={info => (
128
+ <Item
129
+ key={info.name}
130
+ info={info}
131
+ onArchive={() => {
132
+ archive(info.email);
133
+ }}
134
+ />
135
+ )}
136
+ />
137
+ </div>
138
+ );
139
+ }
140
+
141
+ export default Index;
142
+ ```
143
+
144
+ 执行 `pnpm run dev`,访问 `http://localhost:8080/`,点击 Archive 按钮后,可以看到按钮置灰:
145
+
146
+ ![display](https://lf3-static.bytednsdoc.com/obj/eden-cn/nuvjhpqnuvr/modern-website/tutorials/c07-contacts-all.png)
147
+
148
+ 接下来点击顶部导航,切换到 Archives 列表,可以发现刚才 **Archive** 的联系人已经出现在列表当中:
149
+
150
+ ![display](https://lf3-static.bytednsdoc.com/obj/eden-cn/nuvjhpqnuvr/modern-website/tutorials/c07-contacts-archives.png)
151
+
152
+ ## 抽离容器组件
153
+
154
+ 前面章节中,我们把项目中的业务逻辑拆分成了两个 layer,一个是**视图组件**,另一个是**业务模块**。前者负责 UI 展示、交互等,后者负责实现 UI 无关的业务逻辑,专门管理状态。
155
+
156
+ 像 `src/routes/page.tsx` 和 `src/routes/archives/page.tsx` 这样使用了 `useModel` API 的组件,负责把 View 和 Model 这两个 layer 连接起来,类似传统 MVC 架构中 Controller 的角色,在 Modern.js 里我们沿用习惯,把它们称作**容器组件(Container)**。
157
+
158
+ 容器组件推荐放在专门的 `containers/` 目录里,我们执行以下命令,创建新的文件:
159
+
160
+ <Tabs>
161
+ <TabItem value="macOS" label="macOS" default>
162
+
163
+ ```bash
164
+ mkdir -p src/containers
165
+ touch src/containers/Contacts.tsx
166
+ ```
167
+
168
+ </TabItem>
169
+ <TabItem value="Windows" label="Windows">
170
+
171
+ ```powershell
172
+ mkdir -p src/containers
173
+ ni src/containers/Contacts.tsx
174
+ ```
175
+
176
+ </TabItem>
177
+ </Tabs>
178
+
179
+ 我们将原本两个 `page.tsx` 中公共的部分抽离出来,`src/containers/Contacts.tsx` 的代码如下:
180
+
181
+ ```tsx
182
+ import { Helmet } from "@modern-js/runtime/head";
183
+ import { useModel } from "@modern-js/runtime/model";
184
+ import { List } from "antd";
185
+ import Item from "../components/Item";
186
+ import { Helmet } from '@modern-js/runtime/head';
187
+ import { useModel } from '@modern-js/runtime/model';
188
+ import { List } from 'antd';
189
+ import Item from '../components/Item';
190
+ import contacts from '../models/contacts';
191
+
192
+ function Contacts({
193
+ title,
194
+ source,
195
+ }: {
196
+ title: string;
197
+ source: 'items' | 'archived';
198
+ }) {
199
+ const [state, { archive }] = useModel(contacts);
200
+
201
+ return (
202
+ <div className="container lg mx-auto">
203
+ <Helmet>
204
+ <title>{title}</title>
205
+ </Helmet>
206
+ <List
207
+ dataSource={state[source]}
208
+ renderItem={info => (
209
+ <Item
210
+ key={info.name}
211
+ info={info}
212
+ onArchive={() => {
213
+ archive(info.email);
214
+ }}
215
+ />
216
+ )}
217
+ />
218
+ </div>
219
+ );
220
+ }
221
+
222
+ export default Contacts;
223
+ ```
224
+
225
+ 修改 `src/routes/page.tsx` 和 `src/routes/archives/page.tsx` 的代码:
226
+
227
+ ```tsx title="src/routes/page.tsx"
228
+ import Contacts from '../containers/Contacts';
229
+
230
+ function Index() {
231
+ return <Contacts title="All" source="items" />;
232
+ }
233
+
234
+ export default Index;
235
+ ```
236
+
237
+ ```tsx title="src/routes/archives/page.tsx"
238
+ import Contacts from '../../containers/Contacts';
239
+
240
+ function Index() {
241
+ return <Contacts title="Archives" source="archived" />;
242
+ }
243
+
244
+ export default Index;
245
+ ```
246
+
247
+ 重构完成,现在的项目结构是:
248
+
249
+ ```md
250
+ .
251
+ ├── README.md
252
+ ├── dist
253
+ ├── modern.config.ts
254
+ ├── node_modules
255
+ ├── package.json
256
+ ├── pnpm-lock.yaml
257
+ ├── src
258
+ │   ├── components
259
+ │   │   ├── Avatar
260
+ │   │   │   └── index.tsx
261
+ │   │   └── Item
262
+ │   │   └── index.tsx
263
+ │   ├── containers
264
+ │   │   └── Contacts.tsx
265
+ │   ├── models
266
+ │   │   └── contacts.ts
267
+ │   ├── modern-app-env.d.ts
268
+ │   ├── routes
269
+ │   │   ├── archives
270
+ │   │   │   └── page.tsx
271
+ │   │   ├── layout.tsx
272
+ │   │   └── page.tsx
273
+ │   └── styles
274
+ │   └── utils.css
275
+ └── tsconfig.json
276
+ ```
277
+
278
+ `components/` 里的**视图组件**,都是目录形式,如 `Avatar/index.tsx`。而 `containers/` 里的**容器组件**,都是单文件形式,如 `contacts.tsx`。**这是我们推荐的一种最佳实践**。
279
+
280
+ 在​ [添加 UI 组件(Component)](./c02-component.md) 章节提到过,视图组件用目录形式,是因为视图组件负责实现 UI 展示和交互细节,可以演变的复杂。用目录形式,可以方便增加子文件,包括专用的资源(图片等)、专用子组件、CSS 文件等。在这个目录内部可以随意重构,只考虑最小局部。
281
+
282
+ 而容器组件只负责连接,是一个胶水层,复杂的业务逻辑和实现细节都交给 View 层和 Model 层去实现。容器组件自己应该保持简单清晰,不应该包含复杂实现细节,所以不应该有内部结构。采用单文件形式不但更简洁,也能起到约束作用,提醒开发者不要把容器组件写复杂。
283
+