【源码学习】第25期 | vant4是如何实现一键换肤的?

1,485 阅读3分钟

背景

    主题切换简称一键换肤,相信在我们日常开发跟生活中都有接触到,实现的方案也有多种,今天就跟着vant4学习一下如何实现一键换肤~

任务清单

  • 暗黑主题的实现跟原理
  • iframe postMessage 和 addEventListener 通信
  • ConfigProvider 组件 CSS 变量实现对主题的深度定制原理

源码下载

git clone https://github.com/youzan/vant.git
cd vant
pnpm install
pnpm run dev

    先从下面的gif图感受一下vant4的一键换肤

theme.gif

源码分析

1. desktop 端

    利用vue-devtools打开源码位置,要注意是App1

file.gif

    关键源码

  • 模板
// 代码有删减
<li v-if="darkModeClass" class="van-doc-header__top-nav-item">
  <a class="van-doc-header__link" target="_blank" @click="toggleTheme">
    <img :src="themeImg" />
  </a>
</li>
  • js代码

import { getDefaultTheme, syncThemeToChild } from '../../common/iframe-sync';

export default {
  name: 'VanDocHeader',

  props: {
    lang: String,
    config: Object,
    versions: Array,
    langConfigs: Array,
    darkModeClass: String,
  },

  data() {
    return {
      currentTheme: getDefaultTheme(),
    };
  },

  computed: {

    themeImg() {
      if (this.currentTheme === 'light') {
        return 'https://b.yzcdn.cn/vant/dark-theme.svg';
      }
      return 'https://b.yzcdn.cn/vant/light-theme.svg';
    },
  },

  watch: {
    currentTheme: {
      handler(newVal, oldVal) {
        window.localStorage.setItem('vantTheme', newVal);
        document.documentElement.classList.remove(`van-doc-theme-${oldVal}`);
        document.documentElement.classList.add(`van-doc-theme-${newVal}`);
        syncThemeToChild(newVal);
      },
      immediate: true,
    },
  },

  methods: {
    toggleTheme() {
      this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
    },
  },
};

    由以上代码可以看出,vant4的desktop 端主要是通过监听主题变量currentTheme来动态添加/删除主题类名的来达到一键换肤的效果,接着看一下getDefaultTheme,和syncThemeToChild的实现:

getDefaultTheme

export function getDefaultTheme() {
  const cache = window.localStorage.getItem('vantTheme');

  if (cache) {
    return cache;
  }

  const useDark =
    window.matchMedia &&
    window.matchMedia('(prefers-color-scheme: dark)').matches;
  return useDark ? 'dark' : 'light';
}

    通过媒体查询window.matchMedia检测prefers-color-scheme属性【用于判断用户是否有将系统的主题色设置为亮色或者暗色】

图片.png

syncThemeToChild

export function syncThemeToChild(theme) {
  const iframe = document.querySelector('iframe');
  if (iframe) {
    iframeReady(() => {
      iframe.contentWindow.postMessage(
        {
          type: 'updateTheme',
          value: theme,
        },
        '*'
      );
    });
  }
}

    先判断iframe是否加载完成,再利用window.postMessage实现跨域通信,其中contentWindow属性返回当前HTMLIFrameElementWindow对象。window.postMessage的用法如下,既然要实现通信,这里的postMessage是发送消息,那么怎么接收消息呢,其实就是getDefaultTheme下面的useCurrentTheme方法:

图片.png

useCurrentTheme()

export function useCurrentTheme() {
  const theme = ref(getDefaultTheme());

  window.addEventListener('message', (event) => {
    if (event.data?.type !== 'updateTheme') {
      return;
    }

    const newTheme = event.data?.value || '';
    theme.value = newTheme;
  });

  return theme;
}

    上面的代码就是监听分发的message,从而替换主题变量的值 图片.png     接着全局搜索一下useCurrentTheme,发现vant/packages/vant-cli/site/mobile/App.vue有用到这个方法:

图片.png     回到咱们的控制台,发现这个文件正好是右侧手机端的入口,desktop 端的手机模拟器是用iframe包裹着的,这也是前面咱们那个gif图切换深色主题时手机模拟器也会跟着切换的效果,接着分析一下mobile端深色主题切换的效果实现~

图片.png

mobile端

App.vue

import { watch } from 'vue';
import DemoNav from './components/DemoNav.vue';
import { useCurrentTheme } from '../common/iframe-sync';
import { config } from 'site-mobile-shared';

export default {
  components: { DemoNav },

  setup() {
    const theme = useCurrentTheme();

    watch(
      theme,
      (newVal, oldVal) => {
        document.documentElement.classList.remove(`van-doc-theme-${oldVal}`);
        document.documentElement.classList.add(`van-doc-theme-${newVal}`);

        const { darkModeClass, lightModeClass } = config.site;
        if (darkModeClass) {
          document.documentElement.classList.toggle(
            darkModeClass,
            newVal === 'dark'
          );
        }
        if (lightModeClass) {
          document.documentElement.classList.toggle(
            lightModeClass,
            newVal === 'light'
          );
        }
      },
      { immediate: true }
    );
  },
};

    这里实现的主要就是监听分发的主题变量,从而给class 集合动态切换类名,这便是深色浅色主题切换的实现原理,最后我们来看一下# ConfigProvider切换深色模式的实现

ConfigProvider

  • 利用dev-tools打开源码所在位置:

configprovider.gif

ConfigProvider.tsx

  • 引用依赖及导出类型
import {
  watch,
  provide,
  computed,
  watchEffect,
  onActivated,
  onDeactivated,
  onBeforeUnmount,
  defineComponent,
  type PropType,
  type InjectionKey,
  type CSSProperties,
  type ExtractPropTypes,
} from 'vue';
import {
  extend,
  inBrowser,
  kebabCase,
  makeStringProp,
  createNamespace,
  type Numeric,
} from '../utils';
import { setGlobalZIndex } from '../composables/use-global-z-index';

const [name, bem] = createNamespace('config-provider');

export type ConfigProviderTheme = 'light' | 'dark';

export type ConfigProviderProvide = {
  iconPrefix?: string;
};

export const CONFIG_PROVIDER_KEY: InjectionKey<ConfigProviderProvide> =
  Symbol(name);

export type ThemeVars = PropType<Record<string, Numeric>>;

export const configProviderProps = {
  tag: makeStringProp<keyof HTMLElementTagNameMap>('div'),
  theme: makeStringProp<ConfigProviderTheme>('light'),
  zIndex: Number,
  themeVars: Object as ThemeVars,
  themeVarsDark: Object as ThemeVars,
  themeVarsLight: Object as ThemeVars,
  iconPrefix: String,
};

export type ConfigProviderProps = ExtractPropTypes<typeof configProviderProps>;
  • mapThemeVarsToCSSVars
function mapThemeVarsToCSSVars(themeVars: Record<string, Numeric>) {
  const cssVars: Record<string, Numeric> = {};
  Object.keys(themeVars).forEach((key) => {
    cssVars[`--van-${kebabCase(key)}`] = themeVars[key];
  });
  return cssVars;
}

    遍历themeVars的key值并将其转化为cssVars,具体用法也可以参考测试用例

图片.png

  • 组件
export default defineComponent({
  name,

  props: configProviderProps,


  setup(props, { slots }) {
  // 可以在这里打断点
  debugger;
    const style = computed<CSSProperties | undefined>(() =>
      mapThemeVarsToCSSVars(
        extend(
          {},
          props.themeVars,
          props.theme === 'dark' ? props.themeVarsDark : props.themeVarsLight
        )
      )
    );

    if (inBrowser) {
      const addTheme = () => {
        document.documentElement.classList.add(`van-theme-${props.theme}`);
      };
      const removeTheme = (theme = props.theme) => {
        document.documentElement.classList.remove(`van-theme-${theme}`);
      };

      watch(
        () => props.theme,
        (newVal, oldVal) => {
          if (oldVal) {
            removeTheme(oldVal);
          }
          addTheme();
        },
        { immediate: true }
      );

      onActivated(addTheme);
      onDeactivated(removeTheme);
      onBeforeUnmount(removeTheme);
    }

    provide(CONFIG_PROVIDER_KEY, props);

    watchEffect(() => {
      if (props.zIndex !== undefined) {
        setGlobalZIndex(props.zIndex);
      }
    });

    return () => (
      <props.tag class= { bem() } style = { style.value } >
        { slots.default?.() }
        < /props.tag>
    );
  },
});

    不难看出这里主要通过css变量来实现动态切换主题的效果

总结

    至此,vant4的一键换肤学习就算告一段落了,主要是通过控制css变量来达到换肤的效果,其中iframe的跨域通信在日常开发中的相同场景也可以借鉴应用。