虛拟dom簡介 中我們将虛拟
dom
轉換為了真實
dom
的結構,但
dom
還包含很多屬性,比如
class
、
style
等,還可以綁定事件函數等都沒有實作,這篇文章來詳細介紹一下綁定原生事件的過程。
綁定整體過程
vue2
中将
style
、
class
、原生事件的設定都單獨為了一個檔案。
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAjM2EzLcd3LcJzLcJzdllmVldWYtl2Pn5GcuU2NzYzMiZmYkRDOwEzY4ADMmFzNkhTY4EDMwYTZ3E2MvwlM3kTN3MjMtUGall3LcVmdhNXLwRHdo9CXt92YucWbpRWdvx2Yx5yazF2Lc9CX6MHc0RHaiojIsJye.png)
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
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
的操作,一方面就是給了使用者更多的操作性,擴充性會更高一些。另一方面當标準化後,對于後續代碼的邏輯也會更順暢一些,有效避免錯誤的發生。
除了事件的綁定,
style
、
class
等的設定,也都在
modules
檔案夾中,調用的位置和上邊的
dom
綁定是一緻的,都是在拿到
cbs
對象後周遊調用,對應源碼的位置在
src/platforms/web/runtime/modules
,細節的話大家感興趣也可以看一看。