Old-School Front-End / 2026 ・ No.15
古法前端入门
第15讲(最终讲) 收尾 ── 用工具检查,加固防守,发布上线,站到框架的门口
发布
不靠 AI,一行一行亲手写的“古法”前端入门。最后一讲是收尾。
把花了 15 讲做出来的报销系统用工具检查一遍,发现的问题当场修好。
接着加上万一有漏洞时的防守,整理发布的方法。
最后,把同一个明细表用 Vue 写一遍,从框架的门口往里看一看。
最后一讲的目标
新井前辈,所有页面都做好了!能申请、能审批、能汇总,还能打印。这就算完成了吧?
古井是“能跑了”。完成,是指能交给别人用的状态。检查、加固防守、送出去。
新井检查,是用眼睛一个个看吗?
古井眼睛会累,也会看漏。能交给工具的就交给工具。古法的工匠也是用尺子的。
当前位置:第15讲(最终讲)
- 检查 ── 用工具数出 HTML 的问题、读屏・操作的易用性、页面的重量。发现了就修
- 加固防守 ── 不运行没写过的脚本(CSP),不让别的网站套框,发现借来的文件被调包(SRI)
- 了解发布方法 ── 页面文件放哪里,API 放哪里。发布前要做的事
- 站到框架的门口 ── 用 Vue 写第8讲的明细表,确认什么变轻松了,又多需要了什么
用工具检查
这个连载每一讲都用 Nu Html Checker(vnu)检查了 HTML。最后,把登录・申请列表・申请表单・审批・仪表盘这 5 个页面一起检查,再用 axe-core(4.14.0)检查读屏・操作的易用性。检查结果汇总在一个表格页面里。
# HTML 检查(Nu Html Checker。三种语言都一样) 页面 错误 login.html 0 index.html 0 form.html 0 approve.html 0 dashboard.html 0 vue.html 16 ← Vue 模板的属性(v-for・:key・@click 等)
HTML 5 个页面都没有错误。vue.html 的 16 处,是第八节要写的 Vue 页面的事,稍后再说。axe-core 那边,发现了看漏的地方。
# axe-core 指出的地方(color-contrast。修改前的值来自 axe-core 的记录,修改后用同一公式计算) 部件 文字颜色 背景 修改前 修改后 页头的菜单和名字 #9aafbc → #d2dce1 #1f4e6b 3.91:1 6.38:1 灰色按钮(返回・保存草稿・打印) #6c757d → #5c636a #f8f9fa 4.44:1 5.78:1 # 指出的数量(5 个页面合计) 修改前 14 处 → 修改后 0 处(三种语言都是)
修改的两处。眼睛看得清,数字却不够
第4讲写过“文字和背景的对比度要 4.5:1 以上”,可页头的文字只有 3.91:1。因为直接用了 Bootstrap 深色导航栏的默认颜色(白色的 55%)。灰色按钮也是 4.44:1,差一点点。两处都各改写了两行 CSS 变量来修好。
/* 第15讲:让文字和背景的对比度达到 4.5:1 以上(按 axe-core 的指出修改) */
.brand-bar {
--bs-navbar-color: rgba(255, 255, 255, .8); /* 页头的文字(原来是白色的 55%) */
--bs-navbar-hover-color: #fff;
}
.btn-outline-secondary {
--bs-btn-color: #5c636a; /* 灰色按钮(原来是 #6c757d) */
--bs-btn-border-color: #5c636a;
}
修改后,三种语言的 5 个页面,指出的问题全部变成 0。另外,在走完日常操作的浏览器记录里,有 1 个 404。浏览器为了找标签页的图标去取 /favicon.ico,而它不存在。在所有页面的 <head> 里加上 <link rel="icon" href="img/logo.svg">,就变成了 0 个。
新井看了 14 讲的页面,居然还有问题啊。
古井人眼越熟悉的东西越看不见。所以用工具。不过,工具能发现的只是一部分。只用键盘能不能用到最后、读屏时意思通不通,要人来试(就像第8讲・第12讲做的那样)。
称一称重量
数了打开申请表单时加载的文件,再用 Chromium 的覆盖率,测量实际操作(检查・添加和删除行・手机宽度的菜单)时用到了多少。
# 打开申请表单时加载的文件(字节),以及操作时实际用到的比例(Chromium 的覆盖率。在日文版上测量) 文件 字节 gzip 用到的 form.html 7,686 2,516 css/custom.css 2,885 1,485 30% vendor/bootstrap/css/bootstrap.min.css 232,111 30,869 11% vendor/bootstrap/js/bootstrap.bundle.min.js 80,496 23,743 22% js/app.js 2,100 1,183 61% js/auth.js 1,646 1,031 54% js/validate.js 3,186 1,592 53% js/form.js 9,423 3,927 70% img/logo.svg 356 235 合计(9 个文件) 339,889 66,581
- 总共约 342KB。其中 232KB 是 Bootstrap 的 CSS,用到的只有一成左右
- 自己写的 JavaScript(app.js・form.js 等)用到了一半以上。没用到的,是只在出错或选择美元时才运行的部分
- 用 gzip 发送的话,总共约 67KB。压缩通常由发布目的地的分发机制代劳(第七节)
要削减没用到的 CSS,可以用 Sass 只组装需要的 Bootstrap 部件,或者用工具去掉没用到的规则。这两种都是转换文件的构建工具的工作。这里正是“只靠手写往前走”和“组合工具”两条路的分岔口(第八节)。像报销这样的内部页面,342KB 也足够轻,所以这次没有削减。
防守①:拦下没写过的脚本
第8讲定下了规矩:别人写的文字用 textContent 放进去,不放进 innerHTML。第14讲看到了放进 jQuery .html() 的脚本真的会运行。堵住漏洞,靠的是代码的写法。可是,万一有一处漏了呢?这时的第二道防线就是 CSP(Content Security Policy)。
把“这个页面上可以运行什么”的约定,和页面一起交出去
CSP 由服务器加在回复的响应头里。练习用服务器加了下面这个函数。
// 第15讲:防守用的响应头。页面和 API 的回复都加上(S.secure 设为 0 就不加。为了比较)
function secureHeaders(res, p) {
if (!S.secure) return;
const evalOk = S.cspEval && p === '/vue.html'; // 只对不构建的 Vue 页面,允许用字符串生成程序(为了比较)
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'" + (evalOk ? " 'unsafe-eval'" : ''),
"style-src 'self'",
"img-src 'self' data:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; '));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
}
- script-src 'self' ── 脚本只能是这个网站的文件。直接写在 HTML 里的脚本、onclick="…"、用字符串生成程序的 eval 都不能运行
- style-src 'self' ── 样式也只能是这个网站的 CSS 文件。写在 HTML 里的 style="…" 不生效(JavaScript 的 el.style.width = … 生效)
- img-src 'self' data: ── Bootstrap 的 CSS 把复选框等的标记做成 data: 图片,所以允许它
- frame-ancestors 'none'、nosniff ── 见第五节
这个连载从一开始就把脚本和样式分开写在文件里,所以加上响应头后没有要改写的地方。实际用三种语言走了一遍提交申请・审批・打开仪表盘,一次违规都没有。我们分别在没有和有响应头的情况下,试了插入。
# 确认防守效果(Chromium 实测。左边没有响应头,右边有) 试了什么 没有响应头 有响应头 用 innerHTML 插入的 img 的 onerror 被执行了 被拦下(script-src-attr) 在别的网站(:3001)的 iframe 里打开 能打开 被拒绝(frame-ancestors) SRI:正确的 integrity 加载了 加载了 SRI:差一个字符的 integrity 报错(不加载) 报错(不加载) 日常操作(申请・审批・仪表盘)中的违规 – 0 个(三种语言)
用 innerHTML 插入的 <img onerror>,没有响应头时执行了,有时被拦下(违规类型是 script-src-attr,也就是“写在属性里的脚本”)。顺带一提,检查用的 axe-core 也是插入页面的脚本,同样被 CSP 拦下。只有在用工具时,才在浏览器的设置里关掉 CSP 来检查。就是这么管用。
防守②:不让套框・不让调包
不让套框。有一种手法:坏网站用透明的框(iframe)盖住审批页面,让人以为按的是“领取礼物”,实际按下了“批准”(点击劫持)。加上 frame-ancestors 'none',页面就不会在别人的框里打开。如上表,从别的网站(端口 3001)用框打开,没有响应头时能打开,有时被拒绝。
不让乱猜内容。X-Content-Type-Options: nosniff 会阻止浏览器把声明为“不是 JavaScript”的文件,自己看了内容就当作 JavaScript 运行。
发现调包。这个连载把 Bootstrap 和 jQuery 都放在自己的网站上,但从 CDN 加载的情况也很多。万一 CDN 上的文件被改写了呢?SRI(Subresource Integrity)就是把文件的“指纹”(哈希)写在 HTML 里,不一致就不运行的机制。
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js" integrity="sha384-fgGyf7Mo7DURSOMnOy7ed+dkq5Job205Gnzu6QIg0BOHKaqt4D76Dt8VlDCzcMHV" crossorigin="anonymous"></script>
这个值是从 npm 上 jQuery 4.0.0 的发布文件计算出来的。值正确就加载,只改一个字符就报错,不运行(见上表)。文件升级了版本,指纹也要重写。
防守③:页面守不住的东西
到这里为止的防守,都是请浏览器“遵守约定”。可是页面的 JavaScript 在用户手边运行,谁都能看,谁都能改。把这个连载反复说的“守护靠服务器”整理成表。
页面能做的事,和只有服务器能做的事
- 输入检查,页面上是引导,服务器上才是正式的(第9讲)。直接发 fetch 就能绕过页面的检查
- 登录和权限(第11讲)、同时按下时的 409(第12讲),只有服务器能判断
- 密码在服务器上不原样保存(做成哈希)。外部服务的密钥不写在页面的 JavaScript 里。写进去的那一刻,就等于分发给了所有人
新井我让 AI“把 API 密钥放进去,从页面上调用”,它就直接写进 JavaScript 了。
古井它是照你说的写的。放在哪里,该由提要求的人决定。
发布
手写页面的好处是,文件原样放上去就能运行。没有转换的步骤。你正在读的这个博客,也是把 HTML、CSS 和图片放在分发静态文件的服务(Cloudflare Pages)上发布的。
页面文件放到静态托管,API 放到服务器
- https ── 给登录的 Cookie(第11讲)加上 Secure,只通过加密的通信发送。练习用服务器是 http,所以没加
- 防守用的响应头 ── 在静态托管服务上,用配置文件代替服务器代码来加。比如 Cloudflare Pages 可以写在名为 _headers 的文件里
- 缓存 ── 浏览器和 CDN 会记住同名的文件。改了内容,就像 form.js?v=2 或 form.2.js 这样把名字也改了,就不会用到旧的
- 删掉后门 ── 练习用的 /__ctl 和演示用的账号・密码,不要带到正式环境
框架的门口
最后,用 Vue 3(3.5.44)写一遍第8讲的“明细行的添加・删除和合计”。React、Vue 这样的框架,是如今工作中常用的工具。有什么不同呢?
从“怎样改页面”,到“数据和页面的关系”
第8讲的合计是这样写的。自己逐行读取表格,计算,再自己改写页面上的文字。
/** 从页面读取明细,显示合计和提示(第10讲:美元换算后相加) */
function updateTotal() {
const items = [];
let unreadable = 0;
let waiting = 0;
for (const row of tbody.rows) {
const text = row.querySelector('[name="amount"]').value;
const usd = row.querySelector('[name="currency"]').value === 'USD';
const amount = usd ? toCents(text) : toAmount(text);
if (amount === null) continue; // 空栏不计
if (Number.isNaN(amount)) { unreadable++; continue; } // 无法读成金额
if (!usd) items.push({ amount });
else if (rate === null) waiting++; // 还没有汇率
else items.push({ amount: centsToYen(amount, rate) }); // 美元换算成日元
}
const total = calcTotal(items);
totalOutput.textContent = formatYen(total);
receiptNote.hidden = !needsReceipt(total);
const notes = [];
if (unreadable > 0) notes.push(`有 ${unreadable} 行的金额无法读取(未计入合计)`);
if (waiting > 0) notes.push(`因为没有汇率,有 ${waiting} 行美元未计入合计`);
skipNote.textContent = notes.join(' / ');
skipNote.hidden = notes.length === 0;
}
在 Vue 里,在 HTML 这边写上“把数据 rows 的每一项显示成表格的一行”“合计显示在这里”(模板)。
<div id="app" v-cloak>
<div>
<table>
<thead><tr><th scope="col">类别</th><th scope="col">内容</th><th scope="col">金额</th><th scope="col">币种</th><th scope="col"><span>删除</span></th></tr></thead>
<tbody>
<tr v-for="(row, i) in rows" :key="row.id">
<td><select v-model="row.category" :aria-label="`第 ${i + 1} 行的类别`">…</select></td>
<td><input v-model="row.item" :aria-label="`第 ${i + 1} 行的内容`"></td>
<td><input v-model="row.amount" inputmode="numeric" autocomplete="off" :aria-label="`第 ${i + 1} 行的金额`"></td>
<td><select v-model="row.currency" :aria-label="`第 ${i + 1} 行的币种`"><option value="JPY">日元</option><option value="USD">美元</option></select></td>
<td><button type="button" @click="remove(i)" :aria-label="`删除第 ${i + 1} 行`">删除</button></td>
</tr>
</tbody>
</table>
</div>
<div>
<button type="button" id="add-row" @click="add" :disabled="rows.length >= MAX_ROWS">+ 添加一行</button>
<p>合计:<strong id="total">{{ formatYen(total) }}</strong></p>
</div>
<p v-if="needsReceipt(total)">合计在 3 万日元以上,需要附上收据。</p>
<p id="rate-note">{{ rate === null ? '正在取得汇率…' : `美元按 1 美元 = ${rate} 日元换算` }}</p>
</div>
JavaScript 这边只有数据和计算。找页面元素的 querySelector、改写的 textContent 都不出现。删除一行,也只是从数组里去掉一项。
// 报销系统:明细表 ── Vue 版(第15讲)
// 用 Vue 3 写第8讲的“行的添加・删除和合计”。不直接改写页面,而是改写数据,页面就跟着变
const { createApp, ref, computed, onMounted } = Vue;
const MAX_ROWS = 10;
createApp({
setup() {
let nextId = 1;
const newRow = () => ({ id: nextId++, category: '', item: '', amount: '', currency: 'JPY' });
const rows = ref([newRow()]); // 明细的数据。页面只是把它映出来
const rate = ref(null); // 1 美元合多少日元(和第10讲一样,从服务器取)
const total = computed(() => { // rows 或 rate 一变,就自动重新计算
let sum = 0;
for (const row of rows.value) {
const usd = row.currency === 'USD';
const amount = usd ? toCents(row.amount) : toAmount(row.amount); // 计算原样使用第7讲・第10讲的函数
if (amount === null || Number.isNaN(amount)) continue;
if (!usd) sum += amount;
else if (rate.value !== null) sum += centsToYen(amount, rate.value);
}
return sum;
});
const add = () => {
if (rows.value.length < MAX_ROWS) rows.value.push(newRow());
};
const remove = (i) => {
rows.value.splice(i, 1);
if (rows.value.length === 0) add(); // 至少保留一行
};
onMounted(async () => { // 页面做好后,去取汇率
const res = await fetch('/api/rates');
if (res.ok) rate.value = (await res.json()).rate;
});
return { rows, rate, total, add, remove, formatYen, needsReceipt, MAX_ROWS }; // 把模板(vue.html 的 #app 里)要用的东西交出去
},
}).mount('#app');
requireLogin(); // 确认登录并显示页头的名字(第11讲的 auth.js)
运行起来是这样。
# 打开 vue.html 时(Chromium 实测) CSP 页面 记录 保持严格(script-src 'self') 表格不显示 违规:script-src eval 加上 'unsafe-eval' 明细 3 行・合计 46,851 日元 删 1 行后 2 行・18,193 日元
保持严格的 CSP,表格整个不显示。不构建直接用的 Vue,会在浏览器里把 HTML 模板转换成程序。这时要用字符串生成程序(和 eval 一样),于是被第四节的 CSP 拦下了。被拦下之后的代码(显示页头名字的处理)也没有运行,停留在“确认中…”。允许 'unsafe-eval' 后,直到合计 46,851 日元,都和第8讲一样运行了。另外,v-for、@click 这样的写法不在 HTML 的规范里,所以 vnu 报出 16 处错误(第二节)。
实际工作中,Vue 的模板写在名为 .vue 的专用文件里,发布前用构建工具(Vite 等)预先转换成普通的 JavaScript。这样就不需要 eval,CSP 也能保持严格。React 也是把类似 HTML 的 JSX 写法,通过构建转换成 JavaScript 来用。也就是说,框架的门口有一道构建工具的门。这个连载停在“文件原样放上去就能运行”这一步,是因为想在那道门之前,亲手确认里面发生了什么。
新井Vue 好爽啊……只改数据,页面就跟着变。
古井是吧。那么,“跟着变”的里面是什么?
新井……读取行、计算、改写 textContent。第8讲做的事,Vue 替我们做了。
古井明白这一点,用什么工具都不会迷路。
回顾 15 讲
全 15 讲做了什么、学了什么
用 HTML 写意义,用 CSS 调外观,用 JavaScript 加动作,和服务器对话,做了登录、审批和汇总,用 jQuery 重写,最后检查并加固防守。每一讲都在重复写、在浏览器里运行、记录、确认。表格里列出的数字,全部是实际运行得到的值。
“古法”不是执着于旧工具,而是能亲手确认里面发生了什么。有了这种能力,无论是 AI 写的代码还是框架的代码,都能沉着地读、改、守。亲手写的能力,是用好 AI 的基础。
结 — 一页总结与今后
- 检查要用工具来数。vnu 查 HTML,axe-core 查读屏・操作的易用性。这次有两处对比度不够,修好后 14 → 0。
- 重量用覆盖率来称。Bootstrap 的 CSS 只用到一成左右。要削减,就轮到构建工具出场。
- 用 CSP 拦下没写过的脚本。只要把脚本和样式分到文件里,加上就行。
- 用 frame-ancestors 不让套框,用 nosniff 不让乱猜,用 SRI 发现调包。
- 密钥和密码不写在页面上。守护靠服务器。
- 发布时,页面放静态托管,API 放服务器。注意 https・响应头・缓存・后门。
- 框架是写“数据和页面的关系”的工具。门口有构建工具。
新井前辈,15 讲谢谢您了。接下来我要好好试试 Vite 和 Vue。
古井好。也用 AI。不过,出来的代码要像今天这样运行、记录、确认。
新井是。……对了,庆功宴的收据,可以申请吗?
古井3 万日元以上的话,别忘了附上收据。页面会提醒你的。
参考
- The Nu Html Checker (v.Nu)
- axe-core (Deque Systems)
- W3C — Understanding SC 1.4.3: Contrast (Minimum)
- MDN — Content Security Policy (CSP)
- MDN — CSP: script-src
- MDN — CSP: script-src-attr
- MDN — CSP: frame-ancestors
- MDN — X-Content-Type-Options
- MDN — Subresource Integrity
- Cloudflare Pages — Headers
- Vue.js — Quick Start
页面截图是笔者制作的演示页面,经由练习用服务器(Node.js),于2026年10月19日在云端的 Chromium(Playwright)中拍摄(页面时钟固定在 2026年10月17日 15:00)。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。账号・申请都是练习用服务器的虚构数据。HTML 检查用 Nu Html Checker(vnu),读屏・操作的易用性用 axe-core 4.14.0,页面重量和用到的比例用 Chromium 的覆盖率,CSP・frame-ancestors・SRI 的效果和 Vue(3.5.44)页面的行为,都是用 Playwright 操作和运行同一个 Chromium 记录的实际值。只有在插入 axe-core 检查时,才在浏览器设置里关掉了 CSP。登场人物均为虚构。感谢陪伴这 15 讲。
古法前端入门 第15讲(最终讲) — 收尾 / 2026.10