学习 kityminder & angular (十四) event 和 scope.$apply

回顾 event 机制 

先回顾一下以前看的 core/event.js, 其提供了 minder 的事件机制 (event) 支持:

// 表示一个脑图中发生的事件
class MinderEvent {
  ctor(type, parms, canstop): 构造一个脑图事件, type 是一个名字字符串, 如 `contentchange'.
  getPosition(): 如果事件是从一个 kity 事件派生的,会有 `getPosition()` 获取事件发生的坐标
  getTargetNode(): 当发生的事件是鼠标事件时,获取事件位置命中的脑图节点
  stopPropagation(): 停止(向上)传播.
  preventDefault(): 取消缺省处理.
  ... 其它辅助函数略...
}

这个类从功能上看, 模仿了 DOM 的事件, 提供了基本类型信息, 以及一些辅助获取信息的函数.

// 当 minder 对象构造时, 调用指定 hook.
// 注册函数实现在 minder.js 中, 方法是在一个闭包数组 _initHooks[] 中添加该函数,
//   当 minder.ctor() 时, 调用 hooks[] 中每一个函数.
Minder.registerInitHook( _initEvents );


extend class Minder {
  _initEvents(): 初始化 event 组件(部分)所需的内部数据. 实际初始化 _eventCallbacks{} 对象.
  _resetEvents(): 估计不会用到.
  
  on(names, callback): {  // names 可以是多个事件 type, 用空格分隔.
    names.split(/s/).foreach { |type|
      This._listen(type, callback);
    }
  },
  _listen(type, callback) {
    // ... 将 callback 函数添加到 this._eventCallbacks{} 对象中名为 type 的队列中. 简写为:
    this._ec{}.type[] += callback;
  },
  off(names, callback): 与 on() 是反操作, 细节略.
 
  fire(type, params): {  // 发布事件.
    var e = new MinderEvent(...); // 构造事件实例
     this._fire(e);  // 发布实现.
    return this;
  }
  _fire(e): {  //  发布事件的实现.
    // 从前面 _listen() 已经知道, 名为 type 的事件回调函数在...
    var callbacks[] = this._eventCallbacks[type].copy(); // 复制一份.
    foreach (cb in callbacks) 
      => this.cb(e)
    return e.shouldStopPropagation();
  }
}

这是一个典型的注册/发布事件的模型. 原理上没有要说的, 主要是实现的一点点细节问题. 例如:
函数 _fire() 返回的值被 fire() 函数抛弃, 那 shouldStopPropagation() 语义如何实现呢...?

另外, 在发布的时候都会"复制"一个 event callbacks[] 数组, 我看不如不要复制, 而是添加的时候采用复制后添加
的方式也许效率更高, 更节省点内存.

======

待求解的问题

问自己一个问题: 当界面选中一个节点(或取消选中), 工具栏的变化是如何产生的?
思路可能是哪个呢? 
   1. minder 发布事件, 某个地方监听后更新UI;
   2: toolbar 设置一个 timer, 定期更新UI.
   3: toolbar 的 ng-disabled 背后做了未知侦听/计算过程, 从而改变了按钮 enabled/disabled 状态.

查看 ng-disabled 文档: https://docs.angularjs.org/api/ng/directive/ngDisabled
  该指令设置元素的 disabled 属性, 根据给出的表达式.

调整 undo-redo ng-disabled='debug_can_undo()', 然后调试加入一些 console.log() 语句, 观察:

当选中一个节点时, 会有 minder 事件 'focus', 'selectionchange', 'beforerender', 'noderender' 被发布
出来. 然后就是 9 次连续的 debug_can_undo() 方法调用, 按钮状态被设置. 那么这些是如何发生的呢?

研究一下这四个事件都是谁在监听:

1. 事件 focus: 没有侦听者;
2. 事件 selection-change: 两个侦听者:
    (1) kityminder-core 内部一个;
    (2) kityminder-editor/src/runtime/input.js:75
3. 事件 before-render: 一个侦听者: 在 kityminder-core 内部.
4. 事件 node-render: 一个侦听者: 在 kityminder-core 内部.

更进一步, 我们 hack 掉 fire() 方法, 使得其一个事件也不发布出去(或不发布 selectionchange 事件), 观察结果,
结果是 can_undo() 仍然会被调用 ( 9 次), 那么看起来不是在事件中更新工具条状态的了.

换一个思路:

记得看 angularjs 的某篇文章中提到, angularjs 会处理整个网页的 mouse,key 消息, 然后更新整个界面, 是
这样的机制吗? 让我们去看书和搜索文章 --- 似乎要调用 scope.$apply() 方法使得 AngularJS 更新界面.

搜索了一下整个 kityminder-editor 部分, 发现 service/commandBinder, service/resourceService,
  directive/kityminderEditor, directive/noteEditor, notePreviewer, resourceEditor, searchBox
这些地方有. 那些对话框我们暂时未使用到, 估计不会是它们产生 $apply() 调用.

虽然现在对 AngularJS 的 service 概念还一无所知, 但还是先看看 commandBinder.js 看看是做什么的.
里面大致是这样:

angular.module(...)
  .service(估计是服务名='commandBinder', function() {
    return {
      bind: function(...) {
        minder.on('interactchange', function() {  // 没见到发布此事件, 所以...?
          这里会调用 scope.$apply();
        });
      } 
    };
  });

为了实验, 我们注释掉 scope.$apply(), 发现有趣的一幕, can_undo() 方法被调用次数变为 4 次.
在 scope.$apply() 前面加上 console.log(), 再次实验. 结果显示有 3 次 `interactchange' 事件发生!

只能是前面我们拦截 minder.fire() 的方式不对. 再换一种方式, 根据我们前面对 kityminder 的 event 系统的知识,
我们这次拦截更底层的 _fire() 方法, 并过滤掉不关心的消息. 再次观察, 发现事件 `interactchange' 之后 "总会"
发生工具条 can_undo() 的调用, 根据点击的地方不同, 有时调用 9 次, 或 5 次不同.

 

为了理解 $apply() 等 scope 上的几个方法, 让我们去翻书吧. 打开找到《精通 AngularJS》一书第 293 页:

Scope.$apply -- 打开 AngularJS 世界的钥匙

难道我们随意想了解的一个问题就接触到了钥匙? 不管钥匙不钥匙, 问题总是要解决的. 继续看...

当 AngularJS 首次向公众发布之后, 就有许多关于它的模型变化监控算法的 "阴谋论". 其中最被津津乐道的一种
是, 怀疑 AngularJS 使用了某种轮询机制. (前面我们提及的 toolbar timer 算是轮询机制, 我也是阴谋论者么?)
书上说: 这猜测是错误的!

AngularJS 模型变化监控 背后的思路是 "善后" (observeat the end of the day), 因为引发模型变化的情况
可以被穷举出来:
   1. DOM 事件. 如 click, char 事件
   2. XHR 回调事件.
   3. 浏览器地址变化.
   4. 定时器事件. (timeout, interval)

AngularJS 只会在被明确告知的情况下才会启动它的模型监控机制. 为了让这种监控机制运转起来, 需要在
scope 对象上执行 $apply 方法. (需要模型主动调用 $apply ...) AngularJS 内置指令和服务实现调用了
$apply() 方法, 它们内部已经处理好了监控工作.

 

深入 $digest 循环

在 AngularJS 中, 检测模型变化的过程成为 $digest 循环. 注: digest 在 IT 中可理解为摘要(算法), 如 MD5, SHA.
该方法会检测注册在所有作用域上的所有监视 ($watch) 对象.

存在 $digest 循环的原因:
   1. 判定模型哪些部分发生了变化, 以及 DOM 中的哪些属性应该被更新.
   2. 减少不必要的重绘, 以提升性能, 减少 UI 闪烁.

AngularJS 在(执行完 JavaScript) 交还控制权给 DOM 渲染部分之前, 确保所有的模型值都已完成计算且已 "稳定".
这保证了 UI 一次性完成重绘. 如果每个单独的属性变化都重绘一次, 就会导致性能低下和界面闪烁.

AngularJS 使用脏检查 (dirty checking) 机制来判定某个模型值是否发生了变化. 工作机制是将之前保存的模型
值和能导致模型变化的事件发生后计算的新模型值做对比.

注册一个新的模型监视基本语法:
   scope.$watch(watchExpression, modelChangeCallback)

当作用域上添加一个新的 $watch 时, AngularJS 会计算 watch-expression, 然后将结果保存到内部.
在后续的 $digest 循环中, watch-expression 会被再次计算, 计算所得的新值和旧值进行对比. 回调函数
model-change-callback 只会在新值vs旧值不同时才会调用.

需要知道的是, 不仅我们可以自己注册 $watch, 任何指令都可以设置自己的 $watch.
(可以明显地猜测, ng-disabled 会注册 $watch).

实验: 让我们在浏览器中观察 scope 的 $$watchers[] 字段, 可以发现对于 undo-redo 按钮的 ng-disabled
注册的 $watcher 的 .last 属性记录了该属性最后的值, .exp 记录了表达式 "debug_can_undo()", 如果继续
深入, 还能发现更多惊喜的细节! 但是只能略了.

模型的稳定性

如果模型上任何一个监视器都检测不到任何变化了, 则 AngularJS 就认为该模型是稳定的. 只要一个监视器有
变化, 就会再次使整个 $digest 循环变 dirty (我猜会再循环一遍, 直到 dirty = false 才停止).

AngularJS 会持续执行 $digest 循环, 反复运算所有作用域上的所有监视, 直到没有发现任何变化为止.
(这就解释了为什么 debug_can_undo() 函数会被调用多达 9 次, 只要有一个监视器发生变化, 就会再调用一次).
实验观察: 在选中一个节点状态下, 选择另一个节点, debug_can_undo() 只调用 5 次.
原因猜测: 只有 4 个 scope 中的监视器发生变化, 加上自己调用 1 次, 然后总计调用 5 次.

实验2: 选中另一个 toolbar 的 tab, 此时 undo-redo 按钮 `不显示出来'. 此时点击节点, debug_can_undo()
  仍然会被调用(多次).

不稳定的模型如 random() 怎么办?

AngularJS 默认最多会执行 10 次循环, 之后就会声明该模型是不稳定的, 然后中断 $digest 循环.
(那我们看到的 debug_can_undo() 最多显示 9 次是这个原因吗...? )

 

小结

综上所述, 工具条状态更新流程为:
   1. kityminder 在点击等操作时发布 'interactchange' 事件;
   2. 该事件被 commandBinder 服务侦听, 并调用 angular scope.$apply() 方法;
   3. AngularJS 会进入 $digest 循环, 调用各个 $watcher (如内建指令 ng-disabled 设置的), 直到状态稳定;
   4. 更新 UI, toolbar 的按钮 disable/enable 状态变化/或不变.

所以, 小心点性能问题, 可能某个方法会被调用 N 次, 在不知道的情况下.

你可能感兴趣的:(学习 kityminder & angular (十四) event 和 scope.$apply)