Skip to content
This page has been auto-translated and may contain errors.View in English

网页是如何工作的 ​

docs.scrimba.com

你打开的每一个页面都经历同样简短的旅程。你根据地址请求某个东西,某处的一台机器把一份文本文档发回来,你的浏览器再把这段文本变成你看到的页面。HTML 正是这段文本所使用的语言,所以理解这趟旅程,是理解本手册后面所有内容的基础。

客户端和服务器 ​

网络本质上是两方之间的一场对话。你的浏览器是**客户端(client):负责发出请求页面的程序。而保存着网站、并把内容交出来的那台机器,是服务器(server)**。你问,它答。

可以想象在餐厅点餐的场景。你是顾客,厨房你看不见,服务员在中间来回传话。你不会自己走进厨房去做饭。你点一道菜,厨房准备好之后端出来给你。你的浏览器就是顾客,服务器就是厨房,网络就是那个在两者之间跑来跑去的服务员。

每一次页面加载,都是**客户端(client)在和服务器(server)**对话。客户端是发出请求的程序,几乎总是浏览器,但也可以是手机 App 或命令行工具。服务器是运行在某台机器上的程序,一直等着请求进来,再把响应发回去。

这种关系一开始就是单向的:客户端总是先开口。服务器不会毫无缘由地主动把页面推给你。它就在那儿等着,直到有客户端发来请求,才做出回应。一台服务器可以同时应答成千上万个客户端,这也是为什么同一个网址能同时服务大量访问者。

客户端和服务器是角色,不是固定不变的机器。**客户端(client)**指的是发起请求的那一方;**服务器(server)**指的是监听请求并返回响应的那一方。同一台物理计算机在某个连接里可以是服务器,在另一个连接里又可以是客户端,比如一台 Web 服务器自己去调用数据库时,它就是客户端。

这套模型本质上是请求驱动、无状态的。**无状态(stateless)**意味着服务器把每个请求都当作独立完整的事情来处理:默认情况下,它不会记住上一个请求的任何信息。这就是为什么登录状态需要在后续请求中重新确认,通常做法是客户端每次都附带一个 cookie 或 token,因为连接本身不携带任何记忆。保持协议无状态,正是让一台服务器能扛住海量流量的关键,因为它不需要为每个访问者在多次请求之间保持一条持续打开的上下文线程。

Juno客户端和服务器 两方,一场对话:你的浏览器是发问的客户端,服务器是作答的机器。就像顾客和厨房之间隔着一个服务员,你从不会走进厨房,你只是下单,然后菜就端出来了。记住这个画面,这一页剩下的内容都只是它的不同版本。
Juno客户端和服务器 客户端发问,服务器作答,而且客户端总是先开口。一台服务器能同时应付许多客户端,这就是为什么同一个网址能服务一大群访问者。每次页面加载,你其实是在看这一来一回的一轮而已。
Juno客户端和服务器 客户端和服务器是角色,不是固定的盒子,同一台机器可以两个角色都扮演。网络默认是无状态的,所以服务器在请求之间什么都不记得,除非客户端每次都重新附带证明,这正是 cookie 或 token 在做的事。这种无状态不是需要绕过的缺陷,恰恰是让一台服务器能扛住真实流量的原因。

URL 和域名 ​

你输入的地址就是一个**URL**:统一资源定位符(Uniform Resource Locator)。它是网络上某个东西的完整地址,就像邮政地址指向某一栋房子。

看看 https://scrimba.com/learn 这个例子。https 部分说明浏览器该用什么方式和服务器通信。scrimba.com 部分是域名(domain),也就是网站的名字,相当于街道和城镇。/learn 部分是通往某个具体页面的路径,相当于门牌号。三者合在一起精确指向一个页面,输入它们就是在告诉浏览器该去哪儿。

URL 由几个部分组成,把每部分都叫出名字,整体就好懂多了。以 https://scrimba.com/learn/html 为例:

  • https 是协议(scheme):浏览器用来获取页面的通信协议。
  • scrimba.com 是域名(domain):这个网站的人类可读名称。
  • /learn/html 是路径(path):这个网站上的具体哪一页。

域名其实是一个数字的“友好代号”。每台服务器都能通过一个IP 地址访问到,形如 192.0.2.10 这样的字符串,而域名的作用就是让你不必去记这些数字。当你输入一个域名,浏览器会通过 DNS(域名系统,Domain Name System)查出它对应的 IP 地址,这个过程就像电话簿把名字翻译成号码。

URL 把浏览器定位一个资源所需的全部信息都编码在内,每一段都各有职责。以 https://shop.example.com:443/products?id=42#reviews 为例:协议是 https,shop 是子域名,example.com 是注册域名,443 是端口,/products 是路径,?id=42 是查询字符串,#reviews 是片段。

**端口(port)**是服务器上带编号的“门”(443 是 HTTPS 的默认端口,所以通常省略不写)。**查询字符串(query string)把参数传给服务器,可能会影响服务器返回的内容。# 之后的片段(fragment)**只由浏览器自己处理,永远不会发送给服务器;它指向页面内的某个位置。把域名解析成 IP 地址要经过 DNS(域名系统,网络上把名字对应到数字的“目录”),而这个查询本身就是一次网络往返,所以一次冷启动的 DNS 解析,是用户在页面第一个字节到达之前就要付出的延迟。了解这套结构很实用:它告诉你服务器能看到什么(路径和查询字符串)、看不到什么(片段),以及慢在哪里可能藏着一次较慢的首次加载。

JunoURL 和域名 URL 就是一个页面的完整地址。https 说明该怎么和服务器通信,像 scrimba.com 这样的域名是网站的名字,像 /learn 这样的路径指向网站上的某一页。从左往右读,它就精确告诉你浏览器要去哪儿。
JunoURL 和域名 URL 拆开来就是协议、域名、路径,认清这几部分之后,任何地址你一眼就能读懂。域名是数字的友好代号:DNS 就是那本电话簿,把 scrimba.com 翻译成浏览器真正能连上的 IP 地址。这个查询过程在页面加载前悄悄完成。
JunoURL 和域名 URL 里的每一部分都有它的用处:协议、子域名、域名、端口、路径、查询字符串、片段。服务器能看到路径和查询字符串,但永远看不到片段,因为 # 后面的内容一直留在浏览器里。而 DNS 解析是真实存在的一次网络往返,所以一次冷解析,是用户在第一个字节到达之前就要承担的延迟,首次加载感觉慢的时候,值得想到这一点。

请求与响应的循环 ​

当你按下回车,浏览器会向服务器发出一个**请求(request):一条简短的消息,大意是“请把这个页面发给我”。服务器则送回一个响应(response)**:也就是页面本身。这一来一回是一次完整的往返,而这正是你每次打开页面或点击链接时发生的事。

想象向图书管理员借书。你说出书名(你的请求),管理员找到书交给你(响应),你就拿到了书。如果这本书不存在,管理员也会告诉你(这也是一种回复)。网络的工作方式和这一样,只不过快得多,而且每秒都要发生很多次。

一次页面加载就是一个**请求(request)加一个响应(response)。浏览器发出请求,说明它想要哪个页面、想以什么方式获取;服务器发回响应,里面装着页面内容,还带着一个状态码**,说明这次请求进展如何。

以下三个状态码你会经常碰到,先把它们记住:

  • 200 OK:成功,这是你要的内容。
  • 404 Not Found:服务器没问题,但那个地址上什么都没有。
  • 301 Moved Permanently:这个页面已经搬到别处了,浏览器应该跟着跳转到新地址。

服务器为一个普通页面返回的东西,并不是页面的一张“照片”。它是一份用 HTML 写成的纯文本文档,和你自己手写的那些标签是一样的。浏览器接收到这段文本,再根据它把可见的页面构建出来。

请求(request)和响应的循环由 HTTP(超文本传输协议,HyperText Transfer Protocol)定义:这是客户端和服务器约定好的一套“说话方式”。一个请求携带一个方法(method)(动词:GET 用于获取,POST 用于提交数据)、一个路径,以及头部(headers)(一组名值对形式的元数据,比如客户端接受哪些格式)。响应则携带一个状态码、它自己的头部,以及通常还有一个主体(body),也就是实际内容。

状态码分成几个区间,区间本身就告诉你该关注谁。2xx 表示成功(200 OK)。3xx 表示重定向(301 Moved Permanently 告诉浏览器和搜索引擎,该资源有了新的规范地址,所以对 SEO 来说把它设置对很重要)。4xx 表示客户端错误,意味着请求本身有问题(404 Not Found、403 Forbidden)。5xx 表示服务器错误,意味着请求没问题,但服务器出了故障(500 Internal Server Error)。这个区分很实用:4xx 提示你去看请求,5xx 提示你去看服务器。一份文档的响应主体是 HTML 文本,而 Content-Type 这个头部就是告诉浏览器该把它当作 HTML 处理,而不是纯文本或图片。还有一点值得知道:HTTP 会把所有内容以可读的形式在网络上明文传输,而 HTTPS 则是给同一套协议加上加密外壳,让中间的任何人都无法读取或篡改内容,这也是为什么如今每个正式的网站都在使用 HTTPS。

Juno请求与响应的循环 每个页面都是一次来回:你的浏览器发出一个请求,说“请把这个发给我”,服务器发回一个带着页面的响应。就像向图书管理员借一本书,拿到手,或者被告知这里没有。这个往返每次打开页面时都会发生,悄无声息,而且很快。
Juno请求与响应的循环 一个请求发出,一个响应回来,还附带一个状态码。先记住这三个:200(成功了)、404(那个地址上什么都没有)、301(搬走了,跟我来),它们覆盖了你会遇到的绝大多数情况。还要记住,一个页面真正返回的是纯 HTML 文本,不是一张图片,浏览器接下来才会把它构建成你看到的样子。
Juno请求与响应的循环 HTTP 就是这趟旅程的“语法”:请求有方法、路径和头部,响应有状态码、头部和主体。按区间去读状态码,4xx 意味着该去修请求,5xx 意味着服务器那边出了问题,这个划分告诉你该往哪儿看。主体是被 Content-Type 标记的 HTML 文本,而 HTTPS 就是给 HTTP 包上一层加密,这也是为什么每个正式网站都在用它。

浏览器拿到 HTML 之后做了什么 ​

响应里装着一整页的 HTML 文本。你的浏览器从上到下读这段文本,再把它画到屏幕上:看到标题标签,就显示大号粗体文字;看到段落标签,就显示一行正文;看到图片标签,就去获取那张图片。

可以把它想成读一份菜谱再照着做菜。文本本身不是那道菜,而是那道菜的描述,浏览器就是那个把描述变成你真能看到、能用的东西的厨师。同一份 HTML 永远得到同一个页面,因为浏览器每次都遵循同一套说明。

浏览器并不是直接把 HTML 文本显示出来,而是根据它构建出一个页面。它从上到下读这份文档的时候,把标签一个个转化成**DOM**,也就是文档对象模型(Document Object Model):一棵保存在内存里的、代表页面所有元素的“活的”树。真正被显示出来的,以及后来 CSS 和 JavaScript 读取、修改的,都是这个 DOM。

顺序在这里很关键。浏览器按你写的顺序读这份文档,所以位于靠前位置的 <script> 或样式表,会先于后面的内容被处理。这正是为什么页面要把关于页面的信息放进 <head>、把可见内容放进 <body>,也是为什么一个标签放在哪儿,会影响它的效果什么时候生效。你在写 HTML 时,做的不只是罗列元素,更是在给浏览器规定一个阅读顺序。

把 HTML 变成屏幕上的像素,是一条流水线,弄清它的形状,就能明白为什么有些页面画得比别的慢。浏览器先解析(parse)HTML 文本(读取并构建结构),生成DOM(文档对象模型:每个元素在内存里组成的树)。接着它把 CSS 也解析成一棵并行的树,把两棵树合并起来,算出几何布局(layout:每个盒子该放在哪儿),最后才**绘制(paint)**出屏幕上的像素。

因为解析器是从上到下读的,放置的位置会改变时机。当浏览器要等某个资源处理完才肯绘制内容时,这个资源就是**渲染阻塞(render blocking)**的。<head> 里的样式表在设计上就是渲染阻塞的,这样页面才不会先闪现一堆没样式的内容,但如果这份样式表很慢,整个首次绘制都会被它拖住。放在 <head> 里的经典 <script> 标签更糟:解析器会停下来,下载并执行这段脚本,然后才继续往下走,所以文档靠前的一段脚本会拖慢它下面每一个元素的呈现(defer 和 async 这两个属性存在的意义,正是打破这种阻塞)。这也是为什么文档顺序和 <head> 里的放置位置,决定了什么内容先被画出来:任何位于阻塞资源之上的东西,都要等它。

这和延迟(latency)——一个字节传到用户手中所需的时间——是相关的,再快的本地速度也消除不了它。每个请求都有一次往返的代价,所以请求越少、字节到达越早,页面就能越早开始绘制。在 HTML 层面,你能掌控的杠杆就是顺序:把用户最需要先看到的内容放在文档靠前的位置,让阻塞资源别挡它的路,首次有意义的绘制就会更早出现。在这一层,性能这场游戏,归根到底就是“字节更少、更早到达”。

Juno浏览器拿到 HTML 之后做了什么 返回来的 HTML 是页面的一份“说明书”,浏览器就是那个把它变成真实页面的厨师。它从上到下读:标题标签,大号粗体文字;段落标签,正文;图片标签,去取那张图。同样的 HTML 进去,每次都得到同样的页面。
Juno浏览器拿到 HTML 之后做了什么 浏览器从上到下读你的 HTML,构建出 DOM——一棵“活的”元素树,真正显示出来的就是它。因为读取是按顺序进行的,一个标签放在哪儿,就决定它什么时候起作用,这正是为什么要区分 head 和 body。你交给浏览器的,与其说是一堆元素,不如说是一份阅读顺序。
Juno浏览器拿到 HTML 之后做了什么 解析成 DOM、和 CSS 合并、布局、绘制:这条流水线正是为什么放置位置会改变时机。任何位于渲染阻塞资源之上的内容都要等它,所以 head 里一份缓慢的样式表,或者一个普通的 <script>,都会拖住首次绘制,这正是 defer 和 async 存在的原因。再加上无法消除的延迟,应对办法永远是同一个:字节更少、更早到达,把最重要的内容放在文档靠前的位置。