fix: refactor slot implementation and improve types

- Improved types.
- Refactored slot implementation to make it more robust.
- Modified the `setOption` behavior to ensure it respects the `manual-update` flag.
- Renamed the `smart-update` files to `update`.
- Improved warnings.
- Added more tests.
This commit is contained in:
Justineo
2025-10-11 21:26:49 +08:00
committed by GU Yiling
parent 2a97adfbd2
commit c9746c2c2f
12 changed files with 402 additions and 172 deletions

View File

@@ -11,7 +11,6 @@ import {
nextTick,
watchEffect,
toValue,
warn,
} from "vue";
import { init as initChart } from "echarts/core";
import type { EChartsOption } from "echarts";
@@ -25,10 +24,10 @@ import {
useSlotOption,
} from "./composables";
import type { PublicMethods, SlotsTypes } from "./composables";
import { isOn, omitOn } from "./utils";
import { isOn, omitOn, warn } from "./utils";
import { register, TAG_NAME } from "./wc";
import { planUpdate } from "./smart-update";
import type { Signature, UpdatePlan } from "./smart-update";
import { planUpdate } from "./update";
import type { Signature, UpdatePlan } from "./update";
import type { PropType, InjectionKey } from "vue";
import type {
@@ -81,7 +80,6 @@ export default defineComponent({
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
const realOption = computed(() => props.option || {});
const realTheme = computed(() => props.theme || toValue(defaultTheme));
const realInitOptions = computed(
() => props.initOptions || toValue(defaultInitOptions) || undefined,
@@ -186,7 +184,7 @@ export default defineComponent({
listeners.set({ event, zr, once }, attrs[key]);
});
function init(option?: Option, manual = false, override?: UpdateOptions) {
function init() {
if (!root.value) {
return;
}
@@ -235,10 +233,17 @@ export default defineComponent({
}
function commit() {
const opt = option || realOption.value;
if (opt) {
applyOption(instance, opt, override, manual);
override = undefined;
const { option } = props;
if (manualUpdate.value) {
if (option) {
applyOption(instance, option, undefined, true);
}
return;
}
if (option) {
applyOption(instance, option);
}
}
@@ -259,9 +264,7 @@ export default defineComponent({
lazyUpdate?: boolean,
) => {
if (!props.manualUpdate) {
warn(
"[vue-echarts] setOption is only available when manual-update is true.",
);
warn("`setOption` is only available when `manual-update` is `true`.");
return;
}
@@ -283,40 +286,32 @@ export default defineComponent({
lastSignature = undefined;
}
let unwatchOption: (() => void) | null = null;
watch(
manualUpdate,
(manualUpdate) => {
if (typeof unwatchOption === "function") {
unwatchOption();
unwatchOption = null;
() => props.option,
(option) => {
if (!option) {
lastSignature = undefined;
return;
}
if (!manualUpdate) {
unwatchOption = watch(
() => props.option,
(option) => {
if (!option) {
lastSignature = undefined;
return;
}
if (!chart.value) {
return;
}
applyOption(chart.value, option);
},
{ deep: true },
if (manualUpdate.value) {
warn(
"`option` prop changes are ignored when `manual-update` is `true`.",
);
return;
}
if (!chart.value) {
return;
}
applyOption(chart.value, option);
},
{
immediate: true,
},
{ deep: true },
);
watch(
realInitOptions,
[manualUpdate, realInitOptions],
() => {
cleanup();
init();

View File

@@ -26,25 +26,26 @@ export type PublicMethods = Pick<EChartsType, MethodName>;
export function usePublicAPI(
chart: Ref<EChartsType | undefined>,
): PublicMethods {
function makePublicMethod<T extends MethodName>(
name: T,
): (...args: Parameters<EChartsType[T]>) => ReturnType<EChartsType[T]> {
return (...args) => {
function makePublicMethod<T extends MethodName>(name: T): EChartsType[T] {
// Return a function that matches the signature of EChartsType[T]
const fn = function (this: unknown, ...args: unknown[]): unknown {
if (!chart.value) {
throw new Error("ECharts is not initialized yet.");
}
return (chart.value[name] as any).apply(chart.value, args);
// Use Reflect.apply to call the method with proper context
return Reflect.apply(chart.value[name], chart.value, args);
};
return fn as EChartsType[T];
}
function makePublicMethods(): PublicMethods {
const methods = Object.create(null);
METHOD_NAMES.forEach((name) => {
methods[name] = makePublicMethod(name);
});
// Build the methods object with proper typing
const methods = METHOD_NAMES.reduce(
(acc, name) => {
acc[name] = makePublicMethod(name);
return acc;
},
{} as Record<MethodName, unknown>,
) as PublicMethods;
return methods as PublicMethods;
}
return makePublicMethods();
return methods;
}

View File

@@ -6,11 +6,10 @@ import {
onMounted,
shallowRef,
shallowReactive,
warn,
} from "vue";
import type { Slots, SlotsType } from "vue";
import type { Option } from "../types";
import { isBrowser, isValidArrayIndex, isSameSet } from "../utils";
import { isBrowser, isValidArrayIndex, isSameSet, warn } from "../utils";
import type { TooltipComponentFormatterCallbackParams } from "echarts";
const SLOT_OPTION_PATHS = {
@@ -52,7 +51,11 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
return h(
"div",
{
ref: (el) => (containers[slotName] = el as HTMLElement),
ref: (el) => {
if (el instanceof HTMLElement) {
containers[slotName] = el;
}
},
style: { display: "contents" },
},
slotContent,
@@ -62,38 +65,68 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
: undefined;
};
// Shallow-clone the option along the path and override the target callback
// Helper to check if a value is a plain object
function isObject(val: unknown): val is Record<string, unknown> {
return val !== null && typeof val === "object" && !Array.isArray(val);
}
// Shallow-clone the option along each path and override the target callback
function patchOption(src: Option): Option {
const root = { ...src };
const root = { ...src } as Record<string, unknown>;
// Ensure the child at `seg` is a writable container (cloned or newly created).
// Returns the child container, or undefined if traversal is blocked by a primitive.
const ensureChild = (
parent: Record<string, unknown>,
seg: string,
): Record<string, unknown> | undefined => {
const next = parent[seg];
if (Array.isArray(next)) {
parent[seg] = [...next];
return parent[seg] as Record<string, unknown>;
}
if (isObject(next)) {
parent[seg] = { ...next };
return parent[seg] as Record<string, unknown>;
}
if (next === undefined) {
parent[seg] = isValidArrayIndex(seg) ? [] : {};
return parent[seg] as Record<string, unknown>;
}
// Blocked by a non-container value
return undefined;
};
Object.keys(slots)
.filter((key) => {
const isValidSlot = isValidSlotName(key);
if (!isValidSlot) {
warn(`Invalid vue-echarts slot name: ${key}`);
const valid = isValidSlotName(key);
if (!valid) {
warn(`Invalid slot name: ${key}`);
}
return isValidSlot;
return valid;
})
.forEach((key) => {
const path = key.split("-");
const prefix = path.shift() as SlotPrefix;
path.push(...SLOT_OPTION_PATHS[prefix]);
let cur: any = root;
for (let i = 0; i < path.length - 1; i++) {
const seg = path[i];
const next = cur[seg];
// Shallow-clone the link; create empty shell if missing
cur[seg] = next
? Array.isArray(next)
? [...next]
: { ...next }
: isValidArrayIndex(seg)
? []
: {};
cur = cur[seg];
const [prefix, ...rest] = key.split("-") as [SlotPrefix, ...string[]];
const tail = SLOT_OPTION_PATHS[prefix];
if (!tail) {
return;
}
const path = [...rest, ...tail];
if (path.length === 0) {
return;
}
// Traverse to the parent of the leaf, cloning or creating along the way
let cur: Record<string, unknown> | undefined = root;
for (let i = 0; i < path.length - 1; i++) {
cur = cur && ensureChild(cur, path[i]);
if (!cur) {
return; // Blocked by a primitive — skip this key
}
}
cur[path[path.length - 1]] = (p: unknown) => {
initialized[key] = true;
params[key] = p;
@@ -101,7 +134,7 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
};
});
return root;
return root as Option;
}
// `slots` is not reactive, so we need to watch it manually

View File

View File

@@ -1,3 +1,5 @@
import { warn as vueWarn } from "vue";
type Attrs = Record<string, any>;
export function isBrowser(): boolean {
@@ -34,10 +36,14 @@ export function isSameSet<T>(a: T[], b: T[]): boolean {
const setA = new Set(a);
const setB = new Set(b);
if (setA.size !== setB.size) return false;
if (setA.size !== setB.size) {
return false;
}
for (const val of setA) {
if (!setB.has(val)) return false;
if (!setB.has(val)) {
return false;
}
}
return true;
@@ -46,3 +52,9 @@ export function isSameSet<T>(a: T[], b: T[]): boolean {
export function isPlainObject(v: unknown): v is Record<string, unknown> {
return v != null && typeof v === "object" && !Array.isArray(v);
}
const LOG_PREFIX = "[vue-echarts]";
export function warn(message: string): void {
vueWarn(`${LOG_PREFIX} ${message}`);
}