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

《禅与摩托车维修艺术》这本书不是讲禅宗的,也不是教授摩托车​维修技术的。就像书的扉页提到的,它是一场对价值的探寻​,对“良质”的探寻。书中的很多观点,和我当下的一些想法不谋而合,而且进一步扩充了我的世界观和价值观,所以读起来感觉很爽。这里将个人觉得不错的一些书中观点摘抄记录了下来​。 当你做某件事的时候,一旦想要求快,就表示你再也不关心它,只想去做别的事。 我们从所观察到的无穷景致中选出一把沙子,然后称这把沙子为世界。 遇到复杂的问题,最好的办法是先把它写下来,描述问题的思路: 问题是什么 假设问题的原因 证实每个假设的实验方法 预测实验的结果 观察实验结果 得出实验结论 哲学上的实在论:要证明一个东西的存在,可以把它从环境中抽离出来,如果原先的环境无法正常运转,那么它就存在。 如果把登上山顶作为目标,你会辛苦得多,而这只是名义上的目标,真正的目标,是体验登山的每一分钟,同样是到达山顶,却要愉悦得多。 一旦你被训练得轻视自己的喜好,那么当然你就会对别人更加顺服——变成好奴隶。一旦你学会不做自己喜欢的事,那么你就会为系统所接受。 价值的僵化是指固守以前的价值观,无法从新的角度衡量事物。如果价值观是僵化的,你就无法接受任何新的事实。如果一直坚持原来的看法,就无法找到真正的答案,即使它就在你的眼前。如果你的价值观僵化了,你要做的就是刻意放慢脚步,然后重新审视过去你认为重要的事物是否仍然重要。用注视鱼线的方式静静地注视着它,不久你一定会看到鱼线在动。 如果你自视甚高,那么你观察新事物的能力就会降低。焦虑来自于急于求成,摒弃烦躁的最好方法是,不设定工作时间。 对自己手中的工作产生认同感,有了认同感,就会看到良质。 梭罗:只有在失去的时候才有所获得。 我们最苛责别人的地方,往往就是我们自己最深的恐惧。 我靠着取悦别人过活,揣测别人希望听到你说什么,然后假装主动又自然地说出来。他却始终忠实于自己的信念。有时候我觉得他是活生生的人,而我才是鬼魂。 在你冥思苦想而不可得的时候,不要沿着原有的思路继续走下去,你应该停下脚步,放松一段时间,发散自己的思想,直到碰到一些事,能够让你拓展原先知识的根基。 科技的问题在于它并没有和人的心灵连在一起,所以盲目地表露出它丑陋的一面,因此必然引起人们的厌恶。 真理的陷阱:除了是与非之外,还有第三种可能性:无。 最后分享一个书中提到的南部印第安人抓猴子的故事。首先猎人把挖空的椰子用绳子绑在一根木头上,椰子里面放了一些米,通过一个小洞就能摸到。由于洞很小,猴子只能把手伸进去。而当手中握了米,就很难拽出来。猴子不会衡量自由和拥有白米孰轻孰重,村民们利用这一点,把它逮到笼子里带走了。

2022/8/26
articleCard.readMore

工作的意义和无意义

最近几个月,常感到工作意义感的缺失。源自几个方面,一是就算继续按部就班工作,也就那样,生活不会有太大改变;二是当人一闲下来,就会开始想一些有的没的。过去似乎没怎么想过这个问题,工作的意义究竟是什么? 因为热爱吗?从当初大学毕业,满怀着热忱加入了这个行业。刚开始,工作是兴趣驱动,对我而言一切都是新的,解决未知的问题会给人带来成就感。凌晨两点上线,见过凌晨四点的北京上海,也乐在其中,不会感觉到累。慢慢地,经历了跳槽、晋升等等大部分职场人的必经之路。发现职场中的很多事情,都是被推着走的,你进入了那个轨道后,一些事就会自然而然发生。人们常说选择大于努力,这句话是有道理的。当你身处其中的时候,会有无数双手推着你往前走。伴随的副作用就是,渐渐地被琐碎的细节,被无意义的会议,被日复一日的加班磨平棱角。有一天发现,已经很久没有见过夕阳,没有闻到雨后泥土的味道,没有听到过蝉鸣的声音。不再有当初那种热爱,多数时候工作就只是不得不做的事情而已。 为了赚钱吗?确实是需要赚钱支撑生活,但我的物质欲望一直维持在一个很低的水平。如果要迎合世俗意义上的成功,人的欲望可能是无止境的。完成了父母的期待,又要追寻社会成功的标准,一切仿佛永无止境。更重要的是,为满足物质欲望,势必要失去很多东西。 我认为现代人本质上都是在和社会做各种交易,车子、房子、彩礼、精力、甚至未来的时间,这些无不可作为交易的筹码。身为无产阶级,最有价值的资产就是身体和时间。体力劳动者是拿身体和时间和雇主做交易,脑力劳动者是用身体、知识、时间和雇主做交易。要从雇主那里拿走更多的钱,就要承担被剥削时间、被压榨身体、被限制自由。既然要一直做交易,必然不能 all in,需要做 trade off,否则手上很快就没有筹码了。所以,一定不要把时间全部交给公司,没有时间为自己投资的话,在这个时代是很危险的。长期来看,个人的单位时间产出不会有指数级增长,甚至到了一定年龄后还会下降,老是埋头干活,没有时间抬头看路、仰望星空,有可能一开始就选择了一条错误的路,也有可能错过了沿途的风景。另外,一定要爱护自己的身体。如果没有发生阶级跃迁,正常情况下 65 岁退休,还有三四十年的时间,需要和社会做这种交易。所以,以人为本,追求可持续发展方为上策(涛哥的科学发展观也是很重要的🐶)。 回到最初的问题,工作的意义究竟是什么呢?时至今日,我发现自己对于未知领域依然有着好奇心,但为了生活,也依然需要赚钱。不同的是,我慢慢发现技术终归是为人服务的,它是一种手段。赚钱是为了满足自己的物质需求,它也只是一种手段,并不应该作为人生的目标。假如自己想要的东西能通过其他手段来满足,赚钱就不再是唯一选择。 我们做的所有事情,都是在解决人类的某一类需求。按照马斯洛需求层次理论,人的需求可分为生理需求、安全需求、爱与归属、尊重需求和自我实现,前面的需求体现了人的动物性,越往后越体现人有别于动物的地方。人远比任何一种机器都要复杂,如果能更加理解人性;如果能解决更多人的真实需求,不管用技术或者非技术的手段;助人即是助己,如果能通过自己的工作,帮助到更多的人;我想都会很有意义。 这么说,有点理想主义化了。不过在现实中,现实的人太多了,多一点理想主义未尝不好。 最后还有一个问题,为什么要探寻工作的意义呢?真的是这些意义感在支撑人类工作吗?并不是。我想最终还是因为,工作直接或者间接满足了人的一些最原始的需求:幸福感、快乐感、成就感……或许工作也好,人生也好,本都是毫无意义的,抛去那些我们为其附加上去的意义,快乐才是永恒不变的真理。所以,借用 TVB 的经典台词来结束:做人呐,最重要的就是开心啦!

2022/8/15
articleCard.readMore

对这一波互联网裁员的看法

怎么看待互联网裁员潮? 最近一年,国内这些互联网公司都过得不太平。从今年年初开始,各个大厂都在裁员,从传出的消息,裁员比例从 10% 到 30% 不等,更惨的是一些不赚钱的边缘部门,面临直接裁撤整条业务线。去年年中,各个大厂人员扩张,我当时就预测到了今年会有这一波大裁员。 从 2020 年疫情之初,互联网行业是为数不多的,受疫情影响不大的行业,甚至有些业务还从疫情中受益,比如游戏、在线办公等。过去两年,许多大厂团队规模在迅速扩张。字节教育团队,豪言一年要招一万人。笔者所在的团队,短短一年多时间,人员也增长了一倍多。 然而,人员快速扩张的背后,业务并没有跟着发展壮大。尤其在去年,受到一系列反垄断政策,国内资本逐渐收缩。叠加“共同富裕”这个大方向,国家要求企业要积极参与到三次分配中来。就在国家提出共同富裕不久,腾讯和阿里就各自拿出了 500 亿的“投名状”。可以预见的是,未来会有越来越多的平台企业要将他们的一部分利润拿出来,给社会再分配。你可以将其理解为“宰肥羊”,也可以理解为企业家们在提高他们的政治意识和社会责任意识。 另一方面,互联网公司们在国外的发展又受政治因素影响,我们经常听到的消息,要么是被某某国家下架我们的 APP,要么被列入某某制裁清单中,使得我们的产品很难发展海外市场。这种内外夹击,使得互联网的底层增长逻辑发生了改变,以前那套靠烧钱来跑马圈地的模式走不通了。过去,一家互联网公司动辄上百倍的 PE,大家觉得理所当然,毕竟是“科技公司”嘛,可实际上呢,这些公司真有什么科技含量吗?披着科技公司的外衣卖菜带货,靠堆积人力来提高产量,我看更像是软件行业的“富士康”。 对企业来说,员工是一种资源。经济形势好、业务扩张的时候,员工的剩余价值会比较多,资本家会尽可能地压榨更多的劳动价值,转换成企业的利润。与此同时,多数员工也是愿意被压榨的,因为在资本家吃饱喝足的时候,这些工人们自然也能跟着一起喝汤。在资本家和工人阶级的一起努力下,创造出了更大的价值。这是一种良性循环。但在经济形势差、业务收缩的时候,员工就不再是一种正向资源。企业在每个员工身上至少要付出两倍于工资的成本,如果员工带来的剩余价值低于企业付出的成本,员工就变成了消耗品。尽快抛弃“消耗品”,是企业节省成本最简单快速的办法,毕竟资本家开公司也不是为了做慈善。这是我理解的裁员背后的逻辑。 总之,资本主义社会中,企业依靠裁员来降低成本,在我看来是可以理解的,这听起来有一些残忍,不过这就是其运作的规则。当然,前提是企业按照劳动法,正常支付员工薪酬和赔偿。 裁员浪潮下,我们应该怎么做? 提升个人竞争力,修炼内功。 这是一个优胜劣汰的社会,要想不被淘汰,只能提升自己的竞争力,让自己具备创造更大价值的能力。在大公司,员工的不可替代性是很低的。提升个人竞争力,也是在提升自己的不可替代性。对做技术的我们来说,提高技术水平是很重要的。除此之外,逻辑思维能力、沟通表达能力,包括语文水平也是必不可少的,甚至我觉得比掌握一个框架或者学习一门编程语言更重要。技术只是一个工具,换一个行业或者岗位,这些专业知识可能就失效了,但那些软素质是会伴随终身的。而且最近有种体会,写代码的水平取决于语文水平,编写一段程序其实和创作一篇文章没啥本质区别,都是在对信息提炼加工,并以文字的形式表达出去。 不要把鸡蛋放在同一个篮子里。 大部分人的工资,是唯一的收入来源。一旦工作没了,收入就断了。怎么样降低工作对自己的不可替代性,即便没有这份工作,也能正常生活下去,这也是我最近在思考的问题。 不要负债。 对于现在的年轻人来说,房贷基本上就是唯一的负债。我的建议,最好别在这时候贷款买房,透支自己的未来,除非这个房子对你的意义特别特别大,不买就家破人亡。但在我看来,且不论将来房价是否会降,房子本身的意义也并没有那么大,它不过是一项固定资产。当你还没有能力吞下这个资产,通过加杠杆来强行上车,带来的只是无尽的压力和巨额的负债。 保持乐观心态,长期主义。 虽然经济环境不好,但我觉得也不用过分悲观。不管再过多少年,人类始终有获取信息的需求,始终有沟通交流的需求,互联网很好的满足了人类的这些需求。所以,这个行业只是在调整,变得像传统行业的水、电、煤一样,成为社会的基础设施,而并不会消失。如果拉长时间线来看,90年代的国企下岗潮远比现在的互联网裁员更猛烈,21世纪初的互联网泡沫造成的冲击也比现在要大。站在二十年后,再来看今天,也许不过是一片时代的小浪花。 偶尔会在这里聊一聊技术、人生,本人微信公众号:小熊写字的地方。

2022/5/24
articleCard.readMore

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 是怎么做到的呢? 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 聊一聊技术、人生,本人微信公众号:小熊写字的地方

2022/3/21
articleCard.readMore

Fastify 如何实现高性能路由

前言 对于一个 Node.js web 框架来说,路由系统的设计是重中之重。所谓路由,干的事情就是根据 HTTP method 和 URL 路径匹配需要执行的处理函数,并在处理函数中提供有关请求和回应的上下文。借助原生的 HTTP 模块,我们可以写出这样的代码,来实现一个简易的路由功能: const http = require("http"); const host = 'localhost'; const port = 8000; const requestListener = function (req, res) { res.setHeader("Content-Type", "application/json"); switch (req.url) { case "/hello": res.writeHead(200); res.end(JSON.stringify({"message": "world"})); break; default: res.writeHead(404); res.end(JSON.stringify({error:"Resource not found"})); } }; const server = http.createServer(requestListener); server.listen(port, host, () => { console.log(`Server is running on http://${host}:${port}`); }); 上面的这段代码有几个可优化点: 我们知道 HTTP 是一个文本传输协议,请求和响应内容实际上是一段字符串。因此我们在这里用了 JSON.stringify 来序列化响应 JSON,而响应内容的数据结构通常是固定的,这就给了我们优化的空间。比如已知会返回 {"message": msg},那直接做字符串拼接 '{"message":' + msg + '}',它的执行速度就会比 JSON.stringify 快。关于这块的性能优化,我会放到下一篇文章中单独介绍。 从可维护性的角度来看,它的请求分发逻辑和处理逻辑是耦合在一起的。在大型项目中,我们可能有成百上千个路由,这样的代码会变得难以维护。 路由查询性能。在这里例子中还不太能体现出来,因为它一共就1条路由规则。但假设项目中有 1000 条路由规则,怎么样根据请求方法和 path,快速匹配到对应的处理函数,这就需要一个高效的路由算法。 集中式路由 vs 分散式路由 对于第 2 点,我们很容易可以想到的一个优化方案是:将所有路由注册到一个映射表上。这个映射表大概是这样的: get / => home#index get /name => user#index post /user => user#create 箭头左边是请求方法和URL路径,右边是对应的处理函数。这样需要新增路由的时候,只需要更新映射表,而不用调整原有代码逻辑。这也是一种很常见的代码优化技巧。这种我将其称之为集中式路由,因为所有路由规则都是集中管理的。基于这种设计,写出来的 Controller 大概长这样: export default class AppController { index(){ return { status: 'ok' }); } } 当请求过来时,框架要找到对应的处理函数,首先要去逐行读取这个映射表文件,对每一行做正则匹配,拿到它的 Controller 名和处理函数名。其次框架要对 Controller 的命名和位置做约束,这样才能根据 Controller 名和处理函数名定位到具体的处理函数。 还有一种思路是,将路由规则的定义放到对应的Controller内部,这样路由的注册就分散到各个Controller了。以 hoth(百度内部基于 Fastify 自研的 Node 框架)为例,比如: import {Controller, GET} from '@hoth/decorators'; import type {FastifyReply, FastifyRequest} from 'fastify'; @Controller('/index') export default class AppController { @GET() getApp(req: FastifyRequest, reply: FastifyReply) { return { status: 'ok' }); } } 它使用了 Controller 和 GET 两个装饰器,实现了路由规则和处理函数的解耦,提升了代码的可读性和可维护性。装饰器只是一个语法糖,最终它还是走的 fastify 内部的路由注册逻辑: fastify.get('/index', (req, reply) => {}) 这里的 fastify.get 方法会在 Node.js 应用启动时执行,将路由规则加载到内存中。 对于小型项目,采用集中式路由或是分散式路由,对性能的影响微乎其微。这两种方案只有在大型项目中才能体现出性能优劣。集中式路由的问题在于,新增了一个(或多个)文件来单独存储这些路由规则,注册路由时框架需要解析文件,这就涉及到文件 IO;查询路由时可能还需要用到正则匹配,这对性能也是一种损耗。而像 fastify 采用的这种分散式路由,路由规则提前被注册到内存中,没有文件 IO,也不用正则来解析规则。 而且集中式注册路由也不利于应用的横向扩展,试想下有多个子应用的场景,框架就需要知道所有子应用的路由规则。 更高效的路由算法 除了上面讲到的注册路由的方式,存储路由的数据结构,对性能也有着不小的影响。最简单的可以想到用数组存储路由,它的查询性能是 O(n),随着路由数量增加,性能会越来越差。而在实际场景中,许多 HTTP 请求的路径有着相同的前缀。某些框架,比如 go 的 echo 和 gin,Node.js 的 fastify 会引入另一种数据结构:Radix-Trie,来存储路由。 在计算机科学中,基数树(Radix Trie,也叫基数特里树或压缩前缀树)是一种数据结构,是一种更节省空间的Trie(前缀树),其中作为唯一子节点的每个节点都与其父节点合并,边既可以表示为元素序列又可以表示为单个元素。 因此每个内部节点的子节点数最多为基数树的基数r ,其中r为正整数,x为2的幂,x≥1,这使得基数树更适用于对于较小的集合(尤其是字符串很长的情况下)和有很长相同前缀的字符串集合。 Radix-Trie 的插入、删除、查询的时间复杂度均为 O(k)(k为字符串集合中最长的字符串长度),因此无论路由规则增长多少,它都可以保证查询性能始终是常量级的。 fastify 的核心路由逻辑是另一个库 find-my-way 实现的,fastify 提供了 get、post、all 等方法用来注册路由,它们的内部调用了 find-my-way 上的 router.on 方法。当应用启动时,它会将所有路由构建成多个 Radix-Trie,比如: fastify.get('/index', (req, reply) => {}) 它其实就是在 Radix-Trie 上插入了一个新节点,tree 的数据结构大体是这样的: { prefix: '/', label: '/', method: 'GET', children: { i: { prefix: 'index', label: 'i', method: 'GET', children: [Object], numberOfChildren: 1, kind: 0, handler: [Object], }, u: { prefix: 'user', label: 'u', method: 'GET', children: [Object], numberOfChildren: 1, kind: 0, handler: [Object], }, numberOfChildren: 2, kind: 0, handler: [Object], params: [] } } fastify 根据不同 method,将路由划分到多个树中。prefix 标记路由前缀,如果某个节点只有一个子节点,那么会与其父节点合并。kind 用于标记路由的类型,find-my-way 支持这五种路由类型: { STATIC: 0, // 静态路由,/info/version PARAM: 1, // 带参数路由 /aaa/:id MATCH_ALL: 2, // 通配路由 /aaa/* REGEX: 3, // 正则路由 /at/(^\\d+) MULTI_PARAM: 4 // 多参数路由 /foo/:param1-:param2 } 现在有这样几条路由(省略了handler): GET /user GET /user/list GET /user/info GET /admin/group GET /admin/delete 它们构成的 tree 长这样: 现在要新增一条路由 GET /usage,假定它是节点 a,插入节点 a 到 tree 的步骤如下: 遍历 tree,找到节点在树中的最长公共前缀长度 len,这里 usage 在树中的最长公共前缀是us,长度是2。 如果最长公共前缀长度 maxCommonPrefixLen 等于当前节点的前缀长度 prefixLen,将(a - 公共前缀)插入到这个节点的子节点。显然节点 a 的 maxCommonPrefixLen 是小于 prefixLen 的。 如果 maxCommonPrefixLen 小于 prefixLen,将节点 a 拆分为公共前缀 + 剩余b,将公共前缀作为父节点,插入树中,然后对剩余部分 b 重复上述步骤。这里会将 us 作为父节点,插入到树中,然后将剩余部分 age 作为子节点插入。 最终,新的 tree 长这样: 讲完了路由节点的插入,再来看路由的查询。这里直接上代码: Router.prototype.find = function find(method, path) { var currentNode = this.trees[method]; if (!currentNode) return null; var originalPath = path; var originalPathLength = path.length; var i = 0; var idxInOriginalPath = 0; while (true) { var pathLen = path.length; var prefix = currentNode.prefix; var prefixLen = prefix.length; var len = 0; var previousPath = path; // 找到路由,返回handler if (pathLen === 0 || path === prefix) { var handle = currentNode.handler; if (handle !== null && handle !== undefined) { return { handler: handle.handler }; } } // 寻找最长公共前缀长度 i = pathLen < prefixLen ? pathLen : prefixLen; while (len < i && path.charCodeAt(len) === prefix.charCodeAt(len)) len++; // 如果最长公共前缀等于当前节点的前缀,将节点一分为二,公共前缀+剩余部分 // 继续对剩余部分遍历 if (len === prefixLen) { path = path.slice(len); pathLen = path.length; idxInOriginalPath += len; } var node = currentNode.findChild(path); // 没有子节点,退回到前一次查询的节点 if (node === null) { var goBack = previousPath.charCodeAt(0) === 47 ? previousPath : '/' + previousPath; if (originalPath.indexOf(goBack) === -1) { var pathDiff = originalPath.slice(0, originalPathLength - pathLen); previousPath = pathDiff.slice(pathDiff.lastIndexOf('/') + 1, pathDiff.length) + path; } idxInOriginalPath = idxInOriginalPath - (previousPath.length - path.length); path = previousPath; pathLen = previousPath.length; len = prefixLen; } // 静态路由 if (node.kind === NODE_TYPES.STATIC) { currentNode = node; continue; } // 还有对带参路由、正则路由等类型的判断,这里简化了 } }; 总结 web 框架中路由的性能主要取决于路由注册和路由匹配两个环节,采用何种介质、何种数据结构存储路由规则,很大程度影响了路由库的性能。本文介绍了一种数据结构 Radix-Trie,及其它在 fastify 中的应用。 除了路由之外,数据序列化也是一个性能优化点,在进程通信、请求响应等环节都会涉及到序列化。下一篇文章将介绍 fastify 中如何实现的高性能 JSON 序列化。 欢迎加微信 xxr0314 聊一聊技术、人生,本人微信公众号:小熊写字的地方

2022/3/15
articleCard.readMore

写给软件工程师的几条成长建议

前言 最近一段时间,通过和不同的人对话,脑海中涌现出了一些想法。另一方面也意识到自己正处在职业生涯的某个节点上,回顾从业的这几年,有得有失,所以想借这篇文章聊一下工程师成长这个话题,旨在能给大家带来一点点思考。受自身经验所限,这些观点不一定对,希望朋友们可以辩证的去思考。因为自己的职业属性,这里讨论的仅限于软件工程师这个群体。当然如果下面这些经验之谈能帮助到更多其他领域的同学,那我也荣幸之至。下面是正文,会分享几个个人认为工程师成长之路上很重要的点。 基础很重要 我之前作为一个求职者在面试的时候,喜欢问面试官一个问题:“你觉得优秀的工程师有哪些特质?”不同的面试官给出的答案不尽相同,但是他们几乎全都肯定了基础的重要性。 究竟什么是基础?我理解的基础大体上可以分为两类,一种是硬性的、可以被量化的,另一种是软性的、不可被量化的。这两者在你踏入职场前,身上就已经具备了其中部分才能或者潜质。 前者就是技术基础,对于这一点,不同岗位的要求是不一样的。比如以前招前端工程师时会要求你会 JavaScript、CSS、HTML 三剑客,随着现在行业越来越卷,要求的东西也越来越多,React、Vue、TypeScript、数据结构、网络等等等等。这里面有很多都是计科或者软工专业的同学在大学里学过的课程。这些也是比较容易被后天习得的,只要你肯踏踏实实静下心来学习几个月,肯定会收获很大。 而后者,不可被量化的那一类,则相对来说要难一些,不是所有人都可以短时间速成的。有些人生下来就具备这些特质,而有些人就算时隔多年也是在原地踏步。这些特质,包括但不限于学习能力、解决问题的能力、沟通能力、领导力。工作时你会发现跟有的同事讲话,非常顺畅,一点就通;而跟有的人沟通起来却很辛苦,恨不得把一句话扳开揉碎了喂到他的嘴边,这其实就是一种软素质较差的体现。 说了这么多,那么怎么样培养建立基础呢?我的答案是:刻意练习。明确自己的弱项,然后不停地反复地去刻意练习它。我刚上大学那会儿,每次上台讲话都会超级紧张,紧张到发抖冒汗。后来我做了个尝试,去学校的电信营业厅卖手机。这样每天都要被迫和许多人讲话,慢慢地学会了比较从容地面对人群讲话。有一个著名的一万小时定律,它说的是要成为某个领域的专家,需要 10000 小时。其实如果能在某一项上,坚持练习 1000 小时,就已经非常非常厉害了。 培养技术品味 品味这个东西很难讲清楚。对设计师来说,审美品味是必备的,对于软件工程师来说,同样也要有对于技术的审美,需要知道什么是好的,是优雅的。而且我发现技术品味跟工作年限并没有太大的关系,更多跟个人的眼界有关系。当你看到的优秀的东西足够多,慢慢地也会具备识别能力。 所以提升技术品味的方法无它,唯有开拓眼界,多去阅读优秀的项目,从中汲取养分。当然这里并不是推荐大家去阅读各种晦涩难懂的源码,前端社区经常见各种源码分析的文章,很多文章在我看来都只是在翻译代码,用中文描述这些代码干了什么,而甚少去研究别人为什么这么写,背后的设计思想是什么。代码会过时,而先进的思想永不过时。 另外想推荐一本书《重构,改善既有代码的设计》。很庆幸我在学校图书馆某个角落翻到了这本书,让我知道了什么是好的代码。 从错误中成长 人非圣贤,是人都会犯错,尤其是对软件开发者来说,犯错更是一件再正常不过的事。之前听公司里一位前辈分享,他说现在的年轻人越来越谨慎,不敢犯错,怕承担责任,但其实犯错有它的积极意义。 刚参加工作那会儿,记得是一个周五的下午。我匆忙提完代码,准备上完线早早下班。结果一个操作失误,把测试环境的代码发布到了线上。没过多久,办公室开始炸了,所有人都发现我们的首页白屏了,因为线上请求全打到了测试环境的一台机器上,整个事故持续了半小时。事后做了参加工作以来第一次 case study,总结出了几点,让我到现在也受用:   1. 不要在周五下午上线。因为一旦出事,可能也会连累你的同事跟你一起加班。减少上线频率肯定是可以减少事故发生概率的。   2. 对上线保持敬畏之心,做好各个环节的线上验证。这样就算不能拦截问题,也可以早一点发现问题。   3. 机器比人可靠。对于上线这种高危操作,应该将过程尽量自动化,交给机器来完成,而不是人手动的操作。机器要比人可靠得多。这其实就涉及到一个公司的基础技术设施的建设,大公司在这方面做的要比小公司好很多。 这种可以让人铭记终身的case,对个人的成长是很有帮助的。所以我想说,不要害怕犯错,犯错后积极总结经验,避免同样的错误发生第二次,才是最重要的。顺便还想说一下,找工作时,团队对于新人的宽容度或者说容错率,我觉得也是求职者需要考虑的一个因素,好的团队氛围会给予新人充足的试错空间,这对于新人成长是很重要的。 不要设限 不要给自己设限,这一点可以决定自己的成长天花板。比如对于前端工程师,如果限定自己只在这一个范畴,那么你可能会错过很多精彩的东西。三年前是切图仔,三年后还是一个切图仔,只是可能切图更快了,这样可以做的事情会非常局限。而如果把自己放到一个更大的领域,做一个软件工程师,或者去接触产品运营,去了解金融知识,会发现世界又变大了一些。 不断去接触新的事物,可以让我们的思路更加开阔,避免成为井底之蛙、坐井观天。一直以来不是很喜欢程序员这个称号,不管是程序,还是开发程序的技术,本质上都只是工具。要去驾驭使用工具,而不是成为工具的信徒。经常见社区争论不同框架哪个更牛,还有 React 粉、Vue 粉,他们之间甚至还互相攻击,这就好像两个工人争论哪个牌子的大锤更好用。管它是黑猫还是白猫,能抓到老鼠就是好猫。 多了解不同领域,跳出固有思维模式,换一个角度来思考,有些问题或许会有不一样的答案。促使我们去了解学习这些的动力,可以是对未知的好奇心,可以是兴趣,可以就是单纯为了赚更多的钱。 以终为始 “以终为始”(Begin With The End In Mind),是《高效能人士的七个习惯》里提到的一个习惯。这个习惯讲的是先在脑海里酝酿,然后进行实质创造,换句话说,就是想清楚了目标,然后努力实现之。一位美团工程师在一篇叫《写给工程师的十条精进原则》的文章里也将这一条作为原则之一。 做一件事情前,先想清楚做这件事的目标,然后围绕这个目标去制定实施方案。虽然道理很简单,但遗憾的是,很多时候我们都忽略了这一点。这样导致的后果是,我们可能花费了很大的工夫,但最后收效甚微,甚至事情做一半就黄了。这一点在技术项目上体现比较明显,我自己之前就犯过这种错误。某一天发现可以在项目中引入一些性能优化技巧,来提升首屏渲染速度,于是很快就开始写代码,然后迅速上线。后来在准备述职材料时发现,我甚至不知道那次究竟优化了多少性能,保不齐还是个负优化。对于这个例子,当我们提出要优化项目首屏性能,那么首先需要知道性能现状,需要知道性能的瓶颈在哪,基于这些信息来合理制定我们的优化目标,例如把首屏时间从 3s 优化到 2s。然后才是制定优化方案,最后才是写代码。 最近几年,国内互联网公司盛行 OKR 文化,OKR 算是以终为始这个原则的有力实践。这个制度本身是好的,只不过在实践过程中,慢慢流于形式,变成了披着 OKR 外壳的 KPI,这也招致很多人的吐槽。这个话题可以另写一篇文章来聊了。 保持身体健康 这一点,也是我认为最重要的一点,所以放在了最后。身体是革命的本钱,没健康的身体,其他任何事情都无从谈起。健康这个东西,只有在失去了它之后才会真正意识到它有多么重要。一些俗套的祝福语,“身体健康”、“幸福快乐”等等,我发现确实是人最需要也最重要的东西。所以,加强体育锻炼,多出去走走吧。人生路还很漫长,有强健的身体,才能正常走完这漫漫长路。 总结 以上是我认为工程师成长路上很重要的几点。有些想法可能会随着年龄的增长、阅历的提升而改变,也希望最好会变化,毕竟如果一成不变,意味着也就没有成长了。

2021/11/6
articleCard.readMore

《亲密关系》读书笔记

亲密关系的过程分为以下几个阶段:绚丽、幻灭、内省和启示。 伴侣的目的:如同上述所说的,你的伴侣不是你的爱与幸福的来源。满足你的期待与使你开心不是他们的职责,但你的伴侣的确在你的生活中扮演了三个重要的角色,尤其在面对情感上的成熟与唤醒真实自我的时候。这时你的伴侣将会依所需而扮演这三种角色之一:一面镜子,让你看见引发你关注的不舒服感;一名老师,在你探寻真实自我的时候,激励与启发你;一名“玩伴”,开启并陪伴你一段生命的旅程。 月晕现象 人们恋爱的真正原因,往往不是他们自己所想的那回事。开始和维持一段亲密关系背后的真正动机,其实在于需求。 在日常生活中,我们的所作所为,绝大部分都是为了让某些需求得到满足。我们追求或吸引别人来做我们的伴侣,是因为我们需要人陪伴、照顾、了解、支持、接受、赞赏、抚摸和相拥而眠…… 孩童的两大主要需求是归属感和确认自己的重要性。在我看来,这两项需求来自相同的根源,那就是人类共同的“爱与被爱”的需求。 想要有重要性、有价值、有用、被赞赏、被接受等欲望,全都衍生自想当特别的人的需求。因为如果没人觉得我们是不可或缺的,我们将被迫面对被全世界遗忘的难受感觉。 想要有重要性、有价值、有用、被赞赏、被接受等欲望,全都衍生自想当特别的人的需求。因为如果没人觉得我们是不可或缺的,我们将被迫面对被全世界遗忘的难受感觉。 如果我不能接受别人现在的样子,或不让他们自由地走自己的路,那么我就不是真的爱他们。 期望=愤恨的前身。 如果对别人取悦我们的能力抱以太大的期望,那么失望将会是必然的结果。 亲密关系通关指南 1.最初你被某人吸引,通常是由于情绪上的需求。 2.这些需求大都源自孩提时代未被满足的需要。幼儿的两大主要需求是归属感和确认自己的重要性。 3.幼时的需求便是构筑梦中情人蓝图的骨架。你相信这个梦中情人会满足你所有的需求,尤其是想当特别的人的需求。随着年龄增长,梦中情人的蓝图变得愈来愈复杂,你的期望也愈来愈高。 4.你会以梦中情人所拥有的特质作为寻觅伴侣的准则。在潜意识中,你把准情人和梦中情人相比,选出和梦中情人最相似的作为你追求的目标。 5.接着你便借由明说或暗示的期望与要求,着手将选中的人改造成你的理想情人。你相信只要伴侣能变得和你的梦中情人一样,你就能得到渴望许久的爱。你不断地向情人提出要求,心想如果他/她“真的爱我”,就一定会顺从。 6.你终究会发现,需求并不能完全得到满足,因而感到失望,甚至愤恨。如果你感到愤恨,这就代表月晕现象的第一阶段已经结束了,你进入了亲密关系的第二阶段——幻灭。 7.想要安全度过“月晕现象”阶段,你就要学习放手和接受。如果你能不把自己的需求强加在伴侣身上,你就能在自己内心找到你真正需要的事物。摆脱了需求的束缚,你就能感受到纯粹的爱。然后,你能和情人分享的事情就更多了。另外,学着接纳你的伴侣(但并不是滥用忍耐力),也能让你学习到接纳本来的自我,而不再认为你需要些什么来让自己变得完整。学会放手和接纳之后,你一定会明白,你原本就是一个完整的个体,所需要的一切,都存在于你心中。 幻灭 珍·尼尔森博士在她的书《正面教育》中说,当小孩的归属感和确认自己重要性的需求没有得到满足时,他/她就会觉得沮丧。每个孩子感到沮丧的程度不同,但都会导致他们做出某种偏差行为。尼尔森说行为不端的孩子并不是坏孩子,只不过是沮丧的孩子。她指出了四种主要的偏差行为: ——引起注意(看看我!看看我!) ——权力斗争(我不想做,你不能逼我!) ——报复心理(你伤害了我多少我也要伤害你多少。) ——自我放逐(努力有什么用呢?反正我一点也不重要。) “大人其实也只是幼稚的小孩。” 绝大多数人都不愿意听到真相,他们只想听到甜蜜花哨的话和他们已经知道的事实。 “如果离开家的时候,你并不感到平静,那么你其实并没有离开。” 只有在彼此熟悉之后,他们才发现对方不怎么吸引人的一面。 “每个人都会伤害他所爱的事物……” ——奥斯卡·王尔德 每段亲密关系发生的争吵之后,不要忍受不愉快的权利拉锯战中,要直视你和对方的期望和需求,还要面对争吵背后的原因,这种原因可能是来自于小时候未被满足的重要感和归属感。 当你与伴侣初遇时,你们所分享的大多是“好的”。到了幻灭的阶段,你们便会开始发现所谓的对方“不好的”一面。在这个阶段快要结束,而内省的阶段即将开始时,事情多半会变得“丑陋”。如果你能用健康的态度来面对“不好的”和“丑陋的”,那么内省的阶段将会让你领悟到亲密关系事实上是多么“神圣”。然而,一开始的时候,我们对“不好的”往往会反应过度,而无法只是去“回应”它。要去了解、接受或宽恕,毕竟不是那么容易,相比较起来,发怒就简单得多。 生气的好处: 怒气能够麻痹我们心中的痛,压过所有的情绪,甚至能够麻痹身体的感觉。 生气能让对方有罪恶感,这样一来,就能有效地控制对方的行为。 事实上,所有的争执都起源于双方共同的痛。只要能察觉彼此有相同的问题,他们就能化争吵为理解。不幸的是,用愤怒来保护自己,永远比面对痛苦要容易得多。 在权力斗争中,愤怒有三种表达方式:攻击,情绪抽离,被动攻击。 攻击是公开、明显表示愤怒的方式,通常包含批评、指责、怪罪、威胁、肢体攻击、下最后通牒或言语中伤等几种形式。 情绪抽离则是较沉默的表达愤怒的方式。情绪抽离的各种形式、效果都是一致的:一言不发地让自己远离造成痛苦的人。 被动攻击就比较像是零星的战火,你假装不太介意对方的行为,但你的言语间却充斥着隐隐约约的批评、讽刺、批判、嘲弄或抱怨。另一种表达方式是装作极度受伤,几乎要哭出来,但并不直接指控对方故意伤害你。装作无辜的受害者,能让对方觉得自己像个坏人,而由于你并没有指控他们做错事,你也同时剥夺了他们自卫的权利。 人必须经过痛苦,才能成长。 有效沟通的八个纲要问题: 1.我想要什么? 2.有没有什么误会要先澄清的? 3.我所表达的情绪,有哪些是绝对真实的? 4.我或我伴侣的情绪,是不是似曾相识? 5.这种情绪是怎么来的? 6.我该怎么回应这种情绪? 7.情绪背后有哪些感觉? 8.我能不能用爱来回应这种感觉? 学会不再用期望去束缚伴侣。

2020/3/9
articleCard.readMore

《人类简史》读书笔记

第十章|金钱的味道 金钱是有史以来最普遍也最有效的互信系统。 金钱制度有两大原则: 万物可换。 万众相信。 金钱信仰的重点是“别人相信”。

2020/3/8
articleCard.readMore

聊一聊 Node.js 错误处理

错误分类 软件程序中,我们可以将错误大致分为外部错误和内部错误两大类。 外部错误是正确编写的程序在运行时产生的错误。它并不是程序本身的 bug,更多是一些外部原因导致的问题,比如请求超时、服务器返回500、内存不足等。 而内部错误是程序里的 bug。比如传参类型错误、读取 undefined 的一个属性等。这类问题跟你选择的开发语言、开发者的编程经验、系统复杂度等因素息息相关,虽然无法避免,但可以通过修改代码来修复它。 对应到 Node.js 程序上,一般遇到以下四类错误: 标准的 JavaScript 错误。例如 SyntaxError、RangeError、ReferenceError、TypeError等。 由底层操作系触发的系统错误,例如试图打开不存在的文件。 用户自定义错误。 断言错误。这类错误通常来自 assert 模块。 注:本文中不区分错误和异常,都将其统称为错误。 错误处理 当错误发生后,我们需要第一时间去处理它。针对不同类型的错误,有不同的措施。处理错误的总体原则: 及时止损,防止系统级崩溃。 详细记录现场,方便分析原因。 外部错误 程序运行过程中,可能会遇到各种外部因素导致的问题,这些问题需要具体问题具体分析。我们没办法保证外部服务提供方的稳定性,但是遇到此类问题时,可以做一些事情,来保证我们的程序不至于直接崩溃。 举个例子,秒杀场景的业务经常会承受非常大的 QPS,在一波瞬间大流量的冲击,后端服务扛不住的话会报 5XX 错误。在后端服务挂掉后,我们可能会去读 redis 等缓存中的数据,用旧数据来兜底。而当 Node.js 应用也挂掉了,还可以在 Nginx 层进行 CDN 降级,给用户输出一个兜底的静态页。 还有些来自后端服务的错误,只需要进行简单的重试就能解决。如果要重试的话,要确定重试的次数,以及重试的间隔。 有人建议在发生错误后直接崩溃掉,防止错误扩散。个人认为其实是不合理的,会降低服务的可用性。我们可以在出现一些严重的错误后,先记录下错误,然后重启进程。在 Node.js 中,未捕获的 JavaScript 异常一直冒泡回到事件循环时,会触发 process.uncaughtException 事件。我们可以在事件回调中做错误上报,然后重启 Node.js 进程。这时,还需要借助 Cluster 来启动多个 Node 进程,保证单进程崩溃重启不会影响整体服务的可用性。实际的生产环境中,使用 PM2 来管理 Node.js 进程是一个更好的选项。 我们永远也无法阻止外部错误,它跟你的业务场景、用户终端等各种不可控的因素相关。但是我们如果做好监控、告警、日志、缓存等工作,可以方便程序员迅速定位/解决问题,从而将损失降至最低。 内部错误 同步场景 对于 JavaScript 错误,我们可以使用 throw 抛出,并用 try catch 来捕获住。 try { throw new Error('some error') } catch(e) { console.error(e) } 而且对于 throw 抛出的异常必须要 try catch 包裹,否则 Node.js 进程会直接退出。这种写法可以获取到完整的错误调用堆栈。比如: fs.js:115 throw err; ^ Error: ENOENT: no such file or directory, scandir '/Users/frank/code/work/wxapp/src/componentsa' at Object.readdirSync (fs.js:783:3) at getDirFilePaths (/Users/frank/code/m/demo/readdir.js:8:22) at Object.<anonymous> (/Users/frank/code/m/demo/readdir.js:27:15) at Module._compile (internal/modules/cjs/loader.js:688:30) at Object.Module._extensions..js (internal/modules/cjs/loader.js:699:10) at Module.load (internal/modules/cjs/loader.js:598:32) at tryModuleLoad (internal/modules/cjs/loader.js:537:12) at Function.Module._load (internal/modules/cjs/loader.js:529:3) at Function.Module.runMain (internal/modules/cjs/loader.js:741:12) at startup (internal/bootstrap/node.js:285:19) 众所周知,JS 函数调用会形成一系列的栈帧,为了尽可能的恢复错误发生现场,最好在错误上报时带上堆栈信息。Node.js 中,Error.captureStackTrace() 方法是 v8 引擎暴露出来的,处理错误堆栈信息的 API。 Error.captureStackTrace(targetObject[, constructorOpt]) 在 targetObject 中添加一个 .stack 属性。对该属性进行访问时,将以字符串的形式返回 Error.captureStackTrace() 语句被调用时的代码位置信息(即:调用栈历史)。 值得注意的是,它的第二个参数可以用来控制栈帧的终点。在一些底层库中,这个参数可以用来向开发者隐藏内部实现细节。 实际的生产环境中,我们可以使用 nested-error-stacks 这类 npm 包来采集堆栈信息,原理其实也是基于 Error.captureStackTrace()。 这里有个问题是:try catch 代码块是同步的,对于异步 API 发生的错误,它不能捕获到。 比如下列代码: try { setTimeout(() => { throw new Error('some error') }, 1000) } catch(e) { console.log('some error...') } 错误并不能被捕获住。这个跟 Node.js 的事件循环机制有关,因为异步任务是通过事件队列来实现的,每次从事件队列中取出一个函数来执行时,实际上这个函数是在调用栈的最顶层执行的,如果它抛出了一个异常,也是无法沿着调用栈回溯到这个异步任务的创建者的。 下面介绍下在异步流程中,我们应该怎么处理错误。 异步场景 Node.js 中常见异步场景包括三类: Node.js style callback Promise EventEmitter 大部分异步 API 都遵循错误回调优先的约定,将 Error 作为 callback 的第一个参数来传递,这种风格比较类似函数式编程中的 Continuation-passing style。 fs.readFile(path, 'r', (err, data) => { if (err) { throw err } else { try { // handle data } catch(e) { } } }) 这种写法很容易造成回调地狱。另一方面,对于回调函数中的同步逻辑,我们还需要用 try catch 去单独处理,这导致错误逻辑的处理被分散了两处。Promise 被正式 ES6 标准化后,我们可以用 Promise 的链式调用来处理错误。 new Promise((resolve, reject) => { reject(new Error('some error')); }) .then(() => { ... }) .then(() => { ... }) .catch(err => { }); 这样,Promise 链上的错误都会在 catch 方法上捕获住。对于没有 catch 的 Promise 异常,会一直冒泡到顶层,在 process.unhandledRejection 事件上被捕获住。 还有一类是 EventEmitter 对象上的错误。它们会被分发到 error 事件上进行处理,比如 Stream 等。我们需要去为每一个流去监听 error 事件,否则会冒泡到process.uncaughtException 事件上去。 异步场景中,还有个问题就是,会丢失异步回调前的错误堆栈。原因还是上文提到的 Node.js 事件循环机制。 const foo = function () { throw new Error('some error') } const bar = function () { setTimeout(foo) } bar() 输出结果: Error: some error at Timeout.foo [as _onTimeout] (/Users/frank/code/m/demo/readdir.js:47:9) at ontimeout (timers.js:436:11) at tryOnTimeout (timers.js:300:5) at listOnTimeout (timers.js:263:5) at Timer.processTimers (timers.js:223:10) 可以看到丢失了 bar 的调用栈。然而在 Node.js 中,异步调用场景还挺多的,有什么办法可以将多个异步调用给串起来,获取到完整的调用链信息呢?答案是有的。Node.js v8+ 上提供了 async_hooks 模块,用来完善异步场景的监控。 async_hooks async_hooks 提供了一些 API 用于跟踪 Node.js 中的异步资源的生命周期。有几个概念: 每个异步函数的作用域,我们称之为 async scope。 每一个 async scope 中都有一个 asyncId, 用来标记当前作用域。相同 async scope 的 asyncId 也相同。每个异步资源在创建时 asyncId 全量递增的。 每一个 async scope 中都有一个 triggerAsyncId 表示当前函数是由哪个 async scope 触发生成的。 通过 asyncId 和 triggerAsyncId,我们可以获取到异步资源的调用链。 async_hooks.createHooks 函数可以用来给每个异步资源添加 init/before/after/destory 等生命周期钩子函数。 console.log('global.asyncId:', async_hooks.executionAsyncId()); // global.asyncId: 1 console.log('global.triggerAsyncId:', async_hooks.triggerAsyncId()); // global.triggerAsyncId: 0 fs.open('./app.js', 'r', (err, fd) => { console.log('fs.open.asyncId:', async_hooks.executionAsyncId()); // fs.open.asyncId: 7 console.log('fs.open.triggerAsyncId:', async_hooks.triggerAsyncId()); // fs.open.triggerAsyncId: 1 }); 回调函数中的 triggerAsyncId 为 1,它等于 global scope 上的 asyncId。这样就可以拿到多个异步调用的调用链。 国内的赵坤大神写过一个 koa 日志中间件 koa-await-breakpoint,用于实现在每个 await 执行的语句前后进行自动打点工作。 // On top of the main file const koaAwaitBreakpoint = require('koa-await-breakpoint')({ name: 'api', files: ['./routes/*.js'] }) const Koa = require('koa') const app = new Koa() // Generally, above other middlewares app.use(koaAwaitBreakpoint) ... app.listen(3000) 每个请求到来时,生成一个 requestId 挂载到 ctx 上,通过 requestId 将日志串起来。核心原理是 hack 了模块的 require 方法(重载 Module.prototype._compile),用 esprima 将模块代码转成 AST,找到其中的 awaitExpression 节点,对其用日志函数包裹后重新插入到 AST,最后用 escodegen 将 AST 生成代码。其中还用到了 async_hooks,在日志函数中,基于 async_hooks 的 init 钩子中将异步调用关系存储到一个 Map 中,最终实现函数调用链的自动日志打点。 不过,使用 async_hooks 在目前有较严重的性能损耗。建议生产环境慎用。 总结 错误可分为外部错误和内部错误两类。对外部错误的处理主要考验系统架构的设计,只有系统设计的足够健壮,才能够抵御各种外部挑战,并损失降到最低。对于内部错误,本文分别讨论了同步和异步两种场景,介绍了 Error.captureStackTrace()、async_hooks 等 API 在收集错误堆栈、异步调用链上的用途,并结合 koa-await-breakpoint 源码,解释了 Node.js 自动化打点的核心原理。 参考链接: [1] 胡子大哈. 深入理解 JavaScript Errors 和 Stack Traces. https://zhuanlan.zhihu.com/p/25338849 [2] Chuan’s blog. 关于 Error.captureStackTrace. http://blog.shaochuancs.com/about-error-capturestacktrace [3] joyent. Error Handling in Node.js. https://www.joyent.com/node-js/production/design/errors [4] 王子亭. Node.js 错误处理实践. https://jysperm.me/2016/10/nodejs-error-handling [5] 张佃鹏. 学习使用 Node.js 中 async-hooks 模块. https://zhuanlan.zhihu.com/p/53036228 [6] bmeurer. https://github.com/bmeurer/async-hooks-performance-impact [7] http://nodejs.cn/api/errors.html#errors_errors

2019/12/26
articleCard.readMore

【译】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)在高端桌面设备上的页面加载, V8 中的 JavaScript 处理占用了页面加载时间的 10-30% 移动设备上,中端机(Moto G4)的 JavaScript 执行时间是高端机(Pixel 3)的 3 到 4 倍,低端机(不到100 刀的 Alcatel 1X)上有超过 6 倍的性能差异: Reddit 在不同设备类型上(低端、中端和高端)的 JavaScript 性能损耗 注意:Reddit 在桌面端和移动端的体验完全不同,因此 MacBook Pro 上的结果并不能和其他设备上的结果直接做比较。 当你尝试优化 JavaScript 执行时间,注意关注长任务[6],它可能长期独占 UI 线程。这些任务会阻塞执行关键任务,即便页面看起来已经加载完成。把长任务拆分成多个小任务。通过代码分割和指定加载优先级,可以提升页面可交互速度,并且有希望降低输入延迟。 长任务独占主线程,应该拆分它们 V8 在提升解析编译速度上做了什么? Chrome 60+ 上,V8 对于初始 JavaScript 的解析速度提升了 2 倍。与此同时, 由于 Chrome 上的其他并行优化,初始解析和编译的性能损耗更少了。 V8 减少了主线程上的解析编译任务,平均减少了 40%(比如 Facebook 上是 46%,Pinterest 上是 62%),最高减少了 81%(YouTube),这得益于将解析编译任务搬到了 worker 线程上。这对于流式解析/编译[7]是一个补充。 不同 V8 版本上的解析时间 下图形象呈现了不同 Chrome V8 版本上 CPU 解析时间。Chrome 61 解析 Facebook 的 JS 所花的时间里,Chrome 75 可以解析 Facebook 和6次 Twitter。 我们来研究下这些释放出来的改变。简言之,流式解析和 worker 线程编译脚本,这意味着: •V8 可以解析编译 JavaScript 时不阻塞主线程。•流式解析始于整个 HTML 解析器遇到 <script> 标签。对于阻塞解析的 JS,HTML 解析器会暂停,而异步 JS 会继续执行。•大多数真实世界的网络连接速度下,V8 解析比下载快,所以 V8 在 JS 下载完后很快就完成了解析编译。 稍微解释下…很老的 Chrome 上会在全部下载完 JS 后才开始解析,这很直接但并没有完全利用好 CPU。Chrome 41 和 68 之间的版本上,Chrome 在下载一开始就在一个独立线程上解析 async 和 defer 的 JavaScript。 页面上的 JavaScript 代码被分割成多个块。V8 只会对超过 30KB 的代码块进行流式解析。 Chrome 71 上,我们开始做一个基于任务的调整,调度器可以一次解析多个 async/defer 脚本。这一改变使得主线程解析时间减少了 20%,在真实网站上,带来超过 2% 的 TTI/FID 提升。 译者注:FID(First Input Delay),第一输入延迟(FID)测量用户首次与您的站点交互时的时间(即,当他们单击链接,点击按钮或使用自定义的JavaScript驱动控件时)到浏览器实际能够的时间回应这种互动。交互时间(TTI)是衡量应用加载所需时间并能够快速响应用户交互的指标。 Chrome 72 上,我们转向使用流式解析作为主要解析方式:现在一般异步的脚本都以这种方式解析(内联脚本除外)。我们也停止了废除基于任务的解析,如果主线程需要的话,因为那样只是在做不必要的重复工作。 早期版本的 Chrome[8] 支持流式解析和编译,来自网络的脚本源数据必须先到达 Chrome 的主线程,然后才会转发给流处理器。 这常会造成流式解析器等待早已下载完成但还没有被转发到流任务的数据,因为它被主线程上的其他任务(比如 HTML 解析,布局或者 JavaScript 执行)所阻塞。 我们目前正在尝试开始对 preload 的 JS 进行解析,而主线程弹跳会事先对此形成阻塞。 Leszek Swirski 的 BlinkOn 演示[9]呈现了更多细节。 DevTools 上如何查看这些改变? 除了上述之外,DevTools 有个问题[10], 它暗中使用了 CPU,这会影响到整个解析任务的呈现。然而,解析器解析数据时就会阻塞(它需要在主线程上运行)。自从我们从一个单一的流处理线程中移动到流任务中,这一点就变成更为明显了。下面是你在 Chrome 69 中经常会看到的: 上图中的“解析脚本”任务花了 1.08 秒。而解析 JavaScript 其实并不慢!多数时间里除了等待数据通过主线程之外什么都不做。 Chrome 76 的表现大不相同: Chrome 76 上,解析脚本被拆分成多个更小的流式任务。 通常,DevTools 性能面板很适合用来查看页面上发生的行为。对于更详细的 V8 特定指标,比如 JavaScript 解析编译时间,我们推荐使用带有运行时调用统计(RCS)的 Chrome Tracing[11]。RCS 结果中,Parse-Background 和 Compile-Background 代表主线程以外的线程解析和编译 JavaScript 花费的时间,然而 Parse 和 Compile 记录了主线程的指标。 这些改变的真实影响? 来看一些真实网站的 JavaScript 流式解析的应用实例。 MacBook Pro 上主线程和 workder 线程解析编译 Reddit 的 JS 所花的时间 Reddit.com 有多个 100 KB+ 的代码包,这些包被包装在引起主线程大量懒编译[12]的外部函数中。在上图中,由于主线程忙碌会延迟可交互时间,其运行时间至关重要。Reddit 花了多数时间在主线程上,Work/Background 线程的利用率很低。 这得益于将大包分割成多个小包(比如每个 50KB),以达到最大并行化,从而每个包都可以被独立地流式解析编译,减轻主线程在启动阶段的压力。 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 编译。这个时候,编译后代码是序列化后的,会作为元数据被添加到缓存的脚本文件上。 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

2019/7/6
articleCard.readMore

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

原文地址:An Illustrated (and Musical) Guide to Map, Reduce, and Filter Array Methods 原文作者:Una Kravets 译文出自:掘金翻译计划 本文永久链接:https://github.com/xitu/gold-miner/blob/master/TODO1/an-illustrated-and-musical-guide-to-map-reduce-and-filter-array-methods.md 译者:熊贤仁 校对者:Endone、Reaper622 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 链接。

2019/4/11
articleCard.readMore

常用 Git 操作总结

合并远程分支 git fetch origin branch git checkout [your-branch] git merge FETCH_HEAD,merge 前可以先git dff [your-branch] [remote-branch],查看本地分支和远程分支的区别 使用 rebase 合并分支 git rebase [origin-branch] 遇到冲突,解决完冲突后 git add 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 git reflog 确定回滚的commit id git reset –hard [commit id] git revert 也可以回退到指定版本,和reset的区别在于,revert会产生一次新的提交,用一次新的提交来消除历史修改。而reset是直接删除历史commit。 合并时遇到冲突,想取消merge git merge –abort 修改最后一次提交的commit message git commit –amend 清空工作区的修改 git checkout . 恢复误删分支 git log -g 找到之前分支提交的 commit id git branch recover_branch [commit id],这时切换到 recover_branch,可以看到原来的文件了。 修改历史提交信息 git rebase -i HEAD~10(查看前10次提交信息,也可以直接输入想要修改的commit id) 将想要的修改的commit前的 pick 修改为 reword,保存并退出。 在弹出的窗口中,修改commit message,保存。 删除历史提交 用 git rebase -i 928582641a 指定 base 为你需要删除的提交的前一个提交。 删除指定的commit, 保存退出. 之后可能 git 会提示出现 conflict, 根据提示完成处理。 创建空白分支 git checkout –orphan new_branch git rm -rf . 查看本地分支和远程分支的差异 git fetch origin git diff master origin/master –minimal 建立远程追踪分支 git branch -u [remote_branch] 删除远程分支 git branch -r -d origin/branch-name git push origin :branch-name

2019/3/12
articleCard.readMore

深入浅出 Node.js Cluster

前言 如果大家用 PM2 管理 Node.js 进程,会发现它支持一种 cluster mode。开启 cluster mode 后,支持给 Node.js 创建多个进程。 如果将 cluster mode 下的 instances 设置为 max 的话,它还会根据服务器的 CPU 核心数,来创建对应数量的 Node 进程。 PM2 其实利用的是 Node.js Cluster 模块来实现的,这个模块的出现就是为了解决 Node.js 实例单线程运行,无法利用多核 CPU 的优势而出现的。那么,Cluster 内部又是如何工作的呢?多个进程间是如何通信的?多个进程是如何监听同一个端口的?Node.js 是如何将请求分发到各个进程上的?如果你对上述问题还不清楚,不妨接着往下看。 核心原理 Node.js worker 进程由child_process.fork()方法创建,这也意味存在着父进程和多个子进程。代码大致是这样: const cluster = require('cluster'); const os = require('os'); if (cluster.isMaster) { for (var i = 0, n = os.cpus().length; i < n; i += 1) { cluster.fork(); } } else { // 启动程序 } 学过操作系统的同学,应该对 fork() 这个系统调用不陌生,调用它的进程为父进程,fork 出来的都是子进程。子进程和父进程具有相同的数据空间、堆栈,但是它们的内存空间不共享。父进程(即 master 进程)负责监听端口,接收到新的请求后将其分发给下面的 worker 进程。这里涉及三个问题:父子进程通信、负载均衡策略以及多进程的端口监听。 进程通信 master 进程通过 process.fork() 创建子进程,他们之间通过 IPC (内部进程通信)通道实现通信。操作系统的进程间通信方式主要有以下几种: 共享内存 不同进程共享同一段内存空间。通常还需要引入信号量机制,来实现同步与互斥。 消息传递 这种模式下,进程间通过发送、接收消息来实现信息的同步。 信号量 信号量简单说就是系统赋予进程的一个状态值,未得到控制权的进程会在特定地方被强迫停下来,等待可以继续进行的信号到来。如果信号量只有 0 或者 1 两个值的话,又被称作“互斥锁”。这个机制也被广泛用于各种编程模式中。 匿名管道 管道本身也是一个进程,它用于连接两个进程,将一个进程的输出作为另一个进程的输入。可以用 pipe 系统调用来创建管道。我们经常用的“ | ”命令行就是利用了管道机制。匿名管道只能在父子进程或兄弟进程上使用。 命名管道 和管道一样只支持单向数据流,但命名管道支持任意两个进程间的通信。 Socket 套接字(Socket)是由 Berkeley 在 BSD 系统中引入的一种基于连接的 IPC,是对网络接口(硬件)和网络协议(软件)的抽象。它既解决了名匿名管道只能在相关进程间单向通信的问题,又解决了网络上不同主机之间无法通信的问题。 Node.js 上的 IPC 由 libuv 实现。对应到 windows 系统上由命名管道实现,*nix 系统则由 Unix Domain Socket 实现。Node.js 为父子进程的通信提供了事件机制来传递消息。下面的例子实现了父进程将 TCP server 对象句柄传给子进程。 const subprocess = require('child_process').fork('subprocess.js'); // 开启 server 对象,并发送该句柄。 const server = require('net').createServer(); server.on('connection', (socket) => { socket.end('被父进程处理'); }); server.listen(1337, () => { subprocess.send('server', server); }); process.on('message', (m, server) => { if (m === 'server') { server.on('connection', (socket) => { socket.end('被子进程处理'); }); } }); 那么问题又来了,如果进程间没有父子关系,换句话说,我们应该如何实现任意进程间的通信呢?大家可以去看看这篇文章:进程间通信的另类实现 负载均衡策略 前面提到,所有请求是通过 master 进程分配的,要保证服务器负载比较均衡的分配到各个 worker 进程上,这就涉及到负载均衡策略了。Node.js 默认采用的策略是 round-robin 时间片轮转法。 round-robin 是一种很常见的负载均衡算法,Nginx 上也采用了它作为负载均衡策略之一。它的原理很简单,每一次把来自用户的请求轮流分配给各个进程,从 1 开始,直到 N(worker 进程个数),然后重新开始循环。这个算法的问题在于,它是假定各个进程或者说各个服务器的处理性能是一样的,但是如果请求处理间隔较长,就容易导致出现负载不均衡。因此我们通常在 Nginx 上采用另一种算法:WRR,加权轮转法。通过给各个服务器分配一定的权重,每次选出权重最大的,给其权重减 1,直到权重全部为 0 后,按照此时生成的序列轮询。 可以通过设置 NODE_CLUSTER_SCHED_POLICY 环境变量,或者通过 cluster.setupMaster(options) 来修改负载均衡策略。读到这里大家可以发现,我们可以 Nginx 做多机器集群上的负载均衡,然后用 Node.js Cluster 来实现单机多进程上的负载均衡。 多进程的端口监听 最初的 Node.js 上,多个进程监听同一个端口,它们相互竞争新 accept 过来的连接。这样会导致各个进程的负载很不均衡,于是后来使用了上文提到的 round-robin 策略。具体思路是,master 进程创建 socket,绑定地址并进行监听。该 socket 的 fd 不传递到各个 worker 进程。当 master 进程获取到新的连接时,再决定将 accept 到的客户端连接分发给指定的 worker 处理。简单说就是,master 进程监听端口,然后将连接通过某种分发策略(比如 round-robin),转发给 worker 进程。这样由于只有 master 进程接收客户端连接,就解决了竞争导致的负载不均衡的问题。但是这样设计就要求 master 进程的稳定性足够好了。 总结 本文以 PM2 的 Cluster Mode 作为切入点,向大家介绍了 Node.js Cluster 实现多进程的核心原理。重点讲了进程通信、负载均衡以及多进程端口监听三个方面。通过研究 cluster 模块可以发现,很多底层原理或者是算法,其实都是通用的。比如 round-robin 算法,它在操作系统底层的进程调度中也有使用;比如 master-worker 这种架构,是不是在 Nginx 的多进程架构中也似曾相识;比如信号量、管道这些机制,也可以在各种编程模式中见到它们的身影。当下市面上各种新技术层出不穷,但核心其实是万变不离其宗,理解了这些最基础的知识,剩下的也可以触类旁通了。 参考链接: 当我们谈论 cluster 时我们在谈论什么(下) Node.js进阶:cluster模块深入剖析 进程间通信的另类实现

2019/3/12
articleCard.readMore

聊一聊浏览器缓存

缓存的概念 什么是缓存 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 页面为什么要有缓存? 缓解服务器压力 提升性能 减少带宽消耗 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) 内存缓存具有两个特点,分别是快速读取和时效性: 快速读取:内存缓存会将编译解析后的文件,直接存入该进程的内存中,占据该进程一定的内存资源,以方便下次运行使用时的快速读取。 时效性:一旦该进程关闭,则该进程的内存则会清空。 硬盘缓存(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是服务器响应请求时,返回该资源文件在服务器最后被修改的时间,如下。 If-Modified-Since则是客户端再次发起该请求时,携带上次请求返回的Last-Modified值,通过此字段值告诉服务器该资源上次请求返回的最后被修改时间。服务器收到该请求,发现请求头含有If-Modified-Since字段,则会根据If-Modified-Since的字段值与该资源在服务器的最后被修改时间做对比,若服务器的资源最后被修改时间大于If-Modified-Since的字段值,则重新返回资源,状态码为200;否则则返回304,代表资源无更新,可继续使用缓存文件,如下。 Etag/If-None-Match Etag 是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成),如下。 ETag的值有可能包含一个 W/ 前缀,来提示应该采用弱比较算法。 If-None-Match 是客户端再次发起该请求时,携带上次请求返回的唯一标识Etag值,通过此字段值告诉服务器该资源上次请求返回的唯一标识值。服务器收到该请求后,发现该请求头中含有If-None-Match,则会根据If-None-Match的字段值与该资源在服务器的Etag值做对比,一致则返回304,代表资源无更新,继续使用缓存文件;不一致则重新返回资源文件,状态码为200,如下。 启发式缓存 浏览器用来确定缓存过期时间的字段一个都没有!那该怎么办?有人可能会说下次请求直接进入协商缓存阶段,携带If-Moified-Since呗,不是的,浏览器还有个启发式缓存阶段😎 根据响应头中2个时间字段 Date 和 Last-Modified 之间的时间差值,取其值的10%作为缓存时间周期。 用户操作行为对缓存的影响

2019/1/6
articleCard.readMore

你不知道的 eval

前言 eval() 是 JavaScript 中一个非常有用的函数,它可以一段代码字符串动态执行。然而各种编码规范和最佳实践都强烈抵制 eval,几乎将 eval 打入了死牢,大牛 Douglas Crockford 也在《JavaScript 语言精粹》一书中将 eval 视为 JavaScript 中糟粕。这篇文章将带大家重新认识这个函数,知道为什么不用它,以及为什么不得不用它。 eval 是什么 在分析 eval 的利弊前,首先来认识一下它。在不清楚一项技术的情况下,就对它做出武断地评价,是有失公允的。 eval 是全局对象上的一个函数,会把传入的字符串当做 JavaScript 代码执行。如果传入的参数不是字符串,它会原封不动地将其返回。eval 分为直接调用和间接调用两种,通常间接调用的性能会好于直接调用。 直接调用时,eval 运行于其调用函数的作用域下; var context = 'outside'; (function(){ var context = 'inside'; return eval('context'); })(); // return 'inside' 而间接调用时,eval 运行于全局作用域。 var context = 'outside'; (function(){ var context = 'inside'; geval = eval; return geval('context'); // 下面两种也属于间接调用 // return eval.call(null, 'context'); // return (1, eval)('context'); })(); // return 'outside' 因此,间接调用时,eval 并不会修改调用函数作用域内的任何东西。JS 解释器有 fast path 和 slow path 两种模式,当直接调用 eval 时,解释器处于 slow path。因为此时作用域是不可控的,需要监听整个作用域,不能应用 v8 的一些编译优化,相应的编译效率也会比 fast path 低。 为什么不用 eval 大家抵制 eval 的原因主要是以下几个原因: 降低性能。具体原因上文已经提到了。网上一些文章甚至说 eval() 会拖慢性能 10 倍。 安全问题。因为它的动态执行特性,给被求值的字符串赋予了太大的权力,于是大家担心可能因此导致 XSS 等攻击。 调试困难。eval 就像一个黑盒,其执行的代码很难进行断点调试。 鉴于以上各种原因,很多人说 eval 是 evil(魔鬼)。另外,eval 还有一些难兄难弟,比如 new Function, setTimeout, setInterval。它们也具备执行一段代码字符串的能力。 究其本质原因,还是因为 JS 赋予这个方法的权限太大了,作为新手很难驾驭它,如果对 eval 没有很好地理解,很容易写出问题来。这有点像 C 语言中 goto 语句,同样是因为权限太大而被封杀的典范。 被误解的 eval 事实上,eval 一直在被误解,它可能是最强大的一个 JavaScript 函数,但却因为一些人的误用,而被开发者们打入了冷宫。接下来,我来根据上述被质疑最多的几个点,给出一点自己的看法。 关于 eval 会拖慢性能 10 倍这个点,出自 Mozila 工程师的演讲 “Know Your Engines - How to make your JavaScript Fast”。 这是一个发布于 2011 年的演讲,时至今日,JS 引擎早已做了各种优化。我们来测试现在的 JS 引擎中,eval 的实际性能。依然使用上图作为测试用例,测试环境为 node v8.11.1,设 N 的值为 10000。 Benchmark 跑出的数据来看,当 N = 10000 时,用了 eval 的 function 执行性能,相比没有 eval 的情况,慢了 3 倍多。 将 N 的值设为 1000000,eval 的性能下降到 8 倍。 从测试结果可知,eval 的确会拖慢函数执行性能,而且随着函数规模增大,性能也越慢。但是在一般情况下(N < 1000000),性能差异并没有 10 倍那么夸张。 关于 eval 会导致 XSS 攻击这点,问题并不在 eval,而在数据源。如果数据源本身就是不可靠的,即便你不用 eval,也可能出现 XSS。 至于第三点,eval 代码的确调试起来比较麻烦,但也不是完全没有办法。可以在 eval 创建的代码末尾添加一行 “//@ sourceURL=name” 就可以给这段代码命名(浏览器会特殊对待这种特殊形式的注释),这样它就会出现在 Sources 面板上,然后就可以设置断点调试了。 真香警告 虽然大家嘴上说不要用,但是 eval 用起来却是真香。 笔者做过的项目中,曾经为了让 HTML 模板(应该说是一套页面主题)也具备动态解析内联表达式的能力,用了 data-eval 将 js 代码存储在 dom 节点,然后渲染时用 with 语句(另一个 JS “毒瘤”,现在严格模式下已经禁用 with 了,rip…)将 data 加到作用域链上,再用 eval 解析执行。实现出来的效果类似这样: <div data-eval="data.count = data.count + 1"> {{data.count}} </div> 渲染出来的结果是 eval 计算后的值。 很多库和框架都用了 eval 实现各种黑魔法。早期的有用 eval 解析 json 的,比如 Douglas Crockford 的 json2.js(真香!)。到后来,各种 MVVM 框架也用 new Function 这个 eval 的好基友,来实现模板内嵌表达式的计算,比如 Vue 和 avalon。要达到的效果和笔者上面介绍的例子大致相同,不同的是这些 MVVM 框架还需要先解析模板,基于正则表达式提取出 new Function 的参数。甚至Chrome的JavaScript控制台,也是用 eval 实现的。 甚至不能用 eval 的时候,也要自己造一个 eval 出来。比如小程序上就不能使用 eval 和 new Function,那么如果想动态注入并执行代码的话,需要绕一个大弯,从编译原理出发,自行实现一个 JS parser。 总结 关于 eval,笔者个人的看法是,你可以不去用它,但要去了解它。写这篇文章的目的也不是为了推荐大家使用 eval。就平时的业务开发而言,eval 几乎没有用武之地。但在一些特殊场合,eval 就像一枚核弹,无往不利。 参考链接: Global eval. What are the options? Knockout, Vue 和 AvalonJS 等 MVVM 框架实现中是否用到 eval 或 Function? eval() isn’t evil, just misunderstood A new V8 is coming, Node.js performance is changing. V8: Behind the Scenes (February Edition feat. A tale of TurboFan)

2018/11/18
articleCard.readMore

【译】无头 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。我不会告诉你现在已经有这样一个工具了! 该工具可以运行所有类型的现代 JavaScript,并吐出静态 HTML。 出现新特性时,该工具可以保持更新 已有应用上只需少量代码就可以运行这个工具 听起来很不错吧?这个工具就是浏览器! 无头 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}; 主要的变化: 添加了缓存。缓存已渲染的 HTML 对于加速响应时间居功至伟。当页面再次有请求过来,避免了无头 Chrome 的重复执行。我随后会讨论其他的优化 。 添加加载页面超时时的基本错误处理。 添加了 page.waitForSelector('#posts') 这行代码。确保在丢弃这个序列化页面之前,posts 节点存在于 DOM 之中。 记录无头浏览器渲染页面所用时间。 代码都被封装进名为 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 API 和 PerformanceObserver 可以获取这些指标: 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。然而,我们只关注于两件事情: 渲染 HTML 生成 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}; } 这段代码: 使用一个 page.on('response') 处理器来监听网络响应。 储藏本地样式表的响应。 找到 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。

2018/9/17
articleCard.readMore

【译】使用 Script-Streaming 提升页面加载性能

script-streaming 是什么? 加载 JavaScript 算是 web 性能最严重的瓶颈之一,尤其在低端 CPU 上情况更糟糕。加载 JavaScript 的性能损耗不仅包括从服务器上下载数据的网络时间,也包括文件解压、解析、编译,以及执行 JavaScript 的时间。为了更好的用户体验,web 页面大量的 JavaScript 代码数量持续地增长,页面加载变得越来越慢。浏览器社区一直在开发很多优化措施来提升 JavaScript 加载速度,比如将解析 JavaScript 文件和下载过程并行化(在此之前,浏览器会等待 JavaScript 文件完全下载后才会在渲染主线程上进行解析)。这项优化措施就是 script-streaming,它在 Chrome v41 上被引入。Google 声称 script-streaming 能够将页面加载速度提升 10%,因为大型 JavaScript 文件会边下载边解析,这可以减少数百毫秒的页面加载时间。 Script-streaming 是为加速 JavaScript 解析而做的一项巨大的优化。然而,Chrome 针对 JavaScript 文件启用 script-streaming 还有一些限制。 首先,下载的 JavaScript 文件大小至少 30KB。这个尺寸限制确保了只有大型 JS 文件能够通过 script-streaming 解析,因为相比于较小的 JavaScript 文件,并行下载和解析大型 JavaScript 文件的收益最大。 其次,目前 Chrome 在 script-streaming 的实现上,同一时刻 script-streaming 只能应用到一个 JavaScript 文件上。这是由于 Chrome 对于 script-streaming 只使用了单线程。既然这个线程忙于解析某个 JavaScript 文件,那么其他 JavaScript 文件就必须在主线程下载完成后,才能进行解析。 web 开发者如何利用 script-streaming ? script-streaming 加速了 JavaScript 的解析。作为一个 web 开发者,你不必为了在页面上启用 script-streaming 做任何事情。然而,script-streaming 还是有一些限制的,这些限制只存在于 Chrome,它们是由于 Chrome 的实现造成的。具体来说,当大型 JavaScript 文件下载完成,script-streaming 线程不保证一定可用。同样地,页面并行下载多个大型 JavaScript 文件,只有一个文件能够通过 script-streaming 解析。并且,某些情况下,script-streaming 可能解析较小的脚本文件,而较大的脚本只能等待 script-streaming 线程可用,或者在完成下载后,由主线程进行解析。 开发者可以使用 performance 开发者工具来研究 script-streaming 是否被用于解析大型 JavaScript 文件,因为这项技术可以显著提升页面性能。下图是 www.akamai.com 在 performance 面板上的一个截图,脚本就是被红框里的 ScriptStreamer 线程解析的。 提升解析速度的两种方法 我做了一些研究,为了启用 script-streaming,可以解析较小的 JavaScript 文件(仍然大于 30KB),这样在解析相对较大的 JavaScript 文件时,保持线程是可用的。理论上,可以在大型 JavaScript 文件上发送一个 HTTP 响应头,告诉浏览器在具有这种响应头的文件上应用 script-streaming。然而,这个方法要求主流浏览器作出调整,并且能让 script-streaming 利用多线程,而不仅仅是单线程。值得注意的是,目前 使用多个 script-streaming 线程在 Chrome 团队内部依然是 TODO 状态。 一个比较实用的,执行起来简单得多,并且不需要浏览器改变的技巧,是重新排序 HTML 中那些加载静态资源 URL 的 <script> 标签的位置,以让大的 JavaScript 文件在较小(仍然大于 30KB)文件之前下载。当 script-streaming 可用时,此方法会强制解析大型 JavaScript 文件。重新排序 <script> 标签在 HTML 中的位置,可能存在潜在风险。因为为了保持页面的功能和 UI,一些 JavaScript 必须按顺序执行。因此,在重新排序时必须要谨慎。重新排序那些带上=有 async 或者 defer 属性的 <script> 标签会保险一些,因为这些脚本执行并不依赖于执行顺序。由于脚本重排序伴随着种种限制,这项实验性的方法将只在指定的网站上生效。除了脚本重排序之外,我加进来的另一个方法是,将多个大型 JavaScript 文件并行下载,让它们竞争 script-streaming 线程。我将所有这样的 JavaScript 文件串联起来,目的是让他们都可以实现边下载边解析。 实验结果 我做了一些实验,去衡量上述两个方法在多个设备上的页面加载时间,包括 MacBook Pro,一个低端移动设备(Motorola Moto E),和一个高端移动设备(Motorola Moto G)。性能数据通过一个私有网页测试实例.收集。在两个手动修改的网站(“Page A” 和 “Page B”,详情见下表)集合上,观察到在多个移动设备和 MacBook Pro 上,对于中等规模的页面有多达 6.2% 的页面加载时间提升。这些性能提升要归功于 JavaScript 解析和下载的并行化。 Page\资源数页面大小#JavaScript 资源数** JavaScript 总大小** A412.7 MB121.3 MB B952.2 MB191.1 MB MacBook Pro 上的性能 图 1 和图 2 展示了在一台 MacBook Pro 上,两个测试页面加载时间的 CDF 分布。页面 A 上,重新排序 script 标签在 HTML 中的位置的页面加载时间减少了 6.2%。页面 B 上,加载重新排序的 scrip 标签的页面加载时间减少了 4.5%。 Motorola Moto E 上的性能 正如下图所示,重排序 script 标签的页面 A 的加载时间减少了 4.3%。 页面 B(没有示例图)上没有出现更快的加载速度,这可能是因为在 Moto E 设备上,当移动版页面 A 加载时, script-streaming 线程被占用了。 Motorola Moto G 上的性能 如下图所示,重排序 script 标签的页面 A 的加载时间减少了 3.5%。 页面 B 上,中等规模页面的加载时间减少了 1.9%。 总结 本文描述的实验性工作举例说明了相比默认的解析,通过 script-streaming 解析较大 JavaScript 文件带来的好处。实验包含重排序 HTML 中的 <script> 标签,以及串联多个大型 JavaScript 文件,让它们并行下载,以允许相对较大的文件能够通过 script-streaming 解析。实验结果显示,在 MacBook Pro 和两个低端和高端的移动设备上,对于中等大小的页面,加载速度有多达 6% 的提升。 原文作者: Utkarsh Goel 原文:https://developer.akamai.com/blog/2018/07/17/experiment-improving-page-load-times-script-streaming 译者: 熊贤仁

2018/8/29
articleCard.readMore

【译】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 不起任何作用。这是因为这个方法不能区分显式返回和正常结束的回调。 可用性 npm 包 promise.prototype.finally 是 .finally() 的一个 polyfill V8 5.8+ (比如. Node.js 8.1.4+):加上 –harmony-promise-finally 标记后可用。(了解更多) 深入阅读 Promises for asynchronous programming 原文:http://exploringjs.com/es2018-es2019/ch_promise-prototype-finally.html

2018/7/9
articleCard.readMore

【译】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

2018/7/9
articleCard.readMore

TCP 性能优化浅析

前言 TCP 作为一种最常用的传输层协议,它的作用是在不可靠的传输信道上,提供可靠地数据传输。在各层网络协议中,只要有一层协议是可靠的,那么整个网络传输就是安全可靠的。现实中,几乎所有的 HTTP 流量都是经过 TCP 传输。因此,我们要进行 web 性能优化,TCP 是其中的关键一环。要针对 TCP 进行性能优化,就得理解其工作原理。 三次握手 众所周知,建立一次 TCP 连接需要进行三次握手。关于三次握手,一图胜千言。 三次握手给 TCP 带来了很大的延迟,不过这个握手过程是必不可少的。因为如果没有三次握手,有可能会出现一些已经失效的请求包突然又传到服务端,服务端认为这是客户端发起的一次新的连接,于是发出确认包,表示同意建立连接。而客户端并不会有响应,导致服务器出现空等,白白浪费服务器资源。 既然三次握手的过程不可避免,那么我们只能通过重用 TCP 连接,减少三次握手的次数。HTTP 1.1 引入了长连接,通过在请求头中加入 Connection: keep-alive, 来告诉请求响应完毕后,不要关闭连接。不过 HTTP 长连接也是有限制的,服务器通常会设置 keep-alive 超时时间和最大请求数,如果请求超时或者超过最大请求数,服务器会主动关闭连接。 除此之外,TFO(TCP Fast Open,TCP 快速打开)这种机制也被设计用于优化三次握手过程。它通过握手开始时的 SYN 包中的 TFO cookie(一个 TCP 选项)来验证一个之前连接过的客户端。如果验证成功,它可以在三次握手最终的 ACK 包收到之前就开始发送数据。 Linux 3.7 及以后的内核在客户端及服务器中支持 TFO, 对于移动端,Android 和 iOS 9+ 都支持 TFO,不过 iOS 并未默认启用。 PS: 推荐大家装一个 wireshark,可以非常直观的观察到三次握手的过程。 流量控制 流量控制是一种预防发送端向接受端发送过多数据的机制。它的主要目的是为了防止接收端服务过载,从而出现丢包。为了实现流量控制,TCP 连接的每一方都要声明自己的通告窗口(rwnd),表示自己的缓冲区最多能接收多少数据。如果其中一端跟不上对方的发送速度,就通知对方一个较小的窗口。如果窗口大小为 0,应用层必须先清空缓冲区,才能继续接收数据。这就是所谓的滑动窗口协议。 大家可能经常遇到这种情况,自己明明是百兆宽带,实际下载速度每秒却只有几M。这种情况有可能就是通告窗口(rwnd)设置的不合理造成的。最初的 TCP 规范分配给接收窗口大小的字段是 16 位,也就是 64KB(2 的 16 次方)。实际上,rwnd 的大小应该由 BDP(带宽延迟积) 而定。BDP(bit) = bandwidth(b/s) * round-trip time(s)。比如一个 100Mbps 的宽带,RTT 是100 ms,那么 BDP = (100 / 8) * 0.1 = 1.25M。此时,要想提高网络传输吞吐量,rwnd 应该为 1.25 M。 为了解决这个问题,TCP 窗口缩放(TCP Window Scaling)出现了,它将窗口大小由 16 位扩展到 32 位。Linux 上自带了缓冲大小调优机制,如下命令,可以查看 Linux 初始窗口大小: sysctl net.ipv4.tcp_rmem // 输出 net.ipv4.tcp_rmem = 409687380 6291456 // 从左到右一次为最小值、默认值、最大值 慢启动 流量控制机制可以防止发送端和接收端之间的服务过载,但无法防止任何一端向某个网络的发送数据过载,因此还需要一个估算机制,根据网络环境动态改变数据传输速度,这就是慢启动出现的原因。 慢启动为发送方的 TCP 增加了一个窗口:拥塞窗口(congestion window),记为 cwnd。当与另一个网络的主机建立 TCP 连接时,cwnd 初始化为 1 个 TCP 段。每收到一个 ACK,cwnd 就增加一个 TCP 段。发送端取 cwnd 和 rwnd 中的最小值作为发送上限。可以这样理解,拥塞窗口是发送端使用的流量控制,而通告窗口是接收端使用的流量控制。 一开始 cwnd 为 1,发送方只发送一个 mss(最大报文段长度) 大小的数据包,收到 ack 后,cwnd 加 1,cwnd=2。 此时 cwnd 2,则发送方要发送两个 mss 大小的数据包,发送方会收到两个 ack,则 cwnd 会进行两次加一的操作,则也就是 cwnd+2,则 cwnd=4,也就是 cwnd = cwnd*2。 以此类推,每次 rtt 后,cwnd 都会变成上次发送前的 2 倍。因此,cwnd 的大小是呈指数级在递增。 随着 cwnd 的增加,会发送网络过载,此时会出现丢包。一旦发现有这种问题,cwnd 会成倍减少。 为了减少往返次数,初始拥塞窗口的大小设定就尤为重要。默认情况下(RFC 2581),初始 cwnd 为 4 个 MSS。Google 建议将初始窗口改为 10 个 MSS。根据 Google 的研究,90% 的 HTTP 请求数据都在 16KB 以内,约为 10 个 MSS。 总结 本文介绍了 TCP 的部分工作原理,包括三次握手、流量控制、慢启动,并阐述了 TCP 快速打开、窗口缩放、增加初始拥塞窗口大小等优化手段。内容有点偏理论,还是需要多多实践,才能合理掌握各种优化手段。 参考文献: 《Web性能权威指南》 《TCP/IP详解,卷1:协议》 浅谈TCP优化 TCP慢启动中cwnd的增长问题? - one no的回答 - 知乎

2018/2/2
articleCard.readMore