如何优雅地创建嵌套对象并传递初值?

1,831 阅读9分钟

如何优雅地创建嵌套对象并传递初值?


一、背景

我们前端开发过程中少不了一项重要的工作,就是与后端对接接口,大部分常规流程简化如下:

  1. 根据需求商定一下接口返回的数据结构;
  2. 前端构建一些接口mock数据;
  3. 定义一个状态字段用于接收接口返回的数据,并基于这个数据结构与视图进行绑定;
  4. 接着编写其它逻辑......

image.png

而在这个过程中我们常常拿着后端返回VO的json直接转换为js对象,然后基于这个结构与视图进行绑定,造成了直接依赖后端接口结构的现状。有些场景下这种模式很不方便。例如:

有一个表单部分字段定义如下:

const formData = {
    name: '',
    email: '',
    age: '',
    jobList: [ // 表单中该项可以动态增减
        {
            companyName:'',
            startDate:'',
            endDate:'',
            ...
        }
    ]
}

该表单支持部分字段暂存提交,待到下一次编辑时,获取到的表单数据中,有值的字段正常返回,那些没有值的字段,后端会怎样返回呢?

答案可能是五花八门的:

  • 有的直接不返回该字段,所以若直接用返回得数据覆盖替换本地时需要注意自行检查补齐缺省字段。
  • 有的会返回字段,但由于没有取到值可能并不是按约定的类型返回,比如约定的Array给返回了Null,以至于在你原本能欢乐使用 Array.map(...) 之类的地方就都得注意了🐶 。
// 上面formData部分保存后,下次获取返回的responseData可能:
const responseData = {
    name: 'foo',
    email: 'foo@163.com',
    // age: '', // 缺失
    jobList: null // 类型不符合预期 []
}

要解决这个问题:

  • 一是,你能大力说服每个与你合作的后端同学,严格按照api约定的字段返回,且补充缺省值比如Array之类的给个[], 而不是null。这显然不现实,且过分依赖后端返回的数据结构,也会导致系统健壮性不足。
  • 二是,增加一层数据处理层,将接到的接口数据处理为自己理想的格式。靠人还是不如自己啊🐶 !

二、思考

要解决上述场景的问题,最好的方式是前端需要维护自己的数据结构,这样

  • 可以设置缺省值
  • 根据缺省值类型 做运行时类型校验,防止接收到异常类型的字段。

接口返回数据只是用于初始化或者更新本地数据。

基于面向对象思想,借助es6 class语法。将上述表单数据model定义如下:

// model 定义
class JobVO {
    companyName = '';
    startDate:'';
    endDate:'';
}
class FormVO {
    name = '';
    email = '';
    age = '';
    jobList = [new JobVO()];
}

空表单数据的初始化方式:

// 初始化表单数据
const formData = new FormVO();

那接下来就剩下更新表单的问题, 也就是如何基于接口数据responseData初始化表单的问题?

三、解决方案

1、最直接的方式:

const formData = new FormVO();
Object.assign(formData, responseData);
const jobList = responseData.jobList.map(item => {
    const job = new JobVO();
    Object.assign(job, item);
    return job;
});
formData.jobList = jobList;

2、尝试做一些优化:

可以借助class的构造函数优化一下model定义:

// JobVO, FormVO 
class XXX {
    constructor(props) {
        Object.assign(this, props);
    }
    // ...
}

这样上述表单初始化过程可变为:

const jobList = responseData.jobList.map(item => new JobVO(item));
const formData = new FormVO({
    ...responseData,
    jobList
});

代码量的确简化了不少。 但这只有一个对象嵌套,如果嵌套的对象较多或层级较深,这个创建过程依然会变得非常繁琐。 所以这就是本文想要重点讨论的话题:如何优雅地创建嵌套对象并传递初始值?

四、如何更优雅

理想很丰满

理想是有那么一个create方法,以如下方式一步到位进行创建

// 不管responseData结构如何,都能一步到位地进行对象创建
const formData = create(FormVO, responseData);

进一步优雅,将create作为FormVO的静态方法

const formData = FormVO.create(responseData);

create创建过程中如何做到属性用相应的对象自动进行创建呢? 需要有地方进行属性与构造对象的映射关系,考虑用一个静态变量进行关联配置。 所以对类定义进一步改造:

class FormVO {
    // classMapping用于配置属性与class的映射关系
    static classMapping = {
        jobList: JobVO // 属性jobList 用JobVO 创建 (JobVO需要在FormVO之前定义)
    }
    static create(props) {
        // TODO: 基于classMapping进行处理
        // ... 
    }
    // ...
}

由于classMapping是静态属性,所以需要注意的点是映射依赖class要定义在当前class之前,否则取到的会是undefined。 那你可能要问:如果两个类互被对方某个属性映射怎么办?文章后面『延伸&拓展』部分会讲到 好了,现在只需要实现create,理想就能变为现实了😄 。

现实并不骨感

考虑到多层对象嵌套的情况,基于继承与封装思想,将create的逻辑放在基类服用,所有model都继承于BaseModel。 create简单实现如下:

class BaseModel {
    // 省略数据校验细节等逻辑
    static create(props) {
        const instance = new this(); // 创建实例
        const needMapProps = Object.keys(this.classMapping);
        const otherProps = omit(props, needMapProps); // omit from lodash
        Object.assign(instance, otherProps); // 其它属性直接赋值
        needMapProps.forEach(key => { // 需要map的属性
            const value = props[key];
            const targetClass = this.classMapping[key]; // 目标Class(也要求是BaseModel的派生类)
            if (Array.isArray(value)) {
                instance[key] = value.map(v => targetClass.create(v)); // 对象数组
            } else {
                instance[key] = value ? targetClass.create(value) : value; // 单个对象
            }
        });
        return instance;
    }
}

五、延伸&拓展

1、缺省值及运行时类型校验

文章开头提到表单问题,前端自定义数据结构,除了需要实现嵌套create,还需要

  • 可以设置缺省值(解决表单字段不全)
  • 根据缺省值类型 做运行时类型校验(解决表单返回字段类型不对)

缺省值在model定义时设置即可,那运行时类型校验怎么做呢? 其实可以这么想,既然某个属性定义缺省值了,那是不是缺省值的类型就是你这个属性想要定义的类型。 所以可以将上面create方法中实例赋值的操作(Object.assign...)改为下述方式。

import {isPlainObject} from 'lodash';
// 修改实例的属性(确保某些重要的数据类型一致才能修改,eg: Array)
const assignPropsWithType = (instance, props) => {
    for (const key in props) {
        const targetValue = props[key];
        if (typeof targetValue !== 'undefined') {
            if (Array.isArray(instance[key])) { // array
                Array.isArray(targetValue) && (instance[key] = targetValue);
            } else if (isPlainObject(instance[key])) { // object
                isPlainObject(targetValue) && (instance[key] = targetValue);
            } else { // other
                instance[key] = targetValue;
            }
        }
    }
});

这样就能确保类中定义的某些重要的数据类型不会被不规范的初始化数据意外篡改。 这个方法可以挂在基类中,派生类实例中都可以用该方法去更新属性值,达到运行时类型保证的效果。

2、class之间存在相互依赖关系如何配置

上面classMapping配置时遗留了一个问题:如果两个类互被对方某个属性映射怎么办?可以用如下方式:映射后置

class A extends BaseModel {}
class B extends BaseModel {
    static classMapping = {
        a: A // A定义在B前 可直接引用
    }
}
A.classMapping = {
    ...A.classMapping, // 注意保留原配置(若有)
    b: B // 等待B定义完毕 延迟配置
};

3、树状结构,类存在自依赖

依然是映射后置即可

class Tree extends BaseModel {
    leftChild;
    rightChild;
}
Tree.classMapping = {
    leftChild: Tree,
    rightChild: Tree
};

可见用这种方式树状结构的数据也能轻松处理,不用外部进行树遍历处理。

4、字段映射

有些时候接口商定了,我们也按照接口字段mock数据开发完页面了,突然后端说接口的某些字段变了,或者后期维护中需求变动导致变化。 常规开发模式下,可能会引起整个页面不同地方产生相应改动。但如果用文章中类定义方式构建前端数据,可以参考classMapping定义一个propsMapping的属性,在create方法中拓展相关处理逻辑。

class UserVO extends BaseModel {
    static propsMapping = {
        userId: 'personId', // this.userId = initData.personId
        email: 'emailAddress'
    }
    userId;
    email;
    // ...
}

这样是不是再也不怕接口突然改字段了!! 而且还有一个优点是可以抽取出一些公共的model,比如用户信息UserVO之类的,这样一些公共的业务组件依赖的数据字段就能固化下来。同一类数据不同接口返回的字段不一致时(别人我们无法改变,我们能做的就是强健自身,更简便地快速兼容😄 ),也能方便在model层映射转换,不用在组件视图层处理。

5、嵌套对象之间字段映射传递

实际业务中,可能出现依赖类中某些字段值来自于当前类中的字段。 可以将上述文中classMapping配置进行拓展, create方法中处理相关逻辑。

class UserVO extends BaseModel {
    userId;
    profileId; // 来自ProfileVO中的id
}
class ProfileVO extends BaseModel {
    static classMapping = {
        userInfo: { // 拓展对象形式的高级配置
            mapClass: UserVO,
            propsTransmit: { // 属性值向下传递
                id: 'profileId', // this.id => userInfo.profileId
            }
        }
    }
    id;
    userInfo;
}

可见高度封装的同时也易于拓展

六、总结

上述这种方式可方便优雅地创建嵌套对象实例,基于这个方法可以建立起一套前端管理Model层的开发模式,那这种开发模式有啥优势呢,适用于哪些场景呢?

优点:

  1. 高度封装,使用方便,灵活易拓展。
  2. 前端开发模式探索,可以类比大多后端语言的mvc架构。前端也可以拿到接口文档后,先定义model,后编写逻辑。开发模式的统一能大大降低团队中开发资源流动成本,提高效率。
  3. 可以方便地配合mobx之类的状态管理工具进行model层的定义。
  4. 配合typeScript,编译时类型校验与运行时类型校验配合,大大降低不必要的bug产生;
  5. 一些强数据结构的开源框架,也是用这种方式架构(如富文本开发框架slate)。

缺点:

1、写法需转变

创建对象的实例时需要使用A.create(initProps) 代替原来的 new A(initProps)方式,会造成一些认知上的成本。

2、习惯与成本

先定义model,后开发逻辑,这种习惯需要慢慢培养和习惯。后端基本都是这种模式。 相比直接用变量接收后端接口的数据,定义一系列model去接收,表面上成本好像提高了不少,但如果考虑维护性、复用性、拓展性等长期效益,绝对不亏。况且model层可以借助swagger to code之类的一些自动化工具进行辅助,从接口文档直接生成代码块,可降低一些成本。

3、多继承问题

es6中 class不支持多继承, 某些场景下可能会不方便,需要自行用mixin方式模拟实现。

// 不支持这种多继承
class K extends N, M {}

// 需要hack实现:
const mixinN = (base) => class N extends base {};
const M {}
class K extends mixinN(M) {} // 模拟多继承

七、免责声明

本文观点为作者本人(丘小羊)基于日常开发经验总结得出,若有不妥之处,欢迎交流指正。