前言

在应用开发中,为了提高开发效率、代码复用率,通常会抽取大量公共组件,这些组件往往包含大量数据,在设计组件时不得不将这些数据考虑在其中,数据的来源、获取时机、如何流转等问题,可能会成为我们设计组件的阻碍。

痛点

考虑以下场景:列表 + 弹窗,列表中有年级筛选项,弹窗中也有年级筛选项,年级的下拉列表数据是通过接口获取的。

在 vue 项目中如何巧妙的处理组件与数据的关系

在 vue 项目中如何巧妙的处理组件与数据的关系

通常代码会这样写:

<template>
  <div>
    <el-form>
      ...
      <el-form-item label="年级">
        <el-select>
          <el-option
            v-for="item in gradeList"
            :key="item.id"
            :label="item.name"
            :value="item.id" />
        </el-select>
      </el-form-item>
    </el-form>
    ...
    <!-- 弹窗组件 -->
    <el-dialog>
      <el-form>
        ...
        <el-form-item label="年级">
          <el-select>
            <el-option
              v-for="item in gradeList"
              :key="item.id"
              :label="item.name"
              :value="item.id" />
          </el-select>
        </el-form-item>
      </el-form>
      ...
    </el-dialog>
  </div>
</template>
<script setup>
import { ref } from 'vue'
// 接口文件
import { getGradeList } from '@/api'
const gradeList = ref([])
// 获取年级
const fetchGradeList = async () => {
  gradeList.value = await getGradeList()
}
fetchGradeList()
</script>

可以看到,弹窗直接写在列表页内,可以很好的复用 gradeList 数据,但有两个弊端:

  1. 弹窗组件无法复用。
  2. 如果将来有更多类似的弹窗加入,列表页将会变得臃肿。

为解决上述问题,我们需要将弹窗抽取出来,但组件抽取后,又会出现一个新的问题:gradeList 数据该如何处理?

方式一:在列表中获取 gradeList 数据,通过 props 传给弹窗。

这样做可以复用 gradeList,但每次使用弹窗组件时需要请求一次接口,不仅代码冗余,且增加使用组件的成本,试想一下,别人用这个弹窗还需要考虑传什么样的数据进去,假设不止一个 gradeList,还有 n 多个数据呢?

方式二:将获取 gradeList 的工作封装到弹窗组件中。

使用者不需要再考虑弹窗中的数据,但是数据不能被列表复用,比如 gradeList,列表也需要,所以只能列表也请求一次,造成同一接口请求两次的情况。

这就是我想说的数据会直接阻碍组件的设计,实际开发中情况要比上面的 demo 要复杂得多,尤其是多人协作开发,这个问题会被放大,随着项目的日渐庞大,公共组件越写越多,组件关系错中复杂,如果没有做好数据处理,不仅会造成代码冗余,甚至有可能出现同一个接口同一时间被调用几次的情况。

解决方案

先看一下我的解决方案。

api.js:

import axios from 'axios'
const request = axios.create({
  baseURL: 'https://www.fastmock.site/mock/61b51f966a9c4d7affc1b62642306e2e/api'
})
export function getGradeList (params) {
  return request({
    url: '/getGradeList',
    method: 'GET',
    params
  }).then(res => {
    return res.data
  })
}
export function getChannelList (params) {
  return request({
    url: '/getChannelList',
    method: 'GET',
    params
  }).then(res => {
    return res.data
  })
}

dataset.js,用于集中管理数据:

// dataset 下面会说实现思路
import Dataset from './dataset'
import { getGradeList, getChannelList } from '@/api'
export default new Dataset({
  config: {
    gradeList: {
      data: () => getGradeList()
    },
    channelList: {
      data: () => getChannelList()
    },
    sex: {
      data: () => {
        return [
          { name: '男', value: 1 },
          { name: '女', value: 2 }
        ]
      }
    }
  }
})

取数据:

<template>
  <div>
    <!-- 连续调用两次 -->
    <div>dataset.get({ name: 'gradeList' })"></div>
    <div>dataset.get({ name: 'gradeList' })"></div>
    <!-- 连续调用两次 -->
    <div>dataset.get({ name: 'channelList' })"></div>
    <div>dataset.get({ name: 'channelList' })"></div>
    <div>{{ dataset.get({ name: 'sex' }) }}</div>
  </div>
</template>
<script setup>
import dataset from '@/dataset'
</script>

这样做的好处:

  • 使用简单,只需要调用 dataset.get 便可取得数据并渲染到模板上,如果是接口数据,会自动调用接口,且多次调用只会执行一次。
  • 组件上无需关注任何数据的细节。
  • 所有数据在配置文件中管理,减小代码冗余。

实现思路

dataset 的实现思路也很简单,核心代码就几十行,上代码:

dataset.js:

import Store from './store'
import memoize from './memoize'
class Dataset {
  constructor (options) {
    options.max = options.max || 100
    this.options = options
    this.store = new Store()
    this.memoFetch = memoize(this.fetch, this.options.max)
  }
  get (options) {
    const { name } = options
    // 第一次调用执行,之后走缓存
    this.memoFetch(name)
    // 若 store 数据改变,触发渲染,返回数据
    return this.store.get(name)
  }
  fetch (name) {
    const data = this.options.config?.[name]?.data
    if (typeof data === 'function') {
      const value = await data()
      // 获取数据后添加到 store
      this.store.set(name, value)
      return value
    }
  }
}
export default Dataset

store.js:

import { reactive } from 'vue'
export default class Store {
  private store
  constructor () {
    this.store = reactive({})
  }
  set (key, value) {
    this.store[key] = value
  }
  get (key) {
    return this.store[key]
  }
}

memoize.js:

import Cache from './cache'
const { stringify } = JSON
// 使用函数参数作为 key,参数相同的同一函数调用多次,只会执行一次,其余返回缓存值
export default function (fn, max): Memoize<T> {
  const cache = new Cache(max)
  const memoize = function (...args) {
    if (args.length === 0) return fn.call(this)
    const key = stringify(args)
    let value = cache.get(key)
    if (!value) {
      value = fn.call(this, ...args)
      cache.set(key, value)
    }
    return value
  }
  return memoize
}

cache.js:

// LRU 缓存策略
export default class Cache {
  constructor (max = 0) {
    this.max = max
    this.cache = new Map()
  }
  get (key) {
    // 获取时,将被获取元素推到栈顶
    const value = this.cache.get(key)
    if (value) {
      this.delete(key)
      this.set(key, value)
    }
    return value
  }
  set (key, value) {
    // 缓存满后,删除第一个
    if (this.max !== 0 && this.cache.size === this.max) {
      this.delete(this.keys()[0])
    }
    this.cache.set(key, value)
  }
}

大概流程如下:

  1. 调用 dataset.get 时,会调用配置文件中对应 key 的 data 函数。
  2. 之后将执行结果存入缓存,再次调用不会再执行 data 函数,而是使用缓存。
  3. 数据返回成功后将数据设置到 store 中,触发渲染。

结语

这个方法很适合处理一些不常变的数据,比如数据字典,因为加了缓存,数据变动需要刷新浏览器,当然也可以添加一些清除缓存的方法,在适当的时候清除缓存。

对此我封装一个插件:vue-reactive-dataset,添加了清除缓存、filter 等方法,有兴趣的小伙伴可以看看,如有帮助,求个 star,谢谢。