“我报名参加金石计划1期应战——分割10万奖池,这是我的第n篇文章,点击检查活动概况”

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

前语

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

给蚂蚁金服antv提个PR, 以为是改个错别字, 未曾想背后的原因竟如此杂乱!

这篇文章全网收获好评无数, 仅仅两天时刻冲上热榜第二.

从百草园修到三味书屋, 我又给蚂蚁金服提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在某个场景下的完成思路. 既然是重构, 那么难度和风险都会显着上升.

假定觉得这篇文章对你有所帮助的话, 费事点个赞!