组件库是前端大规模开发中提高效率的重要一环,同时也是可视化页面搭建、自动化测试等上层建筑的基石。所以设计时要考虑的问题涵盖面很是广。要设计好很是难,可是设计好以后从上层建筑带来的回报会超过你的想象。css
这篇文章中咱们先一块儿来关注和探讨组件库要解决的问题,最后会推导出一套足够灵活——适用于大团队或社区使用,又足够强大——能支撑起上层建筑的组件库方案。也请读者注意,结论其实很简单,文中思考过程才是重点。知道结论并能让你一跃成为架构师,但知道了如何从系统角度设计局部却让你有机会能够。共勉。html
要理清问题域,咱们先要了解组件库在架构层面处于哪一个位置,它都与哪些其余部分有关系,一图蔽之:前端
能够看出问题域大致可分为三部分:node
一,产生于应用框架等上层建筑。例如应用框架可能但愿能控制组件的全部状态,监听全部事件,以便能提供完整的回滚等功能给用户。精确到组件数据的测试框架的示例:react
二,产生于工程工具。但愿获得更多组件内部的信息。对属性的自动读取示例:git
三,产生于组件的需求自己。这里涉及到理想的应用框架中提到的两个问题:有需求但愿组件的逻辑不变,展现稍微变一下怎么办?或者先后二者反过来怎么办?github
接下来再细化每一个部分的问题:数组
任何底层方案设计时首先要关注的就是上层建筑。上层建筑是回报的来源,能承载的上层建筑越多,回报越大。可是同时,高楼带给底层的压力和挑战也是巨大的。例如从咱们上面所举的例子——测试框架经过对组件全部属性变化的监听来实现数据对比或者回滚——如今并无哪个组件引擎自然很好地支持了这样的能力。即便是相似 react 的调试工具中显示的状态也是利用了引擎的特殊支持。缓存
若是引擎不能提供,或者要 hack 才能实现,那创建上层建筑的压力和风险就太大。对这种需求,不少人可能想到这里就放弃了。为何必定要提供组件级别的状态回滚这样的功能呢,之前也没有人这样干过啊?咱们仍是先盖个平房吧。架构
这种想法很可悲,一是认识不到上层建筑的价值,二是不能正确剖析问题。其实不少时候只要再迈一步,想一想它的本质,解法就跃然纸上了。
对上层的应用框架、调试工具、测试工具来讲,他们功能的自己,就是对组件的控制或信息展现,因此它们要求彻底控制组件的构成成分的是合理的。就像木偶身上的线越多,能控制的动做就越精细。要提供构成成分的控制权,咱们先理清楚组件的构成成分有哪些:
最后一个外部不须要,能够忽略。视图的外部控制会在以后提到,暂时搁置。那么这里咱们要考虑就只有数据和事件函数了。若是组件的数据能直接暴露给外部,甚至由外部控制,那么实现调试时数据的查看、状态的回滚等功能就会很简单。咱们在写组件是也常常发现一个现象:
组件内部的 state,一般都要提供一个同名的 prop 容许外部来控制。
由于用户越多,需求也就越多,今天有人问属性 a 能不能配置,明天有人问 b,最后必定会发展到几乎全部能影响视图的数据均可以由外部配置。
事件也是同样,在写组件的过程当中也常会收到这样的需求:能不能在组件XXX事件以后提供一个回调?能不能在以前提供一个回调?能不能提供参数阻止掉默认事件?问题的本质仍然同样,场景越丰富,外部要求的控制也就越强,最后必定会发展到每一次视图变化都得对外提供回调、都提供能阻止默认行为的状况。回想一下,这样的状况是否是有点似曾相识?原生组件基本上就都是这样的!想一想 input 组件有多少事件就知道了。
对这两个场景,可供选择的解决方案有不少。小的方案能够是提供一些工具类,在声明数据或者事件的时候使用工具类包装一下。例如:
// 伪码 class Com extends React.Component { constructor(props) { super() // takePropsAsDefault 负责检查 props 上有没有要外部传入的覆写的数据 this.state = takePropsAsDefault(props, {/*state 的定义*/}) // wrapWithCallback 负责在事件函数先后触发回调 Object.assign(this, wrapWithCallback(props, {/*listener 的定义*/})) } }
大的方案能够是不直接建立组件,而只是将数据和事件声明出来,由上层建筑根据本身的须要使用统一的方法来建立组件。例如:
// 定义 const ComDef = { state: {/* state 的定义*/} onChange() {/* 事件函数代码 */} } // 在外部使用时,使用统一的封装函数封装成组件 const Com = wrap(ComDel)
不管哪一种方式,看起来都是简化定义,但同时又可以支持组件常见的行为。例如上例咱们定义了 onChange。那么用户在使用时应该能自动用相似如下这下方式传入回调或阻止默认事件:
// 普通触发 <Com onChange={() => {}} /> // 阻止默认 onChange 触发,用数组表示有选项要传入。固然也能够别的表示法 <Com onChange={[() => {}, true]} /> // 在默认 onChange 前触发。这里用个数组第三个参数表示。 <Com onChange={[() => {}, false, true]} />
综上,对外提供控制权的基本思路都是组件先只定义,而后统一通过二次包装再变成组件。想一想若是组件库不统一这样设计,而是每一个组件、而且每一个数据和事件函数都单独支持这样的能力,得多花费多少时间!
这里先记住这个结论,至于建立组件是在组件层仍是外部,先不作决定,留下空间,由于还要考虑其余几个层次的问题。
工程工具一般指的文档、示例、版本发布工具等。有的人会把测试也划入到工程工具中,咱们前面已经提到,因此这里再也不赘述。
工程工具遇到最主要的问题就是更新不一样步,例如组件今天加了个新属性,文档忘了写。这种状况还算好,若是是属性删了,文档忘了更新那就会收到一大批 issue 了。因此稍微大点的工程,稍微有点追求的工程师,都会想作自动化。可能会使用 jsDoc 之类的工具,将注释自动变成文档等。
工程工具的核心也正是自动化。
示例,自动读取的文档:
示例,提交代码时的文档自动检测:
那么自动化的前提是什么呢,或者说对组件层的要求是什么?若是我删了一个属性,工具要自动帮我删掉相应的文档,前提是否是工具必须知道我删掉的“是一个属性”,而不是任何其余无关的数据?怎么知道?简单,建立组件时,属性一般会以某种方式声明出来。例如 React 中声明的 propTypes。同理,若是今天删掉的是一个回调呢?若是组件也以某种方式声明函数式一个回调,那么固然就也能识别,就也能自动化。除了代码中的声明,用注解的方式也能够实现。总之就是要告诉外部,什么东西是干什么用的,而且告诉得越多越好。这里就引出了咱们设计组件库时最重要的一个概念:
组件元素的语义化。工程自动化的前提就是组件提供足够多的语意。
咱们继续看实现中的问题。首先会注意到,现代的组件框架中,语意是不够的,例如用户声明在组件上的一个方法,你怎么知道它是个工具方法?仍是用来改变数据而且会引发从新渲染的?一样,用户传入的函数,你是用来作某种判断呢?仍是用来作回调?这些语意不明确下来,工程工具就没法实现它的功能。
组件框架不设计这样的区别是能够理解的,由于从它的角度来讲,并不须要这样的语意。须要这些语意的是更上层的建筑。因此,咱们的方案中须要有个组件的原始定义来保存住足够多的语意。所以第一步的方案中,组件只作声明,由外部来包装这个方案更好。
虽然有告终论,可是到这里思考尚未结束。语意的声明是对每一个数据、函数都再加个描述字段吗?那这样写起来和 jsDoc 的注解没有本质区别。这种方式和文档的风险同样,也会忘记写,并且无感知。最好的开发体验应该是一旦没写,就调试、运行不了,但同时又没有增长开发者的负担。知足这个条件只有一种状况,就是声明自己是组件的一部分。咱们注意到组件中的属性,一般都会有默认值。声明默认值的过程,不就是声明属性的过程吗?一样,声明事件函数的时候,若是不是直接把函数粗暴的暴露出来,而是放在一个指定的字段下,那么就也能轻松地辨识。因此,把组件定义写成一个语意明确的键值对,不就解决了吗:
const Com = { defaultState: {}, // 事件函数 defaultListeners: {}, // 拦截器 defaultIntercepters: {}, // ... }
再回头想一想第一个问题,上层框架要精确控制组件层,语意也是必不可少!要精确控制数据和事件函数,自己就须要先知道哪些函数是事件函数。
维护过组件库的读者会发现,有一类比例很大的需求很累人,就是增长配置项。例如,把 Table 的翻页放在 Table 上面的,还有要求上下都要有的。还有要求给某个组件增长某些拦截器功能,在拦截器成功时就执行默认事件,不然不执行。组件的功能越多,用的场景越多,这样的需求也就越多。而且最后的结果只有两种,一是支持,加上了各类选项,组件配置愈来愈冗杂。二是不支持,请提需求的人本身改改源码以知足需求。
第二种状况下,站在改组件的人角度来看,又会发现新问题。有时源码是用 ts 或者其余变种写的,改起来很不习惯。一般组件库内还有大量的内部约定或者公用代码,要改动的话还得全盘熟悉。又或是打包发布时发现要改写只能重发布一套组件库,单独发布组件还要大改发布的代码。这种种限制,让覆写步步维艰。
其实增长配置项这类需求的本质就是覆写,不管是改一点点样式仍是改一点点行为,都是覆写。若是不想无休止地支持配置项,那么咱们就该让覆写变得简单一点。在前面的结论下,你会发现这个问题已经自然地被解决了。由于个人组件在开发阶段只是定义,都尚未被真正封装成组件,你直接拿来覆盖掉其中的一部分定义便可。而且不管组件本来元什么语言写的,在你拿到的时候,仍然只是个标准的 js 对象,这样就也再也不存在工程问题。
import Com from './Com' export const Com2 = { ...Com, listeners: { ...Com.listeners, onChange() {/* 覆写 onChange */} } }
那么到这里,方案看起来已经能够肯定了?
等等,还有一个问题。就是视图内部的覆写。这个问题讨论得比较少。
这个覆写包括样式的覆写、内容的覆写和功能的覆写三种。目前业界样式的覆写基本上都是经过覆写 css 实现的。虽然对 css 独立仍是 css-in-js 多有争论,但实施上二者并无很明显的优劣,这里先不讨论。
内容的覆写指的是:“组件内的文案写的太差,能不能动态换掉”?”icon 更不能换个更好看的“?”某一块区域能不能高亮“?若是这些细节都要写成配置由外部传入,那组件开发将没完没了,毫无乐趣。但若是让用户像复写逻辑同样彻底复写 render,又过重,复杂的组件实施难度大。有没有可能在框架层面天生提供这样的能力?
固然能够。拿个场景来思考——咱们想要替换掉某一部分的文案——先不论用什么方式,是否是必须先知道哪一块展现的是文案?怎样知道?法宝,语义化!是的,又是语义化。若是我能以种方式告诉外界视图的某一部分是文案,再提供外界覆盖的能力,那么就实现了。以 React 为例:
// 定义 const ComDef = { render({ wrappers }) { const { Text, Root } = wrappers return ( <Root> <Text>some text</Text> </Root> ) } }
// 使用 const Com = wrap(ComDef) const Root = ({children}) => <div style={{background: "red"}}>{children}</div> const Text = ({children}) => <div style={{color: "black"}}>{children}</div> ReactDom.render(( <Com Root={Root} Text={Text}/> ), node)
这个例子里面,咱们能够经过外部配置获得无数种样式的 Com 组件实例,但 Com 在定义时彻底无感知!
一个 Card 组件,动态覆写的效果示例:
有了这个方案,视图覆写的世界已经为你打开了一扇巨大的门。样式的覆写变得更简单,我再也不须要了解组件自己的实现方式,原组件究竟是 css 仍是 css-in-js 我都无论,我只须要关注我想要的就好,至于我怎么实现样式也与原组件没有冲突。再举个例子,国际化,再次基础上咱们就有了更好的方案。过去的国际化一般都须要组件了解国际化工具的存在,而且造成约定,例如 react-intl。而如今经过框架统一的覆写,组件与国际化工具彻底解耦了。
再发挥一下想象力,咱们刚刚还提到了功能的覆写。这里有个典型场景:“可视化编辑中的组件拖拽功能”。拖拽对于普通的组件还好,容器类的组件是个麻烦。例如 Tabs。我要将子组件拖到 Tabs 中,那 Tabs 必需要实现 onDrop 事件我才能收到消息。而谁会在开发 Tabs 的时候就考虑拖拽的问题呢?因此不少可视化的工具的解决方案是:为这一类组件再单独开发了一个长得同样的替身,专门用于编辑时的拖拽。这种方法简单,可是却让维护成本翻倍。一旦原组件改了,替身极可能也要修改。而若是用刚刚的方案,只要组件明确了标签的语意,而且接受从外部传入覆盖,那么咱们只要在传入的组件中实现 onDrop 事件就好了。原组件不须要任何特殊支持。
提供视图覆写能力的意义在于,开发者不须要知道外部需求的细节,始终只维护一份组件源码,就能自动支持海量的视图需求!
综上,回顾问题域的三个部分,咱们有了如下结论:
一份完整的组件声明以下图所示:
export const defaultStateTypes = {/* state 的类型声明 */} export const getDefaultState = () => ({/* 默认的 state */}) export function initialize() { // 返回一个对象,改对象将做为 instance 参数注入到全部函数中。可将 instance 做为数据缓存 return {} } export const defaultIntercepters = {/* 声明外部传入的函数类型的属性 */} export const defaultListeners = { // 第一参数为外部框架注入。后面的参数即调用 listener 时传入的参数。 onClick({ state, instance }, ...args) { // const changedStateValues = ... // changedStateValues 只包含变化了的 state 字段 return changedStateValues } } export defaultWrappers = { // 可由外部传入的语义化的子组件 Text: 'span' } export const identifiers = { // 例如 Tabs 下的 TabPane。Input 的 Prefix 这种占位符式的组件须要在这里声明 } export function render({state, children, instance, listeners, wrappers, intercepters}) { return <div></div> }
本质上,不管什么组件框架都能使用这套方案。甚至能够实现同一个组件声明,由不一样的引擎渲染。咱们的团队目前已经在多个项目中实践这套组件规范,并提供了 React 版的工具仓库,能够将组件定义封装成单独可用的组件。上面的 Card 覆盖效果就是其中一个 React 实现的例子。这里能够在看一个组件只声明 onChange,自动加上回调以及组织默认事件的功能。
然而,除去规范自己,咱们更但愿读者关注到的是它为构建上层建筑所提供的架构基础,以及咱们是如何从系统角度去考虑问题的呃。在作底层基础设施建设时,必定不能只关注自己。石坚,塔方能通天。
我记得小而美的概念最先指的是 Linux 中的命令设计。然而让咱们绝大部分真正感到受益的倒是操做系统之上各类各样的应用。因此上层建筑与小而美并不冲突。上层建筑指从跨层次的概念,是纵向的。小而美指的是在某一个层面的概念的设计上,是横向的。应用就是操做系统的上层建筑,即便实现很复杂,但设计很简洁,功能专一,那么对用户来讲也是小而美的。另外,上层建筑的意义在于,摩天大楼能提供给人的视野毫不是小平房能比的。小平房盖得再多,也提供不了高楼带来的风景。
在咱们团队实施这套方案的初期,确实也受到了“重复造轮子”的指责。咱们的轮子大、重、耐高温,不少人没法理解,可是当装到飞机上,飞机起飞后,就没有人再说话了。因此,在造轮子时首先要扪心自问一下,是为了工做绩效、名声、仍是更远大的理想?若是是远大理想就必定要坚持。一样,在指责别人造轮子的时候,也好好思考下,别人究竟是浪费人力、不懂合做,仍是本身的技术视野高度不如别人。毕竟夏虫不可语冰,可悲的是虫。
它的自己固然没有什么特殊的。特殊的是组件的写法能够有无穷多种,咱们为何使用了这一种。咱们想用它干什么。请关注它的上层建筑。上一篇文章介绍的可视化搭建系统就是基于这样的规范:页面搭建工具的死与生。基于这套规范的应用框架和测试框架咱们也会在近期开源。
从另外一个角度来讲,这套方案与其说是“组件规范”,其实不如说是“组件层与应用框架层的接口规范”更为合适。若是在系统中真的有“影响视图,但外界绝对不可能须要的数据”。那么咱们仍然能够先封装出一个标准的、原子的 React 组件,将这些数据包裹住。再在外层包装成 lego 组件。
由于这类的内容一般与具体的组件引擎相关,而且社区内基本都有成熟的案例参考。所以不在文中赘述。
是的!咱们正在作可视化的系统搭建平台,具体能够参见我上一篇文章。感兴趣的同窗能够发简历到 ariesate@outlook.com :)