[{"content":" 认识 Web 项目的会话与状态保持\n先把历史显示出来 这一节我们要讲的是会话。在讲会话之前，我们先动手改点东西。\n用户在我们的文字实验室里查询的历史已经被存进数据库了，通过/api/history 也能查出来，只是页面上一直没显示它。\n所以我们先对前端进行一次小迭代， 在页面上把这些历史记录显示出来。\n前端直接替换 前端不是这一节的重点，所以这部分我们不手敲，直接在GitHub的demo仓库中拿代码。\ngit clone https://github.com/joylibo/zero-to-tech-demos.git cp zero-to-tech-demos/zero-to-tech-6-6/components/*.jsx ~/zero-to-tech/components/ cp zero-to-tech-demos/zero-to-tech-6-6/css/lab.css ~/zero-to-tech/css/ 新增了一个文件、更新了三个文件：\n新增 HistoryModal.jsx——历史记录的弹窗； 更新 ResultCard.jsx——右上角加了一个「历史记录」按钮； 更新 TextLabView.jsx——管弹窗的开关，点开的那一刻才去请求 /api/history； 更新 lab.css——按钮和弹窗的样式。 搬完就行，这些代码我们就不展开讲了，前端不是这一节要说的事。\n跑起来 启动前端：\ncd ~/zero-to-tech npm run dev 启动后端：\ncd ~/zero-to-tech/backend source .venv/bin/activate fastapi dev 前后端都启动之后，打开文字实验室，结果卡的右上角就会出现「历史记录」按钮了，点击它可以展开历史记录。它背后就是请求的 api/history接口\n如果我们随便分析两句，再点开「历史记录」，就可以看到我们刚刚分析的内容已经可以在历史记录中查看了。\n功能做完了，看上去一切正常。\n换一个浏览器，再看一眼 先别急着往下走，这一步请一定亲手做一次。\n换一个浏览器，打开同一个地址。刚才用的如果是 Chrome，现在就换 Safari、Edge、Firefox 都行，实在不想装，用 Chrome 开一个无痕窗口也可以。\n打开 http://localhost:3000，然后什么都别分析，直接点开「历史记录」。\n看到了什么？\n刚才在 Chrome 里打的那几句话，一字不差地出现在这个新浏览器的历史记录里。\n请再往前想一步：这还只是同一台电脑上的两个浏览器。换成两台电脑、两个人，结果一模一样。只要访问的是同一个后端，看到的就是同一份历史。\n问题出在哪 这就麻烦了。如果这个网站真的发布上线：\n我打的字，所有陌生人都看得见； 别人打的字，也全都堆在我的历史记录里。 没有人会想要这样的\u0026quot;历史记录\u0026quot;。我们想要的显然是每个人看自己的那一份。\n这本质上是因为我们目前做的这个网站应用是 「不认人」的，所有访客的记录混在一张表里，谁来查都是查这张表的全部。\n那给它加上\u0026quot;认人\u0026quot;不就行了？\n这正是这一节要干的活。不过在动手之前，得先把一件事弄明白——它为什么会认不出人。\n服务器为什么不认人 我们的文字实验室，前端有了、后端有了、数据库也有了，为什么它还是认不出人？\n先看直接原因：对于服务端 API 来说，它的任务就是处理前端发来的每一次 HTTP 请求、返回响应，而处理每一次请求时留下的东西，不会延续到下一次。\n我们把 HTTP 拆开看过：一个请求从前端到后端，一个响应从后端到前端，这一轮就结束了。\n关键在\u0026quot;结束\u0026quot;这两个字。请求处理完，服务器就把这一轮的一切都扔掉了。下一个请求再来，在它眼里就是一个全新的陌生人来敲门——它不记得上一个是谁，也不会认为这两个之间有什么关系。\n所以不是服务器不想认，是它压根没留下任何能用来认人的东西。\n这个\u0026quot;处理完就全忘\u0026quot;的脾气，就叫做 无状态（stateless） 。HTTP 就是典型的无状态，任意两次请求完全是独立的。\n顺带说一句：不认人并不总是缺陷。\n互联网上有大把网站从头到尾都不认人，比如说FastAPI 的官网 https://fastapi.tiangolo.com/、Vite 的官网 https://vite.dev/，还有李勃老师的课件网站 https://李勃老师.com/。它们不是\u0026quot;认不出\u0026quot;，是压根不需要认。来的人只是读文档，认得出是谁毫无意义，就没必要费这个劲。\n只有当一个应用要为每个人分别留住点什么的时候——历史记录、购物车、草稿、偏好设置——\u0026ldquo;认人\u0026quot;才变成一道绕不过去的坎。我们的文字实验室，刚好走到了这一步。\n让大模型 API 忘给我们看 无状态不是 HTTP 一家独有。我们可以看一个更极端、也更好玩的例子。\n我们在终端里用 curl 调用一下 DeepSeek API。\n如果没有API Key也可以对照下面的输出往下看。\n第一次调用，我们告诉它我们的名字：\nthinking、reasoning_effort 两个参数是让它\u0026quot;深度思考\u0026quot;用的，这里只要最简单直接的问答，所以关掉了；另外写这篇课件的时候是2026年9月，DeepSeek的最新模型是deepseek-v4-pro, 你阅读本文的时候可以再去DeepSeek API 文档查看最新模型还是不是这个。\ncurl https://api.deepseek.com/chat/completions \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -H \u0026#34;Authorization: Bearer ${DEEPSEEK_API_KEY}\u0026#34; \\ -d \u0026#39;{ \u0026#34;model\u0026#34;: \u0026#34;deepseek-v4-pro\u0026#34;, \u0026#34;messages\u0026#34;: [ {\u0026#34;role\u0026#34;: \u0026#34;system\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;You are a helpful assistant.\u0026#34;}, {\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;我的名字叫李勃，你记着\u0026#34;} ], \u0026#34;thinking\u0026#34;: {\u0026#34;type\u0026#34;: \u0026#34;disabled\u0026#34;}, \u0026#34;reasoning_effort\u0026#34;: \u0026#34;none\u0026#34;, \u0026#34;stream\u0026#34;: false }\u0026#39; 它回得很热情：\n{\u0026#34;role\u0026#34;:\u0026#34;assistant\u0026#34;,\u0026#34;content\u0026#34;:\u0026#34;好的，李勃！我记住了，很高兴认识你。\u0026#34;} 我们紧接着再调一次，整条命令一个字都不改，只把 messages 里那句话换掉：\n{\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;我叫什么名字？\u0026#34;} 结果：\n{\u0026#34;role\u0026#34;:\u0026#34;assistant\u0026#34;,\u0026#34;content\u0026#34;:\u0026#34;我暂时还不知道你的名字呢！你愿意告诉我吗？\u0026#34;} 刚才它才说\u0026quot;我记住了\u0026rdquo;，转头就不认识我们了。\n这不是它撒谎，也不是模型太笨，是它根本没有\u0026quot;刚才\u0026quot;这个概念。 对它来说，我们发过去的每一次请求，都是世界的第一天。至于上一次调用发生过什么，它一无所知，甚至不知道曾经有过上一次。\n这就是 \u0026ldquo;无状态\u0026rdquo; 活生生的样子。\n而且请注意，刚才这一幕里，无状态体现在两个层面：\n底层走的是 HTTP。 我们敲的两条 curl 是两个完全独立的 HTTP 请求。第二个请求发出去的时候，第一个已经结束了，它俩之间没有任何东西相连。 另一层是大模型本身。 大模型本身就是不留记忆的，每一次调用都是独立的，都是从零开始。 这里一定有朋友要问：那我平时用 ChatGPT、DeepSeek 的网页版，或者我用Codex、WorkBuddy之类的，它们都是可以记得我上一句说了什么啊？\n这是因为有人替我们做了事。具体是怎么做的，我们放在这一节的最后来说。\n凡是要服务海量、彼此无关的请求的地方，几乎都会走到这条路上来。这显然不是巧合，而是一种刻意的选择，等会儿也会说。\n什么是「状态」 对于没有计算机技术基础的中文母语者而言，「状态」这个词可能会有一些自带的误导性，因为它最常见的造句是“你今天状态怎么样？” 或者 “保持积极乐观的精神状态” 之类的，让人以为它是一种健康指标。\n但是作为计算机术语，它一般指代的是一组参数。我举个例子：\n打一局游戏打到一半，突然暂停 ⏸️ ！ 此刻这局游戏\u0026quot;是什么样子\u0026quot;？我们在第几关、剩多少血、身上带了哪些装备、刚才那个机关有没有打开？这一整套\u0026quot;此刻的情况\u0026quot;，就是这局游戏的状态（state）。\n它有两个特点，都很要紧：\n它决定了下一步会怎样。 血剩多少，决定了下一刀挨不挨得住。状态不是记着好玩的，它影响接下来发生什么。 它默认是会没的。 一关机，这局就白打了。所以游戏才要有存档。存档干的事说白了就是把状态挪到一个更不容易丢的地方去。 比如说硬盘。 有了「状态」这个词，前面那句\u0026quot;处理完就全忘\u0026quot;就能说得更准了：服务器扔掉的不是别的，正是这一轮的状态。 这就是\u0026quot;无状态\u0026quot;里那个\u0026quot;状态\u0026quot;的所指。\n而且回头看会发现，这门课走到现在，其实好多地方都在跟状态打交道：\n文字实验室的结果卡中显示的内容刷新一下就全没了，那就是活在浏览器里的状态； 在 REPL 练习的时候创建的刘关张三个名字，exit() 一下就找不着了，那是活在内存里的状态； 数据库里搬进 history.db 的历史记录，关机重启它还在，那是落到硬盘上的状态。 我们此前一直会关注这些数据 能活多久， 一次刷新、一次运行、还是关机也不丢。我们一路都在给状态找一个活得更久的地方。\n现在存到了数据库，已经是活得最久的方式了。但现在我们遇到的问题，已经不是它活得久不久，而是它是否能活过两次不同的请求——刚进来的这个请求，和五秒前那个，是否可以共享状态。\n什么是「有状态」 反过来问一句，既然 HTTP 是无状态的，那有状态的长什么样？\nssh 就是有状态的。\n当我们用 ssh 登录上服务器 cd 进某个目录，服务器就记着我们此刻在哪儿，再敲下一条命令时不用再自报家门，它知道是谁在敲。\n这就是有状态：连接的两头维持着一个 \u0026ldquo;现场\u0026rdquo;，后一条命令是接着前一条往下说的。此时如果网断了，现场就没了。 此时重新 ssh 上去就又回到家目录，刚才 cd 到哪儿全得重来。\n有状态和无状态这两种方式没有绝对的好，只有合不合适。ssh 就应该是有状态的。 要是每敲一条命令都得重报一遍\u0026quot;我是谁、我在哪个目录\u0026quot;，那根本没法用。所以 ssh 必须一直维持着这个现场。\n这个表里列出了 ssh 和 HTTP 的差异：\nssh HTTP 面对的连接 少量、长时间、要连续性 海量、极短、彼此无关 选择 有状态，维持现场 无状态，用完就忘 那 HTTP 能不能也维持现场？如果让HTTP也自动维持现场，那服务器得为每个来访者挂着一份现场数据，就会出现这样的局面：\n同时来一百万人，就得同时挂着一百万份现场数据，内存先撑不住； 某一个用户建立了连接，后续请求必须回到同一台机器上，因为现场数据只在那一台里。那还怎么做负载均衡？怎么临时加机器扛流量？ 上一条说的那台特定的机器一挂，挂在它上面的所有人的现场数据就全没了。 而无状态意味着 任何一台服务器，都能处理任何一个请求。 机器可以随便加、随便换、挂一台自动顶上，用户完全无感。\n所以无状态不是 HTTP 的缺陷，是它面对\u0026quot;海量陌生请求\u0026quot;这个场景做出的刻意选择。 互联网能扩张到今天这个规模，很大程度上就靠这个决定。\n代价就是\u0026quot;记住来访者\u0026quot;这件事，协议不管了，得由我们应用自己想办法，也就是我们这一节要干的活。\n协议放弃了一点便利，换来了整个体系的可扩展性。\n在 HTTP 请求之间保持状态 到这儿，我们已经把HTTP\u0026quot;底层是彻底遗忘的\u0026quot;这件事看得很清楚了。\n但是计算机是为人服务的，而人类的活动，几乎没有一件是\u0026quot;孤立的瞬间\u0026quot;，它们全都是\u0026quot;有前因后果的过程\u0026quot;。 而只要是过程，就天然需要状态，需要记着刚才发生了什么。\n于是，一个矛盾就出现了：\n要的是什么 于是倾向 技术底层 效率与规模——每个请求彼此独立，才能海量扩展 遗忘 人的活动 意义与连贯——事情有前因后果，才叫做事 记忆 而且这两边，谁都不能让步。让底层变成有状态的话，内存扛不住、没法负载均衡。而没有记忆的互联网只能做文档网站，就不会诞生电商、游戏、社交媒体这些互联网形态了。\n所以，只剩下一条路：\n承认底层就是无状态的，然后在它上面，用尽可能小的代价，把状态重新\u0026quot;长\u0026quot;出来。\n请特别留意\u0026quot;尽可能小的代价\u0026ldquo;这几个字，它是后面所有设计的出发点。我们并不需要把整个\u0026quot;现场数据\u0026quot;都搬到每个请求里去，我们只需要想办法，让服务器认出这是同一个人就够了；至于这个人的历史记录本身，可以存在服务器的数据库里。\n那么具体到我们的项目，\u0026ldquo;把状态长回来\u0026quot;到底要长出什么？\n要让每个人在文字实验室只看到自己的历史记录，服务器只需要能回答两个问题——\n存的时候：现在这条记录，是谁存的？ 查的时候：现在来问的，又是谁？ 只要这两个问题有答案，剩下的就好办了：存的时候在记录上盖个记号，查的时候只挑记号对得上的——不过是一个 WHERE 语句就能搞定的事情罢了（战术后仰）。\n为了解决这个记号的问题，就有了 \u0026ldquo;会话\u0026rdquo; (session) 的概念。\n会话：把散落的请求认成同一个人 会话，这个被造出来的概念，定义是这样的：\n把一串本来彼此独立、互不相干的请求，认定为\u0026quot;同一个来访者的一次连续交互\u0026rdquo;。\n请注意，会话是被\u0026quot;构造\u0026quot;出来的。 HTTP 里没有会话，网络里也没有会话。它是应用这一层的概念。\n所谓\u0026quot;保持会话\u0026rdquo;，就是我们自己想办法，把散落的请求重新串成一条线。\n标识怎么才能每次都带上 一个用户的多次请求怎么串在一起？我们来推一推。\n服务器要认出\u0026quot;这些请求来自同一个人\u0026quot;，最少需要什么？\n需要每个请求，都得带上同一个标识。\n服务器不需要知道我们是谁、叫什么，它只需要认出\u0026quot;这个标识和刚才那个是同一个\u0026quot;，就够了。\n这个标识得满足三个条件：\n唯一。不能和别人撞上，否则会串号，看到别人的历史； 每次请求都带着。漏一次，那次请求就成了陌生人； 不能被人猜出来。要是能被猜到，别人就能冒充我们的会话； 服务器生成一个唯一的且不容易被猜到的标识其实并不难，真正的问题是在 Web 应用中，前后端用 HTTP 通信时，这个标识可以怎么带？\n我们把能想到的办法都摆出来看看：\n办法 怎么做 为什么会想到它 问题在哪 放在 URL 里 /api/history?sid=abc123 HTTP 请求的时候可以通过 URL 带参数 暴露在 URL 中会被人看到，并且用户随手分享网址的话，会话就给别人了。不安全 认 IP 地址 服务器直接看请求从哪个 IP 来 HTTP 请求的时候会发送请求方 IP 地址 同一间办公室、同一个 WiFi 下，所有人是同一个 IP；手机从流量切到 WiFi，IP 还会变。不可靠 前端自己存，每次手动加一个请求头 自己在浏览器里存一份，发请求时读出来塞进 header 前端自己管，灵活又可靠 能用。但每一个请求都得记得加，不能漏，得前端一直操心 交给浏览器，让它自动带 ？ ？ ？ 最后这一行，正是我们想要的：如果浏览器能替我们记着这个标识，并且每次请求自动带上，那上面所有的麻烦就都没了。\n浏览器有没有这个东西？有，就是 cookie。\n学HTTP的时候我们就见过它，在HTTP协议里有这样的一组请求头和响应头：\n在哪 头 在说什么 响应头 Set-Cookie 给调用方发一张“小纸条”，下次来记得带上 请求头 Cookie 我随身带的“小纸条” 服务端通过Set-Cookie这个响应头可以交给浏览器一段文字； 浏览器收到服务端的那段文字之后会存着，每次通过HTTP发送请求的时候会自动塞进Cookie请求头； cookie 的本质，不是\u0026quot;能在浏览器里存点东西\u0026quot;（浏览器还有别的方式也能存）。它真正值钱的地方是自动。\n如果前面说的标识是通过服务器生成的唯一值，那么就可以满足唯一、猜不出这两个条件。而浏览器又在每次请求时都会通过cookie自动带上这个唯一标识。那就完美满足了那三个条件。\n所以cookie可以用来做这件事，它在浏览器和服务端之间传递标识，就能实现会话保持。\ncookie 和 session 的区别 我不知道为什么，总有人问 cookie 和 session 的区别，甚至这个问题一度成为一个很心照不宣的面试题。所以这里值得停下来好好掰扯一下这俩词。\n之所以老被混在一起，是因为很多人默认它们是同一类东西——好像是两种存数据的办法，可以挑一个用。可它们压根不是一类：\n会话（session）是一种「认定」。 前面我们给它的定义就是\u0026quot;把一串请求认定为同一个来访者的一次连续交互\u0026quot;。它是一段关系，不是一个物件。我们没法指着服务器说\u0026quot;喏，会话就在那儿\u0026quot;； cookie 是一套「机制」。 浏览器替服务器保管一小块数据，并在每次请求时自动带上。它是实打实的东西，有位置、有大小、有有效期。 一个是概念，一个是工具。 所以\u0026quot;cookie 和 session 哪个好\u0026quot;这个问题本身就很奇怪——它们不在一个层面上，更谈不上二选一。\n它们真正的关联机制是：\n一个用户在浏览器上通过 HTTP 请求服务端，此时服务端如果需要维护这次会话，就把用户的数据存储起来（比如说存储到数据库中，并且给一个会话编号 session_id），然后在响应的时候通过 Set-Cookie 向用户传递这个会话编号（session_id）, 那么浏览器在用户下一次通过 HTTP 请求服务端的时候，会自动在请求头里带上 cookie，此时 cookie 里面有什么？\n有 session_id\n服务器凭这个 session_id，可以找到属于它的那份状态数据（业界也常叫它「会话数据」，session data），而 cookie 就是运送 session_id 的载具。\n这有点像存包处：我们把包裹存在柜台，从存进去到取走（或者过期）之间的这段关系，就是会话；存的那个包裹是状态数据；柜台给我们的纸条是 cookie；纸条上写的编号，就是 session_id。\n会话不是那个包裹，也不是那张纸条，而是\u0026quot;我们和柜台之间这档子事\u0026quot;。 包裹和纸条，只是维持它所需要的东西。\n最后还有两点要说清楚：\ncookie 里放的不一定是 session_id。 网站的主题偏好、语言设置，也常常放在 cookie 里——cookie 只是张白纸条，上面写什么由我们定； 会话也不一定非得靠 cookie。 手机 App 里就没有 cookie，它通常把 session_id 放在请求头里带（一般叫 token）。虽然没有cookie，但会话还是会话。 还有一件事得先记着：会话认得出\u0026quot;同一个浏览器\u0026quot;，可认不出\u0026quot;访客到底是谁\u0026quot;——它和登录是两回事。（这条边界很关键，这一节后面会专门说。）\n上面这些都理解了，就可以着手改造我们的项目了。\n动手之前，先想清楚三件事 我们要做的事情，其实说穿了很简单，就三句话：\n用户来的时候，生成一个会话 id； 把状态数据连同这个 id 一起存进数据库（就是给数据表加一个字段的事）； 通过 cookie，让这个 id 在浏览器和服务端之间进行传递。 就这么点事。不过里面有两个地方，值得动手之前先想清楚：这个 id 怎么生成？把id写进 cookie 的时候又该怎么写？\n一、这个 id 怎么生成？ 这个会话id，需要符合\u0026quot;唯一\u0026quot;和\u0026quot;猜不出\u0026quot;这两个条件。遇到这样的情况，我们就可以用UUID（Universally Unique Identifier），\u0026ldquo;通用唯一识别码\u0026rdquo;。\n它会随机生成一串几乎不会出现重复的“乱码”，长这样：\n3f8a1c9e42d7460b8e5f1a2c7d9b0e64 说UUID “几乎” 不会出现重复，是一种很严谨的说法，我们可以认为它就是不会重复的。有人给过一个比喻：两个UUID重复的概率，约等于随手往宇宙里扔一粒沙子，然后从宇宙的另一端再扔一粒，这两粒沙子在太空中相撞的概率。\n这样说是不是放心多了。\nPython 标准库里就有一个 uuid 模块，直接拿来用就行（很简单）。\n二、写进 cookie 的时候怎么写？ 写cookie的时候不是往里面写一个session_id就完事了。真正写出来的时候，是这么一串东西：\nsession_id=3f8a1c9e42d7460b8e5f1a2c7d9b0e64; HttpOnly; Max-Age=2592000; Path=/; SameSite=lax 可以看到，这里不止有 session_id，后面还有用分号 ; 隔开的好几样东西。\n看上去都是字符串，但它们其实分两类：\n最前面的 session_id=3f8a...——这是 cookie 本身，一个名字配一个值。它才是要送回服务器的内容。 后面那四个——它们叫属性，不是内容，是写给浏览器看的设置：这张纸条存多久、给不给页面上的 JS 看、什么时候该带上。 那四个属性，一个一个说：\nMax-Age=2592000， 这个最重要。它决定了cookie的有效期是多久，单位是秒。2592000 就是 30 天（60×60×24×30）。这个数字可以改，但不能不写，不写的话它就成了一张\u0026quot;临时纸条\u0026quot;，浏览器一关就扔了。 HttpOnly 和 SameSite=lax——这两个都和安全有关，先别管那么细，写上就行。 Path=/——意思是这个站点下的所有路径，请求时都带上它。它本来就是默认值，等下写代码时不用我们操心。 等会儿动手的时候，FastAPI 有现成的方法可以写这些东西，所以不用担心自己不会写。\n对我们来说，此刻最重要的事情是看懂它们是啥。\n三、跨源这道坎怎么过？ 浏览器有条安全规则：跨源请求默认不带 cookie。\n我们的前端在 :3000、后端在 :8000，这是跨源的，5.5 那一节学过 CORS，这一点我们已经知道了。\n为什么一跨源就不带了？因为cookie 经常用作身份凭证。要是浏览器不管对方是谁、见谁都自动把凭证递过去，那随便一个网站都能拿着我们的身份，去调别人家的接口了。\n所以浏览器的要求是：送凭证这件事，必须两头都点头。\n后端点头——给 CORSMiddleware 多配一个参数； 前端点头——每次发请求，明说一句\u0026quot;这一次带凭证\u0026quot;。 两处都很简单，等下第〇步一起做掉。\n开始动手吧！！\n开始动手改造 第〇步：让跨源请求能带 cookie 这一步前后端各改一处。\n先看后端，给 CORSMiddleware 补一个参数：\napp.add_middleware( CORSMiddleware, allow_origins=[\u0026#34;http://localhost:3000\u0026#34;], allow_methods=[\u0026#34;GET\u0026#34;, \u0026#34;POST\u0026#34;], allow_credentials=True, # ← 新增：允许跨源请求带上 cookie ) allow_credentials=True 就是后端点头：“可以带凭证（cookie）过来。”\n5.5 那一节我们没图省事写 allow_origins=[\u0026quot;*\u0026quot;]，而是老老实实写死了具体地址\u0026quot;http://localhost:3000\u0026quot;，就是在等今天！因为如果开了 allow_credentials=True，就绝对不能再用通配 *， 这是浏览器的硬规定——带凭证时必须点名到具体的源。\n再看前端，两处 fetch 都加上 credentials: \u0026quot;include\u0026quot;：\n// InputCard 里发分析请求 const res = await fetch(`${API}/api/analyze`, { method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34; }, credentials: \u0026#34;include\u0026#34;, // ← 新增：带上 cookie body: JSON.stringify({ text }), }); // TextLabView 里，点开弹窗时拉历史 async function openHistory() { setHistoryOpen(true); const res = await fetch(`${API}/api/history`, { credentials: \u0026#34;include\u0026#34; }); setHistory(await res.json()); } 前端要改的就这两处。这一句 credentials: \u0026quot;include\u0026quot; 就是前端点头：每次发请求，都明说\u0026quot;这一次带凭证\u0026quot;。\n那 /api/profile 要不要也加？ 不用。它只是把一段固定的介绍数据返回来，压根不认人，带不带 cookie 都一样。哪个接口需要认人，就给哪个加。\n另一个疑问：每个接口请求都得单独写一句，那还算什么\u0026quot;浏览器自动带cookie\u0026quot;？\ncredentials: \u0026quot;include\u0026quot; 的意思不是\u0026quot;手动带 cookie\u0026quot;，而是\u0026quot;允许浏览器带\u0026quot;。前端不需要知道 cookie 里装的是什么，它只是打开一个开关；如果是前端自己存、自己读出来、自己塞进请求头，那才叫手动，因为前端得管内容。\n而且这个开关只有跨源的时候才需要。fetch 的 credentials 默认值是 same-origin，同源请求浏览器默认就带。等到模块 7，我们用 Nginx 把前后端做成同源，这两行也就不需要了。\n两头都点了头，cookie 才过得去。剩下的活，就全在后端了。\n第一步：给表加一列 session_id 打开 storage.py，把 init_db() 里的两句 SQL 都重写一下，建表语句和建索引语句，今天都要改：\ndef init_db(): conn = get_conn() cur = conn.cursor() cur.execute(\u0026#34;\u0026#34;\u0026#34; CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, text TEXT, score REAL, label TEXT, pinyin TEXT, created_at TEXT ) \u0026#34;\u0026#34;\u0026#34;) cur.execute( \u0026#34;CREATE INDEX IF NOT EXISTS idx_history_session_created \u0026#34; \u0026#34;ON history(session_id, created_at)\u0026#34; ) conn.commit() conn.close() 旧表里没有 session_id 这一列，IF NOT EXISTS 又不会去改已有的表。最省事的办法就是把 backend/history.db 删掉（我们现在的数据不值钱，从零来最干净），重启后端会按照新结构重建——新的表和新的索引，一起建出来（旧索引跟着旧库一起没了，不用管它）。\n为什么索引也得跟着换？ 因为等下查询条件会换成 WHERE session_id = ? ORDER BY created_at DESC，先按会话筛，再按时间排。这里用到了两个不同的字段，此时的索引就应该用ON history(session_id, created_at)的写法来同时管住两个字段。\n这里的顺序也有讲究，先写用来筛的列，再写用来排的列。这样数据库先用 session_id 定位到属于这个会话的那一段，而那一段里面本来就是按时间排好的，连排序都省了。\n记住这句：索引是为查询而建的，查询变了，索引就得跟着变。\n第二步：写一个“发纸条 / 认纸条”的小工具 这个属于接口层，写在 main.py 里。顶部先 import uuid，从 fastapi 引入 Request、Response：\nimport uuid from fastapi import Request, Response def get_session_id(request: Request, response: Response) -\u0026gt; str: sid = request.cookies.get(\u0026#34;session_id\u0026#34;) # 先看有没有纸条 if not sid: # 第一次来，没有——发一张 sid = uuid.uuid4().hex # 一串随机、不重复的 id response.set_cookie( \u0026#34;session_id\u0026#34;, sid, httponly=True, samesite=\u0026#34;lax\u0026#34;, max_age=60 * 60 * 24 * 30, # 记 30 天 ) return sid 这段代码里，uuid.uuid4().hex 就是生成 UUID 的，response.set_cookie(...) 就是向响应头里写 cookie 的。这里除了 session_id，还写了刚才提到的 httponly / samesite / max_age 三样。\n只有 Path=/ 没写——因为 set_cookie 的 path 参数默认就是 \u0026quot;/\u0026quot;，不写它也在。等下我们会在响应头里亲眼看到它。\nget_session_id的逻辑很直白：先看请求带来的 cookie 里有没有 session_id，没有就发一张新的。\n第三步：存和查都认 session_id 回到 storage.py，save_record 函数多一个 session_id参数，每次存的时候调用方都得交代一下 session_id；get_history 也多一个session_id参数，每次取的时候调用方也都得说明是取哪一个session_id的历史数据：\ndef save_record(session_id, record): conn = get_conn() cur = conn.cursor() cur.execute( \u0026#34;INSERT INTO history (session_id, text, score, label, pinyin, created_at)\u0026#34; \u0026#34; VALUES (?, ?, ?, ?, ?, ?)\u0026#34;, [session_id, record[\u0026#34;text\u0026#34;], record[\u0026#34;score\u0026#34;], record[\u0026#34;label\u0026#34;], record[\u0026#34;pinyin\u0026#34;], record[\u0026#34;created_at\u0026#34;]], ) conn.commit() conn.close() def get_history(session_id, limit): conn = get_conn() cur = conn.cursor() rows = cur.execute( \u0026#34;SELECT * FROM history WHERE session_id = ? ORDER BY created_at DESC LIMIT ?\u0026#34;, [session_id, limit], ).fetchall() conn.close() records = [] for row in rows: records.append(dict(row)) return records 第四步：两个接口都先认人，再干活 回到 main.py，改这两个接口：\n@app.post(\u0026#34;/api/analyze\u0026#34;) def analyze(req: AnalyzeRequest, request: Request, response: Response): sid = get_session_id(request, response) text = req.text score = round(SnowNLP(text).sentiments, 2) result = { \u0026#34;text\u0026#34;: text, \u0026#34;score\u0026#34;: score, \u0026#34;label\u0026#34;: score_label(score), \u0026#34;pinyin\u0026#34;: \u0026#34; \u0026#34;.join(lazy_pinyin(text, style=Style.TONE)), \u0026#34;created_at\u0026#34;: datetime.now(timezone.utc).isoformat(timespec=\u0026#34;seconds\u0026#34;), } save_record(sid, result) # 存的时候盖上这个会话的记号 return result # ← 返回体一个字没变，session_id 只走 cookie @app.get(\u0026#34;/api/history\u0026#34;) def history(request: Request, response: Response, limit: int = 10): sid = get_session_id(request, response) return get_history(sid, limit) # 只回这个会话自己的 注意，这里的 limit 又往外挪了一步。6.5 我们把\u0026quot;一次给几条\u0026quot;这个决定从存储层交还给了调用方，但那个数字当时还写死在 main.py 里；现在写成 limit: int = 10，这个数字就可以由调用方自己说了：\nhttp://localhost:8000/api/history?limit=2 URL 里写 ?limit=2，就只回 2 条；不带 ?limit=，还是按默认的 10 条。\n上面几步做完，后端的开发工作就搞定了。\n先看一眼那张纸条 到这儿，所有代码就都改完了。不过先别急着打开浏览器——我们先用最朴素的方式，亲眼看看这个纸条（cookie）。\n5.3 我们学过 curl -v 能把 HTTP 的头都打出来。这次换 -i——它会在响应体前面，把响应头也一并打出来；比起 -v，它少了那些 * 开头的旁白和请求原文，输出干净得多：\ncurl -i http://localhost:8000/api/history 响应头里，会多出这么一行：\nset-cookie: session_id=3f8a1c9e42d7460b8e5f1a2c7d9b0e64; HttpOnly; Max-Age=2592000; Path=/; SameSite=lax 这就是后端在向前端\u0026quot;发纸条\u0026quot;，我们交代过的几样东西——session_id、HttpOnly、Max-Age=2592000、Path=/、SameSite=lax——一个不落。\n所谓 cookie，本质上就是 HTTP 头里的一行字符串，没有任何魔法。\ncurl 默认是不存 cookie 的，所以我们连续跑两次上面那条命令，会发现两次的 session_id 不一样。 这是因为 curl 没把第一次的纸条存下来，第二次请求过去时手里空空，服务器只好当它是新访客，又发了一张新的。\n这也体现了浏览器替我们做了多少事：拿到 cookie 存下来，每次请求时自动带上，而且这是浏览器的默认行为。\n在浏览器里验证 现在打开浏览器，来看这一节最值得亲眼看一次的东西。前后端两个程序都要跑着。\n打开文字实验室，按 F12，切到 Application（有的浏览器叫\u0026quot;应用\u0026quot;）标签，左边找到 Cookies → http://localhost:3000。\n可以看到浏览器帮我们列出来了好几项：session_id，后面跟着 Expires、HttpOnly、SameSite，名字基本上和刚才 curl 里看到的那些对得上。只有有效期那一栏不太一样——我们写进去的是 Max-Age=2592000（30 天的秒数），浏览器把它换算成了一个具体日期存下来。\n左边那一栏还列着别的东西——Local Storage、Session Storage 等等。它们都是浏览器给页面用的存储，共同点是不会自动发给服务器：要用得自己写代码读出来、自己塞进请求。这恰好反衬出 cookie 特别在哪——它是唯一一个浏览器会替我们自动带上的。\n然后切到 Network 标签，刷新页面，点开那个 /api/history 请求，看 Request Headers：\nCookie: session_id=3f8a1c9e42d7460b8e5f1a2c7d9b0e64 这就是浏览器在请求头里，自动为我们加上的cookie。\n这里值得停一下。 后端本来通过set-cookie发过来的有五样东西，浏览器都存下来了，可送回去的只剩一个 session_id。为什么呢？\n还记得前面分的那两类吗——session_id=... 是内容，后面那几个是属性。规范里，Set-Cookie 允许跟属性，而 Cookie 只写 名=值，不允许带属性。\n属性是写给浏览器的设置，它收下自己照办就完了，没必要再报回去。服务器要的，只有那个session_id。\n见证：同一个实验再做一次 还记得这一节开头那个实验吗？原封不动地再做一遍。\n两个不同的浏览器（或者一个正常窗口、一个无痕窗口），各自打开文字实验室，各分析一句不一样的话。\n然后分别点开「历史记录」\n这一次，各看各的。开头那份\u0026quot;人人都能翻到别人的字\u0026quot;的公共账本，不见了。\n现在每个访客（浏览器）只看到自己的历史记录了。这也意味着，我们终于把它开发完了！\n边界：会话不是认证 请注意，做到这一步，我们只是用cookie实现了会话，本质上我们仍然无法区分访客。换台电脑、清掉 cookie，纸条就没了， 服务器会把我们当成新访客，历史就\u0026quot;丢\u0026quot;了（其实没丢，还在数据库里，只是没人能凭纸条把它取出来了）。\n它维护的是会话，并不是安全的用户身份。真正的登录 / 认证，是在这套会话机制之上，再加一层“凭什么证明‘我就是我’”。\n注册、登录、权限、密码安全、验证码、OAuth……那是自成体系、又安全敏感的一门课。我们后面可能会讲。但是，无论认证体系多么复杂，它都会需要用我们今天讲的这套会话机制作为地基。\nOne more thing：大模型是怎么\u0026quot;记住\u0026quot;你的 大模型的API是没有状态的，它要保持会话的方式和我们今天讲的这一套方法不同，但会更容易理解，就是在请求体的messages里塞入历史会话。\n还记得刚才那两条 curl 吗？第一句我们说：\n\u0026ldquo;我的名字叫李勃，你记着\u0026rdquo;\n大模型回复：\n\u0026ldquo;好的，李勃！我记住了，很高兴认识你。\u0026rdquo;\n这时候我们再问一次\u0026quot;我叫什么名字\u0026quot;，但把前面那两句一起带上，发过去的就是这样：\n\u0026#34;messages\u0026#34;: [ {\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;我的名字叫李勃，你记着\u0026#34;}, {\u0026#34;role\u0026#34;: \u0026#34;assistant\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;好的，李勃！我记住了，很高兴认识你。\u0026#34;}, {\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;我叫什么名字？\u0026#34;} ] 这次它答对了：「你叫李勃呀，我记着呢。」\n但请注意，模型还是什么都没记住。 是我们把整段对话重新发了一遍。那个 messages 数组就是这次会话的全部记忆——它不在模型那边保存，而是在我们这边（或者应用这边），每一次都得原样再交一遍。\n看懂这一点，好几件事一下就通了：\n所谓\u0026quot;上下文窗口\u0026quot;，就是这个messages数组的长度上限。 聊太长了前面就得被裁掉。这就是所谓的“AI女友记忆崩溃，把我忘了”这件事的真相； 对话越长，每次要发的越多。 所以长对话越来越慢、也越来越贵。因为是按 token 计费，我们每一轮都在为整段历史重新付一次钱 。 也就明白了 compact 是在干嘛。 既然整段历史每轮都要重发，那自然会想到把它总结压缩一下，让数组短一点、便宜一点。 而最有意思的是：它和我们今天做的事，是同一个问题的两种解法。 把两种做法并排放在一起看：\n状态存在哪儿 每次请求带什么 我们的 Web 会话 服务器（history 表，可以很大） 只带一个 id（32 个字符） 裸调大模型 API 客户端自己 把全部历史都带上 表里那个\u0026quot;客户端\u0026quot;，就是现在常见的AI Agent工具，比如DeepSeek Harness、Claude Code、Codex、OpenClaw、WorkBuddy、Pi Agent……我们在对话框里一句接一句地聊，感觉它\u0026quot;记得\u0026quot;；真相是它在背后替我们攒着那个 messages 数组，每问一次，就把整段重新交上去一遍。\n想通这一层，AI 工具里那些名词也就不神秘了：所谓知识库、所谓 Skill，说到底都是在决定往那个数组里放什么、放多少——都是在管上下文（也就是那个 messages 数组）。\n最后回头看一眼这三种做法：\nWeb 应用——状态留在服务器，浏览器只揣着一个编号； 大模型 API——状态全在调用方手里，每次把整段历史原样交上去； 手机 App——没有 cookie，就把编号塞进请求头（前面提过的 token）。 手法各不相同，要办的却是同一件事——\n把一串散落的、彼此不认识的请求，重新认成\u0026quot;同一个人\u0026quot;。\n这就是会话。\n模块 6 收官 这一节到此结束，我们的整个模块 6，也一起走完了。回头看这六节走过的路：\n6.1——生态：不要重复造轮子，优先考虑用别人已经做出来的库； 6.2——第一次换芯：找到pypinyin和snownlp两个库，把文字实验室做成真的； 6.3——文件版历史：理解存储，理解数据库存在的必要性； 6.4——数据库：认识了一些数据库，并上手操作了一下SQLite； 6.5——第二次换芯：把数据存进了 SQLite，理解了重构； 6.6——状态与会话：让每个访客有了自己的历史，也给未来的认证打好了地基。 清点行囊——现在这个项目由三样东西组成：\n静态前端 ＋ FastAPI 后端 ＋ SQLite\n下一个模块，就是把这一整套搬上我们的云服务器——前端用生产地址重新 build、后端用 systemd 变成常驻服务、Nginx 把 /api/ 反代过去让前后端同源。之前埋的很多线，在部署的时候将会一次性全部收回。\n功能，到此完整。剩下的，只差上线。\n← 上一节：模块 6.5 重构，在项目中使用 SQLite | 下一节：模块 7.1 把后端搬上服务器 →\n","date":"2026.09.04","description":"HTTP 天生无状态，服务器认不出人。用 UUID 发一个 session_id、让 cookie 替我们来回运送，把散落的请求认成同一个访客——每个人只看到自己的历史。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-6-6/","title":"模块 6.6：状态与会话"},{"content":" 把文件版的存储系统换成数据库的版本\n这一节的目标 上一节我们已经学习了SQLite数据库的用法，但是我们的zero-to-tech项目现在还没有用上SQLite，仍然是在用history.json这个文件在承担存储的职责。这一节，就可以换成用SQLite做存储了。\n这一节我们要改造一下zero-to-tech这个项目，但是只动存储层：会需要改动不少 python 代码，但不会破坏接口约定（/api/analyze 和 /api/history 对老调用方的行为不变），前端也就\u0026quot;毫无察觉\u0026quot;，因此也不需要改前端代码。\n先给 main.py 分层 \u0026ldquo;把存储层换成数据库\u0026rdquo;，听着像一件事，落到代码里其实是好几处。我们把 backend/main.py 打开，从头到尾扫一遍，看一看这个文件目前做了哪些事情。\n这个文件现在也就几十行，看着是一整块。但仔细看，它里面已经住着三种不同的职责：\nimport json # ← 存储 from datetime import datetime, timezone # ← 业务（时间戳是 analyze 生成的） app = FastAPI() # ← 启动/配置区，不归这三层 # ... CORS ... HISTORY_FILE = \u0026#34;history.json\u0026#34; # ← 存储 def load_history(): ... # ← 存储 def save_record(record): ... # ← 存储 @app.get(\u0026#34;/api/profile\u0026#34;) # ← 接口 def get_profile():... def score_label(score):... # ← 业务 @app.post(\u0026#34;/api/analyze\u0026#34;) # ← 接口 def analyze(req: AnalyzeRequest): text = req.text # ← 业务 score = round(SnowNLP(text).sentiments, 2) # ← 业务 result = { ... } # ← 业务 save_record(result) # （接口层喊一声存储层） return result @app.get(\u0026#34;/api/history\u0026#34;) # ← 接口 def history(): records = load_history() # ← 存储？ records.reverse() # ← 存储？ return records[:10] # ← 存储？ 三层各管各的：\n接口层——对接前端的API（@app.post / @app.get），管的是\u0026quot;接收什么、响应什么\u0026quot;； 业务层——算情感分、算拼音、把结果拼成一条记录，管的是\u0026quot;这件事本身怎么做\u0026quot;； 存储层——所有跟\u0026quot;东西存在哪儿、怎么存、怎么取\u0026quot;打交道的代码。 目前这三层的代码都放在main.py，在项目还小的时候这么做是没问题的，不一定要分开成不同的文件，因为分层分的是职责，不是文件。\n但是，还有个小问题，看 /api/history 里那三行：\nrecords = load_history() # 读出全部 records.reverse() # 倒序 return records[:10] # 切前 10 条 \u0026ldquo;读出全部、倒过来、切前十\u0026rdquo;——这是取数据的手法，是地地道道存储层的活儿，可它现在长在接口函数里。可以看出来我们此前写的main.py文件，代码的分层设计并不清晰。\n分层设计不清晰的代价就是，当我们要改项目背后的存储的时候，因为这个接口函数里直接写了与存储相关的三行代码，我们不得不伸手去改这个接口函数（因为换成数据库之后，这三行会变成一句 SQL）。所以，在真正的做存储层的切换之前，我们值得先对main.py中的代码做一次小型的重构。\n重构方案 在之前学习前端的时候，我们也做过重构，基本思路就是重新划分代码职责，用一种更易于管理的方式组织代码。我们的main.py也可以按照分层思想进行重构，把代码拆成多个文件，再用 import 把它们的依赖关系写进代码里。\n重构还有一条铁律要记住：功能一点都不能变。 重构只挪位置、不改行为，跑出来但凡有一点不一样，就说明搬错了。这条铁律其实不是额外的纪律，它就是\u0026quot;重构\u0026quot;这个词的定义——业内对重构的定义是：在不改变外部可观察行为的前提下，调整代码的内部结构。行为一变，就不叫重构了。（所以严格说，这一节的第二步\u0026quot;把文件换成 SQLite\u0026quot;不算重构，那是换实现；只有第一步的搬家、以及后面把 10 改成参数，才是重构。）\n此外，不要为了重构而重构，现在main.py中的代码比较少，接口层和业务层就那么几行，而且今天一行都不用改；真正要动的只有存储层，它今天整个要被换掉。这种情况下把业务层和接口层拆开几乎换不来任何好处，只会多增加一些来回跳转的成本，而把存储层拆开就很值得。\n所以比较合适的方案是只把存储层单独拆出来一个文件，拆完之后是这样两个文件：\nbackend/ main.py ← 接口层 ＋ 业务层：对外的门，和\u0026#34;这件事怎么算\u0026#34; storage.py ← 存储层：数据存在哪儿、怎么存、怎么取 那什么时候才该给业务层也开一个文件？等它也长到\u0026quot;我们一眼看不出它的边界在哪\u0026quot;的时候。 6.2 那一节我们给业务层换过一次分析内核（从写死的占位值切换到 snownlp），那次没拆文件也很轻松，因为要换的东西就窝在一个函数里。但今天要换的存储层，是散落在文件各处的，边界看不见了，才需要用文件把它框出来。\n再说文件名。文件名为什么叫 storage.py，因为这一层的职责就是存储，无论是用文件实现还是用SQLite实现，哪怕以后改了别的存储实现，它的职责不会变。给边界起名字，要用职责起名字，不用实现来起名字。这和 save_record()、get_history() 是一个道理：这两个函数名里，也没有\u0026quot;file\u0026quot;或\u0026quot;SQL\u0026quot;这些字眼。\n这么一通分析，今天要干的活儿就清楚了，分两步：\n搬家——把存储层原样搬进 storage.py。功能一点不变，存储还是用history.json； 装修——把 storage.py 整个换成 SQLite 版。main.py 几乎不用碰。 为什么非要分两步、不能一边搬一边改？因为搬坏了和改坏了，是两种完全不同的问题。搬完先跑一遍，如果一切没变，说明搬对了；装修之后再跑一遍，如果坏了，那一定是装修弄的。一次只做一件事，出了问题范围就小，这也是一个很值钱的习惯。\n而且这两件事性质本来就不一样：搬家是重构，行为必须不变，所以它自带一把尺子——跑一遍，不一样就是错了；装修是换实现，行为本来就该变，它没有这把尺子。混在一起做，那把尺子就废了：跑出来不对，我们根本分不清是搬错了还是换错了。\n第一步：搬家（功能零变化） 在 backend/ 里新建一个 storage.py，把 main.py 里所有属于存储层的东西原样剪切过来：\n# backend/storage.py import json HISTORY_FILE = \u0026#34;history.json\u0026#34; def load_history(): try: with open(HISTORY_FILE, \u0026#34;r\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as f: return json.load(f) except FileNotFoundError: return [] def save_record(record): records = load_history() records.append(record) with open(HISTORY_FILE, \u0026#34;w\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as f: json.dump(records, f, ensure_ascii=False, indent=2) def get_history(): records = load_history() records.reverse() return records[:10] 前两个函数是一个字没改地搬过来的。最后那个 get_history是一个新定义的函数，它里面写的就是我们前面说的那三行职责定义不清晰的部分，现在我们把它们从接口函数里搬到了真正应该属于它的位置，原样包成一个函数。注意连那个写死的 数字10 都照抄了过来，搬家就是搬家，一个字都不改。\n再回到 main.py。这一侧要做四件事：\n顶上把 import json 换成 from storage import save_record, get_history； 删掉 HISTORY_FILE、load_history、save_record 这三样（它们已经在 storage.py 里了）； /api/history 瘦身成一句 return get_history()； 其余一个字都不动。 from datetime import datetime, timezone 要留着：时间戳是 analyze 生成的，那是业务层的活儿，不跟着搬家。\n搬完之后的 main.py，完整长如下这样，对着核一遍，别搬串了：\n# backend/main.py from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from pypinyin import lazy_pinyin, Style from snownlp import SnowNLP from datetime import datetime, timezone from storage import save_record, get_history # ← 新增：跟存储层打交道，只经过这一行 app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=[\u0026#34;http://localhost:3000\u0026#34;], allow_methods=[\u0026#34;GET\u0026#34;, \u0026#34;POST\u0026#34;], ) profile = { \u0026#34;heroTitle\u0026#34;: \u0026#34;关于我\u0026#34;, \u0026#34;heroSubtitle\u0026#34;: \u0026#34;项目，创意，灵感，心得，我的作品\u0026#34;, \u0026#34;featuredWork\u0026#34;: { \u0026#34;kicker\u0026#34;: \u0026#34;作品\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;文字实验室\u0026#34;, \u0026#34;copy\u0026#34;: \u0026#34;拼音和情绪，挖掘中文里的细节\u0026#34;, \u0026#34;linkLabel\u0026#34;: \u0026#34;打开作品\u0026#34;, }, \u0026#34;identity\u0026#34;: { \u0026#34;motto\u0026#34;: \u0026#34;已识乾坤大，尤怜草木青\u0026#34;, \u0026#34;learning\u0026#34;: \u0026#34;零到全栈\u0026#34;, }, } class AnalyzeRequest(BaseModel): text: str def score_label(score): if score \u0026gt;= 0.6: return \u0026#34;偏积极\u0026#34; elif score \u0026lt;= 0.4: return \u0026#34;偏消极\u0026#34; else: return \u0026#34;中性\u0026#34; @app.get(\u0026#34;/api/profile\u0026#34;) def get_profile(): return profile @app.post(\u0026#34;/api/analyze\u0026#34;) def analyze(req: AnalyzeRequest): text = req.text score = round(SnowNLP(text).sentiments, 2) result = { \u0026#34;text\u0026#34;: text, \u0026#34;score\u0026#34;: score, \u0026#34;label\u0026#34;: score_label(score), \u0026#34;pinyin\u0026#34;: \u0026#34; \u0026#34;.join(lazy_pinyin(text, style=Style.TONE)), \u0026#34;created_at\u0026#34;: datetime.now(timezone.utc).isoformat(timespec=\u0026#34;seconds\u0026#34;), } save_record(result) return result @app.get(\u0026#34;/api/history\u0026#34;) def history(): return get_history() 改完之后，如果启动应用，访问文字实验室、访问/api/history，看到的效果和改之前完全一样，那就说明重构成功了。然后我们再分析一下这次改动之后，两个文件之间是怎么共同工作的。\n首先，在通过fastapi dev启动应用的时候，uvicorn还是只会去找 main.py, 但是因为下面这一行import，就让storage.py文件成功被加载了：\nfrom storage import save_record, get_history 可以看出来Python 的 import 很朴素，因为storage.py 和 main.py 在同一个目录里，所以写 from storage import save_record 就能找到，不用写路径，不用写 .py。\n也就是因为这个原因，storage.py一定要建在 backend/目录下，和main.py两个文件得在一块儿才行。\n职责真的分干净了吗？ 文件拆开了，代码也各就各位，看上去挺整齐。但整齐从来不是重构的目的。\n回头想一想我们为什么要做这次重构——不是为了让代码好看，是为了让每一层只管自己该管的事。分层的价值就在于，如果哪天要换某一层，只动那一层，别人不受牵连。今天我们马上就要换存储层，指望的正是这个。\n所以，判断一次重构做到位了没有，标准不是\u0026quot;文件变多了\u0026quot;\u0026ldquo;函数变短了\u0026rdquo;，而是这么一句追问：\n这一层里面，还有没有不属于它的东西？\n那接下来我们就拿这把尺子，量一量刚拆出来的 storage.py。\nstorage.py这一层的职责是存储， 也就是 数据存在哪儿、怎么存进去、怎么取出来。 除此之外的事，都不该由它操心。\n再逐行看代码：\ndef get_history(): records = load_history() # 把数据取出来 —— 它的事 records.reverse() # 新的排前面 —— 它的事（\u0026#34;怎么取\u0026#34;的一部分） return records[:10] # 只给 10 条 —— ？ 前两行没有疑问。第三行得停一下。它是不是把两件事捏在了一起？\n\u0026ldquo;切一刀，只给一部分\u0026rdquo; 这个动作——是存储层的活儿没错（等会儿换成数据库，它就是那句 LIMIT）； \u0026ldquo;切在 10 这个位置\u0026rdquo; 这个决定——这应该是它管的事吗？ 想两个场景：\n哪天业务需求改了，想在手机上显示的时候取出来 20 条——照现在这个写法，我们得去改 storage.py。可它是管\u0026quot;数据存储\u0026quot;的，凭什么把一次取多少条这个事情交给它管理？ 反过来问：storage.py 需要知道什么时候取10条什么时候取20条吗？知道用户是在手机上看还是在电脑上看吗？——不知道，也不该知道。 所以答案很清楚：动作归存储层，决定归调用方。 这个数字不属于这一层，它属于调用它的那一层。存储层该说的话只有一句——\u0026ldquo;你要几条，我给几条\u0026rdquo;。\n那就把这个数字的决定权交出去。 storage.py 里，把写死的数字换成一个参数：\ndef get_history(limit): records = load_history() records.reverse() return records[:limit] main.py 这一侧，由调用方把数字传给它：\n@app.get(\u0026#34;/api/history\u0026#34;) def history(): return get_history(10) 这样改完之后，功能一点没变，还是最近 10 条，跑一遍结果一模一样；变的只是这个决定归谁管。\n这同样是一次重构，搬家挪的是代码的位置，而这一次挪的是决定的归属。看似只是挪了一个数字，但背后其实是关于职责的划分。\n第二步：装修（把文件存储换成 SQLite） 现在 storage.py 是一个独立的、边界清清楚楚的文件。但是它目前还是在使用文件版的存储系统。接下来我们就要给它换成SQLite的存储方式。\n这次“装修”，我们要改 6 处代码，全在 storage.py 里面\n# 现在（文件版） 改成（数据库版） 1 import json import sqlite3 2 HISTORY_FILE = \u0026quot;history.json\u0026quot; DB_FILE ＋ 一个 get_conn() 3 （文件时代没有这一步） init_db()：启动时建表 4 save_record：读全部+追加+整个写回 一句 INSERT 5 get_history：全量读+倒序+切片 一句 SELECT … ORDER BY … LIMIT 6 load_history 删掉（数据库不需要\u0026quot;整份读出来\u0026quot;） 下面一处一处来\n第 1 处：换 import 把 storage.py 顶上这一行：\nimport json 换成：\nimport sqlite3 换成数据库就不需要再和硬盘上的.json文件打交道了。\n第 2 处：从\u0026quot;文件名\u0026quot;到\u0026quot;连接\u0026quot; 用json文件做存储的时候，入口就是一个文件名，open() 一下就能读写：\nHISTORY_FILE = \u0026#34;history.json\u0026#34; 而SQLite是一个.db文件，程序得先通过sqlite3连上它。所以这一处不是简单换个常量，而是多出一个新概念——把上面那一行，换成下面这一段：\nDB_FILE = \u0026#34;history.db\u0026#34; def get_conn(): conn = sqlite3.connect(DB_FILE) conn.row_factory = sqlite3.Row # 让查询结果带上列名（默认是元组） return conn 中间 row_factory 那行值得说一句。上一节我们在试验田里查出来的每一行都是元组，比如 (1, '肖申克的救赎', '英语', '1994-09-23', ...)——光有值，没有名。加上这一行之后，查出来的每一行就带上了列名，可以直接用 dict(row) 转成一个带字段名的字典，而这正好就是要回给前端的 JSON 形状。等会儿第 5 处就会用到它。\n注意 get_conn() 是个函数，它定义了如何连接数据库。下面每个函数需要连接数据库的时候就调用它、用完 close()——用的时候开门、用完关门，这是最简单也最不容易出错的写法。\n第 3 处：建表 这一处比较特殊：文件版里没有对应的代码，是数据库多出来的一步。\n往文件里写，格式想怎么定就怎么定，写之前不用跟谁打招呼；而关系型数据库是先定表结构、再往里放数据的。所以多一个\u0026quot;确保表在\u0026quot;的函数。\n紧接着 get_conn() 下面，新增：\ndef init_db(): conn = get_conn() cur = conn.cursor() cur.execute(\u0026#34;\u0026#34;\u0026#34; CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT, score REAL, label TEXT, pinyin TEXT, created_at TEXT ) \u0026#34;\u0026#34;\u0026#34;) conn.commit() conn.close() 上一节我们已经学过了如何建表，这里只是换了表名和字段，id 依旧是自增主键。CREATE TABLE IF NOT EXISTS 这个写法的意思是如果还没有这个表就建，如果这个表已经在了就跳过了。\n第 4 处：save_record换成SQL的写法 文件版的 save_record 是\u0026quot;读出整个文件 → 内存里追加一条 → 整个写回\u0026quot;三步：\ndef save_record(record): records = load_history() records.append(record) with open(HISTORY_FILE, \u0026#34;w\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as f: json.dump(records, f, ensure_ascii=False, indent=2) 用SQLite的实现就是通过SQL的INSERT语句来操作：\ndef save_record(record): conn = get_conn() cur = conn.cursor() cur.execute( \u0026#34;INSERT INTO history (text, score, label, pinyin, created_at) VALUES (?, ?, ?, ?, ?)\u0026#34;, [record[\u0026#34;text\u0026#34;], record[\u0026#34;score\u0026#34;], record[\u0026#34;label\u0026#34;], record[\u0026#34;pinyin\u0026#34;], record[\u0026#34;created_at\u0026#34;]], ) conn.commit() conn.close() 中间那句 INSERT 就是往表中插入数据的SQL，注意所有的值都没有直接拼进 SQL 里，而是用 ? 占位、把值放进后面那个列表交给 execute——这就是上一节立下的防 SQL 注入的规矩。\n还有个细节值得说一句：时间戳沿用 Python 生成的那一份。 上一节我们用的是 SQLite 自带的 datetime('now')，图的是省事；而这里我们没有依赖SQLite自己的时间函数，而是用了Python生成的时间。\n第 5 处：get_history——三行变一句 文件版的 get_history 是\u0026quot;全量读 → 倒序 → 切片\u0026quot;三行：\ndef get_history(limit): records = load_history() records.reverse() return records[:limit] 现在换成 SQL版：\ndef get_history(limit): conn = get_conn() cur = conn.cursor() rows = cur.execute( \u0026#34;SELECT * FROM history ORDER BY created_at DESC LIMIT ?\u0026#34;, [limit], ).fetchall() conn.close() records = [] for row in rows: records.append(dict(row)) return records 看点有四个：\n文件版要自己动手做的三件事（全量读、自己倒序、自己切片），现在一句 SQL 全包了。 连 LIMIT 后面那个数字都走占位符 [limit]。值永远走 ?，没有例外——这条规矩的价值就在于，我们不必每次都停下来判断“这个值危不危险”，一律照办就不会漏； 末尾的 .fetchall()上一节讲过，凡是需要拿到数据作为结果的就要写它； 最后那个循环里的 dict(row)，可以把数据库查回来的内容直接当 JSON 回给前端。 别被 fetchall 里的 \u0026ldquo;all\u0026rdquo; 骗了。 这个\u0026quot;all\u0026quot;不是表里全部的数据，而是SQL语句查回来的全部的数据，如果SQL的limit是10 ，那么就只会fetch到10条。\n第 6 处：把 load_history 删掉 这个函数现在没人调用了：\ndef load_history(): try: with open(HISTORY_FILE, \u0026#34;r\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as f: return json.load(f) except FileNotFoundError: return [] 整个删掉。 \u0026ldquo;把整份数据读进内存\u0026quot;这个动作，从今天起再也不需要了。\n对一下：换完的 storage.py 六处都改完，storage.py 应该长这样。对着核一遍，别漏别错：\n# backend/storage.py import sqlite3 DB_FILE = \u0026#34;history.db\u0026#34; def get_conn(): conn = sqlite3.connect(DB_FILE) conn.row_factory = sqlite3.Row return conn def init_db(): conn = get_conn() cur = conn.cursor() cur.execute(\u0026#34;\u0026#34;\u0026#34; CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT, score REAL, label TEXT, pinyin TEXT, created_at TEXT ) \u0026#34;\u0026#34;\u0026#34;) conn.commit() conn.close() def save_record(record): conn = get_conn() cur = conn.cursor() cur.execute( \u0026#34;INSERT INTO history (text, score, label, pinyin, created_at) VALUES (?, ?, ?, ?, ?)\u0026#34;, [record[\u0026#34;text\u0026#34;], record[\u0026#34;score\u0026#34;], record[\u0026#34;label\u0026#34;], record[\u0026#34;pinyin\u0026#34;], record[\u0026#34;created_at\u0026#34;]], ) conn.commit() conn.close() def get_history(limit): conn = get_conn() cur = conn.cursor() rows = cur.execute( \u0026#34;SELECT * FROM history ORDER BY created_at DESC LIMIT ?\u0026#34;, [limit], ).fetchall() conn.close() records = [] for row in rows: records.append(dict(row)) return records 整个文件里，再也找不到 json、open()、history.json——存储的实现，从文件彻底换成了数据库。\n（这份还不是最终版：这一节结尾的“善后”里，我们还会往 init_db() 里加一行索引。到时候会把最终的样子再贴一次。）\n那 main.py 呢？只多两行 storage.py 整个被换掉了。回头看 main.py——它只多了两行，都是为了那个新冒出来的建表动作：\nfrom storage import init_db, save_record, get_history # ← 这一行：import 里多个 init_db app = FastAPI() # ... CORS 等原有代码，一个字不动 ... init_db() # ← 这一行：启动时确保表在 在storage.py中定义的函数，通过一行import就引入到了main.py中。另外，每次启动的时候都执行一次 init_db()，这是为了确保数据库和表都在。正常情况下，只有第一次运行的时候它才会真的建表，以后每次启动都会跳过。\n验证切换成功 把存储从文件系统换成SQLite之后，还需要进行测试来检验我们的存储系统是否切换成功：\n打开文字实验室，分析一句，结果照常出来（前端用的是 /api/analyze，它的路径、请求体、返回字段一个没动，前端自然毫无察觉）； 请求 /api/history 接口，看到数据库中已经存储有刚才通过文字实验室分析的那行文本。 上面两步验证通过，也只能说明功能和此前一致（除了 /api/history 的返回里面多出来了一个 id 字段）。如果想看一看数据确实已经被写入到了数据库，还可以用类似 DB Browser 这样的数据库可视化工具打开 backend/history.db 看一看——在 Browse Data 里，刚才那句分析应该已经躺在表里，id 也自动发好了。\n顺带一提：/api/history 的返回里现在多了个 id 字段，是因为 SELECT * 把 id 也带了出来。我们此前提到过，往返回里加字段并不破坏约定，所以这个 id 可以让它继续待在返回里，下一节还会用到它。\n还有一件事值得说一句：这一节从头到尾，我们没有打开过任何一个前端文件。存储从一个 JSON 文件换成了一个数据库，这是地板下面天翻地覆的改动，可页面上什么都没发生。\n这就是 6.2 那句\u0026rdquo;换芯不换壳\u0026quot;，今天又演了一遍——而且这次演了三层：\n/api/analyze 和 /api/history 这两个 HTTP 接口没变，所以前端毫无察觉； save_record() 和 get_history() 这两个函数的名字和参数没变，所以 analyze 一个字都不用改； storage.py 这个文件对外的样子没变，所以 main.py 只多了两行。 同一件事，在接口、函数、文件三个尺度上各成立了一次。只要边界立得住，边界后面的东西就可以整个换掉。 模块 7 我们还会再用一次这个红利——那时候要换的是 SQLite 本身。\n善后四件事 其一：数据不进 Git。 所以 .gitignore 中需要加一行：\nbackend/*.db 道理：数据是运行时产生的，每台机器、每个环境都该有自己的数据，它和代码不是一类东西。\n这已经是我们立的第三条同类规矩了——前两条是\u0026quot;依赖不进\u0026quot;（node_modules、.venv）和\u0026quot;产物不进\u0026quot;（out、.next）。这三条合起来到底意味着什么，等模块 7 我们把项目搬上服务器、在一台全新的机器上 git pull 完的那一刻，会看得特别清楚。\n其二：history.json 退役。 直接删掉就可以了。真实项目里“数据迁移”是一门正经手艺，老的数据一条都不能丢、格式还得对上。但毕竟我们项目还没上线，里面的历史数据也不值钱，从零开始最干净也最省事儿。\n其三：认识一下ORM。 真实项目里，很多人不直接手写 SQL，而是用 ORM（比如 Python 的 SQLAlchemy），它的做法是把表包装成 Python 对象来操作，由框架帮自动生成SQL。这个东西我们没有用到（我们这两句 SQL，手写反而更清楚），我也不打算展开讲，在这里提出来你知道就行。\n其四：给 created_at 建一个索引。 上一节讲索引的时候立过一条原则——经常拿来排序、或经常拿来筛选的列，才值得建索引。回头看我们这张 history：每次查历史都是 ORDER BY created_at DESC，created_at 正是那种\u0026quot;天天拿来排序\u0026quot;的列，这是标准的建索引场景。\n那就建。回到 storage.py 的 init_db()，在建表语句后面再加一句：\ncur.execute(\u0026#34;CREATE INDEX IF NOT EXISTS idx_history_created ON history(created_at)\u0026#34;) IF NOT EXISTS 还是老搭档：第一次启动的时候建，以后跳过。名字 idx_history_created 是个惯例写法——idx_表名_列名，一眼看得出它是给谁建的。\n加完这一行，init_db() 的最终样子是这样（storage.py 里其余的函数都不用再动）：\ndef init_db(): conn = get_conn() cur = conn.cursor() cur.execute(\u0026#34;\u0026#34;\u0026#34; CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT, score REAL, label TEXT, pinyin TEXT, created_at TEXT ) \u0026#34;\u0026#34;\u0026#34;) cur.execute(\u0026#34;CREATE INDEX IF NOT EXISTS idx_history_created ON history(created_at)\u0026#34;) conn.commit() conn.close() 改完记得让后端重启一次，索引才会真的建出来。\n建完去看一眼。 用 DB Browser 打开 history.db，在 Database Structure 那一栏展开——history 表下面多了个 Indices，idx_history_created 就挂在那儿。6.4 我们讲了半天\u0026quot;在正表旁边，多存一份按时间排好序的目录\u0026quot;，那时候它只是个比喻；现在，它是一个你能点开、能看见的东西了。\n不过我们这张表目前数据量不大， 全表扫一遍比眨眼还快，所以建了之后无法察觉速度的提升，甚至SQLite 的优化器可能压根不会用这个索引，因为走索引还得再回表拿数据，还不如直接扫。索引真正开始省时间，得是几十万、几百万条的时候。\n我们建这个索引主要是为了让大家感受如何为一个表创建索引。但是 6.4 那条原则不能忘，我们应该只给经常排序、经常筛选的列建索引，别每个字段都建。一张表挂十几个索引的事真有人干，查是快了点，但写的时候能慢到让人受不了。\n还差一步：让历史“分到每个人” 存储层升级完成，历史稳稳落进了 history.db。但它现在还是全站一份——所有访客的记录混在一张表里，/api/history 查的是所有用户的记录。\n下一节我们会讲如何通过会话让每个访客只看到自己的记录。\n← 上一节：模块 6.4 数据库正传 | 下一节：模块 6.6 状态与会话 →\n","date":"2026.08.28","description":"先给 main.py 分层、把存储拆进 storage.py，再把文件换成 SQLite——接口一行没破，前端毫无察觉。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-6-5/","title":"模块 6.5：重构，在项目中使用 SQLite"},{"content":" 看数据库如何又快又好地解决存储和查询过程中的各种问题\n七步之内必有解药 上一节我们自己手搓文件存储，埋下了不少问题：效率问题、并发问题、健壮性问题。\n但是，这些问题并不是我们项目特有的——任何人只要用“一个文件”去存不断增长的结构化数据，都会不可避免地遭遇这些问题。\n在计算机领域，如果是人人都可能会遇到的问题，那么请大家相信——七步之内必有解药：一定有人做好了专门的东西，来替我们解决它（5.4 的框架如此、6.1 的库如此，今天也一样）。\n这个解药就是数据库，数据库就是专门负责“把数据存好、帮助我们快速查询数据”的软件。上一节那些痛，全是它的本职工作。\n数据库的类型 目前，市面上已经有太多的数据库软件了，有许多你可能听过，比如说 MySQL、PostgreSQL、ClickHouse、MongoDB、DuckDB、SQLite、Redis 等等\n这些数据库不只是厂商不同与名字不同，它们的底层实现逻辑也可能会有较大差异，擅长的业务类型也会有差异，对于零基础的朋友们，我们可以先不用在这些差异上深入。\n但是我们仍然可以给它们大致用两个维度进行分类：从数据模型上，可以分为关系型与非关系型；从服务方式上，可以分为 嵌入式与服务式。\n关系型 vs 非关系型 先看第一个维度——关系型 与 非关系型。它俩的区别，主要体现在存储的数据的形态是什么样。\n数据的形态大概分为三种：\n结构化——有固定的字段结构，每一行记录一条数据，数据可以整整齐齐摆进一张表格。这种就是最典型的结构化数据。 半结构化——有结构，但比较松散、允许每条数据都长得不完全一样，代表就是 JSON。 非结构化——没有行列结构，数据要么是一段大文本，要么是一张图片或者一段视频。 数据什么形态，就配什么库：\n结构化数据最规整，最趁手的就是关系型数据库——把数据摆成一张张规整的表，这些表的行列固定，每列还规定了类型，支持用一种叫做 SQL 的通用语言去增删改查； 半结构化数据交给非关系型里的文档型数据库——存进去的东西长得就像 JSON，比较典型的是MongoDB； 还有些特殊打法也归非关系型，比如需要按\u0026quot;名字 → 值\u0026quot;极速存取、专做缓存的键值型，比较典型的就是 Redis，数据主要放内存里，读写都很快； 非结构化（图片、视频）一般不塞进数据库，而是丢进对象存储。 关系型数据库对格式的要求更严格，强调数据规范，可以用一种标准的SQL语法进行操作。非关系型数据库也叫NoSQL，意思是 \u0026ldquo;Not Only SQL\u0026rdquo;，它对数据的要求更为松散和灵活。这两类数据库它有专长，不存在哪种更高级，只是为不同场景而生的不同工具。\n开头列的那一串数据库，可以按这个维度归类如下：\n关系型：MySQL、PostgreSQL、SQLite，ClickHouse、DuckDB； 非关系型：MongoDB（文档）、Redis（键值）。 嵌入式 vs 服务式 再看第二个维度——嵌入式 与 服务式，它们的区别在于数据库跟我们的程序是什么关系。\n服务式：数据库是独立的常驻程序，它需要被安装，需要被启动，启动之后是一个独立的进程，持续监听某个端口，在这个端口上7×24 等人连接（MySQL 守 3306、PostgreSQL 守 5432，就和我们的FastAPI守着 8000端口一样）。我们的程序可以通过网络去连它。 嵌入式：这是一种更轻的模式，这类数据库本质就是一个库、一个文件，不用单独启动、也不占端口。SQLite 就是典型——整个数据库就是硬盘上一个 .db 文件，Python 标准库自带了sqlite的库，只需要import sqlite3就可以用。 这两类数据库也是各有千秋。服务式更强，能够支持多个应用同时连，权限控制精密，也可以扛高并发，支持主从备份，数据集群，支持跨网络访问，所以主流的生产环境的网站后端会用它们做数据库。\n但是嵌入式也不差劲，比如说SQLite就很普及，它普及到什么程度？我们手机里此刻就躺着几十个 SQLite 文件——微信的聊天记录、浏览器的历史，底下都是它。\n同样把开头列的那串数据库归归类：\n嵌入式：SQLite、DuckDB； 服务式：MySQL、PostgreSQL、MongoDB、Redis、ClickHouse。 基于需求选择用什么数据库 现在我们认识了这么多数据库，那么我们这个文字实验室该用哪一个呢？ 我们先从需求出发来看一看。\n先看存的是什么数据 。文字实验室的历史记录——原文、分数、标签、时间，每条都这几样、整整齐齐。这是最规整的结构化数据，我们优先选择关系型数据库。\n再看有多大规模、什么场景。文字实验室是一个小项目，不需要多个应用共享同一个库，也没有高并发压力，更不想为了存点数据、单独去养一个 7×24 的常驻服务， 嵌入式就很合适（一个文件搞定，零维护）。\n关系型 ＋ 嵌入式，两个条件一交叉，落点就非常清楚了——SQLite。\nDuckDB 也沾这两条边，但它更偏\u0026quot;数据分析\u0026quot;，而我们做的是日常增删改查，SQLite 更对口；何况它 Python 自带、最成熟。\n所以这一节我们就用 SQLite：零安装（标准库自带）、单文件（整个库就一个 .db，不用多养服务，模块 7 部署的时候更能感受到它的优势）。未来如果想要迁移到 MySQL / PostgreSQL，也比较方便，因为它们都可以用 SQL语言来操作（虽然不同的关系型数据库会有不同的“方言”）。\n体验SQLite 和 SQL语言 我们可以用 python 的 REPL 模式快速体验一下 SQLite。这一节的东西都放在家目录里做，先 cd 到家目录，再进 REPL：\ncd ~ # 先回到家目录，等下的 test.db 就建在这儿 python3 第一步：造一个数据库\nimport sqlite3 conn = sqlite3.connect(\u0026#34;test.db\u0026#34;) # 没有就创建——就建在当前目录（我们刚 cd 进的家目录） cur = conn.cursor() # cursor：往下递 SQL 的“手柄”，固定搭配 此时如果打开家目录，就发现已经有了一个test.db 文件。一个数据库，就是硬盘上这么一个文件，这个就是SQLite 的工作方式。\n第二步：建一张表（CREATE TABLE）\nSQLite 是关系型数据库，而关系型数据库是围绕“表”的。所以上手第一件事，就是学怎么建一张表。\n在数据库里定义一张表，有两个事情必须要明确，一个是表叫什么名字，另一个是这个表的每一个字段（列）叫什么、是什么类型。对应的 SQL 语法就是：\nCREATE TABLE 表名 (字段名 字段类型, 字段名 字段类型, ……) 字段类型就是关系型数据库“严格”的地方——在建表的时候，需要先把每个列的类型确定下来，然后往里放的东西就得守规矩。不同数据库支持的类型不完全一样，但大致就那么几类——文本、数字、日期时间、布尔（数字还会再细分整数和小数）。把三个主流数据库的常见类型摆一起认个脸：\n类别 MySQL PostgreSQL SQLite 整数 INT、BIGINT INTEGER、BIGINT INTEGER 小数 DECIMAL、FLOAT、DOUBLE NUMERIC、REAL REAL 文本 VARCHAR、TEXT VARCHAR、TEXT TEXT 日期时间 DATE、DATETIME、TIMESTAMP DATE、TIMESTAMP 无专门类型，用 TEXT 存 ISO 字符串 布尔 TINYINT(1)／BOOLEAN BOOLEAN 无专门类型，用 INTEGER 的 0/1 代替 很容易可以看得出 SQLite 的类型系统最精简：翻来覆去就 INTEGER / REAL / TEXT（外加一个装二进制的 BLOB）。它连“日期时间”“布尔”的专门类型都没有，布尔就用 0/1 代替。别嫌它简陋，这份精简正是它能塞进我们手机的原因，也是能够支持“一个文件就是一整个库”的原因。\n接下来我们尝试建一张表。 假设我们要用这个表来存电影信息，就叫它 films。一部电影记这么几样信息——名称、语言、上映时间、创建时间。另外，我们再给它加一个唯一的 id字段，这个字段用自增的数字。\n这个建表的SQL语句就可以这样写：\nCREATE TABLE films ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, language TEXT, release_date TEXT, created_at TEXT ) 列名用英文是数据库里的通行习惯：title＝名称、language＝语言、release_date＝上映时间、created_at＝创建时间。\n除了id字段外，其余都是TEXT类型。你可能会问：release_date、created_at 明明是日期时间，怎么也用 TEXT？——正是因为刚才表格里说的，SQLite 没有专门的日期时间类型，所以日期时间在这里就当成一串文字来存，比如上映日期 '1994-09-23'，或者带上时分秒的一串时间。这串时间具体长什么样、由谁生成，下一步插入数据时就见到。\nid字段的定义 id INTEGER PRIMARY KEY AUTOINCREMENT 这个值得说一下。首先，id就是字段名，INTEGER是字段类型，这个是整数数字的意思。AUTOINCREMENT 可以让数据库自动给每条记录发一个递增的“编号”，比如1、2、3, 至于 PRIMARY KEY，这个是主键的意思，你现在不理解也没事。\n这就是我们用于建表的SQL语句。这个语句有时候也会被写成：\nCREATE TABLE IF NOT EXISTS films ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, language TEXT, release_date TEXT, created_at TEXT ) 多了一个IF NOT EXISTS, 意思是如果现在数据库里不存在films表的话才创建，如果已经存在就不创建了。（但实际上就算不写这句，如果已经存在films表的话，是不会再建一个新的films表的）\n但是，一个裸的SQL语句无法直接执行，我们需要用python的cur帮我们执行。所以在REPL中执行的时候可以写成下面这样，把SQL语句包在cur.execute()之中（整段直接粘进 REPL 就行）：\ncur.execute(\u0026#34;\u0026#34;\u0026#34; CREATE TABLE films ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, language TEXT, release_date TEXT, created_at TEXT ) \u0026#34;\u0026#34;\u0026#34;) 敲回车后 REPL 会回显一个 Cursor 对象，不用管它。如果执行没有报错，这张 films 表就写进了刚才家目录里那个 test.db。\n第三步：往films表里插入一行数据（INSERT）\n向一个表里插入一条数据的SQL语句是\nINSERT INTO 表名 (字段名, 字段名, 字段名, ……) VALUES (值, 值, 值, ……) 所以，我们如果向films表中插入一行数据，可以这样写：\nINSERT INTO films (title, language, release_date, created_at) VALUES (\u0026#39;肖申克的救赎\u0026#39;, \u0026#39;英语\u0026#39;, \u0026#39;1994-09-23\u0026#39;, datetime(\u0026#39;now\u0026#39;)) 这里需要注意的是文字的值需要用单引号裹起来，因为片名、语言、日期都是文字，所以都得裹。但是如果是数字就可以不用裹。另外，因为id是自增的，我们不需要给它赋值——插入数据的时候id字段的值会自动生成，这就是自增的意义。\n至于最后那个 created_at（创建时间），我们不想手写死一个时间。SQLite 自带了一个取当前时间的函数 datetime('now')——注意它没有单引号，因为它不是一个文字值，而是一个\u0026quot;函数调用\u0026quot;：让数据库在插入的那一刻自己算出当前时间填进去（默认按 UTC 计算）。\n真正执行的时候，把这句 SQL 包进 cur.execute()：\ncur.execute( \u0026#34;INSERT INTO films (title, language, release_date, created_at) \u0026#34; \u0026#34;VALUES (\u0026#39;肖申克的救赎\u0026#39;, \u0026#39;英语\u0026#39;, \u0026#39;1994-09-23\u0026#39;, datetime(\u0026#39;now\u0026#39;))\u0026#34; ) conn.commit() 但是，请注意，这次在执行cur.execute()之后，还执行了conn.commit()，为什么要这样呢？\n因为执行完 conn.commit() 这一步，才算是把改动真正落盘。 它有个正经名字叫提交事务——就是上一节讲并发安全时点过名的“事务”，这是我们头一回用到它。如果执行了cur.execute()但是没执行commit 就退出，改动就不算数。\ncommit 之后，数据就是真正写入到数据库了，哪怕退出 REPL、重进、再查，这一行还在——因为它正式进了 test.db 这个文件。\n第四步：查数据（SELECT）\n查询数据的SQL语句比较简单，如果想要从一个表中查询到所有的数据记录，可以用这样的SQL语句：\nSELECT * FROM 表名 在python中执行查询语句查films表中的全部数据也需要通过cur.execute()\ncur.execute(\u0026#34;SELECT * FROM films\u0026#34;).fetchall() 语句末尾的fetchall() 是把结果一次性拿成一个列表。\nREPL 直接回显（created_at 是 datetime('now') 算出的当前时间，默认按 UTC 算，所以会和本机时钟差几个时区、也和这里不一样，都正常）：\n[(1, \u0026#39;肖申克的救赎\u0026#39;, \u0026#39;英语\u0026#39;, \u0026#39;1994-09-23\u0026#39;, \u0026#39;2026-08-17 17:07:33\u0026#39;)] 注意开头那个 1就是这一行的id, 我们从没填过，是 id 自动发的身份证。\n照着上面的写法，再插两条（created_at 同样交给 datetime('now')）：\ncur.execute( \u0026#34;INSERT INTO films (title, language, release_date, created_at) \u0026#34; \u0026#34;VALUES (\u0026#39;千与千寻\u0026#39;, \u0026#39;日语\u0026#39;, \u0026#39;2001-07-20\u0026#39;, datetime(\u0026#39;now\u0026#39;))\u0026#34; ) cur.execute( \u0026#34;INSERT INTO films (title, language, release_date, created_at) \u0026#34; \u0026#34;VALUES (\u0026#39;让子弹飞\u0026#39;, \u0026#39;汉语\u0026#39;, \u0026#39;2010-12-16\u0026#39;, datetime(\u0026#39;now\u0026#39;))\u0026#34; ) conn.commit() 这样插入之后，数据库中就有了三条记录，如果想要查询其中一条，可以通过 WHERE 子句：\ncur.execute(\u0026#34;SELECT * FROM films WHERE language = \u0026#39;日语\u0026#39;\u0026#34;).fetchall() [(2, \u0026#39;千与千寻\u0026#39;, \u0026#39;日语\u0026#39;, \u0026#39;2001-07-20\u0026#39;, \u0026#39;2026-08-17 17:07:35\u0026#39;)] WHERE 就是筛选：给某一列出个条件，只把符合的挑出来。上面这句里面，因为我们指定了 WHERE language = '日语', 于是就把这三部电影中日语片给检索了出来。\n第五步：删一行（DELETE）\n删除一条记录的SQL语句是\nDELETE FROM 表名 WHERE 条件表达式 比如说我们想要删除id=2的这一条记录，就可以用cur.execute()来执行下面这个SQL语句\ncur.execute(\u0026#34;DELETE FROM films WHERE id = 2\u0026#34;) conn.commit() DELETE ... WHERE ... 把符合条件的行删掉。WHERE 千万别漏——如果执行的是DELETE FROM films 不带条件，是把整张表的数据全清空。\n另外，如果要执行删除，也需要再带一句conn.commit()\n顺带再认两个SQL语句，不演示：\nUPDATE，是改某行的值 DROP TABLE 是 整张表连结构一起删掉（江湖上说的“删库跑路”，说的就是这类操作——认得它们，敬畏它们） SQL 注入 停下来，看一眼刚才每一句 SQL 的本质：我们递给数据库的，其实是一段文本。数据库拿到后，得先解析这段文本，才知道我们要干嘛。它需要区分 哪些词是命令（SELECT、INSERT）、哪个是表名（films）、哪些是值。\n而 值 ，是靠一对单引号来分界的：'肖申克的救赎'，两个引号之间的，就是一个文字值。\n但是这个地方会有隐患。我们先给网站加个再普通不过的功能——按片名搜索，把用户输进来的关键词拼进一句 SELECT：\nkeyword = \u0026#34;肖申克的救赎\u0026#34; # 用户输入的搜索词 cur.execute(\u0026#34;SELECT * FROM films WHERE title = \u0026#39;\u0026#34; + keyword + \u0026#34;\u0026#39;\u0026#34;).fetchall() 查一部电影，稳稳当当。这句 SELECT 看着人畜无害，对吧？\n直到有一天，一个疯狂的导演来了。 他给新片起的“名字”，不是什么《XX 往事》，而是一串符号——就叫 《' OR '1'='1》（对，片名就是这个，别问，艺术家的世界你不懂）。片子上了架，麻烦也跟着上了架：只要有观众在搜索框里搜这个“片名”想看它，我们那句拼出来的 SQL 就成了——\nSELECT * FROM films WHERE title = \u0026#39;\u0026#39; OR \u0026#39;1\u0026#39;=\u0026#39;1\u0026#39; 看出门道没有？片名开头那个单引号，把我们原本用来框值的引号提前闭合了；于是后面的 OR '1'='1' 不再是“数据”，而被数据库当成 SQL 的一部分去执行——而 '1'='1' 永远为真。结果：WHERE 形同虚设，一句“搜这一部”，硬生生变成了把整个片库哗啦全吐出来。这位导演一个恶趣味的命名，成了全站每次搜索都触发的数据泄露。\n而这还只是“多吐点东西”。要是名字起得更歹毒——塞的是一句删表指令——搜一次，整张 films 表都可能没了（ SQLite 还好，因为它不支持“一次跑多条语句”，但如果换个数据库、换个写法，这刀就真砍下去了）。\n程序员圈有个流传很广的段子：一位家长给孩子登记的名字，写成一段“删除全表”的 SQL，学校系统一拼、一执行，全校的学生表没了\u0026hellip;\n这就是 SQL 注入——把数据伪装成命令，撬开你的 SQL，这是史上最经典，但至今仍在批量发生的漏洞。要命的是：干这事的不一定是黑客，也可能只是一个名字刁钻的正常数据。所以铁律只有一条——只要某个值可能自带引号，就一个都不能信。（想想我们的文字实验室，history 表存的全是用户打的字，更是重灾区）\n既然是数据库通用的问题，那么按照我们的直觉，七步之内必有解药。\n解法就是在使用cur.execute()提交语句的时候，把值的部分写成问号?, 然后让python帮我们往这个SQL语句的问号上传值——要传的值，放进一个列表 [...] 交给 execute。比如可以这样提交SQL语句：\ncur.execute( \u0026#34;INSERT INTO 表名 (字段名, 字段名, 字段名, 字段名) VALUES (?, ?, ?, ?)\u0026#34;, [\u0026#34;值\u0026#34;, \u0026#34;值\u0026#34;, \u0026#34;值\u0026#34;, \u0026#34;值\u0026#34;], ) 或者：\ncur.execute(\u0026#34;SELECT * FROM 表名 WHERE 字段 = ?\u0026#34;, [\u0026#34;值\u0026#34;]).fetchall() 写SQL语句的时候，该放值的地方只写 ?，把值单独作为参数交给 execute，这样写的时候，数据库就认定这个位置铁定是个值，里头无论是引号还是别的什么，都只当纯数据，绝不解析成命令。\n于是那位导演的怪片，照样能安安稳稳存进去：\ncur.execute( \u0026#34;INSERT INTO films (title, language, release_date, created_at) \u0026#34; \u0026#34;VALUES (?, ?, ?, datetime(\u0026#39;now\u0026#39;))\u0026#34;, [\u0026#34;\u0026#39; OR \u0026#39;1\u0026#39;=\u0026#39;1\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;2020-01-01\u0026#34;], ) conn.commit() 搜索也用这样的套路写：\ncur.execute(\u0026#34;SELECT * FROM films WHERE title = ?\u0026#34;, [\u0026#34;\u0026#39; OR \u0026#39;1\u0026#39;=\u0026#39;1\u0026#34;]).fetchall() 执行之后，不多不少，正好返回导演那一部怪片，片库安然无恙。\n立成规矩：SQL 里永远不拼用户输入，值永远走占位符 ?。 这和 4.6 讲 CVE 是同一个道理——安全不是某一章的知识，是每一行代码里的习惯。\nORDER BY / DESC / LIMIT 我们已经了解了数据库和表的最最基本的操作，可以看到它能写、能查、能删、能改，但是我们还没有感受到当数据量比较大的时候，它有哪些优势。\n接下来，我们要学三个SQL关键字，来体验一下查询时的排序、限量操作。\nORDER BY：这个是排序用的，在查询的时候，如果用了ORDER BY 字段名，那么查询的结果就会按照这个字段名来排序，但是默认是正序排序。\nDESC：如果想要逆序排序呢？可以给ORDER BY加上DESC关键字，比如说ORDER BY 字段名 DESC\nLIMIT：如果想要截取前面的少量部分，不想要筛选出来的全部数据，就可以用LIMIT，比如说只想要前10条，就可以用LIMIT 10\n现在我们的films表里电影还太少，尝不出味道。我们索性写个小脚本，一次性灌 24 部电影进去。在家目录新建 seed_data.py（跟 test.db 同一个目录），代码从下方复制：\nimport sqlite3 import time conn = sqlite3.connect(\u0026#34;test.db\u0026#34;) cur = conn.cursor() cur.execute(\u0026#34;DROP TABLE IF EXISTS films\u0026#34;) # 清掉刚才手玩的，从头来 cur.execute(\u0026#34;\u0026#34;\u0026#34; CREATE TABLE films ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, language TEXT, release_date TEXT, created_at TEXT ) \u0026#34;\u0026#34;\u0026#34;) films = [ (\u0026#34;肖申克的救赎\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;1994-09-23\u0026#34;), (\u0026#34;阿甘正传\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;1994-07-06\u0026#34;), (\u0026#34;霸王别姬\u0026#34;, \u0026#34;汉语\u0026#34;, \u0026#34;1993-01-01\u0026#34;), (\u0026#34;龙猫\u0026#34;, \u0026#34;日语\u0026#34;, \u0026#34;1988-04-16\u0026#34;), (\u0026#34;天空之城\u0026#34;, \u0026#34;日语\u0026#34;, \u0026#34;1986-08-02\u0026#34;), (\u0026#34;天堂电影院\u0026#34;, \u0026#34;意大利语\u0026#34;, \u0026#34;1988-11-17\u0026#34;), (\u0026#34;泰坦尼克号\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;1997-12-19\u0026#34;), (\u0026#34;楚门的世界\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;1998-06-05\u0026#34;), (\u0026#34;花样年华\u0026#34;, \u0026#34;汉语\u0026#34;, \u0026#34;2000-09-29\u0026#34;), (\u0026#34;千与千寻\u0026#34;, \u0026#34;日语\u0026#34;, \u0026#34;2001-07-20\u0026#34;), (\u0026#34;无间道\u0026#34;, \u0026#34;汉语\u0026#34;, \u0026#34;2002-12-12\u0026#34;), (\u0026#34;盗梦空间\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;2010-07-16\u0026#34;), (\u0026#34;让子弹飞\u0026#34;, \u0026#34;汉语\u0026#34;, \u0026#34;2010-12-16\u0026#34;), (\u0026#34;少年派的奇幻漂流\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;2012-11-21\u0026#34;), (\u0026#34;星际穿越\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;2014-11-07\u0026#34;), (\u0026#34;疯狂动物城\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;2016-03-04\u0026#34;), (\u0026#34;你的名字\u0026#34;, \u0026#34;日语\u0026#34;, \u0026#34;2016-08-26\u0026#34;), (\u0026#34;摔跤吧！爸爸\u0026#34;, \u0026#34;印地语\u0026#34;, \u0026#34;2016-12-23\u0026#34;), (\u0026#34;燃烧\u0026#34;, \u0026#34;韩语\u0026#34;, \u0026#34;2018-05-17\u0026#34;), (\u0026#34;寄生虫\u0026#34;, \u0026#34;韩语\u0026#34;, \u0026#34;2019-05-30\u0026#34;), (\u0026#34;\u0026#39; OR \u0026#39;1\u0026#39;=\u0026#39;1\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;2020-01-01\u0026#34;), # 疯狂导演的“怪片名” (\u0026#34;奥德赛\u0026#34;, \u0026#34;英语\u0026#34;, \u0026#34;2026-07-17\u0026#34;), (\u0026#34;牛来\u0026#34;, \u0026#34;汉语\u0026#34;, \u0026#34;2026-08-05\u0026#34;), (\u0026#34;欢迎来龙餐馆\u0026#34;, \u0026#34;汉语\u0026#34;, \u0026#34;2026-08-11\u0026#34;), ] for i, (title, language, release_date) in enumerate(films, 1): cur.execute( \u0026#34;INSERT INTO films (title, language, release_date, created_at) \u0026#34; \u0026#34;VALUES (?, ?, ?, datetime(\u0026#39;now\u0026#39;))\u0026#34;, # created_at 取此刻的时间 [title, language, release_date], ) print(f\u0026#34;已灌入 {i}/{len(films)}：{title}\u0026#34;) time.sleep(1) # 歇 1 秒再插下一部，好让每部的入库时间错开 conn.commit() print(\u0026#34;完成\u0026#34;) 这个脚本会先连接到我们家目录刚才建的那个test.db，然后把films表给删除掉(DROP TABLE)，然后重新建films表(CREATE TABLE)，然后把24部电影给灌进这个films表里。其中还混进了上文提到的那位疯狂导演的怪片名 ' OR '1'='1，我们等下也看一看它会不会“SQL注入”成功。\n脚本中一句 time.sleep(1)，这个意思是在轮番把电影灌入films表的过程中，每写入一条，都暂停1秒再写下一条，这是为了让每一条记录的created_at都不同，不然一次性灌进去的全部都是一样的created_at，那我们就玩不了按created_at排序的游戏了。\n跑一下：\npython3 seed_data.py 现在 test.db 的films表里躺着 24 部各不相同的电影，24 条 created_at 依次相差约 1 秒。\n然后我们开始测试，再次回到python的REPL模式，再次连接数据库，拿到cursor：\nimport sqlite3 conn = sqlite3.connect(\u0026#34;test.db\u0026#34;) cur = conn.cursor() 分别执行下面三句SQL语句：\nSELECT id, title, created_at FROM films ORDER BY created_at SELECT id, title, created_at FROM films ORDER BY created_at DESC SELECT id, title, created_at FROM films ORDER BY created_at DESC LIMIT 5 这三句分别是：\n按created_at字段排序 按created_at字段逆序排序 按created_at字段逆序排序之后取前5条 注意，执行的时候，一定要把SQL语句放入到python的cur.execute()的方法中：\ncur.execute(\u0026#34;SELECT id, title, created_at FROM films ORDER BY created_at\u0026#34;).fetchall() cur.execute(\u0026#34;SELECT id, title, created_at FROM films ORDER BY created_at DESC\u0026#34;).fetchall() cur.execute(\u0026#34;SELECT id, title, created_at FROM films ORDER BY created_at DESC LIMIT 5\u0026#34;).fetchall() 与文件版存储对比 回想一下我们的上一节，我们对项目的 history进行逆序排序取前10条是怎么做的：\n# 文件版（6.3，亲手写的） records = load_history() # 全量读进内存 records.reverse() # 自己倒序 return records[:10] # 自己切片 如果换成数据库版，上面三行python代码其实可以直接写成下面的一行SQL语句\n-- 数据库版（下一节就用它） SELECT * FROM history ORDER BY created_at DESC LIMIT 10 它们的主要区别不在行数，而在姿态——文件版是我们告诉程序怎么做（读、倒、切）；SQL 是我们只说要什么（按时间倒序的前 10 条），至于怎么扫、怎么排、怎么快，这些就给数据库自己安排。\n而且，这些只是我们看到的部分，其实，我们用了数据库之后，上一节我们遇到的那些问题，其实一个一个地都消解不见了。\n关于全量读取：无论是读还是写，即使是要全量排序，数据库都不会把全部数据加载到内存里，它的做法要聪明的多。 关于整个文件重写：它如果要写一行，那就只会写一行，不会整个表重写。 关于并发时的脏读和脏写：它用事务来管理和协调不同的任务，可以有效避免脏读脏写。 关于一坏全坏：它有自己的保护机制，比裸文件要皮实和健壮得多。 数据库是怎么做到的？ 对于爱学习的朋友，我知道现在你肯定不尽兴。你只知道上一节的四个问题数据库都解决了，但是你不知道它是怎么解决的，所以有点不甘心，好像没学透，对不对？\n但是，如果真的讲起来，这个东西又有点复杂。所以我们不贪多，只挑最有代表性、也最容易让人犯嘀咕的那一个问题，把它讲透：排序 + 取前几条——就是刚才那句 ORDER BY created_at DESC LIMIT 10。\n为什么挑它？因为它最反直觉。你可能会想：要\u0026quot;按时间排序\u0026quot;，数据库是不是得先把整张表几百万行全搬进内存，排好队，再切下前 10 条？真要这么干，表一大，内存不就爆了吗？\n但换成数据库，这事就是不会爆。而且这不是 SQLite 一家的独门绝技，MySQL、PostgreSQL 这些关系型数据库，路子都大同小异。数据库有三招解决这个问题，一招比一招聪明。\n第一招：只要 10 条，就只攥住 10 条。\n关键在 LIMIT 10 这几个字。它不是\u0026quot;排完之后随手切一刀\u0026quot;的备注，而是一开始就递给数据库的一句话——\u0026ldquo;我最多只要 10 条，多的别给我留着。\u0026rdquo;\n数据库收到这句话，就不会傻乎乎把全表搬进内存。打个比方，这像海选留前 10 名，根本不用把几万个报名的人同时请进会场：主办方手里只备 10 把椅子，选手一个一个上台——\n前 10 个上台的，先都坐下； 从第 11 个起，每来一条，就跟椅子上当前最旧的那条比：比它还旧，当场请走；比它新，就把最旧的那位换下去、自己坐上。 从头到尾，椅子永远只有 10 把，内存里也就始终只攥着这 10 条。全表是 100 条还是 1 亿条，椅子数纹丝不动。\n所以，数据库到最后确实**\u0026ldquo;挨个看过每一行\u0026rdquo;**，但它避免了\u0026quot;同时把每一行都放入内存\u0026quot;。而我们上一节那个文件版，是要先把全部数据都加载进内存，才挑出那 10 条。\n第二招：就算不写 LIMIT，也不硬塞内存。\n那要是我们不写 LIMIT，就是要求数据库把几百万行整个排好、一条不落全都要呢？那是不是就准备几百万个“椅子”，然后都灌进内存里？\n这时数据库还有后手，靠两件事顶住：\n按\u0026quot;页\u0026quot;读盘，不整包搬家。 数据库在硬盘上不是把数据堆成一坨，而是切成一块块固定大小的\u0026quot;页\u0026quot;，就好比一本活页夹，一页一页地装订。要用哪页就翻哪页进来，手上只留有限几页，看完就放回去。所以哪怕表有一千万行，读的过程占的内存也有个上限，跟表多大几乎没关系。 排不下，就摊到硬盘上排。 真要给全部排序、内存装不下时，数据库会把排到一半的中间结果先寄存到硬盘的临时文件里，分批理好再拼起来，而不是死往内存里塞。就像我们整理一大摞考卷，桌子小，就先在桌上分成几摞、理好的挪到旁边地上，而不是非把所有卷子同时铺满桌面。代价顶多是慢一点、多占点硬盘，而绝不会\u0026quot;内存爆掉、程序崩溃\u0026quot;。 第三招：干脆存的时候就排好，这就是\u0026quot;索引\u0026quot;。\n前两招都还是\u0026quot;查询的时候现排\u0026quot;。最狠的一招是：让排序这件事，根本不用等到查询时才做。\n靠的是索引（index）。\n索引有点像是汉语字典前面的检字表。字典有好几百页，如果想查某个字，我们不会把全书从头翻到尾，而是先到检字表上手——它早就按拼音（或部首）排好了序，一下就能找到对应的页码，直接翻过去。\n数据库的索引就是这么个东西：它在硬盘上，替我们把某一列的顺序维护好，放在一个地方，比如我们需要经常按\u0026quot;创建时间\u0026quot;取最新记录的话，就可以给\u0026quot;创建时间\u0026quot;这一列建个索引，数据库便在正表旁边多存一份**\u0026ldquo;按时间排好序的目录\u0026rdquo;**。\n有了这份目录，\u0026ldquo;取最近 10 条\u0026quot;就不用再临时排队了，而变成：翻到目录的末尾，倒着拿 10 条，再顺着找到对应的整行，这样就快得多了。\n索引的代价，其实就是除了存数据之外，还要额外地存一份索引，另外，每次存进来一条新数据的时候，都顺手维护一下这个索引，会稍微拖慢一丢丢的存储速度。但是也正是因为提前把累活给干了，真到了查数据的时候，就轻轻松松游刃有余。\n也正是因为索引有代价，所以我们的原则应该是只给那些经常拿来排序，或经常拿来筛选的列建索引，不要什么字段都去建。\n刚才提到的检字表、活页夹这些比喻，其实背后是一种数据结构，叫 B 树（B-Tree），关系型数据库的索引的底层基本上都是它。B 树有一个优点，就是插一条新数据的时候，它只在局部动一下，不用把整张表重写一遍。我不打算再往下深讲 B 树，但之所以把这个概念点出来，是希望大家能感受到数据结构的价值和意义。读计算机的小伙伴，如果以后想做大数据量的性能优化，数据结构还是要认真学一下。\n收个口。\n其实，知道了数据库的底层逻辑，我们操作文件的时候，也可以实现这些优化，我们也可以在读写文件的时候通过建立 B 树结构来控制内存，我们也可以用python实现索引，我们甚至也可以用python给文件加上事务，然后再做一个SQL的语法解析器\u0026hellip;\n如果我们真的这么做了，那我们就重新发明了数据库。\n但这丝毫没有必要，SQLite、MySQL、PostgreSQL、DuckDB、MariaDB等等数据库都是开源的，又不收费，如果我们真的有伟大的想法，不如直接去给这些开源项目贡献代码。\n数据库可视化工具 我们在上一节用文件做存储的时候，是可以用VS Code打开history.json直接查看其中的数据的。但是对于test.db就不行了。.db 是精心组织的二进制格式，不像 history.json 双击就能看。想看它里面的数据，就得用专门的工具。\n对于SQLite这种数据库，可以选择使用 DB Browser for SQLite，或者 DBeaver 的社区版，它们都是 开源免费 的数据库可视化工具，用任意一个就可以查看我们的test.db数据库了。\n比如说可以通过 https://sqlitebrowser.org 下载它的安装包，Windows、macOS和Linux都支持，装好后双击打开它，点击Open Database，然后选择 test.db，就可以在Database Structure下的tables里看到films这个表了。选择Browse data, 可以看到这个表中的全部数据——连那部片名叫 ' OR '1'='1 的怪片，也安安静静躺在表里。还记得我们担心它会不会\u0026quot;SQL 注入\u0026quot;成功吗？seed 脚本当初是走 ? 占位符把它存进去的，数据库自始至终把它当成一个普普通通的片名，一个字都没多解析，它自然也就没能兴风作浪。这就是\u0026quot;占位符防注入\u0026quot;最直观的一眼。\n除了 DB Browser for SQLite， 也可以用 DBeaver， DBeaver的操作稍微复杂一点点，但是DBeaver的优点是它可以支持多种不同的数据库，例如常见的MySQL、PostgreSQL都可以。\n很多人会觉得这两个工具难用，确实相比一些成熟的商业化软件，它们在UI和交互体验上稍微差一些。如果你不介意付费的话，也可以选择一些体验更棒的数据库可视化工具，比如说DataGrip或者Navicat\n结语 按照惯例，我们结束的时候需要总结一下这一节都讲了什么，但是对于数据库这个话题来说，我们讲的其实还很少，比起我们讲了什么，我更想聊一下我们没有讲什么。\n按照传统的计算机教学方案，数据库应该被单独列为一门大课，讲数据库建模、表关联、索引、字段类型、函数、存储过程、触发器、权限控制、数据库迁移、容灾、分布式存储、性能优化，等等。这些知识的学习需要花费比较长的时间，更重要的是这些需要在业务中使用，才可以体验到它们的价值。\n如果需要较为完整地学习这些知识，一般的课程会配套制作一个小型进销存管理软件，或者教务管理系统之类的信息管理系统。而我们的课程从设计的第一天起，就没有打算往这个方向走。如果你需要学习这方面的知识，还是需要去专门找一个关于数据库的“大课”。\n今天这一节如果要谈收获，我希望是能帮你理清两件事：为什么我们会需要数据库，以及数据库长什么样，这就够了。\n← 上一节：模块 6.3 数据库前传 | 下一节：模块 6.5 重构，在项目中使用 SQLite →\n","date":"2026.08.20","description":"从文件走向 SQLite，学习用 SQL 安全地保存、查询和修改数据。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-6-4/","title":"模块 6.4：数据库正传——SQLite 与 SQL"},{"content":" 项目到现在还没有\u0026quot;记忆\u0026quot;。这一节先把\u0026quot;为什么存、存哪儿、怎么存\u0026quot;想清楚，再从最朴素的文件做起，给文字实验室装上第一份记忆——顺便亲手撞上它的天花板，为下一节的数据库埋好动机。\n到现在为止，项目没有记忆 在文字实验室里分析一句，结果出来了；再分析下一句，前一条就没了。如果刷新页面，已经分析过的内容也没了；重启后端的话，查询记录更是被清除的干干净净。到今天为止，我们项目里的所有数据都是这个命运——用完即弃。\n没有记忆，会有什么影响？\n对用户来说，文字实验室这样的小工具，有没有记忆其实没太大所谓——查完一句、拿到结果就走，记不记得上次查了什么，多数时候并不重要。\n但对运营者来说，如果没有存储，那丢掉的东西可就多了：每天有多少次查询？大家一般都在查些什么？我们那个情感模型，放到真实句子上，到底准不准？——这些问题，全都要靠把数据存下来，才有进一步分析的可能。数据不是\u0026quot;存着好看\u0026quot;，它是运营者手里的一份家底。\n当然，存了用户的输入，也就多了一份责任——怎么保管、给谁看，这些也都是我们需要考虑的事情。\n让项目拥有记忆的能力——这件事就叫持久化。我们这一节就给我们的项目做一下持久化，但在写第一行代码之前，我们先花几分钟，把\u0026quot;存储\u0026quot;这件事本身想清楚。\n存储：一件很古老的事 说到\u0026quot;把数据存起来\u0026quot;，很多人立刻想到数据库。但我们需要把顺序摆正：是先有了存储的需求，才有的数据库，而不是反过来。\n存储这件事，古老得超乎想象。人类存储信息存了几千年——结绳、泥板、账本、档案柜。有一种很有意思的说法：人类最早的文字，很可能就是为了记账，记\u0026quot;谁欠了多少袋粮食\u0026quot;。也就是说，\u0026ldquo;把事记下来、以后能查\u0026quot;这件事，比文字本身还要古老。数据库只是这条几千年长河里最新、最强的一种形态而已，不是什么神秘的东西。\n而且，存和取，从来是一体两面、拆不开的。我们存东西，不是为了\u0026quot;存\u0026quot;这个动作本身，而是为了以后能取出来用——存了永远不取等于没存。正因如此，计算机时代对存储技术的研究，很大一部分精力其实花在了读取上：怎么把海量数据分门别类、怎么快速检索到想要的那一条，是判断一种存储技术是否成熟、可靠的重要考量。而怎么存，直接决定了以后好不好取。\n存在哪儿：四种常见的存储 真要把数据存下来，计算机里常见的地方有四种，各有各的适用场景：\n内存——CPU 干活的空间，飞快，但断电即失； 文件——硬盘上的一份份文件，最直接、谁都会用； 数据库——专为\u0026quot;存好、取快\u0026quot;而生，能筛选、能排序、能统计； 云 / 对象存储——专门存大文件、图片、视频那种。 这些技术\u0026quot;没有绝对的好，只有合不合适\u0026rdquo;。这一节，我们从最上边这两种入手——先看内存，再看文件。\n内存：快，但留不住 代码的运行过程本身就是一个读、写内存的过程——每个变量、每份 state，都活在内存里。我们可以用 Python 的 REPL 试一试：\n\u0026gt;\u0026gt;\u0026gt; brothers = [] \u0026gt;\u0026gt;\u0026gt; brothers.append(\u0026#34;刘备\u0026#34;) \u0026gt;\u0026gt;\u0026gt; brothers.append(\u0026#34;关羽\u0026#34;) \u0026gt;\u0026gt;\u0026gt; brothers.append(\u0026#34;张飞\u0026#34;) \u0026gt;\u0026gt;\u0026gt; brothers [\u0026#39;刘备\u0026#39;, \u0026#39;关羽\u0026#39;, \u0026#39;张飞\u0026#39;] 这个时候，内存里面就有了这三兄弟的名字，只要程序还在运行，这个列表就一直存在于内存里，随时可以取出来看。\n目前看着好好的。但如果现在退出 REPL，再重新进来：\n\u0026gt;\u0026gt;\u0026gt; exit() \u0026gt;\u0026gt;\u0026gt; brothers NameError: name \u0026#39;brothers\u0026#39; is not defined 退出之前我们召唤 brothers，计算机还会告诉我们 brothers 里面存了什么，但随着退出，这个值就不见了。因为它活在内存里，进程一停，内存就清空。内存快，但留不住——内存里存的东西一断电或者一重启就没了；\n何况内存容量也有限，本就不该拿来长期囤东西。\n再回头看我们的需求：用户的查询记录，我们既不想它丢，又要能长期留着。那内存就不合适。最直观、最不容易丢的存储是什么？——文件。\n文件是存储在计算机的硬盘里的，而硬盘的存储一般都是持久化的存储。而且硬盘的存储空间往往比内存的存储空间大得多。\n我们买电脑的时候往往会遇到两个表示存储空间的值，一个是内存，一个是存储。内存就是我们前面说的那个进程一停就会丢的空间，而存储就是指的硬盘，在硬盘里存储的东西即使电脑关机再重启，也都还是在的。\n所以我们如果要更长期的存储，更合适的方案应该是存储在文件里，也就是存储在电脑的硬盘里。\n存到文件：存什么，怎么存 假设我们要把所有用户在文字实验室查过的文字和结果，都存进一个文件。首先需要思考个事情：存什么内容、用什么格式存。\n存什么。 一条记录，至少得有分析结果的那四个字段，再加上\u0026quot;什么时候查的\u0026quot;，这个什么时候查的我们命名为 created_at：\ntext 原文 score 情感分数 label 结论 pinyin 拼音 created_at 时间 怎么存。 最朴素的想法是\u0026quot;按行写\u0026quot;——一条记录写一行纯文本。但很快就会犯难：一行里这么多字段，有时间，有情感分数，有结论等等。靠什么分开？用逗号？可原文里本来就可能有逗号。读回来的时候又怎么切准？\n这是用纯文本存储的一个示例：\n今天心情不错,0.88,偏积极,jīn tiān xīn qíng bù cuò,2026-07-04T07:30:00+00:00 我喜欢, 真的喜欢,0.96,偏积极,wǒ xǐ huān , zhēn de xǐ huān,2026-07-04T07:31:12+00:00 第二行就出事了：原文\u0026quot;我喜欢, 真的喜欢\u0026quot;里本来就带了个逗号，这一行就冒出了六段，程序数着逗号切字段，立刻错位——它分不清哪个逗号是\u0026quot;字段之间的\u0026quot;，哪个是\u0026quot;原文自带的\u0026quot;。\n更好的选择是 JSON。它能清清楚楚地区分每个字段、固定住一种结构，而且用 Python 读它、写它都很方便。所以我们可以选择用 JSON 格式存。\n[ { \u0026#34;text\u0026#34;: \u0026#34;我喜欢, 真的喜欢\u0026#34;, \u0026#34;score\u0026#34;: 0.96, \u0026#34;label\u0026#34;: \u0026#34;偏积极\u0026#34;, \u0026#34;pinyin\u0026#34;: \u0026#34;wǒ xǐ huān , zhēn de xǐ huān\u0026#34;, \u0026#34;created_at\u0026#34;: \u0026#34;2026-07-04T07:31:12+00:00\u0026#34; } ] 同样带逗号的原文，放进 JSON 就一点不含糊：每个字段都有自己的名字（text、score……），原文老老实实待在 text 的引号里，逗号是它内容的一部分，谁也不会跟谁打架。\n单独说说时间：藏着\u0026quot;时区\u0026quot;这个坑 created_at 这个时间戳，看着最简单，其实是编程里最容易踩的坑之一，坑就坑在时区。\n我们用 Python 代码很容易就可以获得时间，比如说随手写一个 datetime.now()，拿到的是\u0026quot;这台机器的本地时间\u0026quot;，而且不带任何时区标记。但运行这行代码的电脑或者服务器可能架在地球上的任何国家，用户也可能来自不同时区——同一个\u0026quot;下午三点\u0026quot;，到底是哪儿的三点？广州的下午三点和旧金山的下午三点肯定不是同一个时间，如果没标清楚，那么肯定会乱套。\n业界的通行规矩是：存的时候，一律用统一、无歧义的基准时间——UTC（协调世界时，全球统一的时间基准）；显示给用户时，再转成他所在的本地时间。比如说北京时间就是 UTC+8。\n也就是说，对于时间类型的值，无论是存起来的时候，还是取出来的时候，都是按照 UTC 时间来的。只有在展示给用户的时候，才会根据用户所在的时区进行转换显示。\n落到代码，就是给\u0026quot;现在\u0026quot;带上 UTC 时区（datetime 是 Python 标准库成员，不需要额外安装）：\n\u0026gt;\u0026gt;\u0026gt; from datetime import datetime, timezone \u0026gt;\u0026gt;\u0026gt; datetime.now(timezone.utc).isoformat(timespec=\u0026#34;seconds\u0026#34;) \u0026#39;2026-07-04T07:30:00+00:00\u0026#39; 末尾那个 +00:00，就是它在明明白白地说\u0026quot;我是 UTC 基准的时间\u0026quot;。带上它，这个时间戳走到世界上任何角落，都能被准确换算成当地时间，再无歧义。\n写与读：存档，和读历史的接口 回到 backend/main.py。顶部补两个标准库 import：\nimport json from datetime import datetime, timezone 先解决\u0026quot;写\u0026quot;。 加两个跟文件打交道的函数，一个读档、一个存档：\nHISTORY_FILE = \u0026#34;history.json\u0026#34; def load_history(): try: with open(HISTORY_FILE, \u0026#34;r\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as f: return json.load(f) except FileNotFoundError: return [] def save_record(record): records = load_history() records.append(record) with open(HISTORY_FILE, \u0026#34;w\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as f: json.dump(records, f, ensure_ascii=False, indent=2) 两张新面孔，认脸即可：\nwith open(...) as f:——打开一个文件来读或写；with 表示\u0026quot;用完自动关好\u0026quot;，是 Python 操作文件的固定搭配，照样写就行； try / except FileNotFoundError:——\u0026quot;试着做，若撞上某种错误，就改走另一条路\u0026quot;。这里：试着读文件；要是撞上\u0026quot;文件还不存在\u0026quot;（第一次运行本来就没有），就当作空列表。5.4 学过读报错，try/except 就是在代码里提前接住报错。 存档逻辑很直白：读出全部 → 追加一条 → 整个写回（记住\u0026quot;整个写回\u0026quot;，等会儿还会说）。ensure_ascii=False、indent=2 是为了让文件人类可读——中文原样、带缩进，一会儿要亲眼看它。\n再让 analyze 每次分析完顺手存档，并给记录补上 UTC 时间戳：\n@app.post(\u0026#34;/api/analyze\u0026#34;) def analyze(req: AnalyzeRequest): text = req.text score = round(SnowNLP(text).sentiments, 2) result = { \u0026#34;text\u0026#34;: text, \u0026#34;score\u0026#34;: score, \u0026#34;label\u0026#34;: score_label(score), \u0026#34;pinyin\u0026#34;: \u0026#34; \u0026#34;.join(lazy_pinyin(text, style=Style.TONE)), \u0026#34;created_at\u0026#34;: datetime.now(timezone.utc).isoformat(timespec=\u0026#34;seconds\u0026#34;), # ← 新增 } save_record(result) # ← 存档到文件 return result 等一下——返回里多了一个 created_at，上一节不是刚说\u0026quot;约定不能动\u0026quot;吗？这里补一条约定的演化规则：往返回里加字段，不破坏约定（老调用方不认识它、当它不存在就好）；改名和删除才是破坏。所以\u0026quot;加\u0026quot;是安全的演化，前端照旧零改动。\n再解决\u0026quot;读\u0026quot;。 开一个只读接口 /api/history。先用最直白的写法——文件里存了什么，就原样返回：\n@app.get(\u0026#34;/api/history\u0026#34;) def history(): records = load_history() # 读出文件里的全部记录 return records 这个时候可以启动我们的后端服务，并且测试一下这个新的 API 接口。\ncd ~/zero-to-tech/backend source .venv/bin/activate fastapi dev curl 一下（开发模式自动重启，不用管）：\ncurl http://localhost:8000/api/history 第一次是 []——正常，这是因为服务起来之后，还没存过档，所以现在文件是空的。我们可以去文字实验室分析两三句，再 curl，记录就出来了。\n但这时你还会发现有两处不称手：\n顺序反了。 文件是一条条往后追加的，老的排前、新的排后；可我们翻历史，总想先看最近的。 一次全给。 现在几条无所谓，可攒到几百条，一次全返回又多又慢——我们通常只想要最近几条。 这两个问题，可以通过在代码中补两行代码来解决：\n@app.get(\u0026#34;/api/history\u0026#34;) def history(): records = load_history() records.reverse() # 倒过来：新的排前面 return records[:10] # 切一刀：只留最近 10 条 reverse() 把列表就地倒过来，records[:10] 是 Python 的切片，取前 10 个。再 curl——新的在前，最多 10 条。\n这三行（全量读 → 倒序 → 切片），请亲手敲、记在心里——我们迟点还会继续讨论它。\n注意：现在它返回的是全站所有人的记录，混在一起。\u0026ldquo;怎么让每个访客只看自己的、还安全地显示在页面上\u0026rdquo;，是专门的话题，放到 6.6 讲。这一节先把\u0026quot;存下来、读得出\u0026quot;跑通。\n见证持久化，再看清读写时发生了什么 先见证成果。用 VS Code 打开 backend/history.json：\n[ { \u0026#34;text\u0026#34;: \u0026#34;今天心情不错\u0026#34;, \u0026#34;score\u0026#34;: 0.88, \u0026#34;label\u0026#34;: \u0026#34;偏积极\u0026#34;, \u0026#34;pinyin\u0026#34;: \u0026#34;jīn tiān xīn qíng bù cuò\u0026#34;, \u0026#34;created_at\u0026#34;: \u0026#34;2026-07-04T07:51:03+00:00\u0026#34; }, ... ] 我们的每一次分析，白纸黑字躺在这里——中文原样、格式工整（刚才那两个参数就是为了这一刻）。这就是\u0026quot;数据落盘\u0026quot;。\n再做一个\u0026quot;危险\u0026quot;动作：把后端 Ctrl + C 杀掉、重启，再 curl 一次——会发现记录一条都没少。和几分钟前 REPL 里那个变量对照一下：用 REPL 体现的时候，内存中的变量会在程序停止的时候被清空，但文件中的数据却纹丝不动。这就是内存和硬盘最直观的一次对撞——从今天起，我们的项目第一次拥有了活得比进程久的数据。这就是持久化（persistence）。\n功能是成了。但趁热，我们把刚才\u0026quot;写\u0026quot;和\u0026quot;读\u0026quot;这两件事掰开看一眼——有些问题现在不显眼，但数据量一大就要命。\n先看\u0026quot;写\u0026quot;一条数据时发生了什么。 save_record 干了三步：读出整个文件 → 内存里加一条 → 整个写回。\n于是第一个隐患冒出来了：存 1 条，要把整个文件重写一遍。 现在几条无所谓；等有一万条，每存一条都要重写一万条。 更麻烦的是\u0026quot;同时\u0026quot;。5.3 讲访问日志时说过，服务端是同时接待很多来访者的。要是两个请求几乎同时来存——两个都\u0026quot;读出全部\u0026quot;、各自加自己那条、再各自\u0026quot;整个写回\u0026quot;，后写的那个会把先写的盖掉，凭空丢一条。这类\u0026quot;你写你的、我写我的，结果互相覆盖、丢了更新\u0026quot;的问题，业界有个名字，叫脏写。 还有更糟的：万一写到一半——json.dump 还没写完——进程崩了或断电，这个文件就残缺了，一个括号对不上，整份记录都读不出来。一坏，全坏。 再看\u0026quot;读\u0026quot;一次时发生了什么。 /api/history 干了三步：读出整个文件 → 倒序 → 切前 10 条。\n又一个隐患：只想要最近 10 条，却把全部读进了内存。 一万条也照样全搬一遍，就为拿最后那 10 条。 同样怕\u0026quot;同时\u0026quot;：要是读的时候，正好有人在写（文件才写了一半），读的人就可能读到一份半新半旧、甚至残缺的数据。这类\u0026quot;读到了别人还没弄完的中间状态\u0026quot;的问题，业界叫脏读。 数一下，我们用一个文件、亲手写的这套方案，其实留下了四处隐患：\n读十条，搬空全部（效率）； 存一条，重写整份（效率）； 多人同时读写就出乱子——写盖写叫脏写、读到写一半叫脏读（并发）； 写到一半崩了，一坏全坏（健壮）。 要说清楚：这四处，没有一个是因为我们代码写得烂，这其实是 \u0026ldquo;拿一个文件存不断增长的结构化数据\u0026quot;这条路本身的天花板。文件天生是给人\u0026quot;整存整取\u0026quot;的，不是给\u0026quot;随时增查改删、还要多人同时读写\u0026quot;准备的。\n而这些问题——尤其\u0026quot;多人同时读写别出乱子\u0026rdquo;——有一套专门的机制来收拾，业界叫事务（transaction）；提供事务的那类软件，正是数据库。下一节，我们请它登场。\n悬念：这历史是\u0026quot;谁的\u0026quot;？ 刚才 curl 出来的历史，是全站一份——所有访客的分析混在一起。真到上线，这里也藏着两个还没解决的问题：\n每个访客应该只看到自己的。 要把历史\u0026quot;分到每个人名下\u0026quot;，服务器得先能认出\u0026quot;这是同一个浏览器\u0026quot;——这套机制叫会话（session），是 6.6 的主角。 不能把大家的输入公开列在页面上。 一个公开展示用户生成内容（UGC）的页面，上线要过备案和内容审核，这一关通常很难过（模块 7 细说）。所以在能\u0026quot;按访客过滤\u0026quot;之前，页面上我们先不摆历史列表——等 6.6 有了会话，页面才安全地只显示\u0026quot;访客自己的\u0026quot;。 再往上一层——\u0026quot;登录、凭密码证明\u0026rsquo;我就是我\u0026rsquo;\u0026quot;——那是认证，一门自成体系、又安全敏感的大课（半吊子的认证，比不做更危险）。会话是它的地基；我们先在 6.6 把地基打好，认证留作更后面的专门话题（甚至这个话题太大了，我们都没办法放进\u0026quot;零到全栈\u0026quot;这个系列课程）。\n一句话记住：存，是这一节的事；认得出每个访客、只给他看自己的，是 6.6「会话」的事；证明\u0026quot;我就是我\u0026quot;，是更后面「认证」的事。\n这一节应该带走什么 顺序要摆正：先有存储的需求（古老到\u0026quot;最早的文字可能就是为了记账\u0026quot;），才有数据库——它只是这条长河里最新的形态，不神秘。 存储的价值，运营者视角最实在：用户未必在意历史，但\u0026quot;多少人查、都查什么、模型准不准\u0026quot;，全靠把数据存下来才答得出。 存取一体两面：存是为了取，而怎么存决定了好不好取——结尾那四处痛，全是\u0026quot;用文件存\u0026quot;埋下的。 四种存储（内存 / 文件 / 数据库 / 云）各有场景；内存快但留不住（REPL 里一 exit 就没），要\u0026quot;不丢 + 长期\u0026quot;，先落到文件；数据落盘、活过重启，这就是持久化。 存什么、怎么存：一条记录五个字段；格式选 JSON（能分字段、定结构、好读写）。 时间存 UTC：datetime.now(timezone.utc)，末尾带 +00:00；存无歧义的基准、显示时再转本地。 两张新面孔：with open(...)（用完自动关）、try/except（提前接住报错）；约定的演化规则——加字段安全，改名 / 删除才破坏。 文件方案的四处痛：搬空全部、重写整份、并发出乱子（脏写 / 脏读）、一坏全坏——不是代码烂，是\u0026quot;用文件存增长的结构化数据\u0026quot;的天花板；专治它们的机制叫事务，在数据库里。 历史现在是全站一份：分到每个访客要会话（6.6）、页面安全展示也等 6.6、\u0026ldquo;证明我是我\u0026quot;的登录 / 认证是更后面的专门话题。 下一节，数据库正传。\n← 上一节：模块 6.2 让网页真的会分析文字 | 下一节：模块 6.4 数据库正传 →\n","date":"2026.08.06","description":"先用文件保存数据，再亲眼看见它为什么会逐渐撑不住。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-6-3/","title":"模块 6.3：数据库前传——数据都存在哪儿？"},{"content":" 在安装了两个库之后，接下来我们把它们应用起来，通过 /api/analyze 对外提供服务——只改接口的“内部”，不动接口的“约定”。\n今天只动一处 上一节我们安装了 pypinyin 和 snownlp，并且也在 REPL 模式下验证了这两个库可用。今天把它们装进项目——通过 /api/analyze 对外提供服务。\n这一节只需要动 analyze 函数的内部。 API 的访问地址不动、方法不动、请求体不动、返回的字段同样也一个不加一个不减。\n看一下现状。此前做好的接口形状长这样——分析值全是写死的占位：\n@app.post(\u0026#34;/api/analyze\u0026#34;) def analyze(req: AnalyzeRequest): return { \u0026#34;text\u0026#34;: req.text, \u0026#34;score\u0026#34;: 0.5, \u0026#34;label\u0026#34;: \u0026#34;偏平静\u0026#34;, \u0026#34;pinyin\u0026#34;: \u0026#34;（模块 6 再说）\u0026#34;, } 分数永远 0.5，标签永远“偏平静”，拼音干脆写着“模块 6 再说”。而现在，就已经是模块 6 了，所以今天我们就把这几个值都搞活。\n动手换芯 打开 backend/main.py。先在文件顶部把两位新成员请进来（import 照例放顶部）：\nfrom pypinyin import lazy_pinyin, Style from snownlp import SnowNLP lazy_pinyin 是上一节我们体验过的；Style 是它的搭档——用它让拼音带上声调。\n再看占位版返回的四个字段：text、score、label、pinyin。\ntext 本来就是真的（原样回传用户输入），剩下三个是假的。我们先挑两个能直接从库里拿到的变量来下手——score 和 pinyin。\n把 /api/analyze 这个 API 的代码改为如下这样：\n@app.post(\u0026#34;/api/analyze\u0026#34;) def analyze(req: AnalyzeRequest): text = req.text score = round(SnowNLP(text).sentiments, 2) # 真模型打的分 return { \u0026#34;text\u0026#34;: text, \u0026#34;score\u0026#34;: score, \u0026#34;label\u0026#34;: \u0026#34;偏平静\u0026#34;, # ← 先留着，下面处理 \u0026#34;pinyin\u0026#34;: \u0026#34; \u0026#34;.join(lazy_pinyin(text, style=Style.TONE)), # 真拼音，带声调 } 主要的变化有两处，一处是用 SnowNLP 计算来一个 score，把这个 score 的值给到了我们 return 的 score；另一处是用 lazy_pinyin 这个函数计算了一些拼音，把它给到了我们 return 的 pinyin。具体语法不理解也没事，我们知道发生了什么就行。\n现在只剩 label 还占着位。它和前两个不一样——目前这两个库里并没有一个函数直接能够吐出“偏积极”或者“偏平静”这三个字。label 是给人看的结论，得由我们从 score 这个数字换算出来：接近 1 说“偏积极”，接近 0 说“偏消极”，中间地带算“中性”。\n这段“数字 → 结论”的翻译逻辑，值得单独拎成一个小函数，放在 analyze 上面（elif 就是“else if”，我们在 5.3 手搓路由时见过这种连排判断）：\ndef score_label(score): if score \u0026gt;= 0.6: return \u0026#34;偏积极\u0026#34; elif score \u0026lt;= 0.4: return \u0026#34;偏消极\u0026#34; else: return \u0026#34;中性\u0026#34; 这两个阈值（0.6 / 0.4）不是什么标准答案，是拿一批句子实测分数之后拍板的产品决定——分数是模型给的，但“多少分算积极”由我们说了算，也可以调成别的。\n有了它，把 analyze 里那行占位的 \u0026quot;label\u0026quot;: \u0026quot;偏平静\u0026quot; 换成 \u0026quot;label\u0026quot;: score_label(score)，这个 API 就已经完全写好了：\n@app.post(\u0026#34;/api/analyze\u0026#34;) def analyze(req: AnalyzeRequest): text = req.text score = round(SnowNLP(text).sentiments, 2) return { \u0026#34;text\u0026#34;: text, \u0026#34;score\u0026#34;: score, \u0026#34;label\u0026#34;: score_label(score), \u0026#34;pinyin\u0026#34;: \u0026#34; \u0026#34;.join(lazy_pinyin(text, style=Style.TONE)), } 其实这样来看，代码也没有多几行，但是能力已经强了许多——它真的可以做计算了。\n见证效果 改完代码之后，接下来我们仍然分别启动前后端服务。\n用开发模式启动前端程序：\ncd ~/zero-to-tech npm run dev 用开发模式启动后端程序：\ncd ~/zero-to-tech/backend source .venv/bin/activate fastapi dev 直接访问 http://localhost:3000，进入文字实验室。\n在输入框里打一句话，点“开始分析”：\n拼音真的出来了——带着声调，多音字也对；（顺带一提：句子里的逗号会原样留在拼音里，因为 lazy_pinyin 只翻译汉字，非汉字原样透传，这是正常的。） 分数是模型打的——试试这几句（实测过的分数，跑出来应该一致）： 输入 score label 我特别喜欢这部电影 0.95 偏积极 今天的风很轻，适合把想法写下来 0.95 偏积极 太失望了，再也不来了 0.00 偏消极 此前欠下的账，全部都清了。\n为什么前端不用改？ 大家有没有注意到一件事，就是这一节，我们的前端一点都没有改。为什么呢？\n这是因为虽然接口的实现变了，但是接口的约定没变：路径还是 /api/analyze，方法还是 POST，请求体还是 {\u0026quot;text\u0026quot;: ...}，返回还是那四个字段。变的只是约定背后的具体实现方案。\n我们前面讲 API 的时候说过，“API 的调用方不需要知道服务端内部怎么实现”；今天我们站在服务方这一侧，可以体会到这句话的另一面：\n只要守住约定，内部随便换。 调用方不知道、也不需要知道。\n顺手看一眼 http://localhost:8000/docs，我们可以发现文档也纹丝没动，因为约定没变。\n我们哪怕用 Java 把后端重写一遍，或者我们换一个新的模型来计算情感，只要这个 API 的约定不变，前端都不用改。\n模型的边界 接下来我们再多试试这个情感模型。试试这两句：\n输入 score label 问题 今天下午三点开会 0.26 偏消极 一句毫无感情的话，被判了“偏消极” 呵呵，真是太棒了呢 0.94 偏积极 阴阳怪气，它当了真 翻车了。为什么？\n因为 snownlp 的情感模型，主要是在商品评论语料上训练出来的——它擅长判断“像评论的句子”（好评差评那种），但“下午三点开会”这种中性陈述、以及反讽阴阳怪气，都在它的训练经验之外。\n这不代表 snownlp 太差，而是所有模型的共性：\n模型没有“常识”，只有“训练时见过的世界”。 用任何模型之前，先弄清它的边界——知道它哪里不准，比迷信它的分数重要得多。\n这句话在大模型时代照样成立——ChatGPT、DeepSeek 也有各自的边界（幻觉就是一种），只是边界更远、更隐蔽。\n放眼看看：从小模型到大模型 说到大模型——也许我们已经想到了：情感分析这件事，今天完全可以调用 LLM 的 API 来做（就像 5.1 调用 DeepSeek 那样，把句子发过去，让它打分）。那为什么我们不用？\n把本地小模型和云端大模型 API 这两条路摆在一起：\n本地小模型（snownlp） 大模型 API（如 DeepSeek） 花钱 免费 按量计费 联网 不需要 必须 速度 本地毫秒级 网络往返＋推理，秒级 准确度 够用，边界明显 强得多，连反讽都懂 隐私 数据不出自己的机器 用户的文本要发给第三方 再往远看一步：大模型也不只有“调云端 API”这一条路。像 DeepSeek 这类开源大模型，权重是公开的，可以下载到自己的机器上跑——业内叫“本地部署”或“私有化部署”：数据不出门、也不按次付费，代价是得自己备一台够劲的机器，还得自己维护。\n而且这类开源模型通常有好几种尺寸：参数量常写成 1.5B、7B、70B、671B 这样（B = 十亿）——尺寸越大越聪明，但也越吃显卡、越慢，具体部署哪一种 size 的模型，得根据自己手上的机器的配置来挑。\n没有绝对的好，只有合不合适。我们的文字实验室是教学项目，免费、快、离线的本地库完全够用。从 snownlp 这样的小模型，到自己部署的开源大模型，再到云端顶配 API，是一条连续的谱系，真做产品时按预算、隐私、精度选其中一段；而不论选哪段，对我们这个项目来说都只是“再换一次芯”的事——壳，还是不用动。\n我们的这个零到全栈的系列课程不会在模型这条路上继续走下去了，但是如果我们不满足 snownlp 的能力，想要把后端改为大语言模型，完全可以基于学到的知识继续改。\n结语 这一节比较轻松，按照这个系列课程原本的设计思路，到这一步，我们算是把这个系列课程的开发部分搞定了，接下来就要讲部署、安全、运营了。\n但是考虑到评论区中关于数据库的讨论比较多，所以我们这个模块的后续几节要开始讲一些数据库的入门知识了。依赖数据库的能力，我们的文字实验室还将会拥有记忆能力，这样，用户查过的句子就不至于用完即弃了。\n下一节，给它记忆。\n← 上一节：模块 6.1 第三方库和 PyPI | 下一节：模块 6.3 数据库前传 →\n","date":"2026.08.05","description":"把第三方库接进网站，让网页真正完成拼音与情感分析。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-6-2/","title":"模块 6.2：让网页真的会分析文字"},{"content":" 并非所有的功能都要从 0 开发，学习使用第三方库，拒绝重新造轮子。\n先接一下模块 5 的账 模块 5 收官时，我们留了两笔账：/api/analyze 的分析是假的，每次分析完就丢了。这个模块把它们还清。这一节，先对付第一笔：让分析变成真的。\n那问题来了：“真的”，怎么算？\n先说拼音。 要给任意一段中文标注拼音，就得有一张覆盖几万个汉字的读音表；这还不够——中文有多音字：“重庆”的“重”和“重要”的“重”不是一个音，“行长”“银行”“行走”里的“行”能读出花来。还得整理出海量的词组规则，才能判断在哪个词里读哪个音。\n再说情感分数。 凭什么说这句话 “0.9 分，偏积极”，而另一句话 “0.5分，偏中性”？这个分不是查表查出来的，得有一个从大量真实文本里学出来的模型来判断。训练模型，得先有语料、有方法、有时间。\n我们掂量一下：这两样，我们自己可以做吗？不是不能做，只是会很花时间。\n所以面对这种需求，正确的第一反应不该是“我怎么把它写出来”。程序员圈有个特别形象的说法——“重新造轮子”（reinvent the wheel）：轮子早被造出来了，非要从头再搓一个，费时费力，还多半造得更糙。\n如果有成熟的现成品，那就别自己重复造。\n所以第一反应应该是先问一句：这件事，社区里是不是早有人写好了？ 这一节，我们就把“找现成的库”这套动作，完整走一遍。\n去哪找：PyPI，和几种查法 “别人写好的库”放在哪？这个问题我们在前端见过答案：npm 的包都放在 npm registry 上，npm install animejs 就是从那儿下载的（4.2）。\nPython 世界的同款叫 PyPI（Python Package Index），网址 pypi.org——这里有几十万个包，人人可取。\n怎么从里面找到想要的那一个？方式不止一种：\n直接上 pypi.org 搜关键词，看简介、安装命令、文档链接、版本历史； 用搜索引擎搜“python 中文 拼音 库”这类； 或者干脆问 AI：把需求描述给它，让它推荐几个候选。 三条路都能给出“候选”，但谁都不保证候选靠谱——尤其是 AI。一是它的知识有截止时间，可能推荐过时的方案；二是它偶尔会一本正经地编出一个不存在的包名（这叫“幻觉”），甚至有坏人专门抢注这类名字、放进恶意代码。再加上 PyPI 上谁都能上传——这是生态繁荣的原因，也意味着上面质量参差。\n所以立一条规矩：不管候选是搜出来的还是 AI 给的，拿到手都得自己验一遍（下一步就验）。回想 5.1 那句话——“照着别人的文档，用上别人的能力”，前提是先找到靠谱的“别人”。\n用这套方法，锁定 pypinyin 和 snownlp 回到我们的两个需求，就用上面的方式查一查。\n搜“中文 拼音”，或者问 AI“Python 里给中文标拼音、还能处理多音字的库有哪些”——线索都指向同一个名字：pypinyin。再搜“中文 情感分析”，答案则落在 snownlp 上。\n候选有了。但先别急着 pip install——按刚立的规矩，先验。\n验库：靠谱吗？能不能满足需求？ 验两层。\n第一层，靠不靠谱。 打开候选在 PyPI 的页面（一般带 GitHub 仓库链接），看两个快信号：\nGitHub 星数——多少人给它点过赞，是人气和信任最直观的参考； 最近更新时间——还活着吗？持续发版说明有人在认真维护；几年没动的库要谨慎。 第二层，能不能满足需求。 人气高不等于合用，还得翻开文档，拿它和我们的需求逐条对照：\n拼音：要能带声调，还要认多音字（“重庆”和“重要”的“重”读不同音）； 情感：要能给出一个 0 到 1 的分数，好换算成“偏积极 / 偏消极”。 读 pypinyin、snownlp 的文档，看它们提供的函数是不是正好覆盖这些——覆盖得上，才算选对了。\n看那这两个库的文档，可以去它们各自的 GitHub 项目主页：\npypinyin：https://github.com/mozillazg/python-pinyin snownlp：https://github.com/isnowfy/snownlp 打开仓库页，首屏那篇长长的说明就是 README——它相当于项目的门面、主页，库怎么装、提供哪些函数、每个函数怎么用，通常都写在这儿。（PyPI 的包页面上一般也有指向 GitHub 的链接，顺着点过去就到。）\n读文档时还会注意到一个细节：它们的用法示例，经常会看到以 \u0026gt;\u0026gt;\u0026gt; 开头的 python 代码。\n比如说 pypinyin 的 GitHub README 文档中的写法：\n\u0026gt;\u0026gt;\u0026gt; from pypinyin import pinyin, lazy_pinyin, Style \u0026gt;\u0026gt;\u0026gt; pinyin(\u0026#39;中心\u0026#39;) # or pinyin([\u0026#39;中心\u0026#39;])，参数值为列表时表示输入的是已分词后的数据 [[\u0026#39;zhōng\u0026#39;], [\u0026#39;xīn\u0026#39;]] \u0026gt;\u0026gt;\u0026gt; pinyin(\u0026#39;中心\u0026#39;, heteronym=True) # 启用多音字模式 [[\u0026#39;zhōng\u0026#39;, \u0026#39;zhòng\u0026#39;], [\u0026#39;xīn\u0026#39;]] 这个 \u0026gt;\u0026gt;\u0026gt; 是什么？这个问题我们等下回答，现在我们先尝试在项目中安装这两个库。\n装上、试运行 —— 顺便认识 REPL 安装之前，先看提示符，确认我们的 venv 环境正确：\ncd ~/zero-to-tech/backend source .venv/bin/activate # 确认提示符前有 (zero-to-tech) pip install pypinyin snownlp snownlp 背后，是别人已经训练好的模型——模型不是代码，而是别人做的训练结果，我们 import 一下就能直接用。如果对于“模型”、“训练”这些词感觉很陌生的朋友，你先不要担心，我们今天毕竟是用轮子，如果你不知道轮胎的橡胶是怎么加工的，其实也没关系。\n装好之后，我们就要跑一下试试，可以写一个 python 脚本，比如说写个 pinyin_test.py 文件再运行。但是这种情况下有一种更便捷的方式值得我们学习一下——刚才文档里满屏的那个 \u0026gt;\u0026gt;\u0026gt;，是 Python 的 REPL（交互式解释器）：敲一行代码，立刻执行、立刻出结果。终端里直接敲 python 命令即可进入 REPL（注意在 (zero-to-tech) 环境里敲——刚装的库在这个环境）：\npython3 回车，提示符变成 \u0026gt;\u0026gt;\u0026gt;，就进来了。名字不用记，体感记住就行：敲一行看一行。\n先别急着 import 库，用几行最简单的代码，先感受一下 REPL 和“写脚本”有什么不一样：\n\u0026gt;\u0026gt;\u0026gt; 1 + 1 2 \u0026gt;\u0026gt;\u0026gt; name = \u0026#34;全栈\u0026#34; \u0026gt;\u0026gt;\u0026gt; name \u0026#39;全栈\u0026#39; \u0026gt;\u0026gt;\u0026gt; print(\u0026#34;你好，\u0026#34; + name) 你好，全栈 注意第一行：敲 1 + 1 回车，它直接把 2 显示了出来——我们并没有写 print。这就是 REPL 和脚本最直观的区别。回想 5.3 手搓的 handmade.py：那是把一整套逻辑写进一个文件，python3 handmade.py 从头到尾一次跑完，屏幕上只会出现我们显式 print 的内容。如果上面这几行要是写进 .py 文件去跑，1 + 1、name 这两行什么都不会显示；想看到 2，得写成 print(1 + 1)。而在 REPL 里，敲进去的只要是个“值”，它就顺手把结果回显出来。\n一句话理清两者的关系：都是同一个 Python——.py 脚本是“把动作写全、一次跑完”，适合正式的程序；REPL 是“敲一句、答一句”，适合把玩、试错、快速验证一个想法或一个新库。今天验库，正是 REPL 的主场。\n好，手感有了。先验 pypinyin，正好对着刚才那两条需求：\n\u0026gt;\u0026gt;\u0026gt; from pypinyin import pinyin \u0026gt;\u0026gt;\u0026gt; pinyin(\u0026#34;你好\u0026#34;) [[\u0026#39;nǐ\u0026#39;], [\u0026#39;hǎo\u0026#39;]] \u0026gt;\u0026gt;\u0026gt; pinyin(\u0026#34;重庆\u0026#34;) [[\u0026#39;chóng\u0026#39;], [\u0026#39;qìng\u0026#39;]] \u0026gt;\u0026gt;\u0026gt; pinyin(\u0026#34;重要\u0026#34;) [[\u0026#39;zhòng\u0026#39;], [\u0026#39;yào\u0026#39;]] 注意“重庆”和“重要”——同一个“重”字，在“重庆”里读 chóng、在“重要”里读 zhòng，pypinyin 都判对了。 它不光认多音字，还能看词定音。文档承诺的、我们需求要的，对上了。这背后就是那张几万字的读音表加词组规则，别人替我们整理好了，import 一下就能用。\n顺带看清 pinyin 返回的形状：[['chóng'], ['qìng']]——一个 “嵌套列表”。这样读起来不太直观。我们的项目只想要一串干净的拼音，用不上这层嵌套。pypinyin 这个库也有对应的方案，另给了一个更省事的函数 lazy_pinyin：\n\u0026gt;\u0026gt;\u0026gt; from pypinyin import lazy_pinyin \u0026gt;\u0026gt;\u0026gt; lazy_pinyin(\u0026#34;重庆\u0026#34;) [\u0026#39;chong\u0026#39;, \u0026#39;qing\u0026#39;] lazy_pinyin 的区别在于：① 每个字只给一个音、不再套那层列表，直接是扁平的字符串列表；② 默认不带声调（chong 而非 chóng）。\n如果我们还需要声调，可以给 lazy_pinyin 加上 style=Style.TONE，声调就回来了：\n\u0026gt;\u0026gt;\u0026gt; from pypinyin import lazy_pinyin, Style \u0026gt;\u0026gt;\u0026gt; lazy_pinyin(\u0026#34;重庆\u0026#34;, style=Style.TONE) [\u0026#39;chóng\u0026#39;, \u0026#39;qìng\u0026#39;] Style 是 pypinyin 提供的一组“拼音样式”开关，Style.TONE 就是“带声调符号”这一档。\n记住 lazy_pinyin(text, style=Style.TONE) 这个写法，下一节直接用它。\n再验 snownlp：\n\u0026gt;\u0026gt;\u0026gt; from snownlp import SnowNLP \u0026gt;\u0026gt;\u0026gt; SnowNLP(\u0026#34;今天的风很轻，适合把想法写下来\u0026#34;).sentiments 0.9465... \u0026gt;\u0026gt;\u0026gt; SnowNLP(\u0026#34;太失望了，再也不来了\u0026#34;).sentiments 0.0027... sentiments 给出一个 0 到 1 之间的分数：越接近 1 越积极。第一句 0.94，第二句 0.003——这不是查表，是包里那个别人训练好的模型在“判断”。跑出来的小数位可能略有出入，这也是正常的。\n两条需求都验证通过，就可以退出 REPL：\n\u0026gt;\u0026gt;\u0026gt; exit() 以后拿到任何新库，都可以尝试进 REPL 玩两下——敲一行看一行，比闷头读半天文档更快建立手感。（REPL 平时还能当计算器、当小试验田，随开随用。）\n顺带认识这片生态：中文 NLP pypinyin 和 snownlp，都属于同一片生态——中文自然语言处理（NLP，让程序“处理人话”的那一类技术）。这片地界上还有一张常见的库，值得认一下：\njieba（结巴分词）：把一句话切成一个个词——“我来到北京清华大学”切成“我 / 来到 / 北京 / 清华大学”。分词是很多中文处理的第一步（搜索、统计词频、做词云……都先得切词）。我们的项目用不上，认得名字就行，不安装。 不过值得我们了解的是：每个领域都有自己的一片生态——图像处理、爬虫、数据分析、AI……套路全是今天这一套：找库 → 验库 → REPL 玩两下 → 接进项目。这一节学的不只是这两个库，而是这个套路。\n记上账：requirements.txt 现在两个重要的库安装好了，最后别忘了那两条老规矩（都是 5.2 立的）：.venv 不进 Git，但是 requirements 清单要进。库变多了，我们要重新在 requirements 中记账：\npip freeze \u0026gt; requirements.txt 这样就把 pypinyin、snownlp 这两个依赖都记进来了，包括它们的依赖。\n这份 requirements 清单的意义是任何人拿到这个项目，只需要——\npip install -r requirements.txt 一条命令，整个环境原样复现。“任何人”这三个字里，也包括后续模块 7 时站在服务器上的我们——到时候我们会亲手体会这份清单值多少钱。（对照老直觉：它就是后端的 package.json，pip install -r 就是后端的 npm install。）\n顺手把改动提交了——这一节代码一行没动，只有 requirements.txt 变了，正好是一次干净的提交。\n这一节应该带走什么 我们写不出来的能力，生态里大概率有——面对需求，先找库、别急着自己重新造轮子；这一节学的是套路：找库 → 验库 → REPL 玩 → 接进项目。 去哪找：PyPI（≈ npm registry，Python 的公共仓库），外加搜索引擎、问 AI 都行——但谁都能上传，候选必须自己验。 AI 只给线索：可能过时、甚至幻觉出不存在的包名——名字它出，靠不靠谱我们验。 怎么验：GitHub 星数 + 是否还在维护（靠不靠谱），再读文档对照需求（能不能满足）。 REPL：python3 进，敲一行出一行，exit() 出——装完新库先来这儿玩两下。 pypinyin 认多音字、能带声调，snownlp 给 0~1 情感分（包里带着训练好的模型）——都验过了，但还没接进项目，下一节换芯。 requirements.txt 重新 freeze——pip install -r 一条命令复现环境，模块 7 的时候我们会依赖这个文件。 顺带认脸：jieba（分词）——中文 NLP 生态的常客。 下一节，把这两个库装进 /api/analyze——只换内部实现，不动接口约定，前端一行不改，分析就变成真的了。\n← 上一节：模块 5.5 前后端联调与 CORS | 下一节：模块 6.2 让网页真的会分析文字 →\n","date":"2026.08.04","description":"学会寻找、判断和使用 Python 第三方库，不必每次都从零造轮子。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-6-1/","title":"模块 6.1：第三方库和 PyPI"},{"content":" 这一节让前端和后端真正握上手。走通“输入 → 请求 → 后端计算 → 响应 → 界面更新”的完整链路，并在路上认识浏览器的一道安全规则：CORS。\n这一节要完成什么 这一节结束时：\n主页的标题、副标题、作品和座右铭，会来自 GET /api/profile； 文字实验室里输入一段文字、点击“开始分析”，结果区会显示 POST /api/analyze 返回的结果； 认识并解决跨源限制 CORS，顺带看清 OPTIONS 预检； 把散落在代码里的后端地址，收进配置文件。 还记得 4.4 讲“数据与界面分离”时埋的那颗种子吗？当时 site.js 里的内容都是写死的，我们说过，它们以后可以从网络接口获取。今天就是兑现这句话的日子。\n先把两边都跑起来。需要两个终端：\n# 终端 1：后端 cd ~/zero-to-tech/backend source .venv/bin/activate fastapi dev # → http://localhost:8000 # 终端 2：前端 cd ~/zero-to-tech npm run dev # → http://localhost:3000 一台电脑，两个一直运行的程序：3000 是前端，8000 是后端。\n先让后端数据与前端约定一致 正式连接之前，先确认两边说的是同一种“数据语言”。\n现在前端 data/site.js 里的 home 不只有标题和副标题，还有作品与身份信息；但 5.4 的 /api/profile 只返回了标题和副标题两个字段——当时我们说过，重点先放在 HTTP 和框架上，数据结构等前端真的来调时再对齐。这一刻到了：如果前端组件按照原来的结构取 featuredWork.title，后端却没有返回 featuredWork，两边就对不上了。\n这就是 5.1 说的：API 是调用方和被调用方之间的一份约定。 当前 /api/profile 返回的字段还不满足前端的需要，所以先改后端，把 profile 补成和 site.js 的 home 同构。为了等会儿一眼看出数据是否真的来自后端，先在标题后面临时加一个明显的标记：（来自后端）：\nprofile = { \u0026#34;heroTitle\u0026#34;: \u0026#34;关于我（来自后端）\u0026#34;, # → 临时加的标记，验证完删掉 \u0026#34;heroSubtitle\u0026#34;: \u0026#34;项目，创意，灵感，心得，我的作品\u0026#34;, \u0026#34;featuredWork\u0026#34;: { \u0026#34;kicker\u0026#34;: \u0026#34;作品\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;文字实验室\u0026#34;, \u0026#34;copy\u0026#34;: \u0026#34;拼音和情绪，挖掘中文里的细节\u0026#34;, \u0026#34;linkLabel\u0026#34;: \u0026#34;打开作品\u0026#34;, }, \u0026#34;identity\u0026#34;: { \u0026#34;motto\u0026#34;: \u0026#34;已识乾坤大，尤怜草木青\u0026#34;, \u0026#34;learning\u0026#34;: \u0026#34;零到全栈\u0026#34;, }, } 保存后，先用 curl 确认后端这边正常：\ncurl http://localhost:8000/api/profile 能看到完整 JSON 和“来自后端”的标记，说明接口本身没有问题。\n用现成的前端代码替换 后端准备好了，轮到前端。这一节前端要改的地方不少：主页要改成向后端请求数据，文字实验室的输入卡和结果卡也要接上接口。这些都是很常规的“取数据、发请求”写法，而这一节真正的重点是联调——请求为什么不通、怎么排查。所以这里不逐行讲前端代码，直接用改好的版本替换。\n改好的代码放在课程的 GitHub 仓库 zero-to-tech-demos 里。克隆下来，用 zero-to-tech-5-5/ 里的文件覆盖项目中的同名文件：\ngit clone https://github.com/joylibo/zero-to-tech-demos.git cp zero-to-tech-demos/zero-to-tech-5-5/components/*.jsx ~/zero-to-tech/components/ cp zero-to-tech-demos/zero-to-tech-5-5/css/lab.css ~/zero-to-tech/css/ 替换了哪些文件、各自做了什么，对照一下即可，不必抠语法：\n文件 改动 HomeView.jsx 变成客户端组件；打开页面先用 site.js 的数据打底，再去 GET /api/profile，拿到后端数据后更新界面 TextLabView.jsx 变成客户端组件；把“分析结果”这份状态提升到这里，分别下发给输入卡和结果卡（4.4 学过的“状态提升”） InputCard.jsx 点“开始分析”时，把文字 POST 给 /api/analyze ResultCard.jsx 改成显示父组件传来的结果，还没有结果时用一份默认占位 css/lab.css 新增一条 .lab-error 样式，用于请求失败时的提示 有几点先记下来：\n因为要在浏览器里发请求，HomeView、InputCard 这些组件顶部都加了 \u0026quot;use client\u0026quot;，成了客户端组件——这个概念 4.5 讲 Next.js 时提过，这里不展开。 这些组件里，后端地址 http://localhost:8000 都是直接写死的。现在能跑，但并不理想，这一节最后会把它收进配置文件。 请求失败时（后端没跑、跨源被拦等），代码用 try/catch 做了基本兜底：主页失败就保持 site.js 的打底数据，输入卡失败就在按钮上方给一行提示，界面不会无声崩掉。这属于常规的错误处理，不是本节重点，就不展开讲了。 替换完，保存，打开 http://localhost:3000。\n按理说，主页大标题应该变成后端返回的“关于我（来自后端）”。但页面上仍然是原来的“关于我”。前端代码是现成的、后端也用 curl 验证过，为什么网页没有拿到数据？\n先不要急着改代码。真正的联调，往往就是从这种“结果与预期不一致”开始的。\n第一次撞墙：顺着线索排查 遇到这种情况，不要盯着代码猜，先弄清楚这次请求走到哪一步断的。\n一次请求会在三个地方留下线索。按数据流动的顺序，从发出请求的这一头开始，一站一站往下看，每一站回答一个问题：\n看哪里 回答什么问题 浏览器 Network 请求发出去了吗？ 后端终端 后端收到了吗？又是怎么处理的？ 浏览器 Console 结果为什么没有交到我们的代码手里？ 第一站：浏览器 Network 打开浏览器开发者工具，切到 Network，刷新页面，找到 /api/profile。请求确实存在——说明它发出去了，不是那段 fetch 代码根本没执行。\n开发环境里如果看到两条相同的 GET，也不用慌。Next.js 的 App Router 默认开启 React 严格模式，开发时会多执行一次 Effect 来帮忙检查副作用；正式构建不会因为这个检查多发一次。\n第一个问题有了答案：请求发出去了。那它到没到后端？\n第二站：后端终端 回到运行后端的终端，可以看到类似：\nGET /api/profile HTTP/1.1 200 OK 这一行说明两件事：请求已经到达后端，而且后端处理完并返回了 200——在后端看来，这次请求是成功的。\n到这里就有点奇怪了：请求发出去了，后端也收到并成功返回了，页面上却没有数据。东西已经送到门口，是谁把它扣下的？\n第三站：浏览器 Console 切到 Console，会看到一段红字：\nAccess to fetch at \u0026#39;http://localhost:8000/api/profile\u0026#39; from origin \u0026#39;http://localhost:3000\u0026#39; has been blocked by CORS policy: No \u0026#39;Access-Control-Allow-Origin\u0026#39; header is present... 把三站的线索连起来看，会发现一个很有意思的现象：\nNetwork 里能看到请求，说明发出去了； 后端日志显示 GET 返回 200，说明收到了也处理成功了； 之前用 curl 也能拿到完整 JSON； 但网页 JavaScript 仍然拿不到结果。 问题不在接口有没有运行，也不在路径写错，而在浏览器提到的 CORS。\nCORS 到底拦了什么 CORS，中文叫“跨源资源共享”，是浏览器对网页 JavaScript 的一条安全规则。\n什么叫“跨源” 一个“源”由三部分组成：\n协议 + 域名 + 端口 任何一项不同，就是不同的源。我们的前端是 http://localhost:3000，后端是 http://localhost:8000：协议相同、域名相同，但端口不同，所以它们是两个源。网页脚本从 3000 去访问 8000，就是跨源。\n请求其实已经到达了后端 对刚才这个简单 GET 来说，浏览器已经把请求发给了后端，后端也返回了 200。CORS 拦下的不是“请求到达服务器”，而是：\n浏览器不允许当前网页的 JavaScript 读取这份未经授权的跨源响应。\n这也解释了为什么 curl 一直畅通无阻：CORS 是浏览器对网页脚本的规则，curl 不是网页，不受这条规则约束。\n浏览器怎样询问，后端怎样回答 网页发起跨源请求时，浏览器会自动带上：\nOrigin: http://localhost:3000 意思是“这段网页脚本来自哪里”。后端如果愿意让这个来源读取响应，就在响应头里给出：\nAccess-Control-Allow-Origin: http://localhost:3000 这就是后端的许可。请求里说明来源，响应里给出许可；浏览器看到两边对得上，才把响应交给网页 JavaScript。\n给 FastAPI 加上 CORS 要让网页读到响应，就得让后端在响应里带上那行 Access-Control-Allow-Origin。我们有两个接口，与其给每个接口都手写响应头，不如用一个现成的中间件统一处理。中间件是加在“请求进入、响应离开”必经之路上的一层处理，所有请求和响应都会过它一道。\n打开 backend/main.py，顶部增加 import：\nfrom fastapi.middleware.cors import CORSMiddleware 紧跟在 app = FastAPI() 后面增加：\napp.add_middleware( CORSMiddleware, allow_origins=[\u0026#34;http://localhost:3000\u0026#34;], ) allow_origins 就是控制 Access-Control-Allow-Origin 这行响应头的开关。把前端地址 http://localhost:3000 填进去，后端就认这个来源。\n中间件还有别的参数，但眼下这个 GET 只差“来源许可”这一项，先加这一行就够；其余等真正遇到问题再补。\n保存后，后端自动重启。刷新主页——标题变成了“关于我（来自后端）”，数据终于从 8000 端口流到了 3000 端口的网页。\n再看 Network 里的 /api/profile，Response Headers 多了一行：\naccess-control-allow-origin: http://localhost:3000 这就是后端发的许可。确认成功后，可以把标题里的“（来自后端）”删掉，恢复正常文案；以后想验证数据是否来自后端，也可以临时改一个字段再刷新页面观察。\n第二次撞墙：POST 被预检拦下 主页的 GET 通了。接下来试文字实验室的 POST /api/analyze：这个接口 5.4 已经写好，前端也替换好了，看起来一切就绪。\n打开文字实验室，输入一段文字，点击“开始分析”——界面上冒出一行红字：Failed to fetch。又撞墙了。\n这个提示很笼统，只说“请求失败了”，没说为什么。于是还是老规矩，去现场找线索。\n打开 Network，会发现这次和上次不同：我们只点了一次“分析”，列表里却出现了一条从没写过的 OPTIONS 请求，而且它失败了；真正想发的那条 POST 反而没有出现。\nOPTIONS /api/analyze ← 失败 （没有 POST） 再看 Console：\n...has been blocked by CORS policy: Method POST is not allowed by Access-Control-Allow-Methods in preflight response. 又是 CORS，但和第一堵墙不一样：第一次是请求发出去了、响应被拦回来；这次那条 POST 根本没发出去，被这个 OPTIONS 挡在了前面。\n这个 OPTIONS 是浏览器自动发的 它有个专门的名字，叫 CORS 预检（preflight）。5.3 认识 HTTP 方法时，OPTIONS 混过一次脸熟——“我能对这个资源做什么”，现在派上了用场。\n要点先说清楚：这个 OPTIONS 不是我们写的，也不是 Next.js 或 React 的功能，而是浏览器自动发的，属于 CORS 规则的一部分。这也解释了为什么之前用 curl 从没见过它——curl 不是浏览器，不受 CORS 约束。\n为什么第一个 GET 没有预检、这个 POST 却有？因为浏览器把跨源请求分成两类。像主页那个 GET，方法普通、也没带特别的头，属于简单请求，浏览器直接发，顶多事后拦下响应不给脚本读。而这次的 POST 带了 Content-Type: application/json，就成了不简单的请求；对这种请求，浏览器会先发一个 OPTIONS 去问后端：允许这个来源吗？允许 POST 吗？允许带这个请求头吗？——预检通过，才发真正的 POST；不通过，POST 根本不会离开浏览器。\n为什么要多这一道？POST 往往会改动数据（比如发一条评论、下一个订单之类的）。要是不问就发，即便响应被拦、脚本读不到，这件事也已经在后端做了。预检“先问后发”，把没授权、又可能产生副作用的请求，挡在发送之前。\n预检问的，正是我们没回答的 预检失败的原因，Console 已经写明：Method POST is not allowed。回头看刚才配的中间件，我们只写了 allow_origins——只说了“谁能来”，没说“能用什么方法”。预检由 CORSMiddleware 自动应答（不需要我们为 OPTIONS 写任何代码），它一查：来源允许，但方法 POST 不在名单里，于是打回。\n补上 allow_methods 即可：\napp.add_middleware( CORSMiddleware, allow_origins=[\u0026#34;http://localhost:3000\u0026#34;], allow_methods=[\u0026#34;GET\u0026#34;, \u0026#34;POST\u0026#34;], ) allow_methods 声明这个接口允许被哪些方法跨源调用。项目用到 GET 和 POST，就如实写这两个。\n预检其实还会问“能不能带某些请求头”，对应参数是 allow_headers。我们带的 Content-Type 属于浏览器默认放行的常见头，不必专门声明；将来若请求要带不常见的头（比如登录用的 token），才需要在 allow_headers 里列出。\n保存后端、自动重启，再点一次“开始分析”。这次结果区出来了：原文正是刚提交的文字，还有分数、判断和占位的拼音。换一段文字再点，原文随之改变——每一次结果都真的来自后端。（分数和拼音目前仍是写死、占位的，模块 6 才会真正计算。）\n回到 Network 再看，这次是两条请求：先一条 OPTIONS 预检（这次通过），紧接一条真正的 POST。这就是“先问后发”走通后的样子。\n同一个 CORS，两副面孔 至此见到了 CORS 的两种场景：\n简单 GET 已经到达后端，但没有许可时，网页不能读取响应； 带 JSON 的 POST 会先发 OPTIONS 预检，通过后才发送真正的 POST。 最后一步：把写死的地址收进配置 GET 和 POST 现在都正常了，但还留着一个前面提过的小尾巴：前端代码里，后端地址是写死的。用 VS Code 全局搜索：\nhttp://localhost:8000 会在 HomeView.jsx 和 InputCard.jsx 里各找到一次。\n一个很现实的问题是：这个地址是会变的。后端换个端口、项目挪到另一台机器、以后要部署到线上——每一种情况，这个地址都得跟着改。现在只有两处，改起来还不费事；可组件一旦多起来，同一个地址散落在十几个文件里，每变一次都要满项目搜索替换，漏掉一处，就是一个特别难找的联调 bug。\n再往深一层看：一个地址该填什么，取决于代码跑在哪个环境——开发机、测试机、线上服务器各不相同。这种跟运行环境绑定的值，本就不该写死进组件，让业务代码为了换个环境反复改动。\n这说明后端地址不属于组件的业务逻辑，而是一项配置。配置应该集中管理。\n你可能会问：后端 allow_origins 里那个 http://localhost:3000，不也是把地址写死在代码里了吗？没错，它同样是一个跟环境绑定的配置值，正式项目里也该从配置读取。道理是对称的——这一节先拿前端这一侧当例子，把“地址属于配置”讲透；后端这类配置，留到部署那一节一起处理。\n创建 .env.local 在前端项目根目录，也就是 package.json 所在的目录，新建 .env.local：\nNEXT_PUBLIC_API_BASE_URL=http://localhost:8000 NEXT_PUBLIC_ 前缀表示这个值可以进入浏览器代码。正因为如此，它只能放后端地址这类可以公开的配置，不能放 API 密钥、密码等秘密信息——那些会被打包进浏览器，等于公开。\n在两个组件的 import 下方都增加：\nconst API = process.env.NEXT_PUBLIC_API_BASE_URL; 然后把硬编码地址分别改成：\nfetch(`${API}/api/profile`) 和：\nfetch(`${API}/api/analyze`, { 创建或修改环境变量后，要重启前端 .env.local 是在开发服务器启动时读取的，光保存文件还不够，必须重启：\n# 按 Ctrl + C 停止原来的开发服务器 npm run dev 重启后，再分别验证主页 GET 和文字实验室 POST，应该一切正常。\n项目的 .gitignore 已经包含 .env*，所以 .env.local 默认不会进入 Git。为什么环境配置通常不直接提交、部署时又怎样提供真实地址，等真正部署后端时再具体处理。\n见证：网站真正活了 现在把这次POST请求的完整链路再看一遍：\n输入文字 → 浏览器发送 POST → OPTIONS 预检通过 → FastAPI 接收并校验请求体 → Python 计算结果 → FastAPI 返回 JSON → 前端拿到 JSON、更新界面 → 结果区自动刷新 从模块 3 那个双击打开的静态页面，到今天——浏览器里运行着 React，电脑上运行着 Python，中间通过真实的 HTTP 请求交换数据。整条链路是我们自己搭起来的。\n主页这类编辑型内容，其实继续留在 site.js 完全没问题。把它接到 GET，主要是为了用最简单的数据学习第一次前后端联调。真正非后端不可的，是 /api/analyze 这种需要根据用户输入实时计算的功能。\n模块 5 收官 回头看这五节走过的路：\n5.1——亲手调用真实 API，理解调用方、被调用方和接口约定； 5.2——安装 Python、建立 venv，用 pip 和 requirements 管理依赖； 5.3——零依赖手搓 API，看清 HTTP 的请求与响应； 5.4——用 FastAPI 重写接口，体验路由、校验和自动文档； 5.5——让 React 与 Python 真正握手，学会联调排查，认识 CORS 与 OPTIONS 预检，并把配置收进环境变量。 现在还有两个明显的欠账：\n/api/analyze 的拼音是占位符，情感分数也只是写死的固定值，还不是真正的分析； 每次分析结果用完就丢，没有历史记录。 下一个模块，我们会使用 Python 生态里的第三方库把分析变成真的，再把结果保存下来。\n这一节应该带走什么 联调先找线索，不要看见红字就盲目改代码：一次请求会在三个地方留下线索，按数据流动的顺序看——浏览器 Network（请求发出去了吗）→ 后端终端（收到了吗、怎么处理的）→ 浏览器 Console（结果为什么没交到我们手里）。 CORS 管的是浏览器里的网页脚本能否读取跨源响应，不是身份验证，也阻止不了 curl；源由协议、域名、端口共同决定。 CORS 有两副面孔：简单跨源 GET 能到达后端，但没有许可网页就读不到响应；带 JSON 的 POST 会先发一个 OPTIONS 预检，通过后才发送真正的 POST。 预检是浏览器自动发的，由 CORSMiddleware 应答；allow_origins / allow_methods 按项目实际需要填写，只放当前用得到的来源和方法。 后端地址属于配置，不应该散落在组件里；公开的前端配置可以放进 .env.local，修改后记得重启开发服务器。 网站完成了第一次全链路：输入 → HTTP 请求 → 后端计算 → 响应 → 界面更新。 下一个模块，我们去领取 Python 生态的红利——让文字实验室真的会分析。\n← 上一节：模块 5.4 FastAPI 登场 | 下一节：模块 6.1 第三方库和 PyPI →\n","date":"2026.07.28","description":"让 React 与 FastAPI 真正连起来，并解决跨域带来的问题。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-5-5/","title":"模块 5.5：前后端联调与 CORS"},{"content":" 认识几个主流后端框架，以及 FastAPI 和 uvicorn 各自负责什么。然后把手搓的接口重写一遍，再顺手加一个 POST\n那些杂活，每个后端都一样 上一节我们手搓了一个 API，也切身体会到它并不轻松。为了把一段 JSON 发出去，我们手写了路由判断、状态码、两行响应头、dumps、encode、404 兜底……而这才一个接口，还只是 GET。\n今天我们开始学一个后端框架。HTTP 的这些事儿，用了框架就会变得轻松很多。\n框架这个词我们已经不陌生了。讲 React 的时候我们就说过：框架，就是管某一摊事的一套规则——React 管的是“UI 组件”这一摊。后端框架管的则是另一摊：接住请求、解析内容、把响应发回去。我们只需要按照框架的规则填上真正关注的部分——“这个路径，该返回什么数据”。\n认识几个主流后端框架 Python 的后端框架不止一个：\nFlask——老牌、轻量，长期的入门经典，生态成熟； Django——“大而全”，自带后台管理界面、用户系统，还有一套操作数据库的 ORM 工具，很多常用功能都有现成方案，适合直接开发大型网站； FastAPI——最年轻的一个，专为写 API 而生：样板代码极少、自动生成接口文档，而且我们把字段和类型写清楚，它就能自动帮我们解析和校验。 我们这门课选 FastAPI。主要有三个理由：代码少、类型校验的反馈直接、自动生成接口文档。对于一个以 API 为主、希望快速获得这些能力的 Python 新项目，FastAPI 是很合适的选择。\n另外顺带推荐一下：FastAPI 官网的 User Guide 写得特别好，不光讲“是什么、怎么用”，还常常讲“为什么”，几乎可以当成一份手把手教程来读，对中文的支持也不错；文档里的 About 还专门对比了 Flask、Django 等框架，值得一读。想深入学 FastAPI，官网就是最好的教材。\n和 4.3 选 React 时说的一样：框架之间的概念是相通的。把 FastAPI 用明白了，回头看 Flask、看 Django，都是熟面孔。\n两个角色：FastAPI 和 uvicorn 上一节的 main.py 其实同时干了两类工作：\n判断路径、组织响应，决定“收到这个请求后返回什么”； 用 HTTPServer(...) 和 serve_forever() 守住 8000 端口，一直等待请求。 用了 FastAPI 之后，这两类工作交给两个工具分工：\n5.3 手搓版的职责 现在由谁负责 if self.path == ...、组织响应 FastAPI——负责定义接口 HTTPServer(...)、serve_forever() uvicorn——负责运行服务器、监听端口 所以，FastAPI 负责“接口该做什么”，uvicorn 负责“让接口跑起来”。FastAPI 自己不会守着端口等请求；uvicorn 收到请求后，会把它交给 FastAPI 处理。\n一会儿我们会先亲手指挥一次 uvicorn，把它的命令认清楚；然后再换官网的快捷命令 fastapi dev。这样以后在别处教程或报错里遇到 uvicorn 这个名字，就都不陌生了。\n把 FastAPI 装进来 FastAPI 是第三方包。“装第三方包”这套动作，5.2 我们已经完整走过一遍了（那次装的是 requests）——pip 装、落进 .venv，这次只是换了包名。安装命令用官网教程的同款：\n先确认自己在 (zero-to-tech) 环境里（装包前先看提示符，5.2 的老规矩）：\ncd ~/zero-to-tech/backend source .venv/bin/activate # 若提示符没带括号 pip install \u0026#34;fastapi[standard]\u0026#34; 包名后面的方括号是 pip 的“套餐”写法：fastapi[standard] = FastAPI 本体 ＋ 官方推荐的一套标准配件。装完 pip list 看一眼，列表一长串——刚认识的 uvicorn 就在里面（它就是随这个套餐装进来的），还有 starlette、pydantic、fastapi-cli 等一串我们没点名的包：依赖还有依赖，5.2 装 requests 时也见过这现象。\n用 FastAPI 重写 /api/profile 铺垫结束，正主登场。先把手搓版留作纪念：\nmv main.py handmade.py 新建 main.py，写 FastAPI 版：\nfrom fastapi import FastAPI app = FastAPI() profile = { \u0026#34;heroTitle\u0026#34;: \u0026#34;关于我\u0026#34;, \u0026#34;heroSubtitle\u0026#34;: \u0026#34;项目，创意，灵感，心得，我的作品\u0026#34;, } @app.get(\u0026#34;/api/profile\u0026#34;) def get_profile(): return profile 没了。这就是全部。\n@app.get(\u0026quot;/api/profile\u0026quot;) 这行是一个新面孔（叫“装饰器”）——和 5.3 的 class 一样，我们不用学会它，读懂意思就行：“/api/profile 这个路径的 GET 请求，交给下面这个函数处理。”\n拿它和 handmade.py 对一对，上一节的杂活都去哪了：\n手搓版（5.3 亲手写的） FastAPI 版 if self.path == ... 路由判断 @app.get(...) 一行装饰器 send_response(200) 自动 send_header(\u0026quot;Content-Type\u0026quot;, ...) 自动设置 JSON 响应的类型 json.dumps(...).encode(...) 自动把返回的 Python 字典序列化为 JSON else 兜底 404 自动（没定义的路径，自动回 404） 跑起来：先用 uvicorn「手动挡」 启动之前，先确认 5.3 的手搓服务已经 Ctrl + C 停掉了——一个端口，同一时间只能由一个程序守着（还记得模块 2 讲的端口吗）。要是忘了停，下面这条命令会报 Address already in use（地址已被占用）——认得这个报错，以后一见到就知道：是端口被别的程序占着呢。\n先直接指挥 uvicorn：\nuvicorn main:app --reload main:app 可以拆开看：\nmain : app 文件 变量 意思是：让 uvicorn 去找 main.py 文件里的 app（这里不写 .py）。--reload 是改代码自动重启——我们在前端早就享受过这待遇（npm run dev 改代码即时生效），这是后端的同款，开发时开着它，就不用像 5.3 那样每改一次手动 Ctrl+C 重启了。\n服务跑起来了——uvicorn 守着 8000 端口，把收到的请求交给 main.py 里的 app。两个角色，就这样接上了头。\n换官网的快捷命令：fastapi dev Ctrl + C 停掉，再用官网教程的写法跑一遍——注意这次连文件名都不用写：\nfastapi dev 启动输出里有几行值得认一认：\nFastAPI Starting development server 🚀 server Server started at http://127.0.0.1:8000 server Documentation at http://127.0.0.1:8000/docs tip Running in development mode, for production use: fastapi run INFO Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 最后一行：Uvicorn running——刚才我们亲手指挥过的 uvicorn，就在这。fastapi dev 干的事，就是替我们把 uvicorn 跑起来：dev 是开发模式，等于帮我们带上了 --reload；连文件名都不用写——它默认就去找 main.py，连 main:app 这种写法都省了。 tip 那行说：正式上线用 fastapi run（不带自动重启）——到部署的时候我们会再见到它。 Documentation 那行，先卖个关子，一会儿揭晓。 两条命令都能用，干的是同一件事。 这门课以后统一用 fastapi dev 这种写法——更简洁，也和官网文档一致；在别处教程里见到 uvicorn main:app --reload，认得出它是“手动挡”就行。\n验证（新开一个终端）：\ncurl http://localhost:8000/api/profile 和手搓版一模一样的 JSON。\n接着再故意访问一个不存在的路径：\ncurl http://localhost:8000/nope 回来的是 {\u0026quot;detail\u0026quot;:\u0026quot;Not Found\u0026quot;}——404 我们一行都没写，而且它连 404 都带着 JSON 格式的说明，比我们手搓的空 404 还周到。\n目录里多了一个东西 接口跑通之后，回到 backend 目录看一眼：\nls 会发现多出来了一个目录：__pycache__/。再看看里面：\nls __pycache__ 里面是 Python 运行代码时自动生成的缓存文件。它能让 Python 下次加载代码时更快一点；删掉也没关系，运行代码时还会重新生成。\n既然它不是我们亲手写的源码、随时可以重新生成，就和 .venv、node_modules 一样，不应该进 Git。在项目的 .gitignore 里加上：\n__pycache__/ *.py[cod] 第一行忽略所有层级的 __pycache__ 目录；第二行忽略项目各处的 .pyc、.pyo、.pyd 这几类 Python 生成文件。\n惊喜：我们的 API 自己长出了文档 浏览器打开：\nhttp://localhost:8000/docs 一个接口文档页面，列着我们的 /api/profile。展开它，依次点击 Try it out 和 Execute，页面会真的调用一次接口。留意其中的 Request URL、Response body 和 Response code——请求地址、响应内容、状态码都替我们摆好了。\n回想 5.1：我们是照着 DeepSeek 的文档学会调用它的 API 的——文档是 API 的说明书。而现在，我们的 API 的说明书，是 FastAPI 自动替我们写的，代码一改，文档跟着变。这就是“框架把通用的事全包了”的又一个例子。\n如果 /docs 打开是一片空白：多半是网络问题——这个文档页面的样式和脚本，默认从公共 CDN 加载，国内网络偶尔抽风。刷新几次或换个网络通常就好；接口本身不受任何影响。\n加一个 POST 接口：/api/analyze 还差文字实验室要用的那个接口：提交一段文字，返回分析结果。“提交内容”——5.1 认过脸的，这就是 POST。\n先把需求说清楚：一来一回长什么样 动手之前，先把这一来一回的数据形状定下来——5.1 说过，API 是一份约定，写接口的第一步就是把约定说清楚：\n调用方提交什么？ 要分析的那段文字。请求体只需要一个字段：\n{ \u0026#34;text\u0026#34;: \u0026#34;今天的风很轻\u0026#34; } 我们回什么？ 分析结果。具体一点：文字实验室的结果卡上有四个位置——原文、拼音、分数、情感标签——约定的形状，来自调用方的需要。不过真正的分析（拼音、情感分数）要等模块 6 用第三方库来做，今天先把接口的形状立起来：原文照抄回去，其余三个先写死占位：\n{ \u0026#34;text\u0026#34;: \u0026#34;今天的风很轻\u0026#34;, \u0026#34;score\u0026#34;: 0.5, \u0026#34;label\u0026#34;: \u0026#34;偏平静\u0026#34;, \u0026#34;pinyin\u0026#34;: \u0026#34;（模块 6 再说）\u0026#34; } 形状定了，“里面怎么算”以后随时可以换——这也是 5.1 那句话的提供方视角：约定不变，实现随便换。\n5.3 我们刻意没有手搓 POST 接口，因为它要做的杂活更多：自己读 Content-Length、自己收字节、自己解析 JSON、自己校验字段全不全……看看 FastAPI 怎么处理。\n动手：三步写完 这个接口一共改三处。先一口气把它们敲完，写完再回头讲每一步的意思。\n第一步，在 main.py 顶部的 import 区加一行：\nfrom pydantic import BaseModel 第二步，在 profile 后面加一段请求体的声明：\nclass AnalyzeRequest(BaseModel): text: str 第三步，在文件末尾加上接口本身：\n@app.post(\u0026#34;/api/analyze\u0026#34;) def analyze(req: AnalyzeRequest): return { \u0026#34;text\u0026#34;: req.text, \u0026#34;score\u0026#34;: 0.5, \u0026#34;label\u0026#34;: \u0026#34;偏平静\u0026#34;, \u0026#34;pinyin\u0026#34;: \u0026#34;（模块 6 再说）\u0026#34;, } 三步写完，完整的 main.py 是这样，核对一下：\nfrom fastapi import FastAPI from pydantic import BaseModel app = FastAPI() profile = { \u0026#34;heroTitle\u0026#34;: \u0026#34;关于我\u0026#34;, \u0026#34;heroSubtitle\u0026#34;: \u0026#34;项目，创意，灵感，心得，我的作品\u0026#34;, } class AnalyzeRequest(BaseModel): text: str @app.get(\u0026#34;/api/profile\u0026#34;) def get_profile(): return profile @app.post(\u0026#34;/api/analyze\u0026#34;) def analyze(req: AnalyzeRequest): return { \u0026#34;text\u0026#34;: req.text, \u0026#34;score\u0026#34;: 0.5, \u0026#34;label\u0026#34;: \u0026#34;偏平静\u0026#34;, \u0026#34;pinyin\u0026#34;: \u0026#34;（模块 6 再说）\u0026#34;, } 回头看：这三步在干嘛 第一步的 BaseModel 是新面孔——它来自 pydantic，pip list 里见过的那个包，FastAPI 的老搭档，专门管数据的解析和校验。BaseModel 是它提供的“数据模型”基类：继承它，就能声明“某一类数据长什么样”。\n第二步的 class AnalyzeRequest(BaseModel)，正是用它把刚才定好的请求形状写了下来：这类请求的请求体里，必须有一个 text 字段，而且是字符串。注意，这不是在处理请求，而是在声明请求应该长什么样。\n第三步的接口，@app.post 和 @app.get 是同一个思路：/api/analyze 的 POST 请求，交给下面这个函数。关键在参数 req: AnalyzeRequest——FastAPI 一看到这份声明，就自动完成了手搓时代最狼狈的全部动作：收字节、解析 JSON、校验字段、转成好用的对象。所以函数里直接 req.text 就能拿到调用方提交的文字，返回的正是刚才定好的响应形状：原文，加三个写死的占位值。\n我们写的声明 FastAPI 自动完成 text 要求请求体里有这个字段 : str 要求这个字段是字符串 req: AnalyzeRequest 从 JSON 请求体解析出一个好用的对象 （如果字段缺失或类型不对） 返回校验错误 那份声明的威力，马上就见分晓。\n保存（开发模式已自动重启），测试：\ncurl http://localhost:8000/api/analyze \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;text\u0026#34;: \u0026#34;今天的风很轻，适合把想法写下来\u0026#34;}\u0026#39; 回来的正是我们定好的形状：原文，加三个写死的占位值。\n这条命令眼熟吗？ -H \u0026quot;Content-Type: application/json\u0026quot;、-d '{...}'——方法我们压根没写，是 -d 自动把它发成了 POST（5.3 讲过的规矩）。和我们用 curl 调 DeepSeek 那条，形状一模一样。那时我们是调用方，看不见对面；现在，我们自己就是“对面”。\n我们再故意发一个错的——字段名写错。这次加上 -i，让 curl 把响应头和状态码也显示出来：\ncurl -i http://localhost:8000/api/analyze \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;txt\u0026#34;: \u0026#34;字段名写错了\u0026#34;}\u0026#39; 先看第一行：HTTP/1.1 422 Unprocessable Entity。下面的 JSON 里明明白白指出：缺 text 字段。校验代码我们一行没写。\n再回到 /docs 刷新一下：/api/analyze 已经自动出现了，展开后还能看到请求体必须有一个字符串类型的 text。代码里的声明，不但带来了自动校验，也自动变成了文档。\n再强调一次：score、label、pinyin 现在都是写死的占位值，这个接口今天只负责把“形状”立住。真正的拼音和情感分数，模块 6 会用第三方库换成真的。到时候我们还会亲眼看到 API 的一个好处：内部实现整个换掉，接口不变，调用方毫无感觉。\n最后一件事：报错了怎么看 后端阶段的最后一项生存技能。我们故意制造一个 bug——把 analyze 里的 req.text 少写一个字母，改成 req.txt：\n\u0026#34;text\u0026#34;: req.txt, # 故意写错 保存，再用刚才那份正确的请求调用一次，同样加上 -i：\ncurl -i http://localhost:8000/api/analyze \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;text\u0026#34;: \u0026#34;测试\u0026#34;}\u0026#39; 第一行是 HTTP/1.1 500 Internal Server Error。\n5xx，服务方的问题——这次，真的是我们的问题。有意思的是：刚才调用方把字段名写错成 txt，得到的是 422；现在同一个手误发生在我们自己的代码里，变成了 500——谁的错，状态码分得清清楚楚。\n再看服务端终端：这一次请求失败了，但服务进程并没有退出，修好之后还能继续接收请求。终端里打出了一大段红字，这就是 traceback（错误回溯）。读终端报错有固定套路：\n先看最后一行：AttributeError: 'AnalyzeRequest' object has no attribute 'txt'——错误类型和原因，一句话：AnalyzeRequest 身上没有 txt 这个东西； 再往上找自己的文件：File \u0026quot;.../main.py\u0026quot;, line XX——这里会显示实际出错的文件和行号。每个人代码里的空行可能不同，所以看到的数字不一定一样。 两步定位，改回 req.text，保存，恢复正常。\n报错不是事故，是线索——最后一行说“是什么错”，上面几行说“在哪儿”。实在读不懂，整段复制丢给 AI，它读 traceback 比谁都熟练。从今往后，见到红字先别关终端，先看最后一行。\n收尾：把依赖清单更新好 现在两个接口都写完、依赖也确定了，最后把今天装的东西记到 requirements.txt 的账上。5.2 建的 requirements.txt 里原本只有 requests，重新生成一次：\npip freeze \u0026gt; requirements.txt cat requirements.txt fastapi、uvicorn，连同 starlette、pydantic 等一整套，都进清单了——[standard] 套餐记的账，比 5.2 那份长了一大截。.venv 和 __pycache__ 不进 Git，但 requirements.txt 要跟着代码一起进 Git——别人才能照着它重建出同样的环境。\n这一节应该带走什么 框架 = 把每个后端都要干的杂活打包复用；我们手搓过一遍，所以确切知道 FastAPI 替我们干了什么。 主流后端框架认脸：Flask / Django / FastAPI——概念相通。 FastAPI 管定义接口，uvicorn 管运行服务器；安装用官网同款 pip install \u0026quot;fastapi[standard]\u0026quot;。启动两条命令都行：uvicorn main:app --reload 是手动挡，fastapi dev 替我们把 uvicorn 跑起来——以后统一用后者，更简洁。 Python 运行后自动生成的 __pycache__ 是可重建的缓存，所以要补进 .gitignore；最终用 pip freeze 更新依赖清单。 /docs 自动接口文档——我们的 API 自己长出了说明书。 写接口先定约定：请求体、响应各长什么样；POST 的解析和校验，靠一个 BaseModel（pydantic 的数据模型基类）声明全自动完成；/api/analyze 返回的分析值目前全是写死的占位，模块 6 换真的。 读报错先看最后一行，再往上找自己文件的行号；读不懂就丢给 AI。 下一节，前端后端正式握手：让网页真的来调用这两个接口。\n← 上一节：模块 5.3 看懂 HTTP，手搓 API | 下一节：模块 5.5 前后端联调与 CORS →\n","date":"2026.07.23","description":"用 FastAPI 重写手搓的 API，体验框架替我们省掉了什么。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-5-4/","title":"模块 5.4：从手搓到框架，FastAPI 登场"},{"content":" 这一节先不写代码——把 HTTP 请求和响应的规范看明白：一次网络对话，双方到底各说了什么、按什么格式说。然后再用 Python 把这套规范亲手实现出来（零依赖，纯标准库），用 curl、浏览器逐一验证。规范在前，代码在后——代码只是规范的一种实现。\nAPI 和 HTTP 上一节我们安装了 Python，现在大家也知道什么是 API 了，那么这一节，我们就用 Python 手搓一个 API 出来。\n先回忆一下，我们之前调用 API 的经历——5.1，我们用 curl 调了两个真实的 API；5.2，我们写了 api_demo.py，用 Python（requests）调了一次 ipify 的 API。\n注意一个共同点：到目前为止，我们一直站在调用方这一边——发请求、收 JSON 的那一边。\n而我们的目标，是给自己的网站写一个 API。这意味着要换到另一边去：做那个“被调用方”，也就是“一直守着、接到请求、回一段 JSON”的程序。\n在动手之前，我们需要先搞清楚一件事：这一来一回的网络对话里，双方传的到底是什么？调用方发来的“请求”长什么样？我们回的“响应”又该长什么样？这两样东西是有明确规范的——这套规范就是 HTTP。5.1 我们提到过：API 没有另起炉灶，直接用了浏览器上网用的那套规矩。\n所以说要手搓 API，就绕不开 HTTP，因为如果要接住请求、按规矩回话，就得把这套规矩本体看清楚。\n这一节的主要知识点，其实是 HTTP。\n动身之前，先把 HTTP 和 API 这两个词的关系摆正，后面的知识点才好记账：\nAPI 是一个程序对外提供能力的入口（5.1 的定义）——而这个入口上的对话协议用的是 HTTP。 HTTP 是一套通信规范，管的是“两个程序之间怎么对话”——对话是什么格式、有哪几种问法、怎么表示成功和失败。它不是为 API 发明的：浏览器加载的每一个网页、每一张图片，走的都是 HTTP（包括我们此前用 Nginx 返回 html 页面）。 也就是说，我们其实已经用过很多次 HTTP 了，只不过，我们从来没有深入进去看一看 HTTP 的全貌：\n用浏览器发起网页请求的时候，浏览器的页面上显示的只是正文，其余信息隐藏起来了，在 Chrome 浏览器上需要用 F12 才可以看到； 在终端用 curl 向 API 发起请求的时候，终端默认只把响应的“正文”打印出来，其余信息全被它省略了而已； 5.2 用 Python 的 requests 发起请求时也一样，resp.json() 拿到的只是正文，并非完整的 HTTP 信息。 下面就看一看 HTTP 的请求、响应信息的全貌。\nHTTP 的一去一回：请求和响应 HTTP 规定的一次对话，就是一去一回、两段有固定格式的文本：调用方发过去的那段叫请求（request），服务方回过来的那段叫响应（response）。两段文本的结构几乎对称：\n请求（去） 响应（回） ├─ 请求行 ├─ 状态行 ├─ 请求头（若干行） ├─ 响应头（若干行） ├─ （一个空行） ├─ （一个空行） └─ 请求体 └─ 响应体 先看请求这一侧的四个部分。\n① 请求行——整个请求的第一行，永远只有一行，说清三件事：方法、路径、协议版本。\n方法：这次是来干什么的。GET 是“取数据”，POST 是“提交内容”——5.1 认过脸的那两位（HTTP 还定义了别的方法，稍后给一张脸谱表）； 路径：要访问对方的哪个资源； 协议版本：先不用管，这一节稍后会交代。 ② 请求头——若干行“附加说明”，一行一条，格式统一是 名字: 值——“我要找哪台服务器”“我是谁”“我提交的内容是什么格式”……都写在这里。\n③ 一个空行——分界线，意思是“头说完了，下面是体”。\n④ 请求体——真正要提交的内容。不是必须有——取数据的请求，一般就没有体。\n再看响应这一侧。它的四个部分——状态行、响应头、一个空行、响应体——和请求几乎对称，唯一的结构差别在第一行：请求的第一行叫请求行，响应的第一行叫状态行。\n① 状态行——一行说清三件事：协议版本、状态码、简短说明。最重要的是中间那个状态码：一个数字，表态“这次处理得怎么样”，后面跟着一句人类友好的简短说明（OK、Not Found）。\n200：成功； 404：没找到——这个数字我们在模块 4 见得够多了，那时是 Nginx 替我们回的； 记个家族规律就行：2xx 成功；4xx 请求方的问题；5xx 服务方的问题。 ② 响应头——同样是一行一条的“附加说明”。最重要的一条是 Content-Type：我给你的这段内容，是什么格式——application/json 就是在说“按 JSON 来解析它”。它有多大威力，一会儿动手实验。\n③ 空行 ＋ ④ 响应体——和请求侧一样：空行分界，体是正文。我们平时在终端、在浏览器页面上“看到”的，基本都只是响应体——5.1 那行 {\u0026quot;ip\u0026quot;: …} 是一段响应体，DeepSeek 回的那段 choices JSON 也是；它们上面顶着的状态行和一排头，当时全被工具省略了。\n把一去一回摆在一起，规律就出来了：\n请求 ＝ 请求行 ＋ 头 ＋ 空行 ＋ 体；响应 ＝ 状态行 ＋ 头 ＋ 空行 ＋ 体。 格式对称，全是纯文本。我们和任何服务器之间的每一次对话，本质就是这样两段文本的往返。\n用 curl -v 验证 到这里，上面说的一切都还只是“纸面上的说法”。空口无凭——curl 有一个 -v 参数（verbose，“把过程全说出来”），能把一次调用的请求原文、响应原文全部亮出来。\n把 5.1 查 IP 的那条命令加上 -v，再跑一次：\ncurl -v \u0026#39;https://api.ipify.org?format=json\u0026#39; 这回输出多了一大截。我们先学会认行首的三种记号：\n* 开头：curl 的过程旁白——建立连接、加密握手之类，全部跳过； \u0026gt; 开头：发出去的请求原文； \u0026lt; 开头：收到的响应原文。 把 * 的行掠过去，剩下的就是一次完整的“一去一回”（具体的值每个人会不同）：\n\u0026gt; GET /?format=json HTTP/2 ← 请求行：方法 + 路径 + 协议版本 \u0026gt; Host: api.ipify.org ← 请求头，从这行开始 \u0026gt; User-Agent: curl/8.7.1 \u0026gt; Accept: */* \u0026gt; ← 空行：请求头完；⚠️ 注意！GET 没有请求体 \u0026lt; HTTP/2 200 ← 状态行：协议版本 + 状态码 \u0026lt; date: Mon, 13 Jul 2026 05:38:00 GMT \u0026lt; content-type: application/json ← 响应头：内容是 JSON 格式 \u0026lt; content-length: 22 \u0026lt; server: cloudflare （还有几条，略） \u0026lt; ← 空行：响应头完 {\u0026#34;ip\u0026#34;:\u0026#34;114.86.123.45\u0026#34;} ← 响应体——5.1 我们看到的，只有这一行 看到了吧，真的是——\u0026gt; 那段：请求行、头、空行；\u0026lt; 那段：状态行、头、空行、体。理论里的每个部件，都能逐行指认出来。 几个需要了解的细节：\n请求行里，方法是 GET（取数据）；路径是 URL 去掉协议和域名之后剩下的那段——? 后面的 format=json 叫查询参数，跟在路径后面（还记得 5.1 为什么要给网址加引号吗？防的就是这个 ? 被终端误解）； 三行请求头：Host——要找哪台服务器；User-Agent（常简称 UA）——我是谁，用什么工具、什么浏览器发的这个请求；Accept——我能接受什么格式的回应。注意：这条命令里我们一个头都没写，它们全是 curl 自动带上的； 两个小注：这里看到的版本多半是 HTTP/2，它和老一些的 HTTP/1.1 在显示上有两处小差别——头的名字统一小写、状态行的状态码后面不带 OK 那句简短说明——其余一模一样；另外，如果电脑开着网络代理，\u0026lt; 里可能先冒出一行 HTTP/1.1 200 Connection established——那是代理隧道的痕迹，跳过它。\n再验证一个带请求体的。给 5.1 调用 DeepSeek 的那条长命令也加上 -v 跑一次（key 用自己的；如果 key 已经删了，对照下面的输出看就行）。这次 \u0026gt; 的部分是：\n\u0026gt; POST /chat/completions HTTP/2 ← 请求行：方法换成了 POST \u0026gt; Host: api.deepseek.com ← URL 拆出来的 \u0026gt; User-Agent: curl/8.7.1 \u0026gt; Accept: */* \u0026gt; Content-Type: application/json ← 我们用 -H 写的那行，原样成为一行请求头 \u0026gt; Authorization: Bearer sk-**** ← 我们用 -H 写的另一行（身份钥匙） \u0026gt; Content-Length: 333 ← curl 自动算好：请求体有多长 \u0026gt; ← 空行：头到此为止 咦，说好的请求体呢？-v 不回显请求体，但紧跟着有一句旁白说明了请求体已经发送了：\n* upload completely sent off: 333 bytes 说的就是它——我们用 -d 写的那段 JSON，此刻已经作为请求体发了出去，长度正好是 Content-Length 说的那个数。\n一一对应：URL 拆成 Host ＋路径；-H 添的是请求头；-d 填的是请求体。 5.1 那条看起来吓人的长命令，不过是在拼一段规范文本——现在我们亲眼看到它拼出来的样子了。\n关于方法，两个可能冒出来的疑问 疑问一：我从头到尾没说过“用 GET”或“用 POST”，是谁决定的？\n是 curl 替我们决定的，规则很简单：默认发 GET；一旦用了 -d（有请求体要提交），自动改发 POST。 查 IP 那条没有 -d，所以是 GET；DeepSeek 那条带着 -d，所以是 POST——证据刚刚都在 -v 里看过了：两个请求行的第一个词。\n疑问二：调 DeepSeek 我也“取回了数据”啊——凭什么它算 POST，而不算 GET，或者“POST ＋ GET”？\n这是因为 GET / POST 描述的不是“数据往哪边流”。\n看一眼刚才的报文就明白了：无论方法是什么，一次调用永远是完整的一来一回——GET 也发出去了一段请求文本，POST 也收回来了一段响应文本。响应从来都有，它不需要、也不由方法来“申请”。\n方法描述的只有一件事：这次请求的意图。\nGET：“把某样东西给我”——通常不带请求体； POST：“我提交一段内容，请你处理”——内容就放在请求体里。 所以调 DeepSeek 是一次 POST：我们的意图是提交一段对话让它处理；它回来的那段 choices JSON，是这次 POST 的响应，而不是另一次 GET。\n顺便把账记到正确的科目上：GET、POST 是 HTTP 的方法，不是 API 的发明。浏览器打开一个网页，发的就是 GET；在网页上提交一张表单，发的往往就是 POST——API 只是沿用了这套问法。\n常见的方法、常见的头 到这里，我们认识了两个方法（GET、POST）和七八个头。HTTP 定义的不止这些。下面几张表把常见的列出来，不需要记住，但可以大致看一下，建立直觉：以后在真实报文里撞见，能大致猜到它在说什么。\n方法就是“请求的意图”，所以这张表其实就是几种常见的意图：\n方法 意图（一句话直觉） GET 把某样东西给我 POST 我提交一段内容，请你处理 PUT 用我给的内容，把某样东西整个换掉 PATCH 把某样东西改一部分 DELETE 把某样东西删掉 HEAD 跟 GET 一样，但只要头、不要体——探路用 OPTIONS 询问：我能对这个资源做什么？ 常见的请求头——调用方的自我交代：\n请求头 在说什么 Host 我要找哪台服务器 User-Agent 我是谁（什么工具、什么浏览器） Accept 我能接受什么格式的回应 Accept-Language 我偏好什么语言 Content-Type 我提交的请求体是什么格式 Content-Length 我提交的请求体有多长 Authorization 我的身份凭证（5.1 的 Bearer sk-… 就放这儿） Cookie 我随身带的“小纸条” 常见的响应头——服务方对内容的交代：\n响应头 在说什么 Content-Type 我回的体是什么格式（本节的主角） Content-Length 我回的体有多长 Server 我是什么服务器软件 Date 我是什么时间处理的 Cache-Control 这份内容可以缓存、能存多久 Set-Cookie 给调用方发一张“小纸条”，下次来记得带上 Location 内容搬家了，去这个新地址找（配合 3xx 跳转用） Access-Control-Allow-Origin 允许哪些来源的网页调用我 上述这些还不是 HTTP 完整的清单，但日常开发一般了解这些就可以了，如果以后遇到陌生的头，先查再用就行。\n认识 HTTP/1.1 和 HTTP/2 报文里反复出现的 HTTP/1.1、HTTP/2，是协议的版本号，其实还有个更新的 HTTP/3。它们有一些小小的差异，但是差别不大，方法、路径、状态码、头、体这套语义都一样，我们一般也不需要管，知道这件事就行。\n手搓 API 要照顾到什么？ 做一个 API，就是做那个“接话、回话”的程序。但规范里的部件这么多，哪些是必须处理的，缺了就不 work 的那种呢？\n首先是请求这一侧\n请求行里的方法和路径，每个请求都必带，我们作为 API 的提供者，也必须要读取请求行，因为请求行里面包含了方法和路径。如果我们不看请求的方法，就分不清对方是来取数据还是来提交内容；如果不看路径，就分不清对方要访问哪个资源，因为不同的路径，得给不同的回应。 请求头那一排，一般的请求都会带，我们作为 API 的提供者，这些信息可读可不读，建议按需读取，需要用到哪条再读哪条。 关于响应这一侧\n状态行——我们必须返回。不回状态码，这就不是一段 HTTP 响应，调用方会直接报错； Content-Type 响应头——严格说规范允许省略，但省了，调用方就只能猜我们回的是什么格式——实践里按必写对待； 那个空行——必须。它是“头”和“体”之间唯一的分界线；漏了它，调用方会把正文误当成头来解析，全盘皆乱； 响应体——真正要给对方的内容。规范上可以没有体，但做 API 回数据，它就是主角（我们的 JSON 就放这儿）。 从这个角度来看，如果要手搓一个 API，要处理的事儿还不少，因为 HTTP 规范的每一样我们都得亲手写；不过，好在 Python 标准库里有一个专门处理 HTTP 的模块——http.server，接下来我们就用它，把这份清单逐项落实。\n用 Python 实现这套规范 我们要实现的接口，就是以后前端真的会来调用的那一个：GET /api/profile，返回主页要显示的内容（现在这些内容是写死在前端 site.js 里的）。\n这一节我们先挑 site.js 里 home 的前两个字段意思一下就行——今天的重点是 HTTP，不是数据本身。等 5.5 前端真的来调这个接口时，我们再把返回的结构和 home 完整对齐。\n回到 5.2 建好的 ~/zero-to-tech/backend/，照例先激活环境（虽然 http.server 是标准库成员，其实不激活也能跑，但“进项目先激活”这个习惯值得从现在养成）：\nsource .venv/bin/activate 然后新建 main.py：\nfrom http.server import BaseHTTPRequestHandler, HTTPServer import json profile = { \u0026#34;heroTitle\u0026#34;: \u0026#34;关于我\u0026#34;, \u0026#34;heroSubtitle\u0026#34;: \u0026#34;项目，创意，灵感，心得，我的作品\u0026#34;, } class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == \u0026#34;/api/profile\u0026#34;: self.send_response(200) self.send_header(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) self.end_headers() body = json.dumps(profile, ensure_ascii=False) # ensure_ascii=False：让中文原样输出 self.wfile.write(body.encode(\u0026#34;utf-8\u0026#34;)) else: self.send_response(404) self.end_headers() print(\u0026#34;后端已启动：http://localhost:8000/api/profile\u0026#34;) HTTPServer((\u0026#34;\u0026#34;, 8000), Handler).serve_forever() 对着刚才的规范，逐行看这段代码分别实现了规范的哪一部分：\n代码 对应规范里的 def do_GET(self): 请求行里的方法——方法是 GET 的请求，归这个函数管 self.path 请求行里的路径——拿它判断对方要访问哪个资源 self.send_response(200) 响应的状态行——回一个 200 self.send_header(...) 响应头——一行一条，我们写了 Content-Type 这一条 self.end_headers() 那个空行——“头写完了”，头和体的分界线 self.wfile.write(...) 响应体——我们的 JSON（encode 是因为网络上传输的是字节，文本要先编码） else 分支的 404 状态码 404——没找到。模块 4 时是 Nginx 替我们回，现在轮到我们自己回 规范里的每一个部件，都能在代码里找到——这段代码没干别的，就是老老实实按 HTTP 规范“接话、回话”。\n再对照刚才那份“缺了就不 work”的清单，把必须项在代码里点一遍名：\nself.send_response(200)——那条必须的状态行。不写它，回出去的就不是一段 HTTP 响应； self.end_headers()——那个必须的空行。名字里带着 headers，干的活其实是“头到此为止”——漏了它，调用方会把正文误当成头； self.send_header(\u0026quot;Content-Type\u0026quot;, ...)——那条按必写对待的头，告诉调用方“这是 JSON”。 这三行不是可有可无的样板。 前两行属于“硬要求”——删掉其中任何一行，curl 那头直接报错，因为收到的根本不是一段合法的 HTTP 响应；Content-Type 那行删掉倒是还能跑通，但调用方就只能猜我们回的是什么格式了——这正是“规范必须”和“实践必写”的区别，落到了代码上。（404 分支里同样是先 send_response 再 end_headers，硬要求一样不能省，只是没有“体”。）\n最后一行代码里还有个数字值得交代：\nHTTPServer((\u0026#34;\u0026#34;, 8000), Handler).serve_forever() 这是指定了 http 服务在 8000 端口上启动。回想模块 2 讲过的：IP 找到机器，端口找到机器上的某个程序——一台电脑可以同时跑很多网络程序，各守各的端口。80（HTTP）和 443（HTTPS）是网页的默认端口（地址栏里不写端口号时走的就是它们），一般留给正式服务；开发的时候，挑一个没被占用的端口就行。8000 是 Python 圈的习惯值（python3 -m http.server 默认就用它），就像前端圈 Vite 爱用 5173、Next 爱用 3000，都只是习惯，不是规定：改成 9000 也照样跑，只是调用时的地址要跟着变。\n跑起来 python3 main.py 终端打印“后端已启动”，然后——光标停住不动了。别慌，这不是卡死：\n还记得 5.1 说的吗，后端是一个“一直运行、守着等请求”的程序。现在它真的出现在我们的终端里了：serve_forever() 就是字面意思——守在 8000 端口，永远等着。想停掉它，按 Ctrl + C。\n换一个新的终端窗口（旧的那个正跑着服务呢），调用它：\ncurl http://localhost:8000/api/profile 回来一段 JSON。再用浏览器打开 http://localhost:8000/api/profile——同样的 JSON。\n成了。我们写出了自己的第一个 API。 5.1 我们对 ipify、对 DeepSeek 做的事，现在别人也可以对我们做了。\n这时候回头看一眼跑着服务的那个终端，多了几行东西：\n127.0.0.1 - - [03/Jul/2026 15:42:10] \u0026#34;GET /api/profile HTTP/1.1\u0026#34; 200 - 127.0.0.1 - - [03/Jul/2026 15:42:31] \u0026#34;GET /api/profile HTTP/1.1\u0026#34; 200 - 127.0.0.1 - - [03/Jul/2026 15:42:31] \u0026#34;GET /favicon.ico HTTP/1.1\u0026#34; 404 - 每来一个请求记一行，这就是服务的访问日志。注意那行 favicon.ico：我们并没有请求它，是浏览器自动多发了一个请求想要标签页小图标，我们没有，它吃了个 404。所以，凭着请求里带的信息，服务端可以把每个来访者的动作都看得清清楚楚。\n再来一次 curl -v：这回是自己服务器的报文 现在做一次漂亮的闭环。前面，我们用 -v 验证过和 ipify、DeepSeek 之间的对话；现在，对我们自己刚写出来的服务器来一次：\ncurl -v http://localhost:8000/api/profile 还是那三种记号——\u0026gt; 请求原文、\u0026lt; 响应原文、* 旁白：\n\u0026gt; GET /api/profile HTTP/1.1 ← 请求行！ \u0026gt; Host: localhost:8000 ← 请求头 \u0026gt; User-Agent: curl/8.7.1 \u0026gt; Accept: */* \u0026gt; ← 空行，头结束 \u0026lt; HTTP/1.0 200 OK ← 状态行！ \u0026lt; Content-Type: application/json ← 我们写的那行头，躺在这 \u0026lt; {\u0026#34;heroTitle\u0026#34;: \u0026#34;关于我\u0026#34;, ...} ← 响应体 和前面 ipify 的那两段逐行对得上——只不过这一回，\u0026lt; 那一段的每一行，都是我们自己的代码生成的。规范 → 代码 → 真实报文，三点连成一线。\n小字注两条：响应第一行是 HTTP/1.0 200 OK——我们这个极简服务器用的老版本协议，状态码后面带着那句简短说明，就是前面说过的样子；响应头里还多了 Server、Date 两条我们没写的——是 http.server 自动替我们加的，服务器顺带做了自我介绍。\n浏览器视角：F12 里的同一份报文 Chrome 打开 http://localhost:8000/api/profile，按 F12 → Network 面板 → 刷新 → 点开那条请求：\nGeneral：Request URL、Request Method: GET、Status Code: 200； Response Headers：我们 send_header 写的那两行，原样躺着； Request Headers：浏览器发出的请求头——一会儿的实验里细看。 F12 的 Network 我们 4.5 用过，那时看加载顺序；今天点进单个请求的内部——curl -v 看到的和这里看到的，是同一套东西的两个视角。\n动手改两处，做两个实验 我们前面说，响应头里面的 Content-Type 不是“规范必须”，但是它是“实践必写”，那我们就做个小实验，看一看为什么说它是实践必写。\n另外，我们前面还说，服务端可以把来访者看得清清楚楚，那我们也通过这个实验看一下。\n打开 main.py，一次改两处。\n改动一：临时加一个 /hello 路径，把它写到 else 那一行的上边：\nelif self.path == \u0026#34;/hello\u0026#34;: self.send_response(200) self.send_header(\u0026#34;Content-Type\u0026#34;, \u0026#34;text/html; charset=utf-8\u0026#34;) # ← 一会儿改成 text/plain 再试 self.end_headers() self.wfile.write(\u0026#34;\u0026lt;h1\u0026gt;你好，HTTP\u0026lt;/h1\u0026gt;\u0026#34;.encode(\u0026#34;utf-8\u0026#34;)) 后面那截 charset=utf-8 是给浏览器多交代一句：内容按 UTF-8 解码。我们的响应体里有中文，不交代这一句，浏览器可能猜错编码，页面上就是乱码——又一个「头是关于内容的说明」的例子。\n改动二：在 do_GET 的开头加两行打印（给实验二用）：\ndef do_GET(self): print(self.headers) # 收到的请求头 print(self.client_address) # 请求是从哪个地址来的 ... 改完，重启服务（Ctrl + C 再 python3 main.py），两个实验连着做。\n实验一：响应头的威力 规范里说 Content-Type 是“告诉对方内容是什么格式”。空口无凭——浏览器打开 http://localhost:8000/hello：一个大标题，浏览器把内容当网页渲染了。\n现在把代码里的 text/html 改成 text/plain，重启，刷新——变成了原样的一行字，\u0026lt;h1\u0026gt; 标签直接露了出来。\n内容一个字没变，头一变，对方的处理方式就变了。 响应头不是内容本身，而是“关于内容的说明”——application/json 同理：它让调用方知道该按 JSON 解析响应体。\n所以也可以说，在浏览器眼里，“网页”和“API 数据”并没有本质区别——都是一段 HTTP 响应，差别只在 Content-Type，text/html 就当网页渲染，application/json 就当数据处理。所谓“做 API”，从 HTTP 的角度看，不过是选择返回 JSON 而不是返回 HTML。\n实验二：服务端能拿到什么？ 响应这一侧摸透了，回头看请求那一侧——刚才加的那两行 print，现在派上用场。\n先用 curl 调用一次 /api/profile，看服务端终端打印了什么：\nHost: localhost:8000 User-Agent: curl/8.7.1 Accept: */* 再用浏览器访问一次：\nHost: localhost:8000 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ... Accept: text/html,application/xhtml+xml,... Accept-Language: zh-CN,zh;q=0.9 Accept-Encoding: gzip, deflate ... 可以看到一大串，但请注意两件事：\n这些头，我们一行都没写过——是 curl、浏览器自动带上的。User-Agent 就是“我是谁”的自我介绍：curl 老老实实报名字和版本，浏览器报出一长串型号。服务端一眼就能分辨请求是浏览器来的还是程序来的； 这正是规范图里“请求头”那一段——在 F12 的 Request Headers 看到的，就是它们出发前的样子；现在我们站在服务端，看到了它们在服务端被接收。 另外，在 5.2 的时候，我们也写过一个 api_demo.py，当时用它请求 ipify 的 API，也可以自己试一下，用它来请求我们的 http://localhost:8000/api/profile，观察一下服务端拿到了什么样的 User-Agent。\n改动二还有第二行 print(self.client_address)，它打出来的是类似 ('127.0.0.1', 54321) 的一对值——发起这次请求的 IP 和端口。本机访问本机，所以是 127.0.0.1；要是别的机器来调用，这里就是对方的 IP。也就是说，服务端天然看得见每个请求是从哪个地址来的——5.1 里 ipify 能报出我们的公网 IP，根源就在这。\n一句边界感 服务端天然能看到 UA、IP、语言偏好这些元信息——这是很多功能的地基（访问统计、防刷、按语言返回内容），也是一个提醒：我们在网络上发出的每个请求，都比自己以为的更“透明”。这也是为什么不要在不信任的网站上乱发请求。\n（两个实验做完：把 /hello 那段删掉，保持 main.py 干净；那两行 print 留着也无妨——下一节这份手搓版整个会被存档成纪念版。）\n数数我们干了多少杂活 最后，清点一下这一节的劳动。为了按规范“把一段 JSON 发出去”，我们亲手做了：\n路由：if / elif 自己判断路径，还得记着写 else 兜底 404； 状态行：每个分支自己 send_response； 响应头：一行一行自己 send_header——一个接口两行，十个接口二十行； 空行：连“头结束”都要自己 end_headers； 响应体：自己 dumps、自己 encode； 而这才一个接口，还只是 GET——要是 POST，还得自己从请求体里读字节、解析 JSON、校验字段全不全…… 一个接口尚且如此，真实项目几十个接口，这么写下去是要出人命的。\n好消息是：这些杂活不属于任何一个具体项目——它们属于 HTTP 规范，谁写后端都得来一遍。 无论用什么语言写后端——Python、JavaScript、Go——写的都是对同一套规范的实现，所以 5.1 才说，API 的概念和语言无关。\n一模一样的事，就有人打包做好了给大家复用。打包好的那个东西，叫框架。\n下一节，FastAPI 登场。我们会看到这一节的全部杂活在框架里缩成几行——而正因为亲手按规范搓过一遍，我们会确切地知道它替我们干了什么。（框架会把状态码和头都藏起来，但它们一直都在——我们已经摸过一遍，以后想看，curl -v 和 F12 里随时都在。）\n这一节你应该带走什么 这一节的知识点，几乎全记在 HTTP 名下：报文格式、方法、状态码、头、版本——它们是整个 Web 的对话规范，浏览器打开网页用的也是这套；API 只是用 HTTP 对外提供能力的一种用法（回 JSON 而不是回 HTML）。 HTTP 的一来一回是两段有格式的纯文本：请求 ＝ 请求行＋头＋空行＋体；响应 ＝ 状态行＋头＋空行＋体——格式对称。 方法描述的是请求的意图，不是数据方向：GET＝“给我”，POST＝“我提交内容请你处理”；响应永远都有，和方法无关。curl 默认发 GET，带上 -d 自动改发 POST。 HTTP/1.1、HTTP/2、HTTP/3 是协议的版本：彼此有些小差异，但方法、路径、状态码、头、体这套语义都一样——一般不用管。 状态码家族：2xx 成功，4xx 请求方的问题，5xx 服务方的问题；404 现在轮到我们自己回了。 回一段响应，哪些不能省：状态行、空行是硬要求（缺了调用方直接报错），Content-Type 按必写对待（省了对方只能猜）——这是 HTTP 的要求，谁写后端都绕不开，框架也只是替我们写。 Content-Type 告诉对方内容是什么格式——内容不变、头一变，对方的处理方式就变。 服务端能看到的，不止我们显式发的内容：User-Agent、来源 IP、语言偏好……都是自动带上的元信息——ipify 的谜底就在这。 我们写出了自己的第一个 API——零依赖，纯标准库；后端就是一个“一直跑着、守着等请求”的程序（Ctrl + C 停止）；curl -v（\u0026gt; 请求、\u0026lt; 响应、* 旁白）和 F12 随时能看到报文原文。 任何语言写后端，都是在实现同一套 HTTP 规范——杂活人人一样，所以有了框架。下一节 FastAPI。 ← 上一节：模块 5.2 Python 的安装和环境设置 | 下一节：模块 5.4 从手搓到框架，FastAPI 登场 →\n","date":"2026.07.13","description":"拆开一次 HTTP 通信，并用 Python 手搓出第一个 API。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-5-3/","title":"模块 5.3：看懂 HTTP，手搓 API"},{"content":" 把 Python 装到电脑上，给项目建好自己的专属环境（venv），跑起第一个 Python 程序、认一认代码结构，再动手装一个第三方库（requests）用 Python 调一次 API——pip、requirements.txt 一并落地。后端要用的环境，这一节一次备齐。\n我们需要 Python 了 上一节我们亲手调用了两个 API，也定下了目标：接下来要用 Python，给我们的网站写一个自己的 API。\n要运行 Python 写的代码，电脑上得先有 Python。\n这个感觉你应该不陌生。如果你已经做过现代前端的学习实战，那很容易想到本地运行 JS 代码之前要先装 Node——因为 Vite、Next 那一套都得靠 Node 才能跑起来。\n后端也是同一个道理：先有运行环境，再谈写代码。 前端项目的运行环境是 Node，我们后端是用 Python 写的，所以项目的运行环境就是 Python。\n这一节把后端要用的环境一次备齐：装好 Python、给项目建一套自己的专属环境、跑起第一个程序认一认代码，最后动手装一个第三方库、用 Python 调一次 API。\n装 Python 安装 Python 之前，可以先确认一下你电脑上是否已经安装了 Python：\npython3 --version # 打印出 Python 3.x 的版本号，就装好了 或者（Windows 一般是输入 python 而非 python3）：\npython --version 打印出 Python 3.x 的版本号，就说明已经安装了 Python。 如果打印出 Python 2.x 的版本号，那要注意一下：2.x 版本的 Python 官方已经不再维护了，我们也不建议再用。 无论你当前电脑上是否已经安装有 Python，我都建议你重新安装一下最新版的 Python（多个不同版本的 Python 可以在电脑上共存，不用担心冲突）。\n安装方式和当初装 Node 一样：去官网下载安装包。\n打开 Python 官网下载页：https://www.python.org/downloads/ 下载最新稳定版的安装包（macOS 是一个 .pkg 文件，Windows 是 .exe）； 双击安装，一路默认下一步即可； 装完后，重新开一个终端窗口，验证： python3 --version # 打印出 Python 3.x 的版本号，就装好了 或者：\npython --version 应该就可以看到刚刚安装的最新版 Python 的版本号了。\npython3 和 python 为什么在 macOS 上敲的命令是 python3 而不是 python 呢？\n和 JavaScript 语言一样，Python 语言诞生也已经有几十年了。在漫长的发展过程中，它也经历过一次不兼容的大版本升级。\n早期的 Python 世界，几乎所有开发者都在使用 Python 2。后来，Python 官方为了修复一些历史设计问题，推出了 Python 3。但这一次升级并不像浏览器自动更新那样平滑，Python 3 中有不少语法和行为与 Python 2 不兼容。\n这意味着，在很长一段时间里，一台电脑上可能需要同时安装 Python 2 和 Python 3——因为有些老项目只能运行在 Python 2，而新项目则推荐使用 Python 3。\n为了避免混淆，许多 Linux 和 macOS 系统约定：\npython 指向 Python 2（或者保留为空）； python3 明确指向 Python 3。 久而久之，python3 就成为了许多系统和教程中的标准写法。\n不过，随着 Python 2 已经停止维护（2020 年正式结束支持），如今越来越多的系统重新把 python 指向了 Python 3。例如在 Windows 官方安装器、以及很多现代 Linux 发行版中，直接输入 python 就已经启动的是 Python 3。\n对于我们使用来说，可以分别试一下，哪个能用就用哪个。\n如果我们电脑里不止一个 Python 对于以前从来没有安装过 Python、但电脑上仍然已经有 Python 的情况——这很正常。它可能是操作系统自带的，也可能是你以前装别的软件时顺带装上的。\n我们前面说过，一台电脑上，可能同时住着好几个 Python。\n那问题来了：我敲 python3 的时候，用的到底是哪一个？\n有一个终端命令，可以查询并回答这个问题。在 Mac 下可以输入：\nwhich python3 如果是 Windows 或 Linux，可以看如下的对照：\n操作系统 命令 macOS which python3 Linux which python3 或 command -v python3 Windows（CMD） where python Windows（PowerShell） Get-Command python 或 where.exe python 它会打印出一条路径——这就是你现在敲 python3 时，真正被执行的那一个。\n还可以更进一步，把候选名单全列出来：\nwhich -a python3 你可能会看到好几条路径（每个人机器不一样，多少不等）——排在最前面的那个，就是当前生效的。\n一个值得记住的生存技能：\n以后遇到“版本不对”“明明装了却找不到”这类怪事，第一招就是 which python3（macOS / Linux）或 where python（Windows）——先搞清楚自己此刻用的到底是哪一个 Python。很多环境问题，看一眼路径就真相大白。\n如果某一次我们明确想用其中某一个版本，但它并不是 python3 这个命令指向的那个默认版本，该怎么办呢？我们可以直接在 python 命令里指明需要用到的版本号。\n例如我的电脑上同时安装有 python3.12 和 python3.14，默认 python3 指向的是 python3.14：\n% python3 --version Python 3.14.6 但如果我明确想用 python3.12，就可以不用 python3 这个命令，而是直接用 python3.12：\n% python3.12 --version Python 3.12.9 venv 与环境隔离 盘点一下刚才看到的现状：电脑里可能住着好几个 Python，python 和 python3 还可能各指各的——每次都要靠 which 确认“我现在用的到底是哪一个”，这本身就是个负担。这是关于不同 Python 版本管理的困境。\n此外，还有另一个问题：我们还需要安装一些第三方库、工具包，这些工具包也各自有自己的版本。以后你电脑上不会只有一个基于 Python 开发的项目，它们各自需要的工具包、甚至包的版本都可能不一样，如果全挤在同一份公共 Python 里，早晚会打架。\n什么叫第三方库？还记得前端的时候我们用过的那个 anime.js 动画库吗？那个就是 JS 生态中的一个第三方库。Python 生态中这样的第三方库也有非常多，我们几乎肯定会用到它们。\n所以，事情现在变得复杂了。首先，电脑上会有不同版本的 Python，我们的不同项目可能分别依赖不同版本的 Python；其次，每个项目还会各自依赖一些不同版本的工具包。\n在 4.2 讲前端依赖时，我们立过一条规矩——依赖应该跟着项目走，每个项目有自己的 node_modules。Python 项目应对同样的处境，也有相似的方案。\nPython 官方对这个局面的解法是：别在系统那堆 Python 里纠结，给每个项目配一套自己专用的环境——术语叫虚拟环境（virtual environment）。Python 自带了一个做这件事的工具，叫 venv。\n具体怎么做呢？\n一个示例 假设一个项目的路径是 ~/project_A，它需要用到 Python 3.12。那么我们就可以先进入项目目录，然后创建一个 .venv：\ncd ~/project_A python3.12 -m venv .venv 这个 .venv 是一个目录，它会帮我们管理当前目录的 Python 环境——用的是电脑上哪个版本的 Python、安装了哪些第三方包，以及这些包分别是什么版本。\n但创建 .venv 之后，还无法立即使用（这点与 Node 的 package.json 不同）：.venv 还需要激活才可以。\nsource .venv/bin/activate 激活之后，终端命令行的最前面，会自动带上 (.venv) 的标识。在这种情况下，无论用 python 命令（此刻可以用 python 命令了，哪怕之前不能用、只能用 python3）还是 python3 命令，都只会指向 Python 3.12；并且安装第三方库的时候，也只会装到 ~/project_A 这个项目下的 .venv 之中，而不是全局。\n如果此时关闭终端会话，再开一个新的终端会话，那么这个 (.venv) 就消失了，venv 虚拟环境也就退出了。\n或者，我们也可以不关闭终端会话，而是用下面的命令显式要求退出：\ndeactivate 常见疑问 1. 两个项目的虚拟环境目录都叫 .venv，激活后都显示 (.venv)，怎么区分？ 如果有两个项目 project_A 和 project_B，它们管理虚拟环境的目录都叫 .venv 吗？激活之后都是显示 (.venv) 这个提示符吗？我该如何判断当前在哪一个虚拟环境？\n管理虚拟环境的目录不是必须叫 .venv，但这是一个约定俗成的名称，许多配套工具（例如 VS Code）都认得它，尤其是 AI 认得它，不建议变更。\n如果想在激活之后让终端有一个不同的提示符，可以在创建 .venv 的时候给它一个自定义提示符：\npython3 -m venv --prompt=project_A .venv 这样，创建的 venv 目录名仍然是 .venv，但在终端上看到的就不再是 (.venv) 而是 (project_A) 了。\n2. 我已经在用 miniconda / anaconda 了，接下来怎么用 venv？ 如果你打开终端之后，每次命令行的最前面都会有一个 (base) 这样的提示符，那么大概率说明你已经在使用 miniconda / anaconda 了，我们统称为 conda。\nconda 也可以管理 Python 虚拟环境。如果你已经在使用 conda 了，那么就不再建议你使用 venv 进行 Python 环境的管理——因为 conda 采用的是一个完全不同的环境管理策略，如图所示：\nconda 实际上是全局安装的，用一套自己的方式在管理多个不同的 Python 环境，每一个环境都会有一个自己的名字，默认的环境名叫 base，用户可以自己创建一个新的环境（比如 myenv312），每一个环境中可以安装不同的 Python 版本。\nconda 的“侵略性”比较强，一旦按照官方的指引安装了 conda 之后，每次启动终端的时候，conda 都会启动。此时直接输入 python，运行的就会是 conda 的默认环境（即 base 环境）。\n与 Python 内置的 venv 不同的是，conda 并不会在项目中创建一个类似 .venv 的目录，所以项目自身并不保存它所依赖的 conda 环境的上下文，需要开发者自己记住项目与环境之间的对应关系。\n从这个角度来说，在 vibe coding 时代，venv 的方式实际上更有优势——AI Agent 可以在项目中明确地理解到项目的依赖。\n我们的课程后续会使用 venv 来做 Python 环境的管理。如果你现在在用 conda，并且想以后也改用 venv，那么建议先退出 conda。\n3. 如何退出 conda 如果每次打开终端都会看到 (base) 这样的提示符，那么可以执行下面的命令，关闭 conda 的自动启动：\nconda config --set auto_activate_base false 这里有个版本差异要注意：较新版本的 conda（24.9 及以后） 把这个配置项改名成了 auto_activate，所以新版也可以写成：\nconda config --set auto_activate false 新版用 auto_activate_base 仍然有效，只是会多打印一句“该项已弃用”的警告。两条命令你机器上哪条不报错就用哪条——旧版认前者，新版两者都认。\n执行之后，关闭终端，再打开，就不会看到 (base) 提示符了。以后再启动终端，也不会默认进入 conda 的 base 环境了。\n上面这个命令只是不再默认启动 conda，并不代表 conda 被移除了。如果以后还需要用到 conda，可以用下面这个命令重新进入：\nconda activate 需要退出 conda 的时候，用下面这个命令可以退出：\nconda deactivate 在项目中创建后端目录和 .venv 环境问题搞定了，接下来我们就回到自己的项目，创建一个后端目录，用 Python 小试牛刀。先给我们的后端代码安个家，就放在贯穿全程的项目里：\ncd ~/zero-to-tech mkdir backend cd backend 这里你可能会冒出一个问题：在 zero-to-tech 项目下，后端有了自己的 backend/ 目录，那要不要也建一个 frontend/，把前端挪进去，弄得对称一点？\n这里我们选择不挪。\n一般而言，新起的全栈仓库确实常见 frontend/ 和 backend/ 并列——但我们这个仓库不是设计出来的，是长出来的：它从模块 3 的一个静态页开始，一路长成 React、再长成 Next，前端占着根目录，是它成长的年轮。这也是真实老项目的常态：结构带着历史痕迹，只要不碍事，就不为了对称而重构。 现在它一点都不碍事——前端在根目录照常 npm run dev，后端在 backend/ 里各干各的，互不打扰；而且 4.6 配好的 Nginx 也全都继续有效。哪天它真碍事了（比如要把前后端拆成两个仓库），再挪不迟——到那时，你自己已经完全有能力挪了。\n顺便，这也回答了“前后端的独立体现在哪”：不在目录的组织方式上，而在两个独立的进程、两套独立的依赖、两种独立的部署方式上——这些我们接下来几节会挨个碰到。\n然后在 backend/ 里创建虚拟环境（顺手用上刚学的 --prompt，给它起个一眼能认出的名字）：\npython3 -m venv --prompt=zero-to-tech .venv 执行完，ls -a 看一眼——多了一个 .venv 文件夹。这个文件夹就是虚拟环境本身：里面放着一份专属于这个项目的 Python（还有一个叫 pip 的工具——它是干什么的，等真正需要的那一刻再说）。\n建好了，我们先“激活”（activate）：\nsource .venv/bin/activate 注意看提示符，前面多了一个 (zero-to-tech)：\n(zero-to-tech) libo@Mac backend % 这个括号在提醒你：你现在用的，是这个项目自己的那套 Python。 空口无凭，拿刚学的 which 验证一下：\nwhich python # → /Users/你的用户名/zero-to-tech/backend/.venv/bin/python which python3 # → /Users/你的用户名/zero-to-tech/backend/.venv/bin/python3 两条路径都指进了 .venv——刚才那一堆候选 Python 带来的混乱，在这个项目里就此终结：python 和 python3 指向同一份，就是项目自己的这份，再无悬念。\n想退出来的时候，一个词：\ndeactivate 提示符前的 (zero-to-tech) 消失，你又回到了系统环境。感受完就再 source .venv/bin/activate 回来。从今往后给自己立个习惯：进这个项目干活，先激活。\nWindows 的同学：激活命令是 .venv\\Scripts\\activate，其余一致。 一个小工具：VS Code 有个微软官方的 Python 扩展（左侧“扩展”面板里搜 Python，装微软出的那个——它同时也负责 Python 的语法高亮、补全、调试，第一次写 Python 建议装上）。装好之后，它会自动认出项目里的 .venv；以后在 VS Code 里新开一个终端，它会自动帮我们激活环境。 顺手立一条规矩：.venv 不进 Git。 它和 node_modules 一个性质——本地生成、体积不小、随时可重建。在项目的 .gitignore 里加上一行：\nbackend/.venv/ 第一个 Python 程序 环境好了，我们写一个程序运行一下：用 Python 生成一段 JSON。\n为什么是 JSON？回想上一节——API 回给调用方的，基本都是 JSON。这正是我们的后端马上要天天干的事。\n确认自己还在 backend/ 目录、提示符上带着 (zero-to-tech)，然后用 VS Code 打开这个目录，新建一个文件 first_json.py，写入：\nimport json site_name = \u0026#34;zero-to-tech\u0026#34; def make_data(): data = {\u0026#34;message\u0026#34;: \u0026#34;hello, world\u0026#34;, \u0026#34;from\u0026#34;: site_name} return json.dumps(data) print(make_data()) 回到终端，运行它（运行之前，先确定当前目录下有这个文件）：\npython3 first_json.py 终端打印出：\n{\u0026#34;message\u0026#34;: \u0026#34;hello, world\u0026#34;, \u0026#34;from\u0026#34;: \u0026#34;zero-to-tech\u0026#34;} 一段 JSON 出来了。这就说明我们的 Python 运行环境已经好了。\n可以看到，运行一个 Python 程序，就是“一条 python3 命令”＋“一个 .py 文件”，就这么直接——不用构建、不用编译、不用浏览器。以前我们的 JS 代码靠浏览器跑，现在我们的 Python 代码靠 python3 跑。\n顺着这几行代码，认一认 Python 先说清楚：这不是语法课。 在有 AI 的时代，语法可以边用边查；我们真正需要的，是认得结构——看到一段 Python 代码，知道它大致的写法。我们一起看一下（看不懂也没关系）。\n刚才这个程序虽然就短短几行，但也已经是比较典型的 Python 代码文件。从上往下看。\n① 引入库 import json 第一行，通过 import 引入一个 json 库。\nPython 在安装的时候，就自带了一大批现成的工具，统称标准库——要用哪个，import 一下就能用，不用另外安装。这个 json 就是标准库的一员，它的作用是处理 JSON 这种格式。除了它之外，常见的标准库成员还有：\ndatetime——日期和时间； random——随机数； os——和操作系统打交道（文件、路径、环境变量）； http.server——一个能接收网络请求的小服务器（注意这个，下一节会用）。 有标准库，自然就有第三方库——不是 Python 自带的，而是世界各地的开发者写好后发布到网上的库，使用第三方库的时候要先安装，才能 import。这套逻辑我们在前端见过：比如 anime.js 就是我们用 npm install 装回来的第三方库。Python 这边的第三方库装到哪儿，你今天其实已经准备好答案了——就装进 .venv 这个项目专属的环境里；至于怎么装，下一段我们就装一个、用一个，亲眼看它落进 .venv。\n② 变量 site_name = \u0026#34;zero-to-tech\u0026#34; 第二行，定义了一个叫 site_name 的变量，它的值是 \u0026quot;zero-to-tech\u0026quot;。这个写法很容易理解，就是直接 名字 = 值。\n③ 函数，以及缩进 def make_data(): data = {\u0026#34;message\u0026#34;: \u0026#34;hello, world\u0026#34;, \u0026#34;from\u0026#34;: site_name} return json.dumps(data) def 用来定义函数。注意函数体是靠缩进表示的——这是 Python 最显眼的规矩：JS 用花括号 {} 圈定代码块，Python 里缩进本身就是语法。哪些行缩进对齐，哪些行就属于同一块。\n函数里面的最后一行是 return，就是这个函数最终会给出一个什么结果。\n④ 打印 print(make_data()) print 的意思就是在终端上打印（或者说显示）一些内容，这一行的意思就是把 make_data() 这个函数的结果打印到终端。\n所以这段代码会把函数里面定义的 data 的值以 JSON 格式打印出来。\n装第一个第三方库：用 Python 调一次 API first_json.py 里的 json 是标准库，import 就能用。但让 Python 真正强大的，是那些海量的第三方库。刚才只是嘴上说了说，这就来装一个、用一个——顺便把 pip 和依赖清单一次讲透。\n装什么好呢？还记得 5.1 我们用 curl 查公网 IP 吗？那件事，Python 也能做。新建一个 api_demo.py：\nimport requests resp = requests.get(\u0026#34;https://api.ipify.org?format=json\u0026#34;) print(resp.json()) requests 是 Python 世界最有名的第三方库之一，专门用来发网络请求——你可以把它理解成“代码版的 curl”。跑跑看：\npython3 api_demo.py 结果不是 IP，而是一段报错：\nModuleNotFoundError: No module named \u0026#39;requests\u0026#39; 报错是线索（这句话这门课会一直强调）。它说得很直白：找不到 requests 这个模块——因为它不是标准库，Python 没自带，得先装。\npip：装第三方库的工具 装 Python 第三方库的工具，叫 pip——它在你装 Python 时就一起装好了，就是后端世界的 npm（4.2 你用 npm install 装过 anime.js，现在轮到 pip install）。先确认自己在 (zero-to-tech) 环境里（提示符带着括号），然后：\npip install requests 一条生存法则，从现在记起：装包之前，先瞄一眼提示符，确认 (zero-to-tech) 在。 最常见的翻车就是忘了激活、把包装到了外面——回头一跑代码报“找不到模块”，人就懵了。\n看它滚动的输出——除了 requests 本身，还捎带装了 certifi、charset-normalizer、urllib3、idna 几个你没点名的：依赖还有依赖，npm 那边如此，pip 这边也一样。\n装完，问一个最关键的问题——它装到哪儿去了？ 看一眼：\npip show requests Name: requests Version: 2.34.2 Location: /Users/你的用户名/zero-to-tech/backend/.venv/lib/python3.x/site-packages Location 那行，路径指进了 .venv——正是我们刚建的那间“项目专属的屋子”。requests 只住进了这个项目，没安装到全局环境，所以也不会和别的项目打架。前面讲了半天 venv 的道理，这一刻落到实处了。（也可以敲 ls .venv/lib/python*/site-packages/，亲眼看到 requests 的目录就躺在里面。）\n现在再跑一次 api_demo.py：\npython3 api_demo.py {\u0026#39;ip\u0026#39;: \u0026#39;114.86.123.45\u0026#39;} 通了。 我们用 Python 查到了自己的公网 IP——和上一节（5.1）用 curl 干的是同一件事，只不过这回是程序在调 API，正是 5.1 说的那句“API 是设计给计算机程序用的”。（往后我们自己的后端，也可以像这样去调别人的 API。）\n眼尖的你可能发现：这回打印出来的 {'ip': ...} 是单引号，而刚才 first_json.py 打印的 {\u0026quot;message\u0026quot;: ...} 是双引号，怎么不一样？因为它们其实是两种东西：first_json.py 里 json.dumps(...) 产出的是一段 JSON 文本（JSON 规定用双引号）；而这里 resp.json() 直接把返回的 JSON 解析成了一个 Python 字典，打印字典时 Python 习惯用单引号。字典 ≈ JSON——这个对应关系以后天天见。\nrequirements.txt：给依赖记一本账 现在冒出一个新问题：.venv 不进 Git（刚立的规矩），那别人拿到这个项目之后，怎么知道要装 requests？或者我们把代码拉到服务器上之后，应该如何安装依赖？\n答案和前端一模一样：前端靠 package.json 记依赖，Python 靠 requirements.txt。生成它：\npip freeze \u0026gt; requirements.txt cat requirements.txt 看一眼：\ncertifi==2026.6.17 charset-normalizer==3.4.8 idna==3.18 requests==2.34.2 urllib3==2.7.0 每个包一行，版本号钉得死死的（连那几个“依赖的依赖”也一并列上了）。以后任何人拿到项目，一条命令就能装齐一模一样的环境：\npip install -r requirements.txt 这就是后端版的 npm install。和 .venv 相反，requirements.txt 要进 Git——它是项目的一部分，得跟着代码走。到模块 7 上服务器时，需要在服务器上安装依赖的时候，这份清单的价值就能体现了。\n又凑齐一组“角色对应”，前端后端一一对上：\n前端 后端 干的活 npm pip 装第三方包 node_modules/ .venv/ 装到哪儿（都不进 Git） package.json requirements.txt 依赖清单（都进 Git） 这一节你应该带走什么 后端项目和前端项目一样，先有运行环境再谈写代码：Python 从官网装，python3 --version 验证。 一台电脑可能有多个 Python；which python3 看当前用的是哪一个——排查环境问题的第一招；想用特定版本，直接敲 python3.12 这样带版本号的命令。 venv 给每个项目一套自己的 Python：python3 -m venv .venv 建（可加 --prompt=名字 自定义提示符）、source .venv/bin/activate 进（提示符出现 (.venv) 或那个自定义名字）、deactivate 出；激活之后 which python 指进项目里——多 Python 的混乱就此和这个项目无关。进项目干活，先激活。 .venv 不进 Git（node_modules 的老规矩）；conda 是同类环境工具——若你在用，本节也讲了怎么退出、改用 venv。 运行 Python 程序 = 一个 .py 文件 ＋ 一条 python3 命令。 认得四个结构：import、变量、def ＋缩进、字典（≈ JSON）——不用背语法，认得就行。 标准库随 Python 一起安装、import 即用（json、datetime、random、os、http.server……）；第三方库（如 requests）得先装才能用。 pip install 装第三方库，装进当前激活的 .venv（pip show 看落点）——装包前先看提示符；requests 让你用 Python 调了一次 API（5.1 curl 的“代码版”）。 requirements.txt 记依赖清单（pip freeze 生成，pip install -r 复现）——它和 .venv 相反，要进 Git。前端 npm/node_modules/package.json ↔ 后端 pip/.venv/requirements.txt。 我们已经能用 Python 生成并收发 JSON；下一节把它做成一个真正的服务——手搓第一个 API。 ← 上一节：模块 5.1 究竟什么是 API？ | 下一节：模块 5.3 看懂 HTTP，手搓 API →\n","date":"2026.07.07","description":"准备好 Python 后端环境，并用第一个程序调用 API。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-5-2/","title":"模块 5.2：Python 的安装和环境设置"},{"content":" “API”这个词，在今天几乎无处不在——AI、Agent、大模型、天气、地图……张口就来。它听起来很神秘。这一节我们不解释、不打比方，直接拿两个真的 API 来，你亲手调用一下，就明白了。\n从一个朋友的问题说起 不止有一个人问过我，究竟什么是 API。\n比如说我的一个朋友是在医院工作的，有天问我说，他听说 Claude 特别厉害，想要用一下，但是找了个渠道说是提供 API，他就问我这个 API 是什么意思。\n从这一节开始，我们进入系列课程的后端部分，就从 API 讲起。这一节我尽量把 API 讲明白，也带你亲手试一次，感受它到底是什么。\n理解什么是 API 特别重要。因为无论是想要做 web 开发、手机 app 开发，或者就是想要认真理解 AI 时代这些新东西，几乎绕不开它——现在有各种开放的 API，也有许多人自己会开发一些 API。有一些 API 能够查天气、有一些 API 能获得大模型的回复、有一些 API 能帮我们做一些计算，它可以说是无处不在。这个概念非常值得我们了解。\n而且，我们的零到全栈系列课程也正好走到这儿了。你还记得吗，文字实验室那一页，点“开始分析”是没反应的，拼音和情感分数都是写死的假数据。要让它真的能算，就得给网站补上一个“后端”；而前端和后端之间怎么对话，靠的也是 API。\n有一些人讲 API 的时候，会做一些比喻，比如说把 API 比喻成餐厅的后厨之类的，我觉得这样比喻有时候适得其反，反而可能让人更迷糊。\n想要理解什么是 API，最快的方式，在我看来就是亲自找一个 API，调用一下。调用完之后，我相信你自己就可以知道它是什么。如果你还可以自己做一个 API，做完之后那就完全通透了。\n我觉得学习任何概念类的东西，都是这样，有一些东西，如果没有用过，那么没办法解释，或者说很难解释。但是你如果用过一两次，那么就不需要解释。\n那我们就先找一个 API 用一下。\n第一个：查一下你自己的公网 IP 我们先调用一个零门槛的——不用注册、不用花钱、不用申请任何东西。\n看一个网站 https://ipify.org，这个是一个免费开放的网站服务。它只干一件事：它可以告诉你，你现在的公网 IP 地址是多少。（公网 IP 是什么，我们在模块 2 讲过——它就是你在互联网上的“地址”。）你在它的主页上看到的那个 IP，就是你现在的 IP 地址。\n但是除了这个网页之外，它还提供 API 服务。我们可以尝试在终端里面调用一下它提供的 API。\n首先介绍一个新的终端命令：curl。它是一个在终端里发网络请求的小工具——平时我们可以用浏览器访问网址，但其实用终端也可以访问一个网址，curl 就是在命令行里访问一个网址的时候用的，它会把服务器原样返回的内容直接打印出来，特别适合用来试 API。（macOS 和大多数 Linux 都自带 curl，直接用即可。）\n在 https://ipify.org 的官网首页，也提供了这个 API 的访问方式。\n打开终端，把下面这行粘进去，回车：\ncurl \u0026#39;https://api.ipify.org?format=json\u0026#39; 网址用引号括起来了。也可以不用引号，但是因为这个网址里头有 ? 这样的符号，终端可能会误解，加上引号最稳妥——告诉终端“这一整串都是网址”。\n按下回车之后，很快终端里就回来一小段 JSON：\n{ \u0026#34;ip\u0026#34;: \u0026#34;114.86.123.45\u0026#34; } 就这么一句。 你没打开任何网站，就问到了自己此刻的公网 IP——就是 ip 那个字段（你看到的会是你自己的 IP）。整个响应就一个字段，干干净净。\n这个请求特别简单，简单到你甚至可以直接把那串网址粘进浏览器地址栏，回车，就能看到同样的一段数据。（这也顺带说明一件事：你平时用浏览器打开网址，本质上也是在向服务器“发请求、拿响应”。）\n我们刚才做的这件事，就叫“调用 ipify 的 API”。\n第二个：调用 DeepSeek 的 API 再调用一个不一样的。这次我们不是“查一份数据”，而是调用 DeepSeek 的大模型，让它回我们一句话。\n这个稍微有点门槛，因为对方得知道“是谁在调”（一来算用量，二来防止被乱用），所以要先拿一把“身份钥匙”，也就是 API Key。\n注册 / 登录 DeepSeek 开放平台：https://platform.deepseek.com/ 找到 API keys 页面，点击创建 API keys，起一个名字，就可以创建一个 API key 了，得到一串以 sk- 开头的字符串，复制存好（只完整显示这一次）。 DeepSeek 的 API 按用量计费，需要充一点点余额——别担心，我们就调用几次，一次花不了几分钱。 完整、最新的说明以官方文档为准：https://api-docs.deepseek.com/zh-cn/。照着别人的文档，用上别人的能力——这本身就是这一节想让你体会的事。如果你此刻实在不想充值，也可以先跟着往下读、把道理看懂；但我强烈建议你亲手调用一次，那种“我居然直接用上了大模型”的感觉，非常值得。\n拿到 key 后，看“接口文档”：\n进入接口文档，可以看到 DeepSeek 提供了使用 curl 调用对话 API 的示例。\ncurl https://api.deepseek.com/chat/completions \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -H \u0026#34;Authorization: Bearer ${DEEPSEEK_API_KEY}\u0026#34; \\ -d \u0026#39;{ \u0026#34;model\u0026#34;: \u0026#34;deepseek-v4-pro\u0026#34;, \u0026#34;messages\u0026#34;: [ {\u0026#34;role\u0026#34;: \u0026#34;system\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;You are a helpful assistant.\u0026#34;}, {\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;Hello!\u0026#34;} ], \u0026#34;thinking\u0026#34;: {\u0026#34;type\u0026#34;: \u0026#34;enabled\u0026#34;}, \u0026#34;reasoning_effort\u0026#34;: \u0026#34;high\u0026#34;, \u0026#34;stream\u0026#34;: false }\u0026#39; 把这个示例里面的 ${DEEPSEEK_API_KEY} 换成刚刚复制的以 sk- 开头的那段 API key。\n至于其他部分，我建议把下面这行里面的 Hello! 改为一句其他的 prompt，比如说改为 你好，请用一句话介绍你自己：\n改之前：\n{\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;Hello!\u0026#34;} 改之后：\n{\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;你好，请用一句话介绍你自己\u0026#34;} 然后，把改完之后示例粘贴到终端，按回车。\n稍等一下，回来的数据里（删掉次要字段后）大概是：\n{ \u0026#34;choices\u0026#34;: [ { \u0026#34;message\u0026#34;: { \u0026#34;role\u0026#34;: \u0026#34;assistant\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;你好！我是 DeepSeek，一个由深度求索打造的 AI 助手，很高兴为你服务。\u0026#34; } } ] } 如果看到类似这样的回应，那么就成功了。 我们没打开任何网页，就在自己的终端里，让 DeepSeek 的大模型回了我们一句话，答案就藏在 choices → message → content 里。这，就是“调用 DeepSeek 的 API”。\nAPI 是设计给计算机程序用的 如果不用 API，我们其实也可以直接通过访问 ipify.org 来查询我们的 IP 地址，还可以使用 DeepSeek 的网页服务或者手机 APP 来使用 DeepSeek 大语言模型。\n但是如果是我们有一个计算机程序想要查看我们当前的 IP 地址，或者我们有一个计算机程序想要使用 DeepSeek 的大语言模型，那么网页的方式就行不通了——程序总不能每次都去打开一个浏览器。\n所以，对于程序调用来说，最友好的方式就是通过 API 这样的方式来使用这些服务。API 的设计本身就是为计算机程序服务的。\n这种 “把自己的能力，持续地通过一个固定的入口对外提供出去，让别的程序来调用”的做法——是整个软件世界通行的做法。 这个对外的入口，就是 API，它的英文全称是 Application Programming Interface（应用程序编程接口）。\nAPI 的格式标准 我们把刚才这两次调用 API 并获得结果的过程放在一起看。\n这两个 API 功能不同，但调用它们的方式，几乎一模一样：\n向一个 URL 地址发起请求（https://api.ipify.org/...、https://api.deepseek.com/...）； 发请求时按照对方的要求带上该带的信息（如果没有要求可以不带）； 对方在它自己那边处理（我们看不见、也不用管）； 它回给我们一段数据（一般是 JSON）。 这就是 API 的形态。\n在使用它的时候，只要按它规定的方式来请求，就能用上它的能力，完全不需要知道它内部是怎么实现的。这就是 API 的意义：让能力可以被别人“对接”过去用。\n不过，上面的两个案例中，请求调用 API 的时候，分别用了两种请求方式：\n查 IP 那次是取数据（叫 GET） DeepSeek 那次需要提交内容给 API（叫 POST） GET 和 POST 是 API 最常用的两种请求方式，但其实 API 约定的标准不仅支持这两种，还有一些其他的，比如说 PUT、DELETE 等，我们后续遇到的时候再介绍。\n当 API 有了格式标准之后，我们就可以不用去在意具体程序是使用什么计算机语言来实现了。只要按照这个规范来请求、按照这个规范来提供响应，那么无论调用的一方还是提供服务的一方，都可以使用任意语言或框架来实现。\n所以我们如果使用 DeepSeek 的 API，我们并不需要关心 DeepSeek 是用 Python 做的还是用 C 语言做的，当然 DeepSeek 也不关心我们是用终端 curl 发起的请求，还是用 Python 发起的请求。\n为什么 AI 时代，到处都是 API 理解了这一点，我们就能解除 AI Agent 的“神秘感”。\n我们平时用的各种 AI 产品，很多本质上就是在调大模型的 API——把我们的话包装一下发过去，把答案拿回来，再包装成好看的界面给你； 而那些看起来无所不能的 AI Agent，它的本事其实来自一件件“工具”。这些工具里，凡是要联网去用别的服务的——查天气、查快递、搜网页、往群里发消息、再喊另一个大模型帮忙……基本都是在调 API；另一些则是直接在你电脑上跑命令、读写文件（这类严格说不算网络 API，但骨子里是一回事：都是“照着一个固定的接口，去调用别人已经做好的能力”）。 换句话说：\nAI 工具之所以看起来无所不能，就是因为它在不停地调用各种现成的能力——其中很大一部分，就是 API。\n所以你理解了 API，就等于揭开了这些工具神秘面纱的一大半。\n回到我们的网站：前端调后端，也是调 API 我们自己的项目，马上要用到一模一样的逻辑。\n到现在为止，我们的网站只有前端——用户在浏览器里看到、点到的那些页面。前端很擅长展示和交互。\n但文字实验室那个“根据你输入的文字，算出情感分数和拼音”，前端干不了，需要一个专门负责计算和处理的程序来做。这个程序，就是后端。\n那前端怎么把“用户输入的文字”交给后端、又怎么把“算出的分数”拿回来？\n答案就是：我们给自己的网站，也做一个 API——就像 DeepSeek 那个 /chat/completions 一样，只不过这个 API 是我们自己写的、专门算拼音和情感分数的。到时候前端会向我们自己写的这个 API 发一个请求（带上用户输入的文字），后端算完回一段 JSON，JSON 里面既可以包含拼音，也可以包含情感分数。\n你看，和你刚才调 DeepSeek，是同一回事。 这就是这门课接下来几节要做的：给网站补上一个后端，并写出我们自己的 API。\n那“后端”到底是个什么东西 既然反复提到后端，我们也有必要给这个词做一下解释：\n前端：跑在用户的浏览器里，负责看得见的展示和交互。 后端：是一个一直运行、守在服务器上、等着接收请求的程序，负责看不见的计算、处理，以及把数据存下来。 刚才 ipify 和 DeepSeek 那两台“一直守着、等你发请求”的程序，就是它们的后端；我们的请求发过去，它们接住、处理、回你。我们接下来要为自己的网站做的，就是这样一个（但相比之下要小得多的）后端。 而 API，就是前端用来调用后端的那个入口。\n后端可以用很多种语言写 “做一个能对外提供 API 的后端”，这件事用什么编程语言都能做：JavaScript（Node）、Python、Go、Java、PHP、Ruby、C#……都可以。ipify、DeepSeek 的后端各自用什么语言写的，我们不知道，也不影响我们调用它们的 API——因为API 这个入口，和它内部用什么语言实现，是两回事。\n这门课，我们选 Python 来写后端。原因有两个：一是它的生态特别成熟，尤其在算法、数据、AI 这些方向——我们文字实验室要算的“拼音”和“情感分数”，正好能借上这个力；二是它的语法对初学者比较友好。\n但值得知道、理解和记住的一件事是：\nPython 只是开发 API 的其中一个选择。API 这个概念，和用什么语言无关。\n这一节你应该带走什么 这节课没写一行程序，但你亲手调通了两个真正的 API。请带走这几点：\nAPI，就是一个程序对外公开的“入口”：按它规定的方式发请求，就能用上它的能力，不必知道它内部怎么实现。 一次 API 调用 = 请求 →（对方处理）→ 响应，回给你的通常是一段 JSON。 你调用了两个不同的服务，方式却一模一样——“通过 API 对外提供能力”，是软件世界的通行做法。 AI 时代到处都是 API；你调用 DeepSeek 做的事，正是那些 AI 应用天天在做的事——它们不神秘。 我们的网站接下来要补上一个后端，前端通过我们自己写的 API 去调用它。 后端可以用很多种语言写，我们选 Python，但概念与语言无关。 下一节，我们就把 Python 装到你的电脑上，跑起你的第一个 Python 程序，为亲手写出我们自己的 API 做好准备。\n← 上一节：模块 4.6 把前端项目发布到公网 | 下一节：模块 5.2 Python 的安装和环境设置 →\n","date":"2026.07.02","description":"亲手调用真实 API，看清一次请求、处理和响应是如何发生的。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-5-1/","title":"模块 5.1：究竟什么是 API？"},{"content":" 前端部分最后一站，把我们一手带起来的现代前端项目发布到服务器上去。\n这一节我们要干什么 到目前为止，我们手上有一个功能完整的双页面 Next.js 项目——就是上一节已经提交的那个 zero-to-tech，可它还只在 localhost:3000 上跑。这一节，我们把它部署上线。\n但动手之前，先有道选择题：一个 Next 项目，“怎么上线”其实有两种方式。我们先把这两种讲清楚、选定一种，再照着干。\n这一节没有配套 demo——就在我们已有的项目上动手（上一节往 Next 的迁移要先做了）。\nNext 项目的两种上线方式 上一节我们提到 next build 默认产出的 .next/server/app/ 里，虽然也躺着 index.html、text-lab.html 这些真实文件，但不能像之前 Vite 的 dist/ 那样直接交给 Nginx。为什么？\n因为 .next/ 不是一个“网站文件夹”，而是 Next 自己的一堆构建半成品——它是做给一台跑着的 Next 服务吃的（使用 npm run start 可以开启这个服务），不是给 Nginx 直接发的：\n那些 .html 里引用的 CSS / JS，散在 .next/ 的别处（.next/static/），凑不成“一个干净的网站根目录”； /text-lab 这种没后缀的网址该回哪个文件、404 怎么兜、缓存头怎么设——这些是那台 Next 服务运行时现办的； 里头还混着一堆只有服务器看得懂的东西（.rsc 数据、各种 manifest、服务端 JS）。 为什么 Next.js 搞了这么个 npm run start 服务呢？这要说回到上一节我们简单提过的一个概念——服务端组件（Server Component）。\nNext.js 把 React 组件分成两类：客户端组件和服务端组件。\n客户端组件，是要在浏览器里“跑起来”的那种——它得响应点击、改状态、播动画（4.3、4.4 我们写的那些组件都是这类），所以它会被构建工具翻译成 js、送到浏览器里去执行。\n服务端组件，则不需要在浏览器里跑，纯粹是把界面画出来给人看——它在服务端（或者构建时）就能把界面画成 HTML，浏览器直接拿到画好的成品，连 js 都不用送。\n这里要点破一个关键、也最容易误会的点：在 Next 里，这两类组件最后都会被“预渲染”成 HTML（这正是 4.5 说它对 SEO 友好的原因——查看源代码，页面内容实打实都在）。区别只在于：客户端组件除了那份 HTML，还会额外送一份 js 到浏览器，好让它“活”过来、能交互；服务端组件就只给 HTML、不送 js。所以 \u0026quot;use client\u0026quot; 不是“不预渲染”，而是“还要在浏览器里再跑一遍”。\n那么 .next/server/app/ 里这些 .html，是不是 build 时就画好了呢？——对我们这个项目，是的。数据全写死在前端，Next 在 build 时就能把每一页画成 HTML 存这儿（build 日志里那排 ○ (Static) 就是它）。\n可它们画好了，也还是不能直接交给 Nginx——原因就是上面那三条：整包 .next/ 是按“喂给 next start 那台服务”的结构摆的，不是一个能直接发的干净静态站。\n那 Next 默认为什么非要你跑这么一台服务？因为它还能干一件静态文件干不了的事——对动态页面，按每个请求当场现画（每次的数据可能都不一样）。我们这个项目没有动态页、用不上这本事；但 Next 默认冲着“通用”去，就默认给你备好那台服务。\n好在 Next 也给了另一条路：output: \u0026quot;export\u0026quot;——等于告诉它“我每一页 build 时都能画死，别给我留服务了，直接打包成一个干净的静态 out/ 文件夹”。\n所以，一个 Next 项目上线，就有了两条路可选：\n第一条路 A：跑一个常驻的 Next 服务（用 npm run start） 在服务器上启动 Next 自带的 Node 服务（next start），由它接住每一个用户请求，服务整个网站。.next/ 那堆半成品，正是喂给它的。\n能耐最大：它能在服务器上、按每个请求，当场把最新数据现画进发出去的 HTML——这是“静态导出”做不到的（静态文件 build 时就定死了）。也正因如此，它是 Next 默认的、主流的部署方式（交给 Vercel 这类平台托管，走的也是这条）。 代价：服务器上得有一个 Node 进程 7×24 常驻。网站不再是“一堆静态文件”，而成了“一个一直跑着的程序”——更重、也更费服务端计算资源。 第二条路 B：静态导出一个 out/（用 output: \u0026quot;export\u0026quot;） 在 build 环节直接吐出一个干净的静态网站文件夹 out/——每页一个 .html，里头两类组件的界面都已经预渲染好了；其中客户端组件再额外配上一份 js，好让它到浏览器里能交互。整体就跟我们之前见过的 dist/ 一个样，以一个文件夹（out/）的形式 交给 Nginx 做转发，服务器上不用常驻任何东西。\n代价：它相比第一条路 A 而言，无法做到在请求环节灵活调整页面内容，更适合内容和结构在 build 时就能被确定的网站。如果需要动态内容，可以在浏览器再次发起请求获取。 怎么选？划清两条路的“地盘” 走 B（静态）：页面在 build 时就能定下来的——纯展示站、博客、文档，以及“静态前端 + 后端 API、数据靠浏览器现取”的那一大类； 走 A（常驻服务）：页面的 HTML 本身必须在服务器上、按每个请求当场拼出来才行——典型是海量又时时变、还要被搜索引擎收录的电商 / 新闻 / 社媒等“重内容”的站点。 看了这个示意图可能就会更容易理解两种的差异了：\n有个特别容易误读的点先点破：“内容是动态的” ≠ “必须走 A”。 登录/注册、读数据库、实时数值展示这些动态功能，绝大多数都是“静态前端 + 后端 API”做的——页面还是那套静态文件，数据由浏览器事后找接口现取，照样走 B。\n我们这个 zero-to-tech，数据全硬编码在前端，就算后面讲到后端部分，接了后端接口之后也计划用“静态前端 + 后端 API”的方式，所以我们会选择 B 的方式。那就没必要去养一台常驻服务器。所以我们会按照 B 的部署方式来进行下一步：build 一次、出一堆静态文件、通过 Nginx 转发，又轻又稳，还跟 3.5 / 4.1 / 4.4 这几节课是同一套发布方式。\n静态导出发布 选定 B，剩下的就跟我们在 4.4 那一节部署 Vite 版几乎一样了。只有两处不同：\n差别一：build 产物从 dist/ 变成 out/——靠项目里加一行 output: \u0026quot;export\u0026quot;； 差别二：Nginx 的 root 指向 out/，并在 location 里加一句 try_files。 下面把这两处落地，其余全是 4.4 的肌肉记忆。\n差别一：加一行 output: \u0026quot;export\u0026quot; 因为 Next.js 默认的发布方式是走 next start，所以如果要构建静态文件，需要做一个配置。打开项目里的 next.config.mjs，加一行：\n// 改之前 const nextConfig = {}; // 改之后 const nextConfig = { output: \u0026#34;export\u0026#34;, // ← 就加这一行 }; 它告诉 Next：build 完别留给那台常驻服务了，直接把每一页预渲染成静态 HTML、连同资源一起塞进一个干净的 out/ 里。\n改完之后，本地 npm run build 跑一次，项目里就多出个 out/：\nout/ index.html ← / 这一页 text-lab.html ← /text-lab 这一页 ← ← ← 看这个，它真实存在！ 404.html _next/static/ ← 打包压缩过的 CSS、JS…… 注意，out/ 是构建产物，不进 Git（要在 .gitignore 里把 out/、.next/ 忽略）。\n差别二：Nginx 指向 out/ + 一句 try_files 部署的命令和我们此前运行过的一样，几乎一字不差，快速走一遍：\n# 1. 本地：推代码（output:export 那行也一起提交了） git push # 2. 服务器：确认 Node 还在 → 拉代码 → 装依赖 → build ssh ubuntu@你的服务器IP node -v # 4.4 用 nvm 装过，能打印版本号就行 cd ~/zero-to-tech git pull npm install npm run build # 服务器上也出一个 out/ 但是 Nginx 配置要改一下——root 指向 out/，并加上一句 try_files：\nsudo vim /etc/nginx/sites-enabled/default server { listen 80 default_server; server_name _; root /home/ubuntu/zero-to-tech/out; # ← 4.4 是 .../dist，这次是 .../out index index.html; location / { # /text-lab 这种没后缀的 URL，自动追个 .html 去找 try_files $uri $uri.html $uri/ =404; } } sudo nginx -t # 检查语法 sudo systemctl reload nginx 和 4.4 那节课中的 Nginx 配置相比，就两处不同：root 从 dist 改成 out，外加这句 try_files。\n这句 try_files 的意思是：根据 url 的内容去找文件，或者根据 url 的内容加上 .html 后缀去找文件，否则就报 404。 当 URL 是 /text-lab 时，Nginx 先找同名文件 text-lab，找不到的话 就自动补个 .html 再找——而 out/text-lab.html 这次真的在那儿等着，所以不再 404。\n见证上线 浏览器直接敲 http://服务器IP/text-lab——直达文字实验室，不会先进入首页、也不会报 404。上一节课开头那三个毛病，全部都没有了：\n404 没了：out/text-lab.html 真实存在，Nginx 找得到、直接送出； 白屏等待没了：服务器送来的就是已经画好的 text-lab.html，访客一来就看到内容，不用再等 JS 现画； SEO 好了：右键“查看网页源代码”——HTML 里实打实就有页面内容（标题、文案都在），不再是 4.5 那个空壳，爬虫不跑 JS 也能读到。 这条路是怎么一步步走过来的：\n4.4 我们把 React 版部署上线，F12 一看——页面是浏览器临时画的； 4.5 Next.js 把每一页预渲染成真实 HTML，从根上拔掉病根，但尚未部署； 4.6 一行 output: \u0026quot;export\u0026quot; 把这些真实 HTML 导成 out/、塞上服务器，Nginx 找得到、送得出。 4.4 的痛、4.5 的解药、4.6 的落地——三节合成一个完整闭环。\n以后改代码之后的发布流程：本地 git commit → git push → 服务器 git pull → npm run build。（工程上可以把这套“pull + build”写成脚本、交给 CI/CD 自动跑——一推代码就自动上线，但那是后话。比自动化 CI/CD 流程更有价值的是你心里有这条链路：明白源码在哪、产物在哪、谁 build、谁服务。）\n我们没走的那条路——A 刚才的两条路，A 和 B，我们走了 B，主线任务就此收工，我们这门课的前端部分也要结束了。但临走前，值得回头看一眼那条没走的路 A——因为它通向的地方，恰好就是这门课的终点关键词：全栈。\nA 的核心，是服务器上有一台跑着的 Next 服务。这意味着 Next 不再只是“前端框架”——它能在服务器上、按每个请求当场把页面拼出来，于是：服务端组件可以直接连数据库、读文件、用上只有服务器才有的密钥，你甚至能在同一个 Next 项目里直接写后端接口（app/api/...）。前端、后端，一个项目全包了——这就是大家说的“Next 全栈”。\n但我们不打算用 Next 的这种方式来做后端。 我们这门课走的是另一条更经典、也更通用的分工：前端归前端（就是这个静态 out/，走 B），后端归后端（我们会用 Python 单独起一个后端服务，专门计算情感分数和拼音），两边靠 API 通话、各自独立部署。这不是“Next 全栈”，而是“静态前端 + 独立后端”。\n两种都成立，我们挑了解耦的这种——它跟你前端用什么、后端用什么语言都无关，替换、迁移、各自扩容都更自由。\n那为什么不顺势教 Next 全栈呢？三个原因：\n它把前后端绑在同一个框架里，尽管这在 AI 编程时代有独特的优势（统一的上下文），但是对初学者而言，不利于建立工程化思维。把它们分开，对于初学者而言更有助于建立“前后端是两个独立角色”的清晰心智； Python 做后端的生态更为成熟，尤其是做一些算法模型服务或数据分析服务，Python 比 Node 有更丰富的生态； 更现实的——Next.js 全栈技术还很年轻，还需要成长。 服务端组件这套“请求时现画”的机制，就在不久前（2025 年 12 月）爆出了一个满分级（CVSS 10.0）的远程代码执行漏洞：攻击者只要一个不用登录的 HTTP 请求喂给那台常驻服务，就能让它跑任意代码，这个漏洞披露后很快被大规模利用，当时我自己也是受害者之一，攻击者直接把勒索信发到了我服务器的家目录里（所幸那只是一台“玩具”服务器，所以并未造成实质损失，我重装了系统，升级了 Next.js 的安全版本）。 新技术好用，但“够不够稳”得自己掂量。生态确实在往服务端组件（RSC）/ 全栈这个方向卷，值得我们认得它、知道它在解决什么；但我们这门课学习的全栈技术，到“纯静态 out/ + 后端 API”为止——已经足够做出可靠、能上线的真东西了。\n收尾 我们整个现代前端课程，到这儿就结束了。现在我们拥有了：\n一个真正可访问的双页面现代前端——挂在自己的云主机上，IP 一访问，世界看得见； 对整个现代前端工程的心智地图：依赖怎么管、构建在做什么、npm run dev/build 各干啥、React 在哪一层、Next.js 又叠了什么、最后这堆东西怎么变成服务器上一坨文件； 一份给后端留好钩子的前端：data/site.js 里的硬编码值、文字实验室那个“开始分析”按钮——下一步全都对接后端 API。 回头瞥一眼模块 4 走过的路—— 4.1 手写到工程化 → 4.2 构建 → 4.3 React 组件 → 4.4 数据驱动 + 路由 + 上线 → 4.5 Next.js → 4.6 静态导出收尾。 这正是任何一个“严肃前端项目”过去十年走过的真实历史。你没死记任何一个名词，而是被痛一路推着走完了一遍。\n模块 5，我们让后端登场，把这些硬编码换成真接口。\n← 上一节：模块 4.5 Next.js | 下一节：进入下一模块 →\n","date":"2026.06.29","description":"比较两种前端上线方式，并把 Next.js 项目真正发布到公网。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-4-6/","title":"模块 4.6：把前端项目发布到公网"},{"content":" React 让我们用组件的方式来拼装界面，并且建立了数据驱动界面的规则，但项目要上路，还有一堆苦力活——路由、SEO、首屏加载速度……而 Next.js 可以替我们把这些一并管起来。\n框架的代价 我们已经知道，React 其实就是一套规则，借助 Vite 这样的构建工具，可以把这套规则翻译成浏览器认识的 html、css 和 js。\n其实，更具体来说，目前为止，我们写的所有的 React 组件的代码，都是被翻译成了 js。\n上一节结尾，我们把项目发布到了公网，那我们就打开看一下吧！\n浏览器访问 http://服务器IP/\n个人主页稳稳出现，看起来挺好。\n接下来我们打开浏览器的「检查」，在 Chrome 浏览器上右键选择“检查”，或者按下 F12 打开浏览器开发者工具，切到 Network（网络） 面板。然后刷新一下页面，看看它到底是怎么加载出来的：\n你会看到一个顺序：浏览器先下载了 index.html——它几乎是空的，\u0026lt;body\u0026gt; 里就一个空 \u0026lt;div id=\u0026quot;root\u0026quot;\u0026gt;；然后才下载那个大大的 index-[hash].js；等这个 JS 跑完，页面内容才被画出来。\n所以服务器送给浏览器的，是一个空壳 index.html ＋ 一个 JS 包；我们最终看到的全部内容，都是浏览器拿到这个 JS 代码之后，执行 JS 代码，让它在浏览器里现画的。\n而这个做法，牵出了三个问题——\n直接访问 /text-lab，打不开。 如果我们在地址栏直接敲 http://服务器IP/text-lab 然后回车，会发现报告404。因为服务器硬盘上根本没有 text-lab 这个文件（它只有那个空壳 index.html）；“文字实验室”这一页，是 JS 在浏览器里临时画出来的。必须要先进首页、再点导航才到得了它；一旦直接访问、或在这一页刷新，就露馅。 首屏会慢一拍。 “先下空壳 → 再下 JS → 再画”，网速一慢，访客就先看到一片白屏，等 JS 到位内容才忽然蹦出来。 对 SEO 不友好。 搜索引擎派爬虫来抓我们的页面，但很多爬虫不会执行 JS（或者执行得不全），它拿到的就是那个空壳 index.html——里头啥内容都没有。在它眼里这就是个空页面，自然给不了好排名。（SEO ＝ 搜索引擎优化，说白了就是“让爬虫抓得到、也看得懂网页的内容”。） 三个问题，其实是同一个病根：页面不是真实的文件，而是浏览器临时根据 JS 算出来的。\n那从根上治，办法很直接：别让浏览器临时算了——提前把每一页都“画好”、存成真实的 HTML 文件，摆在服务器上。这样一来：/text-lab 真有文件（不再 404）、访客一来就拿到画好的页面（不再白屏）、爬虫也直接读到内容（SEO 友好）。三个问题，一齐解决。\n这件事自己手搓不是不行，但很麻烦。好在有个框架，把它连同路由一起打包替我们做好了——这一节的主角，Next.js。\nNext.js 是什么 Next.js 是 React 之上的一个生产级框架。 React 管“组件”，Next.js 在 React 上面再加一层，管那些“组件之外、与整个 web 应用相关的、上线必须的事”：路由、SEO、首屏快……\n它和 React 的关系不是“二选一”——是“二者叠加”。一句话总结这一节的世界观：\n层 谁来管 你能感觉到的 工程化（依赖、构建、命令） Vite / Next 内置 npm install / npm run dev / npm run build UI 组件 React \u0026lt;Nav /\u0026gt;、state 路由、SEO、首屏 Next.js “文件夹 = 页面”，\u0026lt;Link\u0026gt;，部署不 404 但是，别被“路由、SEO、首屏、生产级框架”这一串名词唬住。Next.js 真正解决的，归根到底就两件事：\n路由——“哪个网址显示哪一页”，告别 4.4 那种手搓判断； 把页面预先渲染成真实的 HTML——这一件，正是开头那三个问题（直接访问 404、首屏慢、SEO 差）背后的同一台引擎：同一个病根，一并解决。 路由只是 Next 最显眼的那一块；“预渲染成真实 HTML”才是它作为“生产级框架”最硬的本事\n所以，Next 可以用来帮我们解决路由的问题，并把页面预渲染成真实 HTML。那在 Next 出现之前，这些事谁来扛呢？\n路由解决方案：我们上一节手搓了一个 useRoute.js，其实在 React 项目中管理路由，有一个现成的路由库——最常见的就是 react-router（上一节在 URL 那部分的末尾我们提过）。但不管手搓还是用库，它们都没有解决“在浏览器里算页面”这个问题，也没有直接解决前面说的 404 问题，得自己再去配服务器、把所有路径回退到首页（需要改 Nginx 配置，不展开说了）；\n预渲染 / SEO / 首屏解决方案：得自己搭一个 Node 服务器，手动把 React 渲染成 HTML 字符串、两端各配一遍路由、数据获取自己接……一大堆样板，每个严肃项目都要重写一遍。\nNext.js 的出现，就是把这摊“人人都要重做一遍的脏活”，用一套约定，标准化、自动化地替我们做了。这就是 Next 作为一个生产级框架的价值——是让我们站在别人的肩膀上，不用什么都从零搭。\n看一看一个真实的 Next.js 项目 惯例，这一节的项目也已经准备好了：zero-to-tech-4-5/——一个已经搬成 Next.js 的版本。你不用自己从头搭，直接看我准备好的就行。\n打开 zero-to-tech-4-5/package.json：\n// 4-5 (Next.js) \u0026#34;dependencies\u0026#34;: { \u0026#34;animejs\u0026#34;: \u0026#34;^4.4.1\u0026#34;, \u0026#34;react\u0026#34;: \u0026#34;^19.2.6\u0026#34;, \u0026#34;react-dom\u0026#34;: \u0026#34;^19.2.6\u0026#34;, \u0026#34;next\u0026#34;: \u0026#34;^15.0.0\u0026#34; // ← 多了这一个 }, \u0026#34;scripts\u0026#34;: { \u0026#34;dev\u0026#34;: \u0026#34;next dev\u0026#34;, \u0026#34;build\u0026#34;: \u0026#34;next build\u0026#34;, \u0026#34;start\u0026#34;: \u0026#34;next start\u0026#34; } 和上一节的摆一起对比：\n// 4-4 (Vite + React) \u0026#34;dependencies\u0026#34;: { \u0026#34;animejs\u0026#34;: \u0026#34;^4.4.1\u0026#34;, \u0026#34;react\u0026#34;: \u0026#34;^19.2.6\u0026#34;, \u0026#34;react-dom\u0026#34;: \u0026#34;^19.2.6\u0026#34; }, \u0026#34;scripts\u0026#34;: { \u0026#34;dev\u0026#34;: \u0026#34;vite\u0026#34;, \u0026#34;build\u0026#34;: \u0026#34;vite build\u0026#34;, \u0026#34;preview\u0026#34;: \u0026#34;vite preview\u0026#34; } 在 dependencies 中多了一个 next 包\n而在 scripts 里 dev、build 这两条命令的名字一字没变，只是背后从 vite 换成了 next（第三条 Vite 的 preview 换成了 Next 的 start，干的也是“本地起服务”的活）\nNext.js 自带了一整套构建（它有自己的构建工具，不是 Vite），所以我们可以不再使用 vite 了；它从 package.json 里退场，是因为“构建这摊活儿被 Next 接管了”，不是它过时了。我们 4.2 那一节学的那套“构建”的概念在 Next 里照样在跑——dev/build 命令名一字没变，换的只是背后干活的人。而且在没用 Next 的项目里（海量纯 React / Vue 工程），Vite 依然是首选。所以“构建”这个概念我们没白学，它只是换了个执行者。\n跑起来：\ncd zero-to-tech-4-5 npm install npm run dev 打开 http://localhost:3000（Next.js 默认端口，从 5173 变成了 3000——一个小细节）。网站还是那个网站，长一模一样。\n顺便看一个小福利：npm run dev 跑着的时候，留意页面角落里那个 Next.js 的小标志（一般在左下角）。平时它不声不响；一旦代码出错，它会把错误标出来，点开就能看到完整的报错信息，还能一键复制。\n这在 vibe coding 时特别顺手：报错原样复制给 AI 就可以了。这也是 Next 这类成熟框架替我们做的贴心小工具之一。\n用文件夹结构来管理网站结构 打开 zero-to-tech-4-5/ 的目录，直接对比 4-4：\nzero-to-tech-4-4/ zero-to-tech-4-5/ src/ app/ ← Next.js 的“页面区” main.jsx layout.jsx ← 全站外壳（页面包裹 + 引入 css） App.jsx ← 这一坨 page.jsx ← / 这一页 router/ text-lab/ useRoute.js ← 手搓 26 行 page.jsx ← /text-lab 这一页 components/ components/ Nav.jsx Nav.jsx HomePage.jsx HomeView.jsx TextLabPage.jsx TextLabView.jsx AnimatedCardGrid.jsx AnimatedCardGrid.jsx data/ data/ site.js site.js ← 一字未改 （上图只列了对比时关键的那几个文件；InputCard、PageHeading、ResultCard 这些两边都一样，就没画出来。）\n发现了什么——\n1. src/App.jsx + src/router/useRoute.js 不见了。4.4 那个手搓的小路由（连同它的入口），整块没了。\n2. 多了一个 app/ 目录，里头是一棵小树：\napp/ layout.jsx page.jsx text-lab/ page.jsx 这就是 Next.js 替我们管路由的方式——“文件夹 = 路由”：\napp/page.jsx → 网站 / 这一页 app/text-lab/page.jsx → 网站 /text-lab 这一页 如果未来我们想加一个页面，比如说 /blog，那么就新建 app/blog/page.jsx 就可以了。不用注册、不用维护路由表，只要页面是按照这个 app/blog/page.jsx 路径来创建的，那么就可以用 /blog 这个 URL 来访问。\n跟 4.4 一对比就清楚了：4.4 我们需要自己动手把“在哪页”写进网址、再自己判断该显示谁（那个糙路由）；到这儿，这些我们全都不用管了——把页面文件按文件夹摆好，“哪个网址显示哪页”Next 自动就接上。\n4.4 我们说过，“用 URL 管路由”本质也是数据驱动界面——界面照着 URL 这个“值”显示。这个直觉没有变，变的只是：“读写 URL、按它挑页面”这件苦差，从我们手搓，换成了 Next 自动接管。\n这就是 Next.js 那句口号“约定优于配置”在最直观的样子：它和我们约好“文件夹结构就是网站结构”，然后省掉了一切手写的路由配置。\n打开 app/text-lab/page.jsx，看它一共几行：\nimport TextLabView from \u0026#34;../../components/TextLabView.jsx\u0026#34;; export default function Page() { return \u0026lt;TextLabView /\u0026gt;; } 就这么 5 行——它的全部工作就是说“/text-lab 这个 URL 渲染 TextLabView 这个组件”。剩下的一切——监听 URL、刷新不丢、前进后退——Next.js 已经替我们做完了。\nReact 组件的变化 文件夹路由看明白了，我们再随手翻翻 components/ 里那几个组件。大体上它们和 4.4 几乎一样——AnimatedCardGrid、两个页面组件的主体都照搬了过来，基本是差不多的，不过也有一些不同。\nNav 这个组件有变化，它里面的路由控制换成了 Next 自带的 \u0026lt;Link\u0026gt; 标签（不用细究怎么写，知道“路由被框架接管了”就行）。\n还有一个小小的不同：有些组件文件的最顶上，多了一行 \u0026quot;use client\u0026quot;，有些却没有。这是什么意思呢？\n对一下就发现规律了：\n有 \u0026quot;use client\u0026quot; 的：Nav.jsx、InputCard.jsx、ResultCard.jsx、AnimatedCardGrid.jsx——全是要在浏览器里“动”起来的（导航切换高亮、打字计数、结果卡入场、卡片飞入动画，用到了 useState、useEffect、usePathname 这类只能在浏览器里跑的东西）； 没有 \u0026quot;use client\u0026quot; 的：HomeView.jsx、TextLabView.jsx、PageHeading.jsx，以及 app/ 里那几个 page.jsx——它们要么纯展示、要么只是把别的组件摆在一起，自己不带任何交互。 这行字背后，是 Next 的一个核心设定：\nNext 默认会把组件先在服务器（或 build 时）渲染成 HTML，再把现成的 HTML 发给浏览器——这种“在服务端就画好”的组件，有个正式名字叫 服务端组件（Server Component）。前面说的“预渲染成真实文件、首屏快、对 SEO 友好”，靠的就是它：内容在服务器就画好了，浏览器拿到的直接是带内容的 HTML，不必再等 JS 现画。\n但点击、打字、动画这些事，只能在浏览器里发生。所以一个组件但凡用到这类“得在浏览器里跑”的东西，就得在顶上写一行 \u0026quot;use client\u0026quot;，把自己标成 客户端组件（Client Component）。\n这里要点破一个最容易误会的点：客户端组件也会被预渲染成 HTML——所以我们这个项目里那些带交互的客户端组件（Nav/InputCard…）画出来的东西，查看源代码照样都在、SEO 照样好。它只是额外再被送一份 js 到浏览器，好让它“活”过来、能交互。所以 \u0026quot;use client\u0026quot; 不是“不预渲染”，而是“还要在浏览器里再跑一遍”。\n一句话，那行 \u0026quot;use client\u0026quot; 就是个开关：\n不写（默认）＝ 服务端组件，纯展示，在服务器 / build 时画成 HTML 就完事，又快又利于 SEO； 写上 ＝ 客户端组件，带交互 / 动画，除了那份 HTML，还会多送一份 js 到浏览器让它动起来。 你不用学怎么写、也不用纠结某个组件到底该归哪边，只要认得这行字、知道它大概在说什么就够了。\n其实，服务端组件这条线还能更进一步——让服务器在收到请求的时候，现场算出动态内容再发下来。但那是一条更深、也更需要谨慎的路，下一节会提到，不展开它。\n构建 Next.js 项目 我们现在试着构建一下这个 Next.js 项目，在 zero-to-tech-4-5/ 里跑：\ncd zero-to-tech-4-5 npm run build 跑完看输出最后那张表：\nRoute (app) Size First Load JS ┌ ○ / 486 B 120 kB ├ ○ /_not-found 996 B 103 kB └ ○ /text-lab 1.19 kB 117 kB ○ (Static) prerendered as static content 注意那个 ○ (Static) prerendered as static content——它告诉我们一件大事：\n/ 和 /text-lab 这两个 URL，都被 Next.js 预先渲染成了真实的 HTML 文件。\n接下来打开 zero-to-tech-4-5/.next/server/app/，你会亲眼看到：\n.next/server/app/ index.html ← 真实存在 text-lab.html ← 真实存在 ← ← ← 看这里 从这里可以看出来，现在 next 真的帮我们构建出来了两个真实的 html 文件，这样一来，原来报 404 的病根就除了，因为这两个文件现在是真的在被构建到了我们的项目中。\n这就是开头我们所说的，Next.js 替我们把“页面”从“浏览器临时算的”变成“实打实的 HTML 文件”——不 404、首屏快、搜索引擎能收录（SEO），背后都是这同一件事。\n但是请注意，现在用这个 next build 构建产生的 .next/server/app/ 下的资源和我们此前通过 vite build 产生的 dist/ 下的资源还有一点不一样，它不像曾经我们见到的 dist 资源那样可以当作静态资源直接丢给 Nginx 去部署，我们下一节讲部署的时候再说这件事。\n想本地先完整跑一下也行：npm run start 会用 .next 的产物起个本地 Node 服务器，localhost:3000/text-lab 能直达——但这是 Next 自己的服务器在兜路由，跟把静态文件交给 Nginx 还不是一回事。\n把我们的项目改造成 Next.js 框架 我们已经可以看懂 Next.js 这套文件结构（hierarchy），那就不再一个文件一个文案地迁移了，直接把我们 ~/zero-to-tech 项目中的文件，改为这个新的 zero-to-tech-4-5/ 的文件就可以了。\n把 ~/zero-to-tech 项目里除了隐藏的 .git 以外的东西全删掉，再把整个 zero-to-tech-4-5/ 下的所有文件都拷进来。\n（node_modules、.next 这些产物不用拷，等下 npm install / build 自己生成。）\n留着 .git，是因为它记着这个仓库和 GitHub 的连接、还有我们全部的提交历史。只要它在，这仓库就还是“那个项目”——远程地址、历史，一个都没有丢。\n拷完，照例：\nnpm install npm run dev 打开看一眼——还是那个网站。最后 git add / commit / push，我们的 Next 项目就完成了提交和推送。\n至于服务器那边怎么跟着改成跑 Next，那是下一节的课题。\n从 0 新建一个 Next 项目 这一节我们走的是“把已有项目搬成 Next”——因为我们手上正好有个一路养大的 zero-to-tech。\n但你以后要是从 0 新建一个全新的 Next 项目，可以不用这么折腾，下面这样一行命令就可以搞定：\nnpm create next-app@latest 顺带认识一个词：Tailwind CSS。 你真去跑 npm create next-app，它会当场问你一句“要不要用 Tailwind？”。Tailwind 是现在最流行的样式方案——它把样式拆成一堆“工具类”，你直接在标签上写 className=\u0026quot;flex items-center gap-4 rounded-xl bg-white p-6\u0026quot; 这种，基本不再单独写 css 文件。\n你不用现在学它，但要认得它：如果你让 AI 写前端，那就建议使用 Tailwind。\n我们这个 demo 没选 Tailwind 是为了“外貌跟前面一模一样”，继续用一路带过来的那套 css。哪天你想换，跟 AI 说一句“把样式改成 Tailwind”就行。\n核心概念回顾 Next.js 是 React 之上的生产级框架——React 管组件，Next 管“组件之外、上线必须的事”。回顾一下这一节的核心概念：\n文件夹 = 路由：Next.js 用 app/text-lab/page.jsx 这样的结构，就让网站有了 /text-lab 这个路径——不用注册、不用配置。 跳转交给框架：Next.js 引入了 \u0026lt;Link href=\u0026quot;...\u0026quot;\u0026gt; 标签，接管了全站路由，让 App.jsx + useRoute.js 整体消失，我们只需要关注呈现的页面就可以。 页面被预渲染成真实 HTML：npm run build 把每一页输出成真正的文件，让我们的首屏加载更快，SEO 更友好。 下一节，我们把这个完整的 Next.js 项目部署上线——放到我们那台 Ubuntu 云主机上，用 Nginx 来指向它。\n← 上一节：模块 4.4 让数据驱动界面 | 下一节：模块 4.6 发布到公网 →\n","date":"2026.06.26","description":"认识 Next.js 如何为 React 补上路由、预渲染与生产能力。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-4-5/","title":"模块 4.5：Next.js——React 之上的生产级框架"},{"content":" React 最大的变化，就是把“操作页面”变成了“改变数据/值”。页面根据数据自动长成应该有的样子，这就是 React 的另一个核心思想——数据驱动界面。\n前言 这一节我们继续优化我们上一节课的那个用 React 实现的项目。首先我们先回顾并关注上一节的两个组件细节。\n首先是 PageHeading 组件，它把标题 title 和副标题 subtitle 空出来，留给调用它的组件去往其中喂内容，以此来实现复用。\n然后是 App 组件，它有两个职责，一个是把现有的两个 Page 组件收纳其中，另一个是控制什么时候该呈现 HomePage，什么时候该呈现 TextLabPage。\n它们目前已经可以正常工作，但是我们今天要基于这两个组件的行为，做进一步的思考——现在这样的实现有什么问题吗？还可以更好吗？\n数据驱动界面 我们先看 PageHeading。它把标题那个位置空了出来——你用它的时候，喂给它什么字，它就显示什么字。个人主页喂“关于我”，文字实验室喂“文字实验室”，于是同一个组件，在两页显示成了不同的标题。\n一句话：界面长什么样，不写死在组件里，而是照着“喂进去的值”显示。 喂的值变了，显示就变。\n如果用这个视角来看，那么 App 又何尝不是在照着一个“值”决定显示哪一页呢？只不过这个值的来路不太一样——它不是哪个外部组件喂给它的，而是 App 自己揣在手里的：用户点了导航栏中的“个人主页”，这个值就成了“home”，点了“文字实验室”，它就成了“textlab”；App 再照着这个值，决定当下给用户显示哪一个 Page。（这种“组件自己揣着、还会变”的值，待会儿我们会专门讲。）\n界面，就是照着一些“值”在显示，就是所谓的数据驱动界面。\n数据与界面分离 一个项目在建设完成之后，不会一直不变。总会有需要修改的时候，尤其是数据的部分。比如说我们的 subtitle，或者作品列表，它们发生变化是一个太正常不过的事情了。\n相对来说，界面的结构变化一般不如数据的变化那么频繁。如果是这样的话，那么大家就摸索出来了一种工程管理的思想——把数据和界面分离。\n先看一个现象：假设现在我们想把首页那句“关于我”改成别的，得进入 HomePage 的组件代码里，在一堆标签中间找到它、再改。内容和代码搅在一起，这就是数据和界面没有分离的状态。\n数据和界面分离，就是设想把网站要显示的这些文字，集中到一个地方，专门来管理。\n比如说像这样存数据：\nexport const home = { heroTitle: \u0026#34;关于我\u0026#34;, heroSubtitle: \u0026#34;项目，创意，灵感，心得，我的作品\u0026#34;, featuredWork: { kicker: \u0026#34;作品\u0026#34;, title: \u0026#34;文字实验室\u0026#34;, copy: \u0026#34;拼音和情绪，挖掘中文里的细节\u0026#34;, linkLabel: \u0026#34;打开作品\u0026#34;, }, identity: { motto: \u0026#34;已识乾坤大，尤怜草木青\u0026#34;, learning: \u0026#34;零到全栈\u0026#34;, }, }; export const textLab = { heroTitle: \u0026#34;文字实验室\u0026#34;, heroSubtitle: \u0026#34;拼音和情绪，挖掘中文里的细节\u0026#34;, }; 它基本就是一张纯内容清单——网站上要显示的字，都在这儿。\n有些同学可能会把这个和我们前面学过的那个 json 格式联系起来。它们很相似，但有点不一样，这个大括号里最后一项的结尾是可以有逗号的。\n而组件那边（HomePage）呢，不再写死任何文案了，它只需要 到这张表里，把 heroTitle 取出来显示，至于 heroTitle 写的是“关于我”还是“About Me”，它就不关心了。\n我们现在可以下载这一节的 demo，看一看（目前 demos 中的 zero-to-tech-4-4 就已经把页面展示的数据抽取到了 src/data/site.js 中了）。\ngit clone https://github.com/joylibo/zero-to-tech-demos cd zero-to-tech-demos/zero-to-tech-4-4 npm install npm run dev 看现象，最直接：\n打开 src/data/site.js，把 heroTitle 改成别的字，保存——页面上的大标题当场就变了。而组件代码 HomePage.jsx 和 PageHeading.jsx，你一个字都没碰。\n这就是数据和界面分开：\n组件只管“怎么显示”（排版、样式、结构）； site.js 只管“显示什么内容”。 两边各管各的，谁也不烦谁。\n而且这么一分，好处马上就来。举一个最直观的：哪天你想给网站做一个英文版，怎么办？你会发现，组件一个都不用碰——只要再备一份英文的内容表（把 site.js 里的字都译成英文），让组件根据“当前选的是中文还是英文”去读对应那张表就行。排版、结构、样式，一个字都不用改。“做多语言”这种听起来挺折腾的事，就因为数据和界面早早分了家，一下就变简单了。\n这里还悄悄埋了一颗模块 5 的种子：现在这张表是写死在文件里的。等模块 5 引入后端 API 的概念，site.js 中的这些内容可以改成从网络接口实时取。\n状态驱动 UI 上面看到的示例，都是数据驱动 UI——但它们有个共同点：组件显示的值是从外面喂进来的。哪怕内容集中进了 site.js，也还得由父组件读出来、再传给子组件去显示。这些值还有一个特点：它们都是定死的，你不主动去改 site.js，它就不会变。\n但有些数据，需要在用户用着用着的时候自己变——它不是谁喂进来的，而是组件在被操作时自己产生、自己揣着、自己管的数据。\n这种“会变、且一变显示就跟”的值，在 React 中一般就会被称为组件的状态（state）。\n想象一个最纯粹的组件，它身上揣着一个值：\n这个值是它自己的一部分（不是外面喂的，是它自己揣着的）； 它有个默认值（一出生就带着）； 组件显示什么，全照着这个值； 这个值允许被改；而且一改，显示当场就跟着变。 在 demo-4-4 中，我们已经在一个组件上实现了“状态驱动 UI”的效果。\ncd zero-to-tech-demos/zero-to-tech-4-4 npm run dev 打开 demo 的文字实验室那页，盯住输入框下面那行“已输入 N 字”：\n你往框里多敲几个字、删几个——那行数字当场跟着跳。\n这就是上述我说的状态驱动界面：框里“当前的文字”是个会变的值（state），你打字的过程就是在改它，下面的字数照着它算——一改，立刻变。\n你完全不用看这是怎么写的。React 内部替你盯着这个值、值一变就自动刷新界面——这套机制就是它最核心的引擎。你只要认得这个现象：有的值会变，界面会自动跟着它走。\n其实，这个机制我们在上一节已经见过一次了：在导航栏中点击，切换页面，靠的就是一个这样的值（“现在在哪一页”）。所以 App 组件之所以能够控制什么时候该呈现 HomePage，什么时候该呈现 TextLabPage，靠的也就是这个 state。\n用 URL 管理路由 我们再看一下，上一节课的这个 App 管理 Page 的效果：\ncd ~/zero-to-tech npm run dev 鼠标点击顶部导航栏，的确能够实现页面的切换。\n这个就叫做路由。\n但是，现在用 state 来管理路由，是不是有一点问题？如果，现在选中“文字实验室”，虽然页面呈现的是 TextLabPage 这个页面组件的内容，但是，URL 始终都没有变化，一直是 /，这个 / 代表网站的首页（根路径）。此时如果刷新一下呢？另外，如果我们想要分享这个文字实验室的页面给别人，别人打开之后，看到的是文字实验室，还是个人主页呢？\n答案很显然，尽管我们选中了文字实验室，但是一旦页面刷新，就又回到了个人主页。另外，如果我们在文字实验室页面复制 URL，发送给其他人，其他人打开之后看到的也会是个人主页这个首页，而不是文字实验室页面。\n这个就是用 state 来管理路由的时候，会出现的问题。\n这是因为 state 是被存到了浏览器的内存里的。浏览器的内存一刷新就清空——所以刷新之后，“当前选中了哪个页面”这条信息就丢失了，地址栏也不知道你在哪页。\n但是，如果我们观察 demo-4-4，就会发现它已经解决了这个问题：\ncd zero-to-tech-demos/zero-to-tech-4-4 npm run dev 先看现象。打开网站，点一下导航的“文字实验室”——盯住浏览器最上面的地址栏：它从 …/ 变成了 …/text-lab。\n现在，在这一页按一下刷新。\n它还稳稳停在文字实验室。\n再点浏览器的后退键：回到了首页。\n把地址栏里 …/text-lab 这个网址复制下来发给朋友（或者我们换一个浏览器打开，无中生“友”一下）：朋友一打开，直接就是文字实验室那页。\n为什么 demo-4-4 可以做到呢？差别只在一处——demo-4-4 把它写进了浏览器的网址（URL） 里。网址不会因为刷新消失，还能整条复制——所以刷新还在、链接能发、前进后退也能用。\n把“在哪一页”从“记在内存里”，换成了“写在网址里”。 网址天生就刷新不丢、能复制、能前进后退——它天生就适合用作页面路由。\n这背后其实是有个叫“路由”的小文件在盯着网址、在你点导航时帮你改网址。你不用看它怎么写——看懂上面那个“值存哪儿”的区别，就够了。\n顺带认个名字：这个“小路由”是我们自己手搓的极简版。真实项目里大家往往不手搓，而是用一个现成的库，叫 react-router。你不用学它怎么写，认得这个名字、了解这个概念就行——以后看 React 项目、看 AI 写的代码，可能会碰见它。\n这其实也是数据驱动界面的一个案例，不同的是它的数据是存在于 URL 里面，而不是在内存里。\n关于从外部获取数据 你大概也注意到了——文字实验室“结果区”里显示的拼音、情感分数，目前是写死的假数据；点“开始分析”也不会真发生什么。\n考虑到我们马上要讲后端了，这部分内容我们就留给后端来解决，我们后续会接入一个小小的算法模型，帮我们把这个情感分数给算出来。\n由后端模型把数据算出来再给到前端，其实也是一种数据驱动界面的具体体现，只不过数据是由后端网络接口提供的。这个我们这一节先不演示，你知道这个意思就行。\n升级我们的项目 这一节重点是看懂上面那几件事。我们目前已经知道了 demo-4-4 的代码实现是优化后的版本，那么我们就让自己那个 zero-to-tech 项目跟着长到这一步，不用大动——在我们现有项目的基础上，从 demo 里拷这几个文件就行：\nsrc/data/site.js（新增——那张内容表） src/router/useRoute.js（新增——盯网址的小路由） 用 demo 里更新过的版本覆盖这几个： src/App.jsx src/components/HomePage.jsx src/components/TextLabPage.jsx src/components/InputCard.jsx src/css/lab.css（InputCard 那行“已输入 N 字”用到一个新样式 .lab-count，就加在这里） 拷完 npm run dev，对着上面那些现象挨个试一遍：地址栏会变、刷新还在、输入框打字字数当场跳、改 site.js 页面当场变。一致，就说明你接对了。\ndemo 仓库里 zero-to-tech-4-4/README.md 有更细的分步操作，也可以跟着它的描述走。\n把它发布到公网 到这儿，我们的 zero-to-tech 已经是个像样的双页面 React 项目了——可它还只在你电脑的 localhost:5173 上跑，外面没人看得见。我们现在就把这个改进后的 React 版送上线，让它可以在互联网上被访问。\n我们上一次做发布是在 4.1 那一节课，当时我们的项目还是 vanilla 实现，我们把它发布到了 Ubuntu 服务器，那个发布分三步：\n本地 git push 到 GitHub； SSH 到服务器 git pull 把代码拉下来； Nginx 的 root 指向那个文件夹，reload，搞定。 那次的项目特别“原生”——一堆手写的 .html / .css / .js，Git 拉到哪儿、Nginx 对哪儿，直接就能服务。\n可现在不一样了。这个项目是个 Vite + React 工程：浏览器不认识 .jsx，得先 npm run build 把它构建成普通的 .html / .css / .js（4.2 讲过）。而且 4.2 还立了条规矩——构建产物 dist/ 进 .gitignore，不提交。\n于是这次比 4.1 多绕一道弯：我们 push 上去的是源代码，dist/ 不在里头。那 Nginx 要服务的 dist/ 从哪来？——服务器要先 npm install 把依赖准备好，再自己 npm run build 一次，把源码变成 dist/。\n因此，我们的服务器第一次要请来一个新角色：Node。它负责把源代码 build 成静态文件；Nginx 还是那个 Nginx，负责把文件送给访问者。\n第一步：给服务器装 Node。 SSH 远程登录上去，然后执行下面的命令（这些是来自 Node.js 官网提供的在 Linux 安装 Node.js 的命令）：\n# 下载并安装 nvm： curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash # 代替重启 shell \\. \u0026#34;$HOME/.nvm/nvm.sh\u0026#34; # 下载并安装 Node.js： nvm install 24 # 验证 Node.js 版本： node -v # Should print \u0026#34;v24.17.0\u0026#34;. # 验证 npm 版本： npm -v # Should print \u0026#34;11.13.0\u0026#34;. 第二步：推代码、拉代码（4.1 的老动作）。 本地 git push；服务器上把代码拉下来：\ncd ~/zero-to-tech git pull 第三步：在服务器上安装依赖并 build。\ncd ~/zero-to-tech npm install npm run build build 完，项目里多了个 dist/——这就是要交给 Nginx 的那一坨，跟 4.1 那堆手写 html 同性质：都是浏览器能直接跑的死文件。\ndist/ index.html ← 页面外壳 assets/ index-[hash].js ← 打包压缩过的 JS index-[hash].css ← 打包压缩过的 CSS 第四步：让 Nginx 指向 dist/。\nsudo vim /etc/nginx/sites-enabled/default 仅修改 root 这一行：\nserver { listen 80 default_server; server_name _; root /home/ubuntu/zero-to-tech/dist; # ← 指向 build 产物 index index.html; } 和 4.1 那一节相比，实质就改了一处：root 从手写 html 的目录，挪到了 build 产物 dist/。测试 + reload，还是 4.1 用过的老命令：\nsudo nginx -t sudo systemctl reload nginx 见证一下。 浏览器打开 http://你的服务器IP/：\n个人主页稳稳出现——卡片飞入、分数滚动，跟你本地看到的一模一样。你亲手做的这个 React 网站，现在全世界都访问得到了。 🎉\n以后每次改完代码：本地 git push → 服务器 git pull → npm install → npm run build。跟 4.1 比，工程化项目不再是“拉下来就能直接给 Nginx”，中间多了一个准备依赖、再构建产物的过程——这就是“工程化项目”要付的成本，换来的是组件复用、构建优化那些好处。\n核心概念回顾 界面，说到底就是照着一些“值”在显示。这一节，我们见识了这些“值”的三种玩法：\n值能从外面喂进去，还能集中起来管。 组件空出一个位置，使用它的时候喂它什么、它就显示什么；而要显示的内容，可以集中记在一个文件里——改文案、加作品，只动这个文件，组件一个字不用碰。（这就是 props ＋ 数据分离） 值能自己变，界面自动跟。 组件可以有一些自己控制的值，当用户与组件交互（打字、点一下），组件自己揣着的那个值就变——它一变，界面当场跟着变。（这就是 state） 值还能存进网址里。 把“现在在哪一页”写进 URL，于是刷新不丢、链接能发、前进后退都好使。（这就是路由） 这一节我们还顺手做了件大事：把项目发布到了公网——push 源码、服务器 npm install 准备依赖、npm run build 生成 dist/，再让 Nginx 指向 dist/。与 4.1 的发布流程相比，关键变化是：Nginx 服务的不再是源码目录，而是服务器上装好依赖后构建出来的产物。\n下一节，我们再细看这个刚上线的网站，并且我们要在下一节正式请 Next.js 出场。\n← 上一节：模块 4.3 React 登场 | 下一节：模块 4.5 Next.js →\n","date":"2026.06.26","description":"让数据决定页面显示什么，而不是手动修改页面上的每个元素。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-4-4/","title":"模块 4.4：让数据驱动界面"},{"content":" 构建工具让我们可以“按舒服的方式写，再翻译给浏览器”。React 做的事，就是把这件事往前推到极致：它给前端开发发明了一套新的组织规则。\n先把 4.2 的地基踩实 如果你还不理解上一节讲的 Vite、npm、构建工具，建议先回去看完 4.2。因为 React 不是凭空跑起来的，它正是建立在构建工具之上的。\n上一节最重要的结论是：\n构建工具，是一个“翻译官”。\n我们可以按自己更舒服的方式写代码，构建工具在背后，把这些代码翻译成浏览器真正认识的样子。\n那顺着这个思路再往前推一步：\n既然构建工具能翻译，那我们是不是可以不再老老实实手写浏览器那套底层 HTML、CSS、JavaScript，而是发明一套写起来更舒服、更高效的新规则？\n这听起来很大胆，但它正是现代前端框架的基本逻辑。\n在 Vite 官网首页，你能看到一排框架和工具的 logo：React、Vue、Angular、Astro、Svelte、Solid……这些东西大体都在做同一件事：提供一套新的前端开发规则，再交给构建工具翻译成浏览器认识的代码。\n这一节，我们要讲其中最主流的一套规则：React。\n为什么是 React，不是 Vue？ 在讲 React 之前，先把一个常见问题说清楚：为什么这门课选 React，而不是 Vue？\n这个问题很正常，因为 Vue 在国内确实非常流行，上手也平缓。React 和 Vue 都很优秀，这里没有“谁高级、谁低级”的问题。\n第一，它们是同一类东西 React 和 Vue 都是前端框架，解决的是同一类问题：怎么用组件组织界面，怎么让数据变化时界面跟着变化。\n它们更像不同“方言”：说的是同一件事，只是写法和习惯不同。\n所以你不用担心“学了 React 就放弃 Vue”。你把其中一个框架背后的概念吃透，再去理解另一个，会快很多。\n第二，这门课选 React，是因为生态最大 React 在全球范围内使用非常广，生态也非常厚。npm 下载量、第三方组件库、教程、项目经验、AI 训练语料，都非常丰富。\n这对 0 基础学习者尤其重要。因为你以后不是要从零发明所有东西，而是要能看懂项目、调用工具、指挥 AI，把成熟生态里的东西组合起来。\nVue 也很好，尤其在国内生态很成熟。但这门课需要先选一条主线，我们就选 React。\n第三，对你来说，最重要的不是框架名 这门课的重点不是背语法，而是理解现代软件开发的概念，然后能用 AI 把事做成。\n“组件”“状态”“数据驱动界面”“生态复用”这些概念，在 React 和 Vue 之间完全通用。你在 React 里学明白了，以后要换 Vue，也只是换一套写法。\n所以别在“到底学哪个框架”上消耗太多。选一个，把概念吃透，这才是真正带得走的东西。\n框架是什么：管一摊事的一套规则 “框架”这个词听起来很大，其实可以先这样理解：\n框架，就是管某一摊事的一套规则。\nReact 是一个前端框架，它管的是 UI 组件 这一摊事。\n后面我们讲后端时，还会遇到 Web 框架。那时框架管的就是另一摊事：请求怎么进来、路由怎么分发、接口怎么返回。\n把框架理解成“规则”，而不是“魔法”，后面你看整个软件世界都会清楚很多。\nReact 这套规则的核心是：\n把一块界面定义成一个组件。\n一个组件里面，可以包含这块界面需要的：\n数据 结构 样式 行为 组件还可以像搭积木一样组合、嵌套。小组件拼成大组件，大组件再拼成页面，页面再被总组件管理起来。\n这和我们之前写 HTML 的方式不一样。\n之前是“按文件类型分”：HTML 负责结构，CSS 负责样式，JS 负责行为。\nReact 更像是“按界面单元分”：这张卡片是一整个组件，那它相关的结构、数据、样式类名和行为，就尽量收在这个组件周围。\nVanilla：没有框架的原生写法 在讲框架时，你经常会看到一个词：vanilla。\nVanilla 的意思是“香草”。在软件行业里，它常被借来表示“原生的、没有额外框架包装的版本”。\n所以 vanilla 前端 通常指：\n不依赖 React、Vue 这类前端框架，只使用浏览器原生支持的 HTML、CSS、JavaScript 来构建页面。\n我们在这节课之前写的页面，就基本可以理解成 vanilla 写法。\n不用太纠结这个词的边界。有人会争论“用了第三方库还算不算 vanilla”，这对我们现在没意义。你只要把它理解成“没有使用前端框架的写法”就够了。\n浏览器并不认识 React 无论前端框架有多少，浏览器真正认识的东西始终只有三样：\nHTML、CSS、JavaScript。\nReact 没有抛弃这三件套。它只是在这三件套上面，加了一层更舒服的“作者层”：\n你按 React 的规则写，构建工具在背后把它编译回浏览器认识的 HTML、CSS、JavaScript。\n这就是为什么这门课先讲 Vite，再讲 React。\nReact 这种写法，离不开构建工具。\n没有构建工具那道翻译工序，浏览器根本不认识 React 项目里的 JSX、组件、模块导入这些写法。\n把 React 装进项目 React 从使用方式上看，本质上就是几个 npm 包。\n上一节我们用 npm 安装过 anime.js。安装 React 的方式也一样，只是 React 管的事情更核心、更大。\n在项目根目录里执行：\nnpm install react react-dom 这两个包就是 React 的核心：\nreact：React 本身 react-dom：让 React 能把组件渲染到网页 DOM 上 但只装 React 还不够。前面说过，React 代码需要被 Vite 翻译。那 Vite 凭什么知道怎么翻译 React？\n答案是：需要一个插件。\nnpm install -D @vitejs/plugin-react 注意，install 只是把插件下载到了本地 node_modules 里。插件要真正生效，还得挂进 Vite。\n在项目根目录新建 vite.config.js：\nimport { defineConfig } from \u0026#34;vite\u0026#34;; import react from \u0026#34;@vitejs/plugin-react\u0026#34;; export default defineConfig({ plugins: [react()], }); 这几行的意思是：把 React 插件挂到 Vite 上。\n到这里，Vite 就能看懂 React 代码了。\n再换一个角度看，“框架是一套规则”其实有两层含义：\n对开发者来说，React 是一套更舒服的写界面规则。 对构建工具来说，React 还附带了一套“怎么把这种写法翻译回基础前端”的规则。 这一节的改造路线 这一节有配套代码。你可以把 demo 拉到本地：\ngit clone https://github.com/joylibo/zero-to-tech-demos.git cd zero-to-tech-demos/zero-to-tech-4-3 这个 demo 是“最终答案”：一个已经改造成 React 的版本。\n但学习时不要直接无脑抄完。我们要在 4.2 的项目基础上，一点一点改。需要哪个文件，就从 demo 里把对应文件拷过来。\n这节课的改造分成四拍：\n先把文字实验室的“结果区”卡片改成 React 组件。 再把整个文字实验室页面交给 React。 把个人主页也抽成组件，并收进一个总管 App。 最后用全新的 index.html 和 main.jsx 挂起整个 React 应用。 这样做虽然比直接新建 React 项目麻烦，但好处是你能亲眼看到：一个项目是怎么从 vanilla，一层一层长成 React 项目的。\n第一拍：先把结果区卡片做成 React 组件 我们先启动 4.2 那个项目：\ncd ~/zero-to-tech npm run dev 打开 text-lab.html，先只处理文字实验室右边那张“结果区”卡片。\n这一拍的目标很小：整个页面里，只有这一张卡片由 React 画出来；其他部分全部保持原来的 vanilla 写法。\n1. 拷入组件文件 从 demo 里把 ResultCard.jsx 拷到你的项目：\nsrc/components/ResultCard.jsx 如果项目里还没有 src/components/，就先新建这个目录。\nResultCard.jsx 是组件本身。它定义了这张卡片是什么、长什么样、挂载后做什么动画。\n但组件文件自己不会自动出现在页面上。要把它放到页面里，还需要一个入口文件。\n2. 新建入口文件 src/result.jsx 新建：\nsrc/result.jsx 写入：\nimport { createRoot } from \u0026#34;react-dom/client\u0026#34;; import ResultCard from \u0026#34;./components/ResultCard.jsx\u0026#34;; createRoot(document.getElementById(\u0026#34;result-root\u0026#34;)).render(\u0026lt;ResultCard /\u0026gt;); 这几行做的事很简单：\n把 React 的 createRoot 引进来。 把 ResultCard 组件引进来。 找到页面上 id=\u0026quot;result-root\u0026quot; 的位置。 把 ResultCard 渲染进去。 记住这个分工：\n组件负责“是什么”，入口负责“挂哪儿”。\n3. 在 text-lab.html 里留出挂载点 找到原来结果区那整段 \u0026lt;article\u0026gt;，把它删掉，换成：\n\u0026lt;div id=\u0026#34;result-root\u0026#34; class=\u0026#34;panel-half\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; 然后在页面底部保留原来的 js/main.js，再新加一行：\n\u0026lt;script type=\u0026#34;module\u0026#34; src=\u0026#34;js/main.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34; src=\u0026#34;/src/result.jsx\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 这里有一个细节非常重要：挂载点上要保留 class=\u0026quot;panel-half\u0026quot;。\n因为页面外层是 CSS Grid 布局，.panel-half 决定这一格占半宽。React 组件虽然内部也能写 class，但 CSS Grid 只会对直接子元素生效。\n如果你把 panel-half 只写在 React 组件内部，而外层挂载点只是一个普通 \u0026lt;div id=\u0026quot;result-root\u0026quot;\u0026gt;，这个挂载点就会默认只占很窄的一格，结果卡片看起来就会“崩掉”。\n所以第一拍的正确理解是：\n#result-root 负责在原页面里占位置。 ResultCard 负责画出真正的卡片。 4. 运行看效果 npm run dev 打开：\nlocalhost:5173/text-lab.html 你会看到：页面还是原来的页面，但右边那张结果卡片已经由 React 画出来了。\n这一幕很重要。它说明 React 可以只接管页面的一个角落，不要求你一上来把整个项目全部重写。\n不过，这种“同一页里一半 vanilla、一半 React”的状态，只适合学习演示。真实项目里一般不建议长期这样混着写，因为复杂度会变高，也容易出现布局、动画、状态不同步的小别扭。\n比如你现在会看到，左右两张卡片高度不完全一致——这是“半 vanilla、半 React”这种临时状态带来的小别扭，到第二拍整页交给 React 之后，就自然齐了。\n至于“开始分析”按钮点了没反应，那是另一回事，不是没做完，而是故意留着的：让按钮去驱动结果，属于“点击 → 数据变 → 界面跟着变”，那是下一节 4.4「数据驱动界面」的正题，这一节先不接。所以这一节里，结果卡的数字滚动只是它一出现时自己放一遍的“入场动画”——你刷新能看到，点按钮却不触发。\n读懂 ResultCard：一个完整的 UI 零件 打开你刚拷进来的 src/components/ResultCard.jsx，它的完整代码就是下面这些：\nimport { useEffect, useRef } from \u0026#34;react\u0026#34;; import { animate, scrambleText } from \u0026#34;animejs\u0026#34;; export default function ResultCard() { const cardRef = useRef(null); const scoreRef = useRef(null); useEffect(() =\u0026gt; { // 卡片自己淡入：.card 默认 opacity:0，这张卡负责把自己显出来 animate(cardRef.current, { opacity: [0, 1], translateY: [24, 0], duration: 700, ease: \u0026#34;outBack\u0026#34;, }); // 情感分数滚动归位 animate(scoreRef.current, { innerHTML: scrambleText({ chars: \u0026#34;0-9\u0026#34; }), duration: 1500, }); }, []); return ( \u0026lt;article ref={cardRef} className=\u0026#34;panel panel-half lab-panel result-panel card\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;panel-heading\u0026#34;\u0026gt; \u0026lt;p className=\u0026#34;section-kicker\u0026#34;\u0026gt;结果区\u0026lt;/p\u0026gt; \u0026lt;h3\u0026gt;分析结果\u0026lt;/h3\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div className=\u0026#34;result-stack\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;result-item\u0026#34;\u0026gt; \u0026lt;span\u0026gt;原文\u0026lt;/span\u0026gt; \u0026lt;p\u0026gt;今天的风很轻，适合把脑海里的想法慢慢写下来。\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div className=\u0026#34;result-item\u0026#34;\u0026gt; \u0026lt;span\u0026gt;拼音\u0026lt;/span\u0026gt; \u0026lt;p\u0026gt;jīn tiān de fēng hěn qīng …\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div className=\u0026#34;result-grid\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;result-badge\u0026#34;\u0026gt; \u0026lt;span\u0026gt;情感分数\u0026lt;/span\u0026gt; \u0026lt;strong data-score ref={scoreRef}\u0026gt;0.86\u0026lt;/strong\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div className=\u0026#34;result-badge\u0026#34;\u0026gt; \u0026lt;span\u0026gt;情感判断\u0026lt;/span\u0026gt; \u0026lt;strong\u0026gt;偏积极\u0026lt;/strong\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/article\u0026gt; ); } 你不需要现在就完全掌握每个 API，但可以先看懂它的结构：\nimport：这个组件需要什么，就自己引进来。这里引进了 React 的工具，也引进了 anime.js。 export default：把这个组件交出去，让别的文件可以使用它。 function ResultCard()：React 组件本质上就是一个函数。 return (...)：函数返回一段长得像 HTML 的东西，这种写法叫 JSX。 className：在 JSX 里，HTML 的 class 要写成 className。 useEffect：组件出现在页面上后，执行一次动画逻辑。 ref：拿到真实 DOM 节点，交给 anime.js 做动画。 这一张卡片里面，已经同时包含了：\n数据：原文、拼音、情感分数、情感判断。 结构：return 里的标签层级。 样式：className 指向外部 CSS 里已有的样式规则。 行为：useEffect 里的淡入和数字滚动动画。 所以可以这样总结：\n一个组件，就是把“数据、结构、样式、行为”这一整套，封装成一个能独立拎走的 UI 单元。\n这就是 React 的地基。后面整个项目，都是拿这种零件搭出来的。\n第二拍：把整个文字实验室页面交给 React 现在结果卡片已经是 React 组件了。那文字实验室页面上的其他部分，也可以拆成组件。\n这一页天然可以拆成几块：\n顶部导航：Nav 大标题和副标题：PageHeading 输入卡片：InputCard 结果卡片：ResultCard 外层动画和网格容器：AnimatedCardGrid 然后再用一个大组件，把它们拼成整页：\nTextLabPage ├─ Nav ├─ PageHeading ├─ InputCard └─ ResultCard 注意，AnimatedCardGrid 这类组件自己不一定展示具体内容，它更像一个容器：把别的组件包进去，统一提供布局和动画。这种组件在 React 项目里很常见。\n1. 拷入页面相关组件 从 demo 里把这些文件拷进项目：\nsrc/components/Nav.jsx src/components/PageHeading.jsx src/components/InputCard.jsx src/components/AnimatedCardGrid.jsx src/components/TextLabPage.jsx 同时，把原来那 8 个 CSS 文件拷到：\nsrc/css/ 为什么 CSS 也要进 src？\n因为接下来 text-lab.html 会退化成一个挂载点，不再负责用 \u0026lt;link\u0026gt; 引入样式。样式会改由 React 入口文件 import 进来。\n原来根目录下的 css/ 先不要删，因为个人主页 index.html 此时还在用它们。\n2. 换掉临时入口 第一拍的 src/result.jsx 只负责挂一个 ResultCard。现在 ResultCard 已经被收进 TextLabPage 了，所以这个临时入口可以不要。\n新建：\nsrc/textlab.jsx 完整内容如下，照抄即可：\nimport { createRoot } from \u0026#34;react-dom/client\u0026#34;; import TextLabPage from \u0026#34;./components/TextLabPage.jsx\u0026#34;; import \u0026#34;./css/reset.css\u0026#34;; import \u0026#34;./css/variables.css\u0026#34;; import \u0026#34;./css/layout.css\u0026#34;; import \u0026#34;./css/hero.css\u0026#34;; import \u0026#34;./css/nav.css\u0026#34;; import \u0026#34;./css/cards.css\u0026#34;; import \u0026#34;./css/lab.css\u0026#34;; import \u0026#34;./css/responsive.css\u0026#34;; createRoot(document.getElementById(\u0026#34;root\u0026#34;)).render( \u0026lt;div className=\u0026#34;app-shell\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;page-shell\u0026#34;\u0026gt; \u0026lt;main className=\u0026#34;page-content\u0026#34;\u0026gt; \u0026lt;TextLabPage current=\u0026#34;textlab\u0026#34; onNavigate={() =\u0026gt; {}} /\u0026gt; \u0026lt;/main\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt;, ); 这里的 app-shell / page-shell / page-content 是页面外壳，负责背景、最大宽度、左右留白。\n以前这层外壳写在 text-lab.html 里。现在 HTML 要清空了，所以暂时由入口文件补上。第四拍会把它们再交给更大的 App 统一管理。\n3. 清空 text-lab.html 把 text-lab.html 的 \u0026lt;body\u0026gt; 整块内容，连同底部 script，都换成：\n\u0026lt;body\u0026gt; \u0026lt;div id=\u0026#34;root\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34; src=\u0026#34;/src/textlab.jsx\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;head\u0026gt; 里那些 CSS \u0026lt;link\u0026gt; 也可以删掉，因为 CSS 已经由 src/textlab.jsx 引入。\n这一刻很关键：\nHTML 从“页面主体”，退化成了“一个挂载点”。\n原来整页内容都写在 HTML 里。现在 HTML 只留一块空地，真正的页面由 React 画出来。\n4. 保持 index.html 不动 此时个人主页 index.html 还是原来的 vanilla 页面，不要动。\n运行：\nnpm run dev 你会看到：\nlocalhost:5173/text-lab.html：整页已经由 React 渲染。 localhost:5173/：个人主页仍然是原来的 vanilla 页面。 这说明一个项目里可以出现“一个页面是 React，一个页面还是 vanilla”的过渡状态。\n真实项目迁移时，这种阶段性共存也很常见。\n看懂 TextLabPage：组件可以嵌套组件 打开你刚拷进来的 src/components/TextLabPage.jsx，核心就是下面这段（demo 文件里还有几行注释，作用一样，这里略去）：\nimport Nav from \u0026#34;./Nav.jsx\u0026#34;; import PageHeading from \u0026#34;./PageHeading.jsx\u0026#34;; import AnimatedCardGrid from \u0026#34;./AnimatedCardGrid.jsx\u0026#34;; import InputCard from \u0026#34;./InputCard.jsx\u0026#34;; import ResultCard from \u0026#34;./ResultCard.jsx\u0026#34;; export default function TextLabPage({ current, onNavigate }) { return ( \u0026lt;AnimatedCardGrid className=\u0026#34;dashboard-grid\u0026#34;\u0026gt; \u0026lt;article className=\u0026#34;hero-stage panel-full\u0026#34;\u0026gt; \u0026lt;Nav current={current} onNavigate={onNavigate} /\u0026gt; \u0026lt;PageHeading title=\u0026#34;文字实验室\u0026#34; subtitle=\u0026#34;拼音和情绪，挖掘中文里的细节\u0026#34; /\u0026gt; \u0026lt;/article\u0026gt; \u0026lt;InputCard /\u0026gt; \u0026lt;ResultCard /\u0026gt; \u0026lt;/AnimatedCardGrid\u0026gt; ); } 这里最重要的不是记住代码，而是看懂几条规则。\n大写开头的是组件，小写开头的是 HTML 标签 \u0026lt;Nav /\u0026gt;、\u0026lt;PageHeading /\u0026gt;、\u0026lt;InputCard /\u0026gt;、\u0026lt;ResultCard /\u0026gt; 都是我们自己定义的 React 组件。\n\u0026lt;article\u0026gt; 是普通 HTML 标签。\nReact 里有一条规矩：\n组件名必须大写开头。\n因为构建工具翻译 JSX 时，会根据首字母大小写判断：\n小写开头：当成浏览器原生 HTML 标签。 大写开头：当成你自己定义的 React 组件。 所以组件不是“随便写成标签样子”那么简单。它背后有一套明确的翻译规则。\n组件挂到 HTML，需要入口；组件放进组件，只要写标签 第一拍里，我们把 ResultCard 挂到 HTML 页面上，需要写：\ncreateRoot(document.getElementById(\u0026#34;result-root\u0026#34;)).render(\u0026lt;ResultCard /\u0026gt;); 但在 TextLabPage 里使用 ResultCard，只需要：\n\u0026lt;ResultCard /\u0026gt; 原因是：\n挂到 HTML：这是 React 世界和真实 DOM 世界的交界，需要 createRoot。 放进另一个组件：还在 React 世界内部，写成标签就行。 你可以把 createRoot 理解为“进入 React 世界的门”。整个项目通常只在入口处做一次。\nprops：给组件传参数 这一行：\n\u0026lt;PageHeading title=\u0026#34;文字实验室\u0026#34; subtitle=\u0026#34;拼音和情绪，挖掘中文里的细节\u0026#34; /\u0026gt; 里面的 title 和 subtitle，就是传给组件的参数，React 里通常叫 props。\n同一个 PageHeading，你喂给它不同的标题和副标题，它就能显示不同内容。\n这就是复用的基础。\n第三拍：把个人主页也抽成组件 现在文字实验室已经整个交给 React 了。接下来处理个人主页。\n从 demo 里拷入：\nsrc/components/HomePage.jsx 打开它，你会看到个人主页也被做成了一个大组件。\n这里有两个点特别值得注意。\n1. 复用终于变得很明显 个人主页顶部也用了：\n\u0026lt;Nav current={current} onNavigate={onNavigate} /\u0026gt; \u0026lt;PageHeading title=\u0026#34;关于我\u0026#34; subtitle=\u0026#34;项目，创意，灵感，心得，我的作品\u0026#34; /\u0026gt; 这和文字实验室用的是同一套 Nav 和 PageHeading。\nNav 两页完全复用。PageHeading 则通过 props 显示不同内容：\n在首页，title 是“关于我”。 在文字实验室，title 是“文字实验室”。 同一个组件，两处使用，只是参数不同。\n如果以后要调整标题区域的样式，你只改 PageHeading.jsx，两个页面都会同时生效。\n这就是组件复用最直接的价值。\n2. 不是拆得越细越好 你也会看到，个人主页下面那两张卡片没有被单独拆成组件，而是直接写在 HomePage 里。\n为什么？\n因为它们只在首页用，内容也简单。拆出去换不来复用，反而多几个文件，让项目更碎。\n拆组件不是为了显得专业，而是为了两个目的：\n能复用。 让页面代码更清爽。 一块东西要不要单独拆成组件，就问两句话：\n它会在多个地方用吗？ 拆出去能让当前页面更好读吗？ 如果答案都是否，那就没必要拆。\n把两个页面收进 App 照前面的惯性，你可能会想：那我们再写一个入口，把 HomePage 挂进旧的 index.html 不就行了吗？\n先别急。\n现在我们已经有两个页面组件：\nHomePage TextLabPage 既然小组件可以装进大组件，那这两个页面组件，也可以装进一个更大的组件。\n这个最大的组件，就叫：\nApp 从 demo 里拷入：\nsrc/App.jsx 它的核心代码就是下面这段（同样略去了 demo 里的注释）：\nimport { useState } from \u0026#34;react\u0026#34;; import HomePage from \u0026#34;./components/HomePage.jsx\u0026#34;; import TextLabPage from \u0026#34;./components/TextLabPage.jsx\u0026#34;; export default function App() { const [page, setPage] = useState(\u0026#34;home\u0026#34;); return ( \u0026lt;div className=\u0026#34;app-shell\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;page-shell\u0026#34;\u0026gt; \u0026lt;main className=\u0026#34;page-content\u0026#34;\u0026gt; {page === \u0026#34;home\u0026#34; ? \u0026lt;HomePage current={page} onNavigate={setPage} /\u0026gt; : \u0026lt;TextLabPage current={page} onNavigate={setPage} /\u0026gt;} \u0026lt;/main\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; ); } App 是整个项目的总管。\n它负责记住当前显示哪一页：是 HomePage，还是 TextLabPage。\n这里出现了 useState。你现在只需要知道，它用来“记住一个会变化的值”。这节课不展开讲，下一节 4.4 会专门讲“状态”和“数据驱动界面”。\n到这里，两个页面都已经变成 React 组件，并且被收进了 App。\n但项目还不能直接跑，因为 App 还没有被挂到 HTML 上。\n这就是最后一拍要解决的事。\n第四拍：全新的 index.html 和 main.jsx 现在旧的两个 HTML 文件已经完成历史任务：\n旧 index.html 原来装的是个人主页内容。 text-lab.html 原来装的是文字实验室内容。 但现在这两个页面内容都搬进 React 组件了。\n所以最终项目只需要一个新的 HTML 文件。注意，它不是原来的个人主页，而是一个全新的、几乎空白的 React 挂载壳。\n1. 清理旧文件 此时可以删掉：\n旧 index.html text-lab.html 第二拍临时用的 src/textlab.jsx 旧的 js/ 文件夹 旧的 css/ 文件夹 因为：\n页面内容已经搬进 HomePage 和 TextLabPage。 样式已经搬进 src/css/。 老的 vanilla 脚本已经不再使用。 2. 新建 src/main.jsx 从 demo 拷入：\nsrc/main.jsx 它的完整内容如下：\nimport { StrictMode } from \u0026#34;react\u0026#34;; import { createRoot } from \u0026#34;react-dom/client\u0026#34;; import App from \u0026#34;./App.jsx\u0026#34;; import \u0026#34;./css/reset.css\u0026#34;; import \u0026#34;./css/variables.css\u0026#34;; import \u0026#34;./css/layout.css\u0026#34;; import \u0026#34;./css/hero.css\u0026#34;; import \u0026#34;./css/nav.css\u0026#34;; import \u0026#34;./css/cards.css\u0026#34;; import \u0026#34;./css/lab.css\u0026#34;; import \u0026#34;./css/responsive.css\u0026#34;; createRoot(document.getElementById(\u0026#34;root\u0026#34;)).render( \u0026lt;StrictMode\u0026gt; \u0026lt;App /\u0026gt; \u0026lt;/StrictMode\u0026gt;, ); 它就是整个 React 项目的总入口：把 App 挂进 HTML 的 #root。\n3. 新建全新的 index.html 新的 index.html 只需要做一件事：提供挂载点，并引入 main.jsx。\n\u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026#34;zh-CN\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34; /\u0026gt; \u0026lt;title\u0026gt;Zero to Tech\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div id=\u0026#34;root\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34; src=\u0026#34;/src/main.jsx\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 这和最开始那个写满个人主页内容的 index.html，已经不是同一个角色了。\n旧 index.html 是页面本身。\n新 index.html 是 React 应用的空壳。\n4. 运行和构建 运行：\nnpm run dev 打开：\nlocalhost:5173/ 现在个人主页和文字实验室都由 React 渲染，点击导航可以在两页之间切换。\n再构建一次：\nnpm run build 构建之后，Vite 仍然会输出浏览器认识的文件：HTML、CSS、JavaScript。\n这正好印证了前面的结论：我们写的是 React，但最后交给浏览器的，仍然是基础前端三件套。\n最终的 React 项目骨架 到最后，这个项目的层级会变成这样：\nindex.html └─ src/main.jsx └─ App.jsx ├─ HomePage.jsx │ ├─ Nav.jsx │ └─ PageHeading.jsx └─ TextLabPage.jsx ├─ Nav.jsx ├─ PageHeading.jsx ├─ InputCard.jsx ├─ ResultCard.jsx └─ AnimatedCardGrid.jsx 换成一句话：\nindex.html：空壳，只提供挂载点。 main.jsx：入口，把 App 挂进去。 App.jsx：总管，决定当前显示哪个页面。 HomePage / TextLabPage：页面组件。 更小的组件：页面里的零件。 这就是一个 React 项目最核心的骨架。\n真正从 0 新建 React 项目，不用这么麻烦 这一节我们故意从一个 vanilla 项目开始，一步一步改成 React。\n这样做是为了学习：你能看清 React 是怎么接进旧项目的，也能看清 HTML 是怎么从页面主体变成挂载点的。\n但以后如果你真的从 0 新建一个 React 项目，不需要这么绕。\n用 Vite 可以直接初始化：\nnpm create vite 按照提示选择：\n项目名 React JavaScript Vite 会直接帮你生成一套 React 项目骨架：index.html、src/main.jsx、src/App.jsx、配置文件等。\n你会发现，它生成出来的结构，和我们这一节最后手动改出来的结构非常像。\n区别只是：我们这节课是为了理解，所以亲手走了一遍“从无到有”的过程。\n框架真正难的不是语法，而是生态 现在我们已经知道：\n框架是一套新规则，并且它还要能被构建工具翻译回浏览器认识的代码。\n那你可能会冒出一个想法：\n如果我有一套更好的代码组织思路，我是不是也能造一个新框架？\n理论上，完全可以。\n你可以定义自己的写法，再写一套翻译规则，让构建工具把它编译成 HTML、CSS、JavaScript。\n造框架在技术上并不是遥不可及。\n真正难的是另一件事：\n你的框架，有生态吗？\n假设你要做一个后台管理系统，需要：\n日期选择器 数据表格 图表 富文本编辑器 表单校验 弹窗 文件上传 如果你用 React 或 Vue，这些东西早就有人做好了。很多时候一行命令装进来，就能直接用。\n比如 React 生态里有：\nAnt Design Material UI shadcn/ui React Bits Vue 生态里也有：\nElement Plus Ant Design Vue Naive UI 但如果你造了一个全新的小众框架，对不起，这些轮子很可能都没有。一个日期选择器看起来简单，真要做到可用、稳定、各种边界都处理好，就可能耗掉好几天。\n这就是生态。\n一个框架值不值得用，很大程度上不只是看语法漂不漂亮，而是看它背后有没有：\n现成组件 第三方库 教程 项目经验 社区问题解答 已经替你踩过坑的人 React 和 Vue 之所以是主流，不只是因为它们自己好用，更因为它们背后有足够厚的生态。\n我们这一节复用的，还只是自己写的 Nav 和 PageHeading。\n而框架生态真正带来的复用，是让你可以复用全世界开发者已经写好的东西。\n这才是组件化、框架化最大的红利。\n这一节你应该带走什么 这节课内容很多，但最重要的是这几件事：\nReact 是一套架在构建工具之上的前端开发新规则。 你按 React 写，Vite 把它翻译成浏览器认识的 HTML、CSS、JavaScript。 React 的核心是组件。 一个组件，就是把数据、结构、样式、行为封装成一个能独立拎走的 UI 单元。 组件可以嵌套组件。 小组件拼成页面组件，页面组件再被 App 统一管理。 HTML 在 React 项目里会退化成挂载点。 页面真正的内容由 React 组件渲染出来。 入口文件负责把 React 挂到 HTML 上。 createRoot 是进入 React 世界的门。 拆组件不是越细越好。 拆组件是为了复用，或者为了让页面结构更清楚。 React 和 Vue 是同类工具。 这门课选 React，是因为它生态大，但真正要学的是可迁移的概念。 框架最大的价值是生态。 你不只是复用自己写的组件，还能复用全世界已经做好的组件和库。 下一节，我们继续围绕 React 组件往前走，把“状态”和“数据驱动界面”讲清楚，让页面真正跟着数据变化。\n← 上一节：模块 4.2 Vite、npm 与前端构建 | 下一节：模块 4.4 让数据驱动界面 →\n","date":"2026.06.20","description":"用 React 重新组织网页，理解组件化如何改变前端开发。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-4-3/","title":"模块 4.3：React——前端开发新规则"},{"content":" 上一节 4.1，我们给项目做了模块化改造，迈出了\u0026quot;现代前端\u0026quot;的第一步。这一节开始接触工具——npm、Vite，并认识 Node.js。其中 npm 的性价比尤其高：这一节学会，以后无论做前端开发，还是使用各种开源工具，都会用到它。\n这一节不再提供新代码——你手上 4.1 模块化改完的那份，就是本节的起点。要做的，全是在它身上敲几条命令、改动 package.json 和 .gitignore，没有需要你编写的业务代码。\n模块化，真的什么都好吗？ 上一节我们反复强调了模块化的重要性，以及它如何让代码之间的引用关系变得清晰。但若从开发者的体验出发，模块化改造之后，真的处处都好吗？\n其实未必。上一节的内容偏\u0026quot;规矩\u0026quot;（import / export 这套机制），学起来可能略显枯燥；即使没有完全消化，也不要紧——那一节真正需要记住的只有两点：\n模块化势在必行：前端发展到今天，迟早要走这一步； 模块化之后，要浏览网页就必须走服务器——不能再双击 index.html 直接打开了。 而其中第 2 点——\u0026ldquo;看一眼效果都要走一趟服务器\u0026rdquo;——已经相当影响体验。而且不止这一处：到目前为止，前端这一块至少还剩三件让人难受的事。\n先盘一盘：4.1 之后，还剩哪些难受 4.1 我们把项目模块化了，也介绍了它的价值——它为第三方库的大规模调用铺平了道路。但跟着做下来，你应该已经亲身体会到这几件别扭事：\n难受一：想看一眼效果，太麻烦了。 模块化之后，代码不能再双击 index.html 打开（ES 模块必须走服务器）。所以在 4.1 里，每改一行、想看效果，都得 push、上服务器 pull、再刷新——一整轮折腾下来，黄花菜都凉了。\n难受二：打开一个页面，浏览器要拉一大堆文件。 按 F12 看一眼 Network——十几个请求排成一长队。光 css 就有 8 个文件，每个都单独走一趟网络，再加上几个 js 和 anime.js……浏览器要一个个去取，慢。\n难受三：改了 css，刷新却没变。 改完某个 css、保存、刷新，页面却没有动静。你以为是自己改错了，排查半天——其实是浏览器把旧文件缓存住了，根本没去取新的。这个坑，在模块 3.5 部署时就隐约碰到过。\n这三件事，靠手动去管是治不利索的。好在——有一类工具，专门解决它们。\n有一类工具，专门收拾这些：构建工具 先往深一层看：刚才那三件难受，背后其实是一组矛盾——工程治理、开发体验、用户体验，这三方在互相拉扯。\n工程治理：希望项目规范、安全、好维护、好扩展（模块化、拆文件，都是为它）； 开发者：希望写得简单、顺手、舒服； 浏览器与最终用户：希望加载更快、资源更少、性能更高。 这三方常常彼此冲突：为了规范，牺牲了开发便利；为了开发便利，又影响运行效率……很难同时满意。那么，有没有办法让三方都尽量满意？\n有——让一类工具夹在中间做\u0026quot;翻译\u0026quot;：工程的规矩照守；开发者按自己舒服的方式写；最后由它把开发者写的源码，转换成浏览器想要的样子。这个转换过程就叫前端构建，这类工具就叫 构建工具（build tool）。\n它具体替你做三件事，恰好一一对上那三件难受：\n在本地起一个服务器——改完一保存，浏览器自动更新（这叫热更新），不必再走 push、pull、刷新那一整轮；→ 治难受一 把一堆碎文件合并成一个——8 个 css 合成 1 个，请求数从十几个降到几个；→ 治难受二 给文件名加一段\u0026quot;指纹\u0026quot;（hash）——内容一变，名字就变，浏览器立刻知道需要重新取，缓存便失效了。→ 治难受三 这个构建工具，我们选 Vite 构建工具不止一个。早些年最有名的叫 webpack；而最近几年，前端的首选已经换成了 Vite。\nVite 是个法语词，意为\u0026quot;快\u0026quot;，读音是 /viːt/（不要按英文习惯读成 \u0026ldquo;vait\u0026rdquo;）。它的作者是中国人——尤雨溪。我们选它，不只是因为作者是中国人，更因为它确实是目前最优秀、最流行的那一个。\n接下来要做的，就是把 Vite 请进我们的项目。但在此之前，得先铺一点底——因为 Vite 自己，也是需要\u0026quot;被运行\u0026quot;的。\n想跑 Vite，先得有\u0026quot;运行 JS 的环境\u0026quot;：Node.js Vite 这个工具，本身是用 JavaScript 写的。\n而此前我们接触的 JavaScript 都在浏览器里运行。Vite 不是网页，它是一个命令行工具，需要在浏览器之外、在你的电脑上运行。那么，由谁来运行它？\n打一个你可能熟悉的比方：要运行一个 .py 文件，得先装 Python；要打开一个 .xlsx 文件，得先装 Excel。同样的道理——要在浏览器之外运行 JavaScript，就得先装 Node.js。\nNode.js 就是\u0026quot;让 JavaScript 跳出浏览器、在你电脑上直接运行\u0026quot;的运行环境。 装了它，Vite 这类用 js 写成的命令行工具才跑得起来。\n此外，Node 还附赠了一个非常重要的东西——一个包管理工具，叫 npm。\n这个 npm，你先可以理解成一个\u0026quot;JavaScript 的应用商店\u0026quot;——与模块 2、3 里在 Ubuntu 上用过的 apt 是一个意思。apt 帮你装 nginx、装 git；npm 帮你装各种现成的 js 工具和库。待会儿要的 Vite，就用 npm 来装。（之所以说\u0026quot;先\u0026quot;，是因为它其实还不止是个商店——这一点等用到时再说。）\n先把 Node 装上 按你的系统选择对应方式：\nmacOS：去 nodejs.org 下载 LTS 版安装包，双击安装（或 brew install node）。 Windows：去 nodejs.org 下载 LTS 版 .msi，一路下一步。 Linux：用 NodeSource 走 apt（与模块 3.5 装 nginx 同一思路）： curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt install -y nodejs 装完后新开一个终端验证：\nnode -v # 看到 v20.x.x 之类 npm -v # 看到 10.x.x 之类 两条都能输出版本号，就说明安装成功。npm 随 Node 一起安装，不必单独安装；而且这一次装好，以后所有项目都可以使用。\nnpm 和 apt 有个关键区别：它\u0026quot;只管这一个项目\u0026quot; 虽然 npm 和 apt 都是\u0026quot;应用商店\u0026quot;，但有一处重要的不同：\napt 给整台电脑装东西——装一个 nginx，全系统都能用。 npm 默认给\u0026quot;当前这一个项目\u0026quot;装东西——这个项目用 anime.js 4.4，那个项目用 anime.js 3.0，两边各装各的、互不干扰。 正因为是\u0026quot;按项目来管\u0026quot;，使用前得先明确告诉它：这里有一个项目。这个动作叫初始化。进入上一节做过模块化改造的那份项目的根目录（与 index.html 同级），运行：\nnpm init -y 运行完，目录里会多出一个文件 package.json，大致如下（你的内容会与此略有出入，下面会解释原因）：\n{ \u0026#34;name\u0026#34;: \u0026#34;zero-to-tech\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;1.0.0\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;\u0026gt; 这是 零到全栈 · 模块 4.1 …… 的配套代码—— \u0026gt; 网站模块化改造之前的起点版本。\u0026#34;, \u0026#34;main\u0026#34;: \u0026#34;index.js\u0026#34;, \u0026#34;scripts\u0026#34;: { \u0026#34;test\u0026#34;: \u0026#34;echo \\\u0026#34;Error: no test specified\\\u0026#34; \u0026amp;\u0026amp; exit 1\u0026#34; }, \u0026#34;keywords\u0026#34;: [], \u0026#34;author\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;license\u0026#34;: \u0026#34;ISC\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;commonjs\u0026#34; } 这个 package.json，就是项目的**\u0026ldquo;npm 档案\u0026rdquo;**——npm 以后给它装了什么、它能运行哪些命令，都记在这里。\n这个文件的后缀名是 .json，是本课程中第一次见到这个后缀的文件，顺便介绍一下这种格式。JSON 是一种用纯文本承载数据的通用格式——它就是一堆 \u0026quot;键\u0026quot;: 值，用大括号括起来。人一眼能读懂，机器也便于解析，全世界的程序都用它来存数据、传数据。你不需要学怎么写它，认得出这个\u0026quot;键: 值\u0026quot;结构就够了。\nnpm init 有黑魔法吗？没有。 它做的唯一一件事，就是生成这样一份 package.json。你完全可以手动新建一个相同的文件（你在 4.1 手动新建过 main.js，是一样的操作）——只要内容是合法 JSON，npm 就认（最小的合法写法就是一对大括号 {}）。加 -y 只是图省事，顺手填上几个默认值而已。\n字段看着不少，但不必被它唬住——对我们这个项目而言，真正要管的只有两个，其余的全都用不上。\n现在真正要管的，就这两个：\nname：项目叫什么。默认取你的文件夹名——所以你看到的多半是 zero-to-tech。它只是个名字，对我们的项目并不关键。 version：项目自己的版本号，默认 1.0.0，可改可不改，也不关键。 剩下那一堆——description、main，有的版本还会有 type、keywords、author、license——这里不打算逐个细讲，因为对我们这个网页项目，它们全都用不上：要么是\u0026quot;将来把项目发布成 npm 包\u0026quot;时才用到，要么只是默认占位。\n既然用不上，那就直接删掉。package.json 就是个普通文本文件，手动删几行完全没问题（npm 也不要求这些字段必须存在）。把 name、version 之外的都删掉，只留这两行：\n{ \u0026#34;name\u0026#34;: \u0026#34;zero-to-tech\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;1.0.0\u0026#34; } 一句话收尾：删完，这份档案清清爽爽，但目前基本还是空的——没记什么有用的东西。不必着急，等下真正用 npm 装了东西，npm 会自动往这个干净的文件里写入有用的内容，那时这本账才算开始有用。\n装 Vite 有了 npm、有了 package.json 这本档案，安装 Vite 只需一行命令。在项目根目录（与 index.html、package.json 同级）运行：\nnpm install -D vite 注意这个 -D（即 --save-dev）：意思是\u0026quot;把 Vite 装成开发依赖\u0026quot;。为什么强调它？因为 npm 装的东西分两类——\n一类是在项目里帮你\u0026quot;干活\u0026quot;的工具，例如 Vite。它好比盖房子用的脚手架：帮你把活干完，但本身不会成为房子的一部分——上线的网站里并没有 Vite。这种\u0026quot;脚手架\u0026quot;性质的，安装时加 -D。\n另一类是会真正成为网站一部分的东西，例如等会儿要装的第三方动画库。它是盖房子的砖，会跟着房子一起上线。这种安装时不加 -D。\n两类的处理方式不同，等会儿装 anime 时会再说明。\n命令运行完，发生了两件事——\n第一，目录里多出一个 node_modules 文件夹。 Vite（连同它依赖的一大堆包）都被下载、堆放在这里。这个文件夹通常很大、文件极多——一般不必去翻它，知道\u0026quot;用 npm 装的东西都在这儿\u0026quot;即可。\n你可能会疑惑：只装了一个 Vite，为什么这么多文件？因为 Vite 自己也依赖别的包，那些包又依赖别的包……一层层牵连进来一大串，全都会被装进 node_modules。你可以在 npmgraph.js.org 输入 vite 看一看它的依赖网，就能理解 node_modules 为何这么大了。\n第二，package.json 有了变化，同时还多出一个 package-lock.json。\n你的 package.json 现在多了一段 devDependencies：\n{ ... \u0026#34;devDependencies\u0026#34;: { \u0026#34;vite\u0026#34;: \u0026#34;^7.0.0\u0026#34; } } 这一段是 npm 自动加上的——含义是\u0026quot;这个项目用了 vite，版本 ^7.0.0\u0026quot;（^ 表示\u0026quot;7 这个大版本里的最新，但不跳到 8\u0026quot;，相当于帮你锁住版本范围）。这就是那本账开始记账了。\n不过 devDependencies 只记录我们直接安装的这一个 vite；它所牵连的那些\u0026quot;连环依赖\u0026quot;不记在这本账上——它们各自记在自己的 package.json 里。\n与此同时出现的 package-lock.json，把这次实际安装的每个包的精确版本逐一锁死，确保你换一台电脑、或同事拉下来 npm install 时，装出来的东西完全一致。你不必读懂它，知道它在\u0026quot;锁死版本\u0026quot;、并且需要提交到 git 就够了。\nVite 装哪去了？怎么运行它？ 我们说 Vite 装进了 node_modules。但若去翻，你会发现那里有两个与 vite 相关的东西，二者别混淆：\nnode_modules/vite/ —— 一个文件夹，是 vite 的代码本体，即\u0026quot;vite 这个库住在哪儿\u0026quot;。 node_modules/.bin/vite —— 一条可执行命令，即\u0026quot;把 vite 运行起来的那个开关\u0026quot;。 为什么有这两个？因为有些 npm 包（如 vite）不只是\u0026quot;供你 import 的代码\u0026quot;，它还对外提供一条命令行命令。这类包会在自己的 package.json 里声明一个 bin 字段；npm 见到后，安装时就在 node_modules/.bin/ 下放一个指向命令入口的快捷方式（专业说法叫\u0026quot;符号链接\u0026quot;），并标记为可执行。所以：\n想看 vite 的代码 → node_modules/vite/； 想运行 vite 这条命令 → node_modules/.bin/vite。\n跑起来：开发服务器 + 打包 dev：随改随看 Vite 这条命令在 node_modules/.bin/vite，直接用这个路径运行它：\n./node_modules/.bin/vite Vite 当场在本地起了一个服务器，并给出一个地址 http://localhost:5173。\n顺带认识一个词：localhost。它看着像域名，其实代表的就是你自己这台电脑——所以这个地址的意思是\u0026quot;用 http 访问本机的 5173 端口\u0026quot;，并未走出本机。\n打开它——网站跑起来了（点导航在两个页面之间切换，也都正常）。更重要的是：改一行代码、一保存，浏览器自动就刷新了，不必手动、更不必部署。这就是热更新。\n难受一，解决了。 4.1 里看一眼效果要 push、pull、刷新一整轮；现在改完转眼就能看到。开发反馈从\u0026quot;以分钟计\u0026quot;变成\u0026quot;以秒计\u0026quot;。\nbuild：打包成上线版本 \u0026ldquo;合并文件、加 hash\u0026quot;这两件，则是在上线打包时完成的。先用 Control + C（macOS）停掉开发服务器。\nWindows 的 cmd 用 Ctrl + C；Windows 的 PowerShell 用 Ctrl + Shift + C。\n./node_modules/.bin/vite build 运行完，多出一个 dist 文件夹：\ndist/ ├── index.html └── assets/ ├── main-Beya6efK.css ← 你那 8 个 css，合并成了这 1 个 └── main-_go62SDo.js ← 你那几个 js 模块，合并成了这 1 个 难受二，解决了。 8 个 css 合并成 1 个、几个 js 合并成 1 个，请求数从十几个降到几个。（你可能注意到：anime.js 此刻还是单独从 CDN 拉取的一个请求——这条尾巴，留到本节最后再收。） 难受三，解决了。 注意文件名上挂的那串\u0026quot;乱码\u0026rdquo; main-Beya6efK.css——它就是 hash，由文件内容计算而来。内容一改，名字就变，浏览器一看名字不同，立刻知道\u0026quot;需要重新取\u0026quot;，不会再拿旧缓存敷衍你。 你可能还会发现：dist 里怎么只有 index.html、没有 text-lab.html？这是因为 Vite 打包时默认只认根目录那一个 index.html 作为入口。要让两个页面都打包进来，需要给 Vite 加一个\u0026quot;我有两个入口\u0026quot;的配置——但这一步本节不展开。原因是：下一节我们会把项目改成 React，届时两个页面会合成一个 index.html（页面之间靠组件切换），这个\u0026quot;多入口\u0026quot;的麻烦根本就不存在了。所以这个问题先留着，交给 React 去收。\npreview：上线前本地验一眼 dist 是打包好的产物，但不要双击 dist/index.html——它里头仍是 \u0026lt;script type=\u0026quot;module\u0026quot;\u0026gt;，依然不能用 file:// 双击打开（与 4.1 同理，模块必须走服务器）。要在本地查看打包结果是否正确，用：\n./node_modules/.bin/vite preview 它会起一个服务器，专门伺服 dist。打开后按 F12 看 Network——干净利落，只有几个请求。\n注意：preview 看的是 dist 里的真实产物。而上面说过，dist 里只有 index.html、没有 text-lab.html——所以在 preview 中点导航，是切不到\u0026quot;文字实验室\u0026quot;那一页的（这正好印证了上面那个\u0026quot;多入口\u0026quot;的坑）。\n（补充一句：打开 dist/index.html 会看到 Vite 给 script 加了 crossorigin 标记，那是正经走 http 时的 CORS 处理，此处不必深究。）\n一条要记住的规矩：源代码 ≠ 运行的代码 到这一步，你手上其实有了两份东西：\n你维护的，是源代码——js/、css/ 那些，文件多、好读、用名字 import。 真正上线运行的，是 dist——文件少、经过压缩、改了名，是浏览器认得的形态。 这是两份不同的东西，别混为一谈。\n由此可得几个直接的结论：\n改网站永远改源代码，然后重新 build。不要手动改 dist 里的文件——下一次 build 就会把它覆盖掉。 dist 才是真正要送上线的那一份。 dev 用于\u0026quot;写代码时\u0026quot;（运行源码 + 热更新，图改得快）；build 产出的 dist 才是\u0026quot;给生产服务器运行的版本\u0026quot;。也就是说——下一个模块（4.6 部署）真正推上线的，正是这种 dist。preview 只是上线前在本地先验一眼。 ./node_modules/.bin/vite 太长了：npm run 来帮忙 每次都敲 ./node_modules/.bin/vite 这一长串，实在啰嗦。\n还记得 package.json 里那个 scripts 吗？它正是为此而生——给常用命令登记一个简短别名。把它改成这样：\n\u0026#34;scripts\u0026#34;: { \u0026#34;dev\u0026#34;: \u0026#34;vite\u0026#34;, \u0026#34;build\u0026#34;: \u0026#34;vite build\u0026#34;, \u0026#34;preview\u0026#34;: \u0026#34;vite preview\u0026#34; } 以后就不必敲那一长串了，直接：\nnpm run dev # = ./node_modules/.bin/vite npm run build # = ./node_modules/.bin/vite build npm run preview # = ./node_modules/.bin/vite preview npm run xxx 的意思是\u0026quot;运行 scripts 里那个叫 xxx 的命令\u0026quot;。运行时，它会先去 node_modules/.bin 里查找有没有对应的命令——因为那里正好有 vite，所以这里写 vite 就行，不必写全路径。\n这里要解开前面那个扣子。 前面我们说 npm 是个\u0026quot;应用商店\u0026quot;，那它怎么还能运行我项目里的命令？这就是前面那个\u0026quot;先\u0026quot;字的伏笔：npm 不止\u0026quot;装、卸\u0026quot;这一手。apt 确实只管装卸软件，但 npm 还兼了一份\u0026quot;项目管家\u0026quot;的职责——因为它认 package.json 这本项目档案（前面就说过，它\u0026quot;记着这个项目能运行哪些命令\u0026quot;）。 所以 npm run dev 运行的，并不是 npm 自己的某个功能，而是你写在 scripts 里的那条命令（vite）——npm 只是翻开 package.json、照着上面写的替你执行一遍。一句话：npm run 运行的是\u0026quot;你的\u0026quot;命令，npm 不过是个照本宣科的执行者。\n那 dev / build / preview 这三个名字是固定的吗？不是，是我们自己起的。 scripts 本质上就是一张\u0026quot;命令快捷方式表\u0026quot;——键（名字）可以随便起，值是任意一条命令（哪怕与 js 毫无关系也行，npm 只是把它交给终端去执行）。例如你完全可以加一条 \u0026quot;hello\u0026quot;: \u0026quot;echo 你好\u0026quot;，然后运行 npm run hello。\n至此，这一节的三条命令你都见过了，整理如下：\n命令 作用 何时使用 npm run dev 起本地服务器，随改随看、热更新 开发时，写代码全程 npm run build 打包成 dist/ 上线前，生成给浏览器运行的版本 npm run preview 本地起服务器预览 dist 上线前自测，确认产物是否正确 .gitignore：有些东西，不该提交 到这里，目录里多了两个又大又\u0026quot;沉\u0026quot;的文件夹：node_modules 和 dist。它们要不要提交到 Git？——不要。 它们都是**\u0026ldquo;随时能重新生成\u0026quot;的产物**：\nnode_modules：照着 package.json，运行一次 npm install 就能重装出来。 dist：照着源代码，运行一次 npm run build 就能重新打包出来。 既然随时可重建，提交进 Git 就是白占空间、还拖慢同步。我们用 .gitignore（模块 3.3 学过的\u0026quot;忽略名单\u0026rdquo;）把它们挡在门外——在根目录新建 .gitignore，写上：\nnode_modules dist 这与 3.3 是同一个道理：Git 里提交的，永远是\u0026quot;源头\u0026quot;——你的代码，加上 package.json / package-lock.json 这两本账；而不提交\u0026quot;产物\u0026quot;（装出来的库、打包出来的 dist）。别人拿到源头，npm install + npm run build，就能原样复现一切。\n（至于 dist 究竟怎么送上线——是推源码让服务器自己 build，还是本地 build 好只推 dist——这是 4.6 部署那一节的内容，到时再细说。）\nOne more thing：既然有了 npm，让它来管依赖 到这里，构建这套东西你已经全部见过了。但还记得前面留的那条尾巴吗——anime.js 至今仍是从一个 CDN 网址拉取的。\n回头看 cards.js、score.js 顶上那一行：\nimport { animate, stagger } from \u0026#34;https://cdn.jsdelivr.net/npm/animejs@4/+esm\u0026#34;; 这是 4.1 留下的写法。它能跑，但有两点并不踏实：\n每次打开页面，都要联网去 jsdelivr 那台别人的服务器拉取 anime——一旦没网、或那个网址哪天失效，动画就会失灵； 网址里的 @4 指\u0026quot;最新的 4.x\u0026quot;，哪天它小版本一更新，页面可能毫无征兆地改变行为。 现在情况不同了——我们有 npm 了。 既然 npm 能把库装到本地、还能锁版本，那 anime 也没必要再拴在别人的网址上。安装它：\nnpm install animejs 注意这条命令——它与前面装 Vite 那条几乎一模一样，但角色截然不同。 看一眼 package.json，anime 落进的是 dependencies，而非 vite 所在的 devDependencies：\n{ \u0026#34;dependencies\u0026#34;: { // ← 库：网站\u0026#34;运行时\u0026#34;真正要用的 \u0026#34;animejs\u0026#34;: \u0026#34;^4.4.1\u0026#34; }, \u0026#34;devDependencies\u0026#34;: { // ← 工具：只在\u0026#34;开发 / 构建时\u0026#34;帮忙的 \u0026#34;vite\u0026#34;: \u0026#34;^7.0.0\u0026#34; } } 这两段的区别，正是本节最值得带走的一个直觉——devDependencies 是\u0026quot;脚手架\u0026quot;（Vite 帮你把房子盖好，盖完即撤，上线的网站里没有 Vite）；dependencies 是\u0026quot;房子的砖\u0026quot;（anime 会被打进最终产物、随之上线，网站运行时确实在用它）。我们特意把这两个安装动作隔了大半节课，就是想说明：它俩看着像，实则一个是工具、一个是库。\n装进来之后还差最后一步——仅仅装进来还不算用上。回到 cards.js、score.js，把那行长网址换成一个干净的名字：\nimport { animate, stagger } from \u0026#34;animejs\u0026#34;; 这个 \u0026quot;animejs\u0026quot; 是个\u0026quot;光秃秃的名字\u0026quot;，浏览器原本并不认——但 Vite 会在背后把它翻译成 node_modules 里的真实位置。（这一步千万别漏：只 npm install 而不改 import，代码用的仍是 CDN 那一份，装到本地的等于白装。）\n改完，再 npm run build 一次，此时 dist 里那个 js 已经不一样了——anime.js 也被打了进去。于是：\n那条单独连 CDN 的请求，消失了； 你的网站，不再依赖任何别人的服务器，版本也牢牢锁在自己手里。 4.1 留下的那条 CDN 尾巴，到这里就收干净了。\n这节课结束时，你至少应该理解什么 4.1 之后还剩三件难受：看效果要部署一整轮、打开页面十几个请求、改了 css 被缓存坑——它们都需要一类工具来收拾。 构建工具：夹在\u0026quot;工程治理 / 开发体验 / 用户体验\u0026quot;三方之间做翻译——把开发者舒服的写法，转换成浏览器要的样子；它替你\u0026quot;起本地服务器、合并文件、加 hash\u0026quot;，最流行的一个叫 Vite（作者尤雨溪）。 Node.js：让 JavaScript 跳出浏览器、在你电脑上运行的环境（如同跑 .py 要装 Python、开 .xlsx 要装 Excel）；它附赠 npm——一个\u0026quot;JS 应用商店\u0026quot;，与 apt 同义，但按项目管理。 npm init 就是生成一份 package.json（手写一个也行）；其中 name / version 保留，main / type / description 等用不上、直接删掉，留一个清爽的空账本。 npm install 把东西下载进 node_modules，并自动记进账；你的账只列直接依赖，连环依赖不记在你头上；package-lock.json 锁定精确版本、需要提交 git。 如何运行装好的工具：原始写法是 ./node_modules/.bin/vite；给 scripts 登记别名后，用 npm run dev / build / preview（这三个名字是我们自己起的，scripts 是一张自由的命令别名表）。 源代码 ≠ 运行的代码：你维护源码，上线运行的是 dist；node_modules 和 dist 都是可重建的产物，写进 .gitignore、不提交。 dependencies vs devDependencies：库（运行时要用、会上线）vs 工具（开发时帮忙、不上线）——这也是为什么 anime 进前者、Vite 进后者。 留个尾巴：复用这件事，还是没解决 回顾这两节：4.1 用模块化治了顺序坑、全局污染；4.2 请来 Vite，把\u0026quot;看一眼太慢、请求太多、缓存坑\u0026quot;全收拾了，最后还顺手把 anime 收进本地、甩掉了 CDN。工程化这套地基，至此铺平了。\n但回头看，仍有两笔账模块化和构建都没有碰，且根子在同一处——我们有两个 html 页面：\n那条导航栏，在 index.html 和 text-lab.html 里一字不差地各写了一遍（模块化能复用 js 逻辑，却复用不了这一整块 HTML 结构）； 刚才打包时也撞见了——两个 html 入口，build 还得专门配置才打得全。 这两笔账，根都在\u0026quot;两个独立的 html\u0026quot;。而下一节那个新角色，恰好把它一并解决——它叫 React：它会把两个页面合成一个 index.html，页面之间靠\u0026quot;组件\u0026quot;切换。于是导航写一次便处处可用，多入口的麻烦也随之消失。\n那是下一节的事了。\n← 上一节：模块 4.1 现代前端第一步——模块化 | 下一节：模块 4.3 React 登场 →\n","date":"2026.06.15","description":"认识 Vite、npm 和前端构建，理解现代前端项目是怎样运行起来的。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-4-2/","title":"模块 4.2：Vite, npm 与前端构建"},{"content":" 在 HTML 中引入 css 和 js 脚本的做法很容易理解，但是当网站逐渐变大时，它们将会不可避免地走向混乱。\n回头看一眼：我们已经会什么 模块 3.2 那一节，我们已经亲手把一个 index.html 拆成了三个文件——HTML、CSS、JS 各管各的。并且三个文件最终都能被浏览器所加载和使用，靠的就是这两行：\n\u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;style.css\u0026#34;\u0026gt; \u0026lt;script src=\u0026#34;script.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 这两行，就是这门课目前为止 \u0026ldquo;把 js / css 文件接进 HTML\u0026rdquo; 的全部本事——同一个文件夹下、写明文件名、浏览器自然去取来用。\n模块 3 一路走下来，我们已经能用它做出一个完整的小网页，并把它部署到公网了。\n那么，接下来呢？\n我们尝试给网站添加更多的页面和元素 还记得模块 1.2 我们提到的这门课的\u0026quot;最终产品\u0026quot;需要什么样的功能吗？\n一个个人主页 + 一个文字实验室\n我们模块 3 实现的那张 \u0026ldquo;你好世界\u0026rdquo; 卡片，只是它的雏形。模块 4 这一整段，就是把它的页面结构全部开发成形。\n第一步，我们的网站要长成下面这个样子：\n两个页面：个人主页 + 文字实验室 一个全站导航栏，每页顶部都有 一组卡片：进入页面，卡片随机展示，并带有自然的动画效果 文字实验室里，点\u0026quot;开始分析\u0026quot;按钮，情感分数会有一个动画效果——像真在计算一样 有了 3.1 和 3.2 这两节的基础，其实实现这样的功能并不难，我们都可以让 AI 帮我们把页面做出来——无非就是从一个 html 文件变成了两个 html 文件，无非也就是需要更多的 .css 文件和 .js 文件，内容变多了，但是并没有增加什么新的知识。\n我已经替你做好了，代码就在这里：\nTip\n本节配套代码：github.com/joylibo/zero-to-tech-demos/tree/main/zero-to-tech-4-1\n想本地跑一下（推荐——后半段我们要改它的代码）：\ngit clone https://github.com/joylibo/zero-to-tech-demos.git cd zero-to-tech-demos/zero-to-tech-4-1 只想先看看长什么样？直接点上面的 GitHub 链接，在浏览器里浏览每个文件就行。\n把代码拉到本地之后，打开 zero-to-tech-4-1/，双击 index.html，可以看到网站真的已经是我们期待的样子了。\n变大之后的 zero-to-tech 打开项目的文件目录，我们看一看文件结构：\nzero-to-tech-4-1/ index.html text-lab.html css/ ……8 个 css 文件，每个负责一类样式 js/ cards.js ← 卡片飞入动画 score.js ← \u0026#34;开始分析\u0026#34;按钮的分数动画 nav.js ← 给当前页的导航高亮 项目中的文件比之前变多了——首先是从一个 html 文件变成了两个，分别是 index.html 和 text-lab.html，多了几个 .css 文件、也多了几个 .js 文件。\n这些 .css 文件和 .js 文件被引入到了 .html 文件之中，我们可以打开 index.html 文件看一看。\n看 \u0026lt;head\u0026gt; 里那一长串 \u0026lt;link\u0026gt;，再看 \u0026lt;/body\u0026gt; 之前那几行 \u0026lt;script\u0026gt;：\n\u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;css/nav.css\u0026#34;\u0026gt; \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;css/cards.css\u0026#34;\u0026gt; … \u0026lt;script src=\u0026#34;js/cards.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;js/score.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;js/nav.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 这些都是我们在 3.2 那一节的时候见过的写法——把 css 和 js 引入到 html 之中，让浏览器去加载。\n引用第三方资源 只有一处和我们此前见到的稍微不一样。我们目前能理解的用 \u0026lt;script src=\u0026quot;\u0026quot;\u0026gt;\u0026lt;/script\u0026gt; 这种写法引入进来的，都是一个本地的文件相对路径，比如：\n\u0026lt;script src=\u0026#34;js/nav.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 这一句的含义就是：把当前目录下的 js 目录下的 nav.js 文件引入进来。\n但是在现在的 index.html 代码中，我们却看到了这样一行引入：\n\u0026lt;script src=\u0026#34;https://cdn.jsdelivr.net/npm/animejs@4/lib/anime.iife.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 这其实是一个新的知识点——HTML 的 \u0026lt;script\u0026gt; 标签不仅仅可以引用本地路径的 .js 文件，还可以使用别人发布在互联网上的其他 .js 文件。\n这个 anime.iife.min.js 就是一个别人做好的、发布到互联网上的 js 脚本，它的地址是 https://cdn.jsdelivr.net/npm/animejs@4/lib/anime.iife.min.js。\n这个 js 脚本是专门提供动画方案的，这种第三方的现成的动画脚本，我们把它叫做第三方库。\n我们项目中的卡片加载时那个丝滑动画，就用了这个第三方动画库来做。\n对于这种第三方库，我们可以直接在 html 文件中用 URL 地址来引入，所以这一行：\n\u0026lt;script src=\u0026#34;https://cdn.jsdelivr.net/npm/animejs@4/lib/anime.iife.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 这一行和：\n\u0026lt;script src=\u0026#34;js/cards.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 本质是一样的——把一个 js 文件引到页面上。唯一的区别是：js/cards.js 是个本地文件、https://... 是个网络上的文件。也有人把后一种叫做 CDN 引用。\n在浏览器眼里，这俩都只是\u0026quot;一个资源\u0026quot;——一个住在你硬盘上、一个住在某个公开服务器上。\n你完全可以把那个网址里的内容下载到本地、存成 anime.iife.min.js，再写 \u0026lt;script src=\u0026quot;anime.iife.min.js\u0026quot;\u0026gt;，效果一模一样。\n之所以走 CDN，只是省得自己存一份——别人的服务器替我们存了。\n使用第三方资源 anime.js 被引进来之后，它并不知道应该把什么类型的动画，应用在哪个元素上——它只是提供了一些动画的参数和计算方法，至于这些动画在我们的页面中怎么用，还需要我们再提供应用的脚本。\n这就是 cards.js 文件做的事情——把第三方库里面声明的动画方法应用到具体的 html 页面元素中。\n我们的 cards.js 怎么用它？打开 js/cards.js：\nanime.animate(\u0026#34;.card\u0026#34;, { delay: anime.stagger(120), ease: \u0026#34;outBack\u0026#34;, // … }); 它直接用动画库里面声明好的东西——anime.animate、anime.stagger，你先不用管这俩是啥，你只需要知道这两个东西是 anime.js 这个第三方库定义好的，因为第三方库已经被 html 引用了，所以 cards.js 直接就可以用。\n注意一件关键的事：cards.js 并不需要声明它要用 anime.js。它直接用就可以了。\n也就是说，我们埋下了一个暗依赖：\ncards.js 依赖 anime.js\n因为有这层依赖关系，所以往浏览器的 html 中引入 anime.js 的那个 \u0026lt;script\u0026gt; 标签，一定要放在引入 cards.js 的那个 \u0026lt;script\u0026gt; 标签的前面。\n这个世界上，总有一些像 anime.js 这样开源的项目，他们提供开发好的动画库、3D 图形库，甚至一些特定的业务库，我们不用重新造轮子，直接引入就能用。一切都看起来挺美好。\n灾难的发生 灾难一：四行 \u0026lt;script\u0026gt;，顺序错了就崩 回忆一下我们刚说的那条暗依赖——cards.js 默默依赖 anime.js，所以在浏览器执行 cards.js 的时候，需要 anime.js 已经加载好。\n这条依赖没写在任何代码里，它只藏在 HTML 这四行 \u0026lt;script\u0026gt; 的先后顺序里。\n也就是说：anime.iife.min.js 必须排在 cards.js 之前。一旦顺序错了，cards.js 跑的时候 anime.js 中定义的内容在浏览器里还不存在，整个动画当场报废。\n我们可以亲手试一试 ——\n用浏览器打开 index.html，刷新——卡片正常飞入。OK。 现在打开编辑器，把 \u0026lt;script\u0026gt; 的前两行调换一下： \u0026lt;script src=\u0026#34;js/cards.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;!-- cards 跑的时候 anime 还没加载 --\u0026gt; \u0026lt;script src=\u0026#34;https://cdn.jsdelivr.net/npm/animejs@4/lib/anime.iife.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;js/score.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;js/nav.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 保存，回浏览器刷新页面。 看效果——卡片再也不飞入了。F12 看控制台，一行刺眼的红字： Uncaught ReferenceError: anime is not defined 就因为一行 \u0026lt;script\u0026gt; 排错位置，整个卡片动画当场报废。可这条\u0026quot;anime.js 必须最先加载\u0026quot;的规矩——完全藏在这几行 \u0026lt;script\u0026gt; 标签的排列里，代码里没有任何一句话写明它。文件一多、依赖一交错，光排顺序就够你抓狂。\n做完实验，记得把两行调回原顺序。\n灾难二：全局污染——所有人都挤在同一间屋子里 回忆一下：cards.js 之所以能跑，是靠 anime.animate、anime.stagger 这些名字。这就是传统 \u0026lt;script\u0026gt; 的\u0026quot;约定\u0026quot;——库往当前页面全局提供自己的名字，使用者从全局拿。\n这就好像一个屋子里面，就俩人，其中一个人张三从家里带来了四样东西：笔、墨、纸、砚，放在了一个共享的台面上，然后说屋子里的人随便用。那李四要用笔的时候直接用了。\n但是，如果屋子里的人逐渐变多呢？每个人都会从家里带一些东西放在这个台面上呢？\n等项目长得更大，你引的库就不只 anime.js 了——再加一个图表库、一个日期处理库、一个 ajax 工具库……每个库都靠\u0026quot;往页面上挂自己的名字\u0026quot;的方式提供功能。\n那么——万一两个库都挂了同名的东西，会怎么样？\n你大概能猜到答案：会撞名。而 JavaScript 全局空间里，一个名字只能存一个值——后来挂上去的，会悄悄盖掉先挂上去的，没人通知你。\n这种现象，在编程领域有一个专门的词，叫 全局污染（global pollution）。\n打个现实里的比方：\n张三放在屋子里的纸是用来写字的，后来又来了一个赵六带来了一包纸巾，也叫\u0026quot;纸\u0026quot;，因为赵六来得晚一点，所以在台面上，他的\u0026quot;纸\u0026quot;就覆盖了张三的纸。\n这带来的后果就是，李四某次需要用纸写字的时候，发现根本就写不成。\n代码世界里的全局污染就是这种感觉：你拿到的\u0026quot;纸\u0026quot;，可能不是你以为的那一个。等到 cards.js 里那句 anime.animate(...) 跑出诡异结果，你完全猜不到它到底用的是哪一个 anime——是 CDN 引来的那个？还是被某个后引的库悄悄替换掉的那个？\n项目小的时候，你能记住每个库挂了什么、自己写了什么。项目一长大，撞名就是迟早的事——而每一次撞，都是一个让人抓破头的 bug。\n破局方法 \u0026lt;script\u0026gt; 顺序 + 全局污染——它们看着是两件事，根上是同一件事：\n\u0026ldquo;全堆在一起 + 全局名称（变量） + 手排顺序\u0026quot;的传统组织方式，本来就不是给\u0026quot;严肃项目\u0026quot;准备的。\n不是工程师的水平不够；是工具本身到了它的能力上限。\n全世界的前端开发者，都撞过这堵一模一样的墙。这堵墙撞多了，他们慢慢长出来一整套翻越它的方法——这套方法，统称叫\u0026rdquo;工程化\u0026quot;。\n它的第一招，叫模块化。\n模块化的实现方法 在看具体代码之前，先用一段话把模块化是什么讲清楚。它和传统方式只在一件事上不一样——\n传统方式：你写的东西默认\u0026quot;全世界都看得见\u0026quot;（都堆在一个叫做 window 的全局空间里），库提供的功能也默认\u0026quot;全世界都看得见\u0026quot;。共享靠\u0026quot;公共\u0026quot;。\n模块化：你写的东西默认\u0026quot;只有自己看得见\u0026quot;；想给别人用，得明文标 export；想用别人的，得明文写 import。共享靠\u0026quot;明文声明\u0026quot;。\nexport 和 import 就是模块化的两个新词。它们做的事都很简单：\nexport：在文件里给某个东西打上\u0026quot;对外可用\u0026quot;的标记——没标的，外面拿不到。 import：在文件顶部明文写\u0026quot;我要用某个文件里的某个东西\u0026quot;——不写的，你就用不到。 你完全不用学怎么写它们。我们不教语法。你只要了解这两个词、记住它们各自起什么作用就够了。\n顺便交代一个名词：什么是 \u0026ldquo;ES\u0026rdquo; 后面我们会反复看到 \u0026ldquo;ES 模块\u0026quot;、\u0026rdquo;ES6\u0026ldquo;这一类说法。我们顺手把这个词解释清楚——\nES 是 ECMAScript 的缩写，它是 JavaScript 这门语言的\u0026quot;官方标准规范\u0026rdquo;。 所有浏览器、所有 JS 引擎，都得按照这份规范来实现 JavaScript。 你也可以这样理解：我们日常说的\u0026quot;JavaScript\u0026quot;，本质上就是\u0026quot;按 ECMAScript 规范做出来的那套东西\u0026quot;。\n所以：\n\u0026ldquo;ES 模块\u0026rdquo; = \u0026ldquo;JavaScript 标准里定义的那套模块系统\u0026rdquo;——它不是另一种语言、也不是什么新东西，就是 JavaScript 自带的官方模块方案。 \u0026ldquo;ES6\u0026rdquo; = ECMAScript 的第 6 版（2015 年发布）。我们刚才说的 import / export，就是在 ES6 这一版里被正式定义的。 记住一句话就够了：看到\u0026quot;ES\u0026quot;，就在脑子里替换成\u0026quot;JavaScript 官方标准\u0026quot;。\n听上去像是给自己添麻烦——以前 window 空间里全是大家都能拿的东西，现在每个东西都得明文 export、明文 import。可这恰恰是模块化最有价值的地方——它直接把前面两痛的根拔了：\n想知道一个文件依赖什么？翻到顶上看 import 那几行——白纸黑字写在那儿。再没有\u0026quot;暗依赖\u0026quot;，script 顺序坑自然不存在。 两个文件想用同名的东西？根本撞不上——每个文件 import 进来的，都只活在自己模块里的局部变量。回到 \u0026ldquo;纸\u0026rdquo; 那个比喻：每个人都有了自己的小柜子，再也不挤一个共享的台面上。 下面我们就把项目按照模块化改造一遍，看看 import / export 在真代码里长什么样。\n把我们的项目按照模块化来改造 首先，我们重写 cards.js。\ncards.js 目前的写法是：\n(function () { anime.animate(\u0026#34;.card\u0026#34;, { opacity: [0, 1], translateY: [24, 0], delay: anime.stagger(120), // 每张卡错开 120ms duration: 700, ease: \u0026#34;outBack\u0026#34;, // 弹性落地 }); })(); 我们给它改掉，改成：\nimport { animate, stagger } from \u0026#34;https://cdn.jsdelivr.net/npm/animejs@4/+esm\u0026#34;; export function initCardsAnim() { animate(\u0026#34;.card\u0026#34;, { opacity: [0, 1], translateY: [24, 0], delay: stagger(120), // 每张卡错开 120ms，自动排好节奏 duration: 700, ease: \u0026#34;outBack\u0026#34;, // 弹性收尾，落地像有重量 }); } 此时，cards.js 中使用的已经不是公共的 anime.js 了，而是自己单独引用的一份 anime.js。\n接下来我们给 score.js 也改了，把原有的代码删掉，换成：\nimport { animate, scrambleText } from \u0026#34;https://cdn.jsdelivr.net/npm/animejs@4/+esm\u0026#34;; export function initScoreAnim() { var btn = document.querySelector(\u0026#34;.primary-button\u0026#34;); var scoreEl = document.querySelector(\u0026#34;[data-score]\u0026#34;); if (!btn || !scoreEl) return; // 个人主页没这俩元素 btn.addEventListener(\u0026#34;click\u0026#34;, function () { animate(scoreEl, { innerHTML: scrambleText({ chars: \u0026#34;0-9\u0026#34; }), duration: 1500, }); }); } 此时，我们的数字动画也是用 anime.js 了，注意网址末尾的 +esm——这是 anime.js 的 ES 模块版本，它代表的是按照模块化的规范来组织的 anime 动画库。\n接下来，我们给 nav.js 也改成模块化版本。它是给顶部导航栏做高亮的，这个文件没用任何外部东西——但它和 cards.js / score.js 一起，都挤在同一个\u0026quot;全局空间\u0026quot;里，谁要在顶层定义同名变量都会撞车。所以我们也给它做模块化改造。改造之前它的内容是：\n(function () { var path = location.pathname.split(\u0026#34;/\u0026#34;).pop() || \u0026#34;index.html\u0026#34;; var links = document.querySelectorAll(\u0026#34;.nav-link\u0026#34;); for (var i = 0; i \u0026lt; links.length; i++) { var href = links[i].getAttribute(\u0026#34;href\u0026#34;); if (href === path) links[i].classList.add(\u0026#34;active\u0026#34;); else links[i].classList.remove(\u0026#34;active\u0026#34;); } })(); 改造之后，它变成了：\nexport function initNav() { var path = location.pathname.split(\u0026#34;/\u0026#34;).pop() || \u0026#34;index.html\u0026#34;; var links = document.querySelectorAll(\u0026#34;.nav-link\u0026#34;); for (var i = 0; i \u0026lt; links.length; i++) { var href = links[i].getAttribute(\u0026#34;href\u0026#34;); if (href === path) links[i].classList.add(\u0026#34;active\u0026#34;); else links[i].classList.remove(\u0026#34;active\u0026#34;); } } 注意，改造前后只有头尾两处不一样：外层的 (function () { ... })(); 自调用包裹变成了 export function initNav() { ... }，中间的逻辑一字未改。这就是模块化要的全部：把这块代码从\u0026quot;立即跑\u0026quot;改成\u0026quot;对外暴露一个能被别人调用的函数\u0026quot;。\n最后，我们再在 js 目录下，新建一个 main.js 文件，写入如下内容：\nimport { initNav } from \u0026#34;./nav.js\u0026#34;; import { initCardsAnim } from \u0026#34;./cards.js\u0026#34;; import { initScoreAnim } from \u0026#34;./score.js\u0026#34;; initNav(); initCardsAnim(); initScoreAnim(); 最后，我们分别编辑 index.html 和 text-lab.html，把这两个文件底部的多个 \u0026lt;script\u0026gt; 标签：\n\u0026lt;script src=\u0026#34;https://cdn.jsdelivr.net/npm/animejs@4/lib/anime.iife.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;js/cards.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;js/score.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;js/nav.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 改为一行：\n\u0026lt;script type=\u0026#34;module\u0026#34; src=\u0026#34;js/main.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 至此，我们已经完成了模块化改造。改造完成后，项目的文件结构看起来差不多，但本质已经完全变了：\nindex.html ← 注意：只引一行 \u0026lt;script\u0026gt; text-lab.html css/ ……还是那 8 个 css js/ cards.js ← import { animate, stagger } from \u0026#34;…anime.js\u0026#34; score.js ← import { animate, scrambleText } from \u0026#34;…anime.js\u0026#34; nav.js ← 不依赖任何外部库 main.js ← 入口：import 上面三个，调用它们 最关键的变化是，在两个 html 的 body 底部：\n\u0026lt;script type=\u0026#34;module\u0026#34; src=\u0026#34;js/main.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 就这一行——之前那四个、还得小心排顺序的 \u0026lt;script\u0026gt;，没了。\n到这里你已经见到 import / export 实际长什么样了。这就是模块化在真代码里的样子——每个文件顶上 import 自己要的、用 export 标自己要给外面的。\n模块化改造之后的访问方式 改完之后，你大概会迫不及待地刷新浏览器、看看新版本跑不跑——可你很快会撞上一个意外：\n页面是白的，F12 控制台一行跟 CORS 有关的错误。\n这不是你哪里改错了——而是 ES 模块的一条新规矩：\nES 模块文件必须通过\u0026quot;服务器\u0026quot;提供出来才能加载——浏览器不允许直接从 file:// 读取。\n也就是说，从这一刻起，双击 index.html 直接打开这种我们从模块 3 一路用过来的方式，对模块化的代码不再适用了。\n为什么这么严格？ 简单说，ES 模块走的是浏览器加载网络资源的那一套机制——每个模块文件都得被\u0026quot;正式请求\u0026quot;，有\u0026quot;来源\u0026quot;（origin）才行。而 file:// 这种\u0026quot;本地直接打开\u0026quot;，没有真正意义上的来源——浏览器干脆不许。\n背后是安全考虑：模块化代码可以从一个文件 import 另一个、还能从网络 import 别人的库。如果浏览器对\u0026quot;本地双击打开\u0026quot;也开这道门，意味着任何一个你不小心双击的 html 文件，都能读你硬盘上的别的文件、或者悄悄从网络上拉一堆代码下来跑——这是个大漏洞。\n浏览器一刀切：模块化的代码，得走服务器。\n服务器你早就有了 这台\u0026quot;服务器\u0026quot;，你早就有了——模块 3.5 那台 Nginx 云主机。把项目部署上去（模块 3.5 教过的那一整套：push → 服务器 pull → Nginx 指向它），打开公网 IP，就能看到改造后的项目跑起来的样子——清爽的代码组织、流畅的卡片入场、丝滑的 scramble 数字。\n模块 3.5 教你\u0026quot;把网页发布到服务器\u0026quot;，在这里马上就用上了——一点都没浪费。\nJS 的模块化演变之路 你可能会想：这套\u0026quot;\u0026lt;script\u0026gt; 顺序、全局污染\u0026quot;的痛，世界上的开发者一定撞了好几年才发现的吧？\n不止\u0026quot;几年\u0026quot;——整整 20 年。\nJavaScript 这门语言从 1995 年诞生，前 20 年没有官方的模块系统。所有人都用 \u0026lt;script\u0026gt; 标签拼项目、靠 window 全局变量传东西、手排 script 顺序——你刚才在改造前那一版项目里看到的那堵墙，整个前端界撞了 20 年。\n这 20 年里，工程师们当然没坐着干等——他们在自己的领域里摸索出了各种\u0026quot;模块化\u0026quot;方案。其中最有名的一个，是 2009 年随着 Node.js 诞生的 CommonJS。它和我们今天用的 ES Modules 本质是同一回事——同样的思想（每个文件管好自己的依赖、互不污染），只是写法略有不同。但 CommonJS 终究是社区自己的方案，不是 JavaScript 这门语言自己的官方标准。\n直到 2015 年，JS 在 ES6 这个版本里正式定义了 import / export 语法——模块化从这一天起，才真正成为 JavaScript 语言底层支持的标准；2017 年前后，主流浏览器（Chrome、Safari、Firefox、Edge）才陆续原生支持 \u0026lt;script type=\u0026quot;module\u0026quot;\u0026gt;。\n也就是说，你刚才写下的 import { animate } from \u0026quot;...\u0026quot; 这件事，真正能用还不到十年。但它一出场，就把前 20 年的痛一并根治了。\n这就是为什么我们说\u0026quot;模块化是工程化的第一块基石\u0026quot;——它不是某个聪明人随手发明的工具，是 20 年的痛逼出来的那条路。\n顺带也解释了你刚撞上的那个\u0026quot;不能双击打开\u0026quot;——新东西换来了好处，也带来了一些比以前严格的规矩（都是为了安全）。\n你刚才其实做了一件大事——两件灾难，悄悄被治了 慢一拍，回头看一眼你刚才改完的代码。前面那两件让人抓狂的灾难，已经各自被根治——它们怎么消失的，我们盘一盘。\n第一件：script 顺序坑，消失了 改之前，HTML 底部是四行 \u0026lt;script\u0026gt;，顺序错一格就崩。 改之后，HTML 底部只剩一行 \u0026lt;script type=\u0026quot;module\u0026quot; src=\u0026quot;js/main.js\u0026quot;\u0026gt;。\n那 main.js 又是怎么知道要先加载 anime.js、再跑 cards.js 的？——看 cards.js 顶上那行 import { animate, stagger } from \u0026quot;...\u0026quot;。浏览器读到这句，会自己去取那个网址、加载完再回来跑 cards.js。顺序由代码声明、浏览器执行——再也不藏在 \u0026lt;script\u0026gt; 的排列里。\n我们刚刚亲手把\u0026quot;暗依赖\u0026quot;变成了\u0026quot;明依赖\u0026quot;。\n第二件：全局污染，根本不存在了 改之前，那行 anime.js CDN 的 \u0026lt;script\u0026gt; 把 anime 挂到了 window 全局上，所有文件共用。 改之后，你亲手删掉了那行 CDN \u0026lt;script\u0026gt;——anime 这个名字，在你这个项目里再也不存在于全局空间。\ncards.js 和 score.js 各自顶上 import 自己要用的部分进自己的文件里。两个文件里都叫 animate 的那个名字，根本不是同一个东西——各自只活在自己的文件里。\n回到我们\u0026quot;屋子里那张共享台面\u0026quot;的比方：模块化等于给屋子里每个人配一个自己的私人小柜子——张三的笔墨纸砚锁在他自己柜子里、赵六的也锁在他自己柜子里，不再共用台面。要从别人那里\u0026quot;借\u0026quot;东西？得明文写 import 去借。\n想引第二、第三个库？各自 import 就行，不再挤一个公共空间、不再担心谁覆盖谁。\n改造前 vs 改造后，摆一起对比 改造前（传统 \u0026lt;script\u0026gt;） 改造后（模块化 import） anime.js 怎么进来 \u0026lt;script src=\u0026quot;…\u0026quot;\u0026gt; 挂在 window 全局 每个文件 import 自己要的部分 JS 怎么引 四个 \u0026lt;script\u0026gt;，顺序不能乱 一个入口，依赖写在代码里 函数怎么共享 全靠 window 全局变量 显式 import / export，模块自己作用域 用别人的库 找 CDN、塞 \u0026lt;script\u0026gt;、防全局撞名 一行 import，要谁挑谁 怎么打开 双击就行 得通过服务器（部署到 3.5 那台） 注意一件事：HTML、CSS 一个字没改；用的也是同一个 anime.js。换的只是\u0026quot;怎么把它拉进项目、怎么组织自己的代码\u0026quot;—— 这就是\u0026quot;工程化\u0026ldquo;四个字最朴素的含义。\n你也许注意到：改造后的\u0026quot;开始分析\u0026quot;按钮动画比改造前帅——分数会洗一遍乱码再定格（用了 anime.js 的 scrambleText 特效），而不是改造前那种 setInterval 一格一格滚的电子表效果。这是因为 anime.js v4 把 scrambleText 这个高级特效只放在了 ES 模块版本里，传统 \u0026lt;script\u0026gt; 那一版根本没塞。所以模块化改造之前，我们其实压根儿就够不着 scrambleText——这才是改造前 score.js 只能退而求其次手写 setInterval 的真实原因。\n这节课结束时，你至少应该理解什么 传统手写组织方式有它的上限：\u0026lt;script\u0026gt; 顺序坑、全局污染——这两件事都是\u0026quot;全堆在一起\u0026quot;自带的毛病，项目越大越疼 这不是你的错——是这种组织方式自己到头了；翻越它的那套东西，统称\u0026rdquo;工程化\u0026quot; 第一招：模块化 —— 每个文件用 import / export 自己声明依赖，浏览器替你算顺序，全局不再被污染（你不用学怎么写 import，只要懂它带来的变化） 模块化有个小代价：代码不能再双击打开，得通过服务器跑——而模块 3.5 那台 Nginx 你已经有了，原样部署上去就能看 顺便见识了\u0026quot;生态的力量\u0026quot;——别人打磨好的工具（像 anime.js 的 stagger、scrambleText 这一票），一行 import 就能为你所用；这个生态本身的名字、怎么把它真正\u0026quot;驻\u0026quot;进项目，是下一节的事 下一节，我们让这套模块化的代码真正变成一个\u0026quot;工程\u0026quot;——把它搬进一个叫 Vite 的容器里，请出现代前端真正的命令行节奏。\n← 上一节：模块 3.5 把网页发布到公网 | 下一节：模块 4.2 构建 →\n","date":"2026.06.08","description":"把越来越难维护的网页拆成模块，看清 import 和 export 为什么重要。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-4-1/","title":"模块 4.1：现代前端第一步——模块化"},{"content":" 把页面发布到公网，是\u0026quot;写代码\u0026quot;和\u0026quot;做了一个真实的东西\u0026quot;之间的那道门槛。\n现在我们处在哪里 到这一节为止，你手里已经有这些东西了：\n本地：~/zero-to-tech/ 文件夹里有 index.html、style.css、script.js，三个文件分工清楚 GitHub：上面三个文件已经推送到了你的GitHub的 zero-to-tech 仓库里 服务器：一台 Ubuntu 云服务器，Nginx 已经跑起来，公网 IP 用浏览器能访问 但你现在打开公网 IP，看到的还是 Nginx 默认的\u0026quot;Welcome to nginx!\u0026ldquo;那个欢迎页，不是你写的页面。\n在这一节里，我们实现下面这个目标：\n把这条链路接通——让任何人打开公网 IP，就能看到你写的那个卡片页面、点击按钮文字会变。\n这条链路长什么样 先在脑子里建一张图，后面每一步对应到这张图的某一段：\n你的电脑 GitHub 云服务器 浏览器 ───────── ───────── ───────── ───────── index.html ──push──\u0026gt; zero-to-tech ──pull──\u0026gt; ~/zero-to-tech ──\u0026gt; 公网 IP style.css 仓库 ↑ script.js Nginx 把这个目录 当作网站的内容 这一节我们要做的，就是把中间那两段连起来：\n让服务器从 GitHub 把代码 pull 下来 让 Nginx 知道\u0026quot;网站内容现在在新的目录里\u0026rdquo; 第 1 步：登录服务器 打开终端，用模块 2.4 里那条 SSH 命令登录：\nssh ubuntu@你的公网IP 关于远程登录服务器的操作，如果你已经不记得了，可以再看一下2.4那一节。\n登录成功之后，终端提示符会变成 ubuntu@your-server:~$，而不再是你 Mac 上那个了。\n从现在开始，只要这一节里出现的命令前面没有特别说明，都是在服务器上执行的。需要回到本地执行的命令，我会特别标注。\n第 2 步：确认服务器上有 Git 服务器要能从 GitHub 拉代码，得先有 Git。先看一眼有没有：\ngit --version Ubuntu 的云镜像通常会预装 Git，大概率你会看到类似 git version 2.x.x 的输出——说明已经装好了，直接跳到下一步。\n如果看到的是 command not found，再装：\nsudo apt update sudo apt install -y git 第一行刷新软件源索引，第二行装 Git，-y 表示安装过程中遇到提示直接确认。\n装完再跑一次 git --version 确认，你应该就可以看到git的版本号了，出现版本号就表示git已经安装好了。\n第 3 步：让服务器和 GitHub 之间建立 SSH 信任 接下来要把 GitHub 上的代码拉到服务器。我们继续沿用模块 3.4 里建立的习惯——通过 SSH 和 GitHub 对话。\n关于 HTTPS 和 SSH，多说一句：\nGitHub 支持两种 clone 地址。\nHTTPS（https://github.com/...）：如果你的仓库是 Public，clone 和 pull 不需要任何凭证，也就是说，你可以直接跳过下面这一整步 SSH 配置。但如果是 Private，HTTPS 每次 push / pull 都要你输用户名加一个 token，长期看比较烦。 SSH（git@github.com:...）：无论是Public还是Private，用ssh的话都需要先在这台机器上配一对 key、把公钥交给 GitHub，配过一次以后就一劳永逸。 我们这门课统一走 SSH，原因是：SSH 不分公私，配过一次就一劳永逸；而且和 3.4 里 Mac 上的流程一致。等以后你真的有 Private 项目，这套流程也是同一套，不用再换。\n但这里有一个关键点：\n每一台机器都需要有自己的一对 SSH key。你 Mac 上那对不能搬到服务器上来用。\n所以模块 3.4 里在 Mac 上做过的事情，现在要在服务器上原样再做一遍。流程一模一样，命令也一模一样，只是这次是在 SSH 会话里、对着服务器执行。\n生成 SSH key ssh-keygen -t ed25519 -C \u0026#34;你的邮箱\u0026#34; 连续几个提示一路回车就行。完成后，~/.ssh/ 里会生成两个文件：\nid_ed25519：私钥，不要外传 id_ed25519.pub：公钥，下一步要加到 GitHub 把公钥加到 GitHub cat ~/.ssh/id_ed25519.pub 输出是一整行，以 ssh-ed25519 开头，以你的邮箱结尾。完整复制这一行。\n然后到 GitHub：右上角头像 → Settings → 左侧 SSH and GPG keys → New SSH key。\nTitle 建议写得能一眼区分出是哪台机器，比如 云服务器-阿里云，方便以后管理（GitHub 允许你挂多对 key，Mac 上那一对会继续保留） Key 粘贴刚才复制的公钥，保存 验证连通 回到你的远程服务器上，执行下面这个命令：\nssh -T git@github.com 第一次连接 GitHub 会问 Are you sure you want to continue connecting?，输入 yes 回车。\n如果看到 Hi 你的用户名! You've successfully authenticated，就说明服务器和 GitHub 之间的 SSH 通道打通了。\n第 4 步：把代码 clone 到服务器 我们 SSH 登录的是 ubuntu 用户，它有自己的家目录 /home/ubuntu/。把代码 clone 到这里。\ncd ~ git clone git@github.com:你的用户名/zero-to-tech.git 注意几点：\n用的是 SSH 地址 git@github.com:...，不是 HTTPS 地址——这样以后 git pull 就不需要每次输密码或者 token，直接靠刚才配的 SSH key 自动通过。 不需要 sudo。家目录是当前用户自己的地盘，写操作不需要任何额外权限。 执行完之后，看一眼：\nls ~/zero-to-tech 应该能看到 index.html、style.css、script.js 三个文件，和你本地一模一样。\n这里有一个值得停下来注意的对应：\n你 Mac 上是 ~/zero-to-tech，服务器上也是 ~/zero-to-tech。两边路径完全对称。\n之所以能这样，是因为 ~ 代表\u0026quot;当前用户的家目录\u0026quot;——在 Mac 上是 /Users/你的名字/，在服务器上是 /home/ubuntu/。两边的真实路径不一样，但 ~/zero-to-tech 这个写法在两边都成立。\n第 5 步：看懂 Nginx 的配置文件 代码已经在服务器上了，但 Nginx 还不知道——它现在还指着默认欢迎页。\n接下来这一步要修改 Nginx 的配置，让它指向我们的目录。但在动手改之前，先花一点时间看懂这个配置文件。只有理解了每一行在说什么，下一步的修改才不只是\u0026quot;照抄\u0026quot;，而是变成你真正学到的东西。\n配置文件在哪 先到 Nginx 的配置目录看一眼。在终端里：\ncd /etc/nginx/ ls 会看到这样一片东西：\nconf.d koi-win nginx.conf sites-enabled fastcgi.conf mime.types proxy_params snippets fastcgi_params modules-available scgi_params uwsgi_params koi-utf modules-enabled sites-available win-utf 这里有几件事值得先说清楚。\n第一，这种目录结构是 Ubuntu / Debian 的惯例，不是 Nginx 本身的规定。\nNginx 是跨平台软件，在不同 Linux 发行版（比如 CentOS、Alpine）里，配置目录的组织方式不一样。你现在看到的这种配置文件的组织形式，是 Ubuntu 的 Nginx 安装包替你做主的整理方案，Debian 系也是同一套。以后碰到非 Ubuntu / Debian 的服务器，配置目录可能完全长得不一样，需要看那台机器的具体情况。\n第二，真正的配置入口是 nginx.conf。\nNginx 启动的时候，只直接读 nginx.conf 这一个文件。其他你看到的所有目录（conf.d/、sites-enabled/ 等等），都是 nginx.conf 用 include 指令\u0026quot;包含\u0026quot;进来的。\n这种一个主文件+包含其他文件的模式，其实你在模块 3.2里已经见过一次了——还记得吗？我们把 index.html / style.css / script.js 拆成三个文件之后，浏览器并不是直接加载这三个文件，而是只加载 index.html 一个；CSS 和 JS 是通过 \u0026lt;link rel=\u0026quot;stylesheet\u0026quot; href=\u0026quot;style.css\u0026quot;\u0026gt; 和 \u0026lt;script src=\u0026quot;script.js\u0026quot;\u0026gt;\u0026lt;/script\u0026gt; 标签，从 index.html 里\u0026quot;引\u0026quot;进来的。\nnginx.conf 在 Nginx 这边扮演的就是 index.html 那个角色——它是唯一的入口，其他所有配置文件都是通过 include 被它\u0026quot;引\u0026quot;进来的。\n我们打开它实际看一下：\ncat /etc/nginx/nginx.conf cat 出来内容不算少，但仔细看会发现里面绝大部分是注释（# 开头的行）。如果只看真正生效的部分，整个文件其实非常短：\nuser www-data; worker_processes auto; pid /run/nginx.pid; error_log /var/log/nginx/error.log; include /etc/nginx/modules-enabled/*.conf; events { worker_connections 768; } http { sendfile on; tcp_nopush on; types_hash_max_size 2048; include /etc/nginx/mime.types; default_type application/octet-stream; ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; access_log /var/log/nginx/access.log; gzip on; include /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*; } 也就二十多行。结构上分三层：最外面的全局指令 + 一个 events { ... } 块 + 一个 http { ... } 块。下面一段一段看。\n最外层：全局设置（现在可以看不懂） user www-data; worker_processes auto; pid /run/nginx.pid; error_log /var/log/nginx/error.log; include /etc/nginx/modules-enabled/*.conf; user www-data：Nginx 进程以哪个 Linux 用户身份运行。先记住 www-data 这个名字——等一下第 9 步，我们会撞上一个和它直接相关的报错（Nginx 以 www-data 的身份去读文件，却被挡在门外），到那时你就会真正体会到这一行的分量。 worker_processes auto：开几个工作进程，auto 表示和 CPU 核数一致。 pid：进程 ID 写到哪个文件，给 systemctl 这类系统工具用。 error_log：错误日志写到哪里，Nginx 出问题时第一时间去这个文件里看。 include modules-enabled/*.conf：把动态模块的启用配置读进来——这是文件里第一个 include。 events { ... }：并发处理参数（也可以看不懂） events { worker_connections 768; } 每个 worker 进程最多同时处理多少个连接，是性能调优参数，默认值就够用很久。\nhttp { ... }：HTTP 服务的全部配置（只需要看懂最后一行） 最大的一块，所有跟 HTTP 服务相关的设置都在这里。挑几句说：\ninclude /etc/nginx/mime.types; default_type application/octet-stream; 把刚才在 ls 里看到的 mime.types 读进来。这下你就知道 mime.types 是怎么\u0026quot;用上\u0026quot;的了——它就是被 nginx.conf include 进来的。default_type 是兜底：扩展名在 mime.types 里没有对应规则时，按\u0026quot;二进制流\u0026quot;返回。\ngzip on; 开启 Gzip 压缩，传输文件时自动压缩，省流量。\n剩下的 sendfile、tcp_nopush、ssl_protocols、access_log 这些，要么是性能优化、要么是 HTTPS / 日志参数，有印象就行。\n然后是 http { ... } 块最关键的最后两句：\ninclude /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*; 这两句，就是把整个 /etc/nginx/ 目录串起来的关键。一会儿我们要改的 sites-enabled/default，之所以能影响整个 Nginx 的行为——根源就是 nginx.conf 在这里 include 了它。\n到这里你应该能把整张图在脑子里拼起来：\nnginx.conf (Nginx 启动只读这一个) ├─ 顶层：user / worker / pid / error_log ├─ events { ... } 并发参数 └─ http { ... } HTTP 服务全配置 ├─ include mime.types ├─ include conf.d/*.conf └─ include sites-enabled/* ← 我们要改的网站配置就是从这里被串进来的 文件最底下那段 mail { ... } 实际 cat 的时候，你还会看到文件最底下有一段以 # 开头的 mail { ... }——整段都是注释。Nginx 除了能做 HTTP 服务器，还能做 IMAP / POP3 邮件代理，那一段就是邮件代理功能的模板，默认整段注释掉了，我们也用不到，不用管它。\n第三，剩下的这些文件 / 目录，这门课里你都不需要碰。但是大致认一下它们是干嘛的，以后看见也不会慌：\n名称 做什么 nginx.conf 配置总入口，Nginx 启动时唯一直接读的文件 sites-available/ 所有\u0026quot;写出来\u0026quot;的网站配置（Ubuntu 惯例） sites-enabled/ 当前\u0026quot;启用中\u0026quot;的网站配置（Ubuntu 惯例） conf.d/ 额外的全局配置片段，nginx.conf 默认会 include 这里所有 .conf 文件 snippets/ 可复用的配置片段，需要时自己 include 进来 modules-available/ / modules-enabled/ 动态模块的\u0026quot;可用 / 启用\u0026quot;，模式和 sites- 一样 mime.types 文件扩展名到 MIME 类型的映射表，告诉浏览器返回的是 html、png 还是别的 proxy_params / fastcgi_params / fastcgi.conf / scgi_params / uwsgi_params 反向代理到后端应用时常用的参数片段，需要时 include 即可 koi-utf / koi-win / win-utf 历史遗留的俄文 / Cyrillic 字符编码映射，几乎用不上 回到正题。这一节我们只关心这两个：\n/etc/nginx/sites-available/ ← 所有\u0026#34;写出来\u0026#34;的网站配置 /etc/nginx/sites-enabled/ ← 当前\u0026#34;启用中\u0026#34;的网站配置 这是一个常见的\u0026quot;草稿夹 / 生效夹\u0026quot;模式：\nsites-available/ 里放的是你写过的所有网站配置，不管启不启用，都在这里留底 sites-enabled/ 里只放当前要让 Nginx 真正读到的那几个配置 但是 sites-enabled/ 里的\u0026quot;文件\u0026quot;其实不是真正的文件，而是指向 sites-available/ 里某个文件的软链接（symlink）——你可以理解为 Windows 里的\u0026quot;快捷方式\u0026quot;。换句话说：\n改 sites-enabled/default 和改 sites-available/default，改的是同一份文件。\n这样设计的好处是：\n想临时停掉一个网站，不用删配置，只要把 sites-enabled/ 里的快捷方式删掉就行 以后想恢复，再把软链接挂回去就好 如果你想看清这个软链接的结构，可以执行：\nls -l /etc/nginx/sites-enabled/ 输出大概长这样：\ndefault -\u0026gt; /etc/nginx/sites-available/default 那个箭头 -\u0026gt; 就告诉你：sites-enabled/default 实际指向的是 sites-available/default。\n刚装好的 Nginx 里只挂了 default 这一个，对应那个\u0026quot;Welcome to nginx!\u0026ldquo;欢迎页。看一眼它的内容：\ncat /etc/nginx/sites-enabled/default 文件里有很多以 # 开头的行，这是 Nginx 的注释（不会被执行）。如果只看真正生效的部分，结构是这样的：\nserver { listen 80 default_server; listen [::]:80 default_server; root /var/www/html; index index.html index.htm index.nginx-debian.html; server_name _; location / { try_files $uri $uri/ =404; } } 我们一段一段来看。\nserver { ... }：一个网站的定义 最外层这一对大括号叫一个 server 块。可以这样理解：\n一个 server 块 = 一个网站。\n如果以后这台服务器上要同时跑多个网站（个人主页、博客 等），就会有多个 server 块并列存在。现在只有一个。\nlisten 80 default_server;：监听哪个端口 listen 80 告诉 Nginx：监听 80 端口。\n80 是 HTTP 的标准端口。浏览器里输入 http://你的IP，没写端口号，浏览器就默认连 80。这也是我们在模块 2.4 里特意放行的那个端口。\n后面那个 default_server 表示\u0026quot;当没有其他 server 块匹配时，用我\u0026rdquo;——现在只有一个 server 块，写不写都没差别，但这是默认配置自带的，不用动它。\n下一行 listen [::]:80 default_server; 是同一件事的 IPv6 版本，先不管。\nroot /var/www/html;：去哪里找文件 这是整个配置文件最关键的一行。\n它告诉 Nginx：当有人来访问时，去 /var/www/html 这个目录里找文件返回。\n举几个例子：\n用户访问 http://你的IP/about.html → Nginx 去找 /var/www/html/about.html 用户访问 http://你的IP/css/main.css → Nginx 去找 /var/www/html/css/main.css root 就像是 Nginx 的\u0026quot;主目录\u0026quot;——所有 URL 路径都是从这里开始拼出实际的文件路径。\n这节课要做的修改，全部就在这一行上：把它从 /var/www/html 改成 /home/ubuntu/zero-to-tech，让 Nginx 改去我们 clone 下来的目录里找文件。\nindex index.html index.htm index.nginx-debian.html;：默认入口文件 这一行告诉 Nginx：当用户访问的是一个目录而不是一个具体的文件时，用哪个文件代替。\n举个例子：\n用户访问 http://你的IP/（最后是斜杠，访问的是根目录） Nginx 没办法返回一个目录，它必须返回一个文件 于是按这一行的顺序去找：先看有没有 index.html，没有再找 index.htm，再没有就找 index.nginx-debian.html 我们的项目里有 index.html，所以第一个就命中。\n那个 index.nginx-debian.html，就是 Nginx 默认欢迎页的文件名——到这里你应该能完整地理解为什么之前打开公网 IP 看到的是欢迎页了：root 指向 /var/www/html，那个目录里恰好有 index.nginx-debian.html，Nginx 就把它返回了。\nserver_name _;：响应哪个域名 这一行回答：\u0026quot;用户访问什么域名，我才响应？\u0026quot;\n如果你已经买了域名 example.com，这里会写 server_name example.com;，意思是\u0026quot;只有访问 example.com 的请求才走我这个 server 块\u0026quot;。\n如果没有域名，只有 IP。这里就写一个 _，是一个占位符，表示\u0026quot;什么域名都接\u0026quot;。\nlocation / { try_files $uri $uri/ =404; }：路由规则 这里出现了一个新结构：location 块。\nlocation 块是 Nginx 的\u0026quot;路由规则\u0026quot;，它的意思是：对某一段 URL 路径，按某种方式处理。\nlocation / 表示\u0026quot;对所有路径都用这条规则\u0026quot;\ntry_files $uri $uri/ =404; 是这条规则的具体内容：\n这一行的意思是：先按用户要的路径找文件，找不到就当作目录找，再找不到就返回 404 这是 Nginx 处理一个静态网站请求时最朴素的逻辑。\n把这些拼起来 现在再回头看完整的 server 块，你应该能用自己的话翻译它：\n这台机器监听 80 端口；所有请求都到 /var/www/html 目录里找文件；访问根目录就返回 index.html；什么域名都接；文件找不到就返回 404。\n理解到这一步，下一步\u0026quot;改一行\u0026quot;才有它真正的意义。\n第 6 步：把 root 改成我们自己的目录 用 vim 打开配置文件：\nsudo vim /etc/nginx/sites-enabled/default 必须加 sudo，因为这个文件归 root 所有。\n进入 vim 之后：\n按 / 进入搜索模式 输入 root /var，回车 光标会定位到 root /var/www/html; 那一行 按 i 进入插入模式 把 /var/www/html 改成 /home/ubuntu/zero-to-tech 改完之后这一行应该是：root /home/ubuntu/zero-to-tech; 按 Esc 退出插入模式 输入 :wq，回车，保存并退出 这套 vim 流程和你之前在 2.4 那一节用过的一样：搜索定位 → i 插入 → 改 → Esc → :wq。\n第 7 步：检查配置 + 让 Nginx 重新加载 Nginx 的配置改完之后，不会立刻生效。它还在按内存里加载的旧配置运行。\n我们需要两步：先校验新配置的语法没问题，然后让 Nginx 重新加载。\n第一步：语法检查\nsudo nginx -t 正常情况下，你会看到：\nnginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful 如果看到 syntax is ok 和 test is successful，就说明配置文件没写错。\n如果有错，它会指出错在哪一行——回去用 sudo vim 改了再校验一次。校验失败的时候千万不要直接 reload，否则 Nginx 可能直接挂掉。\n第二步：重新加载\nsudo systemctl reload nginx reload 和 restart 不一样：reload 是平滑加载新配置，不中断正在处理的连接；restart 是完全重启进程。我们这种情况用 reload 就够了。\n这条命令没有任何输出，没有输出就是成功。\n第 8 步：打开浏览器——咦，404？ 打开浏览器，地址栏输入：\nhttp://你的公网IP 注意是 http:// 不是 https://。我们还没有配 SSL 证书，那是后面模块的事。\n然后……你大概率不会看到自己的页面，而是一个报错：404 Not Found。\n（有的服务器环境下，你看到的可能是 403 Forbidden 而不是 404。别在意这个差别，它俩背后是同一个原因，下面的修法也完全一样。）\n先别怀疑自己哪里做错了——配置其实都对。这个 404，是预料之中的，而且是这一节最值得学的一个点。\n配置明明都对了，为什么还是打不开？下一步我们来破案。\n第 9 步：破案——为什么 404，以及怎么修 我们回想一下 Nginx 是以谁的身份去读文件的。\n还记得第 5 步看 nginx.conf 时，最上面那行 user www-data 吗？它的意思是：Nginx 是以 www-data 这个用户的身份去读文件的，不是以你登录用的 ubuntu 身份。\n而 www-data 要读到我们的 /home/ubuntu/zero-to-tech/index.html，它必须能逐层走进这条路径上的每一级文件夹：\n/ → home → ubuntu → zero-to-tech → index.html 我们来查一下是哪一层卡住了。在服务器上运行：\nls -ld /home/ubuntu 你大概率会看到类似这样的输出：\ndrwxr-x--- 5 ubuntu ubuntu 4096 Jun 1 12:00 /home/ubuntu 留意开头那串权限里，最后三位是 ---——这代表\u0026quot;其他人\u0026quot;（others）对这个目录没有任何权限。这就是案发现场：\n/home/ubuntu 这道门，只让属主（ubuntu）和属主组进，不让\u0026quot;其他人\u0026quot;进。而 Nginx 的 www-data 恰恰是\u0026quot;其他人\u0026quot;。它在这一层就被挡住了，根本走不到里面的 zero-to-tech——所以返回 404。\n（里面的 zero-to-tech 目录和文件，权限其实都是好的，www-data 能读。卡住的，就是 /home/ubuntu 这一道门。）\n怎么修：给 /home/ubuntu 开一道\u0026quot;能穿过\u0026quot;的缝：\nsudo chmod o+x /home/ubuntu 这条命令拆开看：\no 指 others——\u0026ldquo;既不是属主、也不在属主组\u0026quot;的用户，Nginx 的 www-data 就属于这一类 x 给的是\u0026rdquo;能进入这个目录\u0026quot;的权限（注意：是\u0026quot;能穿过\u0026quot;，不是\u0026quot;能列出里面有什么\u0026quot;） 合起来就是：让 www-data 能穿过 /home/ubuntu 这道门，去拿到里面的网页文件。而因为我们只给了 x、没给 r，别人依然没法 ls 你的家目录、看不到你家里有什么——这是刚好够用的最小授权，安全上也放心。\n这一步只需要做一次。以后你更新代码、git pull，都不用再碰它了。\n改完之后，回到浏览器，刷新一下。\n这次，你的页面出来了：浅灰色背景、屏幕正中的白色卡片、标题、段落、还有那个蓝色的\u0026quot;点我试试\u0026quot;按钮。点一下按钮，文字会变成\u0026quot;你刚刚触发了一段 JavaScript。\u0026quot;\n这个瞬间值得停一下。\n这意味着：你在自己电脑上写的代码，经过 GitHub 中转，跑到了一台远在云端的 Ubuntu 服务器上，再通过 Nginx 用 80 端口暴露给整个公网。世界上任何一个人，只要拿到这个 IP 地址，都能在他自己的浏览器里看到这个页面、点击这个按钮。\n这就是\u0026quot;上线\u0026quot;。\n第 10 步：体验一次完整的更新流程 代码上线之后，迟早会要改。我们走一遍标准流程。\n在本地（你的 Mac 上），打开 ~/zero-to-tech/index.html，把里面那一句：\n\u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; 改成：\n\u0026lt;h1\u0026gt;你好，世界\u0026lt;/h1\u0026gt; 保存。然后在本地终端执行：\ncd ~/zero-to-tech git add . git commit -m \u0026#34;改一下标题\u0026#34; git push 这一步把改动推到了 GitHub。但服务器上的代码还没有变——它不知道 GitHub 上发生了什么。\n回到服务器的终端（也就是你那个 SSH 会话），执行：\ncd ~/zero-to-tech git pull git pull 的意思就是\u0026quot;把远程仓库的最新代码拉下来\u0026quot;。\n最后，回到浏览器，刷新一下公网 IP 的页面。\n标题变成了\u0026quot;你好，世界\u0026quot;。\n这套流程的肌肉记忆 以后每次你改完代码，要让公网生效，就这三段：\n# 本地：保存 + 推送 git add . git commit -m \u0026#34;写清楚这次改了什么\u0026#34; git push # 服务器（SSH 登录后）：拉取 cd ~/zero-to-tech git pull # 浏览器：刷新 这就是最朴素的\u0026quot;持续部署\u0026quot;。后面的模块里我们会学到自动化方案，比如 GitHub 上一推送，服务器自己就去 pull——但本质就是把上面这三步交给机器来做。\n常见问题排查 问题 1：浏览器还是显示 Nginx 默认欢迎页\n最常见的原因是浏览器缓存。试试：\n强制刷新：Mac 上 Command + Shift + R 或者用无痕窗口打开同一个地址 如果还不行，回到服务器 cat /etc/nginx/sites-enabled/default，看 root 那一行是不是真的改对了，以及有没有忘了执行 sudo systemctl reload nginx。\n问题 2：sudo nginx -t 报错\n错误信息会指明出错的文件和行号。最常见的几种：\n漏了行尾的分号 ; 路径写错了（比如多了空格、拼错了目录名） 把错误改掉，再校验一次。不通过就不要 reload。\n问题 3：改了 chmod 之后，浏览器还是 404 / 403\n先确认 chmod o+x /home/ubuntu 真的执行成功了（再跑一次 ls -ld /home/ubuntu，看最后一位是不是变成了 x，即 drwxr-x--x）。如果改对了还不行，多半是浏览器缓存，强制刷新（Command + Shift + R）或用无痕窗口再试。\n问题 4：git pull 报错说有冲突或者未提交的改动\n这通常是因为你手动改了服务器上的文件。记住一个原则：\n服务器上的代码目录是 GitHub 的\u0026quot;镜像\u0026quot;，所有改动都应该在本地做，通过 push/pull 同步过去。服务器上不要手改文件。\n如果已经改了，最简单的恢复方法是：\ncd ~/zero-to-tech git checkout . # 丢弃本地未提交的改动 git pull 这节课结束时，你至少应该做到什么 服务器上 ~/zero-to-tech/ 里有完整的项目代码 Nginx 配置里的 root 已指向新目录，并通过了 nginx -t 校验 浏览器访问公网 IP，能看到你写的卡片页面，按钮点击有响应 理解第一次访问为什么是 404，并用 sudo chmod o+x /home/ubuntu 修好了它（明白 Nginx 是以 www-data 身份读文件的） 走通过一次\u0026quot;本地改 → push → 服务器 pull → 浏览器刷新\u0026quot;的完整更新 能说出 Nginx 配置里 server、listen、root、index、server_name、location 各自在做什么 能说出这条链路里：GitHub 在做什么、Nginx 在做什么、git pull 在做什么 这一模块到这里告一段落 到这一节为止，模块 3 完整地交付了一件事：\n你写的代码，已经能被全世界看见了。\n虽然这个页面还很简单——一张卡片、一个按钮、一行会变的文字——但你已经走通了\u0026quot;本地开发 → 版本管理 → 远程托管 → 服务器部署 → 公网访问\u0026quot;这条完整链路。这条链路本身，比任何一个具体页面都更有价值。\n接下来的模块 4，我们会让前端\u0026quot;长大\u0026quot;：引入现代前端的工程化方式，让页面能承载更复杂的内容和交互。\n← 上一节：模块 3.4 GitHub 与远程同步 | 下一节：进入下一模块 →\n","date":"2026.06.01","description":"把网页发布到公网：服务器 SSH 信任、git clone、读懂 Nginx 配置、修改 root、跑通一次完整的更新链路。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-3-5/","title":"模块 3.5：把网页发布到公网"},{"content":" 本地存档解决“可回退”，远程同步解决“可备份、可协作、可部署”。\n为什么要有远程仓库 上一节我们已经把本地 Git 跑通了。\n但如果代码只在本机，还会面临几个现实问题：\n电脑损坏或更换时，代码迁移成本高 多人协作时，没有统一同步点 部署时无法稳定拉取同一份代码 远程仓库就是给本地仓库再加一份“网络上的可信副本”。\n最常见的平台就是 GitHub。\n本节目标 这一节做完，你应该能完成完整链路：\n在 GitHub 创建空仓库 配置 SSH 信任 把本地仓库和远程仓库关联 完成第一次 push 理解以后如何持续同步 第 1 步：在 GitHub 创建空仓库 登录 GitHub 后新建仓库，建议：\n仓库名：zero-to-tech Public / Private：都可以（按你的需求） 不勾选 Initialize this repository with a README 为什么不勾选 README：\n你本地已有项目文件 远程保持“空仓库”最利于第一次上手同步 创建完成后，复制 SSH 地址（类似）：\ngit@github.com:你的用户名/zero-to-tech.git 第 2 步：配置 SSH（每台电脑一次） 生成密钥对 ssh-keygen -t ed25519 -C \u0026#34;你的邮箱\u0026#34; 连续提示可直接回车使用默认值。生成后一般会有：\n~/.ssh/id_ed25519（私钥，不要外传） ~/.ssh/id_ed25519.pub（公钥，可提交给平台） 复制公钥 cat ~/.ssh/id_ed25519.pub 复制整行内容。\n添加到 GitHub GitHub -\u0026gt; Settings -\u0026gt; SSH and GPG keys -\u0026gt; New SSH key\n把公钥粘贴进去保存。\n验证连通性 ssh -T git@github.com 如果看到认证成功提示，就说明 SSH 可用。\n如果 SSH 一时配置不通，不要卡死流程，可以临时改用 HTTPS，先把同步链路跑通。\n第 3 步：关联本地与远程仓库 回到项目目录：\ncd ~/zero-to-tech 添加远程地址：\ngit remote add origin git@github.com:你的用户名/zero-to-tech.git 这里 origin 是远程仓库的常见命名。\n你可以验证一下：\ngit remote -v 第 4 步：第一次 push 先确认当前分支名：\ngit branch 如果是 main：\ngit push -u origin main 如果是 master：\ngit push -u origin master -u 的作用是建立本地分支和远程分支的跟踪关系。\n之后再推送通常只需要 git push。\n第 5 步：在 GitHub 页面验证 打开你的仓库页面，确认能看到：\nindex.html style.css script.js .gitignore 如果都看到了，说明本地到远程同步链路已经成功。\n以后每次更新代码的标准流程 git add . git commit -m \u0026#34;写清楚这次改了什么\u0026#34; git push 这三步就是你的日常节奏。\n命令行之外：GitHub Desktop 也是可选项 如果你暂时不想记太多命令，可以用 GitHub Desktop（官方图形工具）：\n可视化查看改动 图形化提交（Commit） 一键推送（Push） 它和命令行操作的是同一仓库，可以随时切换使用。\n本节小结 你已经从“本地存档”走到了“远程同步”：\n本地 Git 负责版本历史 GitHub 负责远程托管与同步 push 把本地提交送到远程 pull 把远程更新拉回本地 下一步我们就能基于这套同步链路，把代码发布到线上环境。\n← 上一节：模块 3.3 Git 入门：给代码设置存档点 | 下一节：模块 3.5 把网页发布到公网 →\n","date":"2026.05.31","description":"把本地仓库连接到 GitHub：SSH、remote、push、后续日常同步。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-3-4/","title":"模块 3.4：GitHub 与远程同步"},{"content":" 版本管理不是“高级流程”，而是你写代码时最实用的一颗后悔药。\n为什么要学 Git 到目前为止，我们已经写过 index.html、style.css、script.js 三个文件。\n接下来很容易发生这种情况：\n你改了几处代码，页面突然坏了 你知道“刚刚改过”，但已经记不清改了什么 你想回到上一版，却没有回退点 在 AI 写代码的场景里也一样：第一版能跑，第二版优化后崩了，你让 AI “回到上一版”，却发现上一版并没有被可靠地保存。\n这就是版本管理要解决的问题：\n在改动前后保留清晰的历史，让你随时可以回看、比较、回退。\nGit 的核心作用 Git 是软件开发里最常用的版本管理工具。你可以先记住两件事：\n1) 本地记录版本历史 Git 会在你的项目目录里记录每一次有意义的改动，这个动作叫 commit（提交）。\n每次提交至少包含三类信息：\n这次提交时，项目文件处于什么状态 提交发生在什么时间、由谁提交 你写的提交说明（例如“增加按钮样式”） 2) 先不讨论远程，只把本地跑通 这一节我们只做一件事：把本地仓库管理跑通。\n远程同步（GitHub）放在下一节。\n仓库是什么 Git 的管理范围不是“整台电脑”，也不是“单个文件”，而是一个目录。\n当你让 Git 开始管理某个目录，这个目录就叫 仓库（repository）。\n例如：\n目录：~/zero-to-tech 含义：这是一个本地 Git 仓库 你电脑里可以有很多个仓库，彼此互不影响。\n第 1 步：确认 Git 已安装 git --version 如果看到类似 git version 2.x.x，说明已安装。\n如果提示 command not found：\nmacOS 通常会引导安装 Command Line Tools 也可以使用 Homebrew 安装 其他系统可从 Git 官方网站安装 第 2 步：配置提交身份（每台电脑一次） 先检查是否已配置：\ngit config --global user.name git config --global user.email 如果未配置，执行：\ngit config --global user.name \u0026#34;你的名字\u0026#34; git config --global user.email \u0026#34;你的邮箱\u0026#34; 这两项会写入全局配置，后续提交会自动带上身份信息。\n第 3 步：初始化仓库 进入项目目录并初始化：\ncd ~/zero-to-tech git init 然后查看状态：\ngit status 你会看到 index.html、style.css、script.js 多半是 Untracked files，意思是 Git 发现了它们，但还没纳入版本管理。\n第 4 步：先配置忽略规则（.gitignore） 在 macOS 上，经常会出现 .DS_Store，它是系统显示设置文件，不属于项目代码，不应提交。\n在项目根目录新建 .gitignore，写入：\n.DS_Store 这表示：以后 Git 会忽略该文件。\n这里有个关键点：\n.gitignore 本身应该提交（它是项目规则） .git 不需要写进 .gitignore（Git 会自动管理） 第 5 步：第一次提交 先加入暂存区 把某个文件加入暂存区：\ngit add index.html 或者一次性加入当前目录全部改动（排除忽略项）：\ngit add . 再检查状态：\ngit status 看到文件进入 Changes to be committed，就表示已经准备好进入下一次提交。\n再执行提交 git commit -m \u0026#34;第一次提交\u0026#34; 到这里，你已经建立了第一个可回退的版本点。\n为什么建议你立刻再做一次提交 Git 的价值不是“完成初始化”，而是“持续记录变化”。\n建议你现在做一个小改动（例如改一行标题文字），然后再走一遍：\ngit add . git commit -m \u0026#34;修改首页标题文案\u0026#34; 这样你就会开始形成真正的版本管理直觉：\n每次有意义的改动，都应该落成一个可解释、可回退的提交。\n常见误区 误区 1：只有大改动才值得提交 不对。只要改动有意义，就值得提交。小步提交比“大包提交”更安全。\n误区 2：先不写提交说明，后面再补 不建议。提交说明就是“你当时为什么这么改”的最短注释，后补通常会失真。\n误区 3：.gitignore 可有可无 不对。忽略规则越早建，项目越干净，后续协作越省事。\n这一节结束时，你应该达到的状态 zero-to-tech 已完成 git init 已配置（或确认）user.name / user.email 已创建 .gitignore 并忽略 .DS_Store 已完成至少一次 commit 理解 git add、git commit、git status 的关系 ← 上一节：模块 3.2 把HTML拆分成三个文件 | 下一节：模块 3.4 GitHub 与远程同步 →\n","date":"2026.05.31","description":"先把本地版本管理跑通：仓库、暂存区、提交记录与 .gitignore。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-3-3/","title":"模块 3.3：Git 入门：给代码设置存档点"},{"content":"最近见到很多人或者组织，打着“降本增效”的幌子在摆烂，我觉得值得警惕。\n尤其是这一两年，很普遍。\n如果注意力没有放在事情本身，而是放在了工具、流程和技巧上。恐怕最后忙了半天，既没降本，也没增效。\n我觉得这其实就是，不想做某件事，但又希望看到结果，于是就会开始研究怎么“更快地完成它”。\n效率是个体面的理由。说“我不想做”，不好听。说“我在优化流程”，就显得很专业。而且研究效率这件事本身还挺有趣的，很容易上瘾，会给人一种“我在努力”的幻觉。\n但是，如果抛开工作，回到生活，或者说回到人生体验上，我们在什么情况下会追求效率呢？\n真正进入状态的时候，人其实不太在意效率。我和一个下午都没钓到一条鱼的钓鱼佬聊过，他们其实不追求效率，享受的是等待和手感，而不是每小时钓了多少。我自己也弹琴，如果哪天弹琴比较开心的时候，也不会去想“怎么样速成”。\n很多能力是在看起来很慢、很笨的时间里长出来的，反复练、容忍看不到进展、不急着把每一分钟换算成产出。\n这种“笨”，反而是认真的样子。\n当然效率本身没有问题。重复的操作、固定格式的东西、纯粹占时间但不需要动脑的流程，越自动化越好，省下来的时间可以去做真正需要用心的事。\n但不能盲目求快，有些事情快不得，“快”会把最重要的东西弄丢。深度理解、对细节的感觉、对过程的热爱，这些几乎都没法通过“再快一点”得到。\n事情一旦快，很容易变形。\n现在 AI 可以很快生成很多东西，同时带来一个风险：你可能在没怎么想清楚的情况下，就已经有了一份看起来还不错的产出。“用心不足”被进一步掩盖了。\n所以我倾向于在做一个事情之前，认真地去想清楚“这件事，我到底想不想认真对待”，而不是“怎么做得更快”。\nAI 可以帮你提速，但不能替你用心。在这个时候，真正稀缺的大概是判断力，判断哪些地方不能省，什么时候该慢下来。\n有一个简单的自检方式：如果不用任何新工具，你愿不愿意先把这件事认真做一遍？如果不愿意，那可能还没有进入状态。\n相关思考 关于“用心”和“专注”的更深层讨论，我会在后续文章里继续展开。\n","date":"2026.05.21","description":"AI 可以帮你提速，但不能替你用心。","section":"blog","slug":"/blog/efficiency-and-care/","title":"效率和用心"},{"content":" 所有东西堆在一起的时候，麻烦还不够明显。拆开了，才能看清各自是什么。\n回顾一下现在的文件 上节课结束之后，~/zero-to-tech/index.html 里同时有三种代码：\nHTML 标签，构成页面结构 \u0026lt;style\u0026gt; 块，里面是 CSS \u0026lt;script\u0026gt; 块，里面是 JavaScript 现在用 VS Code 打开这个文件夹，看一眼：\ncode ~/zero-to-tech/ 你会看到，这一个文件里大约有六七十行代码。\n目前还好。\n但想象一下，随着页面内容越来越多，CSS 样式越来越丰富，交互逻辑越来越复杂，这个文件很快会膨胀到几百行。\n那时候，你每次想修改一个按钮的颜色，都得先在 CSS 里找那一行；每次想调整一段文字，又得在 HTML 里翻一遍。不同性质的代码混在一起，越来越难找，越来越难改。\n更何况，一个完整的项目不会只有一个页面。假如不同页面中有相同样式，那岂不是每个页面都要重复写一次一样的 CSS？\n最关键的是，假如后续需要改样式，那么多个页面中的 CSS 样式就需要一个一个地改，那会是一场灾难。\n即使我们不用自己改，而是让 AI Agent 来改，一个很大的文件对 AI 也是不友好的，因为 AI 自身有上下文长度的限制。\n解决方法很直接：拆开。\n把 CSS 单独放一个文件，把 JavaScript 单独放一个文件。\nHTML 只保留结构，以及对 CSS 和 JavaScript 文件的引用。\n拆分第一步：把 CSS 独立出来 在 VS Code 里，新建一个文件，命名为 style.css：\n点击 VS Code 左侧文件栏上方的“新建文件”图标，或者在 zero-to-tech 文件夹内操作，确保这个新文件和 index.html 在同一个文件夹里。\n把 index.html 里 \u0026lt;style\u0026gt; 标签之间的内容，完整复制到 style.css 里：\n注意：复制的是 \u0026lt;style\u0026gt; 和 \u0026lt;/style\u0026gt; 之间的内容，不包括这两个标签本身。\nstyle.css 里的内容应该是这样的：\nbody { background-color: #f0f4f8; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; font-family: sans-serif; } .card { background: white; padding: 40px; border-radius: 12px; text-align: center; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } h1 { color: #2c3e50; margin-bottom: 12px; } p { color: #666; } button { margin-top: 20px; padding: 10px 24px; background-color: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 16px; } button:hover { background-color: #2980b9; } 保存 style.css。\n然后，回到 index.html，把整个 \u0026lt;style\u0026gt;...\u0026lt;/style\u0026gt; 块删掉，替换成下面这一行，放在同样的位置（\u0026lt;/head\u0026gt; 之前）：\n\u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;style.css\u0026#34;\u0026gt; 这一行的意思是：\n去引入一个样式表文件，文件名叫 style.css，它在同一个文件夹里。\nhref 是文件路径，style.css 没有任何斜杠前缀，表示“和我在同一个文件夹”。\n拆分第二步：把 JavaScript 独立出来 在同一个文件夹里，再新建一个文件，命名为 script.js：\n把 index.html 里 \u0026lt;script\u0026gt; 标签之间的内容，复制到 script.js 里：\n同样，复制的是标签之间的内容，不包括 \u0026lt;script\u0026gt; 和 \u0026lt;/script\u0026gt; 本身。\nscript.js 里的内容应该是这样的：\nfunction changeText() { document.getElementById(\u0026#39;msg\u0026#39;).textContent = \u0026#39;你刚刚触发了一段 JavaScript。\u0026#39;; } 保存 script.js。\n然后回到 index.html，把整个 \u0026lt;script\u0026gt;...\u0026lt;/script\u0026gt; 块删掉，替换成：\n\u0026lt;script src=\u0026#34;script.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 这一行的意思是：\n去引入一个脚本文件，文件名叫 script.js，它在同一个文件夹里。\n注意这行放在 \u0026lt;/body\u0026gt; 之前，位置和原来的 \u0026lt;script\u0026gt; 块一样。\n拆分之后，index.html 应该变成这样 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;zh-CN\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;我的第一个网页\u0026lt;/title\u0026gt; \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;style.css\u0026#34;\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div class=\u0026#34;card\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; \u0026lt;p id=\u0026#34;msg\u0026#34;\u0026gt;这是我的第一个网页，现在它有了一点样式。\u0026lt;/p\u0026gt; \u0026lt;button onclick=\u0026#34;changeText()\u0026#34;\u0026gt;点我试试\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script src=\u0026#34;script.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 干净多了。HTML 文件里只剩下结构，没有任何样式或脚本的具体内容。\n保存，刷新，验证 三个文件都保存之后，刷新浏览器。\n页面的外观和行为应该和之前完全一样：\n背景是浅灰色 内容在白色卡片里 点击按钮，文字发生变化 如果页面变成了没有样式的白底黑字，通常是 style.css 的路径写错了，或者文件名拼写有误。检查一下 \u0026lt;link href=\u0026quot;...\u0026quot;\u0026gt; 里的文件名是否和你创建的文件完全一致。\n现在文件夹里有什么 打开访达，或者在 VS Code 左侧看文件栏，zero-to-tech 文件夹里现在有三个文件：\nzero-to-tech/ index.html ← 页面结构 style.css ← 样式 script.js ← 交互逻辑 这就是一个最小的前端项目结构。\n每种代码有自己的文件，各司其职。以后需要改样式，直接打开 style.css；需要改交互，直接打开 script.js；需要改内容和结构，才去动 index.html。\n这个拆分习惯，在真实项目里非常重要。\n这节课结束时，你至少应该做到什么 文件夹里有三个文件：index.html、style.css、script.js 刷新浏览器后，页面外观和行为与之前完全一致 能说出 \u0026lt;link rel=\u0026quot;stylesheet\u0026quot; href=\u0026quot;...\u0026quot;\u0026gt; 和 \u0026lt;script src=\u0026quot;...\u0026quot;\u0026gt; 各自在做什么 理解“同一个文件夹”和相对路径的关系 ← 上一节：模块 3.1 前端基础——HTML | 下一节：模块 3.3 Git 入门：给代码设置存档点 →\n","date":"2026.04.29","description":"所有东西堆在一起的时候，麻烦还不够明显。拆开了，才能看清各自是什么。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-3-2/","title":"模块 3.2：把HTML拆分成三个文件"},{"content":" 代码能跑起来的那一刻，比任何解释都有说服力。\n找回之前的文件 在模块 2.3 里，你已经创建并编辑过一个 index.html 文件，它现在还在你的电脑里，路径是：\n~/zero-to-tech/index.html 用 VS Code 打开它：\ncode ~/zero-to-tech/ 请注意，我们用上面这行命令打开的是 index.html 所在的上层目录 zero-to-tech，而不只是打开 ~/zero-to-tech/index.html 这一个 html 文件。\n打开之后你会看到这份代码：\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;zh-CN\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;我的第一个网页\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;这是我的第一个网页，现在它还只是一个本地的 HTML 文件。\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 我们再用浏览器打开它看看：\nopen ~/zero-to-tech/index.html 你会看到一个非常朴素的页面：白底，黑字，没有任何装饰。\n这就是我们在 2.3 那一节已经见过的页面。这节课我们来继续发展它。让它变得好看一些，再让它能够响应你的操作，并理解背后发生了什么。\n先认识一下这份代码 虽然你不需要学会写代码，但是这个代码文件真的太简单了，而且又太重要了，所以我们还是需要能够看明白它。\n首先，第一行的内容是固定的，它只是为了告诉浏览器这是一份 HTML 文件，所以这一行你不需要关注。\n\u0026lt;!DOCTYPE html\u0026gt; 然后，就是许多尖括号 \u0026lt;\u0026gt; 括起来的结构，这些用尖括号括起来的东西，叫做标签（tag）。大多数标签成对出现：有开头，有结尾。比如 \u0026lt;html\u0026gt; 和 \u0026lt;/html\u0026gt;，结尾的那个多一个斜杠。两个标签相互呼应形成一对儿，意思就是这两个标签中间的部分，就是 html 代码。\n我们给它简化一下，就是：\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;zh-CN\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 你会发现在我们实际的代码中，\u0026lt;html\u0026gt; 标签里还有一些别的信息：\n\u0026lt;html lang=\u0026#34;zh-CN\u0026#34;\u0026gt; 这里的 lang 表示标签的属性，它的意思是 language（语言），这个属性的值是 zh-CN，就是简体中文的意思。整个这一行的意思是这个 html 标签内的内容语言是简体中文。\n在这一对 \u0026lt;html\u0026gt; 标签的内部，还有一对 \u0026lt;head\u0026gt; 标签和一对 \u0026lt;body\u0026gt; 标签。\n\u0026lt;head\u0026gt; 标签里的内容是页面的“头部”，放的是元信息，不会直接显示在页面上。\n\u0026lt;body\u0026gt; 标签里的内容是页面的“身体”，这里的内容才会显示出来。\nhtml 语法中有许多标签，但是不需要都懂，知道其基本结构就可以了。\n到这里，你对 HTML 的了解已经够用了。\n给它加上样式 现在这个页面太素了，我们来给它加一点样式。\n第一步：在 \u0026lt;head\u0026gt; 里，\u0026lt;/head\u0026gt; 的前面，加入一段 CSS：\n下面这段代码是包裹在一对 \u0026lt;style\u0026gt; 标签内部的，这种格式叫做 CSS。如果我们在 index.html 中写入这些 CSS 代码，页面就会变一种样式，这就是 CSS 的功能。\n复制下面这部分 CSS 内容：\n\u0026lt;style\u0026gt; body { background-color: #f0f4f8; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; font-family: sans-serif; } .card { background: white; padding: 40px; border-radius: 12px; text-align: center; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } h1 { color: #2c3e50; margin-bottom: 12px; } p { color: #666; } button { margin-top: 20px; padding: 10px 24px; background-color: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 16px; } button:hover { background-color: #2980b9; } \u0026lt;/style\u0026gt; 在 index.html 文件中找到这段代码，把复制的内容粘贴到 \u0026lt;/head\u0026gt; 之前：\n\u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;我的第一个网页\u0026lt;/title\u0026gt; \u0026lt;!-- 把 CSS 插入到这个位置 --\u0026gt; \u0026lt;/head\u0026gt; 这一步的意思是，定义了不同标签应该长什么样，并且把这个定义放在 index.html 中给浏览器来读取。\n第二步：修改 \u0026lt;body\u0026gt; 里的内容：\n修改之前：\n\u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;这是我的第一个网页，现在它还只是一个本地的 HTML 文件。\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; 修改之后：\n\u0026lt;body\u0026gt; \u0026lt;div class=\u0026#34;card\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;这是我的第一个网页，现在它有了一点样式。\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/body\u0026gt; 这一步的意思是，告诉浏览器 \u0026lt;div\u0026gt; 标签是 card 类，用 css 里面的 .card 样式定义来显示页面。浏览器并不“理解”card 这个词，它只是去匹配有没有 .card 这条规则。\n保存文件，刷新浏览器。\n页面变了：有了浅灰色背景，内容被放进一张白色卡片里，文字的颜色和位置也变了。\n这些变化，全部来自你刚才加入的那段 \u0026lt;style\u0026gt; 代码。你会发现 \u0026lt;style\u0026gt; 标签内部的代码不太一样，那些内容并不总是用尖括号包裹，而是一些大括号 {}。\nCSS 负责描述“这些内容应该长什么样”。\n让它动起来 光有样式还不够。现在加一个按钮，点击之后页面上的文字会变化。\n第一步：给段落加上 id=\u0026quot;msg\u0026quot; 属性：\n加属性之前：\n\u0026lt;p\u0026gt;这是我的第一个网页，现在它有了一点样式。\u0026lt;/p\u0026gt; 加属性之后：\n\u0026lt;p id=\u0026#34;msg\u0026#34;\u0026gt;这是我的第一个网页，现在它有了一点样式。\u0026lt;/p\u0026gt; 这一步的意思是，给 \u0026lt;p\u0026gt; 标签加一个 id，让浏览器后续可以准确定位到它。\n第二步：在段落下方、\u0026lt;/div\u0026gt; 前面，加入一个按钮：\n加按钮之前：\n\u0026lt;div class=\u0026#34;card\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; \u0026lt;p id=\u0026#34;msg\u0026#34;\u0026gt;这是我的第一个网页，现在它有了一点样式。\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; 加按钮之后：\n\u0026lt;div class=\u0026#34;card\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; \u0026lt;p id=\u0026#34;msg\u0026#34;\u0026gt;这是我的第一个网页，现在它有了一点样式。\u0026lt;/p\u0026gt; \u0026lt;button onclick=\u0026#34;changeText()\u0026#34;\u0026gt;点我试试\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; 第三步：在 \u0026lt;/body\u0026gt; 前面，加入这段脚本：\n\u0026lt;script\u0026gt; function changeText() { document.getElementById(\u0026#39;msg\u0026#39;).textContent = \u0026#39;你刚刚触发了一段 JavaScript。\u0026#39;; } \u0026lt;/script\u0026gt; 检验一下，完成上面三步之后，\u0026lt;body\u0026gt; 标签内部会变成这样：\n\u0026lt;body\u0026gt; \u0026lt;div class=\u0026#34;card\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; \u0026lt;p id=\u0026#34;msg\u0026#34;\u0026gt;这是我的第一个网页，现在它有了一点样式。\u0026lt;/p\u0026gt; \u0026lt;button onclick=\u0026#34;changeText()\u0026#34;\u0026gt;点我试试\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; function changeText() { document.getElementById(\u0026#39;msg\u0026#39;).textContent = \u0026#39;你刚刚触发了一段 JavaScript。\u0026#39;; } \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; 保存，刷新，点击按钮。文字变了。\n你也会发现，在我们上面第三步用到的 \u0026lt;script\u0026gt; 标签内部，代码写法看起来和 html、CSS 都不一样。\n没错，这是一种新的语法，它叫做 JavaScript。在加入它之前，点击按钮是没有作用的；加入之后，再点击按钮就可以改变页面文案。\n这就是 JavaScript 在做的事：\nJavaScript 负责描述“当某件事发生时，应该做什么”。\n完整代码 以上步骤全部做完之后，你的 index.html 应该长这样。\n如果中间出了什么问题，可以把下面这份代码完整复制进去，覆盖原来的内容：\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;zh-CN\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;我的第一个网页\u0026lt;/title\u0026gt; \u0026lt;style\u0026gt; body { background-color: #f0f4f8; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; font-family: sans-serif; } .card { background: white; padding: 40px; border-radius: 12px; text-align: center; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } h1 { color: #2c3e50; margin-bottom: 12px; } p { color: #666; } button { margin-top: 20px; padding: 10px 24px; background-color: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 16px; } button:hover { background-color: #2980b9; } \u0026lt;/style\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div class=\u0026#34;card\u0026#34;\u0026gt; \u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; \u0026lt;p id=\u0026#34;msg\u0026#34;\u0026gt;这是我的第一个网页，现在它有了一点样式。\u0026lt;/p\u0026gt; \u0026lt;button onclick=\u0026#34;changeText()\u0026#34;\u0026gt;点我试试\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; function changeText() { document.getElementById(\u0026#39;msg\u0026#39;).textContent = \u0026#39;你刚刚触发了一段 JavaScript。\u0026#39;; } \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 三层分工 现在这份文件里同时有三种代码，它们的分工是这样的：\n层 写在哪里 负责什么 HTML \u0026lt;body\u0026gt; 里的标签 内容和结构：页面上有什么东西，怎么组织 CSS \u0026lt;style\u0026gt; 块 样式和外观：这些东西长什么样 JavaScript \u0026lt;script\u0026gt; 块 行为和交互：发生了什么事，应该做什么 这三层分工，是前端开发的基础直觉。\n注意，现在这三层都还挤在同一个文件里。项目还小的时候，这没有任何问题。但随着内容越来越多，你会开始感觉不方便。下一节我们就来处理这件事。\n← 上一节：模块 2.4 准备好云服务器 | 下一节：模块 3.2 拆分成三个文件 →\n","date":"2026.04.29","description":"代码能跑起来的那一刻，比任何解释都有说服力。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-3-1/","title":"模块 3.1：前端基础——HTML"},{"content":" 上一节讲的那些概念——服务器、IP、端口——这一节全部亲手体验一遍。\n服务器和我们自己用的电脑，有什么不同 上一节说过，服务器本质上就是一台联网的电脑。\n但它和我们桌上的电脑，还是有几处关键的不同：\n不同 1：服务器有真正的公网 IP 我们用自己的电脑连上 WiFi，此时我们的电脑拿到的是路由器分配的一个内网地址，比如 192.168.1.5。就好像一个电话的内网号码，互联网上的其他人是找不到这个地址的。\n这里可能会有一个疑问：既然别人找不到我的内网地址，如果我电脑上登录了微信，微信消息是怎么送到我电脑上的？\n关键在于，谁先发起了连接。\n你感觉自己“只是开着电脑连着网”，但微信 App 已经默默做了一件事：它一启动，就主动连接了微信的服务器，并且一直保持着这条连接不断开。\n所以当朋友发消息给你，消息先到了微信的服务器，然后微信服务器顺着那条已经建好的连接把消息推过来，最终传给了你的电脑。\n你体验到的是“消息自动弹出来了”，但背后的结构是：你的微信 APP 主动连出去在先，服务器沿着这条通道推消息在后。\n但如果换一个场景：有人想直接“找到你的电脑”，在没有任何已有连接的情况下主动向你发起请求，这就行不通了。因为外面的人最多只知道你路由器的公网 IP，不知道你家里有几台设备，更不知道该把请求转给哪一台。\n这就是内网 IP 和公网 IP 最关键的区别：\n内网 IP 只能“主动出去”，不能“被陌生人主动找到”。公网 IP 两个方向都通。\n网站需要的恰恰是后者，任何人在任何时候都能主动找到它。所以服务器必须有公网 IP。\n不同 2：服务器可能不会配置屏幕、鼠标或键盘 我们自己用的电脑，有屏幕、有鼠标、有图形界面，而且还会带有喇叭、摄像头、麦克风等影音配置。\n服务器作为电脑，当然也可以外接屏幕或鼠标、键盘、麦克风，但人们通常不会给服务器配置这些。\n因为服务器在绝大多数时间内只需要保持开机联网，并稳定向请求者持续提供服务。人们对它的要求是稳定、方便摆放在机架上。\n没有显示器，也没有键盘鼠标，我们如何操控服务器呢？\n我们自然可以走进机房，给它插上显示器、键盘来操作它，但更多时候我们操控服务器的方式，是通过网络远程登录进去，用终端和命令来操作。\n不同 3：它需要一直开着 你自己用的电脑，用完可以关机。\n服务器不行。网站需要随时能被访问，所以正常情况服务器必须一直运行，24 小时不间断。\n不同 4：绝大部分服务器操作系统是 Linux，没有图形界面 个人电脑的操作系统，绝大多数跑的是 Windows 或者 macOS，有桌面和窗口。但在服务器领域，Linux 系统是绝对的主流。\n服务器上运行的 Linux 没有图形界面，只有终端。这也是这门课在前面花时间讲终端和命令行的原因。\n在云平台上，去租一台云服务器 对于上述四项不同点，后三项还可以忍，但没有 公网 IP 就没办法把我们的作品分享出去。\n后面我们的课程会带领大家真正制作并发布一款产品，所以我们的方案是真正地租一台云服务器，来获得公网 IP。\n有许多云厂商提供了云服务器租赁服务，但它们往往都不是免费的。\n“云服务器”的意思是：一些厂商已经组建好了网络、服务器，并分成不同的配置租给用户使用。云厂商帮你管理硬件，你按时间或配置付费，拿到一台可以远程登录并操作的 Linux 机器。\n这里不对云厂商做推荐，腾讯云、阿里云、火山引擎、华为云、天翼云等等都可以，建议你选择购买流程最顺畅、价格最优惠的那一家。\n我目前在用的一台阿里云服务器价格是 99 元/年，是 双 11 时租的，配置是 2 核 2G，也支持每年按这个价格续费，这个价格供你参考。\n购买时，抓住这几个点就够了：\n买的是服务器（可以是云服务器或轻量应用服务器），不是对象存储、数据库、CDN 之类的其他产品 操作系统选 Ubuntu（原因见下一条） 配置从最低档开始，最便宜的那种就够用 确认机器有公网 IP 为什么选 Ubuntu Linux 有很多发行版。这门课选 Ubuntu，原因只有一个：\n它在初学者环境里最常见，资料最多，后面的命令和安装过程最容易对齐。\n在你还没有足够 Linux 直觉之前，用 Ubuntu 最省事。\n有一个需要注意的是，当选择 Ubuntu 的时候还需要你选择具体版本号，例如 24.04 LTS、22.04 LTS 等，2026 年之后可能还会出现 26.04 LTS，你选择哪一项都行。这里建议选择的是 24.04 LTS，它不会太新，也绝对不旧。\n买完以后，你会拿到什么 购买成功后，你就具备了一台云服务器的控制权。云厂商通常会给你几样信息：\n公网 IP 地址 登录用户名（通常是 ubuntu 或 root） 登录密码 一般情况下，第一次使用，你需要设置一个密码。\n也可以点击重置密码的按钮来设置密码（可能设置之后需要重启才能生效），设置时请记住用户名和密码。\n如果你已经有了 公网 IP 地址、用户名、密码 这三个关键信息，那么你已经走完了非常关键的步骤。\n最后我们还需要关注一下防火墙（有些云厂商把它叫做“网络与安全组”）。\n点击防火墙，进入防火墙页面查看目前开放的端口有哪些。我们目前至少需要确保 22 端口是开放的（一般情况下，22 端口会是开放的）。\n至于为什么是 22 端口，我们马上就会讲。\n用 SSH 连上这台服务器 我们要介绍一个新的终端命令了，那就是 SSH，它是登录远程 Linux 服务器的标准方式。\n具体用法是在你自己电脑的终端里，输入：\nssh 用户名@服务器公网IP 比如：\nssh ubuntu@123.45.67.89 此前我们讲端口的时候，说过不同的服务会使用不同的端口，SSH 作为一个远程登录服务，它默认监听的就是 22 端口，所以我们需要确保 22 端口开放。\n第一次连接时，终端会问你要不要信任这台机器，输入 yes 然后按回车。\n接着输入密码（输入时屏幕上不会显示任何字符，这是正常的），按回车。\n此处需注意，虽然常规流程是 ssh 命令之后直接输入密码，但有些云厂商（比如腾讯云）可能会通过终端发送一个二维码要你扫码确认。遇到这种情况就根据提示来操作即可。\n你的浏览器暂不支持视频播放，可直接访问 MP4 文件 查看。 如果登录成功，你会看到终端的提示符变了，变成类似这样的样子：\nubuntu@your-server:~$ 这一刻一定要停下来感受一下：\n你现在操控的，已经不是你眼前这台电脑，而是网络另一端的一台 Linux 服务器。\n先在服务器上执行几条熟悉的命令 登录进去之后，先执行几条你已经熟悉的命令，确认一下自己真的在另一台机器上：\npwd 你会看到当前目录，通常是 /home/ubuntu 或者 /root（这取决于你用什么用户名登录）。\nls 第一次登录一台新的服务器时，用 ls 命令查看家目录下的文件，会发现家目录中什么都没有。\n可以试试进入根目录：\ncd / ls 进入根目录后，你会看到一个标准的 Linux 目录结构：\nbin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var 这和你本地的 macOS 或 Windows 目录结构差别很大。\n这很正常，因为这本来就是另一台机器，跑着另一套操作系统。\n到这里，“服务器就是另一台电脑”这件事，已经不再是概念了。\n用 apt 在服务器上安装 Nginx 如果想要让服务器提供网页服务，我们还需要安装一个软件，这个软件的职责将会是：\n持续监听 80 端口 如果有用户请求 80 端口，那么就返回相应的内容 有许多软件可以做这类事情，而今天我们介绍的是 Nginx，它非常擅长处理这类工作。\nNginx 能够做到的事情绝不止有监听端口并返回内容，它可以做许多事情。比如它可以记录每一次用户请求日志、给用户请求分类并分别处理、限制危险请求等。你了解即可，我们不做展开。\n接下来，进入安装环节。\n先更新软件包列表，再安装 Nginx：\nsudo apt update sudo apt install nginx -y 以 sudo 开头的命令在执行的时候，会要求你提供密码，这个密码就是你登录的时候用的密码。\n你的浏览器暂不支持视频播放，可直接访问 MP4 文件 查看。 这里两个新命令：\nsudo：以更高权限执行命令（类似 Windows 上的“以管理员身份运行”） apt：Ubuntu 上的软件安装工具，类似 macOS 上的 Homebrew 安装完成后，确认 Nginx 已经在运行：\nsystemctl status nginx 如果看到输出里有这一行：\nActive: active (running) 说明 Nginx 已经启动，正持续监听用户请求。\n确认 80 端口开放，用 IP 地址访问默认页面 Nginx 启动后，它默认监听的是 80 端口，也就是普通 HTTP 请求的标准端口。\n所以在我们从浏览器访问它之前，还需要确认一件事：\n云服务器的防火墙（或安全组）有没有放行 80 端口的入站流量。\n这个设置不在服务器里，而在云平台的控制台里。\n还记得前面学习 SSH 的时候，我们在哪里去查看 22 端口是否开放吗？\n在云平台找到你的服务器实例，进入安全组或防火墙配置，确认 TCP 80 端口是对外开放的。\n做好之后，打开你自己电脑上的浏览器，访问下面这个地址，应该就可以看到 Nginx 的默认欢迎页：\nhttp://你的服务器公网IP 如果看到 Nginx 的默认欢迎页面，说明：\nNginx 正在运行 80 端口已经开放 你的浏览器正在通过公网 IP 访问另一台电脑上提供的网页内容 上一节讲的整条链路，在这里第一次真实地发生了：\n浏览器 → IP 地址 → 服务器 → 80 端口 → Nginx → 返回页面内容 → 浏览器显示\n通过配置文件找到 Nginx 的默认页面 上一步我们访问到的浏览器里那个 Nginx 默认页面，背后也是对应着服务器上的一个 HTML 文件。\n但这个文件放在哪里？\n不用猜，去看 Nginx 的配置文件：\ncat /etc/nginx/sites-available/default 终端会打印出一大段配置。不需要全看懂，只需要找到这一行：\nroot /var/www/html; 这行配置的意思就是：\nNginx 会去 /var/www/html 这个目录里找网页文件来提供给浏览器。\n顺着这个路径，进去看看：\ncd /var/www/html ls 你会看到一个文件，通常叫：\nindex.nginx-debian.html 这就是刚才浏览器里默认页面对应的源文件。\n用 cat 看一眼它的内容：\ncat index.nginx-debian.html 终端会打印出一大段 HTML。你不需要看懂它，只需要确认一件事：\n浏览器里的那个网页，背后真的对应着服务器上的一个 HTML 文件。\n文件在哪里、内容是什么，全都可以找到、可以查看，服务器不是黑盒。\n用 Vim 修改这个文件，刷新浏览器看变化 既然这个网页就是一个真实存在的 HTML 文件，那么我们稍微改一改，起步就是改了这个网页吗？\n没错，现在我们尝试亲手改一次这个文件，在浏览器里验证结果。\n上一节学过生存级 Vim，现在就用上了：\nsudo vim /var/www/html/index.nginx-debian.html 进去之后：\n按 i 进入编辑模式 找到页面里最显眼的一行英文，改成任意一句你自己写的话 按 Esc 退出编辑模式 输入 :wq 保存并退出 按回车 改完后，回到浏览器，刷新页面。\n如果你看到了自己刚才写的那句话，这一节就真正完成了。\n你刚刚走完的，是这样一条完整的链路：\nSSH 登录远程服务器 找到 Nginx 配置，顺着 root 路径找到页面源文件 用 Vim 修改文件内容 Nginx 继续对外提供这个文件 浏览器刷新，公网页面内容发生了变化 这一节最常见的卡点 SSH 连不上\n优先检查：用户名是否正确（不一定是 ubuntu，以云平台给的为准）；密码是否输入正确；服务器是否已经启动完成；云平台控制台里，安全组或防火墙是否放行了 22 端口。\n浏览器打不开 IP 地址\n优先检查：云平台控制台里，安全组或防火墙是否放行了 TCP 80 端口的入站流量。这一步很多人会漏掉。\nNginx 状态不是 active (running)\n重新执行 sudo apt install nginx -y，或者执行 sudo systemctl start nginx。\nVim 改了文件，浏览器刷新没有变化\n先确认 Vim 真的保存成功了：是否按了 Esc，是否输入了 :wq，是否按了回车。如果不确定，重新进去改一次。\n这一节结束时，你应该做到什么 知道服务器和个人电脑的几处关键不同 自己购买了一台云服务器，拿到了公网 IP 能用 ssh 登录服务器 能在服务器上执行 pwd、ls、cd 等熟悉的命令 能安装 Nginx，并确认它处于 active (running) 状态 能通过公网 IP 在浏览器里访问到 Nginx 默认页面 能通过 Nginx 配置找到默认页面对应的源文件 能用 Vim 修改这个文件，并在浏览器里看到变化 ← 上一节：模块 2.3 互联网是怎么工作的 | 下一节：模块 3 前端基础 →\n","date":"2026.04.18","description":"上一节讲的那些概念——服务器、IP、端口——这一节全部亲手体验一遍。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-2-4/","title":"模块 2.4：准备好云服务器"},{"content":"很多人以为，技术课程做得好，核心在“老师懂得多”。\n我以前也这么想。\n但真正开始做课之后，我越来越确定一件事：\n课程质量的上限，不由知识储备决定，而由学习路径设计决定。\n学生不是来参观讲师知识库的。学生来，是为了完成一次能力迁移：从“不会”到“会一点”，再到“能独立做出来”。\n好课程的标准，不是“覆盖完整” 判断一门技术课程是否靠谱，我现在更看这几件事：\n学生是否知道自己为什么学这门课 学生是否能持续获得进展感 学生卡住时是否有明确指引 学完后是否真的做出了结果 “讲得清楚”和“学得会”是两种不同能力。\n前者是表达能力，后者是课程设计能力。\n一门技术课，先回答“给谁学” 做大纲之前，先定义人群。\n如果“面向所有人”，通常就等于“没有明确面向任何人”。\n你需要先回答：\n学员目前知道什么，不知道什么 主要障碍是技术难，还是路径复杂、工具复杂 学完后要能独立完成什么 人群越清晰，课程边界越清晰；边界越清晰，主线越稳定。\n从“结果”倒推课程，而不是从“我会什么”出发 我最推荐的方法是“从终点倒推”。\n比如目标是：让零基础学习者部署出第一个 Web 应用。\n那课程就该围绕这个结果反推必要能力，而不是把讲师熟悉的知识树搬一遍。\n倒推会自然带来三件好事：\n内容更聚焦 无关扩展更容易克制 学习路径更像“能走通的路” 顺序比内容更容易决定成败 很多课程失败，不是内容错了，而是顺序错了。\n专家常按“抽象模型”讲，新手更需要“先有直觉，再谈抽象”。\n一个更友好的顺序通常是：\n先给场景 再给最小结果 再解释原理 最后逐步增加复杂度 学习顺序不是“知识树怎么长”，而是“人怎么吸收”。\n技术课程要让学生“做”，而不是只“听” 技术能力本质上是操作、判断、排错、迁移。\n所以课程里必须有实践任务和阶段产出。\n但“实践驱动”不等于“作业堆满”。\n关键是：每个练习都要服务能力目标。\n理想状态是，学生每学完一个阶段，都能回答：\n我现在具体能做什么？\n好课程要主动管理挫败感 新手最怕的不是难，而是不确定。\n不知道自己是否正常卡住，不知道该继续还是回退，不知道先查哪一层。\n所以课程设计里，应该把这些前置进去：\n预判高频卡点 给出最小排错路径 明确“哪些报错是常见且正常的” 把任务拆成可获得反馈的小步 课程设计很多时候不是“提高热情”，而是“避免放弃”。\n我自己踩过的几个坑 想讲得太全面，主线反而被稀释 低估上下文切换成本，一节课塞太多概念 把常见环境问题当作“学生自己去查” 模块之间缺少过渡，学生失去方向感 这些问题背后其实是同一个偏差：\n我站在“内容提供者”视角太久，没有持续站在“学习路径设计者”视角。\n最后一句话 做技术课程，表面上是在讲技术，实际上是在设计学习。\n真正好的课程，不是让学生觉得“老师懂很多”，而是让学生感到：\n这条路我走得下去，而且我真的在变强。\n","date":"2026.04.17","description":"技术课程的价值不在于讲了多少知识，而在于是否把学习者从起点带到终点。","section":"blog","slug":"/blog/how-to-design-a-good-tech-course/","title":"如何做好一门技术课程"},{"content":" 网页不是凭空出现的。浏览器里看到的东西，总得先来自某个地方。\n尝试做一个网页文件 首先，请看这四行命令：\ncd ~ mkdir zero-to-tech cd zero-to-tech touch index.html 相信经过上节课的学习，你已经可以看懂这四行命令了。\n它的意思就是在你的家目录下，新建一个文件夹 zero-to-tech，然后进入这个文件夹，在这个文件夹里新建一个 index.html 文件。\n如果你完全理解了这四行命令，接下来逐行执行它们。\n接着，我们需要在这个 index.html 中写入以下代码（千万别手敲，用复制和粘贴）：\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;zh-CN\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;我的第一个网页\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;你好，互联网\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;这是我的第一个网页，现在它还只是一个本地的 HTML 文件。\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 在前面的课程中，你已经学习了两种在文件中编辑代码的方式：Vim 和 VS Code。你可以任意选择一种方式，把上述代码粘贴进 index.html 代码文件中。\n无论你选择了什么方式，当你完成编辑并保存代码之后，你都可以尝试用下面这个命令，来查看 index.html 文件是否已经包含了上述代码：\ncat ~/zero-to-tech/index.html 如果终端里已经完整打印了上述代码，那么你已经完成了本课程至今第一份代码文件的编辑和管理。\n用浏览器打开它 刚才创建的这份 index.html 文件带有 .html 后缀，你的电脑会优先用 Web 浏览器打开它。有两种用浏览器打开 index.html 的方式，这两种方式我建议你都尝试一下。\n方式一 首先，我们在电脑上打开这个 zero-to-tech 文件夹，找到这个新建的文件。\n这里告诉你一个在 Mac 上用终端快速打开文件夹的命令：\nopen ~/zero-to-tech 你会看到访达 (Finder) 已经打开了这个文件夹，并且里面放着我们的 index.html 文件。接下来请你尝试双击这个文件，你会观察到，它会被浏览器打开。\n方式二 如果你愿意，也可以跳过访达这一步，而是在终端里通过下面这个命令，用浏览器打开 index.html：\nopen ~/zero-to-tech/index.html 如果你用的是 Windows，不一定能直接使用 open，那就按路径在文件管理器中找到 index.html 文件，并双击文件，或者右键选择用浏览器打开。\n打开以后，你会在浏览器里看到：\n一个有标题、有段落、有卡片的页面 页面里写着“你好，互联网” 页面里还写着“这是我的第一个网页\u0026hellip;”等文字 现在先把这件事看清楚：\n你在浏览器里看到的网页，来自你刚才保存的那个 index.html 文件。\n你刚才在 VS Code 或 Vim 里改的是代码文件。\n而浏览器里显示的是这个代码文件被读取、被解释之后的结果。\n第三部分：代码文件和网页是什么关系 现在你的电脑里同时存在两样东西：\n一个 index.html 文件 一个浏览器里打开的网页 它们不是同一个形态，但它们有直接关系。\n可以这样理解：\nhtml 文件是网页内容的源文件 浏览器负责读取这个文件，并把它显示成网页 而且你刚才复制进去的那些代码同时包含了：\n页面结构定义 文字内容 后面讲前端基础时，你会再回头拆开认识这些东西。\n到这里，网页这件事就不再那么抽象了。\n它完全可以只是你电脑里的一个文件。\n不同的电脑之间如何通过浏览器分享内容 现在把问题往前推一步。\n浏览器既然能打开你自己电脑里的 index.html，\n那它能不能打开其他电脑上提供的网页内容呢？\n其实，这正是你每天都在做的事。\n你平时访问一个网站时，它本质上就是：\n你的浏览器，正在通过网络访问另一台电脑上提供的内容\n真实的网站当然不一定只是一个单独的 html 文件，它还可能包含：\n其他代码文件 图片 视频 动态数据 但对当前这一节来说，先抓住这一层已经够用了：\n浏览器能打开本地网页文件，也能访问远程电脑提供的网页内容\n那台远程的电脑，通常就叫服务器 如果一台电脑愿意持续对外提供内容、接收请求、返回结果，\n那么在这个语境下，它通常就叫：\n服务器\n新手可能经常会对这里出现的“返回”这个词感觉到困惑，因为中文语境中这个词一般表示同一个主体去了一个地方再原封不动地回来。但是在计算机技术领域下，这个词一般表示的是服务端对客户端的“回复”行为。\n你可以把服务器理解成一台联网的电脑，只不过它的主要工作不是给某个人自己使用，而是对外提供服务。\n比如它可以提供：\n网页文件 图片 接口数据 登录入口 当我们打开一个网站，就是在访问远程服务器上提供的这些内容。\n浏览器访问网页时，要先找到那台服务器 如果网页内容在另一台电脑上，\n浏览器要做的第一件事，就是先找到那台电脑。\n现实世界里，你要去找一栋楼，需要地址。\n网络世界里，一台联网设备也需要地址。\n这个地址，通常就是：\nIP 地址\n我们可以把它理解成：\n一台联网设备在网络中的地址\n比如：\n120.55.66.77 这种地址对机器很方便，但对人不太友好。\n所以才会有域名 如果所有网站都靠人去记 IP 地址，会很麻烦：\n不好记 不好读 不好传播 换地址以后也很麻烦 所以互联网里又有了另一个东西：\n域名\n比如：\nbaidu.com github.com google.com 域名的作用，可以先理解成：\n给机器地址起一个更方便人记忆和输入的名字\n所以你平时在浏览器里输入的，通常不是 IP 地址，而是域名。\nDNS 负责把域名翻译成 IP 地址 到这里，一个问题会自然冒出来：\n你输入的是域名，但浏览器真正要找的是 IP 地址，那中间谁来负责转换？\n负责这件事的，就是：\nDNS\n我们可以先把 DNS 理解成：\n一个把域名翻译成 IP 地址的系统\n比如你输入：\ngithub.com 浏览器不会直接知道它对应哪台机器。\n它需要先查：\ngithub.com 对应的 IP 地址是什么？\n查到以后，浏览器才能继续往下访问。\n找到服务器以后，还要找到具体服务 现在浏览器已经通过域名和 DNS，找到了目标服务器。\n但事情还没结束。\n因为一台服务器上，不一定只跑一个服务。\n比如同一台服务器上，可能同时有好几个不同的服务在运行，就好像我们自己的电脑也可以同时做很多不同的事情。\n所以浏览器还要继续确认一件事：\n我这次到底要连这台电脑上的哪一个服务？\n这里就要引出另一个词：\n端口\n端口可以理解成一台电脑里的不同入口 什么是端口？你可以把它理解为：\n同一台电脑上，不同网络服务的不同入口。\n服务器在同一个 IP 地址上可以开启好多个端口。\n浏览器找到服务器以后，也还要知道：\n我应该连哪个端口，才能拿到网页内容？\n端口是用数字表示，比如后面我们会很频繁地聊的两个端口：\n80 443 这两个端口通常有默认的含义：\n80 常常和普通网页访问有关 443 常常和 HTTPS 加密访问有关 比如我们访问 http://xxxxx.com，就是在请求服务器的 80 端口；如果是访问 https://xxxx.com，那就是在访问服务器的 443 端口了。\n我们目前已经知道：\n服务器是一台电脑，端口是这台电脑上某个具体服务的入口。\n现在把整条链路连起来 到这里，可以把整件事重新说一遍了。\n当你在浏览器里输入一个网址并按下回车时，\n大致的流程是：\n你在浏览器里输入一个域名 浏览器先去查这个域名对应的 IP 地址 DNS 把这个域名对应的 IP 地址告诉浏览器 浏览器根据 IP 地址找到那台服务器 浏览器再通过某个端口访问这台服务器上的具体服务 服务器把网页内容返回给浏览器 浏览器把这些内容显示成你看到的网页 如果你能把这七步用自己的话说出来，\n那你对“输入网址后发生了什么”就已经不是完全黑盒了。\n请注意，上述这个链路整体而言有两个信息发送方向：一个是从用户开始，向服务器发送「请求」（request）；另一个是服务器收到「请求」之后，向用户浏览器返回对应内容作为回应（response）。\n而这个链路看起来很长，实际上有许多工作是不需要我们自己来做的，下一节我们会真正把这个过程走起来。\n这节课结束时，你至少应该做到什么 学完这一节，你至少应该做到下面几件事：\n知道浏览器不仅能打开网站，也能打开本地网页文件 知道 html 文件和浏览器里看到的网页之间是什么关系 知道访问网站时，本质上是在访问另一台电脑提供的内容 知道服务器可以先理解成一台联网的、对外提供服务的电脑 知道域名、IP、DNS、端口分别在这条链路里承担什么角色 能用自己的话大致解释“输入网址后发生了什么” 如果这些你已经具备了，那下一节进入服务器实践时，你会更容易理解自己到底在连接什么、配置什么、发布什么。\n← 上一节：模块 2.2 认识终端 | 下一节：模块 2.4 第一台服务器与第一个公网页面 →\n","date":"2026.04.12","description":"网页不是凭空出现的。浏览器里看到的东西，总得先来自某个地方。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-2-3/","title":"模块 2.3：互联网是怎么工作的"},{"content":" 终端本质上只是另一种和电脑交互的方式。\n终端到底是什么 在上一节里，我们主要是在图形界面里和电脑打交道：\n用文件夹看文件 用编辑器打开项目 用鼠标点击、拖动、双击 这些在日常使用中非常常用。\n但电脑并不只能通过图形界面操作。\n还有另一种方式是使用终端命令来操作。\n在 macOS 中使用终端 “终端”会默认安装在我们的 Mac 上，可以在应用程序启动台中找到它。\n双击即可进入终端，它打开之后的界面非常简单，只有一个空白窗口，等待着用户输入命令。\n这个输入命令的窗口，通常就叫：\n终端 命令行 CLI Terminal 这几个词在入门阶段你可以先大致当成同一类东西。\n它们的核心特点就是：\n你不再靠鼠标点来点去，而是直接告诉电脑“你现在要做什么”。\n比如：\n进入某个文件夹 看当前文件夹里有哪些文件 创建一个新文件 运行一个程序 连接一台远程服务器（也就是另外一台电脑） 后面这些动作，我们都会越来越频繁地通过终端完成。而且我相信你会爱上终端操作的掌控感。\n当然我也知道，很多人第一次看到终端，第一反应通常不是“这东西很强”，而是：\n这界面怎么密密麻麻只有一堆英文 敲命令看不懂，眼都要花了 我是不是一不小心就会把电脑弄坏 我明明会用电脑，为什么到了这里突然像不会了 实际上，终端的操作逻辑非常简单，你唯一需要克服的只是心理恐惧感，直面它，上手操作它，30 分钟就可以上手。\n为什么学软件开发要用终端 很多开发动作，本来就更适合在终端里完成。\n比如做下面这件事：\n在用户文件夹下创建 Projects 文件夹，再在 Projects 文件夹下创建一个 zero2tech 文件夹，再在 zero2tech 文件夹下创建 index.html 文件\n这样的操作，在终端可以用一行命令来完成：\nmkdir -p ~/Projects/zero2tech \u0026amp;\u0026amp; touch ~/Projects/zero2tech/index.html 你的浏览器暂不支持视频播放，可直接访问 MP4 文件 查看。 这些动作全靠图形界面当然也可以完成，但就是：\n更慢 更绕 更难描述 更难让别人远程帮你排查问题 更重要的是，当你操作的是一个远程服务器（设想你通过网络连接在控制另一台电脑）的时候，通过命令来操作，其效率远远高于远程桌面。\n尤其在 AI 时代，这一点会更明显。\n比如当你把终端里的命令、报错等贴给大语言模型时，它更容易理解你现在到底在做什么，以及你当前的环境和处境。\n所以终端不是一种“高手专属工具”。\n而是：\n后面整条开发路径里，最基础的一种工作方式。\n终端和文件、路径是什么关系 文件、文件夹、路径，这些上一节你已经接触过的那些概念，在终端操作的语境下，可能会换一种表达方式。\n比如：\n文件夹，在终端里通常叫目录 路径，还是路径 打开一个文件夹，常常会变成“进入某个目录” 查看文件夹下面的文件，常常会变成“列出当前目录里的文件” 换句话说：\n文件、路径的概念仍然没有变，但操作方式不一样，而且有些说法上的差异。\n先建立一个最重要的感觉 刚开始用终端时，你最需要先建立一个感觉：\n你现在正处在电脑里的哪个位置。\n因为终端里的很多命令，都会默认对“你当前所在的位置”生效。\n比如：\n你当前在哪个目录 你接下来创建的文件会出现在哪 你运行的命令会对哪个目录或者目录下的文件起作用 所以后面只要一迷路，就先问自己一句：\n我现在在哪个路径下？\n这句话和上一节的“我现在操作的是哪个位置”其实是同一件事。\n只是到了终端里，它会变得更重要。\n认识几个马上会用到的命令 先认识下面几个马上会用到的命令：\npwd：查看当前路径 ls：查看当前目录里有什么 cd：进入某个目录 mkdir：创建目录 touch：创建文件 cat：查看文本文件内容 rm：删除文件 rmdir：删除空目录 clear：清空终端画面 还有三个路径符号，这一节也会马上遇到：\n~：家目录 /：根目录 ..：上一级目录 比如：\ncd ~ 表示回到你的家目录。\ncd / 表示去根目录。\ncd .. 表示回到上一层。\n现在亲手做一遍 下面这组操作，建议你真的在终端里敲一遍。\n任务很简单：\n在家目录里创建一个练习目录，进去以后新建文件，编辑文件，查看文件，最后再把文件和目录删掉。\n这一套做完，终端最核心的一条闭环你就走过一遍了。\n第一步，找到自己现在在哪 先输入：\npwd 你会看到终端打印出一条路径。\n这条路径就是你当前所在的位置。\n接着输入（下面两行命令请逐行输入感受变化）：\ncd ~ pwd 第一行的效果是先回到家目录，第二行的效果是把它的完整路径打印出来。\n如果你用的是 macOS，大概率会看到类似：\n/Users/你的用户名 如果你用的是 Linux，常见会像：\n/home/你的用户名 这里先记住：~ 在终端里经常用来表示家目录。\n请注意这个符号是英文打字法下的 ~，而不是中文打字法下的 ～，~ 和 ～ 并不是同一个符号。\n第二步，看看当前目录里有什么 继续输入：\nls 你会看到当前目录里的文件和文件夹列表。\n这一步的感觉很重要：\npwd 是看我在哪，ls 是看这里有什么。\n如果你想顺手看一下根目录，也可以输入：\ncd / ls 你会看到系统最外层的一批目录。\n看完以后，再回到家目录：\ncd ~ 第三步，创建目录并进入目录 现在在家目录里创建一个练习目录：\nmkdir terminal-practice ls 这时候你应该能在列表里看到 terminal-practice。\n然后进入它：\n一个小 tips：敲下面的第一行命令时，尝试在输入 cd ter 之后按一下 Tab 键，终端会尝试自动补全后面的部分。\nTab 键也就是 ⇥ 键，在 Mac 自带键盘上一般位于 Q 键左侧。\ncd terminal-practice pwd 如果这一步成功，pwd 打印出来的路径最后一段通常会是：\nterminal-practice 这时候你也可以顺手试一下：\ncd .. pwd cd terminal-practice 这三行的意思是：\n先回到上一层 看看自己是不是回到了家目录 再重新进入练习目录 第四步，创建文件并查看效果 现在在 terminal-practice 里创建一个文件：\ntouch hello.txt ls 如果这一步成功，你会在 ls 的结果里看到 hello.txt。\n接着输入：\ncat hello.txt 此时大概率什么都不会显示。\n这不是报错，而是因为这个文件刚创建出来，里面还是空的。\n这一步也很重要，因为你已经感受到两件事：\ntouch 负责创建文件 cat 负责查看文本文件内容 第五步，用 Vim 往文件里写一点内容 现在我们来完成这一节最经典的一步：\nvim hello.txt 输入以后，你会进入 Vim。\n先别慌，按下面这组动作来：\n按键盘上的 i 输入一句话，比如 hello terminal 按 Esc 输入 :wq 按回车 如果一切顺利，你就会退出 Vim，回到终端。\n然后马上输入：\ncat hello.txt 如果你看到了刚才写进去的内容，说明你已经完成了：\n打开一个文件 进入编辑状态 写入内容 保存退出 回到终端查看结果 这一步很关键，因为这是你第一次在终端环境里真正改动一个文件。\n再练一次不保存退出 刚才你练的是保存退出。\n现在再练一次“不保存直接退出”，这样后面被困在 Vim 里时你心里会更稳。\n输入：\nvim hello.txt 然后按下面这组动作来：\n按 i 随便多输入几个字 按 Esc 输入 :q! 按回车 回到终端以后，再输入：\ncat hello.txt 如果你看到的内容还是上一次保存下来的那句，而不是刚才乱加的内容，说明这次“不保存退出”也成功了。\n到这里，Vim 最核心的生存动作你已经做过一遍了：\ni Esc :wq :q! 第六步，删除文件和目录 现在把刚才创建的内容删掉，完成这条练习链路的最后一段。\n先删除文件：\nrm hello.txt ls 如果删除成功，ls 的结果里就看不到 hello.txt 了。\n然后回到上一级目录，再删除练习目录：\ncd .. rmdir terminal-practice ls 如果删除成功，列表里也看不到 terminal-practice 了。\n到这里，这一套闭环就完整了：\n创建目录 进入目录 创建文件 编辑文件 查看文件 删除文件 删除目录 关于删除命令，要先有一点敬畏 rm 很常用，但也确实需要更小心一点。\n因为它不像很多图形界面那样，删除前还专门弹一个确认框。\n所以刚开始用删除命令时，习惯上先做两件事：\n先用 pwd 看清自己在哪 先用 ls 看清当前目录里有什么 确认无误，再删。\n这不是因为终端可怕，而是因为终端很直接。\n什么叫 Linux 直觉 这一节标题里有一个词，叫：\nLinux 直觉。\n它指的不是“学完这节你就会 Linux 了”。\n它更接近下面这种感觉：\n电脑里有一层一层的目录 你做的动作总是在某个路径下发生 很多开发工作，本质上是在处理文本文件 服务器通常没有漂亮桌面，很多时候只有终端 后面的服务器课程里，你会越来越频繁地感受到这一点。\n所以这一节其实是在提前适应后面的工作环境。\nWindows 用户怎么办 如果你正在使用 Windows，这一节依然可以学。\n只是你需要注意：\nWindows 与 macOS 的路径写法会不一样 Windows 上的一些命令和 macOS、Linux 不完全一致 如果你确实想要一个更接近课程主线的终端环境，可以考虑下面两个方案：\nGit Bash WSL 这一节先不展开这些工具的细节，先把“终端里的路径感和操作感”建立起来更重要。\n这一节最容易迷路的地方是什么 初学者在这一节最常见的迷路方式，通常有下面几种。\n1. 不知道自己当前在哪 命令敲了很多，但不知道当前路径是什么。\n这时候先输入 pwd。\n2. 目录切错了 很多时候不是命令失效，而是你站错了位置。\n这也是为什么 cd、..、~ 这么重要。\n3. 文件明明创建了，却找不到 通常不是文件消失了，而是它被创建在了你没注意到的位置。\n先看路径，再看目录列表。\n4. 进入 Vim 以后不会出来 这种事太常见了。\n所以一定要跟随课程亲手做一遍保存退出和不保存退出。\n这一节最应该建立的工作习惯 从这一节开始，尽量建立几个很简单但很重要的习惯。\n1. 一进终端先确认路径 先看自己在哪，再做下一步。\n2. 每做一步，都观察结果 比如敲完命令以后，立刻用 pwd、ls、cat 看变化。\n这样路径感和命令感会长得很快。\n3. 删除前多看一眼 尤其是 rm 这种命令，先确认路径，再确认目标。\n4. 多做几次完整闭环 创建、进入、编辑、查看、删除。\n这样的闭环重复几次，终端就不会再像黑箱。\n这节课结束时，你至少应该做到什么 学完这一节，你至少应该做到下面几件事：\n知道终端是另一种操作电脑的方式 知道终端和文件、路径之间的关系 知道 ~、/、.. 这几个路径符号分别在表达什么 能用 pwd、ls、cd、mkdir、touch、cat、rm、rmdir 完成一条最基本的操作链路 能在 Vim 里完成一次保存退出和一次不保存退出 不再把命令行视为完全陌生的黑箱 开始建立对 Linux 环境的最低直觉 如果这些你已经具备了，那我们即将进入下一章，了解互联网的本质。\n← 上一节：模块 2.1 认识你的电脑 | 下一节：模块 2.3 互联网是怎么工作的 →\n","date":"2026.04.11","description":"终端本质上只是另一种和电脑交互的方式。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-2-2/","title":"模块 2.2：终端与Linux直觉"},{"content":" 你当然会用电脑，但“会用软件”和“会在电脑里工作”，不是一回事。\n很多人第一次学编程时，卡住的并不是代码本身，而是这些问题：\n文件到底放在哪 文件夹和路径是什么关系 为什么别人说“路径”，自己脑子里没有画面 代码文件该用什么打开 为什么电脑总是报错说找不到文件 所以这一节我们也只做一件事：\n先建立最基本的电脑工作直觉。\n如果这一节建立起来了，后面学终端、写网页、用 Git，都会顺很多。\n先分清文件和文件夹 最基本的两个词：\n文件 文件夹 文件是“具体内容”。\n比如：\n一张图片是文件 一个视频是文件 一篇文档是文件 一段代码也是文件 文件夹不是内容本身，而是装这些内容的容器。\n比如你会看到这样的结构：\n我的第一个网站/ ├── avatar.png ├── index.html ├── script.js └── style.css 这里 我的第一个网站 是文件夹，里面那几个都是文件。\n后面你会越来越频繁地看到这种结构，因为一个项目本质上就是：\n一组放在同一个文件夹里的文件。\n什么是扩展名 文件名后面经常会有一个点：\nindex.html style.css script.js notes.md photo.png 点后面的那一部分，通常叫扩展名。\n入门阶段你可以先把它理解成：\n它在提醒你，这大概是什么类型的文件。\n例如：\n.mp3 是一种音频文件 .png 是一种图片文件 .html 是代码文件，常见于网页结构文件 .css 是代码文件，常见于样式文件 .js 是代码文件，常见于前端脚本文件 这一节不需要死记，但你要开始习惯看扩展名。\n因为它会直接帮助你分清：\n哪些是代码文件 哪些是图片 哪些是说明文档 请注意，默认情况下，Windows 和 macOS 都可能把常见扩展名隐藏掉。\n所以我建议你尽量让系统显示扩展名。这样你看文件时，不容易糊涂。\n什么是路径 如果说文件回答的是“这是什么”，那么路径回答的就是：\n这东西放在哪里。\n比如你有这样一个文件：\n课程练习/模块3/第一个网页/index.html 它的意思是：\n有一个叫 课程练习 的文件夹 里面有一个叫 模块3 的文件夹 里面又有一个叫 第一个网页 的文件夹 最里面有一个叫 index.html 的文件 这整串位置描述，就是路径。\n你可以把路径理解成地址。它不是随便写的一串字，而是在一层一层描述文件的位置。\n绝对路径和相对路径 后面你会反复遇到两种路径：\n绝对路径 相对路径 绝对路径 绝对路径就是：\n从一个固定起点开始，把完整位置说清楚。\n在 macOS 或 Linux 上，常见写法像这样：\n/Users/libo/Documents/course/index.html 在 Windows 上，常见写法像这样：\nC:\\Users\\libo\\Documents\\course\\index.html 它们长得不一样，但本质一样：\n这个文件在整台电脑里的完整位置。\n根目录 既然绝对路径要从一个固定起点开始说，那这个起点本身也值得认识一下。\n在 macOS 和 Linux 里，这个最外层起点常常写成：\n/ 它叫：\n根目录。\n你可以先把它理解成整套路径系统的最外层。\n所以像这样的路径：\n/Users/libo/Documents/course/index.html 最前面的 /，就在表示：\n我要从整个系统的最外层开始说这个文件的位置。\n在 Windows 里，常见写法不是 /，而是从某个盘符的根目录开始，比如：\nC:\\ 也就是说，在不同系统里，绝对路径的写法不完全一样，但它们都在表达同一件事：\n从一个固定起点开始，把位置完整说清楚。\n相对路径 相对路径就是：\n不从整台电脑开始说，而是从“你当前所在的位置”开始说。\n比如你已经在 course 这个文件夹里，那你可以直接写：\nindex.html 或者：\nimages/avatar.png 相对路径的最前面也可以写 ./，它的意思是“当前路径”，所以上面这两个相对路径还可以写做：\n./index.html 或者：\n./images/avatar.png 家目录是什么 除了根目录之外，还有一个后面会高频遇到的位置，叫：\n家目录。\n家目录可以先理解成：\n你这个用户自己的默认目录。\n在 macOS 上，它常常长这样：\n/Users/(你的名字) 假设你叫 libo，那么它就是：\n/Users/libo 在 Linux 上，它常常长这样：\n/home/libo 后面到了终端里，你还会经常看到一个符号：\n~ 它通常就是家目录的简写。\n所以你现在先建立一个感觉就够了：\n根目录是整个系统路径的最外层起点 家目录是你这个用户最常回到的那个目录 你可以先记一个最够用的判断：\n绝对路径：描述完整位置 相对路径：描述相对当前位置的位置 为什么后面你会反复遇到路径 路径不是这一节学完就结束了。后面很多地方都会用到它：\n用编辑器打开项目文件夹时，你在处理路径 在终端里进入某个目录时，你在处理路径 网页里引用一张图片时，你在处理路径 Git 管理哪些文件时，你也在处理路径 所以它不是附属知识，而是很多后续操作的共同底层。\n文件和软件，不是一回事 这里再分清另一组很容易混在一起的概念：\n文件 软件 文件是被保存的内容。\n软件是打开、查看、编辑、处理这些内容的工具。\n比如：\n.docx 是文件，Word 是软件 .mp3 是文件，QuickTime Player、QQ音乐播放器这类是软件 .png 是文件，图片查看器或图片编辑器是软件 不是所有软件都能处理所有文件。\n你先记住一句就够了：\n软件负责操作，文件负责承载内容，路径负责指路。\n那代码文件该用什么打开 后面我们会接触到：\n.html .css .js 这些也都是文件，它们就是代码文件，而打开和编辑这类代码文件的软件，通常叫：\n代码编辑器。\n这类软件有很多种，这门课里我们先用最常见、也比较适合入门的一种：\nVS Code\nVS Code 不是文件，它是软件。\n它比较擅长处理：\n代码文件 文本文件 配置文件 Markdown 文档 例如：\n.html .css .js .ts .json .md .py 这一节你不用研究编辑器的全部功能，只需要先把它当成后面课程的工作台。\n开始安装并认识 VS Code 到这里，VS Code 已经出现了。\nVS Code 是一个免费的代码编辑器软件，你可以在 VS Code 的官网下载并安装它：https://code.visualstudio.com\n入门阶段，你对 VS Code 的要求其实很低。你只需要会：\n打开一个文件夹 在左侧文件树里看到文件结构 新建文件 打开一个文件 修改并保存文件 能看懂自己当前正在编辑哪个文件 你可以在 B 站观看我的 VS Code 安装和使用课程。\n这一节最容易迷路的地方是什么 初学者在这里最常见的迷路方式，通常有下面几种。\n以为自己在改 A 文件，实际上改的是 B 文件 这很常见，因为电脑里可能会有多个名字很像的文件夹：\ncourse course-new course-final course-final-2 结果你以为自己改的是项目文件，实际上改的是另一个副本。\n只关注“文件名”，没有关注“文件在哪个文件夹里” 只知道有一个 index.html 不够，因为很多地方都可能有 index.html。\n真正重要的是：\n这个文件属于哪个项目文件夹。\n只会双击打开文件，不会打开整个项目文件夹 开发不是只看单个文件，而是经常要同时理解一组文件之间的关系。\n所以比“打开一个文件”更重要的是：\n打开整个项目文件夹。\n代码之间往往是相互引用和关联的，打开整个项目文件夹才能真正看到项目结构。\n这一节最应该建立的工作习惯 从这一节开始，尽量建立几个很简单但很重要的习惯。\n给课程练习准备一个固定总文件夹 比如：\nzero-to-tech 李勃老师的课程练习 fullstack-learning 名字不重要，重要的是固定。\n后面所有练习尽量都放在这个总文件夹下面，这样不容易越学越乱。\n每次先确认“我现在在哪个文件夹里/在哪个路径下” 不管你是用编辑器，还是后面用终端，都尽量先问自己一句：\n我现在操作的是哪个位置？\n这句话后面会非常有用。\n少制造很多“最终版”“最终版2”“最终版3” 初学阶段很容易这样保存：\nindex-final.html index-final-2.html index-final-real.html 短期像在保底，长期很容易把自己绕晕。\n更稳的方式是：\n用清晰的文件夹组织内容 用固定文件名 用 Git 管理版本 这也是为什么模块 3 会专门引入 Git。\n这节课结束时，你至少应该做到什么 学完这一节，你至少应该做到下面几件事：\n能解释文件和文件夹的区别 能大致理解扩展名在帮助你识别文件类型 能分清“文件”和“软件”不是一回事 能用自己的话解释什么是路径 知道绝对路径和相对路径的基本区别 能用 VS Code 打开一个项目文件夹，并找到里面的文件 如果这些你已经具备了，那下一节进入终端时，你就不会觉得自己是在面对一团完全陌生的黑盒。\n← 返回模块 2 | 下一节：模块 2.2 认识终端 →\n","date":"2026.04.11","description":"你当然会用电脑，但“会用软件”和“会在电脑里工作”，不是一回事。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-2-1/","title":"模块 2.1：认识你的电脑"},{"content":" 你可能会有一个“我接下来会怎么学，最后能做出来什么？”的问题，而这节课的任务，就是先把整条路线图交到你手里。\n学完这门课以后，你会得到什么 这门课的结果承诺不是“懂很多概念”。\n而是“做出一个完整结果”，在过程中理解必要的概念。\n当你认真学完，你至少会得到四个收获。\n第一，完整理解一个应用大概是怎么长出来的 电脑、终端、域名、前端、后端、部署这些词你可能听过，也可能完全陌生。\n就算听过，或许也是很散的点，大概知道一些概念，但不成体系。\n如果是这样，那么学完以后，它们会在你脑子里连成一条线。\n第二，真的做出一个完整结果 不是每节课写不同的练习，也不是抄几段代码。\n在课程结束时，你可以做出一个真正能运行、能访问、能上线的网站。\n并且，你可以在 AI 的帮助下继续发展它。\n第三，你和 AI 的关系会变 或许你之前一直听说 AI 很强，但不知道怎么把它变成自己的生产力。\n学完以后，你会更清楚：\n应该让 AI 帮你做什么 它给出的代码该放到哪里 怎么把它写的代码跑起来并发布出去 出问题时先查哪一层 怎样一步一步把它推进成真正的产品 第四，具备开始做任何产品的基础能力 这是这门课最终的目的，不是培养“会写代码的人”，而是更接近“能把产品做出来的人”。\n这门课最后会带你做出什么 这门课最后会带你做出一个：\n属于你自己的真实全栈网站。\n这个项目会有两部分：\n一个个人主页，用来展示技能、项目和个人介绍 一个文字实验室，用来体验一次真实的输入、处理和返回链路 你不需要写代码，并且只需要管理较少的代码文件，就可以得到一个：\n有前端页面 有后端接口 有真实交互 能在本地运行 能部署到云服务器 能绑定自己的域名 能通过 HTTPS 访问 它是一个真正走完“本地开发 → 联调 → 部署 → 上线”这条链路的结果。\n这么设计的原因是：这样的网站能最大程度地把开发中的常用概念串起来。\n这门课会怎么带你走 整个系列共包含 8 个模块，其中模块 1 和 8 都属于聊天类模块，所以真正与技术相关的是模块 2 到模块 7。\n模块 1，建立学习起点 模块 1 包含三节课：\n模块 1.1：为什么在 AI 时代还要学全栈开发（已完成） 模块 1.2：这门课是怎么安排的（本节） 模块 1.3：课前准备 模块 2，建立最小技术地图 虽然不会直接开始做我们的项目，但这个模块非常重要。它的目标是先把基础地图搭起来，有概念解释也有实操部分。\n并且，当这个模块结束时，你实际上已经拥有了一个网站，只是这个网站的内容会非常简单。\n你会先理解：\n文件、路径、编辑器、终端这些最基础的电脑概念 浏览器、域名、DNS、端口、服务器之间的关系 这一段的意义是：后面不管遇到前端、后端还是部署，都不至于完全失去方向。\n模块 3，做出第一个前端结果 这里会真正开始做项目，也就是开发个人主页。\n你会写出第一个静态网页，第一次用 Git 管理代码，并把静态页面部署到服务器上。\n这一段结束时，你已经拿到第一个真正可见、可访问的网页结果。\n并且任何人都可以访问它。\n模块 4，把项目升级成现代前端 当你已经知道静态页面是什么，就更容易理解为什么真实项目不会一直停留在原生 HTML、CSS、JS。\n这一段会引入 Node.js、npm、React、Next.js，把项目升级成更接近真实前端工程的形态。\n重点不是语法和框架学得多深，而是理解现代前端项目为什么这样组织。\n模块 5，理解 API，初识后端 我们当然不会止步于个人主页。在这个模块，我们会开始做一些有趣的功能。\n而当我们想要去做一些“功能”的时候，就很容易理解“为什么只有前端不够”。\n这一段会补上 API 和后端这半边：\n理解后端在解决什么问题 用 Python + FastAPI 写出第一个 GET 接口 让主页内容从接口读取，而不是写死在页面里 到这里，项目开始真正“活起来”。\n模块 6，感受生态的力量 这一段会让你第一次真实感受到：\n开发者并不是从零造一切，而是站在别人已经解决好的问题之上继续前进。\n如果 AI 时代是开发者获得的一次杠杆，那么第三方库就是上一次杠杆。我们会通过第三方库，完成一次真实的处理链路：\n接收输入 调库处理 写入数据 返回结果 同时，文字实验室页面也会在这一段完整成形。\n模块 7，走完真正的上线闭环 这一段会把整套项目真正部署到云服务器，接上域名和 HTTPS，并告诉你黑客可能会怎么攻击你的网站，以及你可以如何保护它。\n也会告诉你如何引导搜索引擎收录你的网站。\n到这里，整门课最核心的目标就完成了：\n你真的做出了一个可以公开访问的产品。\n模块 8，结课 最后这一段不再引入新的主线技术，而是帮你把视野拉开。\n你会看到：\n你现在在整个技术版图中的位置 网站和 APP 有哪些不同，又有哪些相同 这门课为什么选用现在这套技术栈 真实业务世界里还有哪些复杂度还没有纳入本课程 接下来可以怎样继续学，怎样继续和 AI 协作 你应该带着什么期待进入这门课 你知道乘坐高铁的具体步骤吗？\n现在想起来，似乎很简单。\n但是你需要知道在哪里买票，还需要按时去高铁站，需要过安检、候车、检票、上车、找座位、下车、出站。\n我打赌如果你第一次乘坐高铁时就是独自一人，熟悉这个流程肯定没那么轻松。\n网站开发本身不是一件难事，尤其是在有 AI 帮助写代码的情况下。\n但是，对于 0 基础的人来说，学开发往往并不轻松。我希望这节课能够像是带你“第一次乘坐高铁”一样地走完整个旅程。\n我不会告诉你在杭州东站哪里能借到充电宝，也不会告诉你广州南站去深圳有哪些快速通道。我只会带你走必要的流程。\n当你理解了这些流程，尽管以后遇到的高铁站布局各不相同，高铁型号也不一样，但你不会再害怕出门。\n你可以乘坐高铁去任何地方。\n← 上一节：模块 1.1 为什么在 AI 时代还要学全栈开发 | 下一节：模块 1.3 课前准备 →\n","date":"2026.04.03","description":"你可能会有一个“我接下来会怎么学，最后能做出来什么？”的问题，而这节课就是先把整条路线图交到你手里。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-1-2/","title":"模块 1.2：这门课是怎么安排的"},{"content":" 先回答一个问题：今天 AI 已经越来越会写代码了，人为什么还要学习全栈开发？\n什么是全栈开发 第一次接触这个词的话，可以先把它想得朴素一点。\n开发一个网站或者 app，并不是只有“做页面”这一件事。\n通常至少会有几层不同的东西：\n你在浏览器里看到的页面 页面背后拿数据、处理逻辑的部分 让这些东西真正跑起来、能够被别人访问的服务器和部署环境 也就是说，当我们想做一个真正能用的产品时，本来就不是只有一种工作。\n它天然就有分工，也天然就有不同的技术栈。\n有的人主要做页面。\n有的人主要做后端。\n有的人主要做服务器、部署和运行环境。\n而所谓全栈开发，说得简单一点，就是：\n你不只是会其中一个局部，而是能把这些部分连起来，最后真的做出一个可以运行、可以访问的产品。\n“全栈”不是“全能”。\n不是说你什么都要学到最深、最精。\n它更接近一种面向结果的能力：\n从前端页面，到后端逻辑，再到服务器和上线，你知道这些东西分别在干什么，也知道它们怎么接在一起。\n这也是为什么这门课不会把知识点拆成一堆互不相干的小块来讲。\n我们要的不是分别懂一点前端、懂一点后端、懂一点服务器。\n而是：\n真的走通一次从想法到产品的完整链路。\n都 AI 时代了，为什么还要学开发 这个问题很有讨论的价值，因为在当下 AI 编程大放异彩的时代，这是很多初学者都会有的疑问。\n我现在学的是不是很快就过时了 这些知识到底值不值得投入时间 以后是不是只要会用 AI 就够了 对于这些疑问点，我先说一下我的理解。\nAI 擅长写代码，但代码本身并不产生价值 用户只会为一个真正可使用、可访问、可运营的产品付钱，而不是代码。\n写代码这件事本身，并不直接产生价值。产品才产生价值。\n你可以把“会写代码”理解成“会开车”。\n那什么是产品？\n不是“我会开车”这件事本身。\n而是：\n我有一条深圳到广州、每天早上 7 点半发车的客运服务。\n这才是产品。\n一条客运服务真正有价值的地方，不仅在于“有人会把车开起来”，也在于：\n站点怎么设置 线路怎么规划 车怎么保养、加油、充电 超载和超重风险怎么管理 安全、准时、舒适怎么保障 整个服务怎么稳定运营 AI 可以自动驾驶，司机也可以比老板更会开车。\n但作为一个为结果负责的人，你真正关心的，从来都不是“我是否亲手握方向盘”，而是：\n这项服务能不能稳定成立，我如何管理这一切。\n软件开发也是一样。\n所以这门课不会把“手写代码能力”当成核心目标。\n我们学习的是：\n代码到底是什么 项目是怎么组织起来的 AI 写出来的代码该放到哪里 它怎么运行 它怎么被管理 它怎么被部署 它怎么真正变成产品 AI 会带来红利，但这红利不是自动发放的 我不想贩卖焦虑，但也不想把问题说得太轻松。\n大家都在说，AI 是时代红利。\n那问题是：\n如果你无法管理 AI 的产出物，这个时代红利和你有什么关系？\n这件事其实非常现实。\n每次有新的模型能力、AI 编程工具或者新的工作流出现，最兴奋、最活跃、最先把它变成生产力的人，往往都是那群本来就懂计算机系统的人。\n为什么？\n不是因为他们更会\u0026quot;提问\u0026quot;。\n而是因为他们更知道：\n这个东西能在哪个环节发挥价值 应该怎么接进现有项目 产出结果靠不靠谱 出错以后先查哪一层 当然，也有很多刚刚入行或准备入行的朋友会因此产生另一种焦虑：\n现在程序员都在面临裁员，那我现在学软件开发，是不是 「49 年入国军」？\n我觉得这种看法太被动了。\n如果你把自己放在“被替代者”的位置上，你看到的只有焦虑。\n但如果你把自己放在“经营者”的位置上，你会看到完全不同的东西。\n你会发现：\n以前你可能要每个月花大几万块雇几个人做的事 现在你可能每个月花几百块甚至几十块就能让 AI 帮你完成其中很大一部分 这难道不是巨大的时代红利吗？\n所以重点不是“AI 会不会干活”。\nAI 当然会干活。\n重点是：\n你有没有能力把它变成你的生产力。\n不会管理 AI 的人，看到的是冲击。\n会管理 AI 的人，看到的是杠杆。\n所以真正的问题是“代码拿来以后怎么办”\n对于一个 0 基础的人，如果你开始让 AI 帮你做一个什么产品，你会遇到这样一个局面：\n比如你对 AI 说\n“帮我做一个网站，要有首页、关于页、联系页，再加一个注册登录功能。”\nAI 很可能真的会给你一堆代码。\n但接下来，真正卡住你的通常是这些问题：\n这些代码该放在哪里 应该如何跑起来看到效果 怎么发布到互联网上 这就是为什么这门课强调的是“从代码到产品的完整链路”。\nAI 时代，世界给了我们学开发的机会 如果放在大语言模型大规模发展之前，很少有人敢认真对零基础的人说：\n我能在很短的时间里，带你从零走到全栈产品闭环。\n因为在那个时代，光是语法、框架、工具链本身，就足以把学习周期拉得很长。\n很多计算机专业学生，在学校里学了几年编程语言和各种课程，毕业之后也往往只是先进入某个具体岗位。\n而今天的情况变了。\nAI 把很多过去需要大量重复训练的部分压缩掉了。\n它没有让学习彻底消失，但它确实让路径第一次被缩短了。\n所以现在应该庆幸的是：\n“正是因为 AI 时代来了，普通人才第一次有机会 0 基础进入做一款产品” 这里我还想保留一个非常重要的区分：\n我们想培养的，不是程序员，而是工程师。\n程序员一般被理解成“写代码的人”。\n工程师更接近“把系统做出来，并且对结果负责的人”。\n在任何时代，后者都比前者重要得多。\n这门课教什么 放弃和 AI 比拼写代码的速度。\n这门课真正教的，是这些东西：\n一个 Web 产品到底由哪些部分组成 前端、后端、服务器、域名、HTTPS 是怎么连起来的 一个想法怎么被拆解成可以落地的结构 AI 写出来的代码应该怎么组织、运行、修改和上线 出问题时应该先查哪一层 怎样把一个想法真的做成一个能运行、能访问、能维护的产品 更准确地说：\n这门课是在教你，如何在 AI 已经会写代码的时代，拥有把想法做成产品的能力。\n下一节：模块 1.2 这门课是怎么安排的 →\n","date":"2026.04.03","description":"先回答一个问题：今天 AI 已经越来越会写代码了，人为什么还要学习全栈开发？","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-1-1/","title":"模块 1.1：为什么在 AI 时代还要学全栈开发？"},{"content":" 出来混最重要的是“出来”，所有需要的准备就是胸前那一个“勇”字。\n对零基础学习者来说，更容易把人劝退的，往往不是知识本身，而是下面这些更前置的问题：\n不确定自己的知识基础行不行 不确定自己的电脑设备行不行 安装工具出现报错不知道怎么回事 被一堆工具名词和平台名词搞乱 所以这一节我们只解决一件事：\n让你能够顺利开始，而不是在真正开始之前就被上述这些问题打断。\n本课程对知识基础的要求 下面列了几种情况，请你与自己的情况做对照，如果任意一种情况是符合的，那么你至少已经具备了学习本课程的基础要求了。\n我日常使用电脑学习/办公，需要在电脑上管理文件 我在中国参加过高考，接受过高等教育，用电脑打游戏、写课件或论文 我学过英语，对英文单词不惧怕，我有过至少一台自己的电脑 我有耐心和时间，我愿意去想去做，我很有动力 你需要有一台电脑，最好是 Mac 课程后面会真的有许多上手操作的环节，这要求你要有一台电脑。如果开始前没有准备好设备和环境，后面可能会被反复打断，并且失去一些上手练习的机会。\n那么需要什么样的电脑呢？\n如果你现在还没有开始正式学习，而且对电脑操作系统有选择空间，\n那么我非常明确地建议：\n优先选择 macOS。\n是的，或许 macOS 在某些领域表现得不如 Windows 更好，但当下如果我们打算做开发，我们更关心的是：\n这个操作系统，对后面的开发工作是否更友好。\n它单纯是出于课程学习路径的考虑。\n这个建议不只是因为讲师本人在使用 macOS。\n还有一个更重要的现实原因，AI 时代，终端操作 (CLI) 会更容易获得大语言模型的帮助，在后面的课程里，我们会用到大量终端操作，尤其是 Linux 的操作\n而 Windows 的终端体系有着一言难尽的复杂性，这一套复杂性不在我们课程的主线里。\n并且，我们的课程中有一部分操作是在云服务器上操作 Linux 命令，Windows 与 Linux 的路径表现形式和命令之间存在显著的差异。\n这些差异本身不是不能处理。\n但是：\n对这些障碍，我们在当前课程里并没有能力完整兜底。\n所以如果你有得选，macOS 会是更省心、更稳定的选择。\n它和后面课程中会接触到的终端、路径、开发环境，会更加一致。\n如果你已经在用 Windows 当然还能学。\n只是你需要提前有一个现实预期：\n后面遇到终端相关问题的概率会高一些 你会碰到一些课堂不会展开解释的平台差异 有时候你需要自己额外查资料，或者根据实际情况做一点转换 也就是说：\nWindows 不是绝对不行。\n但如果你现在处在“还没开始买电脑”或者“正准备专门为这门课配置学习设备”的阶段，\n那我会更推荐你直接选 macOS，这样后续路径会更顺。\n如果你有闲置电脑，可以考虑装一个 Linux 发行版 除了 macOS 和 Windows 之外，这里还可以给一个更偏进阶一点、但很有价值的建议。\n如果你手头刚好有一台闲置电脑设备，\n那么你也可以考虑：\n在这台闲置设备上安装一个 Linux 发行版，比如 Ubuntu，把它当成专门的学习机器。\n这样做有几个好处：\n你会更早接触到真实的 Linux 环境 后面学习终端、服务器、路径、权限这些概念时，会更有直觉 你会更容易理解“本地机器”和“远程服务器”之间的相似性 这条建议不是主线要求。\n如果你确实有闲置电脑，而且愿意把它变成一台专门的学习设备，\n那么 Linux 会是一个比较友好的起点。\n← 上一节：模块 1.2 这门课是怎么安排的 | 下一节：模块 2.1 认识你的电脑 →\n","date":"2026.04.01","description":"让你能够顺利开始，而不是在真正开始之前就被各种问题打断。","section":"zero-to-fullstack","slug":"/zero-to-fullstack/lessons/module-1-3/","title":"模块 1.3：课前准备"}]