前言
本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
这是源码共读的第33期,链接:【若川视野 x 源码共读】第41期 | vant 4 正式发布了,支持暗黑主题,那么是如何实现的呢
准备工作
点击github图标,进入vant的代码仓库,一探究竟。
下载源码
首先查看README和Contribution Guide,了解项目的基本运行方式。
然后执行下面命令将仓库下载到本地,git clone git@github.com:youzan/vant.git。
进入vant,在根目录下看到pnpm相关文件,vant是采用pnpm实现的monorepo架构组织代码的,更推荐使用pnpm安装依赖,pnpm install。
查看根目录下package.json, 执行pnpm dev启动项目。
如上操作,你可以在浏览器中看到 http://localhost:5173/#/zh-CN 显示官网页面。
定位代码
本篇主旨是了解主题色切换如何实现的。在monorepo架构这类UI库的项目结构里,没有传统vue项目显式的有一个index.html根元素去挂载,因为我们先来稍微分析一下代码的目录结构。
启动指令"dev": "pnpm --dir ./packages/vant dev",定位到packages下的vant文件夹,执行它的dev命令。
packages/vant下package.json里dev指令:"dev": "vant-cli dev",vant-cli是packages下一个脚手架包,它在devDependencies中安装了"@vant/cli": "workspace:*",这里知识点涉及到monorepo,可以查看pnpm官网来了解。
找到vant-cli文件夹,还是先看package.json,对于这种工具库,提供一个bin字段。
"bin": {
"vant-cli": "./bin.js"
},
查看npm官网解释bin的作用,简单来说,就是在安装依赖包时,如果该包的 package.json 文件有 bin 字段,就会在node_modules文件夹下面的 .bin 目录中复制了 bin字段链接的执行文件。我们在调用执行文件时,可以不带路径,直接使用命令名来执行相对应的执行文件。
那这里的命令名是vant-cli,在vant文件夹下安装vant-cli依赖包,就可以了直接使用vant-cli dev, 在当前node_modules下也可以看到vant-cli
所以,我们的焦点应该放在vant-cli这个文件夹,探究两件事:
- 如何
vant-cli dev就可以执行的? - 怎么执行完就打开了官网页面?
当然了,也不能忘了本篇中心主旨主题色切换,因为这个操作是在官网右上角实现的,所以还是先要找到官网页面,一步一步来揭开面纱。
vant-cli 脚手架
"vant-cli": "./bin.js"指向的bin文件
import './lib/cli.js';
但是lib文件夹是打包之后生成的,如果没运行项目是不会有这个目录存在。
国际惯例,核心代码放在src下,src下有个同名的cli.ts, 找到dev指令对象的代码
program
.command('dev')
.description('Run dev server')
.action(async () => {
const { dev } = await import('./commands/dev.js');
return dev();
});
执行了commands下的dev文件中dev方法。
import { setNodeEnv } from '../common/index.js';
import { compileSite } from '../compiler/compile-site.js';
export async function dev() {
setNodeEnv('development');
await compileSite();
}
dev方法里先设置了node环境,然后执行了compileSite方法,那再来定位compiler文件夹下compile-site.js文件。
export async function compileSite(production = false) {
await genSiteEntry();
if (production) {
// ...
} else {
const config = await mergeCustomViteConfig(
getViteConfigForSiteDev(),
'development',
);
const server = await createServer(config);
await server.listen(config.server?.port);
const require = createRequire(import.meta.url);
const { version } = require('vite/package.json');
const viteInfo = color.cyan(`vite v${version}`);
console.log(`\n ${viteInfo}` + color.green(` dev server running at:\n`));
server.printUrls();
}
}
dev方法里调用的compileSite没有传参,采用的还是默认参数false情况,这里只看else中逻辑。简单来说:
- 通过
mergeCustomViteConfig方法生成了vite打包需要的配置参数 - 调用
vite的createServer启动服务,并监听端口 - 将vite版本号和启动的目标地址,输出到控制台,显示给用户
因此在控制台执行pnpm dev命令会出现如下画面
那明显关键在于这个config里都是些什么。
vite config
mergeCustomViteConfig方法来自于
import { mergeCustomViteConfig } from '../common/index.js';
定位common文件夹下index.js
export async function mergeCustomViteConfig(config: InlineConfig, mode: 'production' | 'development'): Promise<InlineConfig> {
const vantConfig = getVantConfig();
const configureVite = vantConfig.build?.configureVite;
const userConfig = await loadConfigFromFile(
{ mode, command: mode === 'development' ? 'serve' : 'build'},
undefined,
process.cwd(),
);
if (configureVite) {
const ret = configureVite(config);
if (ret) {
config = ret;
}
}
if (userConfig) {
return mergeConfig(config, userConfig.config);
}
return config;
}
它接收两个参数,1. config配置项,2. mode模式
获取vantConfig
首先获取vantConfig,那先找到getVantConfig这个方法,它定位在同级的constant.js里,相关代码如下:
async function getVantConfigAsync() {
try {
// https://github.com/nodejs/node/issues/31710
// absolute file paths don't work on Windows
return (await import(pathToFileURL(VANT_CONFIG_FILE).href)).default;
} catch (err) {
return {};
}
}
const vantConfig = await getVantConfigAsync();
export function getVantConfig() {
return vantConfig;
}
关键(await import(pathToFileURL(VANT_CONFIG_FILE).href)).default,为了引入VANT_CONFIG_FILE这个文件中的内容,文件路径的相关代码如下
export const CWD = process.cwd();
export const ROOT = findRootDir(CWD);
export const VANT_CONFIG_FILE = join(ROOT, 'vant.config.mjs');
在我本地打印出来的地址D:\readSourceCode\vant-ui\vant\packages\vant\vant.config.mjs, 因存放目录而异。
回到packages/vant下,vant.config.mjs文件,
export default {
name: 'vant',
build: {
...
},
site: {
...
}
};
内容相当的多,这里就不占据过多篇幅,整体看来就是官网站点的一些布局信息。
通过断点调试,也能看出这儿输出的config就是vant.config.mjs文件的内容,
config对象
获取到vantConfig对象之后,那configureVite就是undefined,并没有找到configureVite
接下来的userConfig为null
因为mergeCustomViteConfig方法返回的就是config参数,而在最外层调用时
const config = await mergeCustomViteConfig(
getViteConfigForSiteDev(),
'development',
);
config就是getViteConfigForSiteDev方法返回的。
import { getViteConfigForSiteDev, getViteConfigForSiteProd } from '../config/vite.site.js';
定位到config文件夹下vite.site.js文件
export function getViteConfigForSiteDev(): InlineConfig {
setBuildTarget('site');
const vantConfig = getVantConfig();
const siteConfig = getSiteConfig(vantConfig);
const title = getTitle(siteConfig);
const headHtml = vantConfig.site?.headHtml;
const baiduAnalytics = vantConfig.site?.baiduAnalytics;
const enableVConsole = isDev() && vantConfig.site?.enableVConsole;
return {
root: SITE_SRC_DIR,
optimizeDeps: {
// https://github.com/youzan/vant/issues/10930
include: ['vue', 'vue-router'],
},
plugins: [
vitePluginGenVantBaseCode(),
vitePluginVue({
include: [/.vue$/, /.md$/],
}),
vitePluginMd(),
vitePluginJsx(),
vitePluginHTML({
...siteConfig,
title,
// `description` is used by the HTML ejs template,
// so it needs to be written explicitly here to avoid error: description is not defined
description: siteConfig.description,
headHtml,
baiduAnalytics,
enableVConsole,
meta: getHTMLMeta(vantConfig),
}),
],
server: {
host: '0.0.0.0',
},
};
}
该方法返回了vite的配置项,其中root指定了项目根目录,在vite官网上来具体了解,SITE_SRC_DIR在我本地打印:"D:\readSourceCode\vant-ui\vant\packages\vant-cli\site",那下一步的焦点应该来到了site文件夹了。
对于其他的,vantConfig的获取上文已经讲到,这里主要是做了些上文获取的config对象的重组工作。
例如,title通过getTitle方法将原有对象中title和description拼接在了一起,
function getTitle(config: { title: string; description?: string }) {
let { title } = config;
if (config.description) {
title += ` - ${config.description}`;
}
return title;
}
所以打开官网,标签页如下展示:
其实更重要的是插件的使用。
vitePluginGenVantBaseCode和vitePluginMd是库作者自己实现的,可以新开一篇学习。
vitePluginHTML传入的对象,通过断点调试打印出来看看
site站点
现在定位到了官网站点的相关代码了,代码位置:packages/vant-cli/site/index.html
插件vitePluginHTML传入的对象,在html中可以通过<%= key %>来展示,例如传入了title,展示就是<title><%= title %></title>
script中引入了/desktop/main.js,是熟悉的vue应用了。
针对本文要讨论的主题色切换,官网中切换按钮在头部的右上角,分析desktop下目录结构,可以定位到相关代码在desktop/components/Header.vue
<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>
darkModeClass主题class变量名是通过父组件传入,在App.vue中,
<van-doc
v-if="config"
:lang="lang"
:config="config"
:versions="versions"
:simulator="simulator"
:has-simulator="hasSimulator"
:lang-configs="langConfigs"
:dark-mode-class="darkModeClass"
>
<router-view />
</van-doc>
import { config } from 'site-desktop-shared';
data() {
return {
hasSimulator: true,
darkModeClass: config.site.darkModeClass,
};
},
这里就是上文讲到的vitePluginGenVantBaseCode插件实现的,在load中调用了site-desktop-shared方法, 代码定位vant-cli/src/compiler/gen-site-desktop-shared.ts
function genExportDocuments(items: DocumentItem[]) {
return `export const documents = {
${items.map((item) => item.name).join(',\n ')}
};`;
}
function genExportConfig() {
return 'export { config };';
}
export function genSiteDesktopShared() {
const dirs = readdirSync(SRC_DIR);
const documents = resolveDocuments(dirs);
const code = `${genImportDocuments(documents)}
${genVantConfigContent()}
${genExportConfig()}
${genExportDocuments(documents)}
${genExportVersion()}
`;
return code;
}
genSiteDesktopShared方法里主要处理了文件数据的获取,通过resolveDocuments方法获取组件的相关md文件,为官网站点各组件tab分发数据。
可以简单理解成,通过config对象确定了官网的结构,然后再将各组件目录下对应的组件使用说明文档再对应填入,形成一个文档网站。
切换主题色
在主题色切换按钮上绑定了一个事件toggleTheme。
toggleTheme() {
this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
},
data中定义的currentTheme设置了默认值,
data() {
return {
currentTheme: getDefaultTheme(),,
};
},
找到getDefaultTheme方法的代码位置,
import { getDefaultTheme, syncThemeToChild } from '../../common/iframe-sync';
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';
}
做了两个判断:
- 判断本地缓存是否存在
vantTheme,存在的话直接取这个值 - 本地缓存没有的话,判断window.matchMedia是否存在,进而使用
Window.matchMedia(prefers-color-scheme)监听系统主题是否为暗主题,判断不成立的话默认返回亮主题
获取到默认主题色之后,也会在实时监听currentTheme变化,
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,
},
},
监听currentTheme的变化,且是立即执行的,意味着当第一次进入页面的时候,获取到默认主题色getDefaultTheme()返回的是亮色light,这时候就会存入本地localStorage。
然后就是移除html上原有class主题类名,添加上新的。
同步修改mobile主题色
syncThemeToChild方法是同步修改iframe里的主题色,也就是移动端框内的主题色。
syncThemeToChild方法如下:
let queue = [];
let isIframeReady = false;
function iframeReady(callback) {
if (isIframeReady) {
callback();
} else {
queue.push(callback);
}
}
export function syncThemeToChild(theme) {
const iframe = document.querySelector('iframe');
if (iframe) {
iframeReady(() => {
iframe.contentWindow.postMessage(
{
type: 'updateTheme',
value: theme,
},
'*',
);
});
}
}
iframeReady方法里,默认isIframeReady为false走else逻辑,那消息事件这个回调事件存入了queue队列中。
当前文件中会执行以下代码,循环执行了queue中回调函数,
if (window.top === window) {
window.addEventListener('message', (event) => {
if (event.data.type === 'iframeReady') {
isIframeReady = true;
queue.forEach((callback) => callback());
queue = [];
}
});
} else {
window.top.postMessage({ type: 'iframeReady' }, '*');
}
有发送消息,就会有监听接收。在desktop文件夹同级目录的mobile里,App.vue中:
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 },
);
},
useCurrentTheme方法定位代码位置,
import { useCurrentTheme } from '../common/iframe-sync';
具体方法如下:
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;
}
内置css变量
官网站点,文档内容部分是内置了两种主题色的css变量名,根据顶层的变量名会切换不同css样式。由此可见,Vant4采用的主题色切换方案就是css变量 + 类名切换。
主题切换方案有很多,常用的有下面几种:
- link标签动态引入
- 提前引入所有主题样式,做类名切换
- css变量 + 类名切换
- v-bind(vue3新特性)
- scss + mixin + 类名切换
- css变量 + 动态setProperty
各方案之间优缺点不同,可以参考这篇文章详细了解,前端主题切换方案
主题色切换时,组件也会跟随改变,组件内部也是内置了两套主题的css变量,以Button按钮组件为例来看看。
先来看亮主题下
默认按钮样式中,background绑定的css变量是--van-button-default-background
点击变量名,定位到这个变量名是定义在root上,它又绑定了变量名--van-background-2
再定位变量,最终找到了背景色是白色#fff
再来看暗主题下
background绑定的变量名仍然叫做--van-button-default-background
区别就在于--van-background-2的值,
那为什么是在这个van-theme-dark下呢,是因为切换了暗主题,mobile这个iframe的顶层绑定了这个类名,
那回到Button代码中,在button/index.less中可以查看到root上定义的--van-button-default-background设置的值就是var(--van-background-2)
全局搜一下--van-background-2是在哪里定义的,直接这样搜会发现很多地方引用到了这个变量,在后面加个冒号--van-background-2:就可以轻松定位到定义的位置了。
在packages/vant/src/style/css-variables.less中,就可以查看到如上截图中定义在root上的那些变量。
总结
一句话总结,Vant4官网采用了css变量和类名切换实现了主题色的切换。
刚接触到一个陌生且大体量的项目,因此本文更多的篇幅在按步骤介绍,通过查看README,贡献指南文档和package.json,了解一个项目的启动方式,再层层深入,抽丝剥茧的一步一步来分析项目中代码,最终定位到我们需求目标实现的代码。