Mutation 事件和 MutationObserver

Mutation 事件

DOM Level 3 提供了一系列的 mutation 事件,如 DOMAttrModifiedDOMNodeInserted,供开发者观察 DOM 的变动。这些事件虽然不太常用,但却非常方便好用。比如在使用 ajax 往 DOM 添加新元素时,需要检查总高度超过了容器,常见的方法是在 ajax 完成时的回调函数里执行,但这样并不利于组织代码,甚至回调函数会很臃肿;如果使用 DOMNodeInserted 就好多了:

$container.on( 'DOMNodeInserted', function() {
  if ( children_height > $container.height() ) {
    // do something
  }
})

$.ajax({
  success: function( data ) {
    $container.append( $( data.html ) )
  }
})

这样职责就清晰很多—— ajax 的回调函数仅处理返回的数据。

但 mutation 事件的性能太糟了,Chrome 也未必吃得消,而 Mozilla 在 Performance best practices in extensions 里也建议尽可能别用 mutation 事件。

因此 mutation 事件的处境很尴尬,能用的只有它,但又不能用,最终也被 DOM Level 4 引入的 MutationObserver 取代了。

MutationObserver

先看以下代码:

<ul></ul>

<script>
  var ul = document.querySelector('ul')
    , observer
  try {
    observer = new ( window.MutationObserver ||
                     window.WebKitMutationObserver )( function( mutationRecord,
                                                    observer ) {
      mutationRecord.forEach( function( mutation ) {
        console.log( mutation )
      })
    })
    observer.observe( ul, { attributes: true, childList: true } )
  } catch ( ex ) {}

  ul.className = 'list'
  ul.appendChild( document.createElement( 'li' ) )
  // 不会触发 
  ul.children[0].className = 'item'

  // 在不需要时,停止继续观察
  observer.disconnect()
</script>

目前支持的浏览器似乎只有 webkit 核心和 Firefox (14+),而在 Chrome 24 和 Safari 6.0.2 里仍属于私有实现,所以必须先判断:window.MutationObserver || window.WebKitMutationObserver

MutationObserver 创建实例时需要一个函数作为参数作为回调函数,该函数有两个参数,一个是包含所有被观察的 MutationRecord 实例的数组,一个是 MutationObserver 实例。

observer.observe( node, anObject ) 接受两个参数,需观察的对象和事件类型。事件类型用对象的形式来赋值,如 { attributes: true, childList: true },这决定了什么事件会被触发,比如上述例子执行结果为:

{
  addedNodes: null,
  attributeName: "class",
  attributeNamespace: null,
  nextSibling: null,
  oldValue: null,
  previousSibling: null,
  removedNodes: null,
  target: ul.list,
  type: "attributes"
}
{
  addedNodes: NodeList[1],
  attributeName: null,
  attributeNamespace: null,
  nextSibling: null,
  oldValue: null,
  previousSibling: #text,
  removedNodes: NodeList[0],
  target: ul,
  type: "childList"
}

MutationObserver 和一般的 event 不一样,默认仅观察指定对象,除非指定 subtree: true,所以更改 liclassName 没有触发属性变动事件:

ul.children[0].className = 'list'

此外还要注意的是,被观察的对象是 node 类型,所以类似这样的操作也会触发事件:ul.appendChild( document.createTextNode( 'Hello World' ) )

更详细的内容请参考 MDN 的文档

CSS 动画事件的妙用

MutationObserver 虽然实用度和性能都很优秀,但毕竟是 DOM Level 4 的东西——太新——在老一点的浏览器有没有别的代替方案呢?

如果只是需要 DOMNodeInserted,那可以利用 CSS 动画事件实现类似的效果:

<style>
  @-webkit-keyframes NodeInserted {  
    from {}
  }

  @keyframes NodeInserted {  
    from {  
      clip: rect(0px, auto, auto, auto);  
    }
    to {  
      clip: rect(0px, auto, auto, auto);
    }
  }

  li {
    -webkit-animation-duration: .001s;
    animation-duration: .001s;
    -webkit-animation-name: NodeInserted;
    animation-name: NodeInserted;
  }
</style>

<ul></ul>

<script>
  var nodeInserted = function( event ) {
    if ( 'NodeInserted' === event.animationName ) {
      console.log( 'New node inserted!' )
    }
  }
  document.addEventListener( 'webkitAnimationStart', nodeInserted, false )
  document.addEventListener( 'animationstart', nodeInserted, false )

  document.querySelector( 'ul' ).appendChild( document.createElement( 'li' ) )
</script>

这个技巧利用了 CSS 动画开始时触发的 animationstart 事件,因为如果一个元素只有在 DOM 里才会开始动画。

一些需要注意的地方:

  • Webkit 系列(Chrome 24 & Safari 6.0.2)要加上 prefix,事件绑定也是;
  • 尽量别用 jQuery.on() 绑定事件,因为 event 参数是被 jQuery 创建的,目前并不支持 event.animationName 等几个属性(1.9.0);
  • Chrome 24 有点奇葩,只有一个空的 from {} 也会触发动画事件(其他 webkit 系没测试);
  • Firefox 需要比较完整并有实际效果的 keyframes,比如示例代码里的 clip,如果想省事,可以单独用 from { opacity: 1 },但不是所有属性都可以触发;
  • keyframes 里的 CSS 属性要多注意,别不小心覆盖了需要的,比较安全的是 opacityclip
  • 建议通过判断 event.animationName 来避免非预期情况;
  • 建议用父元素做事件代理,如 document.querySelector( 'ul' ).addEventListener( 'animationstart', nodeInserted, false ),并酌情停止冒泡。

这个技巧可以用在任何支持 CSS 动画的浏览器上,泛用性比较高,可以参考我写的 Twitter Filter 用户脚本,做成 bookmarklet 一样可以用在别的浏览器。

补充:IE9 只支持 5 个 mutation 事件,不支持 CSS 动画,MutationObserver 目前还没支持的迹象,所以忘了 IE 系列吧。