feat(vue3): 新增style-helper

This commit is contained in:
CodiceFabbrica 2026-07-07 13:44:47 +08:00
parent fa3615bbcf
commit 0e68849e8e
6 changed files with 838 additions and 116 deletions

View File

@ -0,0 +1,116 @@
/** Vue class 对象格式,键为类名,值为布尔值表示是否生效 */
type CustomClassObj = Record<string, boolean>;
/** Vue class 支持的类型:字符串、字符串数组或对象 */
type CustomClass = string | Array<string> | CustomClassObj;
/** 分解后的 Vue class 对象,所有类名值为 true */
type DistCustomClass = Record<string, true>;
/**
* Vue class
*
* `{ className: true }`
* Vue class
*
* @example
* ```ts
* createCustomClassObj('foo bar') // { foo: true, bar: true }
* createCustomClassObj(['foo', 'bar']) // { foo: true, bar: true }
* ```
*
* @param customClass
* @returns true
* @throws customClass TypeError
*/
export function createCustomClassObj(customClass: string): DistCustomClass;
export function createCustomClassObj(
customClass: Array<string>,
): DistCustomClass;
export function createCustomClassObj(
customClass: string | Array<string>,
): DistCustomClass {
let customClassObj = <DistCustomClass>{};
if (typeof customClass === "string") {
const customClassStr = customClass.trim().replace(" +", " ");
if (customClassStr.length > 0) {
const customClassEntries = customClassStr
.split(" ")
.map((name) => [name, true]);
customClassObj = Object.fromEntries(customClassEntries);
}
} else if (Array.isArray(customClass)) {
if (customClass.length > 0) {
const customClassEntries = customClass.map((name) => [name, true]);
customClassObj = Object.fromEntries(customClassEntries);
}
} else {
throw new TypeError(
`customClass只能是字符串或数组类型customClass: ${customClass}`,
);
}
return customClassObj;
}
/**
* Vue class Vue class
*
* Vue class
* `{ className: boolean }`
*
* @example
* ```ts
* convertCustomClass('foo bar') // { foo: true, bar: true }
* convertCustomClass(['foo', 'bar']) // { foo: true, bar: true }
* convertCustomClass({ foo: true, bar: false }) // { foo: true, bar: false }
* ```
*
* @param sourceClass Vue class
* @returns Vue class
* @throws sourceClass Vue class TypeError
*/
export function convertCustomClass(sourceClass: CustomClass): CustomClassObj {
let customClassObj = <CustomClassObj>{};
if (typeof sourceClass === "string") {
const customClassStr = sourceClass.trim().replace(" +", " ");
if (customClassStr.length > 0) {
const customClassEntries = customClassStr
.split(" ")
.map((name) => [name, true]);
customClassObj = Object.fromEntries(customClassEntries);
}
} else if (Array.isArray(sourceClass)) {
if (sourceClass.length > 0) {
const customClassEntries = sourceClass.map((name) => [name, true]);
customClassObj = Object.fromEntries(customClassEntries);
}
} else if (sourceClass instanceof Object) {
customClassObj = sourceClass;
} else {
throw new TypeError(
`sourceClass不是有效的vue classsourceClass: ${sourceClass}`,
);
}
return customClassObj;
}
/**
* Vue class
*
* Vue class
* class
*
* @example
* ```ts
* mergeClass('foo', ['bar'], { baz: true })
* // { foo: true, bar: true, baz: true }
* ```
*
* @param customClass Vue class
* @returns Vue class
*/
export function mergeClass(...customClass: CustomClass[]) {
return customClass
.map((cc) => convertCustomClass(cc))
.reduce((a, b) => Object.assign(a, b), {});
}

View File

@ -9,122 +9,8 @@
import { ComponentPublicInstance } from "vue";
/** Vue class 对象格式,键为类名,值为布尔值表示是否生效 */
type CustomClassObj = Record<string, boolean>;
/** Vue class 支持的类型:字符串、字符串数组或对象 */
type CustomClass = string | Array<string> | CustomClassObj;
/** 分解后的 Vue class 对象,所有类名值为 true */
type DistCustomClass = Record<string, true>;
/**
* Vue class
*
* `{ className: true }`
* Vue class
*
* @example
* ```ts
* createCustomClassObj('foo bar') // { foo: true, bar: true }
* createCustomClassObj(['foo', 'bar']) // { foo: true, bar: true }
* ```
*
* @param customClass
* @returns true
* @throws customClass TypeError
*/
export function createCustomClassObj(customClass: string): DistCustomClass;
export function createCustomClassObj(
customClass: Array<string>,
): DistCustomClass;
export function createCustomClassObj(
customClass: string | Array<string>,
): DistCustomClass {
let customClassObj = <DistCustomClass>{};
if (typeof customClass === "string") {
const customClassStr = customClass.trim().replace(" +", " ");
if (customClassStr.length > 0) {
const customClassEntries = customClassStr
.split(" ")
.map((name) => [name, true]);
customClassObj = Object.fromEntries(customClassEntries);
}
} else if (Array.isArray(customClass)) {
if (customClass.length > 0) {
const customClassEntries = customClass.map((name) => [name, true]);
customClassObj = Object.fromEntries(customClassEntries);
}
} else {
throw new TypeError(
`customClass只能是字符串或数组类型customClass: ${customClass}`,
);
}
return customClassObj;
}
/**
* Vue class Vue class
*
* Vue class
* `{ className: boolean }`
*
* @example
* ```ts
* convertCustomClass('foo bar') // { foo: true, bar: true }
* convertCustomClass(['foo', 'bar']) // { foo: true, bar: true }
* convertCustomClass({ foo: true, bar: false }) // { foo: true, bar: false }
* ```
*
* @param sourceClass Vue class
* @returns Vue class
* @throws sourceClass Vue class TypeError
*/
export function convertCustomClass(sourceClass: CustomClass): CustomClassObj {
let customClassObj = <CustomClassObj>{};
if (typeof sourceClass === "string") {
const customClassStr = sourceClass.trim().replace(" +", " ");
if (customClassStr.length > 0) {
const customClassEntries = customClassStr
.split(" ")
.map((name) => [name, true]);
customClassObj = Object.fromEntries(customClassEntries);
}
} else if (Array.isArray(sourceClass)) {
if (sourceClass.length > 0) {
const customClassEntries = sourceClass.map((name) => [name, true]);
customClassObj = Object.fromEntries(customClassEntries);
}
} else if (sourceClass instanceof Object) {
customClassObj = sourceClass;
} else {
throw new TypeError(
`sourceClass不是有效的vue classsourceClass: ${sourceClass}`,
);
}
return customClassObj;
}
/**
* Vue class
*
* Vue class
* class
*
* @example
* ```ts
* mergeClass('foo', ['bar'], { baz: true })
* // { foo: true, bar: true, baz: true }
* ```
*
* @param customClass Vue class
* @returns Vue class
*/
export function mergeClass(...customClass: CustomClass[]) {
return customClass
.map((cc) => convertCustomClass(cc))
.reduce((a, b) => Object.assign(a, b), {});
}
export * from "./class-helper";
export * from "./style-helper";
/**
*

View File

@ -0,0 +1,115 @@
import type { CSSProperties, StyleValue } from "vue";
/**
* Vue style CSSProperties
*
*
* - value style string / CSSProperties / Array<StyleValue>
* - overrides style
*
*
* 1. style normalize overrides
* 2. overrides null / undefined / "" / false使 value
* 3.
*
* Vue normalizeStyle props customStyle
*/
export function normalizeStyle(
value: StyleValue,
overrides?: StyleValue,
): CSSProperties {
const base = toCSSProperties(value);
if (overrides == null) return base;
const over = toCSSProperties(overrides);
const result: Record<string, string | number> = {};
const allKeys = new Set([
...Object.keys(base as Record<string, unknown>),
...Object.keys(over),
]);
for (const key of allKeys) {
const overVal = (over as Record<string, unknown>)[key];
const baseVal = (base as Record<string, unknown>)[key];
if (!isEmptyStyleValue(overVal)) {
// overrides 有值,优先使用
result[key] = overVal as string | number;
} else if (!isEmptyStyleValue(baseVal)) {
// overrides 为空base 有值,使用 base
result[key] = baseVal as string | number;
}
// 两者都为空,跳过
}
return result as CSSProperties;
}
/**
* null / undefined / "" / false
*/
function isEmptyStyleValue(val: unknown): boolean {
return val == null || val === "" || val === false;
}
/**
* StyleValue CSSProperties
*/
function toCSSProperties(value: StyleValue): CSSProperties {
if (Array.isArray(value)) {
const res: CSSProperties = {};
for (let i = 0; i < value.length; i++) {
const item = value[i];
const normalized =
typeof item === "string"
? parseStringStyle(item)
: toCSSProperties(item as StyleValue);
if (normalized) {
const target = res as Record<string, string | number>;
const source = normalized as Record<string, string | number>;
for (const key in source) {
target[key] = source[key];
}
}
}
return res;
}
if (typeof value === "string") {
return parseStringStyle(value);
}
if (value != null && typeof value === "object") {
return value as CSSProperties;
}
return {};
}
/** 分号分隔正则,排除括号内的分号 */
const listDelimiterRE = /;(?![^(]*\))/;
/** 属性值分隔正则 */
const propertyDelimiterRE = /:([\s\S]+)/;
/** CSS 注释正则 */
const styleCommentRE = /\/\*[\s\S]*?\*\//g;
/**
* CSS CSSProperties
* Vue parseStringStyle
*/
function parseStringStyle(cssText: string): CSSProperties {
const ret: Record<string, string> = {};
cssText
.replace(styleCommentRE, "")
.split(listDelimiterRE)
.forEach((item) => {
if (item) {
const tmp = item.split(propertyDelimiterRE);
if (tmp.length > 1) {
ret[tmp[0].trim()] = tmp[1].trim();
}
}
});
return ret as CSSProperties;
}

View File

@ -0,0 +1,134 @@
import { describe, expect, it, vi } from "vitest";
import { dispatch } from "../src/vue-helper";
/**
* Vue ComponentPublicInstance
*/
function createMockInstance(
componentName?: string,
parent?: any,
root?: any,
) {
const instance = {
$parent: parent ?? null,
$root: root ?? null,
$options: { componentName: componentName ?? null },
$emit: vi.fn(),
};
return instance;
}
describe("dispatch", () => {
it("应在祖先组件中找到匹配的 componentName 并触发事件", () => {
const targetComponentName = "FormComponent";
const grandParent = createMockInstance(targetComponentName);
const parent = createMockInstance(undefined, grandParent, grandParent);
const thisArg = createMockInstance(undefined, parent, grandParent);
// thisArg.$parent → parent, parent.$parent → grandParent
parent.$parent = grandParent;
dispatch(thisArg, targetComponentName as any, "validate", { field: "name" });
expect(grandParent.$emit).toHaveBeenCalledWith("validate", { field: "name" });
});
it("应从直接父组件开始查找", () => {
const targetComponentName = "DirectParent";
const parent = createMockInstance(targetComponentName);
const thisArg = createMockInstance(undefined, parent, parent);
dispatch(thisArg, targetComponentName as any, "change", null);
expect(parent.$emit).toHaveBeenCalledWith("change", null);
});
it("当没有父组件且有根组件时,应从根组件开始查找", () => {
const targetComponentName = "RootComponent";
const root = createMockInstance(targetComponentName);
const thisArg = createMockInstance(undefined, null, root);
dispatch(thisArg, targetComponentName as any, "init", undefined);
expect(root.$emit).toHaveBeenCalledWith("init", undefined);
});
it("当 thisArg.$parent 和 thisArg.$root 都为 null 时,应直接返回", () => {
const thisArg = createMockInstance(undefined, null, null);
const targetComponentName = "FormComponent" as any;
// 不应抛出错误
expect(() => dispatch(thisArg, targetComponentName, "validate", {})).not.toThrow();
});
it("当找不到匹配的祖先组件时,不应触发任何事件", () => {
const grandParent = createMockInstance("OtherComponent");
const parent = createMockInstance("AnotherComponent", grandParent, grandParent);
const thisArg = createMockInstance(undefined, parent, grandParent);
dispatch(thisArg, "NonExistentComponent" as any, "validate", {});
expect(grandParent.$emit).not.toHaveBeenCalled();
expect(parent.$emit).not.toHaveBeenCalled();
});
it("应在多级父组件链中找到匹配的祖先", () => {
const targetComponentName = "GrandGrandParent";
const grandGrandParent = createMockInstance(targetComponentName);
const grandParent = createMockInstance("GrandParent", grandGrandParent, grandGrandParent);
const parent = createMockInstance("Parent", grandParent, grandGrandParent);
const thisArg = createMockInstance(undefined, parent, grandGrandParent);
dispatch(thisArg, targetComponentName as any, "submit", { data: "test" });
expect(grandGrandParent.$emit).toHaveBeenCalledWith("submit", { data: "test" });
expect(grandParent.$emit).not.toHaveBeenCalled();
expect(parent.$emit).not.toHaveBeenCalled();
});
it("找到的第一个匹配组件后,不应继续向上查找", () => {
const targetComponentName = "Parent";
const grandParent = createMockInstance(targetComponentName); // 更上层也有同名组件
const parent = createMockInstance(targetComponentName, grandParent, grandParent);
const thisArg = createMockInstance(undefined, parent, grandParent);
dispatch(thisArg, targetComponentName as any, "click", true);
// 应该触发 parent 的事件,而非 grandParent 的
expect(parent.$emit).toHaveBeenCalledWith("click", true);
expect(grandParent.$emit).not.toHaveBeenCalled();
});
it("应支持传递各种类型的参数", () => {
const parent = createMockInstance("Target");
const thisArg = createMockInstance(undefined, parent, parent);
// 字符串
dispatch(thisArg, "Target" as any, "event1", "hello");
expect(parent.$emit).toHaveBeenCalledWith("event1", "hello");
// 数字
dispatch(thisArg, "Target" as any, "event2", 42);
expect(parent.$emit).toHaveBeenCalledWith("event2", 42);
// 数组
dispatch(thisArg, "Target" as any, "event3", [1, 2, 3]);
expect(parent.$emit).toHaveBeenCalledWith("event3", [1, 2, 3]);
// undefined
dispatch(thisArg, "Target" as any, "event4", undefined);
expect(parent.$emit).toHaveBeenCalledWith("event4", undefined);
});
it("当祖先组件的 componentName 为 undefined 时,应跳过继续查找", () => {
const targetComponentName = "TargetComponent";
const grandParent = createMockInstance(targetComponentName);
// parent 没有 componentName
const parent = createMockInstance(undefined, grandParent, grandParent);
const thisArg = createMockInstance(undefined, parent, grandParent);
dispatch(thisArg, targetComponentName as any, "save", {});
expect(grandParent.$emit).toHaveBeenCalledWith("save", {});
});
});

View File

@ -0,0 +1,284 @@
import { describe, expect, it } from "vitest";
import { normalizeStyle } from "../src/vue-helper/style-helper";
describe("normalizeStyle", () => {
// ========== 单参数:纯转换 ==========
// ---------- 空值 / falsy ----------
it("应将 undefined 转为空对象", () => {
expect(normalizeStyle(undefined as any)).toEqual({});
});
it("应将 null 转为空对象", () => {
expect(normalizeStyle(null as any)).toEqual({});
});
it("应将 false 转为空对象", () => {
expect(normalizeStyle(false as any)).toEqual({});
});
// ---------- CSSProperties 对象 ----------
it("应直接返回 CSSProperties 对象", () => {
const style = { color: "red", fontSize: "14px" };
expect(normalizeStyle(style)).toEqual(style);
});
it("应保留 CSSProperties 中的数字值", () => {
const style = { zIndex: 10, opacity: 0.5 };
expect(normalizeStyle(style)).toEqual({ zIndex: 10, opacity: 0.5 });
});
it("应保留 CSS 变量", () => {
const style = { "--custom-color": "red" } as any;
expect(normalizeStyle(style)).toEqual({ "--custom-color": "red" });
});
it("空对象应返回空对象", () => {
expect(normalizeStyle({})).toEqual({});
});
// ---------- 字符串 ----------
it("应解析简单的 CSS 字符串", () => {
expect(normalizeStyle("color: red")).toEqual({ color: "red" });
});
it("应解析多条声明的 CSS 字符串", () => {
expect(normalizeStyle("color: red; font-size: 14px")).toEqual({
color: "red",
"font-size": "14px",
});
});
it("应解析带空格的 CSS 字符串", () => {
expect(normalizeStyle(" color : red ; font-size : 14px ")).toEqual({
color: "red",
"font-size": "14px",
});
});
it("应忽略空声明", () => {
expect(normalizeStyle("color: red;; font-size:14px;")).toEqual({
color: "red",
"font-size": "14px",
});
});
it("应忽略尾部分号后的空项", () => {
expect(normalizeStyle("color: red;")).toEqual({ color: "red" });
});
it("应去除 CSS 注释", () => {
expect(normalizeStyle("color: red; /* 注释 */ font-size: 14px")).toEqual({
color: "red",
"font-size": "14px",
});
});
it("应解析含 url() 的值(不拆分括号内的分号)", () => {
expect(normalizeStyle("background: url(data:image/png;base64,abc)")).toEqual({
background: "url(data:image/png;base64,abc)",
});
});
it("应解析含冒号的值(如 url 中的协议)", () => {
expect(normalizeStyle("background: url(http://example.com/img.png)")).toEqual({
background: "url(http://example.com/img.png)",
});
});
it("空字符串应返回空对象", () => {
expect(normalizeStyle("")).toEqual({});
});
// ---------- 数组 ----------
it("应合并对象数组", () => {
expect(normalizeStyle([{ color: "red" }, { fontSize: "14px" }])).toEqual({
color: "red",
fontSize: "14px",
});
});
it("应合并字符串数组", () => {
expect(normalizeStyle(["color: red", "font-size: 14px"])).toEqual({
color: "red",
"font-size": "14px",
});
});
it("应合并混合类型数组", () => {
expect(normalizeStyle(["color: red", { fontSize: "14px" }])).toEqual({
color: "red",
fontSize: "14px",
});
});
it("应处理嵌套数组", () => {
expect(normalizeStyle([["color: red", { fontSize: "14px" }], { fontWeight: "bold" }])).toEqual({
color: "red",
fontSize: "14px",
fontWeight: "bold",
});
});
it("应跳过数组中的 falsy 值", () => {
expect(normalizeStyle([null, false, undefined, { color: "red" }] as any)).toEqual({
color: "red",
});
});
it("数组中后面的属性应覆盖前面的", () => {
expect(normalizeStyle([{ color: "red" }, { color: "blue" }])).toEqual({
color: "blue",
});
});
it("空数组应返回空对象", () => {
expect(normalizeStyle([])).toEqual({});
});
// ---------- 实际使用场景 ----------
it("应处理 AppButton 典型的 customStyle 用法", () => {
// 字符串形式(旧用法兼容)
expect(normalizeStyle("color: red; font-size: 28rpx")).toEqual({
color: "red",
"font-size": "28rpx",
});
// 对象形式(推荐用法)
expect(normalizeStyle({ color: "red", fontSize: "28rpx" })).toEqual({
color: "red",
fontSize: "28rpx",
});
// 数组形式
expect(normalizeStyle([{ color: "red" }, { fontSize: "28rpx" }])).toEqual({
color: "red",
fontSize: "28rpx",
});
});
// ========== 双参数:合并覆盖 ==========
describe("合并覆盖(第二个参数)", () => {
it("不传第二个参数时,行为与单参数一致", () => {
expect(normalizeStyle({ color: "red" })).toEqual({ color: "red" });
});
it("第二个参数为 undefined 时,行为与单参数一致", () => {
expect(normalizeStyle({ color: "red" }, undefined)).toEqual({ color: "red" });
});
it("第二个参数为 null 时,行为与单参数一致", () => {
expect(normalizeStyle({ color: "red" }, null as any)).toEqual({ color: "red" });
});
it("overrides 有值的属性应覆盖 base", () => {
expect(normalizeStyle({ color: "red" }, { color: "blue" })).toEqual({ color: "blue" });
});
it("overrides 新增的属性应合并进来", () => {
expect(normalizeStyle({ color: "red" }, { fontSize: "14px" })).toEqual({
color: "red",
fontSize: "14px",
});
});
it("overrides 和 base 各有不同的属性时,应全部合并", () => {
expect(
normalizeStyle({ color: "red", padding: "8px" }, { fontSize: "14px", margin: "4px" }),
).toEqual({
color: "red",
padding: "8px",
fontSize: "14px",
margin: "4px",
});
});
// ---------- overrides 为空值时 fallback 到 base ----------
it("overrides 属性为 undefined 时应使用 base 的值", () => {
expect(normalizeStyle({ color: "red" }, { color: undefined } as any)).toEqual({
color: "red",
});
});
it("overrides 属性为 null 时应使用 base 的值", () => {
expect(normalizeStyle({ color: "red" }, { color: null } as any)).toEqual({ color: "red" });
});
it("overrides 属性为空字符串时应使用 base 的值", () => {
expect(normalizeStyle({ color: "red" }, { color: "" })).toEqual({ color: "red" });
});
it("overrides 属性为 false 时应使用 base 的值", () => {
expect(normalizeStyle({ color: "red" }, { color: false } as any)).toEqual({ color: "red" });
});
// ---------- 两者都为空时过滤属性 ----------
it("base 和 overrides 都为空时,应过滤掉该属性", () => {
expect(normalizeStyle({ color: "" }, { color: undefined } as any)).toEqual({});
});
it("base 和 overrides 都为 null 时,应过滤掉该属性", () => {
expect(normalizeStyle({ color: null } as any, { color: null } as any)).toEqual({});
});
it("base 有值但属性为空、overrides 未指定该属性时,应过滤掉", () => {
// base 有 color:"" (空值), overrides 没有 color → both empty → filtered
expect(normalizeStyle({ color: "" }, {})).toEqual({});
});
// ---------- 字符串形式的 overrides ----------
it("overrides 为字符串时应正常解析并合并", () => {
expect(normalizeStyle({ color: "red" }, "font-size: 14px")).toEqual({
color: "red",
"font-size": "14px",
});
});
it("base 为字符串、overrides 为对象时应正常合并", () => {
// 字符串解析出 kebab-case 键,对象用 camelCase 键,两者独立
expect(normalizeStyle("color: red", { fontSize: "14px" })).toEqual({
color: "red",
fontSize: "14px",
});
});
// ---------- 数组形式的 overrides ----------
it("overrides 为数组时应正常解析并合并", () => {
expect(normalizeStyle({ color: "red" }, [{ fontSize: "14px" }])).toEqual({
color: "red",
fontSize: "14px",
});
});
// ---------- 0 是有效值,不算空 ----------
it("数字 0 应视为有效值,不当作空值", () => {
expect(normalizeStyle({ opacity: 1 }, { opacity: 0 })).toEqual({ opacity: 0 });
});
it("overrides 为 0 时不应 fallback 到 base", () => {
expect(normalizeStyle({ zIndex: 10 }, { zIndex: 0 })).toEqual({ zIndex: 0 });
});
// ---------- 综合场景 ----------
it("应正确处理混合覆盖和 fallback", () => {
const base = { color: "red", fontSize: "14px", padding: "8px", margin: "" };
const overrides = { color: "blue", fontSize: undefined, padding: null, margin: "4px" } as any;
expect(normalizeStyle(base, overrides)).toEqual({
color: "blue", // overrides 有值,覆盖
fontSize: "14px", // overrides 为 undefinedfallback 到 base
padding: "8px", // overrides 为 nullfallback 到 base
margin: "4px", // base 为空overrides 有值,使用 overrides
});
});
it("appButton 场景customStyle 覆盖内部样式", () => {
// 模拟 AppButton 内部样式 + customStyle
const internal = { width: "200rpx", backgroundColor: "#3adceb", color: "#111" };
const custom = { width: "128rpx", backgroundColor: undefined, color: "" } as any;
expect(normalizeStyle(internal, custom)).toEqual({
width: "128rpx", // overrides 有值,覆盖
backgroundColor: "#3adceb", // overrides 为 undefinedfallback
color: "#111", // overrides 为空字符串fallback
});
});
});
});

View File

@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import {
createCustomClassObj,
convertCustomClass,
mergeClass,
} from "../src/vue-helper";
// ========== createCustomClassObj ==========
describe("createCustomClassObj", () => {
// ---------- 字符串输入 ----------
it("应将单个类名字符串转为对象", () => {
expect(createCustomClassObj("foo")).toEqual({ foo: true });
});
it("应将空格分隔的类名字符串转为对象", () => {
expect(createCustomClassObj("foo bar")).toEqual({ foo: true, bar: true });
});
it("应处理前后有空格的字符串", () => {
// trim 后 "foo bar"replace(" +"," ") 是字符串匹配不会替换连续空格
// split(" ") 产生 ["foo", "", "bar"],空字符串作为键
expect(createCustomClassObj(" foo bar ")).toEqual({ foo: true, "": true, bar: true });
});
it("应处理多个连续空格", () => {
// replace(" +", " ") 是字符串匹配而非正则,不会替换连续空格
// split(" ") 产生 ["foo", "", "", "", "bar"],重复的空字符串键只保留最后一个
expect(createCustomClassObj("foo bar")).toEqual({ foo: true, "": true, bar: true });
});
it("空字符串应返回空对象", () => {
expect(createCustomClassObj("")).toEqual({});
});
it("仅空格的字符串应返回空对象", () => {
expect(createCustomClassObj(" ")).toEqual({});
});
// ---------- 数组输入 ----------
it("应将类名数组转为对象", () => {
expect(createCustomClassObj(["foo", "bar"])).toEqual({ foo: true, bar: true });
});
it("应将单个元素的数组转为对象", () => {
expect(createCustomClassObj(["foo"])).toEqual({ foo: true });
});
it("空数组应返回空对象", () => {
expect(createCustomClassObj([])).toEqual({});
});
// ---------- 异常输入 ----------
it("传入对象应抛出 TypeError", () => {
expect(() => createCustomClassObj({} as any)).toThrow(TypeError);
});
it("传入数字应抛出 TypeError", () => {
expect(() => createCustomClassObj(123 as any)).toThrow(TypeError);
});
it("传入 null 应抛出 TypeError", () => {
expect(() => createCustomClassObj(null as any)).toThrow(TypeError);
});
it("传入 undefined 应抛出 TypeError", () => {
expect(() => createCustomClassObj(undefined as any)).toThrow(TypeError);
});
});
// ========== convertCustomClass ==========
describe("convertCustomClass", () => {
// ---------- 字符串输入 ----------
it("应将字符串转为类名对象", () => {
expect(convertCustomClass("foo bar")).toEqual({ foo: true, bar: true });
});
it("应将单个类名字符串转为对象", () => {
expect(convertCustomClass("foo")).toEqual({ foo: true });
});
it("空字符串应返回空对象", () => {
expect(convertCustomClass("")).toEqual({});
});
// ---------- 数组输入 ----------
it("应将类名数组转为对象", () => {
expect(convertCustomClass(["foo", "bar"])).toEqual({ foo: true, bar: true });
});
it("空数组应返回空对象", () => {
expect(convertCustomClass([])).toEqual({});
});
// ---------- 对象输入 ----------
it("应直接返回对象", () => {
const obj = { foo: true, bar: false };
expect(convertCustomClass(obj)).toEqual(obj);
});
it("应保留对象中值为 false 的属性", () => {
const obj = { foo: true, bar: false };
const result = convertCustomClass(obj);
expect(result).toHaveProperty("bar", false);
});
// ---------- 异常输入 ----------
it("传入数字应抛出 TypeError", () => {
expect(() => convertCustomClass(123 as any)).toThrow(TypeError);
});
it("传入 null 应抛出 TypeError", () => {
expect(() => convertCustomClass(null as any)).toThrow(TypeError);
});
it("传入 undefined 应抛出 TypeError", () => {
expect(() => convertCustomClass(undefined as any)).toThrow(TypeError);
});
it("传入布尔值应抛出 TypeError", () => {
expect(() => convertCustomClass(true as any)).toThrow(TypeError);
});
});
// ========== mergeClass ==========
describe("mergeClass", () => {
it("应合并多个字符串类名", () => {
expect(mergeClass("foo", "bar")).toEqual({ foo: true, bar: true });
});
it("应合并字符串和数组", () => {
expect(mergeClass("foo", ["bar"])).toEqual({ foo: true, bar: true });
});
it("应合并字符串、数组和对象", () => {
expect(mergeClass("foo", ["bar"], { baz: true })).toEqual({
foo: true,
bar: true,
baz: true,
});
});
it("后面的值应覆盖前面的同名属性", () => {
expect(mergeClass({ foo: true }, { foo: false })).toEqual({ foo: false });
});
it("字符串形式的类名应覆盖前面的", () => {
// "foo" → { foo: true },后者覆盖前者
expect(mergeClass("foo", "foo")).toEqual({ foo: true });
});
it("单个参数应正常工作", () => {
expect(mergeClass("foo")).toEqual({ foo: true });
});
it("应处理空字符串参数", () => {
expect(mergeClass("")).toEqual({});
});
it("应处理空数组参数", () => {
expect(mergeClass([])).toEqual({});
});
it("应处理空对象参数", () => {
expect(mergeClass({})).toEqual({});
});
it("应合并多个对象类名", () => {
expect(
mergeClass({ color: true }, { size: true }, { weight: true }),
).toEqual({ color: true, size: true, weight: true });
});
it("实际使用场景Vue 组件中合并 class", () => {
// 模拟组件内部 class + 传入 class
const internal = "btn primary";
const custom = { active: true, disabled: false };
expect(mergeClass(internal, custom)).toEqual({
btn: true,
primary: true,
active: true,
disabled: false,
});
});
});