MobX 快速启动指南(二)
原文:
zh.annas-archive.org/md5/ac898efa7699227dc4bedcb64bab44d7译者:飞龙
第六章:处理真实用例
当您开始使用 MobX 时,应用 MobX 的原则可能会看起来令人生畏。为了帮助您完成这个过程,我们将解决两个非平凡的使用 MobX 三要素可观察-操作-反应的示例。我们将涵盖可观察状态的建模,然后确定跟踪可观察对象的操作和反应。一旦您完成这些示例,您应该能够在使用 MobX 处理状态管理时进行心智转变。
本章我们将涵盖以下示例:
-
表单验证
-
页面路由
技术要求
您需要具备 JavaScript 编程语言。最后,要使用本书的 Git 存储库,用户需要安装 Git。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Mobx-Quick-Start-Guide/tree/master/src/Chapter06
查看以下视频,以查看代码的运行情况:
表单验证
填写表单和验证字段是 Web 的经典用例。因此,我们从这里开始,并看看 MobX 如何帮助我们简化它。在我们的示例中,我们将考虑一个用户注册表单,其中包含一些标准输入,如名字、姓氏、电子邮件和密码。
注册的各种状态如下图所示:
交互
从前面的屏幕截图中,我们可以看到一些标准的交互,例如:
-
输入各种字段的输入
-
对这些字段进行验证
-
单击“注册”按钮执行网络操作
这里还有一些其他交互,不会立即引起注意:
-
基于网络的电子邮件验证,以确保我们不会注册已存在的电子邮件地址
-
显示注册操作的进度指示器
许多这些交互将使用 MobX 中的操作和反应进行建模。状态当然将使用可观察对象进行建模。让我们看看在这个示例中 Observables-Actions-Reactions三要素是如何生动起来的。
建模可观察状态
示例的视觉设计已经暗示了我们需要的核心状态。这包括firstName、lastName、email和password字段。我们可以将这些字段建模为UserEnrollmentData类的可观察属性。
此外,我们还需要跟踪将发生在电子邮件上的异步验证。我们使用布尔值validating属性来实现这一点。在验证过程中发现的任何错误都将使用errors进行跟踪。最后,enrollmentStatus跟踪了围绕注册的网络操作。它是一个字符串枚举,可以有四个值之一:none、pending、completed或failed。
class UserEnrollmentData {
@observable email = '';
@observable password = '';
@observable firstName = '';
@observable lastName = '';
@observable validating = false;
@observable.ref errors = null;
@observable enrollmentStatus = 'none'; // none | pending | completed | failed
}
您会注意到errors标记为@observable.ref,因为它只需要跟踪引用更改。这是因为验证输出是一个不透明对象,除了引用更改之外没有任何可观察的东西。只有当errors有一个值时,我们才知道有验证错误。
进入操作
这里的操作非常简单。我们需要一个操作来根据用户更改设置字段值。另一个是在单击 Enroll 按钮时进行注册。这两个可以在以下代码中看到。
作为一般惯例,始终从调用configure({ enforceActions: 'strict' })开始。这确保您的可观察对象只在操作内部发生突变,为您提供了我们在第五章中讨论的所有好处,派生、操作和反应:
import { action, configure, flow } from 'mobx';
**configure({ enforceActions: 'strict' });**
class UserEnrollmentData {
/* ... */
@action
setField(field, value) {
this[field] = value;
}
getFields() {
const { firstName, lastName, password, email } = this;
return { firstName, lastName, password, email }
}
enroll = flow(function*() {
this.enrollmentStatus = 'pending';
try {
// Validation
const fields = this.getFields();
yield this.validateFields(fields);
if (this.errors) {
throw new Error('Invalid fields');
}
// Enrollment
yield enrollUser(fields);
this.enrollmentStatus = 'completed';
} catch (e) {
this.enrollmentStatus = 'failed';
}
});
}
对于enroll操作使用flow()是故意的。我们在内部处理异步操作,因此在操作完成后发生的突变必须包装在runInAction()或action()中。手动执行这个操作可能很麻烦,也会给代码增加噪音。
使用flow(),您可以通过使用带有yield语句的生成器函数来获得清晰的代码,用于promises。在前面的代码中,我们有两个yield点,一个用于validateFields(),另一个用于enroll(),两者都返回promises。请注意,在这些语句之后我们没有包装代码,这样更容易遵循逻辑。
这里隐含的另一个操作是validateFields()。验证实际上是一个副作用,每当字段更改时都会触发,但也可以直接作为一个操作调用。在这里,我们再次使用flow()来处理异步验证后的突变:
我们使用validate.js (validatejs.org) NPM 包来处理字段验证。
**import Validate from 'validate.js';**
class UserEnrollmentData {
/* ... */
validateFields = flow(function*(fields) {
this.validating = true;
this.errors = null;
try {
yield Validate.async(fields, rules);
this.errors = null;
} catch (err) {
this.errors = err;
} finally {
this.validating = false;
}
});
/* ... */
}
注意flow()如何像常规函数一样接受参数(例如:fields)。由于电子邮件的验证涉及异步操作,我们将整个验证作为异步操作进行跟踪。我们使用validating属性来实现这一点。当操作完成时,我们在finally块中将其设置回false。
用反应完成三角形
当字段发生变化时,我们需要确保输入的值是有效的。因此,验证是输入各个字段的值的副作用。我们知道 MobX 提供了三种处理这种副作用的方法,它们是autorun()、reaction()和when()。由于验证是应该在每次字段更改时执行的效果,一次性效果的when()可以被排除。这让我们只剩下了reaction()和autorun()。通常,表单只会在字段实际更改时进行验证。这意味着效果只需要在更改后触发。
这将我们的选择缩小到reaction(<tracking-function>, <effect-function>),因为这是唯一一种确保effect函数在tracking函数返回不同值后触发的反应类型。另一方面,autorun()立即执行,这对于执行验证来说太早了。有了这个,我们现在可以在UserEnrollmentData类中引入验证副作用:
从技术上讲,这也可以通过autorun()实现,但需要一个额外的布尔标志来确保第一次不执行验证。任何一种解决方案在这种情况下都可以很好地工作。
class UserEnrollmentData {
disposeValidation = null;
constructor() {
this.setupValidation();
}
setupValidation() {
this.disposeValidation = reaction(
() => {
const { firstName, lastName, password, email } = this;
return { firstName, lastName, password, email };
},
() => {
this.validateFields(this.getFields());
},
);
}
/* ... */
**cleanup**() {
this.disposeValidation();
}
}
在前述reaction()中的tracking函数选择要监视的字段。当它们中的任何一个发生变化时,tracking函数会产生一个新值,然后触发验证。我们已经看到了validateFields()方法,它也是使用flow()的动作。reaction()设置在UserEnrollmentData的构造函数中,因此监视立即开始。
当调用this.validateFields()时,它会返回一个promise,可以使用其cancel()方法提前取消。如果validateFields()被频繁调用,先前调用的方法可能仍在进行中。在这种情况下,我们可以cancel()先前返回的 promise 以避免不必要的工作。
我们将把这个有趣的用例留给读者来解决。
我们还跟踪reaction()返回的disposer函数,我们在cleanup()中调用它。这是为了清理和避免潜在的内存泄漏,当不再需要UserEnrollmentData时。在反应中始终有一个退出点并调用其disposer总是很好的。在我们的情况下,我们从根 React 组件中调用cleanup(),在其componentWillUnmount()挂钩中。我们将在下一节中看到这一点。
现在,验证不是我们示例的唯一副作用。更宏伟的副作用是 React 组件的 UI。
React 组件
我们知道的 UI 在 MobX 中是一个副作用,并且通过在 React 组件上使用observer()装饰器来识别。这些观察者可以在render()方法中读取可观察对象,从而设置跟踪。每当这些可观察对象发生变化时,MobX 将重新渲染组件。这种自动行为与最小的仪式非常强大,使我们能够创建对细粒度可观察状态做出反应的细粒度组件。
在我们的示例中,我们确实有一些细粒度的观察者组件,即输入字段、注册按钮和应用程序组件。它们在以下组件树中用橙色框标记:
每个字段输入都分离成一个观察者组件:InputField。电子邮件字段有自己的组件EmailInputField,因为它的视觉反馈还涉及在验证期间显示进度条并在检查输入的电子邮件是否已注册时禁用它。同样,EnrollButton也有一个旋转器来显示注册操作期间的进度。
我们正在使用Material-UI(material-ui.com)作为组件库。这提供了一组优秀的 React 组件,按照 Google 的 Material Design 指南进行了样式设置。
InputField只观察它正在渲染的字段,由field属性标识,该属性是从store属性(使用store[field])解除引用的。这作为InputField的value:
const InputField = observer(({ store, field, label, type }) => {
const errors = store.errors && store.errors[field];
const hasError = !!errors;
return (
<TextField
fullWidth
type={type} value={store[field]} label={label} error={hasError} onChange={event => store.setField(field,
event.target.value)} margin={'normal'} helperText={errors ? errors[0] : null} />
);
});
用户对此输入进行的编辑(onChange事件)将通过store.setField()操作通知回存储。InputField在 React 术语中是一个受控组件。
InputField组件的关键思想是传递可观察对象(store)而不是值(store[field])。这确保了可观察属性的解引用发生在组件的render()内部。这对于一个专门用于渲染和跟踪所需内容的细粒度观察者来说非常重要。在创建 MobX 观察者组件时,您可以将其视为设计模式。
UserEnrollmentForm 组件
我们在UserEnrollmentForm组件中使用了几个这些InputFields。请注意,UserEnrollmentForm组件不是观察者。它的目的是通过inject()装饰器获取存储并将其传递给一些子观察者组件。这里的inject()使用了基于函数的参数,比inject('store')的基于字符串的参数更安全。
import React from 'react';
import { inject } from 'mobx-react';
import { Grid, TextField, Typography, } from '@material-ui/core';
@inject(stores => ({ store: stores.store }))
class UserEnrollmentForm extends React.Component {
render() {
const { store } = this.props;
return (
<form>
<Grid container direction={'column'}>
<CenteredGridItem>
<Typography variant={'title'}>Enroll
User</Typography>
</CenteredGridItem>
<CenteredGridItem>
<EmailInputField store={store} />
</CenteredGridItem>
<CenteredGridItem>
<**InputField**
type={'password'} field={'password'} label={'Password'} store={store} />
</CenteredGridItem>
<CenteredGridItem>
<**InputField**
type={'text'} field={'firstName'} label={'First Name'} store={store} />
</CenteredGridItem>
<CenteredGridItem>
<**InputField**
type={'text'} field={'lastName'} label={'Last Name'} store={store} />
</CenteredGridItem>
<CenteredGridItem>
<EnrollButton store={store} />
</CenteredGridItem>
</Grid>
</form>
);
}
}
store,即UserEnrollmentData的一个实例,通过在组件树的根部设置的Provider组件传递下来。这是在根组件的constructor中创建的。
import React from 'react';
import { UserEnrollmentData } from './store';
import { Provider } from 'mobx-react';
import { App } from './components';
export class FormValidationExample extends React.Component {
constructor(props) {
super(props);
this.store = new UserEnrollmentData();
}
render() {
return (
<Provider store={this.store}>
<App />
</Provider>
);
}
componentWillUnmount() {
this.store.cleanup();
this.store = null;
}
}
通过Provider,任何组件现在都可以inject() store并访问可观察状态。请注意使用componentWillUnmount()钩子来调用this.store.cleanup()。这在内部处理了验证反应,如前一部分所述(“使用反应完成三角形”)。
其他观察者组件
在我们的组件树中还有一些更细粒度的观察者。其中最简单的之一是App组件,它提供了一个简单的分支逻辑。如果我们仍在注册过程中,将显示UserEnrollmentForm。注册后,App将显示EnrollmentComplete组件。这里跟踪的可观察对象是store.enrollmentStatus:
@inject('store')
@observer export class App extends React.Component {
render() {
const { store } = this.props;
return store.enrollmentStatus === 'completed' ? (
<EnrollmentComplete />
) : (
<UserEnrollmentForm />
);
}
}
EmailInputField相当不言自明,并重用了InputField组件。它还包括一个进度条来显示异步验证操作:
const EmailInputField = observer(({ store }) => {
const { validating } = store;
return (
<Fragment>
<InputField
type={'text'} store={store} field={'email'} label={'Email'} />
{validating ? <LinearProgress variant={'query'} /> : null}
</Fragment>
);
});
最后,最后一个观察者组件是EnrollButton,它观察enrollmentStatus并在store上触发enroll()动作。在注册过程中,它还显示圆形旋转器:
const EnrollButton = observer(({ store }) => {
const isEnrolling = store.enrollmentStatus === 'pending';
const failed = store.enrollmentStatus === 'failed';
return (
<Fragment>
<Button
variant={'raised'} color={'primary'} style={{ marginTop: 20 }} disabled={isEnrolling} onClick={() => store.enroll()} >
Enroll
{isEnrolling ? (
<CircularProgress
style={{
color: 'white',
marginLeft: 10,
}} size={20} variant={'indeterminate'} />
) : null}
</Button>
{failed ? (
<Typography color={'secondary'} variant={'subheading'}>
Failed to enroll
</Typography>
) : null}{' '}
</Fragment>
);
});
这些细粒度观察者的集合通过加速 React 的协调过程来提高 UI 的效率。由于更改局限于特定组件,React 只需协调该特定观察者组件的虚拟 DOM 更改。MobX 鼓励在组件树中使用这样的细粒度观察者并将它们分散其中。
如果您正在寻找一个专门用于使用 MobX 进行表单验证的库,请查看mobx-react-form(github.com/foxhound87/mobx-react-form)。
页面路由
单页应用程序(SPA)已经成为我们今天看到的许多 Web 应用程序中的常见现象。这些应用程序的特点是在单个页面内使用逻辑的客户端路由。您可以通过修改 URL 而无需完整加载页面来导航到应用程序的各个部分(路由)。这是由诸如react-router-dom之类的库处理的,它与浏览器历史记录一起工作,以实现URL驱动的路由更改。
在 MobX 世界中,路由更改或导航可以被视为副作用。可观察对象发生了一些状态变化,导致 SPA 中的导航发生。在这个例子中,我们将构建这个可观察状态,它跟踪浏览器中显示的当前页面。使用react-router-dom和history包的组合,我们将展示如何路由成为可观察状态变化的副作用。
购物车结账工作流
让我们看一个用例,我们可以看到路由更改(导航)作为 MobX 驱动的副作用。我们将使用典型的购物车结账工作流作为示例。如下截图所示,我们从主页路由开始,这是工作流的入口点。从那里,我们经历剩下的步骤:查看购物车,选择付款选项,查看确认,然后跟踪订单:
我们故意保持各个步骤在视觉上简单。这样我们可以更多地关注导航方面,而不是每个步骤内部发生的细节。然而,工作流的这些步骤中有一些共同的元素。
如下截图所示,每个步骤都有一个加载操作,用于获取该步骤的详细信息。加载完成后,您可以单击按钮转到下一步。在导航发生之前,会执行一个异步操作。完成后,我们将导航到工作流程的下一步。由于每个步骤都遵循这个模板,我们将在下一节中对其进行建模:
建模可观察状态
这个 SPA 的本质是逐步进行结账工作流程,其中每个步骤都是一个路由。由于路由由 URL 驱动,我们需要一种监视 URL 并在步骤之间移动时有能力更改它的方法。步骤之间的导航是可观察状态的某种变化的副作用。我们将使用包含核心可观察状态的CheckoutWorkflow类来对这个工作流程进行建模:
const routes = {
shopping: '/',
cart: '/cart',
payment: '/payment',
confirm: '/confirm',
track: '/track',
};
export class CheckoutWorkflow {
static steps = [
{ name: 'shopping', stepClass: ShoppingStep },
{ name: 'cart', stepClass: ShowCartStep },
{ name: 'payment', stepClass: PaymentStep },
{ name: 'confirm', stepClass: ConfirmStep },
{ name: 'track', stepClass: TrackStep },
];
tracker = new HistoryTracker();
nextStepPromise = null;
@observable currentStep = null;
@observable.ref step = null;
}
如前面的代码所示,我们用name和stepClass表示每个步骤。name也是我们用来识别该步骤对应路由的方式,存储在单例routes对象中。steps的有序列表存储为CheckoutWorkflow类的静态属性。我们也可以从单独的 JavaScript 文件(模块)中加载这些步骤,但为简单起见,我们将其保留在这里。
核心的可观察状态在这里非常简单:一个存储当前步骤的字符串名称的currentStep属性和一个step属性,作为observable.ref属性存储的stepClass的实例。当我们在步骤之间导航时,这两个属性会发生变化以反映当前步骤。我们将看到这些属性在处理路由更改时的使用方式。
一条路线对应一步,一步对应一条路线
你可能会想为什么我们需要两个单独的属性来跟踪当前步骤。是的,这似乎多余,但有原因。由于我们的工作流将是一组 url 路由,路由的变化也可以通过浏览器的返回按钮或直接输入 URL 来发生。将路由与步骤相关联的一种方法是使用其名称,这正是我们在currentStep属性中所做的。请注意,步骤的name与routes对象的键完全匹配。
当路由在外部发生变化时,我们依赖浏览器历史记录来通知我们 URL 的变化。tracker属性是HistoryTracker的一个实例(我们将创建一个自定义类),其中包含监听浏览器历史记录并跟踪浏览器中当前 URL 的逻辑。它公开了一个被CheckoutWorkflow跟踪的 observable 属性。我们稍后将在本章中查看它的实现:
CheckoutWorkflow中的每个步骤都是WorkflowStep类的子类型。WorkflowStep捕获了步骤及其异步操作的详细信息。工作流简单地编排步骤的流程,并在每个步骤的异步操作完成后在它们之间进行转换:
class ShowCartStep extends WorkflowStep { /* ... */}
// A mock step to simplify the representation of other steps
class MockWorkflowStep extends WorkflowStep { /* ... */ }
class PaymentStep extends MockWorkflowStep { /* ... */ }
class ConfirmStep extends MockWorkflowStep { /* ... */ }
class TrackStep extends MockWorkflowStep { /* ... */ }
对于大多数步骤,我们正在扩展MockWorkflowStep,它使用一些内置的默认值来创建一个模板WorkflowStep。这使得步骤非常简单,因此我们可以专注于步骤之间的路由。请注意下面的代码片段,我们只是模拟了load和main操作的网络延迟。delay()函数只是一个简单的帮助函数,返回一个在给定毫秒间隔后解析的Promise。
我们将在下一节中看到getLoadOperation()和getMainOperation()方法是如何使用的:
class MockWorkflowStep extends WorkflowStep {
getLoadOperation() {
return delay(1000);
}
getMainOperation() {
return delay(1000);
}
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
WorkflowStep
WorkflowStep充当了工作流中所有步骤的模板。它包含一些 observable 状态,用于跟踪它执行的两个异步操作:加载详情和执行主要工作。
class WorkflowStep {
workflow = null; // the parent workflow
@observable loadState = 'none'; // pending | completed | failed
@observable operationState = 'none'; // pending | completed |
failed async getLoadOperation() {}
async getMainOperation() {}
@action.bound
async load() {
doAsync(
() => this.getLoadOperation(),
state => (this.loadState = state),
);
}
@action.bound
async perform() {
doAsync(
() => this.getMainOperation(),
state => (this.operationState = state),
);
}
}
load()和perform()是WorkflowStep执行的两个异步操作。它们的状态分别通过loadState和operationState observables 进行跟踪。这两个操作中的每一个都调用一个委托方法,子类重写该方法以提供实际的 promise。load()调用getLoadOperation(),perform()调用getMainOperation(),每个方法都会产生一个 promise。
doAsync()是一个帮助函数,它接受一个promise 函数并使用传入的回调(setState)通知状态。请注意这里使用runInAction()来确保所有变化发生在一个 action 内部。
load()和perform()使用doAsync()函数适当地更新loadState和operationState observables:
有一种不同的编写doAsync()函数的方法。提示:我们在早期的章节中已经看到过。我们将把这留给读者作为一个练习。
async function doAsync(getPromise, setState) {
setState('pending');
try {
await getPromise();
runInAction(() => {
setState('completed');
});
} catch (e) {
runInAction(() => {
setState('failed');
});
}
}
现在我们可以看到可观察状态由CheckoutWorkflow和WorkflowStep实例承载。可能不清楚的一点是CheckoutWorkflow如何执行协调。为此,我们必须看一下动作和反应。
工作流的动作和反应
我们已经看到WorkflowStep有两个action方法,load()和perform(),处理步骤的异步操作:
class WorkflowStep {
workflow = null;
@observable loadState = 'none'; // pending | completed | failed
@observable operationState = 'none'; // pending | completed |
failed async getLoadOperation() {}
async getMainOperation() {}
@action.bound
async load() {
doAsync(
() => this.getLoadOperation(),
state => (this.loadState = state),
);
}
@action.bound
async perform() {
doAsync(
() => this.getMainOperation(),
state => (this.operationState = state),
);
}
}
load()操作由CheckoutWorkflow调用,因为它加载工作流的每个步骤。perform()是用户调用的操作,当用户点击暴露在 React 组件上的按钮时发生。一旦perform()完成,operationState将变为completed。CheckoutWorkflow跟踪这一点,并自动加载序列中的下一个步骤。换句话说,工作流作为对当前步骤的operationState变化的反应(或副作用)而进展。让我们在以下一组代码片段中看到所有这些:
export class CheckoutWorkflow {
/* ... */
tracker = new HistoryTracker();
nextStepPromise = null;
@observable currentStep = null;
@observable.ref step = null;
constructor() {
this.tracker.startListening(routes);
this.currentStep = this.tracker.page;
autorun(() => {
const currentStep = this.currentStep;
const stepIndex = CheckoutWorkflow.steps.findIndex(
x => x.name === currentStep,
);
if (stepIndex !== -1) {
this.loadStep(stepIndex);
this.tracker.page = CheckoutWorkflow.steps[stepIndex].name;
}
});
reaction(
() => this.tracker.page,
page => {
this.currentStep = page;
},
);
}
@action
async loadStep(stepIndex) {
/* ... */
}
}
CheckoutWorkflow的构造函数设置了核心副作用。我们需要知道的第一件事是浏览器使用this.tracker.page提供的当前页面。请记住,我们正在将工作流的currentStep与使用共享名称的基于 URL 的路由相关联。
第一个副作用使用autorun()执行,我们知道它立即运行,然后在跟踪的可观察对象发生变化时运行。在autorun()内部,我们首先确保加载currentStep是有效的步骤。由于我们在autorun()内部观察currentStep,我们必须确保我们保持this.tracker.page同步。成功加载当前步骤后,我们这样做。现在,每当currentStep发生变化时,tracker.page会自动同步,这意味着 URL 和路由会更新以反映当前步骤。稍后我们将看到,tracker,即HistoryTracker的实例,实际上是如何在内部处理这一点的。
下一个副作用是对tracker.page的变化的reaction()。这是对先前副作用的对应部分。每当tracker.page发生变化时,我们也必须改变currentStep。毕竟,这两个可观察对象必须协同工作。因为我们已经通过一个单独的副作用(autorun())来跟踪currentStep,当前的step加载了WorkflowStep的实例。
这里引人注目的一点是,当currentStep改变时,tracker.page会更新。同样,当tracker.page改变时,currentStep也会更新。因此,可能会出现一个无限循环:
然而,MobX 会发现一旦变化在一个方向上传播,另一方向就不会发生更新,因为两者是同步的。这意味着这两个相互依赖的值很快就会达到稳定状态,不会出现无限循环。
加载步骤
WorkflowStep是步骤变得活跃的地方,唯一能创建实例的是CheckoutWorkflow。毕竟,它是整个工作流的所有者。它在loadStep()动作方法中执行此操作:
export class CheckoutWorkflow {
/* ... */
@action
async loadStep(stepIndex) {
if (this.nextStepPromise) {
this.nextStepPromise.cancel();
}
const StepClass = CheckoutWorkflow.steps[stepIndex].stepClass;
this.step = new StepClass();
this.step.workflow = this;
this.step.load();
this.nextStepPromise = when(
() => this.step.operationState === 'completed',
);
await this.nextStepPromise;
const nextStepIndex = stepIndex + 1;
if (nextStepIndex >= CheckoutWorkflow.steps.length) {
return;
}
this.currentStep = CheckoutWorkflow.steps[nextStepIndex].name;
}
}
上述代码的有趣部分概述如下:
-
我们通过从步骤列表中检索当前步骤索引的
stepClass来获得当前步骤索引的stepClass。我们创建了这个stepClass的实例,并将其分配给可观察的step属性。 -
然后触发
WorkflowStep的load()。 -
可能最有趣的部分是等待
step的operationState改变。我们从前面知道,operationState跟踪步骤的主要异步操作的状态。一旦它变为completed,我们就知道是时候转到下一步了。 -
注意使用带有 promise 的
when()。这为我们提供了一个很好的方法来标记需要在when()解析后执行的代码。还要注意,我们在nextStepPromise属性中跟踪 promise。这是为了确保在当前步骤完成之前,我们也要cancel掉 promise。值得思考这种情况可能会出现的时候。提示:步骤的流程并不总是线性的。步骤也可以通过路由更改来更改,比如通过单击浏览器的返回按钮!
历史跟踪器
observable state puzzle的最后一部分是HistoryTracker,这是一个专门用于监视浏览器 URL 和历史记录的类。它依赖于history NPM 包(github.com/ReactTraining/history)来完成大部分工作。history包还为我们的 React 组件提供动力,我们将使用react-router-dom库。
HistoryTracker的核心责任是公开一个名为page的 observable,用于跟踪浏览器中的当前 URL(路由)。它还会反向操作,使 URL 与当前page保持同步:
import createHashHistory from 'history/createHashHistory';
import { observable, action, reaction } from 'mobx';
export class HistoryTracker {
unsubscribe = null;
history = createHashHistory();
@observable page = null;
constructor() {
reaction(
() => this.page,
page => {
const route = this.routes[page];
if (route) {
this.history.push(route);
}
},
);
}
/* ... */
}
在构造函数中设置了reaction(),路由更改(URL 更改)实际上是page observable 变化的副作用。这是通过将路由(URL)推送到浏览器历史记录中实现的。
HistoryTracker的另一个重要方面,正如其名称所示,是跟踪浏览器历史记录。这是通过startListening()方法完成的,可以由此类的消费者调用。CheckoutWorkflow在其构造函数中调用此方法来设置跟踪器。请注意,startListening()接收一个路由映射,其中key指向 URL 路径:
export class HistoryTracker {
unsubscribe = null;
history = createHashHistory();
@observable page = null;
startListening(routes) {
this.routes = routes;
this.unsubscribe = this.history.listen(location => {
this.identifyRoute(location);
});
this.identifyRoute(this.history.location);
}
stopListening() {
this.unsubscribe && this.unsubscribe();
}
@action
setPage(key) {
if (!this.routes[key]) {
throw new Error(`Invalid Page: ${key}`);
}
this.page = key;
}
@action
identifyRoute(location) {
const { pathname } = location;
const routes = this.routes;
this.page = Object.keys(routes).find(key => {
const path = routes[key];
return path.startsWith(pathname);
});
}
}
当浏览器中的 URL 更改时,page observable 会相应地更新。这发生在identifyRoute()方法中,该方法从history.listen()的回调中调用。我们已经用 action 修饰它,因为它会改变page observable。在内部,MobX 会通知所有page的观察者,例如CheckoutWorkflow,它使用page observable 来更新其currentStep。这保持了整个路由同步,并确保更改是双向的。
以下图表显示了currentStep、page和url-route之间的双向同步。请注意,与history包的交互显示为灰色箭头,而 observable 之间的依赖关系显示为橙色箭头。这种颜色上的差异是有意的,并表明基于 url 的路由实际上是 observable 状态变化的副作用:
React 组件
在这个例子中,observable 状态的建模比 React UI 组件更有趣。在 React 方面,我们有设置Provider的顶层组件,其中store是CheckoutWorkflow的实例。Provider来自mobx-react包,并帮助将store注入到任何使用inject()装饰的 React 组件中:
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'mobx-react';
import { CheckoutWorkflow } from './CheckoutWorkflow';
const workflow = new CheckoutWorkflow();
export function PageRoutingExample() {
return (
<Provider store={workflow}>
<App />
</Provider>
);
}
App组件只是使用react-router-dom包设置所有路由。在<Route />组件中使用的路径与我们在routes对象中看到的 URL 匹配。请注意,HistoryTracker中的history用于Router。这允许在react-router和mobx之间共享浏览器历史记录:
import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Router, Switch } from 'react-router-dom';
import { CheckoutWorkflow } from './CheckoutWorkflow';
import { Paper } from '@material-ui/core/es/index';
import { ShowCart } from './show-cart';
import {
ConfirmDescription,
PaymentDescription,
ShoppingDescription,
TemplateStepComponent,
TrackOrderDescription,
} from './shared';
const workflow = new CheckoutWorkflow();
class App extends React.Component {
render() {
return (
<Paper elevation={2} style={{ padding: 20 }}>
<Router history={workflow.tracker.history}>
<Switch>
<**Route**
exact
path={'/'} component={() => (
<TemplateStepComponent
title={'MobX Shop'} renderDescription=
{ShoppingDescription} operationTitle={'View Cart'} />
)} />
<Route exact path={'/cart'} component=
{ShowCart} />
<**Route**
exact
path={'/payment'} component={() => (
<TemplateStepComponent
title={'Choose Payment'} renderDescription=
{PaymentDescription} operationTitle={'Confirm'} />
)} />
<**Route**
exact
path={'/confirm'} component={() => (
<TemplateStepComponent
title={'Your order is confirmed'} operationTitle={'Track Order'} renderDescription=
{ConfirmDescription} />
)} />
<**Route**
exact
path={'/track'} component={() => (
<TemplateStepComponent
title={'Track your order'} operationTitle={'Continue
Shopping'} renderDescription=
{TrackOrderDescription} />
)} />
</Switch>
</Router>
</Paper>
);
}
}
如前所述,我们故意保持了工作流程的各个步骤非常简单。它们都遵循固定的模板,由WorkflowStep描述。它的 React 对应物是TemplateStepComponent,它呈现步骤并公开按钮,用于导航到下一步。
TemplateStepComponent
TemplateStepComponent为WorkflowStep提供了可视化表示。当步骤正在加载时,它会呈现反馈,当主要操作正在执行时也是如此。此外,它会在加载后显示步骤的详细信息。这些细节通过renderDetails属性显示,该属性接受一个 React 组件:
@inject('store')
export class TemplateStepComponent extends React.Component {
static defaultProps = {
title: 'Step Title',
operationTitle: 'Operation',
renderDetails: step => 'Some Description', // A render-prop to render details of a step
};
render() {
const { title, operationTitle, renderDetails } = this.props;
return (
<Fragment>
<Typography
variant={'headline'} style={{ textAlign: 'center' }} >
{title}
</Typography>
<Observer>
{() => {
const { step } = this.props.store;
return (
<OperationStatus
state={step.loadState} render={() => (
<div style={{ padding: '2rem 0' }}>
{renderDetails(step)}
</div>
)} />
);
}}
</Observer>
<Grid justify={'center'} container>
<Observer>
{() => {
const { step } = this.props.store;
return (
<Button
variant={'raised'} color={'primary'} disabled={step.operationState ===
'pending'} onClick={step.perform}>
{operationTitle}
{step.operationState === 'pending'
? (
<CircularProgress
variant={'indeterminate'} size={20} style={{
color: 'black',
marginLeft: 10,
}} />
) : null}
</Button>
);
}}
</Observer>
</Grid>
</Fragment>
);
}
}
Observer组件是我们以前没有见过的东西。这是由mobx-react包提供的一个特殊组件,简化了粒度观察者的创建。典型的 MobX 观察者组件将要求您创建一个单独的组件,用observer()和/或inject()装饰它,并确保适当的可观察对象作为 props 传递到该组件中。您可以通过简单地用<Observer />包装虚拟 DOM的一部分来绕过所有这些仪式。
它接受一个函数作为它唯一的子元素,在其中您可以从周围范围读取可观察对象。MobX 将自动跟踪函数作为子组件中使用的可观察对象。仔细观察Observer会揭示这些细节:
<Observer>
{() => {
const { step } = this.props.store;
return (
<OperationStatus
state={step.loadState} render={() => (
<div style={{ padding: '2rem 0' }}>
{renderDetails(step)}
</div>
)} />
);
}}
</Observer>
在上面的片段中,我们将一个函数作为<Observer />的子元素传递。在该函数中,我们使用step.loadState可观察对象。当step.loadState发生变化时,MobX 会自动呈现函数作为子组件。请注意,我们没有将任何 props 传递给Observer或子组件。它直接从外部组件的 props 中读取。这是使用Observer的优势。您可以轻松创建匿名观察者。
一个微妙的要点是TemplateStepComponent本身不是一个观察者。它只是用inject()获取store,然后在<Observer />区域内使用它。
ShowCart 组件
ShowCart是显示购物车中物品列表的组件。在这里,我们正在重用TemplateStepComponent和购物车的插件细节,使用renderDetails属性。这可以在以下代码中看到。为简单起见,我们不显示CartItem和TotalItem组件。它们是纯粹的呈现组件,用于呈现单个购物车项目:
import React from 'react';
import {
List,
ListItem,
ListItemIcon,
ListItemText,
Typography,
} from '@material-ui/core';
import { Divider } from '@material-ui/core/es/index';
import { TemplateStepComponent } from './shared';
export class ShowCart extends React.Component {
render() {
return (
<**TemplateStepComponent**
title={'Your Cart'} operationTitle={'Checkout'} renderDetails={step => {
const { items, itemTotal } = step;
return (
<List>
{items.map(item => (
<CartItem key={item.title} item={item} />
))}
<Divider />
<TotalItem total={itemTotal} />
</List>
);
}} />
);
}
}
function CartItem({ item }) {
return (
/* ... */
);
}
function TotalItem({ total }) {
return (
/* ... */
);
}
基于状态的路由器
现在您可以看到,所有WorkflowStep实例之间的路由纯粹是通过基于状态的方法实现的。所有导航逻辑都在 MobX 存储中,这种情况下是CheckoutWorkflow。通过连接可观察对象(tracker.page,currentStep和step)通过一系列反应,我们创建了更新浏览器历史的副作用,并创建了WorkflowStep的实例,这些实例由TemplateStepComponent使用。
由于我们在react-router-dom和 MobX 之间共享浏览器历史(通过HistoryTracker),我们可以使可观察对象与 URL 更改保持同步。
这种基于状态的路由方法有助于保持清晰的工作流心智模型。您的功能的所有逻辑都留在 MobX Store 中,提高了可读性。为这种基于状态的解决方案编写单元测试也很简单。事实上,在 MobX 应用程序中,大多数单元测试都围绕存储和反应中心。许多 React 组件成为可观察对象的纯粹观察者,并且可以被视为普通的演示组件。
使用 MobX,您可以专注于领域逻辑,并确保 UI 上有适当的可观察状态。通过将所有领域逻辑和状态封装在存储中,并将所有演示内容放在 React 组件中,可以清晰地分离关注点。这极大地改善了开发者体验(DX),并有助于随着时间的推移更好地扩展。这是 MobX 的真正承诺。
要了解更丰富功能的基于状态的路由解决方案,请查看mobx-state-router(github.com/nareshbhatia/mobx-state-router)。
摘要
在本章中,我们应用了我们在过去几章中学到的各种技术和概念。两个示例,表单验证和页面路由,分别提出了一套建模可观察状态的独特方法。我们还看到了如何创建细粒度的观察者组件,以实现 React 组件的高效渲染。
MobX 的实际应用始终以建模可观察状态为起点。毕竟,这就是驱动 UI 的数据。下一步是确定改变可观察状态的动作。最后,您需要调用副作用,并查看这些效果依赖于哪些可观察状态。这就是应用于现实场景的副作用模型,以 MobX 三元组的形式呈现:可观察状态-动作-反应。
根据我们迄今积累的所有知识,我们现在准备深入了解 MobX,从第七章开始,特殊情况的特殊 API。
第七章:特殊情况的特殊 API
MobX 的 API 表面非常简洁,为处理状态管理逻辑提供了正确的抽象。在大多数情况下,我们已经看到的 API 将足够。然而,总会有一些棘手的边缘情况需要略微偏离常规。正是为了这些特殊情况,MobX 为您提供了一些特殊的 API。我们将在本章中看到其中一些。
本章我们将涵盖以下主题:
-
使用对象 API 进行直接操作
-
使用
inject()和observe()来连接到内部 MobX 事件系统。 -
将有助于调试的特殊实用函数和工具
-
快速提及一些杂项 API
技术要求
您需要具备 JavaScript 编程语言。最后,要使用本书的 Git 存储库,用户需要安装 Git。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Mobx-Quick-Start-Guide/tree/master/src/Chapter07
查看以下视频,以查看代码的运行情况:
使用对象 API 进行直接操作
在决定可观察状态的数据结构时,您的自然选择应该是使用observable.object()、observable.array()、observable.map()、observable.box(),或者使用方便的observable()API。操作这些数据结构就像直接改变属性或根据需要添加和删除元素一样简单。
MobX 为您提供了另一种对数据结构进行手术式更改的方法。它公开了一个细粒度的对象 API,可以在运行时改变这些数据结构。事实上,它为您提供了一些原始数据结构甚至不可能的功能。例如,向可观察对象添加新属性,并保持其响应性。
细粒度读取和写入
对象 API 专注于对顶层数据结构(对象、数组和映射)的可观察属性进行细粒度控制。通过这样做,它们继续与 MobX 的响应式系统良好地配合,并确保您所做的细粒度更改被reactions捕获。以下 API 适用于可观察的对象/数组/映射:
-
get(thing, key): 检索键下的值。这个键甚至可以不存在。当在反应中使用时,当该键变为可用时,它将触发重新执行。 -
set(thing, key, value)或set(thing, { key: value }): 为键设置一个值。第二种形式更适合一次设置多个键-值对。在概念上,它与Object.assign()非常相似,但增加了响应性。 -
has(thing, key): 返回一个布尔值,指示键是否存在。 -
remove(thing, key): 删除给定的键及其值。 -
values(thing): 给出一个值数组。 -
keys(thing): 返回包含所有键的数组。请注意,这仅适用于可观察对象和映射。 -
entries(thing): 返回一个键值对数组,其中每对是两个元素的数组([key, value])。
以下代码片段练习了所有这些 API:
import {
autorun,
observable,
set,
get,
has,
toJS,
runInAction,
remove,
values,
entries,
keys,
} from 'mobx';
class Todo {
@observable description = '';
@observable done = false;
constructor(description) {
this.description = description;
}
}
const firstTodo = new Todo('Write Chapter');
const todos = observable.array([firstTodo]);
const todosMap = observable.map({
'Write Chapter': firstTodo,
});
// Reactions to track changes autorun(() => {
console.log(`metadata present: ${has(firstTodo, 'metadata')}`);
console.log(get(firstTodo, 'metadata'), get(firstTodo, 'user'));
console.log(keys(firstTodo));
});
autorun(() => {
// Arrays
const secondTodo = get(todos, 1);
console.log('Second Todo:', toJS(secondTodo));
console.log(values(todos), entries(todos));
});
// Granular changes runInAction(() => {
set(firstTodo, 'metadata', 'new Metadata');
set(firstTodo, { metadata: 'meta update', user: 'Pavan Podila' });
set(todos, 1, new Todo('Get it reviewed'));
});
runInAction(() => {
remove(firstTodo, 'metadata');
remove(todos, 1);
});
通过使用这些 API,您可以针对可观察对象的特定属性并根据需要进行更新。使用对象 API 读取和写入不存在的键被认为是有效的。请注意,我们在autorun()中读取firstTodo的metadata属性,这在调用时并不存在。然而,由于使用了get()API,MobX 仍然跟踪这个键。当我们在操作中稍后set()了metadata时,autorun()会重新触发以在控制台上打印出它。
这可以在以下控制台输出中看到。请注意,当移除时,metadata检查从false变为true,然后再变回false:
metadata present: false undefined undefined (2) ["description", "done"] Second Todo: undefined [Todo] [Array(2)] metadata present: true meta update Pavan Podila (4) ["description", "done", "metadata", "user"] Second Todo: {description: "Get it reviewed", done: false} (2) [Todo, Todo] (2) [Array(2), Array(2)] metadata present: false undefined "Pavan Podila" (3) ["description", "done", "user"] Second Todo: undefined [Todo] [Array(2)]
从 MobX 到 JavaScript
所有的可观察类型都是由 MobX 创建的特殊类,它们不仅存储数据,还有一堆用来跟踪变化的杂事。我们将在后面的章节中探讨这些杂事,但就我们现在的讨论而言,这些 MobX 类型并不总是与其他第三方 API 兼容,特别是在使用 MobX 4 时。
当与外部库进行接口时,您可能需要发送原始的 JavaScript 值,而不是 MobX 类型的值。这就是您需要toJS()函数的地方。它将 MobX 可观察对象转换为原始的 JavaScript 值:
toJS(source, options?)
source: 任何可观察的盒子、对象、数组、映射或基元。
options: 一个可选参数,用于控制行为,例如:
-
exportMapsAsObject(boolean): 是否将可观察的映射序列化为对象(当为true时)或 JavaScript 映射(当为false时)。默认为true。 -
detectCycles(boolean): 默认设置为true。它在序列化过程中检测循环引用,并重用已经序列化的对象。在大多数情况下,这是一个很好的默认设置,但出于性能原因,当你确定没有循环引用时,可以将其设置为false。
toJS()的一个重要注意点是它不会序列化computed properties。这是有道理的,因为它纯粹是可以随时重新计算的派生信息。toJS()的目的是仅序列化核心 observable 状态。同样,observable 的任何不可枚举属性都不会被序列化,也不会递归到任何非 observable 的数据结构中。
在下面的例子中,你可以看到toJS() API 是如何应用于 observables 的:
const number = observable.box(10);
const cart = observable({
items: [{ title: 'milk', quantity: 2 }, { title: 'eggs', quantity: 3 }],
});
console.log(toJS(number));
console.log('MobX type:', cart);
console.log('JS type:', toJS(cart));
控制台输出显示了在应用toJS() API 之前和之后的cart observable。
10 **MobX type: Proxy {Symbol(mobx administration): ObservableObjectAdministration$$1}** **JS type: {items: Array(2)}**
观察事件流动
我们在前几章中看到的 API 允许你创建 observables 并通过reactions对变化做出反应。MobX 还提供了一种方法来连接到内部流动的事件,使得响应式系统能够工作。通过将监听器附加到这些事件,你可以微调一些昂贵资源的使用或控制允许应用于 observables 的更新。
连接到可观察性
通常,reactions是我们读取observables并应用一些副作用的地方。这告诉 MobX 开始跟踪 observable 并在变化时重新触发 reaction。然而,如果我们从 observable 的角度来看,它如何知道它何时被 reaction 使用?它如何在被 reaction 读取时进行一次性设置,并在不再被使用时进行清理?
我们需要的是能够知道何时 observable 变为observed和何时变为unobserved:它在 MobX 响应式系统中变为活动和非活动的两个时间点。为此,我们有以下恰如其名的 APIs:
-
disposer = onBecomeObserved(observable, property?: string, listener: () => void) -
disposer = onBecomeUnobserved(observable, property?: string, listener: () => void)
observable:可以是一个包装的 observable,一个 observable 对象/数组/映射。
property: 可观察对象的可选属性。指定属性与直接引用属性有根本的不同。例如,onBecomeObserved(cart, 'totalPrice', () => {})与onBecomeObserved(cart.totalPrice, () => {})是不同的。在第一种情况下,MobX 将能够跟踪可观察属性,但在第二种情况下,它不会,因为它只接收值而不是属性。事实上,MobX 将抛出一个Error,指示在cart.totalPrice的情况下没有东西可跟踪:
Error: [mobx] Cannot obtain atom from 0
前面的错误现在可能没有太多意义,特别是原子一词。我们将在第九章 Mobx Internals中更详细地了解原子。
disposer: 这些处理程序的返回值。这是一个函数,可用于处理这些处理程序并清理事件连接。
以下代码片段展示了这些 API 的使用:
import {
onBecomeObserved,
onBecomeUnobserved,
observable,
autorun,
} from 'mobx';
const obj = observable.box(10);
const cart = observable({
items: [],
totalPrice: 0,
});
onBecomeObserved(obj, () => {
console.log('Started observing obj');
});
onBecomeUnobserved(obj, () => {
console.log('Stopped observing obj');
});
onBecomeObserved(cart, 'totalPrice', () => {
console.log('Started observing cart.totalPrice');
});
onBecomeUnobserved(cart, 'totalPrice', () => {
console.log('Stopped observing cart.totalPrice');
});
const disposer = autorun(() => {
console.log(obj.get(), `Cart total: ${cart.totalPrice}`);
});
setTimeout(disposer);
obj.set(20);
cart.totalPrice = 100;
在前面的代码片段中,当autorun()第一次执行时,onBecomeObserved()处理程序将被调用。调用disposer函数后,将调用onBecomeUnobserved()处理程序。这可以在以下控制台输出中看到:
Started observing obj Started observing cart.totalPrice 10 "Cart total: 0" 20 "Cart total: 0" 20 "Cart total: 100" Stopped observing cart.totalPrice Stopped observing obj
onBecomeObserved()和onBecomeUnobserved()是延迟设置(和清除)可观察对象的绝佳钩子,可以在首次使用(和最后一次使用)时进行。这在某些情况下非常有用,例如可能需要执行昂贵的操作来设置可观察对象的初始值。此类操作可以通过推迟执行,直到实际上某处使用它时才执行。
延迟加载温度
让我们举一个例子,我们将延迟加载城市的温度,但只有在访问时才加载。这可以通过使用onBecomeObserved()和onBecomeUnobserved()的钩子对可观察属性进行建模来实现。以下代码片段展示了这一点:
// A mock service to simulate a network call to a weather API const temperatureService = {
fetch(location) {
console.log('Invoked temperature-fetch');
return new Promise(resolve =>
setTimeout(resolve(Math.round(Math.random() * 35)), 200),
);
},
};
class City {
@observable temperature;
@observable location;
interval;
disposers;
constructor(location) {
this.location = location;
const disposer1 = onBecomeObserved(
this,
'temperature',
this.onActivated,
);
const disposer2 = onBecomeUnobserved(
this,
'temperature',
this.onDeactivated,
);
this.disposers = [disposer1, disposer2];
}
onActivated = () => {
this.interval = setInterval(() => this.fetchTemperature(), 5000);
console.log('Temperature activated');
};
onDeactivated = () => {
console.log('Temperature deactivated');
this.temperature = undefined;
clearInterval(this.interval);
};
fetchTemperature = flow(function*() {
this.temperature = yield temperatureService.fetch(this.location);
});
cleanup() {
this.disposers.forEach(disposer => disposer());
this.disposers = undefined;
}
}
const city = new City('Bengaluru');
const disposer = autorun(() =>
console.log(`Temperature in ${city.location} is ${city.temperature}ºC`),
);
setTimeout(disposer, 15000);
前面的控制台输出显示了temperature可观察对象的激活和停用。它在autorun()中被激活,15 秒后被停用。我们在onBecomeObserved()处理程序中启动定时器来不断更新温度,并在onBecomeUnobserved()处理程序中清除它。定时器是我们管理的资源,只有在访问temperature之后才会创建,而不是之前:
Temperature activated Temperature in Bengaluru is undefinedºC Invoked temperature-fetch Temperature in Bengaluru is 22ºC Invoked temperature-fetch Temperature in Bengaluru is 32ºC Invoked temperature-fetch Temperature in Bengaluru is 4ºC Temperature deactivated
变化的守门人
您对 observable 所做的更改不会立即应用于 MobX。相反,它们经过一层拦截器,这些拦截器有能力保留变化、修改变化,甚至完全丢弃变化。这一切都可以通过intercept()API 实现。签名与onBecomeObserved和onBecomeUnobserved非常相似,回调函数(interceptor)给出了 change 对象:
disposer = intercept(observable, property?, interceptor: (change) => change | null )
observable:一个封装的 observable 或 observable 对象/数组/映射。
property:要拦截的 observable 的可选字符串名称。就像我们之前在onBecomeObserved和onBecomeUnobserved中看到的那样,对于intercept(cart, 'totalPrice', (change) => {})和intercept(cart.totalPrice, () => {})有所不同。对于后者(cart.totalPrice),您拦截的是一个值而不是 observable 属性。MobX 将抛出错误,指出您未传递正确的类型。
interceptor:一个回调函数,接收 change 对象并期望返回最终的变化;原样应用、修改或丢弃(null)。在拦截器中抛出错误也是有效的,以通知异常更新。
disposer:返回一个函数,当调用时将取消此拦截器。这与我们在onBecomeObserved()、onBecomeUnobserved()以及autorun()、reaction()和when()中看到的非常相似。
拦截变化
接收到的 change 参数具有一些已知字段,提供了详细信息。其中最重要的是type字段,它告诉您变化的类型,以及object,它给出了发生变化的对象。根据type,一些其他字段为变化添加了更多的上下文:
-
type:可以是 add、delete 或 update 之一 -
object:一个封装的 observable 或 observable 对象/数组/映射实例 -
newValue:当类型为 add 或 update 时,此字段包含新值 -
oldValue:当类型为 delete 或 update 时,此字段携带先前的值
在拦截器回调中,您有机会最终确定您实际想要应用的变化类型。您可以执行以下操作之一:
-
返回 null 并丢弃变化
-
使用不同的值进行更新
-
抛出指示异常值的错误
-
原样返回并应用变化
让我们举一个拦截主题更改并确保只应用有效更新的示例。在下面的片段中,您可以看到我们如何拦截主题可观察对象的color属性。颜色可以是light或dark,也可以是l或d的简写值。对于任何其他值,我们会抛出错误。我们还防止取消颜色的设置,通过返回null并丢弃更改:
import { intercept, observable } from 'mobx';
const theme = observable({
color: 'light',
shades: [],
});
const disposer = intercept(theme, 'color', change => {
console.log('Intercepting:', change);
// Cannot unset value, so discard this change
if (!change.newValue) {
return **null**;
}
// Handle shorthand values
const newTheme = change.newValue.toLowerCase();
if (newTheme === 'l' || newTheme === 'd') {
change.newValue = newTheme === 'l' ? 'light' : 'dark'; // set
the correct value
return change;
}
// check for a valid theme
const allowedThemes = ['light', 'dark'];
const isAllowed = allowedThemes.includes(newTheme);
if (!isAllowed) {
**throw** new Error(`${change.newValue} is not a valid theme`);
}
return change; // Correct value so return as-is });
观察()变化
作为intercept()对应的实用程序是observe()。正如其名称所示,observe()允许您对可观察对象进行细粒度观察:
observe(observable, property?, observer: (change) => {})
签名与intercept()完全相同,但行为完全不同。observe()在可观察对象被应用更改后被调用。
一个有趣的特点是observe()对事务是免疫的。这意味着观察者回调会在突变后立即被调用,而不是等到事务完成。正如您所知,actions是发生突变的地方。MobX 通过触发它们来优化通知,但只有在顶层action完成后才会触发。使用observe(),您可以在突变发生时获得未经过滤的视图。
建议在感觉需要observe()时使用autorun()。仅在您认为需要立即通知突变时使用它。
以下示例显示了在突变可观察对象时您可以观察到的各种细节。正如您所看到的,change参数与intercept()完全相同:
import { observe, observable } from 'mobx';
const theme = observable({
color: 'light',
shades: [],
});
const disposer = observe(theme, 'color', change => {
console.log(
`Observing ${change.type}`,
change.oldValue,
'-->',
change.newValue,
'on',
change.object,
);
});
theme.color = 'dark';
开发工具
随着应用程序功能的增加,了解 MobX 反应系统的使用方式和时间变得必不可少。MobX 配备了一组调试工具,帮助您监视和跟踪其中发生的各种活动。这些工具为您提供了系统内所有可观察变化、操作和反应的实时视图。
使用 spy()跟踪反应性
之前,我们看到了observe()函数,它允许您对单个可观察对象发生的变化进行*"观察"*。但是,如果您想观察跨所有可观察对象发生的变化,而不必单独设置observe()处理程序,该怎么办?这就是spy()发挥作用的地方。它让您了解系统中各种可观察对象随时间变化的情况:
disposer = spy(listener: (event) => { })
它接受一个监听函数,该函数接收携带所有细节的事件对象。事件具有与observe()处理程序非常相似的属性。有一个type字段告诉您事件的类型。类型可以是以下之一:
-
update:对于对象、数组、映射
-
add:对于对象、数组、映射
-
delete:对于映射
-
create:对于包装的可观察对象
-
action:当动作触发时
-
reaction:在执行
autorun()、reaction()或when()时 -
compute:对于计算属性
-
error:在操作或反应内捕获任何异常的情况下
这是一小段设置spy()并将输出打印到控制台的代码片段。我们还将在五秒后取消此间谍:
import { spy } from 'mobx';
const disposer = spy(event => console.log(event));
setTimeout(disposer, 5000);
// Console output
{type: "action", name: "<unnamed action>", object: undefined, arguments: Array(0), **spyReportStart**: true} {type: "update", object: BookSearchStore, oldValue: 0, name: "BookSearchStore@1", newValue: 2179, …} {**spyReportEnd**: true} {object: Proxy, type: "splice", index: 0, removed: Array(0), added: Array(20), …} {spyReportEnd: true} {type: "update", object: BookSearchStore, oldValue: Proxy, name: "BookSearchStore@1", newValue: Proxy, …} {spyReportEnd: true} {type: "update", object: BookSearchStore, oldValue: "pending", name: "BookSearchStore@1", newValue: "completed", …}
一些间谍事件可能伴随着spyReportStart或spyReportEnd属性。这些标记了一组相关的事件。
在开发过程中直接使用spy()可能不是最佳选择。最好依赖于可视化调试器(在下一节中讨论),它利用spy()来为您提供更可读的日志。请注意,当您将NODE_ENV环境变量设置为*"production"时,对spy()的调用在生产构建中将是无操作*。
跟踪反应
虽然spy()可以让您观察 MobX 中发生的所有更改,但trace()是一个专门针对计算属性、反应和组件渲染的实用程序。您可以通过简单地在其中放置一个trace()语句来找出为什么会调用计算属性、反应或组件渲染:
trace(thing?, property?, enterDebugger?)
它有三个可选参数:
-
thing:一个可观察对象 -
property:一个可观察属性 -
enterDebugger:一个布尔标志,指示您是否希望自动步入调试器
通常会使用trace(true)来调用跟踪,这将在调用时暂停在调试器内。对于书搜索示例(来自第三章,使用 MobX 的 React 应用),我们可以直接在SearchTextField组件的render()内放置一个跟踪语句:
import { trace } from 'mobx';
@inject('store')
@observer export class SearchTextField extends React.Component {
render() {
trace(true);
/* ... */
}
}
当调试器暂停时,您将获得为什么执行了此计算属性、反应或渲染的完整根本原因分析。在 Chrome 开发工具中,您可以看到这些细节如下:
Chrome 开发工具上的详细信息
使用 mobx-react-devtools 进行可视化调试
spy()和trace()非常适合深入了解 MobX 响应式系统的代码级别。然而,在开始分析性能改进时,可视化调试非常方便。MobX 有一个名为mobx-react-devtools的姊妹 NPM 包,它提供了一个简单的<DevTools />组件,可以帮助您可视化组件树如何对可观察对象做出反应。通过在应用程序顶部包含此组件,您将在运行时看到一个工具栏:
import DevTools from 'mobx-react-devtools';
import React from 'react';
export class MobXBookApp extends React.Component {
render() {
return (
<Fragment>
<DevTools />
<RootAppComponent />
</Fragment>
);
}
}
下面的屏幕截图显示了 MobX DevTools 工具栏出现在屏幕的右上角!
通过启用按钮,您可以看到哪些组件在可观察值发生变化时进行渲染,查看连接到 DOM 元素的可观察值的依赖树,并在操作/反应执行时打印控制台日志。组件在渲染时会闪烁一个彩色矩形。矩形的颜色表示渲染所需的时间,绿色表示最快,红色表示最慢。您可以观察闪烁的矩形,以确保只有您打算更改的部分重新渲染。这是识别不必要渲染的组件并可能创建更精细的观察者的好方法。
mobx-react-devtools包依赖于spy()来打印执行操作和反应的控制台日志。
其他一些 API
MobX 提供了一些不太常用的杂项 API。为了完整起见,这里还是值得一提的。
查询响应式系统
在处理 MobX 中的各种抽象(可观察值、操作、反应)时,有时需要知道某个对象、函数或值是否属于某种类型。MobX 有一组isXXX API,可以帮助您确定值的类型:
-
isObservableObject(thing),isObservableArray(thing),isObservableMap(thing): 告诉你传入的值是否是可观察的对象、数组或映射 -
isObservable(thing)和isObservableProp(thing, property?):类似于前面的点,但更一般化地检查可观察值 -
isBoxedObservable(thing): 值是否是一个包装的可观察值 -
isAction(func): 如果函数被操作包装,则返回true -
isComputed(thing)和isComputedProp(thing, property?):检查值是否是计算属性
深入了解响应式系统
MobX 在内部构建了一个反应性的结构,保持所有的可观察对象和反应都连接在一起。我们将在第九章 Mobx Internals中探索这些内部结构,那里我们将看到某些术语的提及,比如atoms。现在,让我们快速看一下这些 API,它们为您提供了可观察对象和反应的内部表示。
-
getAtom(thing, property?):在每个可观察对象的核心是一个Atom,它跟踪依赖于可观察值的观察者。它的目的是在任何人读取或写入可观察值时报告。通过此 API,您可以获取支持可观察对象的Atom的实例。 -
getDependencyTree(thing, property?):这为您提供了给定对象依赖的依赖树。它可用于获取计算属性或反应的依赖关系。 -
getObserverTree(thing, property?):这是getDependencyTree()的对应物,它为您提供了依赖于给定对象的观察者。
摘要
尽管 MobX 有一个精简的外层 API,但也有一组 API 用于更精细的观察和变化。我们看到了如何使用 Object API 来对可观察树进行非常精确的更改。通过observe()和intercept(),您可以跟踪可观察对象中发生的更改,并拦截以修改更改。
spy()和trace()在调试期间是您的朋友,并与mobx-react-devtools配合使用,您可以获得一个用于识别和改进渲染性能的可视化调试器。这些工具和实用程序为您提供了丰富的开发人员体验(DX),在使用 MobX 时非常有用。
在第八章 探索 mobx-utils 和 mobx-state-tree中,我们将提高使用 MobX 与特殊包mobx-utils和mobx-state-tree的水平。
第八章:探索 mobx-utils 和 mobx-state-tree
当你开始深入了解 MobX 的世界时,你会意识到某些类型的用例经常重复出现。第一次解决它们时,会有一种明确的成就感。然而,第五次之后,你会想要标准化解决方案。mobx-utils是一个 NPM 包,为你提供了几个标准实用程序,用于处理 MobX 中的常见用例。
为了进一步推动标准化水平,我们可以将更多结构化的意见引入我们的 MobX 解决方案中。这些意见是在多年的 MobX 使用中形成的,并包含了快速开发的各种想法。这一切都可以通过mobx-state-tree NPM 包实现。
在本章中,我们将更详细地介绍以下包:
-
mobx-utils提供了一系列实用功能 -
mobx-state-tree(MST)是一个有意见的 MobX
技术要求
你需要在系统上安装 Node.js。最后,要使用本书的 Git 存储库,用户需要安装 Git。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Mobx-Quick-Start-Guide/tree/master/src/Chapter08
查看以下视频,了解代码的实际操作:
mobx-utils 的实用功能
mobx-utils提供了各种实用功能,可以简化 MobX 中的编程任务。你可以使用npm或yarn安装mobx-utils:
$ npm install mobx-utils
在本节的其余部分,我们将重点介绍一些经常使用的实用程序。其中包括以下内容:
-
fromPromise() -
lazyObservable() -
fromResource() -
now() -
createViewModel()
使用 fromPromise()可视化异步操作
在 JavaScript 中,promise 是处理异步操作的好方法。在表示 React UI 上的操作状态时,我们必须确保处理 promise 的三种状态中的每一种。这包括 promise 处于pending(操作进行中)状态时,fulfilled(操作成功完成)状态时,或者rejected(失败)状态时。fromPromise()是处理 promise 的一种便利方式,并提供了一个很好的 API 来直观地表示这三种状态:
newPromise = fromPromise(promiseLike)
promiseLike:Promise的实例或(resolve, reject) => { }
fromPromise()包装给定的 promise,并返回一个新的、带有额外可观察属性的、MobX 充电的 promise:
-
state:三个字符串值之一:pending、fulfilled或rejected:这些也作为mobx-utils包的常量可用:mobxUtils.PENDING、mobxUtils.FULFILLED和mobxUtils.REJECTED。 -
value:已解析的value或rejected错误。使用state来区分值。 -
case({pending, fulfilled, rejected}):这用于为三种状态提供 React 组件。
让我们通过一个例子来看看所有这些。我们将创建一个简单的Worker类,执行一些操作,这些操作可能会随机失败。这是跟踪操作的Worker类,通过调用fromPromise()来调用操作。请注意,我们将一个promise作为参数传递给fromPromise():
import { fromPromise, PENDING, FULFILLED, REJECTED } from 'mobx-utils';
class Worker {
operation = null;
start() {
this.operation = fromPromise(this.performOperation());
}
performOperation() {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
clearTimeout(timeoutId);
Math.random() > 0.25 ? resolve('200 OK')
: reject(new Error('500 FAIL'));
}, 1000);
});
}
}
为了可视化这个操作,我们可以利用case() API 来显示每个状态对应的 React 组件。这可以在以下代码中看到。随着操作从pending到fulfilled或rejected的进展,这些状态将以正确的 React 组件呈现。对于fulfilled和rejected状态,已解析的value或rejected error作为第一个参数传入:
import { fromPromise, PENDING, FULFILLED, REJECTED } from 'mobx-utils';
import { observer } from 'mobx-react';
import React, { Fragment } from 'react';
import { CircularProgress, Typography } from '@material-ui/core/es/index';
@observer export class FromPromiseExample extends React.Component {
worker;
constructor(props) {
super(props);
this.worker = new Worker();
this.worker.start();
}
render() {
const { operation } = this.worker;
return operation.case({
[PENDING]: () => (
<Fragment>
<CircularProgress size={50} color={'primary'} />
<Typography variant={'title'}>
Operation in Progress
</Typography>
</Fragment>
),
[FULFILLED]: value => (
<Typography variant={'title'} color={'primary'}>
Operation completed with result: {value}
</Typography>
),
[REJECTED]: error => (
<Typography variant={'title'} color={'error'}>
Operation failed with error: {error.message}
</Typography>
),
});
}
}
我们也可以手动切换可观察的state属性,而不是使用case()函数。实际上,case()在内部执行这个操作。
使用lazyObservable()进行延迟更新
对于执行代价高昂的操作,将其推迟到需要时是有意义的。使用lazyObservable(),您可以跟踪这些操作的结果,并在需要时更新。它接受一个执行计算并在准备就绪时推送值的函数:
result = lazyObservable(sink => { }, initialValue)
在这里,sink是要调用的回调函数,将值推送到lazyObservable上。延迟可观察对象也可以以一些initialValue开始。
可以使用result.current()来检索lazyObservable()的当前值。一旦延迟可观察对象已更新,result.current()将有一些值。要再次更新延迟可观察对象,可以使用result.refresh()。这将重新调用计算,并最终通过sink回调推送新值。请注意,sink回调可以根据需要调用多次。
在以下代码片段中,您可以看到使用lazyObservable()来更新操作的值:
import { lazyObservable } from 'mobx-utils';
class ExpensiveWorker {
operation = null;
constructor() {
this.operation = lazyObservable(async sink => {
sink(null); // push an empty value before the update
const result = await this.performOperation();
sink(result);
});
}
performOperation() {
return new Promise(resolve => {
const timeoutId = setTimeout(() => {
clearTimeout(timeoutId);
resolve('200 OK');
}, 1000);
});
}
}
MobX 跟踪对current()方法的调用,因此请确保仅在需要时调用它。在render()中使用此方法会导致 MobX 重新渲染组件。毕竟,组件的render()在 MobX 中转换为 reaction,每当其跟踪的 observable 发生变化时重新评估。
要在 React 组件(observer)中使用 lazy-observable,我们依赖于current()方法来获取其值。MobX 将跟踪此值,并在其更改时重新渲染组件。请注意,在按钮的onClick处理程序中,我们通过调用其refresh()方法来更新 lazy-observable:
import { observer } from 'mobx-react';
import React, { Fragment } from 'react';
import {
Button,
CircularProgress,
Typography,
} from '@material-ui/core/es/index'; **@observer** export class LazyObservableExample extends React.Component {
worker;
constructor(props) {
super(props);
this.worker = new ExpensiveWorker();
}
render() {
const { operation } = this.worker;
const result = operation.current();
if (!result) {
return (
<Fragment>
<CircularProgress size={50} color={'primary'} />
<Typography variant={'title'}>
Operation in Progress
</Typography>
</Fragment>
);
}
return (
<Fragment>
<Typography variant={'title'} color={'primary'}>
Operation completed with result: {result}
</Typography>
<Button
variant={'raised'} color={'primary'} onClick={() => operation.refresh()} >
Redo Operation
</Button>
</Fragment>
);
}
}
使用 fromResource()的通用 lazyObservable()
还有一种更一般化的lazyObservable()形式,称为fromResource()。类似于lazyResource(),它接受一个带有sink回调的函数。这充当订阅函数,仅在实际请求资源时调用。此外,它接受第二个参数,取消订阅函数,可用于在不再需要资源时进行清理:
resource = fromResource(subscriber: sink => {}, unsubscriber: () => {},
initialValue)
fromResource() 返回一个 observable,当第一次调用它的current()方法时,它将开始获取值。它返回一个 observable,还具有dispose()方法来停止更新值。
在下面的代码片段中,您可以看到一个DataService类依赖于fromResource()来管理其 WebSocket 连接。数据的值可以使用data.current()来检索。这里,data充当 lazy-observable。在订阅函数中,我们设置了 WebSocket 并订阅了特定频道。我们在fromResource()的取消订阅函数中取消订阅此频道:
import { **fromResource** } from 'mobx-utils';
class DataService {
data = null;
socket = null;
constructor() {
this.data = fromResource(
async sink => {
this.socket = new WebSocketConnection();
await this.socket.subscribe('data');
const result = await this.socket.get();
sink(result);
},
() => {
this.socket.unsubscribe('data');
this.socket = null;
},
);
}
}
const service = new DataService(); console.log(service.data.current());
// After some time, when no longer needed service.data.dispose();
我们可以使用dispose()方法显式处理资源。但是,MobX 足够聪明,知道没有更多观察者观察此资源时,会自动调用取消订阅函数。
mobx-utils 提供的一种特殊类型的 lazy-observable 是now(interval: number)。它将时间视为 observable,并以给定的间隔更新。您可以通过简单调用now()来检索其值,默认情况下每秒更新一次。作为 observable,它还会导致任何 reaction 每秒执行一次。在内部,now()使用fromResource()实用程序来管理计时器。
管理编辑的视图模型
在基于数据输入的应用程序中,通常会有表单来接受各种字段。在这些表单中,原始模型直到用户提交表单之前都不会发生变化。这允许用户取消编辑过程并返回到先前的值。这种情况需要创建原始模型的克隆,并在提交时推送编辑。尽管这种技术并不是非常复杂,但它确实增加了一些样板文件。
mobx-utils提供了一个方便的实用程序,名为createViewModel(),专门为这种情况量身定制:
viewModel = createViewModel(model)
model是包含可观察属性的原始模型。createViewModel()包装了这个模型并代理了所有的读取和写入。这个实用程序具有一些有趣的特性,如下:
-
只要
viewModel的属性没有更改,它将返回原始模型中的值。更改后,它将返回更新后的值,并将viewModel视为已修改。 -
要最终确定原始模型上的更新值,必须调用
viewModel的submit()方法。要撤消任何更改,可以调用reset()方法。要恢复单个属性,请使用resetProperty(propertyName: string)。 -
要检查
viewModel是否被修改,请使用isDirty属性。要检查单个属性是否被修改,请使用isPropertyDirty(propertyName: string)。 -
要获取原始模型,请使用方便的
model()方法。
使用createViewModel()的优势在于,您可以将整个编辑过程视为单个事务。只有在调用submit()时才是最终的。这允许您过早取消并保留原始模型在其先前状态。
在以下示例中,我们正在创建一个包装FormData实例并记录viewModel和model属性的viewModel。您将注意到viewModel的代理效果以及值在submit()时如何传播回模型:
class FormData {
@observable name = '<Unnamed>';
@observable email = '';
@observable favoriteColor = '';
}
const viewModel = createViewModel(new FormData());
autorun(() => {
console.log(
`ViewModel: ${viewModel.name}, Model: ${
viewModel.model.name
}, Dirty: ${viewModel.isDirty}`,
);
});
viewModel.name = 'Pavan';
viewModel.email = 'pavan@pixelingene.com';
viewModel.favoriteColor = 'orange';
console.log('About to reset');
viewModel.reset();
viewModel.name = 'MobX';
console.log('About to submit');
viewModel.submit();
autorun()的日志如下。您可以看到submit()和reset()对viewModel.name属性的影响:
ViewModel: <Unnamed>, Model: <Unnamed>, Dirty: false ViewModel: Pavan, Model: <Unnamed>, Dirty: true About to reset... ViewModel: <Unnamed>, Model: <Unnamed>, Dirty: false ViewModel: MobX, Model: <Unnamed>, Dirty: true About to submit... ViewModel: MobX, Model: MobX, Dirty: false
还有很多可以发现的地方
这里描述的一些实用程序绝不是详尽无遗的。mobx-utils提供了更多实用程序,我们强烈建议您查看 GitHub 项目(github.com/mobxjs/mobx-utils)以发现其余的实用功能。
有一些函数可以在 RxJS 流和 MobX Observables 之间进行转换,processor-functions可以在可观察数组附加时执行操作,MobX 的变体when(),它在超时后自动释放,等等。
一个有主见的 MobX 与 mobx-state-tree
MobX 在组织状态和应用各种操作和反应方面非常灵活。然而,它确实留下了一些问题需要你来回答:
-
应该使用类还是只是带有
extendObservable()的普通对象? -
数据应该如何规范化?
-
在序列化状态时如何处理循环引用?
-
还有很多
mobx-state-tree是一个提供了组织和结构化可观察状态的指导性指导的包。采用 MST 的思维方式会让你从一开始就获得几个好处。在本节中,我们将探讨这个包及其好处。
模型 - 属性、视图和操作
mobx-state-tree正如其名称所示,将状态组织在模型树中。这是一种以模型为先的方法,其中每个模型定义了需要捕获的状态。定义模型添加了在运行时对模型分配进行类型检查的能力,并保护您免受无意的更改。将运行时检查与像 TypeScript 这样的语言的使用结合起来,还可以获得编译时(或者说是设计时)类型安全性。通过严格类型化的模型,mobx-state-tree为您提供了安全的保证,并确保了您的类型模型的完整性和约束。这本身就是一个巨大的好处,特别是在处理像 JavaScript 这样的动态语言时。
让我们用一个简单的Todo模型来实现 MST:
import { types } from 'mobx-state-tree';
const Todo = types.model('Todo', {
title: types.string,
done: false,
});
模型描述了它所持有的数据的形状。在Todo模型的情况下,它只需要一个title string和一个boolean done属性。请注意,我们将我们的模型分配给了一个大写的名称(Todo)。这是因为 MST 实际上定义了一个类型,而不是一个实例。
MST 中的所有内置类型都是types命名空间的一部分。types.model()方法接受两个参数:一个可选的字符串name(用于调试和错误报告)和一个定义类型各种属性的object。所有这些属性都将被严格类型化。让我们尝试创建这个模型的一个实例:
const todo = Todo.create({
title: 'Read a book',
done: false,
});
请注意,我们已经将与模型中定义的相同的数据结构传递给了 Todo.create()。传递任何其他类型的数据将导致 MST 抛出类型错误。创建模型的实例也使其所有属性变成了可观察的。这意味着我们现在可以使用 MobX API 的全部功能。
让我们创建一个简单的反应,记录对 todo 实例的更改:
import { autorun } from 'mobx';
autorun(() => {
console.log(`${todo.title}: ${todo.done}`);
});
// Toggle the done flag todo.done = !todo.done;
如果您运行此代码,您会注意到一个异常被抛出,如下所示:
Error: [mobx-state-tree] Cannot modify 'Todo@<root>', the object is protected and can only be modified by using an action.
这是因为我们在动作之外修改了 todo.done 属性。您会回忆起前几章中的一个良好实践是将所有可观察的变化封装在一个动作内。事实上,甚至有一个 MobX API:configure({ enforceActions: 'strict' }),以确保这种情况发生。MST 对其状态树中的数据非常保护,并要求对所有变化使用动作。
这可能听起来非常严格,但它确实带来了额外的好处。例如,使用动作允许 MST 提供对中间件的一流支持。中间件可以拦截发生在状态树上的任何更改,并使实现诸如日志记录、时间旅行、撤销/重做、数据库同步等功能变得微不足道。
在模型上定义动作
我们之前创建的模型类型 Todo 可以通过链接的 API 进行扩展。actions() 就是这样一个 API,可以用来扩展模型类型的所有动作定义。让我们为我们的 Todo 类型做这件事:
const Todo = types
.model('Todo', {
title: types.string,
done: false,
})
.actions(self => ({
toggle() {
self.done = !self.done;
},
}));
const todo = Todo.create({
title: 'Read a book',
done: false,
});
autorun(() => {
console.log(`${todo.title}: ${todo.done}`);
});
todo.toggle();
actions() 方法接受一个函数作为参数,该函数接收模型实例作为其参数。在这里,我们称之为 self。这个函数应该返回一个定义所有动作的键值映射。在前面的片段中,我们利用了 ES2015 的对象字面量语法,使动作对象看起来更易读。接受动作的这种风格有一些显著的好处:
-
使用函数允许您创建一个闭包,用于跟踪只被动作使用的私有状态。例如,设置在其中一个动作内部的 WebSocket 连接,不应该暴露给外部世界。
-
通过将模型的实例传递给
actions(),您可以保证this指针始终是正确的。您再也不必担心在actions()中定义的函数的上下文了。toggle()动作利用self来改变模型实例。
定义的 actions 可以直接在模型实例上调用,这就是我们在todo.toggle()中所做的。MST 不再对直接突变提出异议,当todo.done改变时,autorun()也会触发。
使用视图创建派生信息
与 actions 类似,我们还可以使用views()扩展模型类型。在 MST 中,模型中的派生信息是使用views()来定义的。就像actions()方法一样,它可以链接到模型类型上:
const Todo = types
.model(/* ... */)
.actions(/* ... */)
.views(self => ({
get asMarkdown() {
return self.done
? `* [x] ~~${self.title}~~`
: `* [ ] ${self.title}`;
},
contains(text) {
return self.title.indexOf(text) !== -1;
},
})); const todo = Todo.create({
title: 'Read a book',
done: false,
});
autorun(() => {
console.log(`Title contains "book"?: ${todo.contains('book')}`);
});
console.log(todo.asMarkdown);
// * [ ] Read a book
console.log(todo.contains('book')); // true
在Todo类型上引入了两个视图:
-
asMarkdown()是一个getter,它转换为一个 MobX 计算属性。像每个计算属性一样,它的输出被缓存。 -
contains()是一个常规函数,其输出不被缓存。然而,它具有在响应式上下文中重新执行的能力,比如reaction()或autorun()。
mobx-state-tree引入了一个非常严格的模型概念,其中明确定义了state、actions和derivations。如果你对在 MobX 中构建代码感到不确定,MST 可以帮助你应用 MobX 的理念并提供清晰的指导。
微调原始类型
到目前为止我们所看到的单一模型类型只是一个开始,几乎不能称为树。我们可以扩展领域模型使其更加真实。让我们添加一个User类型,他将创建todo项目:
import { types } from 'mobx-state-tree';
const User = types.model('User', {
name: types.string,
age: 42,
twitter: types.maybe(types.refinement(types.string, v =>
/^\w+$/.test(v))),
});
在前面的定义中有一些有趣的细节,如下所示:
-
age属性被定义为常量42,这对应于age的默认值。当没有为用户提供值时,它将被设置为这个默认值。此外,MST 足够聪明,可以推断类型为number。这对于所有原始类型都适用,其中默认值的类型将被推断为属性的类型。此外,通过给出默认值,我们暗示age属性是可选的。声明属性的更详细形式是:types.optional(types.number, 42)。 -
twitter属性有一个更复杂的定义,但可以很容易地分解。types.maybe()表明twitter句柄是可选的,因此它可能是undefined。当提供值时,它必须是字符串类型。但不是任何字符串;只有与提供的正则表达式匹配的字符串。这为您提供了运行时类型安全性,并拒绝无效的 Twitter 句柄,如Calvin & Hobbes或空字符串。
MST 提供的类型系统非常强大,可以处理各种复杂的类型规范。它还很好地组合,并为您提供了一种将许多较小类型组合成较大类型定义的功能方法。这些类型规范为您提供了运行时安全性,并确保了您的领域模型的完整性。
组合树
现在我们有了Todo和User类型,我们可以定义顶层的App类型,它组合了先前定义的类型。App类型代表应用程序的状态。
const App = types.model('App', {
todos: types.array(Todo),
users: types.map(User),
});
const app = App.create({
todos: [
{ title: 'Write the chapter', done: false },
{ title: 'Review the chapter', done: false },
],
users: {
michel: {
name: 'Michel Westrate',
twitter: 'mwestrate',
},
pavan: {
name: 'Pavan Podila',
twitter: 'pavanpodila',
},
},
});
app.todos[0].toggle();
我们通过使用高阶类型(接受类型作为输入并创建新类型的类型)定义了App类型。在前面的片段中,types.map()和types.array()创建了这些高阶类型。
创建App类型的实例只是提供正确的 JSON 负载的问题。只要结构与类型规范匹配,MST 在运行时构建模型实例时就不会有问题。
记住:数据的形状始终会被 MST 验证。它永远不会允许不符合模型类型规范的数据更新。
请注意在前面的片段中,我们能够无缝调用app.todos[0].toggle()方法。这是因为 MST 能够成功构建app实例并用适当的类型包装 JSON 节点。
mobx-state-tree提升了对建模应用程序状态的重要性。为应用程序中的各种实体定义适当的类型对于其结构和数据完整性至关重要。一个很好的开始方式是将从服务器接收到的 JSON 编码为 MST 模型。下一步是通过添加更严格的类型、附加操作和视图来加强模型。
引用和标识符
到目前为止,本章一直在完全讨论在树中捕获应用程序的状态。树具有许多有趣的属性,易于理解和探索。但通常,当一个人开始将新技术应用于实际问题领域时,往往会发现树在概念上不足以描述问题领域。例如,友谊关系是双向的,不适合单向树。处理不是组合性质的关系,而是关联性质的关系,通常需要引入新的抽象层和技术,如数据规范化。
我们的应用程序中可以通过为Todo添加一个assignee属性来快速介绍这种关系。现在,很明显Todo并不拥有它的assignee,反之亦然;todos不是由单个用户拥有的,因为它们可以稍后被重新分配。因此,当组合不足以描述关系时,我们经常会退回到使用外键来描述关系。
换句话说,Todo项的 JSON 可以像下面的代码一样,其中Todo的assignee字段对应于User对象的userid字段:
使用name来存储assignee关系是一个坏主意,因为一个人的name并不是唯一的,而且它可能随时间改变。
{
todos: [
{
title: 'Learn MST',
done: false,
assignee: '37',
},
],
users: {
'37': {
userid: '37',
name: 'Michel Weststrate',
age: 33,
twitter: 'mweststrate',
},
},
}
我们最初的想法可能是将assignee和userid属性类型化为types.string字段。然后,每当我们需要时,我们可以在users映射中查找指定的用户,因为用户存储在其自己的userid下。由于用户查找可能是一个常见的操作,我们甚至可以引入一个视图和操作来读取或写入该用户。这将使我们的用户模型如下所示的代码所示:
import { types, getRoot } from 'mobx-state-tree';
const User = types.model('User', {
userid: types.string, // uniquely identifies this User name: types.string,
age: 42,
twitter: types.maybe(types.refinement(types.string, v => /^\w+$/.test(v))),
});
const Todo = types
.model('Todo', {
assignee: types.string, // represents a User title: types.string,
done: false,
})
.views(self => ({
getAssignee() {
if (!this.assignee) return undefined;
return getRoot(self).users.get(this.assignee);
},
}))
.actions(self => ({
setAssignee(user) {
if (typeof user === 'string') this.assignee = user;
else if (User.is(user)) this.assignee = user.userid;
else throw new Error('Not a valid user object or user id');
},
}));
const App = {
/* as is */ };
const app = App.create(/* ... */);
console.log(app.todos[0].getAssignee().name); // Michel Weststrate
在getAssignee()视图中,我们方便地利用了每个 MST 节点都知道自己在树中的位置这一事实。通过利用getRoot()实用程序,我们可以导航到users映射并获取正确的User对象。通过使用getAssignee()视图,我们获得了一个真正的User对象,以便我们可以直接访问和打印其name属性。
有几个有用的实用程序可以用来反映或处理树中的位置,例如getPath()、getParent()、getParentOfType()等。作为替代方案,我们可以将getAssignee()视图表示为return resolvePath(self, "../../users/" + self.assignee)。
我们可以将 MST 树视为状态的文件系统!getAssignee()只是将其转换为符号链接。
此外,我们引入了一个更新assignee属性的操作。为了确保setAssignee()操作可以方便地通过提供userid或实际用户对象来调用,我们应用了一些类型区分。在 MST 中,每种类型不仅公开了create()方法,还公开了is方法,以检查给定值是否属于相应的类型。
通过 types.identifier()和 types.reference()进行引用
我们可以在 MST 中清晰地表达这些查找/更新实用程序,这很好,但是如果您的问题领域很大,这将变得相当重复。幸运的是,这种模式内置在 MST 中。我们可以利用的第一种类型是types.identifier(),它表示某个字段唯一标识某个模型类型的实例。因此,在我们的示例中,我们可以将userid的类型定义为types.identifier(),而不是types.string。
其次,还有types.reference()。这种类型表示某个字段被序列化为原始值,但实际上表示对树中另一种类型的引用。MST 将自动为我们匹配identifier字段和reference字段,因此我们可以简化我们之前的状态树模型如下:
import { types } from "mobx-state-tree"
const User = types.model("User", {
userid: types.identifier(), // uniquely identifies this User
name: types.string,
age: 42,
twitter: types.maybe(types.refinement(types.string, (v => /^\w+$/.test(v))))
})
const Todo = types.model("Todo", {
assignee: types.maybe(types.reference(User)), // a Todo can be assigned to a User
title: types.string,
done: false
})
const App = /* as is */
const app = App.create(/* */)
console.log(app.todos[0].assignee.name) // Michel Weststrate
由于引用类型,读取Todo的assignee属性实际上将解析存储的标识符并返回正确的User对象。因此,我们可以立即在前面的示例中打印其名称。请注意,我们的状态仍然是一个树。还要注意的是,我们不必指定User实例的引用应该在何处或如何解析。MST 将自动维护一个内部的基于类型+标识符的查找表来解析引用。通过使用引用和标识符,MST 具有足够的类型信息来自动处理我们的数据(去)规范化。
types.reference非常强大,并且可以自定义,例如根据相对路径(就像真正的符号链接!)而不是标识符解析对象。在许多情况下,您将与上述一样结合types.maybe,以表达Todo不一定有assignee。同样,引用的数组和映射可以以类似的方式建模。
声明性模型的开箱即用的好处
MST 帮助您以声明性方式组织和建模复杂的问题领域。由于在您的领域中定义类型的一致方法,我们得到了清晰简单的心智模型的好处。这种一致性还为我们带来了许多开箱即用的功能,因为 MST 深入了解状态树。我们之前看到的一个例子是使用标识符和引用进行自动数据规范化。MST 内置了许多更多功能。其中,有一些功能在实际中最为实用。我们将在本节的其余部分简要讨论它们。
不可变的快照
MST 始终在内存中保留状态树的不可变版本,可以使用getSnapshot()API 检索。基本上,const snapshot = getSnapshot(tree)是const tree = Type.create(snapshot)的反向操作。getSnapshot()使得快速序列化整个树的状态非常方便。由于 MST 由 MobX 支持,我们也可以很好地跟踪这一点。
快照在模型实例上转换为计算属性。
以下代码片段在每次更改时自动将树的状态存储在local-storage中,但每秒最多一次:
import { reaction } from 'mobx';
import { getSnapshot } from 'mobx-state-tree';
const app = App.create(/* as before */);
reaction(
() => getSnapshot(app),
snapshot => {
window.localStorage.setItem('app', JSON.stringify(snapshot));
},
{ delay: 1000 },
);
应该指出,MST 树中的每个节点本身都是 MST 树。这意味着在根上调用的任何操作也可以在其任何子树上调用。例如,如果我们只想存储整个状态的一部分,我们可以只获取子树的快照。
与getSnapshot()搭配使用的相关 API 是applySnapshot()。这可以用来以高效的方式使用快照更新树。通过结合getSnapshot()和applySnapshot(),你可以只用几行代码就构建一个时间旅行者!这留给读者作为练习。
JSON 补丁
尽管快照有效地捕获了整个应用程序的状态,但它们不适合与服务器或其他客户端频繁通信。这是因为快照的大小与要序列化的状态的大小成线性增长。相反,对于实时更改,最好向服务器发送增量更新。JSON-patch(RFC-6902)是关于如何序列化这些增量更新的官方标准,MST 默认支持此标准。
onPatch()API 可用于监听作为更改副作用生成的patches。另一方面,applyPatch()执行相反的过程:给定一个补丁,它可以更新现有树。onPatch()监听器会生成由操作所做的状态更改产生的patches。它还公开了所谓的inverse-patches:一个可以撤消patches所做更改的集合。
import { onPatch } from 'mobx-state-tree';
const app = App.create(/* see above */);
onPatch(app, (patches, inversePatches) => {
console.dir(patches, inversePatches);
});
app.todos[0].toggle();
切换todo的前面代码在控制台上打印如下内容:
// patches: [{
op: "replace", path: "/todos/0/done", value: true }] // inverse-patches: [{
op: "replace", path: "/todos/0/done", value: false }]
中间件
我们在前面的部分简要提到了中间件,但让我们在这里扩展一下。中间件充当对状态树上调用的操作的拦截器。因为 MST 要求使用操作,我们可以确保每个 操作 都会通过中间件。中间件的存在使得实现几个横切面特性变得微不足道,例如以下内容:
-
日志记录
-
认证
-
时间旅行
-
撤销/重做
事实上,mst-middlewares NPM 包包含了一些先前提到的中间件,以及一些其他中间件。有关这些中间件的更多详细信息,请参阅:github.com/mobxjs/mobx-state-tree/blob/master/packages/mst-middlewares/README.md。
进一步阅读
我们几乎只是触及了 MobX-State-Tree 的表面,但希望它已经在组织和构建 MobX 中的可观察状态方面留下了印象。这是一个明确定义的、社区驱动的方法,它融入了本书中讨论的许多最佳实践。要深入探索 MST,您可以参考官方入门指南:github.com/mobxjs/mobx-state-tree/blob/master/docs/getting-started.md#getting-started。
总结
在本章中,我们涵盖了使用 mobx-utils 和 mobx-state-tree 等包采用 MobX 的实际方面。这些包将社区对于在各种场景中使用 MobX 的智慧编码化。
mobx-utils 为您提供了一组用于处理异步任务、处理昂贵的更新、为事务编辑创建视图模型等的实用工具。
mobx-state-tree 是一个全面的包,旨在简化使用 MobX 进行应用程序开发。它采用规范化的方法来构建和组织 MobX 中的可观察状态。通过这种声明性的方法,MST 能够更深入地理解状态树,并提供各种功能,例如运行时类型检查、快照、JSON 补丁、中间件等。总的来说,它有助于开发对 MobX 应用程序的清晰的心智模型,并将类型域模型置于前沿。
在下一章中,我们将通过一瞥了解 MobX 的内部工作,来完成 MobX 的旅程。如果 MobX 的某些部分看起来像黑魔法,下一章将驱散所有这些神话。
第九章:Mobx 内部
到目前为止我们所看到的 MobX 是从消费者的角度出发的,重点是如何使用它,最佳实践以及处理真实用例的 API。本章将向下一层,并揭示 MobX 响应式系统背后的机制。我们将看到支撑和构成Observables-Actions-Reactions三元组的核心抽象。
本章将涵盖的主题包括以下内容:
-
MobX 的分层架构
-
Atoms 和 ObservableValues
-
Derivations 和 reactions
-
什么是透明函数式响应式编程?
技术要求
您需要在系统上安装 Node.js。最后,要使用本书的 Git 存储库,用户需要安装 Git。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Mobx-Quick-Start-Guide/tree/master/src/Chapter09
查看以下视频以查看代码的运行情况:
分层架构
像任何良好的系统一样,MobX 由各个层构建而成,每个层提供了更高层的服务和行为。如果你把这个视角应用到 MobX 上,你可以从下往上看到这些层:
-
Atoms:Atoms 是 MobX observables 的基础。顾名思义,它们是可观察依赖树的原子部分。它跟踪它的观察者,但实际上不存储任何值。
-
ObservableValue,ComputedValue 和 Derivations:
ObservableValue扩展了Atom并提供了实际的存储。它也是包装 Observables 的核心实现。与此同时,我们有 derivations 和 reactions,它们是原子的观察者。它们对原子的变化做出响应并安排反应。ComputedValue建立在 derivations 之上,也充当一个 observable。 -
Observable{Object, Array, Map}和 APIs:这些数据结构建立在
ObservableValue之上,并使用它来表示它们的属性和值。这也是 MobX 的 API 层,是与库从消费者角度交互的主要手段。
层的分离也在源代码中可见,不同的抽象层有不同的文件夹。这与我们在这里描述的情况并不是一一对应的,但在概念上,这些层在代码中也有很多相似之处。MobX 中的所有代码都是使用 TypeScript 编写的,并得到了一流的支持。
原子
MobX 的响应式系统由存在于可观察对象之间的依赖关系图支持。一个可观察对象的值可能依赖于一组可观察对象,而这些可观察对象又可能依赖于其他可观察对象。例如,一个购物车可以有一个名为description的计算属性,它依赖于它所持有的items数组和应用的任何coupons。在内部,coupons可能依赖于CouponManager类的validCoupons 计算属性。在代码中,这可能看起来像这样:
class Coupon {
@observable isValid = false;
/*...*/ }
class CouponManager {
@observable.ref coupons = [];
@computed
get validCoupons() {
return this.coupons.filter(coupon => coupon.isValid);
}
/*...*/ }
class ShoppingCart {
@observable.shallow items = [];
couponManager = new CouponManager();
@computed
get coupons() {
return this.couponManager.validCoupons;
}
@computed
get description() {
return `Cart has ${this.items.length} item(s) with ${
this.coupons.**length**
} coupon(s) applied.`;
}
/*...*/ }
可视化这组依赖关系可能会给我们一个简单的图表,如下所示:
在运行时,MobX 将创建一个支持依赖树。这棵树中的每个节点都将由Atom的一个实例表示,这是 MobX 的核心构建块。因此,我们可以期望在前面图表中的树中的节点有五个原子。
原子有两个目的:
-
当它被读取时通知。这是通过调用
reportObserved()来完成的。 -
当它被改变时通知。这是通过调用
reportChanged()来完成的。
作为 MobX 响应性结构的一个节点,原子扮演着通知每个节点上发生的读取和写入的重要角色。
在内部,原子会跟踪其观察者并通知它们发生的变化。当调用reportChanged()时会发生这种情况。这里一个明显的遗漏是原子的实际值并没有存储在Atom本身。为此,我们有一个名为ObservableValue的子类,它是建立在Atom之上的。我们将在下一节中看到它。
因此,原子的核心约定包括我们之前提到的两种方法。它还包含一些像observers数组、是否正在被观察等一些管理属性。我们可以在讨论中安全地忽略它们:
class Atom {
observers = [];
reportObserved() {}
reportChanged() {}
/* ... */ }
在运行时读取原子
MobX 还让你能够在运行时看到后台的原子。回到我们之前的计算description属性的例子,让我们探索它的依赖树:
import { autorun, **$mobx**, **getDependencyTree** } from 'mobx';
const cart = new ShoppingCart();
const disposer = autorun(() => {
console.log(cart.description);
});
const descriptionAtom = cart[$mobx].values.get('description'); console.log(getDependencyTree(descriptionAtom));
在前面的片段中有一些细节值得注意:
-
MobX 为您提供了一个特殊的符号
$mobx,其中包含对可观察对象的内部维护结构的引用。cart实例使用cart[$mobx].values维护其所有可观察属性的映射。通过从此映射中读取,可以获得description属性的后备原子:cart[$mobx].values.get('description')。 -
我们可以使用 MobX 公开的
getDependencyTree()函数获取此属性的依赖树。它以Atom作为输入,并返回描述依赖树的对象。
这是description属性的getDependencyTree()的输出。为了清晰起见,已经删除了一些额外的细节。您看到ShoppingCart@16.items被提到两次的原因是因为它指向items(引用)和items.length属性:
{
name: 'ShoppingCart@16.description',
dependencies: [
{ name: 'ShoppingCart@16.items' },
{ name: 'ShoppingCart@16.items' },
{
name: 'ShoppingCart@16.coupons',
dependencies: [
{
name: 'CouponManager@19.validCoupons',
dependencies: [{ name: 'CouponManager@19.coupons' }],
},
],
},
],
};
还有一个方便的 API,getAtom(thing: any, property: string),用于从可观察对象和观察者中读取原子。例如,在我们之前的示例中,我们可以使用getAtom(cart, 'description')来获取description原子,而不是使用特殊符号$mobx并读取其内部结构。getAtom()是从mobx包中导出的。作为练习,找出前一个代码片段中autorun()的依赖树。您可以使用disposer[$mobx]或getAtom(disposer)来获取反应实例。类似地,还有getObserverTree()实用程序,它可以给出依赖于给定可观察对象的观察者。看看您是否可以从支持description属性的原子找到与autorun()的连接。
创建一个原子
作为 MobX 用户,您很少直接使用Atom。相反,您会依赖 MobX 公开的其他便利 API 或数据结构,如ObservableObject、ObservableArray或ObservableMap。然而,现实世界总是会出现一些情况,您可能需要深入了解一些更深层次的内容。
MobX 确实为您提供了一个方便的工厂函数来创建原子,恰当地命名为createAtom():
createAtom(name, onBecomeObservedHandler, onBecomeUnobservedHandler)
-
name(string):原子的名称,由 MobX 中的调试和跟踪工具使用 -
onBecomeObservedHandler(()=> {}):当原子首次被观察时通知的回调函数 -
onBecomeUnobservedHandler(()=> {}):当原子不再被观察时通知的回调函数
onBecomeObserved和onBecomeUnobserved是原子在响应系统中变为活动和非活动的两个时间点。这些通常用于资源管理,分别用于设置和拆除。
原子钟示例
让我们看一个使用Atom的例子,也说明了原子如何参与响应系统。我们将创建一个简单的时钟,当原子被观察时开始滴答,并在不再被观察时停止。实质上,我们这里的资源是由Atom管理的计时器(时钟):
import { createAtom, autorun } from 'mobx';
class Clock {
constructor() {
this.atom = createAtom(
'Clock',
() => {
this.startTicking();
},
() => {
this.stopTicking();
},
);
this.intervalId = null;
}
startTicking() {
console.log('Clock started');
this.tick();
this.intervalId = setInterval(() => this.tick(), 1000);
}
stopTicking() {
clearInterval(this.intervalId);
this.intervalId = null;
console.log('Clock stopped');
}
tick() {
this.atom.reportChanged();
}
get() {
this.atom.reportObserved();
return new Date();
}
}
const clock = new Clock();
const disposer = autorun(() => {
console.log(clock.get());
});
setTimeout(disposer, 3000);
在前面的片段中有许多有趣的细节。让我们在这里列出它们:
-
在调用
createAtom()时,我们提供了当原子被观察和不再被观察时的处理程序。当原子实际上变得被观察时,这可能看起来有点神秘。这里的秘密在于使用autorun(),它设置了一个副作用来读取原子钟的当前值。由于autorun()立即运行,调用了clock.get(),进而调用了this.atom.reportObserved()。这就是原子在响应系统中变为活动的方式。 -
一旦原子被观察,我们就开始时钟计时器,每秒滴答一次。这发生在
onBecomeObserved回调中,我们在其中调用this.startTicking()。 -
每秒,我们调用
this.atom.reportChanged(),将改变的值传播给所有观察者。在我们的例子中,我们只有一个autorun(),它重新执行并打印控制台日志。 -
我们不必存储当前时间,因为我们在每次调用
get()时返回一个新值。 -
另一个神秘的细节是当原子变得未被观察时。这发生在我们在三秒后处理
autorun()后,导致在原子上调用onBecomeUnobserved回调。在回调内部,我们停止计时器并清理资源。
由于Atoms只是依赖树的节点,我们需要一个可以存储可观察值的构造。这就是ObservableValue类的用处。将其视为带有值的Atom。MobX 在内部区分两种可观察值,ObservableValue和ComputedValue。让我们依次看看它们。
ObservableValue
ObservableValue是Atom的子类,它增加了存储可观察值的能力。它还增加了一些功能,比如提供拦截值更改和观察值的钩子。这也是ObservableValue的定义的一部分。以下是ObservableValue的简化定义:
class ObservableValue extends Atom {
value;
get() {
/* ... */
this.reportObserved();
}
set(value) {
/* Pass through interceptor, which may modify the value (*newValue*) ... */
this.value = newValue;
this.reportChanged();
}
intercept(handler) {}
observe(listener, fireImmediately) {}
}
请注意get()方法中对reportObserved()的调用以及set()方法中对reportChanged()的调用。这些是原子值被读取和写入的地方。通过调用这些方法,ObservableValue参与了响应系统。还要注意,intercept()和observe()实际上并不是响应系统的一部分。它们更像是钩入到可观察值发生的更改的事件发射器。这些事件不受事务的影响,这意味着它们不会排队等到批处理结束,而是立即触发。
ObservableValue也是 MobX 中所有高级构造的基础。这包括 Boxed Observables、Observable Objects、Observable Arrays 和 Observable Maps。这些数据结构中存储的值都是ObservableValue的实例。
包装在ObservableValue周围的最薄的包装器是箱式可观察值,您可以使用observable.box()创建它。这个 API 实际上会给您一个ObservableValue的实例。您可以使用它来调用ObservableValue的任何方法,就像在以下代码片段中看到的那样:
import {observable} from 'mobx';
const count = observable.box(0);
count.intercept(change => {
console.log('Intercepted:', change);
return change; // No change
// Prints // Intercepted: {object: ObservableValue$$1, type: "update", newValue: 1} // Intercepted: {object: ObservableValue$$1, type: "update", newValue: 2} });
count.observe(change => {
console.log('Observed:', change);
// Prints
// Observed: {object: ObservableValue$$1, type: "update", newValue: 1} // Observed: {object: ObservableValue$$1, type: "update", newValue: 2, oldValue: 1} });
// Increment count.set(count.get() + 1);
count.set(count.get() + 1);
ComputedValue
在可观察树中,您可以拥有的另一种可观察值是ComputedValue。这与ObservableValue在许多方面都不同。ObservableValue为基础原子提供存储并具有自己的值。MobX 提供的所有数据结构,如 Observable Object/Array/Map,都依赖于ObservableValue来存储叶级别的值。ComputedValue在某种意义上是特殊的,它没有自己的内在值。其值是从其他可观察值(包括其他计算值)计算得出的。
这在ComputedValue的定义中变得明显,它不是Atom的子类。相反,它具有与ObservableValue类似的接口,除了拦截的能力。以下是一个突出显示有趣部分的简化定义:
class ComputedValue {
get() {
/* ... */
reportObserved(this);
/* ... */
}
set(value) { /* rarely applicable */ }
observe(listener, fireImmediately) {}
}
在前面的片段中需要注意的一件重要的事情是,由于ComputedValue不依赖于Atom,它对reportObserved()使用了不同的方法。这是一个更低级别的实现,它建立了可观察对象和观察者之间的链接。这也被Atom在内部使用,因此行为完全相同。此外,没有调用reportChanged(),因为ComputedValue的 setter 没有定义得很好。
正如你所看到的,ComputedValue主要是一个只读的可观察对象。虽然 MobX 提供了一种设置计算值的方法,但在大多数情况下,这并没有太多意义。计算值的 setter 必须对 getter 进行相反的计算。在大多数情况下,这几乎是不可能的。考虑一下本章前面的关于购物车description的例子。这是一个从其他可观察对象(如items和coupons)产生字符串的计算值。这个计算属性的setter会是什么样子?它必须解析字符串,并以某种方式得到items和coupons的值。这显然是不可能的。因此,一般来说,最好将ComputedValue视为只读的可观察对象。
由于计算值依赖于其他可观察对象,实际的值计算更像是一个副作用。它是依赖对象中任何一个变化的副作用。MobX 将这种计算称为派生。稍后我们将看到,派生与反应是同义词,强调了计算的副作用方面。
ComputedValue是依赖树中唯一一种既是可观察的又是观察者的节点。它的值是可观察的,并且由于它依赖于其他可观察值,它也是观察者。
ObservableValue = 仅可观察
Reaction = 仅观察者
ComputedValue = 可观察和观察者
高效的计算
ComputedValue的派生函数可能是一个昂贵的操作。因此,最好缓存这个值,并尽可能懒惰地计算。这是 MobX 的规范,并且它采用了一堆优化来使这个计算变成懒惰评估:
-
首先,除非明确请求或者有一个依赖于这个
ComputedValue的反应,否则值永远不会被计算。如预期的那样,当没有观察者时,它根本不会被计算。 -
一旦计算出来,它的值将被缓存以供将来读取。它会一直保持这种状态,直到依赖的可观察对象发出变化信号(通过其
reportChanged())并导致推导重新评估。 -
ComputedValue可以依赖于其他计算值,从而创建依赖树。除非直接子级发生了变化,否则它不会重新计算。如果依赖树深处发生了变化,它将等待直接依赖项发生变化。这种行为提高了效率,不会进行不必要的重新计算。
正如您所看到的,ComputedValue中嵌入了多个级别的优化。强烈建议利用计算属性的强大功能来表示领域逻辑及其 UI 的各种细微差别。
推导
到目前为止,我们已经看到了 MobX 的构建模块,它用Atoms、ObservableValue和ComputedValue表示可观察状态。这些都是构建应用程序的反应状态图的良好选择。但是,反应性的真正力量是通过使用推导或反应来释放的。观察对象和反应一起形成了 MobX 的阴阳。它们彼此依赖,以推动反应系统。
推导或反应是跟踪发生的地方。它跟踪在推导或反应的上下文中使用的所有可观察对象。MobX 将监听它们的reportObserved()并将它们添加到被跟踪的可观察对象列表(ObservableValue或ComputedValue)。每当可观察对象调用reportChanged()(当它被改变时会发生),MobX 将安排运行所有连接的观察者。
我们将交替使用推导和反应。两者都旨在传达使用可观察对象产生新值(推导)或副作用(反应)的副作用执行。这两种类型之间的跟踪行为是共同的,因此我们将它们视为同义词使用。
推导的周期
MobX 使用globalState来保持对当前执行的推导或反应的引用。每当反应运行时,所有触发其reportObserved()的可观察对象都将被标记为该反应的一部分。事实上,这种关系是双向的。一个可观察对象跟踪其所有观察者(反应),而一个反应跟踪它当前正在观察的所有可观察对象。当前执行的反应将被添加为每个可观察对象的观察者。如果观察者已经被添加,它将被忽略。
当您设置观察者时,它们都会返回一个清理函数。我们已经在autorun(),reaction()或when()的返回值中看到了这一点,它们都是清理函数。调用此清理函数将从连接的可观察对象中删除观察者:
在执行反应时,只有现有的可观察对象才会被考虑进行跟踪。然而,在同一反应的不同运行中,可能会引用一些新的可观察对象。当由于某些分支逻辑而原本被跳过的代码段执行时,这是可能的。由于在跟踪反应时可能会发现新的可观察对象,MobX 会对可观察对象进行检查。新的可观察对象将被添加到可观察对象列表中,而不再使用的可观察对象将被移除。可观察对象的移除不会立即发生;它们将在当前反应完成后排队等待移除。
在可观察对象和反应之间的相互作用中,操作似乎是非常缺失的。嗯,并非完全如此。它们确实有一定的作用要发挥。正如本书中多次提到的,操作是改变可观察对象的推荐方式。操作创建一个事务边界,并确保所有更改通知仅在完成后触发。这些操作也可以嵌套,导致嵌套事务。只有当最顶层的操作(或事务)完成时,通知才会被触发。这也意味着在事务(嵌套或非嵌套)进行时,反应都不会运行。MobX 将此事务边界视为批处理,并在内部跟踪嵌套。在批处理期间,所有反应将被排队并在最顶层批处理结束时执行。
当排队的反应执行时,循环再次开始。它将跟踪可观察对象,将它们与执行的派生链接起来,添加任何新发现的可观察对象,并在批处理期间排队任何发现的反应。如果没有更多的批处理,MobX 将认为自己是稳定的,并回到等待任何可观察变化的状态。
关于反应的一个有趣的事情是它们可以重新触发自己。在一个反应中,你可以读取一个可观察对象,并触发一个改变同一个可观察对象的动作。这可能发生在同一段代码中,也可能间接地通过从反应中调用的某个函数。唯一的要求是它不应该导致无限循环。MobX 期望反应尽快变得稳定。
如果由于某种原因,迭代超过 100 次并且没有稳定性,MobX 将以异常退出。
反应在 100 次迭代后没有收敛到稳定状态。可能是反应函数中存在循环:Reaction[Reaction@14]
如果没有 100 次迭代的上限,它会在运行时导致堆栈溢出,使得更难追踪其原因。MobX 通过100 次迭代的限制来保护你免受这种困境的影响。请注意,它并不禁止你使用循环依赖,而是帮助识别导致不稳定(无限循环)的代码。
即使在100 次反应之后仍然不稳定的简单片段如下所示。这个反应观察counter可观察对象,并通过调用spinLoop()动作来修改它。这导致反应一遍又一遍地运行,直到在100 次迭代后放弃:
class Infinite {
@observable counter = 0;
constructor() {
reaction(
() => this.counter,
counterValue => {
console.log(`Counter is ${counterValue}`);
this.spinLoop();
},
);
}
@action
spinLoop() {
this.counter = this.counter + 1;
}
}
new Infinite().spinLoop();
/* Console log:
*Reaction doesn't converge to a stable state after 100 iterations. Probably there is a cycle in the reactive function: Reaction[Reaction@14]* */
正如你所知,执行派生或反应对于建立可观察对象和观察者之间的联系至关重要。没有反应,反应性系统中就没有生命。它只会是一组可观察对象。你仍然可以触发动作和改变它们,但它仍然会非常静态和非反应性。反应(派生)完成了可观察对象-动作-反应的三元组,并为这个反应性系统注入了生命。
最终,反应是从你的状态中提取值并启动整个反应过程的关键!
异常处理
处理错误被认为是 MobX 反应的一个重要部分。事实上,它为autorun()、reaction()和when()提供了一个提供错误处理程序(onError)的选项,在computed()的情况下,每当读取计算值时都会将错误抛回给你。在这些情况下,MobX 会像预期的那样继续工作。
在内部,MobX 在执行 reactions 和 derivations 时加入了额外的try-catch块。它会捕获这些块内部抛出的错误,并通过onError处理程序或在读取计算值时将它们传播回给你。这种行为确保你可以继续运行你的 reactions,并在onError处理程序内采取任何恢复措施。
如果对于一个 reaction 没有指定onError处理程序,MobX 也有一个全局的onReactionError()处理程序,它将被调用来处理 reaction 中抛出的任何异常。你可以注册一个监听器来处理这些全局 reaction 错误,比如错误监控、报告等:
onReactionError(handler-function: (error, reaction) => { })
handler-function:一个接受错误和 reaction 实例作为参数的函数。
在调用全局onReactionError处理程序之前,MobX 首先检查失败的 reaction 是否有一个onError处理程序。只有当不存在时,才会调用全局处理程序。
现在,如果出于某种原因,你不希望 MobX 捕获异常并在全局onReactionError处理程序上报告它,你有一个出路。通过配置 MobX 为configure({ disableErrorBoundaries: true }),你将会在失败点得到一个常规异常。现在你需要通过try-catch块在 reaction 内部直接处理它。
在正常情况下不应该使用configure({ disableErrorBoundaries: true }),因为不处理异常可能会破坏 MobX 的内部状态。然而,打开这个配置可以帮助你调试,因为它会使异常未被捕获。现在你可以在引起异常的确切语句上暂停调试器。
API 层
这是 MobX 面向消费者的最外层层,建立在前面提到的基础之上。在这一层中,突出的 API 包括本书中遍布的observable()、observable.box()、computed()、extendObservable()、action()、reaction()、autorun()、when()等。当然,我们还有装饰器,比如observable.ref、observable.deep、observable.shallow、action.bound、computed.struct等。
核心数据结构,如ObservableObject、ObservableArray和ObservableMap依赖于ObservableValue来存储它们的所有值。
对于ObservableObject...:
-
键值对的值由
ObservableValue支持。 -
每个计算属性都由
ComputedValue支持。 -
ObservableObject的keys()方法也由Atom支持。这是必要的,因为您可能会在其中一个反应中对keys()进行迭代。当添加或删除键时,您希望您的反应再次执行。keys()的这个原子会对添加和删除触发reportChanged(),并确保连接的反应被重新执行。
对于ObservableArray...:
-
每个索引值都由
ObservableValue支持。 -
length属性明确由Atom支持。请注意,ObservableArray具有与 JavaScript 数组相同的接口。在MobX 4中,它是一个类似数组的数据结构,在MobX 5中成为了真正的 JS 数组(由 ES6 的Proxy支持)。对length的读取和写入将导致在原子上调用reportObserved()和reportChanged()。实际上,当使用map、reduce、filter等方法时,将使用支持Atom来触发reportObserved()。对于任何类似splice、push、pop、shift等的变异方法,将触发reportChanged()。这确保了连接的反应按预期触发。
对于ObservableMap...:
-
键-值对的值由
ObservableValue支持。 -
就像ObservableObject一样,它也为
keys()方法维护了一个Atom的实例。任何添加或删除键的操作都会通过原子上的reportChanged()通知。调用keys()方法本身将在原子上触发reportObserved()。
MobX 中的集合,包括对象、数组和映射,本质上是可观察盒子(ObservableValue)的集合。它们可以组织为列表或映射,或者组合在一起创建复杂的结构。
所有这些数据结构还公开了intercept()和observe()方法,允许对值进行细粒度拦截和观察。通过构建在Atom、ObservableValue和derivations的基础上,MobX 为您提供了一个强大的 API 工具箱,用于在应用程序中构建复杂的状态管理解决方案。
透明的函数式响应式编程
MobX 被认为是透明的函数式响应式编程(TFRP)系统。是的,在那一行有太多的形容词!让我们逐字逐句地分解它。
它是透明的...
将可观察对象连接到观察者,使观察者能够对可观察对象的变化做出反应。这是我们对 MobX 的基本期望,我们建立这些连接的方式非常直观。除了使用装饰器和在观察者内部取消引用可观察对象之外,没有明确的连接。由于连接的开销很低,MobX 变得非常声明式,您可以表达您的意图,而不必担心机制。在可观察对象和观察者之间建立的自动连接使反应系统能够自主运行。这使 MobX 成为一个透明的系统,因为连接可观察对象和观察者的工作基本上被取消了。在反应中使用可观察对象就足以连接这两者。
它是反应性的...
这种反应性也非常细粒度。可观察对象的依赖树可以尽可能简单,也可以同样深入。有趣的是,您永远不必担心连接的复杂性或效率。MobX 深知您的依赖关系,并通过仅在需要时做出反应来确保效率。没有轮询或过多的事件被触发,因为依赖关系不断变化。因此,MobX 也是一个非常反应灵敏的系统。
它是功能性的...
正如我们所知,功能编程是利用函数的力量来执行数据流转换。通过使用各种功能操作符,如 map、reduce、filter、compose 等,我们可以对输入数据进行转换并产生输出值。在 MobX 的情况下,关键在于输入数据是可观察的,是一个随时间变化的值。MobX 结合了反应系统的特性,并确保在输入数据(可观察对象)发生变化时自动应用功能-转换。正如前面讨论的那样,它以一种透明的方式通过建立可观察对象和反应之间的隐式连接来实现这一点。
这些特质的结合使 MobX 成为一个 TFRP 系统。
从作者的角度来看,TFRP 的首字母缩略词的起源来自以下文章:github.com/meteor/docs/blob/version-NEXT/long-form/tracker-manual.md。
价值导向编程
MobX 也涉及价值导向编程(VOP),在这里你关注值的变化、它的依赖关系以及在响应系统中的传播。通过 VOP,你关注的是*连接的值是什么?而不是值是如何连接的?*它的对应物是事件导向编程(EOP),在这里你关注的是一系列事件来通知变化。事件只报告已发生的事情,没有依赖关系的概念。与价值导向编程相比,它在概念上处于较低级别。
VOP 依赖事件在内部执行其工作。当一个值发生变化时,会触发事件来通知变化。这些事件的处理程序将把值传播给所有监听器(观察者)的可观察值。这通常会导致调用反应/派生。因此,反应和派生,即值变化的副作用,处于值传播事件的尾端。
以 VOP 的方式思考会提高抽象级别,使你更接近正在处理的领域。与其担心值传播的机制,你只需专注于通过可观察值、计算属性和观察者(反应/派生)建立连接。正如我们所知,这就是 MobX 的三位一体:可观察值-动作-反应。这种思维方式在本质上非常声明式:值的变化是什么而不是如何。当你更深入地沉浸在这种思维模式中时,许多状态管理中的场景会变得更加可行。你会对这种范式提供的简单、强大和高效感到惊讶。
如果你确实需要深入了解事件层,MobX 有intercept()和observe()的 API。它们允许你钩入当可观察值添加、更新或删除时触发的事件。还有来自mobx-utils npm 包的fromStream()和toStream()的 API,它们提供了与 RxJS 兼容的事件流。这些事件不参与 MobX 事务(批处理),永远不会排队,总是立即触发。
在消费者代码中很少使用事件 API;它们主要被工具和实用函数(如spy()、trace()等)使用,以便深入了解 MobX 的事件层。
总结
通过这个深入了解 MobX 的窥视,您可以欣赏到 TFRP 系统的强大之处,它暴露了一个令人惊讶地简单的 API。从 Atoms 开始的功能层,由 ObservableValue 包装,具有 API 和更高级的数据结构,为您的领域建模提供了全面的解决方案。
在内部,MobX 管理着可观察对象和观察者(反应/推导)之间的所有连接。它会自动完成,几乎不会干扰您通常的编程风格。作为开发者,您编写的代码会感觉很自然,而 MobX 则消除了管理响应式连接的复杂性。
MobX 是一个经过各种领域的实战考验的开源项目,接受来自世界各地开发者的贡献,并在多年来不断成熟。通过这次对 MobX 的内部了解,我们希望能够降低对这个强大的状态管理库的贡献障碍。