Skip to content

Web Base综合实践:无框架阅读记录页 ​

阅读清单的 Chromium 操作与 Node 模型测试属于原文 2026-10-17 历史记录;本轮未重跑。下文保留静态服务步骤,供区分 DOM 行为、模型断言和 localStorage 结果。

前置:HTML语义、CSS层叠与布局、JavaScript函数集合、DOM事件、URL与同源。目标是在站内说明和完整源码帮助下独立完成页面,基础路径不依赖React/Vue或Node业务后端。

文件、启动与预期 ​

路径 examples/web/reading,只需现代浏览器和本地静态HTTP服务。下列文件构成完整项目,不需要npm install;本批Chromium实际验证,模型测试使用Node24.21.0。用Python3在该目录运行 python -m http.server 5186 --bind 127.0.0.1,打开 http://127.0.0.1:5186/;Linux替换为python3。HTTP基础和服务器影响见浏览器章节。

index.html(完整结构)
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="styles.css">
</head>
<body>
 <header><h1>阅读记录实验</h1><p>本地保存的无框架练习,最多50条。</p></header>
 <main>
  <section aria-labelledby="add-heading">
   <h2 id="add-heading">添加记录</h2>
   <form id="add-form">
    <label for="title">阅读标题</label>
    <input id="title" name="title" required maxlength="160" autocomplete="off">
    <button type="submit">添加</button>
   </form>
   <button id="sample" type="button">从静态文件追加示例</button>
   <p id="status" role="status" aria-live="polite"></p>
  </section>
  <section aria-labelledby="list-heading">
   <h2 id="list-heading">记录列表</h2>
   <label for="filter">显示</label>
   <select id="filter"><option value="all">全部</option><option value="open">未完成</option><option value="done">已完成</option></select>
   <p id="count"></p><ul id="notes"></ul>
  </section>
 </main>
 <noscript>需要启用JavaScript才能添加与保存记录。</noscript>
 <script type="module" src="app.mjs"></script>
</body>
</html>
styles.css(完整样式)
css
* { box-sizing: border-box; }
body { margin: 0 auto; padding: 1rem; max-width: 64rem; font: 1rem/1.6 system-ui,sans-serif; color: #18263a; background: #f5f7fb; }
main { display: grid; gap: 1rem; }
section { min-width: 0; background: white; padding: 1rem; border: 1px solid #cad3e0; border-radius: .5rem; }
form { display: grid; gap: .5rem; margin-bottom: 1rem; }
input,select,button { font: inherit; max-width: 100%; min-width: 0; padding: .5rem; }
button { cursor: pointer; background: #1748a0; color: white; border: 0; border-radius: .25rem; }
:focus-visible { outline: 3px solid #b94e00; outline-offset: 3px; }
ul { padding: 0; list-style: none; }
li { padding: .75rem 0; border-bottom: 1px solid #cad3e0; overflow-wrap: anywhere; }
li button { margin: .25rem .5rem .25rem 0; }
[role="status"] { overflow-wrap: anywhere; min-height: 3em; }
@media (min-width: 48rem) { main { grid-template-columns: 1fr 1.5fr; } }
model.mjs(完整数据与存储)
javascript
export const key='showdocs-reading-v1';
export function validateNotes(notes){
 if(!Array.isArray(notes)||notes.length>50)throw new Error('记录必须是最多50条的数组');
 return notes.map(note=>{
  if(!note||typeof note.title!=='string'||typeof note.done!=='boolean')throw new Error('记录格式不正确');
  const title=note.title.trim();
  if(!title||Array.from(title).length>80)throw new Error('标题需要1到80个字符');
  return {title,done:note.done};
 });
}
export function addNote(notes,title){return validateNotes([...notes,{title,done:false}]);}
export function loadNotes(storage){
 try {const text=storage.getItem(key);return {notes:text===null?[]:validateNotes(JSON.parse(text)),error:''};}
 catch {return {notes:[],error:'无法恢复本地记录:存储不可用或内容损坏。当前显示空列表,保存会替换本站旧数据。'};}
}
export function saveNotes(storage,notes){storage.setItem(key,JSON.stringify(validateNotes(notes)));}
app.mjs(完整交互)
javascript
import {addNote,validateNotes,loadNotes,saveNotes} from './model.mjs';
const $=selector=>document.querySelector(selector);
const status=$('#status');
let storage;
try {storage=window.localStorage;} catch {storage={getItem(){throw new Error('blocked')},setItem(){throw new Error('blocked')}};}
const restored=loadNotes(storage);
let notes=restored.notes;
status.textContent=restored.error;
function render(){
 $('#notes').replaceChildren();
 $('#count').textContent=`共 ${notes.length} 条,已完成 ${notes.filter(n=>n.done).length} 条`;
 notes.forEach((note,index)=>{
  if($('#filter').value==='open'&&note.done||$('#filter').value==='done'&&!note.done)return;
  const item=document.createElement('li'),text=document.createElement('p');
  text.textContent=note.title;item.append(text);
  for(const [action,label] of [['toggle',note.done?'标记未完成':'标记完成'],['delete','删除']]){
   const button=document.createElement('button');button.type='button';button.textContent=label;
   button.dataset.action=action;button.dataset.index=String(index);item.append(button);
  }
  $('#notes').append(item);
 });
}
function commit(next){
 try {saveNotes(storage,next);notes=next;render();status.textContent='已保存到当前浏览器';return true;}
 catch {status.textContent='保存失败:存储被禁用或空间不足,本次修改未应用。';return false;}
}
$('#add-form').addEventListener('submit',event=>{
 event.preventDefault();
 try {if(commit(addNote(notes,$('#title').value)))$('#title').value='';}
 catch(error){status.textContent=error.message;}
});
$('#filter').addEventListener('change',render);
$('#notes').addEventListener('click',event=>{
 const button=event.target instanceof Element?event.target.closest('button[data-action]'):null;
 if(!button||!$('#notes').contains(button))return;
 const index=Number(button.dataset.index);
 if(!Number.isInteger(index)||index<0||index>=notes.length)return;
 commit(button.dataset.action==='delete'?notes.filter((_,i)=>i!==index):notes.map((n,i)=>i===index?{...n,done:!n.done}:n));
});
$('#sample').addEventListener('click',async()=>{
 const button=$('#sample');button.disabled=true;status.textContent='正在读取示例';
 try {
  const response=await fetch('./seed.json');
  if(!response.ok)throw new Error(`HTTP ${response.status}`);
  const loaded=validateNotes(await response.json());
  commit(validateNotes([...notes,...loaded]));
 }catch(error){status.textContent='读取失败:'+error.message;}
 finally {button.disabled=false;}
});
render();
seed.json
json
[{"title":"理解HTML语义","done":false},{"title":"验证CSS响应式","done":true}]

首次打开预期空列表。输入“学习HTML”并添加,显示共1条;标记完成后已完成数为1;切换未完成过滤后该项隐藏,切回全部再出现。刷新保留记录;删除后刷新仍为空。最后点击静态示例追加两项。演示会写当前源下的showdocs-reading-v1,只放教学数据。

HTML表单与CSS响应式 ​

页面有唯一h1、main和具备标题的section;label的for关联input的id,name用于表单字段,button明确type,避免普通操作意外提交。required拦截空字符串,业务校验还会拒绝纯空格、超过80个Unicode码点及超过50条记录。HTML maxlength=160仅提供宽松的UTF-16输入上限,实际业务长度以model校验为准,避免把中文/表情和字节混为一谈。

CSS先使用正常文档流和单列Grid,宽度达到48rem时分两列;border-box包含内边距与边框,section设min-width:0避免网格最小宽度撑开页面,长标题允许换行。focus-visible给键盘焦点明确轮廓,不用颜色作为唯一状态信号。用Tab/Shift+Tab在标签输入、添加、示例、过滤和记录按钮之间导航;Enter提交表单,焦点应可见。窄至390px检查没有意外横向滚动。

DOM事件与安全文本 ​

应用用addEventListener注册submit/change/click,submit中preventDefault避免页面导航。按钮事件委托在稳定ul上,通过closest找到data-action按钮;修改数据后重建列表。用数组索引识别当前渲染条目,过滤不改变原索引,所以隐藏项不会导致删除错项。更复杂的排序/协作应用应使用稳定ID,本批不预建同步架构。

所有输入标题用textContent输出,不通过innerHTML解析。输入<img src=x onerror=alert(1)>只显示文字,不产生图片或执行脚本。数据模型返回新数组和新条目,不修改传入数组;这和JS对象可变本身不是矛盾,属于本项目约定。原生按钮仍可键盘操作,不把整个li做成只有鼠标能点击的容器。

localStorage损坏与保存失败 ​

启动时getItem可能抛错,JSON可能损坏,数据也可能结构不合格。此时显示清楚提示和空列表,不声称旧数据恢复成功;原存储在首次保存前保持不变。用户随后添加会替换本站旧键,提示已说明该影响。保存采用先setItem再更新界面;配额或权限错误时本次修改不应用,不能留下“刷新后消失”的假成功。

读取示例会先检查HTTP状态、JSON及字段,随后追加并持久化。最多50条,连续点击不会并发导入;如果用户在请求期间新增记录,回调基于当前notes追加,不覆盖其已添加内容。状态完全本地,不支持多标签合并、远程账号或离线同步,不能把localStorage当数据库。

模型测试与浏览器验收 ​

model.test.mjs(完整模型与失败测试)
javascript
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {addNote,validateNotes,loadNotes,saveNotes} from './model.mjs';
test('输入规范化、不可变更新与边界',()=>{
 const old=[];const next=addNote(old,'  学习 HTML  ');
 assert.deepEqual(next,[{title:'学习 HTML',done:false}]);assert.equal(old.length,0);
 for(const text of ['', ' '.repeat(4),'a'.repeat(81)])assert.throws(()=>addNote([],text));
 assert.throws(()=>addNote(Array.from({length:50},()=>({title:'x',done:false})),'x'));
 assert.throws(()=>validateNotes([{title:'x',done:'false'}]));
 assert.throws(()=>validateNotes({}));
});
test('损坏与不可用存储可见报错,不能伪装持久化成功',()=>{
 const bad={getItem(){return 'not json'}};
 assert.equal(loadNotes(bad).error.length>0,true);
 assert.deepEqual(loadNotes({getItem(){return null}}).notes,[]);
 assert.throws(()=>saveNotes({setItem(){throw new Error('quota')}},[]),/quota/);
 assert.ok(loadNotes({getItem(){throw new Error('blocked')}}).error);
});

在项目目录运行 node --test model.test.mjs,预期两组测试通过,覆盖空白、过长、条数、布尔字段、损坏JSON、被禁存储和配额异常。Node没有document,这些测试不替代真实浏览器的表单、按钮、样式与刷新验收。

浏览器按setup流程验证,再输入HTML样式文本、测试空白标题、加载静态示例、过滤/删除和刷新。通过开发者工具Application仅修改本项目键为坏JSON,刷新出现损坏提示;修复或删除该键,不清空其他站点。测试要区分DOM行为、数据行为和持久化行为,不能只截一张正常页面就宣称全通过。

阶段练习与参考 ​

给页面增加“仅显示标题包含关键词”筛选,不更改存储中的记录;用已有done筛选组合验证。再把seed.json临时移走,确认失败不删除现有记录,恢复文件后按钮仍可重试。关闭静态服务后页面已有数据是否还可读,与新请求能否成功分别观察。

参考解法

新增带label的搜索框,在render时同时检查done和title.includes(query);只修改显示条件,不通过过滤结果覆盖notes。seed404应进入catch并显示HTTP404,finally恢复按钮;同源本地存储与网络请求是两种能力,旧页面脚本和存储仍可用不代表fetch能成功。增加筛选后仍通过原有模型测试,并重新走键盘和窄屏流程。

依据:MDN核心模块、textContent、事件、存储。更复杂可访问性审计、跨浏览器矩阵、框架与前端工程化留到后续,未标全覆盖。

深入课程已接续:完整异步查询实验。基础内容与旧锚点保留,按当前路线进入完整机制和实践。

继续阅读