前端组件化之路(一)
1. 为什么组件化这么难做
Web应用的组件化是一个很复杂的话题。
在大型软件中,组件化是一种共识,它一方面提高了开发效率,另一方面降低了维护成本。但是在Web前端这个领域,并没有很通用的组件模式,因为缺少一个大家都能认同的实现方式,所以很多框架/库都实现了自己的组件化方式。
前端圈最热衷于造轮子了,没有哪个别的领域能出现这么混乱而欣欣向荣的景象。这一方面说明前端领域的创造力很旺盛,另一方面却说明了基础设施是不完善的。
我曾经有过这么一个类比,说明某种编程技术及其生态发展的几个阶段:
最初的时候人们忙着补全各种API,代表着他们拥有的东西还很匮乏,需要在语言跟基础设施上继续完善
然后就开始各种模式,标志他们做的东西逐渐变大变复杂,需要更好的组织了
然后就是各类分层MVC,MVP,MVVM之类,可视化开发,自动化测试,团队协同系统等等,说明重视生产效率了,也就是所谓工程化
那么,对比这三个阶段,看看关注这三种东西的人数,觉得Web发展到哪一步了?
细节来说,大概是模块化和组件化标准即将大规模落地(好坏先不论),各类API也大致齐备了,终于看到起飞的希望了,各种框架几年内会有非常强力的洗牌,如果不考虑老旧浏览器的拖累,这个洗牌过程将大大加速,然后才能释放Web前端的产能。
但是我们必须注意到,现在这些即将普及的标准,很多都会给之前的工作带来改变。用工业体系的发展史来对比,前端领域目前正处于蒸汽机发明之前,早期机械(比如《木兰辞》里面的机杼,主要是动力与材料比较原始)已经普及的这么一个阶段。
所以,从这个角度看,很多框架/库是会消亡的(专门做模块化的AMD和CMD相关库,专注于标准化DOM选择器铺垫的某些库),一些则必须进行革新,还有一些受的影响会比较小(数据可视化等相关方向),可以有机会沿着自己的方向继续演进。
2. 标准的变革
对于这类东西来说,能获得广泛群众基础的关键在于:对将来的标准有怎样的迎合程度。对前端编程方式可能造成重大影响的标准有这些:
module
Web Components
class
observe
promise
module的问题很好理解,JavaScript第一次有了语言上的模块机制,而Web Components则是约定了基于泛HTML体系构建组件库的方式,class增强了编程体验,observe提供了数据和展现分离的一种优秀方式,promise则是目前前端最流行的异步编程方式。
这里面只有两个东西是绕不过去的,一是module,一是Web Components。前者是模块化基础,后者是组件化的基础。
module的标准化,主要影响的是一些AMD/CMD的加载和相关管理系统,从这个角度来看,正如seajs团队的@afc163 所说,不管是AMD还是CMD,都过时了。
模块化相对来说,迁移还比较容易,基本只是纯逻辑的包装,跟AMD或者CMD相比,包装形式有所变化,但组件化就是个比较棘手的问题了。
Web Components提供了一种组件化的推荐方式,具体来说,就是:
通过shadow DOM封装组件的内部结构
通过Custom Element对外提供组件的标签
通过Template Element定义组件的HTML模板
通过HTML imports控制组件的依赖加载
这几种东西,会对现有的各种前端框架/库产生很巨大的影响:
由于shadow DOM的出现,组件的内部实现隐藏性更好了,每个组件更加独立,但是这使得CSS变得很破碎,LESS和SASS这样的样式框架面临重大挑战。
因为组件的隔离,每个组件内部的DOM复杂度降低了,所以选择器大多数情况下可以限制在组件内部了,常规选择器的复杂度降低,这会导致人们对jQuery的依赖下降。
又因为组件的隔离性加强,致力于建立前端组件化开发方式的各种框架/库(除Polymer外),在自己的组件实现方式与标准Web Components的结合,组件之间数据模型的同步等问题上,都遇到了不同寻常的挑战。
HTML imports和新的组件封装方式的使用,会导致之前常用的以JavaScript为主体的各类组件定义方式处境尴尬,它们的依赖、加载,都面临了新的挑战,而由于全局作用域的弱化,请求的合并变得困难得多。
3. 当下最时髦的前端组件化框架/库
在2015年初这个时间点看,前端领域有三个框架/库引领时尚,那就是Angular,Polymer,React(排名按照首字母),关于这三者的细节分析,请看《前端杂谈 - 浅析2015年Web前端框架》
我们可以看到,Polymer这个东西在这方面是有先天优势的,因为它的核心理念就是基于Web Components的,也就是说,它基本没有考虑如何解决当前的问题,直接以未来为发展方向了。
React的编程模式其实不必特别考虑Web标准,它的迁移成本并不算高,甚至由于其实现机制,屏蔽了UI层实现方式,所以大家能看到在native上的使用,canvas上的使用,这都是与基于DOM的编程方式大为不同的,所以对它来说,处理Web Components的兼容问题要在封装标签的时候解决,反正之前也是要封装。
Angular 1.x的版本,可以说是跟同时代的多数框架/库一样,对未来标准的兼容基本没有考虑,但是重新规划之后的2.0版本对此有了很多权衡,变成了激进变更,突然就变成一个未来的东西了。
这三个东西各有千秋,在可以预见的几年内将会鼎足三分,也许还会有新的框架出现,能不能比这几个流行就难说了。
此外,原Angular 2.0的成员Rob Eisenberg创建了自己的新一代框架aurelia,该框架将成为Angular 2.0强有力的竞争者。
4. 前端组件的复用性
看过了已有的一些东西之后,我们可以大致来讨论一下前端组件化的一些理念。假设我们有了某种底层的组件机制,先不管它是浏览器原生的,或者是某种框架/库实现的约定,现在打算用它来做一个大型的Web应用,应该怎么做呢?
所谓组件化,核心意义莫过于提取真正有复用价值的东西。那怎样的东西有复用价值呢?
控件
基础逻辑功能
公共样式
稳定的业务逻辑
对于控件的可复用性,基本上是没有争议的,因为这是实实在在的通用功能,并且比较独立。
基础逻辑功能主要指的是一些与界面无关的东西,比如underscore这样的辅助库,或者一些校验等等纯逻辑功能。
公共样式的复用性也是比较容易认可的,因此也会有bootstrap,foundation,semantic这些东西的流行,不过它们也不是纯粹的样式库了,也带有一些小的逻辑封装。
最后一块,也就是业务逻辑。这一块的复用是存在很多争议的,一方面是,很多人不认同业务逻辑也需要组件化,另一方面,这块东西究竟怎样去组件化,也很需要思考。
除了上面列出的这些之外,还有大量的业务界面,这块东西很显然复用价值很低,基本不存在复用性,但仍然有很多方案中把它们“组件化”了,使得它们成为了“不具有复用性的组件”。为什么会出现这种情况呢?
组件化的本质目的并不一定是要为了可复用,而是提升可维护性。这一点正如面向对象语言,Java要比C++纯粹,因为它不允许例外情况的出现,连main函数都必须写到某个类里,所以Java是纯面向对象语言,而C++不是。
在我们这种情况下,也可以把组件化分为:全组件化,局部组件化。怎么理解这两个东西的区别呢,有人问过js框架和库的区别是什么,一般来说,有某种较强约定的东西,称为框架,而约定比较松散的,称为库。框架很多都是有全组件化理念的,比如说,很多年前就出现的ExtJS,它是全组件化框架,而jQuery和它的插件体系,则是局部组件化。所以用ExtJS写东西,不管写什么都是差不多一样的写法,而用jQuery的时候,大部分地方是原始HTML,哪里需要有些不一样的东西,就只在那个地方调用插件做一下特殊化。
对于一个有一定规模的Web应用来说,把所有东西都“组件化”,在管理上会有较大的便利性。我举个例子,同样是编写代码,短代码明显比长代码的可读性更高,所以很多语言里会建议“一个方法一般不要超过多少行,一个类最好不要超过多少行”之类。在Web前端这个体系里,JavaScript这块是做得相对较好的,现在入门水平的人,也已经很少会有把一堆js都写在一起的了。CSS这块,最近在SASS,LESS等框架的引领下,也逐步往模块化方面发展,否则直接编写bootstrap那种css,会非常痛苦。
这个时候我们再看HTML的部分,如果不考虑模板等技术的使用,某些界面光布局代码写起来就非常多了,像一些表单,都需要一层套一层,很多简单的表单元素都需要套个三层左右,更不必说一些有复杂布局的东西了。尤其是整个系统单页化之后,界面的header,footer,各种nav或者aside,很可能都有一定复杂性。如果这些东西的代码不作切分,那么主界面的HTML一定比较难看。
我们先不管用什么方式切分了,比如用某种模板,用类似Angular中的include,或者Polymer,React中的标签,或者直接使用原生Web Components,总之是把一块一块都拆开了,然后包含进来。从这个角度看,这些拆出去的东西都像组件,但如果从复用性的角度看,很可能多数东西,每一块都只有一个地方用,压根没有复用度。这个拆出去,纯粹是为了使得整个工程易于管理,易于维护。
这时候我们再来关注不同框架/库对UI层组件化的处理方式,发现有两个类型,模板和函数。
模板是一种很常见的东西,它用HTML字符串的方式表达界面的原始结构,然后通过代入数据的方式生成真正的界面,有的是生成目标HTML,有的还生成各种事件的自动绑定。前者是静态模板,后者是动态模板。
另外有一些框架/库偏爱用函数逻辑来生成界面,早期的ExtJS,现在的React(它内部还是可能使用模板,而且对外提供的是组件创建接口的进一步封装——jsx)等,这种实现技术的优势是不同平台上编程体验一致,甚至可以给每种平台封装相同的组件,调用方轻松写一份代码,在Web和不同Native平台上可用。但这种方式也有比较麻烦的地方,那就是界面调整比较繁琐。
如何能把组件变得更易重用? 具体一点:
我在用某个组件时需要重新调整一下组件里面元素的顺序怎么办?
我想要去掉组件里面某一个元素怎么办? 如何把组件变得更易扩展? 具体一点:
业务方不断要求给组件加功能怎么办?
为此,还提出了“模板复写”方案,在这一点上我有不同意见。
我们来看看如何把一个业务界面切割成组件。
有这么一个简单场景:一个雇员列表界面包括两个部分,雇员表格和用于填写雇员信息的表单。在这个场景下,存在哪些组件?
对于这个问题,主要存在两种倾向,一种是仅仅把“控件”和比较有通用性的东西封装成组件,另外一种是整个应用都组件化。
对前一种方式来说,这里面只存在数据表格这么一个组件。 对后一种方式来说,这里面有可能存在:数据表格,雇员表单,甚至还包括雇员列表界面这么一个更大的组件。
这两种方式,就是我们之前所说的“局部组件化”,“全组件化”。
我们前面提到,全组件化在管理上是存在优势的,它可以把不同层面的东西都搞成类似结构,比如刚才的这个业务场景,很可能最后写起来是这个样子:
对于UI层,最好的组件化方式是标签化,比如上面代码中就是三个标签表达了整个界面。但我个人坚决反对滥用标签,并不是把各种东西都尽量封装就一定好。
全标签化的问题主要有这些:
第一,语义化代价太大。只要用了标签,就一定需要给它合适的语义,也就是命名。但实际用的时候,很可能只是为了把一堆html简化一下而已,到底简化出来的那东西应当叫什么名字,光是起名也费不知多少脑细胞。比如你说雇员管理的表单,这个表单有heading吗,有footer吗,能折叠吗,等等,很难起一个让别人一看就知道的名字,要么就是特别长。这还算简单的,因为我们是全组件化,所以很可能会有组合了多种东西的一个较复杂的界面,你想来想去也没法给它起个名字,于是写了个:
这尼玛……可能我夸张了点,但很多时候项目规模够大,你不起这么复杂的名字,最后很可能没法跟功能类似的一个组件区分开,因为这些该死的组件都存在于同一个命名空间中。如果仅仅是当作一个界面片段来include,就不存在这种心理负担了。
比如Angular里面的这种:
就不给它什么名字,直接include进来,用文件路径来区分。这个片段的作用可以用其目录结构描述,也就是通过物理名而非逻辑名来标识,目录层次充当了一个很好的命名空间。
现在的一些主流MVVM框架,比如knockout,angular,avalon,vue等等,都有一种“界面模板”,但这种模板并不仅仅是模板,而是可以视为一种配置文件。某一块界面模板描述了自身与数据模型的关系,当它被解析之后,按照其中的各种设置,与数据建立关联,并且反过来再更新自身所对应的视图。
不含业务逻辑的UI(或者是业务逻辑已分离的UI)基本不适合作为组件来看待,因为即使在逻辑不变的情况下,界面改版的可能性也太多了。比如即使是换了新的CSS实现方式,从float布局改成flex布局,都有可能把DOM结构少套几层div,因此,在使用模板的方案中,只能把界面层视为配置文件,不能看成组件,如果这么做,就会轻松很多。
部队行军的时候讲究“逢山开路,遇水搭桥”,这句话的重点在于只有到某些地形才开路搭桥,使用MVVM这类模式解决的业务场景,多数时候是一马平川,横着走都可以,不必硬要造路。所以从整个方案看的话,UI层实现应该是模板与控件并存,大部分地方是模板,少数地方是需要单独花时间搞的路和桥。
第二,配置过于复杂。有很多东西其实不太适合封装,不但封装的代价大,使用的代价也会很大。有时候会发现,调用代码的绝大部分都是在写各种配置。
就像刚才的雇员表单,既然你不从标签的命名上去区分,那一定会在组件上加配置。比如你原来想这样:
然后在组件内部,判断有没有设置heading,如果没有就不显示,如果有,就显示。过了两天,产品问能不能把heading里面的某几个字加粗或者换色,然后码农开始允许这个heading属性传入html。没多久之后,你会惊奇地发现有人用你的组件,没跟你说,就在heading里面传入了折叠按钮的html,并且用选择器给折叠按钮加了事件,点一下之后还能折叠这个表单了……
然后你一想,这个不行,我得给他再加个配置,让他能很简单地控制折叠按钮的显示,但是现在这么写太不直观,于是采用对象结构的配置:
然后又有一天,发现有很多面板都可以折叠,然后特意创建了一个可折叠面板组件,又创建了一种继承机制,其他普通业务面板从它继承,从此一发不可收拾。
我举这例子的意思是为了说明什么呢,我想说,在规模较大的项目中,企图用全标签化加配置的方式来描述所有的普通业务界面,是一定事倍功半的,并且这个规模越大就越坑,这也正是ExtJS这类对UI层封装过度的体系存在的最大问题。
这个问题讨论完了,我们来看看另外一个问题:如果UI组件有业务逻辑,应该如何处理。
比如说,性别选择的下拉框,它是一个非常通用化的功能,照理说是很适合被当做组件来提供的。但是究竟如何封装它,我们就有些犯难了。这个组件里除了界面,还有数据,这些数据应当内置在组件里吗?理论上从组件的封装性来说,是都应当在里面的,于是就这么造了一个组件:
这个组件非常美好,只需直接放在任意的界面中,就能显示带有性别数据的下拉框了。性别的数据很自然地是放在组件的实现内部,一个写死的数组中。这个太简单了,我们改一下,改成商品销售的国家下拉框。
表面上看,这个没什么区别,但我们有个要求,本公司商品销售的国家的信息是统一配置的,也就是说,这个数据来源于服务端。这时候,你是不是想把一个http请求封装到这组件里?
这样做也不是不可以,但存在至少两个问题:
如果这类组件在同一个界面中出现多次,就可能存在请求的浪费,因为有一个组件实例就会产生一个请求。
如果国家信息的配置界面与这个组件同时存在,当我们在配置界面中新增一个国家了,下拉框组件中的数据并不会实时刷新。
第一个问题只是资源的浪费,第二个就是数据的不一致了。曾经在很多系统中,大家都是手动刷新当前页面来解决这问题的,但到了这个时代,人们都是追求体验的,在一个全组件化的解决方案中,不应再出现此类问题。
如何解决这样的问题呢?那就是引入一层Store的概念,每个组件不直接去到服务端请求数据,而是到对应的前端数据缓存中去获取数据,让这个缓存自己去跟服务端保持同步。
所以,在实际做方案的过程中,不管是基于Angular,React,Polymer,最后肯定都做出一层Store了,不然会有很多问题。
5. 为什么MVVM是一种很好的选择
我们回顾一下刚才那个下拉框的组件,发现存在几个问题:
界面不好调整。刚才的那个例子相对简单,如果我们是一个省市县三级联动的组件,就比较麻烦了。比如说,我们想要把水平布局改成垂直的,又或者,想要把中间的label的字改改,都会非常麻烦。按照传统的做组件的方式,就要加若干配置项,然后组件里面去分别判断,修改DOM结构。
如果数据的来源不是静态json,而是某个动态的服务接口,那用起来就很麻烦。
我们更多地需要业务逻辑的复用和纯“控件”的复用,至于那些绑定业务的界面组件,复用性其实很弱。
所以,从这些角度,会尽量期望在HTML界面层与JavaScript业务逻辑之间,存在一种分离。
这时候,再看看绝大多数界面组件存在什么问题:
有时候我们考虑一下DOM操作的类型,会发现其实是很容易枚举的:
创建并插入节点
移除节点
节点的交换
属性的设置
多数界面组件封装的绝大部分内容不过是这些东西的重复。这些东西,其实是可以通过某些配置描述出来的,比如说,某个数组以什么形式渲染成一个select或者无序列表之类,当数组变动,这些东西也跟着变动,这些都应当被自动处理,如果某个方案在现在这个时代还手动操作这些,那真的是一种落伍。
所以我们可以看到,以Angular,Knockout,Vue,Avalon为代表的框架们在这方面做了很多事,尽管理念有所差异,但大方向都非常一致,也就是把大多数命令式的DOM操作过程简化为一些配置。
有了这种方式之后,我们可以追求不同层级的复用:
业务模型因为是纯逻辑,所以非常容易复用
视图模型基本上也是纯逻辑,界面层多数是纯字符串模板,同一个视图模型搭配不同的界面模板,可以实现视图模型的复用
同一个界面模板与不同的视图模型组合,也能直接组合出完全不同的东西
所以这么一来,我们的复用粒度就非常灵活了。正因为这样,我一直认为Angular这样的框架战略方向是很正确的,虽然有很多战术失误。我们在很多场景下,都是需要这样的高效生产手段的。
6. 组件的长期积累
我们做组件化这件事,一定是一种长期打算,为了使得当前的很多东西可以作为一种积累,在将来还能继续使用,或者仅仅作较小的修改就能使用,所以必须考虑对未来标准的兼容。主要需要考虑的方面有这几点:
尽可能中立于语言和框架,使用浏览器的原生特性
逻辑层的模块化(ECMAScript module)
界面层的元素化(Web Components)
之前有很多人对Angular 2.0的激进变更很不认同,但它的变更很大程度上是对标准的全面迎合。这不仅仅是它的问题,其实是所有前端框架的问题。不面对这些问题,不管现在多么好,将来都是死路一条。这个问题的根源是,这几个已有的规范约束了模块化和元素化的推荐方式,并且,如果要对当前和未来两边做适配的话,基本就没法干了,导致以前的都不得不做一定的迁移。
模块化的迁移成本还比较小,无论是之前AMD还是CMD的,都可以根据一些规则转换过来,但组件化的迁移成本太大了,几乎每种框架都会提出自己的理念,然后有不同的组件化理念。
还是从三个典型的东西来说:Polymer,React,Angular。
Polymer中的组件化,其实就是标签化。这里的标签,并不只是界面元素,甚至逻辑组件也可以这样,比如这个代码:
注意到这里的core-ajax标签,很明显这已经是纯逻辑的了,在大多数前端框架或者库中,调用ajax肯定不是这样的,但在浏览器端这么干也不是它独创,比如flash里面的WebService,比如早期IE中基于htc实现的webservice.htc等等,都是这么干的。在Polymer中,这类东西称为非可见元素(non-visual-element)。
React的组件化,跟Polymer略有不同,它的界面部分是标签化,但如果有单纯的逻辑,还是纯JavaScript模块。
既然大家的实现方式都那么不一致,那我们怎么搞出尽量可复用的组件呢?问题到最后还是要绕到Web Components上。
在Web Components与前端组件化框架的关系上,我觉得是这么个样子:
各种前端组件化框架应当尽可能以Web Components为基石,它致力于组织这些Components与数据模型之间的关系,而不去关注某个具体Component的内部实现,比如说,一个列表组件,它究竟内部使用什么实现,组件化框架其实是不必关心的,它只应当关注这个组件的数据存取接口。
然后,这些组件化框架再去根据自己的理念,进一步对这些标准Web Components进行封装。换句话说,业务开发人员使用某个组件的时候,他是应当感知不到这个组件内部究竟使用了Web Components,还是直接使用传统方式。(这一点有些理想化,可能并不是那么容易做到,因为我们还要管理像import之类的事情)。
未完待续,请继续关注本头条号。