谭光志

谭光志 查看完整档案

天津编辑  |  填写毕业院校  |  填写所在公司/组织 github.com/woai3c 编辑
编辑

生命不息,学习不止。

个人动态

谭光志 关注了用户 · 11月19日

Peter谭金杰 @jerrytanjinjie

前端架构师

微信公众号:前端巅峰

欢迎技术探讨~

个人微信:CALASFxiaotan

关注 4243

谭光志 关注了用户 · 11月19日

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2119

谭光志 发布了文章 · 11月18日

chrome 开发者工具——前端实用功能总结

1. 查看元素伪类 css 样式

例如我想查看元素触发 hover 时的 css 样式。先选中该元素,然后按下图操作:

2. 临时增删元素 class

3. document.body.contentEditable="true"

在控制台输入 document.body.contentEditable="true",就可以对页面直接进行编辑。

4. 查看 placeholder 样式

现在可以查看元素的 placeholder 样式了:

5. 测试页面性能和 SEO

下面是测试报告:

参考资料:

6. Network 显示资源的其他信息

一般 Network 会显示加载资源的详细信息,但它默认只显示部分信息。如果我想查询网页资源是通过 HTTP1.1 还是 HTTP2 加载的,要怎么做呢?

从 GIF 中可以看出,除了 HTTP 协议版本外,还可以查看其他信息,例如 HTTP 请求的方法、域名等等。

7. 查看元素绑定事件

鼠标移到 handler 上,可查看具体的函数代码。

8. 全局搜索代码

打开开发者工具,点击 Console 标签,按 ESC 弹出:

点击左边竖形排列的三个小点,选择 Search

点击搜索结果,会跳到具体的源码文件。它会搜索该网页下所有引入的文件。

9. 利用 Performance 检查运行时性能

打开开发者工具,点击 Performance 标签:

点击左上角的 Record 按钮开始记录,然后你模拟正常用户使用网页。测试完毕后,点击 Stop

可以看到右上角分别有 FPS、CPU、NET、HEAP:

  1. FPS 对应的是帧率,红色代表帧率低,可能会降低用户体验;绿色代表帧率正常,绿色条越高,FPS 越高。
  2. CPU 部分上有黄色、紫色等色块,它们的释义请看图的左下角。谁的占比高,说明 CPU 主要的时间花在哪里。
  3. HEAP 就是堆内存占用。

NET 最好点击下面的 Network 查看,可以看到具体的加载资源等。

一般根据这些信息就能判断出网页性能问题出在哪。

如果想了解更多,请查看下面的参考资料,需要翻 qiang。或者用搜索引擎搜索 chrome performance,也有很多讲解使用方法的文章。

参考资料

10. Rendering 实时检测网页变化

打开开发者工具,点击 Console 标签,按 ESC 弹出:

点击左边竖形排列的三个小点,选择 Rendering

下面是比较实用的功能:

  1. Paint flashing,实时高亮重绘区域(绿色)。
  2. Layout Shift Regions,实时高亮重排(重新布局)区域(蓝色)。
  3. Layer borders,将合成层用边框标出来(橙色、橄榄色、青色)。
  4. Frame Rendering Stats,显示 GPU 的信息,旧版本还有实时 FPS 显示,但新版本不知道为何没有(chrome 86)。

11. Application 查看应用信息

从图中看到,在 Application 标签下可以查到本页面很多信息。拿 localStorage 举例,现在我执行代码 localStorage.setItem('token', '123'),然后打开 Application

不出意外,能看到新增的 localStorage 信息。

查看原文

赞 55 收藏 40 评论 1

谭光志 发布了文章 · 11月16日

而立之年——回顾我的前端转行之路

为什么转行

因为混得不好。

在成为程序员之前,我干过很多工作。由于学历的问题(高中),我的工作基本上都是体力活。包括但不限于:工厂普工、销售(没有干销售的才能)、搬运工、摆地摊等,转行前最后一份工作是修电脑。这么多年,月薪没高过 3300...

后来偶然一个机会我发现了知乎这个网站,在上面了解到程序员的各种优点。于是,我下定决心转行(2016 年,当时 28 了),辞职在家自学编程。并且也得到了媳妇的支持,感谢我的媳妇。

转行准备

转行选择前端也是在知乎上看网友分析的,比后端好入门。

如何选择教程?

最好在网上多查查资料,找评价高的或者去豆瓣上找评分高的书。

我在网上查了很多资料,最终确定 HTML、CSS 在 w3cschool 学习。JavaScript 则选择了JavaScript 高级程序设计第三版(俗称红宝书,现在已经有第四版了)。

光看不练是学不好编程的,我非常幸运的遇到了百度前端技术学院。它从易到难设置了 52 个任务,共分为四个阶段。任务难度循序渐进,每一个任务都有清晰的讲解和学习参考资料。它还怕你不会做,允许你查看其他人上传的任务答案。

我先学习了 HTML、CSS,做完了第一阶段任务。再看完红宝书前十三章,做完了第二阶段任务。然后把红宝石剩下的全看完,做到第三阶段的任务四十五。后面的任务对于当时的我来说实在太难了,就没往下做。在 1 月的时候,又学习了 ajax,了解了前后端如何相互通信。

我从 16 年 11 月开始自学前端,一直到 17 年 2 月。历时 3 个月,平均每天学习 3-4 个小时。中间有好几次因为太难想过放弃,不过最后还是坚持下来了。

找工作的过程非常艰难,我在网上各大招聘平台投了很多简历,但由于没学历、没经验,所以一个回复都没有。最后还是我媳妇工作的公司在招前端,给了我一个内推的机会,才有了第一次面试。并且第一次面试也很顺利,居然过了,这是我没想到的。直到多年后我和面试官又在一个公司的时候,才知道原因。他的意思是:看在我这么努力自学编程的份上,愿意给我一个机会。

虽然人生很艰难,但很有可能,遇到一个愿意给你机会的人,就能改变你的命运。

正式工作

第一年

在正式的项目中写代码和在学习时写代码是不一样的。你必须得考虑这样写安不安全,会不会引起 BUG,会不会引起性能问题。在工作的第一年,写业务代码对我的提升非常大。

第一年的主要任务,就是提升前端基础能力。因此我看了很多 JavaScript 的书籍来提升自己的水平:

  1. JavaScript高级程序设计(第三版)
  2. 高性能JavaScript
  3. JavaScript语言精粹
  4. 你不知道的JavaScript(上中下三卷)
  5. ES6标准入门
  6. 深入浅出Node.js

这些书都是非常经典的书籍,有几本我还看了好几篇。

除了看书外,我还做了百度前端技术学院 2017 年的任务,它比 2016 年的任务(转行时做的是 2016 年的任务)更有难度和深度,非常适合进阶。

另外还学习了 jquery 和 nodejs。jquery 是工作中要用,nodejs 则是出于兴趣学习的,没有多深入。

第二年

到了第二年,写业务代码对于我来说,已经提升不大了,就像一个熟练工一样。而且感觉前端方面掌握的知识已经足够把工作做好了。于是我就想,为了成为一名顶尖的程序员,还需要做什么。我在网上查了很多资料,看了很多前辈的回答,最后决定自学计算机专业。

我制定了一个自学计算机专业的计划,并且减少花在前端上的时间。因为说到底,基础是地基。基础打好了,楼才能建得高。

计算机系统要素

计算机系统要素是我制订计划后开始学习的第一本书。它主要讲解了计算机原理(1-5章)、编译原理(6-11章)、操作系统相关知识(12章)。不要看内容这么多,其实这本书的内容非常通俗易懂,翻译也很给力。每一章后面都有相关的实验,需要你手写代码去完成,堪称理论与实践结合的经典。

这里引用一下书里的简介,大家可以感受一下:

本书通过展现简单但功能强大的计算机系统之构建过程,为读者呈现了一幅完整、严格的计算机应用科学大图景。本书作者认为,理解计算机工作原理的最好方法就是亲自动手,从零开始构建计算机系统。

通过12个章节和项目来引领读者从头开始,本书逐步地构建一个基本的硬件平台和现代软件阶层体系。在这个过程中,读者能够获得关于硬件体系结构、操作系统、编程语言、编译器、数据结构、算法以及软件工程的详实知识。通过这种逐步构造的方法,本书揭示了计算机科学知识中的重要成分,并展示其它课程中所介绍的理论和应用技术如何融入这幅全局大图景当中去。

全书基于“先抽象再实现”的阐述模式,每一章都介绍一个关键的硬件或软件抽象,一种实现方式以及一个实际的项目。完成这些项目所必要的计算机科学知识在本书中都有涵盖,只要求读者具备程序设计经验。本书配套的支持网站提供了书中描述的用于构建所有硬件和软件系统所必需的工具和资料,以及用于12个项目的200个测试程序。

全书内容广泛、涉猎全面,适合计算机及相关专业本科生、研究生、技术开发人员、教师以及技术爱好者参考和学习。

做完这些实验,让我有了一个质的提升。以前感觉计算机就是一个黑盒,但现在不一样了。我开始了解计算机内部是如何运作的。明白了自己写的代码是怎么经过编译变成指令,最后在 CPU 中执行的。也明白了指令、数据怎么在 CPU 和内存之间流转的。

这本书所有实验的答案我都放在了 github 上,有兴趣不妨了解一下。

Vue

这一年还学会了 Vue。除了熟读文档外,还为了研究源码而模仿 Vue1.0 版本写了一个 mini-vue。不过学习源码对于我写业务代码并没有什么帮助。如果不是出于兴趣去研究源码,最好不要去学,熟读文档就能完全应付工作了。如果是为了面试,那也不需要阅读源码。只需要在网上找一些质量高的源码分析文章看一看,作一下笔记就 OK 了。

为什么我不建议阅读源码?因为阅读源码效率太低,而且相对于你的付出,收益并不大。到后面 Vue 出了 3.0 版本时,我也是有选择地阅读部分源码。

第三年

第三年有大半年的时间浪费在王者荣耀上,那会天天只想着冲荣耀,根本没心思学习。后来终于醒悟过来了,王者荣耀是我成为顶级程序员的阻碍。于是痛定思痛,给戒掉了。

由于打王者的原因,第三年没学习多少新知识。基本上只做了三件事:

  1. 写了几个 Vue 相关的插件和项目。
  2. 将过去所学的前端知识,整理了一下放在 github 上,有空就复习一下。
  3. 学习数据结构与算法。

数据结构与算法

数据结构和算法有什么用?学了算法后,我觉得至少会懂得去分析程序的性能问题。

一个程序的性能有问题,需要你去优化。如果学过数据结构和算法,你会从时间复杂度和空间复杂度去分析代码,然后解决问题。如果没学过,你只能靠猜、碰运气来解决问题。

理论知识上,我主要看的是算法这本书,课后习题没做,改成用刷 leetcode 代替。目前已经刷了 300+ 道题,还在继续刷。不过由于数学差,稍微复杂一点的算法知识都看不懂,效果不是很好。

第四年

第四年,也就是今年(2020),是我重新奋斗的一年。今年比以往的任何一年都要努力,每天保证 3 小时以上的学习时间。如果实在太忙了,达不到要求,那就改天把时间补上。附上我今年的学习时长图(记录软件为 Now Then):

今年我做了非常多的事情:

  1. 研究前端工程化。
  2. 学习操作系统。
  3. 学习计算机网络。
  4. 学习软件工程。
  5. 学习 C++。
  6. 学英语。

前端工程化

研究前端工程化的目的,就是为了提高团队的开发效率。为此我看了很多书和资料:

...

研究了一年的时间,写了一篇质量较高的入门教程——手把手带你入门前端工程化——超详细教程。除此之外,还有其他工程化相关的一系列文章:

操作系统

操作系统是管理计算机硬件与软件资源的计算机程序。通常情况下,程序是运行在操作系统上的,而不是直接和硬件交互。一个程序如果想和硬件交互就得通过操作系统。

如果你掌握了操作系统的知识,你就知道程序是怎么和硬件交互的。

例如你知道申请内存,释放内存的内部过程是怎样的;当你按下 k 键,你也知道 k 是怎么出现在屏幕上的;知道文件是怎么读出、写入的。

对于操作系统,我主要学习了以下书籍:

  1. x86汇编语言:从实模式到保护模式
  2. xv6-chinese
  3. 操作系统导论

然后做 MIT6.828 的实验,实现了一个简单的操作系统内核。

计算机网络

计算机网络的作用主要是解决计算机之间如何通信的问题。

例如 A 地区和 B 地区的的计算机怎么通信?同一局域网的两台电脑又如何通信?学习计算机网络知识就是了解它们是怎么通信的以及怎么将它们联通起来。

对于计算机网络,我主要学习了以下书籍:

  1. 计算机网络--自顶向下
  2. 计算机网络
  3. HTTP权威指南
  4. HTTP/2基础教程

并且做了计算机网络--自顶向下的实验。

软件工程

软件工程是一门研究用工程化方法构建和维护有效的、实用的和高质量的软件的学科。它涉及程序设计语言、数据库、软件开发工具、系统平台、标准、设计模式等方面。

学习以下书籍:

  1. 代码大全(第2版)
  2. 重构(第2版)
  3. 软件工程

软件工程是一门非常庞大的学科,我只学习了一点皮毛。主要学习的是关于代码怎么写得更好、结构组织更合理的知识,这需要一边学习一边在工作中运用。

C++

学习 C++ 其实是为了研究 nodejs 源码用的,看的这本书C++ Primer 中文版(第 5 版)

英语

我从转行开始就一直在学习英语,不过今年花的时间比较多。

英语对于程序员的好处非常非常多,就我知道的有:

  1. 可以用 google 和 stackoverflow 来解决问题。
  2. 知道怎么给变量、函数起一个好的命名。
  3. 很多流行的软件都是国外程序员写的,有问题你可以直接看文档以及和别人交流。

在我转行前英语词汇量只有几百,三年多过去了,现在词汇量有 6000(都是用百词斩测的)。

写作

写作的好处是非常多的,越早写越好。我还记得第一篇文章是 2017 年 2 月发表的,是我工作后的第 13 天,发表在 CSDN 上。

个人认为写作的好处有三点:

  1. 锻炼你的写作能力。一般情况下,写得越多,写作能力越好。这个好,不是说你的文章遣词造句有多好,而是指文章条理清晰,通俗易懂,容易让人理解。
  2. 写作其实是费曼学习法的运用,帮助自己加深理解所学的知识。有没有试过,学完一个知识点后,觉得自己懂了。但让你向别人讲述这个知识点时,反而吞吞吐吐不知道怎么讲。其实这是没理解透才会这样的,要让别人明白你在表达什么,首先你得非常熟悉这个知识点。一知半解是不可能把它讲明白的,所以写作也是在帮你梳理知识。
  3. 增加自己的曝光度。在我三年多的程序员生涯中,一共写了 50 多篇文章,因此在一些平台上也收获了不少赞和粉丝。因为我写的某些文章质量还行,不少大厂的程序员找过我,给我内推。不过由于个人学历问题,基本上都没下文...

总之一句话,写作对你只有好处,没有坏处。

学习

有选择的学习

我觉得学习一定要有非常清晰的目标,知道你要学什么,怎么学。对于前端来说,我认为很多框架和库都是不用学的。例如前端三大框架,没有必要三个都学,把你工作中要用的那个掌握好就行。

比如你公司用的是 Vue,就深入学习 Vue,如果要看源码就只看重点部分的源码。例如模板编译、Diff 算法、Vue 原生组件实现、指令实现等等。

剩下的两个框架 React、Angular 做个 DEMO 熟悉一下就行,毕竟原理都是相通的。等你公司要上这两个再深入学习,不过也不建议阅读源码了,太累。看别人写的现成的源码分析文章就好。

其他的,像 easyui、Backbone.js、各种小程序... 用不到的坚决不学,浪费时间。用的时候看文档就行了,当然,如果有兴趣了解如何实现也是可以的。

学习方法

我觉得好的学习方法非常重要,对我比较有用的两个是:

  1. 费曼学习法。
  2. 学习一个知识点,最好把它吃透。

费曼学习法在《写作》一节中已经说过了,这里着重说说第二个。

你有没有过这种感觉:觉得自己会的东西很多,但其实掌握的知识很多都停留在表面上,别人要是往深一问,就懵逼了。

我以前就有过这种感觉,主要问题出在对知识的学习仅停留在浅尝即止的状态。就是学习新知识,能写个 DEMO,就觉得自己学得差不多了。这种学习方法是很有害的,首先知识存留度不高,其次是浪费时间,因为很快就会忘掉。

后来我尝试改正这种状态,在学习新的知识点时,时常问自己三个问题:

  1. 这是什么?
  2. 为什么要这样?可以不这样吗?
  3. 有没有更好的方式?

当然,不是所有问题都能适用灵魂三问,但它适用大多数情况。

举个例子:看过性能优化相关文章的同学应该知道有这么一条规则,要减少页面上的 HTTP 请求。

这是什么?

先了解一下 HTTP 请求是啥,查资料发现原来是向服务器请求资源用的。

为什么要减少 HTTP 请求?

查资料发现:HTTP 请求需要经历 DNS 查找,TCP 握手,SSL 握手(如果有的话)等一系列过程,才能真正发出这个请求。并且现代浏览器对于 TCP 并发数也是有限制的,超过 TCP 并发数的 HTTP 请求只能等前面的请求完成了才能继续发送。

我们可以打开 chrome 开发者工具看一下一个 HTTP 请求所花费的具体时间。

在这里插入图片描述

这是一个 HTTP 请求,请求的文件大小为 28.4KB。

名词解释:

  1. Queueing: 在请求队列中的时间。
  2. Stalled: 从TCP 连接建立完成,到真正可以传输数据之间的时间差,此时间包括代理协商时间。
  3. Proxy negotiation: 与代理服务器连接进行协商所花费的时间。
  4. DNS Lookup: 执行DNS查找所花费的时间,页面上的每个不同的域都需要进行DNS查找。
  5. Initial Connection / Connecting: 建立连接所花费的时间,包括TCP握手/重试和协商SSL。
  6. SSL: 完成SSL握手所花费的时间。
  7. Request sent: 发出网络请求所花费的时间,通常为一毫秒的时间。
  8. Waiting(TFFB): TFFB 是发出页面请求到接收到应答数据第一个字节的时间总和,它包含了 DNS 解析时间、 TCP 连接时间、发送 HTTP 请求时间和获得响应消息第一个字节的时间。
  9. Content Download: 接收响应数据所花费的时间。

从这个例子可以看出,真正下载数据的时间占比为 13.05 / 204.16 = 6.39%。文件越小,这个比例越小,文件越大,比例就越高。这就是为什么要建议将多个小文件合并为一个大文件,从而减少 HTTP 请求次数的原因。

有没有更好的方式?

使用 HTTP2,所有的请求都可以放在一个 TCP 连接上发送。HTTP2 还有好多东西要学,这里不深入讲解了。

经过灵魂三问后,是不是这条优化规则的来龙去脉全都理清了,并且在你查资料动手的过程中,知识会理解得更加深刻。

掌握了这种学习方法,并且时刻运用在学习中、工作中,突破瓶颈只是时间的问题

总结

下面提前回答一下可能会有的问题。

百度前端技术学院

百度前端技术学院 2017 年及往后的任务,如果没有报名,那就只能做部分任务。2016 年的任务则由于百度服务器的问题,很多题的示例图都裂了。这个其实是有解决方案的,那就是看别人的答案。把别人的源码下载下来,用浏览器打开 html 文件当示例图看。这两年的任务我都做了大部分,附上答案:

  1. 百度前端技术学院2016任务
  2. 百度前端技术学院2017任务

学历提升

我从 18 年开始,已经报考了成人高考大专,19 年报了自考本科。大专明年 1 月就能毕业,自考本科比较难,可能 2021 年或 2022 年才能考下来。

写在最后

从转行到现在,已经过去 3 年多了。不得不说转行当程序员给了我人生第二次机会,我也很喜欢这个职业。不过这几年一直都是在小公司,导致自己的技术和视野得不到很大的提升。所以现在的目标除了学习计算机专业外,就是进大厂,希望有一天能实现。

虽然今年已经 32 了,但我对未来仍然充满希望。努力地学习,努力地提升自己,为了成为一名顶尖的程序员而努力。

查看原文

赞 46 收藏 20 评论 17

谭光志 发布了文章 · 11月10日

编译原理实战入门:用 JavaScript 写一个简单的四则运算编译器(修订版)

编译器是一个程序,作用是将一门语言翻译成另一门语言

例如 babel 就是一个编译器,它将 es6 版本的 js 翻译成 es5 版本的 js。从这个角度来看,将英语翻译成中文的翻译软件也属于编译器。

一般的程序,CPU 是无法直接执行的,因为 CPU 只能识别机器指令。所以要想执行一个程序,首先要将高级语言编写的程序翻译为汇编代码(Java 还多了一个步骤,将高级语言翻译成字节码),再将汇编代码翻译为机器指令,这样 CPU 才能识别并执行。

由于汇编语言和机器语言一一对应,并且汇编语言更具有可读性。所以计算机原理的教材在讲解机器指令时一般会用汇编语言来代替机器语言讲解。

本文所要写的四则运算编译器需要将 1 + 1 这样的四则运算表达式翻译成机器指令并执行。具体过程请看示例:

// CPU 无法识别
10 + 5

// 翻译成汇编语言
push 10
push 5
add

// 最后翻译为机器指令,汇编代码和机器指令一一对应
// 机器指令由 1 和 0 组成,以下指令非真实指令,只做演示用
0011101001010101
1101010011100101
0010100111100001

四则运算编译器,虽然说功能很简单,只能编译四则运算表达式。但是编译原理的前端部分几乎都有涉及:词法分析、语法分析。另外还有编译原理后端部分的代码生成。不管是简单的、复杂的编译器,编译步骤是差不多的,只是复杂的编译器实现上会更困难。

可能有人会问,学会编译原理有什么好处

我认为对编译过程内部原理的掌握将会使你成为更好的高级程序员。另外在这引用一下知乎网友-随心所往的回答,更加具体:

  1. 可以更加容易的理解在一个语言种哪些写法是等价的,哪些是有差异的
  2. 可以更加客观的比较不同语言的差异
  3. 更不容易被某个特定语言的宣扬者忽悠
  4. 学习新的语言是效率也会更高
  5. 其实从语言a转换到语言b是一个通用的需求,学好编译原理处理此类需求时会更加游刃有余

好了,下面让我们看一下如何写一个四则运算编译器。

词法分析

程序其实就是保存在文本文件中的一系列字符,词法分析的作用是将这一系列字符按照某种规则分解成一个个字元(token,也称为终结符),忽略空格和注释。

示例:

// 程序代码
10 + 5 + 6

// 词法分析后得到的 token
10
+
5
+
6

终结符

终结符就是语言中用到的基本元素,它不能再被分解。

四则运算中的终结符包括符号和整数常量(暂不支持一元操作符和浮点运算)。

  1. 符号+ - * / ( )
  2. 整数常量:12、1000、111...

词法分析代码实现

function lexicalAnalysis(expression) {
    const symbol = ['(', ')', '+', '-', '*', '/']
    const re = /\d/
    const tokens = []
    const chars = expression.trim().split('')
    let token = ''
    chars.forEach(c => {
        if (re.test(c)) {
            token += c
        } else if (c == ' ' && token) {
            tokens.push(token)
            token = ''
        } else if (symbol.includes(c)) {
            if (token) {
                tokens.push(token)
                token = ''
            } 

            tokens.push(c)
        }
    })

    if (token) {
        tokens.push(token)
    }

    return tokens
}

console.log(lexicalAnalysis('100    +   23   +    34 * 10 / 2')) 
// ["100", "+", "23", "+", "34", "*", "10", "/", "2"]

四则运算的语法规则(语法规则是分层的)

  1. x*, 表示 x 出现零次或多次
  2. x | y, 表示 x 或 y 将出现
  3. ( ) 圆括号,用于语言构词的分组

以下规则从左往右看,表示左边的表达式还能继续往下细分成右边的表达式,一直细分到不可再分为止。

  • expression: addExpression
  • addExpression: mulExpression (op mulExpression)*
  • mulExpression: term (op term)*
  • term: '(' expression ')' | integerConstant
  • op: + - * /

addExpression 对应 +- 表达式,mulExpression 对应 */ 表达式。

如果你看不太懂以上的规则,那就先放下,继续往下看。看看怎么用代码实现语法分析。

语法分析

对输入的文本按照语法规则进行分析并确定其语法结构的一种过程,称为语法分析。

一般语法分析的输出为抽象语法树(AST)或语法分析树(parse tree)。但由于四则运算比较简单,所以这里采取的方案是即时地进行代码生成和错误报告,这样就不需要在内存中保存整个程序结构。

先来看看怎么分析一个四则运算表达式 1 + 2 * 3

首先匹配的是 expression,由于目前 expression 往下分只有一种可能,即 addExpression,所以分解为 addExpression
依次类推,接下来的顺序为 mulExpressionterm1(integerConstant)、+(op)、mulExpressionterm2(integerConstant)、*(op)、mulExpressionterm3(integerConstant)。

如下图所示:

这里可能会有人有疑问,为什么一个表达式搞得这么复杂,expression 下面有 addExpressionaddExpression 下面还有 mulExpression
其实这里是为了考虑运算符优先级而设的,mulExpraddExpr 表达式运算级要高。

1 + 2 * 3
compileExpression
   | compileAddExpr
   |  | compileMultExpr
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 1
   |  |  |_
   |  | matches '+'
   |  | compileMultExpr
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 2
   |  |  | matches '*'
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 3
   |  |  |_ compileOp('*')                      *
   |  |_ compileOp('+')                         +
   |_

有很多算法可用来构建语法分析树,这里只讲两种算法。

递归下降分析法

递归下降分析法,也称为自顶向下分析法。按照语法规则一步步递归地分析 token 流,如果遇到非终结符,则继续往下分析,直到终结符为止。

LL(0)分析法

递归下降分析法是简单高效的算法,LL(0)在此基础上多了一个步骤,当第一个 token 不足以确定元素类型时,对下一个字元采取“提前查看”,有可能会解决这种不确定性。

以上是对这两种算法的简介,具体实现请看下方的代码实现。

表达式代码生成

我们通常用的四则运算表达式是中缀表达式,但是对于计算机来说中缀表达式不便于计算。所以在代码生成阶段,要将中缀表达式转换为后缀表达式。

后缀表达式

后缀表达式,又称逆波兰式,指的是不包含括号,运算符放在两个运算对象的后面,所有的计算按运算符出现的顺序,严格从左向右进行(不再考虑运算符的优先规则)。

示例:

中缀表达式: 5 + 5 转换为后缀表达式:5 5 +,然后再根据后缀表达式生成代码。

// 5 + 5 转换为 5 5 + 再生成代码
push 5
push 5
add

代码实现

编译原理的理论知识像天书,经常让人看得云里雾里,但真正动手做起来,你会发现,其实还挺简单的。

如果上面的理论知识看不太懂,没关系,先看代码实现,然后再和理论知识结合起来看。

注意:这里需要引入刚才的词法分析代码。

// 汇编代码生成器
function AssemblyWriter() {
    this.output = ''
}

AssemblyWriter.prototype = {
    writePush(digit) {
        this.output += `push ${digit}\r\n`
    },

    writeOP(op) {
        this.output += op + '\r\n'
    },

    //输出汇编代码
    outputStr() {
        return this.output
    }
}

// 语法分析器
function Parser(tokens, writer) {
    this.writer = writer
    this.tokens = tokens
    // tokens 数组索引
    this.i = -1
    this.opMap1 = {
        '+': 'add',
        '-': 'sub',
    }

    this.opMap2 = {
        '/': 'div',
        '*': 'mul'
    }

    this.init()
}

Parser.prototype = {
    init() {
        this.compileExpression()
    },

    compileExpression() {
        this.compileAddExpr()
    },

    compileAddExpr() {
        this.compileMultExpr()
        while (true) {
            this.getNextToken()
            if (this.opMap1[this.token]) {
                let op = this.opMap1[this.token]
                this.compileMultExpr()
                this.writer.writeOP(op)
            } else {
                // 没有匹配上相应的操作符 这里为没有匹配上 + - 
                // 将 token 索引后退一位
                this.i--
                break
            }
        }
    },

    compileMultExpr() {
        this.compileTerm()
        while (true) {
            this.getNextToken()
            if (this.opMap2[this.token]) {
                let op = this.opMap2[this.token]
                this.compileTerm()
                this.writer.writeOP(op)
            } else {
                // 没有匹配上相应的操作符 这里为没有匹配上 * / 
                // 将 token 索引后退一位
                this.i--
                break
            }
        }
    },

    compileTerm() {
        this.getNextToken()
        if (this.token == '(') {
            this.compileExpression()
            this.getNextToken()
            if (this.token != ')') {
                throw '缺少右括号:)'
            }
        } else if (/^\d+$/.test(this.token)) {
            this.writer.writePush(this.token)
        } else {
            throw '错误的 token:第 ' + (this.i + 1) + ' 个 token (' + this.token + ')'
        }
    },

    getNextToken() {
        this.token = this.tokens[++this.i]
    },

    getInstructions() {
        return this.writer.outputStr()
    }
}

const tokens = lexicalAnalysis('100+10*10')
const writer = new AssemblyWriter()
const parser = new Parser(tokens, writer)
const instructions = parser.getInstructions()
console.log(instructions) // 输出生成的汇编代码
/*
push 100
push 10
push 10
mul
add
*/

模拟执行

现在来模拟一下 CPU 执行机器指令的情况,由于汇编代码和机器指令一一对应,所以我们可以创建一个直接执行汇编代码的模拟器。
在创建模拟器前,先来讲解一下相关指令的操作。

在内存中,栈的特点是只能在同一端进行插入和删除的操作,即只有 push 和 pop 两种操作。

push

push 指令的作用是将一个操作数推入栈中。

pop

pop 指令的作用是将一个操作数弹出栈。

add

add 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,然后执行 a + b,再将结果 push 到栈中。

sub

sub 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,然后执行 a - b,再将结果 push 到栈中。

mul

mul 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,然后执行 a * b,再将结果 push 到栈中。

div

sub 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,然后执行 a / b,再将结果 push 到栈中。

四则运算的所有指令已经讲解完毕了,是不是觉得很简单?

代码实现

注意:需要引入词法分析和语法分析的代码

function CpuEmulator(instructions) {
    this.ins = instructions.split('\r\n')
    this.memory = []
    this.re = /^(push)\s\w+/
    this.execute()
}

CpuEmulator.prototype = {
    execute() {
        this.ins.forEach(i => {
            switch (i) {
                case 'add':
                    this.add()
                    break
                case 'sub':
                    this.sub()
                    break
                case 'mul':
                    this.mul()
                    break
                case 'div':
                    this.div()
                    break                
                default:
                    if (this.re.test(i)) {
                        this.push(i.split(' ')[1])
                    }
            }
        })
    },

    add() {
        const b = this.pop()
        const a = this.pop()
        this.memory.push(a + b)
    },

    sub() {
        const b = this.pop()
        const a = this.pop()
        this.memory.push(a - b)
    },

    mul() {
        const b = this.pop()
        const a = this.pop()
        this.memory.push(a * b)
    },

    div() {
        const b = this.pop()
        const a = this.pop()
        // 不支持浮点运算,所以在这要取整
        this.memory.push(Math.floor(a / b))
    },

    push(x) {
        this.memory.push(parseInt(x))
    },

    pop() {
        return this.memory.pop()
    },

    getResult() {
        return this.memory[0]
    }
}

const tokens = lexicalAnalysis('(100+  10)*  10-100/  10      +8*  (4+2)')
const writer = new AssemblyWriter()
const parser = new Parser(tokens, writer)
const instructions = parser.getInstructions()
const emulator = new CpuEmulator(instructions)
console.log(emulator.getResult()) // 1138

一个简单的四则运算编译器已经实现了。我们再来写一个测试函数跑一跑,看看运行结果是否和我们期待的一样:

function assert(expression, result) {
    const tokens = lexicalAnalysis(expression)
    const writer = new AssemblyWriter()
    const parser = new Parser(tokens, writer)
    const instructions = parser.getInstructions()
    const emulator = new CpuEmulator(instructions)
    return emulator.getResult() == result
}

console.log(assert('1 + 2 + 3', 6)) // true
console.log(assert('1 + 2 * 3', 7)) // true
console.log(assert('10 / 2 * 3', 15)) // true
console.log(assert('(10 + 10) / 2', 10)) // true

测试全部正确。另外附上完整的源码,建议没看懂的同学再看多两遍。

更上一层楼

对于工业级编译器来说,这个四则运算编译器属于玩具中的玩具。但是人不可能一口吃成个胖子,所以学习编译原理最好采取循序渐进的方式去学习。下面来介绍一个高级一点的编译器,这个编译器可以编译一个 Jack 语言(类 Java 语言),它的语法大概是这样的:

class Generate {
    field String str;
    static String str1;
    constructor Generate new(String s) {
        let str = s;
        return this;
    }

    method String getString() {
        return str;
    }
}

class Main {
    function void main() {
        var Generate str;
        let str = Generate.new("this is a test");
        do Output.printString(str.getString());
        return;
    }
}

上面代码的输出结果为:this is a test

想不想实现这样的一个编译器?

这个编译器出自一本书《计算机系统要素》,它从第 6 章开始,一直到第 11 章讲解了汇编编译器(将汇编语言转换为机器语言)、VM 编译器(将类似于字节码的 VM 语言翻译成汇编语言)、Jack 语言编译器(将高级语言 Jack 翻译成 VM 语言)。每一章都有详细的知识点讲解和实验,只要你一步一步跟着做实验,就能最终实现这样的一个编译器。

如果编译器写完了,最后机器语言在哪执行呢?

这本书已经为你考虑好了,它从第 1 章到第 5 章,一共五章的内容。教你从逻辑门开始,逐步组建出算术逻辑单元 ALU、CPU、内存,最终搭建出一个现代计算机。然后让你用编译器编译出来的程序运行在这台计算机之上。

另外,这本书的第 12 章会教你写操作系统的各种库函数,例如 Math 库(包含各种数学运算)、Keyboard 库(按下键盘是怎么输出到屏幕上的)、内存管理等等。

想看一看全书共 12 章的实验做完之后是怎么样的吗?我这里提供几张这台模拟计算机运行程序的 DEMO GIF,供大家参考参考。

这几张图中的右上角是“计算机”的屏幕,其他部分是“计算机”的堆栈区和指令区。

这本书的所有实验我都已经做完了(每天花 3 小时,两个月就能做完),答案放在我的 github 上,有兴趣的话可以看看。

参考资料

我的博客即将同步至 OSCHINA 社区,这是我的 OSCHINA ID:谭光志,邀请大家一同入驻:https://www.oschina.net/shari...

查看原文

赞 14 收藏 9 评论 2

谭光志 发布了文章 · 11月6日

手把手带你入门前端工程化——超详细教程

本文将分成以下 7 个小节:

  1. 技术选型
  2. 统一规范
  3. 测试
  4. 部署
  5. 监控
  6. 性能优化
  7. 重构

部分小节提供了非常详细的实战教程,让大家动手实践。

另外我还写了一个前端工程化 demo 放在 github 上。这个 demo 包含了 js、css、git 验证,其中 js、css 验证需要安装 VSCode,具体教程在下文中会有提及。

技术选型

对于前端来说,技术选型挺简单的。就是做选择题,三大框架中选一个。个人认为可以依据以下两个特点来选:

  1. 选你或团队最熟的,保证在遇到棘手的问题时有人能填坑。
  2. 选市场占有率高的。换句话说,就是选好招人的。

第二点对于小公司来说,特别重要。本来小公司就不好招人,要是还选一个市场占有率不高的框架(例如 Angular),简历你都看不到几个...

UI 组件库更简单,github 上哪个 star 多就用哪个。star 多,说明用的人就多,很多坑别人都替你踩过了,省事。

统一规范

代码规范

先来看看统一代码规范的好处:

  • 规范的代码可以促进团队合作
  • 规范的代码可以降低维护成本
  • 规范的代码有助于 code review(代码审查)
  • 养成代码规范的习惯,有助于程序员自身的成长

当团队的成员都严格按照代码规范来写代码时,可以保证每个人的代码看起来都像是一个人写的,看别人的代码就像是在看自己的代码。更重要的是我们能够认识到规范的重要性,并坚持规范的开发习惯。

如何制订代码规范

建议找一份好的代码规范,在此基础上结合团队的需求作个性化修改。

下面列举一些 star 较多的 js 代码规范:

css 代码规范也有不少,例如:

如何检查代码规范

使用 eslint 可以检查代码符不符合团队制订的规范,下面来看一下如何配置 eslint 来检查代码。

  1. 下载依赖
// eslint-config-airbnb-base 使用 airbnb 代码规范
npm i -D babel-eslint eslint eslint-config-airbnb-base eslint-plugin-import
  1. 配置 .eslintrc 文件
{
    "parserOptions": {
        "ecmaVersion": 2019
    },
    "env": {
        "es6": true,
    },
    "parser": "babel-eslint",
    "extends": "airbnb-base",
}
  1. package.jsonscripts 加上这行代码 "lint": "eslint --ext .js test/ src/"。然后执行 npm run lint 即可开始验证代码。代码中的 test/ src/ 是指你要进行校验的代码目录,这里指明了要检查 testsrc 目录下的代码。

不过这样检查代码效率太低,每次都得手动检查。并且报错了还得手动修改代码。

为了改善以上缺点,我们可以使用 VSCode。使用它并加上适当的配置可以在每次保存代码的时候,自动验证代码并进行格式化,省去了动手的麻烦。

css 检查代码规范则使用 stylelint 插件。

由于篇幅有限,具体如何配置请看我的另一篇文章ESlint + stylelint + VSCode自动格式化代码(2020)

在这里插入图片描述

git 规范

git 规范包括两点:分支管理规范、git commit 规范。

分支管理规范

一般项目分主分支(master)和其他分支。

当有团队成员要开发新功能或改 BUG 时,就从 master 分支开一个新的分支。例如项目要从客户端渲染改成服务端渲染,就开一个分支叫 ssr,开发完了再合并回 master 分支。

如果改一个 BUG,也可以从 master 分支开一个新分支,并用 BUG 号命名(不过我们小团队嫌麻烦,没这样做,除非有特别大的 BUG)。

git commit 规范

<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

大致分为三个部分(使用空行分割):

  1. 标题行: 必填, 描述主要修改类型和内容
  2. 主题内容: 描述为什么修改, 做了什么样的修改, 以及开发的思路等等
  3. 页脚注释: 可以写注释,BUG 号链接

type: commit 的类型

  • feat: 新功能、新特性
  • fix: 修改 bug
  • perf: 更改代码,以提高性能
  • refactor: 代码重构(重构,在不影响代码内部行为、功能下的代码修改)
  • docs: 文档修改
  • style: 代码格式修改, 注意不是 css 修改(例如分号修改)
  • test: 测试用例新增、修改
  • build: 影响项目构建或依赖项修改
  • revert: 恢复上一次提交
  • ci: 持续集成相关文件修改
  • chore: 其他修改(不在上述类型中的修改)
  • release: 发布新版本
  • workflow: 工作流相关文件修改
  1. scope: commit 影响的范围, 比如: route, component, utils, build...
  2. subject: commit 的概述
  3. body: commit 具体修改内容, 可以分为多行.
  4. footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.

示例

fix(修复BUG)

如果修复的这个BUG只影响当前修改的文件,可不加范围。如果影响的范围比较大,要加上范围描述。

例如这次 BUG 修复影响到全局,可以加个 global。如果影响的是某个目录或某个功能,可以加上该目录的路径,或者对应的功能名称。

// 示例1
fix(global):修复checkbox不能复选的问题
// 示例2 下面圆括号里的 common 为通用管理的名称
fix(common): 修复字体过小的BUG,将通用管理下所有页面的默认字体大小修改为 14px
// 示例3
fix: value.length -> values.length
feat(添加新功能或新页面)
feat: 添加网站主页静态页面

这是一个示例,假设对点检任务静态页面进行了一些描述。
 
这里是备注,可以是放BUG链接或者一些重要性的东西。
chore(其他修改)

chore 的中文翻译为日常事务、例行工作,顾名思义,即不在其他 commit 类型中的修改,都可以用 chore 表示。

chore: 将表格中的查看详情改为详情

其他类型的 commit 和上面三个示例差不多,就不说了。

验证 git commit 规范

验证 git commit 规范,主要通过 git 的 pre-commit 钩子函数来进行。当然,你还需要下载一个辅助工具来帮助你进行验证。

下载辅助工具

npm i -D husky

package.json 加上下面的代码

"husky": {
  "hooks": {
    "pre-commit": "npm run lint",
    "commit-msg": "node script/verify-commit.js",
    "pre-push": "npm test"
  }
}

然后在你项目根目录下新建一个文件夹 script,并在下面新建一个文件 verify-commit.js,输入以下代码:

const msgPath = process.env.HUSKY_GIT_PARAMS
const msg = require('fs')
.readFileSync(msgPath, 'utf-8')
.trim()

const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)(\(.+\))?: .{1,50}/

if (!commitRE.test(msg)) {
    console.log()
    console.error(`
        不合法的 commit 消息格式。
        请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md
    `)

    process.exit(1)
}

现在来解释下各个钩子的含义:

  1. "pre-commit": "npm run lint",在 git commit 前执行 npm run lint 检查代码格式。
  2. "commit-msg": "node script/verify-commit.js",在 git commit 时执行脚本 verify-commit.js 验证 commit 消息。如果不符合脚本中定义的格式,将会报错。
  3. "pre-push": "npm test",在你执行 git push 将代码推送到远程仓库前,执行 npm test 进行测试。如果测试失败,将不会执行这次推送。

项目规范

主要是项目文件的组织方式和命名方式。

用我们的 Vue 项目举个例子。

├─public
├─src
├─test

一个项目包含 public(公共资源,不会被 webpack 处理)、src(源码)、test(测试代码),其中 src 目录,又可以细分。

├─api (接口)
├─assets (静态资源)
├─components (公共组件)
├─styles (公共样式)
├─router (路由)
├─store (vuex 全局数据)
├─utils (工具函数)
└─views (页面)

文件名称如果过长则用 - 隔开。

UI 规范

UI 规范需要前端、UI、产品沟通,互相商量,最后制定下来,建议使用统一的 UI 组件库。

制定 UI 规范的好处:

  • 统一页面 UI 标准,节省 UI 设计时间
  • 提高前端开发效率

测试

测试是前端工程化建设必不可少的一部分,它的作用就是找出 bug,越早发现 bug,所需要付出的成本就越低。并且,它更重要的作用是在将来,而不是当下。

设想一下半年后,你的项目要加一个新功能。在加完新功能后,你不确定有没有影响到原有的功能,需要测试一下。由于时间过去太久,你对项目的代码已经不了解了。在这种情况下,如果没有写测试,你就得手动一遍一遍的去试。而如果写了测试,你只需要跑一遍测试代码就 OK 了,省时省力。

写测试还可以让你修改代码时没有心理负担,不用一直想着改这里有没有问题?会不会引起 BUG?而写了测试就没有这种担心了。

在前端用得最多的就是单元测试(主要是端到端测试我用得很少,不熟),这里着重讲解一下。

单元测试

单元测试就是对一个函数、一个组件、一个类做的测试,它针对的粒度比较小。

它应该怎么写呢?

  1. 根据正确性写测试,即正确的输入应该有正常的结果。
  2. 根据异常写测试,即错误的输入应该是错误的结果。

对一个函数做测试

例如一个取绝对值的函数 abs(),输入 1,2,结果应该与输入相同;输入 -1,-2,结果应该与输入相反。如果输入非数字,例如 "abc",应该抛出一个类型错误。

对一个类做测试

假设有这样一个类:

class Math {
    abs() {

    }

    sqrt() {

    }

    pow() {

    }
    ...
}

单元测试,必须把这个类的所有方法都测一遍。

对一个组件做测试

组件测试比较难,因为很多组件都涉及了 DOM 操作。

例如一个上传图片组件,它有一个将图片转成 base64 码的方法,那要怎么测试呢?一般测试都是跑在 node 环境下的,而 node 环境没有 DOM 对象。

我们先来回顾一下上传图片的过程:

  1. 点击 <input type="file" />,选择图片上传。
  2. 触发 inputchange 事件,获取 file 对象。
  3. FileReader 将图片转换成 base64 码。

这个过程和下面的代码是一样的:

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    const reader = new FileReader()
    reader.onload = (res) => {
        const fileResult = res.target.result
        console.log(fileResult) // 输出 base64 码
    }

    reader.readAsDataURL(file)
}

上面的代码只是模拟,真实情况下应该是这样使用

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    tobase64(file)
}

function tobase64(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.onload = (res) => {
            const fileResult = res.target.result
            resolve(fileResult) // 输出 base64 码
        }

        reader.readAsDataURL(file)
    })
}

可以看到,上面代码出现了 window 的事件对象 eventFileReader。也就是说,只要我们能够提供这两个对象,就可以在任何环境下运行它。所以我们可以在测试环境下加上这两个对象:

// 重写 File
window.File = function () {}

// 重写 FileReader
window.FileReader = function () {
    this.readAsDataURL = function () {
        this.onload
            && this.onload({
                target: {
                    result: fileData,
                },
            })
    }
}

然后测试可以这样写:

// 提前写好文件内容
const fileData = 'data:image/test'

// 提供一个假的 file 对象给 tobase64() 函数
function test() {
    const file = new File()
    const event = { target: { files: [file] } }
    file.type = 'image/png'
    file.name = 'test.png'
    file.size = 1024

    it('file content', (done) => {
        tobase64(file).then(base64 => {
            expect(base64).toEqual(fileData) // 'data:image/test'
            done()
        })
    })
}

// 执行测试
test()

通过这种 hack 的方式,我们就实现了对涉及 DOM 操作的组件的测试。我的 vue-upload-imgs 库就是通过这种方式写的单元测试,有兴趣可以了解一下。

TDD 测试驱动开发

TDD 就是根据需求提前把测试代码写好,然后根据测试代码实现功能。

TDD 的初衷是好的,但如果你的需求经常变(你懂的),那就不是一件好事了。很有可能你天天都在改测试代码,业务代码反而没怎么动。
所以到现在为止,三年多的程序员生涯,我还没尝试过 TDD 开发。

虽然环境如此艰难,但有条件的情况下还是应该试一下 TDD 的。例如在你自己负责一个项目又不忙的时候,可以采用此方法编写测试用例。

测试框架推荐

我常用的测试框架是 jest,好处是有中文文档,API 清晰明了,一看就知道是干什么用的。

部署

在没有学会自动部署前,我是这样部署项目的:

  1. 执行测试 npm run test
  2. 推送代码 git push
  3. 构建项目 npm run build
  4. 将打包好的文件放到静态服务器。

一次两次还行,如果天天都这样,就会把很多时间浪费在重复的操作上。所以我们要学会自动部署,彻底解放双手。

自动部署(又叫持续部署 Continuous Deployment,英文缩写 CD)一般有两种触发方式:

  1. 轮询。
  2. 监听 webhook 事件。

轮询

轮询,就是构建软件每隔一段时间自动执行打包、部署操作。

这种方式不太好,很有可能软件刚部署完我就改代码了。为了看到新的页面效果,不得不等到下一次构建开始。

另外还有一个副作用,假如我一天都没更改代码,构建软件还是会不停的执行打包、部署操作,白白的浪费资源。

所以现在的构建软件基本采用监听 webhook 事件的方式来进行部署。

监听 webhook 事件

webhook 钩子函数,就是在你的构建软件上进行设置,监听某一个事件(一般是监听 push 事件),当事件触发时,自动执行定义好的脚本。

例如 Github Actions,就有这个功能。

对于新人来说,仅看我这一段讲解是不可能学会自动部署的。为此我特地写了一篇自动化部署教程,不需要你提前学习自动化部署的知识,只要照着指引做,就能实现前端项目自动化部署。

前端项目自动化部署——超详细教程(Jenkins、Github Actions),教程已经奉上,各位大佬看完后要是觉得有用,不要忘了点赞,感激不尽。

监控

监控,又分性能监控和错误监控,它的作用是预警和追踪定位问题。

性能监控

性能监控一般利用 window.performance 来进行数据采集。

Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。

这个 API 的属性 timing,包含了页面加载各个阶段的起始及结束时间。

在这里插入图片描述
在这里插入图片描述

为了方便大家理解 timing 各个属性的意义,我在知乎找到一位网友对于 timing 写的简介(忘了姓名,后来找不到了,见谅),在此转载一下。

timing: {
        // 同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面,这个值会和fetchStart相同。
    navigationStart: 1543806782096,

    // 上一个页面unload事件抛出时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventStart: 1543806782523,

    // 和 unloadEventStart 相对应,unload事件处理完成时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventEnd: 1543806782523,

    // 第一个HTTP重定向开始时的时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回0。
    redirectStart: 0,

    // 最后一个HTTP重定向完成时(也就是说是HTTP响应的最后一个比特直接被收到的时间)的时间戳。
    // 如果没有重定向,或者重定向中的一个不同源,这个值会返回0. 
    redirectEnd: 0,

    // 浏览器准备好使用HTTP请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。
    fetchStart: 1543806782096,

    // DNS 域名查询开始的UNIX时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和fetchStart一致。
    domainLookupStart: 1543806782096,

    // DNS 域名查询完成的时间.
    //如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等
    domainLookupEnd: 1543806782096,

    // HTTP(TCP) 域名查询结束的时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 fetchStart一致。
    connectStart: 1543806782099,

    // HTTP(TCP) 返回浏览器与服务器之间的连接建立时的时间戳。
        // 如果建立的是持久连接,则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
    connectEnd: 1543806782227,

    // HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,则返回0。
    secureConnectionStart: 1543806782162,

    // 返回浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的时间戳。
    requestStart: 1543806782241,

    // 返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间戳。
        //如果传输层在开始请求之后失败并且连接被重开,该属性将会被数制成新的请求的相对应的发起时间。
    responseStart: 1543806782516,

    // 返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时
        //(如果在此之前HTTP连接已经关闭,则返回关闭时)的时间戳。
    responseEnd: 1543806782537,

    // 当前网页DOM结构开始解析时(即Document.readyState属性变为“loading”、相应的 readystatechange事件触发时)的时间戳。
    domLoading: 1543806782573,

    // 当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时)的时间戳。
    domInteractive: 1543806783203,

    // 当解析器发送DOMContentLoaded 事件,即所有需要被执行的脚本已经被解析时的时间戳。
    domContentLoadedEventStart: 1543806783203,

    // 当所有需要立即执行的脚本已经被执行(不论执行顺序)时的时间戳。
    domContentLoadedEventEnd: 1543806783216,

    // 当前文档解析完成,即Document.readyState 变为 'complete'且相对应的readystatechange 被触发时的时间戳
    domComplete: 1543806783796,

    // load事件被发送时的时间戳。如果这个事件还未被发送,它的值将会是0。
    loadEventStart: 1543806783796,

    // 当load事件结束,即加载事件完成时的时间戳。如果这个事件还未被发送,或者尚未完成,它的值将会是0.
    loadEventEnd: 1543806783802
}

通过以上数据,我们可以得到几个有用的时间

// 重定向耗时
redirect: timing.redirectEnd - timing.redirectStart,
// DOM 渲染耗时
dom: timing.domComplete - timing.domLoading,
// 页面加载耗时
load: timing.loadEventEnd - timing.navigationStart,
// 页面卸载耗时
unload: timing.unloadEventEnd - timing.unloadEventStart,
// 请求耗时
request: timing.responseEnd - timing.requestStart,
// 获取性能信息时当前时间
time: new Date().getTime(),

还有一个比较重要的时间就是白屏时间,它指从输入网址,到页面开始显示内容的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    whiteScreen = new Date() - performance.timing.navigationStart
</script>

通过这几个时间,就可以得知页面首屏加载性能如何了。

另外,通过 window.performance.getEntriesByType('resource') 这个方法,我们还可以获取相关资源(js、css、img...)的加载时间,它会返回页面当前所加载的所有资源。

在这里插入图片描述

它一般包括以下几个类型

  • sciprt
  • link
  • img
  • css
  • fetch
  • other
  • xmlhttprequest

我们只需用到以下几个信息

// 资源的名称
name: item.name,
// 资源加载耗时
duration: item.duration.toFixed(2),
// 资源大小
size: item.transferSize,
// 资源所用协议
protocol: item.nextHopProtocol,

现在,写几行代码来收集这些数据。

// 收集性能信息
const getPerformance = () => {
    if (!window.performance) return
    const timing = window.performance.timing
    const performance = {
        // 重定向耗时
        redirect: timing.redirectEnd - timing.redirectStart,
        // 白屏时间
        whiteScreen: whiteScreen,
        // DOM 渲染耗时
        dom: timing.domComplete - timing.domLoading,
        // 页面加载耗时
        load: timing.loadEventEnd - timing.navigationStart,
        // 页面卸载耗时
        unload: timing.unloadEventEnd - timing.unloadEventStart,
        // 请求耗时
        request: timing.responseEnd - timing.requestStart,
        // 获取性能信息时当前时间
        time: new Date().getTime(),
    }

    return performance
}

// 获取资源信息
const getResources = () => {
    if (!window.performance) return
    const data = window.performance.getEntriesByType('resource')
    const resource = {
        xmlhttprequest: [],
        css: [],
        other: [],
        script: [],
        img: [],
        link: [],
        fetch: [],
        // 获取资源信息时当前时间
        time: new Date().getTime(),
    }

    data.forEach(item => {
        const arry = resource[item.initiatorType]
        arry && arry.push({
            // 资源的名称
            name: item.name,
            // 资源加载耗时
            duration: item.duration.toFixed(2),
            // 资源大小
            size: item.transferSize,
            // 资源所用协议
            protocol: item.nextHopProtocol,
        })
    })

    return resource
}

小结

通过对性能及资源信息的解读,我们可以判断出页面加载慢有以下几个原因:

  1. 资源过多
  2. 网速过慢
  3. DOM元素过多

除了用户网速过慢,我们没办法之外,其他两个原因都是有办法解决的,性能优化将在下一节《性能优化》中会讲到。

错误监控

现在能捕捉的错误有三种。

  1. 资源加载错误,通过 addEventListener('error', callback, true) 在捕获阶段捕捉资源加载失败错误。
  2. js 执行错误,通过 window.onerror 捕捉 js 错误。
  3. promise 错误,通过 addEventListener('unhandledrejection', callback)捕捉 promise 错误,但是没有发生错误的行数,列数等信息,只能手动抛出相关错误信息。

我们可以建一个错误数组变量 errors 在错误发生时,将错误的相关信息添加到数组,然后在某个阶段统一上报,具体如何操作请看代码

// 捕获资源加载失败错误 js css img...
addEventListener('error', e => {
    const target = e.target
    if (target != window) {
        monitor.errors.push({
            type: target.localName,
            url: target.src || target.href,
            msg: (target.src || target.href) + ' is load error',
            // 错误发生的时间
            time: new Date().getTime(),
        })
    }
}, true)

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    monitor.errors.push({
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    })
}

// 监听 promise 错误 缺点是获取不到行数数据
addEventListener('unhandledrejection', e => {
    monitor.errors.push({
        type: 'promise',
        msg: (e.reason && e.reason.msg) || e.reason || '',
        // 错误发生的时间
        time: new Date().getTime(),
    })
})

小结

通过错误收集,可以了解到网站错误发生的类型及数量,从而可以做相应的调整,以减少错误发生。
完整代码和 DEMO 请看我另一篇文章前端性能和错误监控的末尾,大家可以复制代码(HTML文件)在本地测试一下。

数据上报

性能数据上报

性能数据可以在页面加载完之后上报,尽量不要对页面性能造成影响。

window.onload = () => {
    // 在浏览器空闲时间获取性能及资源信息
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
    if (window.requestIdleCallback) {
        window.requestIdleCallback(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        })
    } else {
        setTimeout(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        }, 0)
    }
}

当然,你也可以设一个定时器,循环上报。不过每次上报最好做一下对比去重再上报,避免同样的数据重复上报。

错误数据上报

我在DEMO里提供的代码,是用一个 errors 数组收集所有的错误,再在某一阶段统一上报(延时上报)。
其实,也可以改成在错误发生时上报(即时上报)。这样可以避免在收集完错误延时上报还没触发,用户却已经关掉网页导致错误数据丢失的问题。

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    const data = {
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    }
    
    // 即时上报
    axios.post({ url: 'xxx', data, })
}

SPA

window.performance API 是有缺点的,在 SPA 切换路由时,window.performance.timing 的数据不会更新。
所以我们需要另想办法来统计切换路由到加载完成的时间。
拿 Vue 举例,一个可行的办法就是切换路由时,在路由的全局前置守卫 beforeEach 里获取开始时间,在组件的 mounted 钩子里执行 vm.$nextTick 函数来获取组件的渲染完毕时间。

router.beforeEach((to, from, next) => {
    store.commit('setPageLoadedStartTime', new Date())
})
mounted() {
    this.$nextTick(() => {
        this.$store.commit('setPageLoadedTime', new Date() - this.$store.state.pageLoadedStartTime)
    })
}

除了性能和错误监控,其实我们还可以做得更多。

用户信息收集

navigator

使用 window.navigator 可以收集到用户的设备信息,操作系统,浏览器信息...

UV(Unique visitor)

是指通过互联网访问、浏览这个网页的自然人。访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。一天内同个访客多次访问仅计算一个UV。
在用户访问网站时,可以生成一个随机字符串+时间日期,保存在本地。在网页发生请求时(如果超过当天24小时,则重新生成),把这些参数传到后端,后端利用这些信息生成 UV 统计报告。

PV(Page View)

即页面浏览量或点击量,用户每1次对网站中的每个网页访问均被记录1个PV。用户对同一页面的多次访问,访问量累计,用以衡量网站用户访问的网页数量。

页面停留时间

传统网站
用户在进入 A 页面时,通过后台请求把用户进入页面的时间捎上。过了 10 分钟,用户进入 B 页面,这时后台可以通过接口捎带的参数可以判断出用户在 A 页面停留了 10 分钟。
SPA
可以利用 router 来获取用户停留时间,拿 Vue 举例,通过 router.beforeEachdestroyed 这两个钩子函数来获取用户停留该路由组件的时间。

浏览深度

通过 document.documentElement.scrollTop 属性以及屏幕高度,可以判断用户是否浏览完网站内容。

页面跳转来源

通过 document.referrer 属性,可以知道用户是从哪个网站跳转而来。

小结

通过分析用户数据,我们可以了解到用户的浏览习惯、爱好等等信息,想想真是恐怖,毫无隐私可言。

前端监控部署教程

前面说的都是监控原理,但要实现还是得自己动手写代码。为了避免麻烦,我们可以用现有的工具 sentry 去做这件事。

sentry 是一个用 python 写的性能和错误监控工具,你可以使用 sentry 提供的服务(免费功能少),也可以自己部署服务。现在来看一下如何使用 sentry 提供的服务实现监控。

注册账号

打开 https://sentry.io/signup/ 网站,进行注册。

选择项目,我选的 Vue。

安装 sentry 依赖

选完项目,下面会有具体的 sentry 依赖安装指南。

根据提示,在你的 Vue 项目执行这段代码 npm install --save @sentry/browser @sentry/integrations @sentry/tracing,安装 sentry 所需的依赖。

再将下面的代码拷到你的 main.js,放在 new Vue() 之前。

import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
import { Integrations } from "@sentry/tracing";

Sentry.init({
  dsn: "xxxxx", // 这里是你的 dsn 地址,注册完就有
  integrations: [
    new VueIntegration({
      Vue,
      tracing: true,
    }),
    new Integrations.BrowserTracing(),
  ],

  // We recommend adjusting this value in production, or using tracesSampler
  // for finer control
  tracesSampleRate: 1.0,
});

然后点击第一步中的 skip this onboarding,进入控制台页面。

如果忘了自己的 DSN,请点击左边的菜单栏选择 Settings -> Projects -> 点击自己的项目 -> Client Keys(DSN)

创建第一个错误

在你的 Vue 项目执行一个打印语句 console.log(b)

这时点开 sentry 主页的 issues 一项,可以发现有一个报错信息 b is not defined

这个报错信息包含了错误的具体信息,还有你的 IP、浏览器信息等等。

但奇怪的是,我们的浏览器控制台并没有输出报错信息。

这是因为被 sentry 屏蔽了,所以我们需要加上一个选项 logErrors: true

然后再查看页面,发现控制台也有报错信息了:

上传 sourcemap

一般打包后的代码都是经过压缩的,如果没有 sourcemap,即使有报错信息,你也很难根据提示找到对应的源码在哪。

下面来看一下如何上传 sourcemap。

首先创建 auth token。

这个生成的 token 一会要用到。

安装 sentry-cli@sentry/webpack-plugin

npm install sentry-cli-binary -g
npm install --save-dev @sentry/webpack-plugin

安装完上面两个插件后,在项目根目录创建一个 .sentryclirc 文件(不要忘了在 .gitignore 把这个文件添加上,以免暴露 token),内容如下:

[auth]
token=xxx

[defaults]
url=https://sentry.io/
org=woai3c
project=woai3c

把 xxx 替换成刚才生成的 token。

org 是你的组织名称。

project 是你的项目名称,根据下面的提示可以找到。

在项目下新建 vue.config.js 文件,把下面的内容填进去:

const SentryWebpackPlugin = require('@sentry/webpack-plugin')

const config = {
    configureWebpack: {
        plugins: [
            new SentryWebpackPlugin({
                include: './dist', // 打包后的目录
                ignore: ['node_modules', 'vue.config.js', 'babel.config.js'],
            }),
        ],
    },
}

// 只在生产环境下上传 sourcemap
module.exports = process.env.NODE_ENV == 'production'? config : {}

填完以后,执行 npm run build,就可以看到 sourcemap 的上传结果了。

我们再来看一下没上传 sourcemap 和上传之后的报错信息对比。

未上传 sourcemap

已上传 sourcemap

可以看到,上传 sourcemap 后的报错信息更加准确。

切换中文环境和时区

选完刷新即可。

性能监控

打开 performance 选项,就能看到你每个项目的运行情况。具体的参数解释请看文档 Performance Monitoring

性能优化

性能优化主要分为两类:

  1. 加载时优化
  2. 运行时优化

例如压缩文件、使用 CDN 就属于加载时优化;减少 DOM 操作,使用事件委托属于运行时优化。

在解决问题之前,必须先找出问题,否则无从下手。所以在做性能优化之前,最好先调查一下网站的加载性能和运行性能。

手动检查

检查加载性能

一个网站加载性能如何主要看白屏时间和首屏时间。

  • 白屏时间:指从输入网址,到页面开始显示内容的时间。
  • 首屏时间:指从输入网址,到页面完全渲染的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    new Date() - performance.timing.navigationStart
</script>

window.onload 事件里执行 new Date() - performance.timing.navigationStart 即可获取首屏时间。

检查运行性能

配合 chrome 的开发者工具,我们可以查看网站在运行时的性能。

打开网站,按 F12 选择 performance,点击左上角的灰色圆点,变成红色就代表开始记录了。这时可以模仿用户使用网站,在使用完毕后,点击 stop,然后你就能看到网站运行期间的性能报告。如果有红色的块,代表有掉帧的情况;如果是绿色,则代表 FPS 很好。

另外,在 performance 标签下,按 ESC 会弹出来一个小框。点击小框左边的三个点,把 rendering 勾出来。

这两个选项,第一个是高亮重绘区域,另一个是显示帧渲染信息。把这两个选项勾上,然后浏览网页,可以实时的看到你网页渲染变化。

利用工具检查

监控工具

可以部署一个前端监控系统来监控网站性能,上一节中讲到的 sentry 就属于这一类。

chrome 工具 Lighthouse

如果你安装了 Chrome 52+ 版本,请按 F12 打开开发者工具。

它不仅会对你网站的性能打分,还会对 SEO 打分。

使用 Lighthouse 审查网络应用

如何做性能优化

网上关于性能优化的文章和书籍多不胜数,但有很多优化规则已经过时了。所以我写了一篇性能优化文章前端性能优化 24 条建议(2020),分析总结出了 24 条性能优化建议,强烈推荐。

重构

《重构2》一书中对重构进行了定义:

所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减小整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。

重构和性能优化有相同点,也有不同点。

相同的地方是它们都在不改变程序功能的情况下修改代码;不同的地方是重构为了让代码变得更加易读、理解,性能优化则是为了让程序运行得更快。

重构可以一边写代码一边重构,也可以在程序写完后,拿出一段时间专门去做重构。没有说哪个方式更好,视个人情况而定。

如果你专门拿一段时间来做重构,建议你在重构一段代码后,立即进行测试。这样可以避免修改代码太多,在出错时找不到错误点。

重构的原则

  1. 事不过三,三则重构。即不能重复写同样的代码,在这种情况下要去重构。
  2. 如果一段代码让人很难看懂,那就该考虑重构了。
  3. 如果已经理解了代码,但是非常繁琐或者不够好,也可以重构。
  4. 过长的函数,需要重构。
  5. 一个函数最好对应一个功能,如果一个函数被塞入多个功能,那就要对它进行重构了。

重构手法

《重构2》这本书中,介绍了多达上百个重构手法。但我觉得有两个是比较常用的:

  1. 提取重复代码,封装成函数
  2. 拆分太长或功能太多的函数

提取重复代码,封装成函数

假设有一个查询数据的接口 /getUserData?age=17&city=beijing。现在需要做的是把用户数据:{ age: 17, city: 'beijing' } 转成 URL 参数的形式:

let result = ''
const keys = Object.keys(data)  // { age: 17, city: 'beijing' }
keys.forEach(key => {
    result += '&' + key + '=' + data[key]
})

result.substr(1) // age=17&city=beijing

如果只有这一个接口需要转换,不封装成函数是没问题的。但如果有多个接口都有这种需求,那就得把它封装成函数了:

function JSON2Params(data) {
    let result = ''
    const keys = Object.keys(data)
    keys.forEach(key => {
        result += '&' + key + '=' + data[key]
    })

    return result.substr(1)
}

拆分太长或功能太多的函数

假设现在有一个注册功能,用伪代码表示:

function register(data) {
    // 1. 验证用户数据是否合法
    /**
     * 验证账号
     * 验证密码
     * 验证短信验证码
     * 验证身份证
     * 验证邮箱
     */

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    /**
     * 新建 FileReader 对象
     * 将图片转换成 base64 码
     */

    // 3. 调用注册接口
    // ...
}

这个函数包含了三个功能,验证、转换、注册。其中验证和转换功能是可以提取出来单独封装成函数的:

function register(data) {
    // 1. 验证用户数据是否合法
    // verify()

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    // tobase64()

    // 3. 调用注册接口
    // ...
}

如果你对重构有兴趣,强烈推荐你阅读《重构2》这本书。

参考资料:

总结

写这篇文章主要是为了对我这一年多工作经验作总结,因为我基本上都在研究前端工程化以及如何提升团队的开发效率。希望这篇文章能帮助一些对前端工程化没有经验的新手,通过这篇文章入门前端工程化。

如果这篇文章对你有帮助,请点一下赞,感激不尽。

查看原文

赞 123 收藏 79 评论 21

谭光志 发布了文章 · 11月2日

手把手教你搭建 Vue 服务端渲染项目

建议先阅读官方指南——Vue.js 服务器端渲染指南,再回到本文开始阅读。

本文将分成以下两部分:

  1. 简述 Vue SSR 过程
  2. 从零开始搭建 SSR 项目

好了,下面开始正文。

简述 Vue SSR 过程

客户端渲染过程

  1. 访问客户端渲染的网站。
  2. 服务器返回一个包含了引入资源语句和 <div id="app"></div> 的 HTML 文件。
  3. 客户端通过 HTTP 向服务器请求资源,当必要的资源都加载完毕后,执行 new Vue() 开始实例化并渲染页面。

服务端渲染过程

  1. 访问服务端渲染的网站。
  2. 服务器会查看当前路由组件需要哪些资源文件,然后将这些文件的内容填充到 HTML 文件。如果有 asyncData() 函数,就会执行它进行数据预取并填充到 HTML 文件里,最后返回这个 HTML 页面。
  3. 当客户端接收到这个 HTML 页面时,可以马上就开始渲染页面。与此同时,页面也会加载资源,当必要的资源都加载完毕后,开始执行 new Vue() 开始实例化并接管页面。

从上述两个过程中,可以看出,区别就在于第二步。客户端渲染的网站会直接返回 HTML 文件,而服务端渲染的网站则会渲染完页面再返回这个 HTML 文件。

这样做的好处是什么?是更快的内容到达时间 (time-to-content)

假设你的网站需要加载完 abcd 四个文件才能渲染完毕。并且每个文件大小为 1 M。

这样一算:客户端渲染的网站需要加载 4 个文件和 HTML 文件才能完成首页渲染,总计大小为 4M(忽略 HTML 文件大小)。而服务端渲染的网站只需要加载一个渲染完毕的 HTML 文件就能完成首页渲染,总计大小为已经渲染完毕的 HTML 文件(这种文件不会太大,一般为几百K,我的个人博客网站(SSR)加载的 HTML 文件为 400K)。这就是服务端渲染更快的原因

客户端接管页面

对于服务端返回来的 HTML 文件,客户端必须进行接管,对其进行 new Vue() 实例化,用户才能正常使用页面。

如果不对其进行激活的话,里面的内容只是一串字符串而已,例如下面的代码,点击是无效的:

<button @click="sayHi">如果不进行激活,点我是不会触发事件的</button>

那客户端如何接管页面呢?下面引用一篇文章中的内容:

客户端 new Vue() 时,客户端会和服务端生成的DOM进行Hydration对比(判断这个DOM和自己即将生成的DOM是否相同(vuex store 数据同步才能保持一致)

如果相同就调用app.$mount('#app')将客户端的vue实例挂载到这个DOM上,即去“激活”这些服务端渲染的HTML之后,其变成了由Vue动态管理的DOM,以便响应后续数据的变化,即之后所有的交互和vue-router不同页面之间的跳转将全部在浏览器端运行。

如果客户端构建的虚拟 DOM 树与服务器渲染返回的HTML结构不一致,这时候,客户端会请求一次服务器再渲染整个应用程序,这使得ssr失效了,达不到服务端渲染的目的了

小结

不管是客户端渲染还是服务端渲染,都需要等待客户端执行 new Vue() 之后,用户才能进行交互操作。但服务端渲染的网站能让用户更快的看见页面

从零开始搭建 SSR 项目

配置 weback

webpack 配置文件共有 3 个:

  1. webpack.base.config.js,基础配置文件,客户端与服务端都需要它。
  2. webpack.client.config.js,客户端配置文件,用于生成客户端所需的资源。
  3. webpack.server.config.js,服务端配置文件,用于生成服务端所需的资源。

webpack.base.config.js 基础配置文件

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const isProd = process.env.NODE_ENV === 'production'

function resolve(dir) {
    return path.join(__dirname, '..', dir)
}

module.exports = {
    context: path.resolve(__dirname, '../'),
    devtool: isProd ? 'source-map' : '#cheap-module-source-map',
    output: {
        path: path.resolve(__dirname, '../dist'),
        publicPath: '/dist/',
        // chunkhash 同属一个 chunk 中的文件修改了,文件名会发生变化 
        // contenthash 只有文件自己的内容变化了,文件名才会变化
        filename: '[name].[contenthash].js',
        // 此选项给打包后的非入口js文件命名,与 SplitChunksPlugin 配合使用
        chunkFilename: '[name].[contenthash].js',
    },
    resolve: {
        extensions: ['.js', '.vue', '.json', '.css'],
        alias: {
            public: resolve('public'),
            '@': resolve('src')
        }
    },
    module: {
        // https://juejin.im/post/6844903689103081485
        // 使用 `mini-css-extract-plugin` 插件打包的的 `server bundle` 会使用到 document。
        // 由于 node 环境中不存在 document 对象,所以报错。
        // 解决方案:样式相关的 loader 不要放在 `webpack.base.config.js` 文件
        // 将其分拆到 `webpack.client.config.js` 和 `webpack.client.server.js` 文件
        // 其中 `mini-css-extract-plugin` 插件要放在 `webpack.client.config.js` 文件配置。
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader',
                options: {
                    compilerOptions: {
                        preserveWhitespace: false
                    }
                }
            },
            {
                test: /\.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/
            },
            {
                test: /\.(png|svg|jpg|gif|ico)$/,
                use: ['file-loader']
            },
            {
                test: /\.(woff|eot|ttf)\??.*$/,
                loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'
            },
        ]
    },
    plugins: [new VueLoaderPlugin()],
}

基础配置文件比较简单,output 属性的意思是打包时根据文件内容生成文件名称。module 属性配置不同文件的解析 loader。

webpack.client.config.js 客户端配置文件

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const CompressionPlugin = require('compression-webpack-plugin')
const WebpackBar = require('webpackbar')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

const isProd = process.env.NODE_ENV === 'production'

const plugins = [
    new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(
            process.env.NODE_ENV || 'development'
        ),
        'process.env.VUE_ENV': '"client"'
    }),
    new VueSSRClientPlugin(),
    new MiniCssExtractPlugin({
        filename: 'style.css'
    })
]

if (isProd) {
    plugins.push(
        // 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
        new CompressionPlugin(),
        // 该插件会根据模块的相对路径生成一个四位数的hash作为模块id, 用于生产环境。
        new webpack.HashedModuleIdsPlugin(),
        new WebpackBar(),
    )
}

const config = {
    entry: {
        app: './src/entry-client.js'
    },
    plugins,
    optimization: {
        runtimeChunk: {
            name: 'manifest'
        },
        splitChunks: {
            cacheGroups: {
                vendor: {
                    name: 'chunk-vendors',
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10,
                    chunks: 'initial',
                },
                common: {
                    name: 'chunk-common',
                    minChunks: 2,
                    priority: -20,
                    chunks: 'initial',
                    reuseExistingChunk: true
                }
            },
        }
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            // 解决 export 'default' (imported as 'mod') was not found
                            // 启用 CommonJS 语法
                            esModule: false,
                        },
                    },
                    'css-loader'
                ]
            }
        ]
    },
}

if (isProd) {
    // 压缩 css
    config.optimization.minimizer = [
        new CssMinimizerPlugin(),
    ]
}

module.exports = merge(base, config)

客户端配置文件中的 config.optimization 属性是打包时分割代码用的。它的作用是将第三方库都打包在一起。

其他插件作用:

  1. MiniCssExtractPlugin 插件, 将 css 提取出来单独打包。
  2. CssMinimizerPlugin 插件,压缩 css。
  3. CompressionPlugin 插件,将资源压缩成 gzip 格式(大大提升传输效率)。另外还需要在 node 服务器上引入 compression 插件配合使用。
  4. WebpackBar 插件,打包时显示进度条。

webpack.server.config.js 服务端配置文件

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled.
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const WebpackBar = require('webpackbar')

const plugins = [
    new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
        'process.env.VUE_ENV': '"server"'
    }),
    new VueSSRServerPlugin()
]

if (process.env.NODE_ENV == 'production') {
    plugins.push(
        new WebpackBar()
    )
}

module.exports = merge(base, {
    target: 'node',
    devtool: '#source-map',
    entry: './src/entry-server.js',
    output: {
        filename: 'server-bundle.js',
        libraryTarget: 'commonjs2'
    },
    externals: nodeExternals({
        allowlist: /\.css$/ // 防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖
    }),
    plugins,
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    'vue-style-loader',
                    'css-loader'
                ]
            }
        ]
    },
})

服务端打包和客户端不同,它将所有文件一起打包成一个文件 server-bundle.js。同时解析 css 需要使用 vue-style-loader,这一点在官方指南中有说明:

配置服务器

生产环境

pro-server.js 生产环境服务器配置文件

const fs = require('fs')
const path = require('path')
const express = require('express')
const setApi = require('./api')
const LRU = require('lru-cache') // 缓存
const { createBundleRenderer } = require('vue-server-renderer')
const favicon = require('serve-favicon')
const resolve = file => path.resolve(__dirname, file)

const app = express()
// 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
const compression = require('compression')
app.use(compression())
// 设置 favicon
app.use(favicon(resolve('../public/favicon.ico')))

// 新版本 需要加 new,旧版本不用
const microCache = new LRU({
    max: 100,
    maxAge: 60 * 60 * 24 * 1000 // 重要提示:缓存资源将在 1 天后过期。
})

const serve = (path) => {
    return express.static(resolve(path), {
        maxAge: 1000 * 60 * 60 * 24 * 30
    })
}

app.use('/dist', serve('../dist', true))

function createRenderer(bundle, options) {
    return createBundleRenderer(
        bundle,
        Object.assign(options, {
            basedir: resolve('../dist'),
            runInNewContext: false
        })
    )
}

function render(req, res) {
    const hit = microCache.get(req.url)
    if (hit) {
        console.log('Response from cache')
        return res.end(hit)
    }

    res.setHeader('Content-Type', 'text/html')

    const handleError = err => {
        if (err.url) {
            res.redirect(err.url)
        } else if (err.code === 404) {
            res.status(404).send('404 | Page Not Found')
        } else {
            res.status(500).send('500 | Internal Server Error~')
            console.log(err)
        }
    }

    const context = {
        title: 'SSR 测试', // default title
        url: req.url
    }

    renderer.renderToString(context, (err, html) => {
        if (err) {
            return handleError(err)
        }

        microCache.set(req.url, html)
        res.send(html)
    })
}

const templatePath = resolve('../public/index.template.html')
const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('../dist/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/vue-ssr-client-manifest.json') // 将js文件注入到页面中
const renderer = createRenderer(bundle, {
    template,
    clientManifest
})

const port = 8080

app.listen(port, () => {
    console.log(`server started at localhost:${ port }`)
})

setApi(app)

app.get('*', render)

从代码中可以看到,当首次加载页面时,需要调用 createBundleRenderer() 生成一个 renderer,它的参数是打包生成的 vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json 文件。当返回 HTML 文件后,页面将会被客户端接管。

在文件的最后有一行代码 app.get('*', render),它表示所有匹配不到的请求都交给它处理。所以如果你写了 ajax 请求处理函数必须放在前面,就像下面这样:

app.get('/fetchData', (req, res) => { ... })
app.post('/changeData', (req, res) => { ... })
app.get('*', render)

否则你的页面会打不开。

开发环境

开发环境的服务器配置和生产环境没什么不同,区别在于开发环境下的服务器有热更新。

一般用 webpack 进行开发时,简单的配置一下 dev server 参数就可以使用热更新了,但是 SSR 项目需要自己配置。

由于 SSR 开发环境服务器的配置文件 setup-dev-server.js 代码太多,我对其进行简化后,大致代码如下:

// dev-server.js
const express = require('express')
const webpack = require('webpack')
const webpackConfig = require('../build/webpack.dev') // 获取 webpack 配置文件
const compiler = webpack(webpackConfig)
const app = express()

app.use(require('webpack-hot-middleware')(compiler))
app.use(require('webpack-dev-middleware')(compiler, {
    noInfo: true,
    stats: {
        colors: true
    }
}))

同时需要在 webpack 的入口文件加上这一行代码 webpack-hot-middleware/client?reload=true

// webpack.dev.js
const merge = require('webpack-merge')
const webpackBaseConfig = require('./webpack.base.config.js') // 这个配置和热更新无关,可忽略

module.exports = merge(webpackBaseConfig, {
    mode: 'development',
    entry: {
        app: ['webpack-hot-middleware/client?reload=true' , './client/main.js'] // 开启热模块更新
    },
    plugins: [new webpack.HotModuleReplacementPlugin()]
})

然后使用 node dev-server.js 来开启前端代码热更新。

热更新主要使用了两个插件:webpack-dev-middlewarewebpack-hot-middleware。顾名思义,看名称就知道它们的作用,

webpack-dev-middleware 的作用是生成一个与 webpack 的 compiler 绑定的中间件,然后在 express 启动的 app 中调用这个中间件。

这个中间件的作用呢,简单总结为以下三点:通过watch mode,监听资源的变更,然后自动打包; 快速编译,走内存;返回中间件,支持express 的 use 格式。

webpack-hot-middleware 插件的作用就是热更新,它需要配合 HotModuleReplacementPluginwebpack-dev-middleware 一起使用。

打包文件 vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json

webpack 需要对源码打包两次,一次是为客户端环境打包的,一次是为服务端环境打包的。

为客户端环境打包的文件,和以前我们打包的资源一样,不过多出了一个 vue-ssr-client-manifest.json 文件。服务端环境打包只输出一个 vue-ssr-server-bundle.json 文件。

vue-ssr-client-manifest.json 包含了客户端环境所需的资源名称:

从上图中可以看到有三个关键词:

  1. all,表示这是打包的所有资源。
  2. initial,表示首页加载必须的资源。
  3. async,表示需要异步加载的资源。

vue-ssr-server-bundle.json 文件:

  1. entry, 服务端入口文件。
  2. files,服务端依赖的资源。

填坑记录

1. [vue-router] failed to resolve async component default: referenceerror: window is not defined

由于在一些文件或第三方文件中可能会用到 window 对象,并且 node 中不存在 window 对象,所以会报错。
此时可在 src/app.js 文件加上以下代码进行判断:

// 在 app.js 文件添加上这段代码,对环境进行判断
if (typeof window === 'undefined') {
    global.window = {}
}

2. mini-css-extract-plugin 插件造成 ReferenceError: document is not defined

使用 mini-css-extract-plugin 插件打包的的 server bundle, 会使用到 document。由于 node 环境中不存在 document 对象,所以报错。

解决方案:样式相关的 loader 不要放在 webpack.base.config.js 文件,将其分拆到 webpack.client.config.jswebpack.client.server.js 文件。其中 mini-css-extract-plugin 插件要放在 webpack.client.config.js 文件配置。

base

module: {
    rules: [
        {
            test: /\.vue$/,
            loader: 'vue-loader',
            options: {
                compilerOptions: {
                    preserveWhitespace: false
                }
            }
        },
        {
            test: /\.js$/,
            loader: 'babel-loader',
            exclude: /node_modules/
        },
        {
            test: /\.(png|svg|jpg|gif|ico)$/,
            use: ['file-loader']
        },
        {
            test: /\.(woff|eot|ttf)\??.*$/,
            loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'
        },
    ]
}

client

module: {
    rules: [
        {
            test: /\.css$/,
            use: [
                {
                    loader: MiniCssExtractPlugin.loader,
                    options: {
                        // 解决 export 'default' (imported as 'mod') was not found
                        esModule: false,
                    },
                },
                'css-loader'
            ]
        }
    ]
}

server

module: {
    rules: [
        {
            test: /\.css$/,
            use: [
                'vue-style-loader',
                'css-loader'
            ]
        }
    ]
}

3. 开发环境下跳转页面样式不生效,但生产环境正常。

由于开发环境使用的是 memory-fs 插件,打包文件是放在内存中的。如果此时 dist 文件夹有刚才打包留下的资源,就会使用 dist 文件夹中的资源,而不是内存中的资源。并且开发环境和打包环境生成的资源名称是不一样的,所以就造成了这个 BUG。

解决方法是执行 npm run dev 时,删除 dist 文件夹。所以要在 npm run dev 对应的脚本中加上 rimraf dist

"dev": "rimraf dist && node ./server/dev-server.js --mode development",

4. [vue-router] Failed to resolve async component default: ReferenceError: document is not defined

不要在有可能使用到服务端渲染的页面访问 DOM,如果有这种操作请放在 mounted() 钩子函数里。

如果你引入的数据或者接口有访问 DOM 的操作也会报这种错,在这种情况下可以使用 require()。因为 require() 是运行时加载的,所以可以这样使用:

<script>
// 原来报错的操作,这个接口有 DOM 操作,所以这样使用的时候在服务端会报错。
import { fetchArticles } from '@/api/client'

export default {
  methods: {
    getAppointArticles() {
      fetchArticles({
        tags: this.tags,
        pageSize: this.pageSize,
        pageIndex: this.pageIndex,
      })
      .then(res => {
          this.$store.commit('setArticles', res)
      })
    },
  }
}
</script>

修改后:

<script>
// 先定义一个外部变量,在 mounted() 钩子里赋值
let fetchArticles
export default {
  mounted() {
    // 由于服务端渲染不会有 mounted() 钩子,所以在这里可以保证是在客户端的情况下引入接口
      fetchArticles = require('@/api/client').fetchArticles
  },
  methods: {
    getAppointArticles() {
      fetchArticles({
        tags: this.tags,
        pageSize: this.pageSize,
        pageIndex: this.pageIndex,
      })
      .then(res => {
          this.$store.commit('setArticles', res)
      })
    },
  }
}
</script>

修改后可以正常使用。

5. 开发环境下,开启服务器后无任何反应,也没见控制台输出报错信息。

这个坑其实是有报错信息的,但是没有输出,导致以为没有错误。

setup-dev-server.js 文件中有一行代码 if (stats.errors.length) return,如果有报错就直接返回,不执行后续的操作。导致服务器没任何反应,所以我们可以在这打一个 console.log 语句,打印报错信息。

小结

这个 DEMO 是基于官方 DEMO vue-hackernews-2.0 改造的。不过官方 DEMO 发表于 4 年前,最近修改时间是 2 年前,很多选项参数已经过时了。并且官方 DEMO 需要翻墙才能使用。所以我在此基础上对其进行了改造,改造后的 DEMO 放在 Github 上,它是一个比较完善的 DEMO,可以在此基础上进行二次开发。

如果你不仅仅满足于一个 DEMO,建议看一看我的个人博客项目,它原来是客户端渲染的项目,后来重构为服务端渲染,绝对实战。

参考资料

更多文章,敬请关注

查看原文

赞 27 收藏 24 评论 4

谭光志 回答了问题 · 11月2日

为什么服务端渲染的网站(SPA),当路由改变时,网站源码也会改变?

这个问题困扰我好几天了,找不到答案。没想到提完问题后,又捣鼓了一会,突然找到答案了...

由于问题无法删除,所以在这补充一下答案,算是自问自答。

其实之前的理解是错的,因为在网页点击查看源码其实是发起一个新的请求去请求页面。所以每次看到的源码都是页面原本的样子。

所以就不难理解为什么我从 a 路由切到 b 路由,再查看源码看到的是 b 路由页面的源码。

关注 3 回答 2

谭光志 提出了问题 · 11月1日

为什么服务端渲染的网站(SPA),当路由改变时,网站源码也会改变?

客户端渲染的网站,当路由改变时,网站源码(在浏览器上查看源码)是不会随着页面变化而改变的。

为什么服务端渲染的网站却不是这样呢?

第一次访问服务端渲染的网站时,服务器返回来一个已经渲染好的页面。当页面被客户端接管后,为什么路由改变,网站源码还是会改变?这种情况下应该是客户端渲染页面,按理说源码应该和刚接收到 HTML 时的源码一样 。

我 google 了一下,也没找到答案(也许是我搜索的关键词不对)。

请问有没有懂的大佬,帮忙解答一下,感谢!

关注 3 回答 2

谭光志 发布了文章 · 10月26日

前端项目自动化部署——超详细教程(Jenkins、Github Actions)

本教程主要讲解了怎么使用 Jenkins 和 Github Actions 部署前端项目。

  1. 第一部分是使用 Gitea 配置局域网 git 服务器,再使用 Jenkins 将 Gitea 下的项目部署到局域网服务器。
  2. 第二部分是使用 Github Actions 将 Github 项目部署到 Github Page 和阿里云。

阅读本教程并不需要你提前了解 Jenkins 和 Github Actions 的知识,只要按照本教程的指引,就能够实现自动化部署项目。

PS:本人所用电脑操作系统为 windows,即以下所有的操作均在 windows 下运行。其他操作系统的配置大同小异,不会有太大差别。

Gitea + Jenkins 自动构建前端项目并部署到服务器

Gitea 用于构建 Git 局域网服务器,Jenkins 是 CI/CD 工具,用于部署前端项目。

配置 Gitea

  1. 下载 Gitea,选择一个喜欢的版本,例如 1.13,选择 gitea-1.13-windows-4.0-amd64.exe 下载。
  2. 下载完后,新建一个目录(例如 gitea),将下载的 Gitea 软件放到该目录下,双击运行。
  3. 打开 localhost:3000 就能看到 Gitea 已经运行在你的电脑上了。
  4. 点击注册,第一次会弹出一个初始配置页面,数据库选择 SQLite3。另外把 localhost 改成你电脑的局域网地址,例如我的电脑 IP 为 192.168.0.118

在这里插入图片描述
在这里插入图片描述

  1. 填完信息后,点击立即安装,等待一会,即可完成配置。
  2. 继续点击注册用户,第一个注册的用户将会成会管理员。
  3. 打开 Gitea 的安装目录,找到 custom\conf\app.ini,在里面加上一行代码 START_SSH_SERVER = true。这时就可以使用 ssh 进行 push 操作了。

在这里插入图片描述

  1. 如果使用 http 的方式无法克隆项目,请取消 git 代理。
git config --global --unset http.proxy
git config --global --unset https.proxy

配置 Jenkins

  1. 需要提前安装 JDK,JDK 安装教程网上很多,请自行搜索。
  2. 打开 Jenkins 下载页面。

在这里插入图片描述

  1. 安装过程中遇到 Logon Type 时,选择第一个。

在这里插入图片描述

  1. 端口默认为 8080,这里我填的是 8000。安装完会自动打开 http://localhost:8000 网站,这时需要等待一会,进行初始化。
  2. 按照提示找到对应的文件(直接复制路径在我的电脑中打开),其中有管理员密码。

在这里插入图片描述

  1. 安装插件,选择第一个。

在这里插入图片描述

  1. 创建管理员用户,点击完成并保存,然后一路下一步。

在这里插入图片描述

  1. 配置完成后自动进入首页,这时点击 Manage Jenkins -> Manage plugins 安装插件。

在这里插入图片描述

  1. 点击 可选插件,输入 nodejs,搜索插件,然后安装。
  2. 安装完成后回到首页,点击 Manage Jenkins -> Global Tool Configuration 配置 nodejs。如果你的电脑是 win7 的话,nodejs 版本最好不要太高,选择 v12 左右的就行。

在这里插入图片描述

创建静态服务器

  1. 建立一个空目录,在里面执行 npm init -y,初始化项目。
  2. 执行 npm i express 下载 express。
  3. 然后建立一个 server.js 文件,代码如下:
const express = require('express')
const app = express()
const port = 8080

app.use(express.static('dist'))

app.listen(port, () => {
    console.log(`Example app listening at http://localhost:${port}`)
})

它将当前目录下的 dist 文件夹设为静态服务器资源目录,然后执行 node server.js 启动服务器。

由于现在没有 dist 文件夹,所以访问网站是空页面。
在这里插入图片描述
不过不要着急,一会就能看到内容了。

自动构建 + 部署到服务器

  1. 下载 Jenkins 提供的 demo 项目 building-a-multibranch-pipeline-project,然后在你的 Gitea 新建一个仓库,把内容克隆进去,并提交到 Gitea 服务器。

在这里插入图片描述

  1. 打开 Jenkins 首页,点击 新建 Item 创建项目。

在这里插入图片描述

  1. 选择源码管理,输入你的 Gitea 上的仓库地址。

在这里插入图片描述

  1. 你也可以尝试一下定时构建,下面这个代码表示每 5 分钟构建一次。

在这里插入图片描述

  1. 选择你的构建环境,这里选择刚才配置的 nodejs。

在这里插入图片描述

  1. 点击增加构建步骤,windows 要选 execute windows batch command,linux 要选 execute shell

  1. 输入 npm i && npm run build && xcopy .\build\* G:\node-server\dist\ /s/e/y,这行命令的作用是安装依赖,构建项目,并将构建后的静态资源复制到指定目录 G:\node-server\dist\ 。这个目录是静态服务器资源目录。

在这里插入图片描述

  1. 保存后,返回首页。点击项目旁边的小三角,选择 build now

在这里插入图片描述

  1. 开始构建项目,我们可以点击项目查看构建过程。

在这里插入图片描述

  1. 构建成功,打开 http://localhost:8080/ 看一下结果。

在这里插入图片描述
在这里插入图片描述

  1. 由于刚才设置了每 5 分钟构建一次,我们可以改变一下网站的内容,然后什么都不做,等待一会再打开网站看看。

在这里插入图片描述

  1. 把修改的内容提交到 Gitea 服务器,稍等一会。打开网站,发现内容已经发生了变化。

在这里插入图片描述

使用 pipeline 构建项目

使用流水线构建项目可以结合 Gitea 的 webhook 钩子,以便在执行 git push 的时候,自动构建项目。

  1. 点击首页右上角的用户名,选择设置

在这里插入图片描述

  1. 添加 token,记得将 token 保存起来。

在这里插入图片描述

  1. 打开 Jenkins 首页,点击 新建 Item 创建项目。

在这里插入图片描述

  1. 点击构建触发器,选择触发远程构建,填入刚才创建的 token。

在这里插入图片描述

  1. 选择流水线,按照提示输入内容,然后点击保存

在这里插入图片描述

  1. 打开 Jenkins 安装目录下的 jenkins.xml 文件,找到 <arguments> 标签,在里面加上 -Dhudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION=true。它的作用是关闭 CSRF 验证,不关的话,Gitea 的 webhook 会一直报 403 错误,无法使用。加好参数后,在该目录命令行下输入 jenkins.exe restart 重启 Jenkins。

在这里插入图片描述

  1. 回到首页,配置全局安全选项。勾上匿名用户具有可读权限,再保存。

在这里插入图片描述
在这里插入图片描述

  1. 打开你的 Gitea 仓库页面,选择仓库设置

在这里插入图片描述

  1. 点击管理 web 钩子,添加 web 钩子,钩子选项选择 Gitea
  2. 目标 URL 按照 Jenkins 的提示输入内容。然后点击添加 web 钩子

在这里插入图片描述
在这里插入图片描述

  1. 点击创建好的 web 钩子,拉到下方,点击测试推送。不出意外,应该能看到推送成功的消息,此时回到 Jenkins 首页,发现已经在构建项目了。

在这里插入图片描述

  1. 由于没有配置 Jenkinsfile 文件,此时构建是不会成功的。所以接下来需要配置一下 Jenkinsfile 文件。将以下代码复制到你 Gitea 项目下的 Jenkinsfile 文件。jenkins 在构建时会自动读取文件的内容执行构建及部署操作。
pipeline {
    agent any
    stages {
        stage('Build') {
            steps {  // window 使用 bat, linux 使用 sh
                bat 'npm i'
                bat 'npm run build'
            }
        }
        stage('Deploy') {
            steps {
                bat 'xcopy .\\build\\* D:\\node-server\\dist\\ /s/e/y' // 这里需要改成你的静态服务器资源目录
            }
        }
    }
}
  1. 每当你的 Gitea 项目执行 push 操作时,Gitea 都会通过 webhook 发送一个 post 请求给 Jenkins,让它执行构建及部署操作。

在这里插入图片描述

小结

如果你的操作系统是 Linux,可以在 Jenkins 打包完成后,使用 ssh 远程登录到阿里云,将打包后的文件复制到阿里云上的静态服务器上,这样就能实现阿里云自动部署了。具体怎么远程登录到阿里云,请看下文中的 《Github Actions 部署到阿里云》 一节。

Github Actions 自动构建前端项目并部署到服务器

如果你的项目是 Github 项目,那么使用 Github Actions 也许是更好的选择。

部署到 Github Page

接下来看一下如何使用 Github Actions 部署到 Github Page。

在你需要部署到 Github Page 的项目下,建立一个 yml 文件,放在 .github/workflow 目录下。你可以命名为 ci.yml,它类似于 Jenkins 的 Jenkinsfile 文件,里面包含的是要自动执行的脚本代码。

这个 yml 文件的内容如下:

name: Build and Deploy
on: # 监听 master 分支上的 push 事件
  push:
    branches:
      - master
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest # 构建环境使用 ubuntu
    steps:
      - name: Checkout
        uses: actions/checkout@v2.3.1  
        with:
          persist-credentials: false

      - name: Install and Build # 下载依赖 打包项目
        run: |
          npm install
          npm run build

      - name: Deploy # 将打包内容发布到 github page
        uses: JamesIves/github-pages-deploy-action@3.5.9 # 使用别人写好的 actions
        with:  # 自定义环境变量
          ACCESS_TOKEN: ${{ secrets.VUE_ADMIN_TEMPLATE }} # VUE_ADMIN_TEMPLATE 是我的 secret 名称,需要替换成你的
          BRANCH: master
          FOLDER: dist
          REPOSITORY_NAME: woai3c/woai3c.github.io # 这是我的 github page 仓库
          TARGET_FOLDER: github-actions-demo # 打包的文件将放到静态服务器 github-actions-demo 目录下

上面有一个 ACCESS_TOKEN 变量需要自己配置。

  1. 打开 Github 网站,点击你右上角的头像,选择 settings

在这里插入图片描述

  1. 点击左下角的 developer settings

在这里插入图片描述

  1. 在左侧边栏中,单击 Personal access tokens(个人访问令牌)

在这里插入图片描述

  1. 单击 Generate new token(生成新令牌)

在这里插入图片描述

  1. 输入名称并勾选 repo

在这里插入图片描述

  1. 拉到最下面,点击 Generate token,并将生成的 token 保存起来。

在这里插入图片描述

  1. 打开你的 Github 项目,点击 settings

在这里插入图片描述
点击 secrets->new secret
在这里插入图片描述
创建一个密钥,名称随便填(中间用下划线隔开),内容填入刚才创建的 token。
在这里插入图片描述

在这里插入图片描述
将上文代码中的 ACCESS_TOKEN: ${{ secrets.VUE_ADMIN_TEMPLATE }} 替换成刚才创建的 secret 名字,替换后代码如下 ACCESS_TOKEN: ${{ secrets.TEST_A_B }}。保存后,提交到 Github。

以后你的项目只要执行 git push,Github Actions 就会自动构建项目并发布到你的 Github Page 上。

Github Actions 的执行详情点击仓库中的 Actions 选项查看。

在这里插入图片描述
在这里插入图片描述
具体详情可以参考一下我的 demo 项目 github-actions-demo

构建成功后,打开 Github Page 网站,可以发现内容已经发布成功。

在这里插入图片描述

Github Actions 部署到阿里云

初始化阿里云服务器

  1. 购买阿里云服务器,选择操作系统,我选的 ubuntu
  2. 在云服务器管理控制台选择实例->更多->密钥->重置实例密码(一会登陆用)
  3. 选择远程连接->VNC,会弹出一个密码,记住它,以后远程连接要用(ctrl + alt + f1~f6 切换终端,例如 ctrl + alt + f1 是第一个终端)
  4. 进入后是一个命令行 输入 root(默认用户名),密码为你刚才重置的实例密码
  5. 登陆成功, 更新安装源 sudo apt-get update && sudo apt-get upgrade -y
  6. 安装 npm sudo apt-get install npm
  7. 安装 npm 管理包 sudo npm install -g n
  8. 安装 node 最新稳定版 sudo n stable

创建一个静态服务器

mkdir node-server // 创建 node-server 文件夹
cd node-server // 进入 node-server 文件夹
npm init -y // 初始化项目
npm i express
touch server.js // 创建 server.js 文件
vim server.js // 编辑 server.js 文件

将以下代码输入进去(用 vim 进入文件后按 i 进行编辑,保存时按 esc 然后输入 :wq,再按 enter),更多使用方法请自行搜索。

const express = require('express')
const app = express()
const port = 3388 // 填入自己的阿里云映射端口,在网络安全组配置。

app.use(express.static('dist'))

app.listen(port, '0.0.0.0', () => {
    console.log(`listening`)
})

执行 node server.js 开始监听,由于暂时没有 dist 目录,先不要着急。

注意,监听 IP 必须为 0.0.0.0 ,详情请看部署Node.js项目注意事项

阿里云入端口要在网络安全组中查看与配置。

在这里插入图片描述

创建阿里云密钥对

请参考创建SSH密钥对绑定SSH密钥对 ,将你的 ECS 服务器实例和密钥绑定,然后将私钥保存到你的电脑(例如保存在 ecs.pem 文件)。

打开你要部署到阿里云的 Github 项目,点击 setting->secrets。

在这里插入图片描述
点击 new secret
在这里插入图片描述
secret 名称为 SERVER_SSH_KEY,并将刚才的阿里云密钥填入内容。

在这里插入图片描述
点击 add secret 完成。

在你项目下建立 .github\workflows\ci.yml 文件,填入以下内容:

name: Build app and deploy to aliyun
on:
  #监听push操作
  push:
    branches:
      # master分支,你也可以改成其他分支
      - master
jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v1
    - name: Install Node.js
      uses: actions/setup-node@v1
      with:
        node-version: '12.16.2'
    - name: Install npm dependencies
      run: npm install
    - name: Run build task
      run: npm run build
    - name: Deploy to Server
      uses: easingthemes/ssh-deploy@v2.1.5
      env:
          SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
          ARGS: '-rltgoDzvO --delete'
          SOURCE: dist # 这是要复制到阿里云静态服务器的文件夹名称
          REMOTE_HOST: '118.190.217.8' # 你的阿里云公网地址
          REMOTE_USER: root # 阿里云登录后默认为 root 用户,并且所在文件夹为 root
          TARGET: /root/node-server # 打包后的 dist 文件夹将放在 /root/node-server

保存,推送到 Github 上。

以后只要你的项目执行 git push 操作,就会自动执行 ci.yml 定义的脚本,将打包文件放到你的阿里云静态服务器上。

这个 Actions 主要做了两件事:

  1. 克隆你的项目,下载依赖,打包。
  2. 用你的阿里云私钥以 SSH 的方式登录到阿里云,把打包的文件上传(使用 rsync)到阿里云指定的文件夹中。

如果还是不懂,建议看一下我的 demo

ci.yml 配置文件讲解

  1. name,表示这个工作流程(workflow)的名称。
  2. on,表示监听的意思,后面可以加上各种事件,例如 push 事件。

下面这段代码表示要监听 master 分支的 push 事件。当 Github Actions 监听到 push 事件发生时,它就会执行下面 jobs 定义的一系列操作。

name: Build app and deploy to aliyun
on:
  #监听push操作
  push:
    branches:
      # master分支,你也可以改成其他分支
      - master
jobs:
...
  1. jobs,看字面意思就是一系列的作业,你可以在 jobs 字段下面定义很多作业,例如 job1job2 等等,并且它们是并行执行的。
jobs:
  job1:
      ...
  job2:
      ...
  job3:
    ...

回头看一下 ci.yml 文件,它只有一个作业,即 build,作业的名称是自己定义的,你叫 good 也可以。

  1. runs-on,表示你这个工作流程要运行在什么操作系统上,ci.yml 文件定义的是最新稳定版的 ubuntu。除了 ubuntu,它还可以选择 Mac 或 Windows。

  1. steps,看字面意思就是一系列的步骤,也就是说这个作业由一系列的步骤完成。例如先执行 step1,再执行 step2...

setps 步骤讲解

setps 其实是一个数组,在 YAML 语法中,以 - 开始就是一个数组项。例如 ['a', 'b', 'c'] 用 YAML 语法表示为:

- a
- b
- c

所以 setps 就是一个步骤数组,从上到下开始执行。从 ci.yml 文件来看,每一个小步骤都有几个相关选项:

  1. name,小步骤的名称。
  2. uses,小步骤使用的 actions 库名称或路径,Github Actions 允许你使用别人写好的 Actions 库。
  3. run,小步骤要执行的 shell 命令。
  4. env,设置与小步骤相关的环境变量。
  5. with,提供参数。

综上所述,ci.yml 文件中的 setps 就很好理解了,下面从头到尾解释一边:

    steps:
    - uses: actions/checkout@v1
    - name: Install Node.js
      uses: actions/setup-node@v1
      with:
        node-version: '12.16.2'
    - name: Install npm dependencies
      run: npm install
    - name: Run build task
      run: npm run build
    - name: Deploy to Server
      uses: easingthemes/ssh-deploy@v2.1.5
      env:
          SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
          ARGS: '-rltgoDzvO --delete'
          SOURCE: dist # 这是要复制到阿里云静态服务器的文件夹名称
          REMOTE_HOST: '118.190.217.8' # 你的阿里云公网地址
          REMOTE_USER: root # 阿里云登录后默认为 root 用户,并且所在文件夹为 root
          TARGET: /root/node-server # 打包后的 dist 文件夹将放在 /root/node-server
  1. 使用 actions/checkout@v1 库克隆代码到 ubuntu 上。
  2. 使用 actions/setup-node@v1 库安装 nodejs,with 提供了一个参数 node-version 表示要安装的 nodejs 版本。
  3. ubuntushell 上执行 npm install 下载依赖。
  4. 执行 npm run build 打包项目。
  5. 使用 easingthemes/ssh-deploy@v2.1.5 库,这个库的作用就是用 SSH 的方式远程登录到阿里云服务器,将打包好的文件夹复制到阿里云指定的目录上。

env 上可以看到,这个 actions 库要求我们提供几个环境变量:

  1. SSH_PRIVATE_KEY: 阿里云密钥对中的私钥(需要你提前写在 github secrets 上),
  2. ARGS: '-rltgoDzvO --delete',没仔细研究,我猜是复制完文件就删除掉。
  3. SOURCE:打包后的文件夹名称
  4. REMOTE_HOST: 阿里云公网 IP 地址
  5. REMOTE_USER: 阿里云服务器的用户名
  6. TARGET: 你要拷贝到阿里云服务器指定目录的名称

如果你想了解一下其他 actions 库的实现,可以直接复制 actions 库的名称去搜索引擎搜索一下,例如搜索 actions/checkout 的结果为:

都看到这了,给个赞再走吧。

参考资料

更多文章,敬请关注

查看原文

赞 89 收藏 74 评论 6

谭光志 发布了文章 · 9月20日

前端国际化辅助工具——自动替换中文并翻译

github 项目地址

在这里插入图片描述

i18n-replace 是一个能够自动替换中文并支持自动翻译的前端国际化辅助工具。

它具有以下功能

  1. 根据你提供的默认映射数据({ 中文:变量 }),i18n-replace 会把中文替换成对应的变量。
  2. 如果没有提供映射数据,则使用默认规则替换中文并生成变量。
  3. 将替换出来的中文自动翻译成目标语言(默认为 en,即英语)。

自动翻译功能使用的是百度免费翻译 API,每秒只能调用一次,并且需要你注册百度翻译平台的账号。

然后将 appid 和密钥填入 i18n-replace 的配置文件 i18n.config.js,这个配置文件需要放置在你项目根目录下。

使用

安装

npm i -g i18n-replace

全局安装后,打开你的项目,建立一个 i18n.config.js 文件,配置项如下:

module.exports = {
    delay: 1500, // 自动翻译延时,必须大于 1000 ms,否则调用百度翻译 API 会失败
    mapFile: '', // 需要生成默认 map 的文件
    appid: '', // 百度翻译 appid
    key: '', // 百度翻译密钥
    output: 'i18n.data.js', // i18n 输出文件
    indent: 4, // i18n 输出文件缩进
    entry: '', // 要翻译的入口目录或文件,默认为命令行当前的 src 目录
    prefix: '', // i18n 变量前缀  i18n 变量生成规则 prefix + id + suffix
    suffix: '', // i18n 变量后缀
    id: 0, // i18n 自增变量 id
    translation: false, // 是否需要自动翻译中文
    to: 'en', // 中文翻译的目标语言
    mode: 1, // 0 翻译所有 i18n 数据,1 只翻译新数据
    loader: 'loader.js',
    pluginPrefix: '$t', // i18n 插件前缀 例如 vue-i18n: $t, react-i18next: t
    include: [], // 需要翻译的目录或文件
    exclude: [], // 不需要翻译的目录或文件 如果 exclude include 同时存在同样的目录或文件 则 exclude 优先级高
    extra: /(\.a)|(\.b)$/, // 默认支持 .vue 和 .js 文件 如果需要支持其他类型的文件,请用正则描述 例如这个示例额外支持 .a .b 文件
}

上面是 i18n-replace 工具的配置项,具体说明请看文档

这些配置项都不是必要的,如果你需要翻译功能,一般只需要填入 appid、key 并且将 translation 设为 true。

设置完配置项后,执行 rep(这是一个全局命令),i18n-replace 就会对你的入口目录进行递归替换、翻译。

i18n-replace 能识别以下中文:

不能包含有空格,可以包含中文、中文符号,数字,-
测试123
测试-12-测试
几点了?12点。

DEMO

更多测试用例,请查看项目下的 test 目录。

jsx

翻译前

<div>
    <input
        type="二"
        placeholder="一"
        value="s 四 f"
    />

    <MyComponent>
        非常好 <header slot="header">测试</header> 非常好
        非常好 <footer slot="footer">再一次测试</footer> 非常好
    </MyComponent>
</div>

翻译后

<div>
    <input
        type={this.$t('0')}
        placeholder={this.$t('1')}
        value={`s ${this.$t('2')} f`}
    />

    <MyComponent>
        {`${this.$t('3')} `}<header slot="header">{this.$t('4')}</header>{` ${this.$t('3')}`}
        {`${this.$t('3')} `}<footer slot="footer">{this.$t('5')}</footer>{` ${this.$t('3')}`}
    </MyComponent>
</div>

sfc

翻译前

<template>
    <div>
        有人<div value="二" :val="abc + '三 afb'">一</div>在国
    </div>
</template>

<script>
export default {
    created() {
        const test = '测试'
    }
}
</script>

翻译后

<template>
    <div>
        {{ $t('0') }}<div :value="$t('1')" :val="abc + $t('2') + ' afb'">{{ $t('3') }}</div>{{ $t('4') }}
    </div>
</template>

<script>
export default {
    created() {
        const test = this.$t('5')
    }
}
</script>

文档

在你的项目根目录下建立一个 i18n.config.js 文件,i18n-replace 将会根据配置项来执行不同的操作。

注意,所有配置项均为选填。

module.exports = {
    delay: 1500, // 自动翻译延时,必须大于 1000 ms,否则调用百度翻译 API 会失败
    mapFile: '国际化资源管理.xlsx', // 需要生成默认 map 的文件
    appid: '', // 百度翻译 appid
    key: '', // 百度翻译密钥
    output: 'i18n.data.js', // i18n 输出文件
    indent: 4, // i18n 输出文件缩进
    entry: 'src', // 要翻译的入口目录或文件,默认为命令行当前的 src 目录
    prefix: '', // i18n 变量前缀  i18n 变量生成规则 prefix + id + suffix
    suffix: '', // i18n 变量后缀
    id: 0, // i18n 自增变量 id
    translation: true, // 是否需要自动翻译中文
    to: 'en', // 中文翻译的目标语言
    mode: 1, // 0 翻译所有 i18n 数据,1 只翻译新数据
    loader: 'loader.js',
    pluginPrefix: '$t', // i18n 插件前缀 例如 vue-i18n: $t, react-i18next: t
    include: [],  // 需要翻译的目录或文件,如果为空,将不进行任何操作。
    exclude: [], // 不需要翻译的目录或文件 如果 exclude include 同时存在同样的目录或文件 则 exclude 优先级高
}

appid 和 key

appid: '', // 百度翻译 appid
key: '', // 百度翻译密钥

这是百度免费翻译 API 的 appid 和密钥。

如果你需要自动翻译,这两个是必填项。

具体注册流程请看官网

entry

entry: 'src'

入口目录或入口文件,默认为项目根目录下的 src 目录。

替换或翻译将从这里开始。

delay

delay: 1500

单位毫秒,默认 1500。

百度翻译 API 调用延时,由于免费的翻译 API 1 秒只能调用一次,所以该选项必须大于 1000。经过本人测试,该项设为 1500 比较稳定。

mapFile

mapFile: 'data.js'

如果你提供一个默认的映射文件(中文和变量之间的映射),本工具将根据映射文件将中文替换为对应的变量。

例如有这样的映射关系:

module.exports = {
    zh: {
        10000: '测试',
    },
    en: {},
}

和一个源码文件:

const test = '一'

启用工具后,源码文件中的 const test = '一' 将会被替换为 const test = this.$t('10000')

所以如果你有默认的映射文件,请提供它的地址。

loader

loader: 'src/loader.js'

i18n-replace 提供了一个内置的 loader,以便将下面的数据:

module.exports = {
    zh: {
        10000: '测试',
    },
    en: {},
}

转换成 i18n-replace 要求的映射数据格式:

{
    "测试": "10000",
}

所以为了能将其他文件翻译成这种格式,本工具提供了一个 loader 选项。

这个 loader 是一个本地文件地址,你提供的文件需要写一个转换函数,将其他格式的文件转换成 i18n-replace 要求的数据格式,就像下面这个函数一样:

const excelToJson = require('convert-excel-to-json')

function translateExcelData(file, done) {
    const data = excelToJson({ sourceFile: file })
    const result = {}
    data.Sheet1.forEach(item => {
        if (item.C == '中文') {
            result[item.B] = item.A
        }
    })

    done(result)
}

module.exports = translateExcelData

它接收两个参数,分别是文件地址 file 和 完成函数 done()

支持异步操作,只要在转换完成时调用 done(result) 即可。

prefix、suffix、id

如果你没有提供一个默认映射文件,i18n-replace 在将中文替换成变量时,将遵循下面的公式来生成变量:

prefix + id + suffix
  • id 默认为 0,自动递增。
  • 变量前缀,默认为空。
  • 变量后缀,默认为空。

pluginPrefix

pluginPrefix: '$t'

翻译前缀,默认为 $t。请根据应用场景配置。

例如 vue-i18n 国际化工具使用的是 $t,而 react-i18next 使用的是 t

translation

是否需要自动翻译,默认为 false

如果设为 true,将会调用百度免费翻译 API 进行翻译。

to

翻译的目标语言,默认为 en,即英语。

具体的配置项请查看百度翻译 API 文档

mode

翻译模式,默认为 1

翻译模式有两种:01

如果你提供了一个默认的映射文件:

{
    "一": "10000",
    "二": "10001",
}

同时在替换过程中产生了两个新的变量,最后映射数据变成这样:

{
    "一": "10000",
    "二": "10001",
    "三": "10002",
    "四": "10003"
}

这时,翻译模式为 0 将会对所有数据进行翻译。而翻译模式为 1 只对新产生的数据进行翻译。

output

翻译后的文件输出名称,默认为 i18n.data.js

include

工具默认翻译入口目录下所有的文件,如果你提供了 include 选项,将只会翻译 include 指定的目录或指定的文件。

如果这个选项是一个空数组,将不会进行任何操作。

exclude

exclude 优先级比 include 高,如果有文件包含在 exclude 里面,它将不会被翻译。

indent

生成文件的缩进,默认为 4

extra

由于 i18n-replace 默认只支持 .vue 和 .js 文件。
所以提供了一个 extra 选项,以支持其他的文件格式,它的值为正则表达式。

extra: /(\.a)|(\.b)$/

例如使用上述的正则表达式,i18n-replace 将额外支持 .a.b 文件

我的博客即将同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/dev...

查看原文

赞 12 收藏 8 评论 4

谭光志 发布了文章 · 9月14日

深入了解 webpack 模块加载原理

webpack 是一个模块打包器,在它看来,每一个文件都是一个模块。

无论你开发使用的是 CommonJS 规范还是 ES6 模块规范,打包后的文件都统一使用 webpack 自定义的模块规范来管理、加载模块。本文将从一个简单的示例开始,来讲解 webpack 模块加载原理。

CommonJS 规范

假设现在有如下两个文件:

// index.js
const test2 = require('./test2')

function test() {}

test()
test2()
// test2.js
function test2() {}

module.exports = test2

以上两个文件使用 CommonJS 规范来导入导出文件,打包后的代码如下(已经删除了不必要的注释):

(function(modules) { // webpackBootstrap
    // The module cache
    // 模块缓存对象
    var installedModules = {};

    // The require function
    // webpack 实现的 require() 函数
    function __webpack_require__(moduleId) {
        // Check if module is in cache
        // 如果模块已经加载过,直接返回缓存
        if(installedModules[moduleId]) {
            return installedModules[moduleId].exports;
        }
        // Create a new module (and put it into the cache)
        // 创建一个新模块,并放入缓存
        var module = installedModules[moduleId] = {
            i: moduleId,
            l: false,
            exports: {}
        };

        // Execute the module function
        // 执行模块函数
        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

        // Flag the module as loaded
        // 将模块标识为已加载
        module.l = true;

        // Return the exports of the module
        return module.exports;
    }


    // expose the modules object (__webpack_modules__)
    // 将所有的模块挂载到 require() 函数上
    __webpack_require__.m = modules;

    // expose the module cache
    // 将缓存对象挂载到 require() 函数上
    __webpack_require__.c = installedModules;

    // define getter function for harmony exports
    __webpack_require__.d = function(exports, name, getter) {
        if(!__webpack_require__.o(exports, name)) {
            Object.defineProperty(exports, name, { enumerable: true, get: getter });
        }
    };

    // define __esModule on exports
    __webpack_require__.r = function(exports) {
        if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
            Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
        }
        Object.defineProperty(exports, '__esModule', { value: true });
    };

    // create a fake namespace object
    // mode & 1: value is a module id, require it
    // mode & 2: merge all properties of value into the ns
    // mode & 4: return value when already ns object
    // mode & 8|1: behave like require
    __webpack_require__.t = function(value, mode) {
        if(mode & 1) value = __webpack_require__(value);
        if(mode & 8) return value;
        if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
        var ns = Object.create(null);
        __webpack_require__.r(ns);
        Object.defineProperty(ns, 'default', { enumerable: true, value: value });
        if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
        return ns;
    };

    // getDefaultExport function for compatibility with non-harmony modules
    __webpack_require__.n = function(module) {
        var getter = module && module.__esModule ?
            function getDefault() { return module['default']; } :
            function getModuleExports() { return module; };
        __webpack_require__.d(getter, 'a', getter);
        return getter;
    };

    // Object.prototype.hasOwnProperty.call
    __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };

    // __webpack_public_path__
    __webpack_require__.p = "";


    // Load entry module and return exports
    // 加载入口模块,并返回模块对象
    return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
  "./src/index.js": (function(module, exports, __webpack_require__) {
    eval("const test2 = __webpack_require__(/*! ./test2 */ \"./src/test2.js\")\r\n\r\nfunction test() {}\r\n\r\ntest()\r\ntest2()\n\n//# sourceURL=webpack:///./src/index.js?");
  }),
  
  "./src/test2.js": (function(module, exports) {
    eval("function test2() {}\r\n\r\nmodule.exports = test2\n\n//# sourceURL=webpack:///./src/test2.js?");
  })
});

可以看到 webpack 实现的模块加载系统非常简单,仅仅只有一百行代码。

打包后的代码其实是一个立即执行函数,传入的参数是一个对象。这个对象以文件路径为 key,以文件内容为 value,它包含了所有打包后的模块。

{
  "./src/index.js": (function(module, exports, __webpack_require__) {
    eval("const test2 = __webpack_require__(/*! ./test2 */ \"./src/test2.js\")\r\n\r\nfunction test() {}\r\n\r\ntest()\r\ntest2()\n\n//# sourceURL=webpack:///./src/index.js?");
  }),
  
  "./src/test2.js": (function(module, exports) {
    eval("function test2() {}\r\n\r\nmodule.exports = test2\n\n//# sourceURL=webpack:///./src/test2.js?");
  })
}

将这个立即函数化简一下,相当于:

(function(modules){
    // ...
})({
    path1: function1,
    path2: function2
})

再看一下这个立即函数做了什么:

  1. 定义了一个模块缓存对象 installedModules,作用是缓存已经加载过的模块。
  2. 定义了一个模块加载函数 __webpack_require__()
  3. ... 省略一些其他代码。
  4. 使用 __webpack_require__() 加载入口模块。

其中的核心就是 __webpack_require__() 函数,它接收的参数是 moduleId,其实就是文件路径。

它的执行过程如下:

  1. 判断模块是否有缓存,如果有则返回缓存模块的 export 对象,即 module.exports
  2. 新建一个模块 module,并放入缓存。
  3. 执行文件路径对应的模块函数。
  4. 将这个新建的模块标识为已加载。
  5. 执行完模块后,返回该模块的 exports 对象。
   // The require function
   // webpack 实现的 require() 函数
function __webpack_require__(moduleId) {
       // Check if module is in cache
       // 如果模块已经加载过,直接返回缓存
    if(installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
       // Create a new module (and put it into the cache)
       // 创建一个新模块,并放入缓存
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };

       // Execute the module function
       // 执行模块函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

       // Flag the module as loaded
       // 将模块标识为已加载
    module.l = true;

    // Return the exports of the module
    return module.exports;
}

从上述代码可以看到,在执行模块函数时传入了三个参数,分别为 modulemodule.exports__webpack_require__

其中 modulemodule.exports 的作用和 CommonJS 中的 modulemodule.exports 的作用是一样的,而 __webpack_require__ 相当于 CommonJS 中的 require

在立即函数的最后,使用了 __webpack_require__() 加载入口模块。并传入了入口模块的路径 ./src/index.js

__webpack_require__(__webpack_require__.s = "./src/index.js");

我们再来分析一下入口模块的内容。

(function(module, exports, __webpack_require__) {
    eval("const test2 = __webpack_require__(/*! ./test2 */ \"./src/test2.js\")\r\n\r\nfunction test() {}\r\n\r\ntest()\r\ntest2()\n\n//# sourceURL=webpack:///./src/index.js?");
  })

入口模块函数的参数正好是刚才所说的那三个参数,而 eval 函数的内容美化一下后和下面内容一样:

const test2 = __webpack_require__("./src/test2.js")
function test() {}
test()
test2()
//# sourceURL=webpack:///./src/index.js?

将打包后的模块代码和原模块的代码进行对比,可以发现仅有一个地方发生了变化,那就是 require 变成了 __webpack_require__

再看一下 test2.js 的代码:

function test2() {}
module.exports = test2
//# sourceURL=webpack:///./src/test2.js?

从刚才的分析可知,__webpack_require__() 加载模块后,会先执行模块对应的函数,然后返回该模块的 exports 对象。而 test2.js 的导出对象 module.exports 就是 test2() 函数。所以入口模块能通过 __webpack_require__() 引入 test2() 函数并执行。

到目前为止可以发现 webpack 自定义的模块规范完美适配 CommonJS 规范。

ES6 module

将刚才用 CommonJS 规范编写的两个文件换成用 ES6 module 规范来写,再执行打包。

// index.js
import test2 from './test2'

function test() {}

test()
test2()
// test2.js
export default function test2() {}

使用 ES6 module 规范打包后的代码和使用 CommonJS 规范打包后的代码绝大部分都是一样的。

一样的地方是指 webpack 自定义模块规范的代码一样,唯一不同的是上面两个文件打包后的代码不同。

{
     "./src/index.js":(function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _test2__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./test2 */ \"./src/test2.js\");\n\r\n\r\nfunction test() {}\r\n\r\ntest()\r\nObject(_test2__WEBPACK_IMPORTED_MODULE_0__[\"default\"])()\n\n//# sourceURL=webpack:///./src/index.js?");
    }),
    
    "./src/test2.js": (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"default\", function() { return test2; });\nfunction test2() {}\n\n//# sourceURL=webpack:///./src/test2.js?");
    })
}

可以看到传入的第二个参数是 __webpack_exports__,而 CommonJS 规范对应的第二个参数是 exports。将这两个模块代码的内容美化一下:

// index.js
__webpack_require__.r(__webpack_exports__);
 var _test2__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/test2.js");
 function test() {}
 test()
 Object(_test2__WEBPACK_IMPORTED_MODULE_0__["default"])()
 //# sourceURL=webpack:///./src/index.js?
// test2.js
 __webpack_require__.r(__webpack_exports__);
 __webpack_require__.d(__webpack_exports__, "default", function() { return test2; });
 function test2() {}
 //# sourceURL=webpack:///./src/test2.js?

可以发现,在每个模块的开头都执行了一个 __webpack_require__.r(__webpack_exports__) 语句。并且 test2.js 还多了一个 __webpack_require__.d() 函数。

我们先来看看 __webpack_require__.r()__webpack_require__.d() 是什么。

__webpack_require__.d()

// define getter function for harmony exports
__webpack_require__.d = function(exports, name, getter) {
    if(!__webpack_require__.o(exports, name)) {
        Object.defineProperty(exports, name, { enumerable: true, get: getter });
    }
};

原来 __webpack_require__.d() 是给 __webpack_exports__ 定义导出变量用的。例如下面这行代码:

__webpack_require__.d(__webpack_exports__, "default", function() { return test2; });

它的作用相当于 __webpack_exports__["default"] = test2。这个 "default" 是因为你使用 export default 来导出函数,如果这样导出函数:

export function test2() {}

它就会变成 __webpack_require__.d(__webpack_exports__, "test2", function() { return test2; });

__webpack_require__.r()

// define __esModule on exports
__webpack_require__.r = function(exports) {
    if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
    }
    Object.defineProperty(exports, '__esModule', { value: true });
};

__webpack_require__.r() 函数的作用是给 __webpack_exports__ 添加一个 __esModuletrue 的属性,表示这是一个 ES6 module。

添加这个属性有什么用呢?

主要是为了处理混合使用 ES6 module 和 CommonJS 的情况。

例如导出使用 CommonJS module.export = test2 导出函数,导入使用 ES6 module import test2 from './test2

打包后的代码如下:

// index.js
__webpack_require__.r(__webpack_exports__);
var _test2__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/test2.js");
var _test2__WEBPACK_IMPORTED_MODULE_0___default = __webpack_require__.n(_test2__WEBPACK_IMPORTED_MODULE_0__);
function test() {}
test()
_test2__WEBPACK_IMPORTED_MODULE_0___default()()
//# sourceURL=webpack:///./src/index.js?
// test2.js
 function test2() {}
 module.exports = test2
 //# sourceURL=webpack:///./src/test2.js?

从上述代码可以发现,又多了一个 __webpack_require__.n() 函数:

__webpack_require__.n = function(module) {
   var getter = module && module.__esModule ?
       function getDefault() { return module['default']; } :
       function getModuleExports() { return module; };
   __webpack_require__.d(getter, 'a', getter);
   return getter;
};

先来分析一下入口模块的处理逻辑:

  1. __webpack_exports__ 导出对象标识为 ES6 module。
  2. 加载 test2.js 模块,并将该模块的导出对象作为参数传入 __webpack_require__.n() 函数。
  3. __webpack_require__.n 分析该 export 对象是否是 ES6 module,如果是则返回 module['default']export default 对应的变量。如果不是 ES6 module 则直接返回 export

按需加载

按需加载,也叫异步加载、动态导入,即只在有需要的时候才去下载相应的资源文件。

在 webpack 中可以使用 importrequire.ensure 来引入需要动态导入的代码,例如下面这个示例:

// index.js
function test() {}

test()
import('./test2')
// test2.js
export default function test2() {}

其中使用 import 导入的 test2.js 文件在打包时会被单独打包成一个文件,而不是和 index.js 一起打包到 bundle.js

在这里插入图片描述
这个 0.bundle.js 对应的代码就是动态导入的 test2.js 的代码。

接下来看看这两个打包文件的内容:

// bundle.js
(function(modules) { // webpackBootstrap
    // install a JSONP callback for chunk loading
    function webpackJsonpCallback(data) {
        var chunkIds = data[0];
        var moreModules = data[1];

        // add "moreModules" to the modules object,
        // then flag all "chunkIds" as loaded and fire callback
        var moduleId, chunkId, i = 0, resolves = [];
        for(;i < chunkIds.length; i++) {
            chunkId = chunkIds[i];
            if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
                resolves.push(installedChunks[chunkId][0]);
            }
            installedChunks[chunkId] = 0;
        }
        for(moduleId in moreModules) {
            if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
                modules[moduleId] = moreModules[moduleId];
            }
        }
        if(parentJsonpFunction) parentJsonpFunction(data);

        while(resolves.length) {
            resolves.shift()();
        }

    };


    // The module cache
    var installedModules = {};

    // object to store loaded and loading chunks
    // undefined = chunk not loaded, null = chunk preloaded/prefetched
    // Promise = chunk loading, 0 = chunk loaded
    var installedChunks = {
        "main": 0
    };

    // script path function
    function jsonpScriptSrc(chunkId) {
        return __webpack_require__.p + "" + chunkId + ".bundle.js"
    }

    // The require function
    function __webpack_require__(moduleId) {

        // Check if module is in cache
        if(installedModules[moduleId]) {
            return installedModules[moduleId].exports;
        }
        // Create a new module (and put it into the cache)
        var module = installedModules[moduleId] = {
            i: moduleId,
            l: false,
            exports: {}
        };

        // Execute the module function
        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

        // Flag the module as loaded
        module.l = true;

        // Return the exports of the module
        return module.exports;
    }

    // This file contains only the entry chunk.
    // The chunk loading function for additional chunks
    __webpack_require__.e = function requireEnsure(chunkId) {
        var promises = [];

        // JSONP chunk loading for javascript

        var installedChunkData = installedChunks[chunkId];
        if(installedChunkData !== 0) { // 0 means "already installed".

            // a Promise means "currently loading".
            if(installedChunkData) {
                promises.push(installedChunkData[2]);
            } else {
                // setup Promise in chunk cache
                var promise = new Promise(function(resolve, reject) {
                    installedChunkData = installedChunks[chunkId] = [resolve, reject];
                });
                promises.push(installedChunkData[2] = promise);

                // start chunk loading
                var script = document.createElement('script');
                var onScriptComplete;

                script.charset = 'utf-8';
                script.timeout = 120;
                if (__webpack_require__.nc) {
                    script.setAttribute("nonce", __webpack_require__.nc);
                }
                script.src = jsonpScriptSrc(chunkId);

                // create error before stack unwound to get useful stacktrace later
                var error = new Error();
                onScriptComplete = function (event) {
                    // avoid mem leaks in IE.
                    script.onerror = script.onload = null;
                    clearTimeout(timeout);
                    var chunk = installedChunks[chunkId];
                    if(chunk !== 0) {
                        if(chunk) {
                            var errorType = event && (event.type === 'load' ? 'missing' : event.type);
                            var realSrc = event && event.target && event.target.src;
                            error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
                            error.name = 'ChunkLoadError';
                            error.type = errorType;
                            error.request = realSrc;
                            chunk[1](error);
                        }
                        installedChunks[chunkId] = undefined;
                    }
                };
                var timeout = setTimeout(function(){
                    onScriptComplete({ type: 'timeout', target: script });
                }, 120000);
                script.onerror = script.onload = onScriptComplete;
                document.head.appendChild(script);
            }
        }
        return Promise.all(promises);
    };

    // expose the modules object (__webpack_modules__)
    __webpack_require__.m = modules;

    // expose the module cache
    __webpack_require__.c = installedModules;

    // define getter function for harmony exports
    __webpack_require__.d = function(exports, name, getter) {
        if(!__webpack_require__.o(exports, name)) {
            Object.defineProperty(exports, name, { enumerable: true, get: getter });
        }
    };

    // define __esModule on exports
    __webpack_require__.r = function(exports) {
        if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
            Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
        }
        Object.defineProperty(exports, '__esModule', { value: true });
    };

    // create a fake namespace object
    // mode & 1: value is a module id, require it
    // mode & 2: merge all properties of value into the ns
    // mode & 4: return value when already ns object
    // mode & 8|1: behave like require
    __webpack_require__.t = function(value, mode) {
        if(mode & 1) value = __webpack_require__(value);
        if(mode & 8) return value;
        if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
        var ns = Object.create(null);
        __webpack_require__.r(ns);
        Object.defineProperty(ns, 'default', { enumerable: true, value: value });
        if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
        return ns;
    };

    // getDefaultExport function for compatibility with non-harmony modules
    __webpack_require__.n = function(module) {
        var getter = module && module.__esModule ?
            function getDefault() { return module['default']; } :
            function getModuleExports() { return module; };
        __webpack_require__.d(getter, 'a', getter);
        return getter;
    };

    // Object.prototype.hasOwnProperty.call
    __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };

    // __webpack_public_path__
    __webpack_require__.p = "";

    // on error function for async loading
    __webpack_require__.oe = function(err) { console.error(err); throw err; };

    var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
    var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
    jsonpArray.push = webpackJsonpCallback;
    jsonpArray = jsonpArray.slice();
    for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
    var parentJsonpFunction = oldJsonpFunction;

    // Load entry module and return exports
    return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
  "./src/index.js":(function(module, exports, __webpack_require__) {
    eval("function test() {}\r\n\r\ntest()\r\n__webpack_require__.e(/*! import() */ 0).then(__webpack_require__.bind(null, /*! ./test2 */ \"./src/test2.js\"))\n\n//# sourceURL=webpack:///./src/index.js?");
  })
});
// 0.bundle.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push(
  [
    [0],
    {
      "./src/test2.js":(function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"default\", function() { return test2; });\nfunction test2() {}\n\n//# sourceURL=webpack:///./src/test2.js?");
      })
    }
  ]
);

这次打包的代码量有点膨胀,bundle.js 代码居然有 200 行。我们来看看相比于同步加载的 webpack 模块规范,它有哪些不同:

  1. 定义了一个对象 installedChunks,作用是缓存动态模块。
  2. 定义了一个辅助函数 jsonpScriptSrc(),作用是根据模块 ID 生成 URL。
  3. 定义了两个新的核心函数 __webpack_require__.e()webpackJsonpCallback()
  4. 定义了一个全局变量 window["webpackJsonp"] = [],它的作用是存储需要动态导入的模块。
  5. 重写 window["webpackJsonp"] 数组的 push() 方法为 webpackJsonpCallback()。也就是说 window["webpackJsonp"].push() 其实执行的是 webpackJsonpCallback()

而从 0.bundle.js 文件可以发现,它正是使用 window["webpackJsonp"].push() 来放入动态模块的。动态模块数据项有两个值,第一个是 [0],它是模块的 ID;第二个值是模块的路径名和模块内容。

然后我们再看一下打包后的入口模块的代码,经过美化后:

function test() {}
test()
__webpack_require__.e(0).then(__webpack_require__.bind(null, "./src/test2.js"))
//# sourceURL=webpack:///./src/index.js?

原来模块代码中的 import('./test2') 被翻译成了 __webpack_require__.e(0).then(__webpack_require__.bind(null, "./src/test2.js"))

__webpack_require__.e() 的作用是什么呢?

__webpack_require__.e()

__webpack_require__.e = function requireEnsure(chunkId) {
    var promises = [];
    // JSONP chunk loading for javascript
    var installedChunkData = installedChunks[chunkId];
    if(installedChunkData !== 0) { // 0 means "already installed".
        // a Promise means "currently loading".
        if(installedChunkData) {
            promises.push(installedChunkData[2]);
        } else {
            // setup Promise in chunk cache
            var promise = new Promise(function(resolve, reject) {
                installedChunkData = installedChunks[chunkId] = [resolve, reject];
            });
            promises.push(installedChunkData[2] = promise);

            // start chunk loading
            var script = document.createElement('script');
            var onScriptComplete;

            script.charset = 'utf-8';
            script.timeout = 120;
            if (__webpack_require__.nc) {
                script.setAttribute("nonce", __webpack_require__.nc);
            }
            script.src = jsonpScriptSrc(chunkId);

            // create error before stack unwound to get useful stacktrace later
            var error = new Error();
            onScriptComplete = function (event) {
                // avoid mem leaks in IE.
                script.onerror = script.onload = null;
                clearTimeout(timeout);
                var chunk = installedChunks[chunkId];
                if(chunk !== 0) {
                    if(chunk) {
                        var errorType = event && (event.type === 'load' ? 'missing' : event.type);
                        var realSrc = event && event.target && event.target.src;
                        error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
                        error.name = 'ChunkLoadError';
                        error.type = errorType;
                        error.request = realSrc;
                        chunk[1](error);
                    }
                    installedChunks[chunkId] = undefined;
                }
            };
            var timeout = setTimeout(function(){
                onScriptComplete({ type: 'timeout', target: script });
            }, 120000);
            script.onerror = script.onload = onScriptComplete;
            document.head.appendChild(script);
        }
    }
    return Promise.all(promises);
};

它的处理逻辑如下:

  1. 先查看该模块 ID 对应缓存的值是否为 0,0 代表已经加载成功了,第一次取值为 undefined
  2. 如果不为 0 并且不是 undefined 代表已经是加载中的状态。然后将这个加载中的 Promise 推入 promises 数组。
  3. 如果不为 0 并且是 undefined 就新建一个 Promise,用于加载需要动态导入的模块。
  4. 生成一个 script 标签,URL 使用 jsonpScriptSrc(chunkId) 生成,即需要动态导入模块的 URL。
  5. 为这个 script 标签设置一个 2 分钟的超时时间,并设置一个 onScriptComplete() 函数,用于处理超时错误。
  6. 然后添加到页面中 document.head.appendChild(script),开始加载模块。
  7. 返回 promises 数组。

当 JS 文件下载完成后,会自动执行文件内容。也就是说下载完 0.bundle.js 后,会执行 window["webpackJsonp"].push()

由于 window["webpackJsonp"].push() 已被重置为 webpackJsonpCallback() 函数。所以这一操作就是执行 webpackJsonpCallback() ,接下来我们看看 webpackJsonpCallback() 做了哪些事情。

webpackJsonpCallback()

对这个模块 ID 对应的 Promise 执行 resolve(),同时将缓存对象中的值置为 0,表示已经加载完成了。相比于 __webpack_require__.e(),这个函数还是挺好理解的。

小结

总的来说,动态导入的逻辑如下:

  1. 重写 window["webpackJsonp"].push() 方法。
  2. 入口模块使用 __webpack_require__.e() 下载动态资源。
  3. 资源下载完成后执行 window["webpackJsonp"].push(),即 webpackJsonpCallback()
  4. 将资源标识为 0,代表已经加载完成。由于加载模块使用的是 Promise,所以要执行 resolve()
  5. 再看一下入口模块的加载代码 __webpack_require__.e(0).then(__webpack_require__.bind(null, "./src/test2.js")),下载完成后执行 then() 方法,调用 __webpack_require__() 真正开始加载代码,__webpack_require__() 在上文已经讲解过,如果不了解,建议再阅读一遍。

更多文章,敬请关注

查看原文

赞 9 收藏 6 评论 1

谭光志 发布了文章 · 9月11日

实现一个 webpack loader 和 webpack plugin

loader

官网上的定义:

loader 是一个转换器,用于对源代码进行转换。

例如 babel-loader 可以将 ES6 代码转换为 ES5 代码;sass-loadersass 代码转换为 css 代码。

一般 loader 的配置代码如下:

module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    // loader 的执行顺序从下到上
                    {
                        loader: path.resolve('./src/loader2.js'),
                    },
                    {
                        loader: path.resolve('./src/loader1.js'),
                    },
                ]
            }
        ]
    },

rules 数组包含了一个个匹配规则和具体的 loader 文件。

上述代码中的 test: /\.js$/ 就是匹配规则,表示对 js 文件使用下面的两个 loader。

而 loader 的处理顺序是自下向上的,即先用 loader1 处理源码,然后将处理后的代码再传给 loader2。

loader2 处理后的代码就是最终的打包代码。

loader 的实现

loader 其实是一个函数,它的参数是匹配文件的源码,返回结果是处理后的源码。下面是一个最简单的 loader,它什么都没做:

module.exports = function (source) {
    return source
}

这么简单的 loader 没有挑战性,我们可以写一个稍微复杂一点的 loader,它的作用是将 var 关键词替换为 const

module.exports = function (source) {
    return source.replace(/var/g, 'const')
}

写完之后,我们来测试一下,测试文件为:

function test() {
    var a = 1;
    var b = 2;
    var c = 3;
    console.log(a, b, c);
}

test()

wepback.config.js 配置文件为:

const path = require('path')

module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js'
    },
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    {
                        loader: path.resolve('./src/loader1.js'),
                    },
                ]
            }
        ]
    },
}

运行 npm run build,得到打包文件 bundle.js,我们来看一看打包后的代码:

eval("function test() {\r\n    const a = 1;\r\n    const b = 2;\r\n    const c = 3;\r\n    console.log(a, b, c);\r\n}\r\n\r\ntest()\n\n//# sourceURL=webpack:///./src/index.js?");

可以看到,代码中的 var 已经变成了 const

异步 loader

刚才实现的 loader 是一个同步 loader,在处理完源码后用 return 返回。

下面我们来实现一个异步 loader:

module.exports = function (source) {
    const callback = this.async()

    // 由于有 3 秒延迟,所以打包时需要 3+ 秒的时间
    setTimeout(() => {
        callback(null, `${source.replace(/;/g, '')}`)
    }, 3000)
}

异步 loader 需要调用 webpack 的 async() 生成一个 callback,它的第一个参数是 error,这里可设为 null,第二个参数就是处理后的源码。当你异步处理完源码后,调用 callback 即可。

下面来试一下异步 loader 到底有没生效,这里设置了一个 3 秒延迟。我们来对比一下打包时间:

在这里插入图片描述
在这里插入图片描述
上图是调用同步 loader 的打包时间,为 141 ms;下图是调用异步 loader 的打包时间,为 3105 ms,说明异步 loader 生效了。

如果想看完整 demo 源码,请点击我的 github

plugin

webpack 在整个编译周期中会触发很多不同的事件,plugin 可以监听这些事件,并且可以调用 webpack 的 API 对输出资源进行处理。

这是它和 loader 的不同之处,loader 一般只能对源文件代码进行转换,而 plugin 可以做得更多。plugin 在整个编译周期中都可以被调用,只要监听事件。

对于 webpack 编译,有两个重要的对象需要了解一下:

Compiler 和 Compilation
在插件开发中最重要的两个资源就是 compiler 和 compilation 对象。理解它们的角色是扩展 webpack 引擎重要的第一步。

compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。

compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。

这两个组件是任何 webpack 插件不可或缺的部分(特别是 compilation),因此,开发者在阅读源码,并熟悉它们之后,会感到获益匪浅。

plugin 的实现

我们看一下官网的定义,webpack 插件由以下部分组成:

  1. 一个 JavaScript 命名函数。
  2. 在插件函数的 prototype 上定义一个 apply 方法。
  3. 指定一个绑定到 webpack 自身的事件钩子。
  4. 处理 webpack 内部实例的特定数据。
  5. 功能完成后调用 webpack 提供的回调。

简单的说,一个具有 apply 方法的函数就是一个插件,并且它要监听 webpack 的某个事件。下面来看一个简单的示例:

function Plugin(options) { }

Plugin.prototype.apply = function (compiler) {
    // 所有文件资源都被 loader 处理后触发这个事件
    compiler.plugin('emit', function (compilation, callback) {
        // 功能完成后调用 webpack 提供的回调
        console.log('Hello World')
        callback()
    })
}

module.exports = Plugin

写完插件后要怎么调用呢?

先在 webpack 配置文件中引入插件,然后在 plugins 选项中配置:

const Plugin = require('./src/plugin')

module.exports = {
    ...
    plugins: [
        new Plugin()
    ]
}

这就是一个简单的插件了。

下面我们再来写一个复杂点的插件,它的作用是将经过 loader 处理后的打包文件 bundle.js 引入到 index.html 中:

function Plugin(options) { }

Plugin.prototype.apply = function (compiler) {
    // 所有文件资源经过不同的 loader 处理后触发这个事件
    compiler.plugin('emit', function (compilation, callback) {
        // 获取打包后的 js 文件名
        const filename = compiler.options.output.filename
        // 生成一个 index.html 并引入打包后的 js 文件
        const html = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script data-original="${filename}"></script>
</head>
<body>
    
</body>
</html>`
        // 所有处理后的资源都放在 compilation.assets 中
        // 添加一个 index.html 文件
        compilation.assets['index.html'] = {
            source: function () {
                return html
            },
            size: function () {
                return html.length
            }
        }

        // 功能完成后调用 webpack 提供的回调
        callback()
    })
}

module.exports = Plugin

OK,执行一下,看看效果。

在这里插入图片描述
完美,和预测的结果一模一样。

完整 demo 源码,请看我的 github

想了解更多的事件,请看官网介绍 compiler 钩子

参考资料

查看原文

赞 9 收藏 7 评论 0

谭光志 发布了文章 · 8月30日

从 rollup 初版源码学习打包原理

前言

为了学习 rollup 打包原理,我克隆了最新版(v2.26.5)的源码。然后发现打包器和我想像的不太一样,代码实在太多了,光看 d.ts 文件就看得头疼。为了看看源码到底有多少行,我写了个脚本,结果发现有 19650行,崩溃...

这就能打消我学习 rollup 的决心吗?不可能,退而求其次,我下载了 rollup 初版源码,才 1000 行左右。

我的目的是学习 rollup 怎么打包的,怎么做 tree-shaking 的。而初版源码已经实现了这两个功能(半成品),所以看初版源码已经足够了。

好了,下面开始正文。

正文

rollup 使用了 acornmagic-string 两个库。为了更好的阅读 rollup 源码,必须对它们有所了解。

下面我将简单的介绍一下这两个库的作用。

acorn

acorn 是一个 JavaScript 语法解析器,它将 JavaScript 字符串解析成语法抽象树 AST。

例如以下代码:

export default function add(a, b) { return a + b }

将被解析为:

{
    "type": "Program",
    "start": 0,
    "end": 50,
    "body": [
        {
            "type": "ExportDefaultDeclaration",
            "start": 0,
            "end": 50,
            "declaration": {
                "type": "FunctionDeclaration",
                "start": 15,
                "end": 50,
                "id": {
                    "type": "Identifier",
                    "start": 24,
                    "end": 27,
                    "name": "add"
                },
                "expression": false,
                "generator": false,
                "params": [
                    {
                        "type": "Identifier",
                        "start": 28,
                        "end": 29,
                        "name": "a"
                    },
                    {
                        "type": "Identifier",
                        "start": 31,
                        "end": 32,
                        "name": "b"
                    }
                ],
                "body": {
                    "type": "BlockStatement",
                    "start": 34,
                    "end": 50,
                    "body": [
                        {
                            "type": "ReturnStatement",
                            "start": 36,
                            "end": 48,
                            "argument": {
                                "type": "BinaryExpression",
                                "start": 43,
                                "end": 48,
                                "left": {
                                    "type": "Identifier",
                                    "start": 43,
                                    "end": 44,
                                    "name": "a"
                                },
                                "operator": "+",
                                "right": {
                                    "type": "Identifier",
                                    "start": 47,
                                    "end": 48,
                                    "name": "b"
                                }
                            }
                        }
                    ]
                }
            }
        }
    ],
    "sourceType": "module"
}

可以看到这个 AST 的类型为 program,表明这是一个程序。body 则包含了这个程序下面所有语句对应的 AST 子节点。

每个节点都有一个 type 类型,例如 Identifier,说明这个节点是一个标识符;BlockStatement 则表明节点是块语句;ReturnStatement 则是 return 语句。

如果想了解更多详情 AST 节点的信息可以看一下这篇文章《使用 Acorn 来解析 JavaScript》

magic-string

magic-string 也是 rollup 作者写的一个关于字符串操作的库。下面是 github 上的示例:

var MagicString = require( 'magic-string' );
var s = new MagicString( 'problems = 99' );

s.overwrite( 0, 8, 'answer' );
s.toString(); // 'answer = 99'

s.overwrite( 11, 13, '42' ); // character indices always refer to the original string
s.toString(); // 'answer = 42'

s.prepend( 'var ' ).append( ';' ); // most methods are chainable
s.toString(); // 'var answer = 42;'

var map = s.generateMap({
  source: 'source.js',
  file: 'converted.js.map',
  includeContent: true
}); // generates a v3 sourcemap

require( 'fs' ).writeFile( 'converted.js', s.toString() );
require( 'fs' ).writeFile( 'converted.js.map', map.toString() );

从示例中可以看出来,这个库主要是对字符串一些常用方法进行了封装。这里就不多做介绍了。

rollup 源码结构

│  bundle.js // Bundle 打包器,在打包过程中会生成一个 bundle 实例,用于收集其他模块的代码,最后再将收集的代码打包到一起。
│  external-module.js // ExternalModule 外部模块,例如引入了 'path' 模块,就会生成一个 ExternalModule 实例。
│  module.js // Module 模块,开发者自己写的代码文件,都是 module 实例。例如有 'foo.js' 文件,它就对应了一个 module 实例。
│  rollup.js // rollup 函数,一切的开始,调用它进行打包。
│
├─ast // ast 目录,包含了和 AST 相关的类和函数
│      analyse.js // 主要用于分析 AST 节点的作用域和依赖项。
│      Scope.js // 在分析 AST 节点时为每一个节点生成对应的 Scope 实例,主要是记录每个 AST 节点对应的作用域。
│      walk.js // walk 就是递归调用 AST 节点进行分析。
│
├─finalisers
│      cjs.js // 打包模式,目前只支持将代码打包成 common.js 格式
│      index.js
│
└─utils // 一些帮助函数
        map-helpers.js
        object.js
        promise.js
        replaceIdentifiers.js

上面是初版源码的目录结构,在继续深入前,请仔细阅读上面的注释,了解一下每个文件的作用。

rollup 如何打包的?

在 rollup 中,一个文件就是一个模块。每一个模块都会根据文件的代码生成一个 AST 语法抽象树,rollup 需要对每一个 AST 节点进行分析。

分析 AST 节点,就是看看这个节点有没有调用函数或方法。如果有,就查看所调用的函数或方法是否在当前作用域,如果不在就往上找,直到找到模块顶级作用域为止。

如果本模块都没找到,说明这个函数、方法依赖于其他模块,需要从其他模块引入。

例如 import foo from './foo.js',其中 foo() 就得从 ./foo.js 文件找。

在引入 foo() 函数的过程中,如果发现 foo() 函数依赖其他模块,就会递归读取其他模块,如此循环直到没有依赖的模块为止。

最后将所有引入的代码打包在一起。

上面例子的示例图:

接下来我们从一个具体的示例开始,一步步分析 rollup 是如何打包的

以下两个文件是代码文件。

// main.js
import { foo1, foo2 } from './foo'

foo1()

function test() {
    const a = 1
}

console.log(test())
// foo.js
export function foo1() {}
export function foo2() {}

下面是测试代码:

const rollup = require('../dist/rollup')

rollup(__dirname + '/main.js').then(res => {
    res.wirte('bundle.js')
})

1. rollup 读取 main.js 入口文件。

rollup() 首先生成一个 Bundle 实例,也就是打包器。然后根据入口文件路径去读取文件,最后根据文件内容生成一个 Module 实例。

fs.readFile(path, 'utf-8', (err, code) => {
    if (err) reject(err)
    const module = new Module({
        code,
        path,
        bundle: this, // bundle 实例
    })
})

2. new Moudle() 过程

在 new 一个 Module 实例时,会调用 acorn 库的 parse() 方法将代码解析成 AST。

this.ast = parse(code, {
    ecmaVersion: 6, // 要解析的 JavaScript 的 ECMA 版本,这里按 ES6 解析
    sourceType: 'module', // sourceType值为 module 和 script。module 模式,可以使用 import/export 语法
})

接下来需要对生成的 AST 进行分析。

第一步,分析导入和导出的模块,将引入的模块和导出的模块填入对应的对象。

每个 Module 实例都有一个 importsexports 对象,作用是将该模块引入和导出的对象填进去,代码生成时要用到。

上述例子对应的 importsexports 为:

// key 为要引入的具体对象,value 为对应的 AST 节点内容。
imports = {
  foo1: { source: './foo', name: 'foo1', localName: 'foo1' },
  foo2: { source: './foo', name: 'foo2', localName: 'foo2' }
}
// 由于没有导出的对象,所以为空
exports = {}

第二步,分析每个 AST 节点间的作用域,找出每个 AST 节点定义的变量。

每遍历到一个 AST 节点,都会为它生成一个 Scope 实例。

// 作用域
class Scope {
    constructor(options = {}) {
        this.parent = options.parent // 父作用域
        this.depth = this.parent ? this.parent.depth + 1 : 0 // 作用域层级
        this.names = options.params || [] // 作用域内的变量
        this.isBlockScope = !!options.block // 是否块作用域
    }

    add(name, isBlockDeclaration) {
        if (!isBlockDeclaration && this.isBlockScope) {
            // it's a `var` or function declaration, and this
            // is a block scope, so we need to go up
            this.parent.add(name, isBlockDeclaration)
        } else {
            this.names.push(name)
        }
    }

    contains(name) {
        return !!this.findDefiningScope(name)
    }

    findDefiningScope(name) {
        if (this.names.includes(name)) {
            return this
        }

        if (this.parent) {
            return this.parent.findDefiningScope(name)
        }

        return null
    }
}

Scope 的作用很简单,它有一个 names 属性数组,用于保存这个 AST 节点内的变量。
例如下面这段代码:

function test() {
    const a = 1
}

打断点可以看出来,它生成的作用域对象,names 属性就会包含 a。并且因为它是模块下的一个函数,所以作用域层级为 1(模块顶级作用域为 0)。

第三步,分析标识符,并找出它们的依赖项。

什么是标识符?如变量名,函数名,属性名,都归为标识符。当解析到一个标识符时,rollup 会遍历它当前的作用域,看看有没这个标识符。如果没有找到,就往它的父级作用域找。如果一直找到模块顶级作用域都没找到,就说明这个函数、方法依赖于其它模块,需要从其他模块引入。如果一个函数、方法需要被引入,就将它添加到 Module_dependsOn 对象里。

例如 test() 函数中的变量 a,能在当前作用域找到,它就不是一个依赖项。foo1() 在当前模块作用域找不到,它就是一个依赖项。

打断点也能发现 Module_dependsOn 属性里就有 foo1

这就是 rollup 的 tree-shaking 原理。

rollup 不看你引入了什么函数,而是看你调用了什么函数。如果调用的函数不在此模块中,就从其它模块引入。

换句话说,如果你手动在模块顶部引入函数,但又没调用。rollup 是不会引入的。从我们的示例中可以看出,一共引入了 foo1()foo2() 两个函数,_dependsOn 里却只有 foo1(),因为引入的 foo2() 没有调用。

_dependsOn 有什么用呢?后面生成代码时会根据 _dependsOn 里的值来引入文件。

3. 根据依赖项,读取对应的文件。

_dependsOn 的值可以发现,我们需要引入 foo1() 函数。

这时第一步生成的 imports 就起作用了:

imports = {
  foo1: { source: './foo', name: 'foo1', localName: 'foo1' },
  foo2: { source: './foo', name: 'foo2', localName: 'foo2' }
}

rollup 将 foo1 当成 key,找到它对应的文件。然后读取这个文件生成一个新的 Module 实例。由于 foo.js 文件导出了两个函数,所以这个新 Module 实例的 exports 属性是这样的:

exports = {
  foo1: {
    node: Node {
      type: 'ExportNamedDeclaration',
      start: 0,
      end: 25,
      declaration: [Node],
      specifiers: [],
      source: null
    },
    localName: 'foo1',
    expression: Node {
      type: 'FunctionDeclaration',
      start: 7,
      end: 25,
      id: [Node],
      expression: false,
      generator: false,
      params: [],
      body: [Node]
    }
  },
  foo2: {
    node: Node {
      type: 'ExportNamedDeclaration',
      start: 27,
      end: 52,
      declaration: [Node],
      specifiers: [],
      source: null
    },
    localName: 'foo2',
    expression: Node {
      type: 'FunctionDeclaration',
      start: 34,
      end: 52,
      id: [Node],
      expression: false,
      generator: false,
      params: [],
      body: [Node]
    }
  }
}

这时,就会用 main.js 要导入的 foo1 当成 key 去匹配 foo.jsexports 对象。如果匹配成功,就把 foo1() 函数对应的 AST 节点提取出来,放到 Bundle 中。如果匹配失败,就会报错,提示 foo.js 没有导出这个函数。

4. 生成代码。

由于已经引入了所有的函数。这时需要调用 Bundlegenerate() 方法生成代码。

同时,在打包过程中,还需要对引入的函数做一些额外的操作。

移除额外代码

例如从 foo.js 中引入的 foo1() 函数代码是这样的:export function foo1() {}。rollup 会移除掉 export ,变成 function foo1() {}。因为它们就要打包在一起了,所以就不需要 export 了。

重命名

例如两个模块中都有一个同名函数 foo(),打包到一起时,会对其中一个函数重命名,变成 _foo(),以避免冲突。

好了,回到正文。

还记得文章一开始提到的 magic-string 库吗?在 generate() 中,会将每个 AST 节点对应的源代码添加到 magic-string 实例中:

magicString.addSource({
    content: source,
    separator: newLines
})

这个操作本质上相当于拼字符串:

str += '这个操作相当于将每个 AST 的源代码当成字符串拼在一起,就像现在这样'

最后将拼在一起的代码返回。

return { code: magicString.toString() }

到这就已经结束了,如果你想把代码生成文件,可以调用 write() 方法生成文件:

rollup(__dirname + '/main.js').then(res => {
    res.wirte('dist.js')
})

这个方法是写在 rollup() 函数里的。

function rollup(entry, options = {}) {
    const bundle = new Bundle({ entry, ...options })
    return bundle.build().then(() => {
        return {
            generate: options => bundle.generate(options),
            wirte(dest, options = {}) {
                const { code } = bundle.generate({
                    dest,
                    format: options.format,
                })

                return fs.writeFile(dest, code, err => {
                    if (err) throw err
                })
            }
        }
    })
}

结尾

本文对源码进行了抽象,所以很多实现细节都没说出来。如果对实现细节有兴趣,可以看一下源码。代码放在我的 github 上。

我已经对 rollup 初版源码进行了删减,并添加了大量注释,让代码更加易读。

查看原文

赞 7 收藏 3 评论 0

谭光志 发布了文章 · 8月12日

Vue3 模板编译原理

Vue 的编译模块包含 4 个目录:

compiler-core
compiler-dom // 浏览器
compiler-sfc // 单文件组件
compiler-ssr // 服务端渲染

其中 compiler-core 模块是 Vue 编译的核心模块,并且是平台无关的。而剩下的三个都是在 compiler-core 的基础上针对不同的平台作了适配处理。

Vue 的编译分为三个阶段,分别是:parse、transform、codegen。

其中 parse 阶段将模板字符串转化为语法抽象树 AST。transform 阶段则是对 AST 进行了一些转换处理。codegen 阶段根据 AST 生成对应的 render 函数字符串。

Parse

Vue 在解析模板字符串时,可分为两种情况:以 < 开头的字符串和不以 < 开头的字符串。

不以 < 开头的字符串有两种情况:它是文本节点或 {{ exp }} 插值表达式。

而以 < 开头的字符串又分为以下几种情况:

  1. 元素开始标签 <div>
  2. 元素结束标签 </div>
  3. 注释节点 <!-- 123 -->
  4. 文档声明 <!DOCTYPE html>

用伪代码表示,大概过程如下:

while (s.length) {
    if (startsWith(s, '{{')) {
        // 如果以 '{{' 开头
        node = parseInterpolation(context, mode)
    } else if (s[0] === '<') {
        // 以 < 标签开头
        if (s[1] === '!') {
            if (startsWith(s, '<!--')) {
                // 注释
                node = parseComment(context)
            } else if (startsWith(s, '<!DOCTYPE')) {
                // 文档声明,当成注释处理
                node = parseBogusComment(context)
            }
        } else if (s[1] === '/') {
            // 结束标签
            parseTag(context, TagType.End, parent)
        } else if (/[a-z]/i.test(s[1])) {
            // 开始标签
            node = parseElement(context, ancestors)
        }
    } else {
        // 普通文本节点
        node = parseText(context, mode)
    }
}

在源码中对应的几个函数分别是:

  1. parseChildren(),主入口。
  2. parseInterpolation(),解析双花插值表达式。
  3. parseComment(),解析注释。
  4. parseBogusComment(),解析文档声明。
  5. parseTag(),解析标签。
  6. parseElement(),解析元素节点,它会在内部执行 parseTag()
  7. parseText(),解析普通文本。
  8. parseAttribute(),解析属性。

每解析完一个标签、文本、注释等节点时,Vue 就会生成对应的 AST 节点,并且会把已经解析完的字符串给截断

对字符串进行截断使用的是 advanceBy(context, numberOfCharacters) 函数,context 是字符串的上下文对象,numberOfCharacters 是要截断的字符数。

我们用一个简单的例子来模拟一下截断操作:

<div name="test">
  <p></p>
</div>

首先解析 <div,然后执行 advanceBy(context, 4) 进行截断操作(内部执行的是 s = s.slice(4)),变成:

 name="test">
  <p></p>
</div>

再解析属性,并截断,变成:

  <p></p>
</div>

同理,后面的截断情况为:

></p>
</div>
</div>
<!-- 所有字符串已经解析完 -->

AST 节点

所有的 AST 节点定义都在 compiler-core/ast.ts 文件中,下面是一个元素节点的定义:

export interface BaseElementNode extends Node {
  type: NodeTypes.ELEMENT // 类型
  ns: Namespace // 命名空间 默认为 HTML,即 0
  tag: string // 标签名
  tagType: ElementTypes // 元素类型
  isSelfClosing: boolean // 是否是自闭合标签 例如 <br/> <hr/>
  props: Array<AttributeNode | DirectiveNode> // props 属性,包含 HTML 属性和指令
  children: TemplateChildNode[] // 字节点
}

一些简单的要点已经讲完了,下面我们再从一个比较复杂的例子来详细讲解一下 parse 的处理过程。

<div name="test">
  <!-- 这是注释 -->
  <p>{{ test }}</p>
  一个文本节点
  <div>good job!</div>
</div>

上面的模板字符串假设为 s,第一个字符 s[0] 是 < 开头,那说明它只能是刚才所说的四种情况之一。
这时需要再看一下 s[1] 的字符是什么:

  1. 如果是 !,则调用字符串原生方法 startsWith() 看看是以 '<!--' 开头还是以 '<!DOCTYPE' 开头。虽然这两者对应的处理函数不一样,但它们最终都是解析为注释节点。
  2. 如果是 /,则按结束标签处理。
  3. 如果不是 /,则按开始标签处理。

从我们的示例来看,这是一个 <div> 开始标签。

这里还有一点要提一下,Vue 会用一个栈 stack 来保存解析到的元素标签。当它遇到开始标签时,会将这个标签推入栈,遇到结束标签时,将刚才的标签弹出栈。它的作用是保存当前已经解析了,但还没解析完的元素标签。这个栈还有另一个作用,在解析到某个字节点时,通过 stack[stack.length - 1] 可以获取它的父元素。

从我们的示例来看,它的出入栈顺序是这样的:

1. [div] // div 入栈
2. [div, p] // p 入栈
3. [div] // p 出栈
4. [div, div] // div 入栈
5. [div] // div 出栈
6. [] // 最后一个 div 出栈,模板字符串已解析完,这时栈为空

接着上文继续分析我们的示例,这时已经知道是 div 标签了,接下来会把已经解析完的 <div 字符串截断,然后解析它的属性。

Vue 的属性有两种情况:

  1. HTML 普通属性
  2. Vue 指令

根据属性的不同生成的节点不同,HTML 普通属性节点 type 为 6,Vue 指令节点 type 为 7。

所有的节点类型值如下:

ROOT,  // 根节点 0
ELEMENT, // 元素节点 1
TEXT, // 文本节点 2
COMMENT, // 注释节点 3
SIMPLE_EXPRESSION, // 表达式 4
INTERPOLATION, // 双花插值 {{ }} 5
ATTRIBUTE, // 属性 6
DIRECTIVE, // 指令 7

属性解析完后,div 开始标签也就解析完了,<div name="test"> 这一行字符串已经被截断。现在剩下的字符串如下:

  <!-- 这是注释 -->
  <p>{{ test }}</p>
  一个文本节点
  <div>good job!</div>
</div>

注释文本和普通文本节点解析规则都很简单,直接截断,生成节点。注释文本调用 parseComment() 函数处理,文本节点调用 parseText() 处理。

双花插值的字符串处理逻辑稍微复杂点,例如示例中的 {{ test }}

  1. 先将双花括号中的内容提取出来,即 test ,再对它执行 trim(),去除空格。
  2. 然后会生成两个节点,一个节点是 INTERPOLATION,type 为 5,表示它是双花插值。
  3. 第二个节点是它的内容,即 test,它会生成一个 SIMPLE_EXPRESSION 节点,type 为 4。
return {
  type: NodeTypes.INTERPOLATION, // 双花插值类型
  content: {
    type: NodeTypes.SIMPLE_EXPRESSION,
    isStatic: false, // 非静态节点
    isConstant: false,
    content,
    loc: getSelection(context, innerStart, innerEnd)
  },
  loc: getSelection(context, start)
}

剩下的字符串解析逻辑和上文的差不多,就不解释了,最后这个示例解析出来的 AST 如下所示:

从 AST 上,我们还能看到某些节点上有一些别的属性:

  1. ns,命名空间,一般为 HTML,值为 0。
  2. loc,它是一个位置信息,表明这个节点在源 HTML 字符串中的位置,包含行,列,偏移量等信息。
  3. {{ test }} 解析出来的节点会有一个 isStatic 属性,值为 false,表示这是一个动态节点。如果是静态节点,则只会生成一次,并且在后面的阶段一直复用同一个,不用进行 diff 比较。

另外还有一个 tagType 属性,它有 4 个值:

export const enum ElementTypes {
  ELEMENT, // 0 元素节点
  COMPONENT, // 1 组件
  SLOT, // 2 插槽
  TEMPLATE // 3 模板
}

主要用于区分上述四种类型节点。

Transform

在 transform 阶段,Vue 会对 AST 进行一些转换操作,主要是根据不同的 AST 节点添加不同的选项参数,这些参数在 codegen 阶段会用到。下面列举一些比较重要的选项:

cacheHandlers

如果 cacheHandlers 的值为 true,则表示开启事件函数缓存。例如 @click="foo" 默认编译为 { onClick: foo },如果开启了这个选项,则编译为

{ onClick: _cache[0] || (_cache[0] = e => _ctx.foo(e)) }

hoistStatic

hoistStatic 是一个标识符,表示要不要开启静态节点提升。如果值为 true,静态节点将被提升到 render() 函数外面生成,并被命名为 _hoisted_x 变量。

例如 一个文本节点 生成的代码为 const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一个文本节点 ")

下面两张图,前者是 hoistStatic = false,后面是 hoistStatic = true。大家可以在网站上自己试一下。

prefixIdentifiers

这个参数的作用是用于代码生成。例如 {{ foo }} 在 module 模式下生成的代码为 _ctx.foo,而在 function 模式下是 with (this) { ... }。因为在 module 模式下,默认为严格模式,不能使用 with 语句。

PatchFlags

transform 在对 AST 节点进行转换时,会打上 patchflag 参数,这个参数主要用于 diff 比较过程。当 DOM 节点有这个标志并且大于 0,就代表要更新,没有就跳过。

我们来看一下 patchflag 的取值范围:

export const enum PatchFlags {
  // 动态文本节点
  TEXT = 1,

  // 动态 class
  CLASS = 1 << 1, // 2

  // 动态 style
  STYLE = 1 << 2, // 4

  // 动态属性,但不包含类名和样式
  // 如果是组件,则可以包含类名和样式
  PROPS = 1 << 3, // 8

  // 具有动态 key 属性,当 key 改变时,需要进行完整的 diff 比较。
  FULL_PROPS = 1 << 4, // 16

  // 带有监听事件的节点
  HYDRATE_EVENTS = 1 << 5, // 32

  // 一个不会改变子节点顺序的 fragment
  STABLE_FRAGMENT = 1 << 6, // 64

  // 带有 key 属性的 fragment 或部分子字节有 key
  KEYED_FRAGMENT = 1 << 7, // 128

  // 子节点没有 key 的 fragment
  UNKEYED_FRAGMENT = 1 << 8, // 256

  // 一个节点只会进行非 props 比较
  NEED_PATCH = 1 << 9, // 512

  // 动态 slot
  DYNAMIC_SLOTS = 1 << 10, // 1024

  // 静态节点
  HOISTED = -1,

  // 指示在 diff 过程应该要退出优化模式
  BAIL = -2
}

从上述代码可以看出 patchflag 使用一个 11 位的位图来表示不同的值,每个值都有不同的含义。Vue 在 diff 过程会根据不同的 patchflag 使用不同的 patch 方法。

下图是经过 transform 后的 AST:

可以看到 codegenNode、helpers 和 hoists 已经被填充上了相应的值。codegenNode 是生成代码要用到的数据,hoists 存储的是静态节点,helpers 存储的是创建 VNode 的函数名称(其实是 Symbol)。

在正式开始 transform 前,需要创建一个 transformContext,即 transform 上下文。和这三个属性有关的数据和方法如下:

helpers: new Set(),
hoists: [],

// methods
helper(name) {
  context.helpers.add(name)
  return name
},
helperString(name) {
  return `_${helperNameMap[context.helper(name)]}`
},
hoist(exp) {
  context.hoists.push(exp)
  const identifier = createSimpleExpression(
    `_hoisted_${context.hoists.length}`,
    false,
    exp.loc,
    true
  )
  identifier.hoisted = exp
  return identifier
},

我们来看一下具体的 transform 过程是怎样的,用 <p>{{ test }}</p> 来做示例。

这个节点对应的是 transformElement() 转换函数,由于 p 没有绑定动态属性,没有绑定指令,所以重点不在它,而是在 {{ test }} 上。{{ test }} 是一个双花插值表达式,所以将它的 patchFlag 设为 1(动态文本节点),对应的执行代码是 patchFlag |= 1。然后再执行 createVNodeCall() 函数,它的返回值就是这个节点的 codegenNode 值。

node.codegenNode = createVNodeCall(
    context,
    vnodeTag,
    vnodeProps,
    vnodeChildren,
    vnodePatchFlag,
    vnodeDynamicProps,
    vnodeDirectives,
    !!shouldUseBlock,
    false /* disableTracking */,
    node.loc
)

createVNodeCall() 根据这个节点添加了一个 createVNode Symbol 符号,它放在 helpers 里。其实就是要在代码生成阶段引入的帮助函数。

// createVNodeCall() 内部执行过程,已删除多余的代码
context.helper(CREATE_VNODE)

return {
  type: NodeTypes.VNODE_CALL,
  tag,
  props,
  children,
  patchFlag,
  dynamicProps,
  directives,
  isBlock,
  disableTracking,
  loc
}

hoists

一个节点是否添加到 hoists 中,主要看它是不是静态节点,并且需要将 hoistStatic 设为 true。

<div name="test"> // 属性静态节点
  <!-- 这是注释 -->
  <p>{{ test }}</p>
  一个文本节点 // 静态节点
  <div>good job!</div> // 静态节点
</div>

可以看到,上面有三个静态节点,所以 hoists 数组有 3 个值。并且无论静态节点嵌套有多深,都会被提升到 hoists 中。

type 变化

从上图可以看到,最外层的 div 的 type 原来为 1,经过 transform 生成的 codegenNode 中的 type 变成了 13。
这个 13 是代码生成对应的类型 VNODE_CALL。另外还有:

// codegen
VNODE_CALL, // 13
JS_CALL_EXPRESSION, // 14
JS_OBJECT_EXPRESSION, // 15
JS_PROPERTY, // 16
JS_ARRAY_EXPRESSION, // 17
JS_FUNCTION_EXPRESSION, // 18
JS_CONDITIONAL_EXPRESSION, // 19
JS_CACHE_EXPRESSION, // 20

刚才提到的例子 {{ test }},它的 codegenNode 就是通过调用 createVNodeCall() 生成的:

return {
  type: NodeTypes.VNODE_CALL,
  tag,
  props,
  children,
  patchFlag,
  dynamicProps,
  directives,
  isBlock,
  disableTracking,
  loc
}

可以从上述代码看到,type 被设置为 NodeTypes.VNODE_CALL,即 13。

每个不同的节点都由不同的 transform 函数来处理,由于篇幅有限,具体代码请自行查阅。

Codegen

代码生成阶段最后生成了一个字符串,我们把字符串的双引号去掉,看一下具体的内容是什么:

const _Vue = Vue
const { createVNode: _createVNode, createCommentVNode: _createCommentVNode, createTextVNode: _createTextVNode } = _Vue

const _hoisted_1 = { name: "test" }
const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一个文本节点 ")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

return function render(_ctx, _cache) {
  with (_ctx) {
    const { createCommentVNode: _createCommentVNode, toDisplayString: _toDisplayString, createVNode: _createVNode, createTextVNode: _createTextVNode, openBlock: _openBlock, createBlock: _createBlock } = _Vue

    return (_openBlock(), _createBlock("div", _hoisted_1, [
      _createCommentVNode(" 这是注释 "),
      _createVNode("p", null, _toDisplayString(test), 1 /* TEXT */),
      _hoisted_2,
      _hoisted_3
    ]))
  }
}

代码生成模式

可以看到上述代码最后返回一个 render() 函数,作用是生成对应的 VNode。

其实代码生成有两种模式:module 和 function,由标识符 prefixIdentifiers 决定使用哪种模式。

function 模式的特点是:使用 const { helpers... } = Vue 的方式来引入帮助函数,也就是是 createVode()createCommentVNode() 这些函数。向外导出使用 return 返回整个 render() 函数。

module 模式的特点是:使用 es6 模块来导入导出函数,也就是使用 import 和 export。

静态节点

另外还有三个变量是用 _hoisted_ 命名的,后面跟着数字,代表这是第几个静态变量。
再看一下 parse 阶段的 HTML 模板字符串:

<div name="test">
  <!-- 这是注释 -->
  <p>{{ test }}</p>
  一个文本节点
  <div>good job!</div>
</div>

这个示例只有一个动态节点,即 {{ test }},剩下的全是静态节点。从生成的代码中也可以看出,生成的节点和模板中的代码是一一对应的。静态节点的作用就是只生成一次,以后直接复用。

细心的网友可能发现了 _hoisted_2_hoisted_3 变量中都有一个 /*#__PURE__*/ 注释。

这个注释的作用是表示这个函数是纯函数,没有副作用,主要用于 tree-shaking。压缩工具在打包时会将未被使用的代码直接删除(shaking 摇掉)。

再来看一下生成动态节点 {{ test }} 的代码: _createVNode("p", null, _toDisplayString(test), 1 /* TEXT */)

其中 _toDisplayString(test) 的内部实现是:

return val == null
    ? ''
    : isObject(val)
      ? JSON.stringify(val, replacer, 2)
      : String(val)

代码很简单,就是转成字符串输出。

_createVNode("p", null, _toDisplayString(test), 1 /* TEXT */) 最后一个参数 1 就是 transform 添加的 patchflag 了。

帮助函数 helpers

在 transform、codegen 这两个阶段,我们都能看到 helpers 的影子,到底 helpers 是干什么用的?

// Name mapping for runtime helpers that need to be imported from 'vue' in
// generated code. Make sure these are correctly exported in the runtime!
// Using `any` here because TS doesn't allow symbols as index type.
export const helperNameMap: any = {
  [FRAGMENT]: `Fragment`,
  [TELEPORT]: `Teleport`,
  [SUSPENSE]: `Suspense`,
  [KEEP_ALIVE]: `KeepAlive`,
  [BASE_TRANSITION]: `BaseTransition`,
  [OPEN_BLOCK]: `openBlock`,
  [CREATE_BLOCK]: `createBlock`,
  [CREATE_VNODE]: `createVNode`,
  [CREATE_COMMENT]: `createCommentVNode`,
  [CREATE_TEXT]: `createTextVNode`,
  [CREATE_STATIC]: `createStaticVNode`,
  [RESOLVE_COMPONENT]: `resolveComponent`,
  [RESOLVE_DYNAMIC_COMPONENT]: `resolveDynamicComponent`,
  [RESOLVE_DIRECTIVE]: `resolveDirective`,
  [WITH_DIRECTIVES]: `withDirectives`,
  [RENDER_LIST]: `renderList`,
  [RENDER_SLOT]: `renderSlot`,
  [CREATE_SLOTS]: `createSlots`,
  [TO_DISPLAY_STRING]: `toDisplayString`,
  [MERGE_PROPS]: `mergeProps`,
  [TO_HANDLERS]: `toHandlers`,
  [CAMELIZE]: `camelize`,
  [CAPITALIZE]: `capitalize`,
  [SET_BLOCK_TRACKING]: `setBlockTracking`,
  [PUSH_SCOPE_ID]: `pushScopeId`,
  [POP_SCOPE_ID]: `popScopeId`,
  [WITH_SCOPE_ID]: `withScopeId`,
  [WITH_CTX]: `withCtx`
}

export function registerRuntimeHelpers(helpers: any) {
  Object.getOwnPropertySymbols(helpers).forEach(s => {
    helperNameMap[s] = helpers[s]
  })
}

其实帮助函数就是在代码生成时从 Vue 引入的一些函数,以便让程序正常执行,从上面生成的代码中就可以看出来。而 helperNameMap 是默认的映射表名称,这些名称就是要从 Vue 引入的函数名称。

另外,我们还能看到一个注册函数 registerRuntimeHelpers(helpers: any(),它是干什么用的呢?

我们知道编译模块 compiler-core 是平台无关的,而 compiler-dom 是浏览器相关的编译模块。为了能在浏览器正常运行 Vue 程序,就得把浏览器相关的 Vue 数据和函数导入进来。
registerRuntimeHelpers(helpers: any() 正是用来做这件事的,从 compiler-dom 的 runtimeHelpers.ts 文件就能看出来:

registerRuntimeHelpers({
  [V_MODEL_RADIO]: `vModelRadio`,
  [V_MODEL_CHECKBOX]: `vModelCheckbox`,
  [V_MODEL_TEXT]: `vModelText`,
  [V_MODEL_SELECT]: `vModelSelect`,
  [V_MODEL_DYNAMIC]: `vModelDynamic`,
  [V_ON_WITH_MODIFIERS]: `withModifiers`,
  [V_ON_WITH_KEYS]: `withKeys`,
  [V_SHOW]: `vShow`,
  [TRANSITION]: `Transition`,
  [TRANSITION_GROUP]: `TransitionGroup`
})

它运行 registerRuntimeHelpers(helpers: any(),往映射表注入了浏览器相关的部分函数。

helpers 是怎么使用的呢?

在 parse 阶段,解析到不同节点时会生成对应的 type。

在 transform 阶段,会生成一个 helpers,它是一个 set 数据结构。每当它转换 AST 时,都会根据 AST 节点的 type 添加不同的 helper 函数。

例如,假设它现在正在转换的是一个注释节点,它会执行 context.helper(CREATE_COMMENT),内部实现相当于 helpers.add('createCommentVNode')。然后在 codegen 阶段,遍历 helpers,将程序需要的函数从 Vue 里导入,代码实现如下:

// 这是 module 模式
`import { ${ast.helpers
  .map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`)
  .join(', ')} } from ${JSON.stringify(runtimeModuleName)}\n`

如何生成代码?

从 codegen.ts 文件中,可以看到很多代码生成函数:

generate() // 代码生成入口文件
genFunctionExpression() // 生成函数表达式
genNode() // 生成 Vnode 节点
...

生成代码则是根据不同的 AST 节点调用不同的代码生成函数,最终将代码字符串拼在一起,输出一个完整的代码字符串。

老规矩,还是看一个例子:

const _hoisted_1 = { name: "test" }
const _hoisted_2 = /*#__PURE__*/_createTextVNode(" 一个文本节点 ")
const _hoisted_3 = /*#__PURE__*/_createVNode("div", null, "good job!", -1 /* HOISTED */)

看一下这段代码是怎么生成的,首先执行 genHoists(ast.hoists, context),将 transform 生成的静态节点数组 hoists 作为第一个参数。genHoists() 内部实现:

hoists.forEach((exp, i) => {
    if (exp) {
        push(`const _hoisted_${i + 1} = `);
        genNode(exp, context);
        newline();
    }
})

从上述代码可以看到,遍历 hoists 数组,调用 genNode(exp, context)genNode() 根据不同的 type 执行不同的函数。

const _hoisted_1 = { name: "test" }

这一行代码中的 const _hoisted_1 = genHoists() 生成,{ name: "test" }genObjectExpression() 生成。
同理,剩下的两行代码生成过程也是如此,只是最终调用的函数不同。

Vue3 系列文章

查看原文

赞 5 收藏 2 评论 0

谭光志 发布了文章 · 7月24日

用递归的思想实现二叉树前、中、后序迭代遍历

先复习一下前、中、后遍历的顺序:

  1. 前序遍历顺序:中-左-右
  2. 中序遍历顺序:左-中-右
  3. 后序遍历顺序:左-右-中

用递归来写二叉树遍历是非常简单的,例如前序遍历的代码如下:

const result = []
function preorderTraversal(node) {
    if (!node) return null
    result.push(node.val)
    preorderTraversal(node.left)
    preorderTraversal(node.right)
}

preorderTraversal(root)

我们都知道,在调用函数时,系统会在栈中为每个函数维护相应的变量(参数、局部变量、返回地址等等)。

例如有 a,b,c 三个函数,先调用 a,a 又调用 b,b 最后调用 c。此时的调用栈如图所示:

为什么要说这个呢?因为递归遍历的执行过程就是这样的,只不过是函数不停的调用自身,直到遇到递归出口(终止条件)。

举个例子,现在要用递归前序遍历以下二叉树:

   1
    \
     2
    /
   3

它的遍历顺序为 1-2-3,调用栈如图所示:

理解了递归调用栈的情况,再来看看怎么利用递归思想实现前序迭代遍历:

function preorderTraversal(root) {
    const result = []
    // 用一个数组 stack 模拟调用栈
    const stack = []
    let node = root
    while (stack.length || node) {
        // 递归遍历节点的左子树,直到空为止
        while (node) {
            result.push(node.val)
            stack.push(node)
            node = node.left
        }
        // 跳出循环时 node 为空,由于前序遍历的特性
        // 当前 node 节点的上一个节点必定是它的父亲节点
        // 前序遍历是中-左-右,现在左子树已经到头了,该遍历父节点的右子树了
        // 所以要弹出父节点,从它的右子树开始新一轮循环
        node = stack.pop()
        node = node.right
    }

    return result
}

再看一个具体的示例,用迭代遍历跑一遍:

            1
          /    \
         2      3
        /  \   /  \
       4    5 6    7
  1. 初始节点 node 为 1
  2. while 遍历完它的左子节点
  3. 当前 stack == [1,2,4]
  4. 初始节点已经遍历完它下面的最后一个左子节点了,即节点 4 的左子节点,所以现在要开始遍历 4 的右子节点
  5. 弹出节点 4 并从它的右子节点开始新的循环
  6. 由于节点 4 的右子节点为空,所以不会进入 while 循环,然后弹出节点 4 的父节点 2
  7. 再从节点 2 的右子节点开始循环

看到这是不是已经发现了这个迭代遍历的过程和递归遍历的过程一模一样?

而且用递归的思想来实现迭代遍历,优点在于好理解,以后再遇到这种问题马上就能想起来怎么做了。

中序遍历

中序遍历和前序遍历差不多,区别在于节点出栈时,才将节点的值推入到 result 中。

function inorderTraversal(root) {
    const result = []
    const stack = []
    let node = root
    while (stack.length || node) {
        while (node) {
            stack.push(node)
            node = node.left
        }
        
        node = stack.pop()
        result.push(node.val)
        node = node.right
    }
    
    return result
}

后序遍历

前序遍历过程为中-左-右,逆前序遍历过程就是将遍历左右子树的顺序调换一下,即中-右-左。

然后再看一下后序遍历的过程左-右-中,可以看出逆前序遍历顺序的倒序就是后序遍历的顺序。

利用这一特点写出的后序遍历代码如下:

function postorderTraversal(root) {
    const result = []
    const stack = []
    let node = root
    while (stack.length || node) {
        while (node) {
            result.push(node.val)
            stack.push(node)
            node = node.right // 原来是 node.left,这里换成 node.right
        }

        node = stack.pop()
        node = node.left // 原来是 node.right,这里换成 node.left
    }
    
    return result.reverse() // 逆前序遍历顺序的倒序就是后序遍历的顺序
}

参考资料

他来了,他带着他的三兄弟来了,前中后序遍历统一的算法

查看原文

赞 4 收藏 2 评论 0

谭光志 发布了文章 · 7月17日

vue-mergeable-table 动态生成的可合并行列的表格

项目地址

可以根据数据动态生成可合并行列的表格。

image.png

文档

数据选项

options: {
    cols: 6, // 要生成的表格列数
    rows: 7, // 要生成的表格行数 这个表是 7 * 6
    data: [ // 表格数据,生成表格后将数据按顺序一一填充到表格
        { content: '测试1', id: 10, a: 100, b: 100 }, // content 字段为表格的内容
        // 需要合并的数据需要填入一个 merge 对象
        // row col 为起始行列,rowspan 和 colspan 为合并的行数,值默认为 1,为 1 时可以不填
        // 这代表这个数据要放在 row 为 3,col 为 1 的单元格上,并且占 3 行 1列
        { content: '测试2', merge: { row: 3, col: 0, rowspan: 3 } },
        { content: '测试3' },
    ],
},

事件

组件可监听 click 事件,回调函数事件参数格式:

// 当前点击的数据 行数据 行索引 列索引,返回的行数据 行索引 列索引均以合并后的表格为准
handleClick(data, rowData, row, col) {
    console.log(data)
    console.log(rowData)
    console.log(row)
    console.log(col)
},

在线 DEMO

注意事项

假设你创建了一个 4*4 的表格,如下图所示。

image.png

现在每一行你都合并了 3 列,这时表格会发生崩溃现象。

image.png

这是表格自身的问题,和组件无关。要解决此问题,只需再加一行没有合并的表格即可。

image.png

使用

在单文件组件中引用

npm i vue-mergeable-table
import VueMergeableTable from 'vue-mergeable-table'

Vue.use(VueMergeableTable)
<template>
    <div id="app">
        <VueMergeableTable :options="options" @click="handleClick" />
        // 或者 <vue-mergeable-table :options="options"/>
    </div>
</template>

在HTML文件中直接引用

使用的是dist目录中的 vue-mergeable-table.js

<div id="app">
    <vue-mergeable-table :options="options" @click="handleClick"></vue-mergeable-table>
</div>
查看原文

赞 3 收藏 2 评论 0

谭光志 发布了文章 · 7月11日

Vue3 响应式原理

前言

学习 Vue3.0 源码必须对以下知识有所了解:

  1. proxy reflect iterator
  2. map weakmap set weakset symbol

这些知识可以看一下阮一峰老师的《ES6 入门教程》

如果不会 ts,我觉得影响不大,了解一下泛型就可以了。因为我就没用过 TS,但是不影响看代码。

阅读源码,建议先过一遍该模块下的 API,了解一下有哪些功能。然后再看一遍相关的单元测试,单元测试一般会把所有的功能细节都测一边。对源码的功能有所了解后,再去阅读源码的细节,效果更好。

proxy 术语

const p = new Proxy(target, handler)
  • handler,包含捕捉器(trap)的占位符对象,可译为处理器对象。
  • target,被 Proxy 代理的对象。

友情提醒

在阅读源码的过程中,要时刻问自己三个问题:

  1. 这是什么?
  2. 为什么要这样?为什么不那样?
  3. 有没有更好的实现方式?

正所谓知其然,知其所以然。

阅读源码除了要了解一个库具有什么特性,还要了解它为什么要这样设计,并且要问自己能不能用更好的方式去实现它。
如果只是单纯的停留在“是什么”这个阶段,对你可能没有什么帮助。就像看流水账似的,看完就忘,你得去思考,才能理解得更加深刻。

正文

reactivity 模块是 Vue3.0 的响应式系统,它有以下几个文件:

baseHandlers.ts
collectionHandlers.ts
computed.ts
effect.ts
index.ts
operations.ts
reactive.ts
ref.ts

接下来按重要程度顺序来讲解一下各个文件的 API 用法和实现。

reactive.ts 文件

在 Vue.2x 中,使用 Object.defineProperty() 对对象进行监听。而在 Vue3.0 中,改用 Proxy 进行监听。Proxy 比起 Object.defineProperty() 有如下优势:

  1. 可以监听属性的增删操作。
  2. 可以监听数组某个索引值的变化以及数组长度的变化。

reactive()

reactive() 的作用主要是将目标转化为响应式的 proxy 实例。例如:

const obj = {
    count: 0
}

const proxy = reactive(obj)

如果是嵌套的对象,会继续递归将子对象转为响应式对象。

reactive() 是向用户暴露的 API,它真正执行的是 createReactiveObject() 函数:

// 根据 target 生成 proxy 实例
function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>
) {
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
  
  // target is already a Proxy, return it.
  // exception: calling readonly() on a reactive object
  if (
    target[ReactiveFlags.raw] &&
    !(isReadonly && target[ReactiveFlags.isReactive])
  ) {
    return target
  }
  // target already has corresponding Proxy
  if (
    hasOwn(target, isReadonly ? ReactiveFlags.readonly : ReactiveFlags.reactive)
  ) {
    return isReadonly
      ? target[ReactiveFlags.readonly]
      : target[ReactiveFlags.reactive]
  }
  // only a whitelist of value types can be observed.
  if (!canObserve(target)) {
    return target
  }
 
  const observed = new Proxy(
    target,
    // 根据是否 Set, Map, WeakMap, WeakSet 来决定 proxy 的 handler 参数
    collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers
  )
  // 在原始对象上定义一个属性(只读则为 "__v_readonly",否则为 "__v_reactive"),这个属性的值就是根据原始对象生成的 proxy 实例。
  def(
    target,
    isReadonly ? ReactiveFlags.readonly : ReactiveFlags.reactive,
    observed
  )
  
  return observed
}

这个函数的处理逻辑如下:

  1. 如果 target 不是一个对象,返回 target。
  2. 如果 target 已经是 proxy 实例,返回 target。
  3. 如果 target 不是一个可观察的对象,返回 target。
  4. 生成 proxy 实例,并在原始对象 target 上添加一个属性(只读则为 __v_readonly,否则为 __v_reactive),指向这个 proxy 实例,最后返回这个实例。添加这个属性就是为了在第 2 步做判断用的,防止对同一对象重复监听。

其中第 3、4 点需要单独拎出来讲一讲。

什么是可观察的对象

const canObserve = (value: Target): boolean => {
  return (
    !value[ReactiveFlags.skip] &&
    isObservableType(toRawType(value)) &&
    !Object.isFrozen(value)
  )
}

canObserve() 函数就是用来判断 value 是否是可观察的对象,满足以下条件才是可观察的对象:

  1. ReactiveFlags.skip 的值不能为 __v_skip__v_skip 是用来定义这个对象是否可跳过,即不监听。
  2. target 的类型必须为下列值之一 Object,Array,Map,Set,WeakMap,WeakSet 才可被监听。
  3. 不能是冻结的对象。

传递给 proxy 的处理器对象是什么

根据上面的代码可以看出来,在生成 proxy 实例时,处理器对象是根据一个三元表达式产生的:

// collectionTypes 的值为 Set, Map, WeakMap, WeakSet
collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers

这个三元表达式非常简单,如果是普通的对象 ObjectArray,处理器对象就使用 baseHandlers;如果是 Set, Map, WeakMap, WeakSet 中的一个,就使用 collectionHandlers。

collectionHandlers 和 baseHandlers 是从 collectionHandlers.tsbaseHandlers.ts 处引入的,这里先放一放,接下来再讲。

有多少种 proxy 实例

createReactiveObject() 根据不同的参数,可以创建多种不同的 proxy 实例:

  1. 完全响应式的 proxy 实例,如果有嵌套对象,会递归调用 reactive()
  2. 只读的 proxy 实例。
  3. 浅层响应的 proxy 实例,即一个对象只有第一层的属性是响应式的。
  4. 只读的浅层响应的 proxy 实例。

浅层响应的 proxy 实例是什么?

之所以有浅层响应的 proxy 实例,是因为 proxy 只代理对象的第一层属性,更深层的属性是不会代理的。如果确实需要生成完全响应式的 proxy 实例,就得递归调用 reactive()。不过这个过程是内部自动执行的,用户感知不到。

其他一些函数介绍

// 判断 value 是否是响应式的
export function isReactive(value: unknown): boolean {
  if (isReadonly(value)) {
    return isReactive((value as Target)[ReactiveFlags.raw])
  }
  return !!(value && (value as Target)[ReactiveFlags.isReactive])
}
// 判断 value 是否是只读的
export function isReadonly(value: unknown): boolean {
  return !!(value && (value as Target)[ReactiveFlags.isReadonly])
}
// 判断 value 是否是 proxy 实例
export function isProxy(value: unknown): boolean {
  return isReactive(value) || isReadonly(value)
}

// 将响应式数据转为原始数据,如果不是响应数据,则返回源数据
export function toRaw<T>(observed: T): T {
  return (
    (observed && toRaw((observed as Target)[ReactiveFlags.raw])) || observed
  )
}

// 给 value 设置 skip 属性,跳过代理,让数据不可被代理
export function markRaw<T extends object>(value: T): T {
  def(value, ReactiveFlags.skip, true)
  return value
}

baseHandlers.ts 文件

baseHandlers.ts 文件中针对 4 种 proxy 实例定义了不对的处理器。
由于它们之间差别不大,所以在这只讲解完全响应式的处理器对象:

export const mutableHandlers: ProxyHandler<object> = {
  get,
  set,
  deleteProperty,
  has,
  ownKeys
}

处理器对五种操作进行了拦截,分别是:

  1. get 属性读取
  2. set 属性设置
  3. deleteProperty 删除属性
  4. has 是否拥有某个属性
  5. ownKeys

其中 ownKeys 可拦截以下操作:

  1. Object.getOwnPropertyNames()
  2. Object.getOwnPropertySymbols()
  3. Object.keys()
  4. Reflect.ownKeys()

其中 get、has、ownKeys 操作会收集依赖,set、deleteProperty 操作会触发依赖。

get

get 属性的处理器是用 createGetter() 函数创建的:

// /*#__PURE__*/ 标识此为纯函数 不会有副作用 方便做 tree-shaking
const get = /*#__PURE__*/ createGetter()

function createGetter(isReadonly = false, shallow = false) {
  return function get(target: object, key: string | symbol, receiver: object) {
    // target 是否是响应式对象
    if (key === ReactiveFlags.isReactive) {
      return !isReadonly
      // target 是否是只读对象
    } else if (key === ReactiveFlags.isReadonly) {
      return isReadonly
    } else if (
      // 如果访问的 key 是 __v_raw,并且 receiver == target.__v_readonly || receiver == target.__v_reactive
      // 则直接返回 target
      key === ReactiveFlags.raw &&
      receiver ===
        (isReadonly
          ? (target as any).__v_readonly
          : (target as any).__v_reactive)
    ) {
      return target
    }

    const targetIsArray = isArray(target)
    // 如果 target 是数组并且 key 属于三个方法之一 ['includes', 'indexOf', 'lastIndexOf'],即触发了这三个操作之一
    if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
      return Reflect.get(arrayInstrumentations, key, receiver)
    }
    // 不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。
    // 如果不用 Reflect 来获取,在监听数组时可以会有某些地方会出错
    // 具体请看文章《Vue3 中的数据侦测》——https://juejin.im/post/5d99be7c6fb9a04e1e7baa34#heading-10
    const res = Reflect.get(target, key, receiver)

    // 如果 key 是 symbol 并且属于 symbol 的内置方法之一,或者访问的是原型对象,直接返回结果,不收集依赖。
    if ((isSymbol(key) && builtInSymbols.has(key)) || key === '__proto__') {
      return res
    }

    // 只读对象不收集依赖
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }
    
    // 浅层响应立即返回,不递归调用 reactive()
    if (shallow) {
      return res
    }

    // 如果是 ref 对象,则返回真正的值,即 ref.value,数组除外。
    if (isRef(res)) {
      // ref unwrapping, only for Objects, not for Arrays.
      return targetIsArray ? res : res.value
    }

    if (isObject(res)) {
      // 由于 proxy 只能代理一层,所以 target[key] 的值如果是对象,就继续对其进行代理
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

这个函数的处理逻辑看代码注释应该就能明白,其中有几个点需要单独说一下:

  1. Reflect.get()
  2. 数组的处理
  3. builtInSymbols.has(key) 为 true 或原型对象不收集依赖

Reflect.get()

Reflect.get() 方法与从对象 (target[key]) 中读取属性类似,但它是通过一个函数执行来操作的。

为什么直接用 target[key] 就能得到值,却还要用 Reflect.get(target, key, receiver) 来多倒一手呢?

先来看个简单的示例:

const p = new Proxy([1, 2, 3], {
    get(target, key, receiver) {
        return target[key]
    },
    set(target, key, value, receiver) {
        target[key] = value
    }
})

p.push(100)

运行这段代码会报错:

Uncaught TypeError: 'set' on proxy: trap returned falsish for property '3'

但做一些小改动就能够正常运行:

const p = new Proxy([1, 2, 3], {
    get(target, key, receiver) {
        return target[key]
    },
    set(target, key, value, receiver) {
        target[key] = value
        return true // 新增一行 return true
    }
})

p.push(100)

这段代码可以正常运行。为什么呢?

区别在于新的这段代码在 set() 方法上多了一个 return true。我在 MDN 上查找到的解释是这样的:

set() 方法应当返回一个布尔值。

  • 返回 true 代表属性设置成功。
  • 在严格模式下,如果 set() 方法返回 false,那么会抛出一个 TypeError 异常。

这时我又试了一下直接执行 p[3] = 100,发现能正常运行,只有执行 push 方法才报错。到这一步,我心中已经有答案了。为了验证我的猜想,我在代码上加了 console.log(),把代码执行过程的一些属性打印出来。

const p = new Proxy([1, 2, 3], {
    get(target, key, receiver) {
        console.log('get: ', key)
        return target[key]
    },
    set(target, key, value, receiver) {
        console.log('set: ', key, value)
        target[key] = value
        return true
    }
})

p.push(100)

// get:  push
// get:  length
// set:  3 100
// set:  length 4

从上面的代码可以发现执行 push 操作时,还会访问 length 属性。推测执行过程如下:根据 length 的值,得出最后的索引,再设置新的置,最后再改变 length

结合 MDN 的解释,我的推测是数组的原生方法应该是运行在严格模式下的(如果有网友知道真相,请在评论区留言)。因为在 JS 中很多代码在非严格模式和严格模式下都能正常运行,只是严格模式会给你报个错。就跟这次情况一样,最后设置 length 属性的时候报错,但结果还是正常的。如果不想报错,就得每次都返回 true

然后再看一下 Reflect.set() 的返回值说明:

返回一个 Boolean 值表明是否成功设置属性。

所以上面代码可以改成这样:

const p = new Proxy([1, 2, 3], {
    get(target, key, receiver) {
        console.log('get: ', key)
        return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
        console.log('set: ', key, value)
        return Reflect.set(target, key, value, receiver)
    }
})

p.push(100)

另外,不管 Proxy 怎么修改默认行为,你总可以在 Reflect 上获取默认行为。

通过上面的示例,不难理解为什么要通过 Reflect.set() 来代替 Proxy 完成默认操作了。同理,Reflect.get() 也一样。

数组的处理

// 如果 target 是数组并且 key 属于三个方法之一 ['includes', 'indexOf', 'lastIndexOf'],即触发了这三个操作之一
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
  return Reflect.get(arrayInstrumentations, key, receiver)
}

在执行数组的 includes, indexOf, lastIndexOf 方法时,会把目标对象转为 arrayInstrumentations 再执行。

const arrayInstrumentations: Record<string, Function> = {}
;['includes', 'indexOf', 'lastIndexOf'].forEach(key => {
  arrayInstrumentations[key] = function(...args: any[]): any {
    // 如果 target 对象中指定了 getter,receiver 则为 getter 调用时的 this 值。
    // 所以这里的 this 指向 receiver,即 proxy 实例,toRaw 为了取得原始数据
    const arr = toRaw(this) as any
    // 对数组的每个值进行 track 操作,收集依赖
    for (let i = 0, l = (this as any).length; i < l; i++) {
      track(arr, TrackOpTypes.GET, i + '')
    }
    // we run the method using the original args first (which may be reactive)
    // 参数有可能是响应式的,函数执行后返回值为 -1 或 false,那就用参数的原始值再试一遍
    const res = arr[key](...args)
    if (res === -1 || res === false) {
      // if that didn't work, run it again using raw values.
      return arr[key](...args.map(toRaw))
    } else {
      return res
    }
  }
})

从上述代码可以看出,Vue3.0 对 includes, indexOf, lastIndexOf 进行了封装,除了返回原有方法的结果外,还会对数组的每个值进行依赖收集。

builtInSymbols.has(key) 为 true 或原型对象不收集依赖

const p = new Proxy({}, {
    get(target, key, receiver) {
        console.log('get: ', key)
        return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
        console.log('set: ', key, value)
        return Reflect.set(target, key, value, receiver)
    }
})

p.toString() // get:  toString
             // get:  Symbol(Symbol.toStringTag)
p.__proto__  // get:  __proto__

p.toString() 的执行结果来看,它会触发两次 get,一次是我们想要的,一次是我们不想要的(我还没搞明白为什么会有 Symbol(Symbol.toStringTag),如果有网友知道,请在评论区留言)。所以就有了这个判断: builtInSymbols.has(key)true 就直接返回,防止重复收集依赖。

再看 p.__proto__ 的执行结果,也触发了一次 get 操作。一般来说,没有场景需要单独访问原型,访问原型都是为了访问原型上的方法,例如 p.__proto__.toString() 这样使用,所以 key 为 __proto__ 的时候也要跳过,不收集依赖。

set

const set = /*#__PURE__*/ createSetter()

// 参考文档《Vue3 中的数据侦测》——https://juejin.im/post/5d99be7c6fb9a04e1e7baa34#heading-10
function createSetter(shallow = false) {
  return function set(
    target: object,
    key: string | symbol,
    value: unknown,
    receiver: object
  ): boolean {
    const oldValue = (target as any)[key]
    if (!shallow) {
      value = toRaw(value)
      // 如果原来的值是 ref,但新的值不是,将新的值赋给 ref.value 即可。
      if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
        oldValue.value = value
        return true
      }
    } else {
      // in shallow mode, objects are set as-is regardless of reactive or not
    }

    const hadKey = hasOwn(target, key)
    const result = Reflect.set(target, key, value, receiver)
    // don't trigger if target is something up in the prototype chain of original
    if (target === toRaw(receiver)) {
      if (!hadKey) {
        // 如果 target 没有 key,就代表是新增操作,需要触发依赖
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        // 如果新旧值不相等,才触发依赖
        // 什么时候会有新旧值相等的情况?例如监听一个数组,执行 push 操作,会触发多次 setter
        // 第一次 setter 是新加的值 第二次是由于新加的值导致 length 改变
        // 但由于 length 也是自身属性,所以 value === oldValue
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }
}

set() 的函数处理逻辑反而没那么难,看注释即可。track()trigger() 将放在下面和 effect.ts 文件一起讲解。

deleteProperty、has、ownKeys

function deleteProperty(target: object, key: string | symbol): boolean {
  const hadKey = hasOwn(target, key)
  const oldValue = (target as any)[key]
  const result = Reflect.deleteProperty(target, key)
  // 如果删除结果为 true 并且 target 拥有这个 key 就触发依赖
  if (result && hadKey) {
    trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue)
  }
  return result
}

function has(target: object, key: string | symbol): boolean {
  const result = Reflect.has(target, key)
  track(target, TrackOpTypes.HAS, key)
  return result
}

function ownKeys(target: object): (string | number | symbol)[] {
  track(target, TrackOpTypes.ITERATE, ITERATE_KEY)
  return Reflect.ownKeys(target)
}

这三个函数比较简单,看代码即可。

effect.ts 文件

等把 effect.ts 文件讲解完,响应式模块基本上差不多结束了。

effect()

effect() 主要和响应式的对象结合使用。

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  // 如果已经是 effect 函数,取得原来的 fn
  if (isEffect(fn)) {
    fn = fn.raw
  }
  
  const effect = createReactiveEffect(fn, options)
  // 如果 lazy 为 false,马上执行一次
  // 计算属性的 lazy 为 true
  if (!options.lazy) {
    effect()
  }
  
  return effect
}

真正创建 effect 的是 createReactiveEffect() 函数。

let uid = 0

function createReactiveEffect<T = any>(
  fn: (...args: any[]) => T,
  options: ReactiveEffectOptions
): ReactiveEffect<T> {
  // reactiveEffect() 返回一个新的 effect,这个新的 effect 执行后
  // 会将自己设为 activeEffect,然后再执行 fn 函数,如果在 fn 函数里对响应式属性进行读取
  // 会触发响应式属性 get 操作,从而收集依赖,而收集的这个依赖函数就是 activeEffect
  const effect = function reactiveEffect(...args: unknown[]): unknown {
    if (!effect.active) {
      return options.scheduler ? undefined : fn(...args)
    }
    // 为了避免递归循环,所以要检测一下
    if (!effectStack.includes(effect)) {
      // 清空依赖
      cleanup(effect)
      try {
        enableTracking()
        effectStack.push(effect)
        activeEffect = effect
        return fn(...args)
      } finally {
        // track 将依赖函数 activeEffect 添加到对应的 dep 中,然后在 finally 中将 activeEffect
        // 重置为上一个 effect 的值
        effectStack.pop()
        resetTracking()
        activeEffect = effectStack[effectStack.length - 1]
        
      }
    }
  } as ReactiveEffect
  effect.id = uid++
  effect._isEffect = true
  effect.active = true // 用于判断当前 effect 是否激活,有一个 stop() 来将它设为 false
  effect.raw = fn
  effect.deps = []
  effect.options = options
  
  return effect
}

其中 cleanup(effect) 的作用是让 effect 关联下的所有 dep 实例清空 effect,即清除这个依赖函数。

function cleanup(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

从代码中可以看出来,真正的依赖函数是 activeEffect。执行 track() 收集的依赖就是 activeEffect。
趁热打铁,现在我们再来看一下 track()trigger() 函数。

track()

// 依赖收集
export function track(target: object, type: TrackOpTypes, key: unknown) {
  // activeEffect 为空,代表没有依赖,直接返回
  if (!shouldTrack || activeEffect === undefined) {
    return
  }
  // targetMap 依赖管理中心,用于收集依赖和触发依赖
  let depsMap = targetMap.get(target)
  // targetMap 为每个 target 建立一个 map
  // 每个 target 的 key 对应着一个 dep
  // 然后用 dep 来收集依赖函数,当监听的 key 值发生变化时,触发 dep 中的依赖函数
  // 类似于这样
  // targetMap(weakmap) = {
  //     target1(map): {
  //       key1(dep): (fn1,fn2,fn3...)
  //       key2(dep): (fn1,fn2,fn3...)
  //     },
  //     target2(map): {
  //       key1(dep): (fn1,fn2,fn3...)
  //       key2(dep): (fn1,fn2,fn3...)
  //     },
  // }
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }
  
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
    // 开发环境下会触发 onTrack 事件
    if (__DEV__ && activeEffect.options.onTrack) {
      activeEffect.options.onTrack({
        effect: activeEffect,
        target,
        type,
        key
      })
    }
  }
}

targetMap 是一个 WeakMap 实例。

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。

弱引用是什么意思呢?

let obj = { a: 1 }
const map = new WeakMap()
map.set(obj, '测试')
obj = null

当 obj 置为空后,对于 { a: 1 } 的引用已经为零了,下一次垃圾回收时就会把 weakmap 中的对象回收。

但如果把 weakmap 换成 map 数据结构,即使把 obj 置空,{ a: 1 } 依然不会被回收,因为 map 数据结构是强引用,它现在还被 map 引用着。

trigger()

// 触发依赖
export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  
  const depsMap = targetMap.get(target)
  // 如果没有收集过依赖,直接返回
  if (!depsMap) {
    // never been tracked
    return
  }
  
  // 对收集的依赖进行分类,分为普通的依赖或计算属性依赖
  // effects 收集的是普通的依赖 computedRunners 收集的是计算属性的依赖
  // 两个队列都是 set 结构,为了避免重复收集依赖
  const effects = new Set<ReactiveEffect>()
  const computedRunners = new Set<ReactiveEffect>()
  
  const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => {
        // effect !== activeEffect 避免重复收集依赖
        if (effect !== activeEffect || !shouldTrack) {
          // 计算属性
          if (effect.options.computed) {
            computedRunners.add(effect)
          } else {
            effects.add(effect)
          }
        } else {
          // the effect mutated its own dependency during its execution.
          // this can be caused by operations like foo.value++
          // do not trigger or we end in an infinite loop
        }
      })
    }
  }

  // 在值被清空前,往相应的队列添加 target 所有的依赖
  if (type === TriggerOpTypes.CLEAR) {
    // collection being cleared
    // trigger all effects for target
    depsMap.forEach(add)
  } else if (key === 'length' && isArray(target)) { // 当数组的 length 属性变化时触发
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        add(dep)
      }
    })
  } else {
    // schedule runs for SET | ADD | DELETE
    // 如果不符合以上两个 if 条件,并且 key !== undefined,往相应的队列添加依赖
    if (key !== void 0) {
      add(depsMap.get(key))
    }
    // also run for iteration key on ADD | DELETE | Map.SET
    const isAddOrDelete =
      type === TriggerOpTypes.ADD ||
      (type === TriggerOpTypes.DELETE && !isArray(target))

    if (
      isAddOrDelete ||
      (type === TriggerOpTypes.SET && target instanceof Map)
    ) {
      add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY))
    }
    
    if (isAddOrDelete && target instanceof Map) {
      add(depsMap.get(MAP_KEY_ITERATE_KEY))
    }
  }

  const run = (effect: ReactiveEffect) => {
    if (__DEV__ && effect.options.onTrigger) {
      effect.options.onTrigger({
        effect,
        target,
        key,
        type,
        newValue,
        oldValue,
        oldTarget
      })
    }
    if (effect.options.scheduler) {
      // 如果 scheduler 存在则调用 scheduler,计算属性拥有 scheduler
      effect.options.scheduler(effect)
    } else {
      effect()
    }
  }

  // Important: computed effects must be run first so that computed getters
  // can be invalidated before any normal effects that depend on them are run.
  computedRunners.forEach(run)
  // 触发依赖函数
  effects.forEach(run)
}

对依赖函数进行分类后,需要先运行计算属性的依赖,因为其他普通的依赖函数可能包含了计算属性。先执行计算属性的依赖能保证普通依赖执行时能得到最新的计算属性的值。

track() 和 trigger() 中的 type 有什么用?

这个 type 取值范围就定义在 operations.ts 文件中:

// track 的类型
export const enum TrackOpTypes {
  GET = 'get', // get 操作
  HAS = 'has', // has 操作
  ITERATE = 'iterate' // ownKeys 操作
}

// trigger 的类型
export const enum TriggerOpTypes {
  SET = 'set', // 设置操作,将旧值设置为新值
  ADD = 'add', // 新增操作,添加一个新的值 例如给对象新增一个值 数组的 push 操作
  DELETE = 'delete', // 删除操作 例如对象的 delete 操作,数组的 pop 操作
  CLEAR = 'clear' // 用于 Map 和 Set 的 clear 操作。
}

type 主要用于标识 track()trigger() 的类型。

trigger() 中的连续判断代码

if (key !== void 0) {
  add(depsMap.get(key))
}
// also run for iteration key on ADD | DELETE | Map.SET
const isAddOrDelete =
  type === TriggerOpTypes.ADD ||
  (type === TriggerOpTypes.DELETE && !isArray(target))

if (
  isAddOrDelete ||
  (type === TriggerOpTypes.SET && target instanceof Map)
) {
  add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY))
}

if (isAddOrDelete && target instanceof Map) {
  add(depsMap.get(MAP_KEY_ITERATE_KEY))
}

trigger() 中有这么一段连续判断的代码,它们作用是什么呢?其实它们是用于判断数组/集合这种数据结构比较特别的操作。
看个示例:

let dummy
const counter = reactive([])
effect(() => (dummy = counter.join()))
counter.push(1)

effect(() => (dummy = counter.join())) 生成一个依赖,并且自执行一次。
在执行函数里的代码 counter.join() 时,会访问数组的多个属性,分别是 joinlength,同时触发 track() 收集依赖。也就是说,数组的 joinlength 属性都收集了一个依赖。

当执行 counter.push(1) 这段代码时,实际上是将数组的索引 0 对应的值设为 1。这一点,可以通过打 debugger 从上下文环境看出来,其中 key 为 0,即数组的索引,值为 1。

设置值后,由于是新增操作,执行 trigger(target, TriggerOpTypes.ADD, key, value)。但由上文可知,只有数组的 key 为 joinlength 时,才有依赖,key 为 0 是没有依赖的。

从上面两个图可以看出来,只有 joinlength 属性才有对应的依赖。

这个时候,trigger() 的一连串 if 语句就起作用了,其中有一个 if 语句是这样的:

if (
  isAddOrDelete ||
  (type === TriggerOpTypes.SET && target instanceof Map)
) {
  add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY))
}

如果 target 是一个数组,就添加 length 属性对应的依赖到队列中。也就是说 key 为 0 的情况下使用 length 对应的依赖。

另外,还有一个巧妙的地方。待执行依赖的队列是一个 set 数据结构。如果 key 为 0 有对应的依赖,同时 length 也有对应的依赖,就会添加两次依赖,但由于队列是 set,具有自动去重的效果,避免了重复执行。

示例

仅看代码和文字,是很难理解响应式数据和 track()trigger() 是怎么配合的。所以我们要配合示例来理解:

let dummy
const counter = reactive({ num: 0 })
effect(() => (dummy = counter.num))

console.log(dummy == 0)
counter.num = 7
console.log(dummy == 7)

上述代码执行过程如下:

  1. { num: 0 } 进行监听,返回一个 proxy 实例,即 counter。
  2. effect(fn) 创建一个依赖,并且在创建时会执行一次 fn
  3. fn() 读取 num 的值,并赋值给 dummy。
  4. 读取属性这个操作会触发 proxy 的属性读取拦截操作,在拦截操作里会去收集依赖,这个依赖是步骤 2 产生的。
  5. counter.num = 7 这个操作会触发 proxy 的属性设置拦截操作,在这个拦截操作里,除了把新的值返回,还会触发刚才收集的依赖。在这个依赖里把 counter.num 赋值给 dummy(num 的值已经变为 7)。

用图来表示,大概这样的:

collectionHandlers.ts 文件

collectionHandlers.ts 文件包含了 MapWeakMapSetWeakSet 的处理器对象,分别对应完全响应式的 proxy 实例、浅层响应的 proxy 实例、只读 proxy 实例。这里只讲解对应完全响应式的 proxy 实例的处理器对象:

export const mutableCollectionHandlers: ProxyHandler<CollectionTypes> = {
  get: createInstrumentationGetter(false, false)
}

为什么只监听 get 操作,set has 等操作呢?不着急,先看一个示例:

const p = new Proxy(new Map(), {
    get(target, key, receiver) {
        console.log('get: ', key)
        return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
        console.log('set: ', key, value)
        return Reflect.set(target, key, value, receiver)
    }
})

p.set('ab', 100) // Uncaught TypeError: Method Map.prototype.set called on incompatible receiver [object Object]

运行上面的代码会报错。其实这和 Map Set 的内部实现有关,必须通过 this 才能访问它们的数据。但是通过 Reflect 反射的时候,target 内部的 this 其实是指向 proxy 实例的,所以就不难理解为什么会报错了。

那怎么解决这个问题?通过源码可以发现,在 Vue3.0 中是通过代理的方式来实现对 Map Set 等数据结构监听的:

function createInstrumentationGetter(isReadonly: boolean, shallow: boolean) {
  const instrumentations = shallow
    ? shallowInstrumentations
    : isReadonly
      ? readonlyInstrumentations
      : mutableInstrumentations

  return (
    target: CollectionTypes,
    key: string | symbol,
    receiver: CollectionTypes
  ) => {
    // 这三个 if 判断和 baseHandlers 的处理方式一样
    if (key === ReactiveFlags.isReactive) {
      return !isReadonly
    } else if (key === ReactiveFlags.isReadonly) {
      return isReadonly
    } else if (key === ReactiveFlags.raw) {
      return target
    }

    return Reflect.get(
      hasOwn(instrumentations, key) && key in target
        ? instrumentations
        : target,
      key,
      receiver
    )
  }
}

把最后一行代码简化一下:

target = hasOwn(instrumentations, key) && key in target? instrumentations : target
return Reflect.get(target, key, receiver);

其中 instrumentations 的内容是:

const mutableInstrumentations: Record<string, Function> = {
  get(this: MapTypes, key: unknown) {
    return get(this, key, toReactive)
  },
  get size() {
    return size((this as unknown) as IterableCollections)
  },
  has,
  add,
  set,
  delete: deleteEntry,
  clear,
  forEach: createForEach(false, false)
}

从代码可以看到,原来真正的处理器对象是 mutableInstrumentations。现在再看一个示例:

const proxy = reactive(new Map())
proxy.set('key', 100)

生成 proxy 实例后,执行 proxy.set('key', 100)proxy.set 这个操作会触发 proxy 的属性读取拦截操作。

打断点可以看到,此时的 key 为 set。拦截了 set 操作后,调用 Reflect.get(target, key, receiver),这个时候的 target 已经不是原来的 target 了,而是 mutableInstrumentations 对象。也就是说,最终执行的是 mutableInstrumentations.set()

接下来再看看 mutableInstrumentations 的各个处理器逻辑。

get

// 如果 value 是对象,则返回一个响应式对象(`reactive(value)`),否则直接返回 value。
const toReactive = <T extends unknown>(value: T): T =>
  isObject(value) ? reactive(value) : value
  
get(this: MapTypes, key: unknown) {
    // this 指向 proxy
    return get(this, key, toReactive)
}
  
function get(
  target: MapTypes,
  key: unknown,
  wrap: typeof toReactive | typeof toReadonly | typeof toShallow
) {
  target = toRaw(target)
  const rawKey = toRaw(key)
  // 如果 key 是响应式的,额外收集一次依赖
  if (key !== rawKey) {
    track(target, TrackOpTypes.GET, key)
  }
  track(target, TrackOpTypes.GET, rawKey)
  // 使用 target 原型上的方法
  const { has, get } = getProto(target)
  // 原始 key 和响应式的 key 都试一遍
  if (has.call(target, key)) {
    // 读取的值要使用包装函数处理一下
    return wrap(get.call(target, key))
  } else if (has.call(target, rawKey)) {
    return wrap(get.call(target, rawKey))
  }
}

get 的处理逻辑很简单,拦截 get 之后,调用 get(this, key, toReactive)

set

function set(this: MapTypes, key: unknown, value: unknown) {
  value = toRaw(value)
  // 取得原始数据
  const target = toRaw(this)
  // 使用 target 原型上的方法
  const { has, get, set } = getProto(target)

  let hadKey = has.call(target, key)
  if (!hadKey) {
    key = toRaw(key)
    hadKey = has.call(target, key)
  } else if (__DEV__) {
    checkIdentityKeys(target, has, key)
  }

  const oldValue = get.call(target, key)
  const result = set.call(target, key, value)
  // 防止重复触发依赖,如果 key 已存在就不触发依赖
  if (!hadKey) {
    trigger(target, TriggerOpTypes.ADD, key, value)
  } else if (hasChanged(value, oldValue)) {
    // 如果新旧值相等,也不会触发依赖
    trigger(target, TriggerOpTypes.SET, key, value, oldValue)
  }
  return result
}

set 的处理逻辑也较为简单,配合注释一目了然。

还有剩下的 hasadddelete 等方法就不讲解了,代码行数比较少,逻辑也很简单,建议自行阅读。

ref.ts 文件

const convert = <T extends unknown>(val: T): T =>
  isObject(val) ? reactive(val) : val
  
export function ref(value?: unknown) {
  return createRef(value)
}
  
function createRef(rawValue: unknown, shallow = false) {
  // 如果已经是 ref 对象了,直接返回原值
  if (isRef(rawValue)) {
    return rawValue
  }
  
  // 如果不是浅层响应并且 rawValue 是个对象,调用 reactive(rawValue)
  let value = shallow ? rawValue : convert(rawValue)
  
  const r = {
    __v_isRef: true, // 用于标识这是一个 ref 对象,防止重复监听 ref 对象
    get value() {
      // 读取值时收集依赖
      track(r, TrackOpTypes.GET, 'value')
      return value
    },
    set value(newVal) {
      if (hasChanged(toRaw(newVal), rawValue)) {
        rawValue = newVal
        value = shallow ? newVal : convert(newVal)
        // 设置值时触发依赖
        trigger(
          r,
          TriggerOpTypes.SET,
          'value',
          __DEV__ ? { newValue: newVal } : void 0
        )
      }
    }
  }
  
  return r
}

在 Vue2.x 中,基本数值类型是不能监听的。但在 Vue3.0 中通过 ref() 可以实现这一效果。

const r = ref(0)
effect(() => console.log(r.value)) // 打印 0
r.value++ // 打印 1

ref() 会把 0 转成一个 ref 对象。如果给 ref(value) 传的值是个对象,在函数内部会调用 reactive(value) 将其转为 proxy 实例。

computed.ts 文件

export function computed<T>(
  options: WritableComputedOptions<T>
): WritableComputedRef<T>
export function computed<T>(
  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>
) {
  let getter: ComputedGetter<T>
  let setter: ComputedSetter<T>
  // 如果 getterOrOptions 是个函数,则是不可被配置的,setter 设为空函数
  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions
    setter = __DEV__
      ? () => {
          console.warn('Write operation failed: computed value is readonly')
        }
      : NOOP
  } else {
    // 如果是个对象,则可读可写
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  // dirty 用于判断计算属性依赖的响应式属性有没有被改变
  let dirty = true
  let value: T
  let computed: ComputedRef<T>

  const runner = effect(getter, {
    lazy: true, // lazy 为 true,生成的 effect 不会马上执行
    // mark effect as computed so that it gets priority during trigger
    computed: true,
    scheduler: () => { // 调度器
      // trigger 时,计算属性执行的是 effect.options.scheduler(effect) 而不是 effect()
      if (!dirty) {
        dirty = true
        trigger(computed, TriggerOpTypes.SET, 'value')
      }
    }
  })
  
  computed = {
    __v_isRef: true,
    // expose effect so computed can be stopped
    effect: runner,
    get value() {
      if (dirty) {
        value = runner()
        dirty = false
      }
      
      track(computed, TrackOpTypes.GET, 'value')
      return value
    },
    set value(newValue: T) {
      setter(newValue)
    }
  } as any
  return computed
}

下面通过一个示例,来讲解一下 computed 是怎么运作的:

const value = reactive({})
const cValue = computed(() => value.foo)
console.log(cValue.value === undefined)
value.foo = 1
console.log(cValue.value === 1)
  1. 生成一个 proxy 实例 value。
  2. computed() 生成计算属性对象,当对 cValue 进行取值时(cValue.value),根据 dirty 判断是否需要运行 effect 函数进行取值,如果 dirty 为 false,直接把值返回。
  3. 在 effect 函数里将 effect 设为 activeEffect,并运行 getter(() => value.foo) 取值。在取值过程中,读取 foo 的值(value.foo)。
  4. 这会触发 get 属性读取拦截操作,进而触发 track 收集依赖,而收集的依赖函数就是第 3 步产生的 activeEffect。
  5. 当响应式属性进行重新赋值时(value.foo = 1),就会 trigger 这个 activeEffect 函数。
  6. 然后调用 scheduler() 将 dirty 设为 true,这样 computed 下次求值时会重新执行 effect 函数进行取值。

index.ts 文件

index.ts 文件向外导出 reactivity 模块的 API。

Vue3 系列文章

参考资料

查看原文

赞 3 收藏 1 评论 0

谭光志 发布了文章 · 6月10日

webpack + express 实现文件精确缓存

由于最近开发的个人博客(Vue + node)在使用过程中,发现网络加载有点慢,所以打算对它进行一次优化。本次优化的目标如下:

  1. index.html 设置成 no-cache,这样每次请求的时候都会比对一下 index.html 文件有没变化,如果没变化就使用缓存,有变化就使用新的 index.html 文件。
  2. 其他所有文件一律使用长缓存,例如设置成缓存一年 maxAge: 1000 * 60 * 60 * 24 * 365
  3. 前端代码使用 webpack 打包,根据文件内容生成对应的文件名,每次重新打包时只有内容发生了变化,文件名才会发生变化。

以上三点结合,就能实现文件的精确缓存。

换句话说,在一年内,如果我的个人博客没有进行任何更新,那同一台电脑在这段时间内访问网站不会发起任何请求;如果有某个文件更新了,只会请求新的文件,旧的文件依旧从缓存读取。

小知识

  • max-age: 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。在这个时间前,浏览器读取文件不会发出新请求,而是直接使用缓存。
  • 指定 no-cache 表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性。

webpack 打包

根据文件内容生成文件名

通过配置 output 的 filename 属性可以实现这个需求。filename 属性的值选项中有一个 [contenthash],它将根据文件内容创建出唯一 hash。当文件内容发生变化时,[contenthash] 也会发生变化。

output: {
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].[contenthash].js',
    path: path.resolve(__dirname, '../dist'),
},

提取第三方库

由于引入的第三方库一般都比较稳定,不会经常改变。所以将它们单独提取出来,作为长期缓存是一个更好的选择。
这里需要使用 webpack4 的 splitChunk 插件 cacheGroups 选项。

optimization: {
      runtimeChunk: {
        name: 'manifest' // 将 webpack 的 runtime 代码拆分为一个单独的 chunk。
    },
    splitChunks: {
        cacheGroups: {
            vendor: {
                name: 'chunk-vendors',
                test: /[\\/]node_modules[\\/]/,
                priority: -10,
                chunks: 'initial'
            },
            common: {
                name: 'chunk-common',
                minChunks: 2,
                priority: -20,
                chunks: 'initial',
                reuseExistingChunk: true
            }
        },
    }
},
  • test: 用于控制哪些模块被这个缓存组匹配到。原封不动传递出去的话,它默认会选择所有的模块。可以传递的值类型:RegExp、String和Function;
  • priority:表示抽取权重,数字越大表示优先级越高。因为一个 module 可能会满足多个 cacheGroups 的条件,那么抽取到哪个就由权重最高的说了算;
  • reuseExistingChunk:表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的。
  • minChunks(默认是1):在分割之前,这个代码块最小应该被引用的次数(译注:保证代码块复用性,默认配置的策略是不需要多次引用也可以被分割)
  • chunks (默认是async) :initial、async和all
  • name(打包的chunks的名字):字符串或者函数(函数可以根据条件自定义名字)

除了提取第三方库外,结合 Vue 使用 import 动态引入组件还能实现按需加载。

express 设置

app.use((req, res, next) => { // 将 index.html 设为 no-cache
     if(req.url == '/') {
         res.setHeader('Cache-control', 'no-cache')
     }

     next()
 })

 app.use(express.static('dist', {
     etag: false,
     maxAge: 1000 * 60 * 60 * 24 * 365, // 缓存一年
 })) // 将dist设为根目录

详细的代码可以看一下我的个人博客项目

参考资料

查看原文

赞 8 收藏 7 评论 0

谭光志 发布了文章 · 5月16日

半小时搞懂 HTTP、HTTPS和HTTP2

本文将尽量用通俗易懂的方式来向读者讲述 HTTP 的知识。

另外,建议在学习 HTTP 知识的时候,利用 Chrome 开发者工具来做实践,这可以帮助你理解得更深刻。

在这里插入图片描述

(此图在网上找来的,侵删)

HTTP 概述

HTTP 超文本传输​​协议是位于 TCP/IP 体系结构中的应用层协议,它是万维网数据通信的基础。

当我们访问一个网站时,需要通过统一资源定位符(uniform resource locator,URL)来定位服务器并获取资源。

<协议>://<域名>:<端口>/<路径>

一个 URL 的一般形式通常如上所示(http://test.com/index.html ),现在最常用的协议就是 HTTP,HTTP 的默认端口是 80,通常可以省略。

在这里插入图片描述

HTTP/1.1

HTTP/1.1 是目前使用最广泛的版本,一般没有特别标明版本都是指 HTTP/1.1。

HTTP 连接建立过程

我们来看一下在浏览器输入 URL 后获取 HTML 页面的过程。

  1. 先通过域名系统(Domain Name System,DNS)查询将域名转换为 IP 地址。即将 test.com 转换为 221.239.100.30 这一过程。
  2. 通过三次握手(稍后会讲)建立 TCP 连接。
  3. 发起 HTTP 请求。
  4. 目标服务器接收到 HTTP 请求并处理。
  5. 目标服务器往浏览器发回 HTTP 响应。
  6. 浏览器解析并渲染页面。

下图中的 RTT 为往返时延(Round-Trip Time: 往返时延。表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延)。

在这里插入图片描述

HTTP 连接拆除过程

所有 HTTP 客户端(浏览器)、服务器都可在任意时刻关闭 TCP 连接。通常会在一条报文结束时关闭连接,但出错的时候,也可能在首部行的中间或其他任意位置关闭连接。

TCP 三次握手和四次挥手

由于 HTTP 是基于 TCP 的,所以打算在这补充一下 TCP 连接建立和拆除的过程。

首先,我们需要了解一些 TCP 报文段的字段和标志位:

  1. 32 比特的序号字段和确认号字段,TCP 字节流每一个字节都按顺序编号。确认号是接收方期望从对方收到的下一字节的序号。
  2. ACK 标志位,用于指示确认字段中的值是有效的 ACK=1 有效,ACK=0 无效。
  3. SYN 标志位,用于连接建立,SYN 为 1 时,表明这是一个请求建立连接报文。
  4. FIN 标志位,用于连接拆除,FIN 为 1 时,表明发送方数据已发送完毕,并要求释放连接。

在这里插入图片描述

TCP 三次握手建立连接

TCP 标准规定,ACK 报文段可以携带数据,但不携带数据就不用消耗序号。

  1. 客户端发送一个不包含应用层数据的 TCP 报文段,首部的 SYN 置为 1,随机选择一个初始序号(一般为 0)放在 TCP 报文段的序号字段中。(SYN 为 1 的时候,不能携带数据,但要消耗掉一个序号)
  2. TCP 报文段到达服务器主机后,服务器提取报文段,并为该 TCP 连接分配缓存和变量。然后向客户端发送允许连接的 ACK 报文段(不包含应用层数据)。这个报文段的首部包含 4 个信息:ACK 置 为 1,SYN 置为 1;确认号字段置为客户端的序号 + 1;随机选择自己的初始序号(一般为 0)。
  3. 收到服务器的 TCP 响应报文段后,客户端也要为该 TCP 连接分配缓存和变量,并向服务器发送一个 ACK 报文段。这个报文段将服务器端的序号 + 1 放置在确认号字段中,用来对服务器允许连接的报文段进行响应,因为连接已经建立,所以 SYN 置为 0。最后一个阶段,报文段可以携带客户到服务器的数据。并且以后的每一个报文段,SYN 都置为 0。

下图是一个具体的示例:

在这里插入图片描述

(此截图是我使用 Wireshark 抓包工具截取的 TCP 报文段截图)。

TCP 四次挥手拆除连接

FIN 报文段即使不携带数据,也要消耗序号。

  1. 客户端发送一个 FIN 置为 1 的报文段。
  2. 服务器回送一个确认报文段。
  3. 服务器发送 FIN 置为 1 的报文段。
  4. 客户端回送一个确认报文段。

TCP 为什么是四次挥手,而不是三次?

  1. 当 A 给 B 发送 FIN 报文时,代表 A 不再发送报文,但仍可以接收报文。
  2. B 可能还有数据需要发送,因此先发送 ACK 报文,告知 A “我知道你想断开连接的请求了”。这样 A 便不会因为没有收到应答而继续发送断开连接的请求(即 FIN 报文)。
  3. B 在处理完数据后,就向 A 发送一个 FIN 报文,然后进入 LAST_ACK 阶段(超时等待)。
  4. A 向 B 发送 ACK 报文,双方都断开连接。

参考资料:

HTTP 报文格式

HTTP 报文由请求行、首部、实体主体组成,它们之间由 CRLF(回车换行符) 分隔开。

注意:实体包括首部(也称为实体首部)和实体主体,sp 即是空格 space

在这里插入图片描述

请求行和首部是由 ASCII 文本组成的,实体主体是可选的,可以为空也可以是任意二进制数据。

请求报文和响应报文的格式基本相同。

请求报文格式

<method> <request-URL> <version>
<headers>
<entity-body>

响应报文格式

<version> <status> <reason-phrase>
<headers>
<entity-body>

一个请求或响应报文由以下字段组成

  1. 请求方法,客户端希望服务器对资源执行的动作。
  2. 请求 URL,命名了所请求的资源。
  3. 协议版本,报文所使用的 HTTP 版本。
  4. 状态码,这三位数字描述了请求过程中所发生的情况。
  5. 原因短语,数字状态码的可读版本(例如上面的响应示例跟在 200 后面的 OK,一般按规范写最好)。
  6. 首部,可以有零或多个首部。
  7. 实体的主体部分,可以为空也可以包含任意二进制数据。

一个 HTTP 请求示例

GET /2.app.js HTTP/1.1
Host: 118.190.217.8:3389
Connection: keep-alive
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
Accept: */*
Referer: http://118.190.217.8:3389/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9

一个 HTTP 响应示例

HTTP/1.1 200 OK
X-Powered-By: Express
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Sat, 07 Mar 2020 03:52:30 GMT
ETag: W/"253e-170b31f7de7"
Content-Type: application/javascript; charset=UTF-8
Vary: Accept-Encoding
Content-Encoding: gzip
Date: Fri, 15 May 2020 05:38:05 GMT
Connection: keep-alive
Transfer-Encoding: chunked

方法

方法描述
GET从服务器获取一份文档
HEAD只从服务器获取文档的头部
POST向服务器发送需要处理的数据
PUT将请求的数据部分存储在服务器上
TRACE对可能经过代理服务器传送到服务器上去的报文进行追踪
OPTIONS决定可以在服务器上执行哪些方法
DELETE从服务器上删除一份文档
GET 和 HEAD

其中 GET 和 HEAD 被称为安全方法,因为它们是幂等的(如果一个请求不管执行多少次,其结果都是一样的,这个请求就是幂等的),类似于 POST 就不是幂等的。

HEAD 方法和 GET 方法很类似,但服务器在响应中只返回首部。这就允许客户端在未获取实际资源的情况下,对资源的首部进行检查。使用 HEAD,可以:

  1. 在不获取资源的情况下了解资源的情况。
  2. 通过查看响应状态码,看看某个对象是否存在。
  3. 通过查看首部,了解测试资源是否被修改了。

服务器开发者必须确保返回的首部与 GET 请求所返回的首部完全相同。遵循 HTTP/1.1 规范,就必须实现 HEAD 方法。

PUT

与 GET 方法从服务器读取文档相反,PUT 方法会向服务器写入文档。PUT 方法的语义就是让服务器用请求的主体部分来创建一个由所请求的 URL 命名的新文档。 如果那个文档已存在,就覆盖它。

POST

POST 方法通常用来向服务器发送表单数据。

TRACE

客户端发起一个请求时,这个请求可能要穿过路由器、防火墙、代理、网关等。每个中间节点都可能会修改原始的 HTTP 请求,TRACE 方法允许客户端在最终发起请求时,看看它变成了什么样子。

TRACE 请求会在目的服务器端发起一个“环回”诊断。行程最后一站的服务器会弹回一条 TRACE 响应,并在响应主体中携带它收到的原始请求报文。 这样客户端就可以查看在所有中间 HTTP 应用程序组成的请求/响应链上,原始报文是否被毁坏或修改过。

在这里插入图片描述

TRACE 方法主要用于诊断,用于验证请求是否如愿穿过了请求/响应链。它也是一种工具,用来查看代理和其他应用程序对用户请求所产生的效果。 TRACE 请求中不能带有实体的主体部分。TRACE 响应的实体主体部分包含了响应服务器收到的请求的精确副本。

OPTIONS

OPTIONS 方法请求 Web 服务器告知其支持的各种功能。

DELETE

DELETE 方法就是让服务器删除请求 URL 所指定的资源。

状态码

整体范围已定义范围分类
100~199100~101信息提示
200~299200~206成功
300~399300~305重定向
400~499400~415客户端错误
500~599500~505服务器错误
300~399 重定向状态码

重定向状态码要么告诉客户端使用替代位置来访问他们感兴趣的资源,要么提供一个替代的响应而不是资源的内容。 如果资源已被移动,可以发送一个重定向状态码和一个可选的 Location 首部来告知客户端资源已被移走,以及现在在哪里可以找到它。这样,浏览器可以在不打扰使用者的情况下,透明地转入新的位置。

400~499 客户端错误状态码

有时客户端会发送一些服务器无法处理的东西,例如格式错误的请求报文、一个不存在的 URL。

500~599 服务器错误状态码

有时客户端发送了一条有效请求,服务器自身却出错了。

首部

首部和方法共同配合工作,决定了客户端和服务器能做什么事情。

首部分类

  1. 通用首部,可以出现在请求或响应报文中。
  2. 请求首部,提供更多有关请求的信息。
  3. 响应首部,提供更多有关响应的信息。
  4. 实体首部,描述主体的长度和内容,或者资源自身。
  5. 扩展首部,规范中没有定义的新首部。
通用首部

有些首部提供了与报文相关的最基本信息,它们被称为通用首部。以下是一些常见的通用首部:

在这里插入图片描述

请求首部

请求首部是只在请求报文中有意义的首部,用于说明请求的详情。以下是一些常见的请求首部:

在这里插入图片描述

响应首部

响应首部让服务器为客户端提供了一些额外的信息。

实体首部

实体首部提供了有关实体及其内容的大量信息,从有关对象类型的信息,到能够对资源使用的各种有效的请求方法。

例如内容首部,提供了与实体内容有关的特定信息,说明了其类型、尺寸以及处理它所需的其他有用信息。
另外,通用的缓存首部说明了如何或什么时候进行缓存。实体的缓存首部提供了与被缓存实体有关的信息。
在这里插入图片描述

性能优化

1. 减少 HTTP 请求

每发起一个 HTTP 请求,都得经历三次握手建立 TCP 连接,如果连接只用来交换少量数据,这个过程就会严重降低 HTTP 性能。所以我们可以将多个小文件合成一个大文件,从而减少 HTTP 请求次数。

其实由于持久连接(重用 TCP 连接,以消除连接及关闭时延;HTTP/1.1 默认开启持久连接)的存在,每个新请求不一定都需要建立一个新的 TCP 连接。但是,浏览器处理完一个 HTTP 请求才能发起下一个,所以在 TCP 连接数没达到浏览器规定的上限时,还是会建立新的 TCP 连接。从这点来看,减少 HTTP 请求仍然是有必要的。

2. 静态资源使用 CDN

内容分发网络(CDN)是一组分布在多个不同地理位置的 Web 服务器。我们都知道,当服务器离用户越远时,延迟越高。CDN 就是为了解决这一问题,在多个位置部署服务器,让用户离服务器更近,从而缩短请求时间。

3. 善用缓存

为了避免用户每次访问网站都得请求文件,我们可以通过添加 Expires 头来控制这一行为。Expires 设置了一个时间,只要在这个时间之前,浏览器都不会请求文件,而是直接使用缓存。

不过这样会产生一个问题,当文件更新了怎么办?怎么通知浏览器重新请求文件?

可以通过更新页面中引用的资源链接地址,让浏览器主动放弃缓存,加载新资源。

具体做法是把资源地址 URL 的修改与文件内容关联起来,也就是说,只有文件内容变化,才会导致相应 URL 的变更,从而实现文件级别的精确缓存控制。什么东西与文件内容相关呢?我们会很自然的联想到利用数据摘要要算法对文件求摘要信息,摘要信息与文件内容一一对应,就有了一种可以精确到单个文件粒度的缓存控制依据了。

参考资料:

4. 压缩文件

压缩文件可以减少文件下载时间,让用户体验性更好。

gzip 是目前最流行和最有效的压缩方法。可以通过向 HTTP 请求头中的 Accept-Encoding 头添加 gzip 标识来开启这一功能。当然,服务器也得支持这一功能。

举个例子,我用 Vue 开发的项目构建后生成的 app.js 文件大小为 1.4MB,使用 gzip 压缩后只有 573KB,体积减少了将近 60%。

5. 通过 max-age 和 no-cache 实现文件精确缓存

通用消息头部 Cache-Control 其中有两个选项:

  1. max-age: 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。在这个时间前,浏览器读取文件不会发出新请求,而是直接使用缓存。
  2. no-cache: 指定 no-cache 表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性。

我们可以将那些长期不变的静态资源设置一个非常长的缓存时间,例如设置成缓存一年。

然后将 index.html 文件设置成 no-cache。这样每次访问网站时,浏览器都会询问 index.html 是否有更新,如果没有,就使用旧的 index.html 文件。如果有更新,就读取新的 index.html 文件。当加载新的 index.html 时,也会去加载里面新的 URL 资源。

例如 index.html 原来引用了 a.jsb.js,现在更新了变成 a.jsc.js。那就只会加载 c.js 文件。

具体请看 webpack + express 实现文件精确缓存

HTTPS

HTTPS 是最流行的 HTTP 安全形式,由网景公司首创,所有主要的浏览器和服务器都支持此协议。 使用 HTTPS 时,所有的 HTTP 请求和响应数据在发送之前,都要进行加密。加密可以使用 SSL 或 TLS。
在这里插入图片描述
SSL/TLS 协议作用在 HTTP 协议之下,对于上层应用来说,原来的发送/接收数据流程不变,这就很好地兼容了老的 HTTP 协议。由于 SSL/TLS 差别不大,下面统一使用 SSL。

要想了解 HTTPS 为何安全,还得继续了解一下这些概念:加密算法摘要算法数字签名数字证书

加密算法

对称密钥密码体制

对称密钥密码体制,即加密密钥和解密密钥是使用相同的密码体制。对称密钥加密技术的缺点之一就是发送者和接收者在对话之前,一定要有一个共享的密钥,所以不太安全。

公钥密码体制

公钥密码体制使用不同的加密密钥与解密密钥。公钥密码体制产生的主要原因有两个:一是对称密钥密码体制的密钥分配问题,二是对数字签名的需求。

在公钥密码体制中,加密密钥是公开的,解密密钥是需要保密的,加密算法和解密算法也是公开的。

公钥密码体制的加密和解密有如下特点:

  1. 密钥对产生器产生出接收者 B 的一对密钥,即加密密钥 PK 和解密密钥 SK。
  2. 发送者 A 用 B 的公钥 PK 作为加密密钥来加密信息,B 接收后用解密密钥 SK 解密。

![\[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uWP5p7So-1589532545350)(../imgs/is3.png)\]](https://img-blog.csdnimg.cn/2...

使用对称密钥时,由于双方使用同样的密钥,因此在通信信道上可以进行一对一的双向保密通信,双方都可以用同一个密钥加密解密。

使用公开密钥时,在通信信道上可以是多对一的单向保密信道。即可以有多人持有 B 的公钥,但只有 B 才能解密。

摘要算法

摘要算法的主要特征是加密过程不需要密钥,并且经过加密的数据无法被解密,目前可以被解密逆向的只有CRC32算法,只有输入相同的明文数据经过相同的消息摘要算法才能得到相同的密文。

数字签名

用加密系统对报文进行签名,以说明是谁编写的报文,同时证明报文未被篡改过,这种技术称为数字签名

数字签名是附加在报文上的特殊加密校验码。使用数字签名的好处有:

  1. 签名可以证明是作者编写了这条报文。只有作者才会有最机密的私有密钥,因此,只有作者才能计算出这些校验和。
  2. 签名可以防止报文被篡改,如果有人在报文传输过程中对其进行了修改,校验和就不再匹配了。

数字签名通常是用非对称公开密钥技术产生的。

在这里插入图片描述

看上图,任何人都能用 A 的公钥 PK 对密文进行 E 运算后得到 A 发送的明文。可见这种通信并非为了保密,而是为了进行签名和核实签名,即确认此信息是 A 发送的(使用 A 的密钥进行加密的报文,只有使用 A 的公钥才能正确解密)。 但上述过程仅对报文进行了签名,对报文 X 本身却未保密,所以要采用下图的方法,同时实现秘密通信和数字签名。

在这里插入图片描述

数字证书

假如你想访问一个网站,怎么确保对方给你的公钥是你想访问的网站的公钥,而不是被中间人篡改过的?

数字证书的出现就是为了解决这个问题,它是由数字证书认证机构颁发的,用来证明公钥拥有者的身份。换句话说,数字证书的作用就相当于人的身份证,身份证证明了张三就是张三,而不是别人。

数字证书一般包含以下内容

  1. 对象的名称(人、服务器、组织等);
  2. 过期时间;
  3. 证书发布者(由谁为证书担保);
  4. 来自证书发布者的数字签名;
  5. 对象的公钥;
  6. 对象和所用签名算法的描述性信息。

任何人都可以创建一个数字证书,但由谁来担保才是重点。

数字证书的数字签名计算过程

  1. 用摘要算法对数字证书的内容计算出摘要;
  2. 用数字证书的私钥对摘要进行加密得到数字签名。

在这里插入图片描述

当浏览器收到证书时,会对签名颁发机构进行验证,如果颁发机构是个很有权威的公共签名机构,浏览器可能就知道其公开密钥了(浏览器会预装很多签名颁发机构的证书)。如果对签名颁发机构一无所知,浏览器通常会向用户显示一个对话框,看看他是否相信这个签名发布者。

因为数字证书的公钥是公开的,任何人都可以用公钥解密出数字证书的数字签名的摘要,然后再用同样的摘要算法对证书内容进行摘要计算,将得出的摘要和解密后的摘要作对比,如果内容一致则说明这个证书没有被篡改过,可以信任。

这个过程是建立在被大家所认可的证书机构之上得到的公钥,所以这是一种安全的方式。

在这里插入图片描述

HTTPS 连接建立过程

HTTPS 连接建立过程和 HTTP 差不多,区别在于 HTTP(默认端口 80) 请求只要在 TCP 连接建立后就可以发起,而 HTTPS(默认端口 443) 在 TCP 连接建立后,还需要经历 SSL 协议握手,成功后才能发起请求。

在这里插入图片描述

在这里插入图片描述

我知道肯定会有人不满足于简化版的 SSL 握手过程,所以我找了一篇文章SSL/TLS 握手过程详解,这篇文章非常详细的讲解了 SSL 握手的每一步骤。建议有兴趣的同学看一看。

HTTP/2

HTTP/2 是 HTTP/1.x 的扩展,而非替代。所以 HTTP 的语义不变,提供的功能不变,HTTP 方法、状态码、URL 和首部字段等这些核心概念也不变。

之所以要递增一个大版本到 2.0,主要是因为它改变了客户端与服务器之间交换数据的方式。HTTP 2.0 增加了新的二进制分帧数据层,而这一层并不兼容之前的 HTTP 1.x 服务器及客户端——是谓 2.0。

HTTP/2 连接建立过程

现在的主流浏览器 HTTP/2 的实现都是基于 SSL/TLS 的,也就是说使用 HTTP/2 的网站都是 HTTPS 协议的,所以本文只讨论基于 SSL/TLS 的 HTTP/2 连接建立过程。

基于 SSL/TLS 的 HTTP/2 连接建立过程和 HTTPS 差不多。在 SSL/TLS 握手协商过程中,客户端在 ClientHello 消息中设置 ALPN(应用层协议协商)扩展来表明期望使用 HTTP/2 协议,服务器用同样的方式回复。通过这种方式,HTTP/2 在 SSL/TLS 握手协商过程中就建立起来了。

HTTP/1.1 的问题

1. 队头阻塞

在 HTTP 请求应答过程中,如果出现了某种情况,导致响应一直未能完成,那后面所有的请求就会一直阻塞着,这种情况叫队头阻塞。

2. 低效的 TCP 利用

由于 TCP 慢启动机制,导致每个 TCP 连接在一开始的时候传输速率都不高,在处理多个请求后,才会慢慢达到“合适”的速率。对于请求数据量很小的 HTTP 请求来说,这种情况就是种灾难。

3. 臃肿的消息首部

HTTP/1.1 的首部无法压缩,再加上 cookie 的存在,经常会出现首部大小比请求数据大小还大的情况。

4. 受限的优先级设置

HTTP/1.1 无法为重要的资源指定优先级,每个 HTTP 请求都是一视同仁。

在继续讨论 HTTP/2 的新功能之前,先把 HTTP/1.1 的问题列出来是有意义的。因为 HTTP/2 的某些新功能就是为了解决上述某些问题而产生的。

二进制分帧层

HTTP/2 是基于帧的协议。采用分帧是为了将重要信息封装起来,让协议的解析方可以轻松阅读、解析并还原信息。

而 HTTP/1.1 是以文本分隔的。解析 HTTP/1.1 不需要什么高科技,但往往速度慢且容易出错。你需要不断地读入字节,直到遇到分隔符 CRLF 为止,同时还要考虑不守规矩的客户端,它只会发送 LF。

解析 HTTP/1.1 的请求或响应还会遇到以下问题:

  1. 一次只能处理一个请求或响应,完成之前不能停止解析。
  2. 无法预判解析需要多少内存。

HTTP/2 有了帧,处理协议的程序就能预先知道会收到什么,并且 HTTP/2 有表示帧长度的字段。

在这里插入图片描述

帧结构

 +-----------------------------------------------+
 |                 Length (24)                   |
 +---------------+---------------+---------------+
 |   Type (8)    |   Flags (8)   |
 +-+-------------+---------------+-------------------------------+
 |R|                 Stream Identifier (31)                      |
 +=+=============================================================+
 |                   Frame Payload (0...)                      ...
 +---------------------------------------------------------------+
名称长度描述
Length3 字节表示帧负载的长度,取值范围为 (2 的 14 次方)至 (2 的 24 次方 - 1)。(2 的 14 次方) 16384 字节是默认的最大帧大小,如果需要更大的帧,必须在 SETTINGS 帧中设置
Type1 字节当前帧类型(见下表)
Flags1 字节具体帧类型的标识
R1 位保留位,不要设置,否则可能会带来严重的后果
Stream Identifier31 位每个流的唯一 ID
Frame Payload长度可变真实的帧内容,长度是在 Length 字段中设置的

由于 HTTP/2 是分帧的,请求和响应都可以多路复用,有助于解决类似类似队头阻塞的问题。

帧类型

名称ID描述
DATA0x0传输流的核心内容
HEADERS0x1包含 HTTP 首部,和可选的优先级参数
PRIORITY0x2指示或更改流的优先级和依赖
RST_STREAM0x3允许一端停止流(通常由于错误导致的)
SETTINGS0x4协商连接级参数
PUSH_PROMISE0x5提示客户端,服务器要推送些东西
PING0x6测试连接可用性和往返时延(RTT)
GOAWAY0x7告诉另一端,当前的端已结束
WINDOW_UPDATE0x8协商一端将要接收多少字节(用于流量控制)
CONTINUATION0x9用以扩展 HEADERS 模块

多路复用

在 HTTP/1.1 中,如果客户端想发送多个并行的请求,那么必须使用多个 TCP 连接。

而 HTTP/2 的二进制分帧层突破了这一限制,所有的请求和响应都在同一个 TCP 连接上发送:客户端和服务器把 HTTP 消息分解成多个帧,然后乱序发送,最后在另一端再根据流 ID 重新组合起来。

这个机制为 HTTP 带来了巨大的性能提升,因为:

  • 可以并行交错地发送请求,请求之间互不影响;
  • 可以并行交错地发送响应,响应之间互不干扰;
  • 只使用一个连接即可并行发送多个请求和响应;
  • 消除不必要的延迟,从而减少页面加载的时间;
  • 不必再为绕过 HTTP 1.x 限制而多做很多工作;

在这里插入图片描述

HTTP/2 规范对流的定义是:HTTP/2 连接上独立的、双向的帧序列交换。如果客户端想要发出请求,它会开启一个新流,然后服务器在这个流上回复。 由于有分帧,所以多个请求和响应可以交错,而不会互相阻塞。流 ID 用来标识帧所属的流。

客户端到服务器的 HTTP/2 连接建立后,通过发送 HEADERS 帧来启动新的流。如果首部需要跨多个帧,可能还会发送 CONTINUATION 帧。该 HEADERS 帧可能来自请求或响应。 后续流启动的时候,会发送一个带有递增流 ID 的新 HEADERS 帧。

消息

HTTP 消息泛指 HTTP 请求或响应,消息由一或多个帧组成,这些帧可以乱序发送,然后再根据每个帧首部的流 ID 重新组装。

一个消息至少由 HEADERS 帧(它初始化流)组成,并且可以另外包含 CONTINUATION 和 DATA 帧,以及其他的 HEADERS 帧。

在这里插入图片描述

HTTP/1.1 的请求和响应部分都分成消息首部和消息体两部分;HTTP/2 的请求和响应分成 HEADERS 帧和 DATA 帧。

优先级

把 HTTP 消息分解为很多独立的帧之后,就可以通过优化这些帧的交错和传输顺序,进一步提升性能。

通过 HEADERS 帧和 PRIORITY 帧,客户端可以明确地和服务器沟通它需要什么,以及它需要这些资源的顺序。具体来讲,服务器可以根据流的优先级,控制资源分配(CPU、内存、带宽),而在响应数据准备好之后,优先将最高优先级的帧发送给客户端。

流量控制

在同一个 TCP 连接上传输多个数据流,就意味着要共享带宽。标定数据流的优先级有助于按序交付,但只有优先级还不足以确定多个数据流或多个连接间的资源分配。

为解决这个问题,HTTP/2 为数据流和连接的流量控制提供了一个简单的机制:

  • 流量控制基于每一跳进行,而非端到端的控制;
  • 流量控制基于 WINDOW_UPDATE 帧进行,即接收方广播自己准备接收某个数据流的多少字节,以及对整个连接要接收多少字节;
  • 流量控制窗口大小通过 WINDOW_UPDATE 帧更新,这个字段指定了流 ID 和窗口大小递增值;
  • 流量控制有方向性,即接收方可能根据自己的情况为每个流乃至整个连接设置任意窗口大小;
  • 流量控制可以由接收方禁用,包括针对个别的流和针对整个连接。

HTTP/2 连接建立之后,客户端与服务器交换 SETTINGS 帧,目的是设置双向的流量控制窗口大小。除此之外,任何一端都可以选择禁用个别流或整个连接的流量控制。

服务器推送

HTTP/2 新增的一个强大的新功能,就是服务器可以对一个客户端请求发送多个响应。换句话说,除了对最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确地请求。

在这里插入图片描述

为什么需要这样一个机制呢?通常的 Web 应用都由几十个资源组成,客户端需要分析服务器提供的文档才能逐个找到它们。那为什么不让服务器提前就把这些资源推送给客户端,从而减少额外的时间延迟呢?服务器已经知道客户端下一步要请求什么资源了,这时候服务器推送即可派上用场。

另外,客户端也可以拒绝服务器的推送。

首部压缩

HTTP/1.1 存在的一个问题就是臃肿的首部,HTTP/2 对这一问题进行了改进,可以对首部进行压缩。
在一个 Web 页面中,一般都会包含大量的请求,而其中有很多请求的首部往往有很多重复的部分。

例如有如下两个请求:

:authority: unpkg.zhimg.com
:method: GET
:path: /za-js-sdk@2.16.0/dist/zap.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
:authority: zz.bdstatic.com
:method: GET
:path: /linksubmit/push.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36

从上面两个请求可以看出来,有很多数据都是重复的。如果可以把相同的首部存储起来,仅发送它们之间不同的部分,就可以节省不少的流量,加快请求的时间。

HTTP/2 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送。

下面再来看一个简化的例子,假设客户端按顺序发送如下请求首部:

Header1:foo
Header2:bar
Header3:bat

当客户端发送请求时,它会根据首部值创建一张表:

索引首部名称
62Header1foo
63Header2bar
64Header3bat

如果服务器收到了请求,它会照样创建一张表。
当客户端发送下一个请求的时候,如果首部相同,它可以直接发送这样的首部块:

62 63 64

服务器会查找先前建立的表格,并把这些数字还原成索引对应的完整首部。

性能优化

使用 HTTP/2 代替 HTTP/1.1,本身就是一种巨大的性能提升。
这小节要聊的是在 HTTP/1.1 中的某些优化手段,在 HTTP/2 中是不必要的,可以取消的。

取消合并资源

在 HTTP/1.1 中要把多个小资源合并成一个大资源,从而减少请求。而在 HTTP/2 就不需要了,因为 HTTP/2 所有的请求都可以在一个 TCP 连接发送。

取消域名拆分

取消域名拆分的理由同上,再多的 HTTP 请求都可以在一个 TCP 连接上发送,所以不需要采取多个域名来突破浏览器 TCP 连接数限制这一规则了。

参考资料

更多文章,敬请关注

查看原文

赞 34 收藏 27 评论 0