高性能 MobX 模式(part 3)- 用例教程

前面两部分把重点放在了 MobX 基础模块的构建上。用这些模块我们可以开始解决一些现实场景的问题了。这篇文章将会通过一系列的示例来应用我们已经了解的概念。

译者:阿里云前端-也树

原文链接:

当然了,这不会是一个冗长的列表,而是可以让你尝试转变思维去应用 MobX。所有示例都没有使用 @decorator 的语法来实现。这样可以让你在 Chrome 控制台、Node命令行环境或者是像 Webstrom 这样支持临时文件的 IDE 中尝试。

没有概述(TLDR)?

这是一篇长文。很抱歉,没有概述。这里有四个示例,我觉得第二个示例之后,后面的阅读起来会更快也更容易理解。:-)

  1. 为重要的动作发送数据分析
  2. 作为工作流的一部分来触发操作
  3. 表单内容变化时显示验证信息
  4. 追踪是否所有已注册的组件完成加载

思维做出一些转变

当你学习某个库或框架背后的理论知识并且尝试解决你自己的问题时,你大概会先初始化一个空白项目。像我这样的一般人还有甚至说最棒的一些开发者都会这样。

我们需要的是从简单到复杂的示例,从而使我们的思维方式成型。只有当我们在实际应用中,我们才能开始思考自己问题的解决方法。

对于 MobX 来说,你最先需要理解的是你有一个响应式的对象数据表。在这个树形结构上,某些部分会依赖另一些部分。当这个树形结构发生突变,为了反应这些变化,有联系的部分就会响应并且更新。

思维的转变在于想象整个系统是由一系列的突变和一系列相应的反应作用组成的。

作为响应式的变化的结果,产生的作用可以是能产出输出的任何东西。让我们去探索一些真实的示例并且看看我们如何使用 MobX 对它们进行建模和描述。

示例1:为重要的动作发送数据分析

问题: 我们在应用中有某些一次性的操作,需要在服务器记录下来。当这些动作被触发并且发送数据分析时,我们想要去追踪它们。

解决办法

第一步是去给状态建模。我们的操作是受限的,同时我们只在乎它什么时候第一次被触发。我们可以通过一个动作名称-布尔值的 map 结构建立模型。下面是我们观察的状态。

1
2
3
4
5
6
7
const actionMap = observable({
login: false,
logout: false,
forgotPassword: false,
changePassword: false,
loginFailed: false
});

接下来我们需要对发生在这些动作状态上的变化做出响应。既然他们在整个生命周期中只发生一次,我们就不准备使用像 autorun()reaction() 这样长期运行的作用函数。我们同样也不想这些动作在执行后保留产生的作用。那么,留着我们的就只有一个选择了:when

1
2
3
4
5
6
7
8
9
10
11
12
13
Object.keys(actionMap)
.forEach(key => {
when(
() => actionMap[key],
() => reportAnalyticsForAction(key)
);
});
function reportAnalyticsForAction(actionName) {
console.log('Reporting: ', actionName);
/* ... JSON API Request ... */
}

在上面的代码中,我们简单的遍历了我们 actionMap 中的 key 并且给每个 key 都设置了一个 when() 方法来处理副作用。这些副作用在追踪函数(第一个参数)返回 true 的时候执行。在执行作用函数(第二个参数)之后,when() 方法会自动销毁。所以不会存在应用中发送多次报告的问题。

我们还需要一个 MobX 的 action 来改变被观察的状态。记住:永远不要直接修改被观察的变量,使用 action() 来做这件事。

对我们这个问题来说,代码会像下面这样:

1
2
3
4
5
6
7
8
9
10
11
const markActionComplete = action((name) => {
actionMap[name] = true;
});
markActionComplete('login');
markActionComplete('logout');
markActionComplete('login');
// [LOG] Reporting: login
// [LOG] Reporting: logout

注意,即使我触发了两次 login 的 action,也不会有报告发生。完美,这就是我们需要的行为表现。

成功的两点原因:

  1. login 标志位已经被置为 true,所以值是没有发生变化的
  2. when() 方法的副作用已经被销毁,所以也不会再有追踪发生

示例2:作为工作流的一部分来触发操作

问题: 我们在一个工作流中包含了许多状态。每一个状态都是某些任务的映射,当工作流达到这个状态时,这些任务会被执行。

解决办法

从上面的描述中来看,唯一需要被观察的是工作流中的状态。每种状态都有对应的任务需要执行,这些任务通过简单的映射关系储存。通过这些信息我们可以为我们的工作流建立模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class Workflow {
constructor(taskMap) {
this.taskMap = taskMap;
this.state = observable({
previous: null,
next: null
});
this.transitionTo = action((name) => {
this.state.previous = this.state.next;
this.state.next = name;
});
this.monitorWorkflow();
}
monitorWorkflow() {
/* ... */
}
}
// Usage
const workflow = new Workflow({
start() {
console.log('Running START');
},
process(){
console.log('Running PROCESS');
},
approve() {
console.log('Running APPROVE');
},
finalize(workflow) {
console.log('Running FINALIZE');
setTimeout(()=>{
workflow.transitionTo('end');
}, 500);
},
end() {
console.log('Running END');
}
});

注意我们储存了一个叫 state 的实例变量,它可以追踪工作流现在和之前的状态。我们同样传入了 state 到 task 的映射关系,储存在 taskMap 中。

监听工作流是这里有趣的部分。在这个例子中,我们没有像之前示例中有一次性的操作。一个工作流通常是在整个应用的生命周期中长期运转的。这里需要的是 autorun() 或者 reaction()

状态对应的任务只会在你过渡到这个状态时触发。所以在我们触发任何副作用(任务)之前,我们需要等待 this.state.next 的变化。等待一个变化意味着我们需要使用 reaction(),因为它只会在追踪的被观察变量发生变化时被触发。所以我们的监听函数代码会像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Workflow {
/* ... */
monitorWorkflow() {
reaction(
() => this.state.next,
(nextState) => {
const task = this.taskMap[nextState];
if (task) {
task(this);
}
}
)
}
}

reaction() 的第一个参数是追踪函数,在这里就是简单的返回 this.state.next。当追踪函数的返回值发生变化,就会触发作用函数。作用函数接受当前的状态,从 this.taskMap 中找出对应的任务并执行。

注意,我们也把工作流的实例传入到任务中。这样就可以把工作流过渡到其它状态。

1
2
3
4
5
6
7
8
workflow.transitionTo('start');
workflow.transitionTo('finalize');
// [LOG] Running START
// [LOG] Running FINALIZE
/* ... after 500ms ... */
// [LOG] Running END

有趣的是,像 this.state.next 并且使用 reaction() 来触发副作用的这种储存简单观察变量的技术,还可以被用来做这些:

  • 通过 react-router 管理路由
  • 在演示的应用中导航
  • 基于一种模式切换不同的视图

示例3:表单内容变化时显示验证信息

问题: 一堆文本框需要被验证是一个经典了 Web 表单的使用场景。当它们验证通过,你可以允许表单进行提交。

解决方法

让我们给一个需要验证表单字段的简单 FormData 类建立模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class FormData {
constructor() {
extendObservable(this, {
firstName: '',
lastName: '',
email: '',
acceptTerms: false,
errors: {},
get valid() { // 这里会变成 compute() 方法的属性
return (this.errors === null);
}
});
this.setupValidation(); // 我们会在下面看到
}
}

extendObservable() API是我们以前没有见过的。通过应用它到我们类的实例(this)上,我们通过 ES5 的等价方式实现了 @observable 装饰类的属性。

1
2
3
4
class FormData {
@observable firstName = '';
/* ... */
}

接下来我们需要监听所有字段的变化,并且执行某些验证逻辑。如果验证逻辑执行通过,我们可以标记这个实体是可用的并且允许提交。可用性本身是通过一个计算属性 valid 来被追踪的。

既然验证逻辑需要在 FromData 的整个生命周期中执行,我们将会使用 autorun() 方法。我们也可以使用 reaction() 方法,但是我们希望立即执行验证而不是等待数据发生第一次变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class FormData {
setupValidation() {
autorun(() => {
// Dereferencing observables for tracking
const {firstName, lastName, email, acceptTerms} = this;
const props = {
firstName,
lastName,
email,
acceptTerms
};
this.runValidation(props, {/* ... */})
.then(result => {
this.errors = result;
})
});
}
runValidation(propertyMap, rules) {
return new Promise((resolve) => {
const {firstName, lastName, email, acceptTerms} = propertyMap;
const isValid = (firstName !== '' && lastName !== '' && email !== '' && acceptTerms === true);
resolve(isValid ? null : {/* ... map of errors ... */});
});
}
}

在上面的代码中,autorun() 方法会在被追踪的观察变量发生变化时自动触发。注意为了让 MobX 恰当的追踪你的观察变量,你需要使用间接引用(dereferencing)

runValidation() 是同步触发的,这就是我们为什么返回一个 promise 对象。在上面的例子中这不重要,但是在实际场景中你可能会因为一些特殊的验证给服务器发送请求。当结果返回的时候我们会设置观察变量 error 的值,它反过来也会更新计算属性 valid

如果你的验证逻辑性能开销很大,你甚至可以使用 autorunAsync(),它有一个参数可以对验证逻辑的执行在短暂延迟后采取防抖措施。

现在让我们的代码跑起来。我们要通过 autorun() 创建一个简单的控制台日志器并且追踪计算属性 valid

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const instance = new FormData();
autorun(() => {
// 追踪这个变量,autorun() 可以在每一个文本框发生变化的时候执行
const validation = instance.errors;
console.log(`Valid = ${instance.valid}`);
if (instance.valid) {
console.log('--- Form Submitted ---');
}
});
// 让我们改变一些字段
instance.firstName = 'Pavan';
instance.lastName = 'Podila';
instance.email = 'pavan@pixelingene.com';
instance.acceptTerms = true;

输出的日志为:

1
2
3
4
5
6
7
Valid = false
Valid = false
Valid = false
Valid = false
Valid = false
Valid = true
--- Form Submitted ---

因为 autorun() 会立即执行,你会看到最开始会有两条额外的日志,一条是因为 instance.errors,另一条是因为 instance.valid。剩下的四条是每次字段发生变化时触发的。

每个字段的改变会触发 runValidation() 方法,这个方法每次会在内部返回一个新的 error 对象。这会导致引用的 instance.errors 发生变化并且触发 autorun() 方法来打印 valid 的值。最后,当我们设置了所有字段的值, instance.errors 变成了 null(再次改变引用的值)并且打印出最终的 Valid = true

所以简单来说,我们通过让表单字段被观察来进行表单验证。同时添加一个额外的 errors 属性和一个 valid 计算属性来保证对可用性的追踪。autorun 通过把所有事情绑在一起来控制它们。

示例4:追踪是否所有已注册的组件完成加载

问题: 有一系列已注册的组件,我们想要在它们全部完成加载之前时保持追踪。每一个组件都会暴露出一个 load() 方法,这个方法返回一个 promise 对象。如果这个 promise 对象进入 resolve 状态,我们就把这个组件标记为已加载。如果 promise 进入 reject 状态,我们把这个组件标记为加载失败。当所有的组件完成加载,我们会报告整个系列的组件完成加载或失败。

解决办法

让我们先来看一下我们面对的组件。我们创建一系列的组件,它们会随机的报告它们的加载状态。注意,有一些是异步的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const components = [
{
name: 'first',
load() {
return new Promise((resolve, reject) => {
Math.random() > 0.5 ? resolve(true) : reject(false);
});
}
},
{
name: 'second',
load() {
return new Promise((resolve, reject) => {
setTimeout(() => {
Math.random() > 0.5 ? resolve(true) : reject(false);
}, 1000);
});
}
},
{
name: 'third',
load() {
return new Promise((resolve, reject) => {
setTimeout(() => {
Math.random() > 0.25 ? resolve(true) : reject(false);
}, 500);
});
}
},
];

第二部是为 Tracker 类设计观察变量的状态。组件的 load() 方法不会以一个特定的顺序完成。所以我们需要一个可观察的数组去保存每一个组件的 loaded 状态。我们也要追踪每一个组件的 reported 状态。

当所有的组件已经发送报告(reported),我们可以发布所有组件最终的 loaded 状态。下面的代码创建了观察变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Tracker {
constructor(components) {
this.components = components;
extendObservable(this, {
// 创建一个组件状态的可观察数组,
// 每个组件都有
states: components.map(({name}) => {
return {
name,
reported: false,
loaded: undefined
};
}),
// 所有组件完成报告时,获得的计算属性
get reported() {
return this.states.reduce((flag, state) => {
return flag && state.reported;
}, true);
},
// 所有组件完成加载时,获得的计算属性
get loaded() {
return this.states.reduce((flag, state) => {
return flag && !!state.loaded;
}, true);
},
// 一个标记 reported 和 loaded 的 action 方法
mark: action((name, loaded) => {
const state = this.states.find(state => state.name === name);
state.reported = true;
state.loaded = loaded;
})
});
}
}

我们再次使用了 extendObservable() 方法来设置我们可观察的状态。reportedloaded 计算属性追踪何时组件完成它们的加载。mark() 是我们用来改变观察变量状态的 action 方法。

顺便说一下,无论在你需要从你的观察变量中提取值的任何地方,使用计算属性是最佳实践。把计算属性当做生产值的一种观察变量。计算属性值也会被缓存,可以表现的更好。另一方面 autorunreaction 不会产出值,而是为创建副作用提供必要的一层包裹。

为了开始追踪,我们在 Tracker类中新建一个 track() 属性。这会触发每个组件的 load() 方法并且等待返回 Promise 对象的结果。基于这些 track() 方法会标记每一个组件的加载状态。

当所有组件都完成报告(reported),追踪者会报告最终的 loaded 状态。我们在这里使用 when() 方法,因为我们要等待 this.reported 变成 true。这个副作用仅仅需要触发一次,是个 when() 方法绝佳的适用场景。

下面的代码实现了上面我们描述的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Tracker {
/* ... */
track(done) {
when(
() => this.reported,
() => {
done(this.loaded);
}
);
this.components.forEach(({name, load}) => {
load()
.then(() => {
this.mark(name, true);
})
.catch(() => {
this.mark(name, false);
});
});
}
setupLogger() {
autorun(() => {
const loaded = this.states.map(({name, loaded}) => {
return `${name}: ${loaded}`;
});
console.log(loaded.join(','));
});
}
}

setupLogger() 方法不是真正的真正的解决办法,只是用来打印报告的。这是获取我们的解决方案是否工作的一个好办法。

现在我们要做如下的尝试:

1
2
3
4
5
const t = new Tracker(components);
t.setupLogger();
t.track((loaded) => {
console.log('All Components Loaded = ', loaded);
});

输出的日志显示我们的方法按照我们的预期执行。当组件报告的时候,我们打印当前每个组件的 loaded 状态。当所有的组件都完成报告时,this.reported 变为 true,我们就会看到 All Components Loaded的信息。

1
2
3
4
5
first: undefined, second: undefined, third: undefined
first: true, second: undefined, third: undefined
first: true, second: undefined, third: true
All Components Loaded = false
first: true, second: false, third: true

思维转变过来了吗?

希望上边一系列的示例让你对 MobX 有了新的思考。

MobX 就是在一个可观察的数据表中产生的副作用。

  1. 设计可观察的状态
  2. 创建 action 方法来改变可观察的状态
  3. 放入追踪函数(when, autorun, reaction)去响应可观察状态的变化

上面这个步骤可以适用于更复杂的场景,比如你需要在某些事情发生变化后追踪某些事情,只需要重复进行步骤1-3即可。