mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2025-10-28 03:25:02 +08:00
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:
@ -155,7 +155,7 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
|
||||
|
||||
#### Smart Update
|
||||
- If you supply `update-options` (via prop or injection), Vue ECharts forwards it directly to `setOption` and skips the planner.
|
||||
- Manual `setOption` calls (only available when `manual-update` is `true`) behave like native ECharts, honouring only the per-call override you pass in.
|
||||
- Manual `setOption` calls (only available when `manual-update` is `true`) behave like native ECharts, honouring only the per-call override you pass in and are not carried across re-initializations.
|
||||
- Otherwise, Vue ECharts analyses the change: removed objects become `null`, removed arrays become `[]` with `replaceMerge`, ID/anonymous deletions trigger `replaceMerge`, and risky changes fall back to `notMerge: true`.
|
||||
|
||||
- `update-options: object`
|
||||
@ -184,7 +184,7 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
|
||||
|
||||
- `manual-update: boolean` (default: `false`)
|
||||
|
||||
For performance critical scenarios (having a large dataset) we'd better bypass Vue's reactivity system for `option` prop. By specifying `manual-update` prop with `true` and not providing `option` prop, the dataset won't be watched any more. After doing so, you need to retrieve the component instance with `ref` and manually call `setOption` method to update the chart (manual `setOption` calls are ignored when `manual-update` is `false`).
|
||||
Handy for performance-sensitive charts (large or high-frequency updates). When set to `true`, Vue only uses the `option` prop for the initial render; later prop changes do nothing and you must drive updates via `setOption` on a template ref. If the chart re-initializes (for example due to `init-options` changes, flipping `manual-update`, or a remount), the manual state is discarded and the chart is rendered again from the current `option` value.
|
||||
|
||||
### Events
|
||||
|
||||
@ -245,7 +245,7 @@ Vue ECharts support the following events:
|
||||
- `zr:dblclick`
|
||||
- `zr:contextmenu`
|
||||
|
||||
See supported events [here →](https://echarts.apache.org/en/api.html#events)
|
||||
See supported events in the [ECharts API reference →](https://echarts.apache.org/en/api.html#events)
|
||||
|
||||
#### Native DOM Events
|
||||
|
||||
@ -339,7 +339,7 @@ export default {
|
||||
|
||||
Vue ECharts allows you to define ECharts option's [`tooltip.formatter`](https://echarts.apache.org/en/option.html#tooltip.formatter) and [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/en/option.html#toolbox.feature.dataView.optionToContent) callbacks via Vue slots instead of defining them in your `option` object. This simplifies custom HTMLElement rendering using familiar Vue templating.
|
||||
|
||||
**Slot Naming Convention**
|
||||
#### Slot Naming Convention
|
||||
|
||||
- Slot names begin with `tooltip`/`dataView`, followed by hyphen-separated path segments to the target.
|
||||
- Each segment corresponds to an `option` property name or an array index (for arrays, use the numeric index).
|
||||
|
||||
@ -155,7 +155,7 @@ app.component('VChart', VueECharts)
|
||||
|
||||
#### 智能更新
|
||||
- 如果提供了 `update-options`(或通过 inject 注入),Vue ECharts 会直接把它传给 `setOption`,不会执行智能计划。
|
||||
- 手动调用 `setOption`(仅当 `manual-update` 为 `true` 时可用)与原生 ECharts 保持一致,只使用本次调用传入的参数。
|
||||
- 手动调用 `setOption`(仅当 `manual-update` 为 `true` 时可用)与原生 ECharts 保持一致,只使用本次调用传入的参数,重新初始化后不会保留这些调用的效果。
|
||||
- 其他情况下,Vue ECharts 会分析差异:删除的对象写入 `null`,删除的数组写入 `[]` 并加入 `replaceMerge`,ID/匿名项减少时追加 `replaceMerge`,风险较高的变更会退回 `notMerge: true`。
|
||||
|
||||
- `update-options: object`
|
||||
@ -182,9 +182,9 @@ app.component('VChart', VueECharts)
|
||||
|
||||
Inject 键名:`LOADING_OPTIONS_KEY`。
|
||||
|
||||
- `manual-update: boolean`(默认值`false`)
|
||||
- `manual-update: boolean`(默认值 `false`)
|
||||
|
||||
在性能敏感(数据量很大)的场景下,我们最好对于 `option` prop 绕过 Vue 的响应式系统。当将 `manual-update` 指定为 `true` 且不传入 `option` prop 时,数据将不会被监听。此时需要用 `ref` 获取组件实例并手动调用 `setOption` 来更新图表(当 `manual-update` 为 `false` 时,手动调用 `setOption` 会被忽略)。
|
||||
适用于性能敏感的场景(例如 `option` 很大或更新频繁)。设为 `true` 时,`option` 只参与首次渲染,后续的 prop 变更不会触发图表更新,需要你通过模板 `ref` 手动调用 `setOption`。如果图表因为修改 `init-options`、切换 `manual-update` 或重新挂载而被重新初始化,之前通过 `setOption` 写入的状态会丢失,并重新使用当前的 `option` 值渲染。
|
||||
|
||||
### 事件
|
||||
|
||||
@ -245,7 +245,7 @@ Vue ECharts 支持如下事件:
|
||||
- `zr:dblclick`
|
||||
- `zr:contextmenu`
|
||||
|
||||
请参考支持的事件列表。[前往 →](https://echarts.apache.org/zh/api.html#events)
|
||||
更多事件说明可参考 [ECharts 官方事件文档 →](https://echarts.apache.org/zh/api.html#events)
|
||||
|
||||
#### 原生 DOM 事件
|
||||
|
||||
@ -330,7 +330,7 @@ export default {
|
||||
- `dispose` [→](https://echarts.apache.org/zh/api.html#echartsInstance.dispose)
|
||||
|
||||
> [!NOTE]
|
||||
> 如下 ECharts 实例方法没有被暴露,因为它们的功能已经通过组件 [props](#props) 提供了:
|
||||
> 如下 ECharts 实例方法没有被暴露,因为它们的功能已经通过组件 [prop](#props) 提供了:
|
||||
>
|
||||
> - [`showLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.showLoading) / [`hideLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.hideLoading):请使用 `loading` 和 `loading-options` prop。
|
||||
> - [`setTheme`](https://echarts.apache.org/zh/api.html#echartsInstance.setTheme):请使用 `theme` prop。
|
||||
@ -339,7 +339,7 @@ export default {
|
||||
|
||||
Vue ECharts 允许你通过 Vue 插槽来定义 ECharts 配置中的 [`tooltip.formatter`](https://echarts.apache.org/zh/option.html#tooltip.formatter) 和 [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/zh/option.html#toolbox.feature.dataView.optionToContent) 回调,而无需在 `option` 对象中定义它们。你可以使用熟悉的 Vue 模板语法来编写自定义提示框或数据视图中的内容。
|
||||
|
||||
**插槽命名约定**
|
||||
#### 插槽命名约定
|
||||
|
||||
- 插槽名称以 `tooltip`/`dataView` 开头,后面跟随用连字符分隔的路径片段,用于定位目标。
|
||||
- 每个路径片段对应 `option` 对象的属性名或数组索引(数组索引使用数字形式)。
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
TooltipComponent,
|
||||
} from "echarts/components";
|
||||
import { shallowRef } from "vue";
|
||||
import type { InitOptions, LoadingOptions, Option } from "../../src/types";
|
||||
import type { LoadingOptions, Option } from "../../src/types";
|
||||
import VChart from "../../src/ECharts";
|
||||
import VExample from "./Example.vue";
|
||||
import worldMap from "../data/world.json";
|
||||
@ -40,7 +40,7 @@ function isFlightDataset(value: unknown): value is FlightDataset {
|
||||
|
||||
const chart = shallowRef<ChartInstance | null>(null);
|
||||
const loading = shallowRef(false);
|
||||
const loaded = shallowRef(false);
|
||||
const flightData = shallowRef<FlightDataset | null>(null);
|
||||
|
||||
const loadingOptions: LoadingOptions = {
|
||||
text: "",
|
||||
@ -50,29 +50,39 @@ const loadingOptions: LoadingOptions = {
|
||||
zlevel: 0,
|
||||
};
|
||||
|
||||
const initOptions: InitOptions = {
|
||||
renderer: "canvas",
|
||||
};
|
||||
|
||||
function load(): void {
|
||||
loaded.value = true;
|
||||
loading.value = true;
|
||||
|
||||
import("../data/flight.json").then(({ default: rawData }) => {
|
||||
if (!isFlightDataset(rawData)) {
|
||||
loading.value = false;
|
||||
return;
|
||||
async function load(): Promise<FlightDataset> {
|
||||
if (flightData.value) {
|
||||
return flightData.value;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { default: data } = await import("../data/flight.json");
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (!isFlightDataset(data)) {
|
||||
throw new Error("Invalid flight dataset");
|
||||
}
|
||||
|
||||
flightData.value = data;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function render(): Promise<void> {
|
||||
let data = flightData.value;
|
||||
if (!data) {
|
||||
data = await load();
|
||||
}
|
||||
|
||||
const getAirportCoord = (index: number): [number, number] => [
|
||||
rawData.airports[index][3],
|
||||
rawData.airports[index][4],
|
||||
data.airports[index][3],
|
||||
data.airports[index][4],
|
||||
];
|
||||
|
||||
type Route = [[number, number], [number, number]];
|
||||
const routes = rawData.routes.map<Route>(([, from, to]) => {
|
||||
const routes = data.routes.map<Route>(([, from, to]) => {
|
||||
const fromCoord = getAirportCoord(from);
|
||||
const toCoord = getAirportCoord(to);
|
||||
return [fromCoord, toCoord];
|
||||
@ -91,9 +101,9 @@ function load(): void {
|
||||
backgroundColor: "#003",
|
||||
tooltip: {
|
||||
formatter({ dataIndex }: { dataIndex: number }) {
|
||||
const route = rawData.routes[dataIndex];
|
||||
const fromName = rawData.airports[route[1]][1];
|
||||
const toName = rawData.airports[route[2]][1];
|
||||
const route = data.routes[dataIndex];
|
||||
const fromName = data.airports[route[1]][1];
|
||||
const toName = data.airports[route[2]][1];
|
||||
return `${fromName} > ${toName}`;
|
||||
},
|
||||
},
|
||||
@ -123,7 +133,6 @@ function load(): void {
|
||||
},
|
||||
],
|
||||
} satisfies Option);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -134,7 +143,6 @@ function load(): void {
|
||||
autoresize
|
||||
:loading="loading"
|
||||
:loading-options="loadingOptions"
|
||||
:init-options="initOptions"
|
||||
style="background-color: #003"
|
||||
manual-update
|
||||
/>
|
||||
@ -144,7 +152,7 @@ function load(): void {
|
||||
use cases.
|
||||
</p>
|
||||
<p class="actions">
|
||||
<button :disabled="loaded" @click="load">Load</button>
|
||||
<button :disabled="loading" @click="render">Load</button>
|
||||
</p>
|
||||
</template>
|
||||
</VExample>
|
||||
|
||||
@ -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,23 +286,21 @@ export default defineComponent({
|
||||
lastSignature = undefined;
|
||||
}
|
||||
|
||||
let unwatchOption: (() => void) | null = null;
|
||||
watch(
|
||||
manualUpdate,
|
||||
(manualUpdate) => {
|
||||
if (typeof unwatchOption === "function") {
|
||||
unwatchOption();
|
||||
unwatchOption = null;
|
||||
}
|
||||
|
||||
if (!manualUpdate) {
|
||||
unwatchOption = watch(
|
||||
() => props.option,
|
||||
(option) => {
|
||||
if (!option) {
|
||||
lastSignature = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualUpdate.value) {
|
||||
warn(
|
||||
"`option` prop changes are ignored when `manual-update` is `true`.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chart.value) {
|
||||
return;
|
||||
}
|
||||
@ -308,15 +309,9 @@ export default defineComponent({
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
realInitOptions,
|
||||
[manualUpdate, realInitOptions],
|
||||
() => {
|
||||
cleanup();
|
||||
init();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
16
src/utils.ts
16
src/utils.ts
@ -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}`);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
resetECharts,
|
||||
type ChartStub,
|
||||
} from "./helpers/mock";
|
||||
import type { UpdateOptions } from "../src/types";
|
||||
import type { InitOptions, Option, UpdateOptions } from "../src/types";
|
||||
import { withConsoleWarn } from "./helpers/dom";
|
||||
import ECharts, { UPDATE_OPTIONS_KEY } from "../src/ECharts";
|
||||
import { renderChart } from "./helpers/renderChart";
|
||||
@ -74,9 +74,9 @@ describe("ECharts component", () => {
|
||||
const manualOption = { series: [{ type: "bar", data: [1, 2, 3] }] };
|
||||
exposed.value.setOption(manualOption);
|
||||
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(2);
|
||||
expect(chartStub.setOption.mock.calls[1][0]).toMatchObject(manualOption);
|
||||
expect(chartStub.setOption.mock.calls[1][1]).toEqual({});
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject(manualOption);
|
||||
expect(chartStub.setOption.mock.calls[0][1]).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores setOption when manual-update is false", async () => {
|
||||
@ -91,11 +91,113 @@ describe("ECharts component", () => {
|
||||
exposed.value.setOption({ title: { text: "ignored" } }, true);
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(initialCalls);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[vue-echarts] setOption is only available"),
|
||||
expect.stringContaining(
|
||||
"[vue-echarts] `setOption` is only available when `manual-update` is `true`.",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when option prop changes in manual-update mode", async () => {
|
||||
const option = ref({ title: { text: "initial" } });
|
||||
const exposed = shallowRef<any>();
|
||||
|
||||
renderChart(() => ({ option: option.value, manualUpdate: true }), exposed);
|
||||
await nextTick();
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
// noop
|
||||
});
|
||||
|
||||
option.value = { title: { text: "next" } };
|
||||
await nextTick();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls[0][0]).toContain(
|
||||
"[vue-echarts] `option` prop changes are ignored when `manual-update` is `true`.",
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not replay manual option after initOptions-triggered reinit", async () => {
|
||||
const initOptions = ref<InitOptions>({ renderer: "canvas" });
|
||||
const exposed = shallowRef<any>();
|
||||
|
||||
renderChart(
|
||||
() => ({ manualUpdate: true, initOptions: initOptions.value }),
|
||||
exposed,
|
||||
);
|
||||
await nextTick();
|
||||
|
||||
const manualOption: Option = {
|
||||
title: { text: "manual" },
|
||||
series: [{ type: "bar", data: [1, 2, 3] }],
|
||||
};
|
||||
|
||||
exposed.value.setOption(manualOption);
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject(manualOption);
|
||||
|
||||
const firstStub = chartStub;
|
||||
const replacementStub = enqueueChart();
|
||||
chartStub = replacementStub;
|
||||
|
||||
initOptions.value = { renderer: "svg" as const };
|
||||
await nextTick();
|
||||
|
||||
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(replacementStub.setOption).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-initializes manual chart from option prop after reinit", async () => {
|
||||
const option = ref<Required<Option>>({
|
||||
title: { text: "base" },
|
||||
series: [{ type: "bar", data: [1] }],
|
||||
});
|
||||
const initOptions = ref<InitOptions>({ renderer: "canvas" });
|
||||
const exposed = shallowRef<any>();
|
||||
|
||||
renderChart(
|
||||
() => ({
|
||||
option: option.value,
|
||||
manualUpdate: true,
|
||||
initOptions: initOptions.value,
|
||||
}),
|
||||
exposed,
|
||||
);
|
||||
await nextTick();
|
||||
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||
title: { text: "base" },
|
||||
});
|
||||
|
||||
chartStub.setOption.mockClear();
|
||||
|
||||
const manualOption: Option = {
|
||||
title: { text: "manual" },
|
||||
series: [{ type: "bar", data: [2] }],
|
||||
};
|
||||
|
||||
exposed.value.setOption(manualOption);
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject(manualOption);
|
||||
|
||||
const firstStub = chartStub;
|
||||
const replacementStub = enqueueChart();
|
||||
chartStub = replacementStub;
|
||||
|
||||
initOptions.value = { renderer: "svg" as const };
|
||||
await nextTick();
|
||||
|
||||
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(replacementStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(replacementStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||
title: { text: "base" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes theme and initOptions props and reacts to theme changes", async () => {
|
||||
const option = ref({ title: { text: "brew" } });
|
||||
const theme = ref("dark");
|
||||
@ -173,6 +275,7 @@ describe("ECharts component", () => {
|
||||
const option = ref({ title: { text: "initial" } });
|
||||
const manualUpdate = ref(true);
|
||||
const exposed = shallowRef<any>();
|
||||
const firstStub = chartStub;
|
||||
|
||||
renderChart(
|
||||
() => ({
|
||||
@ -183,14 +286,33 @@ describe("ECharts component", () => {
|
||||
);
|
||||
await nextTick();
|
||||
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(firstStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(firstStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||
title: { text: "initial" },
|
||||
});
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
// noop
|
||||
});
|
||||
|
||||
option.value = { title: { text: "manual" } };
|
||||
await nextTick();
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(firstStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls[0][0]).toContain(
|
||||
"[vue-echarts] `option` prop changes are ignored when `manual-update` is `true`.",
|
||||
);
|
||||
warnSpy.mockClear();
|
||||
|
||||
const replacementStub = enqueueChart();
|
||||
manualUpdate.value = false;
|
||||
chartStub = replacementStub;
|
||||
await nextTick();
|
||||
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(replacementStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(replacementStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||
title: { text: "manual" },
|
||||
});
|
||||
|
||||
option.value = { title: { text: "reactive" } };
|
||||
await nextTick();
|
||||
@ -199,6 +321,8 @@ describe("ECharts component", () => {
|
||||
expect(chartStub.setOption.mock.calls[1][0]).toMatchObject({
|
||||
title: { text: "reactive" },
|
||||
});
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses injected updateOptions defaults when not provided via props", async () => {
|
||||
@ -511,6 +635,26 @@ describe("ECharts component", () => {
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies option when nested data mutates", async () => {
|
||||
const option = ref<Option>({
|
||||
series: [{ type: "bar", data: [1, 2, 3] }],
|
||||
});
|
||||
const exposed = shallowRef<any>();
|
||||
|
||||
renderChart(() => ({ option: option.value }), exposed);
|
||||
await nextTick();
|
||||
|
||||
chartStub.setOption.mockClear();
|
||||
|
||||
(option.value!.series as any)[0].data.push(4);
|
||||
await nextTick();
|
||||
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||
series: [{ data: [1, 2, 3, 4] }],
|
||||
});
|
||||
});
|
||||
|
||||
it("honors override.replaceMerge in update options", async () => {
|
||||
const option = ref({ series: [{ type: "bar", data: [1] }] });
|
||||
const exposed = shallowRef<any>();
|
||||
@ -674,12 +818,28 @@ describe("ECharts component", () => {
|
||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Toggle to manual mode; watcher should be cleaned up (unwatchOption branch)
|
||||
const firstStub = chartStub;
|
||||
const replacementStub = enqueueChart();
|
||||
manual.value = true;
|
||||
chartStub = replacementStub;
|
||||
await nextTick();
|
||||
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(replacementStub.setOption).toHaveBeenCalledTimes(1);
|
||||
chartStub.setOption.mockClear();
|
||||
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
// noop
|
||||
});
|
||||
|
||||
chartStub.setOption.mockClear();
|
||||
option.value = { title: { text: "reactive-2" } } as any;
|
||||
await nextTick();
|
||||
expect(chartStub.setOption).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls[0][0]).toContain(
|
||||
"[vue-echarts] `option` prop changes are ignored when `manual-update` is `true`.",
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,6 +16,7 @@ export function createEChartsModule() {
|
||||
|
||||
export interface ChartStub {
|
||||
setOption: ReturnType<typeof vi.fn>;
|
||||
getOption: ReturnType<typeof vi.fn>;
|
||||
resize: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
isDisposed: ReturnType<typeof vi.fn>;
|
||||
@ -36,9 +37,15 @@ export function createChartStub(): ChartStub {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
let lastOption: unknown;
|
||||
|
||||
const setOption = vi.fn((option: unknown) => {
|
||||
lastOption = option;
|
||||
});
|
||||
|
||||
return {
|
||||
setOption: vi.fn(),
|
||||
setOption,
|
||||
getOption: vi.fn(() => lastOption),
|
||||
resize: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
isDisposed: vi.fn(() => false),
|
||||
|
||||
@ -188,7 +188,7 @@ describe("useSlotOption", () => {
|
||||
const patched: any = exposed.value!.patchOption({});
|
||||
const flattened = warnSpy.mock.calls.flat().join(" ");
|
||||
|
||||
expect(flattened).toContain("Invalid vue-echarts slot name: legend");
|
||||
expect(flattened).toContain("[vue-echarts] Invalid slot name: legend");
|
||||
expect(patched.legend).toBeUndefined();
|
||||
expect(changeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -224,6 +224,20 @@ describe("useSlotOption", () => {
|
||||
expect(container?.textContent).toBe("series-0");
|
||||
});
|
||||
|
||||
it("skips slot patch when path is blocked by non-object", async () => {
|
||||
const { exposed } = renderSlotComponent(() => ({
|
||||
"tooltip-series-0": () => [h("span", "series-0")],
|
||||
}));
|
||||
|
||||
await nextTick();
|
||||
|
||||
const option = { series: 1 as any };
|
||||
const patched = exposed.value!.patchOption(option);
|
||||
|
||||
expect(patched.series).toBe(1);
|
||||
expect(typeof patched.series).toBe("number");
|
||||
});
|
||||
|
||||
it("creates array shells when target slot path is missing", async () => {
|
||||
const { exposed } = renderSlotComponent(() => ({
|
||||
"tooltip-series-1": () => [h("span", "series-1")],
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildSignature, planUpdate } from "../src/smart-update";
|
||||
import { buildSignature, planUpdate } from "../src/update";
|
||||
import type { EChartsOption } from "echarts";
|
||||
|
||||
describe("smart-update", () => {
|
||||
Reference in New Issue
Block a user