阅读视图

发现新文章,点击刷新页面。
🔲 ⭐

读《禅与摩托车维修艺术》

《禅与摩托车维修艺术》这本书不是讲禅宗的,也不是教授摩托车​维修技术的。就像书的扉页提到的,它是一场对价值的探寻​,对“良质”的探寻。书中的很多观点,和我当下的一些想法不谋而合,而且进一步扩充了我的世界观和价值观,所以读起来感觉很爽。这里将个人觉得不错的一些书中观点摘抄记录了下来​。

  1. 当你做某件事的时候,一旦想要求快,就表示你再也不关心它,只想去做别的事。
  2. 我们从所观察到的无穷景致中选出一把沙子,然后称这把沙子为世界。
  3. 遇到复杂的问题,最好的办法是先把它写下来,描述问题的思路:
  • 问题是什么
  • 假设问题的原因
  • 证实每个假设的实验方法
  • 预测实验的结果
  • 观察实验结果
  • 得出实验结论
  1. 哲学上的实在论:要证明一个东西的存在,可以把它从环境中抽离出来,如果原先的环境无法正常运转,那么它就存在。
  2. 如果把登上山顶作为目标,你会辛苦得多,而这只是名义上的目标,真正的目标,是体验登山的每一分钟,同样是到达山顶,却要愉悦得多。
  3. 一旦你被训练得轻视自己的喜好,那么当然你就会对别人更加顺服——变成好奴隶。一旦你学会不做自己喜欢的事,那么你就会为系统所接受。
  4. 价值的僵化是指固守以前的价值观,无法从新的角度衡量事物。如果价值观是僵化的,你就无法接受任何新的事实。如果一直坚持原来的看法,就无法找到真正的答案,即使它就在你的眼前。如果你的价值观僵化了,你要做的就是刻意放慢脚步,然后重新审视过去你认为重要的事物是否仍然重要。用注视鱼线的方式静静地注视着它,不久你一定会看到鱼线在动。
  5. 如果你自视甚高,那么你观察新事物的能力就会降低。焦虑来自于急于求成,摒弃烦躁的最好方法是,不设定工作时间。
  6. 对自己手中的工作产生认同感,有了认同感,就会看到良质。
  7. 梭罗:只有在失去的时候才有所获得。
  8. 我们最苛责别人的地方,往往就是我们自己最深的恐惧。
  9. 我靠着取悦别人过活,揣测别人希望听到你说什么,然后假装主动又自然地说出来。他却始终忠实于自己的信念。有时候我觉得他是活生生的人,而我才是鬼魂。
  10. 在你冥思苦想而不可得的时候,不要沿着原有的思路继续走下去,你应该停下脚步,放松一段时间,发散自己的思想,直到碰到一些事,能够让你拓展原先知识的根基。
  11. 科技的问题在于它并没有和人的心灵连在一起,所以盲目地表露出它丑陋的一面,因此必然引起人们的厌恶。
  12. 真理的陷阱:除了是与非之外,还有第三种可能性:无。

最后分享一个书中提到的南部印第安人抓猴子的故事。首先猎人把挖空的椰子用绳子绑在一根木头上,椰子里面放了一些米,通过一个小洞就能摸到。由于洞很小,猴子只能把手伸进去。而当手中握了米,就很难拽出来。猴子不会衡量自由和拥有白米孰轻孰重,村民们利用这一点,把它逮到笼子里带走了。

🔲 ☆

工作的意义和无意义

最近几个月,常感到工作意义感的缺失。源自几个方面,一是就算继续按部就班工作,也就那样,生活不会有太大改变;二是当人一闲下来,就会开始想一些有的没的。过去似乎没怎么想过这个问题,工作的意义究竟是什么?

因为热爱吗?从当初大学毕业,满怀着热忱加入了这个行业。刚开始,工作是兴趣驱动,对我而言一切都是新的,解决未知的问题会给人带来成就感。凌晨两点上线,见过凌晨四点的北京上海,也乐在其中,不会感觉到累。慢慢地,经历了跳槽、晋升等等大部分职场人的必经之路。发现职场中的很多事情,都是被推着走的,你进入了那个轨道后,一些事就会自然而然发生。人们常说选择大于努力,这句话是有道理的。当你身处其中的时候,会有无数双手推着你往前走。伴随的副作用就是,渐渐地被琐碎的细节,被无意义的会议,被日复一日的加班磨平棱角。有一天发现,已经很久没有见过夕阳,没有闻到雨后泥土的味道,没有听到过蝉鸣的声音。不再有当初那种热爱,多数时候工作就只是不得不做的事情而已。

为了赚钱吗?确实是需要赚钱支撑生活,但我的物质欲望一直维持在一个很低的水平。如果要迎合世俗意义上的成功,人的欲望可能是无止境的。完成了父母的期待,又要追寻社会成功的标准,一切仿佛永无止境。更重要的是,为满足物质欲望,势必要失去很多东西。

我认为现代人本质上都是在和社会做各种交易,车子、房子、彩礼、精力、甚至未来的时间,这些无不可作为交易的筹码。身为无产阶级,最有价值的资产就是身体和时间。体力劳动者是拿身体和时间和雇主做交易,脑力劳动者是用身体、知识、时间和雇主做交易。要从雇主那里拿走更多的钱,就要承担被剥削时间、被压榨身体、被限制自由。既然要一直做交易,必然不能 all in,需要做 trade off,否则手上很快就没有筹码了。所以,一定不要把时间全部交给公司,没有时间为自己投资的话,在这个时代是很危险的。长期来看,个人的单位时间产出不会有指数级增长,甚至到了一定年龄后还会下降,老是埋头干活,没有时间抬头看路、仰望星空,有可能一开始就选择了一条错误的路,也有可能错过了沿途的风景。另外,一定要爱护自己的身体。如果没有发生阶级跃迁,正常情况下 65 岁退休,还有三四十年的时间,需要和社会做这种交易。所以,以人为本,追求可持续发展方为上策(涛哥的科学发展观也是很重要的🐶)。

回到最初的问题,工作的意义究竟是什么呢?时至今日,我发现自己对于未知领域依然有着好奇心,但为了生活,也依然需要赚钱。不同的是,我慢慢发现技术终归是为人服务的,它是一种手段。赚钱是为了满足自己的物质需求,它也只是一种手段,并不应该作为人生的目标。假如自己想要的东西能通过其他手段来满足,赚钱就不再是唯一选择。

我们做的所有事情,都是在解决人类的某一类需求。按照马斯洛需求层次理论,人的需求可分为生理需求、安全需求、爱与归属、尊重需求和自我实现,前面的需求体现了人的动物性,越往后越体现人有别于动物的地方。人远比任何一种机器都要复杂,如果能更加理解人性;如果能解决更多人的真实需求,不管用技术或者非技术的手段;助人即是助己,如果能通过自己的工作,帮助到更多的人;我想都会很有意义。

这么说,有点理想主义化了。不过在现实中,现实的人太多了,多一点理想主义未尝不好。

最后还有一个问题,为什么要探寻工作的意义呢?真的是这些意义感在支撑人类工作吗?并不是。我想最终还是因为,工作直接或者间接满足了人的一些最原始的需求:幸福感、快乐感、成就感……或许工作也好,人生也好,本都是毫无意义的,抛去那些我们为其附加上去的意义,快乐才是永恒不变的真理。所以,借用 TVB 的经典台词来结束:做人呐,最重要的就是开心啦!

🔲 ⭐

Fastify 如何实现更快的 JSON 序列化

前言

对于 web 框架而言,更快的 HTTP 请求响应速度意味着更优异的性能。而 HTTP 协议是一个文本协议,传输的格式都是字符串,而我们在代码中常常操作的是 JSON 格式的数据。因此,需要在返回响应数据前将 JSON 数据序列化为字符串。JavaScript 原生提供了 JSON.stringify 这个方法,来将 JSON 转成字符串。先来介绍这个方法。

缓慢的 JSON.stringify

由于 JavaScript 是一个动态语言,它的类型是在运行时才能确定,因此 JSON.stringify 的执行过程中要进行大量的类型判断,对不同类型的键值做不同的处理。由于不能做静态分析,我们很难做进一步优化。而且还需要一层一层的递归,循环引用的话还有爆栈的风险。在以性能著称的 Node.js 框架 Fastify 中,通过使用 fast-json-stringify 这个库,来替代 JSON.stringify,实现 JSON 序列化性能翻倍。那么,fast-json-stringify 是怎么做到的呢?

image.png

fast-json-stringify 揭秘

fast-json-stringify 基于 JSON Schema Draft 7 来定义(JSON)对象的数据格式。比如对象:

{
foo: 1,
bar: "abc"
}

它的 JSON Schema 可以是这样:

{
type: "object",
properties: {
foo: {type: "integer"},
bar: {type: "string"}
},
required: ["foo"]
}

除了这种简单的类型定义,JSON Schema 还支持一些条件运算,比如字段类型可能是字符串或者数字,可以用oneOf 关键字:

"oneOf": [
{
"type": "string"
},
{
"type": "number"
}
]

关于 JSON Schema 的完整定义,可以参考 Ajv 的文档,Ajv 是一个流行的 JSON Schema验证工具,性能表现也非常出众。来看一段使用 fast-json-stringify 的简单代码:

require('http').createServer(handle).listen(3000)
var flatstr = require('flatstr')

var stringify = require('fast-json-stringify')({
type: 'object',
properties: { hello: { type: 'string' } }
})

function handle (req, res) {
res.setHeader('Content-Type', 'application/json')
res.end(flatstr(stringify({ hello: 'world' })))
}

这段代码里,fast-json-stringify 接受一个 JSON Schema 对象作为参数,生成了一个 stringify 函数。通常,Response 的数据结构是固定的,所以可以将其定义为一个 Schema,就相当于提前告诉 stringify 函数,需序列化的对象的数据结构,这样它就可以不必再在运行时去做类型判断。下面来看看 stringify 函数是如何生成的。

生成 stringify 函数

首先,需要对 JSON Schema 进行校验。底层校验逻辑是基于 Ajv 实现的,这里暂不赘述。
然后需要预先注入一些工具方法,用于将一些常见类型转成字符串。

const asFunctions = `
function $asAny (i) {
return JSON.stringify(i)
}

function $asNull () {
return 'null'
}

function $asInteger (i) {
if (isLong && isLong(i)) {
return i.toString()
} else if (typeof i === 'bigint') {
return i.toString()
} else if (Number.isInteger(i)) {
return $asNumber(i)
} else {
return $asNumber(parseInteger(i))
}
}

function $asNumber (i) {
const num = Number(i)
if (isNaN(num)) {
return 'null'
} else {
return '' + num
}
}

function $asBoolean (bool) {
return bool && 'true' || 'false'
}

// 省略了一些其他类型......
`

可以看到,使用你使用的是 any 类型,它内部依然还是用的 JSON.stringify。我们经常建议 ts 开发者避免使用 any 类型是有道理的,因为如果是基于 ts interface 生成 JSON Schema 的话,使用 any 也会影响到 JSON 序列化的性能。

接下来,遍历 schema,对不同类型调用对应的工具函数来生成代码。

let code = `
'use strict'
${asFunctions}
`
let main
switch (schema.type) {
// 省略了对象和数组
case 'integer':
main = '$asInteger'
break
case 'number':
main = '$asNumber'
break
case 'boolean':
main = '$asBoolean'
break
case 'null':
main = '$asNull'
break
case undefined:
main = '$asAny'
break
default:
throw new Error(`${schema.type} unsupported`)
}

code += `
;
return ${main}
`

最后,对生成出来的 code 调用 Function 构造函数。

const dependencies = [new Ajv(options.ajv)]
const dependenciesName = ['ajv']
dependenciesName.push(code)
return (Function.apply(null, dependenciesName).apply(null, dependencies))

这里将 ajv 对象作为参数注入到函数里,是为了处理 JSON Schema 中 if、then、else、anyOf 等情况。

另外,由于最终是调用的 new Function 来动态执行代码,这里其实是有一定的安全风险的。所以建议开发者一定不要使用用户生成的 schema,保证生成的 schema 是安全可控的。

最终,开发者调用 stringify 函数,将 JSON 转成字符串。执行 stringify 的过程本质上就是在做字符串拼接。

总结

Fastify 使用 fast-json-stringify 替代 JSON.stringify,实现了更快的 JSON 序列化。它的原理是通过开发者预先定义 JSON Schema,使得框架可以提前知道 JSON 数据的结构。然后根据 JSON Schema 生成一个 stringify 函数,stringify 内部做的事情其实就是字符串拼接。最后开发者调用 stringify 函数来序列化 JSON。本质上是将类型分析从运行时提前到编译时了。


欢迎加微信 xxr0314 聊一聊技术、人生,本人微信公众号:小熊写字的地方

qrcode_for_gh_a2d61e1595a5_258.jpg

🔲 ⭐

【译】2019 年的 JavaScript 性能

建议阅读本文前先读完这篇文章:使用Script-Streaming提升页面加载性能

原文作者:Addy Osmani (@addyosmani[1])

过去几年中,JavaScript 性能[2]的大幅改进很大程度上依赖于浏览器解析和编译 JavaScript 的速度。2019 年,处理 JavaScript 的主要性能损耗在于下载和 CPU 执行时间。

浏览器主线程忙于执行 JavaScript 时,用户交互会被延迟,因此脚本执行时间和网络上的瓶颈优化尤其重要。

可行的高级指南


这对于 web 开发者意味着什么?解析和编译的性能损耗不再像从前我们认为的那样慢。我们需要关注三点:

提升下载速度

•减小 JavaScript 包的体积,尤其是在移动设备上。更小的包可以提升下载速度,带来更低的内存占用,并减少 CPU 性能损耗。•避免把代码打包成一个大文件。如果一个包超过 50–100 kB,把它分割成多个更小的包。(由于 HTTP/2 的多路复用特性,多个请求和响应可以同时到达,从而减少额外请求的负载。)•由于移动设备上的网络速度,你应该减少网络传输,而且也需要维持更低的内存使用。

提升执行速度

•避免使主线程忙碌的长任务[3],使页面快点进行可交互态。脚本执行时间目前成为了一个主要的性能损耗。

避免大型内联脚本(因为它们也会在主线程中解析和编译)一个不错的规定是:如果脚本超过 1KB,就不要将其内联(也因为外部脚本的字节码缓存[4]要求最小为 1KB)。

为何优化下载和执行时间很重要?


为何优化下载和执行时间很重要?下载时间在低端网络环境下很关键。尽管 4G(甚至 5G)在全球范围快速发展,我们实际感受到的网络速度[5]和宣传并不一致,很多时候感觉就像 3G(甚至更差)。

JavaScript 执行时间在使用低端 CPU 的手机上很重要。由于 CPU、GPU 和散热上的差异,不同手机上性能差异非常大。这会影响到 JavaScript 的性能,因为 JavaScript 的执行是 CPU 密集型任务。

实际上,像 Chrome 这样的浏览器上的页面加载总时间,有多达 30% 的时间花在 JavaScript 执行上。下面是一个很典型的网站(Reddit.com)在高端桌面设备上的页面加载,

image

V8 中的 JavaScript 处理占用了页面加载时间的 10-30%

移动设备上,中端机(Moto G4)的 JavaScript 执行时间是高端机(Pixel 3)的 3 到 4 倍,低端机(不到100 刀的 Alcatel 1X)上有超过 6 倍的性能差异:

image

Reddit 在不同设备类型上(低端、中端和高端)的 JavaScript 性能损耗

注意:Reddit 在桌面端和移动端的体验完全不同,因此 MacBook Pro 上的结果并不能和其他设备上的结果直接做比较。

当你尝试优化 JavaScript 执行时间,注意关注长任务[6],它可能长期独占 UI 线程。这些任务会阻塞执行关键任务,即便页面看起来已经加载完成。把长任务拆分成多个小任务。通过代码分割和指定加载优先级,可以提升页面可交互速度,并且有希望降低输入延迟。

image

长任务独占主线程,应该拆分它们

V8 在提升解析编译速度上做了什么?


Chrome 60+ 上,V8 对于初始 JavaScript 的解析速度提升了 2 倍。与此同时, 由于 Chrome 上的其他并行优化,初始解析和编译的性能损耗更少了。

V8 减少了主线程上的解析编译任务,平均减少了 40%(比如 Facebook 上是 46%,Pinterest 上是 62%),最高减少了 81%(YouTube),这得益于将解析编译任务搬到了 worker 线程上。这对于流式解析/编译[7]是一个补充。

image

不同 V8 版本上的解析时间

下图形象呈现了不同 Chrome V8 版本上 CPU 解析时间。Chrome 61 解析 Facebook 的 JS 所花的时间里,Chrome 75 可以解析 Facebook 和6次 Twitter。

image

我们来研究下这些释放出来的改变。简言之,流式解析和 worker 线程编译脚本,这意味着:

•V8 可以解析编译 JavaScript 时不阻塞主线程。•流式解析始于整个 HTML 解析器遇到 <script> 标签。对于阻塞解析的 JS,HTML 解析器会暂停,而异步 JS 会继续执行。•大多数真实世界的网络连接速度下,V8 解析比下载快,所以 V8 在 JS 下载完后很快就完成了解析编译。

稍微解释下…很老的 Chrome 上会在全部下载完 JS 后才开始解析,这很直接但并没有完全利用好 CPU。Chrome 41 和 68 之间的版本上,Chrome 在下载一开始就在一个独立线程上解析 async 和 defer 的 JavaScript。

image

页面上的 JavaScript 代码被分割成多个块。V8 只会对超过 30KB 的代码块进行流式解析。

Chrome 71 上,我们开始做一个基于任务的调整,调度器可以一次解析多个 async/defer 脚本。这一改变使得主线程解析时间减少了 20%,在真实网站上,带来超过 2% 的 TTI/FID 提升。

译者注:FID(First Input Delay),第一输入延迟(FID)测量用户首次与您的站点交互时的时间(即,当他们单击链接,点击按钮或使用自定义的JavaScript驱动控件时)到浏览器实际能够的时间回应这种互动。交互时间(TTI)是衡量应用加载所需时间并能够快速响应用户交互的指标。

image

Chrome 72 上,我们转向使用流式解析作为主要解析方式:现在一般异步的脚本都以这种方式解析(内联脚本除外)。我们也停止了废除基于任务的解析,如果主线程需要的话,因为那样只是在做不必要的重复工作。

早期版本的 Chrome[8] 支持流式解析和编译,来自网络的脚本源数据必须先到达 Chrome 的主线程,然后才会转发给流处理器。

这常会造成流式解析器等待早已下载完成但还没有被转发到流任务的数据,因为它被主线程上的其他任务(比如 HTML 解析,布局或者 JavaScript 执行)所阻塞。

我们目前正在尝试开始对 preload 的 JS 进行解析,而主线程弹跳会事先对此形成阻塞。

Leszek Swirski 的 BlinkOn 演示[9]呈现了更多细节。

DevTools 上如何查看这些改变?


除了上述之外,DevTools 有个问题[10], 它暗中使用了 CPU,这会影响到整个解析任务的呈现。然而,解析器解析数据时就会阻塞(它需要在主线程上运行)。自从我们从一个单一的流处理线程中移动到流任务中,这一点就变成更为明显了。下面是你在 Chrome 69 中经常会看到的:

image

上图中的“解析脚本”任务花了 1.08 秒。而解析 JavaScript 其实并不慢!多数时间里除了等待数据通过主线程之外什么都不做。

Chrome 76 的表现大不相同:

image

Chrome 76 上,解析脚本被拆分成多个更小的流式任务。

通常,DevTools 性能面板很适合用来查看页面上发生的行为。对于更详细的 V8 特定指标,比如 JavaScript 解析编译时间,我们推荐使用带有运行时调用统计(RCS)的 Chrome Tracing[11]。RCS 结果中,Parse-Background 和 Compile-Background 代表主线程以外的线程解析和编译 JavaScript 花费的时间,然而 Parse 和 Compile 记录了主线程的指标。

image

这些改变的真实影响?


来看一些真实网站的 JavaScript 流式解析的应用实例。

image

MacBook Pro 上主线程和 workder 线程解析编译 Reddit 的 JS 所花的时间

Reddit.com 有多个 100 KB+ 的代码包,这些包被包装在引起主线程大量懒编译[12]的外部函数中。在上图中,由于主线程忙碌会延迟可交互时间,其运行时间至关重要。Reddit 花了多数时间在主线程上,Work/Background 线程的利用率很低。

这得益于将大包分割成多个小包(比如每个 50KB),以达到最大并行化,从而每个包都可以被独立地流式解析编译,减轻主线程在启动阶段的压力。

image

Facebook 在 Macbook Pro 上的主线程和 worker 线程解析编译时间对比

再来看看 Facebook.com。Facebook通过 292 个请求加载了 6MB 压缩后的 JS,其中有些是异步的,有些是预加载的,还有些的加载优先级较低。它们很多 JavaScript 的粒度都非常小 - 这对 Background/Worker 线程上的整体并行化很有用,因为这些小的 JavaScript 可以同时被流式解析编译。

注意,你可能不是 Facebook,很可能没有一个类似 Facebook 或者 Gmail 这样的长寿应用,在桌面端,它们放如此多的 JavaScript 是无可非议的。然而,一般来说,应该让你的包的粒度较粗,并且按需加载。

尽管多数 JavaScript 解析编译任务可以在 background 线程中以流的形式完成,但是某些任务仍然必须要在主线程中进行。当主线程忙碌时,页面不能响应用户输入。注意关注下载执行代码对你的用户体验造成的影响。

注意:当下,不是所有的 JavaScript 引擎和浏览器都实现了 script streaming 来优化加载。但我们相信大家为了优秀用户体验会加入这项优化的。

解析 JSON 的性能损耗


由于 JSON 语法比 JavaScript 语法简单得多,解析 JSON 也会更快。这一点可以用于提升 web 应用的启动性能,我们可以使用类似 JSON 的对象字面量配置(比如内联 Redux store)。不要使用 JavaScript 对象字面量来内联数据,比如这样:

const data = { foo: 42, bar: 1337 }; // 🐌

它可以被表示成字符串化的 JSON 格式,运行时会变成解析后的 JSON:

const data = JSON.parse('{"foo":42,"bar":1337}'); // 🚀

只要 JSON 字符串只被执行一次,尤其是在冷启动阶段,JSON.parse 方法相比 JavaScript 对象字面量会快得多。在大于 10 KB 的对象上使用这个技巧的效果更佳 - 但在实际应用前,还是先要测试下真实效果。

在大型数据上使用普通对象字面量还有个风险:它们可能被解析两次!

1.第一次发生于字面量预解析阶段。2.第二次发生于字面量懒解析阶段。

第一次解析无法避免。幸运地,第二次可以通过将对象字面量放在顶层来避免,或者放在 PIFE[13]

关于重复访问上的解析/编译?


V8 的字节码缓存优化大有帮助。当首次请求 JavaScript,Chrome 下载然后将其交给 V8 编译。Chrome 也会将文件存进浏览器的磁盘缓存中。当 JS 文件再次请求,Chrome 从浏览器缓存中将其取出,并再次将其交给 V8 编译。这个时候,编译后代码是序列化后的,会作为元数据被添加到缓存的脚本文件上。

image

V8 中的字节码缓存工作示意图

第三次,Chrome 将文件和文件元数据从缓存中取出,一起交给 V8 处理。V8 对元数据作反序列化,这样可以跳过编译。字节码缓存会在 72 小时内的前两次访问生效。配合使用 serive worker 来缓存 JavaScript 代码,Chrome 的字节码缓存效果更佳。你可以在给开发者讲的字节码缓存[14]这篇文章中了解到更多细节。

结论


2019 年,下载和执行时间是加载 JavaScript 的主要瓶颈。首屏展示内容里使用异步的(内联)JavaScript的小型包,页面剩下部分使用延迟(deferred)加载的 JavaScript。分解大型包,实现代码按需加载。这样可以最大化 V8 的并行解析。

移动设备上,考虑到网络、内存使用和低端 CPU 上的执行时间,你应该传输更少的 JavaScript。平衡可缓存性和延迟,实现在主线程之外解析编译任务数量的最大化。

进一步阅读


•Blazingly fast parsing, part 1: optimizing the scanner[15]•Blazingly fast parsing, part 2: lazy parsing[16]

References

[1] @addyosmani: https://twitter.com/addyosmani
[2] JavaScript 性能: https://medium.com/@addyosmani/the-cost-of-javascript-in-2018-7d8950fbb5d4
[3] 长任务: https://w3c.github.io/longtasks/
[4] 字节码缓存: https://v8.dev/blog/code-caching-for-devs
[5] 实际感受到的网络速度: https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/effectiveType
[6] 长任务: https://web.dev/long-tasks-devtools/
[7] 流式解析/编译: https://blog.skrskrskrskr.com/2018/08/29/%E3%80%90%E8%AF%91%E3%80%91%E4%BD%BF%E7%94%A8Script-Streaming%E6%8F%90%E5%8D%87%E9%A1%B5%E9%9D%A2%E5%8A%A0%E8%BD%BD%E6%80%A7%E8%83%BD/
[8] 早期版本的 Chrome: https://v8.dev/blog/v8-release-75#script-streaming-directly-from-network
[9] BlinkOn 演示: https://www.youtube.com/watch?v=D1UJgiG4_NI
[10] DevTools 有个问题: https://bugs.chromium.org/p/chromium/issues/detail?id=939275
[11] 使用带有运行时调用统计(RCS)的 Chrome Tracing: https://v8.dev/docs/rcs
[12] 懒编译: https://v8.dev/blog/preparser
[13] PIFE: https://v8.dev/blog/preparser#pife
[14] 给开发者讲的字节码缓存: https://v8.dev/blog/code-caching-for-devs
[15] Blazingly fast parsing, part 1: optimizing the scanner: https://v8.dev/blog/scanner
[16] Blazingly fast parsing, part 2: lazy parsing: https://v8.dev/blog/preparser

🔲 ⭐

【译】图解Map、Reduce和Filter数组方法

map、reduce 和 filter 是三个非常实用的 JavaScript 数组方法,赋予了开发者四两拨千斤的能力。我们直接进入正题,看看如何使用(并记住)这些超级好用的方法!

Array.map()

Array.map() 根据传递的转换函数,更新给定数组中的每个值,并返回一个相同长度的新数组。它接受一个回调函数作为参数,用以执行转换过程。

let newArray = oldArray.map((value, index, array) => {
...
});

一个帮助记住 map 的方法:Morph Array Piece-by-Piece(逐个改变数组)

你可以使用 map 代替 for-each 循环,来遍历并对每个值应用转换函数。这个方法适用于当你想更新数组的同时保留原始值。它不会潜在地删除任何值(filter 方法会),也不会计算出一个新的输出(就像 reduce 那样)。map 允许你逐个改变数组。一起来看一个例子:

[1, 4, 6, 14, 32, 78].map(val => val * 10)
// the result is: [10, 40, 60, 140, 320, 780]

上面的例子中,我们使用一个初始数组([1, 4, 6, 14, 32, 78]),映射每个值到它自己的十倍(val * 10)。结果是一个新数组,初始数组的每个值被这个等式转换:[10, 40, 60, 140, 320, 780]

本节代码图解

Array.filter()

当我们想要过滤数组的值到另一个数组,新数组中的每个值都通过一个特定检查,Array.filter() 这个快捷实用的方法就派上用场了。

类似搜索过滤器,filter 基于传递的参数来过滤出值。

举个例子,假定有个数字数组,想要过滤出大于 10 的值,可以这样写:

[1, 4, 6, 14, 32, 78].filter(val => val > 10)
// the result is: [14, 32, 78]

如果在这个数组上使用 map 方法,比如在上面这个例子,会返回一个带有 val > 10 判断的和原始数组长度相同的数组,其中每个值都经过转换或者检查。如果原始值大于 10,会被转换为真值。就像这样:

[1, 4, 6, 14, 32, 78].map(val => val > 10)
// the result is: [false, false, false, true, true, true]

但是 filter 方法,返回真值。因此如果所有值都执行指定的检查的话,结果的长度会小于等于原始数组。

把 filter 想象成一个漏斗。部分混合物会从中穿过进入结果,而另一部分则会被留下并抛弃。

本节代码图解,演示了数字从漏斗上面进去,其中小部分从下面出来,并附上手写的代码

假设宠物训练学校有一个四只狗的小班,学校里的所有狗都会经过各种挑战,然后参加一个分级期末考试。我们用一个对象数组来表示这些狗狗:

const students = [
{
name: "Boops",
finalGrade: 80
},
{
name: "Kitten",
finalGrade: 45
},
{
name: "Taco",
finalGrade: 100
},
{
name: "Lucy",
finalGrade: 60
}
]

如果狗狗们的期末考试成绩高于 70 分,它们会获得一个精美的证书;反之,它们就要去重修。为了知道证书打印的数量,要写一个方法来返回通过考试的狗狗。不必写循环来遍历数组的每个对象,我们可以用 filter 简化代码!

const passingDogs = students.filter((student) => {
return student.finalGrade >= 70
})

/*
passingDogs = [
{
name: "Boops",
finalGrade: 80
},
{
name: "Taco",
finalGrade: 100
}
]
*/

你也看到了,Boops 和 Taco 是好狗狗(其实所有狗都很不错),它们取得了通过课程的成就证书!利用箭头函数的隐式返回特性,一行代码就能实现。因为只有一个参数,所以可以删掉箭头函数的括号:

const passingDogs = students.filter(student => student.finalGrade >= 70)

/*
passingDogs = [
{
name: "Boops",
finalGrade: 80
},
{
name: "Taco",
finalGrade: 100
}
]
*/

Array.reduce()

reduce() 方法接受一个数组作为输入值并返回一个值。这点挺有趣的。reduce 接受一个回调函数,回调函数参数包括一个累计器(数组每一段的累加值,它会像雪球一样增长),当前值,和索引。reduce 也接受一个初始值作为第二个参数:

let finalVal = oldArray.reduce((accumulator, currentValue, currentIndex, array) => {
...
}), initalValue;

本节代码图解,演示了用炖锅调制调料,并附上手写的代码

来写一个炒菜函数和一个作料清单:

// our list of ingredients in an array
const ingredients = ['wine', 'tomato', 'onion', 'mushroom']

// a cooking function
const cook = (ingredient) => {
return `cooked ${ingredient}`
}

如果我们想要把这些作料做成一个调味汁(开玩笑的),用 reduce() 来归约!

const wineReduction = ingredients.reduce((sauce, item) => {
return sauce += cook(item) + ', '
}, '')

// wineReduction = "cooked wine, cooked tomato, cooked onion, cooked mushroom, "

初始值(这个例子中的 '')很重要,它决定了第一个作料能够进行烹饪。这里输出的结果不太靠谱,自己炒菜时要当心。下面的例子就是我要说到的情况:

const wineReduction = ingredients.reduce((sauce, item) => {
return sauce += cook(item) + ', '
})

// wineReduction = "winecooked tomato, cooked onion, cooked mushroom, "

最后,确保新字符串的末尾没有额外的空白,我们可以传递索引和数组来执行转换:

const wineReduction = ingredients.reduce((sauce, item, index, array) => {
sauce += cook(item)
if (index < array.length - 1) {
sauce += ', '
}
return sauce
}, '')

// wineReduction = "cooked wine, cooked tomato, cooked onion, cooked mushroom"

可以用三目操作符、模板字符串和隐式返回,写的更简洁(一行搞定!):

const wineReduction = ingredients.reduce((sauce, item, index, array) => {
return (index < array.length - 1) ? sauce += `${cook(item)}, ` : sauce += `${cook(item)}`
}, '')

// wineReduction = "cooked wine, cooked tomato, cooked onion, cooked mushroom"

记住这个方法的简单办法就是回想你怎么做调味汁:把多个作料归约到单个。

和我一起唱起来!

我想要用一首歌来结束这篇博文,给数组方法写了一个小调,来帮助你们记忆:

Video

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。

🔲 ☆

常用 Git 操作总结

合并远程分支

  1. git fetch origin branch
  2. git checkout [your-branch]
  3. git merge FETCH_HEAD,merge 前可以先git dff [your-branch] [remote-branch],查看本地分支和远程分支的区别

使用 rebase 合并分支

  1. git rebase [origin-branch]
  2. 遇到冲突,解决完冲突后 git add
  3. git rebase –continue

rebase 和 merge 的区别

合并历史commit

git rebase -i [commit id] commit id是合并的提交的前一个提交节点的commitID,最上的是最早的提交
修改需要合并的commit,将pick修改成 squash
退出保存 wq
git push origin [branch-name] -f
rebase 的主要用途一是替代merge,保持分支历史的线性提交;二是方便合并历史提交。

撤销一次merge

  1. git reflog 确定回滚的commit id
  2. git reset –hard [commit id]

git revert 也可以回退到指定版本,和reset的区别在于,revert会产生一次新的提交,用一次新的提交来消除历史修改。而reset是直接删除历史commit。

合并时遇到冲突,想取消merge

git merge –abort

修改最后一次提交的commit message

git commit –amend

清空工作区的修改

git checkout .

恢复误删分支

  1. git log -g 找到之前分支提交的 commit id
  2. git branch recover_branch [commit id],这时切换到 recover_branch,可以看到原来的文件了。

修改历史提交信息

  1. git rebase -i HEAD~10(查看前10次提交信息,也可以直接输入想要修改的commit id)
  2. 将想要的修改的commit前的 pick 修改为 reword,保存并退出。
  3. 在弹出的窗口中,修改commit message,保存。

删除历史提交

  1. 用 git rebase -i 928582641a 指定 base 为你需要删除的提交的前一个提交。
  2. 删除指定的commit, 保存退出. 之后可能 git 会提示出现 conflict, 根据提示完成处理。

创建空白分支

  1. git checkout –orphan new_branch
  2. git rm -rf .

查看本地分支和远程分支的差异

  1. git fetch origin
  2. git diff master origin/master –minimal

建立远程追踪分支

git branch -u [remote_branch]

删除远程分支

  1. git branch -r -d origin/branch-name
  2. git push origin :branch-name
🔲 ⭐

聊一聊浏览器缓存

缓存的概念

什么是缓存

In computing, a cache is a hardware or software component that stores data so future requests for that data can be served faster.

cache 是一个硬件或软件的组件,用来存储将来会请求到的数据,让数据获取更快。狭义上,cache 指介于 CPU 和内存之间存储介质。广义上,凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为 cache。

缓存可分为硬件缓存和软件缓存两类。

  • 硬件 cache:CPU cache,GPU cache,DSP;
  • 软件 cache:Disk Cache,Web Cache 等。

两者主要区别在于,硬件缓存完全由硬件管理,而软件缓存是由软件来管理的。这里我们主要讨论软件缓存中的 Web Cache。

Web 缓存是一项临时存储 Web 资源的技术,以减少服务器负载。

Web 页面为什么要有缓存?

  1. 缓解服务器压力
  2. 提升性能
  3. 减少带宽消耗

Web 缓存的分类

  • 浏览器缓存: 针对单个用户
  • 代理缓存
  • 网关缓存:CDN
  • 数据库缓存
    数据库缓存是为了减少数据库查询,数据库缓存的数据都基本都存储在内存中。

浏览器缓存

计算机科学领域只有两个难题,缓存失效和命名。 —— Phil Karlton

浏览器的缓存策略

缓存的目标

  • 一个检索请求的成功响应: 对于 GET请求,响应状态码为:200,则表示为成功。一个包含例如HTML文档,图片,或者文件的响应;
  • 不变的重定向: 响应状态码:301;
  • 可用缓存响应:响应状态码:304,这个存在疑问,Chrome会缓存304中的缓存设置,Firefox;
  • 错误响应: 响应状态码:404 的一个页面;
  • 不完全的响应: 响应状态码 206,只返回局部的信息;
  • 除了 GET 请求外,如果匹配到作为一个已被定义的cache键名的响应;

浏览器对于缓存的处理是根据第一次请求资源时返回的响应头来确定的。

强缓存

强缓存就是向浏览器缓存查找该请求结果,并根据该结果的缓存规则来决定是否使用该缓存结果的过程。控制强制缓存的字段分别是 Expires 和 Cache-Control,其中 Cache-Control 优先级比 Expires 高。

Expires
Expires 是 HTTP/1.0 控制网页缓存的字段,其值为服务器返回该请求结果缓存的到期的绝对时间,即再次发起该请求时,如果客户端的时间小于 Expires 的值时,直接使用缓存结果。

Cache-Control
在 HTTP/1.1 中,Cache-Control 是最重要的规则,主要用于控制网页缓存,主要取值为:

  • public:所有内容都将被缓存(客户端和代理服务器都可缓存)
  • private:所有内容只有客户端可以缓存,Cache-Control的默认取值
  • no-cache:可以使用缓存内容,但是每次是否使用缓存,需要经过协商缓存来验证决定
  • no-store:所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存
  • max-age=xxx (xxx is numeric):缓存内容将在xxx秒后失效

from memory cache代表使用内存中的缓存,from disk cache则代表使用的是硬盘中的缓存,浏览器读取缓存的顺序为memory –> disk。

内存缓存(from memory cache)
内存缓存具有两个特点,分别是快速读取和时效性:

  1. 快速读取:内存缓存会将编译解析后的文件,直接存入该进程的内存中,占据该进程一定的内存资源,以方便下次运行使用时的快速读取。
  2. 时效性:一旦该进程关闭,则该进程的内存则会清空。

硬盘缓存(from disk cache)
硬盘缓存则是直接将缓存写入硬盘文件中,读取缓存需要对该缓存存放的硬盘文件进行I/O操作,然后重新解析该缓存内容,读取复杂,速度比内存缓存慢。

在浏览器中,浏览器会在js和图片等文件解析执行后直接存入内存缓存中,那么当刷新页面时只需直接从内存缓存中读取(from memory cache);而css文件则会存入硬盘文件中,所以每次渲染页面都需要从硬盘读取缓存(from disk cache)。内存缓存和磁盘缓存都只能用于派生类的资源请求。

协商缓存

协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程。协商缓存是无法减少请求数的开销的,但是可以减少返回的正文大小。主要有以下两种情况:

协商缓存生效,返回304
协商缓存失效,返回200和请求结果结果

控制协商缓存的字段分别有:Last-Modified/If-Modified-Since 和 Etag/If-None-Match,其中 Etag/If-None-Match 的优先级比 Last-Modified/If-Modified-Since 高。

Last-Modified / If-Modified-Since
Last-Modified是服务器响应请求时,返回该资源文件在服务器最后被修改的时间,如下。
f78610c56fac74d69ea5bea30ff4ce8f.png
If-Modified-Since则是客户端再次发起该请求时,携带上次请求返回的Last-Modified值,通过此字段值告诉服务器该资源上次请求返回的最后被修改时间。服务器收到该请求,发现请求头含有If-Modified-Since字段,则会根据If-Modified-Since的字段值与该资源在服务器的最后被修改时间做对比,若服务器的资源最后被修改时间大于If-Modified-Since的字段值,则重新返回资源,状态码为200;否则则返回304,代表资源无更新,可继续使用缓存文件,如下。

Etag/If-None-Match
Etag 是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成),如下。
1751531d0244181a16d8d01a27be7b1a.png
ETag的值有可能包含一个 W/ 前缀,来提示应该采用弱比较算法。

If-None-Match 是客户端再次发起该请求时,携带上次请求返回的唯一标识Etag值,通过此字段值告诉服务器该资源上次请求返回的唯一标识值。服务器收到该请求后,发现该请求头中含有If-None-Match,则会根据If-None-Match的字段值与该资源在服务器的Etag值做对比,一致则返回304,代表资源无更新,继续使用缓存文件;不一致则重新返回资源文件,状态码为200,如下。

启发式缓存

ae5076aeb2713256708f6389fbee55d1.png

f15c74ca21c152411b97997fd6607287.png
浏览器用来确定缓存过期时间的字段一个都没有!那该怎么办?有人可能会说下次请求直接进入协商缓存阶段,携带If-Moified-Since呗,不是的,浏览器还有个启发式缓存阶段😎
根据响应头中2个时间字段 Date 和 Last-Modified 之间的时间差值,取其值的10%作为缓存时间周期。

用户操作行为对缓存的影响

0ddeed8a00c722ce4fbd9b08f0985b87.png

🔲 ⭐

【译】无头 Chrome:服务端渲染 JS 页面的一个解决方案

TL;DR

无头 Chrome 是一个将动态 JS 页面转成静态 HTML 页面的即插即用的解决方案。将其运行于 web 服务器之上,你可以预渲染任何现代 JS 特性,从而提速内容加载,并且是可被搜索引擎索引的

本篇文章介绍的技术,旨在教大家如何使用 Puppeteer 的 API,给一个 Express 服务器添加服务端渲染(SSR)能力。最棒的地方是,应用本身几乎不需要修改任何代码。无头 Chrome 做了所有的重活。三两行代码,SSR 页面带回家。

大餐之前先来点甜点:

import puppeteer from 'puppeteer';

async function ssr(url) {
const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();
await page.goto(url, {waitUntil: 'networkidle0'});
const html = await page.content(); // serialized HTML of page DOM.
await browser.close();
return html;
}

注意: 我会在文章中使用 ES 模块(import),这要求 Node 8.5.0+,并在运行时加上 --experimental-modules 标志。觉得麻烦的话可以自行使用 require() 语句。关于 Node 上的 ES 模块支持可以读读这篇文章


## 导论----------------------------

如果我对 SEO 理解没有偏差的话,你读到这篇文章可能因为下面两个原因之一。首先,你已经搭建了一个 web 应用,并且它没有被搜索引擎索引!你的应用可能是 SPA,PWA,使用了 vanilla JS,或者使用了其他更复杂的框架或类库。老实说,你使用何种技术并不重要。重要的是,你花费了大量时间搭建出优秀的 web 页面,然而用户却搜不到它。你读这篇文章的另一个理由可能是因为,网上一些文章说了服务端渲染可以提升性能。你希望快速减少 JavaScript 启动时间,提升首次有效绘制速度。

一些框架,比如 Preact 使用了工具来实现服务端渲染。如果你使用的框架具备预渲染的解决方案,请继续使用。没有任何理由引入另一个工具(无头 Chrome / Puppeteer)。

爬取现代网站

搜索引擎爬虫,社交平台,甚至浏览器自诞生至今就唯一依赖于静态 HTML 标记,来索引 web 页面和表层内容。现代 web 页面已经演变的大为不同。基于 JavaScript 的应用,在很多时候,需要保持网站内容是对于爬取工具是可见的。

一些爬虫,比如 Google 搜索,已经变得更智能了!Google 的爬虫使用 Chrome 41 执行 JavaScript,并渲染出最终的页面。但是这个方案才刚出来,还不完美。举个例子,使用了新特性的页面,比如 ES6 Class,模块,箭头函数等,将会在这个比较老的浏览器上报错,使得页面不能正确渲染。至于其他搜索引擎,鬼知道它们在干嘛!?¯_(ツ)_/¯

使用无头 Chrome 预渲染页面


所有的爬虫程序都能够理解 HTML。我们要“解决”索引问题的话需要一个工具,它来执行 JS 生成 HTML。我不会告诉你现在已经有这样一个工具了!

  1. 该工具可以运行所有类型的现代 JavaScript,并吐出静态 HTML。
  2. 出现新特性时,该工具可以保持更新
  3. 已有应用上只需少量代码就可以运行这个工具

听起来很不错吧?这个工具就是浏览器

无头 Chrome 不在乎你使用什么库、框架或者工具。它将 JavaScript 作为早餐,在午饭前吐出静态 HTML。可能会更快一点 :) -Eric

如果你用的 Node,Puppeteer 容易上手。它的 API 提供了预渲染客户端应用的能力。下面用个例子演示下。

1. JS 应用示例

我们以一个 JavaScript 生成 HTML 的动态页面为例:

public/index.html

<html>
<body>
<div id="container">
<!-- Populated by the JS below. -->
</div>
</body>
<script>
function renderPosts(posts, container) {
const html = posts.reduce((html, post) => {
return `${html}
<li class="post">
<h2>${post.title}</h2>
<div class="summary">${post.summary}</div>
<p>${post.content}</p>
</li>`;
}, '');

// CAREFUL: assumes html is sanitized.
container.innerHTML = `<ul id="posts">${html}</ul>`;
}

(async() => {
const container = document.querySelector('#container');
const posts = await fetch('/posts').then(resp => resp.json());
renderPosts(posts, container);
})();
</script>
</html>

2. 服务端渲染函数

接下来,我们会使用之前提到的 ssr() 函数,并充实它的内容。

ssr.mjs

import puppeteer from 'puppeteer';

// In-memory cache of rendered pages. Note: this will be cleared whenever the
// server process stops. If you need true persistence, use something like
// Google Cloud Storage (https://firebase.google.com/docs/storage/web/start).
const RENDER_CACHE = new Map();

async function ssr(url) {
if (RENDER_CACHE.has(url)) {
return {html: RENDER_CACHE.get(url), ttRenderMs: 0};
}

const start = Date.now();

const browser = await puppeteer.launch();
const page = await browser.newPage();
try {
// networkidle0 waits for the network to be idle (no requests for 500ms).
// The page's JS has likely produced markup by this point, but wait longer
// if your site lazy loads, etc.
await page.goto(url, {waitUntil: 'networkidle0'});
await page.waitForSelector('#posts'); // ensure #posts exists in the DOM.
} catch (err) {
console.error(err);
throw new Error('page.goto/waitForSelector timed out.');
}

const html = await page.content(); // serialized HTML of page DOM.
await browser.close();

const ttRenderMs = Date.now() - start;
console.info(`Headless rendered page in: ${ttRenderMs}ms`);

RENDER_CACHE.set(url, html); // cache rendered page.

return {html, ttRenderMs};
}

export {ssr as default};

主要的变化:

  1. 添加了缓存。缓存已渲染的 HTML 对于加速响应时间居功至伟。当页面再次有请求过来,避免了无头 Chrome 的重复执行。我随后会讨论其他的优化
  2. 添加加载页面超时时的基本错误处理。
  3. 添加了 page.waitForSelector('#posts') 这行代码。确保在丢弃这个序列化页面之前,posts 节点存在于 DOM 之中。
  4. 记录无头浏览器渲染页面所用时间。
  5. 代码都被封装进名为 ssr.mjs 的模块中。

3. web 服务器示例

最后,一个小的 express 服务器完成了所有的工作。它预渲染 URL http://localhost/index.html(主页),并在响应中返回渲染结果。由于响应中包含了静态 HTML, 当用户访问页面,posts 节点会立刻呈现。

server.mjs

import express from 'express';
import ssr from './ssr.mjs';

const app = express();

app.get('/', async (req, res, next) => {
const {html, ttRenderMs} = await ssr(`${req.protocol}://${req.get('host')}/index.html`);
// Add Server-Timing! See https://w3c.github.io/server-timing/.
res.set('Server-Timing', `Prerender;dur=${ttRenderMs};desc="Headless render time (ms)"`);
return res.status(200).send(html); // Serve prerendered page as response.
});

app.listen(8080, () => console.log('Server started. Press Ctrl+C to quit'));

要运行这个例子,需安装依赖 (npm i --save puppeteer express),然后使用 Node 8.5.0+ 并带有 --experimental-modules 标志来运行服务器。

这是一个该服务器返回的响应示例:

<html>
<body>
<div id="container">
<ul id="posts">
<li class="post">
<h2>Title 1</h2>
<div class="summary">Summary 1</div>
<p>post content 1</p>
</li>
<li class="post">
<h2>Title 2</h2>
<div class="summary">Summary 2</div>
<p>post content 2</p>
</li>
...
</ul>
</div>
</body>
<script>
...
</script>
</html>

Server-Timing API 的一个最佳用例

Server-Timing API 支持将服务器性能指标(比如请求/响应时间,数据库查询)返回给浏览器。客户端可以使用这些信息来追踪 web 应用的所有性能数据。

Server-Timing 的一个最佳用例是上报无头 Chrome 预渲染页面的时间!只需在响应上添加 Server-Timing 头,就可以实现这一点:

res.set('Server-Timing',  `Prerender;dur=1000;desc="Headless render time (ms)"`);

客户端上,Performance Timeline APIPerformanceObserver 可以获取这些指标:

  const entry = performance.getEntriesByType('navigation').find(
e => e.name === location.href);
console.log(entry.serverTiming[0].toJSON());
{
"name": "Prerender",
"duration": 3808,
"description": "Headless render time (ms)"
}

性能结果

注意: 这些数据体现了我随后讨论的大多数性能优化

性能数据怎么样?在我的一个应用(代码)上,无头 Chrome 渲染页面大约需要 1s。页面被缓存后, 3G 低网速模拟下,FCP 要比客户端渲染版本的快 8.37s

&nbsp;首次绘制 (FP)首次内容绘制 (FCP)
客户端渲染4s11s
服务端渲染2.3s~2.3s

这些结果很有用。因为服务端渲染页面不再依赖于 JavaScript 的加载,用户看到有意义的内容比以前快得多。


Preventing re-hydration


还记得我说“我们无需在客户端应用上改任何代码”吗?那是骗你们的。

Express 应用接收请求,使用 Puppeteer 将页面加载进无头浏览器,然后在响应中返回结果。但这里有一个问题。

浏览器加载页面时,无头 Chrome 中相同的 JS 会在服务器上再次执行。有两处都在生成 HTML。

一起来修复这个问题。我们要告知页面,它的 HTML 早就名花有主了。我找到的解决方案是,在页面加载时判断 <ul id="posts"> 是否已在 DOM 中,如果在,页面就已经在服务端渲染过了,这样就可以避免重新创建 DOM。

public/index.html

<html>
<body>
<div id="container">
<!-- Populated by JS (below) or by prerendering (server). Either way,
#container gets populated with the posts markup:
<ul id="posts">...</ul>
-->
</div>
</body>
<script>
...
(async() => {
const container = document.querySelector('#container');

// Posts markup is already in DOM if we're seeing a SSR'd.
// Don't re-hydrate the posts here on the client.
const PRE_RENDERED = container.querySelector('#posts');
if (!PRE_RENDERED) {
const posts = await fetch('/posts').then(resp => resp.json());
renderPosts(posts, container);
}
})();
</script>
</html>

优化


除了缓存渲染结果之外,还有一些有趣的优化技巧。有的优化可以快速见效,而有的可能带有猜测性的。

中止不必要的请求

现在,整个页面(以及它请求的所有资源)都无脑地加载进无头 Chrome。然而,我们只关注于两件事情:

  1. 渲染 HTML
  2. 生成 HTML 的 JS

不构造 DOM 的网络请求是浪费的。一些资源,比如图片、字体、样式表和媒体内容,不参与页面的 HTML 构建。它们负责添加样式,补充页面的结构,但并不显式地创建页面。我们应该告诉浏览器去忽略掉这些资源!这样可以减少无头 Chrome 的工作负担,从而节省带宽,并且潜在地加速了大型页面的预渲染时间

Protocol 开发者工具提供了一个强大的特性,叫做网络拦截。它可以用于在浏览器发出之前修改请求。Puppeteer 也支持网络拦截,它是通过打开 page.setRequestInterception(true),监听页面的 request 事件来实现的。这样我们可以中止某些资源请求。

ssr.mjs

async function ssr(url) {
...
const page = await browser.newPage();

// 1. Intercept network requests.
await page.setRequestInterception(true);

page.on('request', req => {
// 2. Ignore requests for resources that don't produce DOM
// (images, stylesheets, media).
const whitelist = ['document', 'script', 'xhr', 'fetch'];
if (!whitelist.includes(req.resourceType())) {
return req.abort();
}

// 3. Pass through all other requests.
req.continue();
});

await page.goto(url, {waitUntil: 'networkidle0'});
const html = await page.content(); // serialized HTML of page DOM.
await browser.close();

return {html};
}

注意: 安全起见,我使用了一个白名单,允许所有其他类型的请求能够继续正常发出。预先避免中止掉其他必要的请求。

内联关键资源

使用构建工具(比如 gulp)编译应用,并在构建时将关键 CSS/JS 内联到页面内,是一种很常见的做法。由于浏览器初始化页面加载时的请求数更少了,这样也就加速了首次有效绘制时间。

别用构建工具了,浏览器就是你的构建工具!我们可以用 Puppeteer 管理页面 DOM,内联样式,JavaScript, 或者其他任何你想在预渲染之前加到页面中的东西。

这个例子演示了如何拦截本地样式表的响应,并将这些资源内联进 <style> 标签中:

ssr.mjs

import urlModule from 'url';
const URL = urlModule.URL;

async function ssr(url) {
...
const stylesheetContents = {};

// 1. Stash the responses of local stylesheets.
page.on('response', async resp => {
const responseUrl = resp.url();
const sameOrigin = new URL(responseUrl).origin === new URL(url).origin;
const isStylesheet = resp.request().resourceType() === 'stylesheet';
if (sameOrigin && isStylesheet) {
stylesheetContents[responseUrl] = await resp.text();
}
});

// 2. Load page as normal, waiting for network requests to be idle.
await page.goto(url, {waitUntil: 'networkidle0'});

// 3. Inline the CSS.
// Replace stylesheets in the page with their equivalent <style>.
await page.$$eval('link[rel="stylesheet"]', (links, content) => {
links.forEach(link => {
const cssText = content[link.href];
if (cssText) {
const style = document.createElement('style');
style.textContent = cssText;
link.replaceWith(style);
}
});
}, stylesheetContents);

// 4. Get updated serialized HTML of page.
const html = await page.content();
await browser.close();

return {html};
}

这段代码:

  1. 使用一个 page.on('response') 处理器来监听网络响应。
  2. 储藏本地样式表的响应。
  3. 找到 DOM 中所有的 <link rel="stylesheet">,将它们替换成一个等价的 <style>。具体见 page.$$eval API 文档。style.textContent 被设为样式表的响应内容。

自动压缩资源

另一个可以借助网络拦截玩的小把戏是修改请求的响应内容

举个例子,你想要压缩 CSS,但也希望开发阶段不要被压缩,这样开发时能方便些。假设你已经用另一个工具来预压缩 styles.css,可以用 Request.respond(),将 styles.css 的内容重写为 styles.min.css

ssr.mjs

import fs from 'fs';

async function ssr(url) {
...

// 1. Intercept network requests.
await page.setRequestInterception(true);

page.on('request', req => {
// 2. If request is for styles.css, respond with the minified version.
if (req.url().endsWith('styles.css')) {
return req.respond({
status: 200,
contentType: 'text/css',
body: fs.readFileSync('./public/styles.min.css', 'utf-8')
});
}
...

req.continue();
});
...

const html = await page.content();
await browser.close();

return {html};
}

重用 Chrome 实例实现交叉渲染

每次预渲染都启动新的浏览器会很浪费。相反,你希望只启动一个实例,然后在多个页面渲染时重用它。

Puppeteer 可以通过调用 puppeteer.connect(),连接到一个已有的 Chrome 实例,它接收实例的远程调试 URL 作为参数。为保证浏览器实例的长时间运行,我们可以将 ssr() 函数启动 Chrome 这部分代码移到 Express 服务器里。

server.mjs

import express from 'express';
import puppeteer from 'puppeteer';
import ssr from './ssr.mjs';

let browserWSEndpoint = null;
const app = express();

app.get('/', async (req, res, next) => {
if (!browserWSEndpoint) {
const browser = await puppeteer.launch();
browserWSEndpoint = await browser.wsEndpoint();
}

const url = `${req.protocol}://${req.get('host')}/index.html`;
const {html} = await ssr(url, browserWSEndpoint);

return res.status(200).send(html);
});

ssr.mjs

import puppeteer from 'puppeteer';

/**
* @param {string} url URL to prerender.
* @param {string} browserWSEndpoint Optional remote debugging URL. If
* provided, Puppeteer's reconnects to the browser instance. Otherwise,
* a new browser instance is launched.
*/
async function ssr(url, browserWSEndpoint) {
...
console.info('Connecting to existing Chrome instance.');
const browser = await puppeteer.connect({browserWSEndpoint});

const page = await browser.newPage();
...
await page.close(); // Close the page we opened here (not the browser).

return {html};
}

例子:实现周期性预渲染的定时任务

App 引擎面板应用 里,我创建了一个定时处理器,来周期性的重复渲染排名前几位的页面。帮助用户快速看到最新内容,他们根本感知不到一个新页面的启动性能消耗。在这个例子中,生成多个 Chrome 实例会很浪费。相反,我用了一个共享的浏览器实例来一次性渲染这些页面。

import puppeteer from 'puppeteer';
import * as prerender from './ssr.mjs';
import urlModule from 'url';
const URL = urlModule.URL;

app.get('/cron/update_cache', async (req, res) => {
if (!req.get('X-Appengine-Cron')) {
return res.status(403).send('Sorry, cron handler can only be run as admin.');
}

const browser = await puppeteer.launch();
const homepage = new URL(`${req.protocol}://${req.get('host')}`);

// Re-render main page and a few pages back.
prerender.clearCache();
await prerender.ssr(homepage.href, await browser.wsEndpoint());
await prerender.ssr(`${homepage}?year=2018`);
await prerender.ssr(`${homepage}?year=2017`);
await prerender.ssr(`${homepage}?year=2016`);
await browser.close();

res.status(200).send('Render cache updated!');
});

我还在 ssr.js export 上加了一个 clearCache() 函数。

...
function clearCache() {
RENDER_CACHE.clear();
}

export {ssr, clearCache};

## 其他因素------------------------------------

告诉页面:“你正在被无头浏览器渲染”

当页面正在服务器上的无头 Chrome 中渲染时,客户端逻辑很有必要知道这一信息。我的应用使用了钩子来“关闭”部分不参与渲染 post 节点的页面。举例来说,我禁用了懒加载 firebase-auth.js 这部分代码。根本不需要用户登录!

在 URL 上加一个 ?headless 参数,是一个给页面加钩子的简单方法:

ssr.mjs

import urlModule from 'url';
const URL = urlModule.URL;

async function ssr(url) {
...
// Add ?headless to the URL so the page has a signal
// it's being loaded by headless Chrome.
const renderUrl = new URL(url);
renderUrl.searchParams.set('headless', '');
await page.goto(renderUrl, {waitUntil: 'networkidle0'});
...

return {html};
}

可以在页面内查询该参数:

public/index.html

<html>
<body>
<div id="container">
<!-- Populated by the JS below. -->
</div>
</body>
<script>
...

(async() => {
const params = new URL(location.href).searchParams;

const RENDERING_IN_HEADLESS = params.has('headless');
if (RENDERING_IN_HEADLESS) {
// Being rendered by headless Chrome on the server.
// e.g. shut off features, don't lazy load non-essential resources, etc.
}

const container = document.querySelector('#container');
const posts = await fetch('/posts').then(resp => resp.json());
renderPosts(posts, container);
})();
</script>
</html>

Tip:Page.evaluateOnNewDocument() 也可以方便的查询参数。它会在页面中注入代码,让 Puppeteer 在页面中剩余待执行的 JavaScript 之前运行这些代码。

避免 PV 膨胀

你如果正在页面上使用分析工具,那么要小心了。预渲染的页面可能会造成 PV 出现膨胀。具体来说,打点数据将会提升2倍,一半是在无头 Chrome 渲染时,另一半出现在用户浏览器渲染时。

那么怎么修复这个问题呢?将所有加载分析脚本的请求拦截掉。

page.on('request', req => {
// Don't load Google Analytics lib requests so pageviews aren't 2x.
const blacklist = ['www.google-analytics.com', '/gtag/js', 'ga.js', 'analytics.js'];
if (blacklist.find(regex => req.url().match(regex))) {
return req.abort();
}
...
req.continue();
});

代码不加载,页面访问就不会被记录。真 Skr 个机灵鬼 💥。

或者,你也可以继续加载分析脚本,来获悉服务器上运行的预渲染器数。


结论


Puppeteer 通过运行无头 Chrome,不费吹灰之力就实现了服务端渲染。提升加载性能没有改动大量代码就增强了应用的可索引性,是这个方案中我最喜欢的“特性”。

注意: 如果你对文章中描述的技术感兴趣,可以去看看这个应用,以及它的代码


附录


现有技术的讨论

很难在服务端上渲染客户端应用。有多难?去看看大家给这个话题奉献了多少个 npm 包就知道了。有数不清的模式工具,和服务来辅助服务端渲染的 JS 应用。

同构 JavaScript

同构 JavaScript 的概念很简单:同样的代码既能在服务端运行,也能在客户端(浏览器)运行。服务器和客户端共享代码,美滋滋!

实践中,我发现同构 JS 很难实现。这是我自己的问题…

我最近开始做一个项目,尝试下 lit-html。Lit 是一个优秀的库,它可以允许你写使用 JS 模板字符串写 HTML <template>,然后高效地将这些模板渲染为 DOM。问题是它的核心特性(使用 <template> 元素)只能在浏览器上工作。这意味着它在 Node 服务器上不能运行。我希望 Node 和前端共享的 SSR 代码能够脱离 window 对象。

最后我意识到可以使用无头 Chrome 来服务端渲染应用,Chrome 是经用户的手运行或是在服务器上自动运行并不重要,它反正是愉快地执行了所有 JS。不要多问。

无头 Chrome 在服务器和客户端上启用 “同构 JS”。它对于当前库不支持服务端(Node)给出了一个不错的解决方案。

预渲染工具

Node 社区已经诞生了好几吨解决服务端渲染 JS 应用的工具。毫无新意!个人而言,我发现各人对于这些工具的体会可能不同,所以使用这些工具前肯定要做好功课。比如说,一些服务端渲染工具比较老,并且没有使用无头 Chrome(或者任何其他无头浏览器)。相反,它们使用 PhantomJS(又名旧 Safari),这意味着使用新特性时页面不会正确渲染。

一个值得注意的例外是 Prerender。Prerender 使用了无头 Chrome 和 Express 中间件

const prerender =  require('prerender');  
const server = prerender();
server.use(prerender.removeScriptTags());
server.use(prerender.blockResources());
server.start();

Prerender 省去了跨平台下载和安装 Chrome 的所有细节。要正确完成这一过程通常是相当棘手的,这也是 Puppeteer 存在的原因之一。我也提了一些渲染我的部分应用的 issue。

浏览器中渲染的 Chrome 状态

prerender 渲染的 Chrome 状态

🔲 ☆

【译】ES2018 新特性:Promise.prototype.finally()

Jordan Harband 提出了 Promise.prototype.finally 这一章节的提案。

如何工作?

.finally() 这样用:

promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});

finally 的回调总是会被执行。作为比较:

  • then 的回调只有当 promise 为 fulfilled 时才会被执行。
  • catch 的回调只有当 promise 为 rejected,或者 then 的回调抛出一个异常,或者返回一个 rejected Promise 时,才会被执行。
    换句话说,下面的代码段:
    promise
    .finally(() => {
    «statements»
    });

等价于:

promise
.then(
result => {
«statements»
return result;
},
error => {
«statements»
throw error;
}
);

使用案例

最常见的使用案例类似于同步的 finally 分句:处理完某个资源后做些清理工作。不管是一切正常,还是出现了错误,这样的工作都是有必要的。
举个例子:

let connection;
db.open()
.then(conn => {
connection = conn;
return connection.select({ name: 'Jane' });
})
.then(result => {
// Process result
// Use `connection` to make more queries
})
···
.catch(error => {
// handle errors
})
.finally(() => {
connection.close();
});

.finally() 类似于同步代码中的 finally {}

同步代码里,try 语句分为三部分:try 分句,catch 分句和 finally 分句。
对比 Promise:

  • try 分句相当于调用一个基于 Promise 的函数或者 .then() 方法
  • catch 分句相当于 Promise 的 .catch() 方法
  • finally 分句相当于提案在 Promise 新引入的 .finally() 方法

然而,finally {} 可以 return 和 throw ,而在.finally() 回调里只能 throw, return 不起任何作用。这是因为这个方法不能区分显式返回和正常结束的回调。

可用性

深入阅读


原文:http://exploringjs.com/es2018-es2019/ch_promise-prototype-finally.html

🔲 ☆

【译】ES2018 新特性:Rest/Spread 特性

Sebastian Markbåge 提出的 Rest/Spread Properties 提案包括两部分:

  • 用于对象解构的 rest 操作符(…)。目前,这个操作符只能在数组解构和参数定义中使用
  • 对象字面量中的 spread 操作符(…)。目前,这个操作符只能用于数组字面量和在函数方法中调用。

对象解构中的 rest 操作符(…)

在对象解构模式下,rest 操作符会将解构源的除了已经在对象字面量中指明的属性之外的,所有可枚举自有属性拷贝到它的运算对象中。

const obj = {foo: 1, bar: 2, baz: 3};
const {foo, ...rest} = obj;
// Same as:
// const foo = 1;
// const rest = {bar: 2, baz: 3};

如果你正在使用对象解构来处理命名参数,rest 操作符让你可以收集所有剩余参数:

function func({param1, param2, ...rest}) { // rest operator
console.log('All parameters: ',{param1, param2, ...rest}); // spread operator
return param1 + param2;
}

语法限制

在每个对象字面量的顶层,可以使用 rest 操作符最多一次,并且必须只能在末尾出现:

const {...rest, foo} = obj; // SyntaxError
const {foo, ...rest1, ...rest2} = obj; // SyntaxError

如果是嵌套结构,你可以多次使用 rest 操作符:

const obj = {
foo: {
a: 1,
b: 2,
c: 3,
},
bar: 4,
baz: 5,
};

const {foo: {a, ...rest1}, ...rest2} = obj;

// Same as:
// const a = 1;
// const rest1 = {b: 2, c: 3};
// const rest2 = {bar: 4, baz: 5};

对象字面量中的 spread 操作符

对象字面量内部,spread 操作符将自身运算对象的所有可枚举的自有属性,插入到通过字面量创建的对象中:

> const obj = {foo: 1, bar: 2, baz: 3};
> {...obj, qux: 4}
{ foo: 1, bar: 2, baz: 3, qux: 4 }

要注意的是顺序问题,即使属性 key 并不冲突,因为对象会记录插入顺序:

> {qux: 4, ...obj}
{ qux: 4, foo: 1, bar: 2, baz: 3 }

如果 key 出现了冲突,后面的会覆盖前面的属性:

> const obj = {foo: 1, bar: 2, baz: 3};
> {...obj, foo: true}
{ foo: true, bar: 2, baz: 3 }
> {foo: true, ...obj}
{ foo: 1, bar: 2, baz: 3 }

对象 spread 操作符的使用场景

这一节,我们会看看 spread 操作符的使用场景。我也会用 Object.assign() 实现一遍,它和 spread 操作符很相似(之后我们会更详细地比较它们)。

拷贝对象

拷贝对象 obj 的可枚举自有属性:

const clone1 = {...obj};
const clone2 = Object.assign({}, obj);

clone 对象们的原型都是 Object.prototype,它是所有通过对象字面量创建的对象的默认原型:

> Object.getPrototypeOf(clone1) === Object.prototype
true
> Object.getPrototypeOf(clone2) === Object.prototype
true
> Object.getPrototypeOf({}) === Object.prototype
true

拷贝一个对象 obj,包括它的原型:

const clone1 = {__proto__: Object.getPrototypeOf(obj), ...obj};
const clone2 = Object.assign(
Object.create(Object.getPrototypeOf(obj)), obj);

注意,一般来说,对象字面量内部的 proto 只是浏览器内置的特性,并非 JavaScript 引擎所有。

对象的真拷贝

有时候,你需要老老实实地拷贝对象的所有自有属性(properties)和特性(writable, enumerable, …),包括 getters 和 setters。这时候 Object.assign() 和 spread 操作符就回天乏术了。你需要使用属性描述符(property descriptors):

const clone1 = Object.defineProperties({},
Object.getOwnPropertyDescriptors(obj));

如果还希望保留 obj 的原型,可以用 Object.create()

const clone2 = Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj));

“探索 ES2016 and ES2017” 里介绍了 Object.getOwnPropertyDescriptors()

陷阱:总是浅拷贝

我们之前见过的所有拷贝对象的方式,都是浅拷贝:如果原始属性值是一个对象,拷贝的对象将指向同一个对象,它不会(递归的、深度的)拷贝自身:

const original = { prop: {} };
const clone = Object.assign({}, original);

console.log(original.prop === clone.prop); // true
original.prop.foo = 'abc';
console.log(clone.prop.foo); // abc

其他使用场景

合并 obj1 和 obj2 两个对象:

const merged = {...obj1, ...obj2};
const merged = Object.assign({}, obj1, obj2);

给用户数据填充默认值

const DEFAULTS = {foo: 'a', bar: 'b'};
const userData = {foo: 1};

const data = {...DEFAULTS, ...userData};
const data = Object.assign({}, DEFAULTS, userData);
// {foo: 1, bar: 'b'}

安全地更新属性 foo:

const obj = {foo: 'a', bar: 'b'};
const obj2 = {...obj, foo: 1};
const obj2 = Object.assign({}, obj, {foo: 1});
// {foo: 1, bar: 'b'}

指定属性 foo 和 bar 的默认值:

const userData = {foo: 1};
const data = {foo: 'a', bar: 'b', ...userData};
const data = Object.assign({}, {foo:'a', bar:'b'}, userData);
// {foo: 1, bar: 'b'}

展开对象 VS Object.assign()

spread 操作符和 Object.assign() 很相似。主要的区别在于前者定义了新属性,而后者还进行了赋值。稍后将解释这究竟意味着什么。

Object.assign() 的两种使用方式

Object.assign() 有两种使用方式:
第一种,带有破坏性的(修改已有对象):

Object.assign(target, source1, source2);

这里的 target 对象被修改了;source1 和 source2 被拷贝进去了。
第二种,非破坏性的(已有对象不会被修改):

const result = Object.assign({}, source1, source2);

新对象是通过将 source1 和 source2 拷贝进一个空对象而生成的。最终,这个新对象被返回并赋值给 result。
spread 操作符类似于 Object.assign() 的第二种方式。接下来,我们来看看两者的相似和不同之处。

都是通过 “get” 操作符读值

在写对象之前,两者都使用了 ”get“ 操作符去读取源对象的属性值。这一过程会将 getter 被转换成正常的数据属性。
来看个例子:

const original = {
get foo() {
return 123;
}
};

original 有一个 foo getter(它的属性描述符有 get 和 set 属性)

> Object.getOwnPropertyDescriptor(original, 'foo')
{ get: [Function: foo],
set: undefined,
enumerable: true,
configurable: true }

但是在它拷贝的结果 clone1 和 clone2 里,foo 是一个正常的数据属性(属性描述符有value 和 writable 属性):

> const clone1 = {...original};
> Object.getOwnPropertyDescriptor(clone1, 'foo')
{ value: 123,
writable: true,
enumerable: true,
configurable: true }

> const clone2 = Object.assign({}, original);
> Object.getOwnPropertyDescriptor(clone2, 'foo')
{ value: 123,
writable: true,
enumerable: true,
configurable: true }

spread 定义属性,Object.assign() 设置属性

spread 操作符在目标对象上定义了新的属性,而Object.assign() 使用了一个 “set” 操作符来创建属性。这会导致两个结果:

目标对象带有 setter

首先,Object.assign() 触发 setter,而 spread 不会:

Object.defineProperty(Object.prototype, 'foo', {
set(value) {
console.log('SET', value);
},
});
const obj = {foo: 123};

以上代码段设置了一个 foo setter,它会被所有普通对象继承。
如果我们通过 Object.assign() 拷贝 obj,继承的 setter 会被触发:

> Object.assign({}, obj)
SET 123
{}

而 spread 就不会:

> { ...obj }
{ foo: 123 }

Object.assign() 在拷贝时还会触发自有 setter,这里并没有发生重写。

目标对象带有只读属性

第二,你可以通过继承只读属性,来阻止 Object.assign() 创建自有属性,但 spread 上这是做不到的:

Object.defineProperty(Object.prototype, 'bar', {
writable: false,
value: 'abc',
});

以上代码设置了只读属性 bar,它会被所有普通对象继承。
这样,你就再也不能使用赋值语句去创建自有属性 bar(严格模式下会抛一个异常,宽松模式会静默失败):

> const tmp = {};
> tmp.bar = 123;
TypeError: Cannot assign to read only property 'bar'

下列代码,我们使用对象字面量成功地创建了属性 bar。因为对象字面量没有设置属性,它只是定义了它们:

const obj = {bar: 123};

然而,Object.assign() 使用赋值语句创建属性,这就是不能拷贝 obj 的原因:

> Object.assign({}, obj)
TypeError: Cannot assign to read only property 'bar'

通过 spread 操作符拷贝没有问题:

> { ...obj }
{ bar: 123 }

spread 和 Object.assign() 都只拷贝自有可枚举属性

它们都会忽略所有继承的属性和不可枚举的自有属性。
对象 obj 从 proto 继承了一个可枚举属性,并且有两个自有属性:

const proto = {
inheritedEnumerable: 1,
};
const obj = Object.create(proto, {
ownEnumerable: {
value: 2,
enumerable: true,
},
ownNonEnumerable: {
value: 3,
enumerable: false,
},
});

如果拷贝 obj,结果将只有属性 ownEnumerable。属性 inheritedEnumerable 和 ownNonEnumerable 没有被拷贝:

> {...obj}
{ ownEnumerable: 2 }
> Object.assign({}, obj)
{ ownEnumerable: 2 }

原文:http://exploringjs.com/es2018-es2019/ch_rest-spread-properties.html

❌