likes
comments
collection
share

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

作者站长头像
站长
· 阅读数 8

给文章点个赞后, 可观看视频版: www.bilibili.com/video/BV1zV…

前言

Hello! 各位同学大家好! 前几天一篇关于修bug的文章火遍全网.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

既然大家这么认可我, 那我必须给大家再安排一波! 我又抽空修复了一个antv的bug. 不同的是, 这个bug是我很早以前就发现的, 曾经就尝试过修复, 但是修复失败了. 今日, 我重整旗鼓, 再战恶龙! 这个bug修复了差不多一周, 要个点赞不过分吧? 前排提示, 该bug难度相当大, 如果有同学跟不上文章的分析节奏, 建议看我的视频版. 点赞发车了!

细节之微

先介绍下今天的主角, Tooltip组件.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

没错! 就是鼠标划过图表时, 会跟随鼠标的浮动窗口就是Tooltip. 我们可以看下这个组件是如何定义的

export type Tooltip = false | TooltipOptions;

这里很好理解, 如果设置为false则为不显示Tooltip. 如果需要显示, 则配置这个TooltipOptions即可. 那么我们看看这又是啥定义

export type TooltipOptions = Types.TooltipCfg & TooltipMapping;
// TooltipMapping 不是要讨论的重点, 就不展示了
export interface TooltipCfg {
    /** 省略前面若干个属性 */
    /**
     * @title 自定义模板
     */
    customContent?: (title: string, data: any[]) => string | HTMLElement;
}

那么这个Tooltip的内容, 通常都是自动生成的. 如果用户想要创建一个完全自定义的容器, 那么可以通过customContent函数返回一个DOM即可.

tooltip: {
  customContent: (value, items) => {
    const container = document.createElement('div');
    container.innerText = '这是自定义的容器';
    container.style.position = 'absolute';
    container.style.background = '#fff';
    container.style['box-shadow'] = 'rgb(174 174 174) 0px 0px 10px';
    return container;
  },
}

接下来咱们看看视觉效果

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

哦豁! 看起来很不错! 收工下班! 当我准备起身收起我3万块的mac book pro的时候! 突然间眼睛好痒, 抬手擦了擦眼角, 却一不小心开启了写轮眼!

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

再定睛一看, 发现默认的Tooltip与自定义Tooltip好像有些不太一样.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了! 从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

不知道各位发现区别了没有! 如果你没看出有什么区别. 建议你也揉一揉眼睛, 开启写轮眼再观察一下. 我能很明显地看出来, 自定义的容器看起来总是不停的闪现?

但是人的主观感受是不可靠的, 一模一样的事物在你眼中就是有可能表现的不一样. 比如周一周三周五, 明明都是工作日, 但是周五你就感觉已经放假了一样, 不想干活. 所以不能靠主观下定论. 那么到底该如何从理论上证明他们的确是不同的呢? 我们观察下他们的dom结构. 先来看看默认的Tooltip

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

通过观察dom的变化, 可以得到以下结论

  • 图表是使用Canvas绘制的. 而Tooltip却是使用HTML绘制的.
  • 之所以Tooltip能够跟随鼠标, 是因为使用了绝对定位, 设置了lefttop属性
  • 设置了transition属性, 以保证位置的移动变得丝滑

OK! 接下来咱们看看自定义Tooltip的dom结构

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

看起来好像和默认的Tooltip区别不大. 但是隐隐觉得哪里不对劲. 怎么这个dom的变化好像激烈了许多呢? 来回对比多次后, 我发现了一个非常重要的不同. 我们知道, 当dom的属性发生变化时, 属性会闪动. 这也是为什么鼠标移动的时候, dom一直在闪. 但是自定义Tooltip的闪, 不仅仅体现在属性上, 连div标签都在闪了.

是什么行为能够导致标签都在闪动呢? 我想到了一种可能, 是不是这个dom被整个替换了? 于是我又做了个实验

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

我选中了tooltip, 但是一旦鼠标移动到图表上, 会发现刚刚选中的标签突然处于未选状态了! 这里和我们前面的猜想不谋而合, 整个Tooltip的标签都被替换了. 那自然transition就不生效了. 难怪自定义的Tooltip看起来会是一闪一闪的, 原来是transition属性根本没起作用. 揭开谜底的这一刻, 我露出了邪魅的笑容, 就这???

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

完璧归赵

这个bug是我在使用G2Plot的时候发现的, 那么自然我们就需要先从这个项目的源码入手. 其实Tooltip只是图表的一部分. 还有图例(Legend)、轴(Axis)、标签(Label)等各个组件. 假如把图表理解为一辆汽车的话, Tooltip最多也就是个轮胎. 那么如果车速提不上去的话, 不一定就是轮胎本身的问题, 也可能是汽车没油了, 也可能是发动机出了故障.

简单来说, Tooltip很可能只是表象, 我们需要从整个图表自顶向下去分析. 在图中, 我使用的案例是折线图, 那么我们看看一个折线图是如何创建的呢?

const line = new Line('container', {
  data,
  padding: 'auto',
  xField: 'Date',
  yField: 'scales',
  tooltip: {
    customContent: (value, items) => {
      const container = document.createElement('div');
      container.innerText = '这是自定义的容器';
      container.style.position = 'absolute';
      container.style.background = '#fff';
      container.style['box-shadow'] = 'rgb(174 174 174) 0px 0px 10px';
      return container;
    },
  }
});
line.render();

从API层面来说, 是new了一个Line的实例对象. 那么我们就从这个类入手, 看看他是怎么写的.

import { Plot } from '../../core/plot';
import { Adaptor } from '../../core/adaptor';
import { LineOptions } from './types';
import { adaptor, meta } from './adaptor';
import { DEFAULT_OPTIONS } from './constants';
import './interactions';

export type { LineOptions };

export class Line extends Plot<LineOptions> {
  /**
   * 获取 折线图 默认配置项
   * 供外部使用
   */
  static getDefaultOptions(): Partial<LineOptions> {
    return DEFAULT_OPTIONS;
  }

  /** 图表类型 */
  public type: string = 'line';

  /**
   * @override
   * @param data
   */
  public changeData(data: LineOptions['data']) {
    this.updateOption({ data });
    const { chart, options } = this;
    meta({ chart, options });
    this.chart.changeData(data);
  }

  /**
   * 获取 折线图 默认配置
   */
  protected getDefaultOptions() {
    return Line.getDefaultOptions();
  }

  /**
   * 获取 折线图 的适配器
   */
  protected getSchemaAdaptor(): Adaptor<LineOptions> {
    return adaptor;
  }
}

这段代码我看的一脸懵逼, 完全不知道从何下手.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

这段代码我唯一能看懂的就是getDefaultOptions, 看起来是获取折线图默认配置的. 另外, 通过API层面(new Line()的用法)可得知, 调用的是Line的构造函数. 那么这个类怎么没有构造函数呢? 正当我疑惑之际, 不经意间瞄到了第十行代码

export class Line extends Plot<LineOptions>

原来是继承自一个叫做Plot的类

/**
 * 所有 plot 的基类
 */
export abstract class Plot<O extends PickOptions> extends EE {
  /**
   * 获取默认的 options 配置项
   * 每个组件都可以复写
   */
  static getDefaultOptions(): any {
    return {
      renderer: 'canvas',
      xAxis: {
        nice: true,
        label: {
          autoRotate: false,
          autoHide: { type: 'equidistance', cfg: { minGap: 6 } },
        },
      },
      yAxis: {
        nice: true,
        label: {
          autoHide: true,
          autoRotate: false,
        },
      },
      animation: true,
    };
  }

  constructor(container: string | HTMLElement, options: O) {
    super();
    this.container = typeof container === 'string' ? document.getElementById(container) : container;

    this.options = deepAssign({}, this.getDefaultOptions(), options);

    this.createG2();

    this.bindEvents();
  }
}

我将PlotLine两个类的源代码反复揣摩, 得到了一些思考. Line为什么要继承自Plot? 直接把构造函数写在Line里面不好吗? 其实折线图只是图表里的一种. 可视化领域需要完成的图表远不止这一种, 还有柱状图、面积图、条形图等.

那么这些图表之间, 会不会有一些公共的行为和属性呢? 我想应该是有的. 至少折线图、柱状图, 本质上是同一种图. 那么公共的行为逻辑, 如果分散在各个组件实现里, 是不是维护成本过高呢? 所以Plot就横空出世了, 承载着所有图表的公共行为.

而这里的getDefaultOptions是不是有点眼熟? 在之前的Line组件当中也是存在的. 那么显然是子组件覆写了父组件的这个方法. 不同图表有不同的默认配置项, 各个子组件独立去实现, 父组件统一调用. 这更加证实了Plot承担了所有图表(至少大部分)的公共行为.

在了解了基本的组件关系后, 我们重点看构造函数, 我在注释中写了一些我的思考.

constructor(container: string | HTMLElement, options: O) {
  super(); // Plot 继承自 EE, 这个 EE 是 antv 的一个事件相关的 lib, 显然问题肯定不在这
  this.container = typeof container === 'string' ? document.getElementById(container) : container; // 容器相关, 应该也不是这

  this.options = deepAssign({}, this.getDefaultOptions(), options); // 配置相关, 有可能是这

  this.createG2(); // 创建G2, 有可能是这

  this.bindEvents(); // 绑定事件, 大概率应该不是这
}

通过对构造函数的分析, 问题有可能有2个方向

  • options初始化有问题
  • createG2有问题

咱们一个个分析, 逐个击破. 直接上断点分析, 看看options到底是什么成分

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

看起来好像一切正常. customContent也的确拿到了, 是一个回调函数. 看样子没有什么有效的线索. 那么我们看下第二个方向.

/**
 * 创建 G2 实例
 */
private createG2() {
  const { width, height, defaultInteractions } = this.options;

  this.chart = new Chart({
    container: this.container,
    autoFit: false,
    ...this.getChartSize(width, height),
    localRefresh: false,
    ...pick(this.options, PLOT_CONTAINER_OPTIONS),
    defaultInteractions,
  });

  // 给容器增加标识,知道图表的来源区别于 G2
  this.container.setAttribute(SOURCE_ATTRIBUTE_NAME, 'G2Plot');
}

看起来这个函数创建了一个G2实例. 仔细看, 在new Chart的时候, 似乎把options传了进去. 那么是不是可以这么理解. G2Plotoptions配置, 完完整整的转交给了G2? 但是再仔细一看, 发现G2Plot是使用pick处理了options的.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

看样子是从options里挑出来一些属性. 那么PLOT_CONTAINER_OPTIONS是啥呢? 盲猜肯定有tooltip.

/** plot 图表容器的配置 */
export const PLOT_CONTAINER_OPTIONS = [
  'padding',
  'appendPadding',
  'renderer',
  'pixelRatio',
  'syncViewPadding',
  'supportCSSTransform',
  'limitInPlot',
];

居然猜错了. 也就是说, G2Plot只是将一部分的属性传给了G2, 当中并不包括tooltip. 奇了怪了, 这到底怎么回事呢? 我们推理一下, 在之前的调试中, options当中的确存在着tooltip. 那么假设真的是传递给G2的, 那么无论如何都得经过options这个变量吧? 那么我们搜索一下引用, 看看哪里使用过它.

/**
 * 执行 adaptor 操作
 */
protected execAdaptor() {
  const adaptor = this.getSchemaAdaptor();

  const { padding, appendPadding } = this.options;
  // 更新 padding
  this.chart.padding = padding;
  // 更新 appendPadding
  this.chart.appendPadding = appendPadding;

  // 转化成 G2 API
  adaptor({
    chart: this.chart,
    options: this.options,
  });
}

看了几个引用处, 结合注释, 就这个地方最有可能了, adaptor其实就是把options里的配置传递过去了. 只不过这里用了一个所谓的适配器架构. 关于这个架构我就不在这里展开了, 因为确实有点复杂. 我自己也是一知半解. 总之, 都到这个地方了, 还用继续调试吗? 基本已经确定就是G2的问题了. 咱们可以去G2的仓库中试试demo就知道了.

完璧又归赵

哎, 生活不易, 猪猪叹气. 咱们又得换另一个仓库再分析一波. 麻利地打开了G2的仓库, 又念出熟悉的咒语启动它.

npm run start

并且我们在G2中尝试使用自定义Tooltip

import DataSet from '@antv/data-set';
import { Chart } from '@antv/g2';
let count = 0;
fetch('https://gw.alipayobjects.com/os/antvdemo/assets/data/terrorism.json')
  .then(res => res.json())
  .then(data => {
    const ds = new DataSet();

    const chart = new Chart({
      container: 'container',
      autoFit: true,
      height: 500,
      syncViewPadding: true,
    });
		/** 省略不重要的部分 */
    chart.tooltip({
      shared: true,
      // 重点是下面这个函数
      customContent: (name, items) => {
        count++;
        const container = document.createElement('div');
        container.className = 'g2-tooltip';
        container.innerHTML = `<div class="level1">${count}</div>`;
        return container;
      }
    })
    chart.render();
  });

这里需要注意一个小逻辑, 为了确保customContent是在鼠标滑动期间实时调用的, 我写了个全局变量count, 每次调用时都会执行自增. 接下来我们看下视觉效果

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

一毛一样呀! 好! 很好! 非常好! 现在拿出咱们熟悉的破案手册记录一下

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

在上面的这个案例中, customContent返回的其实是下面的代码.

<div class="g2-tooltip">
  <div class="level1">
    <!-- count 值 -->
    320
  </div>
</div>

页面dom渲染的结构也的确是这样的. 其实g2-tooltip是默认Tooltip的场景下会自动添加的类名, 而我这里是手动添加的. 如果手动添加的类名不是这个会怎么样呢? 咱们再随便写一个试试.

customContent: (name, items) => {
  count++;
  const container = document.createElement('div');
  container.className = 'crazy-thursday';
  container.innerHTML = `<div class="level1">${count}</div>`;
  return container;
}

再看下视觉效果.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

完犊子了! 效果完全不一样了. 为什么此时自定义的容器一直在左下角了? 其实观察下dom结构就会发现, 他少了定位属性. 也就是说, 此时我们可以判断, 如果添加了g2-tooltip的类名, G2(也可能是更底层的, 目前不知道是谁, 不管了, 这个锅就先让他背了)会自动添加定位属性. 其实不止是定位属性, 看样子是少了一大串的属性. 所以我们有2个方案, 第一个是添加g2-tooltip, 让G2自动帮我们+上定位属性. 另一个方案是自行添加style属性.

然后我又突发奇想, 这个customContent在类型上, 应该返回啥呢? 我们看下类型定义

customContent?: (title: string, data: any[]) => string | HTMLElement;

我们刚才的demo只测了返回dom的, 也就是HTMLElement. 那么我们看看返回string会怎么样呢? 试试下面的代码

customContent: (name, items) => {
  return `count`;
}

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

渲染是没问题的, 只是动画依旧和之前一样, 一卡一卡的. 我又突发奇想, 那我要返回一个number类型呢?

customContent: (name, items) => {
  count++;
  return count;
}

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

好家伙, 这...控制台还有点炫酷. 那么通过这些测试demo, 我们确定了存在以下问题.

  • 当返回值是HTML时, 如果容器类名不是g2-tooltip则始终显示在左下角
  • 当返回值是string时, 会始终显示在左下角
  • 当返回值是number时, 会始终显示在左下角并且无限堆叠

第三条其实不能算是有问题, 因为人家的返回类型给你限定了, 你不按规则怎么能行呢? 接下来咱们看看G2的源码, 就不带大家分析目录结构了, 反正还是老规矩, 全靠猜, 看哪个像就点哪个.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

很快我就找到了一个名为tooltip.ts的文件.

private renderTooltip() {
  const canvas = this.view.getCanvas();
  const region = {
    start: { x: 0, y: 0 },
    end: { x: canvas.get('width'), y: canvas.get('height') },
  };

  const cfg = this.getTooltipCfg();
  const tooltip = new HtmlTooltip({
    parent: canvas.get('el').parentNode,
    region,
    ...cfg,
    visible: false,
    crosshairs: null,
  });

  tooltip.init();
  this.tooltip = tooltip;
}

这里所渲染的Tooltip应该就是我们想要找的那个地方. 我们看看这里的cfg是啥.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

很好! 接下来我们看看HtmlTooltip是啥呢?

import { Tooltip } from '@antv/component'; // 卧槽, 又一个兄弟仓库?
const { Html: HtmlTooltip } = Tooltip;
export { HtmlTooltip };

好家伙! 又来个仓库? 我这屁股还没坐热呢, 又得换战场了. 我们尝试搜索下@antv/component, 看看readme有没有啥线索.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

又一个不打自招了. 此时此刻, 我们不仅能确定问题是出在component这个lib. 而且对G2生态有了初步的认识.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

知道的这是多仓库的架构, 不知道的还以为是佩恩在打团呢. 每一个角色都承担着独立领域的工作, 起到关注点分离的作用. OK, 接下来, 咱们更新下破案手册.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

狸猫换太子

git clone git@github.com:antvis/component.git
cd component
npm install
npm start

一气呵成, 行如流水. 这一套动作熟练的让人心疼.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

然而! 报错了!?

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

看提示是不存在start脚本, 我赶紧打开packge.json一探究竟.

"scripts": {
  "build": "run-s clean lib",
  "clean": "rimraf lib esm",
  "lib": "run-p lib:*",
  "lib:cjs": "tsc -p tsconfig.json --target ES5 --module commonjs --outDir lib",
  "lib:esm": "tsc -p tsconfig.json --target ES5 --module ESNext --outDir esm",
  "lint-stage": "lint-staged",
  "lint": "tslint -c tslint.json src/**/* tests/**/*",
  "lint-fix": "run-s lint-fix:*",
  "lint-fix:prettier": "prettier --write 'src/**/*.ts'",
  "lint-fix:tslint": "tslint -c tslint.json --fix 'src/**/*.ts' 'tests/**/*'",
  "coverage": "jest --coverage",
  "test": "jest",
  "test-live": "DEBUG_MODE=1 jest --watch tests",
  "ci": "run-s build test coverage",
  "changelog": "generate-changelog",
  "prepublishOnly": "npm-run-all --parallel test build"
}

好像所有的命令看起来都没有一个和启动相关的. 这是怎么回事儿呢? 写代码都不用开发环境? 难道蚂蚁的同学把V8引擎装脑子里了, 看看代码就能想到视觉效果?

咱们分析一下. component是一个只提供组件的库, 他本身是不关心数据的. 以Tooltip为例, component只负责渲染Tooltip, 至于里面显示的数据、文案, 这些是不关心的. 最关键的是, 这些数据、文案是由G2提供的. 那么脱离了G2component好像完全就没有意义了. 那么单独运行component的话, 也只能看到一个空的Tooltip之类的.

因此, component必须是由G2调用展示才有价值. 那么问题来了, 虽然本地已经有了G2仓库, 而且还跑起来了. 但是G2component依赖自node_modules, 我就算修改了component的代码, 如何在G2中看效果呢? 在这里, 我们可以借助 yalc来实现. 作用和npm link是差不多的, 但是我更喜欢用yalc一些.

以现在这个场景为例, 简单介绍下yalc. 它可以将component的包发布出去. 但是并非是发布在npm服务器上, 而是本地服务器. 在G2node_modules中, component的包本来是来自npm服务器, 但是yalc可以让它来自本地服务器. 什么? 听不懂? 没关系, 看我操作!

虽然component没有start命令, 但是有build呀. 那么我们先执行构建命令将其打包.

$ npm run build

> @antv/component@0.8.28 build
> run-s clean lib


> @antv/component@0.8.28 clean
> rimraf lib esm


> @antv/component@0.8.28 lib
> run-p lib:*


> @antv/component@0.8.28 lib:cjs
> tsc -p tsconfig.json --target ES5 --module commonjs --outDir lib


> @antv/component@0.8.28 lib:esm

之后再将其发布出去.

$ yalc publish
> @antv/component@0.8.28 published in store.

之前我们切到G2项目, 将component的依赖指向yalc的服务器

$ yalc add @antv/component@0.8.28
> Package @antv/component@0.8.28 added ==> /Users/evesama/Desktop/Github/G2/node_modules/@antv/component

之后我们就可以看到package.json中的component版本号发生了变化, 指向了一个本地文件

"dependencies": {
  "@antv/adjust": "^0.2.1",
  "@antv/attr": "^0.3.1",
  "@antv/color-util": "^2.0.2",
  "@antv/component": "file:.yalc/@antv/component",
  // 省略其他的依赖
},

当我们修改了component的代码之后, 我们通过下面的代码就可以使得G2读取到最新的代码

npm run build
yalc push

相信大家已经知道如何使用yalc进行本地调试了. 那么接下来, 我们重新把注意力放到component的仓库上, 看看它提供的Tooltip到底出了什么问题. component的文件结构是非常清晰的.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

这么清晰的目录结构我都不用猜, 直接找到这个html.ts文件, 翻看了下里面的内容, 我发现了一个很可疑的函数.

private getHtmlContentNode() {
  let node: HTMLElement | undefined;
  const customContent = this.get('customContent');
  if (customContent) {
    const elem = customContent(this.get('title'), this.get('items'));
    if (isElement(elem)) {
      node = elem as HTMLElement;
    } else {
      node = createDom(elem);
    }
  }
  return node;
}

不多说, 直接上断点调试.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

发现得到的costomContent是一个函数, 盲猜这个函数就是之前在G2的demo中, 我们传入的函数. 继续往下走.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

返回的是我们事先定义好的html内容. 证实了这个customContent的确是我们传入的函数. 感觉我们已经非常接近真相了. 至少那个bug出现时, 一定会调用这个getHtmlContentNode函数. 那么我在文件里搜了下这个函数的调用, 一共两处.

protected initContainer() {...}
private renderCustomContent() {...}

通过命名可以判断, renderCustomContent应该是每次渲染时都会执行的函数. 接下来, 通过调试, 我对这段函数有了新的理解.

// 根据 customContent 渲染
private renderCustomContent() {
  const node = this.getHtmlContentNode(); // customContent 新产生的 dom
  const parent: HTMLElement = this.get('parent'); // canvas 和 tooltip 共同的父节点
  const curContainer: HTMLElement = this.get('container'); // 旧 tooltip 组件, 也就是上一次 customContent 产生的dom
  if (curContainer && curContainer.parentNode === parent) { // 说实话, 我没看懂为什么要判断 parentNode
    parent.replaceChild(node, curContainer); // 我的调试都是进入的这个 if 条件, 执行 replace, 将旧节点替换为新节点
  } else {
    parent.appendChild(node);
  }
  this.set('container', node);
  // 先忽略下面俩函数, 这些和容器的产生无关
  this.resetStyles();
  this.applyStyles();
}

显然就是因为执行了replaceChild而导致dom一直被替换. OK, 接下来掏出来咱们的破案手册更新一下.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

虽然说, 咱们已经看到了问题所在. 但是好像不太好改. 因为customContent是一定要获取的, 而replaceChild好像也是一定要执行的, 不然如何把最新的内容渲染出来呢? 刚刚还有一个函数也调用了renderCustomContent, 咱们看看这个函数是怎么写的呢.

protected initContainer() {
  super.initContainer();
  if (this.get('customContent')) {
    if (this.get('container')) {
      this.get('container').remove();
    }
    const container = this.getHtmlContentNode();
    this.get('parent').appendChild(container);
    this.set('container', container);
    // 先忽略下面俩函数, 这些和容器的产生无关
    this.resetStyles();
    this.applyStyles();
  }
}

这个比较简单. 其实就做了俩事儿, 先获取回调结果, 再插入节点. 咱们梳理下这俩函数的逻辑.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

先不要想太多, 咱们简化一下问题. 给你一个customContent的回调函数, 你如何保证每次调用的时候, 宿主容器是保持不变的? 我画了个图, 大家感受下.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

这里的一层dom是指一个dom没有子节点. 不用在意这个细节. 这是最开始思考的一个版本图, 懒得重新画了, 问题不大.

简单来说, 就是人为创建一个容器, 类名为g2-tooltip, 然后将回调函数返回值塞进去. 不管返回的啥, 都塞进去就行了. 接下来咱们先修改初始化函数.

protected initContainer() {
  super.initContainer();
  if (this.get('customContent')) {
    if (this.get('container')) {
      this.get('container').remove();
    }
    const container = this.getHtmlContentNode();
    
    const customContainer = document.createElement('div'); // 自行创建容器
    customContainer.className = CONTAINER_CLASS; // CONTAINER_CLASS 就是 g2-tooltip
    customContainer.appendChild(container); // 此时的结构就是 g2-tooltip 包含一个返回值
   
    this.get('parent').appendChild(customContainer);
    this.set('container', customContainer);
    this.resetStyles();
    this.applyStyles();
  }
}

在初始化函数中的修改, 只能说是小改. 因为原来就比较简单, 获取回调、插入元素. 只不过现在我们多创建了一层容器罢了.那么问题来了, 如果按这个逻辑执行的话, 那么每次重新渲染时, 最外层容器还要再创建一次吗? 那不还是会导致dom被替换的问题吗? 因此, 重新渲染时不能重新创建宿主容器, 而是将宿主容器里的子节点清空, 再次把回调函数返回值放进去. 这样就保证了宿主容器始终是不变的, 变化的只有子节点. 因此, transition才会生效.

private renderCustomContent() {
  // const node = this.getHtmlContentNode();
  // const parent: HTMLElement = this.get('parent');
  // const curContainer: HTMLElement = this.get('container');
  // if (curContainer && curContainer.parentNode === parent) {
  //   parent.replaceChild(node, curContainer);
  // } else {
  //   parent.appendChild(node);
  // }
  // this.set('container', node);

  const newContainer = this.getHtmlContentNode();
  const oldContainer: HTMLElement = this.get('container');
  oldContainer.innerHTML = ''; // 灵魂之笔, 只清除内容, 而不是重新创建容器
  oldContainer.appendChild(newContainer);

  this.resetStyles();
  this.applyStyles();
}

很好! 如此优雅的代码, 效果一定不会让我失望的! 咱们保存后看一下视觉效果.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

没错! 完全符合预期! 这个bug就修好了! 此时我很好奇一个问题. 就是g2-tooltip到底为什么能够自带那些样式呢? 其实component对不同的类名设有不同的样式, 这些都写在了一个配置文件里.

export const CONTAINER_CLASS = 'g2-tooltip';
export const TITLE_CLASS = 'g2-tooltip-title';
// 省略

export default {
  // css style for tooltip
  [`${CssConst.CONTAINER_CLASS}`]: {
    position: 'absolute',
    visibility: 'visible',
    zIndex: 8,
    transition:
      'visibility 0.2s cubic-bezier(0.23, 1, 0.32, 1), ' +
      'left 0.4s cubic-bezier(0.23, 1, 0.32, 1), ' +
      'top 0.4s cubic-bezier(0.23, 1, 0.32, 1)',
    backgroundColor: 'rgba(255, 255, 255, 0.9)',
    boxShadow: '0px 0px 10px #aeaeae',
    borderRadius: '3px',
    color: 'rgb(87, 87, 87)',
    fontSize: '12px',
    fontFamily: Theme.fontFamily,
    lineHeight: '20px',
    padding: '10px 10px 6px 10px',
  },
  [`${CssConst.TITLE_CLASS}`]: {
    marginBottom: '4px',
  },
  // 省略
};

然后再通过一个名为applyStyles的函数去设置样式. 这个函数眼熟吗? 在刚才的初始化和重新渲染的函数中都有调用.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

到这里, 这个bug就修好了. 我非常自信地提交了代码.

git add .
git commit -m "fix(tooltip): 修复自定义 tooltip 的动画没有 transition 效果"

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

然后报错了

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

仔细一看, 发现是单测没过. 出大事情了! 这说明我的改动遇到了不兼容的场景了. 顺着它的代码提示, 我找到了这个地方.

it('init', () => {
  tooltip.init();
  container = tooltip.getContainer();
  expect(Array.from(container.classList).includes('g2-tooltip')).toBe(true);
  // 报错在下面这行
  expect(Array.from(container.classList).includes('custom-html-tooltip')).toBe(true);
  each(HtmlTheme[CssConst.CONTAINER_CLASS], (val, key) => {
    if (!['transition', 'boxShadow', 'fontFamily', 'padding'].includes(key)) {
      expect(container.style[key] + '').toBe(val + '');
    }
  });
});

出错的是关于custom-html-tooltip的检测为false了. 这个类名此前从未见过, 那么它到底是啥呢?

const tooltip = new HtmlTooltip({
  customContent: (title: string, data: any[]) => {
    return `
          <div class="g2-tooltip custom-html-tooltip">
	          <!-- 省略 -->
          </div>
          `;
  },
});

原来是个单测用例自己添加的类名. 回顾下在本次代码修改之前的逻辑, customContent返回的元素, 会被直接用作宿主元素. 而我们新的代码逻辑, 则是由初始化函数创建固定容器. 因此, 容器上当然没有custom-html-tooltip的类名了. 那我们只需要把这个单测条件去掉就好了.

但是转念一想, 删掉可能不太合适. 因为custom-html-tooltip只是从宿主元素移动到了子元素, 而并非是消失了. 因此, 我们要做的是修改这个单测用例. 将其检测范围从宿主根元素上, 修改为包含子节点范围.

it('init', () => {
  // 省略其他
  // expect(Array.from(container.classList).includes('custom-html-tooltip')).toBe(true);
  const target = container.getElementsByClassName('custom-html-tooltip');
  expect(target.length).toBe(1);
});

翻车了

然后我就开心的提交了PR并且被合并了. 然而...出大事情了! 这样修改真的对吗? 大家想一个问题. 假如某个用户是这样设置customContent

tooltip: {
  customContent: (value, items) => {
    const container = document.createElement('div');
    container.style.position = 'absolute';
    container.innerText = 'MBP-16inch-M1Max-64G-1TB';
    return container;
  },
}

那么是不是理论上, 在本次bug修复之前, 他的效果, 应该是下面这样的

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

而bug修复后, component自行创建一个带g2-tooltip的容器, 而又因为之前的逻辑中, 会自动对这个类名添加样式, 就会导致下面的表现.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

对线上已有业务造成了影响, 这可是break change. 真的翻车了! 可能大家看我一路娓娓道来, 实际上, 我中间是想过不同的解决方案的. 这个g2-tooltip是其中一个解决方案, 并且提交的PR被官方合并了, 发布了新版本. 然后蚂蚁自己的线上业务出现了bug. 紧急做了revert回退处理.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

实际上的业务影响是tooltip消失了...影响更大了. 我在视频版中复现了. 其实这是个很可怕的信号, 意味着, 你不知道有多少场景你没考虑到, 只能靠自己对API的理解和单测用例去推测.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

客观上来说, 这锅在我, 是我考虑的不够仔细. 随后我有了一个疑问, 为什么component一发版, 其他的仓库就会有问题. 准确的说, 为什么速度会那么快? 按理不应该是修改版本号、安装依赖、构建之后发版才会出现的问题吗?

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

通过log可得知, 官方发布的为小版本号. 一个冷知识, 小版本号会自动更新. 也就是说. 如果依赖是下面这样的

"dependencies": {
  "@antv/component": "^0.8.27",
},

则在安装依赖时, 会自动安装0.8.29版本. 因此, 当发布了0.8.29版本以后, 所有依赖了component的项目, 只要执行了安装依赖操作, 必然会引入新的bug. 那么问题来了, 难道在这之前, 他们发布版本从来不会出现bug吗? 我想应该偶尔还是会遇到的. 那么有没有什么手段可以防范于未然呢?

单测, 是行之有效的方法之一. 其实当时我执行了component的单测, 前面不是还报错了吗? 我都改好了. 但是, 出问题的场景, 相应的单测用例存在于G2Plot当中(可能, 我也忘了在哪个仓库了). 这就糟糕了, G2Plot依赖了G2, 而G2依赖了component, 我如何能在本地修改完代码以后, 跨那么多仓库去跑单测呢? 在前面我们使用过yalc, 这虽然比较麻烦, 但的的确确是一种方案. 最最大的问题是, 你无法判断到底有多少仓库依赖了component.

其实问题就出在仓库架构上. G2使用的是多仓库架构. 其实这几个仓库之间是强关联的, 非常适合单体仓库, 也就是monorepo. 可能很多同学对monorepo的架构不是很了解. 接下来我先简单介绍下.

假如你要开发一个微信, 那么是不是存在微信PC端、微信移动端(例子未必合适, 大概就那个意思)? 这个时候你创建了项目wx-pcwx-mobile. 然后写着写着你就会发现, 这2个项目有很多共同的内容. 比如都需要有一些公共的请求. 于是你又创建了个项目wx-request来管理公共的请求. 再后来, 你发现他们都有一些公共的工具函数. 于是又写了个wx-utils. 于是仓库越来越多, 而且因为多仓库的原因, 经常要发版更新, 所有的小版本都会自动更新, 存在风险. 这不就是现在的G2生态吗?

"dependencies": {
  "@antv/adjust": "^0.2.1",
  "@antv/attr": "^0.3.1",
  "@antv/color-util": "^2.0.2",
  "@antv/component": "^0.8.27",
  "@antv/coord": "^0.3.0",
  "@antv/dom-util": "^2.0.2",
  "@antv/event-emitter": "~0.1.0",
  "@antv/g-base": "~0.5.6",
  "@antv/g-canvas": "~0.5.10",
  "@antv/g-svg": "~0.5.6",
  "@antv/matrix-util": "^3.1.0-beta.3",
  "@antv/path-util": "^2.0.15",
  "@antv/scale": "^0.3.14",
  "@antv/util": "~2.0.5",
  "tslib": "^2.0.0"
},

如果这是单体仓库, 那么在修改完component的bug时, 可以执行所有仓库的单测, 最大限度保证所有的单测起作用. 当然, 单体仓库也有单体仓库的缺点, 比如冲突的可能性会加大. 各有利弊, 大家可以在评论区聊聊自己的看法, 以G2的仓库关系, 用monorepo是否是更合适的选择? 当然, 不管怎么说, 这波事故都是我的问题, 我的我的, 向antv相关业务受影响的同学致歉.

那么话说回来, 那么我们应该如何修改代码, 以适应线上业务呢? 我们知道, 问题就出在g2-tootlip这个类会被自动添加样式. 那么我们的自定义容器去掉这些样式不就可以了吗? 但是g2-tooltip已经在很多业务中使用了这个类名, 此时去掉它的样式, 必然又是一个break change. 那么, 我们创建一个全新的容器, 设置全新的属性是不是就可以了呢?

export const CONTAINER_CLASS_CUSTOM = 'g2-tooltip-custom';
// 其他文件的代码
export default {
  [`${CssConst.CONTAINER_CLASS_CUSTOM}`]: {
    position: 'absolute',
    zIndex: 8,
    transition:
      'visibility 0.2s cubic-bezier(0.23, 1, 0.32, 1), ' +
      'left 0.4s cubic-bezier(0.23, 1, 0.32, 1), ' +
      'top 0.4s cubic-bezier(0.23, 1, 0.32, 1)',
  },
};

逻辑还是和之前一模一样, 只是最外层容器只负责定位, 不渲染任何样式. 此时不管用户返回什么, 都会保持原先自身的样式, 我们只负责最外层固定一个容器用于定位. 还记得前面我们一开始观察时发现的几个问题吗?

  • 当返回值是HTML时, 如果容器类名不是g2-tooltip则始终显示在左下角
  • 当返回值是string时, 会始终显示在左下角
  • 当返回值是number时, 会始终显示在左下角并且无限堆叠

现在这几个场景还会存在吗? 完全不会, 因为从原理上, 只要你敢返回, 我就敢渲染.

Antv周边开箱

在上一期咱们聊到过. Antv官方打算送咱们一个小礼物. 在这里, 特别感谢Antv的缨缨同学, 她是S2的负责人. 大家有表格类需求可以试用下~

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

一开始缨缨问了我衣服的尺码, 我寻思应该是件短袖吧. 于是我去驿站拿快递时, 我想象中的快递应该长下面这样

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

但是找了半天都没找到类似这个样子的, 我还以为快递丢了. 所有这种小包装的, 收件人都不是我. 找了好几遍了, 实在没办法了, 我就开始看看剩余没找过的大件快递, 终于在一个角落里找到了...

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

挖槽, 咋那么大!? 真的好沉! 接下来咱们开箱看看有哪些东西!

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

我最喜欢的是这个徽章相册. 真的很有纪念意义! 第一排是Antv的徽章. 第二排我放入了现公司满周年时送的徽章. 其实应该还有个三周年徽章, 但是因为疫情原因, 一直没有集中发放. 等什么时候发了我再更新一波好了, 哈哈.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

贴纸, 里面是antv的全家桶(也不能那么说, 比如S2的贴纸就没有). 但是我个人是没有贴纸的习惯的. 很多人可能喜欢在自己的电脑上贴各种各样的标签. 我没有这个习惯, 我喜欢保持mac的整洁与统一.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了! 从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

另外, 还送了个杯子

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

马克杯. u1s1, 这个杯子挺沉的

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

没有戴帽子的习惯, 就送同事了.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

一堆衣服! 我是真没想到居然有四件. 分别是短袖、长袖、卫衣、毛衣. 刚好昨天洗了衣服今天还没干, 就拆了一件穿上了.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

总结

在本篇文章中, 向大家介绍了Tooltip的基本概念, 并顺着线索一步步定位到是antv/component这个库的问题. 关于本次修改其实我提了好几个PR分多步解决的. 不过引起了线上问题都被revert了, 最后集成在一个PR里统一解决.

github.com/antvis/comp…

对于造成了线上问题, 蛮惭愧的, 再次向那些被影响到的同学而道歉.

从百草园修到三味书屋, 我又给蚂蚁金服提PR了!

最后的解决方案应该是比较完美的. 不过本文其实并不是完全按照我最初的探索思路一步一步来的, 我只挑选了一些比较核心的想法与尝试, 因为真正的排查, 经历了好几个来回分析. 说出来你们可能不信, 文章是我在写第一版修复时就写了的. 但是写的过程中就发现有地方不对劲了. 于是立刻开始了新的修改. 然后再修改文章思路, 然后又想到了新的场景, 再改...到最后, 其实有很多细节我都没体现在文章里, 比如单测, 我其实前前后后跑了起码几十次单测. 想了解最终解决方案的, 直接看PR详情吧, 都在里面了.

我想了想, 之所以修的感觉有点别扭, 是因为与其说是修复了一个bug, 更不如说是重构了这个bug在某个场景下的实现思路. 既然是重构, 那么难度和风险都会明显上升.

如果觉得这篇文章对你有所帮助的话, 麻烦点个赞!