# 小程序事件系统

# 介绍

小程序中提供了一套自定义的事件系统, 事件支持有taplongpresstouchstarttouchmovetouchendtouchcancel..., 支持捕获阶段冒泡阶段、互斥事件等。

在使用方式上小程序的事件采用的是标签指令的方式:如


<view bindtap="{{handleTap}}">
   <text>我是一个文本</text>
</view>

<view catchtap="{{handleTap}}">
   <text>我是一个文本</text>
</view>

<view capture-bind:tap="{{handleTap}}">
   <text>我是一个文本</text>
</view>

从上面的使用方式可以看出,一个事件绑定指令中,会包含这些信息: 事件名事件阶段是否冒泡等,虽然小程序的事件与现代浏览器中支持的事件很类似,但支持了tap、longpress这些浏览器中没有内置的事件,如何实现这样一套小程序事件系统呢。

# 探索

抛出问题:

  • A1: tap、longpress的实现
  • A2: 这样的事件系统如果在react这样的库中实现

Q1: 如果了解zepto的同学肯定很快就能大概知道tap、longpress事件的实现原理,基于touch事件的四个阶段就可以很好的实现。

Q2: 由于react的诞生初衷就是write once run everywhere,我们常用的react-dom是react针对浏览器环境的一个渲染器,react-dom、react-native这些适配器中都会依赖react的核心协调器react-reconciler,因此我们可以基于react-reconciler实现一个小程序渲染层的渲染器,实现对小程序的事件系统的支持。

# 实践

开始实现自定义渲染器,由于自定义渲染器的实现不是很重要,有很多案例比如Remax中就有,可自行查阅。

下面主要详细介绍事件这部分的处理, 由于react的构建时babel插件的语法限制,不支持bind:tap这种带‘:’的jsx写法会有警告(虽然"throwIfNamespace": false可以关闭), 我们这里默认在运行时拿到的prop都是不带‘:’, 编译时会处理掉。

1、处理event prop,在事件回调保存在dom

const eventReg = /^(capture-)?(bind|catch)([A-Za-z_]+)/;

function isAnEvent(attr) {
  if (eventReg.test(attr)) {
    return true;
  }

  return false;
}

function patchEvent(eventName, lastEvent, nextEvent, domNode) {
  const { raw, name, options } = toEventName(eventName);
  const { capture } = options;

  const listener = domNode._listeners || (domNode._listeners = {});

  if (nextEvent) {
    listener[`${name}`] = listener[`${name}`] || {};

    if (!lastEvent) {
      const fn = eventProxy.bind(domNode, name, capture);
      // 事件绑定
      addListener(domNode, name, fn, options);
    }

    const displayName = nextEvent.name;

    listener[`${name}`][`${capture ? 'capture' : 'bubble'}`] = {
      options,
      handler: nextEvent,
      name: displayName,
    };
    domNode.setAttribute(raw, displayName);
  } else {
    // 回收事件
    listener[`${name}`][`${capture ? 'capture' : 'bubble'}`] = null;
    domNode.removeAttribute(raw);
  }
}

function toEventName(prop) {
  const matches = prop.match(eventReg);

  const capture = matches[1] === 'capture-';
  const stop = matches[2] === 'catch';
  const name = matches[3];

  const options = { capture, stop };

  return {
    raw: prop,
    name,
    options,
  };
}

2、 格式化包装event对象,并且处理是否阻止冒泡

function eventProxy(name, capture, nativeEvent) {
  if (EVENT_BLACK_LIST.includes(name)) return false;

  let canBubble = true;

  const listener = this._listeners[`${name}`][`${capture ? 'capture' : 'bubble'}`];

  if (!listener) return;

  const event = wrapEvent(this, nativeEvent, name);
  // 处理冒泡
  if (listener.options.stop) {
    nativeEvent.preventDefault();
    nativeEvent.stopPropagation();

    canBubble = false;
  }

  listener.handler(event);

  return canBubble;
}

// 格式化event
function wrapEvent(node, nativeEvent, type) {
  const targetElement = nativeEvent.target;

  const target = {
    id: targetElement.id || '',
    dataset: targetElement._dataset || {},
  };
  const currentTarget = {
    id: node.id || '',
    dataset: node._dataset || {},
  };

  return {
    type,
    timeStamp: nativeEvent.timeStamp,
    target: {
      ...target,
      offsetLeft: targetElement.offsetLeft || 0,
      offsetTop: targetElement.offsetTop || 0,
    },
    currentTarget: {
      ...currentTarget,
      offsetLeft: node.offsetLeft || 0,
      offsetTop: node.offsetTop || 0,
    },
    detail: nativeEvent.detail,
    touches: getTouches(nativeEvent.touches),
    changedTouches: getTouches(nativeEvent.changedTouches),
  };
}

3、事件绑定

由于tap和longpress事件是由touch模拟的,所以在事件绑定时这两个事件所在的dom只需要执行一次touch四个阶段的绑定, 模拟tap事件注册时的touch事件都使用冒泡阶段

// dom 事件名 绑定事件
function addListener(node, type, callback, options) {
  const { capture = false } = options;

  switch (eventType) {
      case 'tap':
        if (!node.__hasTapEvent) {
          node.__hasTapEvent = true;
          addTapEvent(node);
        }

        node.addEventListener('tiny-tap', (e) => {
          return callback.call(node, e);
        }, capture);
        return;

      case 'longtap':
      case 'longpress':
        if (!node.__hasTapEvent) {
          node.__hasTapEvent = true;
          addTapEvent(node);
        }
        node.addEventListener('tiny-longpress', (e) => {
          e.longpressFired();
          return callback.call(node, e);
        }, capture);
        return;
  }
}

function addTapEvent(node) {
  let pressTimer;
  let pressStart;
  let ended;

  const touchstartHandler = (e) => {
    // 解决tap事件多次调用的问题
    if (e.__handledTap) return;

    ended = false;
    pressStart = {
      x: e.touches[0].pageX,
      y: e.touches[0].pageY,
    };
    clearTimeout(pressTimer);
    pressTimer = setTimeout(() => {
      // 触发长按时间
      const pressEvent = new Event(`${elementPrefix}-longpress`, {
        bubbles: true,
        composed: true,
      });
      pressEvent.detail = pressStart;

      // 由回调执行设置结束,防止回调未执行成功
      pressEvent.longpressFired = function () {
        ended = true;
      };

      node.dispatchEvent(pressEvent);
    }, PRESS_DELAY);

    e.__handledTap = true;
  };

  // 主要用来判断tap结束和取消长按计时
  const touchmoveHandler = (e) => {
    if (ended || !pressStart) {
      return;
    }

    const dx = Math.abs(e.changedTouches[0].pageX - pressStart.x);
    const dy = Math.abs(e.changedTouches[0].pageY - pressStart.y);

    if (dx > TAP_DISTANCE || dy > TAP_DISTANCE) {
      ended = true;
      clearTimeout(pressTimer);
    }
  };

  const touchendHandler = (e) => {
    if (ended || !pressStart) {
      return;
    }

    ended = true;
    clearTimeout(pressTimer);
    // 如果没有触发touchmove的判断,这里还要再来一下
    const dx = Math.abs(e.changedTouches[0].pageX - pressStart.x);
    const dy = Math.abs(e.changedTouches[0].pageY - pressStart.y);

    if (dx > TAP_DISTANCE || dy > TAP_DISTANCE) {
      return;
    }

    // dispatch tap event
    if (node.disabled && TAP_BLACK_LIST.indexOf(node.tagName) !== -1) {
      // if element is disabled, 那就不触发了
      return;
    }

    const tapEvent = new Event(`${elementPrefix}-tap`, {
      bubbles: true,
      composed: true,
    });

    tapEvent.detail = {
      x: e.changedTouches[0].pageX,
      y: e.changedTouches[0].pageY,
      sourceEndEvent: e,
    };

    node.dispatchEvent(tapEvent);
  };

  const touchcancelHandler = (e) => {
    ended = true;
    clearTimeout(pressTimer);
  };

  node.addEventListener('touchstart', touchstartHandler);
  node.addEventListener('touchmove', touchmoveHandler);
  node.addEventListener('touchend', touchendHandler);
  node.addEventListener('touchcancel', touchcancelHandler);
}

# 最终效果

如下这样一段小程序代码,分别在react和微信中的执行结果表现一致

react模版

<tiny-view
      id="parent"
      style="background: green;"
      capture-bindtap={() => { console.log('parent capture tap', Date.now())} }
      bindtap={() => { console.log('parent tap', Date.now())} }
      bindtouchstart={() => {console.log('parent touchstart');}}
      bindtouchmove={() => {console.log('parent touchmove');}}
    >
      <tiny-view
        capture-bindtap={() => { console.log('component capture tap');}}
        catchtap={() => { console.log('component catch tap');}}
        bindlongpress={() => {console.log('component longpress');}}
        bindtouchstart={() => { console.log('component touchstart');}}
        catchtouchmove={() => {console.log('component touchmove');}}
      >
        longpress/tap component
      </tiny-view>
    </tiny-view>

小程序模版

  <view
    id="parent"
    style="background: green;"
    capture-bind:tap="pct"
    bindtap="pt"
    bindtouchstart="pts"
    bindtouchmove="ptm"
  >
    <view
      capture-bind:tap="cct"
      catchtap="ct"
      bindtouchstart="cts"
      catchtouchmove="ctm"
    >
      longpress/tap component
    </view>
  </view>

# 反思

其实这样的事件实现有一个bug存在,由于tap是通过touch来模拟的,当我们给某个标签同时绑定了touchstart和tap捕获、冒泡事件时,触发顺序是这样的:

touchstart
capture tap
tap

很明显的问题touchstart在tap捕获之前执行了...(这个问题微信小程序同样存在)

# 参考资料