《看懂 AI 写的网站》第一章:L0 基础环境——Web 运行在什么之上

《看懂 AI 写的网站》第一章:L0 基础环境——Web 运行在什么之上

本文属于《看懂 AI 写的网站》· 第 1 / 9 章 · 目录

文章目录 69 节

本章导读#

AI 让你做的第一批事,往往和网页没什么关系。

假设你想做一个博客。你把需求交给 AI,它返回的指令大致是这样:先确认电脑上装了 Node.js,然后打开终端,切到项目目录,运行 pnpm install,启动开发服务器,然后在浏览器里打开 localhost:4321。你照着做完,网站确实跑起来了,但心里会留下几个没解开的疑问——我只是想做个网页,为什么先要装一个和浏览器无关的东西?为什么要在那个黑色窗口里敲字?地址栏里那个 4321 又是什么,正常的网址里没有这一段。

这些步骤操作的都不是网页本身,而是网页得以运行的地方。这一章讲的就是这个地方,它在本书里叫 L0。

L0 里没有一行是「网页的写法」:没有标签、没有样式、没有函数。它由四样东西组成:你的操作系统、你用来下命令的命令行、最终把页面显示出来的浏览器,以及把这些连起来的网络。上面十二章的所有内容,都要装在这四样东西上才跑得起来。

初学者第一次遇到的挫败,通常也发生在这里。项目在别人的电脑上能跑,在你的电脑上不行;终端说找不到某个命令;浏览器打不开那个地址。这些现象有一个共同点:看起来像网页出了问题,实际上网页还没有开始工作。搞清楚这一层,你才有能力判断一条报错该往哪儿找。

这一章的读法也和后面不同。它不教你怎么用命令行,也不会把 HTTP 协议的细节讲完。它要解决的是一个很具体的问题:当 AI 说出「装个东西」「切到项目目录」「端口被占了」「换成 127.0.0.1 试试」这类话时,你能不能明白它在动什么,以及为什么要动。

本章在技术栈中的位置#

L0 不是一个「学完才能往上走」的预备阶段。它是上面所有内容实际运行的地方:语言、框架、构建工具、部署脚本,全都要装在某个操作系统上,通过命令行被启动,通过网络把结果送到浏览器。

flowchart TD
  A[L0 基础环境:操作系统 · 命令行 · 浏览器 · 网络] --> B[L1–L12:网页、语言、工具链、框架、发布]
  B --> C[第十四章 把这些层重新串成一条真实流程]

这一章的八节就是这四样东西展开的样子。1.1 和 1.2 讲你自己的电脑——操作系统与命令行。1.3 讲浏览器,它是整条链的终点,也是唯一直接把页面呈现给人的地方。1.4 到 1.7 讲网络——客户端与服务器、URL、域名与 DNS、HTTP。1.8 把这些零件串成一次完整的访问。

有一件事先提醒:这一章的名词密度是全书最高的,因为它们大多是平时不会主动去了解的底层概念。如果某一段读起来像在念定义,可以先跳到 1.8 看这些零件怎么配合,再回头补细节。

读完本章,你应该能看懂#

  • “‘node’ is not recognized as an internal or external command.”
  • “Port 4321 is already in use.”
  • “Check localhost:4321 in your browser.”
  • “It works on 127.0.0.1 but not on localhost.”
  • “Add the key to your .env file.”
  • “This looks like a DNS issue — it may not have propagated yet.”
  • “The request came back as a 404.”
  • “You’re probably seeing a cached version. Try a hard refresh.”

这八句话本身不需要你现在就完全理解。读完这一章,你至少应该知道每一句指的是哪一类操作、该去哪一侧找原因。


1.1 操作系统与 Web 开发#

现代 Web 开发有一个常被忽略的前提:你写的文件、AI 修改的文件、命令行里跑起来的工具,全都存在同一台电脑上,由同一个操作系统管理。上面十几层里出现的每一样东西,最后都要落到这台机器的文件系统、进程和端口上。

这一节讲的就是这台机器提供的四样基础设施:文件和目录、路径、环境变量、进程与端口。它们本身不是 Web 技术,但 AI 的指令里有很大一部分在操作它们,而且多数「跑不起来」的问题,根子都在这里。

1.1.1 操作系统是什么#

操作系统(Operating System,简称 OS) 是管理一台电脑的硬件、并给所有程序提供统一接口的那一层软件。Windows、macOS、Linux 是三种常见的操作系统。

它主要负责三件事:文件存在哪里、哪个程序什么时候运行、以及内存和磁盘这些资源怎么分配给正在运行的程序。这三件事听起来和网页无关,但你对电脑做的每一次操作——装一个软件、保存一个文件、启动一个服务——都是通过它在进行。

Web 开发需要关心它,因为你用到的所有工具都是操作系统上的程序。AI 说“install Node.js”,装到哪里由操作系统决定;说“add it to your PATH”,改的是操作系统的环境变量;说“run this in your terminal”,打开的是操作系统的命令行。同一个项目在 Windows 和 macOS 上遇到的第一批差异,几乎全部来自这一层。

没有它会怎样?每个程序都得自己直接跟硬件打交道:做一个编辑器要自己写读磁盘的代码,做一个浏览器要自己写联网的代码。这件事在个人电脑早期确实发生过,结果是每个程序都有自己的规矩,文件互相看不见。

平时会在哪里看到它:软件安装包的文件名(.exe、.dmg、.deb)、C:\Program Files 或 /usr/local 这类目录,以及 AI 指令里那些平台相关的安装命令——macOS 上的 brew install、Windows 上的 winget install、Linux 上的 apt install。看到这些,你就知道 AI 正在往操作系统里装东西,而不是往你的项目里装东西。

这里有一个区分需要先记住:装在操作系统里的东西,和放在项目文件夹里的东西,不是一回事。 Node.js 这类工具装在操作系统里,通常装一次、所有项目共用(也有版本管理工具可以让不同项目各用各的版本,那属于进阶话题,本书不展开);而项目自己需要的依赖装在项目文件夹里,每个项目一套。

1.1.2 文件与目录#

文件(File) 是磁盘上一段有名字的数据。目录(Directory,也叫文件夹) 是装文件的容器,目录里可以再放目录。

这个结构叫文件系统,它解决的问题是:一台电脑上有几十万个文件,怎么让人和程序都能找到其中一个。答案是给每个文件一个位置,位置由它所在的一层层目录决定。

一个 Web 项目在文件系统里就是一个目录。里面装着若干文件,其中一部分自己又组成目录:

my-blog/
├── package.json
├── astro.config.mjs
└── src/
└── pages/
└── index.astro

这段结构需要看懂两点:my-blog 是项目的根目录,项目里所有相对的位置都从它算起;src/pages/index.astro 表示 index.astro 这个文件,在 src 目录下的 pages 目录里。AI 说“create a new file at src/pages/about.astro”时,说的就是这个位置。

有两个细节在实际操作里经常造成困扰。Windows 的路径分隔符是反斜杠 \,macOS 和 Linux 用正斜杠 /;但 AI 的输出里几乎总是用 /,因为它在 Windows 上通常也能正常工作。另外,以 . 开头的文件(例如 .env、.gitignore)在 macOS 和 Linux 上默认不显示,它们和普通文件没有区别,只是被隐藏了。

1.1.3 路径#

路径(Path) 是描述一个文件或目录位置的一串文字。它从磁盘的根开始,一层层往下写。

根目录是路径的起点。Windows 上根目录带盘符,写作 C:\;macOS 和 Linux 上根目录就是一个 /。从根出发,每一层用分隔符隔开,就得到了一个完整路径:

C:\Users\you\my-blog\package.json

路径的存在是因为文件系统是一棵树,而树上的每个节点都需要一个不重复的名字。这个说法听起来平淡,但它有一个直接后果:同一台电脑上不可能有两个路径完全相同的文件。 很多「文件到底在哪儿」的争论,归根到底都落在这一点上。

平时会在哪里看到它:AI 的指令里、编辑器的标题栏、报错信息里,以及配置文件里那些指向别的文件的字符串。

1.1.4 绝对路径与相对路径#

路径分两种,区别在于从哪里开始算。

绝对路径(Absolute Path) 从根目录写起,不管你现在站在哪里,它指的都是同一个位置。上面那个 C:\Users\you\my-blog\package.json 就是绝对路径。

相对路径(Relative Path) 从你当前所处的目录写起,同一个相对路径,在不同位置指向不同的文件。src/pages/index.astro 是相对路径——它到底指向哪个文件,取决于你在哪个目录里执行操作。

相对路径里有两个缩写需要认识:

. 当前目录
.. 上一级目录

所以 ./scripts/build.sh 是「当前目录下 scripts 里的 build.sh」,../package.json 是「上一层目录里的 package.json」。macOS 和 Linux 上还有一个 ~,表示当前用户的主目录,例如 ~/my-blog。

这个区分在实际开发里的分量比看上去重。AI 说“run ./scripts/build.sh”时,它默认你站在项目根目录;如果你站在了别的地方,同一条命令会报「文件不存在」。这不是命令写错了,是相对路径的起点变了。

1.1.5 文件扩展名#

扩展名(Extension) 是文件名最后那个点之后的部分:index.astro 里的 astro、package.json 里的 json。

它的第一个作用是给人看的,让你一眼看出这是个什么文件。第二个作用是给工具看的:不同的工具会按扩展名决定怎么处理一个文件。TypeScript 编译器处理 .ts,Astro 处理 .astro,Node.js 遇到 .mjs 会当成模块、遇到 .cjs 会当成旧式脚本。

这里有一个初学者最容易搞错的地方:通过 HTTP 取回来的内容,扩展名不决定浏览器怎么解释它。 决定权在服务器发回来的 Content-Type(1.7.5 会讲)。同一个 style.css,服务器可以用两种方式送出去:

Content-Type: text/css 浏览器当样式表处理,页面样式生效
Content-Type: text/plain 浏览器把文件内容当纯文本显示出来

文件一个字都没变,两种结果完全不同。这也是为什么排查「样式没生效」时,要去看服务器返回的头,而不是盯着文件名。

但这里有一个很容易走反的地方,需要说清楚:服务器通常正是按扩展名来决定发什么 Content-Type 的。 常见的静态服务器都内置了一张扩展名到类型的对照表,nginx 自带的配置里就写着这么一行:

text/html html htm shtml;

意思是 html、htm、shtml 这三个扩展名都按 text/html 发出去。所以把一个 .txt 改名成 .html,在大多数服务器上确实会让它变成网页——服务器看到新扩展名,就改发 text/html 了。

变的不是浏览器,是服务器。真正可靠的推论只有一条:光看文件名,你没法确定浏览器会怎么处理它。 扩展名影响的是服务器、构建工具和编辑器怎么对待这个文件;等文件到了浏览器手里,起决定作用的是响应头。

只有一种情况例外:用 file:// 直接打开本机文件时没有响应头,浏览器这时确实只能退回按扩展名判断。反过来,一个地址末尾没有 .html,也可能返回一个完整的网页——那种情况下服务器是按别的方式决定类型的。

这个区分在你排查「为什么样式没生效」「为什么图片显示不出来」这类问题时会很有用:那时候要看的不是源文件叫什么名字,而是服务器实际发回了什么头。

1.1.6 环境变量#

环境变量(Environment Variable) 是操作系统层面上的一组键值对,程序启动时能读到它们。

它的用途是:把那些不该写死在代码里的信息放在代码外面。数据库地址、接口密钥、当前是开发还是生产——这些东西在不同环境下取值不同,直接写在代码里会导致每次换环境都要改代码,还容易把密钥提交到 Git 仓库里。

一个 Web 项目里常见的样子是这样:

NODE_ENV=development
DATABASE_URL=postgres://localhost:5432/blog
API_KEY=sk-xxxxxxxx

需要看懂的是:左边是名字,右边是值,程序运行时按名字去读。改值不用改代码,这就是它存在的全部理由。

上面第一个变量 NODE_ENV 值得单独说一句,因为它是一个典型例子:它不是 Node.js 官方定义的东西。 Node.js 运行时既不读它也不设它,在 Node.js 的官方 API 文档里搜不到这个名字。它是社区里形成的约定——一批构建工具和框架会读它,据此决定要不要压缩代码、要不要输出详细的错误信息。

这个区分在判断 AI 的说法时很有用。如果 AI 说「Node.js 根据 NODE_ENV 判断当前环境」,那是把一个约定说成了官方机制。作为对照,Node.js 官方文档里专门有一节讨论这个话题,标题就是「为什么 NODE_ENV 被认为是一种反模式」,理由是:把它设成 production 以外的任何值都会带来麻烦,因为它把构建优化和代码行为捆在了运行环境上,容易让不同环境出现无法复现的差异。

这不是说不要用它——AI 让你设 NODE_ENV=production 时照着做通常是对的,因为那些工具确实认它。需要建立的是那个区分:约定不等于语言或运行时提供的机制。 这个角度在后面还会反复用到。

环境变量在命令行里可以直接读出来:

$ echo $NODE_ENV
production

这两行需要看懂的是:$ 后面那个名字被替换成了它对应的值。程序内部读环境变量的写法各不相同,但读到的都是同一份东西——由启动它的那个环境传进来的键值对。

PATH 也是一个环境变量,而且是最重要的一个,1.2.5 会专门讲它。

有一个区分必须说清楚:.env 文件不是操作系统的东西。 它只是项目里的一个普通文本文件,是开发工具(Vite、Astro、Node.js)在启动时读它,然后把里面的键值对设成环境变量。所以「把密钥放进 .env」并不等于「操作系统记住了它」,只等于「这个项目的工具会加载它」。

还有一个平台差异很实用。Windows 上环境变量名不区分大小写,macOS 和 Linux 上区分。这意味着 AI 让你写 API_KEY、你自己写成 api_key,在 Windows 上可能一切正常,换到 Linux 服务器上就变成「找不到这个变量」。跨平台项目里,名字的大小写要照抄。

AI 什么时候会提到它:让你创建 .env 文件、说“set the NODE_ENV to production”,或者报“missing environment variable”。最后一类报错的意思通常是:代码要求某个名字的环境变量存在,但它没读到。

AI 可能会说:

“Add the key to your .env file.”

翻译成人话:

项目里有一个专门放这类信息的文本文件,现在要往里面加一行。加完之后,项目启动时会读到这一行,代码就能按名字取到这个值。它改的是配置,不是代码。

实际发生的是:

.env 本身只是一个普通文本文件,没有任何特殊能力。是开发工具在启动时读它,把里面的键值对放进进程的环境变量里,代码再从环境变量取值。因为这一步发生在启动的那一刻,所以改完 .env 通常要重启开发服务器才会生效。

1.1.7 进程#

进程(Process) 是一个正在运行的程序。

程序和进程的区别,相当于菜谱和做菜。node 这个程序装在磁盘上,是一个文件;当你运行它,操作系统会创建一个进程,把这份文件读进内存跑起来。同一个程序可以同时有多个进程——你开三个终端,每个里面都能跑着一个 Node.js 进程。

进程有生命周期:启动、运行、结束。它占着内存,可能占着文件,也可能占着端口。结束它的方式有两种,一种是自己跑完,一种是被强行终止。命令行里按 Ctrl+C、任务管理器里点「结束任务」,做的都是第二件事。

flowchart LR
  A[启动开发服务器] --> B[操作系统创建一个进程]
  B --> C[进程占着内存和一个端口]
  C --> D[关掉终端或按 Ctrl+C]
  D --> E[进程结束,端口释放]

为什么 Web 开发要理解进程:因为开发服务器就是进程。AI 说“start the dev server”,意思是创建一个进程;说“stop the dev server”,意思是结束它。这有一个很实际的表现——如果你启动开发服务器的那个终端窗口被关掉了,服务器进程通常也跟着结束,浏览器里那个地址随即打不开。这解释了一个常见困惑:明明刚才还能访问,怎么突然就不行了。

平时会在哪里看到它:任务管理器或活动监视器里的列表、Ctrl+C 这个操作,以及 AI 说的 “the process is still running”“kill the process”。后一句通常出现在端口被占用的时候,因为占着那个端口的正是某个还没结束的进程。

一个进程内部还可以有更小的执行单位,叫线程(Thread)。这个概念对理解 AI 的日常输出暂时用不上,本书不展开。

1.1.8 端口#

端口(Port) 是一个数字,用来区分同一台机器上的不同服务。

IP 地址回答的是「哪一台机器」,端口回答的是「这台机器上的哪一个服务」。两者缺一不可:一台电脑上可能同时跑着开发服务器、数据库和某个本地接口服务,它们共享同一个 IP 地址,靠端口区分开。

flowchart LR
  A[你的电脑 · 一个 IP 地址] --> B[端口 4321 开发服务器]
  A --> C[端口 5432 数据库]
  A --> D[端口 8000 本地接口服务]

端口号的范围是 0 到 65535。其中 0 到 1023 是系统端口,通常需要管理员权限才能占用,公认用途也比较固定——80 给 HTTP,443 给 HTTPS。1024 以上的端口普通程序就能用,开发工具一般从这里挑。有几个数字在 AI 输出里出现频率特别高:Vite 默认用 5173,Astro 的开发服务器默认用 4321,1.6.6 会专门讲它。

一个端口在同一时刻只能被一个进程占用。更准确地说,这个「占用」是按协议、IP 地址、端口三者合起来算的:第二个程序想在完全相同的位置上再占一次才会失败,这就是「端口被占用(port is already in use)」的全部含义——它不是说你的代码有问题,而是说这台机器上已经有个东西站在那个位置上了。

这三个条件为什么要合起来看,1.6.5 会给出一半答案:一个服务可以只监听 127.0.0.1:4321,另一个服务同时监听 ::1:4321,两者并不冲突。所以「端口明明没被占用,却连不上」是可能发生的,而且原因不在端口上。

平时会在哪里看到它:启动开发服务器时终端打印的那行地址、localhost:4321 里的 :4321,以及各种「端口已被占用」的报错。

1.2 命令行#

AI 给出的操作指令里,有一半以上要在命令行里执行。这一节不教你怎么写命令,只讲清楚一件事:那行字敲下去以后,是谁在读它、它在哪里执行。搞清楚这两点,多数「照抄命令却报错」的情况都能自己定位。

1.2.1 GUI 与 CLI#

GUI(Graphical User Interface,图形用户界面) 是靠鼠标点击操作的界面。CLI(Command Line Interface,命令行界面) 是靠输入文字操作的界面。你双击文件夹是 GUI,你敲 cd my-blog 是 CLI。

两者能做的事高度重叠:用 GUI 可以删除一个文件,用 CLI 也可以。差别在于,用 CLI 做的事可以被完整地写成一行文字。

这正是 AI 偏爱命令行的原因。它可以输出 pnpm create astro@latest 让你粘贴执行,却没法输出「打开访达,进入这个文件夹,右键新建文件夹,重命名为 src」让你照做——后面这种描述不光啰嗦,还会因为界面版本不同而对不上。

代价是命令行不会主动告诉你当前的状态。图形界面里你能看见自己站在哪个文件夹,命令行不说,得自己问它。这就是 1.2.3 要讲的工作目录。

1.2.2 Terminal、Shell 与命令#

这三个词经常被混着用,但它们指的不是同一个东西。

终端(Terminal) 是那个窗口本身,负责显示文字、接收你的键盘输入。Shell 是运行在这个窗口里的程序,负责读懂你敲的那行字,然后去执行,常见的有 bash、zsh,Windows 上还有 PowerShell 和 cmd。命令(Command) 是你敲进去的那一行,比如 pnpm install。不同的 Shell 提示符长得不一样:

you@mac my-blog % 只显示当前目录的名字
you@host:/home/you/my-blog$ 显示完整路径
PS D:\Dev\my-blog> 显示完整路径

这里需要看懂的是:三种写法说的是同一件事——你当前站在哪个目录里——但它们显示的信息量不一样。末尾那个符号(%、$、>)只表示「可以输入了」。为什么有的显示完整路径、有的只显示末一级,1.2.3 会讲。

分清它们是因为报错来自不同的地方,而来源决定了怎么查。窗口里一个字都没有、或者显示成乱码,问题在终端本身;Shell 提示语法错误,是它没读懂你敲的那行;命令跑起来了但返回失败,说明问题在它要操作的对象上:目录不存在,或者文件格式不对。

平时会在哪里看到它:AI 会说 “open a terminal”“run this in your shell”,这两个说法在实际操作上没有区别。Windows 上有一件事需要留意:cmd、PowerShell 和 Git Bash 是不同的 Shell,同一行命令在它们之间的写法可能不一样。AI 说「在 PowerShell 里运行」时,把命令粘到 cmd 里可能失败。

1.2.3 工作目录#

工作目录(Working Directory) 是 Shell 当前所处的目录,也叫当前目录。

它在终端里的表现形式是提示符本身。PowerShell 默认把完整路径整个显示出来,就是 1.2.2 里那个 PS D:\Dev\my-blog>。macOS 和 Linux 上显示什么就不一定了:有的配置只显示当前目录的名字,有的显示完整路径,还有的用 ~ 代替主目录,取决于发行版和你的个人配置。

不同人的屏幕长得不一样,所以 pwd(print working directory)才是那个在任何 Shell 里都能给出完整路径的命令。

$ pwd
/Users/you/my-blog
$ cd src/pages
$ pwd
/Users/you/my-blog/src/pages

这两次 pwd 的输出需要看懂一件事:同一个 pwd 命令本身没变,变的只是你站的位置。

工作目录之所以重要,是因为它是相对路径的起点。1.1.4 里说过 ./scripts/build.sh 指向哪里取决于你站在哪里,工作目录决定的就是这个「哪里」。

它还有一个更实际的影响:很多命令必须在特定目录里执行。 pnpm install 要求当前目录下有 package.json,git status 要求当前目录在一个 Git 仓库里。站在别的地方执行,它们会报「找不到文件」或「不是一个仓库」。这类报错的措辞看起来像文件丢了,实际上文件好好的,只是你没站在它旁边。

平时会在哪里看到它:提示符末尾、pwd 的输出,以及 AI 那句出现频率极高的话——“make sure you are in the project root”。这句话的字面意思是确认你在项目根目录,对应的操作就是 cd 到那个文件夹。

1.2.4 常见文件操作#

命令行里的文件操作就那么几类,不需要背,只要在 AI 输出里认出它想干什么。

最常出现的是 cd 切换目录、ls 列出当前目录的内容(Windows 的 cmd 里写作 dir)、mkdir 新建目录。再往下是 cp 复制、mv 移动或改名、rm 删除;cmd 里对应的是 copy、move 和 del,PowerShell 则同时接受前后两套写法。

AI 给的一串操作,往往就是这几个词的组合:

cd my-blog
mkdir src
cp ../template/index.astro src/
rm -rf .cache

这四行需要看懂的不是语法,而是它们各自在动什么:进入一个目录、新建一个目录、复制一个文件进去、删掉一个目录连同里面的内容。

这些词你不需要记住拼写。需要建立的是另一种反应:看到它们出现时,知道 AI 正准备对文件系统做什么,以及动的是哪些文件——那才是判断的关键。cd src 和 cd ../.. 都是切换目录,但一个往下走一层,一个往回退两层。

删除需要单独说一句。rm -rf 会递归删除一个目录以及里面的全部内容,不进回收站,删掉就没了;Windows 上的 Remove-Item -Recurse -Force 是同一类操作。AI 建议清理文件时经常用这两种写法,语气通常很平常,和它建议改一行样式没什么区别。看到它们时该做的第一件事是把路径念一遍,确认那确实是你想删的东西。

1.2.5 PATH 是什么#

PATH 是一个环境变量(1.1.6),内容是一串目录的路径。它决定了你在命令行里敲下一个名字时,Shell 去哪些地方找这个程序。

$ echo $PATH
/usr/local/bin:/usr/bin:/bin
敲下 node 时,Shell 按这个顺序一个目录一个目录地找:
/usr/local/bin/node ← 先看这里
/usr/bin/node
/bin/node

这段输出需要看懂的是:冒号分隔的每一项都是一个目录,而且顺序有意义。过程是这样的:你敲 node,Shell 不会翻遍整个磁盘,它按 PATH 里列出的顺序一个目录一个目录地找,看哪个目录里有一个叫 node 的可执行文件。找到第一个就用它,后面的不再看。

上面是类 Unix 的规则,Windows 上有两处不同。PATH 的条目之间用分号分隔,不是冒号;cmd 还会先看当前目录,找不到时再自动尝试补上 .exe、.com、.bat、.cmd 这些后缀。第二条解释了一个 Windows 上常见的困惑:在 cmd 里,PATH 明明没有某个程序,敲它的名字却能跑起来——多半是因为你恰好站在了它所在的那个目录里。

PowerShell 不这样。同一个目录里的程序,必须写成 .\hello 才肯执行,直接敲名字会报「无法识别」。所以从 cmd 换到 PowerShell 的时候,原本能跑的命令有可能突然找不到:问题不在 PATH,在 Shell 自己的查找规则。

flowchart TD
  A["你敲下 node"] --> B["Shell 读取 PATH"]
  B --> C["按顺序在各个目录里查找 node"]
  C --> D["找到就执行,找不到就报错"]

为什么需要它:不然每运行一个程序都要敲完整路径。C:\Program Files\nodejs\node.exe 显然不如 node 好用。

它还有一个副作用:排在 PATH 前面的目录会赢。 如果两个目录里各有一个叫 node 的程序,Shell 用先找到的那个。这解释了一类很难查的问题——明明升级了 Node.js,node -v 显示的版本却没变,原因往往是旧版本还留在 PATH 更靠前的位置。

平时会在哪里看到它:echo $PATH 会打印出这一长串目录,Windows PowerShell 里是 $env:PATH,有几十条很正常。AI 说的 “add it to your PATH” 是让你把某个目录加进这个列表,“Node is not in your PATH” 是说列表里没有它,所以敲 node 找不到。

这里可以顺带解释一个几乎人人都遇到过的现象。装完 Node.js,AI 让你运行 node -v 确认,结果报「找不到命令」。这不是没装上,而是你已经打开的那个终端窗口是在安装之前启动的,它读的是当时的环境变量。关掉终端重新开一个,PATH 就更新了。这个动作看起来很玄,原理就是这么简单。

1.2.6 “command not found”是什么意思#

报这条错的时候,Shell 想说的是:我知道你想运行一个叫这个名字的程序,但我在 PATH 里的所有目录都找过了,没有它。

情况有三种,按可能性从高到低排。最常见的是真的没装——你在新电脑上敲 pnpm,而 pnpm 从没安装过。其次是装了,但装的位置不在 PATH 里,程序确实在磁盘上,Shell 不知道去哪找。第三种最容易让人困惑:装好了,PATH 里也有,但当前这个终端窗口是在安装之前打开的,环境变量在窗口启动时只读一次,之后不会自动更新。

这三种情况的处理方式不同,所以看到这条报错时不要直接重装。先做一件成本最低的事:关掉终端重新打开一个,再试一次。这个动作同时能排除第三种情况,也顺便刷新了环境变量。如果还不行,再去看是不是真的没装。

同一条报错在不同的系统上长得完全不一样:

'pnpm' is not recognized as an internal or external command,
operable program or batch file.
术语 'pnpm' 不会被识别为 cmdlet、函数、脚本文件或可执行程序的名称。
pnpm: command not found

第一段来自 Windows 的 cmd,第二段来自 PowerShell 7,第三段来自 macOS 和 Linux 的 Shell。

三句话说的是同一件事,但它们的差别值得留意一下:差别不在操作系统的语言,而在你用的是哪个 Shell。 同一台中文 Windows 上,cmd 输出的是第一段英文,而 PowerShell 7 输出的是第二段中文——连 PowerShell 自己的前后两代之间,措辞都改过一次。

这解释了一个很常见的困惑:AI 抄给你一条英文报错,你在自己屏幕上却一个字都对不上。它给的是它见过的那个版本,不是你这个 Shell 会说的话。对不上的时候,先确认你们说的是不是同一个 Shell,再去比内容,而不是怀疑自己看错了。这也是 AI 在跨平台说明里常把这条统一写成 “command not found” 的原因。

AI 可能会说:

“It looks like Node isn’t installed, or it’s not on your PATH.”

翻译成人话:

你敲的那个命令没跑起来,因为系统不知道该去哪里找这个程序。可能是它没装,也可能是装了但没告诉系统它在哪。AI 接下来通常会让你先运行 node -v 确认,再决定是重新安装还是配置 PATH。

实际发生的是:

Shell 读了一遍 PATH 里列出的所有目录,没有找到与命令同名的可执行文件,于是直接报错返回,什么都没执行。你敲的那行命令没有产生任何副作用,可以放心排查。

1.3 浏览器#

前面两节讲的是你自己电脑上的一般设施。这一节讲一个更具体的程序——浏览器。它是整条技术链的终点:你写的、AI 改的、构建出来的所有东西,都要交给它,也只有它会把结果变成你能看见的页面。

初学者对浏览器的印象通常停留在「上网用的软件」。但在现代 Web 开发里,它同时是运行环境、调试工具和一面镜子——你想知道代码实际产生了什么效果,最终都要回到这里看。

1.3.1 浏览器到底是什么#

flowchart TD
  B[浏览器] --> N[网络客户端:按 URL 去取文件]
  B --> P[解析器:把 HTML 和 CSS 读成结构]
  B --> R[排版与绘制引擎:算出每个元素画在哪、长什么样]
  B --> J[JavaScript 引擎:执行脚本]
  B --> S[本地存储]
  B --> X[安全边界]

浏览器不是功能单一的程序,它是几套东西装在一起:一个网络客户端(按 URL 去取文件)、一套解析器(把 HTML 和 CSS 读成结构)、一个排版与绘制引擎(算出每个元素画在哪、长什么样)、一个 JavaScript 引擎(执行脚本)、一块本地存储,以及一圈安全边界。

拆开看是有用的,因为 AI 提到「浏览器」时,指的往往只是其中一部分。「浏览器缓存」说的是本地存储,「浏览器沙箱」说的是安全边界,「浏览器控制台」说的是那套调试界面,「Safari 不支持这个属性」说的是排版与绘制引擎。知道它由几块组成,你就能判断一句话在说哪一块。

浏览器同时跨越了 L0 和 L1 两层。作为运行环境,它属于 L0;而它读的那些材料——HTML 和 CSS——属于 L1。这个交界位置也解释了一件事:为什么「在浏览器里」和「在服务器上」会成为本书反复出现的分界线。

1.3.2 浏览器引擎#

浏览器引擎(Browser Engine) 是浏览器里负责把 HTML 和 CSS 变成画面的那一部分,也叫渲染引擎。

主流的有三个:Blink、WebKit、Gecko。基于 Chromium 的浏览器用 Blink,Safari 用 WebKit,Firefox 用 Gecko。它们对同一段 HTML 和 CSS 的理解大部分一致,但在新特性的支持时间和细节表现上会有差别。

需要知道它,是因为同一个页面在不同浏览器里可能长得不一样,而 AI 有时会提到这件事。它说 “this works in Chrome but not Safari”,意思就是某个特性在 WebKit 那边还没实现,或者实现方式不同。

Chromium 和 Chrome 的关系需要先拆开。Chromium 是一个开源项目,Chrome 和 Edge 都是基于它做出来的产品。AI 说 “Chromium-based browsers” 时,指的就是 Chrome、Edge、Brave 这一类共享同一套引擎的浏览器,它们在渲染行为上基本一致。这个区分在排查兼容性问题时很有用:问题只出现在 Safari,那大概率是 WebKit 的差异;两个 Chromium 系的浏览器都正常,就不用怀疑 Blink。

1.3.3 JavaScript 引擎#

JavaScript 引擎(JavaScript Engine) 是浏览器里负责执行 JavaScript 代码的那一部分。它读一段文本形式的 JavaScript,把它变成机器能执行的动作。

常见的也是三个:V8、JavaScriptCore、SpiderMonkey。基于 Chromium 的浏览器用 V8,Safari 用 JavaScriptCore,Firefox 用 SpiderMonkey。

它和浏览器引擎是两回事,虽然经常一起被提起。浏览器引擎管「页面长什么样」,JavaScript 引擎管「脚本执行什么」。一段 JavaScript 报错,报错来自 JavaScript 引擎;一个布局错位,问题在浏览器引擎。

这里有一个连接很关键,它会在第五章展开:Node.js 用的也是 V8。 JavaScript 本来是为了在浏览器里操作页面而设计的,能执行它的程序就是 JavaScript 引擎。把这个引擎从浏览器里拿出来,换上一套访问文件和网络的接口,就得到了一个能在你自己电脑上运行 JavaScript 的环境——这就是 Node.js。理解了这一点,你就能回答那个反复出现的疑问:一个做网页的项目,为什么先要装 Node.js。

1.3.4 浏览器沙箱#

沙箱(Sandbox) 是浏览器给网页划出的一圈限制:网页里的 JavaScript 不能读写你磁盘上的任意文件,不能随意启动别的程序,也不能随便访问别的网站的数据。

为什么必须有它:你每天会打开几十个陌生网站,其中绝大多数都会在你电脑上运行代码。没有沙箱,一个页面就能悄悄读走你的文件。沙箱把「打开一个网页」这件事的风险限制在浏览器内部。

在沙箱成为标准之前,网页要运行复杂功能,得靠浏览器插件——在你机器上拥有完整权限的小程序,也因此长期是安全问题的来源。今天浏览器的做法是:先假设网页不可信,再逐项开放它需要的能力。

这条边界在实际开发里会以几种具体形式出现:

网页里的 JavaScript 可以做的事:
显示界面、响应点击
发起网络请求(受同源策略限制)
把数据存进 localStorage 这类容器
读取用户主动选中的文件
做不到的事:
读写磁盘上任意位置的文件
读取另一个网站的数据
启动这台电脑上的其它程序
拿到用户选中文件的真实路径

这张对照需要看懂的是:下面那一半不是「暂时没做」,而是浏览器不允许——所以凡是落在下半部分的需求,都得交给服务器。

表里有两个名字顺便认识一下。localStorage 是浏览器提供的一个存储容器,页面可以往里存数据,关掉浏览器也还在,容量以 MB 计。同源策略则是浏览器限制一个站点去读另一个站点数据的规则,违反它的请求会被拦下来。这两样后面都会再遇到。

对本书读者来说,沙箱最实用的价值是它能帮你推理。当 AI 说某件事「必须在服务器上做」时,原因往往就在这里——不是技术上写不出来,而是浏览器不允许。要发邮件、要读数据库、要接触密钥,都得放到服务器那边去。判断某个功能能不能纯在浏览器里实现,是读 AI 输出时很常用的一次判断。

1.3.5 浏览器缓存#

浏览器缓存(Browser Cache) 是浏览器把下载过的东西存一份在本地的机制。下次需要同一个文件时,先用本地那份,不再去服务器取。

为什么需要它:一个页面会涉及很多文件——HTML、CSS、JavaScript、图片、字体。如果每次打开都重新下载一遍,会慢得难以接受。缓存让第二次访问快得多。

在 DevTools 的 Network 面板里,命中缓存的请求会显示成这个样子,关键在 Size 那一列:

Name Status Size
style.css 200 (disk cache)
app.js 200 (memory cache)
index.html 200 1.2 kB

前两行没有被重新下载,第三行才是真的从服务器取回来的。

缓存带来的代价,是开发过程中最常见的一类困惑:你改了代码,浏览器里看不到变化。 因为浏览器认为它手上那份还有效,压根没去服务器问。AI 提示 “hard refresh”——按 Ctrl+Shift+R,macOS 上是 Cmd+Shift+R——就是在绕过缓存重新下载一次。

缓存不完全是浏览器单方面决定的。服务器可以在响应里带上 Cache-Control 这类说明,告诉浏览器这个文件可以存多久。1.7.5 会讲这个字段出现在哪里,1.7.8 里那个 304 状态码也属于这件事。

这里要加一条限定,免得你排查时走错方向:开发服务器一般会让源码每次都重新取,因为你希望改完立刻看到结果。所以如果你用的是开发服务器、页面却还是旧的,问题多半不在缓存上。缓存问题更常出现在构建产物和线上环境。

缓存生效的时候,浏览器和服务器之间会有一小段你没看见的对话。浏览器带着上次记下的标记去问「这个文件变了没有」,服务器如果回答「没变」,就只回一个状态码、不重传内容:

浏览器:GET /style.css
If-None-Match: "a1b2c3"
服务器:304 Not Modified

这几行需要看懂的是:第二次访问一个页面时,很多文件根本没有被重新下载,只发生了这样一次很短的确认。这也解释了为什么清空缓存之后第一次打开会明显变慢——那一次是真的从头下载了一遍。

1.3.6 开发者工具 DevTools#

开发者工具(DevTools) 是浏览器内置的一套调试界面,一般按 F12 打开,也可以右键选择「检查」。

它有十几个面板,本书后面会用到的主要是这几个:

  • Elements:当前页面的结构,以及每条样式是从哪来的。
  • Console:脚本输出的信息和报错。
  • Network:这个页面发起了哪些请求、每个请求花了多久、返回了什么。
  • Application:本地存了哪些数据、缓存里有什么。

为什么本书的读者要用它:因为它是浏览器那一侧实际情况的唯一直接来源。AI 可以描述代码应该产生什么效果,但页面到底变成了什么样、有没有报错、哪个请求失败了,只有在这里能看到。

Console 里的报错通常长这样,它们说的都是在页面上发生的事:

Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')
at main.js:12

最后一行指出出错的位置在哪个文件、第几行,这是排查时最有用的信息。

有一件事需要单独强调:Console 里的报错和终端里的报错属于两个不同的世界。 终端里报的是你本机工具链的问题——依赖没装、命令敲错。Console 里报的是浏览器执行页面时的问题——某个属性不存在、某个请求被拒绝。看到报错时先分清它出现在哪个窗口,能省掉很多来回。

1.4 客户端与服务器#

前面三节讲的都是「你这台机器」。这一节把第二台机器引进来,因为网站的本质就是两台机器在说话。

这一节要给出的是一组基础词汇:谁在问、谁在答、问的是什么、答的是什么。这组词汇在 1.7 会以 HTTP 的正式形式重新出现一次。

1.4.1 Client#

客户端(Client) 是主动发起请求的那一方。

最常见的客户端是浏览器:你输入一个地址,它去把那台机器上的东西取回来。但客户端不只有浏览器——手机 App、命令行里的 curl、另一个服务器上的程序,只要它主动去要东西,它此刻就是客户端。

需要这个称呼,是因为「谁来要」和「谁提供」是网络里最基本的角色分工,而同一个程序在不同场景下可能扮演不同角色。你的开发服务器在浏览器眼里是服务器;如果它去调用另一个接口取数据,那它此刻又是客户端。

1.4.2 Server#

服务器(Server) 是等待请求、收到就回应的一方。

它和客户端最本质的差别在于角色:一方主动去要,一方等着被要、收到就回应。

至于「一直在待命」,那是部署时的现实要求,不是定义的一部分。真实用户随时可能来访问,所以正式上线的网站通常放在一台持续在线的机器上;但技术上你完全可以在自己的笔记本上跑一个服务——本章 1.6.6 里的 --host 就是让你用手机去访问这台笔记本上的页面。这也让「服务器」这个词在日常语境里有了两种含义:既指一台机器,也指这台机器上运行的那个程序。AI 说 “deploy to the server” 时通常指机器,说 “start the server” 时通常指程序。

关于服务器,本书先建立这一个认知:你在本地开发时用的那个「开发服务器」(0.4.2 讲过),和你发布网站时用的服务器,运行的是同一类东西,区别只在于一个只有你能访问,另一个所有人都能访问。

1.4.3 Request#

请求(Request) 是客户端发给服务器的一条消息,意思是「我要这个」。

一条请求要说明三件事:要什么、用什么方式要,以及一些附加信息。完整格式在 1.7.2 展开。这里先建立一个直觉——请求是有固定结构的,不是随便喊一句。正因为双方都遵守同一个结构,浏览器和服务器才不需要事先认识对方:你访问一个从没去过的网站,它的服务器也能正确回应你,靠的就是这个共同约定。

1.4.4 Response#

响应(Response) 是服务器发回的消息,包含三件事:这件事办得怎么样、返回的是什么类型的东西,以及东西本身。

「办得怎么样」用一个数字表示,1.7.7 会讲。「是什么类型的东西」同样重要,它决定了浏览器接下来怎么处理内容——当网页渲染,当图片显示,还是当数据交给脚本。1.1.5 提过,扩展名不决定这件事,响应里的类型说明才决定。

请求和响应合起来构成一次完整的往返。这个一问一答的模型是网络部分的骨架:URL 是「问什么」,DNS 是「问谁」,HTTP 是「怎么问」。

1.4.5 前端与后端#

前端(Frontend) 指在用户浏览器里运行的代码。后端(Backend) 指在服务器上运行的代码。

划分依据是代码跑在哪里,不是文件放在哪里。项目目录里可能既有前端的文件也有后端的文件,甚至混在同一个文件夹里;决定一个文件属于哪一侧的,是它最终在哪里执行。

flowchart TD
  U[用户] --> B[浏览器:前端代码在这里运行]
  B --> S[服务器:后端代码在这里运行]
  S --> DB[数据库:只有后端能直接访问]

两侧的分工直接来自 1.3.4 讲的沙箱。前端能做的事被限制在浏览器允许的范围内:显示界面、响应用户操作、发起请求。它不能直接读数据库,不能替用户做需要权限的判定,也不能真正保住一个密钥——写进前端代码的任何东西,用户都能在浏览器里翻出来。这些都得放到后端。所以一个功能该放哪一侧,往往不是偏好问题,而是由沙箱边界决定的。

这里有一个澄清:前端和后端不等于两种语言。传统上后端常用 PHP、Java、Python,前端只能用 JavaScript;但有了 Node.js 之后,JavaScript 在两边都能跑。第五章会讲这件事是怎么发生的。在那之前,记住区分依据是「运行在哪里」就够了。

1.4.6 静态网站与动态网站#

这两个词说的是服务器返回内容的方式,不是网站看起来动不动。

静态网站(Static Site) 指服务器直接把已经存在的文件递出去。about.html 就在磁盘上,有人要就给他。这类网站简单、快、不容易出故障。

动态网站(Dynamic Site) 指服务器收到请求后现算一份内容再返回。比如一个要显示当前用户信息的页面,服务器得先知道你是谁,才决定返回什么。

还有第三种做法,在现代 Web 开发里很常见:构建时生成。内容不在用户请求时算,而是在你发布之前,由构建工具一次性算好,生成一批静态文件。用户访问时拿到的是静态文件,速度接近静态网站;而内容是程序生成的,又保留了动态网站的方便。

flowchart TD
  R[用户请求一个页面] --> A[静态文件:磁盘上现成的,直接给]
  R --> B[动态:服务器收到请求后现算一份]
  R --> C[构建时生成:发布前就算好,请求时按静态文件给]

为什么现在提这个:因为 AI 经常说 “this page is statically generated”。这句话的意思是,这个页面的 HTML 在你发布之前就已经生成好了,服务器不需要在每次访问时重新算一遍。它能解释很多现象——比如为什么改一篇文章可能需要重新构建,以及为什么依赖用户登录状态的内容不能这样生成。第七章讲 Astro 时会回到这里。

1.5 URL#

前面几节反复出现同一个东西,一直没有展开:地址。你在浏览器里输入的、AI 让你打开的、报错信息里出现的,都是它。

URL 需要单独占一节,因为它是网络部分最具体的概念——HTTP 请求要发到哪里、DNS 要查什么、服务器怎么知道你要哪个页面,答案全部藏在这一串字符里。

1.5.1 URL 是什么#

URL(Uniform Resource Locator,统一资源定位符) 是一个地址,用来说明「用什么方式、去哪台机器的哪个服务、要哪一份东西、带什么附加条件」。

一个完整的 URL 长这样:

https://example.com:443/blog/hello?from=home#comments

这一串分成六段,每一段回答一个问题:

https:// 用什么方式取 Protocol
example.com 去哪台机器 Host
:443 哪个服务 Port
/blog/hello 要哪一份东西 Path
?from=home 附带什么条件 Query
#comments 页面内哪个位置 Fragment

接下来六节逐个讲它们——这样安排是因为,排查问题时出错的通常只是其中某一段,而不是整条地址都错。

先记住一件事:URL 里的每一段都有明确职责,不是一串随手拼出来的字符。所以看到 localhost:4321 时,你可以准确指出 localhost 说的是「哪台机器」、4321 说的是「哪个服务」,而不是笼统地觉得那是个网址。

严格说,URL 还能在机器名前面带上用户名和密码。它在普通网页的地址里几乎见不到,但在配置文件的连接串里很常见,比如数据库地址:

postgres://bloguser:secret@localhost:5432/blog
协议 postgres
用户名 bloguser
密码 secret
机器 localhost
端口 5432
库名 blog

这段需要看懂的是:连接串本身就是 URL,只是多带了身份信息。AI 让你把密码写成这种字符串是常规做法,但那份配置不应该被提交到 Git 仓库——1.1.6 讲的 .env 正是为此存在的。

1.5.2 Protocol#

协议(Protocol) 是 URL 最前面那一段,以 :// 结尾。它说明用什么方式去取这份东西。

最常见的两个是 http 和 https,区别在 1.7.9 展开。除此之外还有几个会见到:file 表示直接读本机文件,mailto 表示发邮件,ws 和 wss 用于需要双向持续通信的场景。

为什么需要它:同一台服务器可以用多种方式提供东西,协议是第一步分流。浏览器看到 https:// 就知道要走加密连接,看到 http:// 就走明文。

平时会在哪里看到它:AI 让你打开某个地址时、配置文件里的接口地址、以及 “mixed content blocked” 这类报错。后一条报错的意思是:一个 HTTPS 页面里混进了一个 HTTP 地址,浏览器出于安全考虑拒绝加载它。

1.5.3 Host#

Host 是 :// 之后、到下一个 / 或 : 之前的那一段。它说明要访问哪台机器。

它可以是一个域名,比如 example.com;也可以直接是 IP 地址,比如 127.0.0.1。这两者的关系在 1.6 展开。

Host 的第二个作用是很多人没注意到的:同一台服务器上可以放很多个网站。 请求发过去时 Host 会一并带上,服务器靠它判断你要的是哪一个站。这也解释了为什么改一下域名解析就能让网站「搬走」,而你不需要改任何代码——服务器认的是这个名字。

平时会在哪里看到它:URL 里、配置文件的 host 字段,以及报错里的 “host not found” 或 “could not resolve host”。后两条的意思是:这个名字没能换成 IP 地址,属于 DNS 的问题(1.6.3)。

1.5.4 Port#

Port 是 Host 后面冒号加数字的那一段,说明要访问这台机器上的哪个服务。

1.1.8 讲过端口本身,这里要说的是 URL 里的省略规则:不写端口时,按协议取默认值。 对 http 是 80,对 https 是 443。所以下面两个地址完全等价:

https://example.com/
https://example.com:443/

这条规则解释了一个初学者常有的困惑:为什么访问普通网站不用写端口,而访问你自己的开发服务器必须写 localhost:4321。因为开发服务器既不在 80 上,也不在 443 上,它在 4321 上。少了 :4321,浏览器会去找 80 端口,那里没有东西。

1.5.5 Path#

Path 是 Host 和 Port 之后的那一段,从 / 开始。它说明要这份东西的哪一部分。https://example.com/blog/hello 里的 /blog/hello 就是 Path。

这里有一个初学者最容易误解的地方:URL 里的 Path 看起来像文件路径,但在动态网站里通常不是。 同一个地址,服务器至少有两种完全不同的做法:

做法一:磁盘上真的有一个 hello.html,读出来直接发回去
做法二:拿 “hello” 去数据库查一篇文章,把标题和正文填进模板,生成 HTML 发回去

服务器收到 /blog/hello,它可以去磁盘上找这个文件,也可以拿这段文字去数据库里查一篇文章、现拼一个页面出来,甚至把它转交给另一个程序处理。对浏览器来说,这几种情况的区别是看不见的。

把这个误解清掉很有用,因为它同时解释好几件事:为什么地址末尾没有 .html 也能显示网页;为什么同一个 Path 在登录前后返回的内容不一样;为什么改了文章标题,地址却可以保持不变。AI 说 “this route returns a 404” 时,说的就是这段 Path 在服务器那边没匹配上任何处理逻辑。

1.5.6 Query#

Query 是 ? 之后那一段,由一组键值对组成,用 & 分隔。它给服务器附带条件:

https://example.com/blog?page=2&tag=astro

这个地址里 page=2 和 tag=astro 是两个条件,服务器据此决定返回第二页、并且只看带 astro 标签的文章。

它和 Path 的分工需要分清:Path 说明「你要哪一类东西」,Query 说明「在这一类里,你要哪一份」。所以翻页、筛选、搜索关键词这类信息通常放在 Query 里,而文章本身的地址放在 Path 里。

平时会在哪里看到它:搜索结果的地址栏、AI 让你手动打开某个带参数的接口地址,以及代码里发网络请求时拼出来的地址——这类请求在 JavaScript 里通常用 fetch 发出,第三章会讲。

1.5.7 Fragment#

Fragment 是 # 之后那一段,也叫锚点。

它和其他五段有一个本质区别:Fragment 不会被发送给服务器。 浏览器在发出请求之前就把它去掉了,只留给自己用。服务器永远不知道你的地址里带了 #comments。

这一点很反直觉,但后果很直接。常见用途有两种:跳到页面内的某个位置,#comments 让浏览器滚到那段内容;以及给前端路由用,某些框架把当前页面的状态写在 Fragment 里。

地址栏里写:https://example.com/blog/hello#comments
服务器实际收到:GET /blog/hello

对照这两行就能看出差别:# 连同它后面的内容,在请求发出之前就已经被去掉了。

因为它不发给服务器,一类困惑会因此产生:你看到一个带 # 的地址,以为服务器返回了不同的内容,实际上服务器收到的是同一个请求,变化全部发生在浏览器里。AI 说 “that part is client-side only” 时,说的往往就是它。

这也是判断「某件事发生在哪一侧」的一个具体入口:URL 里 # 之前的部分服务器能看到,# 之后的部分只有浏览器知道。

1.5.8 相对 URL 与绝对 URL#

1.1.4 讲过路径分绝对和相对,URL 也是同一回事,只是对象从本机文件换成了网络地址。

绝对 URL 写全了协议和 Host(有时还有 Port),不管出现在哪个页面里,它指向的都是同一个地方:

https://example.com/about

相对 URL 只有一部分,实际指向哪里取决于它出现在哪个页面上:

/about 从这个网站的根算起
about 从当前页面所在的位置算起
../about 从当前位置的上一层算起

为什么要分清:你写链接时用哪一种,决定了它在不同页面上的行为。相对 URL 的好处是网站换域名不用改链接,坏处是在嵌套较深的页面里,同一个写法可能指到意料之外的地方。AI 生成的站内导航大多是相对 URL,而指向外部网站的链接必须是绝对 URL。

还有一个细节在实际项目里经常出问题:/about 和 about 不一样。带斜杠的表示「从网站根目录算起」,不带斜杠的表示「从当前目录算起」。在首页上它们碰巧指向同一个地方,在 /blog/hello 这样的页面上就完全不同。

1.6 域名与 DNS#

上一节讲了 URL 里有一段叫 Host,它说明要访问哪台机器。这一节讲这个名字和真实机器之间还差的那一步。

这也是本章最实用的一节,因为本地开发中那些「地址看上去明明是对的,却打不开」的问题,答案大多在这里。

1.6.1 域名#

域名(Domain Name) 是给人看的机器名字,比如 example.com。

它的结构是从右往左读的层级:最右边是顶级域(com、org、cn 这类),往左一级是你注册的那个名字,再往左是你可以自己划的子域。三层拼起来就是一个完整的主机名:

flowchart LR
  T[com 顶级域] --> D[example 你注册的域名]
  D --> S[blog 你可以自己划的子域]

为什么需要它:IP 地址不好记,而且可能会变。服务器换机房时 IP 会变,但域名可以保持不变——这正是域名存在的意义:用户记的是名字,而名字指向哪里是可以改的。

平时会在哪里看到它:URL 里、部署服务让你配置的「自定义域名」那一栏,以及配置里的 allowedHosts 这类字段。这些配置要填域名,是因为服务器经常需要按名字来决定该不该回应。

以 allowedHosts 为例,它是开发服务器的一份主机名白名单,默认只接受 localhost 和 IP 地址。它需要存在,恰恰是因为 1.3.4 讲的同源策略在这里帮不上忙:如果别的网站把自己的域名解析到 127.0.0.1,再让你的浏览器去请求,那在浏览器看来仍然是同源的,拦不住——只能由开发服务器自己检查请求里带来的名字。官方文档把这个风险叫 DNS rebinding,并明确建议不要把这个列表放开成「接受任意主机」。

顺带把两个容易混的词分开:同源策略是浏览器的规矩,判断依据是协议、域名、端口三者合起来的「源」(1.5.4 讲过端口也参与区分);CORS 则是服务器用来放宽这条规矩的一套说明,所以它出现在响应头里(1.7.5 会讲)。

1.6.2 IP 地址#

IP 地址是机器在网络里的编号。数据要送到某台机器,最终必须知道它的 IP 地址。

目前有两套编号方式并存:

IPv4 192.168.1.1 四段十进制数字
IPv6 2001:db8::1 八组十六进制,连续的全零可以缩写

IPv6 出现的原因是 IPv4 的地址不够用了。两套写法各有自己的「本机地址」,这一点在 1.6.5 会造成一个很实际的麻烦:

127.0.0.1 IPv4 里表示本机自己
::1 IPv6 里表示本机自己

两套编号并存带来一个实际后果:同一台机器通常同时有一个 IPv4 地址和一个 IPv6 地址。 这个事实看起来平淡,却是 1.6.5 那个常见故障的根源。

127.0.0.1 和 ::1 是两个特殊地址,分别表示 IPv4 和 IPv6 里的「本机自己」。它们不对应任何网络设备,数据不会离开这台机器。

1.6.3 DNS#

DNS(Domain Name System,域名系统) 是把域名翻译成 IP 地址的系统。可以把它理解成网络的电话簿:你说出一个名字,它告诉你去哪里找。

为什么需要它:1.6.1 说过域名是给人记的,但网络传输认的是 IP 地址,中间这一步翻译必须有东西来做。

flowchart TD
  B[浏览器要访问一个域名] --> C1[先查浏览器自己的缓存]
  C1 --> C2[再查操作系统缓存]
  C2 --> C3[都没有,才去问 DNS 服务器]
  C3 --> IP[拿到对应的 IP 地址]

没有它会怎样:你访问每个网站都得先查出它的 IP,然后手动敲那串数字;网站一换服务器,你记下的地址就失效了。

DNS 有一个特点会在实际工作中造成困扰:它有缓存。 查询结果会在浏览器、操作系统和沿途的各级服务器上保留一段时间。你改了域名的指向,自己的电脑可能几分钟就更新,别的地区可能还要等上几小时。这就是 AI 说的 “DNS propagation”(DNS 传播)。严格说并没有人在推送,而是各处的缓存陆续到期。看到这句话你该知道的是:这通常不是配置错了,而是需要等。

平时会在哪里看到它:部署平台让你去域名服务商那里改的解析记录、报错里的 “could not resolve host” 或 “getaddrinfo ENOTFOUND”,以及 AI 判断「这是 DNS 问题」的时候。

1.6.4 localhost#

localhost 是一个特殊的域名,它固定指向「这台机器自己」。

它和其他域名最大的不同是:解析它的规则是特殊的。按标准约定,负责解析域名的程序遇到 localhost 时应当直接返回本机回环地址,不去问网络上的 DNS 服务器。所以哪怕你完全断网,localhost 通常也照样能用。

这里用「应当」和「通常」,是因为这条约定是建议性质的,并非所有解析程序都严格照做。日常开发你不用关心这个差别,但它解释了一件事:localhost 的行为在不同系统上可能略有不同,遇到怪现象时不必先怀疑自己的代码。

这一点解释了本地开发为什么能在离线状态下进行:开发服务器跑在你自己的机器上,浏览器访问 localhost 时也没有离开这台机器,整条链路不涉及外部网络。

那它为什么有时候会出问题?因为它可以对应两个地址——IPv4 的 127.0.0.1 和 IPv6 的 ::1。到底用哪一个,取决于系统和程序查找的顺序。

1.6.5 127.0.0.1#

127.0.0.1 是 IPv4 里表示「本机自己」的地址,IPv6 里对应的是 ::1。

日常使用中,localhost 和 127.0.0.1 看起来是一回事,很多时候也确实是一回事。但它们不总是等价,而这正是本地开发里一个经典故障的来源。

问题出在顺序上。程序去解析 localhost 时,如果它先拿到 ::1,就会去连 IPv6 的那个地址;而如果你的服务只监听了 IPv4 的 127.0.0.1,这次连接就会失败——即使服务好好地跑着。

在启用了 IPv6 的机器上,这个顺序确实可能把 IPv6 排在前面。下面是本机实际的一次查询结果:

$ node -p "require('dns').getDefaultResultOrder()"
verbatim
$ node -e "require('dns').lookup('localhost',{all:true},(e,a)=>console.log(JSON.stringify(a)))"
[{"address":"::1","family":6},{"address":"127.0.0.1","family":4}]

这两行输出需要看懂三点。第一行的 verbatim 表示解析结果按系统给的顺序返回,程序不会替你把 IPv4 排到前面(Node.js 从 17.0.0 起才把默认行为改成这样,更早的版本会优先给出 IPv4)。第二行说明 localhost 在这台机器上确实同时对应两个地址,而且 IPv6 那个 ::1 排在前面。合起来就是:在这台机器上,如果一个服务只监听 127.0.0.1,用 localhost 去访问就可能连不上,直接写 127.0.0.1 指定 IPv4 反而能通。

这个过程可以画成一条分叉:

flowchart TD
  L[浏览器访问 localhost:4321] --> A[名字解析出两个地址]
  A --> B[先试 ::1]
  B --> C[服务只监听 127.0.0.1,这里没人]
  C --> D[这次连接失败]

这也解释了为什么这时看不到任何「端口被占用」的提示:1.1.8 说过,占用是按协议、IP、端口三者一起算的,::1:4321 和 127.0.0.1:4321 本来就是两个不同的位置。

这就是 AI 说 “it works on 127.0.0.1 but not on localhost” 时背后实际发生的事。它不是缓存问题,也不是代码问题,而是名字解析出了两个结果、而服务只站在了其中一个上面。遇到这个现象,第一件可以做的事就是把 localhost 换成 127.0.0.1 试一次——这个动作能立刻把问题范围缩小一半。

1.6.6 localhost:4321 到底表示什么#

把前面几节合起来,这个地址可以完整地读出来。localhost 是 Host 段,表示要访问的是这台机器自己(1.6.4)。4321 是 Port 段,表示这台机器上监听 4321 端口的那个服务(1.1.8)。前面省掉的协议默认是 http,因为本地开发通常不使用 HTTPS(原因在 1.7.9)。

所以这句话的完整意思是一个很日常的动作:打开浏览器,访问我刚刚在自己电脑上启动的那个服务。 它不涉及互联网上的任何服务器,这句话在你完全断网时依然成立。

那为什么是 4321 这个数字?它是 Astro 开发服务器的默认端口。官方文档写得很直接:server.port 的默认值是 4321。更早的版本用的是 3000,从 Astro 3.0 起才改成 4321。Vite 的默认端口是 5173,预览服务器是 4173。你在 AI 输出里看到的端口号,通常是这几个之一,或者项目自己配置的某个值。

接下来是这一节最有用的一件事:端口被占用时会发生什么。

假定 4321 已经被别的程序占着——也许是上一次没关掉的开发服务器,也许是另一个项目。这时 Astro 不会直接失败,它的做法是自动换一个能用的端口。官方文档的原话是,如果给定的端口已被占用,Astro 会自动尝试下一个可用端口。Vite 的行为一样,文档里还多说了一句:这个端口不一定是服务器最终采用的那一个。

这时终端上会打印出它实际使用的地址,大意是这样两行(不同工具的措辞和排版不一样):

Local http://localhost:4322/
Network use --host to expose

结果就是:终端打印出的地址变成了 localhost:4322,而你或者 AI 可能还在打开 localhost:4321。打开的那个地址上,要么什么都没有,要么是上一个项目的页面——因为占着 4321 的正是那个没关掉的旧服务。

这个场景在 AI 辅助开发里出现频率很高,症状又很有迷惑性:你以为是代码改错了,实际上是看错了端口。识别它的方法是以终端打印出来的那行地址为准——启动开发服务器后就看那一行,不要用记忆里或者对话里出现过的地址。

AI 可能会说:

“Port 4321 is already in use, so the dev server is now on 4322.”

翻译成人话:

你想用的那个端口已经被别的程序占着,所以这次服务换了一个端口启动。终端上打印的新地址才是真的,原来那个地址现在指向的是别人。要么用新地址,要么先把占用端口的旧进程结束掉,再按原地址重启一次。

实际发生的是:

开发服务器启动时先尝试绑定 4321,系统告诉它这个端口已被占用,于是它依次尝试后面的端口,直到找到一个空闲的。整个过程不会中断,所以你在终端里看到的是一个换了号的地址,而不是一条报错。

如果希望端口被占用时直接报错而不是悄悄换一个,Vite 提供了 strictPort 选项。两种做法各有道理:自动换端口让你能立刻开始工作,代价是地址和你预期的不一样;严格模式让问题立刻暴露,代价是每次都要自己先关掉旧进程。

还有一个初学者常问的问题:这个地址能发给朋友吗?不能,而且原因是设计如此。开发服务器默认只监听本机,不对外网开放。Astro 的 server.host 默认是 false,含义是不在网络地址上暴露。

要让它被同一局域网里的其他设备访问,需要显式打开——命令行加 --host,或者配置里设 host: true。官方文档在这里给了一句明确的警告:不要在生产环境用 --host 把开发服务器暴露出去,这个服务器只为本地开发设计。原因不难理解——它没有为承载真实流量和抵御攻击做准备。

平时会在哪里看到它:AI 让你打开某个 localhost 地址、说「我用 --host 让你在手机上预览一下」,或者说「端口被占用,换成 4322 试试」。这三句话现在都能对应到具体的东西上了。

1.7 HTTP#

前面几节讲了地址怎么组成、名字怎么换成 IP。这一节讲最后一步:消息本身长什么样。

HTTP 是本书出现频率最高的一个词,因为 AI 报的很多问题都会归到它身上——404、500、请求头、响应体、接口。这一节不打算把协议讲完,只讲清楚那几条你每天都会碰到的规则。

1.7.1 HTTP 是什么#

HTTP(HyperText Transfer Protocol,超文本传输协议) 是浏览器和服务器之间约定好的对话格式。

为什么需要一个约定:因为这两边可能是完全不同的程序、由不同的人写、跑在不同的操作系统上。它们事先不认识对方,却要能互相理解。办法就是事先定好格式——请求必须长成某个样子,响应必须长成某个样子,谁不遵守谁就沟通不了。

HTTP 有一个性质需要先建立:它是无状态的。 每个请求都是独立的,服务器处理完一个请求,不会自动记得上一个请求是谁发的。

flowchart LR
  A[第一次请求] --> S[服务器:处理完就不记得了]
  B[第二次请求] --> S2[服务器:不知道这次是谁]
  S2 --> F[所以每次请求都要自己带上身份说明]

这个性质带来一个直接后果:网站要「记住你」,必须额外想办法。你登录之后,服务器怎么知道下一个请求还是你?靠的是每次请求都额外带上一份身份说明,而不是靠服务器自己的记忆。Cookie、登录令牌这类东西,就是这份身份说明。本书不展开它们的实现,但你需要知道:你在网站上看到的几乎所有「记住我」的功能,都是在无状态的 HTTP 之上额外搭出来的。

平时会在哪里看到它:AI 说 “the API returned a 404”、DevTools 的 Network 面板,以及各种接口文档。

1.7.2 HTTP Request#

一条 HTTP 请求由三部分组成:请求行、若干 Header,以及可选的 Body。

请求行说明「用什么方式、要哪个路径」。Header 是一行行的键值对,提供附加信息。Body 是随请求一起提交的内容,不是每条请求都有。一个最小的请求长这样:

GET /blog/hello HTTP/1.1
Host: example.com
Accept: text/html

这段内容需要看懂三点。第一行的 GET 是方法(1.7.4),/blog/hello 是要的路径(1.5.5)。第二行的 Host 告诉服务器「我要的是这个域名下的内容」,1.5.3 提过同一台服务器上可能放着很多站。第三行的 Accept 是浏览器在说「我希望能收到 HTML」——注意这是期望,不是命令,服务器完全可以返回别的东西。

提交表单时,请求会长成这样——比上面多了一个 Body:

POST /subscribe HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
email=me%40example.com

对照两段请求可以看出:方法从 GET 变成了 POST,空行之后多了一段内容,而 Header 里的 Content-Type 说明了这段内容要怎么读。

平时会在哪里看到它:在 DevTools 的 Network 面板里点开任意一个请求,都能看到它的 Header;AI 让你检查「请求头里有没有带上这个字段」时,说的就是这里。

1.7.3 HTTP Response#

一条 HTTP 响应同样由三部分组成:状态行、若干 Header,以及 Body。

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<h1>Hello</h1>

这段内容需要看懂三点。第一行的 200 说明这次请求办成了(1.7.7)。第二行的 Content-Type 说明后面那段内容是什么类型——1.1.5 讲过,决定浏览器怎么处理一份东西的是它,不是文件扩展名。空行之后是 Body,也就是正文内容。

charset=utf-8 那一段说明文字用哪种编码。中文网页如果没有正确声明编码就会出现乱码,根源就在这里,而不是「中文显示不出来」。你自己项目里的中文出现乱码时,要检查的也是这一行。

1.7.4 Method#

Method(方法) 说明这次请求想做什么。常用的有五个:GET 取东西,是最常见的一种;POST 提交东西,通常附带 Body;PUT 和 PATCH 更新已有的东西,PUT 整体替换,PATCH 只改一部分;DELETE 删除东西。

对本书读者来说,最有用的是一条经验:方法本身说明了这次操作的意图,而意图决定了它会不会改变服务器上的数据。 GET 是只读的,不会改动任何东西;POST、PUT、PATCH、DELETE 都可能改动数据。看到 AI 说要发一个 DELETE 请求,你就知道这次操作真的会删东西,要先确认清楚。

AI 可能会说:

“I’ll send a DELETE request to remove that record.”

翻译成人话:

它准备让服务器删掉一条数据。这和改一个字段不是一回事——改错了还能改回来,删掉了通常就没了。所以在同意之前,要先确认两件事:删的是哪一条,以及有没有备份。

实际发生的是:

客户端向服务器发出一条方法为 DELETE 的请求,路径指向那一条数据。服务器收到后执行删除,再返回一个状态码说明结果。整个动作发生在服务器那一侧,浏览器只是发起了这个请求。

浏览器里有一个固定的对应关系:在地址栏输入地址、点链接,发出去的都是 GET;提交表单则可能是 GET 也可能是 POST,取决于表单怎么写的。

平时会在哪里看到它:DevTools 的 Network 面板里每个请求都标着方法;AI 说 “use a POST request here” 或 “this should be a GET” 时,说的是同一个字段。

1.7.5 Header#

Header(请求头 / 响应头) 是附加信息,一行一个键值对。请求有请求头,响应有响应头。它们的作用是补充那条消息本身的说明,几个你会反复见到的:

  • Content-Type:内容是什么类型。这是最重要的一条,它决定浏览器怎么处理 Body。
  • Accept:客户端希望收到什么类型。
  • User-Agent:客户端自报身份,服务器据此判断你是浏览器、爬虫还是命令行工具。
  • Authorization:身份凭证,登录之后的请求通常带着它。
  • Cache-Control:缓存规则,1.3.5 提过它。
文件名: about.txt
服务器对照表: txt → text/plain
响应头: Content-Type: text/plain
结果: 浏览器按纯文本显示,不解析标签

1.1.5 里那条结论在这里得到解释:为什么光看文件名,判断不了浏览器会怎么处理它? 因为浏览器不按文件名判断,它读的是响应头里的 Content-Type。文件名是给服务器和工具看的线索——服务器通常照着扩展名去查自己那张对照表,再把查到的结果写进响应头。一份内容最终是当网页渲染,还是当纯文本显示,由响应头决定。

平时会在哪里看到它:DevTools 的 Network 面板、AI 说的 “add the header” 或 “the header is missing”,以及报错里的 “CORS header missing”——那条报错的意思是,服务器没有在响应头里带上允许跨域访问的说明。

1.7.6 Body#

Body(正文) 是消息里承载内容的那一部分。

请求可以有 Body,也可以没有。GET 请求通常没有,因为它只是去取东西;提交表单或上传文件时,内容放在 Body 里。响应通常有 Body——你看到的 HTML、CSS、JavaScript、图片、JSON 数据,全都是某个响应的 Body。

Body 和 Header 的分工可以这样记:Header 说明这份内容是什么,Body 就是那份内容本身。 一个响应可以只有 Header 没有 Body,比如 1.7.8 要讲的 304,它的意思是「内容没变,用你缓存里那份」,自然不需要再传一遍内容。

1.7.7 Status Code#

状态码(Status Code) 是响应第一行里的那个三位数,说明这次请求的结果。

它有一百多个取值,但不需要背。第一位数字就是类别,记住这四个类别,绝大多数情况你都能判断:

  • 2xx:成功,请求被正常处理了。
  • 3xx:重定向,你要的东西不在这个地址,去另一个地址拿。
  • 4xx:客户端这边的问题,请求本身有问题——地址错了、没权限、参数不对。
  • 5xx:服务器那边的问题,请求可能没毛病,是服务器处理时出错了。

这个分类方式比记具体数字有用得多,因为它直接告诉你该往哪一侧找原因。看到一个 4xx,先去检查你发出去的地址和参数;看到一个 5xx,问题通常不在你的调用方式上。

本书希望你养成的是这个推理习惯,而不是把状态码背下来。这也正是第〇章说的那种替换:把一个要记的条目,换成一个能推理的判断。

1.7.8 200、301、404、500#

几个具体数字需要认识,因为它们在 AI 输出里出现得太频繁了。

200 OK 是最常见的成功响应。看到它,说明这次请求按预期完成了。

301 Moved Permanently 是永久重定向。这里要区分四兄弟:301 和 308 表示永久搬走,302 和 307 表示临时改道。 还有一个细节平时感知不到,但在涉及登录跳转和表单提交时会造成很难查的问题。301 和 302 允许把一个 POST 请求改写成 GET,而且规范里写的是「可以」(MAY),也就是留给实现自己决定。如果你希望重定向之后请求方法原样保留,就不该用这两个,而该用 307 或 308——规范对它们的要求是不得改写。

结果就是:一个本该提交数据的请求,可能在重定向之后变成一次普通的取内容,表单里填的东西跟着丢掉。

404 Not Found 是服务器在说:我能收到你的请求,但我这里没有你要的东西。它不代表服务器坏了。 服务器一直好好地在回应你,只是明确告诉你这个地址上没有内容。所以看到 404 时该检查的是地址或数据,不是服务器状态。AI 说 “the API returned a 404” 时,问题通常在路径写错了,或者那条数据真的不存在。

同样是失败,两者在响应里的样子完全不同:

HTTP/1.1 404 Not Found 服务器在,但这个地址上没有东西
HTTP/1.1 500 Internal Server Error 服务器在,但它处理时出错了

500 Internal Server Error 是服务器在说:我收到了,但我在处理的时候出错了。这是一个笼统的回答,具体原因要去服务器端的日志里看。因此看到 500 该做的是找日志,而不是继续改请求地址。

还有两个也应该认识。304 Not Modified 表示内容没变、让浏览器用缓存里那份,1.3.5 讲过它。403 Forbidden 表示服务器知道你要什么,但拒绝给你——它和 404 的区别是:404 是「没有这东西」,403 是「有,但不给你」。

1.7.9 HTTPS#

HTTPS 就是 HTTP 外面套了一层加密保护。它做的事和 HTTP 完全一样,只是整个过程被 TLS 包住了(1.7.10 展开)。

为什么需要它:HTTP 是明文传输。数据从你的电脑到服务器,中间要经过很多设备——路由器、运营商、各种中转节点。明文意味着路径上任何一跳都能看到内容,也能改内容。用公共 Wi-Fi 时这一点尤其明显。

今天 HTTPS 已经不是可选项。Chrome 从 2018 年起就把所有纯 HTTP 页面在地址栏标成「不安全」,Safari 也会在访问不安全的网站时给出警告;越来越多只有 HTTPS 下才能用的浏览器功能同样被限制住了。你部署一个网站时,配置 HTTPS 通常是最先要做的事之一。

回到开头那个问题:为什么 localhost:4321 是 http:// 而不是 https://?因为 localhost 是这条规则的一个正式例外——相关的 Web 规范把本机地址单独列了出来,允许浏览器把它当作可信来源处理。所以本地开发时它不会被标成「不安全」,那些平常要求 HTTPS 的浏览器功能也照样能用,你不需要给自己的开发环境配证书。

例外只覆盖本机地址,一旦换成真实域名就不再适用。如果某个功能在本地一切正常、部署上去却突然失灵,先检查一下地址是不是从 localhost 变成了 http:// 开头的真实域名。

混合内容被拦截时,Console 里会出现这样的提示:

Mixed Content: The page at 'https://example.com/' was loaded over HTTPS,
but requested an insecure resource 'http://example.com/api/data'.
This request has been blocked.

这三行的意思很直接:页面本身是安全的,但它试图去取一个不安全的资源,浏览器出于保护把它拦下了。

平时会在哪里看到它:地址栏里的小锁图标、“not secure” 提示、AI 让你配置证书,以及 “mixed content” 那类报错。

1.7.10 TLS 与证书#

TLS(Transport Layer Security,传输层安全) 是 HTTPS 底下负责加密和验证的那一层。它解决两个问题。

第一个是加密:数据在传输途中被第三方看到也读不懂,被改动也能被发现。

第二个是身份验证:确认你连上的那台服务器确实是它自称的那个。这一半靠证书(Certificate)完成。

证书由证书颁发机构(CA,Certificate Authority)签发。你的操作系统和浏览器里预装了一批受信任的 CA 名单。在这台机器上数一下:

$ (Get-ChildItem Cert:\LocalMachine\Root | Measure-Object).Count
65

这行命令数的是本机受信任的根证书数量。它说明了一件平时感觉不到的事:浏览器之所以能判断一个网站是不是可信,是因为你的系统里预先存了一份信任名单。 网站出示自己的证书,浏览器顺着签发链一路查到名单里的某个 CA,查得到就认为这个站是真的,查不到就弹出警告。

这里要分清一件事:「证书必须由受信任的 CA 签发」是浏览器的策略,不是加密协议本身的规定。 协议层用的是一条信任链——只要能找到一个自己信得过的起点,链条完整就算通过。所以自己给自己签发的证书在技术上是合法的,只是它不在任何浏览器的预装名单里,浏览器自然会报警告。本地开发时偶尔会用到这种做法,浏览器会提示你手动信任,或者把它加进例外名单。理解了这层区分,「证书无效」这类提示就不再是一句空话。

几个常见的报错因此可以读懂了。「证书已过期」是说服务器忘了续期,网站本身可能没坏;「证书无效」通常是证书上的域名和你访问的域名对不上,或者签发它的 CA 不在你的信任名单里;「你的连接不是私密连接」是浏览器在说它没能验证对方的身份——这可能只是配置问题,也可能是真的有人在中间拦截,所以不要习惯性地点击「继续访问」。

日常开发里你几乎不用亲手碰证书,部署平台通常会自动申请和续期,这也是 HTTPS 现在这么普及的原因。需要理解的是它为什么存在,以及那几种报错分别意味着什么。

1.8 当你输入一个网址之后#

前面七节是零件。这一节把它们装起来,看一次真实的访问是怎么发生的。

选择从「输入网址」开始,是因为这条链路上的每一环前面都已经单独讲过。把顺序串一遍,你就能在遇到问题时判断卡在了哪一段——这也是整章想给你的能力。

flowchart TD
  A[输入网址] --> B[DNS 查询:名字换成 IP]
  B --> C[建立连接:按 IP 和端口连上服务器]
  C --> D[发出 HTTP 请求]
  D --> E[服务器返回响应]
  E --> F[浏览器解析页面并请求其它资源]
  F --> G[页面显示到屏幕]

这条链上每一步都可能失败,而失败的样子各不相同。往下六节逐段说明。

1.8.1 DNS 查询#

浏览器先要弄清楚:这个域名对应哪台机器。

它会依次看几个地方——自己的缓存里有没有、操作系统缓存里有没有,都没有才去问 DNS 服务器。这也是为什么 1.6.3 说的缓存会影响你看到的结果:改了域名指向以后,快的地方和慢的地方可能相差几小时。

这一步的典型故障是 “could not resolve host”。它意味着名字根本没换成 IP,后面的步骤一步都没发生。遇到它不用去检查代码,先检查域名拼写和解析记录。

1.8.2 建立连接#

拿到 IP 之后,浏览器按 URL 里的端口(不写就用默认值,1.5.4)去连那台机器上的服务。

flowchart LR
  A[浏览器] -->|1 验证服务器证书| B[服务器]
  B -->|2 协商加密方式| A
  A -->|3 之后传输的内容都是加密的| B

如果地址是 https://,这一步还会多出上面这段 TLS 握手:验证服务器证书、协商加密方式。1.7.10 讲的证书问题就发生在这里——证书有问题,连接根本建立不起来,HTTP 请求一个字都还没发出去。

这一步的典型故障是「连接被拒绝」和「连接超时」,它们指向的原因不同,要分开对待。被拒绝通常意味着那台机器活着,但那个端口上没有服务在听——端口写错了,或者服务没启动。超时通常意味着数据包根本没到达,可能是地址不对,也可能是被防火墙挡住了。

1.8.3 发出 HTTP 请求#

连接建立好之后,浏览器发出请求:一个 GET,带上路径、Host,以及浏览器自己的那些请求头。

GET / 200 text/html
GET /style.css 200 text/css
GET /app.js 200 text/javascript
GET /cover.webp 200 image/webp

这四行来自同一个页面的 Network 面板,第一行是 HTML,后面三行是浏览器读完 HTML 之后才发起的。

还有一件事:一个网页通常不止一个请求。 第一次请求拿回来的是 HTML,浏览器读完 HTML 才发现里面还引用了 CSS、JavaScript、图片和字体,于是再为每一样东西发一次请求。DevTools 的 Network 面板里那一长串列表,就是这件事的样子。

1.8.4 服务器返回文件#

服务器收到请求,返回状态码、响应头和内容。

需要理解的是,它返回的不一定是磁盘上现成的文件。1.5.5 讲过,服务器完全可能拿路径去查数据库、现拼一个页面。1.4.6 讲的三种做法——静态文件、现算、构建时生成——在这一步的表现完全一样,浏览器看不出来,你也看不出来。这也是为什么「这个页面到底是静态还是动态」这种问题,光看浏览器是回答不了的。

1.8.5 浏览器解析页面#

浏览器拿到 HTML 之后开始解析,把它读成一棵结构树。解析过程中遇到样式表和脚本,会按各自的规则去处理。

这一步对应的是 L1 的内容:HTML 描述结构,CSS 描述外观,而 DOM 是浏览器读完 HTML 之后在内存里留下的那棵结构树——它和源文件不是同一个东西,第二章会专门讲。这里先知道它在链条上的位置:前面几步解决的是「文件怎么到这里」,从这一步开始才是「文件怎么变成页面」。

一个实用的判断:如果页面完全空白,问题可能出在前面的网络环节,也可能出在解析阶段——HTML 拿到了,但结构不对。区分方法在 DevTools 里:Network 面板看网络环节,Elements 面板看解析结果。

1.8.6 页面显示到屏幕#

最后一步,浏览器算出每个元素画在哪儿、长什么样,然后画到屏幕上。这就是你看到的结果。

到这里,一次访问就结束了。用户可能永远不知道中间发生过什么,而这一整章讲的就是那些中间发生过的事。

把这条链记住以后,它有一个很实际的用法:当 AI 说「这是个前端问题」或者「这是个网络问题」时,你可以在脑子里把这条链过一遍,判断它说的是哪一段。判断对了,你就知道该去查哪里,而不只是照着 AI 的下一句话继续试。

1.9 L0 知识地图#

这一章的名词多,但它们不是散的。下面这张图把 L0 的四块拼在一起,也标出每一块在什么地方和别的块接上。

flowchart TD
  OS[操作系统] --> FS[文件 · 路径 · 环境变量 · PATH]
  OS --> P[进程]
  P --> PORT[端口]
  FS --> CLI[命令行:在工作目录里执行命令]
  CLI --> DEV[开发服务器:一个进程占着一个端口]

  URL[URL 拆出 Host 与 Port] --> DNS[DNS 把域名换成 IP]
  DNS --> IP[本机地址有 127.0.0.1 和 ::1 两个]
  IP --> DEV
  DEV --> HTTP[HTTP 请求与响应]
  HTTP --> SC[状态码说明问题出在哪一侧]
  HTTP --> TLS[HTTPS 与证书]

  HTTP --> BR[浏览器解析并渲染]
  BR --> DT[DevTools:Network 看链路,Console 看页面]

这张图里有三条关系值得单独看出来。

第一条在上半部分:操作系统管着文件和进程,命令行决定命令在哪个目录执行,两者合起来才能启动一个开发服务器。这就是 1.6.6 里 localhost:4321 的来源——它指向的不是互联网上的某个网站,而是一个进程在某个端口上待命的位置。

第二条在中间:URL 说出要访问谁,DNS 把名字换成地址,HTTP 规定说什么、怎么说,HTTPS 在这之上加一层验证和加密。这是「两台机器对话」的完整链路。

第三条在末尾:网络那一侧发生的事,最终都要在浏览器那一侧收尾。DevTools 是把两侧对上的地方——Network 面板看链路,Console 看页面。

1.10 本章小结

这一章讲的东西在日常开发里很少被单独提起,但它们决定了你遇到问题时能不能找对方向。

需要留下的第一个认知是:网页运行在四样基础设施之上——操作系统管理文件和进程,命令行是启动一切工具的方式,浏览器是终点,网络把这些连起来。AI 输出的指令里有很大一部分不是在写网页,而是在操作这四样东西。看到 pnpm install、cd、PATH、端口这些词时,你应该知道它们属于这一层,和 HTML、CSS 没有关系。

第二个认知是:一次访问是一条有顺序的链。 DNS 把名字换成地址,连接建立起来,请求发出去,响应回来,浏览器解析并渲染。链上每一段都会以不同的方式失败,而失败的样子能告诉你该去哪一段找原因——could not resolve host、连接被拒绝、404 这三者指向三个完全不同的地方,它们分别属于这条链的不同环节。

第三个认知来自 1.6.5 和 1.6.6:本地开发里那些「明明应该能跑」的故障,大多有具体原因,不是玄学。 localhost 可能解析到两个地址,而服务只监听了其中一个;端口被占用时工具会悄悄换一个,而你还在打开旧地址。这两件事都有明确的检查方法,知道它们存在,就能少走很多弯路。

还有一点:这一章没有教你任何「网页的写法」。HTML 里的标签长什么样、CSS 怎么让一个盒子居中、JavaScript 怎么响应点击,一个都还没出现。但后面每一章都会回到这四样东西上,因为上面所有的内容,最终都要在这里跑起来。

1.11 AI 黑话速查#

AI 常说的话实际意思
Run it from the project root命令必须在项目根目录执行,相对路径和配置文件都从那里算起
'pnpm' is not recognized as an internal or external command系统在 PATH 里找不到这个程序:没装、装了但不在 PATH,或者当前终端是在安装之前打开的
Port 4321 is already in use这个端口已被另一个进程占着;换个端口,或者先结束那个进程
It works on 127.0.0.1 but not on localhost两个名字在本机解析到的地址版本不同,服务只监听了其中一个
Hard refresh绕过浏览器缓存重新下载,用来确认看到的是最新版本

到这里,L0 的部分结束了:你知道了一个项目跑在什么之上,文件怎么通过网络到达浏览器,以及那些看起来莫名其妙的报错各自指的是哪一段。

但整章下来,我们始终没有打开过一个网页文件。浏览器拿到的那份 HTML 到底是什么,它凭什么能把一堆文字变成标题、段落和链接;样式又是怎么附着到这些结构上去的;浏览器读完之后在内存里留下的那棵树,为什么和源文件不是同一个东西。这些是下一章的内容——L1,网页本身。

版权声明:若无特殊注明,本文皆为《WindowsIt》原创,采用 CC BY-NC-SA 4.0 协议授权,转载请保留文章出处。
本文链接: 《看懂 AI 写的网站》第一章:L0 基础环境——Web 运行在什么之上
正文到此结束

本文属于《看懂 AI 写的网站》· 第 1 / 9 章

热门推荐

评论区

“The purpose of computing is insight, not numbers.”

计算的目的在于洞见,不在于数字。

— Richard Hamming