從 Chrome 原始碼看瀏覽器的事件機制

會程式設計的銀豬發表於2017-02-06

在上一篇《從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,一般是跟在</html>後面的一個\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<std::pair<AtomicString, Member<EventListenerVector>>, 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>

js綁事件如下:

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<Member<Node>, 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節點的聯絡,然後重點分析了事件的捕獲及冒泡過程。相信看完本文,對事件的本質會有一個更透徹的理解。

相關文章