单页应用的HATEOAS实战

要点

  • HATEOAS是Hypertext As The Engine Of Application State的缩写。在 Richardson Maturity Model中, 它是REST的最高级形态
  • 单页应用正越来越受到欢迎,前后端分离的开发模式进一步细化了分工,但同时也引入了不少重复的工作,例如一些业务规则在后端必须实现的情况下,前端也需要再实现一遍以获得更好的用户体验。HATEOAS虽然不是唯一消除这些重复的方法,但作为一种架构原则,它更容易让团队找到消除重复的“套路”

什么是HATOEAS

HATEOAS是Hypertext As The Engine Of Application State的缩写。采用Hypermedia的API在响应(response)中除了返回资源(resource)本身外,还会额外返回一组Link。 这组Link描述了对于该资源,消费者(consumer)接下来可以做什么以及怎么做。

举例来说,假设向API发起一次get请求,获取指定订单的资源表述(representation),那么它应该长得像这样:

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Content-Type: application/hal+json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 05 Jun 2015 02:54:57 GMT

{
    "tracking_id": "123456",
    "status": "WAIT_PAYMENT",
    "items": [
        {
            "name": "potato",
            "quantity": 1
        }
    ],
    "_Links": {
        "self": {
            "href": "http://localhost:57900/orders/123456"
        },
        "cancel": {
            "href": "http://localhost:57900/orders/123456"
        },
        "payment": {
            "href": "http://localhost:57900/orders/123456/payments"
        }
    }
}
  • 理解Link中的“self”的消费者知道使用get方法访问其“href”的uri可以查看该订单的详细信息
  • 理解Link中的“cancel”的消费者知道使用delete方法访问其“href”的uri可以取消该订单
  • 理解Link中的“payment”的消费者知道使用post方法访问其“href”的uri可以为该订单付款

REST是目前业界相当火热的术语,似乎发布的API不带个REST前缀,你都不好意思和别人打招呼了。 然而大部分号称REST的API实际上并没有达到Richardson成熟度模型的第三个级别:Hypermedia。 而REST的发明者Roy Fielding博士更是直言HATEOAS是REST的前提, 这不是一个可选项,如果没有Hypermedia,那就不是REST。(摘自Infoq对Fielding博士的第二段访谈)

那么HATOEAS带来了什么优势?

一个显而易见的好处是,只要客户端总是使用Link Rel来获取URI,那么服务端可以在不破坏客户端实现的情况下实现URI的修改,从而进一步解耦客户端和服务端。

另一个容易被忽视的优势是它可以帮助客户端开发者探索API,Links实际上提示了开发者接下来可以进行何种业务操作,开发者虽然精通技术,但往往对于业务不甚了解,这些提示可以帮助他们理解业务,至少是一个查询API文档的好起点。想象一下,如果某个API的响应中多了一个新的Link,敏感的开发者可能就会询问这个Link是用来做什么的,是一个新的特性吗?虽然看起不起眼,但这往往使两个团队的成员沟通起来更容易。

单页应用和HATEOAS

在过去的几年里,WEB开发技术发生了很多重大的变革,其中之一就是单页应用,它们往往能带来更平滑的用户体验。在这一领域,分工进一步细化,前端工程师专精客户端程序构建和HTML、CSS等效果的开发,后端工程师则更偏重高并发、DevOps等技能,大部分特性需要前后端工程师配合完成。或许有人会质疑,为什么不是全栈工程师?诚然,如果一个人就能端到端的交付特性,那自然会减少沟通成本,但全栈工程师可不好找,细化分工才能适应规模化的开发模式。继Ajax之后,单页应用和前后端分离架构进一步催生了大量的API,我们急需一些方法来管理这些API的开发和演进,而HATEOAS应该在此占有一席之地。

在摸索中前进,自由地重命名你的资源

我们常说在敏捷开发中,应该拥抱变化。所以敏捷开发中推崇重构、单元测试、持续集成等技术,因为它们可以使变化更容易、更安全。HATOEAS也是这样一种技术。想象一下,在项目初始阶段,团队对业务的理解还不深入,很有可能会得出错误的业务术语命名,或者业务对象的建模也不完全合适。反映在API上,可能你希望能够修正API的URI,在非HATOEAS的项目中,由于URI是在客户端硬编码的,即使你把它们设计的非常漂亮(准确的HTTP动词,以复数命名的资源,禁止使用动词等等),也不能帮助你更容易地修改它们,因为你的重构需要前端开发者的配合,而他/她不得不停下手头的其他工作。但在采用了HATEOAS的项目中,这很容易,因为客户端是通过Link来查找API的URI,所以你可以在不破坏API Scheme的情况下修改它的URI。当然,你不可能保证所有API的URI都是通过Link来获取的,你需要安排一些Root Resource,例如 /api/currentLoggedInUser,否则客户端没有办法发起第一次请求。

HTTP/1.1 200 OK
Path: /api/currentLoggedInser (1)
{

​    …… //omitted content

​    "_Links": {  (2)
        "searchUserStories": {
            "href": "http://localhost:8080/userStories/search{?page, size}"
        },

​        "searchUsers": {
            "href": "http://localhost:8080/users/search{?page, size, username}"
        },

​        "logout": {
            "href": "http://localhost:8080/logout"
        }

​    }
}
  1. Root Resource,它们是API的入口,客户端通过他们浏览当前用户有哪些资源可以访问,你可以定义多个Root Resource,并确保它们的URI不会改变
  2. Link引入的URI可以自由地变化,可能是因为需要重命名资源,也可能是需要抽取出新的服务(域名变化)

消除重复的业务规则校验实现,更容易得适应变化

经验告诉我们,不能相信客户端的请求,所以在服务端我们需要根据业务规则校验当前的请求是否合法。这样确保了业务正确,但当用户发起了请求后才告诉他们请求失败,有时候是一件令人沮丧的事情。为了用户体验,可能会要求某些组件根据业务规则展示。例如,对于某个业务对象,要求编辑按钮只在当前用户可以编辑的情况下才展示。在传统的服务端渲染架构下,一般都可以复用校验的代码,而在单页应用中,往往由于技术栈不同,代码无法直接共用,业务规则在前后端都分别实现了一次。例如,在我们最近的一次项目中,前后端分别实现了如下规则:

  • 给定一个用户故事
  • 只有它的作者才能编辑它

服务端通过在用户故事的API中暴露作者帮助前端完成编辑按钮的有条件渲染。

HTTP/1.1 200 OK
Path: /api/userStories/123
{

​    "author": "john.doe@gmail.com"  (1)

}

1. 与当前用户比较判断是否渲染编辑按钮

但如果规则发生变化,前后端都需要适应这一改变,所以我们用HATEOAS重构了一下:

HTTP/1.1 200 OK
Path: /api/userStories/123
{

​    "author": "john.doe@gmail.com",
    "_links": {

        ​"updateUserStory": { 
        ​    "href": "http://localhost:8080/api/userStories/123"  (1)
        ​}

​    } 

}

2. 现在前端会根据 updateUserStorylink是否出现来验证当前用户是否具有编辑用户故事的能力

后来业务规则变为除了作者之外,系统管理员也可以编辑用户故事,这时候只需要后端去响应这个变化就行了。你可能会质疑,通过为用户故事暴露一个 isCurrentLoggedInUserAvailableToUpdate的计算属性也可以做到。没错,HATOEAS并不是唯一的办法,但作为一种架构约束,团队会自然而然地想到它,而计算属性则要求团队成员有更强的抽象技能。

总结

HATEOAS提倡在响应返回Link来提示对该资源接下来的操作。这种方式解耦了服务端URI,也可以让客户端开发者更容易地探索API。最后,通过Link来判断业务状态,还能有效地消除单页应用中的业务规则重复实现。


更多精彩洞见,请关注微信公众号:思特沃克

Share

被踢出去的用户

0

在还没有掌握全部证据之前就下结论会犯严重的错误,会使判断带有偏见。——《血字的研究》

“齐识,路老板又来邮件了。”白娜一脸无耐地说。

“一定没好事吧?”齐识回头看了一眼,手依旧在快速敲着代码,并没有停。

“你刚从泰国回来?”齐识说到。

“你怎么知道?”白娜瞪着眼睛一头雾水。她清明假期去泰国玩这件事,并没有告诉任何同事。

“你脸明显比节前黑了,说明去了热带地区玩。你戴的耳环以前没看到过,说明是假期新买的。耳环上刻着大象……”,齐识停下敲代码的手,回过头,“说明你去了视大象为国宝的泰国。”

“你……牛……,不愧是程序员名侦探……”。

“说吧,又出什么事了?”齐识回到显示器前继续敲代码。

“还是用户被踢出去的那个问题。”

“上次不是已经查清楚了吗?是低版本IE的锅。”

“可是,这次是IE 11……”

“哎——”齐识长叹一声,“好吧,把邮件转给我。”

“已经转给你了。拜托了,我去开会了哈。”

齐识很不情愿地将界面从IDE切换到网页,打开了邮件。这是一封几经转发的邮件,一开始是路老板的用户向路老板抱怨系统用着用着就自动登出了你们这什么烂系统,路老板回复说可能是低版本IE的锅爸爸请考虑升级IE或换其他浏览器。客户说老子TM用的就是最新版IE你是不是在玩老子,于是路老板转发给项目经理白娜说今天必须解决否则炒了你们整个团队。最后白娜转发给齐识,只有三个字母:FYI……

很麻烦。这个问题以前也出现过几次,一直没有找到具体原因。上一次时发现用户用的是IE 8,而当时系统支持的IE最低版本是9。当所有人都焦头烂额束手无策的时候,这位超级英雄背锅侠IE站了出来。果不其然当用户换了其他浏览器后,就再也没有出现过类似问题了。

但这次,背锅侠说这锅老子不背了。

齐识看了看表,上午11点。不知道要为这个问题加班到几点。

他先打开了日志分析工具,按照用户名查询与该用户有关的日志。永远不要相信一个不懂电脑的用户所描述的事实,所有的信息都要再次确认。但这次事实似乎很无情无义无理取闹,这个用户用的确实是IE 11。

通过日志,可以看到这个用户所有的访问记录:他先是登录系统,打开几个页面,然后到了文件管理子系统,找到某个文件夹,上传了一个文件,然后就登出了。如果用户描述属实的话,这次登出请求不是他主动触发的,是系统自动把他踢出去了。

“用着好好的,怎么就被踢出去了呢?”齐识自言自语道。

“怎么了大侦探,愁眉苦脸的。”老夏端着泡了枸杞的保温杯路过。现在是11点半,这杯应该是第三泡。老夏以前也是项目上的大牛,后来不知怎的,去了培训部专门做员工培训了。不过他时不时还是会到研发部这边溜达溜达,从背后窥探一下别人的屏幕,指出代码上的一些问题,然后哈哈一笑扬长而去深藏功与名。

“用户又被自动踢出去了。”齐识说。

“又是那个问题?不是IE的锅吗?”

“看来我们有可能冤枉IE了,这次用户用的是新版本IE,按理说我们的前端是肯定支持的,也在测试环境验证过无数遍了。”

“这就怪了。这几次出问题的用户,是不是都用的IE?”

“是的。”

“那就还是IE的锅。”老夏吸溜了一口枸杞茶,走了。

他看似不经意的一句话,反倒提醒了齐识。他立马继续查询日志,找出几个用不同浏览器的用户,他们的各种操作错落有致,最后的登出请求也并无破绽。只有一些IE用户,他们的登出请求是在上传完文件之后发生的。难道是上传文件的后台接口出了问题?但不同浏览器产生了不同的行为,这现象又 不像是后台的事儿啊。齐识一看表,已经12点半了。得了,先去吃饭吧。

1

……权衡点滴证据,做出不同的假设,把它们进行对比,最后再确定哪几点是重要的、哪些是不真实的…… ——《巴斯克维尔的猎犬》

草草吃完,买了杯咖啡,齐识又坐到了电脑前。像这种线上问题,尤其是本地无法复现的线上问题,是他最喜欢的。定位这些问题的过程,就像是侦探在探案,从最初的案件出发,将看似毫无关联的线索逐一梳理过滤,那最终唯一的真相也会慢慢浮出水面。每到这时,齐识就会感觉像是个超级英雄一样拯救了世界。这样的问题解决得多了,同事们给他起了个外号,叫“程序员侦探”。他倒也不介意别人这么叫他,反倒有点自豪。

但这次用户被踢出的问题,齐识前前后后处理过三次,都无法定位。按照目前系统的实现,用户自动登出可能是因为网站的登录cookie过期且SSO上的登录cookie也过期,这时当某个请求到达后台时,会清空所有与认证有关的cookie并重定向到SSO的登录页面。登录cookie的有效期是20分钟,但用户抱怨的是刚登录不久就被自动踢出,从日志上看也的确如此。所以“登录cookie过期”的不在场证明相当完美。

另外的可能就是缓存挂了。每个请求到达后台时,都会到服务器缓存中取出在用户登录时存储的一个token,将之与请求所携带的cookie中的token比较,如果不相符就自动登出。之所以这样做是考虑用户的安全,将伪造或窃取cookie登录的黑客拒之门外。如果存储或读取缓存失败,自然也会自动登出。齐识以前在读写缓存的地方加了很详细的日志,并没看到任何错误发生。“缓存”作案的可能性也不大。

最后一个嫌犯是心跳请求。网站前端每隔2分钟会自动向后台发一个心跳请求,如果服务器发现本次心跳与上一次心跳间隔时间超过3分钟,就认为用户已处于不活跃状态,自动将其登出。这么做也是为了用户安全,比如将所有网站页面关闭,3分钟后再次打开,将会自动跳转到登录页面。如果心跳请求没有发送成功,下次请求到来时很可能已经超过了3分钟,就会把用户踢出去。心跳请求可以在Web服务器的日志中查到,每次请求都是成功的。所以,“心跳”的嫌疑似乎也撇清了。

就在上一次处理这个问题时,齐识偶然发现请求日志里显示的UserAgent是网站不支持的IE 8。使用系统不支持的浏览器,任何诡异的事情都可能发生。当用户升级了IE或使用了其他浏览器后,问题不再发生了。但是这一次,为什么新版的IE也不行了呢?

现在,齐识的目光集中在了上传文件的后台API上。这是这次发现的新嫌疑人,不,还有IE,可能是团伙作案。现在掌握的最新线索是,用户使用IE,不管什么版本,在上传完文件后,被自动登出。对于IE这种惯犯,不容易找出它的破绽,所以齐识决定先从API下手。

2

在没有事实作为参考以前妄下结论是个很大的错误。主观臆断的人总是为了套用理论而扭曲事实,而不是用理论来解释事实。——《波希米亚丑闻》

两个小时过去了,还是没有任何进展。上传文件的API若无其事地待在那里,一脸蔑视地看着齐识。

“有新的线索吗?”老夏又端着保温壶过来了。现在是下午3点,壶里应该泡上了新茶。老夏喜欢在午饭后泡一壶酽茶。

“今天的普洱味道如何?”齐识问。

“不是普洱,是正山小种。”

“咦?红茶应该是周四泡的啊,今天周三应该是普洱才对。”

“这个嘛,突然就想喝红茶了,呵呵。”老夏说完吐了吐茶沫。“你有不错的洞察力,总是能发现别人不太在意的事情。但有时候,根据那些事实并不能推理出你的结论。有些事情之间的关联可能很偶然,并不具备规律性。”老夏微笑地看着齐识,“现在情况如何?”

“我现在在看上传文件的API。我发现每次用户调用完这个API之后就会被踢出去。”

“哦?有意思,这个API做了什么见不得人的事了吗?”

“并没有。我已经检查了它和它所调用的所有方法,甚至还看了它本身的filter和全局filter,并没有任何地方会清空cookie或者token缓存。只有清空这些才会自动登出。”

“对了,你是怎么发现调用完这个API后会被踢出的?”老夏接着问。

齐识调出了某个用户所有请求日志,定位到上传文件的请求。“你看,这个POST请求是上传文件,后面这些就是登出请求了,而前面的都是一些文件夹跳转的请求。这是其中一个用户的请求,这里还有其他用户。”说着,齐识又开了几个窗口,平铺在了显示器上。

“有意思。”老夏眯缝着眼睛紧盯着屏幕。“虽然他们都是上传完文件就被踢出了,但能确定就是上传文件的API导致的吗?是每次上传完文件都会登出吗?”

“还真不是!”

“而且别忘了IE,只有IE才会踢出用户不是么?那就更能洗清后台API的嫌疑了。”

“对啊老夏,我怎么没有想到?”齐识茅塞顿开。

“就像我连续两周都是周三喝普洱周四喝红茶,你认为那是某种必然。但这是你的主观臆断。主观判断一定要有事实作为依据,否则就都是臆断。”老夏说着,拿起茶壶晃晃悠悠地走了。

“等等,老夏,别走啊,我需要你!”

“我还有事。”老夏回过头冲齐识眨了眨眼睛说,“等你再需要我的时候我自然会出现的。”

3

他的表情不再那样淡漠,我看到他炯炯有神的双眼迸射出智慧和兴奋的光芒。——《格兰奇庄园》

齐识的目光又聚焦在了日志身上。如果用户真的在上传文件之后被踢出,一定能从日志中找到什么蛛丝马迹。齐识又换了一个留下的操作日志较多的用户,将日志按时间排好序。这个是登录成功的请求,这个是进入文件管理子系统的请求,这个是进入某个文件夹的请求,这个是心跳请求,然后上传文件,成功了,然后跳转到其他文件夹,继续上传文件,又发了个心跳,看看离上次心跳的间隔,嗯,2分钟,没有任何问题。齐识点击进入了下一页日志。

又进入了另一个文件夹,还是上传文件,成功了,然后……就登出了。这里面一定有什么猫腻。

齐识揉了揉眼睛,将所有注意力都集中到这几行日志身上,仿佛要看穿这屏幕,看到屏幕那头的另一个镜像的宇宙。

用户在15:32:26进入了第一个文件夹,15:32:45发送了第一个心跳请求,然后上传了第一个文件,15:33:20跳转到第二个文件夹,15:34:30上传第二个文件,15:34:45发送了第二个心跳请求,15:35:07进入第三个文件夹,15:37:48上传第三个文件,15:37:49,用户被踢出。

齐识目不转睛地盯着每个请求的发送时间,突然,他炯炯有神的双眼迸射出智慧和兴奋的光芒。用户在15:37:48上传第三个文件之前,丢失了一个心跳请求,这个心跳本应该在15:36:45发出来。

心跳怎么断了?如果是心跳断了,是必然会被踢出的,这样是解释得通的。但心跳怎么能断呢?齐识打开发心跳请求的JavaScript文件,就是一个简单的setInterval,没有什么特别的。是什么,让这个2分钟的轮询停止了呢?

老夏呢?老夏呢?这老家伙跑哪去了?他说过会在我需要的时候出现的,现在我需要你,可你人呢?我发现了重要的线索,已经锁定了嫌犯,现在就差证据了,就差证据了。

4

排除所有不可能,剩下的那个不管多不可思议,都是事实真相。——《四签名》

“老夏?他说今天要去幼儿园接孩子,提前下班了。”培训部的同事说。

齐识的一脸兴奋变成了一脸沮丧,他本想告诉老夏这个重要的发现,然后跟他一起找到问题的根本原因的。但老夏他居然提前下班走人了。

“你要有事,就给他打电话吧。”培训部的同事看齐识如此低落,就提醒道。

对呀,我怎么忘了这个世界上还有电话这么神奇的存在。齐识拨通了老夏的电话,没有人接。

“老夏,我有了重要的发现……”齐识把刚才的线索编辑成一条微信,发给了老夏。然后回到座位上接着分析。

用户前两次上传文件都没有问题,可是第三次就被踢出了。从时间轴来看,前两次进入文件夹后,都是很快便发出了上传文件的请求,唯独第三次,进入文件夹之后,停留了2分多钟才发出了请求。用户在干什么?进入文件夹后去喝茶聊天了?那样的话心跳没有理由断掉啊。

齐识启动IE,打开开发者工具,登录本地的系统,进入文件管理子系统,打开一个文件夹。接下来该干什么?齐识发呆了几分钟,一个心跳请求发送出去了。他回过神来,点击上传文件的按钮,弹出了选择文件的窗口。上传哪个文件呢?该死,电脑里没有PDF文件。这个文件管理系统只能上传PDF文件,并且做了文件头校验,直接改后缀名是不起作用的。于是齐识只好搜了一个TXT转PDF的在线转换工具,丢上去一个空的文本文件,得到了一个PDF。然后切回刚刚打开的系统,选择文件的窗口还开着。他找到转换好的PDF,点击按钮。然后,然后浏览器就自动跳转到了登录界面……

什么?复现了?这怎么可能?这么轻易就复现了?发生了什么?

齐识又进入系统,上传刚才转换好的PDF,一切正常。

闹鬼了?齐识把刚刚所有操作的日志拿出来看,第一次上传文件——也就是失败的那次——的时间,比进入文件夹晚了2分多钟,比上次心跳请求晚了3分多钟。按照系统的实现,超过3分钟没有心跳请求,后台会认为用户已经不活跃,将其自动登出。也就是完全复现了生产系统中用户的问题。而在此期间,齐识是去转换文件了。

齐识盯着日志,良久之后,哈哈哈哈地笑出了声。

5

通过搜索,齐识很快验证了自己的想法。在IE下,像JavaScript引发的alert窗口或file组件打开的窗口,都属于模态窗口,它们会阻塞所有主线程中正在执行的JavaScript代码。至于Chrome、Firefox这样的浏览器,打开的并不是模态窗口,这也就是为什么只有IE频繁报出类似的问题,其他浏览器则一直表现良好。当这种模态窗口一直处于打开状态时,心跳请求就被迫中断了,继而在上传成功后,被自动登出。

证据确凿,“凶手”就是你了!

一定要把这个消息告诉老夏。齐识拿起手机,看到老夏半个小时前的一条回复:是不是上传文件的窗口打开的时间太长了?

齐识不仅震惊,更是钦佩得五体投地。他把刚才的经过,一五一十地发给了老夏。过了一会儿老夏回复道:“哈哈哈,果然,是哪个笨蛋用户打开了窗口3分钟都找不到文件?等等,不对,不是3分钟,是1分钟!”

“没错,所以问题出现得还是很频繁的。”齐识又和老夏聊了几句,突然想起来什么,就问:“老夏,你明明不姓夏,可是为什么大家都叫你老夏呢?”

“呵呵,这次解决问题,或者叫探案的过程你感觉怎么样?”老夏似乎有点顾左右而言他,”我看前两天你桌上放着一本福尔摩斯探案集,想必也是个Sherlockian,那你一定听说过那句话吧?”

世界上没有真正完美的犯罪,其实真相一直就在我们眼前,只不过还没有被发现。所谓推理,不过就是把重要的细节放大。

当齐识和老夏同时打出这句话时,两个人都笑了。

6

“那个,我以前做开发的时候,大家都叫我夏洛克”。


更多精彩洞见,请关注微信公众号:思特沃克

Share

给Java程序员的Angular快速指南

太长不读版:

Spring + Angular 的全栈式开发,生产力高、入门难度低(此处省略一万字),是 Java 程序员扩展技术栈的上佳选择。

如果你动心了,接下来就是那省略的一万字……

痛点 – 团队分工与协作

在前后端分离的开发方式中,拆故事卡是个难题。

如果前后端同时工作于一张卡上,但配合不够默契或节奏不同步,就会出现一方空转的现象。如果前后端各一张卡,又不容易实现端到端验收,可能导致先做完的一方在另一个结束后还要再次返工的现象。而且,两个人都要深入理解这张卡所描述的业务细节,而这往往是不必要的。

更重要的是,BUG 最容易出现在边界处。

业务卡不像技术卡那样能跟其它卡片划出明确的边界,前后端之间必然具有千丝万缕的联系。这种联系越紧密,出 BUG 的机会也就越大。

技术架构上的挑战,也会反映到人员架构上。我们人类不是星灵,无法做到心灵相通。因此前后端开发者需要对合作方所拥有的知识进行很多主观假设。

如果这些假设中存在错误,又没能及时沟通来消除它(甚至可能都意识不到这些假设的存在),那么 BUGs 就要登场了。而像业务卡这种级别的密切协作中可能隐含的假设实在太多了,除非经过长时间的磨合,否则很难消除,但大多数项目上可没有那么多磨合时间。

解决方案 —— 全栈式开发

人员架构

该如何解决呢?克服上述问题的办法就是全栈式开发。也就是说,调整人员架构去适应技术架构。

简单来说:每个人都同时写前端和后端。他不必是前端专家也不必是后端专家,但是两边都要会写。他的关注点不是技术知识,而是业务知识。他的工作目标是贯穿前后端的价值流,对单个故事进行端到端交付。

但是,要如何克服实现中遇到的技术难题以及保障代码质量呢?那就要靠团队中的技术专家了。

总体来说,全栈式团队的人员架构就是大量全栈业务工程师 + 少量技术专家。当然,技术专家不一定要安排单独的人担任,只要技术满足要求,也可以由某位全栈工程师兼任,只是他做计划时要留出做技术支持的时间。

通过 Code Review、Pair 等敏捷实践,技术专家可以起到团队放大器的作用,让整个团队的生产力翻倍。

个人工作流

作为全栈工程师,你首先要对一个业务故事进行建模,包括业务模型、视图模型、领域模型、存储模型等,建模的过程也就是你理解业务的过程。这时候要注意多和 BA、UX、DBA 等沟通,以确保你的理解不存在方向性错误,不要太沉迷细节,防止见木不见林。

单源建模的优点是这些模型之间很容易保持一致,这无论是对前期开发还是对后期维护都是有帮助的。

建模完毕之后,就要开始设计前后端之间的接口了。接口是前后端分离式架构中最容易开裂的地方,也是对未来的演化影响最大的地方之一。它很重要,但也不必小心翼翼的 —— 全栈工程师对接口变化的适应能力要强大得多。因为接口的提供方和消费方都是你,信息非常透明,不存在任何额外的假设。对不完美的接口,你可以在后续开发过程中迭代好几个版本来把它打磨到最理想的形态,改接口将不再沉重和危险。

接口设计完之后,有两种路径,取决于界面和后台逻辑的特点。

如果对业务理解还不是很有信心,那就先用 Mock 的方式把前端写出来,然后把这个 Mock 版当做可执行的原型去跟 BA、QA,甚至客户进行实际操作演示,用可操作原型来验证你对业务的理解。对一般粒度的故事卡,线框图级的可操作原型通常能在半天内完成。通过原型尽早发现错误,可以避免以后沉重的返工。而且,这是一个可演化原型,不是一次性原型,不会浪费掉。

如果后端很容易实现(但先不必做优化工作),那么就可以不必 Mock,先初步完成后端开发,并让前端直接对接真实的后端。先拿这个比 Mock 版原型更逼真一点的原型串起流程,然后再进行优化和打磨工作。

在整个过程中,你可以根据不同的需要,来与不同的技术专家进行 Pair,并且你最终的代码也会在例行 Code Review 中得到前端专家、后端专家、DBA、DevOps 专家等人的点评和改进,不必担心自己在单项技术上的短板影响交付。

全栈的挑战

全栈固然美好,但也要迎接很多挑战,而 Angular 会帮你分担这些痛苦。

首先遇到的挑战是语言切换

前后端 JavaScript 全栈固然在特定场景下有效,但是在很多企业应用中是远远不够的。至少到目前为止,企业应用还主要是 Java 的天下。本文所讨论的也都是 Java + JavaScript 的全栈。

我们都知道,Java 和 JavaScript 之间的差异就像雷锋和雷峰塔之间的差异。Java 程序员通常很难适应 JavaScript,不过现在有了更像 Java 的 TypeScript。而 Angular 就是原生基于 TypeScript 的框架,稍后我会做一个摘要讲解,你会发现自己很熟悉它的味道。

(图片来自:http://t.cn/RobG5nA

其次是基础设施

基于 JRE 的构建体系和基于 NodeJS 的构建体系看似差异很大,实际上却有很大程度的相似性。但前端两年一换代的疯狂迭代,以及层出不穷的新名词、新工具,仍然难免会让后端心生恐惧。不过不用担心,Angular 替你封装了一切,你只需要装上 NodeJS 环境和 Angular CLI 就可以了。你不需要关心它封装了哪些第三方工具,至于今后的工具链怎么疯狂迭代,那都是 Angular 开发组需要操心的事。

最后是最佳实践

前后端从表面上看差异很大 —— 前端轻灵,后端稳重。

但在我看来它们很少存在本质性的差异,更像是不同的社区文化导致的结果。而在更高的层次上看,两边的技术具有很大的相似性。无论是函数式编程还是工程化开发,都不是某一方所特有的,而是 IT 领域的共同资产。况且,它们还一直在相互影响,相互渗透 —— 这两年后端变得越来越轻灵,而前端变得越来越工程化。长远来看,文化合流是必然的趋势。

事实上,前后端很多优秀设计和最佳实践都是殊途同归的。像 Spring 和 Angular,它们都采用了久经考验的面向对象范式;都使用依赖注入技术进行解耦;都拥抱函数式编程;都提供了丰富的 AOP 支持等。虽然细节上各有千秋,但仅从代码上就能感受到它们之间的相似性。

我该怎么办?

听完这些,你是否已经蠢蠢欲动?接下来,就跟我开始 Angular 之旅吧。

语言 – TypeScript

Angular 使用 TypeScript 作为主要开发语言。如果你还不熟悉 TypeScript,那可以把它看做 Java 和 JavaScript 的混合体。TypeScript 是 ES6 的超集,这就意味着,任何有效的 ES6 语法都同样是有效的 TypeScript 语法。

事实上,从 Java 出发学 TypeScript,可能比从 ES5/6 学 TypeScript 还要简单一些。不过,对于 Javaer 来说,学习 TypeScript 时有一些重要的不同点要特别注意。

TypeScript 的类型只存在于编译期

TypeScript 的一个首要设计约束就是要兼容 ES5/6,因此不能随意增加基础设施,而像 Java 这种级别的类型支持在原生 JavaScript 中是根本不存在的。

你可以把 TypeScript 的类型看做仅仅给编译器和 IDE 用的。因此,在运行期间没有任何额外的类型信息(只有 ES5 固有的那一小部分),像 Java 那样完善的反射机制是很难实现的(可以用装饰器/注解实现,但比较繁琐)。

TypeScript 的装饰器 vs. Java 的注解

TypeScript 的装饰器和 Java 的注解在语法上很相似,但其实在语法含义上有着本质的区别。TypeScript 的装饰器是个函数,而 Java 的注解是个数据。语法上,装饰器名字后面必须带括号,不能像注解那样省略。

不过,在 Angular 中,TypeScript 装饰器的实际用途就是为类或属性添加注解而已。因此,有些文章中,包括早期的官方文档中,用的都是注解的说法。当然,以后写新文章还是都用装饰器吧。

类与接口

TypeScript 中的类和 ES6 中的类几乎是一样的,和 Java 中的类也很相似。

接口则不同,我们前面说过,TypeScript 中的类型信息只存在于编译期,而接口作为“纯粹的”类型信息,也同样只存在于编译期。也就是说,在运行期间你无法判断某个对象的类是否实现了某个接口。在 Angular 中,实际上使用的是暴力探测法来判断的:查找这个接口中规定的方法(只匹配名称),如果存在,则认为实现了这个接口。

这也意味着,你就算不显式 implements 接口,但只要声明了其中的方法,Angular 也会正确的识别它。但这不是一个好习惯,你应该始终显式 implements 接口,删除时也要同时删除接口声明和对应的方法。不过也不用担心,Angular 自带的 lint 工具会帮你检查是否有忘了显式 implements 接口,多注意提示就可以了。

接口是给编译器和 IDE 看的,这很有用。比如,我们可以在 IntelliJ/WebStorm 中声明某个类实现了一个接口,然后在这个类名上按 alt-enter ,就会出现 “Implement interface XXX” 菜单 —— 就像 Java 中一样。事实上,一些 IDE 对 TypeScript 的支持程度已经接近 Java 了:代码提示、重构、类型检查、简短写法提醒等,应有尽有。

值得注意的是:你也可以 implement 一个类,而不仅是 extends 它,也就是说类可以在很多场景下代替接口!Angular 风格指南提出,“考虑在服务和可声明对象(组件、指令和管道)中用类代替接口”。因为运行期间接口不存在,所以在 Angular 中不能把接口用作依赖注入的 Token,也就不能像 Java 中那样要求注入一个接口,并期待框架帮你找出实现了这个接口的可注入对象,但类存在,因此,上述场景下要尽量用抽象类来代替接口。

鸭子类型

为了支持 JavaScript 的动态性和遗留代码,TypeScript 的类型匹配要比 Java 宽松不少。比如,如果两个类(或接口)的属性和方法(名称、类型)都完全一致,那么即使它们没有继承关系,也可以相互替代(但如果类有私有属性,则不能,就算两者完全一样也不行)。表面上看这可能过于宽松了,但在实际开发中还是很有用的,使用中要注意突破 Java 固有思维的限制。

在 TypeScript 中还支持可选属性(name?: Type),也就是说如果两个类的差别仅仅在可选属性上,那么它们也是可以相互替代的。

字面量与匿名类型

TypeScript 在某些方面可能更符合你对 Java “应该是什么样子”的期待,至少在我看来是这样。要声明一个匿名对象、匿名数组型变量?直接写出来就好了const user = {name: 'tom', age: 20}。除此之外,它还能声明匿名类型 let user: {name: string, age: number} = ...

当然,也不能滥用它们。对于一次性使用或暂时一次性使用的变量或类型,用字面量和匿名类型很方便,可读性也好,但是如果它要使用两次以上,那就该重构成正式的类型了。

any

TypeScript 中的 any 大致相当于 Java 中的 Object,如果你看到通篇 Object 的 Java 代码你会不会想骂街?any 也一样。不必完全禁止 any,但如果你要使用 any,请务必先想清楚自己要做什么。

void

如果你在 Java 中经常使用 void,那就遵循同样的原则用在 TypeScript 中。在 TypeScript 中,当你不声明函数的返回类型时,它会返回自动推断的类型(没有明确的 return value 语句时会推断为 undefined 类型),如果你不想返回任何值,那么请把返回类型指定为 void 来防止别人误用。

this

JavaScript 中的 this 是个奇葩。虽然这是函数式语言中的标配,但从语言设计上真是让人忍不住吐槽。要是能像 Groovy 那样分出 this / owner / delegate 就好了。

吐槽归吐槽,对于 Java 程序员,该怎么避免自己踩坑呢?很简单:对普通函数,任何涉及到 this 的地方都用箭头函数 ()=>,而不要用普通的 function foo(),因为前者是替你绑定好了符合直觉的 this 的;对方法,不要把任何涉及到 this 的方法当作函数指针传给别人,但可以在模板中自由使用。在 Angular 中,这两条原则可以帮你回避掉绝大部分 this 错误。更多的细节可以先不管,随着使用经验的增加,你会逐渐弄明白这些规则的。

其它

以上这些是开发中常遇到的注意事项,其它的特性我就不一一列举了,请自行参考 TypeScript 的官方文档。

范式与模型

MVVM

Angular 的基本编程模型是 MVVM,你可以把它看做 MVC 的一个变种。事实上,这是一个很符合直觉的模型:你看到一个页面,先在大脑中抽取出它的信息架构(属性)和操作(方法),定义好它们之间的逻辑关系和处理流程,这就是视图模型(VM)。你把它们落实到代码,变成内存对象,然后 Angular 就会帮你把它和页面(View)关联起来。你不懂怎么操作 DOM?没关系,你只要会操作内存对象就可以了,这应该是你非常擅长的吧?剩下的那些脏活儿 Angular 都会帮你搞定。

不过,Angular 关心的只是“要有” VM,至于你如何生成这个 VM,它并不会做任何假设和限制。

自由混搭与切换

你想怎么生成 VM?

  • 像后端控制器那样直接写在组件中?没问题!
  • 像后端那样委托给服务?没问题!
  • 像 Redux 那样委托给单一 Store?没问题!
  • 像 Java 8 Stream 那样用流水线生成?没问题!
  • 自己几乎不处理,完全委托给后端 API?没问题!

这么多方式各有不同的适用场景,但也不必过早担心如何选型。只要你的组件设计合理(职责分明、接口明确等),那么在这些方式之间切换,或者混用它们,都不会很难。

作为起点,可以先直接写在组件中,然后按需重构成服务,服务中可以直接写代码,也可以实现 Redux 风格的单一 Store,或者用 RxJS 写流水线。

RxJS

在 Angular 开发人员的成长过程中,有一个很重要的坎就是 RxJS,它的背后是 FRP(函数响应式编程)范式。不过对于 Javaer 来说,它的门槛并不高。如果你会用 RxJava / RxGroovy 等 ReactiveX 族的任何一个库,那么你几乎可以不用专门再学,它们都是同一个大家族,编程范式甚至部分操作符的名称都一样,稍微对比一下差异就可以了。如果不会,请继续往下读(以下的讨论也适用于 RxJava 等,不过我文中只用 RxJS 举例)。

RxJS 是一种 FRP(函数响应式编程)库,它同时具有函数式编程和响应式编程的优点。

如果你会用 Java 8 Stream,那么也有很多知识可以复用到这里。相对于 Java 8 Stream,RxJS 的限制稍微宽松一些,但我建议你仍然按照 Java 那种严格的方式使用它(比如不要对流外的变量赋值)。

所谓响应式编程,我们可以把它想象成一条流水线,流水线上不断传送待加工的材料(原料、半成品、成品等),流水线上每个工序的工人负责对传送到眼前的材料进行一定的处理(做出响应),然后放回流水线,接着它就会被传送到下一个工序。

设计上,每个工序的职责都应该是明确而单一的,这样才能达到最高的效率和流水线的可定制性。

把这些概念映射到 RxJS,流水线就是 Observable(可观察对象),工序就是 operator(操作符),材料就是传给每个 operator 的参数。

是不是感到很熟悉?没错,它跟 MessageQueue 是一样的模型,只是应用在不同的层次而已。在编程领域,这种现象随处可见,善于发现并掌握这种现象,是你作为资深程序员能实现快速跨领域学习的根本保障。

相对于 Java 8 Stream,RxJS 比较特别的一点是它完全屏蔽了同步和异步之间的差异。也就是说,其中的 operator 不知道也不需要关心这个数据是同步传过来的还是异步传过来的。只要你遵循一些显而易见的原则,你就可以一直用同步方式给数据,之后即使要突然改成异步,原有的代码也不会被破坏。

事实上,我在 Angular 开发中经常利用这种特性来加速开发。比如假设我最终需要从后端 API 获取某些信息,在这个 API 开发好之前,我可以先在前端模拟出响应结果,进行后续开发。这时候,如果我用 Observable 的方式声明数据源,那么虽然我目前用同步的方式提供数据,但是将来我可以直接切换成 HTTP 数据源,而不用担心破坏现有代码。

细部原理

宏观上的要点已经讲完了,接下来我们快速过一遍微观的。我只讲要点,要想深入细节请参阅文中给出的参考资料。

Angular 模块

Angular 模块不同于 JavaScript 模块,它是一个架构级的基础设施,用来对应用进行宏观拆分,硬化边界,防止意外耦合。

模块的划分主要基于业务领域的边界,而在开发组织形式上,也要和模块划分方式相互对齐,尽量让每个模块都有明确的负责人。

参见 https://angular.cn/guide/ngmodules

路由

传统的路由功能完全是由后端提供的,但是在单页面应用中,在页面中点击 URL 时,将会首先被前端程序拦截,如果前端程序能处理这个 URL,那就会直接在前端处理,而不会向后端发送这个请求。

前端可以根据这个 URL 修改视图,给用户与后端路由一样的结果,但省去了网络交互的过程,因此会显得非常快捷。

路由是业务功能的天然边界,善用路由对于改善代码结构和可维护性是很有帮助的。

在 Angular 中,路由还同时提供了惰性加载等特性,因此,早期对路由进行合理规划非常重要。不过也不用过于担心,Angular 中重新划分路由的代价并不高。

参见 https://angular.cn/guide/router#appendix-emlocationstrategyem-and-browser-url-styles

模板与视图

你可以把模板看做 JSP,主要区别是 JSP 是后端渲染的,每次生成都需要一次网络交互,而模板是前端渲染的,在浏览器中执行模板编译成的 JS 来改变外观和响应事件。

模板语法

虽然看起来奇怪,但 [prop](click)*ngFor 等模板语法中的特殊符号都是完全合法的 HTML 属性名,实际上,属性名中只禁用各类空白字符、单双引号等少数几个显而易见的无效字符(正则:[^\t\n\f \/>"'=])。

参见 https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#syntax-attribute-name

属性与……属性

由于历史原因,英文的 Attribute 和 Property 都被译为属性,然而两者是截然不同的。Angular 中的常规绑定语法针对的都是 Property,只有 [attr.xxx] 绑定针对的是 Attribute。

参见 https://angular.cn/guide/template-syntax#html-attribute-vs-dom-property

组件与指令

你可以把组件看做后端模板中的 taglib,区别是它们运行在浏览器中而不是服务端。组件与指令在用途上的区别是,组件充当搭建界面的砖块,它的地位和 HTML 元素并无区别;而指令用于为 HTML 元素(包括组件)添加能力或改变行为。

所以,组件中不应该操纵 DOM,只应该关注视图模型,而指令负责在模型和 DOM 之间建立联系。指令应该是单一职责的,如果需要完成多个职责,请拆成多个指令附加到同一个元素上。

服务与依赖注入

Angular 的服务与依赖注入和 Spring 中的很像,主要的区别是 Angular 是个树状的多级注入体系,注入器树是和组件树一一对应的,当组件要查找特定的服务时,会从该组件逐级向上查找,直到根部。

这实际上是职责链模式。当前组件找不到某个服务时,就会委托给其父节点来查找。和策略模式结合使用,组件就可以通过自己提供一个服务来替换父组件提供的服务,实现一种支持默认处理的逻辑。

参见 https://angular.cn/guide/hierarchical-dependency-injection

表单与验证

在前端程序中,验证主要是为了用户友好性,而不是安全。安全是后端的工作,不能因为前端做了验证而放松。

Angular 对表单提供了非常强力的支持。如果你的应用中存在大量表单、大型表单、可复用表单或交互比较复杂的表单,那么 Angular 的表单功能可以为你提供强大的助力。

Angular 的表单提供了不同层级的抽象,让你可以在开发中轻松分离开显示、校验、报错等不同的关注点。也让你可以先用文本框快速搭出一个表单,将来再逐个把这些文本框替换成自定义编辑框,而不会破坏客户代码。

参见 https://angular.cn/guide/user-input

测试

Angular 对测试的支持非常全面,可以实现各个不同层次的测试。

但是不要因为拿到把这么好用的锤子就满世界敲。要根据不同的价值需求去决定测什么不测什么。

别忘了每个 Angular 的类,无论服务、组件、指令还是管道等,都是 POJO,你可以用测 POJO 的方式测试它们,得到毫秒级反馈,而且这往往会更高效。

参见 https://angular.cn/guide/testing。但要记住:虽然 Angular 支持这么多种方式,但你不一定要用到这么多种方式。

安全

在 Angular 中,你不会无意间造成安全隐患。只要注意一点就够了:DomSanitizer.bypassSecurityTrust* 要慎用,务必确保传给它的东西不可能被攻击者定制,必要时请找安全专家推演。参见 https://angular.cn/guide/security#sanitization-and-security-contexts。

如果你在发起 POST 等请求时收到了 403 错误,那可能是因为后端开启了 CSRF 防护。Angular 内置了一个约定 —— 如果服务端 csrf token 的cookie名是 XSRF-TOKEN,并且能识别一个名叫 X-XSRF-TOKEN 的请求头,那么它就会自动帮你完成 CSRF 验证。当然,你也可以自定义这些名称来适配后端,参见 https://angular.cn/guide/http#configuring-custom-cookieheader-names

跨域与反向代理

本地开发时,前端有自己的服务器,显然无法与后端 API 服务器运行在同一个端口上,这样就导致了跨域问题。要解决跨域问题,主要有 CORS 和反向代理这两种方式。CORS 是标准化的,但是受浏览器兼容性的影响较大;而反向代理则通过把 API “拉”到前端的同一个域下,从根本上消除了跨域访问。

开发时,Angular CLI 内置了对反向代理的支持;部署时,各个主流 Web 服务器都能很好地支持反向代理。

一般项目中建议还是优先使用反向代理的方式。

(图片来自:http://t.cn/RgsWKEJ

杂谈

你不必写 CSS

很多后端初学前端时会被卡在 CSS 上,在心里喊一句 WTF。但实际上,在团队开发中,你可能根本不必写 CSS。

现在已经有了很多现成的 CSS 库,比如已经熟透的 Bootstrap,还有后起之秀 Material Design、Ant Design 等等。你只要能根据其表达的视觉含义,正确套用它们定义的 CSS 类就够了。尽量不要自己手写 CSS,否则可能反倒会给将来的页面美化工作带来困扰。

选好了基础框架,并且和 UX 对齐之后,团队中只需要一个 CSS 高手就能实现所有的全局性设计规则。对于全栈工程师来说,充其量只有对当前页面中的少量元素进行定制时才需要写 CSS,况且还可以通过找人 pair 来解决偶尔碰到的难题。

全栈,让设计更简单

前后端技术各有所长,有些事情用前端实现更简单,有些用后端实现更简单。综合考量前端技术和后端技术,往往可以产生更简单、更优秀的设计。广度在业务开发中往往比深度有用,这也是全栈工程师的优势所在。而团队中的技术专家主要负责深度。

分工是动态的

技术专家或全栈工程师,并不是什么荣誉头衔,只是分工不同而已。

同一个项目上你可以同时担任全栈工程师和技术专家;这个项目你是全栈工程师,下一个项目上也可能专门担任技术专家。团队的协作方式永远是动态的、随需应变的。

不用担心全栈会限制你的技术深度,实际上,全栈对提高你的技术深度是有帮助的,因为很多技术的“根”都是互通的。

相信你的直觉

资深后端首先是一个资深程序员,你对于“应该如何”的期待,很可能是对的。如果你觉得 Angular 应该有某项功能或某种设计,它很可能确实有。去 Stackoverflow 搜一下,找找你的答案,这是你成为高级 Angular 程序员的捷径。

万法归一

形容某人聪明时经常说“万法皆通”,实际上“万法皆通”不如“一法通而万法通”。很多技术之间的相似程度超出你的想象,这些相似的部分其实就是技术的核心。用万法归一的思路去学习总结,会给你带来真正的提高。

资料 & 学习指南

学习 Angular 的最佳资料是它的官方文档,它无论是从准确、全面,还是及时性等方面都是最佳的。

它的英文文档站是 https://angular.io,中文文档站是 https://angular.cn,这是由我和另外两位社区志愿者共同翻译的,期间还得到了很多社区志愿者的支持。中文文档和英文文档至少在每个大版本都会进行一次同步翻译。虽然时间有限导致语言上还有粗糙之处,不过你可以相信它的技术准确度是没问题的。

阅读时,请先阅读架构概览 https://angular.cn/guide/architecture,然后阅读教程 https://angular.cn/tutorial(有经验的程序员不需要跟着敲代码,如果时间紧也可跳过),最后阅读风格指南 https://angular.cn/guide/styleguide。风格指南很重要,不用记住,但务必通读一遍,有点印象供将来查阅即可。

文档站中还提供了 API 参考手册,它提供了简单快速的站内搜索功能,需要了解哪些细节时到里面查就可以了。

另外,ng-zorro 组件库的一位开发者还整理了一份不完全指南,包括中英文资料:https://zhuanlan.zhihu.com/p/36385830


更多精彩洞见,请关注微信公众号:思特沃克

Share

前端不止:Web性能优化 – 关键渲染路径以及优化策略

我问你:“当你从搜索引擎的结果页面选择打开一条搜索结果时,你觉得多长时间之后,如果页面还处于白屏或者没有加载到关键信息,你会选择关掉这个窗口?”

《Designing for Performance》的作者 Lara Swanson 在2014年写过一篇文章《Web性能即用户体验》,她在文中提到“网站页面的快速加载,能够建立用户对网站的信任,增加回访率,大部分的用户其实都期待页面能够在2秒内加载完成,而当超过3秒以后,就会有接近40%的用户离开你的网站”。

Google和亚马逊的研究表明,Google页面加载的时间从0.4秒提升到0.9秒导致丢失了20%流量和广告收入,对于亚马逊,页面加载时间每增加100毫秒就意味着1%的销售额损失。可见,页面的加载速度对于用户可能的下一步操作是多么的举足轻重。

想一想,如果你希望你的网站在一秒钟之内呈现用户想看的关键信息,有哪些可行的手段?Minify,压缩,雪碧图等等。

Google的Web性能工程师 Ilya Grigorik 会告诉你,你只需要理解浏览器的关键渲染路径。

页面性能可能是一个感性的东西

页面的性能,看似是一个理性和量化的概念,实则也来自于用户的感知,主观的评价,是一个偏感性的东西。

(参考自Google关键渲染路径)

如果页面可以做到优先显示与用户操作有关的内容,就可以让用户更快速的感知到操作得到响应,这个过程叫做“优化关键渲染路径”。

什么是关键渲染路径

我记得,有一个非常经典的面试题叫做:《当浏览器地址栏输入URL并回车后,发生了什么?》。

关键渲染路径就是描述浏览器从收到 HTML、CSS 和 JavaScript 字节开始,到如何使用HTML、CSS 和 JavaScript 在屏幕上渲染像素的中间过程。

如果我们能够优化这条路径,就能让页面更快速的展示内容,给用户更好的体验。

全景图

我们先尝试站在高处,看一眼关键渲染路径的全景图,这样能够快速的领略一个大致轮廓和一些关键概念。

文档对象模型 (DOM)

DOM概念之于Web开发人员再熟悉不过了,当浏览器发出请求并接收到HTML文档后,它会有这样一个流程来构建DOM:字节 → 字符 → 令牌 → 节点 → 对象模型。

以下面这段代码为例:

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>

浏览器接收到HTML请求的返回结果,根据预定的流程解析HTML,文档中的“开标签”,比如<html><head>等会转换成一个令牌(Token),然后令牌转换成节点对象(Node)。

这个令牌解析并转换为节点对象的过程,也是每个节点建立关系(树形结构)的过程。例如:head的令牌出现在html令牌之后,但其闭标签出现在html闭标签之前,这就意味着headhtml的子节点,以此类推,建立节点的父子关系。

这个过程在浏览器中,叫做“Parse HTML”。

CSS 对象模型 (CSSOM)

当DOM捕获了页面的内容,我们还需要知道页面如何展示这些内容,所以需要构建CSS 对象模型(CSSOM)。

浏览器解析DOM,遇到了link标签,发现它引用了一个外部样式资源:style.css,于是浏览器会向外部请求样式资源,然后进行后续的DOM构建工作。

CSS 被视为阻塞渲染的资源,这意味着浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕。

CSSOM有着一个和DOM构建相似的流程:字节 → 字符 → 令牌 → 节点 → CSS对象模型。

以下面的CSS样式为例,它会根据具体解析规则,将CSS文档转换成下面的树形结构:

body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }

这种树形结构让CSS有层级继承关系,子节点会继承父节点的样式。

前面谈到CSS会阻塞浏览器的渲染过程,因为渲染树的构建同时需要DOM和CSSOM,所以当浏览器请求的style.css返回之后,浏览器会开始解析样式表,并重新计算样式(Recalculate Style),将CSS转换成CSSOM,然后进行后续的操作。

值得注意的是,CSSOM运算是一个非常复杂的过程,性能消耗会比较大,所以你会常常听到“老人们”说写样式“尽量使用classid,保证层级扁平,减少过度层叠”,而且越是通用的CSS样式,执行速度越快,越是具体(选择器)的CSS样式,则执行速度越慢。

DOM + CSSOM = 渲染树

渲染树和DOM树不同,它只会捕获一些页面上可见的元素,比如,Headerdisplay:none的元素不会放在渲染树中。

渲染树的构建会从DOM的根节点开始遍历,对于不可见节点会忽略,然后在CSSOM中找到每个对应节点的样式规则并应用,最后输出的渲染树会包含所有的可见内容和样式信息,如下图:

布局和绘制

有了渲染树,浏览器会进入布局和绘制阶段。

布局就是弄清每个对象在页面视窗(Viewport)上的确切大小和位置,它的输出是一个“盒模型”,里面准确的捕获每一个元素在页面视窗中的位置和尺寸。

在布局工作完成之后,浏览器会开始绘制,将渲染树转换成屏幕上的像素,这样,我们就能在浏览器中看到页面的内容。

短暂回顾一下“关键渲染路径”的步骤

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树。
  3. 将 DOM 与 CSSOM 合并成一个渲染树。
  4. 根据渲染树来布局。
  5. 将各个节点绘制到屏幕上。

当DOM或者CSSOM发生变化的时候,浏览器就需要再次执行一次上面的步骤。

JavaScript

到目前为止,我们还没涉及到JavaScript,但它在整个关键渲染路径中扮演着非常重要的角色,就如全景图中画的那样,我们从一段简单的代码开始:

<body>
   <p>Hello <span>web performance</span> students!</p>
   <script>
     var span = document.getElementsByTagName('span')[0];
     span.textContent = 'javascript';
   </script>
</body>

一个大家都知道的重要事实是:脚本在文档的何处插入,就在何处执行。

当HTML解析过程中遇到一个script标记时,它会暂停DOM构建,将控制权移交给JavaScript引擎,等JavaScript引擎运行完毕,浏览器再从中断的地方恢复DOM构建。也就是说,执行内联的JavaScript会阻塞页面的首次渲染。

现在我们假设,这段JavaScript是外部资源。

<body>
   <p>Hello <span>web performance</span> students!</p>
   <script src="write.js"></script>
</body>

则浏览器的渲染会阻塞直到write.js的请求返回后,并执行JavaScript后,继续。

需要注意的是,在网页中引入JavaScript脚本有一个微妙事实,就是JavaScript不仅可以读取和修改DOM属性,还可以读取和修改CSSOM属性。

前面我们提到CSS是阻塞渲染的资源,当它和JavaScript一起出现在页面上时,会发生这样的事情:

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path: Script</title>
  </head>
  <body>
    <p>Hello <script>document.write('web performance')</script> students!</p>
  </body>
</html>

在浏览器解析HTML构建DOM过程中,发现了link标签,于是发出请求获取style.css,然后继续构建DOM,此时,它发现script标签,由于JavaScript可能会访问样式属性,所以它会阻止JavaScript的执行直到styles.css返回并完成CSSOM构建,然后执行这一段JavaScript代码,再继续后面DOM的构建和相关渲染操作。

于是styles.css的请求不仅阻塞后面的渲染,还阻塞了DOM的构建。

如果将这段JavaScript作为外部资源,就是一个比较典型的页面结构:

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link href="style.css" rel="stylesheet">
    <title>Critical Path Render</title>
  </head>
  <body>
    <p>Hello <span>web performance</span></p>
    <script src="app.js"></script>
  </body>
</html>

JavaScript和CSS资源请求是并行的,但仍然需要等到CSSOM构建完成之后,JavaScript才可以执行,然后在进行后面的渲染工作。于是,当 DOM、CSSOM 和 JavaScript 执行之间有大量的依赖关系时,就很可能导致浏览器在处理及渲染网页时出现延迟。

优化策略

我们花了大量的篇幅来理解浏览器的渲染过程,理解DOM,CSSOM,渲染树,浏览器绘制,分析HTML,CSS和JS在渲染过程中的关系,我相信你已然受益匪浅,现在,我们来运用这些知识加速你的网站。

第一步,分析你的网站渲染状况

我们以Google为例,通过Chrome的Performance工具查看页面渲染情况,如下图,你应该可以清晰的看到图中有四条竖线,他们分别是什么含义呢?

(Google主页的性能分析情况)

  1. 绿色竖线,代表First Paint,即浏览器开始进行像素的绘制
  2. 黄色竖线,代表First Meaningful Paint(首次有效绘制)用户可以开始看到部分内容,但绘制仍在继续
  3. 蓝色竖线,代表大家比较熟悉的DOMContentLoaded
  4. 红色竖线,代表load,页面加载完成

优秀的网站都能够把“首次有效渲染”做到1秒之内完成,这样能够让用户更快的看到所请求的页面得到响应。如果你的网站“首次有效渲染”超过1秒,那么就非常有必要重新分析一下网站的关键渲染路径是否合理。

第二步,分析关键渲染路径

在关键渲染路径中,我们通常要关注三个点:

  1. 页面首次渲染需要的关键资源数量
  2. 关键资源的大小
  3. 关键渲染路径的往返次数(Roundtrip)

我们的策略也非常简单,就是减少关键资源数量,降低资源大小,减少关键路径的往返次数。

关键渲染的资源一般是阻止屏幕首次渲染HTML,CSS和JavaScript,所以最重要也是最难的部分的是你需要根据自己网站的实际情况分析,哪些是页面绘制的所必须的,哪些是无关的。

第三步,根据分析采取优化手段

1、减少关键资源的大小

我们首先从最简单也是最直接的减少关键资源的大小开始:

对于所有的资源(HTML,JavaScript,CSS,Image等),你都应该用上三大绝招:Minify,Compression和Cache,这里不过多的赘述里面的细节。

这一点对于HTML来说,非常关键,HTML作为渲染的关键资源,消除或者延迟加载肯定不太可能(这里指的是非局部渲染的关键HTML),能够做到是消除无用代码(比如:注释)和最小化代码(Minify)以及动态局部渲染等。

(Google对页面的HTML进行了压缩)

2、延迟JavaScript非阻塞资源加载

JavaScript和CSS都是阻塞渲染的资源,对于已经鉴别出的对于首次渲染没有起到关键作用的代码,我们首先想到的是要延迟它的加载,让它脱离关键渲染路径。

首先,对于阻塞渲染的JavaScript,应该将它放置在页面body的底部,为什么呢?

JavaScript可以查询和操作DOM和CSSOM,正如前面介绍的,HTML解析过程中构建DOM,当遇到JavaScript就停止DOM构建执行JavaScript,如果被执行的JavaScript是放置在head附近,那么很可能要被操作或者查询的DOM还没有构建到DOM当中。

而对于,非阻塞渲染的JavaScript,我们应该采用异步的方式加载,如下:

  <script src="script.js"></script>
  <script async src="script.js"></script>
  <script defer src="myscript.js"></script>
</body>
</html>

方式一:即阻塞的JavaScript,HTML解析过程中遇到script标签,发出网络请求获取script.js,在网络请求返回后,解析并执行script.js,然后浏览器继续HTML解析。

方式二:async,完全的异步操作,HTML解析遇到该标签后,发出网络请求,但不阻止HTML解析和其后面的渲染操作,当JavaScript请求返回后立刻执行,且不等待HTML解析或其他操作的完成。所以,如果脚本中有DOM操作,就并不适合。比较适合的场景是Google Analytics。

方式三:defer,HTML的解析和对JavaScript资源的网络请求是并行的,但它会等待HTML解析完成之后,才执行脚本。

(图片参考自:Asynchronous and deferred JavaScript execution explained « Peter Beverloo)

不过,async和defer,他们对浏览器的兼容性有一定的要求,但仍然应该使用它们,同时可以采用退而求其次的延迟代码执行的方法(比如:on DOMContentLoad后),特别是与首次渲染无关的计算逻辑和功能。

3、尽早和按需的加载CSS

你可能在思考,有没有异步加载CSS的需求?我认为不应该有,页面应该只引用与该页面相关的样式文件。(只不过很多时候,我们将所有的CSS都打包在了一个压缩的CSS文件中了。)目前,已经有许多帮助你分析关键渲染路径上的所需要的CSS的工具:grunt-criticalcsscriticalcriticalCSS,在线CRPCSS工具。

前面已经提到,CSS是阻塞渲染的资源,在CSSOM完全解析完成之前,浏览器不可能开始屏幕的绘画。

所以,我们应该尽早的开始对样式资源的请求,将它尽早、尽快地下载到客户端,这样解释了为什么我们看到样式资源的link标签一般都放在head表中:

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Sample Site</title>
  <link href="style.css" rel="stylesheet">
</head>

CSSOM的运算是一个非常复杂和相对耗时的过程,但它也有一个特点,就是可能只有在特定的情况下才会起作用,比如:响应式设计的页面。

对于响应式页面,我们可以考虑将不同媒体上的样式分离,在<link>中使用媒体查询,浏览器仍然会下载对应的资源,但是可以避免不必要的CSSOM解析导致对渲染的阻塞。

<link href="style.css" rel="stylesheet" media="all">
<link href="portrait.css" rel="stylesheet" media="orientation:portrait">
<link href="print.css" rel="stylesheet" media="print">

同时,我们还应该避免在首次渲染的CSS样式中使用@import指令,因为它只有在收到并解析完带有@import规则的CSS资源之后,才会发现导入的 CSS 资源,这个时候就会重新请求,从而增加了关键渲染路径的往返次数。

4、内联CSS来提高渲染性能

到目前为止,我们已经做到了识别关键渲染资源,将非关键资源延迟加载或者不加载。那么,减少关键路径的往返次数是什么意思?其实就是减少关键渲染资源从服务器端到客户端的往返次数。比如,外链的JS和CSS文件以前CSS的@import,在页面渲染的过程中,都会重新去服务器端请求。这其实,和我们常说的减少http请求量(合并http请求)类似,但是我么从渲染路径的角度来理解这样一种性能的消耗。

根据这样的逻辑,我们很容易就想到可以将渲染必备CSS内联到HTML中,来减少渲染路径的往返次数。

实际上不少的优秀网站都采用了在head内联样式的做法:Google,百度,淘宝,京东。

(百度和Google将样式inline在head中)

关于内联样式还有更进一步的做法,在文章的一开始就提到,优化关键渲染路径就是要优先显示和用户先关内容。

所以,我们可以考虑仅仅将当前屏幕展示的内容(above-the-fold,一屏)所需的CSS内联到HTML的head中,然后采用异步的方式加载整个页面所需要的完整CSS,以便用户能够更快的看到首屏出现的内容。(inlining-critical-css-for-better-web-performance

5、一个神奇的数字14kb

在最开始我们提到,要减小关键资源的大小,那么多小比较合适呢?(废话,当然是越小越好)。

其实,有一个神奇的数字14kb,它是怎么来?

HTTP的传输层协议是TCP,TCP协议有一个慢启动的过程,即它在第一次传递数据时,只能同时传递14kb的数据块,所以当数据超多14kb时,TCP协议传递数据实际是多次的往返(roundtrip)。如果能够将渲染所需要的资源控制在14kb之内,那么就能TCP协议启动时,一次完成数据的传递。

其他Web资源和关键渲染路径的关系

你一定会思考,除了HTML,JavaScript和CSS,Web页面还包含许多其他的资源,比如:图片,网络字体(Icon Font),他们和关键渲染路径的关系是什么?

大家对图片加载感受都应该大致一样,它会在页面加载过程中或完成后,逐步显示,也就是说它不是阻塞渲染的资源,它的痛点主要在于质量和资源大小的权衡,以及请求数量带来的性能消耗(雪碧图)。

网络字体,在网络加载比较慢的情况下,用户可能会感受到字体或者图形的变化(Icon Font)。其实,浏览器在渲染树构建完成之后,会指示需要哪些字体在网页上渲染指定文本,然后分派字体请求,浏览器执行布局并将内容绘制到屏幕上,如果字体尚不可用,浏览器可能不会渲染任何文本像素,待字体可用之后,再绘制文本像素,当然,不同浏览器之间实际行为有所差异,这里不在赘述,请参考文章尾部的资料链接。

总结

优化关键渲染路径的最终目的是优先显示和用户操作相关的内容,减少低优先级资源对浏览器渲染的阻塞,从而尽早显示用户真正关心的关键内容。页面性能就是用户体验的一个重要维度,尝试用感性的思维去思考理性的代码,也许真的能受益不少。


参考资料:

  1. Google关键渲染路径
  2. 《Web性能即用户体验》
  3. https://peter.sh/experiments/asynchronous-and-deferred-javascript-execution-explained/

更多精彩洞见,请关注微信公众号:思特沃克

Share

WebAssembly:系统编程语言的逆袭

引子

Any application that can be written in JavaScript, will eventually be written in JavaScript. ——Atwood‘s Law

有人用 JavaScript 做语法词法解析,有人写了 x86 模拟器, 还有人用 JavaScript 写了可自举的 JavaScript 引擎。JavaScript 早已经在”重新发明一切”的路上一骑绝尘了,JavaScript 的流行也使它始终位于各大语言排行榜上的前列,这无疑是属于 JavaScript 程序yuan们最好的时代。

这并非是因为 JavaScript 是门优秀的语言 (恰恰相反),而是因为当今的世界是 Web 的世界,Web 的载体浏览器只会说 JavaScript。这难免使人眼红,王侯将相,世人无数次想要取代 JavaScript 的地位,目前为止的历史我们都看到了,无一不铩羽而归。求上不得,得其中,最新成果是大家(伙儿)齐心协力把 JavaScript 变成了新一代的汇编语言。请移步这里看大家的最新成果。

WebAssembly

去年 11 月 13 日,Mozilla 在其官方博客上发表了一篇文章,WebAssembly support now shipping in all major browsers,指出当今世界四大主流浏览器 Firefox,Chrome,Safari,Edge(排名分先后),都已经支持了名为 WebAssembly 的新技术,并回顾了一路走来的艰难历程。最后指出这是新时代的开端,大家一起欢呼吧。那么,WebAssembly 到底是啥?让我们发出发聋振聩的三连问:

可以吃吗?

请移步 WebAssembly 官网。官网解释如下:

WebAssembly or wasm is a new portable, size- and load-time-efficient format suitable for compilation to the web.

关键词:

  1. 可移植: WebAssembly 是一种可移植的二进制格式,它不依赖于具体的浏览器平台。
  2. 高效:WebAssembly 被设计为针对 Size 和 Load Time 进行优化的格式,可以在各个硬件平台上以 native speed 运行。
  3. 安全:WebAssembly 是运行在沙盒内的,甚至可以和当前的 JavaScript 虚拟机共享一套环境,并且也遵守浏览器各种跨域不跨域的规章制度。
  4. 开放:WebAssembly 是开放标准,不受某一家厂商控制(Oracle你听到了吗)。并且被设计为可以和 JavaScript API 和 Context 交互。

简而言之,WebAssembly 可以被看做是通过浏览器运行的某种高效的开放的二进制格式,并且可以和 JavaScript 环境互通。

WebAssembly 的目的是取代 JavaScript 吗?FAQ 这样回答:不,WebAssembly 是被设计来补充而不是替代 JavaScript。随着时间推移,越来越多的语言可以被编译为 WebAssembly,但是 JavaScript 还是作为 Web 唯一的动态语言而存在。

这样看来老二的位置摆得很正嘛。对于 WebAssembly, 笔者最看重的一点是作为开放标准的同时有粗大腿的支持 (M$ Google Apple Mozilla),这才是它有可能活下来的原因。问题是可以吃吗,答案当然是可以吃(佛系码农也可以不吃)。

怎么吃?

WebAssembly 同时存在一个二进制格式和一个文本的描述格式,这很像是机器语言和汇编语言的关系。这里我们用一个例子解释一下。

事实上,WebAssembly 可以被看作是运行在一个 structured stack virtual machine 里,懂行的朋友一眼就可以看出这和 Java bytecode 非常的像。所以大家不要以为 WebAssembly 是在重新发明 Flash 了,这货明明是在重新发明 Java Applet 啊,好吧 Silverlight 也有点像…。顺带一提,Android 的 Dalvik 为了效率,使用的是 register-based virtual machine。对 WebAssembly spec 感兴趣的朋友可以移步这里

作为 WebAssembly 的 MVP,C/C++ 及其类库的支持是首当其冲的。因为基于 LLVM 的平台,所以理论 LLVM 支持的语言都可以编译为 WebAssembly,C/C++,rust,甚至 .net 和 Java 也可以编译到 WebAssembly,只不过托管语言都需要附带一个巨大的runtime。

下面我们以 C/C++ 为例,我们写一个函数给 JavaScript 使用。

步骤:

  • 安装 WebAssembly 构建工具链 emscripten,针对 macOS,请参考这里
  • 安装后,执行 emcc --version 判断是否成功
  • 创建 C++ source:cat random.cc

include <random>

include <cmath>

extern “C” {

long normal_rand() {

static std::random_device rd{};
static std::mt19937 gen{rd()};
return std::lround(std::normal_distribution&amp;amp;lt;&amp;amp;gt;(0, 100)(gen));

}

} `

这里用 C++ 产生一个正态分布,期望为0,方差100的随机数,然后导出为一个 C 函数 normal_rand

执行 emcc --bind -std=c++14 --emrun -s WASM=1 -s EXPORTED_FUNCTIONS='["_normal_rand"]' -O3 -o random.html random.cc 顺利的话会在当前目录生成如下文件

`$ ls -l
total 496
-rw-r--r--  1 haoli  staff     810 Dec 24 21:44 random.cc
-rw-r--r--  1 haoli  staff  102728 Dec 24 22:17 random.html
-rw-r--r--  1 haoli  staff  120624 Dec 24 22:17 random.js
-rw-r--r--  1 haoli  staff   20130 Dec 24 22:17 random.wasm

random.wasm 就是我们的 WebAssembly,random.jsrandom.html 是模板代码,帮助我们加载 WebAssembly。

执行 emrun --no_browser --port 8821 random.html 启动一个 WebServer

用支持 WebAssembly 的浏览器访问http://localhost:8821/random.html,然后在 console 里面执行 Module._normal_rand() 即可看到结果

怎么做好吃?

古往今来,在浏览器里面尝试改善 JavaScript 性能和增强功能的尝试大约都失败了吧,前有 ActiveX,Java Applet,Flash,后有 Silverlight,Flex,NaCl。WebAssembly 应该是各个浏览器大佬的最新尝试。不过这次大家都学乖了,没人指(xi)望一个私有标准会成功,于是联合起来开发一个开放的标准。

至少目前看来,结果还是很让人欣喜的。因为开放标准的缘故,除了上面的 emscripten,还有大量的工具开始支持 WebAssembly,甚至 clang 可以直接指定 target 为 WebAssembly。加上浏览器把诸如 DOM API 以及 WebGL API 都暴露给了 WebAssembly,应用场景相当可观。首当其冲的就是游戏厂商,EpicUnity 都是 WebAssembly 的早期尝试者,他们已经把自己的游戏引擎移植到 Web 平台而不用重写代码。不仅如此, WebAssembly 还支持 non-web 的场景,比如 NodeJs 也开始支持 WebAssembly。WebAssembly 官网有个 use case 清单,列举了可能的应用场景。图形图像的处理,计算机辅助设计,AR/VR,VPN,加解密等等。到那时,前端可以玩出的花样,想象空间实在太大。

有点过于美好了哈,我们还是就此打住,拭目以待吧。

Reference

这里列举一些 WebAssembly 相关的资源,各位随喜:

  1. Funky Karts, 移植到 WebAssembly 的网页游戏,作者在网站记录了学习 WebAssembly 到移植成功的全过程。
  2. WasmExplorer,在线的 C/C++ to WebAssembly 编辑器,同时也可以查看 Assembly 内容。
  3. WasmFiddle,另一个在线编辑 WebAssembly 的工具
  4. WasmRocks WebAssembly 新闻站
  5. emscripten 官网

更多精彩洞见,请关注微信公众号:思特沃克

Share