1

这一篇还是一个简单的例子所引发的思考。

你看,如今的框架和库,无论规模大小功能多少,它们在本质上都朝着“组件化”的思路快速演进着。Angular 有 directives,Angular 2应该也还是这个叫法;Ember 从 View 过渡到了 Component,并且接下来的迭代会朝向 WebComponent 的标准来设计生命周期及其 API;React 自身就是一个组件化的范式;还有 Polymer,那就是 Google 为 WebComponent 搞得一套 polyfills……大家都这么玩。

我们都无法完整精确的预测 WebComponent 能让 Web 进化到什么程度,但是现在已经有了这么多可用的工具了,大家自然是跃跃欲试的搞起。我也没有例外,在之前使用 Angular 的时候就在尽力做组件的抽象,把 directives 那一套算是玩儿转了——那个时候并没有意识到一件事情,直到最近返回 Ember 之后才开始有所体会。几天前我在 Ember Community 提了个问题来讨论此事,虽然也得到很多建议却还是模模糊糊的;后来又和 @darkbaby123 询问了一番,感谢他给了我很多启迪。然而始终没有想到一个确切的应用场景来验证一番。

说了半天估计你们都看糊涂了:到底什么事情啊?

Components 用来封装可重用的 HTML+CSS+JavaScript 片段,这很好,然而当下的框架们都要处理事件委托的问题,这就意味着框架会给你一个范围来开发你的应用,这个范围的边界就是框架用于事件委托的临界线,出了这条线就是浏览器自身的事件处理机制在作用了。那么,当你不得不“迈过”这条线的时候,框架(以及它提供的组件化机制)该如何帮助你呢?

举例来说,Angular 的边界在你声明 ng-app 的地方(所以我见过不少人把它放在 <html></html> 上来扩大这个界限);Ember 默认在 <body></body> 上,当然你可以改;React 也是一样,你总是要把你的第一个 component 渲染到 <body></body> 下面。那么,当你要做的事情超出 body 之外,它们应该如何处理呢?

对于像 jQuery 这样以 DOM 为中心(当然还有 BOM)的工具来说,这个问题很简单——以 DOM 为中心就意味着浏览器有什么你就用什么,无非就是它原生的 API 不好用或不够用,你拿过来用 jQuery 封装一下就好了,换汤不换药。所以当你要操作 body 以外的东西,原生的东西随便你用,比如 document(DOM),比如 window(BOM) 等等,你唯一要做的就是外面套一个 $() 壳子。

在我们的大脑模型里已经习惯了把 HTML 和 DOM 视为一体,但除此之外 DOM 和 BOM 还提供了丰富的接口来处理很多额外的事情,每一个框架或库都会多多少少提供这些额外接口的封装,比如 Angular 里的 $cookie$location,甚至干脆彻底的 $document$window,然而当这些东西和 component 关联在一起的时候,事情会变得微妙起来:什么可以做不可以做?何时/何处来做?这些问题的界线变得摇摆不定。

Angular 有 DI(依赖注入)的机制,在 directives 的层面上,它巧妙的设计了一个 Attribute Level 的 directive 定义,通过 DI 你可以把超出 body 以外的操作通过 HTML 的属性绑定给其他的 Tag Level 的 directives。因为组件的存在范围被限制在 body 以内,这就是这种机制(目前)存在的意义所在。我们还不知道当 WebComponents 尘嚣落定之时会给出我们怎样的答案,当然届时 JavaScript 已经有了 modules,所以全局污染的问题已经不复存在,现在唯一不明朗的就是如何与组件的生命周期关联起来。

让我们来看一个例子。现在有很多应用都有这样的设计:Header 与 Main Content 没有明显的界限,看起来像一个整体。但如果 Main Content 的内容超出了浏览器一屏的高度,那么当用户向下滚动的时候,Header 会“浮”起来(通过下放的阴影)并固定在窗口顶部,很不错的视觉效果。

问题就在于监听用户滚动事件的动作应该是发生在 BOM 范围内的,如果你的应用是基于以组件为中心的思想开发的,这个动作到底应该在哪里做?

这个问题其实会有很多变数,比如说你可以设想这个动作和任何具体的组件无关,而是在应用程序初始化的时候直接执行。很好,但是有两个问题:

  1. 固定 Header 并为它添加阴影是需要 Header 已经存在于 DOM 之中的,通常在应用程序初始化的时候这个条件尚未达成

  2. 这个动作并不是发生在全局范围之内的,比如说某个路由进入之后或某种组件渲染之后才发生

以上任意一点都可以否决初始化执行这个方案,如果你考虑长远和周全一些的话就必须另寻出路。

好,我不废话了,先把最近用 Ember 完成的这个例子代码写出来,最后我再说一点对此的想法吧。

第一步,把 Header 抽象为组件

这个很简单,直接 ember generate component app-header 就好了,代码略过。

第二步,在组件渲染之后执行监听用户向下滚动的事件并为组件添加 class,这个 class 完成了阴影等效果。

const SCROLL_THRESHOLD = 50  // header' height is 50px

export default Ember.Component.extend({
  classNameBindings: ['sticky'],
  didInsertElement() {
    window.addEventListener('scroll', () => {
      if (window.scrollY >= SCROLL_THRESHOLD) {
        this.set('sticky', true)
      } else {
        this.set('sticky', false)
      }
    }
  }
})

第三步,当组件销毁后,注销监听回调

这就可以发生在 body 以外的操作能和组件的生命周期紧密联系在一起。这一点很重要,不管你用 Ember 还是 Angular/React,一定要注意组件的生命周期,特别是组件销毁时这些框架都会提供对应的 hook,要注意清理“垃圾”,移除绑定,释放内存等等,避免内存泄漏

const SCROLL_THRESHOLD = 50  // header' height is 50px

function _stickHeaderHandler() {
  if (window.scrollY >= SCROLL_THRESHOLD) {
    this.set('sticky', true)
  } else {
    this.set('sticky', false)
  }
}

export default Ember.Component.extend({
  classNameBindings: ['sticky'],
  didInsertElement() {
    window.addEventListener('scroll', _stickHeaderHandler.bind(this))  // remember to bind!!!
  },
  willDestroyElement() {
    window.removeEventListener('scroll', _stickHeaderHandler)
  }
})

DONE

我们还可以怎样改进它呢?问题有二:

  1. 组件不应该固化特殊的行为,如果这个组件是跨应用共享的(比如你发布成 Addon),那么其他应用可能是不需要置顶的使用者期望的是如下的可选项:

    {{app-header stickyOnScroll=50}}
  2. 监听滚动那一套行为如果不是组件特有的(这就派出了发步成 Addon 的条件)而是应用内共享的,则应该想办法抽象出去——监听滚动这个事情很典型

对于问题一,答案已经揭示在那里了。组件都是可以传递参数或外部作用域的,利用此机制进行判断来执行可选行为,这是对用户友好的举措。

对于问题二,在 Ember 里你至少有三个选项:

  1. 抽象成 Mixin。这个很直观,缺点是 Mixin 提供的属性不是 default value,它不能由你主动去覆盖,不够灵活;

  2. 定义成新的 Component。需要继承的其他组件可以 extend 它,解决 Mixin 不够灵活的问题,局限是只能给组件用——不过对于处理浏览器事件和操作 DOM/BOM 已够用了;

  3. 抽象成 Service。这个等价于 Angular 的 DI,可以由你自己定义丰富的接口来配置和调用,最灵活,适合封装需要的外部接口等等。

关于 Service,具体的代码先 hold,以后我会专门讲 Service 在 Ember 里的用法。今天这个例子不适合抽象 Service,原因就是上面的第二点。

当我在几天前对此还很困惑时,我一度认为像 Angular 的 Attribute Level Directives 才是处理此类问题的最佳方案,然而 WebComponent 并没有 Attribute Level Component 这种设计,这也是我困惑的最初原因。现在想一想,Attribute Level Directives 等于无视组件的生命周期(当然它有自己的生命周期,但是和要附着的目标组件无关,你得管理两份),它把可选行为附着于目标组件的过程等同于你创建一个新的特殊的 Service(特殊之处就在于它可以放在模版里),然后利用这个 Service 去写实现代码并且还可以再 DI 其他的 Services,以此来实现可选性和可复用性。这种设计乍看讨巧但也有很多缺点,比如说多个 directives 共存的时候要考虑优先级和行为覆盖的问题,比如说和未来的 WebComponents 不兼容改造起来很费事,等等。

现在我们看到,React 一开始做得就很不错(后起之秀借鉴了很多前辈们的经验教训),不过它只是一个渲染引擎,做大型应用还需要你在整体架构上下功夫;Ember 的架构很完整,以前的问题很多但现在都在一一完善,设计思路没有什么错误,拿来做 UI 交互复杂的 web 应用的确是很不错的选择。


n͛i͛g͛h͛t͛i͛r͛e͛
31.1k 声望3.1k 粉丝

正在更新 Elixir 语言的系列文章:[链接]