如何写好 React 业务代码?

React 是当前最火的前端框架,但 React 只是一个 View 层,需要搭配 Redux 等库才会在大型项目里面发挥作用。

业务代码一直是我们写项目时的痛点,这里是笔者工作几年来对 React 在业务层面的一些思考和实践。

本文适合有一些 React 开发经验,但又不知道该怎么写好 React 业务代码的人。这篇文章会通过例子来告诉你如何做到比较优雅的代码组织、如何设计 store 结构、以及介绍下一代 MVC 框架 —— React-imvc 框架的使用等等。

主要内容:

  1. 什么是 MVC
  2. 代码的组织与分层
  3. 数据的管理
  4. 介绍 React-imvc 框架

      • 前言
      • 什么是 MVC
      • 代码的组织与分层
        • Service
        • formatter
        • MVC
        • 其他
      • 数据的管理
      • React-IMVC
        • 安装
        • npm scripts
        • 添加 src 目录和路由文件
        • 编写每个页面的 MVC 结构
      • 总结

前言

在前端全面拥抱框架之后,很多人都产生了一种误解,认为使用框架就会少写原生 JS,降低了 JS 的使用能力。但不管是什么框架,对于业务代码来说,最终还是要用原生 JS 来写的。

如何写出可维护和可读性高的代码,也一直是一个困扰很多人的问题,这里只介绍如何写好 React 中的业务代码,至于一些优化小技巧,推荐去看《代码大全 2》和《编写可读代码的艺术》。

什么是 MVC

你可能会问,不是讲 React 吗?怎么又扯到了 MVC 身上?我想起来之前在某技术网站,有个人反驳我说 React 推崇函数式编程,你怎么又倒退回了面向对象和 MVC?我回应说 MVC 是一种架构,函数式编程是一种思想,两者并没有冲突。

那么是什么 MVC 呢?相信大家都对 MVC 比较熟悉了,我这里引用一下维基百科的解释:

MVC 模式(Model–view–controller)是软件工程中的一种软件架构模式,把软件系统分为三个基本部分:模型(Model)、视图(View)和控制器(Controller)。

MVC 模式最早由 Trygve Reenskaug 在 1978 年提出,是施乐帕罗奥多研究中心(Xerox PARC)在 20 世纪 80 年代为程序语言 Smalltalk 发明的一种软件架构。MVC 模式的目的是实现一种动态的程序设计,使后续对程序的修改和扩展简化,并且使程序某一部分的重复利用成为可能。除此之外,此模式通过对复杂度的简化,使程序结构更加直观。软件系统通过对自身基本部分分离的同时也赋予了各个基本部分应有的功能。专业人员可以通过自身的专长分组:

  1. 控制器(Controller):负责转发请求,对请求进行处理。
  2. 视图(View):界面设计人员进行图形界面设计。
  3. 模型(Model):程序员编写程序应有的功能(实现算法等等)、数据库专家进行数据管理和数据库设计 (可以实现具体的功能)。

如何写好 React 业务代码?_第1张图片

在从携程离职前,我曾经听过工业聚做的一场关于 MVC 的分享会,他提出了一些有意思的见解。

对于一个 React 组件来说,我们可以把 state 看做 model,把 render 看做 view,把 event handler 看做 controller,每个组件本质上都是一个 MVC 结构。我们可以通过 event handler 函数来修改 state,state 变化后引发 view 重新渲染。

class App extends React.Component {    // model    state = {        count: 0    }    // controller    clickHandler = () => {        this.setState({            count: this.state.count + 1        })    }    // view    render() {        return (            
{this.state.count}
) }}

现在前端 MVVM 框架那么火,MVC 不是早就过时了吗?

其实 MVC 从来都没有过时,只是以 Backbone 为代表的前端 MVC 框架跟不上时代的发展了。Backbone 的技术选型过于落后,我们完全可以用新的技术来实现前端 MVC,以 React/Vue 作为 view,以 Redux/Vuex 作为 model。

代码的组织与分层

对于业务代码来说,大部分的前端应用都还是以展示数据和用户交互为主,无非是从接口拿到数据,进行一系列数据格式化后,显示在页面当中。

首先,应当对代码进行合理的分层,传统的 MVC 分层很适用于前端开发。但是对于复杂页面来说,随着业务逻辑增加,往往会造成 controller 臃肿的问题。所以,在此之上,可以将 controller 更加细分成 formatter、service 等等。

如何写好 React 业务代码?_第2张图片

下面这是一些分层后一个简单的目录结构。

    + pages        + hotelList            + components                + Header.jsx            + formatter                + index.js            + share                + constants.js                + utils.js            + view.js            + controller.js            + model.js

Service

统一管理所有请求路径,并且将页面中涉及到的网络请求封装为 class。

// url.jsexport default {    HOTELLIST: '/hotelList',    HOTELDETAIL: '/hotelDetail'}// Service.jsclass Service {    fetchHotelList = (params) => {        return fetch(HOTELLIST, params);    }    fetchHotelDetail = (params) => {        return fetch(HOTELLIST, params);    }}export default new Service

这样带来的好处就是,很清楚的知道页面中涉及了哪些请求,如果使用了 TypeScript,后续某个请求方法名修改了后,在所有调用的地方也会提示错误,非常方便。

formatter

formatter 层会储存一些用于格式化数据的方法,这些方法可以接收数据,返回新的数据,这里面不应该再涉及到其他的逻辑,这样有利于单元测试。单个 format 函数也不应该格式化过多数据,函数应该根据功能进行适当拆分,合理复用。

MVC

正如我们前面所说的一样,以 React 作为 view 层,Redux 作为 model 层,那么 controller 应该就是处理各种副作用操作(网络请求、缓存、事件响应等)的地方。

当处理一个请求的时候,controller 会调用 service 里面对应的方法,拿到数据后再调用 formatter 的方法,将格式化后的数据存入 store 中,展示到页面上。

class Controller {    fetchHotelList = () => async (dispatch) => {        const params = {}        this.showLoading();        try {            const res = await Service.fetchHotelList(params)            const hotelList = formatHotelList(res.Data && res.Data.HotelList)            dispatch({                type: 'UPDATE_HOTELLIST',                hotelList            })        } catch (err) {            this.showError(err);        } finally {            this.hideLoading();        }    }}

这里建议尽量用纯函数组件,当然有些涉及到业务逻辑的组件依然可以使用 class 组件。有了 hooks 之后,React 组件也会变得更加纯粹。

对于 React 应用来说,最外层的组件一般称作容器组件,我们会在容器组件里面进行网络请求、连接 store 等副作用的操作。

在这里,容器组件里面的一些逻辑也可以剥离出来放到 controller 中(React-IMVC 就是这种做法),这样就可以给 controller 赋予生命周期,容器组件也只用于纯展示。

我们将容器组件的生命周期放到 wrapper 这个高阶组件中,并在里面调用 controller 里面封装的生命周期,这样我们可以就编写更加纯粹的 view,例如:

wrapper.js

// wrapper.js(伪代码)const Wrapper = (view) => {    return class extends Component {        constructor(props) {            super(props)        }        componentWillMount() {            this.props.pageWillMount && this.props.pageWillMount()        }        componentDidMount() {                this.props.pageDidMount && this.props.pageDidMount()            }        }        componentWillUnmount() {            this.props.pageWillLeave && this.props.pageWillLeave()        }        render() {            const {                store: state,                actions            } = this.props            return view({state, actions})        }    }}

view.js

// view.jsfunction view({    state,    actions}) {    return (        <>            
)}export default Wrapper(view)

controller.js

// controller.jsclass Controller {    pageDidMount() {        this.bindScrollEvent('on')        console.log('page did  mount')    }    pageWillLeave() {        this.bindScrollEvent('off')        console.log('page will leave')    }    bindScrollEvent(status) {        if (status === 'on) {            this.bindScrollEvent('off');            window.addEventListener('scroll', this.handleScroll);        } else if (status === 'off') {            window.removeEventListener('scroll', this.handleScroll);        }    }    // 滚动事件    handleScroll() {    }}

其他

对于埋点来说,原本也应该放到 controller 中,但也是可以独立出来一个 tracelog 层,至于 tracelog 层如何实现和调用,还是看个人爱好,我比较喜欢用发布订阅的形式。

如果还涉及到缓存,那我们也可以再封装一个 storage,这里存放对缓存进行增删查改的各种操作。

对于一些常用的固定不变的值,也可以放到 constants.js,通过引入 constants 来获取值,这样便于后续维护。

// constants.jsexport const cityMapping = {    '1': '北京',    '2': '上海'}export const traceKey = {    'loading': 'PAGE_LOADING'}// tracelog.jsclass TraceLog {    traceLoading = (params) => {        tracelog(traceKey.loading, params);    }}export default new TraceLog// storage.jsexport default class Storage {    static get instance() {        //     }    setName(name) {        //    }    getName() {        //    }}

数据的管理

不过也不代表着只要分层就万事大吉,分层只能够保证代码文件结构上的清晰。真正想写出好的业务代码,最重要的还是你对业务逻辑的理解足够清晰,页面上的数据流动是怎样的?数据结构怎么设计更加合理?页面上涉及到了哪些交互?这些交互会带来哪些影响?

以如下携程的酒店列表页为例,这个页面看似简单,实际上包含了很多复杂的交互。

上方的是四个筛选项菜单,点开后里面包含了很多子类筛选项,比如筛选里面包括了双床、大床、三床,价格/星级里面包含了高档/豪华、¥150~300 等。

下方是快捷筛选项,对应了部分筛选项菜单里面的子类筛选项。

如何写好 React 业务代码?_第3张图片

当我们选中筛选里面的双床后,下方的双床也会被默认选中,反之当我们选中下方的双床后,筛选类别里面的双床也会被选中,名称还会回显到原来的筛选上。

如何写好 React 业务代码?_第4张图片

如何写好 React 业务代码?_第5张图片

除此之外,我们点击搜索框后,输入“双床”,联想词会出现双床,并表示这是个筛选项,如果用户选中了这个双床,我们依然需要筛选项和快捷筛选项默认选中。

这三个地方都涉及到了筛选项,并且修改一个,其他两个地方就要跟着改变,更何况三者的数据来自于三个不同的接口数据,这是多么蛋疼的一件事情!

如何写好 React 业务代码?_第6张图片

我借助这个例子来说明,在开始写页面之前,一定要对页面中的隐藏交互和数据流动很熟悉,也需要去设计更加合理的数据结构。

对于深层次的列表结构,键值对会比数组查询速度更快,通过 key 也会更容易和其他数据进行联动,但是却不能保证顺序,有时候可能就需要牺牲空间来换时间。

// 假设筛选项床型type为1,大床id为1,双床id为2.const bed = {    '1-1': {        name: '大床',        id: 1,        type: 1    },    '1-2': {        name: '双床',        id: 2,        type: 1    }}const bedSort = ['1-1', '1-2'] // 保证展示顺序

当我们选中大床的时候,只需要保存“1-1”这个 key,再和 store 中快捷筛选项列表里面的 key 进行 mapping(快捷筛选项里面的项也应该格式化为 {‘type-id’: filterItem} 的键值对格式),这样从时间复杂度上说,比直接遍历两个数组更高效。

在设计 store 的时候应当尽量使用扁平化的数据结构,这样有利于提高查询的效率,也提高了可读性。

React-IMVC

React-IMVC 是携程度假研发部的工业聚大佬开源的一个同构框架,目前已经在部门里面大量使用。

前面实现的简单分层,就是 React-IMVC 的设计思想,当然 React-IMVC 做的事情更多,包括了服务端渲染、路由、状态管理等等,这是一整套解决方案。

引用一个 React-IMVC 的官方文档介绍:

MVC 三者都是 Isomorphic,既是服务端 MVC,也是浏览器端 MVC。

React-IMVC 是 Isomorphic MVC 的 React 实现,它是一个 Web 框架。通过 React-IMVC,我们可以更便利地实现同构 Web 应用的开发。

这里通过实现一个简单的加减器来探索 React-IMVC 的使用方法。

安装

首先,我们需要通过 npm 或者 yarn 来安装 React-IMVC 的相关依赖。

npm install --save react react-dom react-imvc

npm scripts

在你的 package.json 里添加 npm scripts 如下命令:

{    "scripts": {        "start": "react-imvc start",        "build": "react-imvc build",        "test": "react-imvc test"    }}

添加 src 目录和路由文件

在 package.json 所在的目录下,新建一个名称为 src 的文件夹。

在 src 文件夹里新增 index.js 入口文件,添加相关的路由配置。

// src/index.jsexport default [    {        path: '/',        controller: () => import('./home/Controller')    }]

编写每个页面的 MVC 结构

每个页面必须是一个包含 controller.js 的文件夹,其中 controller.js 是页面的入口文件。

很明显这里以 controller 为入口,引入了 model 来初始化 store,引入 view 作为视图入口。

import Controller from 'react-imvc/controller' import React from 'react'import View from './view'import * as model from './model'import service from './service'const { initialState, ...actions } = modelexport default class Home extends Controller {     View = View     initialState = initialState    actions = actions    preload = {        'index': '/css/index.css' // 配置 css 文件路径    }    // 当页面渲染完成后,调用网络请求    componentDidMount() {        this.fetchAndSaveHomeData();    }    // 加    handleCountPlus = () => {        const {            count        } = this.store.getState();        const {            UPDATE_COUNT        } = this.store.actions        UPDATE_COUNT({            count: count + 1        })    }    // 减    handleCountMinus = () => {        const {            count        } = this.store.getState();        const {            UPDATE_COUNT        } = this.store.actions        UPDATE_COUNT({            count: count - 1        })    }    // 从接口获取初始count值    fetchAndSaveHomeData = async () => {        const {            UPDATE_COUNT        } = this.store.actions        try {            const data = await service.fetchHomeCount()            // 将数据更新到store中            UPDATE_COUNT({                count: data.count            })        } catch (err) {            console.log(err)        }    }}

React-IMVC 使用了一个类 Redux 的状态管理库——Relite,写法和 Redux 有一些不同。

// model.jsexport const initialState = {    title: '首页',    count: 0}export const UPDATE_COUNT = (state, payLoad) => {    return {        ...state,        count: payLoad.count    }}

剩下的就是页面视图的入口了,这里和 React 组件的写法并无区别,只是接受了 state 和 handlers 两个参数。

import React from 'react'import {    Style} from 'react-imvc/component'export default function View({    state,    handlers}) {    const {        handleCountMinus,        handleCountPlus    } = handlers    return (