转 Web应用组件化的权衡

原文https://github.com/xufei/blog/issues/22

作者徐飞(@民工精髓V)

1. 基本概念

什么是Web应用?

所谓Web应用,指的是那些虽然用Web技术构建,但是展现形式却跟桌面程序或者移动端原生应用类似的产品。这类产品的特点是逻辑较重,交互复杂,通常也是单页式的。

主要包括:

  • 交互占比较高的页面体系
  • 以各种Hybrid技术构建的应用,其中的Web部分

大部分可以等同于所谓的“单页面应用”,可以参见之前写的这篇:构建单页Web应用

组件化开发的优势是什么?

组件化的最重要作用就是提升开发和维护的效率。

最原始的组件,其功能可以单独开发测试,然后逐级拼装成更复杂的组件,直到整个应用。每一级都是易装配,可追踪,可管控的。

在Web应用中,组件化一般指什么?

在开发Web应用的时候,无论技术选型,工程方案,还是对人员的技能需求都是有一些特点的,最重要的特点莫过于组件化。

组件化这个词,在UI这一层通常指“标签化”,也就是把大块的业务界面,拆分成若干小块,然后进行组装。

狭义的组件化一般是指标签化,也就是以自定义标签(自定义属性)为核心的机制。

广义的组件化包括对数据逻辑层业务梳理,形成不同层级的能力封装。

在Web应用中,组件化的主要目标是什么?

很多人会把复用作为组件化的第一需求,但实际上,在UI层,复用的价值远远比不上分治。

分治带来的是可管理性,相比一大团HTML和JavaScript的混杂,组件化之后,整个应用成为了一个很清晰的树,一眼就能看清包含关系,也能够很容易理清数据的传递方向。而且,整个应用可以从叶子节点,逐步向上测试,哪一级出了问题,可以很容易发现。

但是复用就很麻烦了,因为组件的内部实现与外部接口都很难取舍。很可能我们在设计之初,都是把组件设想成一个单一的东西,然后在实际项目中,发现最后都面目全非了。

所以,复用的工程成本很高,在使用的时候需要权衡,除了最常用了基础控件,其他的不要刻意追求。

2. 组件化应当做到什么程度?

一个软件产品中,如果把核心稳定的部分视为资产,灵活可变的部分视为耗材,我们如何对待资产?如何对待耗材?

对待资产,我们一般会比较重视,会有长远的规划,优雅的实现,持续的维护,细致的测试,详尽的文档等等,但是对于耗材,基本上会视为一次性的东西,不会有这么严谨的过程。

组件属于资产还是耗材?模板呢?

按照上面的分类,组件明显属于资产,而模板一般属于耗材。

在有些框架中,模板的使用度较低,但是常见的包含双向绑定的框架中,都有很大比重的模板。有些模板是嵌入到组件内部的,有些则是独立存在的,比如Angular中,可以使用ng-include动态包含一个模板,这个模板就是独立的了。

大部分Web应用中,资产多一些,还是耗材多一些?

大部分Web系统的前端部分,其实都是耗材比资产多,人们选用Web相关技术的一个典型心理就是容易写,而且相对随意一些。

大部分Web应用都适合“全”组件化吗?

这个问题要从几个方面回答:

  • 成本。从技术角度,任何系统都是可以不计成本的,如果资源无限充足,我们可以把每个东西都实现得非常完美,但现实世界不是这样的,每个东西都会有开发时间之类的限制,这就迫使我们只能对重要性较高,可复用性较高的东西多花时间,其他东西少花时间。
  • 实现难度。组件化方案是需要有规划能力的,不但需要全局的规划能力,还需要各个局部的规划能力,这其实是比较高的需求了。
  • 集成难度。很多时候,我们做一个东西,并不是就只有它自己,还会有跟其他系统的集成,比如说“我的淘宝”PC版,它现在的版本是用React实现 的,但仍然需要跟其他东西集成,比如公共头尾,购物车之类,而这些东西是需要兼顾老系统,所以可能就会集成得比较别扭。一切组件化框架,如果要跟其他异构 系统作集成,基本上都不可能优雅。

组件与模板的对比

在展示内容偏多的网站中,模板是一个很常见的东西,它通过某种占位的HTML,包含简单的文本格式化,简单的条件判断,做一些很基础的动态内容生成操作。

但是在Web应用中,因为强调组件化,所以很多人对模板的重要性有些忽视了。这里的“模板”指的是双向绑定的动态模板,不是传统的静态模板,这个基本概念之前有过回答:

Handlebars 和angularjs有什么区别?分别在什么情况下使用?

在Web应用中,应当如何看待模板的地位呢?我们先来看另外一个问题:

HTML,CSS,JS,这三者里面,谁是整个Web工程的入口?

展示型的Web项目中,毫无疑问HTML是入口,也是根基,不管是JS还是CSS都是作为它的辅助。但到了Web应用中,还是这样吗?我们很多Web应用实际上是以JS为入口的,HTML不再被视为骨架,而是视为一种动态的东西,由JS创建并管理。

在这个前提下,人们对动态的HTML又有两种不同方式的认知:它是模板,还是组件?

从典型的MVVM三层中,我们可以看到,View Model是Model的外围,View是View Model的外围,一层一层出去,外层实际上可以视为内层的配置文件。而如果从组件化的角度出发,View跟View Model共同构成了组件层。

因此,动态的HTML究竟算是什么,取决于我们从什么角度去看待它,也取决于我们在使用什么框架。

3. 组件化框架

目前有哪些流行的组件化框架?

我们现在开发Web应用,一般也不会从0开始,通常是选取一个核心框架(库),然后在此基础上确定一些规则,逐步构建外围体系,现在比较火的有React,Angular,Vue,Polymer等。

“MV*”:Angular,Vue等
“反应式”:React,Reactive等
标准增强:Polymer

几个流派各自特点是什么?

MV*: 分层,绑定
React: 组件化,单向数据流

React中一般的组件相当于MVVM流派中的什么?

以上提到的几个东西,在组件化这块,可能争议最大的是Angular,因为Angular 1.x的官方指引中,并未在组件化这个方向上作一些指导,也没有提倡,甚至连建议都没有,而React和Polymer是天然组件化的,Vue提供的文档 里以很大篇幅详细说明了组件化的机制和实践方式。

但是,这并不是说,Angular 1.x就是与组件化冲突的,它仍然可以通过directive等相关机制,实现自己特色的组件化方案。

Directive可以实现自定义标签和自定义属性,这两者可以理所当然地归类到组件中,但是,在Angular中,模板本身也可以视为一种组件,一种轻量级的组件,它不一定就是静态的,仍然可以有一些简单的操作和行为。

Directive和模板相当于MVVM中的View层,它们的运行,一般是离不开ViewModel的支撑的,在Angular中,这就是 controller。所以,如果以Angular框架来说,directive和模板、controller,共同形成了视图层组件体系。推广到其他 MVVM框架来说,也就是View和ViewModel,而React整体就处于视图层,所以这两者算是一个对等关系。

这些流派有共同的未来吗,会是什么?

无论是哪种框架,在开发Web应用的时候都要面临一个问题:业务数据层如何设计?

这一层东西,其实目前各路框架都未提出有力的解决方案,大家的重点都还是在做上层UI。

但是从长远来看,业务数据层会是一个基本没有框架差异的东西,同一个方案,大家都可以用,比如说之前有人把flux之类的东西放到React之外的框架用,也一样可以。

而上层UI,其实现过程现在也很明确地是要往Web Components靠拢,实现逻辑都是使用ES新标准,数据绑定机制都是getter setter或者observe,加载方式都在考虑HTTP2之类,一旦某个领域出现了理念突破,很快就会被其他框架吸收融合。

所以总的来说,各框架是趋同的。

4. 组件化的实践

一个全组件化体系,会形成组件树,上下级组件之间应当如何通讯?不同层级的组件之间应当如何通讯?

当我们把一个应用使用组件化的理念进行构建的时候,整个应用就形成了一个倒置的树,树根就是应用本身,其余节点是层层嵌套的组件们,叶子节点是最基础的组件。

如何规划组件树的层级与组件的粒度?

如果我们有两个不同团队,同样基于组件化的理念,使用同一个框架,做同样功能的产品,最终形成的组件树可能差别很大,这个差别主要在于:

把什么视为组件,组件的粒度是怎样的。

在组件化的应用中,组件树的层级不宜过深,从根节点算起,应当尽可能控制在3到5层内,如果层级太多的话,会造成组件通讯和数据传递的负担。

如何约定组件之间的通讯方式?

在一个组件化的应用中,会存在组件之间的数据传递。

以React为例,如果存在两级嵌套的组件:

<TodoList>
    <TodoItem></TodoItem>
    <TodoItem></TodoItem>
    <TodoItem></TodoItem>
</TodoList>

这里面可能存在:

  • 直接对TodoList进行整组数据的赋值
  • 直接对某个TodoItem赋值
  • TodoList对下属的TodoItem赋值
  • TodoList和TodoItem自己去某个“全局”数据中读取配置项

这里面,前三种都可以通过该组件的props传递进去,属于对组件的常规用法,第四种,则属于对数据层的利用。

那么,我们如何权衡两种数据通讯方式呢?

一个比较粗糙的办法是,从数据模型的角度去考虑。如果一个组件所要获取的数据模型是比较独立的,不依赖其他业务数据,可以直接去获取,如果跟其他这个数据模型跟其他数据之间存在耦合,比如主从联动关系,由父组件进行分发会比较好。

另外一个着眼点是权衡上下两级组件之间的关系密切程度,如果它们之间的关系很强,对外界来说是一个紧密结合的整体,可以直接在它们之间传递数据,如果关系不强,或者在组件树上距离较远,适合通过第三方转发通信。

从这里我们得出的结论是:

并不是选择了框架,就可以顺利把一个Web应用做出来了,还需要一件很重要的事,那就是:业务架构。组件之间的关系都是需要统筹规划的,这里面有很多技巧,可以参见一些大型桌面程序的架构,从中获取不少经验。

数据通讯层

全组件化还带来另外一个课题,那就是数据层的设计。比如说,我们可能有一个选择城市的列表组件,它的数据来源于服务端的一个查询,为了方便起见,很 可能你会选择把查询的调用封装在组件内部,然后这个组件如果被同一个可见区域的多个部分使用,或者是这个查询及其数据结果被同一可见区域的其他组件也调用 了,就出现了两个问题:

  • 数据同步
  • 请求的浪费

另外,对于关联数据的更新,也不太便于控制,RESTful之类的服务端接口规范在复杂场景下会显得力不从心。

在数据通信这层,Meteor这样的框架提出了自己的解决思路,跳出传统HTTP的局限,把眼光转向WebSocket这样的东西,并且在前端实现类似数据库的访问接口。

Facebook对此问题提出了更暴力的解决方式,Relay和GraphQL,这两个东西我认为意义是很大的,它解决的不光是自己的痛点,而且是可以用于其他任意的前端组件化体系,对前端组件化这个领域的完善度作出了极其重大的贡献。

5. 其他思考

如何看待“可视化继承”?

在不少组件化框架,包括桌面端的,Web端的,都有“可视化继承”这个概念,比如说,我们有一个List组件用于展现列表数据,然后,又有另外一个需求,在这个列表上显示checkbox,用于多选。在很多组件化框架里,都会存在这样的继承关系:

class CheckList extends List {
}

我觉得有必要探讨一下这里这个extends,是不是一定要用这样的方式来实现一个形态类似原组件的新组件?

在全组件式体系中,继承是不如组合优雅的,以上面这个情况来说,它会在render方法里,重新实现自己的东西,所以,它继承了什么呢,很少很少的东西。

我们可以换种思路,保持组件不变,通过不同的配置项使其相应不同的功能。

模板外置的组件实现方式

在实现一个很基础的UI组件的时候,我们一般都会想要把它搞得既简洁,又强大,但这件事情本身是很难权衡的,针对不同的组件,可能会有不同的策略。

我们在开始实现组件的时候,通常会尽可能考虑需求,然后将其作为默认实现,并且对外提供一些配置项,用于开关这些功能。

还是用列表举例,比如我们有一个列表,可以用于选中,内部结构可能会搞成这样:

<ul class="list">
    <li></li>
    <li class="selected"></li>
    <li></li>
    <li></li>
</ul>

然后对外的形式这样:

<List data="arr"></List>

或者这样:

<List>
    <ListItem data="aaa"></ListItem>
    <ListItem data="bbb" selected></ListItem>
    <ListItem data="ccc"></ListItem>
    <ListItem data="ddd"></ListItem>
</List>

然后,加需求了,列表有多种形态,一种横着排的,一种竖着排的,一种片状的,每行N个,排满换行,然后这里面还再分,元素是否定宽,还是流式。

那我们就面临着几个选择:

<List type="Tile"></List>

加配置属性,或者增加不同的元素,如TileList,HorizontalList等等。

接着,我们来了对列表项的自定义需求:

  • 每个列表项带一个checkbox。
  • 列表可以设置有无表头。
  • 表头可以设置有无checkbox。
  • 如果表头有checkbox,需要跟每行的checkbox状态进行关联。当表头checkbox点击的时候,所有行的checkbox与它同步;当每行checkbox点击的时候,表头checkbox状态也与之同步。
  • checkbox需要可以设置显示在列表左侧还是右侧
  • 列表内容可以自定义文本格式化函数
  • 列表内容可以自定义为其他组件,并且有一些数据传递和事件通信方式……
  • 然后,还要可以自定义样式…… ……

所以,这个组件变得非常复杂,对外的接口很复杂,内部实现也很复杂,代码更是臃肿不堪。摆在我们面前的有这么一个矛盾:

怎样让我们的组件既强大,又便于使用?

面对此类场景,我想给出一个解决方案,那就是:

  • 把组件实现为一种插件平台
  • 针对组件的各种形态,将其特征分离出来当成一种插件

为了说明这个理念,我花了大约一个小时,写了这样一个demo,看其中datagrid那段。

其主体实现逻辑是这段:datagrid.js

看看这个代码,再对比所展示出来的这些功能,会不会觉得差异有点大?

奥秘在哪里呢,在于我们给每种场景传入了不同的模板,如下:

<sn-datagrid grid-cols="cols" grid-data="students"></sn-datagrid>

<sn-datagrid grid-cols="cols" grid-data="students" header-cell-tpl="sortHeaderTpl"></sn-datagrid>

<sn-datagrid grid-cols="checkboxCols" grid-data="students" cell-tpl="checkboxTpl" header-cell-tpl="checkboxHeaderTpl"></sn-datagrid>

<sn-datagrid grid-cols="buttonCols" grid-data="students" cell-tpl="buttonCellTpl" header-cell-tpl="checkboxHeaderTpl"></sn-datagrid>

这个理念其实并不新鲜,在Adobe Flex的组件框架中,List系列的组件就通过开放自定义itemRenderer的方式,极大提升了可扩展性,并且保持原组件实现的优雅。同理,使用类似的方式,用React也可以这样实现。

但我们这个地方会更加简洁,其原因在于两点:

  • Angular的模板即可起到轻量组件的作用,代码更精炼
  • Angular的作用域有继承机制,这样,传入的模板直接与原组件融为一体,共享同一份数据

对于Angular的这个作用域机制,很多人都反感,但我认为,它并不一定就比全部在传递时候赋值的immutable机制差,在业务开发中,组件化固然是有用,但频繁的上下级数据传递可能会让整个系统更加零碎化,数据层的零碎化是非常不利的。

今年大家有了React,黑Angular就格外狠了,我举这个例子也是为了说明,Angular 1.x的设计,除了module是完全的败笔,变更检测机制值得商榷,其他的并无大问题,甚至还存在一些优势。使用某框架的时候,如果熟悉原理并加以合理 利用,能够巧妙解决业务上遇到的很多问题。

模板的意义

除了上面提到的,模板还有另外的意义。

我们会发现,在React的体系里,HTML和DOM本身还重要吗?重要性其实是大幅降低了,所以我们会看到 ReactNative,ReactCanvas之类的实现,而且,最新版本的React中,把React DOM单独抽取出来了,这意味着,React未来只把DOM作为它的可选视图渲染层之一。

但是我们必须认识到,在Web体系中,HTML和DOM有不可替代的优势,它们是当前Web技术的根基,尽管有缺点,并不代表应当被抛弃,至少是在现在这个时代。

所以,在Web应用这样的体系中,组件的实现技术还是应当尽可能基于DOM来考虑。也正是在这种场景下,模板和绑定技术仍然存在很重要的作用,比如可访问性等等特性,都是别的非DOM体系所缺乏积累的。

此外,模板某种程度上可以视为“组件的字面量形式”,也就是组件的一种序列化形式,如果我们要动态加载组件,使用模板会非常方便,这也就是我上面那个数据表格例子的意义所在。

HTML体系做组件化的不利因素

HTML本身的标签,其实做组件化是有些别扭的,这个原因在哪里呢,两点:

  • 标签没有命名空间
  • 有些内置标签是依赖于别的标签而存在的,并且往往有默认的布局语义,比如TR,比如LI,这些东西单独跟内部一些元素一起封装而成的“组件”,并不能做到可以任意放置。

在其他一些体系里并不存在这样的问题,比如WPF,比如Adobe Flex,因为他们没有这样的“历史负担”。

另外一个方面,所谓的组件嵌套,从声明式代码的编写方式来看,就是标签的嵌套。标签嵌套的含义在UI层被赋予了更多潜规则,比如这个代码:

<Panel>
  <Service/>
</Panel>

如果Service并非有UI展现的东西,而是像polymer里面的core-ajax那样,或者Adobe Flash体系里的WebService,你可以把它当做Panel实例里面的一个成员变量,然后设置它的属性或者调用方法。但是,对于更普通的情形:

<Panel>
  <Button></Button>
</Panel>

同样的写法,这个含义一样吗?很明显不一样,因为Button也是一个可展示的组件,这时候你默认它是被放置在Panel的展现内部,作为它的可视化子元素的。也就是说,这时候,你不但在逻辑上把两者建立了关联,还要在布局上考虑它们的约束。

如果你的外层元素是一个布局为主的容器,那好说,比如这里的Panel,我们默认它有一块展示区,所有子节点都放在里面以某种方式排版,或者flow,或者float,或者flex,甚至border-layout,东西南北中。

如果外层元素不是一个布局为主的容器,允许它嵌套别的东西,逻辑上就很难理解。它必须约束自己所能允许放置的子元素的类型。比如:

List下面就只能放ListItem类型的东西。

再回头看Web Components

我觉得,在有了类似angular那种自定义元素、属性的方式(具体实现可以改进),或者React那种自定义标签之后,Web Components的使用场景变得很尴尬了。

我们现在看Web Components的作用,主要还是隔离,包括对逻辑和内部展现的隔离。JavaScript逻辑的隔离其实作用不是很大,因为我们用其他办法也能达到相同的效果,但是Shadow DOM和Scoped CSS这两个东西就很耐人寻味了。

比如说,我们现在用Shadow DOM实现了一个东西,然后,在浏览器里面打开查看开关,还是可以看到里面的东西,那如果不纠结它的实现机制的话,跟使用某种组件化框架创建的自定义元素 相比,差异是不是就没有那么大了?因为写的时候都只是写一个自定义的元素,运行的时候在内部放了具体实现细节。

至于Scoped CSS,更有意思,因为它实际上带来了对已有的工程方案的挑战。我们思考Web Components普及之后的组件化思路,在样式这块几乎都必然走到一条路上,那就是:样式的inline化,把组件的样式全部内置,否则,组件的独立 性无从保证。但我们不要忘了,/deep/和::shadow选择器是用来干什么的?这是允许外部的样式对组件内部的东西作调整,这是一个很无奈的选择, 因为确实有这种场景,比如你需要对所有组件设置全局风格之类。另外上次听谁说到父选择器,允许元素控制其上级的样式……真是被震惊了,我理解这种需求,比 如某种图片放到一个容器里,不管它放在哪,都希望其父容器背景如何如何,但是,这是对组件化技术的一种挑战……

在实际工程中,样式inline化是有很多缺陷的,比如刚才提到的:theme怎么办?从我近期的一些文章可以看到观点,就是不赞同全组件化,尤其 是在上层更倾向于直接使用HTML模板而不是封装过的组件,因为我认为:Web,或者说泛HTML体系,它跟其他任何的客户端展现技术,比如Java Swing,WPF,QT,Adobe Flex之类相比,最本质的不同在于极其强大的CSS,正是因为有它,我们才有可能极尽所能地、简单而优雅地打造不同的用户体验,而不是用各种画布去绘制 像素。如果你决定在底层去各种绘制,那确实可以把UI层全组件化,但这个事情也只能在有限范围干,比如移动端,比如游戏,否则代价不堪设想。

面对theme的需求,我们只能通过往动态构建的路上去走,这里面也会有很多要考虑的点。

6. 小结

看到这里,有什么感觉?想要在有一定复杂度的Web应用中全面推行组件化,需要考虑的东西非常多,相当于从农业社会到工业社会的飞跃,我们不能期望一蹴而就,需要通盘考虑。

各类客户端开发技术中有很多值得借鉴的地方,结合Web技术自身的一些特点,可以触类旁通。

相关推荐