天天看點

Vue2剝絲抽繭-虛拟 dom 之事件綁定綁定整體過程綁定事件測試總

虛拟dom簡介 中我們将虛拟

dom

轉換為了真實

dom

的結構,但

dom

還包含很多屬性,比如

class

style

等,還可以綁定事件函數等都沒有實作,這篇文章來詳細介紹一下綁定原生事件的過程。

綁定整體過程

vue2

中将

style

class

、原生事件的設定都單獨為了一個檔案。

Vue2剝絲抽繭-虛拟 dom 之事件綁定綁定整體過程綁定事件測試總

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簡介 中介紹的

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.js

class.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

函數去調用

cbs

create

相關的函數即可:

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)
}
           

複制

create

update

destroy

函數都是複用

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
}
           

複制

首先拿到新舊

vonde

on

事件,

on

就是一個對象,對象名是事件名,可能是下邊的樣子:

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

複制

接着就是調用

updateListeners

方法,傳入的參數中除了

on

oldOn

,我們再依次看一下

add, remove, createOnceHandler

函數。

add

方法就是調用

dom

addEventListener

函數,添加事件監聽。

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子產品的産物對比。

接下來我們定義了

opts

passive

get

屬性,在裡邊将

supportsPassive

值改為

true

然後調用

addEventListener

函數,随意綁定一個事件名,将

opts

傳入,如果浏覽器支援

passive

屬性,那麼一定會去讀取

passive

,此時就會走到

get

裡将

supportsPassive

值改為

true

隻能說秒啊!更詳細的說明也可以看一下 MDN。

https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener

然後是

remove

方法:

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

複制

調用

dom

removeEventListener

方法即可。

最後是

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

就是

click

dblclick

,看一下

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

屬性的對象。

其實這裡在解析我們平常開發中在模版中經常用的事件修飾符,

once

capture

等。

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

複制

如果通過

js

寫事件修飾符,我們可以在事件名前加

&

~

!

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

複制

詳見 官方 文檔的介紹:

https://cn.vuejs.org/v2/guide/render-function.html#%E4%BA%8B%E4%BB%B6-amp-%E6%8C%89%E9%94%AE%E4%BF%AE%E9%A5%B0%E7%AC%A6

Vue2剝絲抽繭-虛拟 dom 之事件綁定綁定整體過程綁定事件測試總

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());
           

複制

看一下效果:

Vue2剝絲抽繭-虛拟 dom 之事件綁定綁定整體過程綁定事件測試總

Kapture 2022-06-05 at 16.03.40

控制台成功有了輸出,說明我們的

dom

點選事件綁定成功了。

綁定

dom

的過程其中兩個點還是比較有趣的:一個是

supportsPassive

的指派,還有

dom

事件更新時候通過改變指向,避免了

dom

事件的頻繁移除和添加,隻能用優雅二字來形容了。

另外會發現源碼中會有很多

normalizeXXX

的操作,一方面就是給了使用者更多的操作性,擴充性會更高一些。另一方面當标準化後,對于後續代碼的邏輯也會更順暢一些,有效避免錯誤的發生。

除了事件的綁定,

style

class

等的設定,也都在

modules

檔案夾中,調用的位置和上邊的

dom

綁定是一緻的,都是在拿到

cbs

對象後周遊調用,對應源碼的位置在

src/platforms/web/runtime/modules

,細節的話大家感興趣也可以看一看。