Angular Material 官方实现主题切换的实现研究

摘要:本文通过研究 Angular Material 的官方文档站 的源码,学习了其实现主题切换的方法。

feature image

原理概述

这里的原理十分简单,是通过动态替换整个网页的主题 css 文件来实现的,我们可以通过以下的 GIF 图来直观的感受一下:

the basic theory of angular material theme switch

通过切换主题,可以看到网页源码中的 css 在变化,当选择默认主题时,这个 css 的链接消失,从而实现了主题的切换。

SCSS 相关主题文件及编译

对于如何使用主题,我们可以通过官方文档学习。实现切换主题是基于基础配置的。首先我们来看 scss 文件的结构:

  1. styles.scss 配置默认主题,并引用主题文件
  2. _app-theme.scss 主要的主题配置文件,一方面配置全局的和主题相关的 css,另一方面引用 Component 中主题相关的配置,请参考Angular Material 组件定制主题
  3. assets/custom-themes/purple-green.scss 各个主题配置文件,每个主题单独一个文件,其配置和配置默认主题一致,不过这里需要加入 _app-theme.scss 的依赖,也就是说各个主题下,同样要应用 _app-theme.scss 的配置。实际上这些文件和 styles.scss 处于同等地位。

有了这些文件,基本的主题 SCSS 文件就准备好,下面我们需要编译生成 csss 的主题文件。

这里需要注意编译命令所执行的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash
# because this shell will be execute from package.json, so the path should be in the root folder
DEST_PATH=src/assets
INPUT_PATH=$DEST_PATH/custom-themes/

echo Building custom theme scss files.

# Get the files
FILES=$(find src/assets/custom-themes -name "*.scss")

for FILE in $FILES
do
FILENAME=${FILE#$INPUT_PATH}
BASENAME=${FILENAME%.scss}
$(npm bin)/node-sass $FILE > $DEST_PATH/$BASENAME.css
done

echo Finished building CSS.

该文件 build-themes.sh 放置于 src/tools 下面,但是我们不能执行,需要在 package.json 中加入命令来执行: "build-themes": "bash ./tools/build-themes.sh", 为什么呢?因为 scss 文件又相互引用的关系,引用中使用了相对路径,这样就导致了路径基准需要一致,所以该命令需要在 src 目录下执行。 所以最好的方法就是在 package.json 中执行命令,这样以后集成部署都很方便。

编译完成之后我们会发现在 asset 目录下多处理若干个 css 文件,这些文件和不同主题是相对应的。其内容不仅包含了顶层主题的配置,还包含了各个模块自定义的和主题相关的css

StyleManager

该组件是主题切换的核心,我们来一下,该服务是如何实现动态管理主题的,其中的核心方法是 setStyleremoveStyle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Injectable()
export class StyleManager {
/**
* Set the stylesheet with the specified key.
*/
setStyle(key: string, href: string) {
getLinkElementForKey(key).setAttribute('href', href);
}

/**
* Remove the stylesheet with the specified key.
*/
removeStyle(key: string) {
const existingLinkElement = getExistingLinkElementByKey(key);
if (existingLinkElement) {
document.head.removeChild(existingLinkElement);
}
}
}

通过源码我们可以看到,这里 setStyle 就是将指定主题元素的 DOM 元素的 href 连接动态设为所指定的主题。而这个方法就是根据主题的key 去寻找指定元素的 DOM,该方法同时还要兼容默认主题的处理。 如果是默认主题,实际上这个DOM 应该不存在,所以我们需要处理没有该 DOM 的情形。

ThemePicker

相面我们来介绍一下主题切换器的实现方法。

Theme 模型和其存储

1
2
3
4
5
6
7
export interface SiteTheme {
href: string;
accent: string;
primary: string;
isDark?: boolean;
isDefault?: boolean;
}

这里使用 localStorage 来存储主题选择,这样可以在不清空缓存的情况下保持用户的主题选择。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Injectable()
export class ThemeStorage {
static storageKey = 'site-theme-storage-current';

public onThemeUpdate: EventEmitter<SiteTheme> = new EventEmitter<SiteTheme>();

public storeTheme(theme: SiteTheme) {
try {
window.localStorage[ThemeStorage.storageKey] = JSON.stringify(theme);
} catch (e) { }

this.onThemeUpdate.emit(theme);
}

public getStoredTheme(): SiteTheme {
try {
return JSON.parse(window.localStorage[ThemeStorage.storageKey] || null);
} catch (e) {
return null;
}
}

public clearStorage() {
try {
window.localStorage.removeItem(ThemeStorage.storageKey);
} catch (e) { }
}
}

Theme Picker 的实现

有了 styleManager 和主题的数据结构及存储方法,最终的主题选择器比较简单。

只需要一个下拉菜单,通过点击不同的主题来使用 styleManager 来配置相应的主题,同时进行储存即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
installTheme(theme: SiteTheme) {
this.currentTheme = this._getCurrentThemeFromHref(theme.href);

if (theme.isDefault) {
this.styleManager.removeStyle('theme');
} else {
this.styleManager.setStyle('theme', `assets/${theme.href}`);
}

if (this.currentTheme) {
this._themeStorage.storeTheme(this.currentTheme);
}
}

项目配置

当增加了新的 component theme style,需要重新编译样式文件。