源码赏析·探究Vant4官网主题色切换实现细节

2,625 阅读7分钟

前言

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

这是源码共读的第33期,链接:【若川视野 x 源码共读】第41期 | vant 4 正式发布了,支持暗黑主题,那么是如何实现的呢

准备工作

Vant4官网

点击github图标,进入vant的代码仓库,一探究竟。

下载源码

首先查看READMEContribution 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/vantpackage.jsondev指令:"dev": "vant-cli dev"vant-clipackages下一个脚手架包,它在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这个文件夹,探究两件事:

  1. 如何vant-cli dev就可以执行的?
  2. 怎么执行完就打开了官网页面?

当然了,也不能忘了本篇中心主旨主题色切换,因为这个操作是在官网右上角实现的,所以还是先要找到官网页面,一步一步来揭开面纱。

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中逻辑。简单来说:

  1. 通过mergeCustomViteConfig方法生成了vite打包需要的配置参数
  2. 调用vitecreateServer启动服务,并监听端口
  3. 将vite版本号和启动的目标地址,输出到控制台,显示给用户

因此在控制台执行pnpm dev命令会出现如下画面

微信图片_20230928150149.png

那明显关键在于这个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方法将原有对象中titledescription拼接在了一起,

function getTitle(config: { title: string; description?: string }) {
  let { title } = config;
  if (config.description) {
    title += ` - ${config.description}`;
  }
  return title;
}

所以打开官网,标签页如下展示:

其实更重要的是插件的使用。

vitePluginGenVantBaseCodevitePluginMd是库作者自己实现的,可以新开一篇学习。

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';
}

做了两个判断:

  1. 判断本地缓存是否存在vantTheme,存在的话直接取这个值
  2. 本地缓存没有的话,判断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方法里,默认isIframeReadyfalseelse逻辑,那消息事件这个回调事件存入了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变量 + 类名切换。

主题切换方案有很多,常用的有下面几种:

  1. link标签动态引入
  2. 提前引入所有主题样式,做类名切换
  3. css变量 + 类名切换
  4. v-bind(vue3新特性)
  5. scss + mixin + 类名切换
  6. 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,了解一个项目的启动方式,再层层深入,抽丝剥茧的一步一步来分析项目中代码,最终定位到我们需求目标实现的代码。