作者 | 二哲
来源 | web前端开发(web_qdkf)
本文将分享一下过去一年里,我们项目是如何做视图与业务逻辑抽离的。
正所谓视图就是身为一个用户可见到的图像,对于这个图像来说它正是广为流传的 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是基于provider和inject来实现的。而provider和inject又是基于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我们很容易就可以完成我们的目的。
刚刚我们都是基于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> ); };
本文我们通过两个例子对比,可以深刻意识到视图和逻辑分离的重要性
通过mobx的启发,我们分别实现了基于Class/SFC和hook的视图逻辑分离方案
视图和逻辑分离可以更好的锻炼我们封装抽象思维,写出可维护性更强的代码
视图和逻辑分离更加易于测试,可以单独测试视图或者逻辑
代码示例地址在:https://github.com/MeCKodo/view-and-view-logic
个人网站:http://www.meckodo.com