mobx使用探微

799 阅读14分钟
原文链接: zhuanlan.zhihu.com

1. 前言

写作这篇文章的起因是我有感于实际项目中所遇到的问题。因此,这篇文章只算是摸索的产物,远未臻于完美。在这里,我谨期望阅读这篇文章的同学能够见仁见智,各洒江海。

我所遇到的问题,总结如下:

  1. 项目采用分层设计,pages 为视图层,stores 为模型层,services 为服务层。从某种意义上讲,代码是按功能单元进行划分的,而不是业务单元。需要分别从 pages, stores, services 层取出相应的模块,才能构成一个完整的业务单元。分层设计的优点在于代码组织的清晰性,但是降低了可移植性。当需要向第三方应用输出某个业务单元时,就不得不另起炉灶,需要重新为特定的视图组件串联 stores。
  2. 无论 stores 层,还是 services 层,代码设计大都以视图为出发点,比如 services 层多次对同一个后台接口实现了调用,比如 stores 混杂着特定页面的视图状态,不利于复用。通过这一点,所能感知的症结是,stores 层中的模块称不上是对数据模型的精要抽象。除此以外,这个病症所体现的另一个特征是,stores 层中的模块常常仅作为视图组件和 ajax 接口的桥接层,即简单地将接口数据灌入到视图中、或者将视图数据提交到远端。这样做的好处是 store 极为轻量,但是,其一,远没有挖掘 mobx 用于组织数据模型的价值(近于使用 redux 处理纯数据),其二,store 没法涵盖数据特征,数据在视图层消费时才能展现其意义。事实上,数据处理中的 value - text 转换也常常会落在视图层。这样,在另一张需要消费同一份数据的视图页面中,就仍需要重做一份数据转换处理。

以上种种,促使我在业余时间着意摸索 mobx 的使用。很显然的,需求是技术提升的动力。离开项目环境,仅凭有限的知识和经验储备,我没办法复现实际项目的业务复杂度。相形之下,我所思忖的案例是较为简单的。因此,由这篇文章引出的种种观点无疑都是可商榷的。不过,有什么是一成不变的呢?譬如武侠小说中提到的,“剑招是死的,人是活的”。我再次期望阅读这篇文章的同学对本文所引出的命题有所意会、有所领悟,而对形式上的糟粕持宽容的态度;若是能以丰富的开发经验对我有所指教,那就再好不过了。

备注:示例代码通过自制的 plutarch 脚手架 实现,托管仓库地址为 mobx-demo

2. 案例前情

案例将提取商城系统中的产品配置环节,绘制的页面仅包含产品列表页、产品编辑页和产品详情页。介于案例旨在于探讨 mobx 的使用,势必会使业务屈从于所要考量的功能点,一反业务引导开发的常态。我个人的一些看法,在开发已集大成的前提下,由开发引导业务也未尝不是一种选项;系统设计的舵手譬如指挥官,所需的是纵览全局的能力,不扭于认知、经验和职司。假使一位富于远见的开发对多个商城系统的设计和实现已经了然于胸,他又怎不能称为建站工作的主导者呢?只不过这一点对大多数人来说,都过于理想化,很能达成。毕竟职业、天赋和精力的限制,会让我们的成长道路都有所局限。当然,这是题外话。言归正传,本案例涵盖的考察要点包括:

  1. 适用于多个页面的 Product 商品模型。
  2. 互为关联的 Product, Attribute 商品属性模型。

以上两点,都是以数据模型为导向的,而不是着眼于页面绘制。从职责来看,前端的工作内容是绘制页面,页面逻辑在部分人眼里只是个子内容。但从整体来看,数据才是内容,视图只是表现。mobx 所提供的强大能力不止于像 redux 那样单纯处理数据流,而在于它通过代理提供了数据建模的可能。如果说 redux 是面向过程编程,那么 mobx 就是面向对象编程。附着的那层响应式操作不改变原有类的特征,使我们能更大程度地使用这个类。这是在响应式之外,使用 mobx 编程时尤其需要有所发现的点。下文将作深入探讨。

当前端的开发工作以数据模型为导向时,就更能契合后端的表结构设计、模型层实现,抽象程度也更高,自然能加深对业务的理解。另外,数据处理也不至于散落在视图层。

对于考察要点中的第二项,所需说明的是案例持有的业务特征。当我们访问淘宝,会发现手机从属于“家电 / 数码 / 手机”这一大类,“手机”这一小类;对于“手机”这一小类,还有诸如"机身内存ROM",“手机类型”,“网络类型”,“附加功能”,“摄像头类型”,“分辨率”等特有属性。可以料想的是,这些特有属性和“手机”这个小类呈联动关系,即 Attribute 有单独的表设计(特征量和特征值双表)。特别说明这一点,既是为了剖析案例所有的业务特征,也是为了表明案例更大程度上植根于猜想,我并没有参与商城开发的十足经验,错谬也在所难免,期望阅读这篇文章的同学海涵。

简易的表结构设计如下(参考淘宝开放平台的商品接口设计):

如上图所示,Product 产品表包含 cids 字段指向 Category 分类表,attrs 字段用于聚合 Attribute 属性特征量表和 Attr_Value 属性特征值表。贴图的表结构虽然难免会有差池,但不妨碍本文用于探讨 mobx 的使用。为了简化案列的复杂度,本文也约定 Category, Attribute, Attr_Value 表中的数据不需要另行制作配置页面注入数据。

基于以上,相应接口如下,相关代码参考 mobx-demo 中的 plutarch.mock.js 文件:

  1. get api/category 接口用于获取产品类目,传参 level 用于区分检索大类还是小类,cid 用于锁定产品类目。
  2. get api/attributes 接口用于获取与类目相关的属性,传参 cid 用于锁定产品类目。
  3. post api/product 接口用于保存或更新产品。
  4. get api/product 接口用于获取产品。
  5. get api/products 接口用于获取产品列表。
  6. delete api/product 用于删除产品。

3. 案列实现

案例仍采用分层架构(如何以业务单元形式组织代码暂留作后续的思考命题):

  1. requset 模块:基于 aioxs 类库处理 ajax 请求,使用拦截器诊断错误的响应,并使用 antd/message 组件在页面中显示错误内容(该组件能同时展现多个请求错误)。
  2. services 层:使用 class 语法构造,便于继承,同时也继承了 Cache 类,用于缓存比较稳定的接口数据。同时,utils 工具包提供了 mixinStaticProperty 装饰器,用于将 services 层输出类的原型方法注入为 model 类的静态方法,参见 stores/models/Product 类的实现。services 层也可以用于拆分接口,如 services/category 将 getCategory 接口拆分为 getCategoryByCid, getCategoryByLevel 两个接口。这样便于更细微的控制,当然,拆分接口的稳定性另当别论。
  3. stores/models 基本数据模型:数据模型分为两类,一类需要深入数据库或后台模型的数据特征,如 Product 类拥有诸多的可观察属性,便于以方法的形式操作这些属性值的变更,而列表也是由这些类自底而上构成的;另一类则采用数目不多的属性批量更新的机制实现,不需要微操数据的变更,所包含的方法通常也只跟远程请求相关,如 Category, Attribute 类。
  4. stores 层其余衍生数据模型:基于 models 实现,与页面实际交互的模型,如由 Product 类衍生出 ProductInEdit, ProductInDetail, ProductList 三个类,即分别应用于编辑页、详情页和列表页。编辑页和详情页所使用的模型均可拓展 Product 类实现,案列出于职责分离的考虑,将其细分为多个子类。当然,也可以像案列中 Category 类的实现那样,在该类中聚合多种智能,比如全量拉取产品分类数据,以及只拉取针对某个产品的分类数据。同时,为了更好地实现数据处理,Category, Attribute 类均输出实例作为 Product 的实例属性,这样能聚合该产品的分类、属性数据处理操作。
  5. pages 层:当 stores 层通过数据模型承担数据处理操作时,pages 理论上只承担了展示职能。介于 mobx 实现代理数组的特殊性,改变数组项的内部属性只能通过观察该数组项完成,列表操作又需要调用代理数组的方法,才能启动重绘。因此,使用 mobx 注入可观察数据时,首先,组件的颗粒度需要得到细化处理,其次,在删除某个数组项如某个产品时,不禁需要调用 product.delete 方法,也需要调用调用 products.splice 方法,使列表得到重绘。
  6. locales 层:提供国际化文案,使用命名空间拆分成 action, model,text 三大类。action 包含操作类文案,model 包含数据模型相关文案,text 包含标题、消息和普通文本。国际化文案可直接注入视图层,或者经由 model 作转化处理后注入视图层,后者作为 model 的静态属性注入视图层。

以下内容将通过代码展示部分实现。

3.1. 数据缓存

let caches = {};

class Cache {
  // 设置数据缓存
  setCache(actionName, key, value){
    if ( !caches[actionName] ) caches[actionName] = {};
    caches[actionName][key] = value;
  }

  // 获取数据缓存
  getCache(actionName, key){
    const cache = caches[actionName];
    return cache && key !== undefined ? cache[key] : cache;
  }

  // 清除数据缓存
  clearCache(actionName){
    caches[actionName] = undefined;
  }
}

class CategoryService extends Cache {
  async getCategory(params){
    const { cid, level } = params;

    if ( level !== undefined ) 
      return this.getCategoryByLevel(level);
    else if ( cid !== undefined ) 
      return this.getCategoryByCid(cid);
  }

  // 在 service 层将 getCategory 拆分成多个针对请求的微处理接口
  // 必要时使用缓存数据
  async getCategoryByLevel(level){
    let res = this.getCache('getCategoryByLevel', level);
    if ( res ) return res;

    res = await get('/api/category', { level });
    this.setCache('getCategoryByLevel', level, res);
    return res;
  }

  async getCategoryByCid(cid){
    let res = this.getCache('getCategoryByCid', cid);
    if ( res ) return res;

    res = await get('/api/category', { cid });
    this.setCache('getCategoryByCid', cid, res);
    return res;
  }
}

以上代码,通过 Cache 类实现数据缓存功能,CategoryService 类继承后,将根据请求内容、原型方法名缓存 '/api/category' 接口的响应数据。同时,getCategory 接口也视页面中的调用情况拆分为 getCategoryByLevel, getCategoryByCid 两个原型方法,也许这是画蛇添足的一个举动,既会使代码不够简易,在后台接口变动时,又会增加额外的修改量,不过却暗含着一种可能,利弊交由阅读这篇文章的同学自行判断。

以上代码存在的优化点:

  1. 缓存 key 键的兼容度,Cache 类实现上仅能处理有请求参数的情形,而有些可以作缓存的接口没有请求参数。
  2. 缓存的时效问题,Cache 类的缓存机制在当前访问过程中均有效,在该时间段无法拉取数据库中已作更改的最新值。

3.2. 数据模型

// 可采用继承的方式将远程请求方法混入到 Product 类中,此处使用 mixinStaticProperty 装饰器混入静态方法
@mixinStaticProperty(ProductService)
export default class Product {
  @observable id;// 商品id
  @observable name;// 商品名称
  @observable cids;// 商品分类
  @observable attrValues = {};// 商品属性
  @observable num;// 库存
  @observable price;// 价格
  @observable desc;// 描述
  @observable status;// 状态

  constructor(props){
    this.setValues(props);
  }

  // 后台交互数据全量更新;部分更新可直接使用赋值语句;重置可传空
  @action
  setValues(data = {}){
    this.id = data.id;
    this.name = data.name;
    this.cids = data.cids;
    this.attrValues = data.attrValues || {};
    this.num = data.num;
    this.price = data.price;
    this.desc = data.desc;
    this.status = data.status;
  }

  // 获取后台交互数据
  getValues(){
    return {
      id: this.id,
      name: this.name,
      cids: this.cids,
      attrValues: this.attrValues,
      num: this.num,
      price: this.price,
      desc: this.desc,
      status: this.status
    };
  }

  @action
  async getProduct(id){
    const res = await Product.get({ id });
    if ( res ) this.setValues(res);
    return res || null;
  }

  @action
  async saveProduct(){
    const params = this.getValues();
    const res = params.id ? await Product.update(params) : await Product.save(params);
    return res;
  }

  @action
  async deleteProduct(){
    const res = await Product.del({ id: this.id });
    return res;
  }
}

以上代码为 Product 基类,可以看出,实现上同后台提供的接口和数据模型均强关联,其一通过 observable 装饰器将后台提供的字段全部转化为可观察属性,并提供 setValues, getValues 批量赋值和取值(通常在提交数据前、获取数据后,需要调用这两个方法);其二以原型方法实现 ajax 调用,这里既可以继承 services 层中的类,也可以使用 mixinStaticProperty 装饰器注入静态方法。

Product 基类化用了 backbone 模型特征,可以优化的点:

  1. 代码自动生成。通过 Product 基类也能发现,该类数据模型拥有很高的类同点,基于后台数据模型及接口实现 mobx 数据模型基类,如果能根据 jar 包和配置文件,自动生成这些基类文件,那就再好不过了。当然,这对我来说,也是短期内没法办到的事。需要走的路还长着呢。
  2. 案列中没有考虑两份提交数据,多个接口调用,比如产品状态更新,就需要多一份产品状态提交数据,再调用状态更新的接口。因此,我的一些想法还没经过实践沉淀,合理性和稳定性势必存疑,比如前一条优化建议。
class ProductInDetail extends Product {
  attribute = new Attribute();
  category = new Category();

  @observable categories = [];// 商品分类全量信息
  @observable attributes = [];// 商品属性全量信息

  // 备注,改变单个数组项的属性不会引起视图重绘,必须在数组中改变整个数组项
  // 在 product 实例初始化过程中调用 getCategories 方法,不会引起 Table 视图的重绘
  @action
  async getCategories(cids){
    this.categories = [];
    const res = await this.category.getCategoryByCids(cids);
    if ( res ) this.categories = res;
    return res;
  }

  // 获取商品分类文案
  @computed
  get categoryTexts(){
    return this.categories.map(item => item.name).join(', ');
  }

  // 获取属性
  @action
  async getAttributes(cid){
    this.attributes = [];
    const res = await this.attribute.getAttributes({ cid  });
    if ( res ) this.attributes = res;
    return res;
  }

  @computed
  get attributeTexts(){
    const { attrValues, attributes } = this;
    if ( !Object.keys(attrValues).length || !attributes.length ) return [];

    let result = [];

    Object.keys(attrValues).map(key => {
      const attr = attributes.find(attr => attr.id == key);
      const name = attr.name;
      let value = attrValues[key].map(val => {
        return attr.options.find(item => item.id == val).name;
      });

      result.push({
        name,
        value
      });
    });

    return result;
  }

  // 获取商品状态文案
  @computed
  get statusText(){
    let text = StatusList.filter(item => item.value === this.status)[0].text;

    return text;
  }
}

class ProductInEdit extends Product {
  attribute = new Attribute();

  @observable attributes = [];// 商品属性全量信息

  // 获取属性
  @action
  async getAttributes(cid){
    this.attributes = [];
    const res = await this.attribute.getAttributes({ cid  });
    if ( res ) this.attributes = res;
    return res;
  }

  // 编辑页显示数据
  @computed
  get pageValues(){
    let attrValues = {};
    Object.keys(this.attrValues).map(attrId => {
      attrValues[`attrId${attrId}`] = this.attrValues[attrId];
    });

    return {
      name: this.name,
      cids: this.cids,
      attrs: attrValues,
      num: this.num,
      price: this.price,
      desc: this.desc
    };
  }
}

以上代码基于 Product 类实现详情页、编辑页专用的数据模型,无非获取远程数据,进行 value - name 值转换。在获取远程数据时,可以将另一个数据模型以实例属性的方式注入到当前数据模型中,以便于在当前模型中作数据转换处理,同时增加了模型之间的耦合度。

class ProductList {
  @observable products = [];

  @action
  async getProducts(){
    this.products = [];
    const res = await Product.query();
    (res || []).map(item => {
      this.products.push(new Product(item));// 此处 Product 为 ProductInDetail
    });

    return res;
  }
};

ProductList 类基于 ProductInDetail 实例构建数组项,以便于在视图层绘制列表时直接绘制 ProductInDetail 实例的计算属性或者调用其远程请求接口。

3.3. 并行请求

并行请求可以在视图层通过 Promise.all 加以组织,也可以在模型层组织同一类并行请求接口,如 Category 模型中通过 getCategoryByCids 方法获取产品的多个类目信息。当然,这部分内容通常由后端同学帮忙完成,这里仅展示前端代码实现上的一种可能。

class Category extends CategoryService {
  @observable categories = [];

  @action
  insertToCategories = (category = {}) => {
    if ( !this.categories.some(item => item.id == category.id) ){
      this.categories.push(category);
    };
  }

  async getCategory(params){
    const res = await super.getCategory(params);
    if ( res ){
      // 将多次数据变更合成一个事务,减少重绘的次数
      transaction(() => {
        res.map(item => {
          this.insertToCategories(item);
        });
      });
    };

    return res;
  }

  // 处理并行请求
  getCategoryByCids(cids){
    return new Promise((resolve, reject) => {
      this.categories = [];

      cids.map(async cid => {
        const res = await this.getCategory({ cid });
        if ( !res ) reject(res);

        // 最后一个请求,响应通过 insertToCategories 方法收集到 categories 属性中
        if ( cids.length == this.categories.length ) 
          resolve(this.categories);
      });
    });
  }

  // 传入 cids,便于并行请求
  getCategoryByLevels(cids){
    return new Promise((resolve, reject) => {
      cids.map(async (cid, index) => {
        const res = await this.getCategory({ level: index + 1 });
        if ( !res ) reject(res);

        if ( index + 1 === cids.length ) resolve(this.categories);
      });
    })
  }

  @computed
  get categoriesTree(){
    let tree = [];
    this.categories.toJS().sort((a, b) => a.level - b.level).filter(item => {
      if ( item.level == 1 ){
        tree.push({
          value: item.id,
          label: item.name,
          isLeaf: false
        });
      } else if ( item.level == 2 ){
        let parent = tree.filter(it => it.value == item.parentId)[0];
        if ( !parent.children ) parent.children = [];
        parent.children.push({
          value: item.id,
          label: item.name
        });
      };
    });

    return tree;
  }
}

3.4. 视图组件

组件层即如上文所说的,所需注意的是 mobx 中数组的特殊性,单纯赋值数组项的属性不会引起观察数组的组件重绘,而需要将组件的颗粒度锁定为观察数组项,如下方代码的 CategoryText 组件。删除数组项时,也需要调用代理数组的 splice 方法,才能引起列表组件重绘,如 ProductList 组件内 deleteProduct 方法的实现。

@observer
class CategoryText extends Component{
  componentDidMount(){
    const { product, loadCategory } = this.props;
    if ( loadCategory ) product.getCategories(product.cids);
  }

  render(){
    const { product } = this.props;
    return product.categoryTexts;
  }
};

@inject('productList')
@observer
class ProductList extends Component {
  columns = [{
    title: $i18n('model.product.id'),
    dataIndex: 'id',
    key: 'id'
  }, {
    title: $i18n('model.product.name'),
    dataIndex: 'name',
    key: 'name',
  }, {
    title: $i18n('model.product.categories'),
    dataIndex: 'categories',
    key: 'categories',
    render: (categories, product) => {
      return <CategoryText product={product} loadCategory={true} />
    }
  }, {
    title: $i18n('model.product.price'),
    dataIndex: 'price',
    key: 'price'
  }, {
    title: $i18n('model.product.num'),
    dataIndex: 'num',
    key: 'num'
  }, {
    title: $i18n('model.product.status'),
    dataIndex: 'statusText',
    key: 'statusText'
  }, {
    title: $i18n('model.product.desc'),
    dataIndex: 'desc',
    key: 'desc'
  }, {
    title: $i18n('action.handle'),
    key: 'action',
    render: (text, product, index) => (
      <span>
        <Link to={`/detail/${product.id}`} style={{marginRight: '10px'}}>{$i18n('text.detail')}</Link>
        <Link to={`/edit/${product.id}`} style={{marginRight: '10px'}}>{$i18n('action.edit')}</Link>
        <Popconfirm title={$i18n('text.product.delete_confirm')} 
          onConfirm={() => { this.deleteProduct(product, index) }} 
          okText={$i18n('action.ok')} cancelText={$i18n('action.cancel')}>
          <a href="javascript:;">{$i18n('action.delete')}</a>
        </Popconfirm>
      </span>
    ),
  }];

  componentDidMount(){
    this.props.productList.getProducts();
  }

  // 删除商品
  deleteProduct = async (product, index) => {
    const { products } = this.props.productList;
    const res = await product.deleteProduct();
    if ( res ){
      products.splice(index);
      message.success($i18n('text.product.delete_success'));
    };
  }

  render(){
    const { products } = this.props.productList;

    return (
      <div>
        <Button style={{marginBottom: '15px'}} type='primary'>
          <Link to={'/create'}>{`${$i18n('action.create')}${$i18n('text.product')}`}</Link>
        </Button>
        <Table size="small" rowKey='id' columns={this.columns} dataSource={products.toJS()} />
      </div>
    );
  }
};

更多代码,请参考 mobx-demo,也许阅读这篇文章的同学能有额外的发现呢。

4. 后记

《唐李问对》褒扬诸葛亮而贬低曹操,因为曹操的《孟德新书》适合于照章办事的生手,诸葛亮的《兵法二十篇》适合于独立思考的老手。其中的事理,和吴军博士在《数学之美》中论述道与术一样,浮于浅层的形式抵不过深入的理解。这是一篇富有探索气质的文章,更多地旨在于引发思考,而不是妄下定论。何况这篇文章对于 mobx 的使用及其实现内核的化用,也只是迈出了小小的一两步。要走的路还长着呢。

5. 参考

淘宝开放平台 - 文档中心

淘宝商品数据库设计