前端开发中业务代码和视图分离的思考

1,322 阅读5分钟

前言

目前我们在实际的业务中存在一个问题:多个端中业务逻辑实际上是一样的,但是交互形式确实不一样的,如PC端和手机端的交互有的时候会相差很大;所以有没有一种方式能把业务逻辑和交互分离开呢?如果能够拆分应该怎么拆分呢?以下的内容就是我的一些思考:

什么是依赖注入

首先我们先了解下什么是依赖注入(DI)?以现实中的一件小事为例子: 张三开车回老家;程序伪代码如下:

class GoHome {
    private person = new Person();
    private car = new Car();
    
    constructor() {
      this.person.setName('张三');
    }
    
    go() {
    	this.person.drive(this.car);
    }
}

class Person {
    private name;
    setName(name: string) {
        this.name = name;
    }
    
    drive(car: Car, address: string) {
        car.startup(address)
    }
}

class Car {
    startup(address: string) {
    	// 启动车到达目的地
    }
}

这段代码存在什么问题呢?我们在GoHome这个具体的业务逻辑中耦合了Person、Car两个类,这样会导致一些问题:

  1. 这个业务你没有办法把他交给三个人同时去开发(实际业务可能比例子复杂太多);
  2. 维护的时候维护者每次维护的时候都有必要阅读业务中的所有代码,否则如果Car类中存在一个bug,维护者不小心删除了startup函数, 或者删除了startup函数中的参数后,会立马导致整个程序瘫痪(立即瘫痪还是好的,你能立马发现问题;最怕的是业务复杂的时候会埋下隐藏bug,bug并不会立即出现只有发布后用户某些情境下才出现)

那么怎么解决这两个问题呢?

首先我们需要把GoHome、Person、Car三个类进行解偶,其次定义好Person、Car的接口(interface)方便GoHome调用。

这样GoHome不需要关心Person、Car的具体实现,只需要按照定义好的接口定义去调用函数开发就行; 同理Person、Car也可以交给另外两个人去开发,只要按照interface来实现就OK。

代码如下(di框架可以使用github.com/Microsoft/t…

interface IPerson {
    setName(name: string): void;
    drive(car: ICar, address: string): void;
}
interface ICar {
    startup(address: string): void;
}

class GoHome {
    constructor(
        @inject('IPerson') private person:IPerson,
        @inject('ICar') private car:IPersICaron,
    ) { 
        this.person.setName('张三');
    } 
    
    go() {
    	this.person.drive(this.car, 'home');
    }
}

@injectable()
class Person implements IPerson {
    private name;
    
    setName(name: string) {
        this.name = name; 
    }
    
    drive(car: Car, address: string) {
        car.startup(address)
    }
}

@injectable()
class Car implements ICar {
    startup(address: string) {
    	// 启动车到达目的地
    }
}

所以,依赖注入模式就是:应用程序中的类是从外部源中请求获取依赖,而不是自己创建它们,这样就实现了应用和所依赖的类的解偶。 回到我们最初的问题:如何把业务逻辑和视图交互进行解偶呢?

如何拆分业务、视图

“业务”、“视图”我们可以把它们看成两个大的类:Service、View. 拆分后它们之间存在两种关联模式:

  1. 把视图的交互逻辑类注入到业务逻辑类中
  2. 把业务逻辑类注入到视图的交互逻辑类中
interface IView {
    /* 关于接口的定义 */
}

class Service {
    constructor(
        @inject('IView') private view:IView
    ) { 
    } 
    
    /* 在业务逻辑中调用视图的方法完成交互 */
}

@injectable()
class View implements IView {
   /* 具体代码 */
}
interface IService {
    /* 关于接口的定义 */
}

class View {
    constructor(
        @inject('IService') private service: IService
    ) { 
    } 
    
    /* 在视图中调用底层业务逻辑的方法完成整个具体业务 */
}

@injectable()
class Service implements IService {
   /* 具体代码 */
}

这两个方式有什么区别呢? 我们这里拿一个具体一点的TODO List作为例子:
把视图注入到逻辑层代码如下:

import "reflect-metadata";
import { injectable, container } from 'tsyringe';

interface TodoItem {
  name: string;
  state: 0 | 1;
}

@injectable()
class Dom {
  private inputValue = '';

  private contentFactory(content: HTMLElement) {
    content.innerHTML = `
      <h1>todo demo</h1>
      <div id="content">
        <input
          id="input"
          placeholder="请输入任务"
        />
        <button id="button" type="button">
          添加
        </button>
      </div>
      <div id="list"></div>
    `;
  }

  private factory(task: TodoItem) {
    return `
      <div class="task" data-name="${task.name}">
        <div class="${task.state === 1 ? "task-name done" : "task-name"}"
          data-name="${task.name}">
          ${task.name}
        </div>
      </div>
    `;
  }

  addEvent(btnCallback: Function, toggleCallback: Function) {
    const inputElem = document.getElementById("input") as HTMLInputElement;
    const buttonElem = document.getElementById("button") as HTMLButtonElement;
    const listElem = document.getElementById("list") as HTMLElement;

    inputElem?.addEventListener('input', (event: any) => {
      this.inputValue = event.target.value;
    });

    buttonElem?.addEventListener('click', () => {
      btnCallback(this.inputValue);
      this.inputValue = "";
      inputElem.value = "";
    });

    listElem.addEventListener("click", (event: any) => {
      const target = event.target;

      if (
        target.className.indexOf("task-name") >= 0 ||
        target.className.indexOf("task") >= 0
      ) {
        const name = target.getAttribute("data-name");
        toggleCallback(name);
      }
    });
  }

  init() {
    this.contentFactory(document.body);
  }

  update(tasks: TodoItem[]) {
    const listElem = document.getElementById("list") as HTMLElement;

    listElem.innerHTML = `${tasks.map(task => this.factory(task)).join('')}`;
  }
}

@injectable()
class TodoService {
  private todos: TodoItem[] = [];

  constructor(
    private dom: Dom
  ) {}

  init() {
    this.dom.init();
    this.dom.addEvent(
      (name: string) => this.add(name),
      (name: string) => this.toggle(name)
    );
    this.dom.update(this.todos);
  }

  add(name: string) {
    if (name === "") {
      return;
    }

    this.todos.push({ name, state: 0 });

    this.dom.update(this.todos);
  }

  toggle(name: string) {
    const index = this.todos.findIndex((item) => item.name === name);

    if (this.todos[index].state === 0) {
      this.todos[index].state = 1;
    } else {
      this.todos[index].state = 0;
    }

    this.dom.update(this.todos);
  }
}

const todoService = container.resolve(TodoService);
todoService.init();

把逻辑层注入到视图层的实现:

import "reflect-metadata";
import { injectable, container } from 'tsyringe';

interface TodoItem {
  name: string;
  state: 0 | 1;
}

@injectable()
class TodoService {
  private _onUpdate: ((doms: TodoItem[]) => void) | undefined;

  private todos: TodoItem[] = [];

  private update() {
    this._onUpdate && this._onUpdate(this.todos);
  }

  add(name: string) {
    if (name === "") {
      return;
    }

    this.todos.push({ name, state: 0 });

    this.update();
  }

  toggle(name: string) {
    const index = this.todos.findIndex((item) => item.name === name);

    if (this.todos[index].state === 0) {
      this.todos[index].state = 1;
    } else {
      this.todos[index].state = 0;
    }

    this.update();
  }

  onUpdate(callback: (tasks: TodoItem[]) => void) {
    typeof callback === "function" && (this._onUpdate = callback);
  }
}


@injectable()
class Dom {
  private inputValue = "";

  constructor(
    private todoService: TodoService
  ){}

  private contentFactory(content: HTMLElement) {
    content.innerHTML = `
      <h1>todo demo</h1>
      <div id="content">
        <input
          id="input"
          placeholder="请输入任务"
        />
        <button id="button" type="button">
          添加
        </button>
      </div>
      <div id="list"></div>
    `;
  }

  private factory(task: TodoItem) {
    return `
      <div class="task" data-name="${task.name}">
        <div class="${task.state === 1 ? "task-name done" : "task-name"}"
          data-name="${task.name}">
          ${task.name}
        </div>
      </div>
    `;
  }

  private update(tasks: TodoItem[]) {
    const listElem = document.getElementById("list") as HTMLElement;
    listElem.innerHTML = tasks ? tasks.map(task => this.factory(task)).join("") : "";
  }

  private addEvent() {
    const inputElem = document.getElementById("input") as HTMLInputElement;
    const buttonElem = document.getElementById("button") as HTMLButtonElement;
    const listElem = document.getElementById("list") as HTMLElement;

    inputElem?.addEventListener('input', (event: any) => {
      this.inputValue = event.target.value;
    });

    buttonElem?.addEventListener('click', () => {
      this.todoService.add(this.inputValue);

      this.inputValue = "";
      inputElem.value = "";
    });

    listElem.addEventListener("click", (event: any) => {
      const target = event.target;

      if (
        target.className.indexOf("task-name") >= 0 ||
        target.className.indexOf("task") >= 0
      ) {
        const name = target.getAttribute("data-name");

        this.todoService.toggle(name);
      }
    });
  }

  init() {
    this.contentFactory(document.body);
    this.todoService.onUpdate(this.update.bind(this));
    this.addEvent();
  }
}

const domService = container.resolve(Dom);
domService.init();

比较上面的代码我得出的两种方式分别使用的使用场景为:

  • 把视图注入到逻辑层的方式比较适合DOM结构相对比较固定的业务; 这样方便把整个业务制作成内部的NPM包,在不同平台开发的时候可以快速基于该业务NPM包开发出符合当前平台的交互;

  • 而把逻辑层注入到视图适合底层业务逻辑一致,但是不同平台交互形式差别很大的情况;这样既可以保证业务代码的复用, 减少新平台开发的代码量, 又能保证交互的灵活性, 可以在不频繁更改NPM包的情况下,满足更多的平台交互.

当然以上仅为个人观点,欢迎大家一起讨论,目前这些想法都还在实验探索中.

优点

使用这种依赖注入的方式拆分视图和逻辑的方案优点很明显:

  1. 解耦了视图和逻辑的关联,耦合度更低
  2. 方便多人进行合作,同时进行开发
  3. 代码可以更大可能的复用, 我们可以把所有抽离出来的所有被依赖类做成内部的NPM包,后续开发新平台的时候可以很方便的复用业务逻辑
  4. 代码复用的提高带来了维护成本的降低, 某个业务逻辑修改的时候,一次修改多处生效

缺点

当然这样的方案也会带来一些不利的地方:

  1. 初次开发的时候代码量会有增多
  2. 对开发人员的业务抽象能力要求更高,理解概念成本会更高

结尾

目前这些都是针对当前业务的一些初步的思考、实践,还不是太完善; 后续也可能会有更多的心得继续和大家分享, 也欢迎大家如果有什么想法心得可以提出来, 共同讨论.文章中的例子现在还不是太合适后续也会进行修改、完善, 保证例子能更多的体现观点.

TODO LIST 的地址: github.com/webKity/tod…