基于 ES6 Proxy 实现接口适配器

922 阅读18分钟

【写在前面】

适配器是什么

本文的适配器是指在前后端交互的接口中,让接口数据适配前端程序,减小接口数据对程序影响,更轻松应对接口的改动。

为什么需要

前后端分离后,接口作为前后通信的主干道,是车🚗祸的高发地段。

  1. 缺少文档
    由于赶时间,很多时候要求前后端同步开发,但是只有原型,后端无法给出确定的接口文档,这时前端只能根据原型进行开发,而开发后拿到具体的接口文档再根据文档进行修改。
  2. 接口改动
    当后端改了接口中一个对象的属性名,前端也需要随之作出大量的代码改动。 因为在前端代码中:
  • 可能多个项目用了这个接口

    • H5,APP,小程序等多平台
    • 或是买家端与卖家端,司机端与乘客端等
  • 可能多个页面调用了这个接口

  • 可能这个属性在一个页面多处使用

  • 可能出于需要把这个对象传到其他页面使用

    这些地方都必须一一找出并修改。
    由此可见,如何以最小的代价应对接口的变化是一个值得思考的问题。 接下来,我们就来解决这一问题。


【基础功能】

🎯需求和目标

用自己定义的属性名代替接口返回的属性名来操作数据
举个例子:
假设后端接口返回的用户数据如下

// 后端传来的数据
const response = {
  useId: '1',
  userName: '小白鼠',
}

前端的数据格式如下

// 这是前端在用的数据命名
const user = {
  id: '1',
  name: '小白鼠',
}

现在我想通过 user.name 取到 response.userName 的值, 并且还想修改 user.name = '大白鼠' 时,实际上是修改response.userName 的值。

🔍需求分析

从宏观上来看,属性的命名由两端各自决定,是主观的,并无规律可循。因此,对两端命名之间对应关系的描述,是实现需求必不可少的要素。

对应关系的描述,就是一座桥梁,对接海峡两岸,这座桥梁我称之为 适配器 (Adapter)。

对应到实际需求上,就是这样:

user 适配器 Adapter response
id <= 转换 => userId
name <= 转换 => userName

适配器模式介绍

先举个生活中的例子来理解适配器:

我手机的充电孔跟耳机插孔是共用的,所以它不是圆⚪的,而是扁的 ▋,这就导致一般的 ● 头耳机无处可插,只能用手机配套的 ▋头耳机。
好在手机制造商还是有良心的,就配了一个耳机转接线,这个帮 ● 头耳机适配 ▋孔手机的转接线,就是适配器。

这里粘贴一段百度百科适配器模式词条的简介

在计算机编程中,适配器模式(有时候也称包装样式或者包装)将一个类的接口适配成用户所期待的。一个适配允许通常因为接口不兼容而不能在一起工作的类工作在一起,做法是将类自己的接口包裹在一个已存在的类中。

虽然js里面没有那么浓的的概念,不过核心思想是一致的。

🛠实现功能

结构确定

根据适配器模式的设计理念,可得

Adaptee + Adapter --> Target
源角色 + 适配器 --> 目标角色
接口数据 + 适配器 --> 包装好的数据

转化成代码,就是写一个方法,传入接口数据和适配器,返回包装好的数据。

核心API

天冷了,大家都开始吃火锅。相比于先做好一桌子菜再开吃,火锅这种边煮边吃的模式更受大家欢迎。
一来,吃饭的人多时,做好一整桌菜费时费力,火锅只需要提前处理好食材即可;
二来,吃得慢的时候,菜容易凉掉,而火锅则不会。

得益于火锅的设计思想,我饿了
我放弃了事先把数据处理好,完事后再转回去的愚蠢想法,
也放弃了把所有数据都先Object.defineProperty()的思路,
选择了动态且高性能的 ES6Proxy

一来,事先处理好数据需要遍历+递归,遇到多层的长列表时效率低下,Proxy只在用到的时候才处理;
二来,即使事先处理好了所有数据,面对数据的新增或修改,应对起来也十分不便,而 Proxy则比较智能。

这也是为什么 Vue 3.0 抛弃Object.defineProperty()而采用Proxy的原因之一。

本文重在阐述想法和设计,基础的用法就不啰嗦了。不知道 Proxy 用法的同学,可以去看阮一峰的 ECMAScript 6 入门,顺便把对应的Reflect也看了,本文中只用到最简单的setget

包装方法结构

综上所述,在本例中包装方法结构如下

/**
 * 包装
 * @param {object} adaptee 源角色,被适配者
 * @param {object} adapter 适配器
 * @return {proxy} 一个包装后的对象
 */
function wrap(adaptee, adapter) {
  // Proxy 包装数据
}

适配器的格式

定义适配器的格式,左边是前端的属性名,右边是后端接口返回的属性名

// 适配器 Adapter
const adapter = {
  // 前端用: 后端用
  id: 'useId',
  name: 'userName',
}

可以根据实际情况定义两端属性名的对应关系,若两端相同则省略不写。

为了方便,我们用 ClientServer 的首字母来表示前端和后端
定义 C名 = 适配器中前端用来访问对象的属性名
定义 S名 = 适配器中后端接口返回对象的属性名

现在我们来实现wrap方法, 核心思路

  1. 拦截C名对于对象的存取操作
  2. 用对象本身的S名替换C名
  3. S名继续原本的存取操作

代码实现

function wrap(adaptee, adapter) {
  // 合法性校验
  if (!adaptee || typeof adaptee !== 'object' || !adapter) {
    return adaptee
  }

  return new Proxy(adaptee, {
    // 拦截取值操作 target.prop
    get(target, prop, receiver) {
      // 只处理用户在适配器中定义过的属性
      if (adapter.hasOwnProperty(prop)) {
        // 取出源目标的属性替换取值属性
        prop = adapter[prop] 
      }
      return Reflect.get(target, prop, receiver)
    },

    // 拦截赋值操作 target.prop = value
    set(target, prop, value, receiver) {
      if (adapter.hasOwnProperty(prop)) {
        prop = adapter[prop]
      }
      return Reflect.set(target, prop, value, receiver)
    }
  })

  /* Reflect说明:由于Proxy拦截并修改了对象的
    行为,所以为了保险起见,用Reflect对应方法
    来执行js语言原本的默认行为 */
}

简单测一下

const target = wrap(response, adapter)
console.log(target.name, target.userName)
// '小白鼠' '小白鼠'
target.name = '阿白'
console.log(target.name, target.userName)
// '阿白' '阿白'

👌,无论是取值还是赋值,都符合我们预期,第一步的目标实现了。 接下来是第二个目标。


【多层适配】

👁‍🗨发现问题

上面实现的功能只能对单层的对象生效,而很多情况下,对象里面还有对象,就像俄罗斯套娃一样。
如果用户想让对象内部的对象也具有同样的功能,就必须再次手动调用wrap(),这显然不合理,所以应该化手动为自动

🎯需求和目标

实现适配任意层级的对象。例如下面的数据,需要对selfInfo对象的phoneNumuserPass属性进行适配。

const response = {
  useId: '1',
  userName: '小白鼠',
  selfInfo: {
    phoneNum: 18888888888,
    userPass: 'awsl120120'
  },
}

🔍需求分析

启程前选择了不同的方向,就注定是要走上不同的路。
传统的做法是遍历加递归,而选择了Proxy,就可以走一步看一步。之前有提到化手动为自动,那么只要代替用户来调用wrap()就行了。
那么要解决的问题就是如何在适配器描述自己内部对象的适配方式。

适配器的格式

要保留属性映射的S名,又要在内部描述一个子适配器,字符串显然无法满足2者并存的需求,所以改成对象。
name属性储存S名,用属性adapter储存内部适配器,结构如下:

const adapter = {
  id: 'useId',
  name: 'userName',
  info: {
    name: 'selfInfo',
    adapter: {
      phone: 'phoneNum', // 字符串写法
      password: {
        name: 'userPass' // 对象写法
      }
    }
  },
}

为了方便使用,必须兼容原本字符串的写法。

🛠实现功能

递归适配

为了可读性和复用,把替换属性的代码抽离成单独的函数getProp(),它返回最终操作对象的属性,不过在实现这个函数之前,先实现整体的方法。
核心思路是如果适配器内部有子适配器,就用取到的值+子适配器生成包装后的代理对象再返回。

function wrap(adaptee, adapter) {
  if (!adaptee || typeof adaptee !== 'object' || !adapter) {
    return adaptee
  }

  return new Proxy(adaptee, {
    get(target, prop, receiver) {
      const finalProp = getProp(prop, adapter)
      const value = Reflect.get(target, finalProp, receiver)

      // 尝试取 adapter,没取到是 undefined
      const { adapter: childAdapter } = adapter[prop] || {}

      /* 无论 childAdapter 是否有值,
        都传入 wrap() 方法重新调用一次,
        判断交给写在 wrap() 开头的条件 */
      return wrap(value, childAdapter)
    },

    set(target, prop, value, receiver) {
      const finalProp = getProp(prop, adapter)
      return Reflect.set(target, finalProp, value, receiver)
    }
  })
}

看到这里,部分读者肯定在想,上当了!上当了!wrap()里面调用wrap(), 这不还是递归嘛,你刚刚还嘲笑Object.defineProperty()效率低。
别着急,虽然这里也是递归,但关键是每次调用wrap(),内部wrap()最多只会运行一次,因为内部的wrap()是写在get方法里,所以只有当这个属性被访问时才会触发,触发时才去计算,这就是性能提升的关键。

大家都听过薛定谔的猫,故事的背后研究的是量子力学的一种设想:
当一个量子没有被观测时,它的状态没有被确定,就好像一个变量没有被访问时,它的值没有被确定,既可能是trueCat,也可能是falseCat,此时它处于两种状态的叠加态。
当这个量子或者变量被观测的时候,才呈现出具体状态。
这样的设计可以在这种量子数量非常庞大的情况下,可以大大提高性能。或许这就是我们生活的世界为什么不会卡的原因。

获取属性方法 getProp()

适配增加了对象类型后情况分3种:

  1. 没有定义适配属性,用原来的
  2. 定义了适配属性,是string类型(数组可以是number),直接用
  3. 定义了适配对象,取对象的name属性,若没取就用原来的
/**
 * 获取最终操作对象的属性
 * @param { String } property 取值的属性
 * @param { Object } adapter 适配器
 */
function getProp(property, adapter) {
  let prop = property
  if (adapter.hasOwnProperty(property)) {
    prop = adapter[property]
  }

  const map = {
    'number': prop, // 数组下标
    'string': prop,
    'object': prop.hasOwnProperty('name') ?
      prop.name : 
      property // 没有指定name还是用默认的
  }
  return map[typeof prop]
}

简单测一下

const target = wrap(response, adapter)
const { info } = target
console.log(info.phone, info.password)
// '18888888888' 'awsl120120'
info.password = 'awsl886'
console.log(target.selfInfo.userPass)
// 'awsl886'

没毛病,又搞定一步👏👏👏


【数组特殊处理】

👁‍🗨发现问题

众所周知,数组也是对象,那我连对象都搞定了,还要处理啥数组呢?
先假设一个具体场景,现在有一个对象数组,需要对内部的所有对象都进行适配,对象的结构都长得一样,用户该怎么写适配器的格式?

adapter: {
  0: {
    adapter: {}
  },
  1: {
    adapter: {}
  },
  // ...
}

这么写看起来就很傻,肯定是不行的,那换成数组?

adapter: [
  { adapter: {}},
  { adapter: {}},
  // ...
]

看起来有点意思,好像可以通过.fill()来生成数组。
然而数组还存在另一个方面的问题,就是它的长度往往是动态的,上面的写法仅仅定义了数组初始状态的适配方式,一旦数组添加新元素变长了就废了。
所以数组比起来对象变得不可控,需要特殊处理。

🎯需求和目标

我们在上面的例子中的response加一个属性,是个对象数组

// 后端传来的数据
const response = {
  // ...省略
  friendList: [
    {
      userId: '002',
      userName: '小黑鼠',
      friendTag: '表面兄弟',
      moreInfo: { nickName: '黑黑' }
    },
    {
      userId: '003',
      userName: '小绿鼠',
      friendTag: '塑料姐妹',
      moreInfo: { nickName: '绿绿' }
    },
  ]
}

现在的目标是设计一个合理且友好的方式,让用户可以只写一次数组中对象的适配器,就能对整个数组生效,包括对数组动态添加的对象。

🔍需求分析

首先,毋庸置疑的是,对象的适配器还是那个适配器,不需要另作考虑。要解决的是如何让数组操作内部任意对象时,都使用这个适配器。

方案1. 通配符

约定用通配符*匹配所有对象属性名,当然也包括数组下标

const adapter = {
  // ... 省略
  friendList: {
    adapter: {
      '*': {
        adapter: {
          tag: 'friendTag',
          moreInfo: {
            nick: 'nickName'
          }
        }
      }
    }
  }
}

方案2. 自动深入

数组那一层直接写对象的适配器,在程序中实现自动深入

const adapter = {
  // ... 省略
  friendList: {
    adapter: {
      tag: 'friendTag',
      moreInfo: {
        nick: 'nickName'
      }
    }
  }
}

🛠实现功能

通配符方案-分析

这个方案很简单,只需要在取子适配器的时候,把*作为备胎就行

通配符方案-代码实现

原来的代码

const { adapter: childAdapter } = adapter[prop] || {}

改造一下

let { adapter: childAdapter } = adapter[prop] || adapter['*'] || {}

锵锵!搞定。

自动深入方案-分析

目标只有一个,数组中所有子元素对象的适配器都指向数组自己的适配器。 方法有很多,这里在通配符方案的基础上,实现一种。

自动深入方案-代码实现

let { adapter: childAdapter } = adapter[prop] || adapter['*'] || {}
if (
  Array.isArray(value) &&
  !childAdapter.hasOwnProperty('*') // 用户可能已经写了
) {
  // 给自己包一层
  childAdapter = {
    '*': { adapter: childAdapter }
  }
}

childAdapter给自己包上一层,然后跟据之前写的逻辑,会将其传入

return wrap(value, childAdapter)

并且在下次在执行到adapter['*']的时候,会自动把这层给剥掉,把包裹的适配器取出来。
这一包一剥之间,虽然数据往里面走了一层,但是适配器依旧是原来的适配器。


【值的转换】

👁‍🗨发现问题

上面的适配器解决了两端对于属性命名不同的问题,现在我们把格局放大,重新来看待接口,会发现两端的问题可以分为3个方面:

  1. 数据结构的不同
  2. 属性命名的不同
  3. 值表示的不同

数据结构的不同

这里说的数据结构指的是数据的整体结构,举个例子:
其中一端数据的结构👇

const data = {
  dogs: [{ id: 1 }, { id: 2 }],
  cats: [{ id: 3 }, { id: 4 }],
  mouses: [{ id: 5 }]
}

另一端数据的结构👇

const data = [
  { id: 1, type: 'dog' },
  { id: 2, type: 'dog' },
  { id: 3, type: 'cat' },
  { id: 4, type: 'cat' },
  { id: 5, type: 'mouse' },
]

对于这种大方面的结构的差别,强行去适配显然不合适,根据实际情况单独写一个适配方法会是更好的选择。

属性命名的不同

属性命名的不同就是我们之前一直在努力去解决的问题,而且在解决方式可用的前提,就是数据的结构必须相同。

值表示的不同

什么是值表示的不同?此处的含义,是指表示的意思相同,但是表达不同。就像mouse老鼠耗子都是指这货👉🐀。
再举个大家实际应用的例子:
后端由于面向数据库,为了减小存储空间,用整型 01 分别表示女性和男性。
前端由于面向用户,为了能让用户看懂,用的是字符串
除了性别外,常见的还有类型状态等,对于这类问题,接下来我们就来解决它。

🎯需求和目标

在取值和赋值时,自动把一段对于值的表示转换成另一端对于该值的表示。
除了适配上述的性别这种固定的对应关系外,另一个场景需求也一并做了,那就是适配对于时间表示的不同。

🔍需求分析

简单枚举

类似性别这种一一对应的映射关系,我们接下来就称之为枚举。 对于这类简单枚举,用一个对象就能表示映射关系

const enu = {
  '0': '♀',
  '1': '♂',
}

取值和赋值时就通过这个映射关系来转换

复杂计算

相比于简单枚举,复杂计算就无法用静态数据来表示对应之间的映射关系。
比如时间,一端是时间戳,另一端是时间字符串文本。
再比如一段存数组用的是2,3,3,3这种字符串来存,而另一端则比如转换成数组[2, 3, 3, 3]才能使用。
总之,这种复杂计算变化无常,不能用简单枚举的方法来解决,必须完全交给用户自己来实现。所以,取值和赋值的操作,分别提供两个钩子函数来处理。

🛠实现功能

适配器的格式

首先需要在适配器中再扩展一个属性convert(转换),用来描述值之间的转换

const adapter: {
  name: '', // 转换名
  adapter: {}, // 子适配器
  convert: {} // 转换关系,是一个对象
}

convert允许2种格式:

  1. 用于简单枚举
convert: {
  '0': '♀',
  '1': '♂'
}
  1. 用于复杂计算
convert: {
  // 取值触发,val 是处理前的值
  get(val) {
    return xxx // xxx是处理好取到的值
  },

  // 赋值触发,val 是处理前的值
  set(val) {
    return xxx // xxx是处理好赋予的值
  }
}

添加处理函数

分别在取值和赋值之前,添加对值进行处理的函数getValue()setValue(), 为了方便查看在旧代码中的位置,我用 // +++ 包起来了。

function wrap(adaptee, adapter) {
  if (!adaptee || typeof adaptee !== 'object' || !adapter) {
    return adaptee
  }

  return new Proxy(adaptee, {
    get(target, prop, receiver) {
      const finalProp = getProp(prop, adapter)
      let value = Reflect.get(target, finalProp, receiver)
      let { adapter: childAdapter } = adapter[prop] || adapter['*'] || {}
      if (
        Array.isArray(value) &&
        !childAdapter.hasOwnProperty('*')
      ) {
        childAdapter = {
          '*': { adapter: childAdapter }
        }
      }
      // +++++++++++++++++++++++++++++++++++
      value = getValue(value, adapter[prop])
      // +++++++++++++++++++++++++++++++++++
      return wrap(value, childAdapter)
    },

    set(target, prop, value, receiver) {
      const finalProp = getProp(prop, adapter)
      // +++++++++++++++++++++++++++++++++++
      value = setValue(value, adapter[prop])
      // +++++++++++++++++++++++++++++++++++
      return Reflect.set(target, finalProp, value, receiver)
    }
  })
}

getValue()setValue()

两个函数里面的逻辑几乎一模一样,为了偷懒不写两遍,我用一个方法来生成

/**
 * 生成 get/set 处理函数
 * @param {String} getOrSet 'get'或'set'
 * @return {Function} 处理函数
 */
function genValueFn(getOrSet) {
  /**
   * 取值/赋值处理函数 
   * @param {any} value 处理前的值
   * @param {Object} options 配置对象 { name, adapter, convert }
   * @return {any} 处理后的值
   */
  return function(value, options) {
    // todo
    return value
  }
}
const getValue = genValueFn('get')
const setValue = genValueFn('set')

处理函数的逻辑

两种写法都需要支持,并且还是共用同一个对象,为了防止冲突,就必须给出规定:
如果convert对象中存在setget至少一个方法,那就认为处理复杂计算,就使用getset来处理,
否则就认为是处理简单枚举,convert对象是一个枚举对象。

function genValueFn(xet) {
  return function(value, options) {
    if (options && options.convert) {
      const { get, set } = options.convert
      const isFn = {
        get: typeof get === 'function',
        set: typeof set === 'function'     
      }
      if (isFn.get || isFn.set) { // 有get或set方法
        if (isFn[xet]) { // 可能只有get或set其中之一
          /*允许用户把`get/set`方法所依
            赖的数据存在`convert`对象中,
            因此有必要保证`xet`在执行时,
            `this`指向`convert` */
          value = options.convert[xet](value)
        }
      } else { // convert是一个枚举对象
        // 使用前先进行双向映射,getEnum的实现在下面
        const enu = getEnum(options.convert)
        value = enu[value]
      }
    }
    return value
  }
}

反向映射与getEnum()

由于是JavaScript而非TypeScript,因此并没有emnu可以用😂,所以要实现一下反向映射。
一般情况的正向映射,在取值的时候适用

const enu = {
  '0': '♀',
  '1': '♂'
}

还需要一种反向映射,在赋值的时候使用

const enu = {
  '♀': '0',
  '♂': '1'
}

无论用户写了哪一个方向的映射,我们都将其转换成双向映射使用。

/**
 * 获取双向映射的枚举对象
 * @param {Ojbect} map 单向映射关系
 * @return {Object} 双向映射关系
 */
function bidirectional(map) {
  return Object.keys(map).reduce((enu, key) => {
    enu[enu[map[key]] = key] = map[key]
    return enu
  }, Object.create(null))
}

优化

正常情况下,每次赋值前都执行bidirectional(),进行一次转换成双向映射的操作。考虑到转换双向映射只需要一次就行,并且遍历对象的成本还是挺高的,有必要做一些优化,这里将转换过的对象缓存起来。

bidirectional()外面再包一层,如果缓存对象中有,就直接取出返回,没有才去执行bidirectional(),并且把结果存入缓存对象中

const cache = new WeakMap()
function getEnum(map) {
  let enu = cache.get(map)
  if (!enu) {
    enu = bidirectional(map)
    cache.set(map, enu)
  }
  return enu
}

简单测一下

后端传来的数据

const response = {
  userSex: '0',
  time: '1577531507563',
}

适配器

const adapter = {
  sex: {
    name: 'userSex',
    convert: {
      '0': '♀',
      '1': '♂', 
    }
  },
  time: {
    convert: {
      get: stamp2Str,
      set: str2Stamp
    }
  }
}
// 时间戳转时间文本
function stamp2Str(stamp) {
  stamp = Number.parseInt(stamp)
  return new Date(stamp).toLocaleDateString() + ' ' 
    + new Date(stamp).toTimeString().slice(0, 8)
}
// 时间文本转时间戳
function str2Stamp(str) {
  return + new Date(str)
}

测试

const target = wrap(response, adapter)
console.log(target.sex, target.time)
// ♀  2019-12-28 19:11:47
target.sex = '♂'
target.time = '2019-12-28 20:11:47'
console.log(target)
// { userSex: '1', time: 1577535107000 }

搞定😁

【写在最后】

大家有什么想法或者什么建议,都可以在评论区进行交流😁。