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