我们是如何做视图和视图逻辑分离的

#react#js

本文将分享一下过去一年里,我们项目是如何做视图与视图逻辑抽离的。

什么是视图?什么是逻辑?

正所谓视图就是身为一个用户可见到的图像,对于这个图像来说它正是广为流传的 view = f(data)。这个公式精确的表达了视图就像是一个函数一般,输入即输出,所见即所得,没有任何副作用。

<div>
    <input placeholder="修改名字" onChange={handleChange} />
    <p>姓名:{username}</p>
<div>

以上这样一段DOM代码,它就是我们所见到的视图,它是纯粹的,给了什么就会渲染什么。当用户有了交互的事件,数据有了变化,就会渲染新的视图。

而视图逻辑就是我们前端工程师对视图的显示,对视图的 data 进行的处理。它可能是来自于服务端,可能是来自于本地,亦或者是来自于用户自有的操作行为。一切让数据改变,或者对数据进行操作的行为等等,这些都是我们的业务逻辑。

username = api.getUserName()

handleChange(e) {
    this.username = e.target.value;
}

过去的组件

按照惯例,我们会习惯于把视图和业务逻辑都写在一起,当视图越来越庞大或者逻辑越来越复杂。就会让我们的代码越来越不易维护和测试。比如这样的代码

class Demo extends React.Component<Props> {

  constructor(props: Props) {
    super(props);
    this.state = {
      data: null,
    }
  }

  async componentDidMount() {
    const ret = http.get(`/api/xx/${this.props.id}`);
    this.setState({
      data: ret.data,
    })
  }

  handleClick = () => {
    // do something...
  }

  // other methods...

  render() {

    return (
      <div>
        <p onClick={this.handleClick}>click</p>
        {this.state.data}
      </div>
    )
  }
}

通常我们一个组件的实现大致都长这样,随着业务逻辑复杂,我们Demo组件需要存放的属性和方法也越来越多。我们的dom结构也越来越大。如何抽象封装这样的组件,如何提取我们的业务逻辑,组织出更加可维护易测试的代码,成为大型项目的关键。

改进方案

首先我们可以基于MVP或者MVC的思想,把视图和逻辑抽离分别分为两个文件。(以下的示例代码仅作为思想,不一定能实际运行。)

import { DemoPresenter } from './Demo.presenter';

class Demo extends React.Component<Props> {

  constructor(props: Props) {
    super(props);
    this.presenter = new DemoPresenter(props);
  }

  async componentDidMount() {
    const { fetchData } = this.presenter;
    await fetchData();
  }

  render() {
    const { handleClick, data } = this.presenter;
    return (
      <div>
        <p onClick={handleClick}>click</p>
        {data}
      </div>
    )
  }
}
class DemoPresenter {

  data = {};

  constructor(props: Props) {
    this.props = props;
  }

  fetchData = async () => {
    const { id } = this.props;
    const ret = await http.get(`/api/xx/${id}`);
    this.data = ret.data;
  }

  handleClick = () => {
    // do something...
  }
}

自此我们尝试着把视图和业务逻辑抽离成了两个文件,分别用两个class来维护。降低了视图和逻辑之间的耦合。另外从测试的角度来说,我们可以剥离视图,单独为我们的业务逻辑写UT,这就极大的降低了测试的成本。

看到这里大家有没有发现这特别像某一种代码?其实这不就像是mobx里的inject store吗?把我们手动new Presenter的过程通过inject来完成。

@inject('demoPresenter')
class AppComp extends React.Component<Props> {
  render() {
    const { demoPresenter } = this.props;
    const { handleClick, data } = demoPresenter;

    return (
      <div>
        <p onClick={handleClick}>click</p>
        {data}
      </div>
    )
  }
}

我们是否可以根据这个启发继续摸索呢?

改进一下

mobx是基于providerinject来实现的。而providerinject又是基于react的context。这里就有一个问题,mobx主要为我们做全局状态管理。而我们需要的仅仅是局部的视图和逻辑抽离。又应该如何做呢?

站在mobx的肩膀上,我们来实现一个高阶组件做我们presenter注入。而这两年MVVM这么火,再加上presenter这单词着实麻烦,我们换个名称好了。把我们的视图逻辑层就叫ViewModel吧,当然这里指的是广义上的VM

import React from 'react';

function withViewModel<P = {}>(
  Component: React.ComponentType<any>,
  ViewModel: new (...args: any[]) => any,
) {
  return class withViewModelComp extends React.Component<Omit<P, 'vm'>> {
    vm: any;
    constructor(props: Omit<P, 'vm'>) {
      super(props);
      this.vm = new ViewModel(props);
    }

    render() {
      return <Component {...this.props} vm={this.vm} />;
    }
  };
}

export { withViewModel };

看下我们这个高阶组件的实现非常简单,自动帮我们new一下VM,然后传递给我们需要的组件。我们的组件就可以这样使用

import React from 'react';
import { observer } from 'mobx-react';
import { withViewModel } from '../../hoc';
import { TestVM } from './TestVM';
import { Props } from './types';

@observer
class TestComp extends React.Component<Props> {
  render() {
    const { vm } = this.props;

    return <div onClick={vm.setUserName}>{vm.userName}</div>;
  }
}

// 绑定我们的组件和VM
const Test = withViewModel<Props>(TestComp, TestVM);

export { Test };
// Test.VM.ts
import { observable, action, computed } from 'mobx';

class TestVM {
  @observable userName = '二哲1号';

  @action
  setUserName = () => {
    this.userName = '二哲2号';
  };
}

export { TestVM };

基于mobx,我们现在达到了视图与视图逻辑抽离的目标了。但是有没有发现这样的代码其实还是有问题的?我们虽然在hoc里new VM的时候把props传递进去了,但那是静态的,如果我们写了一段computed是不会生效的。

// Test.VM.ts
import { observable, action, computed } from 'mobx';

class TestVM {
  @observable userName = '二哲1号';
  @observable props: any;

  constructor(props: any) {
    this.props = props;
  }

  @computed
  get someValue() {
    return this.props.value + this.userName;
  }

  @action
  setUserName = () => {
    this.userName = '二哲2号';
  };
}

export { TestVM };

如果我们父组件传递的value props变化了,someValue是拿不到最新的值的。接着我们来修复这个问题。

进阶版

在我初次思考这个问题的时候,我本以为是无解的。因为我们如论如何都需要把props传递给我们VM才行,那一定就是静态的。但如何能与我们的 VM绑定起来就成为了一个关键

这就意味着要处理两件事情。第一个问题是收集props依赖,第二个则是当props变化了,我们传递进VM里的props需要得到相应。

最后我在mobx源码中得到了灵感。https://github.com/mobxjs/mobx-react-lite/blob/master/src/useAsObservableSource.ts#L20-L30

import React from 'react';
import { observable, runInAction, IObservableObject } from 'mobx';

function withViewModel<P = {}>(
  Component: React.ComponentType<any>,
  ViewModel: new (...args: any[]) => any,
) {
  return class withViewModelComp extends React.Component<Omit<P, 'vm'>> {
    vm: any;
    vmProps: IObservableObject;
    constructor(props: Omit<P, 'vm'>) {
      super(props);
      // 转为mobx 观察对象
      this.vmProps = observable(props, {}, { deep: false });
      // 传递引用
      this.vm = new ViewModel(this.vmProps);
    }

    componentDidUpdate() {
      // props变化的时候,重新更新一下我们的观察对象
      runInAction(() => {
        Object.assign(this.vmProps, this.props);
      });
    }

    render() {
      return <Component {...this.props} vm={this.vm} />;
    }
  };
}

export { withViewModel };

新增三行代码,通过mobx的observable和runInAction我们很容易就可以完成我们的目的。

hook

刚刚我们都是基于class实现的,hook这么火爆,当然也少不了我们hook版本。hook实现相对来说就简单许多了。

import { useMemo } from 'react';
import { useAsObservableSource } from 'mobx-react-lite';

function useVM<T>(VM: new (...args: any[]) => T, props: any = {}) {
  const source = useAsObservableSource(props);
  return useMemo(() => new VM(source), []);
}
const HookComponent = (props: Props) => {
  const vm = useVM<HookVM>(HookVM, props);

  return (
    <div onClick={vm.setUserName}>
      hook 组件 组件内部数据 = {vm.userName} 父组件传入数据 = {vm.name111}
    </div>
  );
};

总结

  1. 本文我们通过两个例子对比,可以深刻意识到视图和逻辑分离的重要性
  2. 通过mobx的启发,我们分别实现了基于Class/SFC和hook的视图逻辑分离方案
  3. 视图和逻辑分离可以更好的锻炼我们封装抽象思维,写出可维护性更强的代码
  4. 视图和逻辑分离更加易于测试,可以单独测试视图或者逻辑

代码示例地址在:https://github.com/MeCKodo/view-and-view-logic 个人网站:http://www.meckodo.com