Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

用 Mutation Observers 实现DOM的Undo/Redo功能 #1

Open
iischajn opened this issue Sep 15, 2014 · 0 comments
Open

用 Mutation Observers 实现DOM的Undo/Redo功能 #1

iischajn opened this issue Sep 15, 2014 · 0 comments
Labels

Comments

@iischajn
Copy link
Owner

原文地址:Detect, Undo And Redo DOM Changes With Mutation Observers

文中所出专有名词:

MO == Mutation Observers
ME == Mutation Events
MR == MutationRecord

这篇文章里,我会向大家解释为啥Mutation Observers(以下简称MO)这么牛逼,MO是那个发育不良的DOM Mutation Events(死慢死慢又很吃性能的家伙,以下简称ME)的替代品,它可以监听复数级的DOM的改变。可目前支持它的浏览器并不多,从ChromeStatus.com的数据来看,只有0.03%的业务使用量。所以为啥不再给她多点关注呢,好可怜的说。StackOverflow关于MO的提问大多数来自于Chrome拓展和富文本编辑器,不过我觉得还可以用在更多地方,比如,DOM修改的Undo/Redo是吧,先看下下面这个例子吧:

有木有感觉吊炸天(作者自我感觉真良好),下面我快速说下它是个什么玩意~

对比 Mutation Events 和 Mutation Observers

MO对DOM的监听方式不同于旧的ME,它是等该DOM的所有变更行为都结束之后才异步触发回调的,使得我们可以更专注的对DOM进行处理。这就好比一天撸一次和一周撸一次的区别一样,你说哪个对身体好啊!【哎?作者了什么奇怪的事情~( ⊙ o ⊙ )

如何愉悦的使用 Mutation Observers

大家都很忙(都很懒),我就提供一下基础代码片段,看下它是怎么监听DOM改变的就好了:

// 实例化一个监听,传入回调
var observer = new MutationObserver(mutationCallback);

// 监听一个DOM节点的改变(可以通过config设定更具体的设置,如:监听子孙节点、属性、内容数据等)
observer.observe(node, config);

// 停止监听,字面意思
observer.disconnect();

// 清除已记录的变动队列,和reset差不多啦
observer.takeRecords();

如果你像我一样屌丝整天构造对象实例化对象指向对象就是没对象的,那你肯定有大把的时间来做代码实践。下面是两个更好的例子,通过 contenteditable 与 el.innerHTML 去改变DOM结构,你可以清晰的看到MO是怎么异步处理和输出的啦。

http://jsbin.com/xazok/1/edit

http://jsbin.com/bajuqi/1/edit

我们可以监听到子节点的添加删除、属性和文本内容与数据的改变。再次提醒大家,MO 不是对每个改变都触发回调的,它是在DOM在进行了一组变化结束以后在堆栈空闲时才触发的,就像个记录了很多版本更新后而形成的发布日志一样。

让我们进一步分解代码,实例化的MO需要传入一个回调函数,当触发回调时它会传入一个 MutationRecord(以下简称MR) 对象的数组:

var observer = new MutationObserver(function(mutations) {
    // 遍历一下mutations对象   
    mutations.forEach(function(mutation) {
        // 用不用所有的变化记录自然取决于你     
        var entry = {
            mutation: mutation,
            el: mutation.target,
            value: mutation.target.textContent,
            oldValue: mutation.oldValue
        };
        console.log('Recording mutation:', entry);
    });
});

上面的例子在: http://jsbin.com/cogid/1/edit

我们通过回调得到了一个数组,里面是 MR 对象,从控制台输出的数据你可以看到,MR 除了 target 和 oldValue 以外,还有很多有用的属性:

  • addedNodes 一个有节点、属性或文本添加的 NodeList
  • removedNodes 和上面类似,被删除的 NodeList
  • previousSibling 返回此节点前面的兄弟节点
  • nextSibling 返回此节点后面的兄弟节点
  • attributeName 返回属性被改变的属性名
  • oldValue 返回给你赋值之前的当前值
  • type DOM改变类型 (attribute、 characterData 或 childList)

我们可以用下面的这个 observe() 方法来注册需要监听的DOM节点,并通过options对象参数来指定哪些类型的变化可以触发回调。

var options = {
  subtree: true,
  childList: true,
  attributes: false
};
observer.observe(target, options);

可配参数有:

  • childList: *true 子节点
  • attributes: true 属性
  • characterData: true 文本内容
  • subtree: true 子孙节点
  • attributeOldValue: true 记录老的属性值
  • characterDataOldValue: true 记录老的文本内容
  • attributeFilter: 只监听数组里面的属性

如上,我们可以通过 options 来配置监听我们希望被监听到的节点,即便是从root也OK,但当然,只求所需将会为我们带来更好的性能。来让我们在页面上搞出一块儿可编辑区域吧(比如: <div id="editor” contentEditable> )

首先,让我们取得它的元素引用:

var editor = document.querySelector('#editor');

然后我们用已经实例化好的 MO 来监听它

observer.observe(editor, config);

从下面的截图我们可以看出,只要可编辑区域的内容有任何变化,回调方法都会被调用,并返回 MR 对象列表。

爽不爽!(≧▽≦)/(作者自嗨中),当然,如果我们想停止监听,只要调用disconnect()方法即可:

observer.disconnect();

然后所有的监听就停止了,一切风平浪静。这里我们不太深入的去了解 MO 的 API 什么的了, Dev.OperaMDN 都有良好的介绍文档,有兴趣的去读读吧。下面让我们谈一下如何通过这个逆天的MO去实现 DOM 的撤销功能,噗,会很有趣的,别走开,没有广告啊马上回来。

用 Mutation Observers 实现 Undo/Redo

做过编辑器的同学都知道Undo或Redo在其中扮演着怎样重要的角色,然后懒死的浏览器厂商还都没有一个实现标准API接口的(没API标准?你搞笑呢 UndoManager),我们通常不得不通过用堆栈来存储历史数据,搞的编辑器内存负重直线上升,一桶水不满半桶水晃悠的样子。

这些傻逼大拿们都用各种框架啊React或Clojure什么的去做,光看看代码就有一种历史的厚重感,糟糕透了。那我们是不是可以尝试一下只用原生JS就能够实现的解决方案呢?虽然可能会滥用回调,可谁不是呢~

步骤一

让我们开始布置舞台,在 app.html,用 contentEditable 属性搞出一块儿可编辑区域和两个按钮 —— 撤销和重做。虽然这儿只是拿编辑文本做例子,但本质上是通用的,都是实现DOM改变的撤销与重做(所以自然也适用于任务列表的添加更改)

这是我们的HTML:

<div id='text' contentEditable='true'></div>
<button id='undo'>Undo</button>
<button id='redo'>Redo</button>

然后开始写JS:

// 因为所以嘛我们用$代替querySelectorvar
$ = document.querySelector.bind(document);
var text = $('#text');
var undo = $('#undo');
var redo = $('#redo');

之后我们会用为这两个按钮绑定点击事件,点击后他们会撤销或重做编辑框里的内容。

其实我们需要实现的地方有两部分:内容已经改变的时机和在撤销或重做的时候它的逻辑是什么。我们用 MO 实现第一部分,用 Undo.js 实现第二部分,这是大神Jörn Zaefferer为 Undo/Redo 特意做的一个抽象原型,我们可以像如下代码一样很方便的去用它:

var stack = new Undo.Stack();

步骤二 Step 2

凑巧的是 Undo.js 就有一个关于编辑器撤销重做的例子(我机智的放到了这里 JSBin),但在内容改变时机上用的是onkeyup事件,真low~,大家看看EditCommand的使用方式就好了:

$('#editor').bind('keyup', function() {
    clearTimeout(timer);
    timer = setTimeout(function() {
        var newValue = text.html();
        // ignore meta key presses           
        if (newValue !== startValue) {
            // this could try and make a diff instead of storing snapshots               
            stack.execute(new EditCommand(text, startValue, newValue));
            startValue = newValue;
        }
    }, 250);
});

最主要的一行就是那个stack.execute(new EditCommand(...)),它需要传入三个参数,所编辑的DOM节点、老的内容和新的内容。

所以,我们要先定义一下我们自己的 EditCommand。很简单,继承 Undo.Command 定义一个构造函数,undo 方法和 redo 方法简单的使用 innerHTML 赋值。

var EditCommand = Undo.Command.extend({
    constructor: function(textarea, oldValue, newValue) {
        this.textarea = textarea;
        this.oldValue = oldValue;
        this.newValue = newValue;
    },
    undo: function() {
        this.textarea.innerHTML = this.oldValue;
    },
    redo: function() {
        this.textarea.innerHTML = this.newValue;
    }
});

OK,搞定 EditCommand 后我们有了一个记录堆栈,让我们继续用 MO 把之前的傻逼onkeyup事件替换掉。

步骤三

首先,定义一个实例化 MO 加回调,胡乱把代码复制进去就好啦:

var newValue = '';
var observer = new MutationObserver(function(mutations) {
    newValue = text.innerHTML;
    stack.execute(new EditCommand(text, startValue, newValue));
    startValue = newValue;
});

为了忠于原demo,我们可以简单的将 编辑器里的内容全部输出当作 newValue 传过去,那MO起的作用其实是“这里有些东西改变了”,而不是去使用它提供的具体改变后的内容。解决方案是向下面这样把每一个变化都记录下来:

var observer = new MutationObserver(function(mutations) {
    mutations.forEach(function(mutation) {
        newValue = mutation.target.innerHTML;
        if (newValue !== startValue) {
            stack.execute(new EditCommand(text, startValue, newValue));
            startValue = newValue;
        }
    });
});

无论怎样,我们都会把可编辑节点、老值和新值传入到实例化的 EditCommand 中去。

这里要注意的是,在做Undo/Redo动作时,依旧属于“内容被改变”,会重新触发MO执行回调,为了避免这个问题,我们可以加个状态判断。

var blocked = false;
var observer = new MutationObserver(function(mutations) {
    if (blocked) {
        blocked = false;
        return;
    }
    newValue = text.innerHTML;
    stack.execute(new EditCommand(text, startValue, newValue));
    startValue = newValue;
});

记得在 EditCommand 也要把在这个 blocked 状态用上喔,不然白加了…

var EditCommand = Undo.Command.extend({
    constructor: function(textarea, oldValue, newValue) {
        this.textarea = textarea;
        this.oldValue = oldValue;
        this.newValue = newValue;
    },
    execute: function() {},
    undo: function() {
        blocked = true;
        this.textarea.innerHTML = this.oldValue;
    },
    redo: function() {
        blocked = true;
        this.textarea.innerHTML = this.newValue;
    }
});

步骤四

下面,我们来执行监听器的 observe 方法吧~ 启动起来!

observer.observe(text, {
    attributes: true,
    childList: true,
    characterData: true,
    characterDataOldValue: true,
    subtree: true
});

我们想确保只有在有内容改变的情况下撤销和重做按钮才可点,那么我们可以如下写这样的一个方法,通过 stack 的两个判断方法来检测现在是否可以撤销或重做。

function stackUI() {
    redo.disabled = !stack.canRedo();
    undo.disabled = !stack.canUndo();
}

那如下自然,每当 stack 中有改变的时候,都要执行一下stackUI方法了

stack.changed = function() {
    stackUI();
};

步骤五

最后我们写好按钮的点击事件监听,well done~

redo.addEventListener('click', function() {
    stack.redo();
});
save.addEventListener('click', function() {
    stack.undo();
});

就这样,我们实现了编辑器的撤销和重做功能,so easy,你可以查看Demo源码看看效果如何

Demo: http://jsbin.com/zamacuta/5/

注:我只是简单写写啊目前是单字符撤销,没深处理,你可以再通过一定的延迟来达到更好的效果。

虽然上面的例子可能会有一定的缺陷和性能限制,但它的确是很简易的用原生JS实现了应用的撤销和重做功能啊!嘛……即便你觉得MO没啥用,那下个 Undo.js 玩玩也不错嘛是吧哈哈……

contentEditable 之外

完成了有关于 contentEditable 的撤销重做功能后,大家再看我写的 TodoMVCGoogle Now 这两个例子,上升到应用的层次上的撤销重做功能!牛逼吧啊哈哈哈!我还用 Polymer 搞了一个自定义 Web Component ,我将其命名为 ,哼哼哼,将所有跟撤销重做的代码都封装到这个标签里去,然后使用的话只需要:

<undo-manager>
  <div contentEditable></div>
</undo-manager>

虽然现在说这个还有点早,毕竟各浏览器平台还都没怎么实现它。但从理念上讲讲还是可以的,就是我们写了这个标签在外面之后呢就什么都不用管了!它自动生成按钮与监听DOM改变,如果你感兴趣的话可以看看这个demo Circles

Mutation Observers 的性能优势

上面我们看了代码演示,接下来让我们快速过一下性能的问题。MO 比 DOM ME 更有效也更安全,但从速度对比上还不是很明显,它的一个核心优势是我们避免了如下例使用 DOMNodeInserted 所出现的问题:

// 千万注意: 别这么用,会傻逼的
document.addEventListener('DOMNodeInserted', function() {
    var el = document.createElement('div');
    document.body.appendChild(el);
});

是吧,傻逼都能看出来这么做会引起死循环,监听 DOM 插入事件,结果回调里面执行了插入 DOM 的动作,又引起事件的触发,浏览器不断的中断渲染(重新计算样式和布局),导致 FOUC 不断闪瞎你的双眼。 MO 没有这个问题,浏览器会在有价值的情况下再进行通知(例如当所有JavaScript都执行完毕之后,或在浏览器样式渲染和计算都完成以后)。

如果你对这个话题感兴趣的话,去看 this Stack Overflow thread

注意:我刻意避开了关于 MO 的基准点测试,因为它在正常环境下是没有什么性能问题的,一些具体的差异细节可以去 jsPerf 看看,但我不保证它的准确性。╮(╯_╰)╭

攻受之分

对于监听,我们最常讨论就是 GC 和引用的问题了,MO 闭页面会自动断开文档内所有节点的监听,它相对于目标节点都是弱引用,节点被销毁它也不会再触发事件了。但DOM节点相对于 MO 却是强引用,所以如果目标节点还存在的情况下,你应该避免在MO实例在没断开节点前而销毁它,尽可能的在在页面unload的时候再断开监听吧。

Mutation Observers 的局限性

上面我一直在夸它的样子,但它并不是没有缺点,下面说说MO的局限性:

  • MO 还不能准确的监听表单元素中的状态改变,因为它们的“live”或“true”并不能真正的反射到属性层,而这个"value"其实只是"defaultValue",类似的元素比如<textarea>的值或
    是否闭合都需要单独监听,比如input内容改变,但input.getAttribute('value')返回会一直是null。普遍的解决方案是用通过监听input的keyup事件,我经常也见到有的同学会用50-200毫秒的轮询去检测文本改变。
  • MO 没办法检测到CSS样式的更改(如hover状态什么的)。
  • 变更记录里没有时间戳。
  • DOM ME 有个问题是直接关联于插入的DOM节点,并保持完整的调用作用域,相当耗费内存成本。所以 MO 的延迟处理和批处理的效率就更高些了。但也因此导致很多不需要的信息产生,比如插入每个节点的执行环境什么的。
  • 在Chrome环境下,MO 可能会受到 GC 的影响,比如根据上下文自定义的属性被吞的情况什么的,具体请看 https://code.google.com/p/chromium/issues/detail?id=329103

最后

(以上翻译内容都是我瞎编的,终于要完事儿了,我的灵感也快枯竭了)

有些人可能认为,毕竟是比较新鲜的玩意,不稳定啊时机还不太成熟,还不太敢使用它。但从下面的图中大家可以看到 MO 在浏览器中的支持还是不错的,虽然或多或少都有些缺陷,还是值得一试的。

一如既往,如果你有关于这个API的问题欢迎向 Blink 团队反馈,我很乐于看到你们为本文指出错误,当然,改不改就是我的事情了。希望此篇文章对你有所帮助。感谢 Mathias Bynens 和 Pascal Hartig 的提前浏览,没他们我肯定会写的更好,随时欢迎反馈和点评,反正我不会再动这篇文章了,哼~

@iischajn iischajn added the blog label Sep 16, 2014
@iischajn iischajn changed the title 用Mutation Observers实现DOM变更的Undo/Redo功能 用 Mutation Observers 实现DOM变更的 Undo/Redo 功能 Sep 16, 2014
@iischajn iischajn changed the title 用 Mutation Observers 实现DOM变更的 Undo/Redo 功能 用 Mutation Observers 实现DOM变更的Undo/Redo功能 Sep 16, 2014
@iischajn iischajn changed the title 用 Mutation Observers 实现DOM变更的Undo/Redo功能 用 Mutation Observers 实现DOM的Undo/Redo功能 Sep 16, 2014
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant