前言
- 本文参加了由 公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第38期,链接:juejin.cn/post/715823...
项目克隆和启动
1. 按照下面的命令启动项目
shell
## 1. 克隆官网库
git clone git@github.com:vant-ui/vant.git
## 2. 安装依赖
pnpm i
## 3. 启动
pnpm dev
2. 项目界面

源码分析
1. 点击头部主题切换按钮源码分析

- 主题切换按钮模板代码
绑定了一个toggleTheme事件
ini
<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逻辑
获取默认的currentTheme和切换currentTheme的值
根据currentTheme的值获取动态图标
监听currentTheme的值变化,给html标签注入van-doc-theme-dark和van-doc-theme-light类, 这2个类中声明了各自的css变量,
javascript
data() {
return {
currentTheme: getDefaultTheme() // 读取默认值
};
},
computed() {
themeImg() {
// 根据currentTheme 显示不同的图标
if (this.currentTheme === 'light') {
return 'https://b.yzcdn.cn/vant/dark-theme.svg';
}
return 'https://b.yzcdn.cn/vant/light-theme.svg';
},
},
watch: {
// 监听currentTheme的值
currentTheme: {
handler(newVal, oldVal) {
// 将newVal存到本地localStorage里面
window.localStorage.setItem('vantTheme', newVal);
// html标签移除van-doc-theme-${oldVal}类
document.documentElement.classList.remove(`van-doc-theme-${oldVal}`);
// html标签注入van-doc-theme-${oldVal}类
document.documentElement.classList.add(`van-doc-theme-${newVal}`);
// 处理内嵌mobile端样式,详情见下面的2.syncThemeToChild源码分析
syncThemeToChild(newVal);
},
immediate: true,
},
},
methods: {
// 按钮模板代码中绑定的切换按钮事件,更新currentTheme值
toggleTheme() {
this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
}
}
- getDefaultTheme逻辑
如果localStorage里面存在vantTheme值,则返回
获取系统设置的主题色,如果是黑色系的,则返回dark
javascript
export function getDefaultTheme() {
// 如果localStorage里面存在vantTheme值,则返回
const cache = window.localStorage.getItem('vantTheme');
if (cache) {
return cache;
}
// 获取系统设置的主题色,如果是黑色系的,则返回dark
const useDark =
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
return useDark ? 'dark' : 'light';
}
- css模块
注入全局css变量
van-doc-theme-light和van-doc-theme-dark样式内部注入各自的变量
:root
→ 全局变量(普通页面用)
:host
→ Web Components / Shadow DOM 内部变量 同时写 → 确保样式变量在所有场景里都可用(全局 + 组件)。
css
:root,
:host {
// colors
--van-doc-black: #000;
--van-doc-white: #fff;
--van-doc-gray-1: #f7f8fa;
--van-doc-gray-2: #f2f3f5;
--van-doc-gray-3: #ebedf0;
--van-doc-gray-4: #dcdee0;
--van-doc-gray-5: #c8c9cc;
--van-doc-gray-6: #969799;
--van-doc-gray-7: #646566;
--van-doc-gray-8: #323233;
--van-doc-blue: #1989fa;
--van-doc-green: #07c160;
--van-doc-purple: #8e69d3;
// sizes
--van-doc-padding: 32px;
--van-doc-row-max-width: 1680px;
--van-doc-nav-width: 220px;
--van-doc-border-radius: 20px;
--van-doc-simulator-width: 360px;
--van-doc-simulator-height: 620px;
--van-doc-header-top-height: 64px;
// fonts
--van-doc-code-font-family: 'Menlo', 'Source Code Pro', 'Monaco',
'Inconsolata', monospace;
}
.van-doc-theme-light {
// text
--van-doc-text-color-1: var(--van-doc-black);
--van-doc-text-color-2: var(--van-doc-gray-8);
--van-doc-text-color-3: #34495e;
--van-doc-text-color-4: var(--van-doc-gray-6);
--van-doc-link-color: var(--van-doc-blue);
// background
--van-doc-background: #eff2f5;
--van-doc-background-2: var(--van-doc-white);
--van-doc-background-3: var(--van-doc-white);
--van-doc-header-background: #011f3c;
--van-doc-border-color: var(--van-doc-gray-2);
// code
--van-doc-code-color: #58727e;
--van-doc-code-comment-color: var(--van-doc-gray-6);
--van-doc-code-background: var(--van-doc-gray-1);
// blockquote
--van-doc-blockquote-color: #2f85da;
--van-doc-blockquote-background: #ecf9ff;
}
.van-doc-theme-dark {
// text
--van-doc-text-color-1: var(--van-doc-white);
--van-doc-text-color-2: rgba(255, 255, 255, 0.9);
--van-doc-text-color-3: rgba(255, 255, 255, 0.75);
--van-doc-text-color-4: rgba(255, 255, 255, 0.6);
--van-doc-link-color: #1bb5fe;
// background
--van-doc-background: #202124;
--van-doc-background-2: rgba(255, 255, 255, 0.06);
--van-doc-background-3: rgba(255, 255, 255, 0.1);
--van-doc-header-background: rgba(1, 31, 60, 0.3);
--van-doc-border-color: #3a3a3c;
// code
--van-doc-code-color: rgba(200, 200, 200, 0.85);
--van-doc-code-comment-color: var(--van-doc-gray-7);
--van-doc-code-background: rgba(0, 0, 0, 0.24);
// blockquote
--van-doc-blockquote-color: #bae6fd;
--van-doc-blockquote-background: rgba(7, 89, 133, 0.25);
}
2.syncThemeToChild源码分析
Mobile UI是通过iframe内嵌的,外层PC端主题色更新了,如何通知内嵌的Mobile UI更新样式呢 ?具体需要看syncThemeToChild源码
-
syncThemeToChild源码解析
获取iframe页面元素 把一个回调函数注入到iframeReady函数中去,回调函数中向iframe.contentWindow postMessage一个对象,要求更新主题对象
外层页面的
window
→ 控制当前文档
iframe.contentWindow
→ 控制 iframe 内部文档的window
javascript
export function syncThemeToChild(theme) {
const iframe = document.querySelector('iframe');
if (iframe) {
iframeReady(() => {
把一个消息对象发送给 iframe 内的页面,让它去处理
iframe.contentWindow.postMessage(
{
type: 'updateTheme',
value: theme,
},
'*',
);
});
}
}
- iframeReady源码
如果isIframeReady变量为true, 则直接调用callback函数,否则将callback函数push到queue中
在当前window中注入一个message事件,如果监听到iframeReady事件类型,则设置isIframeReady为true且执行queue里面的函数
ini
let queue = [];
let isIframeReady = false;
function iframeReady(callback) {
if (isIframeReady) {
callback();
} else {
queue.push(callback);
}
}
// 在当前window中注入一个message事件,如果监听到iframeReady事件,则设置isIframeReady为true且执行queue里面的函数
if (window.top === window) {
window.addEventListener('message', (event) => {
if (event.data.type === 'iframeReady') {
isIframeReady = true;
queue.forEach((callback) => callback());
queue = [];
}
});
} else {
如果这段代码在iframe内部执行的,则向父级window发送postMessage事件
window.top.postMessage({ type: 'iframeReady' }, '*');
}
- mobile端什么时候向父级window传递iframeReady
也就是mobile端什么执行如下代码?
bash
window.top.postMessage({ type: 'iframeReady' }, '*')
如图所示,mobile的router.js会导入iframe-sync.js函数,浏览器就会执行该文件内部的所有顶层代码,所以就实现了mobile发送postMessage事件
- mobile端APP.vue代码
mobile端切换主题相关逻辑和PC端类型,watch theme的值变化,然后更新对应的html
xml
<template>
<demo-nav />
<router-view v-slot="{ Component }">
<demo-section>
<keep-alive>
<component :is="Component" />
</keep-alive>
</demo-section>
</router-view>
</template>
<script>
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 },
);
},
};
</script>
<style lang="less">
@import '../common/style/base';
body {
min-width: 100vw;
background-color: inherit;
}
.van-doc-theme-light {
background-color: var(--van-doc-gray-1);
}
.van-doc-theme-dark {
background-color: var(--van-doc-black);
}
::-webkit-scrollbar {
width: 0;
background: transparent;
}
</style>
- mobile端App.vue获取theme函数useCurrentTheme分析
ini
export function useCurrentTheme() {
// 获取theme默认值并声明为响应式数据
const theme = ref(getDefaultTheme());
// 当前window监听一个message事件,当获取到data.type === 'updateTheme'时,更新theme的值
window.addEventListener('message', (event) => {
if (event.data?.type !== 'updateTheme') {
return;
}
const newTheme = event.data?.value || '';
theme.value = newTheme;
});
return theme;
}
ConfigProvider组件解读
介绍
Vant Weapp 组件通过丰富的 CSS 变量 来组织样式,通过覆盖这些 CSS 变量,可以实现定制主题、动态切换主题等效果。
typescript
// 返回一个cssVars变量
function mapThemeVarsToCSSVars(themeVars: Record<string, Numeric>) {
const cssVars: Record<string, Numeric> = {};
Object.keys(themeVars).forEach((key) => {
const formattedKey = insertDash(kebabCase(key));
cssVars[`--van-${formattedKey}`] = themeVars[key];
});
return cssVars;
}
function syncThemeVarsOnRoot(
newStyle: Record<string, Numeric> = {},
oldStyle: Record<string, Numeric> = {},
) {
// 在documentElement的style属性中添加prop
Object.keys(newStyle).forEach((key) => {
if (newStyle[key] !== oldStyle[key]) {
document.documentElement.style.setProperty(key, newStyle[key] as string);
}
});
// 移除documentElement的style属性中历史prop
Object.keys(oldStyle).forEach((key) => {
if (!newStyle[key]) {
document.documentElement.style.removeProperty(key);
}
});
}
export default defineComponent({
name,
props: configProviderProps,
setup(props, { slots }) {
// style对象
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 },
);
// 组件激活时添加van-theme-light 或者 van-theme-dark
onActivated(addTheme);
// 组件激活时去除van-theme-light 或者 van-theme-dark
onDeactivated(removeTheme);
onBeforeUnmount(removeTheme);
// 监听style属性的变化,然后添加到documentElement上面
watch(style, (newStyle, oldStyle) => {
if (props.themeVarsScope === 'global') {
syncThemeVarsOnRoot(
newStyle as Record<string, Numeric>,
oldStyle as Record<string, Numeric>,
);
}
});
watch(
() => props.themeVarsScope,
(newScope, oldScope) => {
if (oldScope === 'global') {
// remove on Root
syncThemeVarsOnRoot({}, style.value as Record<string, Numeric>);
}
if (newScope === 'global') {
// add on root
syncThemeVarsOnRoot(style.value as Record<string, Numeric>, {});
}
},
);
if (props.themeVarsScope === 'global') {
// add on root
syncThemeVarsOnRoot(style.value as Record<string, Numeric>, {});
}
}
provide(CONFIG_PROVIDER_KEY, props);
watchEffect(() => {
if (props.zIndex !== undefined) {
setGlobalZIndex(props.zIndex);
}
});
return () => (
<props.tag
class={bem()}
style={props.themeVarsScope === 'local' ? style.value : undefined}
>
// 默认显示default
{slots.default?.()}
</props.tag>
);
},
});
总结
对主题定制知识点更加清晰,之前对于这方面知识一直是模糊和一知半解的状态,加油拉~