Appearance
组件化-为前端开发降本提效
在上一篇文章中,我给你介绍了前端模块化相关的内容,包括模块化的发展和几种常见的模块化规范。模块化是分治思想在前端的重要实践。除了模块化之外,今天我们来继续介绍分治思想在前端的另一个重要实践 —— 组件化。
什么是前端组件化
在项目发展到一定的程度的时候,页面的逻辑会越来越多,代码量也会越来越多,经常会牵一发而动全身。并且代码之间也可能会相互影响,比如全局变量的问题。不管是 JS 还是 CSS,只要有相同的全局变量,就会存在覆盖的问题,可能哪一天你正在开发代码,QA 小姐姐就过来找你,但是最后排查下来,发现是你负责的功能被其他人的代码影响到了(╬ ̄皿 ̄)=○。
同时,重复的功能重复开发,也是浪费劳动力的一种浪费。所以,组件化解决最大的两个问题就是:代码复用和作用域隔离。
在这种时候,就会考虑进行拆分,分治思想就是很好的方法论。组件化就是分治思想在前端的另一个应用。组件化是将一个复杂的业务场景分解为若干个小场景,这些小场景之间互不干扰、通过暴露出来的接口进行组装。这样就可以分别开发,然后进行随意的组合,后期如果某一部分出问题,就可以单独修复,不影响全局。和模块相比,组件更偏向于运行时,模块偏向于代码结构的组织方式。并且组件有更为复杂的状态和生命周期。
其实组件化的思想在我们平时生活中也十分常见。比如一辆马萨拉蒂跑车:一辆跑车可能包括车身壳体、车门、车床、轮胎等不同的零件。而这些零件可能是由不同的供应商提供的,也可能是自己流水线生产的,最后将这些零件组装在一起。后期在使用的过程中,某个零件出问题了,那么只需要将有问题的零件进行更换或者维修即可。
随着 Vue、React 的前端框架的兴起,前端组件化已经渐渐成为前端开发的共识和标配。就拿 Vue 来说,Vue 的一个核心思想就是组件化。Vue 提倡让我们将大型项目拆分为多个小型、独立和可复用的组件。从 Vue 推崇的文件结构也可以看出其组件化的思想,Vue 提倡我们将一个组件的 CSS、JavaScript、HTML、图片等资源组织在一起,每一个组件内部的资源都是独立的,互不干扰的,目录就是最好的命名空间。组件和组件之间可以相互连接和嵌套。一个页面可以从根节点开始抽象为一棵组件数树。
前端组件化发展之路
JS 组件化
在交互较少的静态页面时期,前端整体的代码量不多,需要共用的代码也不多,大部分需要共享的只是一些公共函数。我们把需要共用的内容存放在一个公共文件中,在用到的地方引入该文件,这就实现了最简单的代码复用。
随着 JQuery、easy-ui、miniUI 等 UI 框架的出现,前端组件化进入到探索阶段。就比如曾经火爆全网的 JQuery,JQuery 建立了一套自己的插件系统,开发者可以将一些常用的逻辑封装为 JQuery 的插件,然后将插件在开源社区中共享。从逻辑简单的轮播,到功能复杂的日期选择器都可以在开源社区找到别人共享的插件。此时,前端组件化初见端倪。
随着 Angular、React、Vue 三大框架的出现,才算是真正解决了前端组件化这个问题。开发者可以创建自定义组件、并且可以将组件进行自由拼装。这些自定义组件由结构 —— 标记语言(HTML)、表现 —— 层叠样式表(CSS)和 行为 —— 脚本语言(JavaScript)组成。并且借助 npm、brew 这些包管理工具,可以方便的将自己开发的组件和其他开发者共享,同时也可以使用全社区他开发者提供的组件。至此,前端组件化有了成熟的发展。
随着三大框架的流行,诞生了一系列配套的优秀前端基础组件库,比如 Vue 的组件库 Element 和 React 的 Ant Design。这些开源的组件库封装了前端常见的基础组件比如 button(按钮)、input(输入框)、select(选择器)等,封装其页面样式、交互以及数据的处理。
基础组件库不仅帮助我们统一页面的设计风格,最重要的是提高了开发效率,特别是对一些开发人员有限的团队。
CSS 组件化
CSS(Cascading Style Sheets)
层叠样式表,严格意义上讲,并不能称之为编程语言。其原始语法非常简单且缺乏可编程性。
在 Less 或 Sass 出现之前,CSS 的复用方式就是使用同一个命名的公共样式。因为 CSS 没有作用域,相同命名的样式后者会覆盖前者,所以 CSS 经常会有命名冲突的问题。公共样式使用同一命名的一个很大问题就是难以维护,不容易修改并且很容易会出现样式被覆盖的问题。
在 Less 或 Sass 出现之后,使得 CSS 具有可编程性,CSS 组件化也成为可能。我们在 CSS 中也可以定义变量、使用嵌套、混入(mixin)、实现继承等功能。提高了代码的可阅读性和可维护性。
CSS in JS,在 CSS 组件化或者说模块化的解决方案中,还有一个思路是不需要单独写 CSS 文件,在 JS 中写 CSS 代码,借助 JS 的力量实现 CSS 组件化。目前已经有很多实践方案但是仍然没有出现一个现象级工具出现。
怎么设计一个组件
现如今,开发一个组件已经是前端工程师日常工作中必不可少的一项。调用 API 相信大家都可以做到,大家都可以做到的就不是你的核心竞争力。如何设计一个前端组件,才是组件化道路的重点和难点,是考验开发者能力的一项基本功。那么如何修炼好自己的基本功呢?就是你需要思考的问题。
一般来说,设计一个好的组件会遵循以下几个原则。
统一性
一个组件必定是要在项目中使用,那么就必须要遵守项目或者社区“公约”。比如项目中都是使用的小驼峰的命名方式,你使用大驼峰命名方式,那么在引入文件的时候,其他同学按照习惯直接引用的小驼峰的文件名,如果本地的开发环境是大小写不敏感的那么问题就不会暴露,在 CI\CD 的环境是大小写敏感的话,就会直接出问题(都是血的教训呀 (╥╯^╰╥))。再比如对外提供的 API,尺寸大小 size 属性都是使用全称large\middle\small
,那么你设计的 API 风格是使用缩写 lg\md\sm
,那么别人使用起来是不是很容易传错参数,增加调试成本呢?
PS:这里有插播一个小插曲,前一段在开发的时候,使用的基础组件库是团队内部的,在使用 ICON 组件的时候需要添加点击事件,然后我直接使用
onClick
API,发现一直没有生效。然后尝试了很多方法调试,都没有成功触发。最后,看了一眼其 API,发现其 API 设计的是onPress
。一口老血喷出 ヘ(;´Д`ヘ)。
所以我们在开发组件的时候,一定要遵循现有的设计规范、开发规范,方便你我他。
单一职责
单一职责是我们在拆分组件的时候要遵守的一个重要原则。一个组件只专注于做一件事情,这样就可以最大程度地进行组件的复用。但是往往在实践的时候就会面临一个问题:拆分的粒度如何把握?粒度太粗,不利于组件的复用,但是粒度太细就会过度抽象导致组件过于碎片化。
所以,如何确定拆分的粒度是我们开发组件的一个难点和重点,也是在考验开发者的技术水平和经验。对于我个人的开发习惯而言,拆分粒度会结合以下几点。
是否被 2 个及以上的父组件复用。如果在开发的时候该部分在功能上应该被设计为一个独立的组件,但是由于只在某一个地方使用,可以暂时不将其开发为独立组件。如果后续迭代中,该组件被 2 个及以上的父组件复用,那么就将其抽象出来,作为独立组件。但是在开发的过程要将其设计的尽量独立方便后续组件抽出。
该组件逻辑功能是否复杂。其实,如果组件拆分的比较好的话,一个组件的代码是不会过长的。但是如果一个组件的逻辑较为复杂导致体量较大,那么就算其只在一个地方使用,那么也应该将其独立出来,方便代码的维护。
根据页面结构进行拆分。就比如门户网站的典型页面,上中下式的结构。最底部的
footer
即只在一个地方出现,并且逻辑也不复杂,但是实际上footer
通常都会作为一个独立的组件,因为按照页面结构进行组件的拆分,能够更方便我们组织和阅读代码。
组件拆分的粒度没有统一的标准,需要你在实践中多多沉淀经验,探索最佳的实践方式。
复用性
我们使用组件化开发的一个很重要的原因就是提高代码的可复用性。虽然某一需求最初都是处于特定的目的进行设计的,但是作为开发者,我们要考虑到我们开发的组件在未来某个时间是否会被复用?是否还有其他使用的业务场景?还可能会对组件进行怎样的扩展?这需要我们在开发之初就要考虑到。
比如一个基础组件:按钮 Button,我们怎么设计才能最大程度的提高组件的复用性呢?首先,我们需要考虑下按钮都会有哪些业务含义,比如:
- 一般业务场景下使用默认按钮;
- 用于深色背景的幽灵按钮;
- 用于导航的链接按钮;
- 用于文本的文本按钮;
- 用于只有图标的图标按钮。
除了业务含义,按钮在不同的场景下的状态也是不同的,比如:
- 用于删除等存在风险的操作的危险状态;
- 不可点击时的禁用状态;
- 用于处于加载中的加载状态。
另外,按钮还应该有不同的尺寸large\middle\small
和形状default\circle\round
,并且还需要具备点击的回调事件。
以上这么多属性结合起来已经可以满足大部分的使用场景了。但是设计师们还是有可能提出定制化需求,比如这个按钮的圆角要小一点,那个按钮的颜色要是特别一点。
组件复用性设计其实从某种意义上来说是要放弃对组件的控制权,让使用者能够最大限度的进行 DIY,这就要我们在设计组件的时候,将一些可以自定义的地方给使用者提供修改的入口。
比如,我们可以让使用者给组件增加 CSS 类名,也可以支持让使用者直接传入 CSS 的具体样式来新增或覆盖原有样式,还可以让使用者自行传入 DOM 元素来替换现有的某些 DOM。
组件的复用性就是将组件的一部分控制权交给使用者,让其能够在合理的范围内最大程度地有自定义的权限,具体怎么暴露出哪些口子,需要组件的设计者反复的思考和调研。
生命周期
在文章的开始部分我们也提到了,组件和模块的一个很大的区别就是组件有生命周期。我们在使用 React 或者 Vue 写组件的时候,也有生命周期的概念。
组件的生命周期,指的是组件自身的一些函数,这些函数在特殊的时间点或遇到一些特殊的框架事件的时候被自动触发。那么我们在设计组件的时候一定要明确在每一个生命周期该做什么事情。
通常情况,一个组件会有创建、更新、销毁三大阶段。在创建阶段,我们会做一些初始化操作,比如读取属性值、获取数据、重置等操作。在更新阶段,比如用户进行了某些操作、组件内的属性值发生了变化或者执行一些定时任务导致组件内的数据或者 UI 发生变化的时候,需要对组件内的数据和状态进行处理。最后是组件的销毁阶段,需要进行某些 DOM 节点的移除以及定时器的清除操作,来减少组件对系统产生的副作用。
通信
除了生命周期,我们在设计组件的时候,还需要考虑组件之间是怎么通信的。比如父组件怎么给子组件传递数据、子组件通知父组件自己内部的数据进行了修改、兄弟组件之间的通信、爷孙组件的通信……
通常情况下,父组件可以通过入参(props)将想要传递的数据传递给子组件。子组件会通过回调函数的方式和父组件进行通信。
不建议修改父组件传入的 props,数据流保持单向传递。 如果子组件内部可以改变父组件的数据,那么父组件内的数据改变就会变得难以追溯。单向数据流保证了父组件的状态不会被子组件意外修改,如果想要修改父组件的值,就需要子组件通过回调函数的方式通知父组件,在父组件的内部进行数据的修改。
爷孙组件的通信可以通过父组件作为中间层进行传递数据(如下图);另外,兄弟节点想要通信也可以先将数据传递给父组件,再由父组件传递给另外一个子组件(如下图)。但是这种方式都又依赖于其他组件,传递起来过于繁琐,开发起来复杂也增加了维护的复杂度。
所以对于非父子组件的通信一般会使用另一种通用的通信方式:EventBus。我们可以理解为事件的分发中心。所有的组件共用相同的事件中心,都可以向该中心发送或者接收事件。
还有一些比如 Redux、Vuex 这样的集中管理的状态容器,可以集中管理所有组件的状态,适用于大型单页应用。 这类的状态管理容器因为写法较为繁琐,对于一些组件关系简单的小型应用在使用之前要充分评估下使用的必要性。有很多的开发者都是在开发项目的时候为了用而用,这完全就是“杀鸡用牛刀”,提高的项目的复杂度和增加了维护成本,有些得不偿失。
React 是如何创建组件的
在 React 中,我们可以使用 JSX 将 JS 和 HTML 结合,在 React 组件内部使用 HTML 语法创建虚拟元素,将 HTML 结构引入到 JS 中。React 中还有一个重要概念就是 Virtual DOM,React 使用 Virtual DOM 创建虚拟的 DOM 节点(Virtual DOM 使用 JS 对象结构模拟 HTML 中的 DOM 结构)。其优点是在数据更新之后,React 会使用 diff 算法对比需要变更的节点,在一次事件循环之后批量将有变化的 DOM 进行更新够在更新到真正的 DOM 树上,从而减少了真实 DOM 的操作、大大提高了项目的整体性能问题。
那么接下来我们就来看下 React 是怎么将 JSX 转换为最终真实 DOM 的过程。
从 JSX 到 React.createElement()
比如,我们使用 JSX 在 react 中创建一个基础的组件,我们可以在 Button.jsx
文件中将 HTML 结构写在 JS 中,JSX 代码如下:
javascript
function Button() {
return (
<div className="custom-button">
<span onClick={function(){ console.log('Hello world') }}>Click!</span>
</div>
)
}
JSX 其实就是将我们平时写的 HTML 通过编译器编译为 JS 对象。JSX 经过编译会返回一个 createElement 函数,function createElement(type, attribute, children) {}
,其中:
- 第一个参数 type 表示这个节点的类型;
- 第二个参数 attribute 表示节点内的所有属性与值;
- 第三个参数是子节点信息,也就是 children;
那么上述 Button.jsx
中的代码经过Babel 提供的 JSX 编译器编译之后的 JS 代码如下(在 React 17 中提供了全新的 JSX 编辑器,感兴趣可以了解:介绍全新的 JSX 转换):
javascript
const Button = React.createElement(
'div',
{
className: 'custom-button'
},
React.createElement( 'span',
{
onClick: function(){ console.log('Hello world') } ,
'Click!',
}
)
)
从 createElement
到 Virtual DOM
上面我们介绍了将 JSX 编译成调用 React 的 createElement 方法。在 createElement 方法中主要做一些参数的处理过程, createElement 具体的源码如下(关键的步骤都在注释中写明了)(源码地址):
javascript
/**
* Create and return a new ReactElement of the given type.
* See https://reactjs.org/docs/react-api.html#createelement
*/
export function createElement(type, config, children) {
// 初始化参数
let propName;
const props = {};
let key = null;
let ref = null;
let self = null;
let source = null;
// 处理 config 中的内容
if (config != null) {
if (hasValidRef(config)) {
ref = config.ref;
if (__DEV__) {
warnIfStringRefCannotBeAutoConverted(config);
}
}
if (hasValidKey(config)) {
if (__DEV__) {
checkKeyStringCoercion(config.key);
}
key = '' + config.key;
}
self = config.__self === undefined ? null : config.__self;
source = config.__source === undefined ? null : config.__source;
// 将 config 的内容复制到 props 中
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
!RESERVED_PROPS.hasOwnProperty(propName)
) {
props[propName] = config[propName];
}
}
}
// 处理 children
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
// 如果只有一个参数,全部赋值给 children
props.children = children;
} else if (childrenLength > 1) {
// 如果有多个参数,合并处理
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
props.children = childArray;
}
// 处理默认值
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
// 返回 ReactElement 实例对象
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);
}
使用 createElement 创建出来的 Virtual DOM 如下:
javascript
{
type: 'div',
props: {
className: 'custom-button',
},
children: [ {
type: 'span',
props: {
children: [ 'Hello World' ]
},
}
}
我们可以看出 Virtual DOM 本质上就是一个 JS 对象,有三个属性 type、props、children,通过这三个属性就可以将页面上的 DOM 结构用 JS 对象表示。
从 Virtual DOM
到真实 DOM
那么有了 Virtual DOM
之后,怎么将 Virtual DOM
转换为真实的 DOM 呢?在 React 中,使用 instantiateReactComponent
可以将不同的 Virtual DOM
类型生成不同的渲染对象,具体的流程如下:
- 当传入的 虚拟DOM 节点为空时,创建空组件
ReactEmptyComponent
- 当传入的 虚拟DOM 节点为对象时,说明该 DOM 节点为 原生DOM 或者 自定义组件,如果节点的类型为 string,那么就说明该节点为 原生DOM 节点,使用
ReactHostComponent.createInternalComponent
创建 原生DOM 节点;反之为自定义组件,会使用ReactCompositeComponentWrapper
(自定义组件的主要实现在ReactCompositeComponent
里面,ReactCompositeComponentWrapper
只是一个防止循环引用的 wrapper); - 当传入的 虚拟DOM 节点为字符串或者数字时,则使用
ReactHostComponent.createInstanceForText
创建文本组件。
具体源码如下,同样的,关键步骤已经在注释中标注出来了。
javascript
/**
* Given a ReactNode, create an instance that will actually be mounted.
*
* @param {ReactNode} node
* @param {boolean} shouldHaveDebugID
* @return {object} A new instance of the element's constructor.
* @protected
*/
function instantiateReactComponent(node, shouldHaveDebugID) {
var instance;
// 创建空组件
if (node === null || node === false) {
instance = ReactEmptyComponent.create(instantiateReactComponent);
} else if (typeof node === 'object') {
var element = node;
invariant(
element && (typeof element.type === 'function' ||
typeof element.type === 'string'),
'Element type is invalid: expected a string (for built-in components) ' +
'or a class/function (for composite components) but got: %s.%s',
element.type == null ? element.type : typeof element.type,
getDeclarationErrorAddendum(element._owner)
);
if (typeof element.type === 'string') {
// 创建原生组件
instance = ReactHostComponent.createInternalComponent(element);
} else {
// 创建自定义组件
instance = new ReactCompositeComponentWrapper(element);
}
} else if (typeof node === 'string' || typeof node === 'number') {
// 创建文本组件
instance = ReactHostComponent.createInstanceForText(node);
} else {
invariant(
false,
'Encountered invalid React node of type %s',
typeof node
);
}
return instance;
}
React 生命周期
在了解了 React 是怎么由 JSX 到最终的真实 DOM 之后,我们来看下 React 是怎么对创建的组件进行管理的。
React 的组件可以看成一个个的有限状态机,状态机有自己的状态并且可以根据当前的状态做出对应的决策,并且可以在进入不同的状态时做出不同的操作。
React 就是将组件、状态机、生命周期三者结合,实现对组件的管理。通过生命周期来控制不同的状态,组件又通过不同的状态来渲染不同的页面。
我们可以在 https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/ 中查看 React 的生命周期图谱。
总结
本篇文章中,我先给你讲解了什么是前端组件化、为什么要使用组件化进行开发,之后又分别介绍了 JS 和 CSS 的组件化之路。紧接着,我们从组件的统一性、单一职责、通用性几个方面来讲述如何设计出一个好的组件。之后分析了组件的生命周期和几种不同的组件通信方式。
组件化体现了高内聚、低耦合的编程思想,不仅提高了开发效率,还降低了维护成本。如何进行组件设计,除了理论知识,还需要长期的实践积累,希望你在平时的开发中多加积累、学习和总结。