web编程30年进化史
一个"在线人数"的 30 年进化史
从静态 HTML 到前后端分离,用最简单的例子讲透一切
引子:一个看似简单的问题
假设你是某网站开发者,老板对你说:
"页面上加一个'当前在线人数',显示现在有多少人在线。"
对用户来说,这只是页面上的一个数字。但为了显示这个数字,整个 Web 技术走了 30 年。
下面跟着这个数字,走完它的一生。
第一幕:1991-1993,静态 HTML 时代
你的代码
<!DOCTYPE html>
<html>
<body>
<h1>欢迎来到我的网站</h1>
<p>当前在线人数:5</p> <!-- 手写的 5 -->
</body>
</html>
逐行解释
<!DOCTYPE html>
告诉浏览器这是 HTML5 文件。
<p>当前在线人数:5</p>
这里的 5 是死的。 你现在打开看到 5,明天打开还是 5,永远不变。
为什么不行?
因为"在线人数"是动态的,每分钟都在变。静态 HTML 没有"变"的能力。
问题:怎么让页面上的数字变起来?
第二幕:1993,CGI 时代——第一次让页面"动"起来
核心思想
1993 年,NCSA 发明了 CGI(Common Gateway Interface)。它的思路是:
服务器遇到请求时,不光能返回静态文件,还能调用一个程序,把程序输出的内容当响应返回。
这是关键的一步:页面内容从"写死的"变成"程序生成的"。
你的代码(Perl 版)
文件放在 /cgi-bin/online.cgi:
#!/usr/bin/perl
# ============ 第一步:算出"在线人数" ============
my $count = 0;
open(my $fh, '<', '/var/data/online_users.txt') or die "打不开文件";
while (my $line = <$fh>) {
chomp($line);
$count++ if $line ne '';
}
close($fh);
# ============ 第二步:输出 HTTP 响应 ============
print "Content-Type: text/html\n\n";
print "<!DOCTYPE html>";
print "<html><body>";
print "<h1>欢迎来到我的网站</h1>";
print "<p>当前在线人数:$count</p>";
print "</body></html>";
逐行解释
my $count = 0;
初始化计数器。my 声明局部变量,$ 表示标量(单个值)。
open(my $fh, '<', '/var/data/online_users.txt') or die "打不开文件";
open打开文件<是"只读"模式or die "..."打开失败就报错退出
while (my $line = <$fh>) {
<$fh>每次从文件读一行while循环到文件末尾为止
chomp($line);
去掉行尾的换行符。比如 "1001\n" 变成 "1001"。
$count++ if $line ne '';
if $line ne ''如果这行不是空行$count++计数器加一- 这是 Perl 的后置
if写法,等价于if ($line ne '') { $count++; }
print "Content-Type: text/html\n\n";
关键行。 这行输出一个 HTTP 响应头:
Content-Type: text/html告诉浏览器响应体是 HTML\n\n是两个换行,表示头结束了,后面开始是响应体
print "<p>当前在线人数:$count</p>";
把 $count 的值(比如 5)拼进字符串。这就是"服务器动态生成 HTML"。
发生了什么?
浏览器 → GET /cgi-bin/online.cgi
Apache → 启动 Perl 解释器
Perl → 打开文件、数行数、拼 HTML
Perl → 把 HTML 写到 stdout
Apache → 把 stdout 当 HTTP 响应发回
浏览器 → 渲染 <p>当前在线人数:5</p>
这时代码和 HTML 混在一起,但这是第一次"页面能变"。
第三幕:1995,PHP 混排时代——你熟悉的起点
核心思想
PHP 让"程序代码"和"HTML"能写在一个文件里,比 Perl CGI 更自然。
你的代码(online.php)
<?php
// ============ 第一部分:算数据 ============
$lines = file('/var/data/online_users.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$count = count($lines);
?>
<!DOCTYPE html>
<html>
<body>
<h1>欢迎来到我的网站</h1>
<p>当前在线人数:<?php echo $count; ?></p>
</body>
</html>
逐行解释
<?php
告诉服务器:这之后是 PHP 代码,需要执行。
$lines = file('/var/data/online_users.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
file()一次读所有行到数组FILE_IGNORE_NEW_LINES去掉每行的换行符FILE_SKIP_EMPTY_LINES跳过空行$lines变成['1001', '1002', '1003', '1004', '1005']
$count = count($lines);
count() 数数组元素个数。$count = 5。
?>
结束 PHP 代码。后面的内容原样输出给浏览器。
<p>当前在线人数:<?php echo $count; ?></p>
<?php ... ?>又切回 PHP,输出$count的值- 结果:
<p>当前在线人数:5</p>
这一段 PHP 代码的本质
如果你在浏览器"查看源代码",看到的只是:
<p>当前在线人数:5</p>
PHP 代码已经被服务器执行完,浏览器收到的是执行结果。
问题
每次打开页面,整个 HTML 重新生成一遍,整个页面刷新一次。体验不流畅。
第四幕:2005,Ajax 时代——局部刷新
核心思想
2005 年,Ajax 技术普及。让 JS 只请求数据,然后手动更新页面的一小块。
不再让服务器返回整个页面。
拆成两个文件
文件 1:count.php(只返回数字)
<?php
// 只返回一个数字,不返回 HTML
$lines = file('/var/data/online_users.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
echo count($lines);
文件 2:index.html(页面 + JS)
<!DOCTYPE html>
<html>
<body>
<h1>欢迎来到我的网站</h1>
<p>当前在线人数:<span id="count">--</span></p>
<script>
// 每 5 秒自动刷新一次
setInterval(async () => {
// 向 count.php 发 GET 请求
const res = await fetch('count.php');
// 把响应当纯文本读取
const text = await res.text();
// 更新页面上 id="count" 的那个 span
document.getElementById('count').textContent = text;
}, 5000);
</script>
</body>
</html>
逐行解释
echo count($lines);
- PHP 只输出一个数字(比如
5) - 不输出 HTML,没有任何标签
- 响应体就是字符串
"5"
<p>当前在线人数:<span id="count">--</span></p>
<span id="count">给一个位置占位- 初始显示
--,等 JS 填充
setInterval(async () => {
setInterval 每隔一段时间执行一次函数。
const res = await fetch('count.php');
fetch向count.php发 HTTP 请求await等请求完成res是响应对象
const text = await res.text();
res.text()把响应体读成字符串- 这里是
"5"
document.getElementById('count').textContent = text;
- 找到页面上 id 为
count的元素 - 把它的文字内容改成
"5"
关键点
- 页面不刷新。只更新了
<span>里的文字。 count.php只返回数据("5"),不返回 HTML。- 浏览器和服务器之间传来传去的,只是纯数据。
这就是"前后端分离"的雏形。
第五幕:2006+,JSON 时代——数据不再只是数字
需求升级
老板说:"不仅显示在线人数,还要显示最多人用的浏览器、平均停留时间。"
一个数字不够了,需要返回一个结构化的对象。
文件 1:stats.php(返回 JSON)
<?php
// 读文件
$lines = file('/var/data/online_users.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// 假装有一些统计数据
$stats = [
'online' => count($lines),
'browser' => 'Chrome',
'avg_time' => 320,
];
// 告诉浏览器:我返回的是 JSON,不是纯文本
header('Content-Type: application/json; charset=utf-8');
// 把 PHP 数组转成 JSON 字符串输出
echo json_encode($stats, JSON_UNESCAPED_UNICODE);
逐行解释
$stats = [
'online' => count($lines),
'browser' => 'Chrome',
'avg_time' => 320,
];
定义一个 PHP 关联数组,相当于 JS 里的对象。
header('Content-Type: application/json; charset=utf-8');
关键行。 告诉浏览器:"我返回的响应体是 JSON 格式"。浏览器拿到后能自动识别。
echo json_encode($stats, JSON_UNESCAPED_UNICODE);
json_encode把 PHP 数组转成 JSON 字符串- 结果:
{"online":5,"browser":"Chrome","avg_time":320} JSON_UNESCAPED_UNICODE防止中文被转义
文件 2:index.html(前端请求 + 渲染)
<!DOCTYPE html>
<html>
<body>
<h1>欢迎来到我的网站</h1>
<p>当前在线人数:<span id="count">--</span></p>
<p>最多浏览器:<span id="browser">--</span></p>
<p>平均停留:<span id="avg">--</span> 秒</p>
<script>
async function loadStats() {
// 1. 发请求
const res = await fetch('stats.php');
// 2. 解析响应为 JS 对象
// res.json() 内部做了:JSON.parse(响应字符串)
const data = await res.json();
// 3. 渲染到页面上
document.getElementById('count').textContent = data.online;
document.getElementById('browser').textContent = data.browser;
document.getElementById('avg').textContent = data.avg_time;
}
loadStats(); // 打开页面立即加载
setInterval(loadStats, 5000); // 每 5 秒刷新
</script>
</body>
</html>
逐行解释
const res = await fetch('stats.php');
向 stats.php 发请求。
const data = await res.json();
res.json()把响应体当 JSON 解析- 内部逻辑等价于:
JSON.parse('{"online":5,"browser":"Chrome","avg_time":320}') data变成 JS 对象{online: 5, browser: 'Chrome', avg_time: 320}
document.getElementById('count').textContent = data.online;
- 从
data里取online字段 - 更新对应的 DOM 元素
关键点
数据格式从"一个数字"进化到"一个 JSON 对象"。
这是现代前后端的标准做法。JSON 成为前后端的通用语言。
第六幕:现代——前后端分离
项目结构
wwwroot/demo/
├── index.html ← 前端页面
└── api/
└── stats.php ← 后端 API
后端:api/stats.php
<?php
// ========== 1. 设置响应头 ==========
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *'); // 允许跨域(开发时)
// ========== 2. 读数据 ==========
$lines = file('/var/data/online_users.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// ========== 3. 组织结果 ==========
$result = [
'code' => 200,
'data' => [
'online' => count($lines),
'browser' => 'Chrome',
'avg_time' => 320,
],
];
// ========== 4. 输出 JSON ==========
echo json_encode($result, JSON_UNESCAPED_UNICODE);
逐行解释:
header('Content-Type: application/json; charset=utf-8');
告诉浏览器返回 JSON。
header('Access-Control-Allow-Origin: *');
CORS 头。 允许任何域名跨域请求这个 API。开发时用 *,生产要改成具体域名。
$result = [
'code' => 200,
'data' => [...],
];
标准化响应结构。前端永远先看 code,再看 data。
echo json_encode($result, JSON_UNESCAPED_UNICODE);
输出 JSON 字符串。
前端:index.html
<!DOCTYPE html>
<html>
<body>
<h1>欢迎来到我的网站</h1>
<p>当前在线人数:<span id="count">--</span></p>
<p>最多浏览器:<span id="browser">--</span></p>
<p>平均停留:<span id="avg">--</span> 秒</p>
<script>
async function loadStats() {
try {
// 1. 发请求到后端 API
const res = await fetch('/api/stats.php');
// 2. 检查 HTTP 状态码
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
// 3. 解析响应为 JS 对象
const json = await res.json();
// 4. 检查业务状态码
if (json.code !== 200) {
throw new Error(json.msg || '未知错误');
}
// 5. 渲染
const data = json.data;
document.getElementById('count').textContent = data.online;
document.getElementById('browser').textContent = data.browser;
document.getElementById('avg').textContent = data.avg_time;
} catch (e) {
console.error('加载失败:', e.message);
}
}
loadStats();
setInterval(loadStats, 5000);
</script>
</body>
</html>
逐行解释
if (!res.ok) {
res.ok 是布尔值。HTTP 状态码在 200-299 之间时为 true。否则为 false。
throw new Error('HTTP ' + res.status);
throw主动抛出异常new Error(...)创建错误对象- 会被下面的
catch捕获
if (json.code !== 200) {
业务层面的检查。 HTTP 状态码是 200(传输成功),但业务层可能返回"用户未登录"之类的错误码。
const data = json.data;
从响应里取出 data 字段。前端和 PHP 约定好:数据永远在 data 字段里。
} catch (e) {
console.error('加载失败:', e.message);
}
捕获任何错误(网络错误、解析错误、业务错误),打印到控制台。
关键点
- 后端只管数据:读文件、组织 JSON、输出。
- 前端只管显示:请求、解析、渲染、错误处理。
- 双方约定:URL 是
/api/stats.php,返回格式是{code, data, msg}。
这就是前后端分离:两个文件,两套代码,一份合同。
第七幕:数据从哪来?——"5"的一生
前面所有例子都依赖 /var/data/online_users.txt。这个文件是怎么来的?
真相:每次用户请求时,被一点点写进去的
写文件的代码(login.php 或其他任何用户请求的页面):
<?php
// 假设用户已登录,用户 ID 是 1001
$userId = 1001;
// 把用户和时间戳追加到文件
// time() 是当前 Unix 时间戳
file_put_contents(
'/var/data/online_users.txt',
$userId . ':' . time() . "\n",
FILE_APPEND
);
逐行解释:
$userId = 1001;
用户 ID。真实场景从 session 或 token 里取。
time();
PHP 内置函数,返回当前时间戳(从 1970-01-01 00:00:00 UTC 到现在的秒数)。
$userId . ':' . time() . "\n";
.是 PHP 字符串拼接- 结果是
"1001:1735689600\n"
file_put_contents(..., FILE_APPEND);
file_put_contents写文件FILE_APPEND标志:追加到文件末尾,不覆盖
清理过期用户
<?php
// clean_online.php,由 cron 每分钟执行
$lines = file('/var/data/online_users.txt', FILE_IGNORE_NEW_LINES);
$keep = [];
foreach ($lines as $line) {
// 按冒号拆分:"1001:1735689600"
[$uid, $last] = explode(':', $line);
// 如果 5 分钟内有活动,保留
if (time() - (int)$last < 300) {
$keep[] = $line;
}
}
file_put_contents('/var/data/online_users.txt', implode("\n", $keep) . "\n");
逐行解释:
[$uid, $last] = explode(':', $line);
explode(':', '1001:1735689600')拆成['1001', '1735689600'][$uid, $last] = ...数组解构,把两个值分别赋给两个变量
if (time() - (int)$last < 300) {
(int)$last把字符串转成整数time() - $last是"上次活动距今多少秒"< 300表示 5 分钟(300 秒)内还有活动
$keep[] = $line;
把这一行加入"保留数组"。
file_put_contents(..., implode("\n", $keep) . "\n");
implode("\n", $keep)把数组拼成多行字符串- 写回文件
时间线
时刻 触发 文件内容
──────────────────────────────────────────────
10:00 空文件 (不存在)
10:01 用户 1001 登录 "1001:1735689660"
10:03 用户 1002 登录 "1001:1735689660
1002:1735689780"
10:05 用户 1001 活动 "1001:1735689900" ← 时间戳更新
1002:1735689780"
10:10 清理脚本跑 "1001:1735689900" ← 保留
(1002 被删,因为超 5 分钟)
结论:文件不是"有人一次生成"的,是每一次用户请求一点一点追加的。
第八幕:为什么能"分离"?——五种语言的同一件事
同样的"读文件、返回 JSON",用不同语言写:
| 语言 | 读文件 | 转 JSON | 输出 |
|---|---|---|---|
| PHP | file() |
json_encode() |
echo |
| C | fgets() |
手动拼接字符串 | printf |
| Go | os.Open() + Scanner |
json.Marshal() |
w.Write() |
| Python | open() |
json.dumps() |
wfile.write() |
| Node.js | fs.readFile() |
JSON.stringify() |
res.end() |
看起来完全不同,但底层做的是同一件事:
- 读数据:打开文件,读内容
- 算结果:数行数,算在线人数
- 序列化:把结果变成 JSON 字符串
- 写字节:把字符串写入 HTTP 响应
浏览器的感受完全一样。 它收到的永远是:
HTTP/1.1 200 OK
Content-Type: application/json
{"code":200,"data":{"online":5}}
前后端能分离的根本原因:所有语言最终都产生相同的 HTTP 字节流。
第九幕:核心原理总结
1. HTTP 就是字节流
HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
\r\n
{"code":200,"data":{"online":5}}
- 状态行、响应头、空行、响应体
- 全部是字节
- 任何语言写后端,本质上都是"把字节写进 socket"
2. 数据不是"存在"的,是"算出来"的
- "在线人数 5"不是服务器"知道"的
- 是每次请求时,程序打开文件、读内容、数有效行算出来的
- 数据源(文件、数据库)也是用户请求时一点点写进去的
3. 前后端的"合同"
双方约定:
- URL:
/api/stats.php - 方法:
GET - 请求格式:(GET 无请求体)
- 响应格式:
{code: 200, data: {online: 5}}
只要遵守合同,前端用 Vue、React、原生 JS 都行;后端用 PHP、Go、Python 都行。
4. 分离的三个层次
| 层次 | 内容 |
|---|---|
| 文件分离 | index.html 和 stats.php 是两个文件 |
| 职责分离 | 前端管显示,后端管数据 |
| 部署分离 | 前端可以放 CDN,后端放服务器;独立升级 |
第十幕:从静态到分离的完整时间线
1991 静态 HTML → 手写"当前在线 5"(永远不变)
1993 CGI → Perl 读文件、拼 HTML(页面能变)
1995 PHP 混排 → PHP + HTML 写一起(更好写)
2005 Ajax → 只请求数据、局部刷新(不刷新页面)
2006 JSON → 数据从数字进化到对象
2010 SPA 框架 → Vue/React 独立渲染页面
2015 前后端分离 → 前端项目 + 后端项目,独立部署
2020 微前端 / BFF → 前端再分化,后端加聚合层
现在 你在做的事 → Vue3 + PHP API
每一次演进,都是因为旧方案遇到了瓶颈。
- 静态 HTML → 页面不能变 → CGI
- CGI 难写 → PHP 混排
- 混排不流畅 → Ajax
- Ajax 只返回数字 → JSON
- 页面逻辑爆炸 → 前端框架
- 前后端耦合 → 分离
结语:从"5"看到整个 Web
现在你再看到页面上"当前在线人数:5"这个数字,应该能想到:
- 用户行为:某个人打开了网页(HTTP 请求)
- 后端写入:
login.php把用户和时间戳追加到文件 - 数据存储:文件
/var/data/online_users.txt一行行累积 - 定时清理:
clean_online.php删掉超时用户 - 后端查询:
stats.php数出有效行数 - 序列化:
json_encode转成 JSON 字符串 - 字节输出:通过 HTTP 响应发给浏览器
- 前端接收:
fetch收到 JSON 字符串 - 前端解析:
res.json()转成 JS 对象 - 前端渲染:
document.getElementById('count').textContent = 5
这个数字不是"存在"于某个地方的,它是整条链路上所有程序协作的产物。
这就是 Web 的本质:数据的采集、存储、查询、传输、渲染。前端和后端不是两个孤立的系统,是同一个数据流的两端。
理解了这一点,无论用什么语言,你都能看懂它背后的逻辑。

浙公网安备 33010602011771号