通天塔之石——企业级前端组件库方案

通天塔之石——企业级前端组件库方案

组件库是前端大规模开发中提升效率的重要一环,同时也是可视化页面搭建、自动化测试等上层建筑的基石。因此设计时要考虑的问题涵盖面非常广。要设计好非常难,但是设计好之后从上层建筑带来的回报会超过你的想象。

这篇文章中我们先一起来关注和探讨组件库要解决的问题,最后会推导出一套足够灵活——适用于大团队或社区使用,又足够强大——能支撑起上层建筑的组件库方案。也请读者注意,结论其实很简单,文中思考过程才是重点。知道结论并能让你一跃成为架构师,但知道了如何从系统角度设计局部却让你有机会可以。共勉。

1. 问题域

要理清问题域,我们先要了解组件库在架构层面处于哪个位置,它都与哪些其他部分有关系,一图蔽之:

可以看出问题域大体可分为三部分:

一,产生于应用框架等上层建筑。例如应用框架可能希望能控制组件的所有状态,监听所有事件,以便能提供完整的回滚等功能给用户。精确到组件数据的测试框架的示例:

二,产生于工程工具。希望得到更多组件内部的信息。对属性的自动读取示例:

三,产生于组件的需求本身。这里涉及到理想的应用框架中提到的两个问题:有需求希望组件的逻辑不变,展示稍微变一下怎么办?或者前后两者反过来怎么办?

接下来再细化每个部分的问题:

1.1 上层建筑

任何底层方案设计时首先要关注的就是上层建筑。上层建筑是回报的来源,能承载的上层建筑越多,回报越大。但是同时,高楼带给底层的压力和挑战也是巨大的。例如从我们上面所举的例子——测试框架通过对组件所有属性变化的监听来实现数据对比或者回滚——现在并没有哪一个组件引擎天然很好地支持了这样的能力。即使是类似 react 的调试工具中显示的状态也是利用了引擎的特殊支持。

如果引擎不能提供,或者要 hack 才能实现,那建立上层建筑的压力和风险就太大。对这种需求,很多人可能想到这里就放弃了。为什么一定要提供组件级别的状态回滚这样的功能呢,以前也没有人这样干过啊?我们还是先盖个平房吧。

这种想法很可悲,一是认识不到上层建筑的价值,二是不能正确剖析问题。其实很多时候只要再迈一步,想想它的本质,解法就跃然纸上了。

对上层的应用框架、调试工具、测试工具来说,他们功能的本身,就是对组件的控制或信息展示,所以它们要求完全控制组件的构成成分的是合理的。就像木偶身上的线越多,能控制的动作就越精细。要提供构成成分的控制权,我们先理清楚组件的构成成分有哪些:

 

  • 用于驱动视图的数据
  • 改变数据的方法(事件函数)
  • 视图(通常就是 render 函数或模板)
  • 组件内部使用的帮助函数和缓存数据等

最后一个外部不需要,可以忽略。视图的外部控制会在之后提到,暂时搁置。那么这里我们要考虑就只有数据和事件函数了。如果组件的数据能直接暴露给外部,甚至由外部控制,那么实现调试时数据的查看、状态的回滚等功能就会很简单。我们在写组件是也经常发现一个现象:

组件内部的 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]} />

综上,对外提供控制权的基本思路都是组件先只定义,然后统一经过二次包装再变成组件。想想如果组件库不统一这样设计,而是每个组件、并且每个数据和事件函数都单独支持这样的能力,得多花费多少时间!

这里先记住这个结论,至于创建组件是在组件层还是外部,先不做决定,留下空间,因为还要考虑其他几个层次的问题。

1.2 工程工具

工程工具通常指的文档、示例、版本发布工具等。有的人会把测试也划入到工程工具中,我们前面已经提到,所以这里不再赘述。

工程工具遇到最主要的问题就是更新不同步,例如组件今天加了个新属性,文档忘了写。这种情况还算好,如果是属性删了,文档忘了更新那就会收到一大批 issue 了。所以稍微大点的工程,稍微有点追求的工程师,都会想做自动化。可能会使用 jsDoc 之类的工具,将注释自动变成文档等。

工程工具的核心也正是自动化。

示例,自动读取的文档:

示例,提交代码时的文档自动检测:

那么自动化的前提是什么呢,或者说对组件层的要求是什么?如果我删了一个属性,工具要自动帮我删掉相应的文档,前提是不是工具必须知道我删掉的“是一个属性”,而不是任何其他无关的数据?怎么知道?简单,创建组件时,属性通常会以某种方式声明出来。例如 React 中声明的 propTypes。同理,如果今天删掉的是一个回调呢?如果组件也以某种方式声明函数式一个回调,那么当然就也能识别,就也能自动化。除了代码中的声明,用注解的方式也可以实现。总之就是要告诉外部,什么东西是干什么用的,并且告诉得越多越好。这里就引出了我们设计组件库时最重要的一个概念:

组件元素的语义化。工程自动化的前提就是组件提供足够多的语意。

我们继续看实现中的问题。首先会注意到,现代的组件框架中,语意是不够的,例如用户声明在组件上的一个方法,你怎么知道它是个工具方法?还是用来改变数据并且会引起重新渲染的?同样,用户传入的函数,你是用来做某种判断呢?还是用来做回调?这些语意不明确下来,工程工具就无法实现它的功能。

组件框架不设计这样的区别是可以理解的,因为从它的角度来说,并不需要这样的语意。需要这些语意的是更上层的建筑。所以,我们的方案中需要有个组件的原始定义来保存住足够多的语意。因此第一步的方案中,组件只做声明,由外部来包装这个方案更好。

虽然有了结论,但是到这里思考还没有结束。语意的声明是对每个数据、函数都再加个描述字段吗?那这样写起来和 jsDoc 的注解没有本质区别。这种方式和文档的风险一样,也会忘记写,而且无感知。最好的开发体验应该是一旦没写,就调试、运行不了,但同时又没有增加开发者的负担。满足这个条件只有一种情况,就是声明本身是组件的一部分。我们注意到组件中的属性,通常都会有默认值。声明默认值的过程,不就是声明属性的过程吗?同样,声明事件函数的时候,如果不是直接把函数粗暴的暴露出来,而是放在一个指定的字段下,那么就也能轻松地辨识。所以,把组件定义写成一个语意明确的键值对,不就解决了吗:

const Com = {
  defaultState: {},
  // 事件函数
  defaultListeners: {},
  // 拦截器
  defaultIntercepters: {},
  // ...
}

再回头想想第一个问题,上层框架要精确控制组件层,语意也是必不可少!要精确控制数据和事件函数,本身就需要先知道哪些函数是事件函数。

1.3 组件扩展

维护过组件库的读者会发现,有一类比例很大的需求很累人,就是增加配置项。例如,把 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 事件就行了。原组件不需要任何特殊支持。

提供视图覆写能力的意义在于,开发者不需要知道外部需求的细节,始终只维护一份组件源码,就能自动支持海量的视图需求!

2 方案

综上,回顾问题域的三个部分,我们有了以下结论:

  • 只声明,不封装,封装交由外部处理。这样上层能获得最大的控制权。
  • 声明组件时保证足够的语意,让工程工具能够更好理解组件。

一份完整的组件声明如下图所示:

  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,自动加上回调以及组织默认事件的功能。

然而,除去规范本身,我们更希望读者关注到的是它为构建上层建筑所提供的架构基础,以及我们是如何从系统角度去考虑问题的呃。在做底层基础设施建设时,一定不能只关注本身。石坚,塔方能通天。

 

3 答读者问

构建庞大的上层建筑不是和小而美的理念冲突了吗?

我记得小而美的概念最早指的是 Linux 中的命令设计。然而让我们绝大部分真正感到受益的却是操作系统之上各种各样的应用。所以上层建筑与小而美并不冲突。上层建筑指从跨层次的概念,是纵向的。小而美指的是在某一个层面的概念的设计上,是横向的。应用就是操作系统的上层建筑,即使实现很复杂,但设计很简洁,功能专注,那么对用户来说也是小而美的。另外,上层建筑的意义在于,摩天大楼能提供给人的视野绝不是小平房能比的。小平房盖得再多,也提供不了高楼带来的风景。

这不是造轮子吗?

在我们团队实施这套方案的初期,确实也受到了“重复造轮子”的指责。我们的轮子大、重、耐高温,很多人无法理解,但是当装到飞机上,飞机起飞后,就没有人再说话了。所以,在造轮子时首先要扪心自问一下,是为了工作绩效、名声、还是更远大的理想?如果是远大理想就一定要坚持。同样,在指责别人造轮子的时候,也好好思考下,别人到底是浪费人力、不懂合作,还是自己的技术视野高度不如别人。毕竟夏虫不可语冰,可悲的是虫。

这个方案看起来就是换了种组件的写法,好像没什么特殊的?

它的本身当然没有什么特殊的。特殊的是组件的写法可以有无穷多种,我们为什么使用了这一种。我们想用它干什么。请关注它的上层建筑。上一篇文章介绍的可视化搭建系统就是基于这样的规范:页面搭建工具的死与生。基于这套规范的应用框架和测试框架我们也会在近期开源。

将数据和事件函数都暴露到全局,不是破坏了封装的原则吗?

封装的目的之一是为了将“与外界不相关”的信息或者逻辑隐藏起来。那么什么是不相关的信息呢?或者反过来问,什么是与外界相关的信息?回想一下上文中曾提到的关于需求的例子,为什么会有人不断提出要求说这个属性要暴露那个属性要暴露呢?因为这些属性都是会影响视图的属性,都是视图的一种状态。而任何一种视图状态,都可能产生需求。例如可能有需求要查询 collapse 是否是打开的状态,因此要暴露表示打开的属性。也可能有需求要查询Tab 的当前选中项,因此要暴露。我们在规范中向外暴露的数据都是会影响视图的,因此都是“相关的”,与封装不矛盾。

从另一个角度来说,这套方案与其说是“组件规范”,其实不如说是“组件层与应用框架层的接口规范”更为合适。如果在系统中真的有“影响视图,但外界绝对不可能需要的数据”。那么我们仍然可以先封装出一个标准的、原子的 React 组件,将这些数据包裹住。再在外层包装成 lego 组件。

方案中好像没有描述公共模块、构建等内容?

因为这类的内容通常与具体的组件引擎相关,并且社区内基本都有成熟的案例参考。因此不在文中赘述。

写文章是不是为了招聘?

是的!我们正在做可视化的系统搭建平台,具体可以参见我上一篇文章。感兴趣的同学可以发简历到 ariesate@outlook.com :)

 

 

 

posted @ 2017-06-13 17:24  侯振宇  阅读(13869)  评论(6编辑  收藏  举报