EmberJS [Tips on Ember 2] How components work when reaching out the DOM boundary of App?

nightire · 2015年09月16日 · 最后由 luikore 回复于 2015年09月19日 · 8686 次阅读

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

你看,如今的框架和库,无论规模大小功能多少,它们在本质上都朝着“组件化”的思路快速演进着。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 应用的确是很不错的选择。

组件化方案之中我最喜欢 Polymer,因为用起来非常原生:

<!-- Polyfill Web Components support for older browsers -->
<script src="components/webcomponentsjs/webcomponents-lite.min.js"></script>

<!-- Import element -->
<link rel="import" href="components/google-map/google-map.html">

<!-- Use element -->
<google-map latitude="37.790" longitude="-122.390"></google-map>

最近也在用 emberjs 2,为什么 ember 1.13 之后就移除 view 了,是为了提倡我们用 componment 管理视图吗?

#1 楼 @rei 如果用 polymer 和 rails 结合使用的话,是建议用现有的 polymer gem 还是其他方案?

#3 楼 @perish 我只看看,还没用上。可以先理解 Polymer 的原理和开发优势,再看 gem 添加的东西是否合理。

#4 楼 @rei 好的,谢谢

@perish 在 Rails 里面可以用这个:https://github.com/alchapone/polymer-rails,我看过用它的一个 demo。

Polymer 是最接近 WebComponents 的 polyfill 实现,只是浏览器的原生支持还早。另外 Polymer 也只是组件化的部分,并不能替代完整的框架(特指在 SPA 的范畴内,非 SPA 的应用会有后端框架负责路由等其他部分)。在纯前端的领域,现在要让 Polymer“跑”起来还需要几个环节:

  1. vulcanize:负责处理所有的 HTML Imports,inline scripts/styles,把它们归并为一个 HTML 文件
  2. crisper:负责把所有的 inline scripts 抽取出去变成独立的 script file

另外就是用了 ES2015 的话,再把第二步的结果交给 Babel.js 或其他编译器输出成 ES5。

前面提到的 polymer-rails,用 rails helper 来处理 HTML Imports,用一个很大的 webcomponents polyfill 来处理其他的部分(7000 多行)。没有深入过它的实现细节,不过也是少不了一些 pre processing(为了能工作在现在的浏览器环境下)。

年初的 Ember Conf 有人讲了一篇 Ember+Polymer 做 hybrid app 的东西,当时觉得挺好玩就实验了一下,的确,当 Shadow DOM,HTML Imports 等等都 ready 之后,组件的开发将会变成一种新的体验。

https://github.com/blangslet/treasure-hunt

这是 Ember+Polymer 做的 hybrid app,由 Cordova 封装,可以克隆到本地体验一下,演讲者放了一个演示视频出来:https://www.youtube.com/watch?v=lXWVmbs5O8A

How components work, not works

#7 楼 @nightire 谢谢,正在看 Shadow DOM, Custom Elements, HTML Imports 方面的知识

#8 楼 @prajnamas 谢谢,打错字了。

  1. 定义成新的 Component。需要继承的其他组件可以 extend 它,解决 Mixin 不够灵活的问题,局限是只能给组件用——不过对于处理浏览器事件和操作 DOM/BOM 已够用了;
  2. 抽象成 Service。这个等价于 Angular 的 DI,可以由你自己定义丰富的接口来配置和调用,最灵活,适合封装需要的外部接口等等

用 OO 的思路解释,第二点是利用继承,第三点是利用组合。我个人的感觉是组合优于继承,可以提供最大的灵活性。最近在用 Angular 和 Ionic,项目里少数几个能用继承和 mixin 的地方我也准备改成组合的方式。这样每个组合的 Object 可以有自己的依赖,会方便不少。

#11 楼 @darkbaby123 👏

Angular 好玩吗?你是主要用 Ionic 做 mobile app?

还不错,代码组织比 Ember 自由一些,Ionic 也还不错,比较坑的是 ion-view 的 cache 机制修改了 scope 的生命周期,顺带导致 controller 的 constructor 和 directive 的 link 函数都不是每次调用了。

其实我接触 Angular 比 Ember 还早半年,之前也是觉得 Angular 自带的 route 完全没法应付复杂场景才尝试 Ember 的,然后发现 Ember 的一些想法挺不错……

#1 楼 @rei

现在手机浏览的时代,用户对框架大小是非常敏感的,能裁掉一点就一点

polymer 缺点就是太大了,如果只用 webcomponents.js 就小很多 (我觉得有 document.registerElement() 这个 API 就可以了)

component 专用的样式比较容易隔离,选择器前面加 component 名就可以 component 的渲染可以选自己喜欢的模板或者 view renderer component 的事件,用 jQuery/Zepto 在容器元素上注册代理,模板重绘了也不影响,当然也有一些支持绑定事件的 view renderer

webcomponents.js 加任选一个轻量路由 (turbolinks, spine), 再加个 zepto 还是非常轻量。

另外一个不错的方案是 riot.js, 把 web component 和 react-like render 组合到一起了,而且还只有 12.5k, 比 polymer 轻量多了

需要 登录 后方可回复, 如果你还没有账号请 注册新账号