JavaScript 启动性能瓶颈分析与解决方案

作者:王下邀月熊
链接:https://zhuanlan.zhihu.com/p/25221314
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

JavaScript 启动性能瓶颈分析与解决方案 翻译自 Addy Osmani 的 JavaScript Start-up Performance,从属于笔者的Web 前端入门与工程实践。本文已获得原作者授权,为InfoQ中文站特供稿件,首发地址为这里;如需转载,请与InfoQ中文站联系。随着现代 Web 技术的发展与用户交互复杂度的增加,我们的网站变得日益臃肿,也要求着我们不断地优化网站性能以保证友好的用户体验。本文作者则着眼于 JavaScript 启动阶段优化,首先以大量的数据分析阐述了语法分析、编译等步骤耗时占比过多是很多网站的性能瓶颈之一。然后作者提供了一系列用于在现代浏览器中进行性能评测的工具,还分别从开发者工程实践与 JavaScript 引擎内部实现的角度阐述了应当如何提高解析与编译速度。

在 Web 开发中,随着需求的增加与代码库的扩张,我们最终发布的 Web 页面也逐渐膨胀。不过这种膨胀远不止意味着占据更多的传输带宽,其还意味着用户浏览网页时可能更差劲的性能体验。浏览器在下载完某个页面依赖的脚本之后,其还需要经过语法分析、解释与运行这些步骤。而本文则会深入分析浏览器对于 JavaScript 的这些处理流程,挖掘出那些影响你应用启动时间的罪魁祸首,并且根据我个人的经验提出相对应的解决方案。回顾过去,我们还没有专门地考虑过如何去优化 JavaScript 解析/编译这些步骤;我们预想中的是解析器在发现<script>标签后会瞬时完成解析操作,不过这很明显是痴人说梦。下图是对于 V8 引擎工作原理的概述:


下面我们深入其中的关键步骤进行分析。

到底是什么拖慢了我们应用的启动时间?

在启动阶段,语法分析,编译与脚本执行占据了 JavaScript 引擎运行的绝大部分时间。换言之,这些过程造成的延迟会真实地反应到用户可交互时延上;譬如用户已经看到了某个按钮,但是要好几秒之后才能真正地去点击操作,这一点会大大影响用户体验。


上图是我们使用 Chrome Canary 内置的 V8 RunTime Call Stats 对于某个网站的分析结果;需要注意的是桌面浏览器中语法解析与编译占用的时间还是蛮长的,而在移动端中占用的时间则更长。实际上,对于 Facebook, Wikipedia, Reddit 这些大型网站中语法解析与编译所占的时间也不容忽视:


上图中的粉色区域表示花费在 V8 与 Blink's C++ 中的时间,而橙色和黄色分别表示语法解析与编译的时间占比。Facebook 的 Sebastian Markbage 与 Google 的 Rob Wormald 也都在 Twitter 发文表示过 JavaScript 的语法解析时间过长已经成为了不可忽视的问题,后者还表示这也是 Angular 启动时主要的消耗之一。

随着移动端浪潮的涌来,我们不得不面对一个残酷的事实:移动端对于相同包体的解析与编译过程要花费相当于桌面浏览器2~5倍的时间。当然,对于高配的 iPhone 或者 Pixel 这样的手机相较于 Moto G4 这样的中配手机表现会好很多;这一点提醒我们在测试的时候不能仅用身边那些高配的手机,而应该中高低配兼顾:

上图是部分桌面浏览器与移动端浏览器对于 1MB 的 JavaScript 包体进行解析的时间对比,显而易见的可以发现不同配置的移动端手机之间的巨大差异。当我们应用包体已经非常巨大的时候,使用一些现代的打包技巧,譬如代码分割,TreeShaking,Service Workder 缓存等等会对启动时间有很大的影响。另一个角度来看,即使是小模块,你代码写的很糟或者使用了很糟的依赖库都会导致你的主线程花费大量的时间在编译或者冗余的函数调用中。我们必须要清醒地认识到全面评测以挖掘出真正性能瓶颈的重要性。

JavaScript 语法解析与编译是否成为了大部分网站的瓶颈?

我曾不止一次听到有人说,我又不是 Facebook,你说的 JavaScript 语法解析与编译到
底会对其他网站造成什么样的影响呢?对于这个问题我也很好奇,于是我花费了两个月的时间对于超过 6000 个网站进行分析;这些网站囊括了 React,Angular,Ember,Vue 这些流行的框架或者库。大部分的测试是基于 WebPageTest 进行的,因此你可以很方便地重现这些测试结果。光纤接入的桌面浏览器大概需要 8 秒的时间才能允许用户交互,而 3G 环境下的 Moto G4 大概需要 16 秒 才能允许用户交互。

大部分应用在桌面浏览器中会耗费约 4 秒的时间进行 JavaScript 启动阶段(语法解析、编译、执行)

而在移动端浏览器中,大概要花费额外 36% 的时间来进行语法解析:


另外,统计显示并不是所有的网站都甩给用户一个庞大的 JS 包体,用户下载的经过 Gzip 压缩的平均包体大小是 410KB,这一点与 HTTPArchive 之前发布的 420KB 的数据基本一致。不过最差劲的网站则是直接甩了 10MB 的脚本给用户,简直可怕。


通过上面的统计我们可以发现,包体体积固然重要,但是其并非唯一因素,语法解析与编译的耗时也不一定随着包体体积的增长而线性增长。总体而言小的 JavaScript 包体是会加载地更快(忽略浏览器、设备与网络连接的差异),但是同样 200KB 的大小,不同开发者的包体在语法解析、编译上的时间却是天差地别,不可同日而语。

现代 JavaScript 语法解析 & 编译性能评测

Chrome DevTools

打开 Timeline( Performance panel ) > Bottom-Up/Call Tree/Event Log 就会显示出当前网站在语法解析/编译上的时间占比。如果你希望得到更完整的信息,那么可以打开 V8 的 Runtime Call Stats。在 Canary 中,其位于 Timeline 的 Experims > V8 Runtime Call Stats 下。


Chrome Tracing

打开 about:tracing 页面,Chrome 提供的底层的追踪工具允许我们使用disabled-by-default-v8.runtime_stats来深度了解 V8 的时间消耗情况。V8 也提供了详细的指南来介绍如何使用这个功能。


WebPageTest


WebPageTest 中 Processing Breakdown 页面在我们启用 Chrome > Capture Dev Tools Timeline 时会自动记录 V8 编译、EvaluateScript 以及 FunctionCall 的时间。我们同样可以通过指明disabled-by-default-v8.runtime_stats的方式来启用 Runtime Call Stats。


更多使用说明参考我的gist

User Timing

我们还可以使用 Nolan Lawson 推荐的User Timing API来评估语法解析的时间。不过这种方式可能会受 V8 预解析过程的影响,我们可以借鉴 Nolan 在 optimize-js 评测中的方式,在脚本的尾部添加随机字符串来解决这个问题。我基于 Google Analytics 使用相似的方式来评估真实用户与设备访问网站时候的解析时间:


DeviceTiming

Etsy 的 DeviceTiming 工具能够模拟某些受限环境来评估页面的语法解析与执行时间。其将本地脚本包裹在了某个仪表工具代码内从而使我们的页面能够模拟从不同的设备中访问。可以阅读 Daniel Espeset 的Benchmarking JS Parsing and Execution on Mobile Devices 一文来了解更详细的使用方式。


我们可以做些什么以降低 JavaScript 的解析时间?

  • 减少 JavaScript 包体体积。我们在上文中也提及,更小的包体往往意味着更少的解析工作量,也就能降低浏览器在解析与编译阶段的时间消耗。

  • 使用代码分割工具来按需传递代码与懒加载剩余模块。这可能是最佳的方式了,类似于PRPL这样的模式鼓励基于路由的分组,目前被 Flipkart, Housing.com 与 Twitter 广泛使用。

  • Script streaming: 过去 V8 鼓励开发者使用async/defer来基于script streaming实现 10-20% 的性能提升。这个技术会允许 HTML 解析器将相应的脚本加载任务分配给专门的 script streaming 线程,从而避免阻塞文档解析。V8 推荐尽早加载较大的模块,毕竟我们只有一个 streamer 线程。

  • 评估我们依赖的解析消耗。我们应该尽可能地选择具有相同功能但是加载地更快的依赖,譬如使用 Preact 或者 Inferno 来代替 React,二者相较于 React 体积更小具有更少的语法解析与编译时间。Paul Lewis 在最近的一篇文章中也讨论了框架启动的代价,与 Sebastian Markbage 的说法不谋而合:最好地评测某个框架启动消耗的方式就是先渲染一个界面,然后删除,最后进行重新渲染。第一次渲染的过程会包含了分析与编译,通过对比就能发现该框架的启动消耗。

如果你的 JavaScript 框架支持 AOT(ahead-of-time)编译模式,那么能够有效地减少解析与编译的时间。Angular 应用就受益于这种模式:


现代浏览器是如何提高解析与编译速度的?

不用灰心,你并不是唯一纠结于如何提升启动时间的人,我们 V8 团队也一直在努力。我们发现之前的某个评测工具 Octane 是个不错的对于真实场景的模拟,它在微型框架与冷启动方面很符合真实的用户习惯。而基于这些工具,V8 团队在过去的工作中也实现了大约 25% 的启动性能提升:


本部分我们就会对过去几年中我们使用的提升语法解析与编译时间的技巧进行阐述。

代码缓存


Chrome 42 开始引入了所谓的代码缓存的概念,为我们提供了一种存放编译后的代码副本的机制,从而当用户二次访问该页面时可以避免脚本抓取、解析与编译这些步骤。除以之外,我们还发现在重复访问的时候这种机制还能避免 40% 左右的编译时间,这里我会深入介绍一些内容:

  • 代码缓存会对于那些在 72 小时之内重复执行的脚本起作用。

  • 对于 Service Worker 中的脚本,代码缓存同样对 72 小时之内的脚本起作用。

  • 对于利用 Service Worker 缓存在 Cache Storage 中的脚本,代码缓存能在脚本首次执行的时候起作用。

总而言之,对于主动缓存的 JavaScript 代码,最多在第三次调用的时候其能够跳过语法分析与编译的步骤。我们可以通过chrome://flags/#v8-cache-strategies-for-cache-storage来查看其中的差异,也可以设置 js-flags=profile-deserialization运行 Chrome 来查看代码是否加载自代码缓存。不过需要注意的是,代码缓存机制仅会缓存那些经过编译的代码,主要是指那些顶层的往往用于设置全局变量的代码。而对于类似于函数定义这样懒编译的代码并不会被缓存,不过 IIFE 同样被包含在了 V8 中,因此这些函数也是可以被缓存的。

Script Streaming

Script Streaming允许在后台线程中对异步脚本执行解析操作,可以对于页面加载时间有大概 10% 的提升。上文也提到过,这个机制同样会对同步脚本起作用。


这个特性倒是第一次提及,因此 V8 会允许所有的脚本,即使阻塞型的<script src=''>脚本也可以由后台线程进行解析。不过缺陷就是目前仅有一个 streaming 后台线程存在,因此我们建议首先解析大的、关键性的脚本。在实践中,我们建议将<script defer>添加到<head>块内,这样浏览器引擎就能够尽早地发现需要解析的脚本,然后将其分配给后台线程进行处理。我们也可以查看 DevTools Timeline 来确定脚本是否被后台解析,特别是当你存在某个关键性脚本需要解析的时候,更需要确定该脚本是由 streaming 线程解析的。


语法解析 & 编译优化

我们同样致力于打造更轻量级、更快的解析器,目前 V8 主线程中最大的瓶颈在于所谓的非线性解析消耗。譬如我们有如下的代码片:

(function (global, module) { … })(this, function module() { my functions })

V8 并不知道我们编译主脚本的时候是否需要module这个模块,因此我们会暂时放弃编译它。而当我们打算编译module时,我们需要重分析所有的内部函数。这也就是所谓的 V8 解析时间非线性的原因,任何一个处于 N 层深度的函数都有可能被重新分析 N 次。V8 已经能够在首次编译的时候搜集所有内部函数的信息,因此在未来的编译过程中 V8 会忽略所有的内部函数。对于上面这种module形式的函数会是很大的性能提升,建议阅读The V8 Parser(s) — Design, Challenges, and Parsing JavaScript Better来获取更多内容。V8 同样在寻找合适的分流机制以保证启动时能在后台线程中执行 JavaScript 编译过程。

预编译 JavaScript?

每隔几年就有人提出引擎应该提供一些处理预编译脚本的机制,换言之,开发者可以使用构建工具或者其他服务端工具将脚本转化为字节码,然后浏览器直接运行这些字节码即可。从我个人观点来看,直接传送字节码意味着更大的包体,势必会增加加载时间;并且我们需要去对代码进行签名以保证能够安全运行。目前我们对于 V8 的定位是尽可能地避免上文所说的内部重分析以提高启动时间,而预编译则会带来额外的风险。不过我们欢迎大家一起来讨论这个问题,虽然 V8 目前专注于提升编译效率以及推广利用 Service Worker 缓存脚本代码来提升启动效率。我们在 BlinkOn7 上与 Facebook 以及 Akamai 也讨论过预编译相关内容

Optimize JS 优化

类似于 V8 这样的 JavaScript 引擎在进行完整的解析之前会对脚本中的大部分函数进行预解析,这主要是考虑到大部分页面中包含的 JavaScript 函数并不会立刻被执行。

预编译能够通过只处理那些浏览器运行所需要的最小函数集合来提升启动时间,不过这种机制在 IIFE 面前却反而降低了效率。尽管引擎希望避免对这些函数进行预处理,但是远不如optimize-js这样的库有作用。optimize-js 会在引擎之前对于脚本进行处理,对于那些立即执行的函数插入圆括号从而保证更快速地执行。这种预处理对于 Browserify, Webpack 生成包体这样包含了大量即刻执行的小模块起到了非常不错的优化效果。尽管这种小技巧并非 V8 所希望使用的,但是在当前阶段不得不引入相应的优化机制。

总结

启动阶段的性能至关重要,缓慢的解析、编译与执行时间可能成为你网页性能的瓶颈所在。我们应该评估页面在这个阶段的时间占比并且选择合适的方式来优化。我们也会继续致力于提升 V8 的启动性能,尽我所能!

延伸阅读

RESTful架构风格下的4大常见安全问题

from: http://insights.thoughtworkers.org/security-issues-in-restful/

伴随着RESTful架构风格的大量应用微服务架构的流行,一些本来难以察觉到的安全问题也逐渐开始显现出来。在我经历过的各种采用RESTful微服务架构风格的应用中,某些安全问题几乎在每个应用中都会出现。然而它们并非是什么高深的技术难题,只不过是借着微服务的流行而显得越发突出,这些都可以通过一些安全实践来避免。本文将一些典型的问题列举出来,希望能引起开发团队的注意,帮助他们绕过这些安全问题的“坑”。

1. 遗漏了对资源从属关系的检查

一个典型的RESTful的URL会用资源名加上资源的ID编号来标识其唯一性,就像这样:/users/<USER ID>,例如:/users/100

一般而言用户只能查看自己的用户信息,而不允许查看其它用户的信息。在这种情况下,攻击者很可能会尝试把这个URL里面的USER ID从100修改为其他数值,以期望应用返回指定用户的信息。不过由于这个安全风险太显而易见,绝大多数应用都会对当前请求者的身份进行校验,看其是否是编号为100的用户,校验成功才返回URL中指定的用户信息,否则会拒绝当前请求。

对于URL中只出现一个资源的情况,绝大多数应用都已经做了安全防御,然而重灾区出现在URL中包含多个资源的时候。

以用户查看订单的RESTful URL为例:/users/100/orders/280010,应用只检查了当前请求发起者是否是编号为100的用户,以及编号为280010的订单是否存在,有很大的概率没有检查URL中的订单和用户之间的从属关系。其结果是,攻击者可以通过修改URL中的订单编号,从而遍历系统中的所有订单信息,甚至对不属于他/她的订单发起操作,例如取消订单。

上面的例子中只有两个资源,如果URL中资源数量继续增加,这种从属关系校验缺失的情况只会更加普遍。

解决这一问题的方法极其简单,只要发现URL里面出现了两个或者两个以上的资源,就像下面这样:

/ResourceA/<ResourceA Id>/ResourceB/<ResourceB Id>/ResourceC/<ResourceC Id>

在对资源进行操作之前,就得先检查这些资源之间的从属关系,以确保当前请求具有相关的访问、操作权限。

2. HTTP响应中缺失必要的 Security Headers

HTTP中有一些和安全相关的Header,通过对它们的合理使用,可以使得应用在具备更高的安全性的同时,并不会显著增大开发者的工作负担,有着“低成本高收益”的效果。不过绝大多数情况下,这些Header是默认关闭的,因此很多应用中也就缺失了这些Security Headers。一些典型的Security Headers如下:

X-Frame-Options
为了防止应用遭受点击劫持攻击,可以使用X-Frame-Options: DENY明确告知浏览器,不要把当前HTTP响应中的内容在HTML Frame中显示出来。

X-Content-Type-Options
在浏览器收到HTTP响应内容时,它会尝试按照自己的规则去推断响应内容的类型,并根据推断结果执行后续操作,而这可能造成安全问题。例如,一个包含恶意JavaScript代码的HTTP响应内容,虽然其Content-Typeimage/png,但是浏览器推断出这是一段脚本并且会执行它。

X-Content-Type-Options就是专门用来解决这个问题的Header。通过将其设置为X-Content-Type-Options: nosniff,浏览器将不再自作主张的推断HTTP响应内容的类型,而是严格按照响应中Content-Type所指定的类型来解析响应内容。

X-XSS-Protection
避免应用出现跨站脚本漏洞(Cross-Site Scripting,简称XSS)的最佳办法是对输出数据进行正确的编码,不过除此之外,现如今的浏览器也自带了防御XSS的能力。

要开启浏览器的防XSS功能,只需要在HTTP响应中加上这个Header:X-XSS-Protection: 1; mode=block。其中,数字1代表开启浏览器的XSS防御功能,mode=block是告诉浏览器,如果发现有XSS攻击,则直接屏蔽掉当前即将渲染的内容。

Strict-Transport-Security
使用TLS可以保护数据在传输过程中的安全,而在HTTP响应中添加上Strict-Transport-Security这个Header,可以告知浏览器直接发起HTTPS请求,而不再像往常那样,先发送明文的HTTP请求,得到服务器跳转指令后再发送后续的HTTPS请求。并且,一旦浏览器接收到这个Header,那么当它发现数据传输通道不安全的时候,它会直接拒绝进行任何的数据传输,不再允许用户继续通过不安全的传输通道传输数据,以避免信息泄露。

3. 不经意间泄露的业务信息

会说话的ID
资源ID是RESTful URL中很重要的一个组成部分,大多数情况下这类资源ID都是用数字来表示的。这在不经意间泄露了业务信息,而这些信息可能正是竞争对手希望得到的数据。

以查看用户信息的RESTful URL为例:/users/100。由于用户ID是一个按序递增的数字,因此攻击者既可以通过ID知道目前应用中的用户规模,也可以分别在月初和月末的时候注册一个用户,并对比两个用户的ID即可知道当前这个月有多少新增用户。同理,如果订单号也是按序自增的数字,攻击者可以了解到一定时间范围内的订单量。

这类ID并不会给应用造成任何技术上的威胁,只是通过ID泄露出来的信息对于你的业务而言可能非常敏感。解决办法是不使用按序递增的数字作为ID,而是使用具有随机性、唯一性、不可预测性的值作为ID,最常见的做法就是使用UUID。

返回多余的数据
前后端分离的情况下,两者之间通常以JSON作为数据传输的主体。有时候可能是为了方便前端代码处理,也可能是疏忽大意,总之后端API返回的JSON数据中包含了远远超出前端代码需要的数据,因此造成数据泄露。

例如,前端代码本意是请求订单信息,但是后端API返回的订单JSON数据中还包含了很多“有意思”的数据。

{    "id": 280010,     "orderItems": [...],     "user": {        "id": 100,         "password": "91B4E3E45B2465A4823BB5C03FF81B65"    },    ...}

上面这个例子里,订单数据中包含了用户信息,最为关键的是连用户的密码字段也被包含在内。

解决办法显而易见,在给前端返回数据之前,将这些敏感的、前端并不需要的数据过滤掉。技术上实现起来易如反掌,但是真正难的地方在于让整个应用都严格的按照这样的方式来处理JSON数据,确保没有任何遗漏之处。

4. API缺乏速率限制的保护

先看一个例子。用户注册时发送短信验证码的API,由于没有做速率限制,使得攻击者可以用一段脚本不断的请求服务器发送短信验证码,导致在短时间内耗尽短信发送配额,或者造成短信网关拥挤等等后果。

受伤的不仅仅是发送短信的API,其他一些比较敏感的API如果缺乏请求速率限制的保护,同样也会遭遇安全问题。例如用户登录的API缺乏速率限制的话,攻击者可以利用其进行用户名密码暴力破解,再例如某些大量消耗服务器资源的API如果缺乏速率限制,攻击者可以利用其发起拒绝式攻击。

解决这类安全问题的原则就是对API请求的速率进行适当的限制。具体的做法有很多,最典型的例子就是使用图片验证码,其他的做法还有利用Redis的Expire特性对请求速率进行统计判断,甚至借助运维的力量(例如网络防火墙)来共同进行防御等等。

总结

开发出一个具备足够安全性的应用不是件容易的事情,本文中提到的只是RESTful架构风格下,众多安全问题中比较典型的一部分而已。之所以会有这些问题,其本质原因在于应用开发过程中,开发团队的注意力集中在业务功能的实现上,应用安全性相关的需求没有得到足够的明确和重视。

如果你不想被这些安全问题所困扰,建议通过在应用开发过程中引入威胁建模、在用户故事卡中设立安全验收标准、进行安全代码审查等一系列安全实践,尽可能从源头上规避这些问题。

webpack 2中的Tree Shaking

from: http://www.css88.com/archives/6946

Tree Shaking 是一个术语,字面的理解就是摇一摇树,树上的枯叶就会掉下来,留下绿叶。

Rich Harris 的模块打包器 Rollup 普及了 JavaScript 圈内一个重要的特性:Tree shaking,即是指消除JavaScript上下文中无用代码,或更精确地说,只保留有用的代码。它依赖于ES6模块 import / export  模块系统的静态结构(static structure)来检测哪一个模块没有被使用,因为,import 和 export 不会在运行时改变。说的再直白一点就是Tree shaking 从模块包中排除未使用的 exports 项。

webpack 2 内置引入的 Tree-shaking 代码优化技术。

示例

看一下官方文档提供的示例:

考虑一个 maths.js 库文件导出两个函数,square 和 cube

ECMAScript6 代码:
  1. // 这个函数不在任何地方被使用
  2. export function square(x) {
  3. return x * x; //平方
  4. }
  5.  
  6. // 这个函数被其他脚本使用
  7. export function cube(x) {
  8. return x * x * x; //立方
  9. }

在我们的 main.js 中,我们选择性地导入 cube :

ECMAScript6 代码:
  1. import {cube} from './maths.js';
  2. console.log(cube(5)); // 125

运行 node_modules/.bin/webpack main.js dist.js 并检查 dist.js 显示没有导出square,请查看 “unused harmony export square”的注释,再查看/* harmony export (immutable) */ __webpack_exports__["a"] = cube;这条语句,显示这里只导出了cube

JavaScript 代码:
  1. /* ... webpackBootstrap ... */
  2. /******/ ([
  3. /* 0 */
  4. /***/ (function(module, __webpack_exports__, __webpack_require__) {
  5.  
  6. "use strict";
  7. /* unused harmony export square */
  8. /* harmony export (immutable) */ __webpack_exports__["a"] = cube;
  9. // This function isn't used anywhere
  10. function square(x) {
  11. return x * x;
  12. }
  13.  
  14. // This function gets included
  15. function cube(x) {
  16. return x * x * x;
  17. }
  18.  
  19. /***/ }),
  20. /* 1 */
  21. /***/ (function(module, __webpack_exports__, __webpack_require__) {
  22.  
  23. "use strict";
  24. Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
  25. /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__maths_js__ = __webpack_require__(0);
  26.  
  27. console.log(__webpack_require__.i(__WEBPACK_IMPORTED_MODULE_0__maths_js__["a" /* cube */])(5)); // 125
  28.  
  29. /***/ })

当运行生产环境构建node_modules/.bin/webpack --optimize-minimize main.js dist.min.js时,只保留了 cube 的压缩版本,而 square 并没有保留在构建的 bundle 中:

JavaScript 代码:
  1. /* ... */
  2. function(e,t,n){"use strict";function r(e){

    JS the Hardcore: 执行上下文(Execution Context)

    from: https://www.qianduan.net/js-the-hardcore-execution-context/

    先看个例子:

    console.log(a)  console.log(foo())  var a = 'hello world'function foo() {      console.log('foo')}

    估计大部分人早就对这种问题了如指掌了,输出结果也是脱口而出:

    undefined  'foo'  

    请解释一下原因?

    这不就是常说的 Hoisting 吗?

    代码在执行的时候,其实是这个样子的:

    function foo() {    console.log('foo')}var aconsole.log(a)  console.log(foo())a = 'hello world'  

    是的,这样解释也可以,但是不够准确。如果仅仅理解到这个层面,而不把这里面涉及到的「执行上下文」(Execution Context, 下面简称 EC)这个概念弄明白,还是不能「知其所以然」。

    首先需要澄清的一点是,像 C 和 Java 这类所谓的「静态语言」需要编译后才能运行,但其实 Javascript 一样也是有编译过程的, 只是处于一些限制,编译过程特别快(以微秒计),以至于感觉上 Javascript 代码好像并没有经过编译。

    当需要向别人描述一件事情的经过时,往往需要把事情的前因后果以及与其相关的场景预先说明一下。 类似,EC 就是一段代码涉及到的场景,在代码运行之前,js engine 会做一些变量内存分配,代码上下文关联的准备工作,这就是 EC。 EC 是 Javascript 引擎实现的一个内部机制,不能在代码中直接访问到。

    对编程语言来说,给变量赋值,获取变量的值,是需要解决的基本问题,Javascript 当然也不例外。 EC 中会给变量分配一个存储空间,与之对应的数据结构称作 enviroment。

    当程序流程需要从当前的 EC 进入另外一个和当前上下文无关的代码片段时,会创建一个新的 EC,并被推入栈中。这就是「执行上下文栈」(Execution Context Stack),可以看作是 调用栈的镜像。由于所有的 js 代码都存在于全局环境中,所以首先会创建 「全局执行上下文」(Global Execution Context),除此之外,js 中每一次的函数调用也会生成 EC,所以栈底肯定会是 全局执行上下文。

    如果把 EC 视为作一个抽象对象,那这个对象包含了代码相关的 this、enviroment(存储标识符包括变量声明、函数声明、函数表达式的数据结构)和一个指向外部 enviroment 的指针。

    Global Execution Context

        Global Execution Context = {        global object,        this: global object,        outer environment: null,        enviroment: {            // all the identifiers            variable,            function expression,            function declaration,        },    }

    Function Execution Context

        Execution Context = {        this: some value,        outer environment: outer lexcial environment,        enviroment: {            // all the identifiers            parameter,            arguments,            variable,            function expression,            function declaration,        },    }

    在 EC 创建之后,js engine 会开始顺序执行代码。

    /** 01 **/ var x = 'hello world'/** 02 **//** 03 **/ function foo() {/** 04 **/   var y = 'hellow foo'/** 05 **/   console.log(y)/** 06 **/ }/** 07 **//** 08 **/ console.log(x)/** 09 **/ foo()

    代码执行前,首先创建全局执行上下文并设置为 「当前执行上下文」(running execution context)

        Execution Context = {        global object,        this: global object,        outer environment: null,        enviroment: {            x: undefined,            foo,            bar,        },    }

    之后,顺序执行代码,执行到 ln 01 时,给变量 x 赋值,

        Global Execution Context = {        global object,        this: global object,        outer environment: null,        enviroment: {            x: 'hello world',            foo,            bar,        },    }

    代码执行到 ln 08 时,需要去 enviroment 中寻找 x 的值,此时为 'hello world'

    代码执行到 ln 09 时,因为是对函数的调用,会创建一个新的执行上下文,并置为「当前执行上下文」:

        foo Execution Context = {        this: window,        outer environment: Global.environment,        enviroment: {            arguments,            y: undefined,            bar,        },    }

    然后代码继续执行,到 ln 04 时,有对 y 的赋值操作,此时

        foo Execution Context = {        this: window,        outer environment: Global.environment,        enviroment: {            arguments,            y: 'hello foo',        },    }

    代码执行到 ln 05 有对 y 的取值操作,y 值在当前的 enviroment 中为 'hellow foo'

    EC 栈的情况,可以参照下图「调用栈」(Call Stack)的状态:

    execution context stack

    到这里就基本上把执行上下文的一些基本概念讲完了,其中涉及到的一些内容目前不理解没有关系,后面会慢慢解释。

聊聊设计模式(3):门面模式

from: http://www.barretlee.com/blog/2017/02/07/the-facade-pattern-in-design-patterns/

Facade,中文译为门面、外观,所以本文要讲的 Facade Pattern 翻译时经常看到有两个名字,门面模式和外观模式。

https://unsplash.com/search/facade?photo=5NArdqS7FW4 by Chris Barbalis

这是一个在 JavaScript 中被经常用到的设计模式:

class modA {}
class modB {}
// 门面模式实例
class Facade {
init() {
modA.initialize();
modB.init();
}
run() {
modA.start();
modB.run();
}
}

modA 和 modB 是客户端运行的两个依赖系统,例子中通过 Facade 类做了简单封装,客户端便可以使用少量代码快捷启动两个子系统,这里的 Facade 就是两个子系统的门面,客户端跟门面而不会直接与子系统打交道。

上面的例子比较抽象,还是有些晦涩的,下面看一个实际的场景。

小喜负责的业务增加了一些社交元素,不久之后业务方给小喜提了一个需求,要求在某类文章下方增加一个赞赏按钮,用户点击按钮时,调用 Z 平台的接口付款,然后将操作记录到后端,最后发送一个统计埋点,需要三天内上线。

小喜笑了笑,心里开心道:就这点需求,哪用得着三天,给我俩小时就够了:

async function payWithZFB() {
request('interface-a');
// ...
}
async function addRecord() {}
async function sendTrack() {}
async function Zan() {
// 调用 Z 平台接口,进行支付操作
let record = await payWithZFB();
// 调用后端接口,记录日志
let track = await addRecord(record);
// 调用埋点接口,发送埋点信息
await sendTrack(track);
}

事实上,小喜找 Z 平台、后端和日志平台的同学要接口就花了一整天,并且 pageWithZFBaddRecord 和 sendTrack 三个函数的实现都十分复杂,各种权限验证、安全验证等,小喜做完这个需求内心是崩溃的。

随着业务规模的扩大,SNS 相关需求量有所增加,其他业务上陆续也开始使用赞赏功能。同组的小天收到了类似的需求,但小天并不知道小喜之前做过,同样花了一天时间,采用了类似的方式实现了功能。

一段时间后, Z 平台那边说需要提升安全防护,要求开发者对接口做调整,需要将 interface-a 调整为 interface-b,小喜和小天都收到了这个需求,他们相互瞪了对方一眼,然后默默地回去修改了。

小苏在进行团队代码 review 的时候,看到了这个问题,为了减少团队在这一块的开销,他让小喜使用 NodeJS 做了一层封装,代码变动其实并不大,从客户端调用 HTTP 接口,变成了 Node 调用 HSF 接口:

class Facade {
zan () {
let record = await payWithZFB();
let track = await addRecord(record);
await sendTrack(track);
}
}

然后小喜和小天在客户端的调用都变成了:

const facade = new Facade();
facade.zan();

这个调整带来的很大的便捷,不管 Z 平台、后端和日志平台的规则如何变化,小喜只需要修改 Node 提供的 Facade 服务就能解决问题,业务上不需要做任何发布。

这个问题应该相当明确,这个模式也很好理解。业务需求经常会牵涉到多个平台的协作,协作就意味着频繁的交流和沟通,最后演变的结果便是:

A 同学:hi,请问如何使用 Z 平台的支付接口呀?
B 同学:[自动回复] 询问支付接口请回复 1,中午请我吃饭请回复 2
A 同学:1
B 同学:[自动回复] 文档链接:http://z/interface

平台通过机器人解决了客服问题,但是使用者却不得不一遍又一遍地阅读粗糙无比的文档(好像有点跑题了,我们回来继续解析问题)。

Facade 隔离了客户端与三个平台之间的通信,尽管客户端依然还可以跳过 Facade 与平台交流,一般情况下不会这么去做,但也不排除这种情况,比如客户端和 Z 平台之间的认证禁止间接进行,那么客户端的调用就会变成:

const facade = new Facade();
authZFB(function() {
facade.zan();
});

门面模式还是允许客户端去直接操作子系统的,这也说明了这个模块比较灵活。

门面模式,要求一个子系统的外部与其内部的通讯必须通过一个统一的对象进行。门面模式提供一个高层次的接口,使得子系统更易于使用。

引入门面模块,可以将子系统之间的通信和相互依赖达到最小,从而降低整个系统的复杂度,也很大程度上提高了客户单使用的便捷性,客户端无需关系子系统的实现细节,通过门面角色便可以完成功能的调用。

门面模式相对复杂的使用方式是,提供抽象的门面类,然后根据配置文件生成具体的门面类,这种模式用的比较广泛,也是值得提倡的。