背景
主题切换简称一键换肤,相信在我们日常开发跟生活中都有接触到,实现的方案也有多种,今天就跟着vant4学习一下如何实现一键换肤~
任务清单
- 暗黑主题的实现跟原理
- iframe postMessage 和 addEventListener 通信
- ConfigProvider 组件 CSS 变量实现对主题的深度定制原理
源码下载
git clone https://github.com/youzan/vant.git
cd vant
pnpm install
pnpm run dev
先从下面的gif图感受一下vant4的一键换肤
源码分析
1. desktop 端
利用vue-devtools打开源码位置,要注意是App1
关键源码
- 模板
// 代码有删减
<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属性【用于判断用户是否有将系统的主题色设置为亮色或者暗色】
syncThemeToChild
export function syncThemeToChild(theme) {
const iframe = document.querySelector('iframe');
if (iframe) {
iframeReady(() => {
iframe.contentWindow.postMessage(
{
type: 'updateTheme',
value: theme,
},
'*'
);
});
}
}
先判断iframe是否加载完成,再利用window.postMessage实现跨域通信,其中contentWindow
属性返回当前HTMLIFrameElement的Window对象。window.postMessage的用法如下,既然要实现通信,这里的postMessage是发送消息,那么怎么接收消息呢,其实就是getDefaultTheme下面的useCurrentTheme方法:
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,从而替换主题变量的值
接着全局搜索一下useCurrentTheme,发现
vant/packages/vant-cli/site/mobile/App.vue
有用到这个方法:
回到咱们的控制台,发现这个文件正好是右侧手机端的入口,desktop 端的手机模拟器是用iframe包裹着的,这也是前面咱们那个gif图切换深色主题时手机模拟器也会跟着切换的效果,接着分析一下mobile端深色主题切换的效果实现~
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.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,具体用法也可以参考测试用例
- 组件
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的跨域通信在日常开发中的相同场景也可以借鉴应用。