EmberJS [Thinking in Ember 2] 组件化的探索——路漫漫其修远兮,还得 Up & Down 啊~

nightire · 2015年11月28日 · 最后由 nightire 回复于 2015年12月13日 · 11618 次阅读
本帖已被管理员设置为精华贴

引子(未完,持续更新中——2015/11/30 00:00)

标题中的 Up & Down 对应了原句里的“上下而求索”,在这里实际上是在说 Data Down Actions Up,简称 DDAU。

当你开始践行组件化这件事情久了你就会有这样的感受:组件本身没什么难的,单独拎出来还不见得比写一个 jQuery Plugin 难,可是当组件开始和周围产生关系的时候复杂性就会逐渐展现出来了。这种关系往细了说既可以是组件与组件之间的关系,也可以是组件和其他层次的关系;而前者再往细了说也可以是先祖与后代组件之间的关系,同辈组件之间的关系,以及无关联(指的是 DOM 结构上的关联)组件之间的关系。

很少会有机会在编写一个组件时遇到上述所有的关系一起朝你汹涌而来的情形,然而一旦你遇到了,那么高层次的预先设计就会尤其重要。像 DDAU 这样的理念,在简单结构下并不难实现,也不会让你有太特别的感受;反之,若场景很复杂,它的好处会体现的很直观(尽管更多时候体现在眼睛看不到的地方,比如在渲染性能和数据的可维护性上),然而你想要始终坚持它也会变得困难起来。

最近我就对此深有感触,一开始就想写写这方面的事情了,但越做发现水越深,同时自身的理解和感悟也在不断增加着,到了后面反而感觉难以下笔了……不过还是要写的,不总结一下就没法真正把知识消化吸收掉。

接下来要写的东西其实可以不和 Ember 紧紧耦合在一起,只不过演示的代码都是用 Ember 写的,所以还是归类在 Ember 系列的文章里吧。此乃前序。

Immutable Data Structure

一个月以前我自己都想象不到会在写这篇文章的时候首先去讲 Immutable Data Structure(以下简称 IDS),因为在那时候我根本就是“只知其名,不见其形”的状态。当社区里大讲特讲 DDAU 的时候,IDS 也是其中被提及最多的概念之一。我能理解 DDAU 的“形状”,也能照猫画虎的去做,但老实讲我并不懂我在做什么以及为何要这样做。直到开始接触 Elixir 的时候(其实并非第一次接触了,一年前就试过水但没形成什么概念)才开始理解为啥要 DDAU。

然而现在我其实也不具备来“传道解惑”的能力,之所以先提提 IDS,是因为对此入门之后我就有意识的去重构之前刚写好的组件了,虽然到现在还没有重构完不过接下来的例子里我会有要解释为什么这么做的地方。总而言之,在当前的前端架构之下(不管你用 Ember 还是其他什么)理解和使用 IDS 是趋势,我现在的感受是它的确会让一些事情变得更简单可控,并且我相信它能让我感受的好处会越来越多。

我的理解是这样的,IDS 在纯函数式语言里是天然的特性,没有任何“人”去直接改写一个数据,如果你需要让数据变化,你做的是:

  1. 复制原始的数据(通常这一步不需要显式的去写代码,语言本身帮你做了);

  2. 计算出新的数据 -> 通过函数,函数就是数据的转换器;

    • 复杂的计算用多个单一行为的函数组合运算,不同的语言提供不同机制来完成这种组合;

以上的概念只是表象化的描述,即使我这样去做了却还是不知道其中的道理,而现在我稍微有些入门了。对于以上描述:

  1. 改写数据,意味着数据在内存中的位置不变(长度可能变化),通过直接操作内存地址来达到目的

    • 如果谁都可以改写数据,那么这样做是危险的。
      • 面向对象把数据封装在一个个对象内部,约定只有对象才能改变自身的数据,其他的对象需要通过消息去告诉它改变数据——这是我们原本惯常熟悉去做的事情,对吧?
    • 如果数据可以改写,那么这样做是没有效率的。
      • 即便用对象来封装数据,但是数据本身依然可以被改变,这就牵扯到许多问题啦,比如说可变地址长度,不连续地址的引用,垃圾回收的算法和效率,并发编程的安全性、可控性、一致性……等等等等。在这些方面我是菜鸟,就不扯远了,只是知道会有这些问题,大概能理解它们的缺陷以及人们为了解决它们需要花费的努力在哪里而已。
      • 另外,面向对象需要“对象”这样的机制来实现对数据的封装(我现在对封装的理解也更深了一些),那么由此就有了类型化的需求——你需要归类对象。由此能否反证非 OO 语言是不是就不需要类型化我并不清楚,但至少更加理解了类型化系统存在的必然性。现在在我的观念里,OO 里的类型不是原始数据类型(因为它的实例是用来封装原始数据类型的,并且能够改写原始数据类型),像 Struct 这样的东东算不算原始数据类型我不确认,但至少它要比 OO 的类型更“轻薄”,更“单纯”。
      • 对应到 JavaScript,尽管对象不是原始数据类型,但它依然要比类型系统里的 Class 要“轻薄”(不过 JavaScript 并没有 IDS 的支持,所以它的对象不“单纯”);它用原型来实现继承确有它的好处,自此我也更能理解大神们为何强调“不要勉强用构造器+原型去模仿类机制”了,这是语言的天性啊。
  2. 复制数据,然后把计算之后的数据写入新的内存地址形成一份拷贝,这样做和改写数据相比自然是有很大的不同了。细节我就不谈了,和上面对照一下也能想明白,但有一点让我“观念性颠覆”的是:用对象来封装数据然后让对象自己去改写数据是没有必要的了。

    • 尽管在 JavaScript 中想完全“模拟”这样的形态很困难(因为语言原生不支持),但:1)可能性还是存在的,只是要自己或第三方实现的底层支持;2)你可以控制对象机制的“存在感”来最大化减少对数据的直接改写。
    • 由此,我对函数式编程的本质也有了更深刻的理解,不是说函数式编程与面向对象是非要互斥的,而是它们各自生存的“世界”大不一样,天性相左;而像 JavaScript 这样揉合二者在一个世界里也是很有趣的事情,真的是你对它的理解会决定你对它的态度和使用方式,关键是把握一个度,掌握住这个平衡你就可以获得来自二者的好处,往往也会让你有多一种的选择。
  3. 而对于函数的使用和理解,暂时还没有太多升华之处。拿 Elixir 来说,它对函数的使用和我在 JavaScript 里使用函数的方式非常类似(除了语言无法支持的部分),唯一的一个是对 ES6 里尾递归优化在原理上的理解。Elixir 让我用 [ head | tail ] 重新认识了一遍递归(我的算法很差很差),在项目里也的确重写了一处用了递归的逻辑。不过我忽然想起我是来写组件的,函数就到此为止好了。

以上讲这么多,貌似和组件化没啥直接关系啊?嗯,我想说的是,当你去设计复杂组件的时候往往要考虑很多数据流转的问题,比如说谁传递谁接收,哪里要获得新数据,哪里要改变数据等等。IDS 会直接改变你的很多思路,理解和不理解 IDS 在对待很多问题的时候在观念上是有非常大的差别的。我在做手头上项目的时候很多次要给同事们解释在组件里我为什么要这样做或者那样做,有时候根本无法让对方理解背后的原因。那么在本文里涉及到可以这样做也可以那样做的时候,我可能就会直接讲:因为 IDS,所以……你懂的。

有人已经编写了向 ECMAScript 增加 Immutable Data Structure 的提案,不过目前还没有看到这份提案正式列入 TC39 的 Stage 0 提案列表里

场景

原本这篇文章里讲述的“模特”是这么个东东:

但是我现在得把它改一下,因为:

  1. 开始计划写本文的时候我没想到这个东西底下的业务逻辑是如此复杂,所以也比原计划耽搁了一月有余;如果照实际情况讲还得解释一些业务逻辑,这和 UI 编程没啥关系
  2. 前面也说了,最近本事看涨,这个组件在上线之后我就直接开始重构了,目前还在进行中;比起重构前的很多写法,我更想在本文里写更好的例子

所以呢,以下我还是以它为原型,但在此基础上简化一下层次只把一些通用的问题和思考描述清楚就足够了。

以下是这个组件在模板內的结构图:

{{#v-calendar}}
  {{#v-calendar-grid}}
    {{#v-calendar-timeline}}
       {{v-calendar-now}}
    {{/v-calendar-timeline}}

    {{#v-calendar-arrangements}}
      {{v-calendar-operators}}

      {{#v-calendar-events}}
        {{#v-calendar-event}}
          {{v-calendar-detail}}
        {{/v-calendar-event}}
      {{#v-calendar-events}}
    {{/v-calendar-arrangements}}
  {{/v-calendar-grid}}

  {{v-calendar-status}}
{{/v-calendar}}

简单描述:

  1. v-calendar:最外层组件,主要负责接收数据以及确定组件整体在 DOM 上的位置
  2. v-calendar-gridv-calendar-status:之所以这里一分为二主要是 v-calendar-status 是置底不动的,而 v-calendar-grid 是内部垂直滚动的(静态截图没体现滚动),纯粹是 UI 上交互的需要。
  3. v-calendar-timelinev-calendar-arrangements:同样这里一分为二是因为 v-calendar-timeline 是置左不动的,而 v-calendar-arrangements 是内部水平滚动的。
    • 重构的时候我也在想:其实我可以让 v-calendar-grid 同时负责水平和垂直滚动,这样可以去掉一层。当时为啥没这样做呢?有可能是里面有坑我现在忘了,也有可能是我犯傻。那么在写这篇文章同时也在重构的时候我会尝试去掉 v-calendar-arrangements,看看行不行得通(九成是可行的)。
  4. v-calendar-operators:顶部那一排人名,它要跟随水平滚动,同时在垂直滚动时要置顶。
  5. v-calendar-eventsv-calendar-event:是若干个垂直列,和 v-calendar-operators 数量相等,每一列包含属于对应 operatorevents

其实这个组件也还没算完工,还有一些 UI 交互方面的工作要做,比如说时间段冲突的 events 要自动并排(这比我想象的要难,以前没做过不知道最优的做法是怎样的思路,,有做过的或是知道开源实现的请不吝赐教,我需要一些先期调研。)等等,业务逻辑那边没确定暂时没有提上日程。

OK,大的需求就这样了。不管看官用的是不是 Ember,模板语法如何不同,思维方式有何差异……反正组件化都是这么搞了,也会遵循一些通用设计原则比如单一职责之类的。你可以分得更细/粗,然而 UI 的复杂性会让你无法避免父子嵌套/同辈并排等组件关系。咱们就先将就着用我这个方案吧,有什么建议请提出我正好采纳重构去。

另外,以上只是一个结构示意图,并不是说在真实的代码里就是这样把组件写进模板的哦。组件自身可以是行内或块级形式的,你甚至可以把内层的所有结构都隐藏在 v-calendar 里面,最终调用的时候只是写一个 {{v-calendar}} 完事,事实上这将是我们开始探讨的第一个话题——

Inline? or Block?

因为组件终究要有使用它的地方,本文就假定它用在 events/template.hbs 里,对应的路由和控制器分别是 events/route.jsevents/controller.js,先予以说明,在后面的代码顶部都会声明是哪个文件,以免看糊涂了。

组件可以是行内的,用来隐藏模版的细节。

{{! 在你使用组件的地方 }}
{{a-inline-component with-attribute=and-value}}

当然也可以是块级的,用来让用户编写包裹于内的内容。

{{! 在你使用组件的地方 }}
{{#a-block-component with-attribute=and-value as |exposed-property|}}

  {{user-define-component can-accept=exposed-property}}

  <p>or, plain html code</p>

{{/a-block-component}}

Ember 也允许你同时兼备二者,使得组件既可以行内调用也可以块级调用。

{{! 在你定义组件模版的地方 }}
<h1>这是组件标题,组件的使用者无法接触到这里</h1>

{{#if hasBlock}}
  <p>{{yield content}}</p>
{{else}}
  <p>{{content}}</p>
{{/if}}
{{! 在你使用组件的地方 }}
{{use-as-inline-component content="用户定义的输出内容"}}

{{! 或者 }}

{{#use-as-block-component content="作为参数输出的内容"
  as |exposed-property|}}
  {{exposed-property}} 用户定义的输出内容
{{/use-as-block-component}}

{{v-calender}} 这样的组件,它自身及其内部众多组成部分,哪些该用行内,哪些该用行外,哪些该二者并用呢?

在一开始我以为这是一个很简单的问题,如果你的组件只有一到两层结构也会很容易做出判断,可 {{v-calendar}} 的复杂度着实让我纠结了好久,最终我也是做了所有可能的尝试才确定了一个方案。到底在纠结哪些东西呢?下面我分别加以说明。

在简单与灵活之间的平衡和取舍

简单、灵活,是所有组件开发者追求的共同目标,简单意味着隐藏实现的细节让用户专注于使用,灵活意味着提供丰富的接口和用户自定义能力。可惜有些时候此二者难以两全。

我对 {{v-calendar}} 做出的第一个决定就是让它非常容易调用,最好就是这样:

{{! events/template.hbs }}
{{v-calendar property=value}}

现在我很惊讶于我的第一直觉,然而在开始阶段我却因此而饱受折磨……

第一直觉使用行内形式(并且不提供块级形式)的原因是:这个组件光是看看完整的组件结构都会让人头晕,更不要说展开成 DOM 结构了。因为它结构非常复杂,HTML 的安排和 CSS 的编写都是经过精心编排的,留给用户自定义的余地实在是太小了,块级结构没什么实用价值。

第二个原因是,{{v-calendar}} 不是那种通用化的组件,更没有要开源出去供更多项目使用的计划(业务逻辑耦合性高,在自家应用里也只有一处需求,就算给别人用也得二次改造不是),所以要提供多大的灵活性不是我要考虑的目标。

既然灵活性的需求很低,那就追求简单直白吧,这就是第一直觉的驱动原力。

OK,写着写着问题来了。第一个问题是让数据向下传递太辛苦了!

{{v-calendar}} 需要的数据很多,由于 API 设计上的一些问题,实际中的数据还无法拿来就用,在 events/route.js 里我做了大量的数据重组的工作,然后把它们一一传递给最外层的 {{v-calendar}},主要的数据罗列如下:

  1. markers:时间标记,用于{{v-calendar-timeline}}。其实在我看来有 start ending interval 就可以在客户端算出对应的时间刻度,不过 API 直接给返回了一个 ['10:00', '10:30', '11:00', ...] 数组,所以就沿用了。
  2. operators:操作者,用于 {{v-calendar-operators}}。这是我虚构的实体名称,原本的业务逻辑里其实分好多种,什么 consultants artificers technicians,简直要命!仔细一看其实全是 employees,无非就是性质不同罢了,但由于 employees 有用,所以我给统一抽象成了 operators
  3. events:预约事件,用于 {{v-calendar-events}} {{v-calendar-event}}{{v-calendar-detail}}。这是最主要的数据,但也是最坑爹的!不过这里关于 API 就不去吐槽了,反正在我坚持之下后端小伙伴们去重构了,以后会专门写关于 Data Store 的文章,到时再议。

关于 events,理想情况下在最外层原本不应该有单独的 events,它们应该是各自 operator 的关系数据,通过 operator.events 就可以直接获取。但由于 API 并没能做到这一点,所以事实上前端这边是做了处理的。后面会有一个谈及这个事情的段落。

这些就是组件需要的主体数据,其实并不太多对吧?仔细观察一下你会发现,使用这些数据的都是组件结构中最里层的几个,第三或第四层。我后来才顿悟,复杂的组件之所以复杂,主要就是因为 UI 结构造成的。UI 和交互上的需求使得组件设计必须分层,逐层处理这些需求的依赖关系,但是往往只有最里层才真正需要使用到业务逻辑需求的数据。换个角度说,如果你不需要很多层级就直接处理业务了,那说明你的 UI/交互并不复杂;而如果你觉得 UI/交互很复杂,但还是没怎么分层的话,老兄你的代码会不会全堆在一起了呢?

我看了一下 {{v-calendar}} 全部的代码,最长的组件也就 167 行,在代码的可读性和维护性方面做得还是可以的(要不然自己都不想重构啊)。

我可以这样调用模版:

{{!-- events/template.hbs --}}

{{v-calendar
  markers=model.markers
  operators=model.operators
  events=model.events
  others=others}}

此时,只有 {{v-calendar}} 能够接收到 context(注:即组件所处的上下文环境,在这里是 events/controller;在未来则应是 Routable Component)里面的数据,也就是 model

如果每一层的组件都是这样把内部的细节隐藏起来,那么所有的数据都需要这样一层接一层的传下去,可以想象会有多么麻烦的吧?麻烦也就罢了,数据在到达真正的使用者那里之前需要一层层的“过滤”,这样留下很多重复之处,也不利于可读性和可维护性,到处散发着 DRY 的味道。

当然,我们也可以这样调用模版:

{{!-- events/template.hbs --}}

{{#v-calendar}}
  {{#v-calendar-grid}}
    {{#v-calendar-timeline markers=model.markers}}
       {{v-calendar-now}}
    {{/v-calendar-timeline}}

    {{#v-calendar-arrangements
      operators=model.operators
      events=model.events
      as |operators|}}

      {{v-calendar-operators operators=operators}}

      {{!-- 下面两个 each 都可以隐藏在直接父级组件内部,这里列出是为了明示 --}}
      {{#each operators key="id" as |operator|}}
        {{#v-calendar-events events=operator.events as |events|}}
          {{#each events key="id" as |event|}}
            {{v-calendar-event event=event}}
          {{/each}}
        {{#v-calendar-events}}
      {{/each}}
    {{/v-calendar-arrangements}}
  {{/v-calendar-grid}}

  {{v-calendar-status data=data}}
{{/v-calendar}}

这样一来,我们把组件的内部细节全部暴露在环境上下文里(这里指的是 events/controller),于是每一个组件都可以直接获得来自上下文的数据了。我们省去了一些向下传递数据的中间环节,但是增加了调用时的复杂性,这在我们的项目中是可以接受的,如我之前所说的,只用这一次而已。但是对于开源组件的开发者来说,这肯定是不能忍受的做法,也是我要重构的一个原因。

不过在

DD 不是形式主义,并非只要能“向下”就完事了

{{v-calendar-arrangements}} 同时接收了 model.operatorsmodel.events 这两个数据,而实际上它也用不着这俩数据。之所以这么做,是因为 API 是分开返回的:

{
  "operators": [...],
  "events": [...]
  ...
}

而它们本应该是关系化的数据,这样在后面遍历 operators 的时候可以直接把所属的 events 分组了。可由于现在不是这样,所以我在这里接收了二者,然后在里面处理成了关系化的数据,这个处理类似这样:

// v-calendar-arrangements/component.js

newOperators: computed('operators', {
  get() {
    return this.get('operators').map(operator => {
      operator.get('events').pushObjects(this.get('events')
        .filterBy('employeeId', +operator.get('id')));
      return operator;
    });
  }
}),

然后在它的模版內将重组后的数据暴露出来:

{{!-- v-calendar-arrangements/template.hbs --}}

{{yield newOperators}}

当组件內的属性被 yield 之后,调用时就可以用 as 按顺序暴露出来,也就是我们在前面看到的:

{{#v-calendar-arrangements
  operators=operators
  events=events
  as |operators|}}

{{/v-calendar-arrangements}}

而我做错的地方就在于,数据的重组不应该发生在组件的内部,我也不知道当时是怎么想的。尽管在这里组件并没有去改变数据而是 operators 自己作出的改变,可地方错了。因为选错了地方,所以才做出了让 {{v-calendar-arrangements}} 去接收 model.operatorsmodel.events 的决策,并且加重了这个组件的负担。

DDAU 没有挑明的一点是:一旦数据向下传递了,数据就不应该被下面的任意一层去改变(这是为了性能),数据的拥有者(即封装了该数据的对象)也不应该在下面的任意一层里去做改变数据的动作(这是为了组件的单一职责)。

尽管 JavaScript 没有原生的 IDS,该去改数据的时候就可以改,但我们开头也说过你就当它是 IDS 好了,只是把改变数据这件事情留到最上层去做。这里面最直接的原因是提高 UI 的性能,如果你不理解这怎么就提高的,去看看 Glimmer 引擎的原理(Youtube 上搜),我就少说点。

那么哪里做这件事情是对的?最理想的状态是 API 的返回棒棒的,于是 Ember Data 已经帮你把数据准备好了,传下来就行。不过总还是有需要你改变的时候,这时可以在路由里做这些事情(推荐的),或者是控制器里(不推荐)。记住一点:MV* 放在前端其实挺牵强的,过去几年大家都没太想明白所以控制器像标配一样到处都是,可现在迹象越来越明显,控制器将死,别再习惯于把代码堆积在那里。

总结一点:向下不是目的,不在下层改写数据才是真正的内涵。

然后我们回到重构模版调用的话题,最新的想法如下所述:

  1. 最终的调用还是要保持简单,哪怕只用一次

    {{!-- events/template.hbs --}}
    
    {{v-calendar
      data-status=model.status
      data-markers=model.markers
      data-operators=model.operators}}
    
  2. 内部结构不必层层隐藏,可以在第一层的模版内适度展开

    {{!-- v-calendar/template.hbs --}}
    
    {{#v-calendar-grid}}
      {{v-calendar-timeline data.markers}}
      {{#v-calendar-arrangements data.operators as |operators|}}
        {{#v-calendar-holders operators as |operator|}}
          <span>{{operator.name}}</span>
        {{/v-calendar-holders}}
    
        {{#each operators key="id" as |operator|}}
          {{#v-calendar-events operator as |event|}}
            {{v-calendar-event event}}
          {{/v-calendar-events}}
        {{/each}}
      {{/v-valendar-arrangements}}
    {{/v-calendar-grid}}
    
    {{v-calendar-status data.status}}
    

这样比之前好看太多了有没有?不过 data-*data.* 什么鬼?为啥要这么写?实际上这个是我新想出来的“花招”,源自于一个新的业务需求,下面单独来说一下这个玩法:

接收不定数量属性的组件(非最佳实践,而且是少见需求,可以跳过无视)

新的需求是这样的:

  • event 可以不仅仅属于 operator,它还可以属于 room 和/或 equipment,也就是说预约事件可以占用操作人,也可以占用房间、设备
  • 应用默认展示在这里的是 operator 的维度,但是可以在 UI 上切换 roomequipment,具体能切换几个取决于用户的付费级别。也就是说 roomequipment 是付费开启的维度
  • 基于此,传给 v-calendar 的数据是可变的,这一层维度会有一到三种数据不等

我们可以总是把三类数据都传给 v-calendar,然后根据当前用户级别决定他/她能切换哪些。但是最理想的状况是 API 服务那里已经判断和筛选过了,我们从路由那里得到的 model 就是不定数量的对象,所以组件自身得拥有接收不定数量属性的能力,于是调用时可能会是这样的:

{{!-- events/template.hbs --}}

{{#if freeUser}}
  {{v-calendar
    data-status=model.status
    data-markers=model.markers
    data-operators=model.operators}}
{{/if}}

{{#if paidUser}}
  {{v-calendar
    data-status=model.status
    data-markers=model.markers
    data-rooms=model.rooms
    data-operators=model.operators
    data-equipments=model.equipments}}
{{/if}}

那么组件要如何处理不定数量属性?上代码:

// example-component.js
import Ember from 'ember'

const { computed } = Ember

export default Ember.Component.extend({
  data: computed({
    get() {
      return Object.keys(this.attrs)
        .filter(prop => prop.includes('data-'))
        .reduce((prev, curr) => {
          prev[curr.substr(5)] = this.attrs[curr]
          return prev
        }, {})
    }
  })
})

戏法戳穿了毫不稀奇,就是约定 data- 开头的属性都是可变数量属性的一员,又因为所有的属性都会在组件的 attrs 对象里,因此在里面把所有 data- 开头的属性取出来构造成新的对象就是了。

很显然目前的写法是有些局限性的,比如说 data- 的前缀太死板了,不过这可以通过把它也变成可变属性的方式来解决。然而真正的问题是上面的代码只能在组件初次渲染时生效,当任意一个 data- 属性改变之后,组件是不会知道的,所以也不会重新渲染,原因是 computed 没有可以监视的属性,你不能事先把所有 data- 的属性名写上去,因为你无法在组件定义的时候就知道有多少个。你也不能想当然的去监视 attrs 属性,因为它本身不是 CP(即:Computed Property,计算属性)。

在我们这个特定的需求里,我可以很简单的通过监视 ['data-rooms.[]', 'data-operators.[]', 'data-equipments.[]'] 来解决,尽管它们是可变的却也就这么多了,即使以后增加也无法改一处即可。可对于那些目标是通用型的组件来说可咋办呢?目前为止,我只想出了一个很傻的办法,希望有人能想个天才的主意出来。

接收数据却不用写属性名的组件

之后我们使用 {{yield data}} 暴露给内层组件,紧接着就看到了这样的接收方式:

{{v-calendar-timeline data.markers}}
{{v-calendar-arrangements data.operators}}

对的,接收数据的时候是可以不用写属性名称的,如果你尚不知道的话,这个特性叫做按位参数(即:Positional Params)。官方文档已经更新了这个特性,我就略过不谈了。不过它给了我一个灵感,或许按位参数能更好的解决不定数量属性的遗留问题?先不整了,这只是一个无关大局的事情。

关于按位参数,刚学会它的时候我恨不得把所有属性都变成这种形式的,多方便呀!但是写多以后发现你很难记住它们的顺序和含义。那现在会这样做:只把最重要的属性作为按位参数来写,甚至完全不用按位参数也没有什么关系。对于组件的使用者来说最重要的就是接口清楚,在一目了然不产生误解或歧义的前提下去用按位参数是好的,否则不如不用。

可遍历的 {{yield}}

前面说过行内模式的组件和块级模式的组件是可以混用的,这个重构的新想法就是结合了二者的优点之后做出的。观察在 {{v-calendar}} 模版内部的一部分:

{{#v-calendar-arrangements data.operators as |operators|}}
  {{#v-calendar-holders operators as |operator|}}
    <span>{{operator.name}}</span>
  {{/v-calendar-holders}}

  {{#each operators key="id" as |operator|}}
    {{#v-calendar-events operator as |event|}}
      {{v-calendar-event event}}
    {{/v-calendar-events}}
  {{/each}}
{{/v-valendar-arrangements}}

{{v-calendar-arrangements}} 的内部其实是有两次对 operators 数组的遍历的,这是因为 {{v-calendar-holders}}{{v-calendar-events}} 在 DOM 结构上是同辈关系。这里我其实会有很多种选择,比如说可以把 {{v-calendar-arrrangements}} 的内部结构完全隐藏起来,也就是使用行内组件,如此一来,两次遍历都在更内层,上面的代码会更干净一些。之所以没有这么做有一部分原因是为了更舒服的绑定动作(即 Actions),这个我们会在后面讲到。另外一部分原因是我希望在观察整体的结构时可以更直观一些——我们已经在使用 {{v-calendar}} 的时候做到足够简洁了,而对于组件的维护者而言继续深入下去也没必要一层一层的去打开不同的文件,这样会很累。

我知道 {{v-calendar-holders}} 的实现比较简单,以后也不会有太多的修改,所以把遍历隐藏起来省点地方;我也知道 {{v-calendar-events}} 以下的部分则是会频繁修改的地方,所以及早展开也无妨,更何况里面的 {{v-calendar-event}} 也是需要去直接绑定很多动作的,所以就暴露在这里了。那么块级组件形态下的遍历是如何被隐藏在组件内部的呢?看一下 {{v-calendar-holders}} 的模版:

{{!-- v-calendar-holder/template.hbs --}}

{{#if hasBlock}}
  {{#each operators key="id" as |operator|}}
    <div class="v-calendar-holder">
      {{yield operator}}
    </div>
  {{/each}}
{{/if}}

就这样很简单的。

小结

在设计复杂组件的结构时,我感觉到 DDAU 里的 Data Down 原则其实会带来很大的影响。早期的设计几乎完全就是参照 UI 的需要,或者更确切地说是以 HTML+CSS 结构为中心来安排组件的结构,其实这也没有错,只是同样的 HTML+CSS 结构使用行内或块级组件都可以做到的时候,就少了一个指标来引导你去做出合适的设计决策。当你实现过一遍,体会过里面数据流转的各种情况之后,对组件结构设计的感受就升华了,忽然之间就对这件事情特别有底气了。然而影响组件设计的重要指标还有一个,那就是 DDAU 里的 Actions Up。接下来我们就把流程反转,来探讨一下复杂组件设计里的动作。

Bubble Up? or Closure?

虽然此标题也是俩问号,但最终的结论却不像 Inline? or Block? 那样任君选择。我先把结论摆前头:

即便 Closure Actions 还未能发挥出它 100% 的能力(在本文写作时的当前版本,即 v2.2.0 下),但是也应当坚持使用 Closure Actions 而不是 Bubble Up Actions

有时候可以有选择的使用 Event Bus 模式,不过这和 Bubble Up Actions 还是有区别的(尽管原理上相通)。后面会讲到这一点,但是不影响这个结论。

目前这个时期的 Ember 在 Actions 这个话题上有些尴尬,因为有两种 Actions 系统并存着,而且它们用的 Template Helper 是一样的,只是写法不同。这个设计在当下会让开发者们感到非常混乱,如果不了解来龙去脉很容易就把自己搞得晕头转向。我先帮大家把这个事情理理头绪:

  1. Bubble Up Actions 是旧的 Actions 系统,原理基于浏览器的事件委托,利用事件冒泡机制自底向上发送动作;
  2. Closure Actions 是新的 Actions 系统,原理基于函数闭包,利用函数是“一等公民”的特性将函数作为值在应用中传递,使得基于 Actions 的通讯变得更灵活、更纯粹(所谓纯粹,意指 Action 通讯是纯函数式的,不依赖事件委托——当然还是靠事件触发的,注意两者的区别);
  3. Bubble Up Actions 是传统的浏览器事件模型的实现,在过去是所有 JavaScript 应用程序离不开的东西。以前我们以 DOM 为中心进行 UI 编程,事件系统和我们开发的关系非常紧密,而基于选择符的 DOM 操作也和事件系统很合拍,所以大家都觉得顺理成章;
  4. 进入组件化时代之后,因为 DOM 自身存在的缺陷难以克服,再加上以 React 为代表的众多视图引擎的推动,我们不再以 DOM 为中心进行 UI 编程了,最显著的特征就是基于选择符的 DOM 操作不再必要了;
  5. 然而,组件最终还是渲染成为 DOM 对象的,它们终究还是树状结构的文档对象模型,当事件向上传递的时候,难免会涉及到指定事件委托节点这样的事情;当我们不再用选择符去操作 DOM 了,怎么去指定事件委托节点呢?
  6. 多数的现代前端框架都会将应用程序的根节点(比如 document.body 或是就近的某个 DOM)做为整个应用程序的事件委托节点,如果你注册一个 Action 在根节点,那么调用这个 Action 的请求(事件)无论距离此节点多远都要逐级向上冒泡才能到达这里;
  7. 如果应用程序是组件化的,那么可以预见随着应用程序变得复杂,组件的层次也会越来越深,对吧?然而很多 Actions 都是要去修改数据/发起请求的,按照 DDAU 的原则,数据不应该在下游被改变,那么相应的 Actions 也应该保持位于上游;
  8. OK,矛盾就此产生——我想不需要再解释了吧?

事件机制在现代 Web 应用的开发里还有别的问题(当然了,也许是因为现在的开发框架们都还不够成熟导致的),我在旁观一些社区动态的时候看到很多相关的讨论,有些东西我也是懵懵懂懂……不过有一点可以肯定,这不是 Ember 一家面临的问题,家家都有遇到,家家也都有自己的一套。

比如说事件对象吧(Event Object),每个框架都要想办法处理这个事情(跨浏览器啊)。Ember 从早期开始就在底层依赖 jQuery 搞定这个,所以 Bubble Up Actions 传递的事件对象其实不是原生的,而是 jQuery.Event。现在随着大家都逐渐学会了使用 state,对这个的依赖也越来越少了,那么 Ember 也在逐渐的做着让 jQuery 变成可选项的事情;React 呢?从开始就彻底一点了,自己做了一个 Event 系统叫 SyntheticEvent,如果你要使用原生事件对象,就得单独用 nativeEvent 属性去接收(兼容性问题自理),而它的 SynthticEvent 对象是很轻很轻的,就十来个属性方法(本来人家就希望你少依赖 DOM 里的东西)。再说一个 Vue.js,它搞了一个和 Angular 一样的 Event 系统:dispatch all the way up to $root,broadcast all the way down to any listener 这样子,和前面说的别无二致。

其实自定义的事件对象 Ember 也有,就是 Ember.Application.customEvents,以前用在 Ember.View 里的,后来搞起了 Glimmer,View 就废弃了。

其实 Event Bus 这样的机制 Ember 也可以搞,最简单的方式就是 Ember.Evented mixin,也可以基于此写一个全局的事件总线,这样就可以跨越重重组件结构,想怎么整就怎么整。如果你对这样的 UI 编程方式很有爱的话,后面我会单独写一节实现一个 Global Event Bus Service。

但是这些在 Ember 里都不是主要的通讯方式,我们先来说说 Bubble Up Actions 吧。

捉摸不定的泡泡

鉴于目前是一个 helper 同时用于两套 Actions 系统,我认为非常有必要从形势上分清楚两者的差异。下面我将以尽可能简单的方式列举 Bubble Up Actions 的用法和特性:

提醒:前面已经说过 Bubble Up Actions 是不推荐使用的 Actions 系统,然而由于向后兼容和历史遗留的一些原因,这个 Actions 系统在 Ember 3 之前都不会消失,而且大量的教程,包括现存的一些官方文档都还有它的痕迹残留着。我在这里之所以要花时间和篇幅讲这个系统,一是为了总结,二十为了在你混淆的时候有一个能明辨真相的地方。如果你真的完全不想搭理这个旧的 Actions 系统,我很赞成的,往后跳吧!

要理解 Bubble Up Actions 是如何被接收(或者说绑定)以及如何被执行的,首先得特别清楚两个概念,一个是执行环境(context),另外一个是冒泡链(bubble chain)。

  • 执行环境:action 在哪个模版被接收的,这个模版所对应的作用域对象就是 action 的执行环境。一般模版对应的作用域对象就是控制器,而组件模版的作用域对象自然就是这个组件了。等到 Routable Components 一统江山以后,这事就简单了,没有控制器什么事儿了。
  • 冒泡链很简单,就是执行环境一层层向上追溯,直到 ApplicationRoute 为止。
    • 这里面有路由什么事儿呢?比方说一个 action 是在 events/edit/template.hbs 被绑定的,那么当它被执行的时候,它的寻找顺序是: EventsEditController -> EventsEditRoute -> EventsRoute -> ApplicationRoute 在此四层之中,第一个被发现的 events.action 会被执行,如果这个函数返回 true,那么冒泡继续,一直到找不到下一个 events.action 或者是一直走到了 ApplicationRoute 为止。

其实 Bubble Up Actions 就这么简单,以下是几个展示其用法和特性的例子:

首先我们假设有这样的 ApplicationControllerApplicationRoute(我就不搞太深的 Bubble Chain 了):

// application/controller.js

export default Ember.Controller.extend({
  actions: {
    bubbleAction(event) {
      console.log(`#bubbleAction in controller...`, event);
      return true;
    }
  }
});
// application/route.js

export default Route.extend({
  actions: {
    bubbleAction(event) {
      console.log(`#bubbleAction in route...`, event);
      return true;
    }
  }
});

我在两个 bubbleAction 里都打印了 event 对象,然而在后面的例子里,不是每种情况都有办法获得 event 对象。我会把打印的结果写在注释里,没有的话就是 undefined,有就是 jQuery.Event 或者 Event,这样你明了了。

1. Default Bubble Up Action

{{! application/handlebars.hbs }}

<button {{action "bubbleAction"}}>Bubble Action</button>
// #bubbleAction in controller... undefined
// #bubbleAction in route... undefined

打印了两次,这就是冒泡的效果。

ApplicationController.actions.bubbleAction 方法里:

  • 如果返回除了 true 以外的任何值,都不会冒泡
  • 如果有 this.send('anotherAction'[, args]),那么 ApplicationController.actions.anotherAction 会被调用
    • 同时,如果 ApplicationController.actions.anotherAction 里面也有 return true,那么此方法也会冒泡

2. actions 命名空间

为什么所有的 Actions 都要放在 actions 对象里?其实就是为了提供一个命名空间,一是方便 Ember 查找方法名,二是便于开发者给方法命名。

不过默认的命名空间其实是可以改变的,假设我在 ApplicationController 里加了这样的代码:

// application/controller.js

export default Ember.Controller.extend({
  actions: {
    bubbleAction(event) {
      console.log(`#bubbleAction in controller...`, event);
      return true;
    }
  },

  customActions: {
    bubbleAction(event) {
      console.log(`customized #bubbleAction in controller...`, event);
    }
  }
});

然后测试以下两种写法:

{{! application/handlebars.hbs }}

<button {{action "bubbleAction" target="actions"}}>Bubble Action</button>
<button {{action "bubbleAction" target="customActions"}}>Custom Action</button>
// #bubbleAction in controller... undefined
// customized #bubbleAction in controller... undefined

target 这个属性的默认值是 Ember.Router 的实例,它是一个单例对象,它有能力遍寻整个应用去找到正确的 action,当然是在遵循默认规则的前提下。如果你不关心规则,你可以手动指明它。但是你要小心!改变 target 会改变 Bubble Up 的规则,这就是为什么上面的例子里都没有冒泡的缘故。那么 target 能指明哪些东西呢?只要是当前执行环境能访问到的东西都可以!

这其实很强大很强大!比方说你把一些特定的 actions 封装到了一个 Service 里面,你可以把这个 Service 注入到 Controller 里然后做为 target 来直接调用:

// application/controller.js

export default Ember.Controller.extend({
  calendarActions: Ember.inject.service('calendar-actions');
});
{{! application/handlebars.hbs }}

<button {{action "addEvent" target="calendarActions"}}>Add Event</button>

再比如说你用 Ember Data 的,你的 Model 有实现自身相关的方法,那你也可以把 target 指向 Model 实例,嗯……你可以试试。

3. Bubble Up Actions on Components

坐等!请多讲一讲在 nested components 的情况下 bubble up actionbest practise,比如怎样避免一个 action 不得不需要被逐层捕捉然后向上抛出的 duplication。

#1 楼 @ugoa 放心,这是重点。不过事先声明,我还没有找到特别舒服的最佳实践,有些东西或许还要等待一些 RFC 里的实现。

#2 楼 @nightire 我感觉这似乎是 Ember component 设计的一个不可逾越的限制,看来一些变通(workaround)是少不了的。我目前的方案就是从 action 入手判断是否需要 nested component,不然很容易产生面条式代码,也会使 Component 失掉 单一职责原则的设计初衷。

#3 楼 @ugoa 不光是 Ember,但凡嵌套了的,哪家的解决方案都有各自的痛点。近段时间我来来回回看了好多种框架/库的处理都是如此,教你一招:找所有热门的框架/库的 repo,然后关键字 action/event 去搜 issues,哈哈,你就会发现世界好复杂好可怕啊……我想只要是 UI 编程,交互总是最大的难题吧。

赞一个。。。看来楼主已经被安利了 Elixir……

……有没有试过 Elm?

阁下在文章截图中是什么软件的界面?

#6 楼 @zhangsm 截图?你是问那个日历一样的东西吗?那是我自己做的啊,这个应用还没有正式上线。

#7 楼 @nightire 嗯,那个日历一样的东西叫什么名字?界面好清爽,看着舒服。

#8 楼 @zhangsm 那个日历一样的东西目前没有名字……它属于我正在开发的应用的一部分,从头开始一行行写出的,包括样式等等。因为没有开源的计划,所以也没有特别起名字,在应用里这个组件的名字就是 mw-calendar。这篇文章就是讲 mw-calendar 怎么做出来的,从组件的角度。

关于 v-calendar 需要接受不定量属性的问题,不考虑继承么?比如 v-calendar-for-free-user, v-calendar-for-paid-user extend v-calendar-for-free-user,这样对于每个特例化的组件它的参数都是固定的。

#10 楼 @zhenhuaa 是的,继承是可以选择的方案,可终究还是会有你不好做预先优化的场景,v-calendar 只是一个例子,仅就此例而言,领域逻辑还是容易预先分析和设计的。

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