一、前言
模式是一种规律或者说有效的方法,所以掌握某一种实践总结出来的模式是快速学习和积累的较好方法,模式的对错需要自己去把握,但是只有量的积累才会发生质的改变,多思考总是好的。(下面的代码实例更多是 React 类似的伪代码,不一定能够执行,函数类似的玩意更容易简单描述问题)
二、前端的关注点迁移
这篇文章主要介绍现在组件化的一些模式,以及设计组件的一些思考,那么为什么是思考组件呢?因为现在前端开发过程是以组件为基本单位来开发。在组件化被普及(因为提及的时间是很早的或者说有些厂实现了自己的一套但是在整个前端还未是一种流行编写页面的单元)前,我们的大多数聚焦点是资源的分离,也就是 HTML、CSS、JavaScript,分别负责页面信息、页面样式、页面行为
,现在我们编程上的聚焦点更多的是聚焦在数据
和组件
。
但是有时候会发现只关心到这一个层级的事情在某些业务情况下搞不定,比如组件之间的关系、通信、可扩展性、复用的粒度、接口的友好度等问题,所以需要在组件上进行进一步的延伸,扩展一下组件所参考的视角,延伸到组件模块
和组件系统
的概念来指导我们编写代码。
概念可能会比较生硬,但是你如果有趣的理解成搭积木的方式可能会更好扩展思路一点。
三、数据之于组件
在说组件之前,先来说下数据的事情,因为现在数据对于前端是很重要的,其实这是一个前、后端技术和工作方式演变形成的,以前的数据行为和操作都是后端处理完成之后,前端基本拿到的就是直接可用的 View 展示数据,但是随着后端服务化,需要提供给多个端的数据以及前后端分离工作模式的形成,前端就变得越来越复杂了,其实 SPA 的形成也跟这些有一定关系,一是体验可能对于用户好,二是演变决定了这种方式。此时,前端的数据层就需要设计以及复用一些后端在这一层级的成熟模式,在这里就产生了一种思想的交集。
比如现在有一个 RadioGroup 组件,然后有下面 2 种数据结构可以选择:
items = [{
id: 1,
name: 'A',
selected: true
}, {
id: 2,
name: 'B',
selected: false
}];
data = {
selected: 1
items: [{
id: 1,
name: 'A'
}, {
id: 2,
name: 'B'
}]
};
那么我们的组件描述(JSX)会怎么写呢?
第一种:
items.map(item =>
return <CheckBox key={`checkbox-${item.id}`}
label={item.name}
selected={item.selected}
onClick={this.handleClick} />
);
第二种:
data.items.map(item =>
const isSelected = item.id === data.selected;
return <Checkbox key={`checkbox-${item.id}`
label={item.name}
selected={isSelected}
onClick={this.handleClick}/>
);
当然,数据结构的选择上是根据需求,因为不同的数据结构有不同的优势,比如这里第二种类似 Dict 的查询很方便,数据也很干净,第一种渲染是比较直接的,但是要理解组件的编写方式其实很大程度上会跟数据产生一种关系,有时候编写发现问题可以返过来思考是否换种结构就变简单了。
数据就谈这些吧,不然都能单独开话题了,接下来看下组件,如果要学习模式就需要采集样本然后去学习与总结,这里我们来看下 Android && iOS
中的组件长什么样子,然后看是否能给我们日常编写 Web 组件提供点灵感,篇幅有限,本来是应该先看下 GUI 的方式。
四、iOS 端的组件概览
假设,先摒弃到 Web 组件的形态比其他端丰富,如果不假设那么这套估计不是那么适用。
4.1 iOS
iOS 的 View 声明能够通过一个故事板的方式,特别爽,比如这里给按钮的状态设定高亮、选中、失效这种,方便得很。
看完界面,直接的感觉下,然后我们来看下这个故事板的源码,上面是 XML 的描述,描述了组件的 View 有哪些部件以及 ViewController 里面映射的属性,用来将 View 和 ViewController 进行解耦。
<!-- 结构描述 -->
<scenes>
<scene sceneID="tne-QT-ifu">
<objects>
<viewController title=“Login" customClass="ViewController">
...
<view key="view" contentMode="scaleToFill"></view>
...
<!-- 这里就是描述 vm 关联对象的地方,ios 里面可能称之为 outlet -->
<connections>
<outlet property="passwordTextField"/>
<outlet property="tipValidLabel"/>
</connections>
</viewController>
</objects>
</scene>
</scenes>
<!-- 状态 & 样式描述 -->
<!-- 单独一个 button 组件描述 -->
<button>
<state key="normal" title="Login">
<color key="titleColor" red="1" green="1" blue="1" alpha="1"/>
</state>
</button>
我这里定义的按钮状态、颜色都在这里,分别给他们命名:结构描述
、样式描述
。
那么具体怎么给用户交互,比较编程化的东西在 ViewController
,来看下代码:
// 数据行为描述
// connection 中关联的钩子
@IBOutlet private weak var passwordTextField: UITextField!
@IBOutlet private weak var tipValidLabel: UILabel!
// 一个密码输入框的验证逻辑,最后绑定给 tipValidLabel、loginButton 组件状态上
let passwordValid: Observable<Bool> = passwordTextField.rx.text.orEmpty
.map { newPassword in newPassword.characters.count > 5 }
passwordValid
.bind(to: tipValidLabel.rx.isHidden)
.disposed(by: disposeBag)
passwordValid
.bind(to: loginButton.rx.isEnabled)
.disposed(by: disposeBag)
上面代码整体可以看做是响应式的对象,绑定3个组件之间的交互,密码不为空以及大于5个字符就执行 bind 地方,主要是同步另外2个组件的状态。其实也不需要看懂代码,这只是为了体会客户端组件的方式的例子,ViewController 我这里就叫:数据行为描述
。这样就有组件最基本的三个描述了:结构、样式、数据行为,虽然样本不多,但是这里直接描述它们就是一个组件的基本要素,整个故事板和 swift 代码很好的描述。
五、什么是组件?
5.1 组件描述
- 结构描述
- 样式描述
- 数据描述
对于组件来说,也是一份代码的集合,基本组成要素还是需要的,但是这三种要素存在和以前的 HTML, CSS, JS 三种资源的分离是不一样的,到了组件开发,更多的是关注如何将这些要素连接
起来,形成我们需要的组件。
比如 React 中对这三要素的描述用一个 .js
文件全部描述或者将结构、数据包裹在一起,样式描述分离成 .<style>
文件,这里就可能会形成下面 2 种形式的组件编写。
=> 3 -> (JSX + styled-components)
// 组件样式
const Title = styled.h1`
font-size: 1.5em;
text-align: center;
`;
// 组件内容
<Title>Hello World!</Title>
=> 2 + 1 -> (JSX + CSS Module)
export default function Button(props) {
// 分离的样式,通过结构化 className 来实现连接
const buttonClass = getClassName(['lv-button', 'primary']);
return (
<button onClick={props.onClick} className={buttonClass}>
{props.children}
</button>
);
}
可能最开始很多不习惯这样写,或者说不接受这类理念,那么再看下 Angular 的实现方式,也有 2 种:
(1) 采用元数据来装饰一个组件行为,然后样式和结构能够通过导入的方式连接具体实现文件。
@Component({
selector: 'app-root',
// 结构模板
templateUrl: './app.component.html',
// 样式模板
styleUrls: ['./app.component.css']
})
// 等同于上面描述的 iOS 组件的 ViewController
export class AppComponent { }
(2) 与第一种方式不同的地方是能够直接将结构和样式写到元数据中。
@Component({
selector: 'app-root',
template: `
<style>
h1 { font-weight: normal; }
</style>
<h1>{{title}}</h1>
<ul>
<li *ngFor="let item of items">{{ item }}</li>
</ul>
`,
// styles: ['h1 { font-weight: normal; }']
})
export class AppComponent {
title = 'Hello Angular';
items: number[] = [1, 2, 3];
}
无论实现的形式如何,其实基本不会影响太多写代码的逻辑,样式是目前前端工程化的难点和麻烦点,所以适合自己思维习惯即可。这里需要理解的是学习一门以组件为核心的技术,都能够先找到要素进行理解和学习,构造最简单的部分。
5.2 组件特性
虽然有了描述一个组件的基本要素,但是还远不足以让我们开发一个中大型应用,需要关注其他更多的点。这里提取组件基本都有的特性:
1. 注册组件
将组件拖到故事板
2. 组件接口(略)
别人家的代码能够修改组件的部分
3. 组件自属性
组件创建之初,就有的一些固定属性
4. 组件生命周期
组件存在到消失如何控制以及资源的整合
5. 组件 Zone
组件存在于什么空间下,或者说是上下文,很可能会影响到设计的接口和作用范围,比如 React.js 可用于写浏览器中的应用,React Native 可以用来写类似原生的 App,在设计上大多数能雷同,但是平台的特殊地方也许就会出现对应的代码措施)
这些主要就是拿来帮助去看一门不懂的技术的时候,只要是组件的范围,就先看看有没有这些东西的概念能不能联想帮助理解。
具体来看下代码是如何来落地这些模式的。
1.组件注册,其实注册就是让代码识别你写的组件
(1) 声明即定义,导入即注册
export SomeOneComponent {};
import {SomeOneComponent} from 'SomeOneComponent';
(2) 直接了当的体现注册的模式
AppRegistry.registerComponent('ReactNativeApp', () => SomeComponent);
(3) 拥有模块来划分组件,以模块为单位启动组件
@NgModule({
// 声明要用的组件
declarations: [
AppComponent,
TabComponent,
TableComponent
],
// 导入需要的组件模块
imports: [
BrowserModule,
HttpModule
],
providers: [],
// 启动组件, 每种平台的启动方式可能不一样
bootstrap: [AppComponent]
})
export class AppModule { }
2.组件的自属性
比如 Button 组件,在平时场景下使用基本需要绑定一些自身标记的属性,这些属性能够认为是一个 Component Model 所应该拥有,下面用伪代码进行描述。
// 将用户的 touch, click 等行为都抽象成 pointer 的操作
~PointerOperateModel {
selected: boolean;
disabled: boolean;
highlighted: boolean;
active: boolean;
}
ButtonModel extends PointerOperateModel { }
LinkModel extends PointerOperateModel { }
TabModel extends PointerOperateModel { }
...
// 或者是具有对立的操作模型
~ToggleModel {
on: boolean;
}
OnOffModel extends ToggleModel { }
SwitchModel extends ToggleModel { }
MenuModel extends ToggleModel { }
...
// 组件的使用
this.ref.attribute = value;
this.ref.attribute = !value;
这些操作如果需要更少的代码,也许能够这样:
~ObserverState<T> {
set: (value: T) => void;
get: (value: T) => T;
changed: () => void;
cacheQueue: Map<string, T>;
private ___observe: Observe;
}
Model extends ObserverState { }
基本上组件的这些属性是遍布在我们整个代码开发过程中,所以是很重要的点。这里还有一个比较重要的思考,那就是表单的模型,这里不扩展开来,可以单独立一篇文章分析。
3.组件的声明周期
与其说是生命周期,更多的是落地时候的代码钩子,因为我们要让组件与数据进行连接,也许需要在特定的时候去操作一份数据。在浏览器(宿主)中,要知道具体是否已经可用是一个关键的点,所以任何在这个平台的组件都会有这类周期,如果没有的话用的时候就会很蛋疼。
最简单的路线是:
mounted => update => destory
但是往往实际项目会至少加一个东西,那就是异常,所以就能够开分支了,但是更清晰的应该是平行的周期方式。
mounted => is error => update => destory
4.组件 Zone
组件在不同的 Zone 下可能会呈现不同的状态,这基本上是受外界影响的,然后自己做出反应。这里可以针对最基本的组件使用场景举例,但是这个 Zone 是一种泛化概念。
比如我们要开发一个弹框组件:Modal
,先只考虑一个最基本需求:弹框的位置,这个弹框到底挂载到哪儿?
- 挂载到组件内部;
- 挂载到最近的容器节点下;
- 挂载到更上层的容器,以至于 DOM 基础节点。
每一种场景下的弹框,对于每种组件的方案影响是不同的:
- 组件内部,如果组件产生了 render,很可能受到影响;
- 挂载到最近的容器组件,看似问题不大,但是业务组件的拆、合是不定的,对于不定的需求很可能代码会改变,但是这种方案是不错的,不用写太远,当然在 React 16 有了新的方案;
- 挂载到更高的层级,这种方案适合项目对弹框需求依赖比较强的情况吧,因为受到的影响更小,弹框其实对于前端更强调的是一种渲染或者说是一种交互。
5.组件的递归特性
组件能够拥有递归是一个很重要的纵向扩展的特性,每一种库或者框架都会支持,就要看支持对于开发的自然度,比如:
// React
this.props.children
// Angular
<ng-content></ng-content>
基本上可以认为现在面向组件的开发是更加贴近追求的设计即实现的理想,因为这是面向对象方法论不容易具备的,组件是一种更高抽象的方法,一个组件也许会有对象分析的插入,但是对外的表现是组件,一切皆组件后经过积累,这将大大提升开发的效率。
六、如何设计组件?
经过前面的描述,知道了组件的概念和简单组件的编写方法,但是掌握了这些东西在实际项目中还是容易陷入蛋痛的地步,因为组件只是组成一个组件模块的基础单元,慢慢的开发代码的过程中,我们需要良好的去组织这些组件让我们的模块即实现效果的同时也拥有一定的鲁棒性和可扩展性。这里将组件的设计方法分为 2 个打点:
- 横向分类
- 纵向分层
其实这种思路是一直以来都有的,这里套用到平时自己的组件设计过程中,让它帮助我们更容易去设计组件。
这种设计的方法论是一个比较容易掌握和把握的,因为它的模型是一个二维的(x, y)两个方向去拆、合自己的组件。注意,这里基本上的代码操作单元是组件
,因为这里我们要组装的目标是模块^0^
感觉很好玩的样子,举例来描述一下。
比如我们现在来设计比较常用的下拉列表组件(DropDownList),最简单的有如下做法:
class DropDownList {
render() {
return (
<div>
<div>
<Button onClick={this.handleClick}>请选择</Button>
</div>
<DropDownItems>
{this.props.dataSource.map((itemData, index) => <DropDownItem></DropDownItem>)}
</DropDownItems>
</div>
);
}
}
现在自己玩的往上加点需求,现在我需要加一个列表前面都加一个统一的 icon, 首先我们要做的肯定是要有一个 Icon 的组件,这个设计也比较依赖场景,目前我们先设计下拉。现在就有2种方案:
- 在 DropDownList 组件里面加一个判断,动态加一个组件就行;
- 重新写一个组件叫 DropDownIconList。
第一种方案比较省事,但是其实写个 if...else... 算是一个逻辑分支的代码,以后万一要加一个 CheckBox 或者 Radio 组件在前面...
第二种方案看上去美好,但是容易出现代码变多的情况,这时候就需要再重新分析需求变化以及变化的趋势。
这时候按垂直和水平功能上,这里拆分 DropDownIconList 组件可以看成一个水平的划分,从垂直的情况来看,将下拉这一个行为做成一个组件叫 DropDown,最后就变成了下面的样子:
class DropDown {
render() {
<div>
<div>
<p onClick={this.handleClick}>请选择</p>
</div>
<div>{this.props.children}</div>
</div>
}
}
class DropDownList {
render() {
return (
<DropDown onClick={this.handleClick} selected={selectedItems}>
<DropDownItems>
{this.props.dataSource.map((itemData, index) => <DropDownItem></DropDownItem>)}
</DropDownItems>
</DropDown>
);
}
}
class DropDownIconList {
render() {
return (
<DropDown onClick={this.handleClick} selected={selectedItems}>
<DropDownItems>
{this.props.dataSource.map((itemData, index) => <DropDownIconItem></DropDownIconItem>)}
</DropDownItems>
</DropDown>
);
}
}
这样的缺点就是存在多个组件,也许会有冗余代码,优点就是以后增加类似组件,不会将代码的复杂度都加到一份代码中,比如我要再加一个下拉里面分页、加入选中的项、下拉内容分页、下拉的无限滚动等等,都是不用影响之前那份代码的扩展。
七、让组件连接起来
组件化的开发在结构上是一种分形架构的体现,是一个应用引向有序组件构成的过程。组件系统的复杂度可以理解成 f(x) = g(x) + u(x), g(x) 表示特有功能,u(x)表示功能的交集或者说有一定重合度的集合。组件弹性体现在 u(x) -> 0(趋近)的过程中,这个论点可参考:面向积木(BO)的方法论与分形架构
上面的过程中,有了组件
、组件模块
,既然有了基础的实体,那么他们或多或少会有沟通的需求(活的模块)。基本上现在主流的方案可以用下面的图来表示。
我们提取一下主要的元素:
- Component 实体
- Component 实体的集合:Container
- Action
- Action 的操作流
- Service Or Store
- 状态同步中心
- Observable Effect
如果要说单向数据流和双向绑定的体现基本可以理解成体现在虚线框选的位置,如果组件或者Store是一个观察的模型,那么方案实现后就很可能往双向绑定靠近。如果是手动党连接 ViewValue 和 ModelValue,按照一条流下来可以理解成单向流。虽然没有按定义完全约束,但是代码的落地上会形成这种模式,这块细讲也会是一个单独的话题,等之后文章再介绍各种模式。
组件的关系能够体现在包含、组合、继承、依赖等方面,如果要更好的松耦合,一般就体现在配置上,配置就是一种自然的声明式,这是声明式的优势同时也是缺点。
以上是一些对组件的思考,码字好累,不一定很深入,但是希望能够帮助到刚踏入组件化前端开发的小伙伴~如果觉得有帮助请帮忙推荐,也可以阅读原文:小撸的博客
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。