前端需要面向对象编程吗?

大家好,我是 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 的必要性):

  1. 对象本身具有属性和行为,对应的就是状态与逻辑,通过对象,能够统一管理相关的状态和操作状态的逻辑,这种统一管理体现了代码良好的封装性、可维护性;
  2. 通过高级抽象,使得开发人员能够从更高(更偏向自然语言)的维度去思考、组织业务逻辑,使得业务分析、代码编写更加容易;
  3. 通过协商对象之间的通信(接口规范),能够将一个大问题拆分为若干个小问题,这种拆分可以是横向或纵向的,也体现了关注点分离这一重要原则,有助于减少代码中的混杂,提高代码的可维护性、可扩展性。

模块(类)设计&实现

通过分析,需要设计 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 实现会非常麻烦。

前端需要面向对象编程吗?显然答案来自于对业务场景复杂度的评估,尤其当你觉得相关的逻辑写得很分散、不相关的逻辑写得很混杂、要写很多重复的内容、拆分问题无从下手等的时候,试试使用面向对象编程这种更优雅的实现吧。

最后,笔者认为更重要的是:作为一名合格的程序员,我们需要掌握面向对象编程的思想方法。不要因为自己是做前端开发的,就给自己设限(或者偷懒),那些经典、优秀、先进的软件编程范式、设计模式、架构、思想都是通用的,值得所有软件开发人员学习。

参考:

相关推荐
你挚爱的强哥10 分钟前
【sgCreateCallAPIFunctionParam】自定义小工具:敏捷开发→调用接口方法参数生成工具
前端·javascript·vue.js
米老鼠的摩托车日记19 分钟前
【vue element-ui】关于删除按钮的提示框,可一键复制
前端·javascript·vue.js
猿饵块1 小时前
cmake--get_filename_component
java·前端·c++
大表哥61 小时前
在react中 使用redux
前端·react.js·前端框架
十月ooOO1 小时前
【解决】chrome 谷歌浏览器,鼠标点击任何区域都是 Input 输入框的状态,能看到输入的光标
前端·chrome·计算机外设
qq_339191141 小时前
spring boot admin集成,springboot2.x集成监控
java·前端·spring boot
pan_junbiao1 小时前
Vue使用代理方式解决跨域问题
前端·javascript·vue.js
明天…ling2 小时前
Web前端开发
前端·css·网络·前端框架·html·web
ROCKY_8172 小时前
web前端-HTML常用标签-综合案例
前端·html
海石2 小时前
从0到1搭建一个属于自己的工作流站点——羽翼渐丰(bpmn-js、Next.js)
前端·javascript·源码