Web 深入:语义表单、层叠布局与浏览器任务
前置与目标
先完成HTML语义、CSS盒模型、JavaScript模块以及浏览器基础。本篇把三种能力放在同一个完整应用中验证,目录仍分别归属HTML、CSS和浏览器,不把它们视为一种语言。项目是课程检索实验;数据来自明确的异步本地函数,不依赖不存在的服务或账号。运行环境为现代Chromium、Node24静态服务;框架版本不影响此无框架页面。
HTML约束、键盘与状态
form代表提交操作,label的for对应唯一input id,按钮的type区分提交和取消;不要用可点击div替代原生button。Enter提交和Tab移动焦点由浏览器提供。required检查空值,maxlength限制用户输入长度,但不能替代模块的业务验证:空格通过required,粘贴和程序构造的数据也须检查。DOM maxlength按UTF-16代码单元计数;模型允许1–80个Unicode码点,界面约束更严格,不能说两者对emoji完全等价。
role=status的节点持续存在,只替换文本,以便辅助技术感知结果;错误不能只改变颜色。原生details将实验提示与操作分开,summary可用键盘展开。关闭面板时若移除了当前焦点,真实产品应把焦点移回触发按钮;本项目无删除聚焦输入的操作。浏览器端表单验证是交互帮助,不是安全授权。HTML内容模型不允许随意嵌套交互元素,例如button内再放button。
CSS层叠、固有尺寸与布局
CSS用@layer显式组织层叠层;本例reset、base、components按职责排序,普通声明和important声明的层优先级不能混为一谈。
先判断样式来源和层,再判断选择器优先级,最后才看同等条件下的出现顺序。项目声明reset、base、components三层,普通声明中后面的层优先;未分层的普通作者样式会优先于已分层普通样式。important会改变排序,不能把这套简化规则用于所有important情况。使用层管理职责,避免为覆盖一条样式不断堆叠id。
Grid的1fr默认仍受项目最小尺寸影响,长内容可把轨道撑宽;minmax(0,1fr)、输入min-width:0和文本overflow-wrap:anywhere分别处理轨道、控件和长词。box-sizing:border-box把padding/border算入声明宽度,不能解决所有overflow。420px断点将输入放到整行,按钮各占一列;clamp控制留白而不把字号缩小到难以阅读。focus-visible有清晰轮廓,动画尊重prefers-reduced-motion;不要为了截图隐藏overflow造成内容不可达。
浏览器任务、生命周期与资源
type=module延后执行且有模块作用域,相对import相对模块文件URL解析。使用HTTP静态服务,避免file协议的模块和同源差异。脚本先读取固定DOM,再注册事件;render只写textContent/replaceChildren,不把查询当作HTML执行。替换列表时先创建DocumentFragment式的节点集合,再一次替换,避免在循环中穿插读取布局;并非所有DOM写入都会立刻触发布局,但写后同步测量可能迫使浏览器计算。
异步任务不能只靠完成顺序决定界面。AbortSignal表达取消意图,加载器可能忽略它;模型还检查当前任务所有者,旧成功与旧失败都不能覆盖新状态。pagehide取消当前任务,返回浏览器缓存页面时模型仍可再次使用;这里没有在pagehide永久dispose,避免后退恢复页面后无法查询。监听器随整个文档释放;组件级拆卸则必须退订,见React和Vue。
HTTP资源缓存、localStorage与模块内存是不同生命周期:刷新会重新创建此模型,结果归空;已有阅读记录项目演示存储持久化。跨源读数据受同源/CORS限制,CORS不等于用户授权。HTTP缓存可复用响应,no-cache要求重新验证,no-store要求不存储;静态服务器的实际响应头决定缓存行为,文件名加hash本身不会设置响应头。HTTPS、CSP及服务器授权须在部署环境落实,本机静态实验不冒充公网安全配置。
完整项目
以下四文件位于examples/web/system-lab,保持相对位置即可复制独立运行。模型和测试见JavaScript完整模块,这里包含页面所需的全部运行文件。
index.html
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>异步状态实验</title><link rel="stylesheet" href="style.css"></head>
<body><main><h1>异步状态实验</h1><p>查询 js、vue、react;slow 延迟800ms,error 模拟失败。数据来自本地函数,不请求外部服务。</p>
<form id="search"><label for="query">课程查询</label><input id="query" maxlength="80" required value="vue"><button>查询</button><button type="button" id="cancel">取消</button></form>
<p id="status" role="status"></p><ul id="results"></ul><details><summary>实验步骤</summary><ol><li>查询slow后立刻查询vue,旧响应不能覆盖新结果。</li><li>查询error,再查react,失败后能恢复。</li><li>查询slow后取消,保持idle。</li><li>Tab依次操作,窄屏不需要横向滚动。</li></ol></details>
</main><script type="module" src="main.mjs"></script></body></html>style.css
@layer reset,base,components;
@layer reset{*{box-sizing:border-box}body{margin:0}}
@layer base{body{font:16px/1.7 system-ui,sans-serif;background:#f4f6fa;color:#18243c}main{max-width:52rem;margin:auto;padding:clamp(1rem,4vw,3rem)}button,input{font:inherit;min-height:44px}button{cursor:pointer}input{min-width:0;width:100%}:focus-visible{outline:3px solid #315dcc;outline-offset:3px}}
@layer components{form{display:grid;gap:.75rem;grid-template-columns:minmax(0,1fr) auto auto}label{grid-column:1/-1}button{padding:.3rem 1rem}#status{min-height:2rem;overflow-wrap:anywhere}li{overflow-wrap:anywhere}@media(max-width:420px){form{grid-template-columns:1fr 1fr}input{grid-column:1/-1}}}
@media(prefers-reduced-motion:no-preference){button{transition:background-color .15s}button:hover{background:#dbe5ff}}main.mjs
import {createLatest} from './model.mjs';
const form=document.querySelector('#search'),input=document.querySelector('#query'),status=document.querySelector('#status'),results=document.querySelector('#results');
const model=createLatest();
function render(){const state=model.getSnapshot();status.textContent=`${state.status} · ${state.query}${state.message?' · '+state.message:''}`;results.replaceChildren(...state.items.map(text=>{const li=document.createElement('li');li.textContent=text;return li;}));}
model.subscribe(render);render();
form.addEventListener('submit',event=>{event.preventDefault();model.run(input.value).catch(error=>{status.textContent=error.message;});});
document.querySelector('#cancel').addEventListener('click',()=>model.cancel());
window.addEventListener('pagehide',()=>model.cancel());model.mjs
const lessons=['JavaScript 事件循环','Node.js 流与背压','React Effect 清理','Vue 响应式','TypeScript 判别联合'];
export function findLessons(query,signal){
return new Promise((resolve,reject)=>{
const abort=()=>{clearTimeout(timer);signal.removeEventListener('abort',abort);reject(new DOMException('cancelled','AbortError'));};
const timer=setTimeout(()=>{
signal.removeEventListener('abort',abort);
if(query==='error')reject(new Error('fixture failure'));
else resolve(lessons.filter(item=>item.toLowerCase().includes(query.toLowerCase())));
},query==='slow'?800:30);
signal.addEventListener('abort',abort,{once:true});
if(signal.aborted)abort();
});
}
export function createLatest(load=findLessons){
let snapshot=Object.freeze({status:'idle',query:'',items:Object.freeze([]),message:''});
let current=null,disposed=false;
const listeners=new Set();
const publish=value=>{snapshot=Object.freeze(value);for(const listener of [...listeners])listener();};
const ensureLive=()=>{if(disposed)throw new Error('model disposed');};
return {
getSnapshot:()=>snapshot,
subscribe(listener){ensureLive();if(typeof listener!=='function')throw new TypeError('listener must be a function');listeners.add(listener);return ()=>listeners.delete(listener);},
async run(input){
ensureLive();
if(typeof input!=='string'||![...input.trim()].length||[...input.trim()].length>80)throw new Error('query length must be 1..80');
const query=input.trim();current?.abort();const owner=new AbortController();current=owner;
publish({status:'loading',query,items:Object.freeze([]),message:''});
try {
const items=await load(query,owner.signal);
if(!Array.isArray(items)||items.length>100||items.some(x=>typeof x!=='string'||[...x].length>80))throw new Error('invalid lesson result');
if(!disposed&¤t===owner)publish({status:'ready',query,items:Object.freeze([...items]),message:''});
}catch(error){
if(!disposed&¤t===owner)publish({status:'error',query,items:Object.freeze([]),message:error instanceof Error?error.message:'unknown failure'});
}finally{if(current===owner)current=null;}
return snapshot;
},
cancel(){ensureLive();if(!current)return;current.abort();current=null;publish({...snapshot,status:'idle',message:'cancelled'});},
dispose(){if(disposed)return;disposed=true;current?.abort();current=null;listeners.clear();},
};
}在项目目录以PowerShell或Bash执行npx --yes http-server . -a 127.0.0.1 -p 5192;该命令会从npm下载工具,已有静态服务也可使用。也可以从仓库根以Python运行python -m http.server 5192 --bind 127.0.0.1 --directory examples/web/system-lab,无第三方Python依赖。浏览器访问http://127.0.0.1:5192/,不要把整个工作目录公开到外网。初始显示idle,查询vue显示ready及“Vue 响应式”;slow后迅速查react,最终只能显示React,error显示fixture failure,再查询能恢复。取消后idle,不伪装空结果成功。
诊断与性能实验
先在Network确认HTML、CSS、main.mjs、model.mjs状态为200,再看Console的第一条错误;模块404不能靠改CSS修复。Styles中检查层级,Computed核对min-width与实际盒尺寸;390px时比较document.documentElement.scrollWidth与innerWidth,宽度不应超过视口。录制Performance,重复查询和取消,查找长任务和布局;本例最多100项,不能用它声称虚拟列表已经实现。内存快照须先取消/移除引用并多次采样,GC时机不确定,单次RSS下降不构成无泄漏证明。
键盘流程:Tab到输入,Enter查询,再Tab到取消和details;焦点必须可见。分别测试空格、HTML样式文本、无结果、slow取消与error恢复。禁用CSS后文档仍应有标题、标签、表单和列表。自动化模型测试不能代替键盘和真实布局验证,实际结果记录在本轮验收文件。
练习
- 为输入超过80码点的调用预测界面与模块行为。2. 临时加入未分层input规则,解释为何覆盖base层。3. 让加载器忽略AbortSignal并延迟旧响应,证明仍不会覆盖。4. 从Network解释刷新后资源缓存命中与应用状态归空为何能同时发生。
提示与参考解法
模型在创建新任务前拒绝非法输入;原生maxlength可能更早截断用户输入,测试应同时覆盖模型调用和界面。未分层普通作者样式优先于分层普通样式,不应靠更长选择器猜测。模型测试用可控Promise先完成新请求再完成旧请求,快照对象应保持不变;取消只是资源优化,所有者比较才隔离过期结果。缓存保存资源响应,模型内存属于新文档实例,两者不是同一存储。
依据:WHATWG表单约束、MDN层叠、中文@layer。核对2026-10-17;语义、层叠和运行机制是本批深入能力,特殊辅助技术及跨浏览器矩阵仍按实际环境另验。
继续阅读
- 综合实践
- 浏览器与协议
- 规范与参考