
留言板代码大全:用 HTML、CSS 和 JavaScript 搭建一个简单留言板
留言板是练习表单处理、页面布局和 DOM 操作的好例子。本文从一个能直接运行的版本开始,逐步说明它怎样保存和展示留言、如何处理用户输入,以及哪些事情它做不到。示例不依赖框架和第三方库,把代码保存为 index.html 后,用浏览器打开即可体验。
这个留言板适合做什么
示例支持填写昵称和留言内容、提交留言、在页面中查看留言,以及删除单条留言。数据保存在当前浏览器的 localStorage 中,刷新页面后通常仍会保留。
它适合用作个人练习、交互原型或静态页面演示。如果多人需要共同查看和发布留言,或者需要登录、审核、跨设备同步、管理后台,就需要服务器端程序和数据库。浏览器本地存储只属于当前浏览器环境,不会自动把留言发送给网站运营者或其他访客。
先了解几个边界
- 存储范围有限:
localStorage按网站来源隔离,数据通常留在当前浏览器的当前来源下。更换设备、浏览器或网站域名后,不会自动同步。 - 存储不可靠:用户可以清除站点数据;浏览器的隐私设置、存储策略或可用空间也可能影响读写。具体容量和行为因浏览器而异,不应假设有固定可用字节数。
- 前端限制可以被绕过:
maxlength和 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>
<style>
:root {
color-scheme: light;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: #20242b;
background: #f4f6f8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 32px 16px;
}
main {
width: min(100%, 720px);
margin: 0 auto;
}
h1 {
margin: 0 0 8px;
font-size: 1.8rem;
}
.intro {
margin: 0 0 24px;
color: #586174;
line-height: 1.6;
}
.panel {
padding: 20px;
border: 1px solid #dce1e8;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 16px rgb(25 40 60 / 5%);
}
label {
display: block;
margin: 0 0 6px;
font-weight: 650;
}
input,
textarea {
display: block;
width: 100%;
margin: 0 0 16px;
padding: 10px 12px;
border: 1px solid #b9c2cf;
border-radius: 7px;
font: inherit;
}
textarea {
min-height: 120px;
resize: vertical;
}
input:focus,
textarea:focus,
button:focus-visible {
outline: 3px solid #9dc7ff;
outline-offset: 2px;
}
button {
padding: 9px 14px;
border: 0;
border-radius: 7px;
background: #185abd;
color: #fff;
font: inherit;
cursor: pointer;
}
button:hover {
background: #124b9f;
}
.hint,
.status {
color: #586174;
font-size: 0.9rem;
}
.status {
min-height: 1.4em;
margin: 12px 0 0;
}
.messages {
display: grid;
gap: 12px;
margin: 16px 0 0;
padding: 0;
list-style: none;
}
.message {
padding: 16px;
border: 1px solid #dce1e8;
border-radius: 10px;
background: #fff;
}
.message-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
}
.message-name {
font-weight: 700;
overflow-wrap: anywhere;
}
.message time {
color: #667085;
font-size: 0.85rem;
}
.message-body {
margin: 10px 0 12px;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.delete-button {
padding: 6px 10px;
background: #626b78;
font-size: 0.9rem;
}
.delete-button:hover {
background: #454d58;
}
.empty {
margin: 16px 0 0;
color: #667085;
}
</style>
</head>
<body>
<main>
<h1>简单留言板</h1>
<p class="intro">写下留言并提交。此示例只在当前浏览器保存数据。</p>
<section class="panel" aria-labelledby="form-title">
<h2 id="form-title">发表留言</h2>
<form id="message-form">
<label for="name">昵称</label>
<input id="name" name="name" type="text"
maxlength="40" autocomplete="nickname" required>
<label for="content">留言内容</label>
<textarea id="content" name="content"
maxlength="500" required></textarea>
<p class="hint">昵称最多 40 个 JavaScript 字符单位,留言最多 500 个。</p>
<button type="submit">提交留言</button>
<p id="status" class="status" role="status" aria-live="polite"></p>
</form>
</section>
<section aria-labelledby="list-title">
<h2 id="list-title">留言列表</h2>
<p id="empty-message" class="empty">还没有留言。</p>
<ul id="messages" class="messages"></ul>
</section>
</main>
<script>
"use strict";
const STORAGE_KEY = "simple-message-board-v1";
const form = document.querySelector("#message-form");
const nameInput = document.querySelector("#name");
const contentInput = document.querySelector("#content");
const status = document.querySelector("#status");
const list = document.querySelector("#messages");
const emptyMessage = document.querySelector("#empty-message");
let messages = [];
function isValidMessage(item) {
return item !== null
&& typeof item === "object"
&& typeof item.name === "string"
&& typeof item.content === "string"
&& typeof item.createdAt === "string";
}
function loadMessages() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === null) return [];
const parsed = JSON.parse(saved);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isValidMessage);
} catch (error) {
status.textContent =
"无法读取本地留言;可能是浏览器存储不可用或数据格式损坏。";
return [];
}
}
function saveMessages(nextMessages) {
messages = nextMessages;
renderMessages();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
status.textContent = "留言已保存在当前浏览器。";
} catch (error) {
status.textContent =
"浏览器无法保存数据;本次内容暂时只在当前页面中显示。";
}
}
function renderMessages() {
list.replaceChildren();
emptyMessage.hidden = messages.length !== 0;
messages.forEach(function (message, index) {
const item = document.createElement("li");
item.className = "message";
const header = document.createElement("div");
header.className = "message-header";
const name = document.createElement("span");
name.className = "message-name";
name.textContent = message.name;
const time = document.createElement("time");
const date = new Date(message.createdAt);
if (!Number.isNaN(date.getTime())) {
time.dateTime = date.toISOString();
time.textContent = date.toLocaleString();
} else {
time.textContent = "时间未知";
}
const body = document.createElement("p");
body.className = "message-body";
body.textContent = message.content;
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "delete-button";
deleteButton.textContent = "删除";
deleteButton.setAttribute("aria-label", "删除这条留言");
deleteButton.addEventListener("click", function () {
const nextMessages = messages.filter(function (_, currentIndex) {
return currentIndex !== index;
});
saveMessages(nextMessages);
});
header.append(name, time);
item.append(header, body, deleteButton);
list.append(item);
});
}
form.addEventListener("submit", function (event) {
event.preventDefault();
const name = nameInput.value.trim();
const content = contentInput.value.trim();
if (name.length === 0 || content.length === 0) {
status.textContent = "昵称和留言内容都不能为空。";
return;
}
if (name.length > 40 || content.length > 500) {
status.textContent = "输入内容超过了允许的长度。";
return;
}
const nextMessages = [
{
name: name,
content: content,
createdAt: new Date().toISOString()
},
...messages
];
saveMessages(nextMessages);
form.reset();
nameInput.focus();
});
messages = loadMessages();
renderMessages();
</script>
</body>
</html>
代码的关键部分
1. 用表单处理输入
昵称和留言分别由 input 与 textarea 接收。两者都有 required,浏览器会提供基础的表单约束检查;提交事件中还调用了 trim(),去掉首尾空白,并再次拒绝空内容。这样可以避免只输入空格的留言。
示例设置昵称长度上限为 40、留言长度上限为 500。HTML 的 maxlength 限制的是表单控件可接受的长度,JavaScript 的 String.length 则按 UTF-16 代码单元计数;它不等同于用户感知的字素数。例如某些 emoji 由多个代码单元组成。因此页面中的“字符”是便于理解的简写,不应把它当成严格的可视字符计数。如果产品要求按字素计数,需要另行实现或使用经过评估的库,并在前后端采用一致规则。
输入框的限制主要是用户体验措施。客户端代码可以被修改,也可以绕开页面直接请求服务端,所以线上应用必须在服务端重新检查字段类型、长度和允许内容。
2. 把数据存成结构化记录
一条留言在内存中是一个对象,包含 name、content 和 createdAt 三个字段。提交时用 new Date().toISOString() 记录时间,再通过 JSON.stringify() 转成 JSON 文本写入本地存储。
读取时使用 JSON.parse() 还原数据,并用 try...catch 处理解析失败、存储不可用等情况。浏览器存储中的内容可能被用户修改,也可能来自旧版本程序,所以不能只因为它是从本地读出的就默认结构可靠。示例会过滤掉字段类型不符合预期的记录;正式产品还应依据自己的数据格式进行更严格的校验和版本迁移。
3. 通过 DOM API 显示留言
展示留言时,程序使用 document.createElement() 创建元素,并将昵称和正文赋给 textContent。这很重要:用户输入应当作为普通文本显示,而不是当作 HTML 解析。
不要把用户内容直接拼进 innerHTML,例如不要写成 container.innerHTML = userInput。如果用户提交了看似 HTML 的内容,innerHTML 会尝试解析它,可能形成跨站脚本(XSS)风险。使用 textContent 可以避免这段文本被浏览器解释为标签或脚本。示例的删除按钮同样由 DOM API 创建,没有将用户数据拼入 HTML 字符串。
正文样式中的 white-space: pre-wrap 用于保留留言中的换行,overflow-wrap: anywhere 则帮助处理很长的连续字符,避免撑宽页面。这些只是显示规则,不会改变存储的原始内容。
4. 删除时更新界面和存储
每个删除按钮对应列表中的一条记录。点击后,代码通过 filter() 生成不包含该条记录的新数组,再统一交给 saveMessages() 更新页面并尝试写回本地存储。这个简化实现依赖数组下标定位,只适合本地单人使用;多人并发编辑时,下标不是稳定标识,应给每条记录分配由服务端管理的唯一 ID,并处理并发更新。
常见问题与错误
刷新后留言不见了
先确认打开页面的来源没有变化。通过不同协议、域名或端口访问的页面,通常会使用不同的本地存储空间。还要检查浏览器是否清除了站点数据,或是否禁用了相关存储。若需要跨设备同步,就必须把数据保存到服务端;仅靠 localStorage 做不到。
代码能运行,但多个访客看不到同一份留言
这是预期行为。每位访客的浏览器各自保存数据,没有网络请求,也没有共享数据库。要实现公共留言板,需要前端向后端 API 发送和获取数据,后端完成验证、持久化、权限控制、限流和必要的审核。
把输入转义后再保存,是否就安全了
不应把“转义一次”当成适用于所有场景的通用安全方案。数据在 HTML 文本、属性、URL、JavaScript 字符串等不同上下文中的安全处理要求并不相同。本文采用的策略是在当前展示位置用 textContent 输出普通文本。如果将来增加富文本,应该采用经过维护的 HTML 清理方案,并限制允许的标签与属性;不能只依赖替换几个字符。
页面提示已提交,为什么刷新后没有保存
示例会尝试调用 localStorage.setItem(),并捕获失败。存储写入失败时,留言仍会在当前页面的内存状态中显示,但刷新后可能丢失。因此页面会提示“暂时只在当前页面中显示”。不要把“成功更新界面”误当成“已经可靠持久化”。若业务要求可靠保存,需要设计服务端确认和失败重试等机制。
昵称和内容是不是可信的身份信息
不是。任何访问者都可以填写任意昵称,本地留言还可以由用户通过开发者工具修改。需要识别用户时,应使用服务端验证的登录身份,而不是相信表单字段;需要防止滥用时,应考虑服务端限流、权限校验、审核流程以及适用的隐私与内容管理要求。
从练习版本走向实际应用
如果要把示例用于公开网站,建议把前端定位为交互层:提交内容到服务端,由服务端执行长度与类型校验、鉴权、频率限制和持久化;读取留言时只返回当前用户有权查看的数据。前端仍应使用安全的文本输出方式,并对网络错误、空列表和加载状态提供清晰反馈。
还要根据实际需求处理删除权限、内容审核、数据保留期限和用户隐私。不要把本地存储误当成备份,也不要让浏览器端保存服务端密钥。对于需要长期保存或多人协作的数据,应由服务端负责数据一致性与访问控制。
这个版本的核心价值是把常见前端环节串起来:表单输入、基础校验、DOM 渲染、本地持久化和删除操作。将代码保存并运行后,可以尝试修改样式、增加留言计数或搜索功能;在扩展之前,始终保持一个原则:用户输入应当按普通数据处理,前端校验不能替代服务端安全校验。








