你不知道的前端 MVVM 模式中的数据层(万字长文,教你造轮子)

共 77841字,需浏览 156分钟

 ·

2020-11-20 02:04


本文目录

  • 引言

  • 前端工程中的 Model 需求和解决

  • 需求 1:API 请求方式的统一封装

  • 需求 2:接口的复用

  • Model 实现

  • 子 Model 实现

  • 需求 3:安全提取数据

  • 需求 4:统一的 Model 返回格式

  • 需求 5:统一错误提示

  • 需求 6:接口监控

    • 监控 1:错误监控

    • 监控 2:超时监控

    • 监控 3:网络异常监控

  • 需求 7:mock 数据管理

  • 需求 8:更简单使用 Model--提供 CLI 支持

  • 需求 9:接口缓存

  • 需求 10:多接口聚合 Model

  • 需求 11:聚合 Model 的可视化

  • 需求 12:Model 对接 GraphQL

  • 需求 13:Model 结合 WebSQL 实现前端数据管理


引言

当今时代,React、Vue、AngularJS 三大框架横行,我们很难争论出哪个是最好的框架,但三者共同点是 MVVM 的模式,用一张简单的图可以看到,MVVM 模式最出色的是 ViewModel 层,ViewModel 帮我们摆脱了麻烦的 DOM 操作,相比 MVC 模式有了质的飞跃。

image

然而本文想探讨的不是 ViewModel,而是当前最被前端开发者忽视的 Model。

Model 在 MVC 与 MVVM 模式中都应该定义为数据层,理论上应该把所有跟数据相关的操作都抽取到这一层,但以笔者的经验来看,目前前端开发者在 Model 层花的精力较少,原因可能有以下几个方面:

  • 前端工程里面的数据操作相对简单,基本都是以 API 调用为主,主要使用后端已经基本处理好的数据
  • 前端业务数据处理相对简单,在 ViewModel 层面进行处理就能满足需求
  • 前端开发者普遍缺乏数据库操作经验和对数据管理方面的意识

我们再看下后端语言中的 Model 层是什么样,以 PHP 圈内流行的 ThinkPhp 框架为例,这是一个用户模型:

class User extends Model
{
  /**
   * 查询用户信息
   */
  public function getUserInfo($uid)
  {
  }
  /**
   * 查询用户等级
   */
  public function getUserLevel($uid)
  {
  }
  /**
   * 查询是否被锁定
   */
  public function checkLockState($uid)
  {
  }
  /**
   * 查询用户订单列表
   */
  public function getUserOrderList($uid)
  {
  }
  /**
   * 查询点赞列表
   */
  public function getUserLikeList($uid)
  {
  }
  /**
   * 查询用户好友列表
   */
  public function getUserFriendList($uid)
  {
  }
  //......其他操作
}

这段代码省略了其他更多方法和类的继承,实际上会把涉及到用户相关的所有增删改查操作都抽取到一个数据模型当中,在 Control 层只使用 Model 提供的各种方法操作数据,而不会在 Control 层里面再做 SQL 查询。

前端工程中的 Model 需求和解决

在前端工程其实有很多数据抽取的需求,以笔者所负责的一个工程(Vue 项目)来举例,随着业务发展,工程里面代码里膨胀非常迅速,在不同阶段会面临着不同的问题,随着问题的逐渐解决,逐渐形成了一套 Model 体系,接下来我们将按照问题时间线来具体看:

需求 1:API 请求方式的统一封装

这个需求很简单,我们不推荐直接使用一些类库进行请求,因为这样完全不利于统一维护,举例:

//Bad case
import axios from 'axios'

axios.get('/user?ID=12345')
  .then(function (response) {
    //...
  })
  .catch(function (error) {
    //...
  });

//Good case
//main.js
import axios from 'axios'
Vue.prototype.$http = axios

//demo.vue
this.$http.get('/user?ID=12345')
  .then((response)=>{
    //...
  })
  .catch((error)=>{
    //...
  });

我们将所有请求封装到$http 当中后可以进行二次封装,做一些改造或数据拦截等操作,这里暂不赘述。

需求 2:接口的复用

我们经常会遇到这样的情况,有些接口经常被不同页面重复使用,而接口数据需要进行一些处理,每次使用这些接口时候都要处理一遍,导致代码冗余,举个例子:

//goods.vue
this.$http.get('/getGoodsDetail?infoId=12345')
  .then((response)=>{
    //判断接口请求成功
    if(response && response.respCode == 0 && response.respData){
        //从接口中提取头图
        this.headPic = response.respData.pics.split('|')[0]
        //将价格由分转换成元
        this.nowPrice = response.respData.nowPrice/100
        //是否包邮
        this.isFreePostage = response.respData.goodsTag.freePostage
        //...其他数据处理逻辑
    }else {
        //错误提示
        toast(response.errorMsg || response.errMsg || '接口错误')
    }
  })
  .catch((error)=>{
    //...
  });

获取商品详情是很多页面都有的需求,每个页面都需要处理以下几个问题:

  1. 接口请求成功的判断(业务层面)
  2. 接口请求失败的错误信息处理(不同接口可能返回的错误信息字段不一致)
  3. 接口数据的统一处理:提取头图、分转元、是否包邮等
  4. 接口数据提取不安全:比如 response.respData.goodsTag.freePostage 这句代码有很高的风险,因为接口当中很可能没有 goodsTag 字段导致报错,常规的安全读取策略可能是下文的多次判断,会导致代码冗余度增加:
this.isFreePostage = response.respData.goodsTag && response.respData.goodsTag.freePostage

基于以上问题,我们一般可能会将商品数据的处理逻辑封装成一个方法进行复用,比如:

const isRequestSuccess = (res)=>{
    return res.respCode == 0 && res.respData
}
const getErrorMsg = (res)=>{
    return res.errorMsg || res.errMsg || '接口错误'
}
const convertDetailData = (res)=>{
    let data = res.respData
    return {
        headPic : data.pics.split('|')[0],
        nowPrice : data.nowPrice/100,
        isFreePostage : data.goodsTag && data.goodsTag.freePostage
    }
}

这样的封装确实能够解决眼前的问题,但伴随着复杂度增加,该处理方法会变得更加难以维护;当有其他类似场景出现,比如 user 接口也需要上述各种处理时,处理逻辑会变得更加多样性,因为你无法预测其他开发者会进行怎么样的封装,当你复用其他开发者代码时会带来更多成本。

因此我们从这里开始抽取出数据层(Model)。

Model 实现

首先我们明确期望:

  • Model 是统一结构的
  • Model 是可以被复用或继承的
  • Model 当中可以预处理好所有数据逻辑,开发者可以直接使用数据而无需关注处理过程
  • Model 应该有清晰的成功失败判定逻辑
  • Model 应该提供安全的获取数据的逻辑

我们先贴出 Model 的代码设计:

//Model.js
import Axios from '../libs/Axios'
export default class Model {
  /**
   * 初始化配置操作
   */
  constructor(options) {
    
  }
  /**
   * 数据获取方法
   */
  async fetch(options) {
    
  }
  /**
   * 配置方法
   */
  config(options) {
    
  }
  /**
   * 判断成功失败逻辑
   */
  isSuccess(result) {
    
  }
  /**
   * 数据处理逻辑
   */
  handleData(result) {
    
  }
  /**
   * 错误处理逻辑
   */
  handleError(result) {
    
  }
  /**
   * 重置请求结果
   */
  resetResult() {
    
  }
}

我们采用 class 的封装模式,定义一个基类,提供了配置、请求、判定成功失败、数据处理逻辑、错误处理、安全读取数据方法,这些方法可以被继承,从而实现不同接口的个性处理,接下来我们填入相关代码:

import Axios from '../libs/Axios'
export default class Model {
  /**
   * 初始化配置操作
   */
  constructor(options) {
    this.resetResult()
    this.domain = 'https://app.zhuanzhuan.com'
    this.defaultType = 'GET'
    this.options = {}
    if (options) this.config(options)
  }
  /**
   * 数据获取方法
   */
  async fetch(options) {
    return new Promise(resolve => {
      Axios[this.options.type](
        this.options.url,
        options
      )
      .then(res => {
        this.resetResult()

        let result = res && res.data
        this.result.state = this.isSuccess(result) ? 'success' : 'fail'
        if (this.result.state == 'success') {
          this.result.data = this.handleData(result)
          resolve(this)
        } else {
          this.result.error = this.handleError(result)
          resolve(this)
        }
      })
      .catch(res => {
        this.resetResult()
        this.result.error = {
          errorCode: -9999,
          errorMsg: '网络错误'
        }
        resolve(this)
      })
    })
  }
  /**
   * 配置方法
   */
  config(options) {
    this.options = Object.assign(
      { type: this.defaultType },
      this.options,
      options
    )
    if (this.options.type) this.options.type = this.options.type.toLowerCase()

    return this
  }
  /**
   * 判断成功失败逻辑
   */
  isSuccess(result) {
    return parseInt(result.respCode) === 0 && result.respData
  }
  /**
   * 数据处理逻辑
   */
  handleData(result) {
    return result.respData
  }
  /**
   * 错误处理逻辑
   */
  handleError(result) {
    return {
      errorCode: result.respCode,
      errorMsg:
        result.errorMsg || 
        result.errMsg || 
        (result.respData
          ? result.respData.errorMsg || result.respData.errMsg
          : '')
    }
  }
  /**
   * 重置请求结果
   */
  resetResult() {
    this.result = {
      state: null,
      data: null,
      error: null
    }
  }
}

上面是我们定义的基类,接下来我们看开发者如何使用:

规范目录结构

首先我们要规范 Model 的目录结构:

├── project
│   ├──src
│   │   └── View  //模板层
│   │   └── Model //数据层
│   │   │   └── Model.js  //基类
│   │   │   └── apiPath1 //接口路径
│   │   │   │   └── GetGoodsDetail.js  //商品详情接口 model 的定义

接口路径是我们定义的接口分类划分依据,我司不同部门提供的接口通过 apiPath 进行划分,因此我们也以此作为分类

子 Model 实现

接下来我们看 GetGoodsDetail.js 是如何实现的

import Model from '../Model.js'
class GetGoodsDetail extends Model {
    constructor(){
      super()
      this.config({
        url : '/apiPath1/getGoodsDetail',
        type'get'
      })
    }
    handleData(result){
        let data = result.respData
        //从接口中提取头图
        data.headPic = data.pics.split('|')[0]
        //将价格由分转换成元
        data.nowPrice = data.nowPrice/100
        //是否包邮
        data.isFreePostage = data.goodsTag && data.goodsTag.freePostage
        //将处理好的数据返回
        return data
    }
    isSuccess(result){
        return parseInt(result.respCode) === 0 
                && result.respData 
                //特殊逻辑:认为商品状态正常的请求才算成功
                && result.respData.goodsState == 0
    }
}

当页面使用该 Model 时:

//demo.vue
import GetGoodsDetail from '@/model/apiPath1/GetGoodsDetail'

export default {
    //.....
    methods : {
        async getGoodsDetail(){
            let res = await (new GetGoodsDetail()).fetch({infoId:'123456'})
            if(res.result.state == 'success'){
                //单独提取数据
                this.headPic = res.data.headPic
                this.nowPrice = res.data.nowPrice
                this.isFreePostage = res.data.isFreePostage
                //解构方式提取数据
                let { headPic,nowPrice,isFreePostage } = res.data
            }else {
                //错误提示
                toast(res.result.error.errorMsg )
            }
        }
    }
    
}

需求 3:安全提取数据

在业务开发当中,前端和后端通常会约定好接口数据返回格式,比如我们约定:

{
    respCode:0,
    respData:{
        //....其他数据
        goodsTag : {
            //...其他数据
            freePostage : 1
        }
    }
}

我们约定数据中必须有 goodsTag,其中必须有是否包邮字段 freePostage,但约定很美好,现实很“脆弱”,你不能相信服务端接口一定会给你返回约定格式的数据,因为复杂的后端构架、复杂的数据结构和状态等因素会有可能出现我们不期望的格式,比如我们的错误监控平台经常会捕获到这种类型错误:

image

这种错误都是因为数据没有按照约定格式返回导致的,因此我们如果要确保程序不报错,就需要进行严格的判断:

let freePostage = res.respData && res.respData.goodsTag && res.respData.goodsTag.freePostage

是不是写起来超级麻烦,那么我们需要给 Model 添加一个安全获取数据的方法:

//Model.js

export default class Model {
    //...
  /**
   * 安全读取数据逻辑
   */
  get() {
    if (this.result && this.result.data) {
      return (
        (!Array.isArray(path)
          ? path
              .replace(/\[/g, '.')
              .replace(/\]/g, '')
              .split('.')
          : path
        ).reduce((o, k) => (o || {})[k], this.result.data) || defaultValue
      )
    } else {
      return defaultValue
    }
  }
}

这样的话我们再使用数据时可以根据 path 进行安全提取,例如:

//demo.vue
import GetGoodsDetail from '@/model/apiPath1/GetGoodsDetail'

export default {
    //.....
    methods : {
        async getGoodsDetail(){
            let res = await (new GetGoodsDetail()).fetch({infoId:'123456'})
            if(res.result.state == 'success'){
                //安全提取数据
                this.headPic = res.get('headPic')
                this.nowPrice = res.get('nowPrice',0)
                this.isFreePostage = res.get('goodsTag.isFreePostage',false)
                //甚至可以进行更复杂的数据提取
                this.firstGoodsCommentAuthor = res.get('goodsComments[0].author','匿名')
            }else {
                //错误提示
                toast(res.result.error.errorMsg )
            }
        }
    }
    
}

需求 4:统一的 Model 返回格式

上面已经有了 Model 的雏形,每一个 Model 都会返回如下数据结构:

{
    result : {
      state:null, // 'success'请求成功 'fail'请求失败
      data:null,  // 处理好的数据
      error:{     //错误信息
          errorCode:xxx, //错误代码,其中-9999 为网络错误
          errorMsg:xxx   //具体错误信息
      },
  }
}

开发者使用时可以信赖 Model 返回结果,比如在请求成功的认定时,开发者无需再考虑返回状态码是多少,商品状态是什么,当返回 state == 'success'时,则可以认定是符合业务定义的请求成功状态。

需求 5:统一错误提示

上述 Model 还可以做进一步简化,比如我们期望在 GetGoodsDetail 模型层面或者所有接口上面针对错误直接进行 toast 提示,那我们可以改造 Model 类:

export default class Model {
  //....
   
  /**
   * 错误处理逻辑
   */
  handleError(result) {
    let errorObj = {
      errorCode: result.respCode,
      errorMsg:
        result.errorMsg || 
        result.errMsg || 
        (result.respData
          ? result.respData.errorMsg || result.respData.errMsg
          : '')
    }
    //在遇到接口错误时候直接 toast
    toast(errorObj.errorMsg)
    
    return errorObj
  }
}

//如果你不想在所有接口都做这样的处理,也可以针对某个 model 处理
class GetGoodsDetail extends Model {
   /**
   * 错误处理逻辑
   */
  handleError(result) {
    //与上述相同处理
  }
}

需求 6:接口监控

我们期望能够对接口稳定性做一些监控,方便我们发现问题,那我们在 Model 层做也很方便,当然首先你需要一个错误收集和展示的系统,目前流行的开源系统有 badjs 和 sentry,我司正在使用 sentry,例如我们可以封装一个错误主动上报方法:

const captureException = (errType,errMsg,extendInfo)=>{
    //通过 sentry 方式
}

监控 1:错误监控

//Model.js
export default class Model {
  //....
   
  /**
   * 错误处理逻辑
   */
  handleError(result) {
    let errorObj = {
      errorCode: result.respCode,
      errorMsg:
        result.errorMsg || 
        result.errMsg || 
        (result.respData
          ? result.respData.errorMsg || result.respData.errMsg
          : '')
    }
    //不同类型错误上报
    switch(errorObj.errorCode){
        case -1: //未登录错误
            captureException('authFail',errorObj.errorMsg,this.options)
            break;
        case -2: //其他类型错误
            captureException('otherError',errorObj.errorMsg,this.options)
            break;
        default: //默认类型错误
            captureException('apiError',errorObj.errorMsg,this.options)
            break;
    }       
    
    return errorObj
  }
}

监控 2:超时监控

//Model.js
export default class Model {
  /**
   * 初始化配置操作
   */
  constructor(options) {
    //...
    //增加一个超时时间
    this.timeOut = 10000
  }
  /**
   * 数据获取方法
   */
  async fetch(options) {
    return new Promise(resolve => {
      //记录下请求开始时间
      let startTime = (new Date()).getTime()    
      
      Axios[this.options.type](
        this.options.url,
        options
      )
      .then(res => {
        //计算请求时间
        let fetchTime = (new Date()).getTime() - startTime
        //如果超时
        if(fetchTime >= this.timeOut){
            //发送一条错误记录
            captureException('apiTimeOut',fetchTime,this.options)
        }
        //.....
      })
      //....
    })
  }
}

监控 3:网络异常监控

网络异常错误就比较简单了,直接在 catch 里面捕获就行:

//Model.js
export default class Model {
  //...
  /**
   * 数据获取方法
   */
  async fetch(options) {
    return new Promise(resolve => {
      Axios[this.options.type](
        this.options.url,
        options
      )
      .then(res => {
        //...
      })
      .catch(res => {
        this.resetResult()
        this.result.error = {
          errorCode: -9999,
          errorMsg: '网络错误'
        }
        //发送一条错误记录
        captureException('netWorkError',res,this.options)
        
        resolve(this)
      })
    })
  }
  //...
}

需求 7:mock 数据管理

通常我们在开发环节会使用 mock 的数据,往往是另一个 api 地址,比如我司普遍使用 json-server;我们期望能够在 Model 当中保存 mock 地址,方便以后测试或者调试,那可以进一步改造下:

//GetGoodsDetail.js
import Model from '../Model.js'
class GetGoodsDetail extends Model {
    constructor(){
      super()
      this.config({
        url : '/apiPath1/getGoodsDetail',
        //我们在子 Model 当中增加一个 mockUrl 字段
        mockUrl : 'http://localhost:3000/apiPath1/getGoodsDetail',
        type'get'
      })
    }
    //....
}

//demo.vue
//在页面当中使用时
export default {
    //...
    methods : {
        async getGoodsDetail(){
            //调试时,我们换成 mock 方法来请求
            let res = await (new GetGoodsDetail()).mock({infoId:'123456'})
            //.....
        }
    }
    //...
}

//Model.js
//Model 当中需要如下改造
export default class Model {
  //...
  //增加一个 mock 方法
  async mock(params) {
    //将 url 替换成 mockUrl
    this.options.url = this.options.mockUrl || this.options.url
    return this.fetch(params)
  }
}

需求 8:更简单使用 Model--提供 CLI 支持

通过上面的各项改造,我们已经能够愉快的使用 Model 功能了,但每一个接口都需要创建一个 Model 文件,随着接口数量增加,在使用和查找方面都有点麻烦,因此我们可以做一个 CLI 工具配合使用,效果如下:

image

开发者在命令行使用 zou model 接口名,即可自动创建/查找(已存在时)Model,并打印出来使用代码

功能实现如下:

//首先我们新建一个项目

//package.json
{
  "name""zou-cli",
  "bin": {
    "zou""bin/cli"
  }
  //.....
}

//其次我们新建/bin/cli 文件
//cli
#!/usr/bin/env node
process.env.NODE_PATH = __dirname + '/../node_modules/'
const { resolve } = require('path')
const res = command => resolve(__dirname, '../commands/'command)
const program = require('commander')
program
  .version(require('../package').version )
program
  .usage('')
program
  .command('model   [type]' )  //定义 model 命令
  .description('快速创建和查找 model 的命令')
  .alias('m')
  .action((api,type) => {
    let fn = require(res('model'))
    fn(api,type)
  })
program.parse(process.argv)

if(!program.args.length){
  program.help()
}

//接下来我们新建/commands/model.js
//model.js
const { writeFile } = require('fs')
const fse = require('fs-extra')
function firstLetterToUpper(str) {  
  return str.toLowerCase().replace(/( |^)[a-z]/g, (L) => L.toUpperCase());  
}  
let path = process.cwd()
module.exports = (api,type)=>{
  type = type || 'get'
  let apiInfo = api.split('/').splice(-3)
  if(!apiInfo || apiInfo.length < 3){
    console.log("\033[34m api 地址不规范,请检查 \033[0m")
    return
  }
  let className = firstLetterToUpper(apiInfo[2])
  let apiPath = `${path}/src/model/${apiInfo[1]}/${apiInfo[2]}.js`

  fse.readFile(apiPath,function(err,file){
   if(!err){
      //文件已存在
      console.log("\033[34m model 文件已存在,请直接使用 \033[0m")
      console.log(`
引用:
import ${className} from '@/model/${apiInfo[1]}/${apiInfo[2]}.js';

使用:

let res = await (new ${className}()).fetch();
    `)
      return
    }else {
      //文件不存在,创建
      fse.ensureFile(apiPath,function(err,exists){
        let modelFileContent = `
import Model from '@zz-vc/zz-open-libs/lib/model/Model'

export default class ${className} extends Model {
  constructor(){
    super()
    this.config({
      url : '/${apiInfo[0]}/${apiInfo[1]}/${apiInfo[2]}',
      type'${type}',
    })
  }
}`
          writeFile(apiPath, modelFileContent, 'utf-8', (err) => {
            if (err) {
              console.log(err)
              return
            }
            console.log( '\033[34m created success!!! \033[0m')
            console.log(`
引用:
import ${className} from '@/model/${apiInfo[1]}/${apiInfo[2]}.js';

使用:

let res = await (new ${className}()).fetch()
          `)
          })
        })
    }
  })
}

接下来 npm run build && npm publish 发布到 npm 上,开发者安装这个包以后就可以使用命令行了

需求 9:接口缓存

从性能优化的角度,我们通常需要一些接口缓存策略,例如:

  • 我们期望缓存如全国城市数据、商品分类数据等信息到用户浏览器,二次访问时可以直接使用缓存来提高性能
  • 我们期望首页等重要页面的首屏接口数据可以缓存下来,二次访问时候优先展示缓存数据,然后再从 server 请求新数据做对比展示

基于以上需求和 Model 的应用,我们可以开发一个配套的接口缓存功能,我们选择ES7 的装饰器作为功能载体,为了便于理解,我们先看下流程图:

image

具体代码实现:

//modelCache.js

//Storage 是我们自己封装的类库,可以规范 storage 当中 key 的目录和自动管理数据过期时间
import Storage from '../storage'
/**
 * 接口缓存装饰器
 * @param {function} model 传入的 model 实例
 * @param {number} expire 缓存过期时间
 * @param {boolean} needUpdate 命中缓存以后,是否需要请求 server 更新数据
 * @param {number} delay 命中缓存后,延时多久进行更新
 */
function modelCache({model,expire = 7,needUpdate = true,delay = 0}){
  return function (target, funcName, descriptor) {
    let oriFunc = descriptor.value,          //缓存原本方法
        oriFetch = model.prototype.fetch,    //缓存原本 model 中的 fetch 方法
        fetchState = '',                     //数据使用状态
        cacheKey = 'modelCache-' + funcName, //缓存命名
        cache = Storage.get(cacheKey)        //读取缓存
    
    //先重写一下 model 当中的 fetch 方法
    model.prototype.fetch = async function(...args) {
      if(fetchState != 'usedCacheData' && cache){
        //如果状态不等于 usedCacheData,且有缓存,直接使用
        fetchState = 'usedCacheData'  //标记已经使用过缓存了
        cache.get = model.prototype.get
        cache.dataType = 'cache'  //给 model 结果增加一个数据源标识
        return cache
      }else {
        //需要从 server 读取数据
        fetchState = 'usedFreshData'
        let res = await oriFetch.apply(this,args)
        if(res.result.state == 'success'){
          //如果请求成功,则更新缓存
          Storage.set(cacheKey,res,expire)
        }
        res.dataType = 'server'  //给 model 结果增加一个数据源标识
        return res
      }
    }
    //再重新一下被装饰函数
    descriptor.value = async function (...args) {
        //先执行一遍原本函数调用
        let res = await oriFunc.apply(this, args);
        //如果需要更新,且上次请求使用了缓存数据
        if(needUpdate && fetchState == 'usedCacheData'){
          //则再次拉取最新数据并执行
          setTimeout(()=>{
            oriFunc.apply(this, args.concat(true))  //此处给原函数追加一个参数,方便标识是二次请求
          },delay)
        }
        return res
    }
  }
}
export default modelCache

在页面当中可以如下使用:

//demo.vue
//...
    //我们期望缓存 FindHomePage 数据,过期时间 15 天,需要 2 秒以后进行二次更新
    @modelCache({ model:FindHomePage , expire:15, needUpdate:true , delay:2000 })
    async findHomePage(reExecution){
      let res = await new FindHomePage().fetch()
      if(res.result.state == 'success'){
        if(reExecution){ 
            //说明是第二次执行该方法,可以执行特殊逻辑
        }
        if(res.dataType == 'cache') {
            //说明本次数据来源于缓存
        }
        if(res.dataType == 'server') {
            //说明本次数据来源于服务端
        }
      }
      //...其他操作
    }
//...

需求 10:多接口聚合 Model

上面我们的 Model 是针对单个接口级别的封装,接下来我们要真正把数据模型化。

首先,还是以我们一个工程的商品详情页举例,由于公司技术架构的原因,我们需要读取 9 个接口才能拿到所需的所有数据,分别是:

  1. 商品基本信息接口
  2. 商品拍卖信息接口
  3. 业务自己维护的拍卖信息接口
  4. 商家等级接口
  5. 商家信息接口
  6. 商品服务标签接口
  7. 推荐商品列表接口
  8. 商品二维码接口
  9. 商品可领红包接口

从性能的角度来看,这是件非常恐怖的事情,我们后面章节将通过 GraphQL 来解决这个问题。

从开发者的体验来讲,商品数据简直是地狱一般,当有一个新页面需要一定的商品数据时,开发者难以确定到底要读取哪些接口才能获取到完整数据,因此我们期望给开发者提供一个便捷的商品模型,期望做到以下结果:

  1. 即使数据需要来源于多个接口,也无需分别调用接口,通过一次 Model 查询即可得到全部数据
  2. 开发者无需关注数据来源于哪个接口,只需要关注我需要什么数据
  3. 需要提供便捷、聚合的接口字段查询能力,最好是可视化界面
  4. 需要提供通过数据组合得到新数据的能力(下文详述)

基于以上需求,我们先升级下 Model 设计,实现一个多 Model 聚合能力

首先我们先规定下目录结构

├── project
│   ├──src
│   │   └── View  //模板层
│   │   └── Model //数据层
│   │   │   └── Model.js  //基类
│   │   │   └── mutiModel //聚合 Model
│   │   │   │   └── Goods  //商品模型
│   │   │   │   │   └── config.js //配置文件
│   │   │   │   │   └── index.js  //入口文件

接下来我们编写下配置文件

//config.js

import Getoriinfodetail from '@/model/tobtoollogic/getOriInfoDetail.js';
import Getauctioninfobyid from '@/model/transfer/getAuctionInfoById.js';
import Checkmerchant from '@/model/tobtoollogic/checkMerchant.js';
import Getmerchantdetail from '@/model/tobtoollogic/getMerchantDetail.js';
import Getproductguaranteeinfo from '@/model/tobtoollogic/getProductGuaranteeInfo.js';
import Getrecommendinfolist from '@/model/tobtoollogic/getRecommendInfoList.js';
import Getwxmpcode from '@/model/tobtoollogic/getWxMpCode.js';
import Queryshopcouponwithreceivedinfo from '@/model/transfer/queryShopCouponWithReceivedInfo.js';
import GetOpenToBProductDetail from '@/model/tobtoollogic/getOpenToBProductDetail.js';
import cookie from '@/libs/cookie'

export default {
    common : {
        basic:{
            name:'商品基本信息',
            docId:121899,
            connector:Getoriinfodetail
        },
        auction:{
            name:'商品拍卖信息',
            docId:648,
            connector:Getauctioninfobyid
        },
        auctionExtra : {
            name:'业务自己维护的拍卖信息',
            docId:121913,
            connector:GetOpenToBProductDetail
        },
        merchantStatus:{
            name:'商家等级',
            docId:121833,
            connector:Checkmerchant
        },
        merchantInfo:{
            name:'商家信息',
            docId:121927,
            connector:Getmerchantdetail
        },
        service:{
            name:'商品服务标签',
            docId:121806,
            connector:Getproductguaranteeinfo
        },
        recommend:{
            name:'推荐商品列表',
            docId:117528,
            connector:Getrecommendinfolist
        },
        qrCode:{
            name:'商品二维码',
            docId:117601,
            connector:Getwxmpcode
        },
        redPackage:{
            name:'商品可领红包',
            docId:56895,
            connector:Queryshopcouponwithreceivedinfo
        }
    },
    custom : {
        isSettleMerchant:{
            name:'卖家是否是免费版卖家',
            dependencies:'basic',
            compute:(res)=>{
                return res.basic.get('searchable') == 52 
            }
        },
        isMyGoods: {
            name:'是否是自己发布的商品',
            dependencies:'basic',
            compute:(res)=>{
                return res.basic.get('uid') && Cookies.getUID() && res.basic.get('uid') == Cookies.getUID()
            }
        },
        isATGoods: {
            name:'是否文玩商品',
            dependencies:'basic',
            compute:(res)=>{
                return res.basic.get('isYiGeProduct')
            }
        },
        isAuctionGoods: {
            name:'是否拍卖类型商品',
            dependencies:'auctionExtra',
            compute:(res)=>{
                return res.auctionExtra.get('infoType') == 2
            }
        }
    }
}

配置文件中包括两种:

  • common 对象:里面放置商品模型所需要的 9 个子 Model 的引用,其中:
    • name 字段:Model 信息
    • docId 字段:接口文档对应的 id
    • connector: 映射的子 Model
    • key:别名
    • value:配置信息
  • custom 对象:放置自定义的组合数据,相当于通过各种数据 computed 出来的新数据
    • name:数据介绍
    • dependencies: 依赖
    • compute: 计算方式
    • key:别名
    • value:配置信息

开发者可以根据数据创造出各种新字段,这里也为 Model 的扩展提供了更多可能

接下来我们看下入口文件:

//index.js
import config from './config.js'
export default class Goods extends Model {
    constructor() {
      super()
      this.mutiModelConfig = config
    }
  }

上述我们就定义好了一个商品模型,当我们在页面里面需要商品模型数据时,可以这样使用:

//demo.vue
import Goods from '@/model/mutiModel/Goods/'

let fields = [
    //基本数据中的几个字段
    "basic.isPlanIdExists",
    "basic.isYiGeProduct",
    "basic.followAmount",
    "basic.videoList",
    //拍卖信息的所有字段,用.*表示
    "auction.*",
    //自定义字段
    "isAuctionGoods"
]
//聚合调用方法
let res = await (new Goods()).want(fields).fetchAll({
  infoId : '123456'
})

通过以上代码可以看出,开发者仅仅需要配置需要哪些字段,无需关注这些字段从哪个接口获取,为了区别普通的 Model 调用,我们需要新增两个方法:

  • want: 配置所需字段功能
  • fetchAll:请求发起功能

所以我们要对 Model 基类进行再次改造:

//Model.js
export default class Model {
  constructor(options) {
    //....新增三个类属性
    this.fields = null              //存放聚合 Model 所需字段
    this.mutiModelConfig = null     //聚合 Model 配置文件
    this.dependencies = []          //本次请求所需的依赖 Model
  }
  /**
  * 请求字段配置方法
  */
  want(fields = []) {
    if(typeof fields == 'string'){
      this.fields = [fields]
    }else if(Array.isArray(fields)){
      this.fields = fields
    }else {
      console.warn('want 方法传入参数类型错误,仅支持 string array')
      return this
    }
    //收集依赖 Model
    if(this.mutiModelConfig && this.fields.length){
      //默认依赖
      let defaultConnector = Object.keys(this.mutiModelConfig.common)[0]

      //收集 fields 里面显式依赖
      this.dependencies = []
      this.fields.forEach((v,k)=>{
        if(v.indexOf('.') > -1){
          this.dependencies.push(v.split('.')[0])
        }else if(this.mutiModelConfig.custom && this.mutiModelConfig.custom[v]){
          this.dependencies = this.dependencies.concat(this.mutiModelConfig.custom[v]['dependencies'])
        }else {
          this.dependencies.push(defaultConnector)
          this.fields[k] = defaultConnector + '.' + v //给默认的 field 补上命名空间
        }
      })
      //得到所有依赖
      this.dependencies = Array.from(new Set(this.dependencies))
    }
    return this
  }
  /**
  * 聚合模型数据获取方法
  */
  async fetchAll(options){
    let Dependencies = this.dependencies.map(v=>this.mutiModelConfig.common[v]['connector'])
    return new Promise((resolve)=>{
      Promise.all(dependencies.map(dependence=>new Dependence().fetch(options)))
        .then((res)=>{
          let result = {},
              resObj = {}
          //对返回结果进行一次封装
          res.forEach((v,k)=>{
            resObj[this.dependencies[k]] = v
          })
          this.fields.forEach(v=>{
            if(v.indexOf('.') > -1){
              let namespace = v.split('.')[0],
                  key = v.split('.')[1],
                  data = resObj[namespace]
              if(key == '*'){
                result[namespace] = data
              }else {
                result[namespace] = result[namespace] || {}
                result[namespace][key] = data.get(key,null)
              }
            }else {
              //自定义字段
              result[v] = this.mutiModelConfig.custom[v].compute(resObj)
            }
          })
          resolve(result)
        })
        .catch((err)=>{
          console.error(err)
          resolve(null)
        })
    })
  }
}

需求 11:聚合 Model 的可视化

聚合 Model 请求很简单,但查找所需字段的过程还是很麻烦,因此我们再实现一个可视化查询的界面,这里我们采用的方法是,通过上面讲到的命令行工具,在本地起一个服务,运行查询界面

首先增加一个命令:

//bin/cli

program
  .command('modelview')
  .description('打开 model 视图')
  .alias('mv')
  .action(() => {
    let fn = require(res('modelview'))
    fn()
  })

然后编写命令代码:

//commands/modelview.js
#!/usr/bin/env node
const fs = require('fs')
const http = require('http');
const request = require('request');
const opn = require('opn')
let path = process.cwd()
module.exports = ()=>{
  let target = {}
  let dirName = `${path}/src/model/mutiModel`
  let files = fs.readdirSync(dirName)
  
  //首先遍历对应目录下的所有 mutlModel
  files.forEach((filedir)=>{
      let subDir = dirName + '/' +filedir
      let stats = fs.statSync(subDir)
      if(stats.isDirectory()){
          //读取 config 配置文件
          let configFile = subDir + '/config.js'
          let file = fs.readFileSync(configFile)
          
          //通过正则方式读取所有 Model 及对应 docId
          let fileContent = file.toString(),
              reg = new RegExp(`(\\w+)\\W*\\:\\W*\\{[^{]*docId[^\\d]+(\\d+)`,'g'),
              result = null
          target[filedir] = {}
          while((result = reg.exec(fileContent)) != null){
              if(result && result.length && result.length > 2){
                  target[filedir][result[1]] = result [2]
              }
          }
      }
  })
  //读取可视化页面 html 模板内容
  let htmlFile = fs.readFileSync(__dirname + '/modelview.html')
  //将读取到的配置的预置变量写入模板中
  let htmlContent = htmlFile.toString().replace(` "model">`,` "model">
   let config =  ${JSON.stringify(target,'',2)}
   let apiData = APIDATAPLACEHOLDER
  `)
  /**
  * 读取接口定义方法
  */
  const getDoc = (id)=>{
       return new Promise((resolve)=>{
          request(`http://接口平台 api 地址/docId= ${id}`, { json:  true }, (err, res, body) => {
               if (err) { 
                  console.log(err)
                  resolve({});
              } else {
                  resolve(body)
              }
          });
      })
  }
  /**
  * 本地创建服务方法
  */
  const createServer = (htmlContent)=>{
      const hostname =  '127.0.0.1';
      const port = 9433;
      //创建一个 server    
      const server = http.createServer((req, res) => {
          res.writeHead(200, { 'Content-type' :  'text/html'});
          res.write(htmlContent);
          res.end( ' ');
      });

      //开启服务
      server.listen(port, hostname, () => {
          console.log(`server run on: http:// ${hostname}: ${port}/`);
          opn(`http:// ${hostname}: ${port}/`)
      });
  }
  //计算需要读取的 api 列表
   let apiList = []
   for( let i  in target){
       for( let item  in target[i]){
          apiList.push([i+ '.'+item,target[i][item]])
      }
  }
  //读取所有接口 api 内容并开启服务
  Promise.all(apiList.map(v=>getDoc(v[1]))). then((res)=>{
       let result = {}
      res.forEach(v=>{
           if(v.result && v.result.docId){
              result[v.result.docId] = v
          }
      })
      //将预置变量替换为接口内容
      htmlContent = htmlContent.replace( 'APIDATAPLACEHOLDER',JSON.stringify(result,null,2))
      
      //开启服务
      createServer(htmlContent)
  }).catch((err)=>{
      console.error(res)
  })
}

当用户在命令行执行 zou modelview 命令时,就会在浏览器打开一个可视化查询界面了

效果预览下:

image

上述界面当中的 html 模板部分代码这里就不贴了,都是数据展示和 DOM 操作而已

需求 12:Model 对接 GraphQL

有性能优化经验的小伙伴看到这里可能早有了质疑,这种聚合方式对于性能上没有任何提升反而因为 Promise.all 而略有下降,是的,从性能角度来讲确实是这样,上面的方式最大的作用就是把复杂的业务数据模型进行了高度的抽离,节省开发者关注字段数据来源的成本,如果有条件的同学,可以在此基础上对接 GraphQL,较完美解决性能问题

关于 GraphQL 服务端部分的搭建以及 schema 的编写,网上有很多文章,这里就不讲了,我们只讲下 Model 层面如何对接 GraphQL 的查询语言:

先改造下 config,增加一个字段

//config.js
export default {
    gqlUri : `https://你的 GraphQL 请求地址`,
    common : {//...},
    custom : {//...}
}

再改造下 Model 基类

//Model.js
import ApolloClient from 'apollo-boost';
export default class Model {
  /**
  * 聚合模型数据获取方法
  */
  async fetchAll(options){
    let Dependencies = this.dependencies.map(v=>this.mutiModelConfig.common[v]['connector'])
    //将结果出来逻辑提取出来
    const dataHandler = (res)=>{
        let result = {},
            resObj = {}
        //对返回结果进行一次封装
        res.forEach((v,k)=>{
            resObj[this.dependencies[k]] = v
        })
        this.fields.forEach(v=>{
            if(v.indexOf('.') > -1){
              let namespace = v.split('.')[0],
                  key = v.split('.')[1],
                  data = resObj[namespace]
              if(key == '*'){
                result[namespace] = data
              }else {
                result[namespace] = result[namespace] || {}
                result[namespace][key] = data.get(key,null)
              }
            }else {
              //自定义字段
              result[v] = this.mutiModelConfig.custom[v].compute(resObj)
            }
        })
        return result 
    }
    
    return new Promise((resolve)=>{
      if(this.mutiModelConfig.gqlUri){
        //GraphQL 请求
        
        //初始化一个 Apollo 实例
        const client = new ApolloClient({
            uri: this.mutiModelConfig.gqlUri
        });
        
        //将请求参数转换成 GraphQL 的请求格式
        let gqlQuery = this._translateGqlQuery(options,this.fields)
        
        client.query(gqlQuery).then((res)=>{
          //处理返回结果    
          res = this._formatQqlResult(res)    
          
          let result = dataHandler(res)
          resolve(result)
        })
        .catch((err)=>{
          console.error(err)
          resolve(null)
        })
      }else {
        //RESTful 请求
        Promise.all(dependencies.map(dependence=>new Dependence().fetch(options)))
            .then((res)=>{
              let result = dataHandler(res)
              resolve(result)
            })
            .catch((err)=>{
              console.error(err)
              resolve(null)
            })
      }
    })
  }
}

我司针对 GraphQL 封装了专门的 client 库,里面涉及较复杂逻辑,因此上面代码做了一定简化,以 apollo-boost 为请求方式作为示例,感兴趣同学可以深入研究

需求 13:Model 结合 WebSQL 实现前端数据管理

业务开发当中,我们经常会需要一些不经常改动的结构化数据,比如:全国城市数据、商品分类数据等,通常我们会用两种方式使用:

  1. 通过 api 读取后使用,这种方式的缺点是每次都需要请求一遍数据,数据量较大,且可能我们每次都需要一些数据格式转换的操作,造成性能浪费
  2. api 读取后,缓存到 localStorage 当中,这种方式有了一定提升,避免了每次请求,但 localStorage 只能以文本形式存储,每次使用都需要 string 转 JSON 的过程,在数据量较大的情况下,代码执行时间较长,而且有可能造成卡顿现象

基于以上情况考虑,我们可以尝试将这类数据存储到浏览器的 WebSQL 中,以 SQL 的方式进行使用,示例:

//Model.js
import Database from './Database.js'
export default class Model {
    //....
    /**
    * 创建/连接数据库方法
    */
    async connect(params){
        return new Promise((resolve)=>{
            this.db = this.db || new Database()
            this.db.isExists(this.options.url).then(res=>{
                //数据库已经存在,返回结果
                resolve({
                    result : {
                        state:'success',
                        data:this.db
                    }
                })
            }).catch(e=>{
                //数据库不存在,请求接口并处理数据,然后存入数据库
                let res = await this.fetch(params)
                if(res.result.state == 'success'){
                    for(let i in res.result.data){
                   //创建表并存储数据 
                      this.db.create(i,data[i])
                    }
                 resolve({
                        result : {
                            state:'success',
                            data:this.db
                        }
                    })
                }else {
                  resolve(res)  
                }
            })
        })
    }
}

页面调用方法:

//demo.vue
import Category from '@/model/apiPath1/Category.js'

export default {
    //.....
    methods : {
        async getCategory(){
            let res = await (new Category()).connect()
            if(res.result.state == 'success'){
                let db = res.result.data
                //select 方法参数分别对应 sql 语句:tableName、condition、fields、order、group、limit
                this.firstCategory = db.select('category',{level:1},'*','cateId desc','','')
            }else {
                //错误提示
                toast(res.result.error.errorMsg)
            }
        }
    }
}

关于 WebSQL 相关介绍和操作方法,因内容与本篇相关性较低,我将在另一篇文章中详细讲述

总结

至此我们实现了 Model 层抽离的全部想法,这套轮子在我司多个项目当中使用,可以有效的将数据与模板、逻辑隔离开。

JavaScript 是一门比较松散的语言,随着近些年发展,JavaScript 在不断完善和吸收其他语言的优点,TypeScript 就是一个优秀的例子,我也期望有更多志同道合的前端开发者认同 Model 的理念,欢迎与我一起探讨。

    

● 你不知道的前端项目自动化部署(实战教学,超详细教程)

● 你不知道的前端工程化(手把手入门,超详细教程)

● 使用 Vue3 和 TypeScript 重构740+ Star WebSocket 插件



·END·

图雀社区

汇聚精彩的免费实战教程



关注公众号回复 z 拉学习交流群


喜欢本文,点个“在看”告诉我

浏览 11
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报