feat: add smart update

This commit is contained in:
Justineo
2025-09-17 18:23:55 +08:00
committed by GU Yiling
parent f5e17356e8
commit 468f7dbfbd
5 changed files with 362 additions and 36 deletions

View File

@ -11,8 +11,10 @@ import {
nextTick,
watchEffect,
toValue,
warn,
} from "vue";
import { init as initChart } from "echarts/core";
import type { EChartsOption } from "echarts";
import {
usePublicAPI,
@ -25,6 +27,8 @@ import {
import type { PublicMethods, SlotsTypes } from "./composables";
import { isOn, omitOn } from "./utils";
import { register, TAG_NAME } from "./wc";
import { planUpdate } from "./merge";
import type { Signature, UpdatePlan } from "./merge";
import type { PropType, InjectionKey } from "vue";
import type {
@ -71,24 +75,21 @@ export default defineComponent({
setup(props, { attrs, expose, slots }) {
const root = shallowRef<EChartsElement>();
const chart = shallowRef<EChartsType>();
const manualOption = shallowRef<Option>();
const defaultTheme = inject(THEME_KEY, null);
const defaultInitOptions = inject(INIT_OPTIONS_KEY, null);
const defaultUpdateOptions = inject(UPDATE_OPTIONS_KEY, null);
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
const realOption = computed(
() => manualOption.value || props.option || null,
);
const realOption = computed(() => props.option || undefined);
const realTheme = computed(
() => props.theme || toValue(defaultTheme) || {},
() => props.theme || toValue(defaultTheme) || undefined,
);
const realInitOptions = computed(
() => props.initOptions || toValue(defaultInitOptions) || {},
() => props.initOptions || toValue(defaultInitOptions) || undefined,
);
const realUpdateOptions = computed(
() => props.updateOptions || toValue(defaultUpdateOptions) || {},
() => props.updateOptions || toValue(defaultUpdateOptions) || undefined,
);
const nonEventAttrs = computed(() => omitOn(attrs));
const nativeListeners: Record<string, unknown> = {};
@ -98,13 +99,72 @@ export default defineComponent({
const { teleportedSlots, patchOption } = useSlotOption(slots, () => {
if (!manualUpdate.value && props.option && chart.value) {
chart.value.setOption(
patchOption(props.option),
realUpdateOptions.value,
);
applyOption(chart.value, props.option);
}
});
let lastSignature: Signature | undefined;
function resolveUpdateOptions(
plan?: UpdatePlan,
override?: UpdateOptions,
): UpdateOptions {
const base = realUpdateOptions.value;
const result: UpdateOptions = {
...(override ?? {}),
};
const replacements = [
...(plan?.replaceMerge ?? []),
...(override?.replaceMerge ?? []),
].filter((key): key is string => key != null);
if (replacements.length > 0) {
result.replaceMerge = [...new Set(replacements)];
} else {
delete result.replaceMerge;
}
const notMerge = override?.notMerge ?? plan?.notMerge;
if (notMerge !== undefined) {
result.notMerge = notMerge;
} else {
delete result.notMerge;
}
return base ? { ...base, ...result } : result;
}
function applyOption(
instance: EChartsType,
option: Option,
override?: UpdateOptions,
manual = false,
) {
const patched = patchOption(option);
if (manual) {
instance.setOption(patched, override ?? {});
lastSignature = undefined;
return;
}
if (realUpdateOptions.value) {
const updateOptions = override ?? realUpdateOptions.value;
instance.setOption(patched, updateOptions);
lastSignature = undefined;
return;
}
const planned = planUpdate(
lastSignature,
patched as unknown as EChartsOption,
);
const updateOptions = resolveUpdateOptions(planned.plan, override);
instance.setOption(planned.option, updateOptions);
lastSignature = planned.signature;
}
// We are converting all `on<Event>` props and collect them into `listeners` so that
// we can bind them to the chart instance later.
// For `onNative:<event>` props, we just strip the `Native:` part and collect them into
@ -140,7 +200,7 @@ export default defineComponent({
listeners.set({ event, zr, once }, attrs[key]);
});
function init(option?: Option) {
function init(option?: Option, manual = false, override?: UpdateOptions) {
if (!root.value) {
return;
}
@ -186,7 +246,8 @@ export default defineComponent({
function commit() {
const opt = option || realOption.value;
if (opt) {
instance.setOption(patchOption(opt), realUpdateOptions.value);
applyOption(instance, opt, override, manual);
override = undefined;
}
}
@ -206,17 +267,20 @@ export default defineComponent({
notMerge,
lazyUpdate?: boolean,
) => {
if (!props.manualUpdate) {
warn(
"[vue-echarts] setOption is only available when manual-update is true.",
);
return;
}
const updateOptions =
typeof notMerge === "boolean" ? { notMerge, lazyUpdate } : notMerge;
if (props.manualUpdate) {
manualOption.value = option;
}
if (!chart.value) {
init(option);
init(option, true, updateOptions ?? undefined);
} else {
chart.value.setOption(patchOption(option), updateOptions);
applyOption(chart.value, option, updateOptions ?? undefined, true);
}
};
@ -225,6 +289,7 @@ export default defineComponent({
chart.value.dispose();
chart.value = undefined;
}
lastSignature = undefined;
}
let unwatchOption: (() => void) | null = null;
@ -239,19 +304,15 @@ export default defineComponent({
if (!manualUpdate) {
unwatchOption = watch(
() => props.option,
(option, oldOption) => {
(option) => {
if (!option) {
lastSignature = undefined;
return;
}
if (!chart.value) {
init();
} else {
chart.value.setOption(patchOption(option), {
// mutating `option` will lead to `notMerge: false` and
// replacing it with new reference will lead to `notMerge: true`
notMerge: option !== oldOption,
...realUpdateOptions.value,
});
applyOption(chart.value, option);
}
},
{ deep: true },
@ -277,7 +338,7 @@ export default defineComponent({
watch(
realTheme,
(theme) => {
chart.value?.setTheme(theme);
chart.value?.setTheme(theme || {});
},
{
deep: true,

243
src/merge.ts Normal file
View File

@ -0,0 +1,243 @@
import type { EChartsOption } from "echarts";
import { isPlainObject } from "./utils";
export interface UpdatePlan {
notMerge: boolean;
replaceMerge?: string[];
}
/** Summary of a top-level array key for deletion detection. */
export interface ArraySummary {
/** Unique, sorted string ids extracted from items' `id` field. */
idsSorted: string[];
/** Count of items without an `id` field. */
noIdCount: number;
}
/** Minimal signature of an option used to decide setOption behavior. */
export interface Signature {
/** Lengths of `option.options` and `option.media` (0 if not arrays). */
optionsLength: number;
mediaLength: number;
/** Map of array-typed top-level keys to their summaries. */
arrays: Record<string, ArraySummary | undefined>;
/** Sorted list of object-typed top-level keys. */
objects: string[];
/** Sorted list of scalar-typed top-level keys (string|number|boolean|null). */
scalars: string[];
}
/**
* Read an item's `id` as a string.
* Only accept string or number. Other types are ignored to surface inconsistent data early.
*/
function readId(item: unknown): string | undefined {
if (!isPlainObject(item)) {
return undefined;
}
const raw = (item as { id?: unknown }).id;
if (typeof raw === "string") {
return raw;
}
if (typeof raw === "number" && Number.isFinite(raw)) {
return String(raw);
}
return undefined;
}
/**
* Build a minimal signature from a full ECharts option.
* Only top-level keys are inspected.
*/
export function buildSignature(option: EChartsOption): Signature {
const opt = option as Record<string, unknown>;
const optionsLength = Array.isArray(opt.options)
? (opt.options as unknown[]).length
: 0;
const mediaLength = Array.isArray(opt.media)
? (opt.media as unknown[]).length
: 0;
const arrays: Record<string, ArraySummary | undefined> = Object.create(null);
const objects: string[] = [];
const scalars: string[] = [];
for (const key of Object.keys(opt)) {
if (key === "options" || key === "media") {
continue;
}
const value = opt[key];
if (Array.isArray(value)) {
const items = value as unknown[];
const ids = new Set<string>();
let noIdCount = 0;
for (let i = 0; i < items.length; i++) {
const id = readId(items[i]);
if (id !== undefined) {
ids.add(id);
} else {
noIdCount++;
}
}
const idsSorted = ids.size > 0 ? Array.from(ids).sort() : [];
arrays[key] = { idsSorted, noIdCount };
} else if (isPlainObject(value)) {
objects.push(key);
} else {
// scalar: string | number | boolean | null (undefined is treated as "absent")
if (value !== undefined) {
scalars.push(key);
}
}
}
if (objects.length > 1) {
objects.sort();
}
if (scalars.length > 1) {
scalars.sort();
}
return { optionsLength, mediaLength, arrays, objects, scalars };
}
function diffKeys(
prevKeys: readonly string[],
nextKeys: readonly string[],
): string[] {
if (prevKeys.length === 0) {
return [];
}
if (nextKeys.length === 0) {
return prevKeys.slice();
}
const nextSet = new Set(nextKeys);
const missing: string[] = [];
for (let i = 0; i < prevKeys.length; i++) {
const key = prevKeys[i];
if (!nextSet.has(key)) {
missing.push(key);
}
}
return missing;
}
function hasMissingIds(
prevIds: readonly string[],
nextIds: readonly string[],
): boolean {
if (prevIds.length === 0) {
return false;
}
if (nextIds.length === 0) {
return true;
}
const nextSet = new Set(nextIds);
for (let i = 0; i < prevIds.length; i++) {
if (!nextSet.has(prevIds[i])) {
return true;
}
}
return false;
}
export interface PlannedUpdate {
option: EChartsOption;
signature: Signature;
plan: UpdatePlan;
}
/**
* Produce an update plan plus a normalized option that encodes common deletions.
* Falls back to `notMerge: true` when the change looks complex.
*/
export function planUpdate(
prev: Signature | undefined,
option: EChartsOption,
): PlannedUpdate {
const next = buildSignature(option);
if (!prev) {
return { option, signature: next, plan: { notMerge: false } };
}
if (next.optionsLength < prev.optionsLength) {
return { option, signature: next, plan: { notMerge: true } };
}
if (next.mediaLength < prev.mediaLength) {
return { option, signature: next, plan: { notMerge: true } };
}
if (diffKeys(prev.scalars, next.scalars).length > 0) {
return { option, signature: next, plan: { notMerge: true } };
}
const replace = new Set<string>();
const overrides = new Map<string, null | []>();
const missingObjects = diffKeys(prev.objects, next.objects);
for (let i = 0; i < missingObjects.length; i++) {
overrides.set(missingObjects[i], null);
}
for (const key of Object.keys(prev.arrays)) {
const prevArray = prev.arrays[key];
if (!prevArray) {
continue;
}
const nextArray = next.arrays[key];
if (!nextArray) {
if (prevArray.idsSorted.length > 0 || prevArray.noIdCount > 0) {
overrides.set(key, []);
replace.add(key);
}
continue;
}
if (hasMissingIds(prevArray.idsSorted, nextArray.idsSorted)) {
replace.add(key);
continue;
}
if (nextArray.noIdCount < prevArray.noIdCount) {
replace.add(key);
}
}
let normalizedOption = option;
let signature = next;
if (overrides.size > 0) {
const clone = { ...(option as Record<string, unknown>) };
overrides.forEach((value, key) => {
clone[key] = value;
});
normalizedOption = clone as EChartsOption;
signature = buildSignature(normalizedOption);
}
const replaceMerge =
replace.size > 0 ? Array.from(replace).sort() : undefined;
const plan = replaceMerge
? { notMerge: false, replaceMerge }
: { notMerge: false };
return {
option: normalizedOption,
signature,
plan,
};
}

View File

@ -38,3 +38,7 @@ export function isSameSet<T>(a: T[], b: T[]): boolean {
return true;
}
export function isPlainObject(v: unknown): v is Record<string, unknown> {
return v != null && typeof v === "object" && !Array.isArray(v);
}