javascript

Глобальная настройка любого компонента в Vue

  • среда, 30 октября 2024 г. в 00:00:12
https://habr.com/ru/articles/854308/

Введение

Раньше использовал Vuetify в качестве UI библиотеки. В связи с его сомнительной репутации, отказался от него, но пока что не нашел ни одной свободной библиотеки, что реализовала бы все его достоинства, одним из которых, является глобальная конфигурация.

Сейчас использую Element Plus, так как используется на основной работе и она на равных с другими схожими библиотеками. У него тоже есть глобальная конфигурация, но он очень кастрирован - я не могу глобально настроить конкретный компонент.

Проблема

В начале разработки, были поставлены требование к проекту, чтобы определенные элементы по умолчанию вели себя одинакова.

Для примера возьмем таблицу: нужно чтобы все таблицы окрашивались через строку.

За это в компоненте ElTable отвечает prop stripe:

<template>
  <el-table :data="tableData" stripe>
    <!-- ... -->
  </el-table>
</template>

Варианты решение

Решение 1 - ручная передача

Передавать в компонент в ручную. Очевидное и самое простое решение. Но, это обязывает программиста каждый раз помнить, что при использование того, или иного компонента, нужно предавать определенные значение. Высокий риск человеческого фактора.

Решение 2 - обертка

Создать некий wrapper, в которому будет находится наша таблица по умолчанию. Не лучше первого решение, так как программисту вместо очевидного ElTable, нужно использовать некий TableWrapper. В добавок, это решение ломает подсказки в текстовых редакторах и IDE-шках.

Решение 3 - перехват компонентов перед импортом

Мы перехватывает импортируемый компонент, изменяем default в нужном props и после этого импортируем его. Так, мы используем лишь открытый Api компонента, сохраняем подсказки в текстовых редакторах и пользователь может переопределить значение. Этот способ и будет использован.

Особенности Vue props

Если создается props, только с типом, без указание default, то он он сведется к функции, а не к объекту

const props = defineProps({ border: { type: Boolean } });

будет преобразован в

border: ƒ Boolean()

тогда как нам нужно:

border: {default: true, type: ƒ Boolean()}

Реализация

Изменение default props

Создадим функцию, который будет принимать компонент и объект prop-сов, который хотим изменить:

const setDefaultProps = (component: any, defaultProps: Record<string, any>) =>
  Object.entries(defaultProps).forEach(([propName, propValue]) => {
    // если компонент не содержит заданный props, идем дальше
    if (!Object.prototype.hasOwnProperty.call(component.props, propName))
      return;
    // если prop компонента является функцией, преобразовываем в объект, в ином случай возвращаем его  
    const propBody = Object.prototype.hasOwnProperty.call(
      component.props[propName],
      "type"
    )
      ? component.props[propName]
      : { type: component.props[propName] };

    component.props[propName] = {
      ...propBody,
      default: propValue, // задаем по умолчанию нужное значение
    };
  });

У аргумента component указан тип any вместо DefineComponent, потому-что element-plus использует свои тип SFCWithInstall, который не совместим с ним, но по итогу, все равно сводится к нему.

<template>
  <el-table-custom :data="list">
    <el-table-column prop="name" />
  </el-table-custom>
</template>

<script setup lang="ts">
import { ElTable as ElTableCustom } from "element-plus";

setDefaultProps(ElTableCustom, { stripe: true });

const name = ref([
  { name: "foo" },
  { name: "bar" },
  { name: "hello" },
  { name: "world" },
]);
</script>

Все прекрасно работает... Хотел бы я сказать, но если захотим изменить компонент ElTooltip, то ничего не получится.

Как писал ранее, компоненты element-plus реализуют свой тип SFCWithInstall, который изнутри не напрямую используют props, из-за чего изменение default не приведут к желаемому результату.

Есть еще одно решение, более грубое: изменение props перед запуском метода setup:

const setPropsUnsafe = (component: any, defaultProps: Record<string, any>) => {
  const setup = component.setup!; // сохраняем, чтобы не получить рекурсию

  component.setup = (props: any, ctx: any) => {
    const newProps = { ...props }; // снимаем защиту readonly Proxy

    Object.entries(defaultProps).forEach(([propName, propValue]) => {
      // если компонент не содержить заданный props, идем дальше
      if (!Object.prototype.hasOwnProperty.call(component.props, propName))
        return; 

      if (
        !Object.prototype.hasOwnProperty.call(component.props[propName], "default") ||
        component.props[propName]["default"] === newProps[propName]
      )
        newProps[propName] = propValue;
    });

    return setup(shallowReadonly(shallowReactive(newProps)), ctx);
  };
};

Меняем только в тому случай, если prop равен значению default

Мы полностью снимаем Proxy у props, так как она не позволяет его изменять, а после оборачиваем обратно. Из-за этого, возможны непредсказуемые поведение. Теперь все должно работать... Но даже так, изменение некоторых prop-сов не приводит ни к чему (В случай с ElTooltip - это content и enterable). Но пока что, хватает того, что есть.

Создаем интерфейс настроек

export interface SettingComponent {
  component: Record<string, any>;
  props: Record<string, any>;
  unsafeProps?: boolean;
}

Так как в Resolver нам нужно указывать, что нам нужно импортировать, указываем у component тип Record<string, any>, чтобы воспользоваться хитростью сокращенной записью - { hello } будет преобразован в { 'hello': hello }, и мы сможем достать название компонента.

Создадим функцию settingRun который запускает ранее описанные функции.

export function settingRun(settings: SettingComponent[]) {
  for (const setting of settings) {
    const [[_, component]] = Object.entries(setting.component);

    if (setting.unsafeProps) setPropsUnsafe(component, setting.props);
    else setDefaultProps(component, setting.props);
  }
}

Соединяем вместе

Создадим 2 файла

  • globalSetting.ts - будет хранит сами настройки

  • globalSettingLib.ts - будет хранит всю логику

Будем использовать unplugin-vue-components который создан для автоматического импорта компонентов в template.

Element plus рекомендует использовать Auto import, так что у нас уже имеется пакеты unplugin-vue-components.

По сути, resolver, является функции, который на вход принимает имя компонента в CapitalCase и возвращает объект который указывает что и откуда импортировать:

Для этого нам и нужен файл globalSetting.ts, где мы импортируем компоненты, который хотим изменить, меняем их и экспортируем в вне.

// globalSetting.ts

import { ElTable, ElTooltip } from "element-plus";
import { SettingComponent, settingRun } from "./globalSettingLib";

export const settings: SettingComponent[] = [
  {
    component: { ElTable },
    props: { border: true, stripe: true, size: "small", tableLayout: "auto" },
  },
  {
    component: { ElTooltip },
    props: { showAfter: 500 },
    unsafeProps: true,
  },
];

settingRun(settings); // меняем компонент

export { ElTable, ElTooltip, ElDialog };

Резолвер

// globalSettingLib.ts

export function SettingComponentsResolver(
  settings: SettingComponent[],
  from: string
): ComponentResolverFunction {
  const names = settings.map((i) => Object.keys(i.component)[0]);

  return (name: string) => {
    if (names.includes(name)) {
      return {
        name: name,
        from: from,
      };
    }
  };
}

По итогу globalSettingLib.ts получается:

globalSettingLib.ts
import { ComponentResolverFunction } from "unplugin-vue-components/types";

const setDefaultProps = (component: any, defaultProps: Record<string, any>) =>
  Object.entries(defaultProps).forEach(([propName, propValue]) => {
    if (!Object.prototype.hasOwnProperty.call(component.props, propName))
      return;

    const propBody = Object.prototype.hasOwnProperty.call(
      component.props[propName],
      "type"
    )
      ? component.props[propName]
      : { type: component.props[propName] };

    component.props[propName] = {
      ...propBody,
      default: propValue,
    };
  });

const setPropsUnsafe = (component: any, defaultProps: Record<string, any>) => {
  const setup = component.setup!;

  component.setup = (props: any, ctx: any) => {
    const newProps = { ...props };
    Object.entries(defaultProps).forEach(([propName, propValue]) => {
      if (!Object.prototype.hasOwnProperty.call(component.props, propName))
        return;

      if (
        !Object.prototype.hasOwnProperty.call(
          component.props[propName],
          "default"
        ) ||
        component.props[propName]["default"] === newProps[propName]
      )
        newProps[propName] = propValue;
    });

    return setup(shallowReadonly(shallowReactive(newProps)), ctx);
  };
};

export interface SettingComponent {
  component: Record<string, any>;
  props: Record<string, any>;
  unsafeProps?: boolean;
}

export function settingRun(settings: SettingComponent[]) {
  const names: string[] = [];
  for (const setting of settings) {
    const [[name, component]] = Object.entries(setting.component);
    names.push(name);

    if (setting.unsafeProps) setPropsUnsafe(component, setting.props);
    else setDefaultProps(component, setting.props);
  }
  return names;
}

export function SettingComponentsResolver(
  settings: SettingComponent[],
  from: string
): ComponentResolverFunction {
  const names = settings.map((i) => Object.keys(i.component)[0]);

  return (name: string) => {
    if (names.includes(name)) {
      return {
        name: name,
        from: from,
      };
    }
  };
}

Остается лишь передать resolver внутри vite.config.ts

export default defineConfig({
  // ...other code
  plugins: [
    // ...other code
    Components({
      resolvers: [
        SettingComponentsResolver(settings, "@/plugins/globalSetting"),
        // ...other resolvers
      ],
    }),
  ],
});