xufei/blog

浴火重生的Angular

xufei opened this issue · 27 comments

浴火重生的Angular

Angular团队近期公布了他们对2.0版本的一些考虑,很详尽,很诚恳,我读了好几遍,觉得有必要写点东西。

对于一个运行在浏览器中的JavaScript框架而言,最喜欢什么,最害怕什么?是标准的变动。那么,放眼最新的这些标准,有哪些因素会对框架产生影响呢?

  • module
  • Web Components
  • observe
  • promise

这几点,我是按照影响程度从大到小排列的。下面逐条来说:

module

早期的JavaScript在模块定义方面基本没有约束,但作为框架来说,不按照某种约定的方式去写代码,就会导致一盘散沙,所以各家都自己搞一套,大部分还是各不兼容的,这是历史原因造成的,不能怪罪这些框架。最近几年,为了解决这些问题,人们又发明了AMD,CMD,以及各种配套库和工程库。好不容易有些框架向它们靠拢了,可是module又来了。

这真是没完。我知道很多人是对module有意见的,我自己也觉得有些别扭,但我坚信一个道理:有一个可用但是稍微别扭的标准,比没有标准要好得多。所以,不管怎样,既然他来了,就得想办法往上靠。不靠拢标准的后果是什么?非常严重,因为现在的Web是加速发展的,浏览器只要过了一个升级瓶颈,后面的发展快得出奇。不要看现在还有这么多老旧浏览器,很可能你睡一觉起来突然发现已经基本没人用了,到那时候,不紧跟标准的框架就很惨了,瞬间就边缘化了。

我们来看看Angular原先的module设计,如果是在五年前,它刚起步的时候看,可能觉得还可以,现在再看,问题就比较多了。Angular现有版本的module,其实跟我们在ES中看到的module不是一个概念,更像C#里面的namespace,它的各种controller,service,factory才是真的模块。

我认为现有版本的这块,有几个不好的地方:

  • 现有的module实际上毫无意义,根本不能起约束作用。
  • 从API的角度强制区分模块职责是没有必要的,比如说,service和factory的区别在哪里?仅仅在于返回与封装的方式不同,其实是可以通用的,所以只需一种工厂方法就可以了,用户愿意返回什么就返回什么,他自己通过命名来区分这个模块的职责。
  • 没考虑模块的动态加载。这是Angular目前版本最大的设计问题,对于大型应用来说,很致命,所以目前大家都是通过各种黑魔法来解决这个问题。

所以,Angular 2.0中,把这一块彻底改变了,使用ES6的module来定义模块,也考虑了动态加载的需求。变动很大,很多人有意见,但我是支持他们的。这件事不得不做,即使现在不做,将来也还是要做。毛主席教导我们:革命不彻底,劳动人民就要吃两茬苦,受两茬罪。在现在这个时代,如果还继续用非标准的模块API,基本等于找死,所以它需要改变。

Web Components

为什么Web Components也能带来这么大的影响呢,因为它同样会造成断代升级,也就是说,你非完全跟着它的路不可,没有选择。

Web Components标准本身同样是个见仁见智的话题,在本文中我不评价,它作为标准,既然来了,大家当然要往上靠。一个致力于大规模Web前端开发的现代框架,不考虑Web Components是完全不可想象的。那么,怎么去靠拢它呢?

Web Components提供了一种封装组件的方式,对外体现为自定义标签,对内体现为Shadow DOM,可以定义自己的属性、事件等,这样问题就来了。

我们看当前版本的Angular,能看到ng-click之类的扩展元素属性,那么,他为什么要写成ng-click?是因为这是对原生click的一层封装和转换,同理,如果我的原始事件不是click,是另外一个名字,你当然也得跟着加一个,不然针对这种东西的操作就玩不下去。所以说,其实它是给每个有价值的原生事件都写了扩展。

这就有问题了,你能这么做的原因是,你预先知道有这么一些元素,这么一些事件,也知道这些元素上的这些事件是什么行为,如果全是自定义的,他不告诉你,你急死也不知道,怎么办?所以这一块必须重新设计。

同理,属性也是这样,之前像img的src,就有一个ng-src,如果没这个,你设置在上面的表达式就会被当成真的url,先去加载一次,显然是不对的。所以,设置在ng-src上,它等表达式解析出结果了,再把结果设置到src去。如果是用Web Components扩展的自定义组件,它不知道你有哪些属性,就搞不下去了。

所以,Angular 2.0团队在这一块还很纠结,需要很多探索,很多权衡才能找到一种能接受的方式。

后来在这一块,我跟@RubyLouvre 探讨了一下,他的观点是不要让数据绑定接触到Web Components,也就是说,不让扫描进入“暗世界”,我想了想,觉得也有道理,只是这样Web Components跟原生元素就要区别对待了。

或者对所有Web Components使用同一个壳子再次封装?感觉还是很怪。

observe

当前版本的Angular使用脏检测的方式来实现数据的关联更新,这种机制有一定优点,但缺点也非常明显。

在其他语言中要监控数据的变更,很多在语言层面上有get和set,一般都是从这个角度入手,有不少JavaScript框架也是从这个方面做下去的,Angular不是。

Angular的脏检测很有特色,它采用的是新旧值比对的方式,也就是说,对每个可变动的模型,保存上一次的值,然后通过手动,或者是封装事件调用检测,一遍又一遍地刷新模型,直到稳定,或者超出容忍限度。

为什么这里面会有不稳定现象呢?我举个简单的例子,这是伪代码,仅供演示:

function Entity() {
    //初始化
    this.a = 1;
    this.b = 1;
    this.c = 1;

    //监控语句,伪代码
    this.b = this.a + 1;
    this.c = this.b + 1;
}

这里面几条语句不是真的赋值,是用来表示:每当a变化了,b就跟着变,然后c也跟着变,那我们在这里就要创建两个监控,一个是对a的监控,在里面给b赋值,一个是对b的监控,在里面给c赋值。

好了,比如有人给a赋了个新值,我们一个脏检测循环下来,b增加了1,c也跟着增加了,好像没什么问题。那我们怎么知道整个模型稳定了呢?很简单也很无奈,再运行脏检测一次,这次a没变,所以另外两个也不变了,跟上一次检测之后的结果一样,所以就认为它稳定了。

这里我们看到,不管你怎样,只要变过数据,至少要跑两次脏检测。为什么说至少呢,因为我们这种情况刚好把坑给绕过了,来改下代码:

function Entity() {
    //初始化
    this.a = 1;
    this.b = 1;
    this.c = 1;

    //监控语句,伪代码
    this.c = this.b + 1;
    this.b = this.a + 1;
}

没改什么,只是把两条监控语句互换了,这个结果就不对了。为什么呢,比如a赋值为1之后,第一遍结果是这样的:

  • a = 1;
  • c = 2;
  • b = 2;

这里讨厌的是c的监控语句先执行了,但b还没有变,可是我们当时是不知道的。然后,我们想看看模型稳定了没有,就再检测一次。所谓的检测,其实是两个步骤:把所有监控语句跑一遍,对比本次结果与上次的差异。

那么,这次变成了:

  • a = 1;
  • c = 3;
  • b = 2;

第二轮结束。模型稳定了吗?其实已经稳定了,但是代码是不知道的,它判断稳定的依据是,本次结果与上次相同,可事实是不同的,所以它还得继续跑。

第三次跑完,终于跟第二次结果一样了,于是他认为模型稳定了,开始把真正的值拿出去用了。

所以这个过程的效率在很多种情况下偏低,但好在他这个变更不是实时的,而是通过某些东西批量触发,所以也还凑合。另外有些框架,是每次对数据赋值了就去立刻更新关联值,这当数据结构比较复杂的时候,这样比较高效。

Object.observe与之相比,在定义监控的时候比较直观一些,而且,基于set get的绑定框架,有些会在原始数据的原型上定义一些“私有”方法,相比来说,observe这种方式从数据的外部视角来处理变更,更合理一些。

Angular 2.0的数据绑定机制应该会使用observe重写,可以期待这个方面有较大的提升。

但不管什么绑定方式,都是有坑的。我知道读者中有不少坏人,你们看到这里肯定想到很多坏主意了,比如刚才的脏检测,有没有办法把这个过程搞死?很容易,我帮你写个简单的:

function Entity() {
    //初始化
    this.a = 1;
    this.b = 1;

    //监控语句,伪代码
    this.a = this.b + 1;
    this.b = this.a + 1;
}

这个代码死循环了,形成了监控闭环。所以,在Angular里面发现循环到一定量的时候,就会觉得它停不下来,终止这个循环。在其他技术实现的绑定框架中,同样要解决此类问题,所以监控到变更的时候,也不是直接拿去应用。

promise

很奇怪啊,我一直喜欢promise这种编写异步代码的方式。可能我对它的喜好来自一些背景,比如说,做可视化组件编程。这个可视化的意思是指通过拖拽配置,配置逻辑流程(注意,不是拖UI)。

比如说,流程细到方法的粒度,每个步骤映射到一个方法,然后拖拽这些步骤,配置出执行流程。这里面有个麻烦就是异步,比如说,某个方法异步了,那就麻烦了,因为在一个纯拖动的配置系统中,如果你还要让他手工调整什么东西甚至改代码的话,这个事情基本就白做了。

所以你看,promise在这里优势很大。每一个有异步倾向的方法,我都让它返回promise,甚至为了一致性,不异步的方法也这么干,每个方法的入参出参都是map,让promise带着,是不是就很好了?

以上是我个人见解,可忽略,谢谢。

那么,在Angular 2.0中promise有什么影响呢?

回顾Angular 1.x版本,在其中已经可以看到很多promise的身影,只是那时候用了$q,一个小型的promise实现。在2.0中,promise的使用将更加广泛,因为更多的东西是异步的了,比如新的路由系统。

promise本身是很容易被降级的,在原生不支持它的浏览器中也很容易搞出一个polyfill来。

这个事情在我个人看来是很喜闻乐见的。

Angular 2.0除了作出符合标准的改进,还有一些提升的方面:

依赖注入

Angular大量使用了依赖注入。在JavaScript里面怎样做依赖注入呢?比如这段代码:

function foo(moduleA, moduleB) {
    moduleA.aaa(moduleB);
}

a跟b这两个模块都要注入进来。对于依赖注入系统而言,首先要知道注入什么,比如这里,至少要先知道a和b是什么,怎么知道呢?很多框架都用一种方式,就是先把foo这个待注入函数toString,这就取得了函数定义的文本,然后使用正则表达式提取参数名。

这个办法可行,但不可靠,它害怕压缩。随便什么压缩工具,肯定认为形参名是没用的,随手就改成a或者b了,这样你连正确的模块名都找不到了。那怎么办呢,只能老土一些:

foo.$inject = ["moduleA", "moduleB"];

这样总可以了吧?

这样写起来还是有些折腾,而且运行时的数据也不够完全,所以Angular 2.0很激进地引入了一种类似TypeScript的语言叫AtScript,支持类型和注解,比如它的这个例子:

import {Component} from 'angular';
import {Server} from './server';

@Component({selector: 'foo'})
export class MyComponent {
  constructor(server:Server) {
      this.server = server;
  }
}

一些配置信息就可以搞在注解里,类型信息也就丰富了,然后这代码编译成ES6或者5,多么美好。更美好的是,2.0借助这种语言,可能把原来的指令、控制器之类的东西统一成组件,使用普通ES6 class加注解的方式来编写它们的代码,消除原来那么多复杂冗余的概念。

其实还有很多改进点,比如路由等等,没法一一列出了,感兴趣的可以查阅Angular 2.0已经流出的文档,或者查阅它的github库。

小结

Angular 2.0这次的规划真是脱胎换骨,看了介绍文档,简直太喜欢了,之前我考虑过的所有问题都得到了解决。这一次版本跟之前有太大变化,从旧版本迁移可能是个难题,不过相对它所带来的改进,这代价还是值得的。勇于革自己的命,总比被别人革命好,期待Angular的浴火重生!

感谢博主翻译。
跟随最新标准,站在未来的角度上来规划2.0版本本来无可厚非,我唯一不理解的地方,在于AtScript。因为时间精力问题,只能够使用coffeescript,所以也搞不清coffeescript,typescript,atscript之间的关系,而且因为个人喜好问题,一股浓浓的Python即视感真的让人有点纠结。

对官方公布细节的翻译在这里:#8

Nice!

脏检查和observe现在感觉就是背道而驰,这一点估计改动会很大,但也非常值得期待。
打个比方,我喜欢用defineProperty来给$scope添加一些带逻辑的纯getter属性,或者带逻辑的setter属性。
为什么?因为用原始值配合expression绑定可能做不出这么复杂的逻辑,expression绑定也解决不了v->m反向绑定中的逻辑。
而且也许我仅仅是想做一个genderText属性,getter就是return this.gender == 0 ? '男' : '女',这的确是一个expression,但它也是个逻辑,因为有一天我可能会改成'帅哥'/'美女',那时候也许会不幸发现那个expression已经遍布全球,改起来很费劲。
但在实际使用中我发现因为脏检查循环的存在,我的getter经常要被调用好几次,原因你懂的。
如果用observe取代脏检查,我们就可以建立起来一个真正的VM层,VM层的数据,甚至是各种复杂类型的字段,是带逻辑的,也就是所谓“视图逻辑”,expression还可以照用不误,方便,灵活。更复杂的组合属性、依赖属性就可以用getter实现了。
web component这一块,我觉得不是“不应该让扫描进入shadow dom的暗世界”,而是就像shadow dom的初衷一个样,就是隔离。外面扫外面的,里面扫里面的,这不正是现在ng的$scope隔离的概念吗。
所以这一点我觉得可真是殊途同归呀~
而我对web component比较期待的地方其实是它的dom事件隔离和css隔离。
dom事件隔离可以让组件的实现和使用都更轻松惬意,具体就不举例了,说起来都是泪。
最近也在用ng,我想尝试语义化(而非结构化)classname,比如用.color-picker>.preview取代.color-picker-preview`,因为“结构化”classname会像叠罗汉一样越来越长,越来越蛋疼。
现在我是靠less来“隔离”,但是写的不好的时候,比如子选择符写成后代选择符的时候,就很容易跪,随随便便就跟别的冲突没商量。css隔离可以让我们对css的架构和规范化这回事的理解产生一定颠覆性的变化,这是我比较期待的一点,而且这是我所知道现在CSS隔离的唯一的一个办法了……

@LiuJi-Jim getter这个的问题在哪里啊,我没明白,getter里面本来就是不该放逻辑的……这种文本的转换应当是通过filter去做啊,因为如果你需要在模型里做getter的转换,基本上一定是给视图用的,所以用filter很合适。假如模型中还存在别的转换,那就是动用$watch的时候,属于在模型内部的关联。

web components这段,可能你没明白我意思,我举个例子:

你有一个组件

<foo bar="123"></foo>

bar属性用于接收一个数字,然后把组件内部的某个东西创建这个数字这么多个,并且显示出来,这本身不会有任何问题,但考虑一个场景,我想把这个数字动态传入,这个数字从哪里来呢?从外层作用域的某个变量中来,如果是普通元素,是不是你要写:

<div ng-controller="TestCtrl">
  <input type="number" ng-model="a"/>  
  <span ng-bind="a"></span>
  <foo bar="{{a}}"></foo>
</div>

你看,a的值可以从输入框传入span并显示,但是传不到组件的bar里。如果你想里面扫里面的,那么,组件在解析插值表达式的时候,从哪里去获取这个a?所以这个问题的关键在于外层世界与组件的传值,别的是没有问题的,其他时候大家都各自做自己的事好了。

样式这块,碰到web components的时候,还是需要重新规划,思路跟之前的大有不同,我想想。

@xufei 我对filter的看法是它应该是高度可复用的util类型的东西,比如urlencode、camelCase这种就是filter,但上面我说的那个例子,或者fullName定义getter为return firstName + lastName我觉得就应该是VM属性,因为它根本不能被除了这个模型以外的别的东西所复用……因为受C#影响,我喜欢用getter/setter,于是这种事情我会觉得不是一码事。

对于<foo bar="{{a}}"></foo>的情况我感觉也没问题啊,如果ng发生了改变那么foo组件会触发attribute的修改回调,这个是web component支持的。web component内部维护一个属性,内部视图可以和这个内部苏醒双向绑定。剩下的就是维护内部属性与外部暴露出来的attribute之间的绑定了。相当于内部的绑定和外部的绑定隔离开了,中间通过attribute来做桥梁。

@LiuJi-Jim 在Angular里面,是可以把filter看作数据getter的装饰器的,它的职责其实就是干这些事。

绑定是有问题的。

这个绑定实现不了,原因是angular不知道组件有bar属性,它必须先穷举,必须预先知道元素上有哪些可插值属性,然后挨个去解析,还要区分找到的是属性还是方法。就算找到了bar,也没法知道它是不是要插值。就算知道它是要插值的,在计算出真实结果传入之前,已经把整个插值表达式传到组件内部了,一个不合法字符串。

所以问题都在内外传递这一步上。

Polymer基础层使用了以下技术:

  1. DOM Mutation OberserversObject.observe():用于监视DOM元素和简单JavaScript对象的改变。该功能可能会在ECMAScript 7中正式标准化。
  2. Pointer Events :在所有的平台上以同样的方式处理鼠标和触摸操作。
  3. Shadow DOM:将结构和样式封装在元素内(比如定制元素)。
  4. Custom Elements:定义自己的HTML5元素。自定义元素的名字中必须包括一个破折号,它的作用类似于命名空间,为了将其与标准元素区分开来。
  5. HTML Imports:封装自定义元素,包中包括HTML、CSS、JavaScript元素。
  6. Model-Driven Views(MDV):直接在HTML中实现数据绑定。仍没有标准化的计划。
  7. Web Animations:统一Web动画实现API。

你们所说的问题就是MDV吧,慢慢等吧,否则自己实现扫描器

支持Polymer,未来的趋势!!!

@xufei

这个绑定实现不了,原因是angular不知道组件有bar属性,它必须先穷举,必须预先知道元素上有哪些可插值属性,然后挨个去解析,还要区分找到的是属性还是方法。就算找到了bar,也没法知道它是不是要插值。就算知道它是要插值的,在计算出真实结果传入之前,已经把整个插值表达式传到组件内部了,一个不合法字符串。

我对angular不是很熟,但是对于它解决的问题大概清楚,有点疑问:

为什么angular不是扫描元素的attributes,读到bar,然后根据值bar="{{a}}"进行插值;而是需要事先知道一个元素可插值属性列表['bar',...]

在计算出表达式结果之前,传入组件内部的值是不合法内容。这不算问题吧,现在的angular+html元素也有这个问题。所以angular在逻辑完成之前会先隐藏内容,计算完成后再更新属性,这时候的属性是合法的,控件对应地更新状态就好了。

Polymer还没碰过,周末看一下先。

hax commented

@RubyLouvre 正美同学的“不要让数据绑定接触到Web Components”是什么意思?

按说你是区分不出一个自定义元素和html本身元素的,所以机制应该是一视同仁的。

当然你可以说我根据自定义元素的规则(带有 - 的)来区分。可是别忘了还有 type extension 的情况(尽管我觉得type extension这个名字有点问题),就是对现有元素的增强,如 <div is="my-decorator">,所以那上面既有本身html的属性和事件,也有扩展的属性和事件。

@hax 他的意思是:不要让绑定扫描机制跨越组件内外。我举个例子:

之前Angular里面的元素指令,如果我们造这么个元素Panel,把它放在一个div中:

<div ng-controller="TestCtrl">
  <Panel></Panel>
</div>

比如这个TestCtrl是外层的视图模型,里面的所有变量,其实可以在Panel的内部实现中用于绑定。

但是如果用Web Components来实现这个自定义元素Panel,这个事情是非常困难的,所以干脆就不要想这些事,组件外的东西限制到组件外,组件内的东西如果有绑定,内部自行通过一个公共类处理。

但我的疑问在于,假如恰好在组件直接使用的时候,要从外部带入一个值,怎么办?

比如TestCtrl里面有变量a和b,我只是想这样:

<div ng-controller="TestCtrl">
  <Panel title="{{a + b}}"></Panel>
</div>

这个从使用者的角度,并未破坏Web Components的封装性,因为没有干涉内部实现,所以应当还是允许比较好,否则,自定义元素与原生元素的行为就不一致了。

hax commented

@RubyLouvre “自行通过公共类处理”是什么意思?让组件自己import相关的model?这不是break了封装和复用性嘛。

我理解中组件必然需要数据绑定。因为可以把组件理解为增强性的form controls,所以是最需要数据绑定的部分。

@hax 他意思是,内外各自绑各自的,但是如果像我上面举的例子,那个表达式只能让外层去解析,内层能拿到表达式,拿不到执行上下文,还是绑不了

浴火是了,重生倒未必,我有几点不太同意楼主:

  1. module 更多是被 es6 的语言机制 import + class 取代了,至于楼主说的几个所谓的缺点,我认为本来就不是 module 设计上的初衷,楼主似乎把这个东西的设计初衷的臆想成与 AMD 等价物了。
  2. 考虑动态加载机制,并不只是 module 机制能解决的问题。其实 hack 2-3行 ng 的源码,动态加载模块的功能就可以实现,所以 module 不是动态加载机制的元凶。hack 以后的代码基本上就是所有节点都 complie-link 一次,差不多就是再运行一次 bootstrap。所以这需要生命周期管理的整个设计要改变,然后各种算法也要重新设计成可以多次重入。所以 ng 的生命周期机制才是动态加载的元凶。
  3. ES 没有语法机制的支持,也没有 getter/setter 的编码文化,如果支持 POJSO 除了 dirty-checking 循环比较新旧值,你还有更好的办法吗?就如 Rivet.JS 那样强制使用 getter/setter,但对于多层嵌套的对象,getter/setter 就让编码的难度加大了,缺点也是同样也很明显。
  4. web component 一块,我认为“不让扫描进入暗世界”不是正确的思路,入口点应该给予程序员选择的余地,这个参数是按值(组件内不可改变该值)还是按引用(组件内可以改变这个值)传递,都应该予以支持。可以看到 angular.module 的 value 和 constant,directive 的 '@' 和 '=' 传参,都体现了这个属性传递处理的明确性,我认为这个在 ng1 里面就已经没什么可以纠结的了。

所以,还能叫 ng,只是利用了一些语言上的新特性来重写一次 angular。从目前的文档来看,浴火的是:class 取代了 controller,干掉了 $scope,annotation 取代了 module。但是骨架仍然 1.x 的形状,重生是未必。

感谢,文章翻译得好啊,后面的讨论也有趣。谢谢上面各位。

nice

nice.

还打算学Angular,Angular2出来Angular是不是就没用了,还有必要学Angualr么?

hbbpb commented

大家的讨论很精彩,赞一个。

cdll commented

学习了~原来ng2的变革如此之大!

ng2已经弃用AtScript改用TypeScript了

luozt commented

@xufei 已经说得很清楚了,angular1是不能动态加载组件的 @nighca

比如:

<div id="container" ng-controller="myController">
  <p class="text-center bg-info" ng-hide="testValue">加载中...</p>
  <p ng-show="testValue">testValue: {{testValue}}</p>
  <hello data-some-val="{{testValue}}"></hello>
</div>

指令hello的属性值这样传进去就报错了。所以这时只能通过事件广播的方式对指令进行更新,非常不方便

谷歌把angular毁了。angular本不配称框架,现有知识的敏捷利用的价值让一代备受追捧,但真不是框架,也没想。谷歌非要强加自己天下一统舍我其谁的个性,完蛋。重写也是个自恋的说法,不得不扔出DI,显示性编程,设计模式里的古怪名字来,一团乱麻,又一个Java类语言。本质问题是异步不是主要问题,service根本就是evil。数据本地化才是app的王道,而所谓的稀释依赖不过是自找麻烦,web app那些需求全世界都知道,你多做点,开发人就少做点。占领市场对谷歌才是最重要的,就跟安卓一样,哭的是码农们,跟着这么个利益驱动,没有品味的大佬,永远痛苦抑郁。同样是框架,ember2年前就明白核心问题。完全是好比苹果和安卓的情况。也难怪,ember的创始人就是来自苹果。

啥玩意都不能免俗,临近秋天大家都要吃起羊肉了。。es6、组件化都是当下趋势,与时俱进,蛮好的,各位可以马上玩起来

再谈angularJS数据绑定机制及背后原理—angularJS常见问题总结
http://www.zhoulujun.cn/zhoulujun/html/webfront/ECMAScript/angularjs/2018_0417_8097.html,
依照本人的口味对您的面试题做了些整理。如有不妥,望告知。