G2 源码版本
4.2.5
话接上回 通过示例来看 G2 源码是如何运行的,我们来聊聊 G2 的具体渲染过程。
渲染代码
渲染逻辑是在 paint()
函数中实现的。
protected paint(isUpdate: boolean) {
this.renderDataRecursive(isUpdate);
// 处理 sync scale 的逻辑
this.syncScale();
this.emit(VIEW_LIFE_CIRCLE.BEFORE_PAINT);
// 初始化图形、组件位置,计算 padding
this.renderPaddingRecursive(isUpdate);
// 布局图形、组件
this.renderLayoutRecursive(isUpdate);
// 背景色 shape
this.renderBackgroundStyleShape();
// 最终的绘制 render
this.renderPaintRecursive(isUpdate);
this.emit(VIEW_LIFE_CIRCLE.AFTER_PAINT);
this.isDataChanged = false; // 渲染完毕复位
}
递归数据处理
由于 View 拥有创建子 View 的能力,而 Chart 是继承与 View 的。所以就形成了一颗顶层为 Chart,子孙层为 View 的树形结构。
// view.ts
public createView(cfg?: Partial<ViewCfg>): View {}
所以,处理树形结构我们就得使用递归来写了。下面是递归函数中的处理过程。
/**
* 递归渲染中的数据处理
* @param isUpdate
*/
private renderDataRecursive(isUpdate: boolean) {
// 1. 处理数据过滤
this.doFilterData();
// 2. 创建坐标系实例
this.createCoordinate();
// 3. 初始化 Geometry
this.initGeometries(isUpdate);
// 4. 处理分面逻辑,最终都是生成子 view 和 geometry
this.renderFacet(isUpdate);
// 同样递归处理子 views
const views = this.views;
for (let i = 0, len = views.length; i < len; i++) {
const view = views[i];
view.renderDataRecursive(isUpdate);
}
}
- 处理数据过滤,得到过滤后的数据 filteredData。
- 创建坐标系实例,计算出坐标系数据。这里可以看到坐标系的变化。
- 绑定几何图形度量对象、初始化几何图形实例(这些实例是在执行
chart.line()
的时候实例化并保存在 geometries 数组中的)。 - 渲染分面 facet.
- 递归处理子 view 的数据
坐标轴同步
如果在 scale 中加上了 sync: true
,那么 G2 就会去进行度量的同步。反之没有 sync 配置项就不会执行 syncScale()
函数。
chart.scale({
Deaths: {
sync: true,
nice: true,
},
death: {
sync: true,
nice: true,
},
});
同步度量函数:
/**
* 处理 scale 同步逻辑
*/
private syncScale() {
// 最终调用 root view 的
this.getRootView().scalePool.sync(this.getCoordinate(), this.theme);
}
scalePoll 的 sync 函数:
/**
* 同步 scale
*/
public sync(coordinate: Coordinate, theme: ViewCfg['theme']) {
// 对于 syncScales 中每一个 syncKey 下面的 scale 数组进行同步处理
this.syncScales.forEach((scaleKeys: string[], syncKey: string) => {
// min, max, values, ranges
let min = Number.MAX_SAFE_INTEGER;
let max = Number.MIN_SAFE_INTEGER;
const values = [];
// 1. 遍历求得最大最小值,values 等
each(scaleKeys, (key: string) => {
const scale = this.getScale(key);
max = isNumber(scale.max) ? Math.max(max, scale.max) : max;
min = isNumber(scale.min) ? Math.min(min, scale.min) : min;
// 去重
each(scale.values, (v: any) => {
if (!values.includes(v)) {
values.push(v);
}
});
});
// 2. 同步
each(scaleKeys, (key: string) => {
const scale = this.getScale(key);
if (scale.isContinuous) {
scale.change({
min,
max,
values,
});
} else if (scale.isCategory) {
let range = scale.range;
const cacheScaleMeta = this.getScaleMeta(key);
// 存在 value 值,且用户没有配置 range 配置 to fix https://github.com/antvis/G2/issues/2996
if (values && !get(cacheScaleMeta, ['scaleDef', 'range'])) {
// 更新 range
range = getDefaultCategoryScaleRange(
deepMix({}, scale, {
values,
}),
coordinate,
theme
);
}
scale.change({
values,
range,
});
}
});
});
}
这个函数对图表中的度量带有 sync 的度量进行了同步,同步的逻辑为:
- 遍历获取最小值和最大值,它取决于系统计算出来的最小最大值和用户定义的最小最大值。
- 遍历数据去重
- 更新图表度量数据的对象 map
scales
下面是我对 G2 同步的一些理解:
- G2 并没有真的将两个视图完全同步,而只是在度量上进行了最大值和最小值的统一。如果说两个 vuew 的位置不同(设置了 region、或者 padding 不一致),那么数据就不可能做到同步。
- 在 G2 中,对属性 y 使用
sync: true
等于是sync: 'y'
。如果想要多个值同步到 y,那么就应该都使用sync: 'y'
。
特别注意的是,被同步的对象也需要加上sync
, 用来计算公共度量的最大最小值。
y: {
min: 0,
sync: true, // 等于 sync: 'y'
nice: true,
},
trendY: {
min: 0,
sync: 'y',
nice: true,
},
- 反过来说,如果所有度量都设置了
sync: true
即同步自身的最大最小值。其实就毫无意义了。
const view1 = chart.createView();
const view2 = chart.createView({
region: {
start: { x: 0.5, y: 0.5 },
end: { x: 1, y: 1 },
},
});
- 另外, G2 的
scales
中不但包含了 x 轴、多个 y 轴,还有色值、形状的度量。当然,同步的时候一般只同步 y 轴的最大值和最小值。
chart.line().position('date*blockchain').color('#1890ff').shape('smooth');
chart.line().position('date*nlp').color('#2fc25b');
[
{ 'view44-date': Object },
{ 'view44-blockchain': Object },
{ 'view44-#1890ff': Object },
{ 'view44-smooth': Object },
{ 'view44-nlp': Object },
{ 'view44-#2fc25b': Object },
]
位置计算
和数据的处理一样,View 的位置信息也必然是递归处理的。
/**
* 递归计算每个 view 的 padding 值,coordinateBBox 和 coordinateInstance
* @param isUpdate
*/
protected renderPaddingRecursive(isUpdate: boolean) {
// 1. 子 view 大小相对 coordinateBBox,changeSize 的时候需要重新计算
this.calculateViewBBox();
// 2. 更新 coordinate
this.adjustCoordinate();
// 3. 初始化组件 component
this.initComponents(isUpdate);
// 4. 布局计算每个 view 的 padding 值
// 4.1. 自动加 auto padding -> absolute padding,并且增加 appendPadding
this.autoPadding = calculatePadding(this).shrink(parsePadding(this.appendPadding));
// 4.2. 计算出新的 coordinateBBox,更新 Coordinate
// 这里必须保留,原因是后面子 view 的 viewBBox 或根据 parent 的 coordinateBBox
this.coordinateBBox = this.viewBBox.shrink(this.autoPadding.getPadding());
this.adjustCoordinate();
// 刷新 tooltip (tooltip crosshairs 依赖 coordinate 位置)
const tooltipController = this.controllers.find((c) => c.name === 'tooltip');
tooltipController.update();
// 同样递归处理子 views
const views = this.views;
for (let i = 0, len = views.length; i < len; i++) {
const view = views[i];
view.renderPaddingRecursive(isUpdate);
}
}
- 计算 view 的容器范围边界,其中包含有 region 的计算过程。这里可以发现 view 的位置属性
region
只能以百分比的形式定义。
// 默认值
props.region = { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }
// region 的计算过程
const { start, end } = this.region;
// 根据 region 计算当前 view 的 bbox 大小。
const viewBBox = new BBox(
x + width * start.x,
y + height * start.y,
width * (end.x - start.x),
height * (end.y - start.y)
);
- 调整坐标范围
- 初始化组件 components,具体有如下组件:
- axis 坐标轴
- legend 图例
- tooltip 提示
- annotation 标注
- slider 缩略轴
- scrollbar 滚动条
- 计算各个组件的 padding 值
- 根据 padding 重新计算 view 的边界,更新坐标系。
- 调整坐标范围
- 刷新 tooltip 组件
- 递归处理子 view
初始化组件的位置
/**
* 递归处理 view 的布局,最终是计算各个 view 的 coordinateBBox 和 coordinateInstance
* @param isUpdate
*/
protected renderLayoutRecursive(isUpdate: boolean) {
// 1. 同步子 view padding
// 根据配置获取 padding
const syncViewPaddingFn =
this.syncViewPadding === true
? defaultSyncViewPadding
: isFunction(this.syncViewPadding)
? this.syncViewPadding
: undefined;
if (syncViewPaddingFn) {
syncViewPaddingFn(this, this.views, PaddingCal);
// 同步 padding 之后,更新 coordinate
this.views.forEach((v: View) => {
v.coordinateBBox = v.viewBBox.shrink(v.autoPadding.getPadding());
v.adjustCoordinate();
});
}
// 3. 将 view 中的组件按照 view padding 移动到对应的位置
this.doLayout();
// 同样递归处理子 views
const views = this.views;
for (let i = 0, len = views.length; i < len; i++) {
const view = views[i];
view.renderLayoutRecursive(isUpdate);
}
}
在这个过程中,有一个 syncViewPadding
函数来进一步处理 padding 信息。
是否同步子 view 的 padding。 比如: view1 的 padding 10, view2 的 padding 20, 那么两个子 view 的 padding 统一变成最大的 20。如果是 Funcion,则使用自定义的方式去计算子 view 的 padding,这个函数中去修改所有的 views autoPadding 值
随后执行 doLayout()
函数,它其实就是调用各个组件的 layout 函数完成组件位置布局的初始化。
export default function defaultLayout(view: View): void {
const axis = view.getController('axis');
const legend = view.getController('legend');
const annotation = view.getController('annotation');
const slider = view.getController('slider');
const scrollbar = view.getController('scrollbar');
// 根据最新的 coordinate 重新布局组件
[axis, slider, scrollbar, legend, annotation].forEach((controller: Controller) => {
if (controller) {
controller.layout();
}
});
}
这些组件的 layout()
函数的工作是:计算并移动组件到目标位置。
背景色渲染
为了可以单独渲染背景色,所以 g2 单独建了个图层分组放在 canvas 最底层。
组件绘制
/**
* 最终递归绘制组件和图形
* @param isUpdate
*/
protected renderPaintRecursive(isUpdate: boolean) {
const middleGroup = this.middleGroup; // G 引擎的图形分组
// 是否对超出坐标系范围的 Geometry 进行剪切。
if (this.limitInPlot) {
const { type, attrs } = getCoordinateClipCfg(this.coordinateInstance);
middleGroup.setClip({
type,
attrs,
});
} else {
// 清除已有的 clip
middleGroup.setClip(undefined);
}
// 1. 渲染几何标记
this.paintGeometries(isUpdate);
// 2. 绘制组件
this.renderComponents(isUpdate);
// 同样递归处理子 views
const views = this.views;
for (let i = 0, len = views.length; i < len; i++) {
const view = views[i];
view.renderPaintRecursive(isUpdate);
}
}
首先,middleGroup 其实是 G 引擎的图形分组,而组件就是绘制在 middleGroup 分组上的。
const props = {
/** 图形容器 */
container: this.middleGroup.addGroup(),
labelsContainer: this.foregroundGroup.addGroup(),
...cfg,
};
如果有 limitInPlot 属性则对坐标系范围外的内容进行裁切,否则不进行裁切。
然后下面是图形渲染的重头戏 —— 渲染图形和组件。
paintGeometries
渲染图形其实就是渲染折线图、柱状图、面积图等图形。
/**
* 根据 options 配置自动渲染 geometry
* @private
*/
private paintGeometries(isUpdate: boolean) {
const doAnimation = this.options.animate; // 是否执行动画
// geometry 的 paint 阶段
const coordinate = this.getCoordinate(); // 获取坐标系
const canvasRegion = {
x: this.viewBBox.x,
y: this.viewBBox.y,
minX: this.viewBBox.minX,
minY: this.viewBBox.minY,
maxX: this.viewBBox.maxX,
maxY: this.viewBBox.maxY,
width: this.viewBBox.width,
height: this.viewBBox.height,
}; // 获取画布位置
// 遍历图形,执行动画和渲染行为。
const geometries = this.geometries;
for (let i = 0; i < geometries.length; i++) {
const geometry = geometries[i];
geometry.coordinate = coordinate;
geometry.canvasRegion = canvasRegion;
if (!doAnimation) {
// 如果 view 不执行动画,那么 view 下所有的 geometry 都不执行动画
geometry.animate(false);
}
geometry.paint(isUpdate);
}
}
继续深入看看 animate()
函数和 paint()
函数都做了些什么?
public animate(cfg: AnimateOption | boolean): Geometry {
this.animateOption = cfg;
return this;
}
和其他配置项一样,animate() 函数只是将配置内容存到配置项中,并返回自身用于链式调用。
最终,所有的渲染行为都是在 paint() 函数中执行的。
- 定义配置项
- 将原始数据转换成带有 xy 位置信息的数据
- 执行
updateElements()
函数创建图形对象,这个函数再某些图形中被重写了。 - 通过
doGroupAppearAnimate()
函数执行动画
/**
* 将原始数据映射至图形空间,同时创建图形对象。
*/
public paint(isUpdate: boolean = false) {
// 合并动画配置项
if (this.animateOption) {
this.animateOption = deepMix({}, getDefaultAnimateCfg(this.type, this.coordinate), this.animateOption);
}
this.defaultSize = undefined;
this.elementsMap = {};
this.elements = [];
const offscreenGroup = this.getOffscreenGroup();
offscreenGroup.clear();
const beforeMappingData = this.beforeMappingData; // 初始的数据源
const dataArray = this.beforeMapping(beforeMappingData); // 转化后的数据源
this.dataArray = new Array(dataArray.length);
for (let i = 0; i < dataArray.length; i++) {
const data = dataArray[i];
this.dataArray[i] = this.mapping(data);
}
this.updateElements(this.dataArray, isUpdate);
this.lastElementsMap = this.elementsMap;
if (this.canDoGroupAnimation(isUpdate)) {
// 如果用户没有配置 appear.animation,就默认走整体动画
const container = this.container;
const type = this.type;
const coordinate = this.coordinate;
const animateCfg = get(this.animateOption, 'appear');
const yScale = this.getYScale();
const yMinPoint = coordinate.convert({
x: 0,
y: yScale.scale(this.getYMinValue()),
});
doGroupAppearAnimate(container, animateCfg, type, coordinate, yMinPoint); // 执行动画
}
// 添加 label
if (this.labelOption) {
const deferred = this.useDeferredLabel;
const callback = (() => this.renderLabels(flatten(this.dataArray) as unknown as MappingDatum[], isUpdate)).bind(
this
);
if (typeof deferred === 'number') {
// Use `requestIdleCallback` to render labels in idle time (like react fiber)
const timeout = typeof deferred === 'number' && deferred !== Infinity ? deferred : 0;
if (!window.requestIdleCallback) {
setTimeout(callback, timeout);
} else {
const options = timeout && timeout !== Infinity ? { timeout } : undefined;
window.requestIdleCallback(callback, options);
}
} else {
callback();
}
}
// 缓存,用于更新
this.lastAttributeOption = {
...this.attributeOption,
};
if (this.visible === false) {
// 用户在初始化的时候声明 visible: false
this.changeVisible(false);
}
}
动画渲染
执行动画的代码
export function doGroupAppearAnimate(
container: IGroup,
animateCfg: AnimateCfg,
geometryType: string,
coordinate: Coordinate,
minYPoint: Point
) {
if (GEOMETRY_GROUP_APPEAR_ANIMATION[geometryType]) {
const defaultCfg = GEOMETRY_GROUP_APPEAR_ANIMATION[geometryType](coordinate);
const animation = getAnimation(get(defaultCfg, 'animation', ''));
if (animation) {
const cfg = {
...DEFAULT_ANIMATE_CFG.appear,
...defaultCfg,
...animateCfg,
};
container.stopAnimate(); // 先结束当前 container 动画
animation(container, cfg, {
coordinate,
minYPoint,
toAttrs: null,
});
}
}
}
可以看到是执行了 animate()
函数,那么动画函数从哪儿来呢?
const ANIMATIONS_MAP: AnimationMap = {};
export function getAnimation(type: string) {
return ANIMATIONS_MAP[type.toLowerCase()];
}
export function registerAnimation(type: string, animation: Animation) {
ANIMATIONS_MAP[type.toLowerCase()] = animation;
}
其实是通过初始化 G2 的时候注册到 ANIMATIONS_MAP 对象中去的,那么我们就找一个动画函数 fade-in 来看看
export function fadeIn(shape: IShape | IGroup, animateCfg: GAnimateCfg, cfg: AnimateExtraCfg) {
const endState = {
fillOpacity: isNil(shape.attr('fillOpacity')) ? 1 : shape.attr('fillOpacity'),
strokeOpacity: isNil(shape.attr('strokeOpacity')) ? 1 : shape.attr('strokeOpacity'),
opacity: isNil(shape.attr('opacity')) ? 1 : shape.attr('opacity'),
};
shape.attr({
fillOpacity: 0,
strokeOpacity: 0,
opacity: 0,
});
shape.animate(endState, animateCfg);
}
这里的 shape 就是 @antv/g-base 中的对象了,所以是通过定义动画再让 G 引擎执行动画来实现的。
renderComponents
渲染组件其实就是渲染图例、提示、标注等组件。
/**
* 最后的绘制组件
* @param isUpdate
*/
private renderComponents(isUpdate: boolean) {
const components = this.getComponents();
// 先全部清空,然后 render
for (let i = 0; i < components.length; i++) {
const co = components[i];
(co.component as GroupComponent).render();
}
}
执行各个组件的 render 函数,那我们以 axis 组件为例。
public render() {
this.update();
}
/**
* 更新 axis 组件
*/
public update() {
this.option = this.view.getOptions().axes;
const updatedCache = new Map<string, ComponentOption>();
this.updateXAxes(updatedCache);
this.updateYAxes(updatedCache);
// 处理完成之后,销毁删除的
// 不在处理中的
const newCache = new Map<string, ComponentOption>();
this.cache.forEach((co: ComponentOption, key: string) => {
if (updatedCache.has(key)) {
newCache.set(key, co);
} else {
// 不存在,则是所有需要被销毁的组件
co.component.destroy();
}
});
// 更新缓存
this.cache = newCache;
}
后续再继续往下找发现组件部分引用了 @antv/component,不得不感叹大项目这层层嵌套的复杂程度。
其他收获和总结
越看源码越是觉得 antv 做的这个数据可视化系统的庞大和复杂,我虽然看了 G2 的源码,又好像什么都没看……如果想细究就得再次深入研究依赖库。
另外,感觉 TS 是个好东西。使用 TypeScript 来写代码让项目看着愈发像一个 Java 项目了,这让项目看着更健壮更规范。
虽然依赖库拆分让项目具有很好的解耦性和可扩展性,但对于阅读源码的人来说还是很痛苦的。不过好在我也能从这次源码阅读经历中看到很多熟悉的 API 的实现。对于如何使用 G2Plot 和 G2 有了更深的理解。
对于业务实现,目前看来 G2Plot、G2、G 他们的上手程度是递增的,同样的自由度也是递增的。以我们项目现阶段而言,使用大量 G2plot 结合少量 G2 应该就可以覆盖业务。
就比如,发现在 G2 中可以使用 registerShape 来进行图形自定义,实现一些特殊需求。
在看完源码整体之后,我要再针对特定场景进行源码学习了。下次见~
网友评论