“用户在浏览器地址输入 URL 之后发生了什么?”
这个问题对于我们前端开发者来说简直是典中典了,是前端基础,也是工作面试八股,更是性能优化依据。但本文想分享的重点不是之后发生了什么,而是之前发生了什么,即我们平时码出来的代码经历了哪些步骤处理,成为互联网用户能打开浏览的页面的?我们又是如何合理的更新网页的?
前一个问题涉及开发与部署,后一个问题涉及发布。下面我将会从网页入口、开发、部署与发布这4方面逐步展开分享。
网页入口
这一章还是简单对用户看到的网页由什么构成,浏览器又做了哪些工作才让这些构成部分呈现在用户面前的网页进行简单介绍。
一个内容丰富、设计美观、交互友好的网页离不开前端三剑客 HTML、CSS、JS 以及图片、字体等资源文件:
HTML 决定网页内容,是用户访问任意一个网站的入口,既可以在 HTML 中直接编写 CSS、JS 代码,也可以将 CSS、JS 代码写在单独的文件中在 HTML 引入;CSS 作用于网页样式;JS 实现用户交互。网页入口 HTML 的基本结构:<!DOCTYPE html><html> <head> <title>网页标题,在浏览器打开的网页tab上显示</title> <meta name="keywords" content="网页关键词,SEO"/> <meta name="description" content="网页描述,SEO"/> <!-- html中内联css写法 --> <style> .foo { color: red; }</style> <!-- html引入外部单独的css文件写法 --> <link rel="stylesheet" href="https://x.alicdn.com/xx/xxx/screen.css"/> </head> <body> <!-- 网页内容 --> <div>用户访问任意网站之前,要先在地址栏输入一个有效地址,接着浏览器会向服务器发起请求,去拿到该地址对应的网页入口文件即"xxx.html",打开浏览器 Network 控制台便可以看到,这一定是浏览器第一个接收到的响应内容。
紧接着,浏览器解析 HTML 代码,识别到其他资源发起更多请求,经过各种类型资源的加载、解析、执行(非必需)逐步成为用户眼前看到的完整页面,讲到这里就不得不提到 CRP(Critical Rendering Path,关键路径渲染),即浏览器将 HTML、JS、CSS 代码转换成屏幕上用户可见像素必经的一系列关键步骤,如下:
CRP
网络下载 HTML,解析 HTML 代码构建 DOM;网络下载 CSS,解析 CSS 代码构建 CSSOM;网络下载 JS,解析执行 JS 代码,可能会修改 DOM 或 CSSOM;待 DOM & CSSOM “定型”,浏览器根据 DOM 和 CSSOM 构造 Render Tree;重排过程计算每个元素节点所在位置与样式;重绘过程将绘制真实像素于屏幕上。至此,网页呈现在用户面前,进行下一步的浏览和操作。
开发阶段看完上章,想必你也知道浏览器搜索-呈现网页是怎么回事了,这一部分将简单介绍现代化网页开发过程。
▐代码编写在网页内容越来越丰富,网页功能越来越复杂的今天,前端三剑客 HTML、CSS、JS 代码都变得庞大起来,显然,将 CSS、JS 代码都组织在单一 HTML 文件里不再合适,我们也并不会再以传统的方式直接去编写 HTML、CSS、JS 代码,取而代之得是使用各种 UI 框架(如 React/Vue/Angular 等)进行组件式开发,CSS 预处理器(如 Sass/Less/Stylus 等) 等去编写样式。
页面——组件树
▐工程能力利用前端构建工具(如 Webpack/Vite/Rollup 等)组织各种类型的文件,并提供模块化、自动化、优化、转义等构建能力,进行本地开发与生产打包。
各类型业务源码经由构建工具处理转化
其中,有必要对模块化进行说明,它带来的好处就是,在开发阶段,我们可以将不同类型的文件统一视为模块处理,模块成为模块系统中的第一公民,它们之间可以相互引用,至于不同文件类型模块之间的差异,交由构建工具去解决。
在JS模块中引入其他模块:
import '@/common/style.scss' // 引入scssimport arrowBack from '@/common/arrow-back.svg' // 引入svgimport { loadScript } from '@/common/utils.js' // 引入js中的函数区别于开发阶段,构建工具还针对生产环境提供了丰富的构建能力,能将业务源码进行压缩、tree-shaking 优化,uglify 混淆、兼容、extract 抽离等处理,成为适用于生产环境的最优代码。
构建出来的生产环境JS:
!function(){"use strict";function t(t){if(null==t)return-1;var e=Number(t);return isNaN(e)?-1:Math.trunc(e)}function e(t){var e=t.name;return/(\.css|\.js|\.woff2)/.test(e)&&!/(\.json)/.test(e)}function n(t){var e="__";return"".concat(t.protocol).concat(e).concat(t.name).concat(e).concat(t.decodedBodySize).concat(e).concat(t.encodedBodySize).concat(e).concat(t.transferSize).concat(e).concat(t.startTime).concat(e).concat(t.duration).concat(e).concat(t.requestStart).concat(e).concat(t.responseEnd).concat(e).concat(t.responseStart).concat(e).concat(t.secureConnectionStart)}var r=function(){return/WindVane/i.test(navigator.userAgent)};function o(){return r()}function c(){return!!window.goldlog}var i=function(){return a()},a=function(){var t=function(t){var e=document.querySelector('meta[name="'.concat(t,'"]'));if(!e)return;return e.getAttribute("content")}("data-spm"),e=document.body&&document.body.getAttribute("data-spm");return t&&e&&"".concat(t,".")......构建出来的生产环境CSS:@charset "UTF-8";.free-shipping-block{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;align-items:center;background-color:#ffe8da;background-position:100% 100%;background-repeat:no-repeat;background-size:200px 100px;border-radius:8px;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;margin-top:24px;padding:12px}.free-shipping-block .content{-webkit-box-flex:1;-ms-flex-positive:1;color:#4b1d1f;-webkit-flex-grow:1;flex-grow:1;font-size:14px;margin-left:8px;margin-top:0!important}.free-shipping-block .content .desc img{padding-top:2px;vertical-align:text-top;width:120px}.free-shipping-block .co.....构建出来的生产环境HTML:<!doctype html><html><head><script defer="defer" src="/build/xxx.js"></script><link href="/build/xxx.css" rel="stylesheet"></head><body><div id="root"></div></body></html>代码部署至此,我们就得到了网页入口所需所有资源(HTML 及相应的 CSS、JS、其他静态资源),双击 html 文件在浏览器中打开即可本地访问我们的页面,哈!前端就是这么简单!那么可以考虑下一步了,我们还得让测试、产品、运营以及网络上的全球用户都能访问到我们的页面吧?那只在本地运行起来玩一玩儿(doge)肯定不行的,起码得把这些资源全部上传到网络上。
在开发阶段访问网页,一种是在本地运行的开发服务器上, IP 通常是 127.0.0.1/本机IPv4/IPv6,端口号自定,通过 IP + Port + Path 形式访问;一种是手动将资源上传至服务器,其他人拿到服务器 IP + Port + Path 访问页面(关于网站域名申请备案、映射绑定本文省略一万个字...)。后者可通过专门的发布平台(CI/CD)将整个流程自动化,发布平台做的事情简单来说有:
CI/CD 平台
检查分支提交信息、必须配置、依赖合规检查等系列卡口;运行脚本,执行事先配置好的依赖安装及打包构建指令,开启云构建,安装项目依赖,并打包一份生产环境产物(说白了云构建这一步就跟我们刚刚 git clone 项目到本地初始化运行、本地 build 是一样的);将产物上传至 CDN。至此,用户就可以在浏览器输入网址访问我们的页面了,服务器返回 HTML,HTML 中引用 CDN 上的资源,交给端(浏览器)去把页面渲染出来。
发布对外
对于上万(百万)DAU 的页面来说,超多的访问量和极致性能指标,要求我们对页面的迭代修改在正式对外访问前,必须考虑安全发布与用户体验。
▐迭代更新index.css:
.foo { background-color: red;}对于 index.css,如果用户每次打开页面都要重新发起对该文件的请求,不仅浪费带宽而且用户还要多等待一段下载时间,完全可以利用 HTTP 缓存中的强缓存将静态资源缓存在浏览器本地,使用户更快看到页面(快体现在浏览器直接从 memory/dist cache 中读取文件,省去了下载时间)。
Cache-Control: max-age=2592000,s-maxage=86400对于静态资源,服务器往往设置一个非常大的缓存过期时间以充分利用缓存,这样浏览器就彻底不用发起请求了。但是浏览器都不发请求了,如果我们页面有更新/bug 修复该怎么办呢?很容易想到的办法是在资源 url 上拼接版本号,如:
<!doctype html><html> <head> <script defer="defer" src="https://x.alicdn.com/build/foo.js?t=0.0.1"></script> <link href="https://x.alicdn.com/build/index.css?t=0.0.1" rel="stylesheet"> </head> <body> <div>下次更新时更换版本号就能强制让浏览器重新发起新的请求:
<!doctype html><html> <head> <script defer="defer" src="https://x.alicdn.com/build/foo.js?t=0.0.2"></script> <link href="https://x.alicdn.com/build/index.css?t=0.0.2" rel="stylesheet"> </head> <body> <div>但这样做存在一个问题,HTML 同时引用了多个文件,如果在一次迭代中只变更了其中的某个文件,其他文件没做修改,统一加版本号的方法岂不是连带让其他文件的本地缓存都失效了!
为解决这个问题,就得实现文件级别粒度的缓存控制,我们很容易想到 HTTPS 中的数据摘要算法,根据文件内容生成唯一 hash 值,文件无修改 hash 值不变,这样就能精确到单个文件的缓存了:
<!doctype html><html> <head> <!-- foo.js 无修改继续使用缓存 --> <script defer="defer" src="https://x.alicdn.com/build/foo.js"></script> <!-- index.css 改了样式,得请求更新后的文件并缓存 --> <link href="https://x.alicdn.com/build/index_1i0gdg6ic.css" rel="stylesheet"> </head> <body> <div>或者通过迭代版本号加入资源路径 Path 的方式:
<!doctype html><html> <head> <!-- 资源路径更新,请求新的资源 --> <script defer="defer" src="https://x.alicdn.com/0.0.2/build/foo.js"></script> <!-- 资源路径更新,请求新的资源 --> <link href="https://x.alicdn.com/0.0.2/build/index.css" rel="stylesheet"> </head> <body> <div>▐动静分离现代前端部署方案,往往将静态资源(JS、CSS、图片等)往往上传到离用户更近的 CDN 上,这些资源基本不怎么改变,需要充分利用缓存提高缓存命中率;而动态页面(HTML)用户数据千人千面、为 SEO 做 SSR,以及为了性能同构,往往存放在离业务服务器更近的地方,取数查数注入数据更快。
两种资源分布在不同地方,那么静态资源就以 CDN 链接引入的方式写于 HTML 中,那么问题来了,我们在更新页面时先发布静态资源还是先发布页面呢?
1)先发布页面,后发布资源:
<!doctype html><html> <head> <!-- 资源还没发布完 --> <script defer="defer" src="https://x.alicdn.com/0.0.1/build/foo.js"></script> <link href="https://x.alicdn.com/0.0.1/build/index.css" rel="stylesheet"> </head> <body> <!-- 页面修改了 --> <div>静态资源发布完成前,期间用户访问到新的页面结构,但是静态资源还是老的,用户可能会看到一个样式错乱的页面,也可能因旧的 JS 脚本找不到元素节点而执行错误的白屏页面,不可行。
2)先发布资源,再发布页面:
<!doctype html><html> <head> <!-- 资源已发布 --> <script defer="defer" src="https://x.alicdn.com/0.0.2/build/foo.js"></script> <link href="https://x.alicdn.com/0.0.2/build/index.css" rel="stylesheet"> </head> <body> <!-- 页面还没发布 --> <div>页面发布完成前,页面结构没变,而资源是新的了,如果用户此前访问过,本地存在老资源的缓存,那么他看到的页面是正常的,否则访问到旧页面却加载新资源,还会出现上述一样的问题,要么样式错乱、要么 JS 执行错误导致白屏,不可行。
所以先部署谁都不行!这也是为啥古早上线项目时要辛苦程序员大佬们半夜偷偷上,挑流量低谷时上的缘故了,毕竟影响面能小些。但是哇,这对于大厂来说可没有绝对的低峰期只有相对低峰期。但哪怕是相对低峰期,对于做事追求极致的我们,也是不可接受的!
上面的问题其实是覆盖式发布导致的,当待发布资源覆盖已发布资源时就会出现问题,对应的解决办法就是非覆盖式发布,通过文件路径添加版本号或文件名加 hash,发布新的资源时不覆盖旧的资源,先全量发布静态资源再逐步灰度推全量发布页面,整个问题就完美解决了。
所以,关于静态资源优化基本要做到:
配置超长缓存过期时间,提高缓存命中率,节省带宽;采用内容摘要或带版本号的文件路径作为缓存更新依据,做到精确缓存控制;静态资源 CDN 部署,节省网络请求传输路径,缩短请求响应时间;以非覆盖式发布更新资源,平滑过渡升级。至此,前端大佬们辛苦码的代码经过不断迭代、(云)构建、产物资源部署,发布对外,全球用户就可以在互联网上,体验我们的产品,愉快冲浪了~
参考资料
[01] Critical Rendering Path: What It Is and How to Optimize It
https://nitropack.io/blog/post/critical-rendering-path-optimization
[02] Understanding the critical path
https://web.dev/learn/performance/understanding-the-critical-path
[03] 大公司里怎样开发和部署前端代码?
https://www.zhihu.com/question/20790576