【Fantastic-admin 技术揭秘】让应用可配置化

457 阅读5分钟

《Fantastic-admin 技术揭秘》系列将带你了解 Fantastic-admin 这款框架各种功能的设计与实现。通过了解这些技术细节,你不光可以更轻松地使用 Fantastic-admin 这款框架,也可以在其他项目中使用这些技术。

你可以点击 这里 查看本系列的所有文章,也欢迎你在评论区留言告诉我你感兴趣的内容,或许下一篇文章就会带你揭秘其中的奥秘。

前言

什么是“应用配置化”?举个最常见的例子,我们使用 VSCode 就提供了一个丰富的应用设置,它允许开发者通过配置文件来配置应用的各项功能,从而实现应用的灵活性。

image.png

在 Fantastic-admin 中,应用设置是一个非常重要的功能,它允许用户配置主题、布局、菜单、标签栏等各项功能。

分析需求

首先需要有一套默认的配置,也就是当用户不进行任何配置时,应用程序会执行的默认行为。

其次,所有配置项都是可选的,用户如果配置了其中某一项,则应用程序会使用用户配置的值,其他未配置的项则使用默认配置。

最后,为了确保配置的正确性,需要对配置进行校验,通常使用 ts 的类型检查即可。

初步实现

假设现在有 3 个配置项,分别是 titleenableLogologo

interface Config {
  // 标题
  title?: string;
  // 是否启用 logo
  enableLogo?: boolean;
  // logo 地址
  logo?: string;
}

// 默认配置
const defaultConfig: Config = {
  title: 'Fantastic-admin',
  enableLogo: true,
  logo: 'https://fantastic-admin.hurui.me/logo.svg',
};

这时候,我们就可以通过 Object.assign 来实现配置的合并:

const config = Object.assign(defaultConfig, {
  enableLogo: false,
} as Config);
// config = { title: 'Fantastic-admin', enableLogo: false, logo: 'https://fantastic-admin.hurui.me/logo.svg' }

复杂配置

当配置项越来越多,你可能会开始考虑将配置项进行分组归类,比如:

interface Config {
  app?: {
    // 标题
    title?: string;
  }
  logo?: {
    // 是否启用
    enable?: boolean;
    // 地址
    url?: string;
  }
}

// 默认配置
const defaultConfig: Config = {
  app: {
    title: 'Fantastic-admin',
  },
  logo: {
    enable: true,
    url: 'https://fantastic-admin.hurui.me/logo.svg',
  },
};

这时候如果继续使用 Object.assign 来实现配置的合并,就会发现无法满足需求了,比如:

const config = Object.assign(defaultConfig, {
  logo: {
    enable: false,
  },
});
// config = { app: { title: 'Fantastic-admin' }, logo: { enable: false } }

合并后 logo 对象被覆盖了,导致 url 属性丢失了。其原因在于 Object.assign 并不关心值的类型,它只是简单地将值赋值给目标属性。

引入 defu

要解决这个问题,可以自己写一个递归合并函数,但是这里推荐使用 defu 这个库,它可以帮助我们更方便地实现配置的合并。

import { defu } from 'defu'

const config = defu({
  logo: {
    enable: false,
  },
} as Config, defaultConfig);
// config = { app: { title: 'Fantastic-admin' }, logo: { enable: false, url: 'https://fantastic-admin.hurui.me/logo.svg' } }

自定义合并规则

通常情况下,配置项类型都是 booleanstringnumber 等基础类型,但是有时候也会出现 array 类型,比如一些用于排序的配置项:

interface Config {
  sort: string[];
}

const defaultConfig: Config = {
  sort: ['name', 'age'],
};

const config = defu({
  sort: ['age', 'name'],
} as Config, defaultConfig);
// config = { sort: ['age', 'name', 'name', 'age'] }

这显然不符合预期,这时候就需要自定义合并规则:

import { createDefu } from 'defu'

const merge = createDefu((obj, key, value) => {
  // 如果源属性和目标属性的值都是数组,则覆盖
  if (Array.isArray(obj[key]) && Array.isArray(value)) {
    obj[key] = value
    return true
  }
})

interface Config {
  sort: string[];
}

const defaultConfig: Config = {
  sort: ['name', 'age'],
};

const config = merge({
  sort: ['age', 'name'],
} as Config, defaultConfig);
// config = { sort: ['age', 'name'] }

到此为止,我们已经实现了配置的合并,也基本能满足应用配置化的需求了。

移除不存在的属性

在一些特殊场景下,配置项是异步加载的,比如用户的偏好设置,这些配置是存放在后端,在应用运行时通过接口获取并动态合并到配置中。

这就会出现一个问题,就是异步返回的配置项可能有一些不存在的属性,虽然和默认配置项合并后不会有太大影响,但总归是不符合预期的。

这时候就需要移除不存在的属性,方法也很简单,只需要稍微修改下自定义合并的规则:

import { createDefu } from 'defu'

const merge = createDefu((obj, key, value) => {
  // 如果属性不存在,则移除
  if (obj[key] === undefined) {
    delete obj[key]
    return true
  }
  // 如果属性是数组,则覆盖
  if (Array.isArray(obj[key]) && Array.isArray(value)) {
    obj[key] = value
    return true
  }
})

interface Config {
  app?: {
    // 标题
    title?: string;
  }
  logo?: {
    // 是否启用
    enable?: boolean;
    // 地址
    url?: string;
  }
}

// 默认配置
const defaultConfig: Config = {
  app: {
    title: 'Fantastic-admin',
  },
  logo: {
    enable: true,
    url: 'https://fantastic-admin.hurui.me/logo.svg',
  },
};

const config = merge({
  app: {
    subTitle: '次标题',
  },
  logo: {
    enable: false,
  },
} as Config, defaultConfig);
// config = { app: { title: 'Fantastic-admin' }, logo: { enable: false, url: 'https://fantastic-admin.hurui.me/logo.svg' } }

扩展

在 Fantastic-admin 中,我将这些配置项提供了一个在线配置的功能,可以动态修改配置并预览,并且还提供了一个复制按钮,方便一键复制当前配置。

image.png

复制按钮会复制出一份完整配置,其中包含了自定义的配置和默认的配置,但其实用户只想要复制其中自定义配置的部分。

这时候需要写一个函数,将完整配置和默认配置比较,提取出其中不同的部分,也就是用户自定义的配置。

function isObject(value: any) {
  return typeof value === 'object' && !Array.isArray(value)
}
// 比较两个对象,提取出不同的部分
function diffTwoObj(originalObj: Record<string, any>, diffObj: Record<string, any>) {
  if (!isObject(originalObj) || !isObject(diffObj)) {
    return diffObj
  }
  const diff: Record<string, any> = {}
  for (const key in diffObj) {
    const originalValue = originalObj[key]
    const diffValue = diffObj[key]
    if (JSON.stringify(originalValue) !== JSON.stringify(diffValue)) {
      if (isObject(originalValue) && isObject(diffValue)) {
        const nestedDiff = diffTwoObj(originalValue, diffValue)
        if (Object.keys(nestedDiff).length > 0) {
          diff[key] = nestedDiff
        }
      }
      else {
        diff[key] = diffValue
      }
    }
  }
  return diff
}

const diff = diffTwoObj(
  // 默认配置
  {
    app: {
      title: 'Fantastic-admin',
    },
    logo: {
      enable: true,
      url: 'https://fantastic-admin.hurui.me/logo.svg',
    },
  },
  // 完整配置
  {
    app: {
      title: 'Fantastic-admin',
    },
    logo: {
      enable: false,
      url: 'https://fantastic-admin.hurui.me/logo.svg',
    },
  }
)
// diff = { logo: { enable: false } }