← 返回蜂巢洞察

如何使用纯JavaScript使静态HTML页面在浏览器中可被编辑

当你为他人维护文档时,比如简历、一页的作品集或可打印的菜单,瓶颈往往不在于布局设计,而在于编辑流程本身。 任何微小的修改(“把这个项目符号上移”、“删除那行内容”、“这个链接已经失效了”)都需要由你这位使用代码编辑器的人来完成,尽管提出修改要求的人自己非常清楚他们想要做什么。 我在为一位家庭成员维护简历时遇到了这个问题。那份简历被制作成了一个静态的HTML文件,设计已经完成,内容也是由当事人提供的。但每次进行修改——无论是重新排列职位顺序、添加证书信息、修复链接,还是调整打印分页设置——都意味着要再次发送修改内容给我,然后让我去编辑文件。经过第十次这样的循环后,我终于意识到:其实应该让页面能够

当你为他人维护文档时,比如简历、一页的作品集或可打印的菜单,瓶颈往往不在于布局设计,而在于编辑流程本身。

任何微小的修改(“把这个项目符号上移”、“删除那行内容”、“这个链接已经失效了”)都需要由你这位使用代码编辑器的人来完成,尽管提出修改要求的人自己非常清楚他们想要做什么。

我在为一位家庭成员维护简历时遇到了这个问题。那份简历被制作成了一个静态的HTML文件,设计已经完成,内容也是由当事人提供的。但每次进行修改——无论是重新排列职位顺序、添加证书信息、修复链接,还是调整打印分页设置——都意味着要再次发送修改内容给我,然后让我去编辑文件。经过第十次这样的循环后,我终于意识到:其实应该让页面能够自动进行这些修改才对。

在这篇文章中,你将学习如何使用`contenteditable`属性、大约一百行纯JavaScript代码,以及无需任何构建步骤,就能为静态HTML页面创建一个浏览器内置的编辑功能。使用者可以随意修改文本、重新排列或删除内容块、添加新内容、编辑链接、控制打印分页,并将页面内容导出为PDF格式。只需刷新页面,文件就会恢复到最初未经过修改的状态。

目录

先决条件

要跟随这篇文章的学习步骤,你需要具备以下条件:

  • 掌握HTML和CSS的基础知识,包括CSS网格布局和`@media print`规则

  • 对JavaScript的DOM API有基本了解(如`querySelector`、事件监听器、元素创建等)

  • 不需要使用任何框架、库或构建工具。这就是本文的重点所在。

你将学到什么

  • `contenteditable`属性能为你提供哪些免费功能,以及它的局限性在哪里

  • 如何使用一个通用的函数为可重复使用的区块添加移动/删除控件

  • 如何利用`:has()`方法仅在最内层的被悬停的区块上显示控制按钮

  • 如何在不依赖任何框架的情况下重新排列DOM节点,以及为确保操作安全性需要做哪些准备工作

  • 如何在可编辑区域内修改链接地址

  • 如何让用户自行设置页面的打印分页位置

  • 如何利用模板添加新内容,并预先选中占位文本

  • 为什么“没有任何数据会被永久保存”反而可以被视为一种功能,而非缺陷

什么是contenteditable

contenteditable是一种HTML属性,它可以将任何元素变成可编辑区域。浏览器会处理其中复杂的操作:光标的位置设置、文本的选择与输入、删除操作、复制粘贴功能以及撤销历史记录等。

<div class="page" contenteditable="true" spellcheck="false">
  <!-- 整个文档内容 -->
</div>

仅仅使用这个属性,你就能实现比预期更多的功能。点击任何段落都会在该处放置光标;按下Cmd+Z键可以撤销输入的内容;在

    标签内按下Enter键会创建一个新的
  • 项。浏览器能够自然地理解列表的结构,因此“按Enter键添加项目符号”这样的操作完全不需要编写任何代码。

    但是,单独使用contenteditable并不能构成一个真正的编辑器。它没有“块”的概念,也无法将某个内容块移动到另一个内容块的上面,无法干净地删除某个元素,也无法修改href属性。在可编辑区域内点击链接时,光标只会被放置在链接文本的位置上;所有与结构相关的操作都需要用户自行完成。本文的后续部分将会介绍如何填补这些空白。

    为什么不使用React应用呢?

    一个显而易见的替代方案是将这个页面重新设计成一个“真正的”应用程序:使用组件、状态管理机制,为每个内容区域单独创建表单,并添加导出功能。但我选择了另一种方法,而这种选择所带来的权衡也是值得考虑的。

    这个页面所在的文件被保存在public/文件夹中,会被作为静态资源进行提供。用户可以通过URL、从磁盘上直接访问,或者通过电子邮件附件来使用它。这个页面不需要安装任何依赖库,也不需要运行构建过程,而且当工具链版本更新时,也不会出现任何问题。

    用户的编辑需求其实非常简单且有限:修改文本、移动或删除内容块、添加新的内容块以及打印输出结果。这些操作都属于DOM操作的范围,而DOM API本身就已经非常擅长处理这类任务了。

    只有当状态数据的生命周期超过了DOM的生命周期时,框架才会体现出它的复杂性——比如数据持久化、多人协作、数据验证以及数据同步等功能。但这个页面根本不需要这些功能。既然你的状态数据本身就是DOM结构,并且整个应用程序的生命周期只持续一个会话时间,那么使用框架就只是多此一举而已。

    如何为重新排序准备标记代码

    在编写任何JavaScript代码之前,先检查你的标记代码中是否存在那些只能“位于”其他元素“之间”的元素。在我的例子中,各个工作项是通过


    分隔符来区分的:

    <div class="job">...</div>
    <hr class="divider">
    <div class="job">>...</div>
    

    一旦允许这些内容块被移动或删除,像


    这样的分隔符就会变成麻烦的根源。如果删除了一个工作项,它的分隔符就会变成孤立的元素留在页面上;如果移动了某个工作项,分隔符可能也会跟着被错误地移动位置。

    解决这个问题的方法是直接删除所有的


    元素,而是通过相邻元素之间的关系来自然地划分内容区域:

    .section .job + .job {
      border-top: 0.5px solid var(--rule);
      padding-top: 18px;
    }
    

    这种兄弟元素组合机制会为每个位于其他元素之后的元素生成相应的控制按钮规则。无论这些元素的位置如何改变——无论是重新排序、删除还是添加新元素——分隔符的位置始终都会保持正确,因为它们是根据页面结构计算得出的,而非被存储在结构中。这一原理与在编程中通过推导状态而非复制状态来简化代码的思路是一样的,只不过在这里它是应用于CSS中的。

    如何为每个区块添加控制按钮

    每一个可移动的区块都会被赋予一个包含“向上移动”、“向下移动”和“删除”功能的小控件组,而这些功能都是通过一个可重用的函数来实现的:

    const SELECTORS = ['.section', '.job', '.bullets li', '.cert-card', '.skills-row', '.edu-row'];
    const BREAKABLE = new Set(['.section', '.job'];
    
    function makeBlock(el, sel) {
      el.classList.add('blk');
      el.dataset.sel = sel;
      const ctl = document.createElement('span');
      ctl.className = 'ctl';
      ctl.setAttribute('contenteditable', 'false');
      ctl.innerHTML =
        '<button data-act="up" data-tip="向上移动">↑</button>' +
        '<button data-act="down" data-tip="向下移动">↓</button>" +
        (BREAKABLE.has(sel) ? '<button data-act="brk" data-tip="在此处分页:开始新的打印页面">⇟</button>' : '') +
        '<button data-act="del" data-tip="删除此元素。刷新页面可恢复原状">×</button>";
      el.appendChild(ctl);
    }
    
    SELECTORS.forEach(sel => {
      page.querySelectorAll.sel).forEach(el => makeBlock(el, sel));
    });
    

    有几点设计决策值得特别提及。

    首先,控制按钮组上设置了`contenteditable="false"`属性。这样一来,页面中所有可编辑的区域都会遵循这一设置;除非你明确允许用户进行编辑,否则任何元素都是不可编辑的。如果不采用这个措施,用户就可能会在按钮内部插入光标并像删除文本一样删除这些按钮。

    其次,`el.dataset.sel`这一属性用于记录“是哪个选择器与当前元素匹配”。这一点在后面的代码中非常重要:当一个区块移动时,它应该只与同类型的区块进行交换。例如,列表项应该在列表项之间移动,职位信息应该在职位信息之间移动。将选择器存储在元素上,可以让这种判断变得非常简单。

    第三,这些控制按钮是位于它们所控制的区块内部的。这样一来,就可以通过使用`position: absolute`来为这些按钮指定位置,而这个定位是相对于区块自身的`position: relative`属性来说的。因此,无论区块移动到哪里,它所包含的控制按钮都会随之移动。

    如何仅在最内层的区块上显示控制按钮

    页面中的区块是可以嵌套的:一个列表项会位于一个职位信息内部,而这个职位信息又会位于一个章节内部。当用户将鼠标悬停在列表项上时,实际上三个层级都会被选中,而如果使用普通的CSS代码,这三个层级的控制按钮都会同时显示出来。这样一来,在用户试图集中注意力的地方就会产生视觉干扰。

    现代CSS用一行代码就能解决这个问题:

    .blk:hover:not(:has(.blk:hover)) > .ctl { display: inline-flex; }
    

    这句话的意思是:当用户将鼠标悬停在某个区块上时,就会显示该区块的控制按钮除非有某个子区块也被悬停了,在这种情况下,较深层的区块会优先显示其控制按钮。如果用户将鼠标悬停在列表项上,就会显示列表项的控制按钮;如果悬停在职位标题上(也就是不在任何列表项内部),就会显示职位的控制按钮。只需要一条规则,就无需使用JavaScript代码了。

    :has()这一功能在当前所有浏览器中都得到了支持,但为了兼容旧版浏览器,需要额外添加一条规则:

    @supports not selector(:has(*)) {
      .blk:hover > .ctl { display: inline-flex; }
    }
    

    对于较旧的浏览器来说,使用这一功能会导致所有祖先元素都被选中,而根本无法正常显示控件。这种兼容性问题的表现非常明显,而且不会悄无声息地发生。

    如何移动和删除区块

    当区块上绑定了控件之后,重新排序这些区块的操作其实非常简单。只需要一个监听器就可以处理页面上的所有按钮操作:

    function siblings(el) {
      return [...el.parentElement.children].filter(c =>
        c.classList.contains('blk') && c.dataset.sel === el.dataset.sel);
    }
    
    page.addEventListener('click', e => {
      const btn = e.targetclosest('.ctl button');
      if (!btn) return;
      e.preventDefault();
      const el = btnclosest('.blk');
      const sibs = siblings(el);
      const i = sibs.indexOf(el);
      const act = btn.dataset.act;
      if (act === 'up' && i > 0) sibs[i - 1].before(el);
      else if (act === 'down' && i < sibs.length - 1) sibs[i + 1].after(el);
      else if (act === 'del') el.remove();
      else if (act === 'brk') el.classList.toggle('page-break');
    });
    

    siblings()这个函数的作用正是让dataset.sel)这一机制发挥作用:它能够从父元素的子元素中筛选出相同类型的区块,因此永远不会发生将某个区块插入到列表中间位置的情况。before()after()方法可以在不进行克隆或重新渲染的情况下移动节点,而且区块上的控件也会随之一起移动。

    不过还有一个需要注意的小问题:在可编辑区域内点击按钮时,文本光标会先被移动到该按钮的位置,这可能会导致页面滚动或选中的内容发生变化。因此需要在浏览器做出任何反应之前,在mousedown事件触发时阻止这种行为:

    page.addEventListener('mousedown', e => {
      if (e.targetclosest('.ctl, .add-btn')) e.preventDefault();
    });
    

    如果忽略了这个问题,你可能只会感觉到点击按钮后的效果有些异常。但在产品发布之前,一定要彻底解决这个漏洞。

    在可编辑区域内,单击链接只会将文本光标移动到链接的位置,而不会直接跳转到该链接对应的页面。这种设计对于文本编辑来说确实很方便,但用户却无法直接修改链接的URL地址。href属性并不是文本,而是一个用于存储链接地址的信息。

    双击链接就可以打开一个编辑窗口,因此可以将URL编辑功能绑定到双击事件上:

    page.addEventListener('dblclick', e => {
      const a = e.targetclosest('a');
      if (!a) return;
      e.preventDefault();
      const url = prompt('链接地址(留空表示删除链接):', a.getAttribute('href'));
      if (url === null) return;
      if (!url.trim()) a.replaceWith(document.createTextNode(a.textContent));
      else a.setAttribute('href', url.trim());
    });
    

    没错,就是prompt()。虽然这种做法已经有些过时了,但试想一下:为了一个仅仅用来询问一个问题的对话框,开发一个自定义的模态窗口需要花费多少成本吧?包括额外的标记代码、样式设置、焦点管理机制以及异常处理逻辑等等。prompt()是原生提供的功能,可以通过键盘进行操作,而且使用起来也不会出现任何问题。

    设置“空字符串”选项这一设计非常巧妙:它会完全替换链接内容,用自身的文本来替代链接,因此用户只需输入“删除此链接”即可,而无需了解任何HTML知识。

    由于这些功能都是隐藏的,因此有必要告知用户。在每个链接上添加title属性(例如“双击可修改此链接”),就能将相关的操作提示显示在用户需要的地方。

    如何让用户控制打印分页

    如果文档的最终输出形式是PDF文件,那么分页的位置应该由用户来决定——比如“将“技能”部分放在第3页”,这样的决策权应该属于用户。而CSS可以让分页设置变得非常简单:

    @media print {
      .page-break { break-before: page; page-break-before: always; }
      .job { break-inside: avoid; page-break-inside: avoid; }
    }
    

    有趣的是这个交互界面。makeBlock功能中使用的按钮,实际上只是用于为某个内容块添加或移除page-break类。在屏幕上,这个类会表现为该内容块上方的一条虚线,这条虚线能够清楚地指示出打印页面的结束位置:

    .page .page-break {
      border-top: 1.5px dashed var(--accent) !important;
      padding-top: 14px !important;
    }
    
    @media print {
      .page .page-break { border-top: none !important; padding-top: 0 !important; }
    }
    

    这条虚线仅在屏幕上显示,在打印输出时就会消失,取而代之的是实际的分页标记。用户只需通过这个按钮来切换分页设置,然后查看相应的分界线再进行打印即可。没有人会去修改CSS代码来重新调整文档的分页格式,同样重要的是,也没有人会要求我这么做。

    同样的@media print代码块还会隐藏所有的编辑界面元素(.toolbar, .ctl, .add-btn { display: none !important; }),因此打印出来的结果与原始的静态页面没有任何区别。

    如何通过模板添加新内容

    单纯的编辑和删除功能是有局限性的。当用户需要添加新的项目、职位信息或证书信息时,系统会提供一个“+ 添加”按钮,让用户能够根据模板创建新的内容块:

    skillsSection.appendChild(newAddBtn('+ 添加技能条目', '用于添加空白条目。可以直接在输入框中输入内容。', btn => {
      const row = document.createElement('div');
      row.className = 'skills-row';
      row.innerHTML =
        '<span class="skill-label">>标签名称</span>>' +
        '<span class="skill-items">>技能一、技能二、技能三</span>';
      skillsSection.insertBefore(row, btn);
      makeBlock(row, '.skills-row');
      selectText(row.querySelector('.skill-label'));
    });
    

    其中有两个细节起到了关键作用。

    新创建的内容块会经过与页面加载时解析的所有内容块相同的makeBlock处理流程。对于任何新增的内容,系统都会立即为其提供移动和删除的功能。而对于职位信息这类特殊内容,系统还会专门为其提供一个“+ 添加项目”按钮。

    如果你发现自己正在为动态内容编写第二条注册路径,那么请立即停止。这样做会导致行为出现分歧,而这种行为本应保持一致。

    selectText函数会预先选中占位符文本:

    function selectText(node) {
      const range = document.createRange();
      range.selectNodeContents(node);
      const sel = getSelection();
      sel.removeAllRanges();
      sel.addRange(range);
    }
    

    点击“+ 添加技能条目”后,Label这个词就已经被高亮显示出来,因此直接输入内容即可替换掉它。无需点击文本框,也无需手动删除占位符文本,更不会在打印后的文档中留下占位符。

    需要注意的一点是:要选择文本节点,而不是整个块级元素。因为块级元素中包含了contenteditable="false"属性,如果选中整个块级元素,那么在按下第一个键时,按钮和占位符都会被删除。

    为什么没有任何内容会持久保存

    所有的编辑操作都存储在DOM中,而当页面刷新时,这些修改就会消失。这听起来似乎是一种缺失的功能,但实际上这就是设计初衷。

    这个页面所支持的工作流程是:打开页面、进行修改、将内容打印成PDF格式、然后关闭页面。PDF文件才是最终的结果,而页面本身只是一个模板。这种临时性的设计使得你可以随时通过刷新页面来撤销所有的修改操作,从而避免了半成品状态成为新的默认设置的情况,同时也确保了文档版本始终与源代码保持一致。

    工具栏上明确写着:“没有任何内容会被保存。刷新页面会重置所有内容。”这种事先明确的说明让消费者觉得这是一种保障,而不是隐藏的陷阱。

    如果允许编辑内容持久保存,就会带来复杂性问题。一旦修改内容在页面刷新后仍然存在,就需要处理序列化、版本控制、与源文件的合并冲突等问题,以及“哪个版本才是正确的”这类疑问。而这种设计正是为了解决这些问题而存在的。

    结论

    现在你拥有这样一个静态HTML页面:它允许用户对文本进行编辑(通过contenteditable属性实现),可以对结构进行修改(使用makeBlock函数),可以通过:has()方法来控制聚焦和悬停效果,还可以通过切换类来实现打印时的分页功能。此外,页面中还包含了预先选好占位符的模板,方便用户添加新内容。整个代码仅由大约一百行JavaScript代码组成,且没有任何依赖关系或构建过程。

    同样重要的是要明白:在什么情况下这种设计不再适用。如果编辑内容必须持久保存,如果有多人同时进行编辑,或者如果内容需要经过验证或遵循特定的工作流程,那么DOM作为状态存储机制就不再适合使用了。在这种情况下,就应该使用真正的应用程序和数据库来处理这些需求。

    但对于那些只需要一个人进行修改并打印出来的文档来说,比如简历、发票、证书和程序列表等,浏览器本身就已经提供了编辑功能。你只需要启用这些功能即可。

相关文章

技术实践

如何对您的网站进行人工智能可提取性审计(我发现有6个标题标签导致了我被扣分)

当人工智能助手回答问题时,它会从寥寥几页内容中提取相关句子并加以引用。你的页面是否适合被人工智能系统引用,并非什么难以理解的现象,而是与你所使用的HTML代码的某些机械性特性有关——这些特性是可以被测量、评估并加以调整的。 本教程将详细介绍我对自己网站进行的那次审计过程:它发现了哪些隐藏在代码中的标题标签,以及如何通过一次简单的修改就能解决这些问题,同时还会讲解如何利用持续集成系统来防止类似问题再次发生。 重点在于:我的主页在可提取性方面的得分是65分(满分100分)。造成这一问题的原因在于有五个UI组件将其标题标记为 或 标签。将这六个标题修改为符合ARIA标准的段落格式后,页面的得分便提高

阅读全文
技术实践

如何利用Gemini构建人工智能功能:面向开发者的提示工程实用指南

大多数关于提示工程的教学教程都遵循相同的流程:安装SDK,输入API密钥,调用 generateContent 函数,然后打印输出结果。模型会生成一些看似合理的内容,之后教学教程也就结束了。 但当你真正尝试将这个系统投入实际使用时,才会发现其实真正的准备工作根本还没有开始。 “API返回的文本”与“让用户感到可信的实际功能”之间的差距,正是需要耗费大量精力去解决的地方。 这个差距中充满了各种棘手的问题:模型生成的内容听起来和其他聊天机器人没什么两样;它会编造用户从未说过的话;它返回的数据会被用Markdown格式包裹起来;系统会在凌晨2点出现故障;而对于那些只是想得到答案的用户来说,系统展示的

阅读全文
技术实践

如何使用 shadcn/ui 在 React 中构建一个可重复使用的日期时间选择器

日期和时间选择器这类组件,在设计文件中看起来可能很简洁,但一旦开始实际开发,就会发现它们会消耗大量的资源。你需要一个日历、一个时间选择器,以及一个能够保证这两者同步的状态管理系统,通常还需要范围选择功能以及对应的多语言版本。 本指南将介绍一些现成的选择器组件,你可以直接将这些组件应用到你的React项目中:组合型日期和时间选择器、日期范围选择器以及时间选择器。 所有这些组件都可以作为 Shadcn日期和时间选择器 组件使用,你只需通过一条CLI命令即可安装它们,而无需从头开始开发。 这些组件都是基于Radix和Base UI的基础架构构建的,下面介绍的版本是使用Base UI实现的。此外,这些

阅读全文
技术实践

如何使用LangSmith来追踪和监控人工智能代理的行为

在本教程中,我将向您展示如何使用LangSmith来追踪和监控本地的AI代理。我们会构建一个简单的本地AI代理,然后为其启用LangSmith追踪功能,这样我们就能通过Web界面查看模型调用情况、工具使用情况以及请求处理延迟等信息。 我们将使用LangChain v1、Ollama、Qwen以及Python这些工具。除了用于实现观测功能的组件外,所有操作都在您的本地机器上完成,因此代理本身不会产生任何与模型API相关的费用。 目录 背景知识 什么是可观测性与监控? 什么是LangSmith? 开发动机与架构设计 步骤1:安装Ollama并下载模型 步骤2:安装Python相关依赖库 步骤3:启

阅读全文