React组件模型启示录

这个话题很难写。

但是反过来说,爱因斯坦有句名言:如果你不能把一个问题向一个六岁孩子解释清楚,那么你不真的明白它。

所以解释清楚一个问题的关键,不是去扩大化,而是相反,最小化。

Let's begin.

组件

组件不是一个很清晰的编程概念。UML里的组件图基本上就是一个图示,远不能和具有数学完备性的State Diagram相比,也不能和静态结构的Class Diagram和时序交互的Sequence Diagram相比。但大家通常还是会画一个出来,便于程序员理解系统的运行时结构,或者代码结构。

你很容易在Google里搜索到一些Component Diagram的图例,所以这里不贴图了。你在组件图上可以看到有这样一些概念是重要的:

  1. port,它指的是包含一组相关函数的接口,一个interface或者一个protocol;

  2. user和provider,谁提供port和谁使用port;

这两个概念都不需要很复杂的阐述,直觉的理解没问题;

但问题是他们定义得特别粗,接口怎么实现的?是function call?rpc?message passing?event?没说,事实上是都行。

常见的Component Diagram画的一般是run-time的instance,black box逻辑,强调的是instance之间的依赖关系。

另一种关于组件的常见说法,是组件是为了重用。这把问题聊到了另一个空间去了。重用是静态概念,它指的是代码里的一个模块,类、结构等等,而不是指run-time实例。

但是这两种说法不矛盾。因为核心的问题,无论运行时还是静态代码,组件首先强调的是黑盒思维,这点不是问题,封装是开发者熟悉的逻辑;但是组件必须集成为系统,无论在静态代码层面还是运行时,组件之间都有依赖关系,在项目具有一定规模时这尤其重要。

在静态代码层面,任何语言都有库和源码模块话机制,include,import,require等语法关键字或函数建立了这种依赖关系;在运行时,组件(或对象实例)之间可能有动态产生的绑定关系;A对象要具有B的引用,才能使用B的方法;或者要降低耦合,采用观察者模式,消息总线或消息路由,Pub/Sub,等等。相对而言,后者更为重要一些,前者你总能通过分拆模块避免循环,静态依赖关系总归是比较清楚的,它定了就定了,不会运行时发生变化。

所以这篇文章主要谈运行时组件依赖关系的处理,特指在一个应用之内,不是微服务或者多个服务器组成的分布式系统。

React

可能可以不用一上来就谈React,但是这样做最简单。

React的基本代码单元称为React Component;它声称是View Component,但也可以是纯state的Component,在render方法里render其他view component即可;有经验的React开发者知道这被社区称为Container Component。

如果从Component的角度看,React的Component有一个非常特别的设计:Component之间只有一种通讯机制!就是通过Props传递对象或函数,原则上Component之间是不会通过引用互相调用方法甚至发送消息的。换句话说,所有Component都是匿名的。开发者不该在运行时查找某个Component实例访问其数据或方法,调用其方法的只有React框架。

从这个意义上说,React象一个Inversion of Control(IOC)模式,所有有态组件都插在React框架之上,他们可以在willReceiveProps或者render方法被调用时获得传递进来的数据或方法,他们也可以通过调用setState方法触发一次更新,但这几乎就是全部了。

React的官方开发者提供了一套叫做flux的数据流方式,需要持久化(生命周期比视图长)的状态存入store,社区也有很多改良的工作,包括流行的redux,mobx等等;但是本质上说,react自己具有完备的态处理能力,只要把两个有相关性的组件的关联状态放到他们的共同祖先即可;只是这样做,如果没有特殊的处理的化并不灵活,在设计变更时大量代码要修改,还不如使用redux等框架来得方便;anyway,这一点不是我们这篇文章要讨论的主题,它指的是静态代码层面的模块化问题,我找时间写文章专述。我们回到React组件是如何组合和互动这个话题上。

我们重新强调一下React组件的匿名问题。对一个组件而言,它要能工作,当然需要和外部组件互动,但是React组件在这里做了一个极致的设计:

一切依赖性都是注入的;注入的依赖性来自哪个外部组件,组件内部一无所知。

依赖性注入(Dependency Injection)一词,对熟悉可测试性(tesability)的开发者来说不陌生,但大多数情况下这停留在测试领域,很少影响设计。绝大多数应用在顶层都有一些类似全局变量的模块,也就是组件图中表达的那些;使用这些模块的其他模块都能用全局的name找到它们,找到就可以使用了。但是在React里,NO! 即使在顶层,每个组件的外部依赖也都是注入的。

所以你看到React的组件模型实际上只包含三个元素:

  1. 父组件向子组件传递的prop是对象或值

  2. 父组件向子组件传递的prop是方法(bound)

  3. 父子组件们用一个tree表示,是单向的传递数据或方法的(即层层注入)

观察者与资源建模

我们先说第一个要素:父组件向子组件传值。本质上,它是子组件对父组件或父组件可观察的某个资源状态的一个观察。

从语法上来说,它比写Observer Pattern要来得方便,因为子组件没有Subscribe的负担,是反过来做的,父组件把子组件的依赖性(需要观察的对象)塞进来。

因为React是function programming风格,这样写更方便;不方便的地方是观察变化的逻辑是在willReceiveProps里,需要自己比较新版本和副本的区别,如果有差别,调用setState方法更新自己。

但是这种观察能实现所有需要的观察吗?比如SomethingStarted,SomethingResumed,SomethingOpened?

确实可能遇到一些棘手的情况难以简单用值的变化来表述一种变化,但是我们反过来想这个问题,数据库是用CRUD实现的,Restful API设计采用资源建模也只有有限的verb,他们都工作的很好;工作的好的原因是他们都是用资源而不是行为建模的,如果确实需要为行为建模,我们也可以使用状态机,对单一模块而言,在各种粒度上状态机都是很好的建模方式;在状态机模型下,状态就一定可以用离散值来表示,比如运行状态可以是started, stopped, resumed, failed,等等。

这样的建模方式是否比自己发明很多message类型更为有效呢?个人看法是的,这是一种远好于用行为语义定义事件的方式。无论crud还是restful都有极为广泛的实践,可以被认为是被证实可行的方式。

从这些意义上说,React的组件建模方式具有类似crud或http verb的统一抽象,是避免出现大量程序员自己发明混乱语义的好办法。

Bound方法传递

父组件向子组件传递的Bound方法,应该看作是父组件向子组件提供的一种触发状态变化的代理。比如你去酒店,你叫服务生来开门,这是一种类似function call或者message passing的机制,但是服务生也可以给你一张卡你自己去开门,这就是一种代理;和观察资源一样,因为使用了统一的Prop机制,在组件内部看,这种代理也是匿名的,组件并不知道到底是谁在提供这项功能,它只是在需要的时候使用而已。

这件事情是前端特有的,受限制于HTML的结构。

很多功能组件都不只是基于观察逻辑工作,他们还会需要提供功能性服务,功能性服务的入口从哪里触发,看应用和系统结构而定,它可能来自用户操作,可能来自操作系统,也可能来自API请求,后面我们还会仔细说这个问题。

Tree

事实上绝大多数App,其组件都是可以用一个tree来表示的,只不过在项目规模不大的时候,大家更喜欢把顶层组件就堆在一起互相引用,这样变化的时候最灵活。

但React组件的Composition结构更符合组件设计的原则:组件和组件可以方便的组合起来实现更大的组件,而且最重要的,它仍然是只有React定义的只有Prop传递的组件。一种自相似性。或者叫做Composability。

组件更新

简单说一下React组件的更新过程;如果一个组件观察到变化,或者被子组件调用了方法,需要更新状态,这时如果变化只影响到自身和某些子组件,它只要直接setState触发变化即可,React回调用它的render方法触发一连串的变化,更新是自上至下的,所以比较容易做到更新收敛;如果变化会影响到组件树上某个非子组件的变化,那么应该通过上面传递下来的Bound方法触发更高层的组件先做状态迁移。这个设计会导致在状态设计上出现mediator模式,anyway,这也是常见模式和基本功了。

这里需要强调的是,理论上这种更新是同步的,虽然React因为效率问题做了其他的工作,它的VDOM渲染实际上是有Batch和异步的,细节不说了。

混乱的组件通讯

那么如果我们不说前端,如果写后端,或者写系统应用,用React的这个模式构建全部组件树可行吗?答案是不,也不必要。

这一节的题目叫做混乱的组件通讯,我们来仔细掰扯一下细节,因为组件模型虽然很常说但是对通讯过程没有约定。

第一个登场的是function call。

function call不管是同步的还是异步的,它没有区分(1)它是否改变了被调用对象的状态(2)它是否需要返回值。如果它不需要返回值,它就和emit了一个event没什么分别。如果它需要一个返回值,那么调用者是user角色,被调用者是provider角色,如果被调用者的状态发生了变化,这相当于crud里的cud,否则是read。

理解了对function call的分类方式,那么event和message passing也就好理解了。message和function call一样是模棱两可的。在Sequence Diagram里,有去必然有回的message被称为synchronous message,有去无回的叫做asynchrnous;但是我们避免这个术语,和我们在JS里说的不是一回事。但是这个分类方式是对的。

相比之下只有event很纯粹,它就是有去无回的。

OK,你看我们的分类方法非常简单,就是单向的或者有去有回的。但是内在的故事不简单。

State & IO

单向的event,它有可能trigger一个模型内的state或者resource变化(后面统称为State)。

双向的通讯,是一种承诺,即使是失败或错误也要有返回,我们称之为IO。注意这个定义是我自己发明的,它仅仅表示双向通讯。

双向通讯难道就不会trigger模型内的state变化吗?这当然是非常可能的。但是问题的关键点就在这里:

对于一个提供IO服务也可能因为IO改变其内部状态的模块,你是否在代码层面上把IO和State分离了呢?

我们专注于说JavaScript的事件模型;如果你把模块写成状态机,模块接收到的event会race吗?当然不会。IO呢?很可能。并发的本质就是IO的并发,event在单线程的事件模型下没有并发的概念。

那么这里就有一个特别简单的建模方式,你可以脑补一个鸡蛋三明治。

三明治两边的面包(其实只有一边有面包的逻辑也是一样的),可以看作一个是向外提供的IO服务,另一个是自己需要使用的IO服务;而中间的鸡蛋,是这个模块的State,全部State,状态机。

进来的IO如果有资源冲突,可以排队;出去的IO如果有返回结果,返回结果要当作一个Event来处理。如果某些Event导致当前正在服务或者排队的IO请求失败,进来的IO请求队列清空,全部返回错误;如果对象出现生命周期结束,其发出的和服务的IO都要清空,返回失败或者abort。你看这超级容易,就是callback队列和handle队列而已。

我们把中间这层鸡蛋,称为该模块的模型(model),它封装了共享资源,实现了内部和外部状态。

在这个模型上前端和后端有没有区别呢?还是有的,虽然两者都可以看作在对外提供服务,一个是服务机器另一个是服务人。后端的服务在对外提供服务的那层面包上,前端呢,前端都是Event进来的。

级联

在级联这个问题上,React的组件模型显示出了它的简单抽象的威力。如果我们能够把所有模块的鸡蛋部分,象React组件那样级联起来:

  1. React的框架的render过程要自己手写,而且也不大现实搞成functional风格的,只要遵循其自上至下的更新逻辑即可。

  2. React的依赖性全注入的组件形式是非常诱人的,但是在设计变更时要修改mediator在组件树上的所在位置也有些恼人。这里会有一些比较tricky的写法,但是好消息是对大多数应用而言,其实粗粒度的组件数量还没有一个React写的网页里的组件数量多,所以这件事情也不见得要做到极致去,组件数量不多的时候Pub/Sub工作的也很好。但是对于明确的Leaf Node组件,这样写是推荐的。

  3. 同步更新。能全部组件同步更新鸡蛋层是非常值得追求的目标。因为它让你的模型具有一个全局的显式状态设计,包含组件相关的数据完整性定义;如果到处是异步状态更新,这个设计本身就有麻烦,其逻辑完备性不容易检验,状态机很容易根据State/Event组合排查设计完备性和合理性,而同步更新是消灭态空间爆炸的利器,否则状态之间要排列组合了。

Event Model

JavaScript是Event Model。Event Model编程的核心就是用状态建模,状态同步更新容易保证数据完整性。建模的开始是看有那些共享资源需要封装,把组件一个一个写出来,然后组合起来。

过程在这里是二等公民,它主要致力于上面说的面包层的IO处理。从这个意义上说,callback还是promise还是async根本不是重点,没有什么值得争执的,哪个合适用哪个。在状态建模之下,IO过程都被碎片化了,试图用长途奔袭的方式串联大量IO操作很难保障设计正确性,光写出来能跑几次成功测试的代码是没意义的,从这个意义上说我不赞同那些伪线程框架。

事务锁的问题不是这篇讨论的重点。事件模型下用状态机和IO排队解决冲突是第一方法,90%以上用这个方法;剩下10%是用opportunistic lock的方式一次性commit多个数据更新状态,这个也很容易,但需要注意读入的数据是尽量同步的(有时这无法保证,但应该去detect非法组合和重试)。

理想的事件模型应该是计算不消耗时间的;实际上这当然不可能。所以主进程的主要目的是维护全局状态层,即所有的鸡蛋;文件和网络IO操作Node大多做得很好,需要算力的任务要用Cluster/Worker了,这是Node的短板,只是要求不高的情况下可用。

如果你的后端或者系统应用是非常stateful的,包括文件持久化的资源,node是很好的选择;如果只是对称的无态逻辑,资源都在数据库里,node没什么意义;如果算力要求高,数据集也大,不适合在进程间抛来抛去,千万别用node,go/java/c++都是好得多的选择。

Rx

我基本没有Rx的开发经验,只是看了半本书。

上面说的全部文字,都可以看作是基于事件模型的reactive编程;但是rx框架是另一个故事,它没有事件模型假设,有很多语言实现,而且它考虑的问题不是一个应用级的,是分布式系统级的。

但rx是不是一个好的选择呢?比如说只用于数据层?

有可能。但是它用于组件层的话,它有几个问题:

1,它没约定单向,这个只能自己来;
2,它需要显式观察,即subscribe,个人认为这不如React的注入机制,后者真正让组件象乐高积木一样容易组合的,没有外部需求的组件才是真正的组件,才可能随意拆装使用;

所以我觉得它写在组件内观察被注入进来的状态变化可能更合适,当然用于密集的异步IO更新的数据集是肯定没问题的。

Final

把关键点陈列一下,该说的前面都说过了。

  1. 依赖性注入的组件

  2. 状态机和资源建模

  3. 状态或资源变化即事件,不要额外发明语义了

  4. 理解State和IO的区别

  5. 全局级联的状态更新,同步!

~~~~~~~~~~~~~~~~

题外话:

最近在重构一个中等规模项目,在组件模型上想了很多;但是React的原作者们并没有特别的觉得他们的设计是unusual的。Jordan Walke的大部分视频都在谈react如何使用。

但在我来看,或者从后端或者系统程序的角度看,react的组件模型在使用上真正符合了组件的定义:无外部依赖,这一点比node里的module们require来require去高明太多。

你可能感兴趣的:(javascript)