# Reddit API
这些 XState v4 文档不再维护
XState v5 现已推出!阅读有关 XState v5 的更多信息 (opens new window) 和 查看 XState v5 文档 (opens new window)。
¥XState v5 is out now! Read more about XState v5 (opens new window) and check out the XState v5 docs (opens new window).
改编自 Redux 文档:高级教程 (opens new window)
¥Adapted from the Redux Docs: Advanced Tutorial (opens new window)
假设我们想要创建一个应用来显示选定的 Reddit 子版块的帖子。该应用应该能够:
¥Suppose we wanted to create an app that displays a selected subreddit's posts. The app should be able to:
有一个预定义的 subreddits 列表,用户可以从中选择
¥Have a predefined list of subreddits that the user can select from
加载选定的子 reddit
¥Load the selected subreddit
显示上次加载所选 subreddit 的时间
¥Display the last time the selected subreddit was loaded
重新加载选定的子 reddit
¥Reload the selected subreddit
随时选择不同的 Reddit 子版块
¥Select a different subreddit at any time
应用逻辑和状态可以使用单个应用级机器以及调用的子机器来建模,以对每个单独的 subreddit 的逻辑进行建模。现在,我们从一台机器开始。
¥The app logic and state can be modeled with a single app-level machine, as well as invoked child machines for modeling the logic of each individual subreddit. For now, let's start with a single machine.
# 为应用建模
¥Modeling the App
我们正在创建的 Reddit 应用可以使用两个顶层状态进行建模:
¥The Reddit app we're creating can be modeled with two top-level states:
'idle'
- 尚未选择 subreddit(初始状态)¥
'idle'
- no subreddit selected yet (the initial state)'selected'
- 选择了 Reddit 子版块¥
'selected'
- a subreddit is selected
import { createMachine, assign } from 'xstate';
const redditMachine = createMachine({
id: 'reddit',
initial: 'idle',
states: {
idle: {},
selected: {}
}
});
我们还需要某个地方来存储选定的 subreddit
,所以让我们将其放入 context
:
¥We also need somewhere to store the selected subreddit
, so let's put that in context
:
// ...
const redditMachine = createMachine({
id: 'reddit',
initial: 'idle',
context: {
subreddit: null // none selected
},
states: {
/* ... */
}
});
由于可以随时选择 subreddit,因此我们可以为 'SELECT'
事件创建顶层转换,这表示用户选择了 subreddit。此事件将有一个有效负载,其中包含 .name
中选定的 subreddit 名称:
¥Since a subreddit can be selected at any time, we can create a top-level transition for a 'SELECT'
event, which signals that a subreddit was selected by the user. This event will have a payload that has the selected subreddit name in .name
:
// sample SELECT event
const selectEvent = {
type: 'SELECT', // event type
name: 'reactjs' // subreddit name
};
该事件将在顶层处理,因此每当 'SELECT'
事件发生时,机器将:
¥This event will be handled at the top-level, so that whenever the 'SELECT'
event occurs, the machine will:
transition 到其子
'.selected'
状态(注意点,它表示 相对目标)¥transition to its child
'.selected'
state (notice the dot, which indicates a relative target)assign
event.name
至context.subreddit
¥assign
event.name
to thecontext.subreddit
const redditMachine = createMachine({
id: 'reddit',
initial: 'idle',
context: {
subreddit: null // none selected
},
states: {
/* ... */
},
on: {
SELECT: {
target: '.selected',
actions: assign({
subreddit: (context, event) => event.name
})
}
}
});
# 异步流程
¥Async Flow
当选择一个 subreddit 时(即,当机器由于 'SELECT'
事件而处于 'selected'
状态时),机器应该开始加载 subreddit 数据。为此,我们 调用 Promise 将使用选定的 subreddit 数据进行解析:
¥When a subreddit is selected (that is, when the machine is in the 'selected'
state due to a 'SELECT'
event), the machine should start loading the subreddit data. To do this, we invoke a Promise that will resolve with the selected subreddit data:
function invokeFetchSubreddit(context) {
const { subreddit } = context;
return fetch(`https://www.reddit.com/r/${subreddit}.json`)
.then((response) => response.json())
.then((json) => json.data.children.map((child) => child.data));
}
const redditMachine = createMachine({
/* ... */
states: {
idle: {},
selected: {
invoke: {
id: 'fetch-subreddit',
src: invokeFetchSubreddit
}
}
},
on: {
/* ... */
}
});
Why specify the invoke ID?
在 invoke
配置对象上指定 id
可以实现更清晰的调试和可视化,并且能够通过 id
将事件直接发送到调用的实体。
¥Specifying an id
on the invoke
config object allows clearer debugging and visualization, as well as the ability to send events directly to an invoked entity by its id
.
当进入 'selected'
状态时,invokeFetchSubreddit(...)
将使用当前的 context
和 event
(此处未使用)调用,并开始从 Reddit API 获取 subreddit 数据。然后,promise 可以进行两个特殊的转换:
¥When the 'selected'
state is entered, invokeFetchSubreddit(...)
will be called with the current context
and event
(not used here) and start fetching subreddit data from the Reddit API. The promise can then take two special transitions:
onDone
- 当调用的 Promise 解析时执行¥
onDone
- taken when the invoked promise resolvesonError
- 当调用的 Promise 拒绝时采取¥
onError
- taken when the invoked promise rejects
这就是 嵌套(分层)状态 很有帮助的地方。我们可以创建 3 个子状态来表示 subreddit 为 'loading'
、'loaded'
或 'failed'
(选择适合你的用例的名称):
¥This is where it's helpful to have nested (hierarchical) states. We can make 3 child states that represent when the subreddit is 'loading'
, 'loaded'
or 'failed'
(pick names appropriate to your use-cases):
const redditMachine = createMachine({
/* ... */
states: {
idle: {},
selected: {
initial: 'loading',
states: {
loading: {
invoke: {
id: 'fetch-subreddit',
src: invokeFetchSubreddit,
onDone: 'loaded',
onError: 'failed'
}
},
loaded: {},
failed: {}
}
}
},
on: {
/* ... */
}
});
请注意我们如何将 invoke
配置移至 'loading'
状态。这很有用,因为如果我们想要将来更改应用逻辑以具有某种 'paused'
或 'canceled'
子状态,则调用的 Promise 将自动为 "canceled",因为它不再处于调用它的 'loading'
状态。
¥Notice how we moved the invoke
config to the 'loading'
state. This is useful because if we want to change the app logic in the future to have some sort of 'paused'
or 'canceled'
child state, the invoked promise will automatically be "canceled" since it's no longer in the 'loading'
state where it was invoked.
当 Promise 解决时,一个特殊的 'xstate.done.actor.<invoke ID>'
事件将被发送到机器,其中包含已解决的数据 event.data
。为了方便起见,XState 将 invoke
对象中的 onDone
属性映射到此特殊事件。你可以将解析后的数据分配给 context.posts
:
¥When the promise resolves, a special 'xstate.done.actor.<invoke ID>'
event will be sent to the machine, containing the resolved data as event.data
. For convenience, XState maps the onDone
property within the invoke
object to this special event. You can assign the resolved data to context.posts
:
const redditMachine = createMachine({
/* ... */
context: {
subreddit: null,
posts: null
},
states: {
idle: {},
selected: {
initial: 'loading',
states: {
loading: {
invoke: {
id: 'fetch-subreddit',
src: invokeFetchSubreddit,
onDone: {
target: 'loaded',
actions: assign({
posts: (context, event) => event.data
})
},
onError: 'failed'
}
},
loaded: {},
failed: {}
}
}
},
on: {
/* ... */
}
});
# 测试一下
¥Testing It Out
最好测试一下你的机器逻辑是否与你想要的应用逻辑相匹配。自信地测试应用逻辑的最直接方法是编写集成测试。你可以针对应用逻辑的真实或模拟实现进行测试(例如,使用真实服务、进行 API 调用等),你可以通过 interpret(...)
进行 在解释器中运行逻辑 并编写一个异步测试,该测试在状态机达到特定状态时完成:
¥It's a good idea to test that your machine's logic matches the app logic you intended. The most straightforward way to confidently test your app logic is by writing integration tests. You can test against a real or mock implementation of your app logic (e.g., using real services, making API calls, etc.), you can run the logic in an interpreter via interpret(...)
and write an async test that finishes when the state machine reaches a certain state:
import { interpret } from 'xstate';
import { assert } from 'chai';
import { redditMachine } from '../path/to/redditMachine';
describe('reddit machine (live)', () => {
it('should load posts of a selected subreddit', (done) => {
const redditService = interpret(redditMachine)
.onTransition((state) => {
// when the state finally reaches 'selected.loaded',
// the test has succeeded.
if (state.matches({ selected: 'loaded' })) {
assert.isNotEmpty(state.context.posts);
done();
}
})
.start(); // remember to start the service!
// Test that when the 'SELECT' event is sent, the machine eventually
// reaches the { selected: 'loaded' } state with posts
redditService.send({ type: 'SELECT', name: 'reactjs' });
});
});
# 实现用户界面
¥Implementing the UI
从这里开始,你的应用逻辑在 redditMachine
中是独立的,并且可以在任何前端框架中随意使用,例如 React、Vue、Angular、Svelte 等。
¥From here, your app logic is self-contained in the redditMachine
and can be used however you want, in any front-end framework, such as React, Vue, Angular, Svelte, etc.
下面是 在 React 中与 @xstate/react
一起使用 的示例:
¥Here's an example of how it would be used in React with @xstate/react
:
import React from 'react';
import { useMachine } from '@xstate/react';
import { redditMachine } from '../path/to/redditMachine';
const subreddits = ['frontend', 'reactjs', 'vuejs'];
const App = () => {
const [current, send] = useMachine(redditMachine);
const { subreddit, posts } = current.context;
return (
<main>
<header>
<select
onChange={(e) => {
send({ type: 'SELECT', name: e.target.value });
}}
>
{subreddits.map((subreddit) => {
return <option key={subreddit}>{subreddit}</option>;
})}
</select>
</header>
<section>
<h1>{current.matches('idle') ? 'Select a subreddit' : subreddit}</h1>
{current.matches({ selected: 'loading' }) && <div>Loading...</div>}
{current.matches({ selected: 'loaded' }) && (
<ul>
{posts.map((post) => (
<li key={post.title}>{post.title}</li>
))}
</ul>
)}
</section>
</main>
);
};
# 劈裂机
¥Splitting Machines
在所选的 UI 框架内,组件提供自然的隔离和逻辑封装。我们可以利用这一点来组织逻辑并制造更小、更易于管理的机器。
¥Within the chosen UI framework, components provide natural isolation and encapsulation of logic. We can take advantage of that to organize logic and make smaller, more manageable machines.
考虑两台机器:
¥Consider two machines:
redditMachine
,应用级机器,负责渲染选定的 subreddit 组件¥A
redditMachine
, which is the app-level machine, responsible for rendering the selected subreddit componentsubredditMachine
,它是负责加载和显示其指定 subreddit 的机器¥A
subredditMachine
, which is the machine responsible for loading and displaying its specified subreddit
const createSubredditMachine = (subreddit) => {
return createMachine({
id: 'subreddit',
initial: 'loading',
context: {
subreddit, // subreddit name passed in
posts: null,
lastUpdated: null
},
states: {
loading: {
invoke: {
id: 'fetch-subreddit',
src: invokeFetchSubreddit,
onDone: {
target: 'loaded',
actions: assign({
posts: (_, event) => event.data,
lastUpdated: () => Date.now()
})
},
onError: 'failure'
}
},
loaded: {
on: {
REFRESH: 'loading'
}
},
failure: {
on: {
RETRY: 'loading'
}
}
}
});
};
请注意原始 redditMachine
中的大量逻辑是如何移至 subredditMachine
的。这使我们能够将逻辑与其特定域隔离并使 redditMachine
更加通用,而无需关心 subreddit 加载逻辑:
¥Notice how a lot of the logic in the original redditMachine
was moved to the subredditMachine
. That allows us to isolate logic to their specific domains and make the redditMachine
more general, without being concerned with subreddit loading logic:
const redditMachine = createMachine({
id: 'reddit',
initial: 'idle',
context: {
subreddit: null
},
states: {
idle: {},
selected: {} // no invocations!
},
on: {
SELECT: {
target: '.selected',
actions: assign({
subreddit: (context, event) => event.name
})
}
}
});
然后,在 UI 框架(本例中为 React)中,<Subreddit>
组件可以使用创建的 subredditMachine
中的逻辑负责显示 subreddit:
¥Then, in the UI framework (React, in this case), a <Subreddit>
component can be responsible for displaying the subreddit, using the logic from the created subredditMachine
:
const Subreddit = ({ name }) => {
// Only create the machine based on the subreddit name once
const subredditMachine = useMemo(() => {
return createSubredditMachine(name);
}, [name]);
const [current, send] = useMachine(subredditMachine);
if (current.matches('failure')) {
return (
<div>
Failed to load posts.{' '}
<button onClick={(_) => send('RETRY')}>Retry?</button>
</div>
);
}
const { subreddit, posts, lastUpdated } = current.context;
return (
<section
data-machine={subredditMachine.id}
data-state={current.toStrings().join(' ')}
>
{current.matches('loading') && <div>Loading posts...</div>}
{posts && (
<>
<header>
<h2>{subreddit}</h2>
<small>
Last updated: {lastUpdated}{' '}
<button onClick={(_) => send('REFRESH')}>Refresh</button>
</small>
</header>
<ul>
{posts.map((post) => {
return <li key={post.id}>{post.title}</li>;
})}
</ul>
</>
)}
</section>
);
};
整个应用可以使用 <Subreddit>
组件:
¥And the overall app can use that <Subreddit>
component:
const App = () => {
const [current, send] = useMachine(redditMachine);
const { subreddit } = current.context;
return (
<main>
<header>{/* ... */}</header>
{subreddit && <Subreddit name={subreddit} key={subreddit} />}
</main>
);
};
# 使用角色
¥Using Actors
我们创建的机器可以工作,并且适合我们的基本用例。但是,假设我们想要支持以下用例:
¥The machines we've created work, and fit our basic use-cases. However, suppose we want to support the following use-cases:
选择 Reddit 子版块时,即使选择了不同的子版块,它也应该完全加载(基本 "caching")
¥When a subreddit is selected, it should load fully, even if a different one is selected (basic "caching")
用户应该看到 subreddit 上次更新的时间,并且能够刷新 subreddit。
¥The user should see when a subreddit was last updated, and have the ability to refresh the subreddit.
一个很好的心理模型是 角色模型,其中每个单独的 Reddit 子版块都是它自己的 "actor",它根据事件(无论是内部事件还是外部事件)控制自己的逻辑。
¥A good mental model for this is the Actor model, where each individual subreddit is its own "actor" that controls its own logic based on events, whether internal or external.
# 催生 Subreddit 角色
¥Spawning Subreddit Actors
回想一下,参与者是一个具有自己的逻辑/行为的实体,它可以接收事件并向其他参与者发送事件。
¥Recall that an actor is an entity that has its own logic/behavior, and it can receive and send events to other actors.
redditMachine
的 context
需要建模为:
¥The context
of the redditMachine
needs to be modeled to:
维护 Reddit 子版块与其生成的 Actor 的映射
¥maintain a mapping of subreddits to their spawned actors
跟踪当前可见的 Reddit 子版块
¥keep track of which subreddit is currently visible
const redditMachine = createMachine({
// ...
context: {
subreddits: {},
subreddit: null
}
// ...
});
选择 Reddit 子版块后,可能会发生以下两种情况之一:
¥When a subreddit is selected, one of two things can happen:
如果该 subreddit actor 已存在于
context.subreddits
对象中,则assign()
将其作为当前的context.subreddit
。¥If that subreddit actor already exists in the
context.subreddits
object,assign()
it as the currentcontext.subreddit
.否则,
spawn()
是一个新的 subreddit actor,具有来自createSubredditMachine
的 subreddit 机器行为,将其指定为当前context.subreddit
,并将其保存在context.subreddits
对象中。¥Otherwise,
spawn()
a new subreddit actor with subreddit machine behavior fromcreateSubredditMachine
, assign it as the currentcontext.subreddit
, and save it in thecontext.subreddits
object.
const redditMachine = createMachine({
// ...
context: {
subreddits: {},
subreddit: null
},
// ...
on: {
SELECT: {
target: '.selected',
actions: assign((context, event) => {
// Use the existing subreddit actor if one already exists
let subreddit = context.subreddits[event.name];
if (subreddit) {
return {
...context,
subreddit
};
}
// Otherwise, spawn a new subreddit actor and
// save it in the subreddits object
subreddit = spawn(createSubredditMachine(event.name));
return {
subreddits: {
...context.subreddits,
[event.name]: subreddit
},
subreddit
};
})
}
}
});
# 把它们放在一起
¥Putting It All Together
现在我们已经将每个 subreddit 封装在自己的 "live" actor 中,并具有自己的逻辑和行为,我们可以将这些 actor 引用(或 "refs")作为数据传递。这些由机器创建的参与者在 XState 中被称为 "services"。就像任何参与者一样,事件可以发送到这些服务,但这些服务也可以被订阅。订阅者将在服务更新时收到最新的服务状态。
¥Now that we have each subreddit encapsulated in its own "live" actor with its own logic and behavior, we can pass these actor references (or "refs") around as data. These actors created from machines are called "services" in XState. Just like any actor, events can be sent to these services, but these services can also be subscribed to. The subscriber will receive the most current state of the service whenever it's updated.
提示
在 React 中,更改检测是通过引用完成的,并且 props/state 的更改会导致重新渲染。Actor 的引用永远不会改变,但其内部状态可能会改变。这使得 Actor 非常适合顶层状态需要维护对生成的 Actor 的引用,但在生成的 Actor 发生更改时不应重新渲染(除非通过发送到父级的事件明确告知这样做)。
¥In React, change detection is done by reference, and changes to props/state cause rerenders. An actor's reference never changes, but its internal state may change. This makes actors ideal for when top-level state needs to maintain references to spawned actors, but should not rerender when a spawned actor changes (unless explicitly told to do so via an event sent to the parent).
换句话说,生成的子 Actor 更新不会导致不必要的重新渲染。🎉
¥In other words, spawned child actors updating will not cause unnecessary rerenders. 🎉
// ./Subreddit.jsx
const Subreddit = ({ service }) => {
const [current, send] = useService(service);
// ... same code as previous Subreddit component
};
// ./App.jsx
const App = () => {
const [current, send] = useMachine(redditMachine);
const { subreddit } = current.context;
return (
<main>
{/* ... */}
{subreddit && <Subreddit service={subreddit} key={subreddit.id} />}
</main>
);
};
使用上面的参与者模型和仅使用具有组件层次结构的机器(例如,使用 React)之间的区别是:
¥The differences between using the actor model above and just using machines with a component hierarchy (e.g., with React) are:
数据流和逻辑层次结构位于 XState 服务中,而不是组件中。当 subreddit 需要继续加载时(即使其
<Subreddit>
组件可能已卸载),这一点很重要。¥The data flow and logic hierarchy live in the XState services, not in the components. This is important when the subreddit needs to continue loading, even when its
<Subreddit>
component may be unmounted.UI 框架层(例如 React)变成了一个普通的视图层;逻辑和副作用不直接与 UI 相关,除非在适当的地方。
¥The UI framework layer (e.g., React) becomes a plain view layer; logic and side-effects are not tied directly to the UI, except where it is appropriate.
redditMachine
→subredditMachine
actor 层次结构是 "self-sustaining",并且允许将逻辑转移到任何 UI 框架,甚至根本没有框架!¥The
redditMachine
→subredditMachine
actor hierarchy is "self-sustaining", and allows for the logic to be transferred to any UI framework, or even no framework at all!
# React 演示
¥React Demo
# 视频演示
¥Vue Demo
毫不奇怪,相同的机器可以在 Vue 应用中使用,并表现出完全相同的行为(感谢 Chris Hannaby (opens new window)):
¥Unsurprisingly, the same machines can be used in a Vue app that exhibits the exact same behavior (thanks to Chris Hannaby (opens new window)):