You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@echarts.apache.org by su...@apache.org on 2020/07/17 11:39:04 UTC

[incubator-echarts] 05/16: fix: fix replaceMerge in feature "restore" and add test cases.

This is an automated email from the ASF dual-hosted git repository.

sushuang pushed a commit to branch remove-component
in repository https://gitbox.apache.org/repos/asf/incubator-echarts.git

commit fa7cd46ab3ee1f421d418d07caf6587c0b51c344
Author: 100pah <su...@gmail.com>
AuthorDate: Wed Jul 8 22:57:10 2020 +0800

    fix: fix replaceMerge in feature "restore" and add test cases.
---
 src/component/graphic.ts      |   3 +-
 src/model/Global.ts           |  58 ++++-----
 src/model/OptionManager.ts    | 138 ++++++++++++++-------
 src/util/model.ts             | 137 ++++++++++++++++-----
 test/option-replaceMerge.html | 271 ++++++++++++++++++++++++++++++++++++++++--
 5 files changed, 491 insertions(+), 116 deletions(-)

diff --git a/src/component/graphic.ts b/src/component/graphic.ts
index 1996918..4079ed3 100644
--- a/src/component/graphic.ts
+++ b/src/component/graphic.ts
@@ -142,8 +142,7 @@ const GraphicModel = echarts.extendComponentModel({
         const flattenedList = [];
         this._flatten(newList, flattenedList);
 
-        const mappingResult = modelUtil.mappingToExistsInNormalMerge(existList, flattenedList);
-        modelUtil.makeIdAndName(mappingResult);
+        const mappingResult = modelUtil.mappingToExists(existList, flattenedList, 'normalMerge');
 
         // Clear elOptionsToUpdate
         const elOptionsToUpdate = this._elOptionsToUpdate = [];
diff --git a/src/model/Global.ts b/src/model/Global.ts
index fe9edae..5f5555e 100644
--- a/src/model/Global.ts
+++ b/src/model/Global.ts
@@ -176,6 +176,15 @@ class GlobalModel extends Model<ECUnitOption> {
             this.restoreData();
         }
 
+        // By design, if `setOption(option2)` at the second time, and `option2` is a `ECUnitOption`,
+        // it should better not have the same props with `MediaUnit['option']`.
+        // Becuase either `option2` or `MediaUnit['option']` will be always merged to "current option"
+        // rather than original "baseOption". If they both override a prop, the result might be
+        // unexpected when media state changed after `setOption` called.
+        // If we really need to modify a props in each `MediaUnit['option']`, use the full version
+        // (`{baseOption, media}`) in `setOption`.
+        // For `timeline`, the case is the same.
+
         if (!type || type === 'recreate' || type === 'timeline') {
             const timelineOption = optionManager.getTimelineOption(this);
             if (timelineOption) {
@@ -220,7 +229,7 @@ class GlobalModel extends Model<ECUnitOption> {
                 return;
             }
 
-            if (!(ComponentModel as ComponentModelConstructor).hasClass(mainType)) {
+            if (!ComponentModel.hasClass(mainType)) {
                 // globalSettingTask.dirty();
                 option[mainType] = option[mainType] == null
                     ? clone(componentOption)
@@ -247,20 +256,12 @@ class GlobalModel extends Model<ECUnitOption> {
             const newCmptOptionList = modelUtil.normalizeToArray(newOption[mainType]);
 
             const oldCmptList = componentsMap.get(mainType);
-            const mapResult = replaceMergeMainTypeMap && replaceMergeMainTypeMap.get(mainType)
-                ? modelUtil.mappingToExistsInReplaceMerge(oldCmptList, newCmptOptionList)
-                : modelUtil.mappingToExistsInNormalMerge(oldCmptList, newCmptOptionList);
-
-            modelUtil.makeIdAndName(mapResult);
+            const mergeMode = (replaceMergeMainTypeMap && replaceMergeMainTypeMap.get(mainType))
+                ? 'replaceMerge' : 'normalMerge';
+            const mappingResult = modelUtil.mappingToExists(oldCmptList, newCmptOptionList, mergeMode);
 
             // Set mainType and complete subType.
-            each(mapResult, function (item) {
-                const opt = item.newOption;
-                if (isObject(opt)) {
-                    item.keyInfo.mainType = mainType;
-                    item.keyInfo.subType = determineSubType(mainType, opt, item.existing);
-                }
-            });
+            modelUtil.setComponentTypeToKeyInfo(mappingResult, mainType, ComponentModel as ComponentModelConstructor);
 
             // Set it before the travel, in case that `this._componentsMap` is
             // used in some `init` or `merge` of components.
@@ -271,7 +272,7 @@ class GlobalModel extends Model<ECUnitOption> {
             const cmptsByMainType = [] as ComponentModel[];
             let cmptsCountByMainType = 0;
 
-            each(mapResult, function (resultItem, index) {
+            each(mappingResult, function (resultItem, index) {
                 let componentModel = resultItem.existing;
                 const newCmptOption = resultItem.newOption;
 
@@ -309,6 +310,7 @@ class GlobalModel extends Model<ECUnitOption> {
                         componentModel = new ComponentModelClass(
                             newCmptOption, this, this, extraOpt
                         );
+                        // Assign `keyInfo`
                         extend(componentModel, extraOpt);
                         if (resultItem.brandNew) {
                             componentModel.__requireNewView = true;
@@ -357,9 +359,9 @@ class GlobalModel extends Model<ECUnitOption> {
     getOption(): ECUnitOption {
         const option = clone(this.option);
 
-        each(option, function (opts, mainType) {
-            if ((ComponentModel as ComponentModelConstructor).hasClass(mainType)) {
-                opts = modelUtil.normalizeToArray(opts);
+        each(option, function (optInMainType, mainType) {
+            if (ComponentModel.hasClass(mainType)) {
+                const opts = modelUtil.normalizeToArray(optInMainType);
                 // Inner cmpts need to be removed.
                 // Inner cmpts might not be at last since ec5.0, but still
                 // compatible for users: if inner cmpt at last, splice the returned array.
@@ -739,8 +741,6 @@ class GlobalModel extends Model<ECUnitOption> {
         };
 
         initBase = function (ecModel: GlobalModel, baseOption: ECUnitOption): void {
-            baseOption = baseOption;
-
             // Using OPTION_INNER_KEY to mark that this option can not be used outside,
             // i.e. `chart.setOption(chart.getModel().option);` is forbiden.
             ecModel.option = {} as ECUnitOption;
@@ -827,7 +827,7 @@ function mergeTheme(option: ECUnitOption, theme: ThemeOption): void {
         }
         // If it is component model mainType, the model handles that merge later.
         // otherwise, merge them here.
-        if (!(ComponentModel as ComponentModelConstructor).hasClass(name)) {
+        if (!ComponentModel.hasClass(name)) {
             if (typeof themeItem === 'object') {
                 option[name] = !option[name]
                     ? clone(themeItem)
@@ -842,22 +842,6 @@ function mergeTheme(option: ECUnitOption, theme: ThemeOption): void {
     });
 }
 
-function determineSubType(
-    mainType: ComponentMainType,
-    newCmptOption: ComponentOption,
-    existComponent: {subType: ComponentSubType} | ComponentModel
-): ComponentSubType {
-    const subType = newCmptOption.type
-        ? newCmptOption.type
-        : existComponent
-        ? existComponent.subType
-        // Use determineSubType only when there is no existComponent.
-        : (ComponentModel as ComponentModelConstructor).determineSubType(mainType, newCmptOption);
-
-    // tooltip, markline, markpoint may always has no subType
-    return subType;
-}
-
 function queryByIdOrName<T extends { id?: string, name?: string }>(
     attr: 'id' | 'name',
     idOrName: string | string[],
@@ -888,7 +872,7 @@ function normalizeReplaceMergeInput(opts: GlobalModelSetOptionOpts): InnerSetOpt
     opts && each(modelUtil.normalizeToArray(opts.replaceMerge), function (mainType) {
         if (__DEV__) {
             assert(
-                (ComponentModel as ComponentModelConstructor).hasClass(mainType),
+                ComponentModel.hasClass(mainType),
                 '"' + mainType + '" is not valid component main type in "replaceMerge"'
             );
         }
diff --git a/src/model/OptionManager.ts b/src/model/OptionManager.ts
index ee195e8..fd4a6fc 100644
--- a/src/model/OptionManager.ts
+++ b/src/model/OptionManager.ts
@@ -22,17 +22,18 @@
  */
 
 
-import * as zrUtil from 'zrender/src/core/util';
-import * as modelUtil from '../util/model';
 import ComponentModel, { ComponentModelConstructor } from './Component';
 import ExtensionAPI from '../ExtensionAPI';
-import { OptionPreprocessor, MediaQuery, ECUnitOption, MediaUnit, ECOption } from '../util/types';
+import {
+    OptionPreprocessor, MediaQuery, ECUnitOption, MediaUnit, ECOption, SeriesOption, ComponentOption
+} from '../util/types';
 import GlobalModel, { InnerSetOptionOpts } from './Global';
-
-const each = zrUtil.each;
-const clone = zrUtil.clone;
-const map = zrUtil.map;
-const merge = zrUtil.merge;
+import {
+    MappingExistingItem, normalizeToArray, setComponentTypeToKeyInfo, mappingToExists
+} from '../util/model';
+import {
+    each, clone, map, merge, isTypedArray, setAsPrimitive, HashMap, createHashMap, extend
+} from 'zrender/src/core/util';
 
 const QUERY_REG = /^(min|max)?(.+)$/;
 
@@ -43,6 +44,9 @@ interface ParsedRawOption {
     mediaList: MediaUnit[];
 }
 
+// Key: mainType
+type FakeComponentsMap = HashMap<(MappingExistingItem & { subType: string })[]>;
+
 /**
  * TERM EXPLANATIONS:
  * See `ECOption` and `ECUnitOption` in `src/util/types.ts`.
@@ -65,6 +69,8 @@ class OptionManager {
 
     private _optionBackup: ParsedRawOption;
 
+    private _fakeCmptsMap: FakeComponentsMap;
+
     private _newBaseOption: ECUnitOption;
 
     // timeline.notMerge is not supported in ec3. Firstly there is rearly
@@ -87,8 +93,8 @@ class OptionManager {
     ): void {
         if (rawOption) {
             // That set dat primitive is dangerous if user reuse the data when setOption again.
-            zrUtil.each(modelUtil.normalizeToArray((rawOption as ECUnitOption).series), function (series) {
-                series && series.data && zrUtil.isTypedArray(series.data) && zrUtil.setAsPrimitive(series.data);
+            each(normalizeToArray((rawOption as ECUnitOption).series), function (series: SeriesOption) {
+                series && series.data && isTypedArray(series.data) && setAsPrimitive(series.data);
             });
         }
 
@@ -101,28 +107,36 @@ class OptionManager {
         // If some property is set in timeline options or media option but
         // not set in baseOption, a warning should be given.
 
-        const oldOptionBackup = this._optionBackup;
+        const optionBackup = this._optionBackup;
         const newParsedOption = parseRawOption(
-            rawOption, optionPreprocessorFuncs, !oldOptionBackup
+            rawOption, optionPreprocessorFuncs, !optionBackup
         );
         this._newBaseOption = newParsedOption.baseOption;
 
         // For setOption at second time (using merge mode);
-        if (oldOptionBackup) {
-            // Only baseOption can be merged.
-            mergeOption(oldOptionBackup.baseOption, newParsedOption.baseOption, opt);
+        if (optionBackup) {
+            // The first merge is delayed, becuase in most cases, users do not call `setOption` twice.
+            let fakeCmptsMap = this._fakeCmptsMap;
+            if (!fakeCmptsMap) {
+                fakeCmptsMap = this._fakeCmptsMap = createHashMap();
+                mergeToBackupOption(fakeCmptsMap, null, optionBackup.baseOption, null);
+            }
+
+            mergeToBackupOption(
+                fakeCmptsMap, optionBackup.baseOption, newParsedOption.baseOption, opt
+            );
 
             // For simplicity, timeline options and media options do not support merge,
             // that is, if you `setOption` twice and both has timeline options, the latter
             // timeline opitons will not be merged to the formers, but just substitude them.
             if (newParsedOption.timelineOptions.length) {
-                oldOptionBackup.timelineOptions = newParsedOption.timelineOptions;
+                optionBackup.timelineOptions = newParsedOption.timelineOptions;
             }
             if (newParsedOption.mediaList.length) {
-                oldOptionBackup.mediaList = newParsedOption.mediaList;
+                optionBackup.mediaList = newParsedOption.mediaList;
             }
             if (newParsedOption.mediaDefault) {
-                oldOptionBackup.mediaDefault = newParsedOption.mediaDefault;
+                optionBackup.mediaDefault = newParsedOption.mediaDefault;
             }
         }
         else {
@@ -133,14 +147,14 @@ class OptionManager {
     mountOption(isRecreate: boolean): ECUnitOption {
         const optionBackup = this._optionBackup;
 
-        this._timelineOptions = map(optionBackup.timelineOptions, clone);
-        this._mediaList = map(optionBackup.mediaList, clone);
-        this._mediaDefault = clone(optionBackup.mediaDefault);
+        this._timelineOptions = optionBackup.timelineOptions;
+        this._mediaList = optionBackup.mediaList;
+        this._mediaDefault = optionBackup.mediaDefault;
         this._currentMediaIndices = [];
 
         return clone(isRecreate
             // this._optionBackup.baseOption, which is created at the first `setOption`
-            // called, and is merged into every new option by inner method `mergeOption`
+            // called, and is merged into every new option by inner method `mergeToBackupOption`
             // each time `setOption` called, can be only used in `isRecreate`, because
             // its reliability is under suspicion. In other cases option merge is
             // performed by `model.mergeOption`.
@@ -263,7 +277,7 @@ function parseRawOption(
 
     // Preprocess.
     each([baseOption].concat(timelineOptions)
-        .concat(zrUtil.map(mediaList, function (media) {
+        .concat(map(mediaList, function (media) {
             return media.option;
         })),
         function (option) {
@@ -295,7 +309,7 @@ function applyMediaQuery(query: MediaQuery, ecWidth: number, ecHeight: number):
 
     let applicatable = true;
 
-    zrUtil.each(query, function (value: number, attr) {
+    each(query, function (value: number, attr) {
         const matched = attr.match(QUERY_REG);
 
         if (!matched || !matched[1] || !matched[2]) {
@@ -359,33 +373,73 @@ function indicesEquals(indices1: number[], indices2: number[]): boolean {
  * When "resotre" action triggered, model from `componentActionModel` will be discarded
  * instead of recreating the "ecModel" from the "_optionBackup".
  */
-function mergeOption(
-    oldOption: ECUnitOption, newOption: ECUnitOption, opt: InnerSetOptionOpts
+function mergeToBackupOption(
+    fakeCmptsMap: FakeComponentsMap,
+    // `tarOption` Can be null/undefined, means init
+    tarOption: ECUnitOption,
+    newOption: ECUnitOption,
+    // Can be null/undefined
+    opt: InnerSetOptionOpts
 ): void {
     newOption = newOption || {} as ECUnitOption;
+    const notInit = !!tarOption;
 
-    each(newOption, function (newCptOpt, mainType) {
-        if (newCptOpt == null) {
+    each(newOption, function (newOptsInMainType, mainType) {
+        if (newOptsInMainType == null) {
             return;
         }
 
-        let oldCptOpt = oldOption[mainType];
-
-        if (!(ComponentModel as ComponentModelConstructor).hasClass(mainType)) {
-            oldOption[mainType] = merge(oldCptOpt, newCptOpt, true);
+        if (!ComponentModel.hasClass(mainType)) {
+            if (tarOption) {
+                tarOption[mainType] = merge(tarOption[mainType], newOptsInMainType, true);
+            }
         }
         else {
-            newCptOpt = modelUtil.normalizeToArray(newCptOpt);
-            oldCptOpt = modelUtil.normalizeToArray(oldCptOpt);
-
-            const mapResult = opt.replaceMergeMainTypeMap.get(mainType)
-                ? modelUtil.mappingToExistsInReplaceMerge(oldCptOpt, newCptOpt)
-                : modelUtil.mappingToExistsInNormalMerge(oldCptOpt, newCptOpt);
+            const oldTarOptsInMainType = notInit ? normalizeToArray(tarOption[mainType]) : null;
+            const oldFakeCmptsInMainType = fakeCmptsMap.get(mainType) || [];
+            const resultTarOptsInMainType = notInit ? (tarOption[mainType] = [] as ComponentOption[]) : null;
+            const resultFakeCmptsInMainType = fakeCmptsMap.set(mainType, []);
+
+            const mappingResult = mappingToExists(
+                oldFakeCmptsInMainType,
+                normalizeToArray(newOptsInMainType),
+                (opt && opt.replaceMergeMainTypeMap.get(mainType)) ? 'replaceMerge' : 'normalMerge'
+            );
+            setComponentTypeToKeyInfo(mappingResult, mainType, ComponentModel as ComponentModelConstructor);
+
+            each(mappingResult, function (resultItem, index) {
+                // The same logic as `Global.ts#_mergeOption`.
+                let fakeCmpt = resultItem.existing;
+                const newOption = resultItem.newOption;
+                const keyInfo = resultItem.keyInfo;
+                let fakeCmptOpt;
+
+                if (!newOption) {
+                    fakeCmptOpt = oldTarOptsInMainType[index];
+                }
+                else {
+                    if (fakeCmpt && fakeCmpt.subType === keyInfo.subType) {
+                        fakeCmpt.name = keyInfo.name;
+                        if (notInit) {
+                            fakeCmptOpt = merge(oldTarOptsInMainType[index], newOption, true);
+                        }
+                    }
+                    else {
+                        fakeCmpt = extend({}, keyInfo);
+                        if (notInit) {
+                            fakeCmptOpt = clone(newOption);
+                        }
+                    }
+                }
 
-            oldOption[mainType] = map(mapResult, function (item) {
-                return (item.newOption && item.existing)
-                    ? merge(item.existing, item.newOption, true)
-                    : (item.existing || item.newOption);
+                if (fakeCmpt) {
+                    notInit && resultTarOptsInMainType.push(fakeCmptOpt);
+                    resultFakeCmptsInMainType.push(fakeCmpt);
+                }
+                else {
+                    notInit && resultTarOptsInMainType.push(void 0);
+                    resultFakeCmptsInMainType.push(void 0);
+                }
             });
         }
     });
diff --git a/src/util/model.ts b/src/util/model.ts
index 95cb000..826c23f 100644
--- a/src/util/model.ts
+++ b/src/util/model.ts
@@ -30,7 +30,7 @@ import {
 } from 'zrender/src/core/util';
 import env from 'zrender/src/core/env';
 import GlobalModel, { QueryConditionKindB } from '../model/Global';
-import ComponentModel from '../model/Component';
+import ComponentModel, {ComponentModelConstructor} from '../model/Component';
 import List from '../data/List';
 import {
     ComponentOption,
@@ -142,7 +142,14 @@ export function isDataItemOption(dataItem: OptionDataItem): boolean {
         // && !(dataItem[0] && isObject(dataItem[0]) && !(dataItem[0] instanceof Array));
 }
 
-type MappingExistItem = {id?: string, name?: string} | ComponentModel;
+// Compatible with previous definition: id could be number (but not recommanded).
+// number and number-like string are trade the same when compare.
+// number id will not be converted to string in option.
+// number id will be converted to string in component instance id.
+export interface MappingExistingItem {
+    id?: string | number;
+    name?: string;
+};
 /**
  * The array `MappingResult<T>[]` exactly represents the content of the result
  * components array after merge.
@@ -150,7 +157,7 @@ type MappingExistItem = {id?: string, name?: string} | ComponentModel;
  * Items will not be `null`/`undefined` even if the corresponding `existings` will be removed.
  */
 type MappingResult<T> = MappingResultItem<T>[];
-interface MappingResultItem<T> {
+interface MappingResultItem<T extends MappingExistingItem = MappingExistingItem> {
     // Existing component instance.
     existing?: T;
     // The mapped new component option.
@@ -160,15 +167,26 @@ interface MappingResultItem<T> {
     brandNew?: boolean;
     // id?: string;
     // name?: string;
-    // keyInfo for new component option.
+    // keyInfo for new component.
+    // All of them will be assigned to a created component instance.
     keyInfo?: {
-        name?: string,
-        id?: string,
-        mainType?: ComponentMainType,
-        subType?: ComponentSubType
+        name: string,
+        id: string,
+        mainType: ComponentMainType,
+        subType: ComponentSubType
     };
 }
 
+export function mappingToExists<T extends MappingExistingItem>(
+    existings: T[],
+    newCmptOptions: ComponentOption[],
+    mode: 'normalMerge' | 'replaceMerge'
+): MappingResult<T> {
+    return mode === 'replaceMerge'
+        ? mappingToExistsInReplaceMerge(existings, newCmptOptions)
+        : mappingToExistsInNormalMerge(existings, newCmptOptions);
+}
+
 /**
  * Mapping to existings for merge.
  * The mapping result (merge result) will keep the order of the existing
@@ -179,7 +197,7 @@ interface MappingResultItem<T> {
  *
  * @return See the comment of <MappingResult>.
  */
-export function mappingToExistsInNormalMerge<T extends MappingExistItem>(
+function mappingToExistsInNormalMerge<T extends MappingExistingItem>(
     existings: T[],
     newCmptOptions: ComponentOption[]
 ): MappingResult<T> {
@@ -205,9 +223,8 @@ export function mappingToExistsInNormalMerge<T extends MappingExistItem>(
         for (let i = 0; i < result.length; i++) {
             const existing = result[i].existing;
             if (!result[i].newOption // Consider name: two map to one.
-                && cmptOption.id != null
                 && existing
-                && existing.id === cmptOption.id + ''
+                && keyExistAndEqual('id', existing, cmptOption)
             ) {
                 result[i].newOption = cmptOption;
                 newCmptOptions[index] = null;
@@ -221,10 +238,9 @@ export function mappingToExistsInNormalMerge<T extends MappingExistItem>(
                 // Can not match when both ids existing but different.
                 && existing
                 && (existing.id == null || cmptOption.id == null)
-                && cmptOption.name != null
                 && !isIdInner(cmptOption)
                 && !isIdInner(existing)
-                && existing.name === cmptOption.name + ''
+                && keyExistAndEqual('name', existing, cmptOption)
             ) {
                 result[i].newOption = cmptOption;
                 newCmptOptions[index] = null;
@@ -235,6 +251,8 @@ export function mappingToExistsInNormalMerge<T extends MappingExistItem>(
 
     mappingByIndexFinally(newCmptOptions, result, false);
 
+    makeIdAndName(result);
+
     return result;
 }
 
@@ -250,7 +268,7 @@ export function mappingToExistsInNormalMerge<T extends MappingExistItem>(
  *
  * @return See the comment of <MappingResult>.
  */
-export function mappingToExistsInReplaceMerge<T extends MappingExistItem>(
+function mappingToExistsInReplaceMerge<T extends MappingExistingItem>(
     existings: T[],
     newCmptOptions: ComponentOption[]
 ): MappingResult<T> {
@@ -272,7 +290,10 @@ export function mappingToExistsInReplaceMerge<T extends MappingExistItem>(
                 innerExisting = existing;
             }
             // Input with inner id is allowed for convenience of some internal usage.
-            existingIdIdxMap.set(existing.id, index);
+            // When `existing` is rawOption (see `OptionManager`#`mergeOption`), id might be empty.
+            if (existing.id != null) {
+                existingIdIdxMap.set(existing.id, index);
+            }
         }
         result.push({ existing: innerExisting });
     }
@@ -283,7 +304,10 @@ export function mappingToExistsInReplaceMerge<T extends MappingExistItem>(
             newCmptOptions[index] = null;
             return;
         }
-        const optionId = cmptOption.id + '';
+        if (cmptOption.id == null) {
+            return;
+        }
+        const optionId = makeComparableKey(cmptOption.id);
         const existingIdx = existingIdIdxMap.get(optionId);
         if (existingIdx != null) {
             if (__DEV__) {
@@ -303,12 +327,14 @@ export function mappingToExistsInReplaceMerge<T extends MappingExistItem>(
 
     mappingByIndexFinally(newCmptOptions, result, true);
 
+    makeIdAndName(result);
+
     // The array `result` MUST NOT contain elided items, otherwise the
     // forEach will ommit those items and result in incorrect result.
     return result;
 }
 
-function mappingByIndexFinally<T extends MappingExistItem>(
+function mappingByIndexFinally<T extends MappingExistingItem>(
     newCmptOptions: ComponentOption[],
     mappingResult: MappingResult<T>,
     allBrandNew: boolean
@@ -331,7 +357,11 @@ function mappingByIndexFinally<T extends MappingExistItem>(
             // not be merged to the existings with different id. Because id should not be overwritten.
             // (3) Name can be overwritten, because axis use name as 'show label text'.
             && (
-                (cmptOption.id != null && resultItem.existing)
+                (
+                    cmptOption.id != null
+                    && resultItem.existing
+                    && !keyExistAndEqual('id', cmptOption, resultItem.existing)
+                )
                 || resultItem.newOption
                 || isIdInner(resultItem.existing)
             )
@@ -354,8 +384,8 @@ function mappingByIndexFinally<T extends MappingExistItem>(
  * Make id and name for mapping result (result of mappingToExists)
  * into `keyInfo` field.
  */
-export function makeIdAndName(
-    mapResult: MappingResult<MappingExistItem>
+function makeIdAndName(
+    mapResult: MappingResult<MappingExistingItem>
 ): void {
     // We use this id to hash component models and view instances
     // in echarts. id can be specified by user, or auto generated.
@@ -385,7 +415,7 @@ export function makeIdAndName(
         );
 
         opt && opt.id != null && idMap.set(opt.id, item);
-        !item.keyInfo && (item.keyInfo = {});
+        !item.keyInfo && (item.keyInfo = {} as MappingResultItem['keyInfo']);
     });
 
     // Make name and id.
@@ -403,7 +433,7 @@ export function makeIdAndName(
         // only in that case: setOption with 'not merge mode' and view
         // instance will be recreated, which can be accepted.
         keyInfo.name = opt.name != null
-            ? opt.name + ''
+            ? makeComparableKey(opt.name)
             : existing
             ? existing.name
             // Avoid diffferent series has the same name,
@@ -411,10 +441,10 @@ export function makeIdAndName(
             : DUMMY_COMPONENT_NAME_PREFIX + index;
 
         if (existing) {
-            keyInfo.id = existing.id;
+            keyInfo.id = makeComparableKey(existing.id);
         }
         else if (opt.id != null) {
-            keyInfo.id = opt.id + '';
+            keyInfo.id = makeComparableKey(opt.id);
         }
         else {
             // Consider this situatoin:
@@ -433,6 +463,25 @@ export function makeIdAndName(
     });
 }
 
+function keyExistAndEqual(attr: 'id' | 'name', obj1: MappingExistingItem, obj2: MappingExistingItem): boolean {
+    const key1 = obj1[attr];
+    const key2 = obj2[attr];
+    // See `MappingExistingItem`. `id` and `name` trade number-like string equals to number.
+    return key1 != null && key2 != null && key1 + '' === key2 + '';
+}
+
+/**
+ * @return return null if not exist.
+ */
+function makeComparableKey(val: string | number): string {
+    if (__DEV__) {
+        if (val == null) {
+            throw new Error();
+        }
+    }
+    return val + '';
+}
+
 export function isNameSpecified(componentModel: ComponentModel): boolean {
     const name = componentModel.name;
     // Is specified when `indexOf` get -1 or > 0.
@@ -444,12 +493,46 @@ export function isNameSpecified(componentModel: ComponentModel): boolean {
  * @param {Object} cmptOption
  * @return {boolean}
  */
-export function isIdInner(cmptOption: ComponentOption): boolean {
+export function isIdInner(cmptOption: MappingExistingItem): boolean {
     return cmptOption
-        && cmptOption.id
-        && (cmptOption.id + '').indexOf('\0_ec_\0') === 0;
+        && cmptOption.id != null
+        && makeComparableKey(cmptOption.id).indexOf('\0_ec_\0') === 0;
+}
+
+
+export function setComponentTypeToKeyInfo(
+    mappingResult: MappingResult<MappingExistingItem & { subType?: ComponentSubType }>,
+    mainType: ComponentMainType,
+    componentModelCtor: ComponentModelConstructor
+): void {
+    // Set mainType and complete subType.
+    each(mappingResult, function (item) {
+        const newOption = item.newOption;
+        if (isObject(newOption)) {
+            item.keyInfo.mainType = mainType;
+            item.keyInfo.subType = determineSubType(mainType, newOption, item.existing, componentModelCtor);
+        }
+    });
 }
 
+function determineSubType(
+    mainType: ComponentMainType,
+    newCmptOption: ComponentOption,
+    existComponent: { subType?: ComponentSubType },
+    componentModelCtor: ComponentModelConstructor
+): ComponentSubType {
+    const subType = newCmptOption.type
+        ? newCmptOption.type
+        : existComponent
+        ? existComponent.subType
+        // Use determineSubType only when there is no existComponent.
+        : (componentModelCtor as ComponentModelConstructor).determineSubType(mainType, newCmptOption);
+
+    // tooltip, markline, markpoint may always has no subType
+    return subType;
+}
+
+
 type BatchItem = {
     seriesId: string,
     dataIndex: number[]
diff --git a/test/option-replaceMerge.html b/test/option-replaceMerge.html
index 2513ec2..30dc848 100644
--- a/test/option-replaceMerge.html
+++ b/test/option-replaceMerge.html
@@ -46,6 +46,8 @@ under the License.
         <div id="main_normalMerge_add_find_hole"></div>
         <div id="main_replaceMerge_inner_and_other_cmpt_not_effect"></div>
         <div id="main_replaceMerge_remove_all"></div>
+        <div id="main_replaceMerge_reproduce_by_getOption_src"></div>
+        <div id="main_replaceMerge_reproduce_by_getOption_tar"></div>
 
 
 
@@ -322,17 +324,39 @@ under the License.
 
         <script>
         require(['echarts'], function (echarts) {
-            var option = makeBasicOption();
+            function makeInitOption() {
+                var option = makeBasicOption();
+
+                option.toolbox = {
+                    left: 'center',
+                    top: 25,
+                    feature: {
+                        magicType: {
+                            show: true,
+                            type: ['line', 'bar']
+                        },
+                        dataZoom: {},
+                        restore: {},
+                        dataView: {}
+                    }
+                };
+
+                return option;
+            }
 
             var chart = testHelper.create(echarts, 'main_replaceMerge_add_find_hole', {
                 title: [
                     '**replaceMerge**: add (find the first hole)',
-                    'click the buttons one by one from left to right',
-                    'should show **TWO checked: Pass**'
+                    '<1> click the buttons one by one from left to right',
+                    'should show **TWO checked: Pass**',
+                    '<2> use **toolbox.dataZoom**, then click btns again, should be OK',
+                    '<3> use **toolbox.magicType**, then click btns again, should be OK',
+                    '<4> use **toolbox.dataView**, then click btns again, should be OK',
+                    '<5> use **toolbox.restore**, then click btns again, should be OK',
                 ],
-                option: option,
+                option: makeInitOption(),
                 buttons: [{
-                    text: 'setOption_remove',
+                    text: 'remove some',
                     onclick: function () {
                         chart.setOption({
                             series: [{
@@ -345,7 +369,7 @@ under the License.
                         }, {replaceMerge: 'series'});
                     }
                 }, {
-                    text: 'check after click setOption_remove',
+                    text: 'then check',
                     onclick: function () {
                         testHelper.printAssert(chart, function (assert) {
                             var seriesModels = chart.getModel().getSeries();
@@ -368,10 +392,22 @@ under the License.
 
                             assert(chart.getModel().getSeriesByIndex(1) == null);
                             assert(chart.getModel().getComponent('series', 1) == null);
+
+                            var eachModelResult = [];
+                            var eachIndexResult = [];
+                            chart.getModel().eachComponent('series', function (seriesModel, seriesIndex) {
+                                eachModelResult.push(seriesModel);
+                                eachIndexResult.push(seriesIndex);
+                            });
+                            assert(eachModelResult.length === 3 && eachIndexResult.length === 3);
+                            for (var i = 0; i < 3; i++) {
+                                assert(eachModelResult[i] === seriesModels[i]);
+                                assert(eachIndexResult[i] === seriesModels[i].componentIndex);
+                            }
                         });
                     }
                 }, {
-                    text: 'setOption_replaceMerge',
+                    text: 'replaceMerge',
                     onclick: function () {
                         chart.setOption({
                             series: [{
@@ -392,7 +428,7 @@ under the License.
                         }, {replaceMerge: 'series'});
                     }
                 }, {
-                    text: 'check after click setOption_replaceMerge',
+                    text: 'then check',
                     onclick: function () {
                         testHelper.printAssert(chart, function (assert) {
                             var seriesModels = chart.getModel().getSeries();
@@ -420,8 +456,25 @@ under the License.
 
                             assert(chart.getModel().getSeriesByIndex(1).id == 'm');
                             assert(chart.getModel().getComponent('series', 1).id == 'm');
+
+                            var eachModelResult = [];
+                            var eachIndexResult = [];
+                            chart.getModel().eachComponent('series', function (seriesModel, seriesIndex) {
+                                eachModelResult.push(seriesModel);
+                                eachIndexResult.push(seriesIndex);
+                            });
+                            assert(eachModelResult.length === 5 && eachIndexResult.length === 5);
+                            for (var i = 0; i < 5; i++) {
+                                assert(eachModelResult[i] === seriesModels[i]);
+                                assert(eachIndexResult[i] === seriesModels[i].componentIndex);
+                            }
                         });
                     }
+                }, {
+                    text: 'reset all',
+                    onclick: function () {
+                        chart.setOption(makeInitOption(), true);
+                    }
                 }],
                 height: 300
             });
@@ -656,6 +709,208 @@ under the License.
 
 
 
+        <script>
+        require(['echarts'], function (echarts) {
+            function makeInitOption() {
+                var option = {
+                    grid: [{
+                        right: '55%',
+                        bottom: 30
+                    }, {
+                        id: 'grid-r',
+                        left: '55%',
+                        bottom: 30
+                    }],
+                    xAxis: [{
+                        type: 'category',
+                        gridIndex: 0,
+                    }, {
+                        id: 'xAxis-r',
+                        type: 'category',
+                        gridIndex: 1,
+                    }],
+                    yAxis: [{
+                        gridIndex: 0
+                    }, {
+                        id: 'yAxis-r',
+                        gridIndex: 1
+                    }],
+                    legend: {},
+                    tooltip: {},
+                    // dataZoom: [{
+                    //     type: 'slider'
+                    // }, {
+                    //     type: 'inside'
+                    // }],
+                    toolbox: {
+                        left: 'center',
+                        top: 25,
+                        feature: {
+                            magicType: {
+                                show: true,
+                                type: ['line', 'bar']
+                            },
+                            dataZoom: {},
+                            restore: {},
+                            dataView: {}
+                        }
+                    },
+                    series: [{
+                        id: 'a',
+                        name: 'aa',
+                        type: 'line',
+                        data: [['a11', 22], ['a33', 44]]
+                    }, {
+                        id: 'b',
+                        name: 'bb',
+                        type: 'line',
+                        data: [['a11', 55], ['a33', 77]],
+                        xAxisIndex: 1,
+                        yAxisIndex: 1,
+                    }, {
+                        id: 'c',
+                        name: 'cc',
+                        type: 'line',
+                        data: [['a11', 66], ['a33', 100]]
+                    }, {
+                        name: 'no_id',
+                        type: 'line',
+                        data: [['a11', 130], ['a33', 160]]
+                    }]
+                };
+
+                return option;
+            }
+
+            var chartSrc = testHelper.create(echarts, 'main_replaceMerge_reproduce_by_getOption_src', {
+                title: [
+                    '**replaceMerge**: reproduce via getOption',
+                    'click the buttons one by one from left to right',
+                    'should show **TWO checked: Pass**',
+                    'The chart reproduced below should be **the same**'
+                ],
+                option: makeInitOption(),
+                buttons: [{
+                    text: 'remove left grid',
+                    onclick: function () {
+                        chartSrc.setOption({
+                            grid: {
+                                id: 'grid-r'
+                            },
+                            xAxis: {
+                                id: 'xAxis-r'
+                            },
+                            yAxis: {
+                                id: 'yAxis-r'
+                            },
+                            series: [{
+                                id: 'b'
+                            }]
+                        }, { replaceMerge: ['series', 'grid', 'xAxis', 'yAxis'] });
+                    }
+                }, {
+                    text: 'reproduce',
+                    onclick: function () {
+                        testHelper.printAssert(chartSrc, function (assert) {
+                            var seriesModels = chartSrc.getModel().getSeries();
+                            assert(seriesModels.length === 3);
+                            assert(seriesModels[0].componentIndex === 0);
+                            assert(seriesModels[1].componentIndex === 2);
+                            assert(seriesModels[2].componentIndex === 3);
+                            assert(seriesModels[0].id === 'a');
+                            assert(seriesModels[1].id === 'c');
+                            assert(seriesModels[2].id === 'd');
+
+                            assert(chartSrc.getModel().getSeriesCount() === 3);
+
+                            var optionGotten = chartSrc.getOption();
+                            assert(optionGotten.series.length === 4);
+                            assert(optionGotten.series[0].name === 'aa');
+                            assert(optionGotten.series[1] == null);
+                            assert(optionGotten.series[2].name === 'cc');
+                            assert(optionGotten.series[3].name === 'dd');
+
+                            assert(chartSrc.getModel().getSeriesByIndex(1) == null);
+                            assert(chartSrc.getModel().getComponent('series', 1) == null);
+                        });
+                    }
+                }, {
+                    text: 'replaceMerge',
+                    onclick: function () {
+                        chartSrc.setOption({
+                            series: [{
+                                id: 'm',
+                                type: 'bar',
+                                data: [['a11', 22], ['a33', 44]]
+                            }, {
+                                id: 'n',
+                                type: 'bar',
+                                data: [['a11', 32], ['a33', 54]]
+                            }, {
+                                id: 'a'
+                            }, {
+                                id: 'c'
+                            }, {
+                                id: 'd'
+                            }]
+                        }, {replaceMerge: 'series'});
+                    }
+                }, {
+                    text: 'then check',
+                    onclick: function () {
+                        testHelper.printAssert(chartSrc, function (assert) {
+                            var seriesModels = chartSrc.getModel().getSeries();
+                            assert(seriesModels.length === 5);
+                            assert(seriesModels[0].componentIndex === 0);
+                            assert(seriesModels[1].componentIndex === 1);
+                            assert(seriesModels[2].componentIndex === 2);
+                            assert(seriesModels[3].componentIndex === 3);
+                            assert(seriesModels[4].componentIndex === 4);
+                            assert(seriesModels[0].id === 'a');
+                            assert(seriesModels[1].id === 'm');
+                            assert(seriesModels[2].id === 'c');
+                            assert(seriesModels[3].id === 'd');
+                            assert(seriesModels[4].id === 'n');
+
+                            assert(chartSrc.getModel().getSeriesCount() === 5);
+
+                            var optionGotten = chartSrc.getOption();
+                            assert(optionGotten.series.length === 5);
+                            assert(optionGotten.series[0].id === 'a');
+                            assert(optionGotten.series[1].id === 'm');
+                            assert(optionGotten.series[2].id === 'c');
+                            assert(optionGotten.series[3].id === 'd');
+                            assert(optionGotten.series[4].id === 'n');
+
+                            assert(chartSrc.getModel().getSeriesByIndex(1).id == 'm');
+                            assert(chartSrc.getModel().getComponent('series', 1).id == 'm');
+                        });
+                    }
+                }, {
+                    text: 'reset all',
+                    onclick: function () {
+                        chartSrc.setOption(makeInitOption(), true);
+                    }
+                }],
+                height: 200
+            });
+
+            var chartTar = testHelper.create(echarts, 'main_replaceMerge_reproduce_by_getOption_tar', {
+                title: [
+                    '↓↓↓ reproduce ↓↓↓ ',
+                ],
+                option: {},
+                height: 200
+            });
+
+        });
+        </script>
+
+
+
+
+
+
     </body>
 </html>
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@echarts.apache.org
For additional commands, e-mail: commits-help@echarts.apache.org