留言板代码大全:用 HTML、CSS 和 JavaScript 搭建一个简单留言板

AI绘画5分钟前发布 jdkjadmin
0 00
留言板代码大全:用 HTML、CSS 和 JavaScript 搭建一个简单留言板

留言板代码大全:用 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 渲染、本地持久化和删除操作。将代码保存并运行后,可以尝试修改样式、增加留言计数或搜索功能;在扩展之前,始终保持一个原则:用户输入应当按普通数据处理,前端校验不能替代服务端安全校验。

© 版权声明

相关文章