大家好,我是 alphaLu。
问题背景
笔者最近加入了一个探索程序员第二曲线的社群(Symbol 社区),看到有位同学提到想做基于面向对象的前端应用的专栏,旨在教前端同学如何使用面向对象编程(Object Oriented Programming,简称 OOP),后面这位同学尝试下来发现"前端业务如果是简单的 crud,react + redux 就够,绝大部分情况下,OOP 不是一个必需品"。笔者也好奇,前端同学不懂 OOP 或者没用过 OOP 没关系吗?前端真的需要 OOP 吗?
恰好笔者正在参与开发一个自研 H5 游戏引擎的项目(小世界,一个非常有意思的项目!),负责对道具属性(下文统称 settings)编辑相关特性的开发,原本这块是支持通过代码编辑器(基于 monaco editor 实现)编辑 settings 的,最近需要支持通过表单编辑,开发过程中有涉及到对 OOP 的一些思考,在此记录下。
需求描述
先上效果图:
具体的需求是这样的:有一个 settings 对象,并且有一个 descriptor 对象去描述 settings 各字段的类型(descriptor 可以类比为 JSON Schema,只不过这里使用的是 async-validator),笔者要做的就是,通过 descriptor 生成一个表单,并将 settings 作为初始值填入,然后用户在表单上编辑,最终保存表单得到新的 settings。
需求分析
看起来并不复杂,生成表单,一般的思路是这样的:将 descriptor + settings 作为 props 给到一个 Form 组件,这个 Form 组件内部会做一些状态和逻辑管理,并根据 settings 的数据类型使用类似 FieldList、FieldItem 组件做递归式地渲染。
但是这样做有一个问题,那就是表单逻辑与视图框架强耦合了。比如当前项目使用的是 solidjs,那么具体实现 Form 组件用的就是 solid jsx 那套,如果用了 UI 框架,比如 hope-ui,那么就要用 hope-ui 的写法。为什么会发现这个问题呢?因为我在寻找开源解决方案的过程中,发现类似 react + antd、vue + element 的方案并不能很方便地移植到当前的 solid 项目,另外我总不能单独为表单引入庞大的 react + antd 吧?
细想一下,表单逻辑应该与视图框架解耦 ,耦合的最明显的坏处就是不方便更换视图或 UI 框架(当前项目使用的 hope-ui 只是过渡性方案,后期应该会换掉的)。这里要体现的是 开闭原则:一个软件实体应该对扩展开放,对修改关闭。也就是软件实体在尽量不修改原来代码的情况下进行拓展。
所以笔者定下的目标是:将表单逻辑与视图解耦,而视图写法至少支持原生 dom、solid jsx,并提供简便的视图扩展机制。
软件设计
解耦的关键要想明白这些:上述提到的 Form、FieldList、FieldItem 组件,它们的作用就是管理状态、逻辑,并作渲染,只不过这里的渲染与视图或 UI 框架强耦合了。我们仍然需要组件的概念,其实就是一个实体对象,而且这个实体对象与视图无关,所以答案显而易见,就是暴露出接口,与视图层对接。参考下图:
面向对象编程
上述思考提到的对应于组件概念的"实体对象",本质上就是面向对象编程范式的体现,你会发现这是一个十分符合程序员思维(抽象、封装、解耦)的自然而然的思考过程。这里使用 OOP 至少有 3 个好处(也是使用 OOP 的必要性):
- 对象本身具有属性和行为,对应的就是状态与逻辑,通过对象,能够统一管理相关的状态和操作状态的逻辑,这种统一管理体现了代码良好的封装性、可维护性;
- 通过高级抽象,使得开发人员能够从更高(更偏向自然语言)的维度去思考、组织业务逻辑,使得业务分析、代码编写更加容易;
- 通过协商对象之间的通信(接口规范),能够将一个大问题拆分为若干个小问题,这种拆分可以是横向或纵向的,也体现了关注点分离这一重要原则,有助于减少代码中的混杂,提高代码的可维护性、可扩展性。
模块(类)设计&实现
通过分析,需要设计 Form、FormField、FormNode 三个类。
- Form 其实就是一个大的 input,遵循 input 的逻辑:可输入值,可校验值,可被监听事件,可读取值。需要注意的是,这里是通过 options 选项传入 createView、mount,对视图做可扩展的配置,待下文细说。
ts
class Form {
...
rootFormFiled: FormField | null = null;
createView: Function;
mount: Function;
constructor(
container: HTMLElement,
data: { descriptor: any, value?: any },
options?: {
createView?: Function,
mount?: Function,
}
) {
...
if (options) {
const { createView, mount } = options;
this.createView = createView || createViewNative;
this.mount = mount || mountNative;
}
this._create(data);
this.mount(this);
}
private _create(data: { descriptor: any, value?: any }) {
// create formFields
// set this.rootFormFiled
}
setValue(value: any) {
this.rootFormFiled?.setValue(value);
}
getValue(): any {
return this.rootFormFiled?.getValue();
}
async onVauleChange(valueNew: any, valueOld: any, field: FormField) {}
async validate() {}
}
- FormField 用于管理单个表单字段的状态与逻辑,并且通过 FormNode 管理视图。需要注意的是,当 FormField 管理一个对象或数组时,会包含多个子 FormField,是一个嵌套的结构。
ts
class FormField {
id: string;
name: string;
type: string;
descriptor: Descriptor;
private _value: any;
isDirty: boolean = false;
error: string = '';
node: FormNode;
form: Form;
parent: FormField | null = null;
children: FormField[] = [];
constructor(valueCompiled: ValueCompiled, form: Form) {
...
this.form = form;
this.node = new FormNode(valueCompiled, this);
}
// Model => ViewModel => View
setValue(value: any) {
...
this._value = value;
this.node?.setView(value); // value: field => node
this.children.forEach(f => f.setValue(value?.[f.name]));
}
// View => ViewModel => Model
async onValueChange(valueNew: any) {
...
await this.validate();
await this.form?.onVauleChange(valueNew, valueOld, this);
}
getValue(): any {
const { type } = this;
let target: any;
if (type === 'object') {
target = {};
this.children.forEach((field: FormField) => {
target[field.name] = field.getValue();
});
} else if (type === 'array') {
target = [];
this.children.forEach((field: FormField) => {
target.push(field.getValue());
});
} else {
target = this._value;
}
return target;
}
async validate() {}
addChild(field: FormField) {
this.children.push(field);
field.parent = this;
this.node.addChild(field?.node);
}
}
- FormNode 用于对接视图,为 FormField 抹平视图层的差异:一方面它提供 api 或声明接口规范(ViewCtx),交由视图层(createView)去调用或实现,另一方面它也提供 api 交由 FormField 去调用。
ts
type ViewCtx = {
[k: string]: any;
addChild?: (node: FormNode) => void;
setValue?: (value: any) => void;
displayValue?: () => void;
hideValue?: () => void;
displayError?: (message: string) => void;
hideError?: () => void;
}
class FormNode {
type: string;
name: string;
controller: FormField;
viewCtx: ViewCtx = {};
constructor(valueCompiled: ValueCompiled, controller: FormField) {
...
this.controller = controller;
// 这里调用 createView 创建了视图
this.controller.form.createView(valueCompiled, this);
}
addChild(node: FormNode) {
if (typeof this.viewCtx?.addChild === 'function') {
this.viewCtx?.addChild(node);
}
}
async onViewChange(valueNew: any) {
await this.controller.onValueChange(valueNew);
}
setView(value: any) {
if (typeof this.viewCtx?.setValue === 'function') {
this.viewCtx?.setValue(value);
}
}
displayError(message: string) {
if (typeof this.viewCtx?.displayError === 'function') {
this.viewCtx?.displayError(message);
}
}
hideError() {
if (typeof this.viewCtx?.hideError === 'function') {
this.viewCtx?.hideError();
}
}
}
- createView 负责创建视图,并实现 FormNode 提供的 ViewCtx 接口规范,而 mount 负责将表单挂载到页面,通常与 createView 一起实现。
ts
// 原生 Dom 视图示例
function createViewNative(valueCompiled: ValueCompiled, node: FormNode) {
const {
__fieldName__: fieldName, __type__: type, __comment__: comment,
} = valueCompiled;
const refs: { [k: string]: HTMLElement } = {};
refs.containerRef = createContainer();
refs.commentRef = createCommentDom(comment);
refs.labelRef = createLabelDom(type, fieldName);
refs.valueRef = createValueDom(valueCompiled);
refs.errorRef = createErrorDom();
node.viewCtx.addChild = (_node: FormNode) => {
const { valueRef } = node.viewCtx.refs;
const { containerRef } = _node.viewCtx.refs;
valueRef.appendChild(containerRef);
}
node.viewCtx.setValue = genSetValue(type);
node.viewCtx.displayValue = () => {
const { valueRef } = node.viewCtx.refs;
valueRef?.classList.remove('fold');
}
node.viewCtx.hideValue = () => {
const { valueRef } = node.viewCtx.refs;
valueRef?.classList.add('fold');
}
node.viewCtx.displayError = (message: string) => {
const { errorRef } = node.viewCtx.refs;
errorRef.innerText = `${message}`;
errorRef.classList.remove('hidden');
}
node.viewCtx.hideError = () => {
const { errorRef } = node.viewCtx.refs;
errorRef.innerText = '';
errorRef.classList.add('hidden');
}
refs.containerRef.appendChild(refs.commentRef);
refs.containerRef.appendChild(refs.labelRef);
refs.containerRef.appendChild(refs.valueRef);
refs.containerRef.appendChild(refs.errorRef);
node.viewCtx.refs = refs;
function createContainer(): HTMLElement {
const dom = document.createElement('div');
dom.className = 'formField';
return dom;
}
function createCommentDom(comment: string): HTMLElement {
...
}
function createLabelDom(type: string, fieldName: string): HTMLElement {
...
}
function createValueDom(valueCompiled: ValueCompiled): HTMLElement {
const { __type__: type, __descriptor__: descriptor, __value__: value } = valueCompiled;
let dom: HTMLElement;
switch(type) {
case 'array':
case 'object': {
dom = document.createElement('div');
break;
}
case 'string': {
dom = document.createElement('input');
(dom as HTMLInputElement).type = 'text';
(dom as HTMLInputElement).value = value;
dom.oninput = async (e) => {
await node.onViewChange(String(e.target?.value));
}
break;
}
...
default: {
dom = document.createElement('div');
dom.innerText = `${value}`;
}
}
dom.classList.add('fieldValue');
return dom;
}
function createErrorDom() {
...
}
function genSetValue(type: string) {
let setValue = (value: any) => {};
switch (type) {
case 'object': break;
case 'array': break;
case 'string': {
setValue = (value: any) => {
const { valueRef } = node.viewCtx.refs;
valueRef!.value = value;
}
break;
}
...
default: {
setValue = (value: any) => {
const { valueRef } = node.viewCtx.refs;
valueRef!.innerText = value;
}
}
}
return setValue;
}
}
function mountNative(form: Form) {
form.container.appendChild(form.rootFormFiled?.node?.viewCtx?.refs?.containerRef);
}
以上为 createViewNative 的核心代码实现,createViewSolid 的过程与 createViewNative 类似,就是将 dom 操作改为编写 solid jsx,不过需要注意的是,mountSolid 的过程是生成渲染函数,最终是交给 solidjs 框架去渲染的,大致是这样的:
ts
// createSignal 是 solidjs 语法
const [formRender, setFormRender] = createSignal(() => (<></>));
function mountSolid(form: Form) {
const FieldRender = (props: any) => {
const { view: NodeView, children } = props.node;
return (
<NodeView>
<For each={children}>
{
(sr, i) => (
<FieldRender node={sr} />
)
}
</For>
</NodeView>
)
}
const FormRender = (props: any) => {
const rootNode = form?.rootFormFiled?.node?.viewCtx;
return (
<FieldRender node={rootNode} />
)
}
setFormRender(FormRender);
}
// 渲染
<div class={styles.formContainer}>
<div class={styles.formDiv} id='formDiv'>{formRender()}</div>
</div>
MVVM&MVC&MVP
上述逻辑与视图解耦的过程让我想到了 MVVM。MVVM 是什么?它和 MVC、MVP 都是客户端应用的一种架构。关于这三种架构,网上的解释很多,但能讲明白的不多,笔者也不觉得自己真的理解,只能说说自己现阶段的理解。
三种架构都强调模型(Model)与视图(View)分离,而不同点在于 Model 与 View 的连接方式,以及三者(Model、View、MV 连接方式)各自承担的职责范围(大小)。笔者倾向于将 Model 解释为 数据 + 业务逻辑 ,而视图也有逻辑,视图的逻辑就是连接用户与应用内部,比如将用户输入更新为应用需要的数据、将应用的数据更新为用户看到的内容等,参考下图:
视图逻辑具有通用性,与业务逻辑弱关联(甚至无关),你可能已经猜到我在说什么了,我在说的就是 VM,即视图模型,这也是目前主流前端框架(Vue、React...)在做的核心事情。
不过当提到 MVVM 时,通常会讲到数据绑定,或者双向绑定,它做的事情就是:当数据变化时,更新视图;当视图变化时(新的用户输入),更新数据(如果有必要的话)。数据绑定是 VM 的职责,当开发者使用了一个 MVVM 框架,对开发者来说,数据与视图的双向联动更新就是自动的,他们只需要在 Model 层写好业务逻辑,提供数据给 VM 即可。传统的 MVC、MVP 没有提供这种自动机制,比如需要手动去调用更新视图的 api,这也正是 MVVM 于他们的一个重要区别。
回到笔者的项目,其实也是在应用 MVVM,实际上 FormField 就是 VM,因为:改变 FormField 的 value 属性会自动更新视图,而用户输入的新值也会同步更新到 FormField 的 value。这里面也没有对象属性劫持,只是简单地结合事件监听,你可能会想到这不就是受控组件吗?当我们在应用一种架构时,更多的是在践行它的核心理念,至于实现方式可以是灵活多变的。
再谈 OOP
根据笔者的工作经验,前端业务按照复杂度可分为两类:
- 一类是较为常见的、简单的、轻量级的应用,核心逻辑是请求服务端数据并按照业务需求构建渲染,开发人员的绝大部分工作就是在处理与服务端的交互、组装数据、编写或大或小的视图组件。这类业务的 Model 层比较薄,通常不用 OOP 也能较好地实现。
- 一类是较为复杂的、重量级的应用,比如代码编辑器(Monaco)、设计工具(Figma)、游戏引擎(Phaser)等,这类业务的 Model 层很厚,它的核心不是围绕渲染,可能渲染只是其中的一层,它还可以有自己的数据层、服务层,这种情况下不用 OOP 实现会非常麻烦。
前端需要面向对象编程吗?显然答案来自于对业务场景复杂度的评估,尤其当你觉得相关的逻辑写得很分散、不相关的逻辑写得很混杂、要写很多重复的内容、拆分问题无从下手等的时候,试试使用面向对象编程这种更优雅的实现吧。
最后,笔者认为更重要的是:作为一名合格的程序员,我们需要掌握面向对象编程的思想方法。不要因为自己是做前端开发的,就给自己设限(或者偷懒),那些经典、优秀、先进的软件编程范式、设计模式、架构、思想都是通用的,值得所有软件开发人员学习。
参考: