likes
comments
collection
share

从零打造todo:面向对象的状态管理架构、React Tricks、多语言、Antd5多主题

作者站长头像
站长
· 阅读数 10

之前分享了打造todo产品的一些想法由来和基础思维方式,今天分享一些大家能够直接吸收的干货知识。

面向对象的状态管理架构

浅谈状态管理的变迁

最近一段时间,useSignal这种reactive(响应式)的概念很火,大有取代useState之势,事实上,在实际生产项目中,响应式状态管理早已有了比较深的探索。

低代码头部厂商Mendix使用MobX-State-Tree构建他们的低代码界面——基于api请求动态生成state tree,并为react提供响应式的状态&数据管理功能。

在react开发者用的比较多的hooks工具库ahooks中,useReactive作为更强大的useSignal也带给了开发者很多便利。

事实上,每一种技术概念走红的背后,都是为了解决局部的一些“痛点”,useState不好吗?redux以及它的衍生方案RTK(Redux Toolkit)不方便吗?以及在RTK之前的Dva,它不香么?

其实,只要能解决问题,让业务运转顺畅,任何状态管理都是可行的。

刚开始接触react的时候(已经用vue写过一些项目),我其实是讨厌react的,总是下意识地拿vue与它进行比较,结果就是,对于一个react新手一样,vue确实更香,尤其在状态管理方面,vue自带响应式系统,一行代码即可添加一个响应式变量,无需像react的useState那样声明式地些写一行啰嗦的模版代码,对于当时的我而言,在开发体验上,vue是要好过react的。

后来学习了redux,以及它的一堆中间件,还是写起来不舒服,不符合直觉

redux是给一个跛脚的人穿上一双充满“高科技”的鞋子,企图让他健步如飞。

再后来用上了dva,这个时候编程范式发生了变化,渐渐有了model(数据模型)层的概念,写起react也有了更多的体会。

但还不够好用,dva以及后来的RTK虽然带来了model层,但其源自于redux的毛病——啰嗦的模版代码依然摆脱不了。

业务倒逼状态管理进行演化

我有一段在umijs对于状态管理的吐槽,算是比较符合今天谈论的话题:

github.com/umijs/umi/i…

为什么非要整这么麻烦用上面这些“看起来还不稳定”的包,主要是我个人强迫症,喜欢代码整洁点(因为team太小,所以一肩挑所有,没有写单元测试的时间),工程架构上也整洁点,为的是极致的维护性和代码健壮性。

最开始写代码用原始redux,一堆“野生”的actions,代码dirty的不行。后来接触了umi,用上了dva,当时感觉是最好的方案,后来项目写多了,有一些项目很复杂,而dva和umi耦合严重,且都是页面级别的model,虽然dva能够给组件单独使用,但一个是文档,另一个是单独用就和redux差不多了,工程上没啥优势。

后来做了很长时间的调研,使用了一下jotai、zustand这些新兴的状态库,不够用,没法做到脱离react完全“数据驱动”(为什么要做到纯数据驱动,这个与我们业务有很大的关系),后来从Vue获得了灵感,用上了mobx,不得不说,维护class是真的爽多了,但工程上还是有很多依赖react的,跨模型数据共享不能依赖react的context,而且要写法上优雅,不能到处实例化,没法维护。

后来就参考了一下Angular,开始研究依赖注入的方案power-di、inversify这些,power-di太简单,满足不了某些场景,inversify啰里八嗦,不够简洁,最后还是tsyringe,更贴近java的一些语法和概念,更符合我的认知和使用喜欢。

脱离UI库,纯数据驱动是做大型前端项目的必要条件,这样通过架构把业务复杂度打平,把复杂度隐藏到数据模型里,离UI库远点,你不用纠结一些UI库本身的问题,后续业务扩展的时候,扩展数据模型就行了。

运行时类型,有两类,一类是validate,以ajv、joi、jsonschema为代表;另一类是io-ts、zod、yup这类比较完整的类型系统库。上述两类运行时类型库,只有第二类才能拥有全类型能力。

但是同时维护一个zod的类型object和ts相关类型定义,太啰嗦(虽然可以互转,但很多限制,用起来不行),冗余度爆炸,我强迫症,必须clean code,几个月前有了解过deepkit他们做的一些事情,就尝试一下,能不能直接使用ts运行时类型。

做工程就是平衡,平衡架构的健壮性和资源的稳定性,现在JS生态发展这么快,我相信都会有解决方案的。

事实上,mobx + tsyringe的组合是我在做低代码架构时,被业务“倒逼”出来的,第一个版本的低代码状态管理是基于dva的,这个版本的状态管理基本上被业务绑架了,当时定的是“特定的业务类型页面”的概念,每种“类型页面”都绑定一个dva的model,后来业务不断拓展,这种模式的弊病越来越不可接受——类型页面越做越复杂,很多组件它需要自己管理自己的状态,使用dva官方文档不提供直接绑定组件的写法(事实上,dva支持直接绑定组件,但要hack),而在umi项目中dva的模型生成是根据页面来的,组件状态的自管理组件与上层页面的状态管理“脱钩了”,这就导致业务架构没法满足B端低代码业务很多“不合理,极其变态”的需求。

之后“刚好”研究了一下《游戏引擎架构》这本书,领悟到了很多前端领域之上的架构理念,但这些理念可以应用到前端项目中(事实上,游戏引擎也是某种意义上的前端低代码项目,前端真的没有边界,低代码、富文本编辑器、代码编辑器,浏览器端的一些重型应用,Flutter这种引擎,甚至游戏,原神那么多技术美术,都可以算是“前端”,前端的定义没你想象的那么狭窄,只要你愿意去学习,甚至能够自己开辟出一种前端业务形态,所以不要害怕前端没有市场了,相反,前端是离用户离客户最近的岗位,是最好创造价值的岗位,真正没有市场的是一些搬砖的工作,这与前端没有任何关系,ai算力不断强大的时代,任何搬砖的工作都会被取代)。

我调研了市面上的流行的所有状态管理方案以及各个框架的状态管理方案,其中svelte的store令我眼前一亮,jotai和它很相似,但仅限于眼前一亮,状态管理可不是修改修改数据那么简单,复杂应用的状态管理主要包含三个模块:

  • 远程数据的operation
  • 运行时状态的维护
  • 事件总线

如果是巨石应用,还涉及数据模型运行时状态的共享——完善的依赖注入。

无论是jotai还是useState,它们都没法提供一个react之外的,易于维护的数据模型层。

最终确定了mobx + tsyringe + emittery的组合,mobx负责构建数据模型层,tsyringe负责依赖注入,模型间的运行时状态共享,emittery负责构建事件总线。

于此同时,提供一个namespace的概念,每一个类型组件绑定一个特定的namespace,这样运行在当前系统中的组件都能通过这个namespace,使用emittery提供的事件监听,call到数据模型内部的方法。

不需要传一大堆方法给各种组件,事实上,在B端业务中,有些组件,它们之间可能根本没啥关系(平行关系,没法传方法),但业务要求它们之间要产生数据联动,这个时候eventBus就能很好滴解决这类问题。

代码实例

我正在构建的todo应用的mobx数据模型长这样:

GlobalModal:

import { makeAutoObservable } from 'mobx'
import { singleton } from 'tsyringe'

import { SettingModel, UserModel } from '@/models'

@singleton()
export default class GlobalModel {
	constructor(public setting: SettingModel, public user: UserModel) {
		makeAutoObservable(this, {}, { autoBind: true })
	}
}

SettingModal:

import { makeAutoObservable } from 'mobx'
import { injectable } from 'tsyringe'

import { nav_items } from '@/appdata'
import { setGlobalAnimation, setStorageWhenChange } from '@/utils'

import type { Theme } from '@/appdata'

@injectable()
export default class Index {
	theme: Theme = 'light'
	color_main = '#ff0000'
	nav_items = nav_items
	show_bar_title = false

	constructor() {
		makeAutoObservable(this, {}, { autoBind: true })
		setStorageWhenChange(['theme', 'color_main', 'nav_items', 'show_bar_title'], this)

		this.setTheme(this.theme || 'light', true)
		this.setColorMain(this.color_main || '#ff0000')
	}

	setTheme(theme: Theme, initial?: boolean) {
		if (!initial) setGlobalAnimation()

		this.theme = theme

		document.documentElement.setAttribute('data-theme', theme)
		document.documentElement.style.colorScheme = theme
	}

	setColorMain(color: string) {
		this.color_main = color

		document.documentElement.style.setProperty('--color_main', color)
	}
}

UserModal:

import { makeAutoObservable } from 'mobx'
import { genConfig } from 'react-nice-avatar'
import { injectable } from 'tsyringe'

import { setStorageWhenChange } from '@/utils'
import { local } from '@matrixages/knife/storage'

import type { AvatarFullConfig } from 'react-nice-avatar'

@injectable()
export default class Index {
	avatar = {} as AvatarFullConfig

	constructor() {
		makeAutoObservable(this, {}, { autoBind: true })
		setStorageWhenChange(['avatar'], this)

		const avatar = (local.avatar || genConfig()) as AvatarFullConfig

		this.setAvatar(avatar)
	}

	setAvatar(avatar: AvatarFullConfig) {
		this.avatar = avatar
	}
}

在layout.tsx中进行实例化:

import { ConfigProvider } from 'antd'
import { toJS } from 'mobx'
import { observer } from 'mobx-react-lite'
import { useState } from 'react'
import { container } from 'tsyringe'

import { GlobalContext, GlobalModel } from '@/context/app'
import { Outlet } from '@umijs/max'

import Sidebar from './component/Sidebar'
import { useLocales, useTheme } from './hooks'
import styles from './index.css'

import type { IPropsSidebar } from './types'

const Index = () => {
	const [global] = useState(() => container.resolve(GlobalModel))
	const theme = useTheme(global.setting.theme, global.setting.color_main)

	useLocales()

	const props_sidebar: IPropsSidebar = {
		theme: global.setting.theme,
		nav_items: toJS(global.setting.nav_items),
		show_bar_title: global.setting.show_bar_title,
		avatar: global.user.avatar
	}

	return (
		<ConfigProvider prefixCls='if' iconPrefixCls='if-icon' theme={theme}>
			<GlobalContext.Provider value={global}>
				<div className={$cx('w_100 border_box flex', styles._local)}>
					<Sidebar {...props_sidebar} />
					<div className='container'>
						<Outlet />
					</div>
				</div>
			</GlobalContext.Provider>
		</ConfigProvider>
	)
}

export default new $app.handle(Index).by(observer).by($app.memo).get()

这样定义GlobalContext:

import { createContext, useContext } from 'react'

import Model from './model'

export { default as GlobalModel } from './model'

// @ts-ignore Avoid duplicate declarations
export const GlobalContext = createContext<Model>()

export const useGlobal = () => useContext(GlobalContext)

在任意页面通过useGlobal获取global实例:

import { useMemoizedFn } from 'ahooks'
import { observer } from 'mobx-react-lite'
import { Fragment } from 'react'

import { color_mains } from '@/appdata'
import { useGlobal } from '@/context/app'
import { useLocale } from '@/hooks'

const Index = () => {
	const global = useGlobal()
	const l = useLocale()

	const onItem = useMemoizedFn((color: string) => global.setting.setColorMain(color))

	return (
		<Fragment>
			<span className='setting_title'>{l('setting.ColorSelector.title')}</span>
			<div className='setting_items w_100 border_box flex flex_column'>
				<div className='setting_item w_100 border_box flex align_center justify_between'>
					{color_mains.map((item) => (
						<div
							className={$cx(
								'color_item_wrap flex justify_center align_center cursor_point',
								item === global.setting.color_main && 'active'
							)}
							onClick={() => onItem(item)}
							key={item}
						>
							<span
								className='color_item w_100 h_100'
								style={{ backgroundColor: item }}
							></span>
						</div>
					))}
				</div>
			</div>
		</Fragment>
	)
}

export default new $app.handle(Index).by(observer).by($app.memo).get()

这样,一个主题色切换的功能就做好了:

从零打造todo:面向对象的状态管理架构、React Tricks、多语言、Antd5多主题

这就是mobx响应式编程的魅力,把你的业务逻辑都放到模型中,真正做到模型驱动,react仅负责渲染,事实上,这就是标准的MVC架构,熟悉angular、java的同学一定知道,用这种架构写出来的代码,你想写成一坨shit,还是有点难度的。

从架构层面约束开发者写clean code,是让架构不会随着业务的扩大而不断“腐化”的必要条件之一。

上面的代码展示了如何使用tsyringe创建一个单例模式下的GlobalModal,做到全局状态共享,事实上,业务中用的最多的还是多例,关于单例和多例的区别,请自行Google或者问问chatGPT这个程序员最好的老师。

总之,mobx、tsyringe提供了一些能力,让我们能够在react使用面向对象的思想去构建前端业务,对于工程师来说,这种编程思想是比较值得学习的(想想为什么所有游戏引擎全部采用面向对象的编程范式,而非面向过程、面向逻辑的编程范式)。

这种思想你一旦领悟了它的关键,那么一通则百通,你写nodejs,写rust自然而然的会按照这种模式来写,你可以把它看作“内功心法”,想要练就“绝世神功”,这门内功心法是必须要领悟的。

编程就是,数据 + 算法,这不正是面向对象提供的吗?

哦,对了,提醒一下,上面mobx是有用到装饰器语法的,如果不想装babel插件的话,使用swc也是可以的,这么配置即可(reflect的polyfill使用@abraham/reflection可以节省不少大小):

jsc: {
        parser: { syntax: 'typescript', tsx: true, decorators: true, topLevelAwait: true },
        transform: { legacyDecorator: true, decoratorMetadata: true }
}

React Tricks

Tricks可以翻译成“把戏”、“绝活”。看了上面的代码,仔细看一些细节,你可能会有疑惑,那个$app.handle$app.memo是干嘛用的?方法为什么要用ahooks的useMemoizedFn包裹?...

下面,分享一下我这几年,“闭关修炼”的一些心得:

memo

memo方法的实现如下:

import { deepEqual } from 'fast-equals'
import { memo } from 'react'

type Element = JSX.Element | null

type Memo = <T>(el: (props: T) => Element) => React.MemoExoticComponent<(props: T) => Element>

const Index: Memo = (el) => memo(el, (prev, next) => deepEqual(prev, next))

export default Index

什么情况下使用memo?

任何组件都可以用memo包裹,我也建议你既然用了这个方案,那么所有组件都应该使用该方法进行包裹。

会不会有性能问题?

完全不会,作为最快的deepEuqal实现,fast-equals提供的deepEqual在即便当个页面有上百个组件甚至上千个组件时,都不会出现性能问题,这其实是拿“空间”换“时间”,拿memo包裹组件会产生“多余的”内存占用,但却提升了渲染性能,相当于给每个组件上了一道“闸门”,让上层组件在刷新时不会随便影响到子组件,如果你没有那么精力去一个个手写比较函数,那采用这种方式是比较稳妥的办法。

useMemoizedFn

传递函数依然会导致重渲染?

这个问题就是useMemoizedFn大展拳脚的时候了,我记得去年还是前年react有过useEvent的提案,事实上useMemoizedFn就是useEvent,它是万能的useCallback,不需要你填写deps参数,所有方法都可以使用它进行包裹,但不建议,它的作用是保持唯一方法的唯一引用,同时函数内部能获取到deps的最新值,它的开销可不小(具体开销的讨论请Google),所以不建议什么方法都包裹。

只有当你需要把这个方法作为参数传递给子组件时,必要对方法使用useMemoizedFn进行包裹(尽量不要用useCallback,否则会产生“意想不到”的效果哦~,生命有限,别用useCallback),这样子组件获取到的方法,它的引用一直都是不变的,如果你不进行包裹的话,即便子组件使用了memo进行包裹,memo在比较同一个具有不同引用的方法时,是会判断为不相等的,也就会导致子组件重渲染。

handle.by

事实上,JS是有pipe operator提案的,而且也有对应的babel插件,但语法警告这个问题好像没啥解决方案,不能全用@ts-ignore吧,对于强迫症如我来说,这不合适。

其实自己写一个专门用于组件的管道操作的函数也不难:

type Element = JSX.Element | null

export default class Index<T> {
	private el: (props: T) => Element

	constructor(el: (props: T) => Element) {
		this.el = el
	}

	public by(fn: Function) {
		this.el = fn.call(this, this.el)

		return this
	}

	get() {
		return this.el
	}
}

用的时候这么用:export default new $app.handle(Index).by(observer).by($app.memo).get()

useDeepMemo

import { deepEqual } from 'fast-equals'
import { useRef } from 'react'

export default <TKey, TValue>(memoFn: () => TValue, key: TKey): TValue => {
	const ref = useRef<{ key: TKey; value: TValue }>()

	if (!ref.current || !deepEqual(key, ref.current.key)) {
		ref.current = { key, value: memoFn() }
	}

	return ref.current.value
}

useMemo就不用说了,在一些复杂的交互中必要使用的,useDeepMemo作为性能优化的手段更是不可或缺,人生苦短,用useDeepMemo(对于一些方法作为deps的场景,配合useMemoizedFn食用更佳)。

mobx sync localstorage

从零打造todo:面向对象的状态管理架构、React Tricks、多语言、Antd5多主题

上面划线的这档代码是将标记的那些类属性与localStorage进行实时同步,实现如下:

import { autorun, get, set } from 'mobx'

import { local } from '@matrixages/knife/storage'

export default (keys: Array<string>, instance: any) => {
	keys.map((key) => {
		const local_value = local.getItem(key)

		if (local_value) set(instance, key, local_value)
	})

	autorun(() => keys.map((key) => local.setItem(key, get(instance, key))))
}

核心就是mobx提供的getset方法,这不比jotai还有zustand提供的编程范式灵活多了,当然,这里只是一个例子,事实上,基于mobx提供的getset等一系列工具函数,你能够扩充出很多基于装饰器的nb写法来。

emittery

emittery这个库让你能够使用eventBus的方式调用Promise方法,基于此,你能够设计出前端领域的actionflow:

async save(data: TableType.SaveRequest) {
        const { res, err } = await this.form.save<SaveRequest, SaveResponse>(this.model, data)

        if (err) return Promise.reject()

        return Promise.resolve(res)
}

on() {
        window.$app.Event.on(`${this.namespace.value}/save`, this.save)
}

off() {
        window.$app.Event.off(`${this.namespace.value}/save`, this.save)
}

在其他地方可以这么调用const res = await window.$app.Event.emit('form/save',data),这个是一般的eventBus办不到的(注意,这里使用的emittery是基于原仓库魔改过的,正常的emittery是支持promise,但没有promise返回值)。

上述一些方案可以在这个开源仓库看到源码。

一些好用的库

  • ahooks react项目必备
  • await-to-js const [err,res] = await find()
  • react-if 在react中使用IFELSE标签
  • framer-motion 基本上所有动画效果都可以用它来实现,而且是以react的方式
  • lodash-es lodash的es版本,不再需要babel插件来处理import了
  • nice-try 更加优雅滴进行try catch,如果可以的话,请自行对它进行改写以满足更多的功能
  • zod 元编程领域的新秀库
  • @editorjs/editorjs 基于block的块级编辑器,据我所知,很多某某“文档”都参考过它的实现
  • rollup-plugin-swc3 使用swc对tsx?进行打包、压缩,支持metadata和paths,而且很快。
  • tsx 直接执行ts代码,写脚本利器,基于esbuild,快的不像话(不支持metadata)

ts-pattern

为什么把ts-pattern单独拿出来说,因为他确实很强大,如果你用的好,能够直接让你的编程范式更加的“结构化”和具有可读性、可测试性,如果对模式匹配不是特别了解的,可以去看一下rust的match模式匹配是如何写代码的.

import { match, P } from 'ts-pattern';

type Data =
  | { type: 'text'; content: string }
  | { type: 'img'; src: string };

type Result =
  | { type: 'ok'; data: Data }
  | { type: 'error'; error: Error };

const result: Result = ...;

return match(result)
  .with({ type: 'error' }, () => `<p>Oups! An error occured</p>`)
  .with({ type: 'ok', data: { type: 'text' } }, (res) => `<p>${res.data.content}</p>`)
  .with({ type: 'ok', data: { type: 'img', src: P.select() } }, (src) => `<img src=${src} />`)
  .exhaustive();

这是官方readme的例子,其中包含了模式匹配、类型校验,必选参数,不仅仅在编写react相关代码,在用ts写nodejs时,ts-pattern能让你的代码更加具有可读性和保证类型安全。

可以说,它是ts版本的rust match实现。

多语言

这里主要讲的是如何让编写多语言相关代码时有key的提示。

直接上代码:

useLocale.ts

import { useCallback } from 'react'

import { useIntl } from '@umijs/max'

import type { App } from '@/types'
import type { MessageDescriptor } from 'react-intl'

export default () => {
	const { locale, formatMessage } = useIntl()

	return useCallback(
		(id: App.LocaleKeys, options?: MessageDescriptor & { values: Record<string, string> }) => {
			const { values, ...desc } = options || {}

			return formatMessage({ id, ...desc }, values)
		},
		[locale]
	)
}

react-intl是不支持对象形式的intl数据源的,但是如果不写成对象的话,一旦字段多了维护起来就很困难,使用flat 可将对象打平:

locales/en-US.ts

import flat from 'flat'

import { nav_title, setting } from './en-US/index'

export default flat({
	nav_title,
	setting
})

locales/en-US/setting.ts

export default {
	Normal: {
		title: 'Setting',
		language: {
			title: 'Language'
		},
		theme: {
			title: 'Theme',
			options: {
				light: 'light',
				dark: 'dark'
			}
		},
		show_bar_title: {
			title: 'Bar Title',
			options: {
				hide: 'hide',
				show: 'show'
			}
		}
	},
	NavItems: {
		title: 'Feature'
	},
	ColorSelector: {
		title: 'Theme Color'
	}
}

数据是“打平”了,类型是不是也得“打平”才能获得类型提示呢:

import { nav_title, setting } from './en-US/index'

const locales = {
	nav_title,
	setting
} as const

export type ObjectLocales = typeof locales
import type { ObjectLocales } from '@/locales/_types'

import type { Flatten } from '@matrixages/knife/types'

export namespace App {
	export type Locales = Flatten<ObjectLocales>
	export type LocaleKeys = keyof Locales
}

最关键的Flatten泛型函数:

// Returns R if T is a function, otherwise returns Fallback
type IsFunction<T, R, Fallback = T> = T extends (...args: any[]) => any ? R : Fallback

// Returns R if T is an object, otherwise returns Fallback
type IsObject<T, R, Fallback = T> = IsFunction<T, Fallback, T extends object ? R : Fallback>

// "a.b.c" => "b.c"
type Tail<S> = S extends `${string}.${infer T}` ? Tail<T> : S

// typeof Object.values(T)
type Value<T> = T[keyof T]

// {a: {b: 1, c: 2}} => {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}}
type FlattenStepOne<T> = {
	[K in keyof T as K extends string ? IsObject<T[K], `${K}.${keyof T[K] & string}`, K> : K]: IsObject<
		T[K],
		{ [key in keyof T[K]]: T[K][key] }
	>
}

// {"a.b": {b: 1, c: 2}, "a.c": {b: 1, c: 2}} => {"a.b": {b: 1}, "a.c": {c: 2}}
type FlattenStepTwo<T> = {
	[a in keyof T]: IsObject<T[a], Value<{ [M in keyof T[a] as M extends Tail<a> ? M : never]: T[a][M] }>>
}

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.c": {d: 1}}
type FlattenOneLevel<T> = FlattenStepTwo<FlattenStepOne<T>>

// {a: {b: 1, c: {d: 1}}} => {"a.b": 1, "a.b.c.d": 1}
export type Flatten<T> = T extends FlattenOneLevel<T> ? T : Flatten<FlattenOneLevel<T>>

顺便吐槽一下,这个函数我问了chatGPT一个小时,愣是一直给错误答案,还是stackoverflow给出了正确结果。

从零打造todo:面向对象的状态管理架构、React Tricks、多语言、Antd5多主题

这样,key组装好了,也都获取到了,在代码里这么用即可:

从零打造todo:面向对象的状态管理架构、React Tricks、多语言、Antd5多主题

多主题

antd5让多主题再也不要提前编译一堆主题css文件了,在antd4时,我是这么搞的

import child_process from 'child_process'
import fs from 'fs'
import lessToJs from 'less-vars-to-js'
import path from 'path'

const antd_theme_path = path.join(process.cwd(), `/node_modules/antd/dist/antd.variable.less`)
const light_theme_path = path.join(process.cwd(), `/styles/theme/light.less`)
const dark_theme_path = path.join(process.cwd(), `/styles/theme/dark.less`)
const init_style_path = path.join(process.cwd(), `/public/styles/init.css`)
const atom_style_path = path.join(process.cwd(), `/public/styles/atom.min.css`)
const output_path = path.join(process.cwd(), `/public/theme`)
const custom_antd_path = path.join(process.cwd(), `/styles/preset/antd.less`)

const light_theme = lessToJs(fs.readFileSync(light_theme_path, 'utf8'))
const dark_theme = lessToJs(fs.readFileSync(dark_theme_path, 'utf8'))

const getModifyVarsString = (theme: any) => {
	return Object.keys(theme).reduce((total, key: string) => {
		const value = theme[key]

		total += `--modify-var="${key}=${value}" `

		return total
	}, '')
}

const light_vars = getModifyVarsString(light_theme)
const dark_vars = getModifyVarsString(dark_theme)

const compile = (vars: string, type: 'light' | 'dark', shadow?: boolean) => {
	child_process.execSync(`lessc --js ${vars} ${antd_theme_path} ${output_path}/${type}.${shadow ? 's' : 'c'}ss`)
}

compile(light_vars, 'light')
compile(dark_vars, 'dark')
compile(light_vars, 'light', true)
compile(dark_vars, 'dark', true)
child_process.execSync(`lessc --js ${custom_antd_path} ${output_path}/custom_antd_styles.css`)

用lessc打包出了对应的主题文件,然后代码里面动态切换,这种方式弊端很多:产生多余的体积,样式优先级问题,好在,antd5基于cssinjs实现的主题,更改主题不要太方便:

getAntdTheme.ts

import { match } from 'ts-pattern'

import common_antd from './common/antd'
import dark from './dark'
import light from './light'

import type { ThemeConfig } from 'antd'
import type { Theme } from '@/appdata'

export default (theme: Theme, color_main: string) => {
	const vars = match(theme)
		.with('light', () => light)
		.with('dark', () => dark)
		.exhaustive()

	return {
		token: {
			...common_antd.token,
			colorPrimary: color_main,
			colorTextBase: vars.color_text,
			colorBgBase: vars.color_bg,
			colorBgContainer: vars.color_bg,
			colorBgElevated: vars.color_bg,
			colorBgLayout: vars.color_bg_1,
			colorBorder: vars.color_border_light,
			colorBorderSecondary: vars.color_border_soft,
			controlItemBgActive: vars.color_bg_2,
			colorPrimaryHover: vars.color_text_grey,
			colorPrimaryTextHover: vars.color_text_grey,
			switchHeight: 34
		}
	} as ThemeConfig
}

useTheme.ts

import { useMemo } from 'react'

import { getAntdTheme } from '@/theme'

import type { Theme } from '@/appdata'

export default (theme: Theme, color_main: string) => {
	return useMemo(() => getAntdTheme(theme, color_main), [theme, color_main])
}

从社区里来,到社区里去。