虚拟dom简介 (opens new window) 中我们将虚拟 dom 转换为了真实 dom 的结构,但 dom 还包含很多属性,比如 classstyle 等,还可以绑定事件函数等都没有实现,这篇文章来详细介绍一下绑定原生事件的过程。

# 绑定整体过程

vue2 中将 styleclass 、原生事件的设置都单独为了一个文件。

image-20220605100050970

见名思意,class.js 处理 class 的添加删除、style.js 处理 style 的添加删除、events.js 就是我们这篇文章的主角,处理 dom 事件的添加删除。

每一个文件都会导出同样名字的几个函数,'create', 'activate', 'update', 'remove', 'destroy',代表在不同生命周期去执行当前函数。

比如 style.js

/* @flow */

import { getStyle, normalizeStyleBinding } from 'web/util/style'
import { cached, camelize, extend, isDef, isUndef, hyphenate } from 'shared/util'

const cssVarRE = /^--/
const importantRE = /\s*!important$/
...

function updateStyle (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  ...
}

export default {
  create: updateStyle,
  update: updateStyle
}

class.js

/* @flow */

import {
  isDef,
  isUndef
} from 'shared/util'

import {
  concat,
  stringifyClass,
  genClassForVnode
} from 'web/util/index'

function updateClass (oldVnode: any, vnode: any) {
  ...
}

export default {
  create: updateClass,
  update: updateClass
}

event.js

/* @flow */

import { isDef, isUndef } from 'shared/util'
...
function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  const on = vnode.data.on || {}
  const oldOn = oldVnode.data.on || {}
  // vnode is empty when removing all listeners,
  // and use old vnode dom element
  target = vnode.elm || oldVnode.elm
  normalizeEvents(on)
  updateListeners(on, oldOn, add, remove, createOnceHandler, vnode.context)
  target = undefined
}

export default {
  create: updateDOMListeners,
  update: updateDOMListeners,
  destroy: (vnode: VNodeWithData) => updateDOMListeners(vnode, emptyNode)
}

这些函数会在什么时候调用呢?

当然是在生成 dom 的过程中了,也就是在 虚拟dom简介 (opens new window) 中介绍的 createPatchFunction 中的 createElm 函数。

function createElm(vnode, parentElm, refElm) {
  const children = vnode.children;
  const tag = vnode.tag;
  if (isDef(tag)) {
    vnode.elm = nodeOps.createElement(tag);
    createChildren(vnode, children);
    /****************/
    // 这里去调用钩子函数,来添加 class、style、事件等
    /****************/
    insert(parentElm, vnode.elm, refElm);
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text);
    insert(parentElm, vnode.elm, refElm);
  }
}

同样的,因为涉及到 dom 的操作,属于平台无关的,我们把 style.jsclass.js 这些放到 modules 文件夹中保存,然后整体导入。

调用 createPatchFunction 的时候,和 dom 的增删改一样,作为参数传入:

import modules from "./modules";  // style.js、class.js 的操作,包含 create、update 等方法

const __patch__ = createPatchFunction({ nodeOps, modules });

createPatchFunction 函数中,我们将 modules 拿到,然后按照生命周期进行分类,放到 cbs 对象中。

const hooks = ["create", "activate", "update", "remove", "destroy"];

export function createPatchFunction(backend) {
  	let i, j;
    const cbs = {};
    const { modules, nodeOps } = backend;

    for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = [];
        for (j = 0; j < modules.length; ++j) {
            if (isDef(modules[j][hooks[i]])) {
                cbs[hooks[i]].push(modules[j][hooks[i]]);
            }
        }
    }
   ...
}

modules 原来样子是:

[
  klass: { // class 相关函数
    create: () => {...},
  	update: () => {...}
  },
  events: { // 事件相关函数
    create: () => {...},
    update: () => {...},
    destroy: () => {...}
  },
  style: { // style 相关函数
    create: () => {...},
  	update: () => {...}
  }
]

然后通过对 modules 的遍历,把相应生命周期的函数都放到 cbs 对象中:

{
  create: [
    () => {...}, // class 对应的 create 函数
  	() => {...}, // 事件对应的 create 函数
    () => {...} // style 对应的 create 函数
  ],
  activate: [
    () => {...},
    () => {...},
    () => {...}
  ],
  update: [
    () => {...},
    () => {...},
    () => {...}
  ],
}

相当于原来的 modules 是按照功能分类,通过转换变为按照生命周期分类,将 create 相关的函数都放在了一起。

然后我们在 createElm 函数中调用 invokeCreateHooks 函数:

function createElm(vnode, parentElm, refElm) {
  const data = vnode.data; // dom 相关的属性都放到 data 中
  const children = vnode.children;
  const tag = vnode.tag;
  if (isDef(tag)) {
    vnode.elm = nodeOps.createElement(tag);
    createChildren(vnode, children);
    if (isDef(data)) { // dom 相关的属性都放到 data 中
      invokeCreateHooks(vnode, insertedVnodeQueue);
    }
    insert(parentElm, vnode.elm, refElm);
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text);
    insert(parentElm, vnode.elm, refElm);
  }
}

invokeCreateHooks 函数去调用 cbscreate 相关的函数即可:

function invokeCreateHooks(vnode) {
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode);
  }
}

接下来我们来详细看一下 event.js 中的 create 函数,也就是 dom 绑定事件的过程。

# 绑定事件

export default {
  create: updateDOMListeners,
  update: updateDOMListeners,
  destroy: (vnode) => updateDOMListeners(vnode, emptyNode)
}

createupdatedestroy 函数都是复用 updateDOMListeners 方法,让我们看一下:

/*
export function isUndef(v) { // 判断没有值
    return v === undefined || v === null;
}
*/
function updateDOMListeners (oldVnode, vnode) {
  // 没有 data.on 属性直接结束
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  const on = vnode.data.on || {}
  const oldOn = oldVnode.data.on || {}
  // vnode is empty when removing all listeners,
  // and use old vnode dom element
  target = vnode.elm || oldVnode.elm // 拿到当前的 dom 元素
  updateListeners(on, oldOn, add, remove, createOnceHandler)
  target = undefined
}

首先拿到新旧 vondeon 事件,on 就是一个对象,对象名是事件名,可能是下边的样子:

on: {
  click: () => console.log(1),
  dblclick: () => console.log(2),
},

接着就是调用 updateListeners 方法,传入的参数中除了 onoldOn,我们再依次看一下 add, remove, createOnceHandler 函数。

add 方法就是调用 domaddEventListener 函数,添加事件监听。

import {  supportsPassive } from "../util";
function add(name, handler, capture, passive) {
    target.addEventListener(
        name,
        handler,
        supportsPassive ? { capture, passive } : capture
    );
}

supportsPassive 这个值的设置比较有意思,这里讲一下。

首先 addEventListener 这个函数的第三个参数在旧版浏览器中应该传一个布尔型变量,代表是否 capture,后来第三个参数变成了一个 options 对象。

所以我们需要知道浏览器是否支持 passive 属性,如果支持的话就传 { capture, passive } ,否则就传 capture 这个布尔值。

那么我们怎么知道浏览器是否支持 passive 属性,也就是 supportsPassive 这个变量的值我们怎么确认呢?

看下 Vue 中的实现:

export const inBrowser = typeof window !== "undefined";

export let supportsPassive = false;
if (inBrowser) {
    try {
        const opts = {};
        Object.defineProperty(opts, "passive", {
            get() {
                /* istanbul ignore next */
                supportsPassive = true;
            },
        }); // https://github.com/facebook/flow/issues/285
        window.addEventListener("test-passive", null, opts);
    } catch (e) {}
}

首先我们利用 esmoule 导出的特性,先导出 supportsPassive 变量赋值为 false,详见 Webpack打包commonjs和esmodule模块的产物对比 (opens new window)

接下来我们定义了 optspassiveget 属性,在里边将 supportsPassive 值改为 true

然后调用 addEventListener 函数,随意绑定一个事件名,将 opts 传入,如果浏览器支持 passive 属性,那么一定会去读取 passive,此时就会走到 get 里将 supportsPassive 值改为 true

只能说秒啊!更详细的说明也可以看一下 MDN (opens new window)

然后是 remove 方法:

function remove(name, handler, capture, _target) {
    (_target || target).removeEventListener(
        name,
        handler,
        capture
    );
}

调用 domremoveEventListener 方法即可。

最后是 createOnceHandler 方法:

function createOnceHandler(event, handler, capture) {
    const _target = target; // save current target element in closure
    return function onceHandler() {
        const res = handler.apply(null, arguments);
        if (res !== null) {
            remove(event, onceHandler, capture, _target);
        }
    };
}

其实就是当 handler 执行结束后就调用上边的 remove 方法解除监听。

三个方法介绍结束后我们再回到 updateDOMListeners 方法

function updateDOMListeners (oldVnode, vnode) {
  // 没有 data.on 属性直接结束
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  const on = vnode.data.on || {}
  const oldOn = oldVnode.data.on || {}
  // vnode is empty when removing all listeners,
  // and use old vnode dom element
  target = vnode.elm || oldVnode.elm // 拿到当前的 dom 元素
  updateListeners(on, oldOn, add, remove, createOnceHandler)
  target = undefined
}

详细看一下 updateListeners 方法的实现:

export function updateListeners(on, oldOn, add, remove, createOnceHandler) {
    let name, def, cur, old, event;
    for (name in on) {
        def = cur = on[name];
        old = oldOn[name];
        event = normalizeEvent(name);
        if (isUndef(old)) { // 说明是第一次添加
            ...
            add(event.name, cur, event.capture, event.passive, event.params);
        } else if (cur !== old) {
            old.fns = cur;
            on[name] = old;
        }
    }
    for (name in oldOn) {
        if (isUndef(on[name])) {
            event = normalizeEvent(name);
            remove(event.name, oldOn[name], event.capture);
        }
    }
}

for 循环遍历 on 中的所有事件,on 可能是下边的样子:

on: {
  click: () => console.log(1),
  dblclick: () => console.log(2),
},

循环中先调用 normalizeEvent(name) 将事件名标准化,这里的 name 就是 clickdblclick ,看一下 normalizeEvents 函数:

const normalizeEvent = cached((name) => {
    const passive = name.charAt(0) === "&";
    name = passive ? name.slice(1) : name;
    const once = name.charAt(0) === "~"; // Prefixed last, checked first
    name = once ? name.slice(1) : name;
    const capture = name.charAt(0) === "!";
    name = capture ? name.slice(1) : name;
    return {
        name,
        once,
        capture,
        passive,
    };
});

先不管内容,首先它调用了 cached 函数,其实就是将每次调用的结果缓存,当后续调用时候传入的参数 name 如果之前调用过就直接返回结果。

/**
 * Create a cached version of a pure function.
 */
export function cached(fn) {
    const cache = Object.create(null);
    return function cachedFn(str) {
        const hit = cache[str];
        return hit || (cache[str] = fn(str));
    };
}

再回到 normalizeEvent 函数:

const normalizeEvent = cached((name) => {
    const passive = name.charAt(0) === "&";
    name = passive ? name.slice(1) : name;
    const once = name.charAt(0) === "~"; // Prefixed last, checked first
    name = once ? name.slice(1) : name;
    const capture = name.charAt(0) === "!";
    name = capture ? name.slice(1) : name;
    return {
        name,
        once,
        capture,
        passive,
    };
});

依次判断了 &~!,最后返回包含 name、once、capture、passive 属性的对象。

其实这里在解析我们平常开发中在模版中经常用的事件修饰符,oncecapture 等。

<div v-on:click.once.capture="doThat">...</div>

如果通过 js 写事件修饰符,我们可以在事件名前加 &~!

on: {
  '~!click': () => console.log(1),
},

详见 官方 (opens new window) 文档的介绍:

image-20220605145323214

normalizeEvent(name) 解析结束后,就是一个 if...else... ,分为两种情况,如果 old 不存在说明是第一次添加,否则就是更新事件:

if (isUndef(old)) { // 说明是第一次添加
  add(event.name, cur, event.capture, event.passive, event.params);
} else if (cur !== old) {
  ...
}

当我们需要更新事件时,常规做法可能是把之前添加过事件的移除,然后新增即可,具体操作如下所示:

if (isUndef(old)) { // 说明是第一次添加
  add(event.name, cur, event.capture, event.passive, event.params);
} else if (cur !== old) { // 更新事件
  remove(event.name, oldOn[name], event.capture);
  add(event.name, cur, event.capture, event.passive);
}

Vue 中采取了一种更加优雅的方式,它没有移除原有的监听函数,而是仅仅改变了原有函数所执行函数的指向。

本质上就是利用对象的属性如果是一个函数,那么该属性只是一个引用,而不是值本身,举个例子:

const a = {
  func: () => {console.log(1)}
}
const b = () => {
  a.func()
}
setTimeout(b, 1000)

a.func = () => {console.log(2)}

问:控制台输出的会是几?

答案是 2 了,因为 b 中执行的函数被动态更改了。

因为 js 中函数也是对象,所以函数也可以挂属性。让我们再改的复杂些:

const a = () => {
  console.log(1)
}

const invoker = () => {
  const fn = invoker.fn;
  fn()
}
invoker.fn = a

setTimeout(invoker, 1000)

invoker.fn = () => {console.log(2)}

invoker 从自己身上取到了 fn 来执行,后来动态改变 invoker.fn 的值,最终同样输出了 2

如果理解了上边的过程,下边对于 vue 处理事件的做法就很好理解了。

回到 updateListeners 方法中:

export function updateListeners(on, oldOn, add, remove, createOnceHandler) {
    let name, def, cur, old, event;
    for (name in on) {
        def = cur = on[name];
        old = oldOn[name];
        event = normalizeEvent(name);
        if (isUndef(old)) {
            if (isUndef(cur.fns)) {
                cur = on[name] = createFnInvoker(cur);
            }
            ...
            add(event.name, cur, event.capture, event.passive);
        } else if (cur !== old) {
            old.fns = cur;
            on[name] = old;
        }
    }
}

我们聚焦到这一行:

if (isUndef(cur.fns)) {
  cur = on[name] = createFnInvoker(cur);
}

看一下 createFnInvoker 函数,其实就是我们上边介绍的过程了:

export function createFnInvoker(fns) {
    function invoker() {
        const fns = invoker.fns;
        if (Array.isArray(fns)) {
            const cloned = fns.slice();
            for (let i = 0; i < cloned.length; i++) {
                cloned[i].apply(null, arguments);
            }
        } else {
            return fns.apply(null, arguments);
        }
    }
    invoker.fns = fns;
    return invoker;
}

我们把当前函数添加到 invoker 上,然后将 invoker 函数返回。invoker 函数执行的时候先取到 fns ,再判断是数组还是函数,通过 apply 方法去执行。

当更新事件的时候,我们只需要更新 fns 的值即可:

if (isUndef(old)) {
  if (isUndef(cur.fns)) {
    cur = on[name] = createFnInvoker(cur);
  }
  add(event.name, cur, event.capture, event.passive);
} else if (cur !== old) {
  old.fns = cur; // 覆盖 fns 的值即可,不需要移除原有的事件
  on[name] = old;
}

再看一下整体代码:

export function updateListeners(on, oldOn, add, remove, createOnceHandler) {
    let name, def, cur, old, event;
    for (name in on) {
        def = cur = on[name];
        old = oldOn[name];
        event = normalizeEvent(name);
        if (isUndef(old)) {
            if (isUndef(cur.fns)) {
                cur = on[name] = createFnInvoker(cur);
            }
            if (isTrue(event.once)) { // 判断是否只需要调用一次
                cur = on[name] = createOnceHandler(
                    event.name,
                    cur,
                    event.capture
                );
            }
            add(event.name, cur, event.capture, event.passive);
        } else if (cur !== old) {
            old.fns = cur;
            on[name] = old;
        }
    }
    for (name in oldOn) {
        if (isUndef(on[name])) {
            event = normalizeEvent(name);
            remove(event.name, oldOn[name], event.capture);
        }
    }
}

因为新传入的 vonde 相比旧的 vnode 可能会少了某些事件,因此我们还需要一个 for 循环判断:如果新的 vonde 已经没有了旧vnode 的事件,调用 remove 即可。

for (name in oldOn) {
  if (isUndef(on[name])) {
    event = normalizeEvent(name);
    remove(event.name, oldOn[name], event.capture);
  }
}

所以如果我们想去除当前 dom 的所有事件,只需要传递一个空的 vnode 即可,如下所示:

export default {
    create: updateDOMListeners,
    update: updateDOMListeners,
    destroy: (vnode) => updateDOMListeners(vnode, emptyNode),
};

以上就是添加 dom 事件和更新 dom 事件的全过程了,下边让我们测试一下。

# 测试

相比于上一篇文章,render 函数中除了传 tag 名和 children ,我们会多传一个 data 参数,包含一个 on 属性。

render(createElement) {
  const test = createElement(
    "div",
    {
      on: {
        click: () => console.log(1),
      },
    },
    [this.text, createElement("div", this.text2)]
  );
  return test;
},

相应的 createElement 也要添加相应的参数用来生成 vnode 对象。

import VNode, { createEmptyVNode } from "./vnode";
import { normalizeChildren } from "./normalize-children";
// wrapper function for providing a more flexible interface
// without getting yelled at by flow
export function createElement(tag, data, children) {
    return _createElement(tag, data, children);
}

export function _createElement(tag, data, children) {
    if (!tag) {
        // in case of component :is set to falsy value
        return createEmptyVNode();
    }
    children = normalizeChildren(children);
    let vnode = new VNode(tag, data, children); // 将 data 传递给 vnode
    return vnode;
}

以下就是全部测试代码:

import * as nodeOps from "./node-ops";
import modules from "./modules"; // 定义了 dom 的更新
import { createPatchFunction } from "./patch";
import { createElement } from "./create-element";

const options = {
    el: "#root",
    data: {
        text: "hello,liang",
        text2: "2",
    },
    render(createElement) {
        const test = createElement(
            "div",
            {
                on: {
                    click: () => console.log(1),
                },
            },
            [this.text, createElement("div", this.text2)]
        );
        return test;
    },
};

const _render = function () {
    const vnode = options.render.call(options.data, createElement);
    return vnode;
};

const $el = document.querySelector(options.el);

const __patch__ = createPatchFunction({ nodeOps, modules });

function _update(vnode) {
    __patch__($el, vnode);
}

_update(_render());

看一下效果:

Kapture 2022-06-05 at 16.03.40

控制台成功有了输出,说明我们的 dom 点击事件绑定成功了。

#

绑定 dom 的过程其中两个点还是比较有趣的:一个是 supportsPassive 的赋值,还有 dom 事件更新时候通过改变指向,避免了 dom 事件的频繁移除和添加,只能用优雅二字来形容了。

另外会发现源码中会有很多 normalizeXXX 的操作,一方面就是给了用户更多的操作性,扩展性会更高一些。另一方面当标准化后,对于后续代码的逻辑也会更顺畅一些,有效避免错误的发生。

除了事件的绑定,styleclass 等的设置,也都在 modules 文件夹中,调用的位置和上边的 dom 绑定是一致的,都是在拿到 cbs 对象后遍历调用,对应源码的位置在 src/platforms/web/runtime/modules ,细节的话大家感兴趣也可以看一看。

Last Updated: 6/6/2022, 9:53:00 PM