Vite 文档官网通过预加载和动态加载技术,实现了高效的内容加载和路由切换,无需依赖后端 API 即可快速响应用户操作。还结合了预渲染(SSG),确保每个路由都有独立的 HTML 文档,极大提升了首屏加载速度和 SEO 效果。通过在 HTML 文档中直接嵌入完整内容,而不是在 window 中存储数据,显著减少了文档的体积,提升了页面加载速度和用户体验。
这次我们来对 Vite 文档官网所做的优化做一次体检式的检索和分析。如果作为一个前端工程师,你现在可能正在使用 Vite 作为你的基础工具。那么你应该好奇,Vite 作为一个前端基础工具,它自身的文档官网又做了哪些优化呢?这一次就让我们打开它的文档官网,逐一搜寻和分析它到底做了哪些事情。
从打开官网开始
首先我们先打开它文档官网的一个url,我们可以看到它的界面左右都是一个 Sider Bar,中间是文本,交互几乎只有点击跳转链接。看起来是一个很简单的典型的博客类型的网站。是的,它看起来十分简单,现在我们打开这个页面的控制台,我们从这里开始分析。
打开控制台后,我们点开 Network 选项卡,类型选择 Doc。现在刷新页面,立马就能发现它返回了一个 “guide/” 的 html 文档,观察它的内容,你发现什么了么?思考一下,它与我们平时直接打包前端 JS 代码产生的 html 文档有何不同?稍加观察你会发现这个 html 文档包含了完整的文档结构,也就是我们之前在页面看到的所有内容,这说明它不像我们平时那样只是单纯的打包了 JS 代码,并通过 html 来加载并执行这些代码来渲染整个页面。
没错,正如你猜测的那样,它使用了服务端渲染。我们再观察文档末尾插入的 script 标签,里面通过 window 存储了两个变量,那就实锤了,这就是典型的服务端渲染!还没结束,我们继续查看这个请求的响应头,它没有 “Transfer-Encoding: chunked” 这样的内容,那也不是使用的流式传输,那我们可以猜测,它不仅使用了服务端渲染,还使用了预渲染(SSG),这一点至关重要,为什么?另外,也许你还没发现,这里同时还存在着另一个重要的问题,这两个 window 存储的变量并不包含构成页面内容的信息,而只是一些边缘的信息,这与我们做服务端渲染时需要遵守的同步客户端与服务端渲染数据的要求似乎有些出入?另外,这里为什么没有加载渲染页面的客户端 JS 的 script 标签?不要着急,随着我们一步一步接着分析,问题的答案随之而出。
html文档请求响应头分析
继续查看它的响应头还有什么内容。我们可以看见它有一些看似非常规的响应头,它们分别是以 “cf-” 和 “x-vercel” 开头的响应头,这些是什么?我来给你解释,以 “cf-” 是 cluodflare 平台的特有的响应头,而以 “x-vercel” 开头的响应头则是 Vercel 平台特有的响应头,这两个都是提供云托管服务的平台,也就是说 Vite 的前端资源是托管在这些平台上的。这就回答了之前的问题,为什么使用预渲染(SSG)。
那么服务端渲染有哪些好处呢?
使用服务端渲染第一个好处就是首屏加载极快,因为它直接提供了完整可渲染的文档,能够第一时间渲染首屏内容,提供良好的用户体验。
而且有完整文档结构的 html 内容十分有利于 SEO,能够快速的让浏览器和搜索引擎知道,这个 url 对应的网站提供了什么内容。
最后,使用服务端渲染的进一步优化便是预渲染(SSG)。通过 SSG 我们可以提前打包好所有的前端 JS 代码以及拥有完整内容的html文件。如此,我们便可以将这些资源完整的托管到 cloudflare 或者 Vercel 这样的托管平台,借用它们的边缘服务提供快速访问响应,这对用户体验又是进一步的提升,但是使用 SSG 的优势在这里并不止步于此,在后面的分析中,答案会随之而出。
继续查看,它使用了 cache-control:public, max-age=0, must-revalidate 这个响应头。public 指明了这个 html 文档经过的所有地方比如服务器、代理、cdn、浏览器本地缓存等都可以缓存。max-age=0,这表示此次请求本地资源立即过期。重点来了 must-revalidate,它指明资源过期后必须立即去服务器进行协商缓存。等等,你肯定会迷惑,不是资源已经通过 max-age=0 过期了么,过期了不是自然就应该应该去协商缓存了么?事实是,有一种边缘情况,那就是如果中间的代理去源站访问不到资源那么它可能就会动用自己副本返回给你。这时 must-revalidate 就强制性的指明,无论如何都要回去源站协商缓存。
接着看,还有一个 priorit: u=0, i 的请求头。这是一个对服务器的请求优先级提示,并非强制性的。u=0 表示最高优先级,i 表示可以接受渐进式传输。除此之外,还有一些 “sec-fetch” 开头的请求头,这些都是和安全相关的请求头,用于给服务器以及后端服务应用层提供安全相关信息的,比如 sec-fetch-site 它指出本次请求是否有跨站、跨域等行为,如果是跨站,你应该考虑是否启用 token 等防御跨站攻击相关的手段。
预加载优化
我们不着急关闭控制台,而是将注意力转移到左边的侧边栏。正常我们都会想到,左边的侧边栏代表这个前端工程肯定做了路由处理,点击不同的侧边栏项目会跳转到不同的路由页面。是的,但不止于此。
我们先把浏览器控制台切换到 Network 选项卡,并清空所有请求记录。然后将鼠标放在侧边栏的某一项上不点击,接着我们向上滑动侧边栏。你发现了么,每个侧边栏项目跟随滚动出现在界面上,就会多一个请求,仔细观察,它们都是 md.[hash].js 为后缀的 js 文件,我们不能就此满足,继续点开其中一个文件,我们发现什么都没有!为什么?别着急,查看它的请求头,存在着一个 sec-purpose: prefetch 的请求头,这表示这应该是一个由 rel 值为 “prefetch” 的 link 标签发出的请求,它是一个低优先级的请求,同时我们立马在查看响应头,里面包含着一个 Priority: u=4, i 的响应头,这表示这个响应是低优先级的,这说明服务器对这个低优先级的响应就是什么都不返回,当然这和服务器自动处理这种低优先级请求的实现有关。不要着急结束对这一点的探索,我们打开浏览器控制台的 Element 选项卡,然后刷新浏览器,再打开文档的 head 标签并滑到标签的底部,重新滑动侧边栏,你会发现每次一个侧边栏选项出现,head 标签的底部都会增加一个 link 标签,它的 rel 值正是 “prefetch”,它的链接也正是以 md.[hash].js 为后缀的请求,这证实了我们之前的猜测,这种预加载行为是通过添加低优先级的 link 标签来实现的,同时配合浏览器的 IntersectionObserver API ,我们便可以实现和它一样的列表项出现才加载的效果。
针对这个请求我们还可以继续探索!正如你所看见的那样,请求的后缀包含了 “md” 还有 hash 值,这向我们传达了什么信息?它又是一个 JS 文件,这又是为什么?我们思考一下,点击侧边栏的项目又是怎么更新页面的呢?
路由加载优化
我们回到浏览器控制台 Network 选项卡并清空记录,点击一个侧边栏项目,Network 里记录了一个如之前一样以 md.[hash].js 为后缀的请求。点开这个文件查看里面的内容,可以看见里面包含的几乎都是这个页面的内容,而且在末尾通过 export 导出了这这些信息。
到了这里我们可以得到几个重要的信息,点击侧边栏是通过站内前端路由来切换页面的,而通过前端路由切换的页面则是通过请求加载这个文件来更新页面内容的。而首次 url 渲染是通过返回 html 来渲染的。到了这里,你一定会觉得很正常:通过服务端渲染,然后站内使用前端路由,一切都很常规。我们忽略了一个地方,我们只是随机选择了一个页面路由的 url 然后由浏览器加载,这意味着这里任意的路由都是通过这种模式来渲染和加载的,说的更清楚一些,每个路由都使用了服务端渲染,而且是通过 SSG 提前打包好了每个路由的前端资源。我们来试验一下,现在随机点一个侧边栏项目,这时它使用了前端路由跳转,此时我们又刷新页面,可以看见它返回了渲染这个页面路由 html 文档。怎么样,每个路由都有自己独立的 html 文档。
但细节还不止于此,每个前端资源的请求都有自己的 hash 值,比如之前提到的以 md.[hash].js 为后缀的请求,仔细一点你会发现,我们在不同路由下刷新页面后,滚动侧边栏加载的这些请求,都有着相同的 hash 值。而且在每个路由下刷新时都会加载同一个 framework.[hash].js 文件还有其它相同的文件,这里又传递了一个信息,那就是虽然每个页面都做了独立的服务端预渲染打包,但又没完全独立,它们在共用相同的资源。具体哪些是独立的,哪些是共用的呢?仔细观察可以发现,只有每个路由自己的 html 文档是独立的,其他都是共用的。
到此,我们可以总结一下 Vite 文档官网的打包方式和渲染方式。全站使用服务端渲染,并且针对页面内每个路由都使用了 SSG 提前打包好了前端资源,而所有的路由仅自己的 html 文档是独立的,其它资源是共享的。于是站内每个路由都可以使用 url 独立访问,而页面加载完成后则使用前端路由。同时,首次的渲染使用路由自己的 html 文档,前端路由的页面加载以 md.[hash].js 为后缀的文件来更新页面,这样既保证了快速首屏渲染又提供了站内前端路由快速切换的体验。
以上的总结又透露出一个问题,为什么每个路由都要独立打包 html 文档?这里其实有一个非常重要的考量,那就是 SEO,我们考虑一下这个站内的信息是什么?是 Vite 的官方文档,它希望浏览器和搜索引擎能快速知晓每个路由对应内容,它希望所有关心 Vite 的人能快速找到这里对应内容,当我们搜索 “Vite 的理念” 就能快速找对应的官方文档。
动态加载文件思路
另一个重要的点,那便是以上反复提及的以 “md.[hash].js 为后缀的文件”,我们可以发现整个网站几乎没有任何针对后端服务器的异步请求。如果是寻常实现,我们可能会将文档内容提前通过后端服务的 api 存储,然后又通过 api 异步请求服务端获取。可是这里的做法是,直接将文档信息放入一个 js 文件并在文件导出,加载这个 js 文件资源就可以获取文档内容。也就是说,它将异步获取信息转变为了动态按需 “获取一个包含并导出信息的js 静态文件”,如此一来,便可将这类文件也托管至云托管平台。整个网站只需要托管所有的静态资源而不需依赖任何后端 api 便可直接部署。同时值得注意的细节便是它包含了 hash 值,说明这类文档同样参与了打包过程而被加上了 hash 值,这也意味着如果文档内容更新,因为 hash 值的改变而不会命中旧版本的文档缓存。以上分析中的几点便又是 SSG 的优势。
这也给了我们一个思路,前端是否需要的所有信息全都通过后端服务的 api 来存取?思考一下 css-in-js 和 Vite 热加载时更新样式,它们也是通过获取生成 css 内容的 js 文件来动态生成样式的,它们用了同样的方式。利用这样的方式我们也可以将一部分信息放入自己定义后缀的静态资源,然后部署它们并通过动态按需加载这些资源来获取我们想要的信息。
head内js资源加载
现在我们随便打开一个路由的 html 文档,查看它的 head 内有什么,可以看见里面有 meta、link、script 三种标签。meta 标签有两类,一类是 以 “og:” 开头的,另一类是以 “twitter:” 开头的,它们是 Facebook 和 twitter 各自协定的社交网站分享时会读取的信息。script 则主要关注 type=“module” 的,它就是这个应用的 js 代码入口,也就是开头我们认为本该放在 body 标签末尾的 script 标签,它被放置在 head 内是因为 type=“module” 的 script 标签默认是以 defer 类型执行的,也就是这些标签是不阻塞 html 解析的,会在文档解析完成后按顺序执行。而放在 head 内则能在更早的预扫描阶段就可以暴露自己进行并行下载而不执行。
而 link 标签则多种多样,主要是以 rel 值为 preload、modulepreload、preconnect、prefetch 这几种。先看 modulepreload 这种,它们的链接是 theme.[hash].js、framework.[hash].js、xxx.md.[hash].lean.js 这几个,同样是默认 defer 类型。到这里已经能看出来了,这就是 Vite 文档官网基本的打包内容,以 type=“module” 的 script 标签为 js 入口,而这三个文件则是依赖文件。查看里面的内容可以发现,theme.[hash].js 是网站配合 head 内脚本而做的主题相关的代码, framework.[hash].js 内可以看见里面声明的 Vue lisence, 这说明这个站使用了 Vue 技术栈, 并且单独将 Vue 的内容打包了出来,剩余的请求则是一些第三方代码。
head内字体资源加载
另外 rel 值为 preload、preconnect 的 link 标签主要是用来加载字体,preconnect 表示提前建立到指定域名的 TCP/TLS/HTTP 相关的链接,preload 则加载对应的字体。关于加载字体这里可以再多探究一下,仔细观察可以发现加载字体的链接都带有 display=swap 的参数比如:“fonts.googleapis.com/css2?family… ...... &display=swap” 这个链接,它会请求一个 css 文件,文件内容全是字体的 css 格式的声明,每个声明都有一个参数 display ,它的值就由这个链接中的 display 决定,也就是说,googleapis 返回的这个 css 文件其实是根据请求参数动态生成的。
而 display=swap 则是告诉浏览器,在字体加载完成之前可以先直接使用你定义的回退的字体。举个例子,假如你在 css 中这样声明字体:font-family: Inter, Songti SC。Inter 是一个需要从 googleapis 加载的网络字体,而 Songti SC 则是一个系统字体,这样一来则会先显示 Songti SC,等到 Inter 加载完成了就立马应用这个字体。
先别着急松口气,关于字体加载的探索还没结束。我们查看请求这个包含字体声明的 css 文件,它里面包含了许多字体声明,你应该好奇,明明就一种字体为什么会有这么多声明?别着急我们接着仔细观察,每个声明有何不同之处,它们有不同的 font-weight 和 unicode-range 等等值,这就是区别这些声明的关键因素。试想一下,我们对文本应用了某个字体,同时又声明了它的 font-weight,这时浏览器就会加载符合这个 font-weight 对应的字体,也就是说只请求这个字体对应的文件。另一方面,假如你的文本里只有几个字,那浏览器就通过这几个字去匹配 unicode-range,并且只请求和加载匹配的字体文件。
没错,你应该注意到了,这就是针对字体加载的优化!这一点针对中文的网站尤其重要!为什么,因为中文是由单个文字组成的,相比之下英文只是由几十个英文字符组成的,这就造成了中文字体库往往非常巨大,有多大呢?一个中文字体库可以包含数百个字体声明,而每个声明都对应了一个字体库的文件,如果你的内容仅仅只有几个字,难道也要加载这数百个文件或者一个包含所有内容的巨大文件?显然不可能,这就是字体加载优化的重要性,仅仅是字体这一项弄不好都可能直接拖垮你的网站。
不要着急,请先憋住这口气,我们对字体加载优化的探索还未结束。经过以上的分析,我们知道浏览器会针对你的文本和要应用的字体,只请求和加载对应的字体库文件,那么浏览器到底是什么时候知道要去哪里请求加载哪些字体库文件的呢?浏览器渲染整个页面会经历多个过程,从 html 文档预扫描到 html 解析,然后分别同时构建 DOM 和 CSSOM,接着将它们合成为一个渲染树,然后进入布局阶段,经过布局阶段的各种样式计算后开始绘制页面。这个过程中浏览器会通过解析 css 来构建CSSOM,也正是在这个过程中会得到你声明和要应用哪些字体,以及通过你的声明知道要去哪里下载网络字体。于是浏览器通过这些信息构建一个记录字体的包含匹配规则的集合。而在布局阶段,浏览器可以知道你有哪些文本,此时结合样式信息中的字体声明,去之前记录字体的集合里根据规则寻找对应的需要下载的字体库文件,最后再应用字体。
通过以上信息,我们能主动做什么呢?想一下,如果我们能提前得知要加载哪些字体库文件,那我们就可以提前在 head 预先加载这些字体库文件!那有没有什么办法能提前知道要加载哪些字体库文件呢?有的,兄弟,有的!我们先要理解,什么 ** 叫做 “提前”?那就是在项目正式发布之前就是提前!我们完全可以在开发末尾阶段或者预发布阶段就先查看,此时我们的页面内容基本已经定型,我们已经可以知道页面加载了哪些字体。此时,还是打开我们的浏览器控制台 Network 选项卡,并切换到 Font 选项,刷新浏览器,你已经可以看到浏览器为我们加载了哪些字体库文件,就是那些 .woff2 文件!你有两种办法,一是直接在 head 内声明预加载它们,二是你直接下载它们并让它们参与你项目的打包,成为你项目的一部分并随之发布,然后在 head 内声明预加载它们。至此,我们通过对 Vite 文档官网的分析和深入探究,完成了一项对字体加载的优化。
url处理细节
仔细观察页面的 url 你会发现,除了根路由 “guide/” 以外,其余的路由无论是通过前端路由跳转还是刷新浏览器访问,均没有 “/” 这个后缀,你可能觉得这有什么好奇怪的?我们直接开始实践,先去掉 “guide/” 末尾的 “/” ,然后刷新页面,我们去掉的 “/” 又被自动加上了。然后我们切换到其它路由,在末尾加上 “/” 然后刷新浏览器,我们加上的又被自动去掉了。而将资源托管至云托管平台后,在默认的情况下,假如你访问 guide/philosophy,托管平台会自然的返回 guide/philosophy/ 下的 html 文档,并将 url 末尾自动加上 “/”。这说明 Vite 的文档官网对这个小小的斜杠做了特殊的处理。有两种方式,一种是你在托管平台设置规则。另一种则是在你的项目工程里,所有代码之前,使用 history.replaceState API 将当前需要去掉斜杠的 url 改成你想要的样子。replaceState 的好处是会直接覆盖之前的 url 请求记录,也就是说,如果用户点击了回退按钮,也不会回退到之前带斜杠的 url 上,并且它在覆盖的时候也不会刷新页面,这样你的客户端代码就能平滑的匹配当前的路由了。
在本文发布不久之前,其实 Vite 文档官网对这个行为做了更改才变成当前这个样子。之前在分析时,点击侧边栏项目进行前端路由跳转时会在 url 末尾自动加上 “.html”,也就是假如前端路由到 guide/philosophy 后,url 会变成 “guide/philosophy.html”,但这时仍然可以匹配路由,我猜也许是先进行了前端路由跳转,然后再无刷新的更改了 url。此时刷新浏览器会访问这个带 “.html” 的 url,并没有直接返回 html 文档,而是通过返回 308 永久重定向到 “guide/philosophy” 的方式返回 html 文档和资源,此时url又变成了没有 “.html” 样子。如此 “怪异” 行为着实让我摸不着头脑,我只能猜测也许是因为官方文档网站代码和前端资源的历史原因或者因为托管平台的路由规则,而不得不这么做。
html文档的优化
在之前我们提到一个问题,那就是在 body 末尾的 window 里并没有放置存放页面内容的变量,按照传统的服务端渲染方式,我们通过服务端渲染返回一个页面的 html 文档,要同时在 window 里放置一个渲染这个页面数据的变量,这样当客户端代码到来并执行时可以通过读取这个变量来同步数据。比如我们是通过 reducer 状态管理来存储页面数据,并在路由匹配时读取它来渲染页面的内容,而当我们的客户端代码到来时,一旦开始执行就应该通过读取这个 window 里存放的数据来初始化 reducer 状态,这样客户端代码才会正常与服务端渲染的数据达到同步并正常做今后的执行。
而 Vite 文档官网是怎么做的呢?它在访问的时候仅仅返回了一个有完整内容的 html,并没有提供让客户端代码初始化数据的变量。我们先不着急考虑它是如何做到的,而是先考虑一下为什么要这么做?你应该有所留意,我之前反复使用了 “有完整内容” 这样一个描述,我们进一步的考虑,如果我们提供的 html 文档已经有了完整的内容,那是否还有必要在这个文档里重复塞进这个文档的数据呢?如果我们能做到,那我们便可以返回一个包含完整页面内容和少量边缘信息的 html 文档,那这个文档的大小就能被很大程度的缩小!是的,这就是对 html 文档的优化。
做同样的文档优化
那它是如何做到的呢?接下来我通过自己的实践来简单说明一下如何实现这样的效果。首先我们先要思考一下,到底什么是服务端渲染。稍加思考,服务端渲染到底做了什么。答案其实很简单,那就是在访问服务器的时候,服务器动态生成并返回这次的 html 文档让浏览器能立即渲染首屏内容,然后通过这个 html 文档加载剩余的资源。那服务预端渲染到底又是什么呢?答案仍然很简单,那就是把本来服务器要动态生成的 html 文档提前生成好。进一步思考,其实也就是说,服务端渲染最基本的任务就是生成一个首屏渲染的 html 文档,即便没有剩余的客户端代码也能正常展示,只是随着剩余的客户端代码到来,这些代码的执行加上了首屏内容之外的交互和新内容。再进一步思考,我们本来就可以不去同步服务端渲染内容与客户端内容的数据。
落到具体的代码上,假如我们的客户端代码仍然使用 reducer 状态管理,并且定义好的里面的数据结构和初始的空数据。当客户端代码到来时,它拿不到服务端渲染的页面提供的初始数据,那就让它保持最开始的状态就好了,并且不要让它首次加载时执行渲染更新页面的动作,因为此时的页面本就是完整的。怎么样,到了这一步想必你已经知道该怎么去做了。继续解释,当初次页面渲染完成后,我们通过前端路由跳转时,对应路由页面的数据因为是空的,这时就可以通过加载对应路由的数据,比如之前提到的以 md.[hash].js 为后缀的文件来提取数据并放到状态管理存储中,并触发页面更新来渲染新的页面内容。注意,这时最开始的路由数据仍然是空的,这次我们跳转回最开始的路由,同样因为数据的空的,那这次就可以触发同样的逻辑去加载数据并渲染页面。
没错!Vite 文档官网就是这么做的!我们马上动手去检验一下!打开浏览器控制台 Network 选项卡并刷新,仔细观察,它并没有加载这个路由页面对应的数据文件,表明它此时只是单纯的渲染了html 文档的内容,此时清空请求记录,跳转到另一个路由页面,它如预期请求了对应的数据文件,然后我们跳转回最开始的路由,你看到了,这时它才请求了对应的数据文件,如之前解释的一模一样。
最后,我们基本上把 Vite 文档官网的优化内容探索的差不多了。接下来,也许你应该很兴奋,通过以上分析我们得到了非常多有用的信息和思考,是时候考虑自己实现一个对标 Vite 文档官网的服务端渲染的工程了!是的,接下里的文章就是要完成这件令人兴奋的事!我们将构建自己的 SSG 工程,并将它托管至云托管平台甚至构建发布自己的边缘服务!另外,我还注意到了目前 svelte 来势汹汹,苹果商店的也使用了svelte 技术栈,吾今后也将深度实践并更新相关内容。