BaseChart的实现
react-vchart中所有图表的封装都是通过高阶组件createChart来实现的,接下来我们以<VChart />的实现来详细解读一下实现原理
1.1
<VChart ``/``>的封装如下:
export const VChart = createChart<VChartProps>('VChart', {
vchartConstrouctor: VChartCore
});
这段代码比较简单,包含了如下内容:
-
使用
createChart工厂函数创建组件 -
指定组件名为
VChart -
注入
VChartCore构造器
1.2 基础图表实现 (BaseChart.tsx)
1.2.1 状态管理
const [updateId, setUpdateId] = useState<number>(0); const chartContext = useRef<ChartContextType>({}); const [view, setView] = useState<IView>(null); const isUnmount = useRef<boolean>(false);
关键状态:
-
updateId:用于控制子组件的更新。 -
chartContext:用于存储图表实例。 -
view:用于存储视图实例。 -
isUnmount:用于控制组件的卸载状态。
1.2.2 Spec 解析系统
const parseSpec = (props: Props) => {
let spec: ISpec;
// 1. 处理直接传入的 spec
if (hasSpec && props.spec) {
spec = props.spec;
if (isValid(props.data)) {
spec = {
...props.spec,
data: props.data
};
}
}
// 2. 处理从子组件收集的 spec
else {
spec = {
...prevSpec.current,
...specFromChildren.current
};
}
// 3. 处理 tooltip
const tooltipSpec = initCustomTooltip(setTooltipNode, props, spec.tooltip);
if (tooltipSpec) {
spec.tooltip = tooltipSpec;
}
return spec;
};
Spec 解析系统包括三个层次:
-
直接传入的 spec 配置。
-
子组件配置的聚合。
-
特殊组件(如 tooltip)的处理。
1.2.3 子组件解析系统
const parseSpecFromChildren = (props: Props) => {
const specFromChildren: Omit<ISpec, 'type' | 'data' | 'width' | 'height'> = {};
toArray(props.children).map((child, index) => {
const parseSpec = child?.type?.parseSpec;
if (parseSpec && child.props) {
// 处理子组件配置...
const specResult = parseSpec(childProps);
// 处理单例和数组配置
if (specResult.isSingle) {
specFromChildren[specResult.specName] = specResult.spec;
} else {
if (!specFromChildren[specResult.specName]) {
specFromChildren[specResult.specName] = [];
}
specFromChildren[specResult.specName].push(specResult.spec);
}
}
});
return specFromChildren;
};
该系统的职责包括:
-
收集所有子组件的配置。
-
区分单例和数组类型的配置。
-
生成最终的配置对象。
1.2.4 更新机制
useEffect(() => {
// 1. 首次渲染
if (!chartContext.current?.chart) {
createChart(props);
renderChart();
return;
}
// 2. spec 更新
if (hasSpec) {
if (!isEqual(eventsBinded.current.spec, props.spec)) {
chartContext.current.chart.updateSpecSync(parseSpec(props));
handleChartRender(true);
}
// 3. 数据更新
else if (eventsBinded.current.data!== props.data) {
chartContext.current.chart.updateFullDataSync(props.data);
handleChartRender(true);
}
return;
}
// 4. 子组件更新
const newSpec = pickWithout(props, notSpecKeys);
if (!isEqual(newSpec, prevSpec.current)) {
// 更新处理...
}
}, [props]);
更新机制涵盖以下四种情况:
-
首次渲染
-
spec 配置更新
-
数据更新
-
子组件引起的更新
1.2.4 生命周期管理
useEffect(() => {
return () => {
if (chartContext.current?.chart) {
chartContext.current.chart.release();
chartContext.current.chart = null;
}
eventsBinded.current = null;
isUnmount.current = true;
};
}, []);
组件销毁的时候,确保资源都能够释放,包括:
-
释放图表实例
-
清理事件绑定
-
更新组件状态
BaseComponent 的实现
2.1 核心实现机制
在 react-vchart 框架中,所有组件的创建均依赖于 createComponent 这一工厂函数。该函数的定义如下:
const createComponent = <T extends ComponentProps>( componentName: string, // 组件名 specName: string, // 规格名称 supportedEvents?: Record<string, string>, // 支持的事件 isSingle?: boolean, // 是否单例 registers?: (() => void)[] // 注册器 ): any => { // ...组件创建逻辑 };
这里通过泛型 <T extends ComponentProps> 来约束传入的组件属性类型。函数接收多个参数:
-
componentName:用于标识组件的名称,在整个应用中具有唯一性,方便开发者识别和管理组件。 -
specName:代表组件对应的规格名称,这对于配置收集和管理非常重要,不同的组件通过不同的规格名称来区分各自的配置。 -
supportedEvents:是一个可选的对象,用于定义组件支持的事件。对象的键值对形式表示事件类型和对应的事件处理逻辑。例如,一个组件可能支持'click'事件,并定义了相应的处理函数。 -
isSingle:布尔值,用于指示组件是否为单例模式。如果为true,则表示在整个应用中该组件只会有一个实例;如果为false,则可以创建多个实例。 -
registers:是一个函数数组,每个函数用于执行特定的注册操作。这些注册操作可能包括向框架中注册组件的特定功能或插件等。
为了更直观地理解,下面以轴组件和图例组件为例展示封装代码:
- 坐标轴
export const Axis = createComponent<AxisProps>('Axis', 'axes');
这里创建了一个名为 Axis 的坐标轴组件,组件名是 Axis,对应的规格名称为 axes。通过这种方式,框架能够准确识别和处理坐标轴组件的相关配置和操作。
- 图例
export const Legend = createComponent<LegendProps>( 'Legend', 'legends', LEGEND_CUSTOMIZED_EVENTS, false, [registerDiscreteLegend] );
此代码创建了 Legend 图例组件,组件名是 Legend,规格名称为 legends。同时,指定了该组件支持的自定义事件 LEGEND_CUSTOMIZED_EVENTS,并且该组件不是单例模式(false),最后还传入了一个注册器函数 registerDiscreteLegend,用于执行特定的注册操作,可能是为图例组件注册离散数据相关的功能。
2.2 组件通信机制
2.2.1 Context 通信
const Comp: React.FC<T> = (props: T) => {
const context = useContext(RootChartContext);
// ...
};
在 React 应用中,组件之间的通信是一个重要的问题。这里使用 useContext 钩子函数来实现组件之间的通信。RootChartContext 是一个上下文对象,它包含了与图表相关的信息,例如图表实例、全局配置等。通过 useContext(RootChartContext),组件可以获取到这些全局信息,从而实现组件之间的数据共享和交互。例如,一个子组件可能需要获取图表的全局配置信息来调整自身的显示方式,就可以通过这种方式获取上下文信息。
2.2.2 事件系统
// 事件绑定 if (supportedEvents) { bindEventsToChart( context.chart, props, eventsBinded.current, supportedEvents ); }
这部分代码实现了组件的事件绑定功能。当组件定义了 supportedEvents(即支持的事件)时,会调用 bindEventsToChart 函数进行事件绑定。该函数接收四个参数:
-
context.chart:代表图表实例,通过上下文获取。事件绑定到该图表实例上,以便在图表发生相应事件时能够触发处理逻辑。 -
props:组件的属性,可能包含与事件相关的配置信息,例如事件处理函数等。 -
eventsBinded.current:可能是一个存储已绑定事件的对象或变量,用于记录当前已经绑定的事件,避免重复绑定。 -
supportedEvents:前面提到的组件支持的事件对象,包含事件类型和处理函数的映射关系。通过这种方式,组件能够与图表实例进行事件交互,实现用户操作与组件行为的关联。
2.3 配置收集机制
每个组件都实现了 parseSpec 方法,用于解析组件对应的配置,最终拼装成 vchart 需要的完整的 spec:
Comp.parseSpec = (props: T) => {
return {
spec: pickWithout(props, notSpecKeys),
specName,
isSingle
};
};
parseSpec 方法在组件配置管理中起着关键作用。它接收组件的属性 props 作为参数,并返回一个包含三个属性的对象:
-
spec:通过pickWithout(props, notSpecKeys)方法获取的组件配置。pickWithout函数可能是一个自定义函数, 用于从props中筛选出需要的配置信息,排除不需要的键(notSpecKeys)。这些配置信息将作为组件的实际配置用于 vchart。 -
specName:前面提到的组件规格名称,用于标识组件的配置类型,方便在整体配置中进行区分和管理。 -
isSingle:指示组件是否为单例模式的布尔值。这个信息在配置拼装和管理过程中也非常重要,例如在处理多个组件配置时,需要根据isSingle的值来决定如何合并配置。通过每个组件实现parseSpec方法,框架能够将各个组件的配置信息收集起来,最终拼装成符合 vchart 要求的完整配置spec。
2.4 组件注册机制
if (registers && registers.length) { VChart.useRegisters(registers); }
这部分代码实现了组件的注册机制。当组件定义了 registers(即注册器数组)并且数组不为空时,会调用 VChart.useRegisters(registers) 方法。VChart 可能是一个全局的图表对象或框架核心对象,useRegisters 方法用于将注册器数组中的函数注册到框架中。这些注册器函数可能用于注册组件的特定功能、插件或与其他模块的集成等。通过这种方式,组件能够将自身的一些特殊功能或配置注册到框架中,以便在整个应用中发挥作用。
2.5 配置过滤
const notSpecKeys = supportedEvents ? Object.keys(supportedEvents).concat(ignoreKeys) : ignoreKeys;
这段代码实现了配置过滤功能。notSpecKeys 变量用于存储不需要的配置键。如果组件定义了 supportedEvents(即支持的事件),则 notSpecKeys 由 supportedEvents 的所有键和 ignoreKeys 合并而成;否则,notSpecKeys 直接等于 ignoreKeys。ignoreKeys 可能是一个预定义的数组,包含了一 些在配置解析过程中需要忽略的键。通过这种方式,在配置收集和解析过程中,能够排除不需要的配置信息,确保最终的配置 spec 只包含有用的信息,提高配置的准确性和有效性。
2.6 更新控制
if (props.updateId!== updateId.current) { updateId.current = props.updateId; // 处理更新逻辑... }
这部分代码实现了组件的更新控制。updateId 是一个用于控制组件更新的标识符。当组件接收到的 props.updateId 与当前存储的 updateId.current 不相等时,说明有更新发生。此时,将 updateId.current 更新为 props.updateId,然后执行后续的更新逻辑(代码中以注释 // 处理更新逻辑... 表示)。这种更新控制机制能够确保组件在接收到新的更新标识时,能够正确地处理更新操作,例如重新渲染组件、更新数据或执行特定的更新任务等,从而保证组件的状态与最新的需求保持一致。
BaseSeries的实现
React-VChart 的系列组件也主要借助高阶组件来实现,以下将对其核心实现部分进行更详细的解析。
3.1 系列组件创建器
export const createSeries = <T extends BaseSeriesProps>( componentName: string, // 组件名称 markNames: string[], // 图形标记名称 type?: string, // 图表类型 registers?: (() => void)[] // 注册函数 ) => { //... }
这个工厂函数在整个系列组件创建过程中扮演着核心角色。它通过泛型 <T extends BaseSeriesProps> 来严格约束传入的组件属性类型,确保类型安全。
函数接收的参数具有各自重要的职责:
-
componentName:作为组件的唯一标识,在整个应用程序中具有唯一性。这使得开发者在管理和识别组件时更加便捷,就如同给每个组件贴上了独一无二的“名字标签”。 -
markNames:系列图元名称数组,用于确定组件所使用的图元。 -
type:图表类型,虽然是可选参数,但明确了组件对应的图表类型 -
registers:申明该系列需要注册的资源,用于通过tree-shaking实现按需加载,做到包体积优化
3.2 Area 组件实现
export type AreaProps = BaseSeriesProps & Omit<IAreaSeriesSpec, 'type'>; export const Area = createSeries<AreaProps>( 'Area', // 组件名 ['area'], // 图形标记 'area', // 类型 [registerAreaSeries] // 注册器 );
Area 组件的定义首先通过 export type AreaProps = BaseSeriesProps & Omit<IAreaSeriesSpec, 'type'> 来定义其属性类型。它结合了 BaseSeriesProps 和 IAreaSeriesSpec,并通过 Omit 操作排除了 'type' 属性,这是因为在创建组件时,类型已经通过 createSeries 函数的参数进行了指定。
然后,通过 createSeries 函数创建 Area 组件。传入的参数分别为组件名 'Area'、图形标记 ['area']、图表类型 'area' 以及注册器 [registerAreaSeries]。注册器 registerAreaSeries 用于执行与面积图相关的特定注册操作,可能包括注册面积图的样式、动画效果等。
3.3 核心功能实现
- 标记 ID 管理
const addMarkId = (spec: any, seriesId: string | number) => {
markNames.forEach(markName => {
const defaultMarkId = `${seriesId}-${markName}`;
if (isNil(spec[markName])) {
spec[markName] = { id: defaultMarkId };
} else if (isNil(spec[markName].id)) {
spec[markName].id = defaultMarkId;
}
});
};
在图表渲染过程中,每个图形标记需要有唯一的标识,这就是标记 ID 管理的作用。addMarkId 函数接收 spec(配置对象)和 seriesId(系列 ID)作为参数。
函数通过遍历 markNames 数组,为每个图形标记生成默认的 markId。生成规则是将 seriesId 和 markName 用 - 连接起来,例如 'series1-area'。
如果 spec 中某个 markName 对应的属性不存在,就创建一个包含默认 markId 的对象;如果 markName 属性存在但 id 不存在,则为其设置默认 markId。这样可以确保每个图形标记都有唯一的标识,方便后续的事件处理和样式设置等操作。
- 事件处理系统
const handleEvent = (e: any) => {
const markIds = markNames.map(markName =>
`${id}-${markName}`
);
if (e?.mark && markIds.includes(e.mark.getUserId())) {
props[VCHART_TO_REACT_EVENTS[e.event.type]](e);
}
};
事件处理系统负责处理用户与图表的交互操作。handleEvent 函数接收一个事件对象 e。
首先,通过 markNames 数组生成所有可能的 markId 数组 markIds。然后检查事件对象 e 中的 mark 是否存在,并且 mark 的用户 ID 是否在 markIds 数组中。
如果满足条件,说明该事件是针对当前组件的图形标记触发的。接着,通过 props[VCHART_TO_REACT_EVENTS[e.event.type]] 找到对应的事件处理函数,并将事件对象 e 传递进去执行相应的操作。VCHART_TO_REACT_EVENTS 是一个映射表,用于将 VChart 的事件类型映射到 React 组件的事件处理函数。
- 配置解析
Comp.parseSpec = (compProps: T) => {
const newSeriesSpec = pickWithout<T>(compProps, notSpecKeys);
// 添加标记 ID
addMarkId(newSeriesSpec, compProps.id?? compProps.componentId);
// 设置类型
if (!isNil(type)) {
newSeriesSpec.type = type;
}
return {
spec: newSeriesSpec,
specName:'series'
};
};
配置解析函数 Comp.parseSpec 负责将组件的属性解析为符合 VChart 要求的配置对象。
首先,通过 pickWithout<T>(compProps, notSpecKeys) 函数从 compProps 中筛选出需要的配置信息,排除不需要的键 notSpecKeys。notSpecKeys 可能包含一些与事件处理或其他无关的属性,通过这种方式确保配置对象的纯净性。
然后,调用 addMarkId 函数为新的系列配置 newSeriesSpec 添加标记 ID,确保每个图形标记都有唯一标识。
接着,如果 type 参数不为空,将图表类型设置到 newSeriesSpec 中。
最后,返回一个包含 spec(解析后的配置对象)和 specName(配置类型名称,这里为 'series')的对象,这个对象将作为最终的配置传递给 VChart 进行渲染。
通过以上详细解析,我们对 React-VChart 系列组件的实现原理有了更深入的理解,包括 组件创建、属性定义以及核心功能的实现方式。这些技术原理为开发者在使用和扩展 React-VChart 时提供了坚实的基础。