通过 confine 研究 tooltip 的实现过程 — eCharts 源码解读

实现业务需求时发现 tooltip 中呈现的内容比较多,当出现在边界时会出现一部分在可视范围以外。所幸 echarts 提供了一个 confine 配置给 tooltip,当为 true 时,可以强制使 tooltip 出现在 view 视图中。

接下来来看看源码中是怎样实现 confine 功能的。

首先可以看到,confine 是在 src/component/tooltip/TooltipModel.js 中定义,默认值是 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 是否约束 content 在 viewRect 中。默认 false 是为了兼容以前版本。
export default echarts.extendComponentModel({
  type: 'tooltip',

  dependencies: ['axisPointer'],

  defaultOption: {
    // ...
    // 'trigger' only works on coordinate system.
    // 'item' | 'axis' | 'none'
    trigger: 'item',
    // 'click' | 'mousemove' | 'none'
    triggerOn: 'mousemove|click',
    // 是否约束 content 在 viewRect 中。默认 false 是为了兼容以前版本。
    confine: false,

    // ...
  },
});

接下来,可以看到在同级目录下的 TooltipView.js 文件,这里负责定义了 TooltipView 相关的显示、隐藏、更新位置等的方法。在该文件中搜索 confine,发现相关代码主要是两处,一处是 confineTooltipPosion function,这里很好理解,通过计算当前 x、y 值,和当前的可视范围的宽高 viewWidth, viewHeight 比较,得到 confine 之后新的 x、y 值。 另一处则是调用confineTooltipPosion_updatePosition 方法.

在这里,一共定义了三种 showTooltip 方法,对应不同的对象。分别是 _showAxisTooltip, _showComponentItemTooltip 和 _showSeriesItemTooltip . 我们只关注 series 中 item 的 tooltip, 至于 AxisTooltip 和 ComponentItemTooltip,在原理上基本一致。

梳理一番之后发现,在该类中,方法的调用链是 confineTooltipPosion -> _updatePosition -> _showTooltipContent -> _showSeriesItemTooltip -> _tryShow -> _initGlobalListener -> render. 执行顺序是从右至左。

理清了思路,接下来我们来看代码是如何实现 confine 的过程。

弄清了执行顺序后,就很好理解 tooltip 的渲染过程了。在生命周期 render 函数中,调用了 _initGlobalListener,在该方法中, 可以获取到一个共享的全局监听器 globalListener. 这个监听器具体实现和属性可参见src/component/axisPointer/globalListener.js。 这里我们先关注暴露出来的 register方法,他接受三个 arguments: function register(key, api, handler); 所以这里就很好理解了,在初始化阶段,判断 tooltip 的触发条件(triggerOn:'click' | 'mousemove' | 'none' ), 如果不是none, 则 globalListener 给itemTooltip 注册了回调 handler。当 currTriggerclickmousemove 时,调用 _tryShow 显示 tooltip,当 leave 时调用 _hide

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// _initGlobalListener
var tooltipModel = this._tooltipModel;
var triggerOn = tooltipModel.get('triggerOn');

globalListener.register(
  'itemTooltip',
  this._api,
  bind(function(currTrigger, e, dispatchAction) {
    // If 'none', it is not controlled by mouse totally.
    if (triggerOn !== 'none') {
      if (triggerOn.indexOf(currTrigger) >= 0) {
        this._tryShow(e, dispatchAction);
      } else if (currTrigger === 'leave') {
        this._hide(dispatchAction);
      }
    }
  }, this),
);

tryShow 调用后, 我们可以看到这个方法实现非常直观,根据条件来判断显示 series、component 还是 axis 的 tooltip。我们重点关注_showSeriesItemTooltip.

走到_showSeriesItemTooltip,这个函数声明并计算了一系列的变量,都是为了 function _showTooltipContent 的参数做准备。我们可以看到

1
2
3
4
5
6
7
8
9
10
11
12
13
this._showOrMove(tooltipModel, function() {
  this._showTooltipContent(
    tooltipModel,
    defaultHtml,
    params,
    asyncTicket,
    e.offsetX,
    e.offsetY,
    e.position,
    e.target,
    markers,
  );
});

结合 echarts tooltip 的文档和 tooltipModel 来看,我们可以传入一个配置参数 showDelay,如果 delay 大于 0 则 setTimeout,若干秒后执行回调函数,在这里则是显示 toolTip( _showTooltipContent);否则立即执行 callback。不过官方文档并不建议设置 delay。 所以我们可以认为_showOrMove 是个定时器,到了时间后显示 tooltip。_showOrMove 实现如下。

1
2
3
4
5
6
7
8
9
//_showOrMove
// showDelay is used in this case: tooltip.enterable is set
// as true. User intent to move mouse into tooltip and click
// something. `showDelay` makes it easyer to enter the content
// but tooltip do not move immediately.
var delay = tooltipModel.get('showDelay');
cb = zrUtil.bind(cb, this);
clearTimeout(this._showTimout);
delay > 0 ? (this._showTimout = setTimeout(cb, delay)) : cb();

回到_showTooltipContent, 在这个方法里我们知道了 echarts 如何兼容 formatter,传入 string 和 function 时不同的处理方法。通过 typeof 判断后,如果是 string, 则通过 formatUtil.formatTpl 直接 replace, return 一个 tpl; 如果 typeof 是 function, 则通过 .innerHTML 插入一段新的 string.

关键代码如下, 实现逻辑在这里就不过多关注了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// is string formatTpl
/**
 * Template formatter
 * @param {string} tpl
 * @param {Array.<Object>|Object} paramsList
 * @param {boolean} [encode=false]
 * @return {string}
 */
export function formatTpl(tpl, paramsList, encode) {
  if (!zrUtil.isArray(paramsList)) {
    paramsList = [paramsList];
  }
  var seriesLen = paramsList.length;
  if (!seriesLen) {
    return '';
  }

  var $vars = paramsList[0].$vars || [];
  for (var i = 0; i < $vars.length; i++) {
    var alias = TPL_VAR_ALIAS[i];
    tpl = tpl.replace(wrapVar(alias), wrapVar(alias, 0));
  }
  for (var seriesIdx = 0; seriesIdx < seriesLen; seriesIdx++) {
    for (var k = 0; k < $vars.length; k++) {
      var val = paramsList[seriesIdx][$vars[k]];
      tpl = tpl.replace(
        wrapVar(TPL_VAR_ALIAS[k], seriesIdx),
        encode ? encodeHTML(val) : val,
      );
    }
  }

  return tpl;
}
1
2
3
4
// is function, setContent
setContent: function (content) {
  this.el.innerHTML = content == null ? '' : content;
},

这里插一个题外话, HTML5 规范中表示

但是当不使用