8大前端安全问题(下)

《8大前端安全问题(上)》这篇文章里我们谈到了什么是前端安全问题,并且介绍了其中的4大典型安全问题,本篇文章将介绍剩下的4大前端安全问题,它们分别是:

  • 防火防盗防猪队友:不安全的第三方依赖包
  • 用了HTTPS也可能掉坑里
  • 本地存储数据泄露
  • 缺乏静态资源完整性校验

防火防盗防猪队友:不安全的第三方依赖包

现如今进行应用开发,就好比站在巨人的肩膀上写代码。据统计,一个应用有将近80%的代码其实是来自于第三方组件、依赖的类库等,而应用自身的代码其实只占了20%左右。无论是后端服务器应用还是前端应用开发,绝大多数时候我们都是在借助开发框架和各种类库进行快速开发。

这样做的好处显而易见,但是与此同时安全风险也在不断累积——应用使用了如此多的第三方代码,不论应用自己的代码的安全性有多高,一旦这些来自第三方的代码有安全漏洞,那么对应用整体的安全性依然会造成严峻的挑战。

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

举个例子,jQuery就存在多个已知安全漏洞,例如jQuery issue 2432,使得应用存在被XSS攻击的可能。而Node.js也有一些已知的安全漏洞,比如CVE-2017-11499,可能导致前端应用受到DoS攻击。另外,对于前端应用而言,除使用到的前端开发框架之外,通常还会依赖不少Node组件包,它们可能也有安全漏洞。

手动检查这些第三方代码有没有安全问题是个苦差事,主要是因为应用依赖的这些组件数量众多,手工检查太耗时,好在有自动化的工具可以使用,比如NSP(Node Security Platform),Snyk等等。

用了HTTPS也可能掉坑里

为了保护信息在传输过程中不被泄露,保证传输安全,使用TLS或者通俗的讲,使用HTTPS已经是当今的标准配置了。然而事情并没有这么简单,即使是服务器端开启了HTTPS,也还是存在安全隐患,黑客可以利用SSL Stripping这种攻击手段,强制让HTTPS降级回HTTP,从而继续进行中间人攻击。

问题的本质在于浏览器发出去第一次请求就被攻击者拦截了下来并做了修改,根本不给浏览器和服务器进行HTTPS通信的机会。大致过程如下,用户在浏览器里输入URL的时候往往不是从https://开始的,而是直接从域名开始输入,随后浏览器向服务器发起HTTP通信,然而由于攻击者的存在,它把服务器端返回的跳转到HTTPS页面的响应拦截了,并且代替客户端和服务器端进行后续的通信。由于这一切都是暗中进行的,所以使用前端应用的用户对此毫无察觉。

解决这个安全问题的办法是使用HSTS(HTTP Strict Transport Security),它通过下面这个HTTP Header以及一个预加载的清单,来告知浏览器在和网站进行通信的时候强制性的使用HTTPS,而不是通过明文的HTTP进行通信:

Strict-Transport-Security: max-age=<seconds>; includeSubDomains; preload

这里的“强制性”表现为浏览器无论在何种情况下都直接向服务器端发起HTTPS请求,而不再像以往那样从HTTP跳转到HTTPS。另外,当遇到证书或者链接不安全的时候,则首先警告用户,并且不再让用户选择是否继续进行不安全的通信。

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

本地存储数据泄露

以前,对于一个Web应用而言,在前端通过Cookie存储少量用户信息就足够支撑应用的正常运行了。然而随着前后端分离,尤其是后端服务无状态化架构风格的兴起,伴随着SPA应用的大量出现,存储在前端也就是用户浏览器中的数据量也在逐渐增多。

前端应用是完全暴露在用户以及攻击者面前的,在前端存储任何敏感、机密的数据,都会面临泄露的风险,就算是在前端通过JS脚本对数据进行加密基本也无济于事。

举个例子来说明,假设你的前端应用想要支持离线模式,使得用户在离线情况下依然可以使用你的应用,这就意味着你需要在本地存储用户相关的一些数据,比如说电子邮箱地址、手机号、家庭住址等PII(Personal Identifiable Information)信息,或许还有历史账单、消费记录等数据。

尽管有浏览器的同源策略限制,但是如果前端应用有XSS漏洞,那么本地存储的所有数据就都可能被攻击者的JS脚本读取到。如果用户在公用电脑上使用了这个前端应用,那么当用户离开后,这些数据是否也被彻底清除了呢?前端对数据加密后再存储看上去是个防御办法,但其实仅仅提高了一点攻击门槛而已,因为加密所用到的密钥同样存储在前端,有耐心的攻击者依然可以攻破加密这道关卡。

所以,在前端存储敏感、机密信息始终都是一件危险的事情,推荐的做法是尽可能不在前端存这些数据。

缺乏静态资源完整性校验

出于性能考虑,前端应用通常会把一些静态资源存放到CDN(Content Delivery Networks)上面,例如Javascript脚本和Stylesheet文件。这么做可以显著提高前端应用的访问速度,但与此同时却也隐含了一个新的安全风险。

如果攻击者劫持了CDN,或者对CDN中的资源进行了污染,那么我们的前端应用拿到的就是有问题的JS脚本或者Stylesheet文件,使得攻击者可以肆意篡改我们的前端页面,对用户实施攻击。这种攻击方式造成的效果和XSS跨站脚本攻击有些相似,不过不同点在于攻击者是从CDN开始实施的攻击,而传统的XSS攻击则是从有用户输入的地方开始下手的。

防御这种攻击的办法是使用浏览器提供的SRI(Subresource Integrity)功能。顾名思义,这里的Subresource指的就是HTML页面中通过<script><link>元素所指定的资源文件。

每个资源文件都可以有一个SRI值,就像下面这样。它由两部分组成,减号(-)左侧是生成SRI值用到的哈希算法名,右侧是经过Base64编码后的该资源文件的Hash值。

<script src=“https://example.js” integrity=“sha384-eivAQsRgJIi2KsTdSnfoEGIRTo25NCAqjNJNZalV63WKX3Y51adIzLT4So1pk5tX”></script>

浏览器在处理这个script元素的时候,就会检查对应的JS脚本文件的完整性,看其是否和script元素中integrity属性指定的SRI值一致,如果不匹配,浏览器则会中止对这个JS脚本的处理。

小结

在上一篇和本篇文章中,我们为大家介绍了在开发前端应用的时候容易遇到的8大安全问题,它们是:

  • 老生常谈的XSS
  • 警惕iframe带来的风险
  • 别被点击劫持了
  • 错误的内容推断
  • 防火防盗防猪队友:不安全的第三方依赖包
  • 用了HTTPS也可能掉坑里
  • 本地存储数据泄露
  • 缺乏静态资源完整性校验

我们希望能通过对这些问题的介绍,引起前端开发小伙伴的注意,尽可能提前绕过这些安全问题的坑。


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

Share

8大前端安全问题(上)

当我们说“前端安全问题”的时候,我们在说什么

“安全”是个很大的话题,各种安全问题的类型也是种类繁多。如果我们把安全问题按照所发生的区域来进行分类的话,那么所有发生在后端服务器、应用、服务当中的安全问题就是“后端安全问题”,所有发生在浏览器、单页面应用、Web页面当中的安全问题则算是“前端安全问题”。比如说,SQL注入漏洞发生在后端应用中,是后端安全问题,跨站脚本攻击(XSS)则是前端安全问题,因为它发生在用户的浏览器里。

除了从安全问题发生的区域来分类之外,也可以从另一个维度来判断:针对某个安全问题,团队中的哪个角色最适合来修复它?是后端开发还是前端开发?

总的来说,当我们下面在谈论“前端安全问题”的时候,我们说的是发生在浏览器、前端应用当中,或者通常由前端开发工程师来对其进行修复的安全问题。

8大前端安全问题

按照上面的分类办法,我们总结出了8大典型的前端安全问题,它们分别是:

  • 老生常谈的XSS
  • 警惕iframe带来的风险
  • 别被点击劫持了
  • 错误的内容推断
  • 防火防盗防猪队友:不安全的第三方依赖包
  • 用了HTTPS也可能掉坑里
  • 本地存储数据泄露
  • 缺失静态资源完整性校验

由于篇幅所限,本篇文章先给各位介绍前4个前端安全问题。

老生常谈的XSS

XSS是跨站脚本攻击(Cross-Site Scripting)的简称,它是个老油条了,在OWASP Web Application Top 10排行榜中长期霸榜,从未掉出过前三名。XSS这类安全问题发生的本质原因在于,浏览器错误的将攻击者提供的用户输入数据当做JavaScript脚本给执行了。

XSS有几种不同的分类办法,例如按照恶意输入的脚本是否在应用中存储,XSS被划分为“存储型XSS”和“反射型XSS”,如果按照是否和服务器有交互,又可以划分为“Server Side XSS”和“DOM based XSS”。

无论怎么分类,XSS漏洞始终是威胁用户的一个安全隐患。攻击者可以利用XSS漏洞来窃取包括用户身份信息在内的各种敏感信息、修改Web页面以欺骗用户,甚至控制受害者浏览器,或者和其他漏洞结合起来形成蠕虫攻击,等等。总之,关于XSS漏洞的利用,只有想不到没有做不到。

如何防御

防御XSS最佳的做法就是对数据进行严格的输出编码,使得攻击者提供的数据不再被浏览器认为是脚本而被误执行。例如<script>在进行HTML编码后变成了&lt;script&gt;,而这段数据就会被浏览器认为只是一段普通的字符串,而不会被当做脚本执行了。

编码也不是件容易的事情,需要根据输出数据所在的上下文来进行相应的编码。例如刚才的例子,由于数据将被放置于HTML元素中,因此进行的是HTML编码,而如果数据将被放置于URL中,则需要进行URL编码,将其变为%3Cscript%3E。此外,还有JavaScript编码、CSS编码、HTML属性编码、JSON编码等等。好在现如今的前端开发框架基本上都默认提供了前端输出编码,这大大减轻了前端开发小伙伴们的工作负担。

其他的防御措施,例如设置CSP HTTP Header、输入验证、开启浏览器XSS防御等等都是可选项,原因在于这些措施都存在被绕过的可能,并不能完全保证能防御XSS攻击。不过它们和输出编码却可以共同协作实施纵深防御策略。

你可以查阅OWASP XSS Prevention Cheat Sheet_Prevention_Cheat_Sheet),里面有关于XSS及其防御措施的详细说明。

警惕iframe带来的风险

有些时候我们的前端页面需要用到第三方提供的页面组件,通常会以iframe的方式引入。典型的例子是使用iframe在页面上添加第三方提供的广告、天气预报、社交分享插件等等。

iframe在给我们的页面带来更多丰富的内容和能力的同时,也带来了不少的安全隐患。因为iframe中的内容是由第三方来提供的,默认情况下他们不受我们的控制,他们可以在iframe中运行JavaScirpt脚本、Flash插件、弹出对话框等等,这可能会破坏前端用户体验。

如果说iframe只是有可能会给用户体验带来影响,看似风险不大,那么如果iframe中的域名因为过期而被恶意攻击者抢注,或者第三方被黑客攻破,iframe中的内容被替换掉了,从而利用用户浏览器中的安全漏洞下载安装木马、恶意勒索软件等等,这问题可就大了。

如何防御

还好在HTML5中,iframe有了一个叫做sandbox的安全属性,通过它可以对iframe的行为进行各种限制,充分实现“最小权限“原则。使用sandbox的最简单的方式就是只在iframe元素中添加上这个关键词就好,就像下面这样:

<iframe sandbox src="..."> ... </iframe>

sandbox还忠实的实现了“Secure By Default”原则,也就是说,如果你只是添加上这个属性而保持属性值为空,那么浏览器将会对iframe实施史上最严厉的调控限制,基本上来讲就是除了允许显示静态资源以外,其他什么都做不了。比如不准提交表单、不准弹窗、不准执行脚本等等,连Origin都会被强制重新分配一个唯一的值,换句话讲就是iframe中的页面访问它自己的服务器都会被算作跨域请求。

另外,sandbox也提供了丰富的配置参数,我们可以进行较为细粒度的控制。一些典型的参数如下:

  • allow-forms:允许iframe中提交form表单
  • allow-popups:允许iframe中弹出新的窗口或者标签页(例如,window.open(),showModalDialog(),target=”_blank”等等)
  • allow-scripts:允许iframe中执行JavaScript
  • allow-same-origin:允许iframe中的网页开启同源策略

更多详细的资料,可以参考iframe中关于sandbox的介绍

别被点击劫持了

有个词叫做防不胜防,我们在通过iframe使用别人提供的内容时,我们自己的页面也可能正在被不法分子放到他们精心构造的iframe或者frame当中,进行点击劫持攻击。

这是一种欺骗性比较强,同时也需要用户高度参与才能完成的一种攻击。通常的攻击步骤是这样的:

  1. 攻击者精心构造一个诱导用户点击的内容,比如Web页面小游戏
  2. 将我们的页面放入到iframe当中
  3. 利用z-index等CSS样式将这个iframe叠加到小游戏的垂直方向的正上方
  4. 把iframe设置为100%透明度
  5. 受害者访问到这个页面后,肉眼看到的是一个小游戏,如果受到诱导进行了点击的话,实际上点击到的却是iframe中的我们的页面

点击劫持的危害在于,攻击利用了受害者的用户身份,在其不知情的情况下进行一些操作。如果只是迫使用户关注某个微博账号的话,看上去仿佛还可以承受,但是如果是删除某个重要文件记录,或者窃取敏感信息,那么造成的危害可就难以承受了。

如何防御

有多种防御措施都可以防止页面遭到点击劫持攻击,例如Frame Breaking方案。一个推荐的防御方案是,使用X-Frame-Options:DENY这个HTTP Header来明确的告知浏览器,不要把当前HTTP响应中的内容在HTML Frame中显示出来。

关于点击劫持更多的细节,可以查阅OWASP Clickjacking Defense Cheat Sheet

错误的内容推断

想象这样一个攻击场景:某网站允许用户在评论里上传图片,攻击者在上传图片的时候,看似提交的是个图片文件,实则是个含有JavaScript的脚本文件。该文件逃过了文件类型校验(这涉及到了恶意文件上传这个常见安全问题,但是由于和前端相关度不高因此暂不详细介绍),在服务器里存储了下来。接下来,受害者在访问这段评论的时候,浏览器会去请求这个伪装成图片的JavaScript脚本,而此时如果浏览器错误的推断了这个响应的内容类型(MIME types),那么就会把这个图片文件当做JavaScript脚本执行,于是攻击也就成功了。

问题的关键就在于,后端服务器在返回的响应中设置的Content-Type Header仅仅只是给浏览器提供当前响应内容类型的建议,而浏览器有可能会自作主张的根据响应中的实际内容去推断内容的类型。

在上面的例子中,后端通过Content-Type Header建议浏览器按照图片来渲染这次的HTTP响应,但是浏览器发现响应中其实是JavaScript,于是就擅自做主把这段响应当做JS脚本来解释执行,安全问题也就产生了。

如何防御

浏览器根据响应内容来推断其类型,本来这是个很“智能”的功能,是浏览器强大的容错能力的体现,但是却会带来安全风险。要避免出现这样的安全问题,办法就是通过设置X-Content-Type-Options这个HTTP Header明确禁止浏览器去推断响应类型。

同样是上面的攻击场景,后端服务器返回的Content-Type建议浏览器按照图片进行内容渲染,浏览器发现有X-Content-Type-OptionsHTTP Header的存在,并且其参数值是nosniff,因此不会再去推断内容类型,而是强制按照图片进行渲染,那么因为实际上这是一段JS脚本而非真实的图片,因此这段脚本就会被浏览器当作是一个已经损坏或者格式不正确的图片来处理,而不是当作JS脚本来处理,从而最终防止了安全问题的发生。

更多关于X-Content-Type-Options的细节请参考这里

小结

本文对前端安全问题进行了一次梳理,介绍了其中4个典型的前端安全问题,包括它们发生的原因以及防御办法。在下篇文章中,我们将介绍其他的几个前端安全问题,敬请期待。


更多精彩洞见,请关注微信公众号:ThoughtWorks商业洞见

Share

在前端性能优化中应用HTTP缓存的三部曲

Spike先生是Best Experience公司的IT运营部门主管,他的团队成功地利用Http Cache优化了前端工程。

Spike将通过三个Scenario来展示他的团队是如何做到这一点的:

  • 通过配置Http Cache Expire来消减访问压力,提高用户体验
  • 通过版本化来强制失效本地的过期缓存
  • 通过内容摘要命名文件来更精确的控制缓存以及实现非覆盖式的发布

第一个故事:我不想要那么多服务器和带宽

Best Experience面临的资源访问压力和用户体验方面的问题

随着Best Experience提供的前端应用越来越强大,Spike的压力也越来越大:

  • IT部门为了应对来自静态资源的访问压力,不断购置服务器和带宽。
  • 糟糕的用户体验使得用户转投到竞争对手的网站。

工程师们刚刚通过应用Minify、AMD、打包、Gzip等手段优化了前端页面的体验, 最终得到如下图所示的一个资源引用关系:

“还是很多东西要下载啊,该拿什么来拯救该死的延迟呢?”——Spike看着图想到。

他突然想起来:在早年间,Yahoo曾发布了关于优化前端体验的35条建议和指导,其中第三条是:“Add an Expires or a Cache-Control Header”。

Yahoo是这样描述这条建议的:

Web page designs are getting richer and richer, which means more scripts, stylesheets, images, and Flash in the page. A first-time visitor to your page may have to make several HTTP requests, but by using the Expires header you make those components cacheable. This avoids unnecessary HTTP requests on subsequent page views. Expires headers are most often used with images, but they should be used on all components including scripts, stylesheets, and Flash components.

Browsers (and proxies) use a cache to reduce the number and size of HTTP requests, making web pages load faster.

“这个正是我寻找的银弹”——Spike得意的笑了。

于是,Spike写下了第一个Technology Story。

作为IT 部门的老大:

我希望通过应用HTTP缓存技术,重用已经下载过的资源,

用于消减用户在浏览页面时产生的不必要的Http Request。

以此,来提升用户在浏览页面时候的体验,

以及降低对于公司服务器资源的访问压力。

并找来了工程师Tom。

Expire带来的美好生活

Tom刚刚参与了前一轮的优化工作,虽然成果显著,但是他并不满足。

当Tom看到Jim写下的Story时眼前一亮:“这个方法太赞了!我甚至可以在登录页面底部放置对其他页面资源的引用。提升用户在整个网站的浏览体验。”——Tom的小宇宙瞬间爆发,很快就完成了新的优化方案。

Best-Experience的用户在接下来的时间里浏览页面,会这样下载资源,以图片bgimage.png为例:

  • 用户第一次获取图片的时候,Http Request 如图:

  • 之后用户再次获取图片的时候,则完全可以从浏览器的缓存中读取数据了。

因为采用了Http缓存方案,

  • 用户的feedback越来越好,访问量提高了;
  • IT部门也不用那么多服务器和带宽了。

财务总监邀请Spike共进晚餐,并谈起了自己在希腊的度假。

“我想我也应该去圣托里尼度个假,犒劳下自己”——Spike美滋滋的想到。

第二个故事:失效缓存是个技术活

这个BUG我们明明修了啊!

一天,QA Tyke发现最近一轮发布的前端应用中没有包含很多新的feature。Jerry承诺说已经跟着这个月的release上线了,还测试过了。经过一番折腾,Jerry发现浏览器一直在使用旧的缓存,而不是最新的版本。Spike找来了Jerry 和Tom,三个人一起手动对引用的资源做了重命名、做了紧急修复。

“真是没有银弹啊,我的圣托里尼啊!”——Spike头疼的想到。

Spike、Jerry、Tom和Tyke坐在了一起,得出了新的结论:

  • 缓存前端工程中的资源时,需要考虑缓存有效期的问题
  • 虽然35条建议和指导中建议“Configure ETags”,但是很难确定静态资源缓存的有效期
  • 虽然Http缓存可以支持No-Cache或者max-age =0的方式,保证浏览器每次都向服务器验证缓存有效性,但是这样会大大增加服务器的压力
  • 可以通过在资源引用上增加形如:<…. src=”###.js?v=$version$”>的版本化方式,来强制浏览器更新缓存。

Spike写下了新的Technology Story

作为IT部门的老大:

我希望在前端系统中,对引用的静态资源进行版本化管理。

使之既可以通过Http缓存来提升用户体验,降低服务器压力;

也可以方便用户即时获得更新后的资源。

“这都10月了,看来是去不成圣托里尼了,总觉得这个方案哪里有问题”——Spike忐忑不安。

用版本机制来保证浏览器更新资源

Jerry和Tom(很难想象他们两怎么配合的)终于在前端工程中实现了自动化的资源版本化管理:用户在最初访问页面的时候,会得到这样一个资源引用:

而当新的版本上线后,用户会得到这样一个资源引用:

第三个故事:更精确的缓存管理和平滑升级

(这个案例来自于知乎的大公司里怎样开发和部署前端代码? 张云龙的回答,前一个 story的内容有涉及)

每次更新后的尖峰时刻

11月的Release后,运维人员Nibbles找到Spike,“这次上线以后,服务器压力突然剧增,从GA上看到用户花了很多时间在资源下载上”,Spike找来了Tom、Jerry、Tyke和Nibbles,几个人坐在一起分析原因:

“这是因为11月的部署完成后,前端应用引用的资源版本升级,所有缓存失效导致的”——Tom 想了想说

“所有的资源引用?我还以为我们能精确到每一个文件的更新呢”——Nibbles惊讶道

“如果单独标明每一个资源的版本,那么按照我们的实际情况来看,每次上线后访问压力就没那么大了”——Tyke

“我之前看WebPack做到了”——Jerry兴致勃勃的谈了起来。

“他们采用的是文件摘要的方式,就是用MD5对文件求值,如果两个文件是相同的,那么就求得同一个hash值;如果文件是不同的,就求得不同的hash值”——Jerry

“我们可以用这些文件的hash值作为版本号,就像这样”——Jerry

“能不能通过文件名做版本管理,我希望知道哪些文件是这次部署要移除的,哪些是新增的”——Nibbles

“这有什么问题么?”——Spike很疑惑

“明年不是要做CDN么?静态资源和页面文件会放置到不同的服务器上,很难做到页面文件和静态资源同批次更新,而且CDN的资源生效是有延迟的”——Nibbles

(关于 CDN 和非覆盖部署式部署,请参考张云龙的大公司里怎样开发和部署前端代码?前端工程之CDN部署

“恩,那么就这样吧,我回去写Story。”——Spike 一锤定音。

“还好,我们之前用了WebPack,这就简单了”——Jerry

Spike写下了第三个story

作为IT 部门的老大:

我希望能用文件hash来命名静态资源文件,

使之可以按照文件来控制缓存和部署

“我觉得这回是最后一个Story了”——Spike越来越乐观。

过渡到非覆盖式部署——大圆满?

如何应用WebPack的具体过程不再概述。

图片来源大公司里怎样开发和部署前端代码?

这样,Nibbles就可以很愉快的通过文件名比对,来分析每次部署变更的内容;而Best Experience未来上线的流程也会变为:

  • 先将新增的静态资源文件发布到静态资源服务器上
  • 验证新的静态资源是否正确发布
  • 服务器暂时离线,替换 html 文件等
  • 删除无用的静态资源文件

“终于可以踏踏实实过圣诞节了”——Spike看着日历。

总结

Spike的总结

年底了,Spike在年终总结中写到:

以后在实施前端工程中,我们可以通过:

  • 配置永不过期的本地缓存——节约带宽,提升用户体验
  • 采用文件摘要作为缓存依据——更精确的缓存控制
  • 采用CDN——降低用户请求资源时解析DNS的延迟
  • 利用文件摘要作为文件名——实现非覆盖式的部署,降低down time

我的总结

我引用前端工程之CDN部署一文中对非覆盖式、缓存设计、CDN这些解决方案间的前因后果做的总结:

如果考虑到项目开发阶段,那么这将是更为复杂的软件工程问题。在这个问题域中,还需要囊括文件压缩、合并、打包、重命名、目录设置等问题。还好Gulp、Webpack、FIS、AMD、RequireJS这些工具及对应的插件能帮助到我们。WebPack提供了Hash、ChunkHash、ContentHash,与此同时,社区提供了MD5-Hash。

当然这些都是关于工具的话题了,这次我们主要谈的是工程。浅谈前端集成解决方案里提到了前端领域的8个技术元素与分类,挺有意思的。

再终——没有银弹

我们的Spike先生来到了2月的北京旅游,放个带色的图:

我们自强不吸

在机场,Spike还是接到了Tyke的电话,“老爹啊,WebPack那个文件摘要不准啊……..”

“您好,因为天气原因,去往####的飞机延误,请您耐心等候……..”

“…….”


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

Share

前端组件化开发方案及其在React Native中的运用

文/刘先宁

本文首发于InfoQ:http://www.infoq.com/cn/articles/front-end-component-develop-and-application-in-react-native

随着SPA,前后端分离的技术架构在业界越来越流行,前端(注:本文中的前端泛指所有的用户可接触的界面,包括桌面,移动端)需要管理的内容,承担的职责也越来越多。再加上移动互联网的火爆,及其带动的Mobile First风潮,各大公司也开始在前端投入更多的资源。

这一切,使得业界对前端开发方案的思考上多了很多,以React框架为代表推动的组件化开发方案就是目前业界比较认可的方案,本文将和大家一起探讨一下组件化开发方案能给我们带来什么,以及如何在React Native项目的运用组件化开发方案

一、为什么要采用组件化开发方案?

在讲怎么做之前,需要先看看为什么前端要采用组件化开发方案,作为一名程序员和咨询师,我清楚地知道凡是抛开问题谈方案都是耍流氓。那么在面对随着业务规模的增加,更多的业务功能推向前端,以及随之而来的开发团队扩张时,前端开发会遇到些什么样的问题呢?

1. 前端开发面临的问题

  1. 资源冗余:页面变得越来越多,页面的交互变得越来越复杂。在这种情况下,有些团队成员会根据功能写自己的CSS、JS,这会产生大量的新的CSS或JS文件,而这些文件中可能出现大量的重复逻辑;有些团队成员则会重用别人的逻辑,但是由于逻辑拆分的粒度差异,可能会为了依赖某个JS中的一个函数,需要加载整个模块,或者为了使用某个CSS中的部分样式依赖整个CSS文件,这导致了大量的资源冗余。
  2. 依赖关系不直观:当修改一个JS函数,或者某个CSS属性时,很多时候只能靠人力全局搜索来判断影响范围,这种做法不但慢,而且很容易出错。
  3. 项目的灵活性和可维护性差:因为项目中的交叉依赖太多,当出现技术方案变化时,无法做到渐进式的、有节奏地替换掉老的代码,只能一次性替换掉所有老代码,这极大地提升了技术方案升级的成本和风险。
  4. 新人进组上手难度大:新人进入项目后,需要了解整个项目的背景、技术栈等,才能或者说才敢开始工作。这在小项目中也许不是问题,但是在大型项目中,尤其是人员流动比较频繁的项目,则会对项目进度产生非常大的影响。
  5. 团队协同度不高:用户流程上页面间的依赖(比方说一个页面强依赖前一个页面的工作结果),以及技术方案上的一些相互依赖(比方说某个文件只能由某个团队修改)会导致无法发挥一个团队的全部效能,部分成员会出现等待空窗期,浪费团队效率。
  6. 测试难度大:整个项目中的逻辑拆分不清晰,过多且杂乱的相互依赖都显著拉升了自动化测试的难度。
  7. 沟通反馈慢:业务的要求,UX的设计都需要等到开发人员写完代码,整个项目编译部署后才能看到实际的效果,这个反馈周期太长,并且未来的任何一个小修改又需要重复这一整个流程。

2.组件化开发带来的好处

组件化开发的核心是“业务的归业务,组件的归组件”。即组件是一个个独立存在的模块,它需要具备如下的特征:

(图片来自:http://serenity.bh/wp-content/)

  • 职责单一而清晰:开发人员可以很容易了解该组件提供的能力。
  • 资源高内聚: 组件资源内部高内聚,组件资源完全由自身加载控制。
  • 作用域独立: 内部结构密封,不与全局或其他组件产生影响。
  • 接口规范化: 组件接口有统一规范。
  • 可相互组合: 组装整合成复杂组件,高阶组件等。
  • 独立清晰的生命周期管理:组件的加载、渲染、更新必须有清晰的、可控的路径。

而业务就是通过组合这一堆组件完成User Journey。下一节中,会详细描述采用组件化开发方案的团队是如何运作的。

在项目中分清楚组件和业务的关系,把系统的构建架构在组件化思想上可以:

  1. 降低整个系统的耦合度:在保持接口不变的情况下,我们可以把当前组件替换成不同的组件实现业务功能升级,比如把一个搜索框,换成一个日历组件。
  2. 提高可维护性:由于每个组件的职责单一,在系统中更容易被复用,所以对某个职责的修改只需要修改一处,就可获得系统的整体升级。独立的,小的组件代码的更易理解,维护起来也更容易。
  3. 降低上手难度:新成员只需要理解接口和职责即可开发组件代码,在不断的开发过程中再进一步理解和学习项目知识。另外,由于代码的影响范围仅限于组件内部,对项目的风险控制也非常有帮助,不会因为一次修改导致雪崩效应,影响整个团队的工作。
  4. 提升团队协同开发效率:通过对组件的拆分粒度控制来合理分配团队成员任务,让团队中每个人都能发挥所长,维护对应的组件,最大化团队开发效率。
  5. 便于自动化测试:由于组件除了接口外,完全是自治王国,甚至概念上,可以把组件当成一个函数,输入对应着输出,这让自动化测试变得简单。
  6. 更容易的自文档化:在组件之上,可以采用Living Style Guide的方式为项目的所有UI组件建立一个‘活’的文档,这个文档还可以成为业务,开发,UX之间的沟通桥梁。这是对‘代码即文档’的另一种诠释,巧妙的解决了程序员不爱写文档的问题。
  7. 方便调试:由于整个系统是通过组件组合起来的,在出现问题的时候,可以用排除法直接移除组件,或者根据报错的组件快速定位问题。另外,Living Style Guide除了作为沟通工具,还可以作为调试工具,帮助开发者调试UI组件。

二、组件化开发方案下,团队如何运作?

前面大致讲了下组件化开发可以给项目带来的好处,接下来聊一聊采用组件化开发方案的团队是应该如何运作?

在ThoughtWorks,我们把一个项目的生命周期分为如下几个阶段:

组件化开发方案主要关注的是在迭代开发阶段的对团队效率的提升。 它主要从以下几个方面提升了开发效率:

1. 以架构层的组件复用降低工作量

在大型应用的后端开发中,为了分工、复用和可维护性,在架构层面将应用抽象为多个相对独立的模块的思想和方法都已经非常成熟和深入人心了。

但是在前端开发中,模块化的思想还是比较传统,开发者还是只有在需考虑复用时才会将某一部分做成组件,再加上当开发人员专注在不同界面开发上时,对于该界面上哪些部分可以重用缺乏关注,导致在多个界面上重复开发相同的UI功能,这不仅拉升了整个项目的工作量,还增加了项目后续的修改和维护成本。

在组件化开发方案下,团队在交付开始阶段就需要从架构层面对应用的UI进行模块化,团队会一起把需求分析阶段产生的原型中的每一个UI页面抽象为一颗组件树,UI页面自己本身上也是一个组件。如下图:

通过上面的抽象之后,我们会发现大量的组件可以在多个UI界面上复用,而考虑到在前端项目中,构建各个UI界面占了80%以上的工作量,这样的抽象显著降低了项目的工作量,同时对后续的修改和维护也会大有裨益。

在这样的架构模式下,团队的运作方式就需要相应的发生改变:

  1. 工程化方面的支持,从目录结构的划分上对开发人员进行组件化思维的强调,区分基础组件,业务组件,页面组件的位置,职责,以及相互之间的依赖关系。
  2. 工作优先级的安排,在敏捷团队中,我们强调的是交付业务价值。而业务是由页面组件串联而成,在组件化的架构模式下,必然是先完成组件开发,再串联业务。所以在做迭代计划时,需要对团队开发组件的任务和串联业务的任务做一个清晰的优先级安排,以保证团队对业务价值的交付节奏。

2.以组件的规范性保障项目设计的统一性

在前端开发中,因为CSS的灵活性,对于相同的UI要求(比如:布局上的靠右边框5个像素),就可能有上十种的CSS写法,开发人员的背景,经历的不同,很可能会选择不同的实现方法;甚至还有一些不成熟的项目,存在需求方直接给一个PDF文件的用户流程图界面,不给PSD的情况,所有的设计元素需要开发人员从图片中抓取,这更是会使得项目的样式写的五花八门。因为同样的UI设计在项目中存在多种写法,会导致很多问题,第一就是设计上可能存在不一致的情况;第二是UI设计发生修改时,出现需要多种修改方案的成本,甚至出现漏改某个样式导致bug的问题。

在组件化开发方案下,项目设计的统一性被上拉到组件层,由组件的统一性来保障。其实本来所有的业务UI设计就是组件为单位的,设计师不会说我要“黄色”,他们说得是我要“黄色的按钮……”。是开发者在实现过程中把UI设计下放到CSS样式上的,相比一个个,一组组的CSS属性,组件的整体性和可理解性都会更高。再加上组件的资源高内聚特性,在组件上对样式进行调整也会变得容易,其影响范围也更可控。

在组件化开发方案下,为了保证UI设计的一致性,团队的运作需要:

  1. 定义基础设计元素,包括色号、字体、字号等,由UX决定所有的基础设计元素。
  2. 所有具体的UI组件设计必须通过这些基础设计元素组合而成,如果当前的基础设计元素不能满足需求,则需要和UX一起讨论增加基础设计元素。
  3. UI组件的验收需要UX参与。

3. 以组件的独立性和自治性提升团队协同效率

在前端开发时,存在一个典型的场景就是某个功能界面,距离启动界面有多个层级,按照传统开发方式,需要按照页面一页一页的开发,当前一个页面开发未完成时,无法开始下一个页面的开发,导致团队工作的并发度不够。另外,在团队中,开发人员的能力各有所长,而页面依赖降低了整个项目在任务安排上的灵活性,让我们无法按照团队成员的经验,强项来合理安排工作。这两项对团队协同度的影响最终会拉低团队的整体效率。

在组件化开发方案下,强调业务任务和组件任务的分离和协同。组件任务具有很强的独立性和自治性,即在接口定义清楚的情况下,完全可以抛开上下文进行开发。这类任务对外无任何依赖,再加上组件的职责单一性,其功能也很容易被开发者理解。

所以在安排任务上,组件任务可以非常灵活。而业务任务只需关注自己依赖的组件是否已经完成,一旦完成就马上进入Ready For Dev状态,以最高优先级等待下一位开发人员选取。

在组件化开发方案下,为了提升团队协同效率,团队的运作需要:

  1. 把业务任务和组件任务拆开,组件的归组件,业务的归业务。
  2. 使用Jira,Mingle等团队管理工具管理好业务任务对组件任务的依赖,让团队可以容易地了解到每个业务价值的实现需要的完成的任务。
  3. Tech Lead需要加深对团队每个成员的了解,清楚的知道他们各自的强项,作为安排任务时的参考。
  4. 业务优先原则,一旦业务任务依赖的所有组件任务完成,业务任务马上进入最高优先级,团队以交付业务价值为最高优先级。
  5. 组件任务先于业务任务完成,未纳入业务流程前,团队需要Living Style Guide之类的工具帮助验收组件任务。

4.以组件的Living Style Guide平台降低团队沟通成本

在前端开发时,经常存在这样的沟通场景:

  • 开发人员和UX验证页面设计时,因为一些细微的差异对UI进行反复的小修改。
  • 开发人员和业务人员验证界面流程时,因为一些特别的需求对UI进行反复的小修改。
  • 开发人员想复用另一个组件,寻找该组件的开发人员了解该组件的设计和职责
  • 开发人员和QA一起验证某个公用组件改动对多个界面上的影响

当这样的沟通出现在上一小节的提到的场景,即组件出现在距离启动界面有多个层级的界面时,按照传统开发方式,UX和开发需要多次点击,有时甚至需要输入一些数据,最后才能到达想要的功能界面。没有或者无法搭建一个直观的平台满足这些需求,就会导致每一次的沟通改动就伴随着一次重复走的,很长的路径。使得团队的沟通成本激增,极大的降低了开发效率。

在组件化开发方案下, 因为组件的独立性,构建Living Style Guide平台变得非常简单,目前社区已经有了很多工具支持构建Living Style Guide平台(比如getstorybook)

开发人员把组件以Demo的形式添加到Living Style Guide平台就行了,然后所有与UI组件的相关的沟通都以该平台为中心进行,因为开发对组件的修改会马上体现在平台上,再加上平台对组件的组织形式让所有人都可以很直接的访问到任何需要的组件,这样,UX和业务人员有任何要求,开发人员都可以快速修改,共同在平台上验证,这种“所见即所得”的沟通方式节省去了大量的沟通成本。

此外,该平台自带组件文档功能,团队成员可以从该平台上看到所有组件的UI,接口,降低了人员变动导致的组件上下文知识缺失,同时也降低了开发者之间对于组件的沟通需求。

想要获得这些好处,团队的运作需要:

  1. 项目初期就搭建好Living Style Guide平台。
  2. 开发人员在完成组件之后必须添加Demo到平台,甚至根据该组件需要适应的场景,多添加几个Demo。这样一眼就可以看出不同场景下,该组件的样子。
  3. UX,业务人员通过平台验收组件,甚至可以在平台通过修改组件Props,探索性的测试在一些极端场景下组件的反应。

5. 对需求分析阶段的诉求和产品演进阶段的帮助

虽然需求分析阶段产品演进阶段不是组件化开发关注的重点,但是组件化开发的实施效果却和这两个阶段有关系,组件化方案需要需求分析阶段能够给出清晰的Domain数据结构,基础设计元素和界面原型,它们是组件化开发的基础。而对于产品演进阶段,组件化开发提供的两个重要特性则大大降低了产品演进的风险:

  • 低耦合的架构,让开发者清楚的知道自己的修改影响范围,降低演进风险。开发团队只需要根据新需求完成新的组件,或者替换掉已有组件就可以完成产品演进。
  • Living Style Guide的自文档能力,让你能够很容易的获得现有组件代码的信息,降低人员流动产生的上下文缺失对产品演进的风险。

三、组件化开发方案在React Native项目中的实施

前面已经详细讨论了为什么和如何做组件化开发方案,接下来,就以一个React Native项目为例,从代码级别看看组件化方案的实施。

1. 定义基础设计元素

在前面我们已经提到过,需求分析阶段需要产出基本的设计元素,在前端开发人员开始写代码之前需要把这部分基础设计元素添加到代码中。在React Native中,所有的CSS属性都被封装到了JS代码中,所以在React Native项目开发中,不再需要LESS,SCSS之类的动态样式语言,而且你可以使用JS语言的一切特性来帮助你组合样式,所以我们可以创建一个theme.js存放所有的基础设计元素,如果基础设计元素很多,也可以拆分位多个文件存放。

import { StyleSheet } from 'react-native'; 
module.exports = StyleSheet.create({   
        colors: {...},   
        fonts: {...},   
        layouts: {...},   
        borders: {...},   
        container: {...}, 
  });

然后,在写具体UI组件的styles,只需要引入该文件,按照JS的规则复用这些样式属性即可。

2.拆分组件树之Component,Page,Scene

在实现业务流程前,需要对项目的原型UI进行分解和分类,在React Native项目中,我把UI组件分为了四种类型:

  • Shared Component: 基础组件,Button,Label之类的大部分其它组件都会用到的基础组件
  • Feature Component: 业务组件,对应到某个业务流程的子组件,但其不对应路由, 他们通过各种组合形成了Pag组件。
  • Page: 与路由对应的Container组件,主要功能就是组合子组件,所有Page组件最好名字都以Page结尾,便于区分。
  • Scene: 应用状态和UI之间的连接器,严格意义上它不算UI组件,主要作用就是把应用的状态和Page组件绑定上,所有的Scene组件以Scene后缀结尾。

Component和Page组件都是Pure Component,只接收props,然后展示UI,响应事件。Component的Props由Page组件传递给它,Page组件的Props则是由Scene组件绑定过去。下面我们就以如下的这个页面为例来看看这几类组件各自的职责范围:

(1)searchResultRowItem.js

export default function (rowData) {
    const {title, price_formatted, 
            img_url, rowID, onPress} = rowData;
    const price = price_formatted.split(' ')[0];
    return (
        <TouchableHighlight
          onPress={() => onPress(rowID)}
          testID={'property-' + rowID}
          underlayColor='#dddddd'>
          <View>
           <View style={styles.rowContainer}>
                  <Image style={styles.thumb} source={{ uri: img_url }}/>
              <View style={styles.textContainer}>
                    <Text style={styles.price}>{price}</Text>
                    <Text style={styles.title} numberOfLines={1}>{title}</Text>
              </View>
            </View>
            <View style={styles.separator }/>
        </View>
           </TouchableHighlight>
  );}

(2)SearchResultsPage.js

import SearchResultRowItem from '../components/searchResultRowItem';
export default class SearchResultsPage extends Component {

  constructor(props) {
    super(props);
    const dataSource = new ListView.DataSource({
      rowHasChanged: (r1, r2) => r1.guid !== r2.guid});
    this.state = {
      dataSource: dataSource.cloneWithRows(this.props.properties),
      onRowPress: this.props.rowPressed,
    };
  }

  renderRow(rowProps, sectionID, rowID) {
    return <SearchResultRowItem {...rowProps} rowID={rowID}
                onPress={this.state.onRowPress} />;
  }

  render() {
    return (
      <ListView
        style={atomicStyles.container}
        dataSource={this.state.dataSource}
        renderRow={this.renderRow.bind(this)} />
    );
  }}

(3)SearchResultsScene.js

import SearchResults from '../components/searchResultsPage';
function mapStateToProps(state) {
  const {propertyReducer} = state;
  const {searchReducer:{properties}} = propertyReducer;
  return {
    properties,
  };
}

function mapDispatchToProps(dispatch) {
  return {
    rowPressed: (propertyIndex) => {
      dispatch(PropertyActions.selectProperty(propertyIndex));
      RouterActions.PropertyDetails();
    }
  };
}

module.exports = connect(
  mapStateToProps,
  mapDispatchToProps,)(SearchResults);

3.Living Style Guide

目前社区上,最好的支持React Native的Living Style Guide工具是getstorybook,关于如何使用getstorybook搭建React Native的Living Style Guide平台可以参见官方文档或者我的博客

搭建好Living Style Guide平台后,就可以看到如下的界面:

接下来的工作就是不断在往该平台添加UI组件的Demo。向storybook中添加Demo非常简单,下面就是一个关于SearchPage的Demo:

import React from 'react';
import {storiesOf, action} from '@kadira/react-native-storybook';
import SearchPage from '../../../../src/property/components/searchPage';
storiesOf('Property', module)
  .add('SearchPage', () => (
    <SearchPage request={{place_name:"London"}} 
        isLoading={false} search={action('Search called')}/>
));

从上面的代码可以看出,只需要简单的三步就可以完成一个UI组件的Demo:

  1. import要做Demo的UI组件。
  2. storiesOf定义了一个组件目录。
  3. add添加Demo。

在构建项目的storybook时,一些可以帮助我们更有效的开发Demo小Tips:

  1. 尽可能的把目录结构与源代码结构保持一致。
  2. 一个UI组件对应一个Demo文件,保持Demo代码的独立性和灵活性,可以为一个组件添加多个Demo,这样一眼就可以看到多个场景下的Demo状态。
  3. Demo命名以UI组件名加上Demo缀。
  4. 在组件参数复杂的场景下,可以单独提供一个fakeData的目录用于存放重用的UI组件Props数据。

4.一个完整的业务开发流程

在完成了上面三个步骤后,一个完整的React Native业务开发流程可简单分为如下几步:

  1. 使用基础设计元素构建基础组件,通过Living Style Guide验收。
  2. 使用基础组件组合业务组件,通过Living Style Guide验收。
  3. 使用业务组件组合Page组件,通过Living Style Guide验收。
  4. 使用Scene把Page组件的和应用的状态关联起来。
  5. 使用Router把多个Scene串联起来,完成业务流程。

四、总结

随着前后端分离架构成为主流,越来越多的业务逻辑被推向前端,再加上用户对于体验的更高要求,前端的复杂性在一步一步的拔高。对前端复杂性的管理就显得越来越重要了。经过前端的各种框架,工具的推动,在前端工程化实践方面我们已经迈进了很多。而组件化开发就是笔者觉得其中比较好的一个方向,因为它不仅关注了当前的项目交付,还指导了团队的运作,帮助了后期的演进,甚至在程序员最讨厌的写文档的方面也给出了一个巧妙的解法。希望对该方法感兴趣的同学一起研究,改进。


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

Share

HTML也可以静态编译?

More than React系列文章:

More than React(一)为什么ReactJS不适合复杂的前端项目?

More than React(二)React.Component损害了复用性?

More than React(三)虚拟DOM已死?

More than React(四)HTML也可以静态编译?


《More than React》系列的上一篇文章《虚拟DOM已死?》比较了Binding.scala和其他框架的渲染机制。本篇文章中将介绍Binding.scala中的XHTML语法。

其他前端框架的问题

对HTML的残缺支持

以前我们使用其他前端框架,比如Cycle.js 、Widok、ScalaTags时,由于框架不支持 HTML语法,前端工程师被迫浪费大量时间,手动把HTML改写成代码,然后慢慢调试。

就算是支持HTML语法的框架,比如ReactJS,支持状况也很残缺不全。

比如,在ReactJS中,你不能这样写:

class BrokenReactComponent extends React.Component {
  render() {
    return (
      <ol>
        <li class="unsupported-class">不支持 class 属性</li>
        <li style="background-color: red">不支持 style 属性</li>
        <li>
          <input type="checkbox" id="unsupported-for"/>
          <label for="unsupported-for">不支持 for 属性</label>
        </li>
      </ol>
    );
  }
}

前端工程师必须手动把 classfor 属性替换成 classNamehtmlFor,还要把内联的 style 样式从CSS语法改成JSON语法,代码才能运行:

class WorkaroundReactComponent extends React.Component {
  render() {
    return (
      <ol>
        <li className="workaround-class">被迫把 class 改成 className</li>
        <li style={{ backgroundColor: "red" }}>被迫把样式表改成 JSON</li>
        <li>
          <input type="checkbox" id="workaround-for"/>
          <label htmlFor="workaround-for">被迫把 for 改成 htmlFor</label>
        </li>
      </ol>
    );
  }
}

这种开发方式下,前端工程师虽然可以把HTML原型复制粘贴到代码中,但还需要大量改造才能实际运行。比Cycle.js、Widok或者ScalaTags省不了太多事。

不兼容原生DOM操作

此外,ReactJS等一些前端框架,会生成虚拟DOM。虚拟DOM无法兼容浏览器原生的DOM API ,导致和jQuery、D3等其他库协作时困难重重。比如ReactJS更新DOM对象时常常会破坏掉jQuery控件。

Reddit很多人讨论了这个问题。他们没有办法,只能弃用jQuery。我司的某客户在用了ReactJS后也被迫用ReactJS重写了大量jQeury控件。

Binding.scala中的XHTML

现在有了Binding.scala ,可以在@dom方法中,直接编写XHTML。比如:

@dom def introductionDiv = {
  <div style="font-size:0.8em">
    <h3>Binding.scala的优点</h3>
    <ul>
      <li>简单</li>
      <li>概念少<br/>功能多</li>
    </ul>
  </div>
}

以上代码会被编译,直接创建真实的DOM对象,而没有虚拟DOM。

Binding.scala对浏览器原生DOM的支持很好,你可以在这些DOM对象上调用DOM API,与 D3、jQuery等其他库交互也完全没有问题。

ReactJS对XHTML语法的残缺不全。相比之下,Binding.scala支持完整的XHTML语法,前端工程师可以直接把设计好的HTML原型复制粘贴到代码中,整个网站就可以运行了。

Binding.scala中XHTML的类型

@dom方法中XHTML对象的类型是Node的派生类。

比如,<div></div> 的类型就是HTMLDivElement,而 <button></button> 的类型就是 HTMLButtonElement

此外, @dom 注解会修改整个方法的返回值,包装成一个Binding

@dom def typedButton: Binding[HTMLButtonElement] = {
  <button>按钮</button>
}

注意typedButton是个原生的HTMLButtonElement,所以可以直接对它调用 DOM API。比如:

@dom val autoPrintln: Binding[Unit] = {
  println(typedButton.bind.innerHTML) // 在控制台中打印按钮内部的 HTML
}
autoPrintln.watch()

这段代码中,typedButton.bind.innerHTML 调用了 DOM API HTMLButtonElement.innerHTML。通过autoPrintln.watch(),每当按钮发生更新,autoPrintln中的代码就会执行一次。

其他HTML节点

Binding.scala支持HTML注释:

@dom def comment = {
  <!-- 你看不见我 -->
}

Binding.scala也支持CDATA块:

@dom def inlineStyle = {
  <section>
    <style><![CDATA[
      .highlight {
        background-color:gold
      }
    ]]></style>
    <p class="highlight">Binding.scala真好用!</p>
  </section>
}

内嵌Scala代码

除了可以把XHTML内嵌在Scala代码中的 @dom 方法中,Binding.scala 还支持用 { ... } 语法把 Scala 代码内嵌到XHTML中。比如:

@dom def randomParagraph = {
  <p>生成一个随机数: { math.random.toString }</p>
}

XHTML中内嵌的Scala代码可以用 .bind 绑定变量或者调用其他 @dom 方法,比如:

val now = Var(new Date)
window.setInterval(1000) { now := new Date }

@dom def render = {
  <div>
    现在时间:{ now.bind.toString }
    { introductionDiv.bind }
    { inlineStyle.bind }
    { typedButton.bind }
    { comment.bind }
    { randomParagraph.bind }
  </div>
}

上述代码渲染出的网页中,时间会动态改变。

强类型的 XHTML

Binding.scala中的XHTML 都支持静态类型检查。比如:

@dom def typo = {
  val myDiv = <div typoProperty="xx">content</div>
  myDiv.typoMethod()
  myDiv
}

由于以上代码有拼写错误,编译器就会报错:

typo.scala:23: value typoProperty is not a member of org.scalajs.dom.html.Div
        val myDiv = <div typoProperty="xx">content</div>
                     ^
typo.scala:24: value typoMethod is not a member of org.scalajs.dom.html.Div
        myDiv.typoMethod()
              ^

内联CSS属性

style 属性设置内联样式时,style 的值是个字符串。比如:

@dom def invalidInlineStyle = {
  <div style="color: blue; typoStyleName: typoStyleValue"></div>
}

以上代码中设置的 typoStyleName 样式名写错了,但编译器并没有报错。

要想让编译器能检查内联样式,可以用 style: 前缀而不用 style 属性。比如:

@dom def invalidInlineStyle = {
  <div style:color="blue" style:typoStyleName="typoStyleValue"></div>
}

那么编译器就会报错:

typo.scala:28: value typoStyleName is not a member of org.scalajs.dom.raw.CSSStyleDeclaration
        <div style:color="blue" style:typoStyleName="typoStyleValue"></div>
         ^

这样一来,可以在编写代码时就知道属性有没有写对。不像原生JavaScript / HTML / CSS那样,遇到bug也查不出来。

自定义属性

如果你需要绕开对属性的类型检查,以便为HTML元素添加定制数据,你可以属性加上 data: 前缀,比如:

@dom def myCustomDiv = {
  <div data:customAttributeName="attributeValue"></div>
}

这样一来Scala编译器就不会报错了。

结论

本文的完整DEMO请访问 ScalaFiddle

从这些示例可以看出,Binding.scala 一方面支持完整的XHTML ,可以从高保真HTML 原型无缝移植到动态网页中,开发过程极为顺畅。另一方面,Binding.scala 可以在编译时静态检查XHTML中出现语法错误和语义错误,从而避免bug 。

以下表格对比了ReactJS和Binding.scala对HTML语法的支持程度:

ReactJS Binding.scala
是否支持HTML语法? 残缺支持 完整支持
是否支持标准的style属性? 不支持,必须改用 JSON 语法 支持,既支持标准的style属性也支持style:前缀
是否支持标准的class属性? 不支持,必须改用className 支持,既支持class也支持className
是否支持标准的for属性? 不支持,必须改用htmlFor 支持,既支持for也支持htmlFor
是否支持HTML注释? 不支持 支持
是否兼容原生DOM操作? 不兼容 兼容
是否兼容jQuery? 不兼容 兼容
能否在编译时检查出错误? 不能

我将在下一篇文章中介绍 Binding.scala 如何实现服务器发送请求并在页面显示结果的流程。

相关链接

Share