项目样式主题切换方法探究

项目主题切换方法探究

背景

目前组内项目有样式主题切换的需求,在和组内同事讨论后,整理出来的需求大致有:

  1. 支持动态切换主题样式
  2. 支持添加新的主题样式
  3. 添加新的主题时尽量少地更改现有代码
  4. 新增主题尽可能便捷

分析

1. 支持动态切换主题样式

主题动态切换基本都是通过 JS 控制 DOM 节点的 class 属性来实现,简而言之就是通过一个 JS 全局变量记录当前主题,并在每次切换主题的时候根据该变量设置项目中所有 DOM 节点的 class 属性。

对于有一定规模的项目而言,由于 DOM 节点众多,直接修改节点 class 属性会变得繁琐。由于项目中使用率 css module,通过 webpack 的 css-loader 将样式文件引入到 js 文件中后会导出一个存放有各个节点真实类名的 styles 对象。如果我们将一个组件在不同主题下的样式分别存放在不同的 css 文件中,我们就可以在 js 代码中通过切换不同主题的 styles 对象来达到动态切换主题的目的。

2. 新增主题

其实需求 2 到需求 4 都是围绕着如何高效便捷地新增主题样式而提出的。根据上一步的分析,我们要在支持动态切换主题的背景下新增主题样式,就需要做两件事情:

  • 对于项目中所有被 js 文件引入的样式文件,比如 .less 文件,都需要创建一个新主题对应的 .less 文件
  • 对于项目中所有引入了样式文件的 js 文件,都需要手动引入新主题的 .less 文件,以供 js 代码中主题动态切换时使用

显然,在大型项目中如果直接手动更新代码,以上两步都会耗费大量时间。考虑到一般不需要在页面上动态新增主题,我们可以考虑通过脚本来实现以上两步的批量执行。

实现

动态切换主题样式

由于我们使用都是 React 技术栈进行开发,故使用 React Hooks 对这一部分逻辑进行封装。

typescript 复制代码
type StyleMap = Record<string, Record<string, string>>

function useTheme(
	styleMap: StyleMap
): [string, Record<string, string>, (value: string) => void] {
	const dispatch = useDispatch()
	const theme = useSelector((state: AppState) => state.theme)

	const onThemeChange = useCallback((value: string) => {
		dispatch(setTheme(value))
	}, [])

	if (!theme) return [theme, styleMap['default'], onThemeChange]
	return [theme, styleMap[theme] || styleMap['default'], onThemeChange]
}

useTheme 的逻辑很简单,参数 styleMap 中包含了调用该 hook 的组件所引入的各个主题对应的 styles 对象,其 key 值为主题名称。通过 redux 中的 theme state 记录当前使用的主题,并通过 onThemeChange 方法实现主题的动态切换。

新增主题

之前我们分析了新增主题中需要脚本化的两步,一是对所有被 js 文件引入的 less 文件 创建一个新主题对应的 less 文件,并修改 less 文件中与颜色相关的样式值。该步骤通过脚本 handleTheme.js 完成。二是在在所有引入了 less 文件的 js 文件中修改 js 源码,新增引入新主题样式文件的代码,该步骤通过自定义的 webpack loader 来完成。以新增一个 dark 主题为例大致流程如下图所示

graph TD S((Start)) --确定主题名 为 dark-->1[创建 theme/darkColorVariables.less] 1-->2[确定 darkColorVariables.less 中各个颜色样式的值] 2-->3[[运行 npm run build && 运行 handleTheme.js 脚本]] 3-->4[遍历 src/*.less] 4-->5{less 文件位于 src/theme/} 5==yes==>4 5==no==>6[拷贝 less文件] 6-->7[less文件中改为引入 darkColorVariable.less] 7-->8[在遍历的 less 文件路径下生成 darkXXX.less 文件] 8-->9{less 文件遍历完毕} 9==yes==>10(dark 主题的所有 less 文件创建完毕) 9==no==>4 10-->11((修改 js 代码)) 11-->12[运行 webpack] 12-->13[[调用 inject-theme-loader]] 13-->14[loader 中在 js 代码中插入import darkStyles from './darkXXX.less'] 14-->15[更改 useTheme 的 styleMap 参数, 加入'dark'] 15-->16[loader 返回更新后的源码] 16-->17[[inject-theme-loader 调用结束]] 17-->18((修改 js 代码完毕)) 18-->19[webpack 后续处理] 19-->20((End))

流程图中除了前三步需要开发者手动操作之外,剩余步骤都是由脚本或者 webpack loader 自动执行

1. 创建新主题的 less 文件

我们使用 nodejs 脚本来完成创建新主题的 less 文件。但这里有个问题,不同主题之间的样式内容肯定是不一样的,而脚本一般只是用来覆盖一些重复的步骤。

一般而言不同主题之间,主要是各种颜色样式的取值不一样,根据对拓展开放对修改封闭的原则(开放封闭原则),新增主题其实新增的只是新主题下各个组件的颜色样式而已。组件之间的关系以及其它类型的样式都是不变的。对此,我们将 less 文件中所有与颜色相关的样式值都提取到以该主题命名的 colorVariables.less 文件中,普通的 less 文件引入 colorVariables.less 文件,通过固定的变量名来获取某个颜色样式的取值。代码如下:

css 复制代码
//colorVariables.less
@containerBgColor: #eee;
@fontColor: #000;
css 复制代码
//index.less
@import './theme/colorVariables.less';

.container {
	width: 100vw;
	height: 100vh;
	background: @containerBgColor;
	padding: 8px 12px;
	:global(.ant-select-selection) {
		border-radius: 0px;
	}
	> header {
		height: 48px;
		display: flex;
		align-items: center;
		justify-content: flex-start;
		> span {
			margin-right: 10px;
		}
	}
	> h1 {
		color: @fontColor;
	}
}

在我们的 demo 中只设置了两个与颜色相关的样式,即 .container 的 background 和 h1 的 color。开发者如果要新增一个 dark 主题。那么只需要在 src/theme/ 目录中新建一个 darkColorVariables.less 文件,将默认的 colorVariables.less 的内容拷贝过去,并手动设置各个颜色变量的取值。创建组件 less 文件的工作就交给下面的脚本来完成。

js 复制代码
const fs = require('fs')
const path = require('path')

//存放所有主题 colorVariables.less 文件的 目录路径
const THEME_PATH = path.resolve(__dirname, '../src/theme')

//根据 theme/ 中各个 colorVariables.less 文件的名称获取所有主题的名称
const themeList = fs
	.readdirSync(THEME_PATH)
	.map(f => f.split('ColorVariables')[0])
const defaultThemeIndex = themeList.findIndex(t => t === 'colorVariables.less')
themeList.splice(defaultThemeIndex, 1)

//将主题名称的列表写入 json 文件,供 自定义 webpack loader 获取
fs.writeFileSync(
	path.resolve(__dirname, 'themes.json'),
	JSON.stringify(themeList)
)

function isDir(v) {
	return fs.lstatSync(v).isDirectory()
}
const traverseDir = (dir, func) => {
	if (dir.endsWith('/theme')) return
	if (!isDir(dir)) func(dir)
	else {
		for (let f of fs.readdirSync(dir)) {
			traverseDir(path.resolve(dir, f), func)
		}
	}
}

//遍历前端代码目录 src/ 找出所有 <filename>.less 文件,并根据主题名称列表依次创建各个主题对应的新的 <themename><filename>.less  文件
//该过程会跳过 src/theme/ 目录
traverseDir('./src', file => {
	const isLess = path.extname(file) === '.less'
	if (!isLess) return

	const dir = path.dirname(file)
	const filename = path.basename(file)
	const source = fs.readFileSync(file, 'utf-8')
	const rows = source.split(/\n|\r\n/g)
	//找到 @import colorVariables.less 的地方
	//替换成 引入当前主题对应的 colorVariables.less 文件的代码
	const importColorVarsIndex = rows.findIndex(
		r => r.startsWith('@import') && r.includes('colorVariables.less')
	)
	if (importColorVarsIndex === -1) return
	const importLine = rows[importColorVarsIndex]
	for (let theme of themeList) {
		const newImportLine = importLine.replace(
			'colorVariables',
			theme + 'ColorVariables'
		)
		const themeStyleSource = [
			...rows.slice(0, importColorVarsIndex),
			newImportLine,
			...rows.slice(importColorVarsIndex + 1)
		].join('\n')
		//将当前主题对应的 less 文件写入文件系统
		fs.writeFileSync(path.resolve(dir, theme + filename), themeStyleSource)
	}
})

从脚本中可以看出,我们对 colorVariables.less 存放的路径以及各个主题对应的 colorVariables 文件命名格式有要求,这些可以后续参数化。

但核心的创建主题对应的 less 文件的功能基本是实现了,当我们在 src/theme/ 新创建 darkColorVariables.less 文件,并修改了颜色变量的取值之后,只要运行上述脚本,项目中所有 文件名.less 文件都会生成一个新的 dark 文件名.less 文件。

2. 修改组件 js 代码

本节需要遍历所有的组件 js 文件,将上一节生成的 darkXXX.less 文件的引入代码插入到各个组件的 js 源码中,并修改 js 源码中 useTheme 的 styleMap 参数的内容。由于项目中使用 webpack,而 webpack 本身就是从 entry 开始遍历项目中被引入的模块(包括 js 和 less 文件),熟悉 webpack 用法的应该都知道 webpack 的 loader 作用就是将引入的各种模块转换为标准的 js 内容,其中也包括处理 js 文件的源码。故我们可以自定义一个 inject-theme-loader 专门负责修改组件的 js 源码。

而熟悉 webpack 工作原理的应该也知道,webpack 先通过 loader 处理模块,再生成 AST(抽象语法树),然后根据 AST 获取模块的各个依赖,最后又通过 loader 处理各个依赖对应的模块,直到所有模块都处理完毕。那么我们在 inject-theme-loader 里面插入的 import darkstyles fromm "./darkXXX.less"代码同样会转换为 AST,然后被 webpack 识别为模块的依赖,最终 darkXXX.less 文件也会被 webpack 处理   inject-theme-loader 在 webpack.config.js 中的配置如下所示

javascript 复制代码
module: {
			rules: [
				{
					test: /\.(js|jsx|ts|tsx)$/,
					exclude: /node_modules/,
					use: [{
						loader: 'babel-loader'
					}, {
						loader: path.resolve(__dirname, './scripts/inject-theme-loader.js'),
						options: {
							themeList: ['dark']
						}
					}]
				},
				...
			]
		}

可以看到所有的 js 文件都会通过 babel-loader 和我们自己创建的 inject-theme-loader 进行处理。由于 inject-theme-loader 在 babel-loader 前面处理(webpack loader 的处理顺序与配置文件中的顺序是相反的),故 inject-theme-loader 拿到的是未经任何 loader 处理的源码,更加方便我们的操作。inject-theme-loader 的源码如下所示

javascript 复制代码
const path = require('path')
const loaderUtils = require('loader-utils')

module.exports = function(source) {
	const options = loaderUtils.getOptions(this)
	const {themeList} = options //从 loader 参数中获取新增主题名称列表
	if (!themeList) return
	//获取当前处理的模块文件名称
	const file = loaderUtils.urlToRequest(this.resourcePath)
	const suffix = path.extname(file)
	//由于我们使用都 typescript + react,故组件代码都是放在 .tsx 文件中
	if (suffix !== '.tsx') return source

	const rows = source.split(/\n|\r\n/g)
	const newRows = []
	const themeMap = {}
	for (let i = 0; i < rows.length; ++i) {
		const line = rows[i]
		newRows.push(line)
		if (line.startsWith('import') && line.includes('.less')) {
			const words = line.split(' ')
			const lessDir = path.dirname(words[words.length - 1])
			const lessFileName = path.basename(words[words.length - 1])
			for (let theme of themeList) {
				const style = theme + 'styles'
				themeMap[theme] = style
				//插入主题对应的 less 文件的 import 代码
				newRows.push(
					'import ' +
						style +
						' from ' +
						lessDir +
						'/' +
						theme +
						lessFileName
				)
			}
		}
	}
	const styleMapLineIndex = newRows.findIndex(
		r => r.includes('stylesMap') && r.includes('default')
	)
	if (styleMapLineIndex === -1) return
	const styleMapLine = newRows[styleMapLineIndex]
	let newLine = styleMapLine.slice(0, styleMapLine.length - 1)
	//修改 useTheme 的 styleMap 参数
	for (let theme in themeMap) {
		newLine += `, ${theme}: ${themeMap[theme]}`
	}
	newLine += '}'
	newRows.splice(styleMapLineIndex, 1, newLine)

	const newSource = newRows.join('\n')
	//返回处理后端源码
	return newSource
}

总结

效果

为了处理方便,我们在 Demo 项目中将 handleTheme.js 脚本的调用放到了 webpack.config.js 中。在手动创建 src/theme/darkColorVariable.less文件,并设置文件内的颜色样式值之后,我们直接执行 npm run build就可以在前端页面中看到效果了。

局限性

  1. 目前主题 colorVariables.less 文件的命名和路径是写死的,而且只考虑了 less 文件
  2. 暂时还未考虑同一个 less 文件被多个 tsx 引入的情况,按照目前的逻辑会导致主题对应的 less 文件重复创建
  3. 对于已有项目,要切换到该方案需要对之前的所有 less 文件进行改动,迁移代价较高
相关推荐
我是谁谁9 分钟前
JavaScript 闭包应用场景详解
前端
LovelyAqaurius10 分钟前
async/await和defer详解
前端
用户669820611298213 分钟前
js setProrperty和setAttribute解析
前端
三希向阳而生蓬勃发展14 分钟前
mac系统 mobaxterm安装
前端
我是谁谁15 分钟前
在 JavaScript 中,call、apply 和 bind 都是用于改变函数执行时的 this 指向的方法。它们的主要区别在于参数传递方式和执行时机。
前端
Java水解16 分钟前
JavaScript 正则表达式
javascript·后端
北凉温华38 分钟前
Vue 3 + AntV X6 实现流程编辑功能
前端·vue.js
独立开阀者_FwtCoder1 小时前
从卡顿到丝滑,AI 应用体验跃升的幕后推手是它!
前端·vue.js·面试
知否技术1 小时前
2025微信小程序开发实战教程(二)
前端·微信小程序
前端小巷子1 小时前
跨标签页通信(一):BroadcastChannel
前端·面试·浏览器