從Chrome源碼看瀏覽器的事件機制
在上一篇《從Chrome源碼看瀏覽器如何構建DOM樹》介紹了blink如何創建一棵DOM樹,在這一篇將介紹事件機制。
上一篇還有一個地方未提及,那就是在構建完DOM之后,瀏覽器將會觸發DOMContentLoaded事件,這個事件是在處理tokens的時候遇到EndOfFile標志符時觸發的:
- if (it->type() == HTMLToken::EndOfFile) {
- // The EOF is assumed to be the last token of this bunch.
- ASSERT(it + 1 == tokens->end());
- // There should never be any chunks after the EOF.
- ASSERT(m_speculations.isEmpty());
- prepareToStopParsing();
- break;
- }
上面代碼第1行,遇到結尾的token時,將會在第6行停止解析。這是***一個待處理的token,一般是跟在后面的一個\EOF標志符來的。
第6行的prepareToStopParsing,會在Document的finishedParseing里面生成一個事件,再調用dispatchEvent,進一步調用監聽函數:
- void Document::finishedParsing() {
- dispatchEvent(Event::createBubble(EventTypeNames::DOMContentLoaded));
- }
這個dispatchEvent是EventTarget這個類的成員函數。在上一篇描述DOM的結點數據結構時將Node作為根結點,其實Node上面還有一個類,就是EventTarget。我們先來看一下事件的數據結構是怎么樣的:
1. 事件的數據結構
畫出事件相關的類圖:
在最頂層的EventTarget提供了三個函數,分別是添加監聽add、刪除監聽remove、觸發監聽fire。一個典型的訪問者模式我在《Effective前端5:減少前端代碼耦合》提到了,這里重點看一下blink實際上是怎么實現的。
在Node類組合了一個EventTargetDataMap,這是一個哈希map,并且它是靜態成員變量。它的key值是當前結點Node實例的指針,value值是事件名稱和對應的listeners。如果畫一個示例圖,它的存儲是這樣的:
如上,按照正常的思維,存放事件名稱和對應的訪問者應該是用一個哈希map,但是blink卻是用的向量vector + pair,這就導致在查找某個事件的訪問者的時候,需要循環所有已添加的事件名稱依次比較字符串值是否相等。為什么要用循環來做而不是map,這在它的源碼注釋做了說明:
- // We use HeapVector instead of HeapHashMap because
- // - HeapVector is much more space efficient than HeapHashMap.
- // - An EventTarget rarely has event listeners for many event types, and
- // HeapVector is faster in such cases.
- HeapVector>, 2> m_entries;
意思是說使用vector比使用map更加節省空間,并且一個dom節點往往不太可能綁了太多的事件類型。這就啟示我們寫代碼要根據實際情況靈活處理。
同時還有一個比較有趣的事情,就是webkit用了一個EventTargetDataMap存放所有節點綁定的事件,它是一個static靜態成員變量,被所有Node的實例所共享,由于不同的實例的內存地址不一樣,所以它的key不一樣,就可以通過內存地址找到它綁的所有事件,即上面說的vector結構。為什么它要用一個類似于全局的變量?按照正常思維,每個Node結點綁的事件是獨立的,那應該把綁的事件作為每個Node實例獨立的數據,搞一個全局的還得用一個map作一個哈希映射。
一個可能的原因是EventTarget是作為所有DOM結點的事件目標的類,除了Node之外,還有FileReader、AudioNode等也會繼承于EventTarget,它們有另外一個EventTargetData。把所有的事件都放一起了,應該會方便統一處理。
這個時候你可能會冒出另外一個問題,這個EventTargetDataMap是什么釋放綁定的事件的,我把一個DOM結點刪了,它會自動去釋放綁定的的事件嗎?換句話說,刪除掉一個結點前需不需要先off掉它的事件?
2. DOM結點刪除與事件解綁
從源碼可以看到,Node的析構函數并沒有去釋放當前Node綁定的事件,所以它是不是不會自動釋放事件?為驗證,我們在添加綁定一個事件后、刪掉結點后分別打印這個map里面的數據,為此給Node添加一個打印的函數:
- void Node::printEventMap(){
- EventTargetDataMap::iterator it = eventTargetDataMap().begin();
- LOG (INFO) << "print event map: ";
- while(it != eventTargetDataMap().end()){
- LOG(INFO) << ((Element*)it->key.get())->tagName();
- ++it;
- }
- }
在上面的第5行,循環打印出所有Node結點的標簽名。
同時試驗的html如下:
- <p id="text">hello, world</p>
- <script>
- function clickHandle(){
- console.log("click");
- }
- document.getElementById("text").addEventListener("click", clickHandle);
- document.getElementById("text").remove();
- document.addEventListener("DOMContentLoaded", function(){
- console.log("loaded");
- });
- </script>
打印的結果如下:
- [21755:775:0204/181452.402843:INFO:Node.cpp(1910)] print event map:
- [21755:775:0204/181452.403048:INFO:Node.cpp(1912)] “P”
- [21755:775:0204/181452.404114:INFO:Node.cpp(1910)] print event map:
- [21755:775:0204/181452.404287:INFO:Node.cpp(1912)] “P”
- [21755:775:0204/181452.404466:INFO:Node.cpp(1912)] “#document”
可以看到remove了p結點之后,它的事件依然存在。
我們看一下blink在remove里面做了什么:
- void Node::remove(ExceptionState& exceptionState) {
- if (ContainerNode* parent = parentNode())
- parent->removeChild(this, exceptionState);
- }
remove是后來W3C新加的api,所以在remove里面調的是老的removeChild,removeChild的關鍵代碼如下:
- Node* previousChild = child->previousSibling();
- Node* nextChild = child->nextSibling();
- if (nextChild)
- nextChild->setPreviousSibling(previousChild);
- if (previousChild)
- previousChild->setNextSibling(nextChild);
- if (m_firstChild == &oldChild)
- setFirstChild(nextChild);
- if (m_lastChild == &oldChild)
- setLastChild(previousChild);
- oldChild.setPreviousSibling(nullptr);
- oldChild.setNextSibling(nullptr);
- oldChild.setParentOrShadowHostNode(nullptr);
前面幾行是重新設置DOM樹的結點關系,比較好理解。***面三行,把刪除掉的結點的兄弟指針和父指針置為null,注意這里并沒有把它delete掉,只是把它隔離開來。所以把它remove掉之后, 這個結點在內存里面依舊存在,你依然可以獲取它的innerText,把它重新append到body里面(但是不推薦這么做)。同時事件依然存在那個map里面。
什么時候這個節點會被真正的析構呢?發生在GC回收的時候,GC回收的時候會把DOM結點的內存釋放,并且會刪掉map里面的數據。為驗證,在啟動Chrome的時候加上參數:
- chromium test.html --js-flags='--expose_gc'
這樣可以調用window.gc觸發gc回收,然后在上面的js demo代碼后面加上:
- setTimeout(function(){
- //添加這個事件是為了觸發Chrome源碼里面添加的打印log
- document.addEventListener("DOMContentLoaded", function(){});
- setTimeout(function(){
- window.gc();
- document.addEventListener("DOMContentLoaded", function(){});
- }, 3000);
- }, 3000);
打印的結果:
- [Node.cpp(1912)] print event map:
- [Node.cpp(1914)] “P”
- [Node.cpp(1914)] “#document”
- [Element.cpp(186)] destroy element “p”
- [Node.cpp(1912)] print event map:
- [Node.cpp(1914)] “#document”
后面三行是執行了GC回收后的結果——析構p標簽并更新存放事件的數據結構。
所以說刪掉一個DOM結點,并不需要手動去釋放它的事件。
需要注意的是DOM結點一旦存在一個引用,即使你把它remove掉了,GC也不會去回收,如下:
- <script>
- var p = document.getElementById("text");
- p.remove();
- window.gc();
- </script>
執行了window.gc之后并不會去回收p的內存空間以及它的事件。因為還存在一個p的變量指向它,而如果將p置為null,如下:
- <script>
- var p = document.getElementById("text");
- p.remove();
- p = null;
- window.gc();
- </script>
***的GC就管用了,或者p離開了作用域:
- <script>
- !function(){
- var p = document.getElementById("text");
- p.remove();
- }()
- window.gc();
- </script>
自動銷毀,p結點沒有人引用了,能夠自動GC回收。
還有一個問題一直困擾著我,那就是監聽X按鈕的click,然后把它的父容器如彈框給刪了,這樣它自已本身也刪了,但是監聽函數還可以繼續執行,實體都沒有了,為什么綁在它身上的函數還可以繼續執行呢?通過上面的分析,應該可以找到答案:刪掉之后GC并不會立刻回收和釋放事件,因為在執行監聽函數的時候,里面有個this指針指向了該節點,并且this是只讀的,你不能把它置成null。所以只有執行完了回調函數,離開了作用域,this才會銷毀,才有可能被GC回收。
還有一種綁事件的方式,沒有討論:
3. DOM Level 0事件
就是使用dom結點的onclick、onfocus等屬性,添加事件,由于這個提得比較早,所以它的兼容性***。如下:
- function clickHandle(){
- console.log("addEventListener click");
- }
- var p = document.getElementById("text");
- p.addEventListener("click", clickHandle);
- p.onclick = function(){
- console.log("onclick trigger");
- };
如果點擊p標簽,將會觸發兩次,一次是addEventListener綁定的,另一次是onclick綁定的。onclick是如何綁定的呢:
- bool EventTarget::setAttributeEventListener(const AtomicString& eventType,
- EventListener* listener) {
- clearAttributeEventListener(eventType);
- if (!listener)
- return false;
- return addEventListener(eventType, listener, false);
- }
可以看到,***還是調的上面的addEventListener,只是在此之前要先clear掉上一次綁的屬性事件:
- bool EventTarget::clearAttributeEventListener(const AtomicString& eventType) {
- EventListener* listener = getAttributeEventListener(eventType);
- if (!listener)
- return false;
- return removeEventListener(eventType, listener, false);
- }
在clear函數里面會去獲取上一次的listener,然后調removeEventListener,關鍵在于它怎么根據事件名稱eventType獲取上次listener呢:
- EventListener* EventTarget::getAttributeEventListener(
- const AtomicString& eventType) {
- EventListenerVector* listenerVector = getEventListeners(eventType);
- if (!listenerVector)
- return nullptr;
- for (auto& eventListener : *listenerVector) {
- EventListener* listener = eventListener.listener();
- if (listener->isAttribute() /* && ... */)
- return listener;
- }
- return nullptr;
- }
在代碼上看很容易理解,首先獲取該DOM結點該事件名稱的所有listener做個循環,然后判斷這個listener是否為屬性事件。判斷成立,則返回。怎么判斷是否為屬性事件?那個是實例化事件的時候封裝好的了。
從上面的源代碼可以很清楚地看到onclick等屬性事件只能綁一次,并且和addEventListener的事件不沖突。
關于事件,還有一個很重要的概念,那就是事件的捕獲和冒泡。
4. 事件的捕獲和冒泡
用以下html做試驗:
- <div id="div-1">
- <div id="div-2">
- <div id="div-3">hello, world</div>
- </div>
- </div>
- var div1 = document.getElementById("div-1"),
- div2 = document.getElementById("div-2"),
- div3 = document.getElementById("div-3");
- function printInfo(event){
- console.log(“eventPhase=“ + ””event.eventPhase + " " + this.id);
- }
- div1.addEventListener("click", printInfo, true);
- div2.addEventListener("click", printInfo, true);
- div3.addEventListener("click", printInfo, true);
- div1.addEventListener("click", printInfo);
- div2.addEventListener("click", printInfo);
- div3.addEventListener("click", printInfo);
第三個參數為true,表示監聽在捕獲階段,點擊p標簽之后控制臺打印出:
- [CONSOLE] “eventPhase=1 div-1”
- [CONSOLE] “eventPhase=1 div-2”
- [CONSOLE] “eventPhase=2 div-3”
- [CONSOLE] “eventPhase=2 div-3”
- [CONSOLE] “eventPhase=3 div-2”
- [CONSOLE] “eventPhase=3 div-1”
在Event類定義里面可以找到關到eventPhase的定義:
- enum PhaseType {
- kNone = 0,
- kCapturingPhase = 1,
- kAtTarget = 2,
- kBubblingPhase = 3
- };
1表示捕獲取階段,2表示在當前目標,3表示冒泡階段。把上面的phase轉化成文字,并把html/body/document也綁上事件,同時at-target只綁一次,那么整一個過程將是這樣的:
- “capture document”
- “capture HTML”
- “capture BODY”
- “capture DIV#div-1”,
- “capture DIV#div-2”,
- “at-target DIV#div-3”,
- “bubbling DIV#div-2”,
- “bubbling DIV#div-1”,
- “bubbling BODY”
- “bubbling HTML”
- “bubbling document”
從document一直捕獲到目標div3,然后再一直冒泡到document,如果在某個階段執行了:
- event.stopPropagation()
那么后續的過程將不會繼續,例如在document的capture階段的click事件里面執行了上面的阻止傳播函數,那么控制臺只會打印出上面輸出的***行。
在研究blink是如何實現之前,我們先來看一下事件是怎么觸發和封裝的
5. 事件的觸發和封裝
以click事件為例,Blink在RenderViewImpl里面收到了外面的進程的消息:
- // IPC::Listener implementation ----------------------------------------------
- bool RenderViewImpl::OnMessageReceived(const IPC::Message& message) {
- // Have the super handle all other messages.
- IPC_MESSAGE_UNHANDLED(handled = RenderWidget::OnMessageReceived(message))
- }
上文已提到,RenderViewImpl是頁面最基礎的一個類,當它收到IPC發來的消息時,根據消息的類型,調用相應的處理函數,由于這是一個input消息,所以它會調:
- IPC_MESSAGE_HANDLER(InputMsg_HandleInputEvent, OnHandleInputEvent)
上面的IPC_MESSAGE_HANDLER其實是Blink定義的一個宏,這個宏其實就是一個switch-case里面的case。
這個處理函數又會調:
- WebInputEventResult WebViewImpl::handleInputEvent(
- const WebInputEvent& inputEvent) {
- switch (inputEvent.type) {
- case WebInputEvent::MouseUp:
- eventType = EventTypeNames::mouseup;
- gestureIndicator = WTF::wrapUnique(
- new UserGestureIndicator(m_mouseCaptureGestureToken.release()));
- break;
- }
- }
它里面會根據輸入事件的類型如mouseup、touchstart、keybord事件等類型去調不同的函數。click是在mouseup里面處理的,接著在MouseEventManager里面創建一個MouseEvent,并調度事件,即捕獲和冒泡:
- WebInputEventResult MouseEventManager::dispatchMouseEvent(EventTarget* target, const AtomicString& mouseEventType, const PlatformMouseEvent& mouseEvent, EventTarget* relatedTarget, bool checkForListener) {
- MouseEvent* event = MouseEvent::create( mouseEventType, targetNode->document().domWindow(), mouseEvent/*...*/);
- DispatchEventResult dispatchResult = target->dispatchEvent(event);
- return EventHandlingUtil::toWebInputEventResult(dispatchResult);
- }
上面代碼第2行創建MouseEvent,第3行dispatch。我們來看一下這個事件是如何層層封裝成一個MouseEvent的:
上圖展示了從原始的msg轉化成了W3C標準的MouseEvent的過程。Blink的消息處理引擎把msg轉化成了WebInputEvent,這個event能夠直接靜態轉化成可讀的WebMouseEvent,也就是事件在底層的時候已經被封裝成帶有相關數據且可讀的事件了,上層再把它這些數據轉化成W3C規定格式的MouseEvent。
我們重點看下MouseEvent的create函數:
- MouseEvent* MouseEvent::create(const AtomicString& eventType, AbstractView* view, const PlatformMouseEvent& event, Node* relatedTarget) {
- bool isMouseEnterOrLeave = eventType == EventTypeNames::mouseenter ||
- eventType == EventTypeNames::mouseleave;
- bool isCancelable = !isMouseEnterOrLeave;
- bool isBubbling = !isMouseEnterOrLeave;
- return MouseEvent::create(
- eventType, isBubbling, isCancelable, view, event.position().x()
- /*.../*, &event);
- }
從代碼第五行可以看到鼠標事件的mouseenter和mouseleave是不會冒泡的。
另外,每個Event都有一個EventPath,記錄它冒泡的路徑:
在dispatchEvent的時候,會初始化EventPath:
- void EventPath::initialize() {
- if (eventPathShouldBeEmptyFor(*m_node, m_event))
- return;
- calculatePath();
- calculateAdjustedTargets();
- calculateTreeOrderAndSetNearestAncestorClosedTree();
- }
第五行會去計算Path,而這個計算Path的核心邏輯非常簡單:
- void EventPath::calculatePath() {
- // For performance and memory usage reasons we want to store the
- // path using as few bytes as possible and with as few allocations
- // as possible which is why we gather the data on the stack before
- // storing it in a perfectly sized m_nodeEventContexts Vector.
- HeapVector, 64> nodesInPath;
- Node* current = m_node;
- nodesInPath.push_back(current);
- while (current) {
- current = current->parentNode();
- if (current)
- nodesInPath.push_back(current);
- }
- m_nodeEventContexts.reserveCapacity(nodesInPath.size());
- for (Node* nodeInPath : nodesInPath) {
- m_nodeEventContexts.push_back(NodeEventContext(
- nodeInPath, eventTargetRespectingTargetRules(*nodeInPath)));
- }
- }
第9行的while循環不斷地獲取當前node的父節點并把它push到一個vector里面,直到null即沒有父節點為止。***再把這個vector push到真正用來存儲成員變量。這段代碼我們又發現一個有趣的注釋,它說明了為什么不直接push到成員變量里面——因為vector變量會自動擴展本身大小,當push的時候容量不足時,會不斷地開辟內存,blink的實現是開辟一個單位元素的空間,剛好存放一個元素:
- ptr = expandCapacity(size() + 1, ptr);
所以如果直接push_back到成員變量,會不斷地開辟新內存。于是它一開始就初始化了一個size為64的棧變量來存放,減少開辟內存的操作。另外有些vector自動擴充容量的實現,可能是size * 1.5或者size + 10,而不是size + 1,這種情況就會導致有多余的空間沒用到。
通過這樣的手段,就有了記錄事件冒泡路徑的EventPath。
6. 事件捕獲和冒泡的實現
上面第5點提到的MouseEventManager會調dispatchEvent,這個函數會先創建一個dispatcher,這個dispatcher實例化的時候就會去初始化上面的EventPath,然后再進行dispatch/事件調度:
- EventDispatcher dispatcher(node, &mediator->event());
- DispatchEventResult dispatchResult = dispatcher.dispatch();
所以核心函數就是第2行調的dispatch,而這個函數最核心的3行代碼為:
- if (dispatchEventAtCapturing() == ContinueDispatching) {
- if (dispatchEventAtTarget() == ContinueDispatching)
- dispatchEventAtBubbling();
- }
(1)先執行Capturing,然后再執行AtTarget,***再Bubbling,我們來看一下Capturing函數:
- inline EventDispatchContinuation EventDispatcher::dispatchEventAtCapturing() {
- // Trigger capturing event handlers, starting at the top and working our way
- // down.
- //改變event的階段為冒泡
- m_event->setEventPhase(Event::kCapturingPhase);
- //先處理綁在window上的事件,并且如果event的m_propagationStopped被設置為true
- //則返回done狀態,不再繼續傳播
- if (m_event->eventPath().windowEventContext().handleLocalEvents(*m_event) &&
- m_event->propagationStopped())
- return DoneDispatching;
上面做了一些初始化的工作后,循環EventPath依次觸發響應函數:
- //從EventPath***一個元素,即最頂層的父結點開始下濾
- for (size_t i = m_event->eventPath().size() - 1; i > 0; --i) {
- const NodeEventContext& eventContext = m_event->eventPath()[i];
- //觸發事件響應函數
- eventContext.handleLocalEvents(*m_event);
- //如果響應函數設置了stopPropagation,則返回done
- if (m_event->propagationStopped())
- return DoneDispatching;
- }
- return ContinueDispatching;
- }
注意上面的for循環終止條件的i是大于0,i為0則為currentTarget。而總的size為6,與我們上面demo控制臺打印一致。
(2)at-target的處理就很簡單了,取i為0的那個Node并觸發它的listeners:
- inline EventDispatchContinuation EventDispatcher::dispatchEventAtTarget() {
- m_event->setEventPhase(Event::kAtTarget);
- m_event->eventPath()[0].handleLocalEvents(*m_event);
- return m_event->propagationStopped() ? DoneDispatching : ContinueDispatching;
- }
(3)bubbling的處理稍復雜,因為它還要處理cancleBubble的情況,不過總體的邏輯是類似的,核心代碼如下:
- inline void EventDispatcher::dispatchEventAtBubbling() {
- // Trigger bubbling event handlers, starting at the bottom and working our way
- // up.
- size_t size = m_event->eventPath().size();
- for (size_t i = 1; i < size; ++i) {
- const NodeEventContext& eventContext = m_event->eventPath()[i];
- if (m_event->bubbles() && !m_event->cancelBubble()) {
- m_event->setEventPhase(Event::kBubblingPhase);
- }
- eventContext.handleLocalEvents(*m_event);
- if (m_event->propagationStopped())
- return;
- }
- }
可以看到bubbling的for循環是從i = 1開始,和capturing相反。因為bubble是三個階段***處理的,所以它不用再返回一個標志了。
上面介紹完了事件的捕獲和冒泡,我們注意到一個細節,所有的事件都會先在capture階段在windows上觸發。
綜合以上,本文從源碼角度介紹了事件的數據結構,從一個側面解綁事件介紹事件和DOM節點的聯系,然后重點分析了事件的捕獲及冒泡過程。相信看完本文,對事件的本質會有一個更透徹的理解。