普通视图

发现新文章,点击刷新页面。
今天 — 2026年5月8日首页

Vue 3.6 Vapor Mode:跳过虚拟 DOM,性能极致优化

作者 Momo__
2026年5月7日 17:46

虚拟 DOM 曾是前端框架的革命性思想,而今天,我们正在超越它。

一、引言

Vue 3.6 正式进入 Beta 阶段,Vapor Mode 作为本轮更新的最大亮点,终于揭开了神秘面纱。这是一个彻底改变 Vue 渲染架构的编译模式——跳过虚拟 DOM,直接操作真实 DOM

从 React 在 2013 年引入虚拟 DOM 思想,到 Vue 2.0 于 2016 年采纳这一方案,再到今天 Vue 3.6 选择「告别」它,前端框架领域正在经历一场静默的范式转移。Svelte 在 2019 年证明了编译时优化可以消除虚拟 DOM 的开销,SolidJS 证明了细粒度响应式无需虚拟 DOM 也能达到极致性能,而现在,拥有全球数百万开发者的 Vue 正式加入这场变革。

Vapor Mode 的命名本身就充满隐喻——Vapor(蒸汽)的目标是让「虚拟 DOM 运行时」像水蒸气一样蒸发消散。这个名称不仅是营销概念,它准确描述了这项技术的核心价值:消除传统虚拟 DOM 带来的运行时开销

二、Vapor Mode 是什么

2.1 核心概念:无虚拟 DOM 的编译模式

Vapor Mode 是 Vue 单文件组件(SFC)的一种全新编译策略。它的核心思路非常直接:在编译时分析模板,生成直接操作真实 DOM 的 JavaScript 代码,而不是生成虚拟 DOM 节点

传统 Vue 组件的编译流程是:

  1. 解析 .vue 模板文件

  2. 编译为返回虚拟 DOM 节点(VNode)的渲染函数

  3. 运行时执行渲染函数,生成 VNode 树

  4. 对比新旧 VNode 树(diffing)

  5. 根据差异补丁化更新真实 DOM

Vapor Mode 改变了这个流程的第 2 和第 3 步:

  1. 解析 .vue 模板文件

  2. 编译为直接创建和更新 DOM 元素的命令式代码

  3. 运行时执行编译后的代码,响应式状态变化直接触发精确的 DOM 变更——无需 VNode 分配,无需树对比,无需补丁计算

2.2 与传统 VDOM 模式的本质区别

让我们通过一个简单组件来直观理解差异:

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h2>{{ user.name }}</h2>
    <p>{{ user.bio }}</p>
    <span :class="{ online: user.isOnline }">
      {{ user.isOnline ? '在线' : '离线' }}
    </span>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const user = reactive({
  name: '张三',
  bio: '前端工程师',
  isOnline: true
})
</script>

传统 Vue 编译输出(简化):

function render(_ctx) {
  return h("div", { class: "user-card" }, [
    h("h2", null, _ctx.user.name),
    h("p", null, _ctx.user.bio),
    h("span", { class: { online: _ctx.user.isOnline } },
      _ctx.user.isOnline ? '在线' : '离线'
    )
  ])
}

user.name 变化时,整个组件的渲染函数重新执行,产生新的 VNode 树,然后 diff 算法遍历两棵树,最终发现只有 <h2> 的文本节点需要更新。

Vapor Mode 编译输出(简化):

import { template, setText, effect } from 'vue/vapor'

const t0 = template('<div class="user-card"><h2></h2><p></p><span></span></div>')

function render(_ctx) {
  const el = t0()
  const [h2, p, span] = el.children
  
  // 静态内容只创建一次
  h2.textContent = _ctx.user.name
  p.textContent = _ctx.user.bio
  
  // 响应式绑定:每个状态只更新它影响的 DOM 节点
  effect(() => {
    h2.textContent = _ctx.user.name
  })
  effect(() => {
    p.textContent = _ctx.user.bio
  })
  effect(() => {
    span.textContent = _ctx.user.isOnline ? '在线' : '离线'
    span.classList.toggle('online', _ctx.user.isOnline)
  })
  
  return el
}

编译时,Vue 已经「知道」了每个响应式变量对应哪个 DOM 节点。运行时,当 user.name 变化时,只有 <h2> 的文本节点被更新,没有 VNode 分配,没有树遍历,没有 diff 计算。

2.3 技术定位

Vapor Mode 是一个 100% 可选(opt-in)的功能,不会破坏任何现有代码。Vue 官方明确表示:

Vapor Mode has demonstrated the same level of performance with Solid and Svelte 5 in 3rd party benchmarks.

这意味着 Vue 开发者现在可以在不换框架的情况下,获得与 Svelte 5、SolidJS 相当的运行时性能。

三、工作原理深度解析

3.1 编译时优化策略

Vapor 编译器在构建阶段完成以下几个关键任务:

模板静态分析

编译器会区分模板中的静态部分和动态部分。静态 HTML 结构只生成一次,存储在模板缓存中;只有动态绑定的部分才会生成响应式 effect。

依赖追踪

编译器分析每个响应式变量在模板中的使用位置,为每个绑定生成精确的更新函数。这种「编译时依赖追踪」避免了运行时 diffing 的开销。

DOM 引用提取

编译产物中包含对所有需要动态更新的 DOM 节点的直接引用(通过 el.childrenel.querySelector 等),而不是通过 VNode 间接访问。

3.2 响应式系统与 DOM 的直接绑定

Vapor Mode 的运行时使用 effect 函数建立响应式状态与 DOM 更新之间的精确映射:

// 当 count.value 变化时,只更新这个特定的文本节点
effect(() => {
  textNode.data = String(count.value)
})

每个 effect 都是独立的、更新的最小单元。相比传统模式中「组件重新渲染→生成完整 VNode 树→diff→补丁更新」,Vapor Mode 的更新链路缩短为:状态变化→触发精确 effect→更新特定 DOM 节点

3.3 Alien Signals:响应式系统的底层革新

Vue 3.6 不仅引入了 Vapor Mode,还同步重构了响应式系统的底层实现。新的 @vue/reactivity 包基于 Johnson Chu 开发的 alien-signals 库,采用 Push-Pull 混合算法,显著提升了响应式性能。

Push-Pull 算法的工作方式:

  • Push 阶段:响应式值变化时,只向依赖方推送「dirty(数据已过期)」通知,不立即重新计算

  • Pull 阶段:值被实际读取时,才触发真正的重新计算(惰性求值)

plaintext

[ref 值变化] → Push: dirty 通知 → [值被读取] → Pull: 执行重算

alien-signals 的实现特点:

  • 核心部分不使用 Array、Set、Map 等高成本数据结构

  • 采用链表等更轻量高效的结构

  • 排除递归调用,防止循环引用

性能提升数据(官方):

指标 Vue 3.5 Vue 3.6(alien-signals) 改善
内存使用量 基准值 -14% -14%
10 万组件挂载 - ~100ms -

关键是:这一切都是向后兼容的。你不需要改任何代码,只需升级到 Vue 3.6,就能享受 alien-signals 的性能提升。refcomputedwatcheffectScope 等 API 保持不变。

3.4 编译输出对比

让我们看一个包含更多场景的组件对比:

输入模板:

<template>
  <div class="list">
    <h1>{{ title }}</h1>
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.name }} - {{ item.count }}
      </li>
    </ul>
    <button @click="addItem">添加</button>
  </div>
</template>

<script setup vapor>
import { ref } from 'vue'

const title = ref('物品列表')
const items = ref([
  { id: 1, name: '苹果', count: 5 },
  { id: 2, name: '香蕉', count: 3 }
])

function addItem() {
  items.value.push({
    id: Date.now(),
    name: '新物品',
    count: 0
  })
}
</script>

传统 VDOM 编译思路:
每次 items 变化,生成新的 VNode 树 → diff 计算 → 对整个列表区域执行补丁更新。

Vapor Mode 编译思路:

  • title 变化 → 只更新 <h1> 文本

  • items 数组变化 → 通过列表渲染优化,只处理变化的行

  • 事件监听器直接绑定到按钮 DOM 节点

Vapor 编译器会生成类似以下的代码结构:

const t0 = template('<div class="list"><h1></h1><ul></ul><button></button></div>')
const t1 = template('<li></li>')

function render(_ctx) {
  const el = t0()
  const [h1, ul, button] = el.children
  
  // 静态设置
  h1.textContent = '物品列表'
  button.textContent = '添加'
  button.addEventListener('click', _ctx.addItem)
  
  // 响应式绑定
  effect(() => {
    h1.textContent = _ctx.title
  })
  
  // 列表渲染 - Vapor 专用指令
  _renderList(ul, _ctx.items, (item) => {
    const li = t1()
    effect(() => {
      setText(li, `${item.name} - ${item.count}`)
    })
    return li
  })
  
  return el
}

四、性能对比

4.1 官方基准测试数据

根据 Vue 官方发布的数据和第三方基准测试:

测试场景 Vue 3 + VDOM Vue 3.6 + Vapor Svelte 5 SolidJS
10,000 行表格首次渲染 247ms 185ms 192ms 145ms
更新 1,000 行数据 41ms 23ms 26ms 52ms
50 个复杂组件内存占用 18.7MB 12.4MB 11.8MB 14.1MB

关键发现:

  • Vapor Mode 首次渲染比传统 Vue 快约 25%,比 React 18 快约 30%

  • 部分更新场景下,Vapor Mode 比传统 Vue 快近 50%

  • 内存占用减少约 34%(相比传统 Vue)

4.2 js-framework-benchmark 结果

2026 年最新测试数据(综合多项基准测试):

框架 操作/秒 相对性能 基准包体积
Vanilla JS ~15,000 基准 0-5 KB
SolidJS ~14,800 99% 8.2 KB
Svelte 5 ~13,200 88% 12.1 KB
Vue 3.6 + Vapor ~11,200 75% <10 KB
Vue 3.6 默认 ~9,800 65% 34.3 KB
React 19 ~8,700 58% 42.5 KB

注意:Vue 3.6 + Vapor 的测试数据来自社区,随着编译器优化持续进行,性能还在不断提升中。

4.3 打包体积对比

这是 Vapor Mode 最直观的优势之一:

框架/配置 未压缩 Gzip 压缩后
Vue 3.6 + Vapor Mode ~40KB <10KB
Vue 3.6 默认 ~58KB ~22KB
React 19 ~72KB ~28KB
Svelte 5 ~28KB ~12KB

当你使用 createVaporApp 创建纯 Vapor 应用时,虚拟 DOM 运行时代码完全不会打包进产物,基础体积直接降到 10KB 以下。

4.4 性能提升的本质原因

  1. 消除 VNode 分配开销:每次渲染,传统模式都需要创建新的 JavaScript 对象来表示虚拟节点,Vapor Mode 直接操作 DOM,无此开销

  2. 消除 diff 计算:传统模式的 diffing 算法在最坏情况下是 O(n³),Vapor Mode 编译时已知更新目标,完全绕过 diffing

  3. 细粒度更新:只有实际依赖变化的 DOM 节点才会更新,组件级别的整体重渲染不复存在

  4. 内存优化:无需维护 VNode 树,GC 压力大幅降低

五、如何使用 Vapor Mode

5.1 安装 Vue 3.6 Beta

# 使用 npm
npm install vue@3.6.0-beta.1

# 或使用 yarn
yarn add vue@3.6.0-beta.1

# 或使用 pnpm
pnpm add vue@3.6.0-beta.1

如果你使用 Vite(推荐),确保 @vitejs/plugin-vue 也是最新版本:

npm install @vitejs/plugin-vue@latest vite@latest

5.2 组件级别开启方式

方式一:在 <script setup> 添加 vapor 属性

<!-- Counter.vue -->
<script setup vapor>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
</script>

<template>
  <div class="counter">
    <h2>计数器</h2>
    <p>当前值:{{ count }}</p>
    <button @click="increment">增加</button>
  </div>
</template>

<style scoped>
.counter {
  text-align: center;
  padding: 20px;
}

button {
  padding: 8px 16px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

这是最简单的迁移方式——只需添加一个 vapor 属性即可。

5.3 全局配置选项

Vite 项目配置(vite.config.js/ts):

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      // 可选:全局配置 Vapor Mode
      compilerOptions: {
        mode: 'vapor'  // 或在单个组件的 script setup 上指定
      }
    })
  ]
})

Vue CLI 项目配置(vue.config.js):

module.exports = {
  chainWebpack: config => {
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        options.compilerOptions = {
          ...options.compilerOptions,
          mode: 'vapor'
        }
        return options
      })
  }
}

5.4 完整应用实例:两种创建方式

方式一:创建纯 Vapor 应用(推荐用于新项目)

// main.ts
import { createVaporApp } from 'vue'
import App from './App.vue'

createVaporApp(App).mount('#app')

这种方式下,虚拟 DOM 运行时代码不会被引入,基础包体积最小。

方式二:混合模式(渐进式迁移现有项目)

// main.ts
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App)
  .use(vaporInteropPlugin)  // 启用 Vapor 互操作
  .mount('#app')

安装 vaporInteropPlugin 后,你可以:

  • 在任意组件的 <script setup> 上添加 vapor 属性使其使用 Vapor 模式

  • Vapor 组件和 VDOM 组件可以相互嵌套

  • 逐步迁移性能敏感的组件,其他部分保持不变

5.5 TypeScript 类型支持

Vapor Mode 完整支持 TypeScript,所有现有类型定义都适用:

<script setup vapor lang="ts">
import { ref, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

const user = ref<User>({
  id: 1,
  name: '张三',
  email: 'zhangsan@example.com'
})

const displayName = computed(() => {
  return user.value.name.toUpperCase()
})

function updateName(newName: string) {
  user.value.name = newName
}
</script>

<template>
  <div class="user-profile">
    <h2>{{ displayName }}</h2>
    <p>邮箱:{{ user.email }}</p>
    <button @click="updateName('李四')">更改姓名</button>
  </div>
</template>

六、适用场景分析

6.1 最佳使用场景

Vapor Mode 在以下场景中能发挥最大价值:

静态内容为主的页面

  • 企业官网首页

  • 文档站点

  • 落地页

  • 博客文章页

这类页面初始化后几乎不需要动态更新,Vapor Mode 可以让它们以极低的 JS 开销运行。

性能敏感的高频更新组件

  • 数据仪表盘的核心数字展示

  • 实时股价/行情显示

  • 游戏计分系统

  • 聊天消息列表(高频滚动)

在高频更新场景下,Vapor Mode 的细粒度更新优势会被显著放大。

移动端 H5 页面

  • 首屏加载速度直接影响用户留存

  • 设备性能有限,减少 JS 解析量尤为重要

  • Vapor Mode 的 <10KB 基础包体积极具竞争力

列表渲染场景

  • 长列表(虚拟滚动列表)

  • 表格组件

  • 瀑布流布局

传统 VDOM 在列表更新时需要 diff 整棵树,Vapor Mode 只需处理实际变化的行。

6.2 不适合使用的情况

复杂动态结构的组件

如果组件的模板结构会根据条件大幅变化(不同的子组件、动态标签名等),Vapor 编译器的静态分析效果会打折扣。

大量使用第三方 UI 库

目前主流的 Vue UI 组件库(如 Element Plus、Ant Design Vue、Vuetify)尚未适配 Vapor Mode,直接在 Vapor 组件中使用会有限制。

重度依赖实例 API

以下 API 在 Vapor 组件中不可用或表现不同:

  • getCurrentInstance() → 返回 null

  • app.config.globalProperties → 不可用

  • onVueComponentMounted 等生命周期钩子 → 不支持

6.3 渐进式迁移策略

第一步:识别收益最大的组件

使用 Chrome DevTools 的 Performance 面板,找出 render 时间最长的组件,或者直接分析高频更新的交互区域。

第二步:从简单组件开始

先迁移不涉及复杂 props/slots 传递的独立组件,积累经验。

第三步:逐步扩大范围

当团队熟悉 Vapor 模式后,可以逐步覆盖更多组件。

第四步:评估混合边界

Vue 官方建议在应用中划分清晰的「Vapor 区域」和「VDOM 区域」,避免过度混合带来的复杂性。

七、注意事项与限制

7.1 兼容性问题

必须使用 <script setup>

Vapor Mode 只支持 <script setup> 语法,不支持:

  • 传统 Options API(data()methodscomputed 等)

  • 手动的 setup() 函数

如果你有大量 Options API 代码,需要先迁移到 Composition API。

不支持的功能清单

类别 功能 说明
API Options API 需迁移到 Composition API
API getCurrentInstance() Vapor 组件中返回 null
API app.config.globalProperties 不可用
API @vue:xxx 生命周期事件 不支持每个元素的生命周期钩子
渲染 渲染函数(Render Functions) 不支持 JSX
渲染 自定义渲染器 不支持
功能 Suspense(纯 Vapor) 不支持,但可在 VDOM Suspense 中渲染 Vapor 组件

7.2 自定义指令的新接口

Vapor Mode 中的自定义指令接口与 VDOM 模式不同:

// VDOM 模式
type Directive = (
  el: HTMLElement,
  binding: DirectiveBinding,
  vnode: VNode
) => void

// Vapor Mode
type VaporDirective = (
  node: Element | VaporComponentInstance,
  value?: () => any,      // 响应式 getter
  argument?: string,
  modifiers?: DirectiveModifiers
) => (() => void) | void  // 可选返回清理函数

关键区别:binding.value 变成了 value,它是一个响应式 getter 函数。使用示例:

// Vapor 模式下的自定义指令
const vFocus = (el, source) => {
  watchEffect(() => {
    if (source()) {
      el.focus()
    }
  })
  return () => console.log('cleanup')
}

7.3 调试工具支持

Vapor Mode 是新特性,Vue DevTools 和其他调试工具的 Vapor 相关支持还在完善中。预计在正式版发布后会有更好的调试体验。

7.4 生态兼容现状

目前适配良好的场景:

  • Vue 核心功能:refcomputedwatchreactiveprovide/inject

  • 条件渲染:v-ifv-show

  • 列表渲染:v-for(带 key)

  • 事件绑定:@click

  • 模板语法::class:style:src 等绑定

  • 过渡动画:TransitionTransitionGroup

需要等待适配的:

  • 第三方 UI 组件库(Element Plus、Ant Design Vue 等)

  • 某些依赖于 VDOM 实例 API 的库

  • SSR 框架集成(Nuxt 等)

7.5 已知限制

  • Vapor 插槽在 VDOM 组件中不能使用 slots.default(),必须使用 renderSlot

  • 动态组件 <component :is="..."> 在复杂场景下可能有限制

  • VDOM 组件库在 Vapor 模式下可能有兼容性问题

Vue 官方表示,随着版本迭代,这些限制会逐步解决。

八、与传统 VDOM 模式的选择指南

8.1 决策矩阵

维度 选择 Vapor Mode 选择 VDOM Mode
页面类型 静态为主、性能敏感 高度动态、交互复杂
包体积要求 极致的轻量化 允许一定开销
更新频率 高频细粒度更新 常规更新频率
UI 库依赖 使用原生 HTML/CSS 依赖第三方组件库
API 使用 纯 Composition API Options API 或混合
项目阶段 新项目 现有大型项目

8.2 迁移成本评估

从 VDOM 迁移到 Vapor 的成本:

因素 成本评估
语法变更 低(只需加 vapor 属性)
API 适配 中(Options API 需迁移)
组件重构 取决于组件复杂度
测试覆盖 高(需完整回归测试)
第三方库适配 高(取决于依赖情况)

推荐迁移路径:

现有项目:
  ↓ 新增组件用 Vapor Mode
  ↓ 识别高频更新组件 → 迁移
  ↓ 静态页面逐步迁移
  ↓ 评估并迁移核心功能组件

新项目:
  ↓ 选择 createVaporApp 或 createApp + plugin
  ↓ 全部使用 Vapor Mode
  ↓ 按需引入 VDOM 组件(通过 interop)

8.3 混合模式最佳实践

<!-- App.vue (VDOM 组件) -->
<script setup>
import Header from './components/Header.vue'
import Footer from './components/Footer.vue'
import Dashboard from './components/Dashboard.vue'  // Vapor 组件
import DataTable from './components/DataTable.vue' // Vapor 组件
</script>

<template>
  <div class="app">
    <Header />  <!-- VDOM -->
    <Dashboard />  <!-- Vapor:性能敏感的仪表盘 -->
    <DataTable />  <!-- Vapor:高频更新的数据表 -->
    <Footer />  <!-- VDOM -->
  </div>
</template>

关键是:识别瓶颈、精准优化,而不是盲目全部迁移。

九、总结与展望

9.1 Vue 的战略选择

Vapor Mode 代表了 Vue 团队的一次重要战略选择:不再追求虚拟 DOM 的极致优化,而是选择「消灭它」。这是一个有魄力的决定,因为:

  1. Vue 拥有全球数百万开发者,稳定性至关重要

  2. 渐进式迁移策略(opt-in)确保现有项目不受影响

  3. 与 alien-signals 的协同优化形成组合拳

9.2 前端渲染范式的演进

jQuery 时代 → 虚拟 DOM 时代 → 编译时优化时代
手动 DOM 操作   声明式 UI        直接 DOM 操作
            (Vue 2, React)    (Vue Vapor, Svelte, Solid)

虚拟 DOM 的历史使命是提供「声明式 UI + 高效更新」的平衡。随着编译器技术的发展,这个平衡可以由编译时完成,无需运行时开销。

9.3 展望

近期(2026 上半年):

  • Vue 3.6 正式版发布

  • Vapor Mode 稳定性提升

  • 主流 UI 库开始适配

中期:

  • Nuxt 等框架集成 Vapor Mode

  • DevTools 支持完善

  • 更多性能优化场景验证

长期:

  • Vapor Mode 可能成为新项目的默认选择

  • Vue 的性能标签从「易用但稍慢」升级为「易用且极致」

  • 推动行业进一步向编译时优化演进

9.4 给开发者的建议

  1. 保持关注:Vue 3.6 正式版发布时,是评估 Vapor Mode 的最佳时机

  2. 小范围试点:在非关键项目中尝试 Vapor Mode,积累第一手经验

  3. 优化意识:即使暂时不迁移Vapor Mode,理解其背后的编译优化思路也有助于写出更高效的 Vue 代码

  4. 拥抱变化:前端技术演进迅速,保持学习心态,享受框架进化带来的红利

Vapor Mode 不是噱头,它是 Vue 回应时代变化、追求技术极致的产物。当 Svelte 和 SolidJS 已经证明了「无虚拟 DOM」路线的可行性,Vue 选择加入这场变革——不是抛弃自己的特色,而是在保持 Vue 灵魂(优雅的 API、渐进式理念、绝佳的开发体验)的同时,补上了性能这块短板。

这场前端渲染技术的范式转移,正在发生。Vue 3.6,是一个重要的节点。

参考资料:

本文由AI辅助整理

昨天以前首页

contenteditable 深度剖析:让网页元素「活」起来

作者 Momo__
2026年4月27日 10:17

contenteditable 深度剖析:让网页元素「活」起来

前端开发者必备技能 | 深入理解 HTML 可编辑属性

📖 基本概念

contenteditable 是什么?

contenteditable 是 HTML5 的一个全局属性(Global Attribute) ,可以让任意 HTML 元素变成可编辑区域。用户可以直接点击元素并修改其内容,无需使用传统的 <input><textarea> 表单元素。

<!-- 最简单用法 -->
<div contenteditable="true">点击这里编辑我</div>

<!-- 等价于 -->
<div contenteditable>我也是可编辑的</div>

属性值说明

说明 示例
true 启用编辑 <div contenteditable="true">
false 禁用编辑 <div contenteditable="false">
plaintext-only 仅纯文本(禁止富文本) <input> 行为类似
空字符串/inherit 继承父元素或默认可编辑 <div contenteditable>
<!-- plaintext-only 场景:评论框只需要纯文本 -->
<article contenteditable="plaintext-only">
  这里只能输入纯文本,富文本格式会被过滤
</article>

浏览器支持情况

现代浏览器全覆盖,包括:

浏览器 支持版本
Chrome 4.0+
Firefox 3.5+
Safari 3.1+
Edge 12+
IE 6.0+(功能有限)

⚠️ 注意:虽然所有现代浏览器都支持,但行为存在差异,需要针对性处理。

⚡ 核心特性

可编辑区域的行为特性

  1. 原生光标(Carets) :自动显示插入符

  2. 文本选择:支持鼠标选中文本

  3. 富文本支持:用户可以输入带格式的文本

  4. 键盘交互:支持快捷键(Ctrl+B 加粗等)

  5. 拖拽操作:支持在元素内拖拽文本

contenteditable vs 表单元素

特性 contenteditable input/textarea
内容格式 HTML 片段(富文本) 纯文本
样式控制 灵活(继承父样式) 受限
语义化
表单提交 需手动处理 自动
XSS 风险
<!-- textarea 的 value 是纯文本 -->
<textarea id="ta">你好</textarea>
<script>
  console.log(document.getElementById('ta').value); // "你好"
</script>

<!-- contenteditable 的 innerHTML 是 HTML -->
<div contenteditable="true">你好</div>
<script>
  // 用户可能输入 <strong>粗体</strong>
  console.log(editor.innerHTML); // "你好" 或 "<strong>粗体</strong>"
</script>

默认的富文本能力

contenteditable="true" 时,浏览器天然支持:

  • 富文本粘贴:从网页复制的带格式内容会保留样式

  • 撤销/重做:Ctrl+Z / Ctrl+Shift+Z

  • 拖拽重新排列:选中文本可拖拽移动位置

  • 浏览器内置格式化:Ctrl+B/I/U 等

🎯 使用场景分析

1. 在线富文本编辑器

最简单的富文本编辑器实现:

<div contenteditable="true" 
     id="editor"
     style="border: 1px solid #ccc; min-height: 200px; padding: 16px;">
</div>

<button onclick="format('bold')">加粗</button>
<button onclick="format('italic')">斜体</button>

<script>
function format(cmd) {
  document.execCommand(cmd, false, null);
}
</script>

2. 可编辑表格

CMS 系统中常见的需求:

<table border="1" style="border-collapse: collapse; width: 100%;">
  <tr>
    <th>商品名称</th>
    <th>价格</th>
    <th>库存</th>
  </tr>
  <tr>
    <td contenteditable="true">iPhone 15</td>
    <td contenteditable="true">5999</td>
    <td contenteditable="true">100</td>
  </tr>
  <tr>
    <td contenteditable="true">MacBook Pro</td>
    <td contenteditable="true">12999</td>
    <td contenteditable="true">50</td>
  </tr>
</table>

<script>
// 保存表格数据
document.querySelectorAll('table').forEach(table => {
  table.addEventListener('blur', (e) => {
    if (e.target.isContentEditable) {
      console.log('Cell updated:', e.target.textContent);
      // 发送到服务器
    }
  }, true);
});
</script>

3. 即时编辑(Click-to-Edit)

用户点击标题直接编辑,类似于 Notion/Figma 的体验:

<h1 class="editable-title" contenteditable="true" 
    data-placeholder="输入标题...">
  点击这里编辑标题
</h1>

<style>
.editable-title {
  outline: none;
  border-bottom: 2px dashed transparent;
  transition: border-color 0.2s;
}
.editable-title:focus {
  border-bottom-color: #4285f4;
}
.editable-title:empty::before {
  content: attr(data-placeholder);
  color: #999;
}
</style>

<script>
document.querySelector('.editable-title').addEventListener('blur', function() {
  saveToServer(this.textContent);
});
</script>

4. 评论/笔记区域

轻量级笔记应用:

<div id="notes" contenteditable="true" 
     style="white-space: pre-wrap;"
     data-placeholder="在这里记录笔记...">
</div>

<script>
// 自动保存到 localStorage
const notes = document.getElementById('notes');
const saved = localStorage.getItem('user-notes');

if (saved) {
  notes.innerHTML = saved;
}

notes.addEventListener('input', debounce(() => {
  localStorage.setItem('user-notes', notes.innerHTML);
}, 500));

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}
</script>

5. 协作编辑场景

配合 WebSocket 或 WebRTC 实现实时协作:

// 基础协作框架示例
class CollaborativeEditor {
  constructor(element) {
    this.element = element;
    this.socket = new WebSocket('ws://your-server');
    
    // 监听本地变化
    this.element.addEventListener('input', () => {
      this.broadcast(this.getContent());
    });
    
    // 接收远程变化
    this.socket.onmessage = (event) => {
      const { content, userId } = JSON.parse(event.data);
      if (userId !== this.userId) {
        this.setContent(content);
      }
    };
  }
  
  getContent() {
    return this.element.innerHTML;
  }
  
  setContent(html) {
    // 保存光标位置
    const selection = window.getSelection();
    const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
    
    this.element.innerHTML = html;
    
    // 恢复光标
    // ... 光标恢复逻辑
  }
  
  broadcast(content) {
    this.socket.send(JSON.stringify({
      content,
      userId: this.userId
    }));
  }
}

💻 代码示例

基础用法

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>ContentEditable 基础示例</title>
  <style>
    .editor {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 20px;
      min-height: 150px;
      font-size: 16px;
      line-height: 1.6;
      outline: none;
      transition: border-color 0.2s, box-shadow 0.2s;
    }
    .editor:focus {
      border-color: #4285f4;
      box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
    }
    .editor:empty::before {
      content: attr(data-placeholder);
      color: #aaa;
      pointer-events: none;
    }
  </style>
</head>
<body>
  <div class="editor" 
       contenteditable="true" 
       data-placeholder="输入内容...">初始内容</div>
  
  <p>HTML 内容:<span id="html-output"></span></p>
  <p>纯文本内容:<span id="text-output"></span></p>
  
  <script>
    const editor = document.querySelector('.editor');
    const htmlOutput = document.getElementById('html-output');
    const textOutput = document.getElementById('text-output');
    
    // 获取内容
    function updateOutput() {
      htmlOutput.textContent = editor.innerHTML;
      textOutput.textContent = editor.textContent;
    }
    
    editor.addEventListener('input', updateOutput);
    updateOutput();
    
    // 监听粘贴,保留纯文本
    editor.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      document.execCommand('insertText', false, text);
    });
  </script>
</body>
</html>

获取/设置内容

const editor = document.getElementById('editor');

// 获取内容
const htmlContent = editor.innerHTML;      // 包含 HTML 标签
const textContent = editor.textContent;    // 仅纯文本
const innerText = editor.innerText;        // 仅纯文本(尊重CSS)

// 设置内容
editor.innerHTML = '<p>新内容</p>';

// 追加内容
editor.innerHTML += '<span>追加内容</span>';

// 安全的追加方式
function safeAppend(element, html) {
  const fragment = document.createDocumentFragment();
  const temp = document.createElement('div');
  temp.innerHTML = html;
  while (temp.firstChild) {
    fragment.appendChild(temp.firstChild);
  }
  element.appendChild(fragment);
}

实现简单的富文本功能

class SimpleEditor {
  constructor(element) {
    this.editor = element;
    this.setupToolbar();
    this.setupKeyboardShortcuts();
  }
  
  setupToolbar() {
    document.querySelectorAll('[data-command]').forEach(btn => {
      btn.addEventListener('click', () => {
        const cmd = btn.dataset.command;
        const value = btn.dataset.value || null;
        
        if (cmd === 'createlink') {
          const url = prompt('输入链接地址:');
          if (url) this.exec(cmd, false, url);
        } else if (cmd === 'insertImage') {
          const url = prompt('输入图片地址:');
          if (url) this.exec(cmd, false, url);
        } else {
          this.exec(cmd, false, value);
        }
      });
    });
  }
  
  setupKeyboardShortcuts() {
    this.editor.addEventListener('keydown', (e) => {
      if (e.ctrlKey || e.metaKey) {
        switch (e.key.toLowerCase()) {
          case 'b': e.preventDefault(); this.exec('bold'); break;
          case 'i': e.preventDefault(); this.exec('italic'); break;
          case 'u': e.preventDefault(); this.exec('underline'); break;
          case 's': e.preventDefault(); this.exec('save'); break;
        }
      }
    });
  }
  
  exec(command, showUI, value) {
    if (command === 'save') {
      this.save();
    } else {
      document.execCommand(command, showUI, value);
    }
  }
  
  exec(command, showUI, value) {
    document.execCommand(command, showUI, value);
  }
  
  save() {
    console.log('HTML:', this.editor.innerHTML);
    console.log('Text:', this.editor.textContent);
    // 发送到服务器
    fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify({ content: this.editor.innerHTML }),
      headers: { 'Content-Type': 'application/json' }
    });
  }
  
  getContent() {
    return this.editor.innerHTML;
  }
  
  setContent(html) {
    this.editor.innerHTML = html;
  }
  
  clear() {
    this.editor.innerHTML = '';
  }
}

// 使用
const editor = new SimpleEditor(document.getElementById('editor'));

与 Selection/Range API 配合

现代替代 execCommand 的方式:

// 获取选区
function getSelectionInfo() {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return null;
  
  const range = selection.getRangeAt(0);
  return {
    text: range.toString(),
    startContainer: range.startContainer,
    startOffset: range.startOffset,
    endContainer: range.endContainer,
    endOffset: range.endOffset,
    collapsed: range.collapsed
  };
}

// 包裹选中内容
function wrapSelection(tagName, attributes = {}) {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return;
  
  const range = selection.getRangeAt(0);
  if (range.collapsed) {
    console.warn('没有选中文本');
    return;
  }
  
  const element = document.createElement(tagName);
  Object.entries(attributes).forEach(([key, value]) => {
    element.setAttribute(key, value);
  });
  
  try {
    range.surroundContents(element);
  } catch (e) {
    // 选区跨越多个节点时,需要使用 extractContents
    console.warn('选区跨越多个节点,使用备用方案');
  }
}

// 示例:加粗选中文本
function boldSelection() {
  wrapSelection('strong');
}

// 示例:创建链接
function linkSelection(url) {
  wrapSelection('a', { href: url, target: '_blank' });
}

// 在光标位置插入内容
function insertAtCursor(html) {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return;
  
  const range = selection.getRangeAt(0);
  range.deleteContents();
  
  const fragment = document.createRange().createContextualFragment(html);
  const lastNode = fragment.lastChild;
  
  range.insertNode(fragment);
  
  // 将光标移动到插入内容之后
  if (lastNode) {
    range.setStartAfter(lastNode);
    range.collapse(true);
    selection.removeAllRanges();
    selection.addRange(range);
  }
}

// 示例:在光标位置插入表情
function insertEmoji(emoji) {
  insertAtCursor(`<span class="emoji">${emoji}</span>`);
}

⚠️ 注意事项与坑点

1. XSS 安全问题

这是 contenteditable 最大的坑! 用户输入的内容会被浏览器解析为 HTML。

<!-- 恶意输入示例 -->
<div contenteditable="true">
  <img src onerror="alert('XSS!')">
  <script>document.cookie</script>
  <div onclick="stealData()">点我</div>
</div>

防御方案

// ❌ 危险:直接输出用户输入
div.innerHTML = userInput;

// ✅ 安全方案 1:转义 HTML
function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

// ✅ 安全方案 2:使用 DOMPurify 白名单过滤
import DOMPurify from 'dompurify';

function sanitize(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['class']
  });
}

// ✅ 安全方案 3:使用 beforeinput 拦截
editor.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'insertFromPaste') {
    e.preventDefault();
    // 自定义粘贴处理
    const text = e.getTargetRanges()[0].text;
    document.execCommand('insertText', false, text);
  }
});

2. 样式继承问题

contenteditable 会继承父元素的许多样式:

/* ❌ 问题:输入的文字可能继承奇怪的颜色 */
.parent {
  color: red;
  font-family: cursive;
}

/* ✅ 解决方案:明确设置 */
[contenteditable] {
  color: inherit;
  font-family: inherit;
  font-size: inherit;
  /* 关键:允许继承但可被覆盖 */
}

/* ✅ 更好的方案:使用 plaintext-only */
[contenteditable="plaintext-only"] {
  all: unset;  /* 重置所有继承 */
  display: block;
  /* 然后显式设置需要的样式 */
}

3. 焦点管理

// ❌ 问题:程序设置内容会丢失光标
editor.innerHTML = 'new content';  // 光标位置丢失

// ✅ 正确做法:保存和恢复光标
function setContentPreservingCursor(element, html) {
  const selection = window.getSelection();
  const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
  
  // 保存相对位置
  let startOffset = 0, endOffset = 0;
  let startNode, endNode;
  
  if (range) {
    const preRange = document.createRange();
    preRange.selectNodeContents(element);
    preRange.setEnd(range.startContainer, range.startOffset);
    startOffset = preRange.toString().length;
    
    preRange.setEnd(range.endContainer, range.endOffset);
    endOffset = preRange.toString().length;
    
    startNode = range.startContainer;
    endNode = range.endContainer;
  }
  
  element.innerHTML = html;
  
  // 恢复位置(简化版,实际需要更复杂)
  if (range) {
    const newRange = document.createRange();
    // ... 恢复逻辑
  }
}

// ✅ 更简洁的方案:使用 beforeinput 事件

4. 换行行为差异

不同浏览器按 Enter 键产生的 HTML 元素不同:

浏览器 产生的元素
Chrome <div>
Firefox <br>
Safari <p>
// ✅ 解决方案:统一换行行为
editor.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) {
    // 检测浏览器并插入统一的元素
    const browser = detectBrowser();
    
    if (browser === 'chrome') {
      e.preventDefault();
      document.execCommand('insertLineBreak');
    }
  }
});

// ✅ 更好的方案:在初始化时统一配置
document.execCommand('defaultParagraphSeparator', false, 'p');

5. 粘贴内容过滤

editor.addEventListener('paste', (e) => {
  e.preventDefault();
  
  // 获取剪贴板内容
  const clipboardData = e.clipboardData || window.clipboardData;
  
  // 方式 1:只粘贴纯文本(最安全)
  const text = clipboardData.getData('text/plain');
  document.execCommand('insertText', false, text);
  
  // 方式 2:粘贴但过滤危险标签
  const html = clipboardData.getData('text/html');
  if (html) {
    const sanitized = DOMPurify.sanitize(html, {
      ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
      ALLOWED_ATTR: ['href', 'target']
    });
    document.execCommand('insertHTML', false, sanitized);
  }
});

6. MutationObserver 监听变化

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    switch (mutation.type) {
      case 'characterData':
        console.log('文本变化:', mutation.target.textContent);
        break;
      case 'childList':
        console.log('子节点变化');
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            console.log('新增元素:', node.tagName);
          }
        });
        break;
    }
  });
});

observer.observe(editor, {
  characterData: true,
  childList: true,
  subtree: true
});

// 清理
// observer.disconnect();

🔧 相关 API

document.execCommand(已废弃但仍在用)

// 常用命令
document.execCommand('bold', false, null);           // 加粗
document.execCommand('italic', false, null);          // 斜体
document.execCommand('underline', false, null);      // 下划线
document.execCommand('strikeThrough', false, null);  // 删除线
document.execCommand('createLink', false, url);      // 创建链接
document.execCommand('insertImage', false, url);      // 插入图片
document.execCommand('formatBlock', false, 'p');     // 段落格式
document.execCommand('insertUnorderedList', false, null); // 无序列表
document.execCommand('insertOrderedList', false, null);   // 有序列表
document.execCommand('undo', false, null);           // 撤销
document.execCommand('redo', false, null);           // 重做
document.execCommand('selectAll', false, null);       // 全选

// 检查命令支持
if (document.queryCommandSupported('bold')) {
  document.execCommand('bold', false, null);
}

⚠️ 警告execCommand 已被 MDN 标记为废弃,但目前在所有浏览器中仍可使用。对于简单场景可以直接使用,对于复杂编辑器建议使用 Selection/Range API。

Selection API

const selection = window.getSelection();

// 获取选中的文本
console.log(selection.toString());

// 获取 Range 对象
if (selection.rangeCount > 0) {
  const range = selection.getRangeAt(0);
  
  // 常用属性
  console.log(range.startContainer);   // 起始容器节点
  console.log(range.startOffset);      // 起始偏移量
  console.log(range.endContainer);      // 结束容器节点
  console.log(range.endOffset);        // 结束偏移量
  console.log(range.collapsed);        // 是否折叠(无选中)
  console.log(range.commonAncestorContainer); // 共同祖先
  
  // 方法
  range.deleteContents();              // 删除选中内容
  range.extractContents();             // 提取选中内容(从 DOM 移除)
  range.cloneContents();               // 克隆选中内容
  range.insertNode(node);              // 插入节点
  range.surroundContents(node);        // 用节点包裹选中内容
}

// 设置选区
const newRange = document.createRange();
newRange.setStart(node, offset);
newRange.setEnd(node, offset);
selection.removeAllRanges();
selection.addRange(newRange);

// 折叠选区
selection.collapseToStart();  // 折叠到起始位置
selection.collapseToEnd();     // 折叠到结束位置

// 全选
selection.selectAllChildren(element);

Range API

const range = document.createRange();

// 设置边界
range.setStart(node, offset);
range.setEnd(node, offset);

// 便捷方法
range.selectNode(node);           // 选中整个节点
range.selectNodeContents(node);    // 选中节点内容
range.setStartBefore(node);       // 开始于节点前
range.setStartAfter(node);        // 开始于节点后
range.setEndBefore(node);         // 结束于节点前
range.setEndAfter(node);          // 结束于节点后

// 比较位置
range.compareBoundaryPoints('START_TO_START', otherRange);
range.compareBoundaryPoints('START_TO_END', otherRange);
range.compareBoundaryPoints('END_TO_END', otherRange);
range.compareBoundaryPoints('END_TO_START', otherRange);

// 操作内容
range.cloneContents();      // 克隆选中内容
range.deleteContents();      // 删除选中内容
range.extractContents();     // 提取内容
range.insertNode(node);     // 插入节点
range.surroundContents(node); // 包裹内容

// 复制粘贴
range.cloneRange();         // 克隆范围
range.detach();            // 释放范围(优化性能)

// 折叠
range.collapse(true);       // 折叠到起点
range.collapse(false);      // 折叠到终点

Input 事件

const editor = document.getElementById('editor');

// input 事件:内容变化后触发
editor.addEventListener('input', () => {
  console.log('内容变化:', editor.innerHTML);
  saveContent();
});

// beforeinput 事件:内容变化前触发,可取消
editor.addEventListener('beforeinput', (e) => {
  // 拦截粘贴为纯文本
  if (e.inputType === 'insertFromPaste') {
    e.preventDefault();
    const text = e.clipboardData.getData('text/plain');
    insertText(text);
  }
  
  // 限制字数
  if (editor.textContent.length >= MAX_LENGTH && 
      e.inputType === 'insertText') {
    e.preventDefault();
  }
});

// compositionstart/end:处理输入法
editor.addEventListener('compositionstart', () => {
  console.log('开始输入中文...');
});
editor.addEventListener('compositionend', () => {
  console.log('中文输入完成');
  handleInput();
});

InputEvent 的 inputType 枚举

// 常用 inputType 值
'insertText'           // 插入文本
'insertLineBreak'      // 插入换行
'insertParagraph'      // 插入段落
'insertOrderedList'    // 插入有序列表
'insertUnorderedList'  // 插入无序列表
'insertFromPaste'      // 从粘贴板粘贴
'formatBold'           // 格式-加粗
'formatItalic'         // 格式-斜体
'formatUnderline'      // 格式-下划线
'deleteContentBackward' // 删除前一个字符
'deleteContentForward'  // 删除后一个字符
'deleteWordBackward'    // 删除前一个单词
'deleteWordForward'     // 删除后一个单词

✅ 最佳实践

安全的内容处理

// 1. 永远不要相信用户输入
function sanitizeUserInput(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: [
      'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
      'p', 'br', 'hr',
      'ul', 'ol', 'li',
      'blockquote',
      'a', 'img',
      'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup',
      'code', 'pre'
    ],
    ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target'],
    ALLOW_DATA_ATTR: false
  });
}

// 2. 显示时二次转义
function escapeHtml(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

// 3. CSP 配置
// Content-Security-Policy: script-src 'self'; style-src 'self' 'unsafe-inline'

数据绑定方案

class ContentEditor {
  constructor(element, options = {}) {
    this.element = element;
    this.options = {
      placeholder: options.placeholder || '',
      onChange: options.onChange || (() => {}),
      debounceMs: options.debounceMs || 300,
      ...options
    };
    
    this.init();
  }
  
  init() {
    this.element.contentEditable = 'true';
    this.element.dataset.placeholder = this.options.placeholder;
    
    // 初始化内容
    if (this.options.initialValue) {
      this.element.innerHTML = this.options.initialValue;
    }
    
    this.bindEvents();
  }
  
  bindEvents() {
    // 防抖保存
    let timer;
    this.element.addEventListener('input', () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        this.options.onChange(this.getContent());
      }, this.options.debounceMs);
    });
    
    // 失去焦点时立即保存
    this.element.addEventListener('blur', () => {
      clearTimeout(timer);
      this.options.onChange(this.getContent());
    });
    
    // 粘贴过滤
    this.element.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      document.execCommand('insertText', false, text);
    });
  }
  
  getContent() {
    return this.element.innerHTML;
  }
  
  getText() {
    return this.element.textContent;
  }
  
  setContent(html) {
    this.element.innerHTML = html;
  }
  
  clear() {
    this.element.innerHTML = '';
  }
  
  focus() {
    this.element.focus();
  }
}

// 使用
const editor = new ContentEditor(document.getElementById('editor'), {
  initialValue: '<p>Hello World</p>',
  placeholder: '输入内容...',
  onChange: (content) => {
    console.log('保存:', content);
    localStorage.setItem('draft', content);
  },
  debounceMs: 500
});

富文本编辑器推荐

对于生产环境,建议使用成熟的富文本编辑器库:

特点 适用场景
TinyMCE 功能全面、插件丰富、企业级 企业应用、CMS
Quill 轻量、API 简洁、文档友好 轻量级应用
Tiptap Vue/React 友好、扩展性强 现代 SPA
Slate.js 完全可定制、插件化 高度定制需求
ProseMirror Schema 驱动、协作支持 复杂文档、协作
Editor.js 块编辑、JSON 输出 博客、笔记

🚀 现代替代方案

Quill 2.0

import Quill from 'quill';

// 初始化
const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      ['link', 'image', 'blockquote', 'code-block'],
      ['clean']
    ]
  }
});

// 获取/设置内容
quill.on('text-change', () => {
  console.log('HTML:', quill.root.innerHTML);
  console.log('Delta:', quill.getContents());
});

quill.setContents({
  ops: [
    { insert: 'Hello ' },
    { insert: 'World', attributes: { bold: true } },
    { insert: '!\n' }
  ]
});

Tiptap

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';

const editor = new Editor({
  element: document.querySelector('#editor'),
  extensions: [StarterKit],
  content: '<p>Hello World!</p>',
  onUpdate: ({ editor }) => {
    console.log(editor.getHTML());
    console.log(editor.getJSON());
  }
});

// 命令
editor.chain().focus().toggleBold().run();
editor.chain().focus().setParagraph().run();

小型项目:使用 contenteditable + Selection API

// 极简富文本框(无依赖)
class MinimalEditor {
  constructor(container) {
    this.container = container;
    this.editor = document.createElement('div');
    this.editor.contentEditable = true;
    this.editor.className = 'minimal-editor';
    this.container.appendChild(this.editor);
    
    this.setupStyles();
    this.setupToolbar();
    this.setupPasteHandler();
  }
  
  setupStyles() {
    const style = document.createElement('style');
    style.textContent = `
      .minimal-editor {
        border: 1px solid #ddd;
        padding: 16px;
        min-height: 100px;
        outline: none;
      }
      .minimal-editor:focus { border-color: #4285f4; }
      .minimal-editor-toolbar { margin-bottom: 8px; }
      .minimal-editor button {
        padding: 4px 8px;
        margin-right: 4px;
        cursor: pointer;
      }
    `;
    document.head.appendChild(style);
  }
  
  setupToolbar() {
    const toolbar = document.createElement('div');
    toolbar.className = 'minimal-editor-toolbar';
    toolbar.innerHTML = `
      <button type="button" data-cmd="bold"><b>B</b></button>
      <button type="button" data-cmd="italic"><i>I</i></button>
      <button type="button" data-cmd="underline"><u>U</u></button>
      <button type="button" data-cmd="createLink">🔗</button>
    `;
    
    toolbar.addEventListener('click', (e) => {
      const btn = e.target.closest('button');
      if (!btn) return;
      
      const cmd = btn.dataset.cmd;
      if (cmd === 'createLink') {
        const url = prompt('URL:');
        if (url) this.exec('createLink', url);
      } else {
        this.exec(cmd);
      }
    });
    
    this.container.insertBefore(toolbar, this.editor);
  }
  
  setupPasteHandler() {
    this.editor.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      this.exec('insertText', text);
    });
  }
  
  exec(cmd, value = null) {
    document.execCommand(cmd, false, value);
  }
  
  getContent() {
    return this.editor.innerHTML;
  }
  
  getText() {
    return this.editor.textContent;
  }
}

// 使用
const editor = new MinimalEditor(document.getElementById('container'));

📋 总结

什么时候用 contenteditable?

适合的场景

  • 轻量级富文本编辑(笔记、评论)
  • 即时编辑(click-to-edit)
  • 可编辑表格/列表
  • 需要灵活布局的编辑区域
  • 原型/内部工具

不适合的场景

  • 企业级文档编辑(用 TinyMCE)
  • 需要复杂协作(用 Tiptap/ProseMirror + Yjs)
  • 严格的格式控制(用成熟的编辑器库)
  • 对 XSS 零容忍(除非做好完整防护)

关键要点

  1. 安全第一:永远不要信任用户输入,使用 DOMPurify 等库进行过滤
  2. 关注差异:不同浏览器的行为差异需要针对性处理
  3. 光标管理:修改内容后记得恢复光标位置
  4. 渐进增强:从简单开始,必要时引入编辑器库
  5. 替代方案:生产环境优先考虑成熟的编辑器库

📝 写在最后

contenteditable 是一个「入门简单、深坑不少」的属性。它能快速实现富文本编辑,但要在生产环境稳定使用,需要处理大量的浏览器兼容性和安全问题。

建议:如果是个人项目或内部工具,直接使用 contenteditable 足够;如果是面向用户的产品,强烈建议使用 TinyMCE、Quill 或 Tiptap 等成熟方案。

文档由AI辅助整理

❌
❌