背景
大背景请参照:《基于美团地图 JSAPI 的地图组件技术方案调研》,技术方案调研结论是“ 使用组件是地图相关业务开发的最佳实践 ”。在确定这一方向后,我又调研了EleFE-高德地图组件,基于百度地图的组件库,滴滴-基于腾讯地图的组件库,大致看了下这些组件库的实现方案,总结出以下规律:
- 因为图形、覆盖物等视图产物最终是地图
JSAPI
渲染,所以地图组件不需要返回JSX
; - 地图组件的主要功能是在处理
props
变更后的响应,以及生命周期管理。
根据以上规律 & 我方诉求,总结出我们地图组件的画像:
- 支持
props
响应式设置; - 支持通过
props
绑定事件监听; - 支持部分
props
便捷式设置,例如:将instance.hide
和instance.show
转为visible
属性通过props
来设置; - 减少用户调用实例方法的场景,尽量通过
props
完成功能。
一、方案概述
有了 组件画像(组件大致的功能),我们就可以考虑输入和输出了。组件的输入是 props
、输出是 null
,所以下面的工作是确定组件的 props
。
1.1 props 设计
通过阅读 美团地图JSAPI 文档,结合上述 组件画像 总结出 props
设计如下:
- 所有的
consturctor
的options
都被设计为组件的props
; - 实例支持的事件被转为
onEventName
和onceEventName
合并进props
; - 将组件实例上
enable & disable
、show & hide
等方法转为如enablePropName
和visible
合并至props
;
1.2 props 响应式功能设计
当用户更改组件的 props
,当然是期待一些事情会发生,比如更改 Infowindow
的 size
后,用户期待地图上指定的 Infowindow
的尺寸被调整,比如原本给 Infowindow
绑定的 click
事件是“点击后隐藏”,当满足某一条件重新渲染后,用户又给其绑定的 click
事件是 alert(xxx)
,此时用户期待点击 Infowindow
后执行 alert(xxx)
。
前面提到我们的 props
合并了好几部分内容,因此需要对 props
进行一个大致的分类,然后再确定每个分类的 props
变更后的预期。
通过观察 constructor options
& instance methods
& instance event
,我将其 props
抽象为以下几种类型:
- 类型 A,有
propName
和instance.setPropName
,见Infowindow
的 size & setSize; - 类型 B,有
propName
无instance.setPropName
,见Infowindow
的 autopanBypadding; - 类型 C,有
propName
和对应的setter
,见DrawingManager
的 offset & offset(setter);
站在用户角度,我对以上 3 类 props
变更的预期是:
- 类型 A,执行
instance.setPropName
; - 类型 B,组件实例重新生成;
- 类型 C,同类型 A,通过
setter
来达到修改实例表现的目的。
二、方案详述
将功能点拆分后使用 hook
实现,并在 HOC
内进行组合,然后开发组件时,调用 HOC
即可完成组件基本功能,组件开发人员关注组件要实现的一些便利性功能即可,开整。
2.1 useReactiveProps 实现类型 A props 的响应式功能
这里能在循环中使用 useEffect
是因为可确保循环次数不变。
import { useEffect } from 'react';
import { headUppercase } from '../utils';
interface UseReactiveProps<T> {
props: T;
instance: any;
reactiveProps?: string[];
}
/**
* @description 使所有的响应式 props 变更后,执行实例的 setter
*/
export const useReactiveProps = <T extends Record<string, any>>(params: UseReactiveProps<T>) => {
const { reactiveProps = [], props, instance } = params;
reactiveProps.forEach((name) => {
const reactivePropName = headUppercase(name as string);
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const setter = instance?.[`set${reactivePropName}`];
const data = props[name];
if (setter && data !== undefined) {
try {
setter.call?.(instance, data);
} catch (error) {
// do sth e.g 埋点
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instance, props[name]]);
});
return reactiveProps;
};
2.2 useInstance 实现类型 C props 响应式功能
主要是把 setter
转为 setPropName
挂载到 instance
上,然后复用 useReactiveProps
。
import { useState } from 'react';
import { useMemoizedFn } from 'ahooks';
import { set, upperFirst } from 'lodash-es';
export const useInstance = <Instance extends Record<string, any>>(
transformKeys: Array<keyof Instance>, // transformKeys 就是有 setter 的 porps 集合
) => {
const [instance, _setInstance] = useState<Instance>();
const setInstance = useMemoizedFn((instance: Instance) => {
transformKeys.forEach((key) => {
// 给实例上添加方法
set(instance, `get${upperFirst(key as string)}`, () => instance[key]);
set(instance, `set${upperFirst(key as string)}`, (data: Instance[typeof key]) => {
instance[key] = data;
});
});
_setInstance(instance);
});
return {
instance,
setInstance,
};
};
2.3 useReCreate 实现类型 B props 响应式功能
hook
内通过一个简易的深比较 prevProps
和 curProps
来判断是否应该重新生成实例
import { get, some } from 'lodash-es';
import { isSameValue } from '../utils';
import { usePrevious } from './use-previous';
/**
* @description 判断地图组件实例是否需要重新创建
* @returns 返回一个 boolean,表示是否需要重新创建
*/
export const useReCreate = (props: Record<string, any>) => {
const curProps = props;
const prevProps = usePrevious(props);
const allProps = { ...curProps, ...(prevProps || {}) };
const allPropsList = Object.keys(allProps);
return some(allPropsList, (key) => {
const prevProp = get(prevProps, key as string);
const curProp = get(curProps, key as string);
if (isSameValue(prevProp, curProp)) return false;
return true;
});
};
2.4 useReactiveEvents 实现将 event 转为 onEventName props
import type { EventEmitter } from '@mtfe/map-web';
import { get, upperFirst } from 'lodash-es';
import { isSameValue } from '../utils';
import { usePrevious } from './use-previous';
interface UseReactiveEventsProps<T> {
instance?: EventEmitter;
reactiveEvents: string[];
props: T;
}
/**
* @description 使所有的事件 handler 具有响应式特征,即 handler 变更后,实例重新绑定监听函数
* @returns 返回事件相关的 props 列表
*/
export const useReactiveEvents = <T extends Record<string, any>>(
params: UseReactiveEventsProps<T>,
) => {
const { instance, reactiveEvents = [], props: curProps } = params;
/**
* usePrevious(instance ? curProps : {}) 是有说法的;
* 这样做是为了丢弃第一次 instance 为 undefined 的 props;
* 请不要修改,否则会造成 bug
*/
const prevProps = usePrevious(instance ? curProps : {});
const eventPropsList = reactiveEvents.map((key) => `on${upperFirst(key as string)}`);
const onceEventPropsList = reactiveEvents.map((key) => `once${upperFirst(key as string)}`);
eventPropsList.forEach((eventProp, index) => {
if (!instance) return;
const eventName = reactiveEvents[index];
// ============== 处理 onEvent 事件绑定 =====================
const curEventHandler = get(curProps, eventProp);
const prevEventHandler = get(prevProps, eventProp);
if (prevEventHandler) instance.off(eventName, prevEventHandler);
if (curEventHandler) instance.on(eventName, curEventHandler);
// ============== 处理 onceEvent 事件绑定 =====================
const onceEventProp = onceEventPropsList[index];
const curOnceEventHandler = get(curProps, onceEventProp);
const prevOnceEventHandler = get(prevProps, onceEventProp);
// 如果是相同的函数,那么即使实例重新创建,也不会再次绑定
if (isSameValue(curOnceEventHandler, prevOnceEventHandler)) return;
if (curOnceEventHandler) instance.once(eventName, curOnceEventHandler);
});
return [...eventPropsList, ...onceEventPropsList];
};
2.5 useVisible 实现实例方法转 props
import { useEffect } from 'react';
import { get, isNil } from 'lodash-es';
/**
* @description 把组件的 visible 转为 show && hide 方法
*/
export const useVisible = <T extends Record<string, any>>(instance: any, props: T) => {
const visible = get(props, 'visible');
useEffect(() => {
if (!instance || isNil(visible)) return;
if (visible) {
instance.show?.();
return;
}
if (visible === false) {
instance.hide?.();
}
}, [instance, visible]);
return ['visible'];
};
2.6 useLifeCycle 组件生命周期管理
import { useEffect, useRef } from 'react';
import type { Map as MTMap } from '@mtfe/map-web';
import { loadMapPlugins } from '@sfe/wand-map/utils';
import { useMemoizedFn, useUpdateEffect } from 'ahooks';
interface UseLifeCycleProps<T> {
/**
* 地图容器组件实例
*/
mapInstance?: MTMap;
/**
* 创建地图组件实例的方法
*/
createInstance: (map: MTMap) => T | null;
/**
* 地图组件实例创建后的钩子
*/
onCreated?: (instance: T) => void;
/**
* 地图组件卸载时的钩子
*/
onUnmount?: (instance: T, mapInstance?: MTMap) => void;
isPlugin?: boolean
pluginName?: keyof mtdpMap
mapConstructor?: mtdpMap
shouldCreate: boolean
}
/**
* @description 对组件生命周期进行管理
*/
export const useLifeCycle = <T = any>(props: UseLifeCycleProps<T>) => {
const {
mapInstance, createInstance, onCreated, onUnmount, mapConstructor, pluginName, isPlugin, shouldCreate,
} = props;
const instanceRef = useRef<T>();
const init = useMemoizedFn(async (map: MTMap) => {
if (isPlugin) {
await loadMapPlugins([pluginName!]);
// 加载后也要判断下插件是否存在
if (!mapConstructor![pluginName!]) return;
}
const instance = createInstance(map);
if (!instance) return;
instanceRef.current = instance;
onCreated?.(instance);
});
useEffect(() => {
/**
* 地图实例化成功后,必须执行一次 init
*/
if (mapInstance) init(mapInstance);
}, [mapInstance, init]);
useUpdateEffect(() => {
if (!shouldCreate) return;
if (instanceRef.current) onUnmount?.(instanceRef.current, mapInstance);
if (mapInstance) init(mapInstance);
}, [shouldCreate]);
};
2.7 WithReactiveHOC 高阶组件内对 hooks 进行组合
import React, { useImperativeHandle } from 'react';
import type { EventEmitter } from '@mtfe/map-web';
import { omit } from 'lodash-es';
import {
useInstance,
useLifeCycle,
useReactiveEvents,
useReactiveProps,
useReCreate,
useVisible,
} from '../hooks';
export interface WithReactiveHOCProps<T> {
instance: T
}
/**
* 这些属性是容器组件传入的,不应当监听
*/
const DEFAULT_OMIT_PROPS = ['mtdpMap', 'map', 'container'];
export const WithReactiveHOC = <
T extends object,
Instance extends EventEmitter,
Ref extends object,
>(
Component: React.FC<any>,
options: {
/**
* 应用于有 setPropName 的属性,这类属性变更后,执行 instance.setPropName
*/
reactiveProps?: Array<keyof T>;
/**
* 将所有的事件绑定转为 onEvent 属性
* 这类属性变更后,实例会解绑上一次绑定的 handler,然后绑定新传入的 handler
*/
reactiveEvents?: string[];
/**
* 将实例的 setter & getter 转为 setPropName & getPropName
*/
setterAndGetterTransform?: Array<keyof Instance>;
isPlugin?: boolean
constructorName: keyof mtdpMap
onUnmount: (instance: Instance, map?: MTMap.Map) => void
},
) => {
const {
reactiveProps = [],
reactiveEvents = [],
setterAndGetterTransform = [],
isPlugin,
constructorName,
onUnmount,
} = options;
const mergedReactiveProps = [...reactiveProps, ...setterAndGetterTransform] as string[];
return React.memo(
React.forwardRef<Ref, T>((props: T, ref: any) => {
const { mtdpMap, map, container } = props as MTMap.PropsWithMapInjected<T>;
const { instance, setInstance } = useInstance<Instance>(setterAndGetterTransform);
// 消费响应式属性
const consumedReactiveProps = useReactiveProps({
instance,
reactiveProps: mergedReactiveProps,
props,
});
// 消费事件绑定相关 props
const consumedEventProps = useReactiveEvents({ instance, reactiveEvents, props });
// 消费 visible
const consumedVisibleProps = useVisible(instance, props);
const notConsumedProps = omit(props, [
...setterAndGetterTransform,
...consumedReactiveProps,
...consumedEventProps,
...consumedVisibleProps,
...DEFAULT_OMIT_PROPS,
]);
/**
* 当代码执行到这里,如果还有未消费的 props,说明这些 props 无 setter
* 预期当这些 props 变更后,组件实例重新生成
*/
const needRecreate = useReCreate(notConsumedProps);
/**
* 组件生命周期管理
*/
useLifeCycle<Instance>({
onCreated: (instance) => setInstance(instance),
createInstance: () => new window.mtdpMap![constructorName](props),
mapConstructor: mtdpMap,
pluginName: constructorName,
mapInstance: map,
shouldCreate: needRecreate,
onUnmount,
isPlugin,
});
useImperativeHandle<MTMap.ComponentRef<Instance>, any>(ref, () => ({
instance,
map,
container,
}), [instance, map, container]);
return (
<Component {...props} instance={instance} />
);
}),
);
};
三、组件开发实践
下面以开发绘图组件 DrawingControl
为例,开发者只需要在自己开发的组件内实现组件特有逻辑,然后用 HOC
包裹即可获得上述所有能力。
这样做主要是为了关注点分离,让开发者能够专心思考组件要实现的特殊便利性功能。
const ESC_CODE = 'Escape';
/**
* 绘图组件
*/
const DrawingControlInner = (
props: DrawingControlProps & WithReactiveHOCProps<DrawingManager>,
) => {
const { onCancel, instance } = props;
const escapeListener = useMemoizedFn((e: KeyboardEvent) => {
if (e?.code === ESC_CODE) {
onCancel?.();
}
});
useUpdateEffect(() => {
if (instance) {
document.removeEventListener('keydown', escapeListener);
document.addEventListener('keydown', escapeListener);
}
}, [instance]);
return null;
};
export const DrawingControl = WithReactiveHOC<
DrawingControlProps,
DrawingManager,
MTMap.ComponentRef<DrawingManager>
>(React.memo(DrawingControlInner), {
reactiveProps: ['activeMode'],
reactiveEvents: [
'addnode',
'circlecomplete',
'close',
'ellipsecomplete',
'hide',
'markercomplete',
'open',
'overlaycomplete',
'overlayremove',
'polygoncomplete',
'polylinecomplete',
'rectanglecomplete',
'show',
],
setterAndGetterTransform: ['style', 'offset', 'position'],
isPlugin: true,
constructorName: 'DrawingManager',
onUnmount: (instance, map) => {
instance.hide();
map?.removeControl(instance);
},
});