React 项目架构设计和状态管理方案演进

最近承担了一个项目的独立开发工作,最早还是使用 Redux 来做的,但是后面我改成了用 Mobx 去做。原因其实主要是两点:

  1. Mobx 对 TypeScript 支持非常完美,而 Redux 的支持略痛苦。
  2. Mobx 更加灵活简洁,开发更加高效。

这是我目前为止做的最大的一个 React 项目,而且还经历了 Redux 到 Mobx 的重构。这篇文章主要对 React,Redux 以及 Mobx 在项目中使用上发表一些我自己个人的看法。

React 层面的架构设计

首先还是说一说 React 和项目构建方面,工程方面的一些设计。无关 Mobx 和 Redux。

目录结构

项目基础脚手架,其实内部是有专门的工具生成的,生成的目录结构还是不错的,唯一比较让我反感的是对于 React 文件,它使用的是 JS 后缀而不是 JSX 后缀。为什么我要纠结这点,原因有二:

  1. 从代码层面来说,JSX 语法并不是合法的 JavaScript 语法,我不认为应该要使用 JS 后缀。
  2. 从使用上来说,JSX 后缀可以让开发者意识到这是一个 React 组件/容器,区别了其他纯 JavaScript 代码文件。
  3. 在 TypeScript 中,使用 TS 后缀来命名 React 文件默认是会报错的,编辑器会建议使用 TSX。保持一致我认为应该使用 JSX。

主要文件夹是以下几个:

  • components

    这里主要存放 Presentational Component,又名展示型组件(区别于 Container Component,容器组件)。一些观点认为,展示型组件是 Stateless Functional Component,没有状态,不涉及业务,只负责展示,但我不这么认为。在我看来,只要是不涉及业务逻辑,不依赖其他状态方案(即 Mobx 和 Redux),比较少有自己的状态(如果有,也是 UI 状态),就可以设计为一个展示型组件。

    其实哪些组件应该被放到这里很明显,像 Wind 的所有组件都可以称为展示型组件,他们不依赖业务,也不依赖状态管理方案。

    但 Component 这一层是有一定讲究的,首先因为大部分组件都是无状态的,所以尽量使用 SFC 来写。其次因为 Component 是可能被大量复用的,所以使用 TypeScript 写的话,定义好 Props,可以带来非常大的帮助。另外,Component 应该尽量定义类的方法而不是类的属性,对于方法在 render 内被使用的情况,手动 bind this,或者借助 @autobind 之类的注解。之所以这么说是现在很多人写 React,因为不想考虑 this 的问题所以都是无脑箭头函数的方式去写定义类的属性。这样带来的问题是,每次类实例化都会一个新的属性被初始化,而如果使用类的方法的话,只是每次实例化的时候做一个 bind 操作,开销会更小。

  • containers

    上面已经提到了 Container 了,也叫 Container Component,区别于 Presentational Component。一般我们称 Presentational Component 为 Component,而称呼 Container Component 为 Container。

    Container 不同于 Component 是关注事物的展示,Container 更关注事物如何工作。Container 内会涉及一定的业务逻辑,一般会从状态管理方案如 Redux 或 Mobx 取得数据,同时 dispatch Action 或者调用 Mobx Store 上面的方法。

    Container 其实是 Route 的更一层细分。如果把一个页面的所有业务都塞到一个 Route 页面里面,无疑会变得复杂且难以维护,这个页面本身也会有很多的状态,容易造成混乱,并且如果使用的 Redux,页面内任何一处 dispatch 都可能造成整个 Route 的 rerender,十分不优雅。所以这一层的存在是有其必要性的。

    对于一个表格,图,Dialog 等都可以且应该设计成一个 Container。

  • routes

    Route 是每个路由对应的页面,该页面一般组合 Container 和 Component。比较少涉及业务,其状态一般也是 UI 状态或者在其内部多个 Container 中需要共享传递的状态。

  • services

    Service 这一层即服务层,跟服务端进行对接。其实 Service 完全可以实现到 Redux 或者 Mobx 的 Store 中去,但是将这一层单独提出来,更加容易维护和管理,而且现在业务往往服务端对一些接口会有特殊的需求,有时候我们也可以在这一层去做一些特殊的出来。

其他几点没什么好说的了。整个目录中关键的就上面几个文件夹。注意他们各自的作用。上面是针对 React 本身而言,不涉及状态管理方案,这个后面会提及。

构建和工程化方面

同样构建方面也是有基建在里面,但这类东西就逃不开定制化,这方面目前我们所使用的库还是差强人意了,原因是:

  1. Webpack 的配置被隐藏了,而且不太好修改,虽然提供了 Webpack Merge,但也不是使用的 smartMerge 方式。稍微想加个 Babel 插件,或者想改下 Less 的编译 Option 就不好处理了。
  2. 只支持 Less 预处理器,挺有年代的预处理器了,现在比较多都使用 Sass 或者 PostCSS 了。
  3. 不支持 TypeScript,这个通过 extend 机制可以自己实现支持。

还有其他几点原因,但是比较次要就不提了。在构建层面上面,我主要是引入了 PostCSS 的支持和 TypeScript 的支持,extend 配置如下:

const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const path = require('path');
const process = require('process');

module.exports = ({ production }) => {
    const plugins = [
        new CaseSensitivePathsPlugin(),    // 大小写敏感插件
        new ExtractTextPlugin('style.css'),    // 打包输出 CSS 文件名
        new webpack.DefinePlugin({    // 定义一些变量
            __DEV__: !production && process.env.NODE_ENV !== 'DEBUG',
            __PROD__: production,
            __DEBUG__: process.env.NODE_ENV === 'DEBUG',
        }),
    ];
    if (!production) {
        plugins.push(
            new ForkTsCheckerWebpackPlugin({    // 使用 worker 检查 TS 语法和类型
                checkSyntacticErrors: true,
                tsconfig: path.resolve(__dirname, 'tsconfig.json'),
                tslint: path.resolve(__dirname, 'tslint.json'),
                watch: ['./src/**/*.tsx*'],
                ignoreLints: ['no-console', 'object-literal-sort-keys', 'quotemark'],
            }),
            new webpack.WatchIgnorePlugin([/\.d\.ts$/]),    // 忽略 d.ts 文件
        );
    }
    return {
        context: path.resolve(__dirname),
        entry: path.resolve(__dirname, 'src', 'index.js'),
        resolve: {
            alias: {
                dva: '@ali/dva-wind',
                '@src': path.resolve(__dirname, 'src'),    // 让 webpack 解析 @src 到 src 文件夹中
            },
            extensions: ['.js', '.jsx', '.ts', '.tsx', '.pcss'],
        },
        // prettier-ignore
        module: {
            rules: [
                {
                    test: /\.pcss$/,
                    use: production
                        ? ExtractTextPlugin.extract({
                            use: [
                                {
                                    loader: require.resolve('css-loader'),
                                    options: {
                                        importLoaders: 1,
                                        modules: true,    // 开启了 CSS Module
                                        namedExport: true,
                                        camelCase: true,
                                        minimize: true,
                                        localIdentName: '[local]_[hash:base64:5]',
                                    },
                                },
                                {
                                    loader: require.resolve('postcss-loader'),
                                },
                            ],
                            fallback: 'style-loader',
                        })
                        : [
                            {
                                loader: 'style-loader',
                            },
                            {
                                loader: require.resolve('typings-for-css-modules-loader'),    // 该插件用于生成 CSS 文件的 d.ts 文件,便于 TS 使用
                                options: {
                                    importLoaders: 1,
                                    modules: true,
                                    namedExport: true,
                                    camelCase: true,
                                    minimize: true,
                                    localIdentName: '[local]_[hash:base64:5]',
                                },
                            },
                            {
                                loader: require.resolve('postcss-loader'),
                            },
                        ],
                },
                {
                    test: /\.tsx*?$/,
                    exclude: /node_modules/,
                    loader: require.resolve('ts-loader'),
                    options: {
                        transpileOnly: true,    // 让 ts-loader 只负责编译代码,不检查类型
                    },
                },
            ],
        },
        plugins,
    };
};

除了对 TS 和 PostCSS 的支持,其实还是做了不少东西的。

关于 Less,其实我并不反感这个,只是我使用 TypeScript 的时候需要 import 一个 CSS 文件,我希望用 typings-for-css-modules-loader 这个插件去自动帮我从 CSS 文件提取生成一个 d.ts 文件。这样一来不仅我可以正确的 import CSS 进来,其次也可以有 CSS 类名的提醒和补全。但前面也提了,对于 Less 我只能通过覆盖他的规则实现,而如果要覆盖的话,我觉得我还是用 PostCSS 好了。当然我更喜欢 PostCSS,这也是我使用他的一个原因。

之所以 ts-loader 中配置 transpileOnly: true,是因为如果不这么做的话,每次编译的时候他都会去检查所有 TS 文件的类型,从而导致编译十分缓慢。这里借助了 ForkTsCheckerWebpackPlugin去帮我们实现这个事情,本身这个插件也是通过单独开多一个进程的方式去检查的,这就可以大大增加开发的编译时间,把检查单独抽了出来在另一个进程中去执行。

最后特别提下就是关于 @src alias 这一点,主要考虑到一个页面可能会引用多个文件,而各个文件可能又分布在不同的层级上面,如果都是用相对路径的方式去引用的话,一旦当前文件发生了层级的变更,就需要去修改引用的代码。所以配置 @src 看起来舒服,重构起来也方便。

工程化方面,主要是以下几点:

  1. 加入 EditorConfig 文件
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true

配置这个的目的,是可以统一所有编辑器和 IDE 处理项目内文件的方式,这个设置要高于所有编辑器和 IDE 本身的配置。有些编辑器和 IDE 可能需要安装 EditorConfig 插件才能识别这个文件。

  1. ESLint 的规则变更和完善
{
    "extends": "eslint-config-ali/react",
    "parser": "babel-eslint",
    "globals": {
        "__PROD__": true,
        "__DEBUG__": true,
        "__DEV__": true
    },
    "rules": {
        "react/jsx-filename-extension": "off",
        "one-var": "off",
        "arrow-parens": "off",
        "indent": ["error", 4],
        "react/jsx-indent": ["error", 4]
    }
}

该文件在 Wind 脚手架初始文件进行了一些修改,主要是原先 Lint 限制了 JSX 的使用,所以这里关闭了这个限制。然后缩进方面改为 4 个空格。另外 globals 处定义了一些变量,这些在项目中我会用到,定义在这里可以避免使用时 ESLint 提示变量未定义。

  1. Prettier

Prettier 是代码格式化,它被很多大公司包括 Facebook,Google 在内的企业使用,用于格式化包括 JavaScript,JSON,CSS 在内的各种代码。虽然看起来跟 ESLint 有些重叠,但是 Prettier 的修复功能更加智能强大。所以我也进行了配置。不过由于我项目里面很少有 JavaScript 代码,所以它跟 ESLint 可能有些冲突需要去解决下。目前跟 TSLint 是已经可以很好的配合工作了。

Prettier 在代码格式化上面做的比 ESLint 和 TSLint 要好很多,建议都使用下,但要注意下他和 ESLint/TSLint 规则的冲突。

{
    "printWidth": 120,
    "semi": true,
    "singleQuote": true,
    "tabWidth": 4,
    "trailingComma": "all"
}
  1. JSConfig

JSConfig 规定了编辑器对 JavaScript 代码的一些处理行为。在 VSCode 中需要该文件来让 VSCode 正确的在 JavaScript 代码中解析 @src ,否则可能失去补全和代码跳转的效果。另外,装饰器还是一个 ECMAScript 的草案,还是不是正式的 JavaScript 语法,默认下 VSCode 检测到 JavaScript 使用 decorator 会提示出错,通过该配置可以开启支持。

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@src/*": [
        "./src/*"
      ]
    },
    "experimentalDecorators": true
  }
}
  1. PostCSS

这是一个 PostCSS 的配置文件,配置了 autoproxifier 以及 CSSNext 和对类名嵌套的支持。

const path = require('path');

module.exports = {
    plugins: [
        require('postcss-import')({
            path: path.resolve(__dirname, 'src'),
        }),
        require('postcss-nested'),
        require('postcss-cssnext'),
    ],
};
  1. TSConfig

TypeScript 的配置文件,推荐配置。

{
  "compilerOptions": {
    "baseUrl": "./",
    "module": "esnext",
    "target": "es5",
    "paths": {
      "@src/*": [
        "./src/*"
      ]
    },
    "lib": [
      "esnext",
      "es7",
      "es6",
      "dom"
    ],
    "sourceMap": true,
    "allowJs": true,
    "jsx": "react",
    "moduleResolution": "node",
    "rootDir": "src",
    "checkJs": true,
    "forceConsistentCasingInFileNames": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": false,
    "strictNullChecks": true,
    "suppressImplicitAnyIndexErrors": true,
    "noUnusedLocals": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "allowSyntheticDefaultImports": true,
    "declaration": false,
    "importHelpers": true,
    "noEmitHelpers": true,
    "noEmitOnError": true,
    "noFallthroughCasesInSwitch": true,
    "strict": true,
    "pretty": true,
    "removeComments": true
  }
}

  1. TSLint

TSLint 的配置文件,推荐配置。

{
    "defaultSeverity": "warning",
    "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
    "globals": {
        "Mie": true,
        "__PROD__": true,
        "__DEBUG__": true,
        "__DEV__": true
    },
    "rules": {
        "no-console": false,
        "object-literal-sort-keys": false,
        "max-classes-per-file": false,
        "member-access": false,
        "interface-name": false
    }
}
  1. package.json

之所以提到这个,是因为其实还有一些配置是放到这里面来了。其中一项是:

  "browserslist": [
    "ie >= 9"
  ]

在 package.json 配置这个,PostCSS 和 @babel/preset-env 会读取这里的配置,以输出支持它的目标代码。

"lint-staged": {
    "src/**/*.js?(x)": [
      "eslint --fix",
      "prettier --write",
      "git add"
    ],
    "src/**/*.ts?(x)": [
      "tslint --fix",
      "prettier --write",
      "git add"
    ],
    "src/**/*.{json,css,pcss}": [
      "prettier --write",
      "git add"
    ]
}

这里的配置需要安装一个模块叫 lint-staged ,并在 npm scripts 中加入 precommit: lint-staged。效果就是每次 commit 的时候,就会根据你变更的代码类型,来执行上面这个配置中的规则。例如如果是 TS 或者 TSX 文件,就会依次使用 TSLint 和 Prettier 修复,然后自动添加到暂存区。

  1. .vscode

我在项目里面还有个文件夹是 .vscode,不要误会他是误提交上去的,这个文件夹是我故意提交上去的,里面有一些推荐的扩展插件,和一些工作区配置。

extensions.json 配置了一些插件,当你打开本项目的时候,VSCode 发现该文件会自动提示你有推荐扩展是否安装。

{
    "recommendations": [
        "dbaeumer.vscode-eslint",
        "ionutvmi.path-autocomplete",
        "mhmadhamster.postcss-language",
        "eg2.tslint",
        "woota.vscode-intl",
        "jpoissonnier.vscode-styled-components",
        "wayou.vscode-todo-highlight",
        "esbenp.prettier-vscode",
        "EditorConfig.editorconfig",
        "naumovs.color-highlight",
        "formulahendry.auto-rename-tag",
        "formulahendry.auto-close-tag"
    ]
}

这些都是不错的插件,建议安装使用。

同时还有一个 settings.json,这个就是一些配置了:

{
    "editor.formatOnSave": true,
    "javascript.implicitProjectConfig.experimentalDecorators": true,
    "eslint.autoFixOnSave": true,
    "tslint.run": "onSave",
    "intl.nlsPath": "src/locales/messages.js",
    "intl.identifier": "intl",
    "path-autocomplete.pathMappings": {
        "@src": "${folder}/src"
    },
    "tslint.autoFixOnSave": true
}

前面提到用 @src 来替代相对路径引用,从代码层面,我们需要配置 Webpack 来支持。但从编辑器角度,我们需要配置 JSConfig 和 TSConfig。但即使这样,VSCode 也只能给我们带来代码的跳转,补全方面还是会有缺陷,所以我们需要借助前面 extensions.json 中提到的一个插件 ionutvmi.path-autocomplete 并在 setting.json 配置了 path-autocomplete.pathMappings 来达到更好的补全支持。

可见工程化方面,改进还是非常多的。需要注意的一点事,由于我几乎没有使用 JavaScript,所以 ESLint 和 Prettier 这两方面对 JavaScript 的支持可能有问题,例如 ESLint 有些规则可能不太符合我们要求,Prettier 可能和 ESLint 部分规则冲突等等,这个只能后面使用 JavaScript 写的时候遇到的时候去解决了。

Redux 的架构设计

我们的脚手架本身集成了 Dva,关于 Dva 本身的结构设计还是可以的。

从目录结构看,Redux 的核心部分在 model 文件夹,model 文件夹又包含以下几个文件:

  • index

    Redux Store 文件,内含 namespace,state,reducers,effects,subscriptions。

    • namespace

      namespace 的存在,和其他语言的 namespace 一样,是为了解决命名冲突的问题,此处的 namespace 是为了避免 reducer 的冲突。

    • state

      Redux Store 的状态,一般存放服务端数据。组件之间需要共享的,或者组件需要缓存的状态数据也可以放到这里。

    • reducers

      reducer 是纯函数,接受上一次的 state 和 payload,返回了新的 state。

      Redux 更新视图的原理就在于,一旦 dispatch 了某个 action,将会遍历所有的 reducer,得到一个新的 state 树,接着通知所有的 subscribers(如果使用 react-redux 的话,就是所有 connect 的地方),connect 得到的返回值如果发生了变化,则作为新的 props 触发了组件的 componentWillReceiveProps 生命周期,否则组件不刷新。

    • effects

      Redux-Saga 不同于 Redux-Thunk 和 Redux-Promise 污染了 dispatch 方法,Redux-Saga 的 dispatch 仍然只接受 Action(建议规范使用 Flux Standard Action)。在副作用的组合处理上面,Redux-Saga 抽象出了新的一层 effect 用来专门处理副作用。所有的副作用和副作用的组合,都应该在 effect 中去处理。

    • subscriptions

      貌似是 Dva 加的一个概念,主要是可以做一些 subscription 操作吧,我个人比较少使用,感觉不到这一层的意义在哪里。

  • constants

    在 Redux 中,reducer 并不会被直接调用,我们总是通过 dispatch 一个 action,来轮询所有的 reducer,并通过 action type 的匹配,得到我们最终需要的新的 state 树。

    Redux action type 是一个字符串的设计,我觉得是 Redux 的缺陷,字符串无法被追踪,容易出错。所以,为了减少字符串使用,减少出错率,人们往往会定义一个 constants 文件。这就是 constants 文件存在的意义之一。

  • selectors

    selectors 是从 Redux Store 中 pick 或者 compute 数据。通常的一个做法是,在这里定义一个个的 selector,选择数据或者对数据进行衍生,然后在组件中通过 Connect 把 state 传入 selector 从而得到组件需要的数据。

    不少人会省去 selector 转而在 Connect 去做这个操作,这是不被建议的方式。

    通常,还会使用 reselect 去做一个优化操作,reselect 的本质就是 memorize。之所以需要它,愿意在于,每次 dispatch 之后所有 Connect 都会被执行,如果 Connect 里面涉及计算,那么当存在大量 Connect 和计算时,可能一个 dispatch 就会导致一定的阻塞。为此,使用 reselect,可以在保证 selector 传入参数不变的情况下,始终返回上次的结果。毕竟 selector 本身也是一个纯函数。

  • actions

    准确的说这里定义的是一个个的 ActionCreator 的,如下:

    export const describeInstances = (payload: IDescribeInstancesReq) => ({
      type: `${cons.namespace}/${cons.describeInstances}`,
      payload,
    });
    

    理由和 selectors 的存在原因类似,都是为了提升代码复用率,以及便于维护的原因。selector 和 actionCreator 都集中在一个地方,会更容易维护理解以及进行优化。

    结合 selectors,实际上我们的 mapStateToProps 和 mapDispatchToProps 方法是这样的。

    const mapStateToProps = (state, ownProps: IOwnProps) => ({
      listenerTableData: getListenerTableData(state),
      instances: getInstanceSelections(state),
      ...ownProps,
    });
    const mapDispatchToProps = dispatch => ({
      updateRules: bindActionCreators(fetchLayer4Rules, dispatch),
      createLayer4Rule: bindActionCreators(createLayer4Rule, dispatch),
      deleteRule: bindActionCreators(deleteLayer4Rule, dispatch),
    });
    

    这里的 bindActionCreators 只是 (...args) => dispatch(fetchLayer4Rules(...args)) 的写法简化而已。

这样看来,Redux 这么做下去的话,分层还是非常清晰的,但是也暴露出来了几个问题:

  1. 模板代码多

    我们会发现,组件和 Redux 总是通过 actionCreator 和 selector 来联系的。当我们新增一个在 Redux Store 上的数据时,我们需要定义 constant 来命名 reducer;然后定义 state,定义 reducer 来进行修改操作,定义 actionCreator 来指定到该 reducer,定义 selector 来获取该 state。想想是挺麻烦的,Redux 天生就这样,无法避免。

  2. 对 TS 支持不好

    模板代码多,意味着我们要写更多的类型。我在使用 TS 写 Redux 的时候,遇到了很多问题,虽然问题都还是可以解决的,但是写的非常不爽,这也是导致我后面项目中改用 Mobx 重构的主要原因。

    • Connect 处理

      一个组件的 Props 来源有三部分,一部分来自上层组件传递(ownProps),一部分来自 mapStateToProps(事实上,也可以把上层组件传递在这里进行 merge),还有一部分来自 mapDispatchToProps。

      那么问题来了,我们怎么定义一个组件的 Props 类型来自上面三部分,但对于调用者来说,只需要检查的只是 ownProps 呢?

      为了解决这个问题,首先你不能使用 decorator 的方式,其次你必须得到 mapStateToProps 和 mapDispatchToProps 的返回值类型以及 ownProps 的类型。

      然后 Dva 本身的 connect 的 type definition 还有问题,你可以使用 react-redux 的 connect 来处理,或者使用 react-redux 的 connect 的 type definition 来覆盖 dva 的 connect 的 type definition。

      export default connect<IMapStateToProps, IMapDispatchToProps, IOwnProps>(
        mapStateToProps,
        mapDispatchToProps
      )(CreateLayer4RuleFrom);
      

      CreateLayer4RuleFrom 组件的 Props 本身还是三部分组合的,但我们这样导出的一个组件,就会只接受 ownProps 了。

    • MapStateToProps 和 MapDispatchToProps

      上面还有一个问题是,我们怎么得到这两个方法的返回值类型呢?

      目前 TypeScript 是没有什么方法是可以直接得到的,唯一可以做的就是执行这个函数,然后使用 typeof 得到返回值的类型。也就是说我们需要污染运行时才能得到。

      关于使用 TS 写 Redux,前面有一篇文章做了详细介绍,这里就不详细说了。

    除此之外,还有很多其他问题,比如 Redux-Saga 使用的 generator 在 TypeScript 无法推导返回值类型,dispatch 事件,无法从 action type 去校验 payload 等等。

  3. 性能问题

    前面说了,每次 dispatch 之后,其实是会遍历全部 reducer,组合得到新的 state,而这其中,可能涉及大量的 switch 切换。switch 的性能怎么样我不太清楚,但是想到要遍历全部 reducer 感觉就很蛋疼,即使你只是改动一个变量的布尔值。

    这是一回事,如果我们没有使用 reselect 库的话,那么大量的 Connect 都会被重新执行,然后接收到新 Props 的组件得以 rerender。如果 Connect 中涉及计算,那么该阶段可能也是比较耗时的。

    如果只是接触过 Redux 并且对 Redux 有所认识的人,可能觉得习以为常了,但是下面要说的 Mobx 就不是这么做的了。在网上看到过这么一张图片,没看到具体文章,仅供参考:
    making-react-part-of-something-greater-32-638

Mobx 的架构设计

知道和用过 Mobx 的人可能不多。Mobx 的思路和 Redux 截然不同。Mobx 通过定义可观察数据,这个可观察数据的意思就是,他可以把一个数据转为可观察的,可观察的意思就是,对于这个数据,比如一个对象,他会重新定义每个 key 的 getter 和 setter 函数。在 getter 中,他会收集调用者信息(你这个组件用到了我),然后在 setter,也就是被重新赋值时,它会触发前面 getter 所收集的调用者的 rerender。

简单来说就是这么一个思路,跟 Vue 的双向绑定思路如出一辙。这个思路看起来要比 Redux 简单而且高效很多。另外,Mobx 本身也进行了大量的优化,包括 batchUpdate 之类的操作。它对数组也进行了一层封装,以支持响应数组的 push,concat 等方法。

这个项目,写到一半的时候被我使用 Mobx 进行了重构。这次重构,删去了 1900 多行的代码,增加了 800 多行代码。可见 Mobx 写起来要比 Redux 简单很多,少去了很多模板代码。

类似于 Redux 中定义 Model,在 Mobx 中,我定义了一个个的 Store。

import logService from '@src/services/log';
import { action, observable, runInAction } from 'mobx';

class LogStore {
    @observable total: number = 0;
    @observable opEntities: struct.OpEntity[] = [];

    @action
    async describeOpEntities(payload: IDescribeOpEntitiesReq) {
        const body = await logService.describeOpEntities(payload);
        runInAction(() => {
            this.opEntities = body.data.OpEntities;
            this.total = body.data.Total;
        });
        return body.data;
    }
}

export default new LogStore();

这是一个简单的例子。totalopEntities 都是被设置为可观察的,我们第一次在 Component 使用它的时候,他们的值还是 0[],但当我们调用了 logStore.describeOpEntities 方法之后(不同于 Redux 需要通过 dispatch,Mobx 直接去调用那个 Action 就可以了),我们在 runInAction 中修改了 opEntitiestotal 的值,本质上,这其实调用了他们的 set 方法,这会最终导致前面 Component 的重新 rerender,从儿展示了我们这边设置的数据。

类似于 Redux 本身只能处理同步一样,Mobx 的 @action 也只能检测方法内同步的修改,对于异步中的修改,我们要通过 runInAction 的方式去实现。事实上上面你的例子我们并不需要 @action,因为在这个同步执行的代码内,并不存在对可观察数据进行修改的情况。

Mobx 的更新原理,使得它可以精确的进行更新操作,自然也不需要 Connect 和 Reselect 这些东西。

@action 在 Mobx 中其实是可选的,通过 Mobx.useStrict(true) 可以强制它的使用,这会导致所有数据的修改必须在 action 中完成,这在某种层面上由接近了 Redux 的修改原则。

通过使用和修改可观察数据,可以实现组件的自动更新,因此我们在业务组件中,也不需要使用 React 的 state 状态管理方案了。众所周知,React setState 操作处理嵌套层次比较深的数据十分麻烦。如果使用 Mobx 的可观察数据,我们在 action 中去直接修改它就可以了,写起来也比较清爽。

不同于 Redux,在组件中不会出现副作用,所有副作用都在 effect 处理。Mobx 中没有强制的要求,我们可以在组织中定义一个异步方法,在异步执行完成之后,通过 runInAction 包装直接修改数据,或者对于同步的方法,直接用 @action 装饰然后直接在方法内去修改。

这么看来其实 Mobx 相比 Redux 具备诸多优势,那么 Mobx 真的是完美的吗?并不是:

  1. Mobx 无法跟踪未定义 key

    这句话意思是,Mobx 使用 defineProperty 给对象的每个 key 都定义个 getter 和 setter,对于未定义的 key,自然无法处理,这进一步会导致当我们使用一个未定义的 key,然后后面再进行赋值时,Mobx 不知道需要去更新。

    在 Vue 中,同样的问题也是存在的。Vue 的下一个大版本,以及 Mobx4,已经计划或者开始使用 Proxy 来重写了,Proxy 不同于 defineProperty,他可以拦截一个对象所有的 key 包括未定义的 key 的访问修改,自如其名,作为一个代理,他可以拦截所有存取,然后偷偷摸摸的干一些你不知道的事情。唯一的问题是在 Proxy 的支持上面,IE 全军覆没,对,即使是 IE11。目前微软也只有 Edge 支持了。

  2. Mobx 之追踪被观察的函数上面的访问

    一般我们都会使用 @observer 来装饰一个组件,其实这装饰的是组件的 render 方法。在 render 方法执行过程对可观察数据的存取操作都是被追踪的。但在这个 render 方法之外,就无法被追踪了。

    这个问题普遍出现于,比如我们使用一些组件库的表格组件,我们可能是传入一个 SFC(Stateless Functional Component)进去,让表格去渲染我们想要的数据出来。这种数据目前是无法被 Mobx 捕捉到的,也就是说当数据更新时,这个 SFC 是不会重新执行的。

    解决这个问题的方法也很简单,我们可以把 SFC 用 observer 同样包装下,又或者使用 <Observer></Observer> 来处理。第一个方法,其实就和我们用 @observer 注解一样了。

    @autobind
    renderInstanceInfo(_, __, instance: struct.InstanceCombine) {
        if (!isObservable(instance, 'Remark')) {
            extendObservable(instance, {
                Remark: undefined,
            });
        }
        return (
            <Observer>
                {() => (
                    <TableCell>
                        <p>ID: {instance.InstanceId}</p>
                        <p className={styles.gray}>
                            备注名: {instance.Remark}
                            <a href="javascript:;" onClick={() => this.onOpenModifyRemarkForm(instance)}>
                                <Icon type="edit" size="xs" />
                            </a>
                        </p>
                        <p>正常业务带宽: 550M</p>
                    </TableCell>
                )}
            </Observer>
        );
    }
    

    上面是一个示例。这里说明下,当数据没有备注名时,服务端是不返回该值的,但前端是允许新增或修改备注的。这里的 a 里面也有打开编辑框的事件。如果我们没有前面 extendObservable 的操作的话,那么假设 Remark 是没有返回的,那么我们后面修改了这个 Remark,并不会触发 rerender。

    另外,如果我们没有使用 Observer 这个组件的话,Mobx 也不会追踪我们这个方法内的存取操作,导致不更新。

    这两个是初学者很容易踩的坑。

其他这两个问题,说是问题感觉也不是,说不是呢又感觉是问题。这个问题导致的结果是没有像我们所期望的那样重新渲染,但是对于深知这两个问题,了解 Mobx 原理的开发者来说,这并不是什么问题。相比起来,Redux 的繁琐是无法解决的问题,没办法的问题。

另外对于 TypeScript 的支持,Mobx 本身就是使用 TypeScript 写的,而这一套东西也是 OOP 的思想,所以对 TypeScript 的支持当然非常棒。

总结

本文总结了在实际项目中得出的一些工程化和构建方面的实践,以及对 React 目录结构设计看法。并分别从 Redux 和 Mobx 的角度去实现状态管理,分析其利弊。

这样看来,其实,Mobx 无论是从代码量,用法,性能,以及对 TypeScript 的支持上面,都是 Mobx 完胜。

但是 Mobx 其实理解起来比 Redux 更难,Redux 的更新很简单粗暴,而 Mobx 则是有一个黑盒,有点 magic 的感觉。一开始使用 Mobx 的话可能会遇到不少问题。

另外 Mobx 和 Redux 还有一点不同就是,Mobx 并不反对在组件中使用副作用,而 Redux-Saga 则是把所有副作用都放到 effect 去处理。等等为什么我说的是 Redux-Saga 不是 Redux?因为 Redux 这小子假装不知道副作用,抛得一干二净的,摆出自己一副非常 Pure(Redux 提倡纯函数) 的样子。都让 Redux-Thunk,Redux-Saga 或者 Redux-Observable 之类的库去收拾了。

其实我觉得,使用 Mobx 还是 Redux,某种程度有点类似你要用 JavaScript(Node.JS) 还是用 Java。对于同一个项目,经验不多的 JavaScript(Node.JS) 开发者写出来的项目除了很容易产生各种各样的问题,代码看起来也可能比较乱,不容易维护,而使用 Java,即使是初学者,遵循像 SpringMVC 之类的一套规范去开发,写起来的代码还是比较有章法和整洁的。JavaScript(Node.JS) 写起来是要比 Java 简单很多,但带来的潜在问题也更多。

从另一个层面上讲,我又觉得 Mobx 像 Java,Redux 像 JavaScript(Node.JS),之所以这么说,是因为 Mobx 对 TypeScript 支持更好,很容易写出静态完备的工程出来,而 Redux 支持则比较差,如果用 TS 写的话,会有种又长又臭的感觉(讲真我就是这种感觉)。

对于选择哪一个,我觉得还是看怎么取舍了。其实很多项目,就只用 Redux-Thunk 也可以写的很好。我个人嘛,还是偏爱 Mobx 多一点,但我也不排斥团队里面使用 Redux,原因嘛,这样我就可以同时熟悉这两个了(个人项目还是会使用 Mobx 来做)。