MobX设计思想与实现

简介: 最近在搜集MobX相关资料,意外的找到MobX作者一篇深度解析MobX工作机制的blog,看过之后受益匪浅。本篇文章将结合MobX源码来整理并理解这篇blog的内容。这篇博客主要是想为读者解读MobX的实现机制,其中穿插介绍了一些作者的设计思路。

最近在搜集MobX相关资料,意外的找到MobX作者一篇深度解析MobX工作机制的blog,看过之后受益匪浅。本篇文章将结合MobX源码来整理并理解这篇blog的内容。这篇博客主要是想为读者解读MobX的实现机制,其中穿插介绍了一些作者的设计思路。

几个重要的概念

首先,作者先澄清了几个MobX中的重要概念

  • Observable State, 所有可以改变的值。
  • Computed Value(又称Derivation), 可以通过Observable State直接计算(pure function)得出的值。
  • Reaction, 与Computed Value类似也是基于Observable State 。但是不是返回一个结果,而是产生一个动作(side effects)
  • Action, 所有修改Observable State的动作

这几个概念的关系如下图:

image.png

这个图解释了,在MobX体系里各个角色的作用。

首先是发生一个Action修改State,接着State的更新会自动触发与其相关联的DerivationReaction

需要注意的是Derivation在这张图中的双重角色。在观察者模式视角下,它不仅是observable也是observer。或者说对于State来说,它是一个observer监视State的变化;但是对于Reaction来说,它可能还是一个observable,它的变化会引发Reaction发生。

一个简单的示例

接着作者给出了一个使用MobX例子,来说明这些概念。

classPerson {
@observablefirstName="Michel";
@observablelastName="Weststrate";
@observablenickName;
@computedgetfullName() {
returnthis.firstName+" "+this.lastName;
  }
}
constmichel=newPerson();
// Reaction: log the profile info whenever it changesautorun(() =>console.log(person.nickName?person.nickName : person.fullName));
// Example React component that observes stateconstprofileView=observer(props=> {
if (props.person.nickName)
return<div>{props.person.nickName}</div>elsereturn<div>{props.person.fullName}</div>});
// Action:setTimeout(() =>michel.nickName="mweststrate", 5000)
React.render(React.createElement(profileView, { person: michel }), document.body);


这个例子的逻辑很简单,通过判断Person对象是否存在nickName属性来决定展示在界面(profileView)上的内容(nickName还是fullName)。

它们的依赖关系大致如下图:

image.png

如果你仔细再看这张图中的fullName,大概你会更清楚为什么我之前说Derivation有着双重角色。实际上Derivation的特殊性也体现在实现里。

MobX实现里,充当observable角色的类都会有一个lowestObserverState标示当前的状态(Stale,UP_TO_DATE等)。(像之前文章里提到的observable体系里类例如ObservableValueObservableArray都有这样的属性。) 同样的observer也会有一个dependenciesState标示当前状态。 然而ComputedValue类却同时拥有这两个属性。

动态更新依赖

回到刚刚的示例中,在最后我们触发了一个Action

// Action:setTimeout(() =>michel.nickName="mweststrate", 5000)


Person对象添加nickName 属性。所以界面profileView也会发生相应的变化。此时的依赖关系已经发生了微妙的变化。

image.png

profileView已经不再依赖或者说监视fullName。如果这时,firstName或者fullName发生了变化,也不会对profileView有任何影响。

这就是MobX里的动态更新依赖。这样的设计,好处在于保证observer只依赖于它需要的依赖。永远也不会出现undersubscribe (forgetting to listen for updates leading to subtle staleness bugs)或者oversubscribe (continue subscribing to a value or store that is no longer used in a component)。

MobX能实现依赖动态更新,是因为它的依赖关系是由框架在运行时计算得到的。或者说,这个依赖关系并不是由用户在写代码时手动的去关联起来,而是由框架自己在运行时自动确定的。

比如在示例中,当执行profileView部分时:

constprofileView=observer(props=> {
if (props.person.nickName)
return<div>{props.person.nickName}</div>elsereturn<div>{props.person.fullName}</div>});


它首先判断是否存在nickName属性,如果不存在就返回fullName属性,在执行profileView部分时就用到了nickNamefullName两个observable,所以现在profileView就依赖于nickNamefullName

但是注意fullName可不是一个普通的observable,它是一个computed value。同样执行fullNamegetter部分:

@computedgetfullName() {
returnthis.firstName+" "+this.lastName;
  }


所以fullName又依赖于firstNamelastName

整个过程如下图:

image.png

这样我们的依赖关系就形成了。


这里体现出了MobX的设计思想:

Reacting to state changes is always better than acting on state changes.

这句话初看时似懂非懂,reacting to changeacting on change似乎没什么差别。实际上这里一个细微的用词决定change发生后动作的主动权归属。acting的主动权在用户,动作是否发生,发生哪些动作都由用户来决定(依赖关系由用户手动订阅);而reacting的主动权在框架(依赖关系由框架自动生成)。


同样当Action被触发,profileView重新计算自己的依赖关系,这次nickName存在,不会用到fullName,所以profileView依赖更新,只依赖于nickName.

这一部分的最后提一下这个依赖更新的算法。本质上是一个两个数组无重复合并的算法,MobX的实现把这个算法的时间复杂度降到了o(n)。具体可以参考这篇文章


从懒加载到MobX响应机制

image.png

让我们再回到这张图,不知道你是否注意到此时,fullName不再被监视时,它的状态已经被修改为lazy。 这代表着,如果现在修改firstName后者lastNamefullName不会立即执行。这就是MobX的性能优化之一——懒加载机制。

想要深入理解懒加载机制是如何实现的,需要我们首先明白MobX是如何响应变化的。当一个action产生时MobX内部发生了什么。


仍然拿之前的例子来说明,假设这样一个场景,依赖关系仍然是完整时(profileView仍然依赖fullName),此时firstName被修改。

  1. action发生,firstName值被修改,firstName状态变更为stale

image.png


2. firstName通知它的observerfullNamefullName状态变更为possible change

image.png

3. fullName通知它的observerprofileViewprofileView状态变更为possible change

image.png

4. MobX监控到所有的状态变更和修改都已经完成

5. MobX通知fullName开始执行, fullName通过firstName的新值重新计算自己的值,fullName修改firstName和自己的状态为new

image.png

6. 这里出现了一个分支,通过fullName的新值和旧值做比较

 6.1  如果fullName新值和旧值相同,即fullName其实没有发生变化,则直接修改profileView状态为new,不需要执行

 6.2  如果fullName确实发生了变化,则profileView重新执行,并且修改自己的状态为new

至此一次完整的更新结束。

如果把上面的过程做一个小结,我大致可以把它分为两个阶段:

  • 冒泡阶段,即被修改的observable去通知它的observer修改状态,这个过程是级联的(或者说是递归发生的) 。在冒泡阶段除了修改状态,实际上并没有发生其他事情。
  • 执行阶段,当冒泡阶段结束,所有的状态都已经被修改完成后,开始执行需要的操作。

为什么一定要分成两个阶段而不是修改状态使立即执行呢?

这实际上也是MobX性能优化的一个设计。它的思路实际就是批处理。因为上面的例子只是一个非常简单的更新。当有多个action一起发生的时候,所以如果每次都立即执行,可能每个action都导致同一个computed value会需要重复执行很多次,求出很多无用的中间结果。 而等到所有状态更新和action都结束后,再执行求值,就只需要执行一次求出最终的结果。

到这里,我比较在意的是MobX的实现是怎么区分这两个阶段的。它怎么知道什么时候冒泡阶段完全结束了需要开始执行阶段论。这牵扯到MobX的另一个概念—— trasnaction 


transaction在数据库中经常出现的概念,在数据库中它定义了一个原子性的操作,并且用它来管理并发访问。

但是MobX里的transaction不是干这个的。它被引入的目的就是为了标注出一次完整的更新冒泡阶段的开始和结束。在一个tranaction内是不会执行reaction或者computed value

/** * During a transaction no views are updated until the end of the transaction. * The transaction will be run synchronously nonetheless. * * @param action a function that updates some reactive state * @returns any value that was returned by the 'action' parameter. */exportfunctiontransaction<T>(action: () =>T, thisArg=undefined): T {
startBatch()
try {
returnaction.apply(thisArg)
    } finally {
endBatch()
    }
}


也就是说,在一个当endBatch()被调用后,MobX就知道冒泡阶段结束了,可以开始执行阶段了。

这里还要多说一句,transaction是一个底层的API,它在上层的封装就是@action。这也是为什么MobX的最佳实践里要求你把所有的state修改都加上action,实际就是把多个修改放到一个transaction里,batch update。


还有一点是执行阶段时的执行顺序。

这里的顺序让我联想到了构建一个最小堆(或者最大堆)时的shift down的过程,在建立一个堆时需要保证它的子树满足最小堆的规则。所以会从n个结点的完全二叉树最后一个分支节点floor( (n-2) / 2 )开始执行shif down。

image.png

而执行阶段也与之类似,它需要保证从整个依赖图(注意不是树)中最底层的一个父节点开始执行(实际就是按照拓扑排序的顺序)。因为执行的规则是保证当前节点所有子节点的值是最新的。

image.png


这个顺序是如何确定的呢?在冒泡阶段,MobX会把经过的Reaction放到一个待执行队列里。执行阶段就直接从队列里取出执行。注意我这里说的是Reaction而不包括Computed Value。所以fullName是不会加入队列的。

但是我刚刚一直在说执行阶段会先执行fullName。这其实并不矛盾。在执行阶段开始,MobX拿到profileView后,检查它的所有依赖,结果找到了fullName是处于possible change状态。它当然会先确定fullName的值,然后才能确定自己的值。

还记得我之前提到的懒加载吗?它就是这样实现的。因为Comupued Value不会加入队列里而是通过Reaction检查依赖获取的。所以如果Computed Value 没有任何observer依赖,就不会有Reaction能到达它。它就当然不会被执行了。只有当以后某个机会,它又与某个Reaction关联起来后,那时才会被执行。

所以懒加载的本质是只有冒泡阶段没有执行阶段。

小结

本篇文章介绍了MobX里的依赖动态更新机制以及变更响应机制的设计与实现。

目录
相关文章
|
7月前
|
数据采集 消息中间件 JavaScript
浏览器渲染揭秘:从加载到显示的全过程;浏览器工作原理与详细流程
了解浏览器工作原理与流程,能有效帮助前端开发与性能优化。 博客不应该只有代码和解决方案,重点应该在于给出解决方案的同时分享思维模式,只有思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
|
3月前
|
人工智能 监控 安全
MCP与企业数据集成:ERP、CRM、数据仓库的统一接入
作为一名深耕企业级系统集成领域多年的技术博主"摘星",我深刻认识到现代企业面临的数据孤岛问题日益严重。随着企业数字化转型的深入推进,各类业务系统如ERP(Enterprise Resource Planning,企业资源规划)、CRM(Customer Relationship Management,客户关系管理)、数据仓库等系统的数据互联互通需求愈发迫切。传统的点对点集成方式不仅开发成本高昂,维护复杂度也呈指数级增长,更重要的是难以满足实时性和一致性要求。Anthropic推出的MCP(Model Context Protocol,模型上下文协议)为这一痛点提供了革命性的解决方案。MCP通过
213 0
|
9月前
|
数据采集 传感器 人工智能
AgiBot World:智元机器人开源百万真机数据集,数据集涵盖了日常生活所需的绝大多数动作
AgiBot World 是智元机器人开源的百万真机数据集,旨在推动具身智能的发展,覆盖家居、餐饮、工业等五大核心场景。
680 9
AgiBot World:智元机器人开源百万真机数据集,数据集涵盖了日常生活所需的绝大多数动作
|
资源调度 前端开发 JavaScript
前端工程化实践:Monorepo与Lerna管理
**前端工程化中,Monorepo和Lerna用于大型项目管理。Monorepo集纳所有项目,便于代码共享、版本控制和CI/CD。Lerna是Monorepo工具,管理多npm包,支持独立或共享版本。安装Lerna用`npm install --save-dev lerna`,初始化后可创建、管理包,通过`lerna bootstrap`、`lerna add`、`lerna publish`等命令协同工作。Lerna配置可在`lerna.json`,与CI/CD工具集成实现自动化。
239 0
|
搜索推荐 程序员 开发工具
Emacs Verilog mode 简单使用指南
【6月更文挑战第17天】Emacs Verilog mode 提升Verilog编程体验,提供语法高亮、代码补全、自动缩进等功能。安装可通过`M-x package-install RET verilog-mode`。常见问题包括补全不生效、高亮不准确,可通过调整配置解决。支持模板插入、代码折叠、错误高亮、代码跳转。通过个性化配置、整合Git、集成其他工具和社区资源,实现高效Verilog开发。Emacs学习曲线虽陡,但效能提升显著。
398 4
|
监控 JavaScript 前端开发
【TypeScript技术专栏】TypeScript的单元测试与集成测试
【4月更文挑战第30天】本文讨论了在TypeScript项目中实施单元测试和集成测试的重要性。单元测试专注于验证单个函数、类或模块的行为,而集成测试关注不同组件的协作。选用合适的测试框架(如Jest、Mocha),配置测试环境,编写测试用例,并利用模拟和存根进行隔离是关键。集成测试则涉及组件间的交互,需定义测试范围,设置测试数据并解决可能出现的集成问题。将这些测试整合到CI/CD流程中,能确保代码质量和快速响应变化。
365 0
|
JavaScript Java 应用服务中间件
nginx 配置~~~本身就是一个静态资源的服务器
nginx 配置~~~本身就是一个静态资源的服务器
335 1
|
前端开发 应用服务中间件 nginx
简单几步,将React项目脚手架Webpack换成Vite⚡⚡,附带性能比较和思考
简单几步,将React项目脚手架Webpack换成Vite⚡⚡,附带性能比较和思考
echarts多条折线图和柱状图实现
echarts多条折线图和柱状图实现
408 0
|
消息中间件 存储 缓存
RocketMQ在项目中的使用总结
RocketMQ在项目中如何使用,什么场景下需要用到RocketMQ以及使用RocketMQ如何保证消息的准备性,都需要我们思考?
RocketMQ在项目中的使用总结