在 Nuxt 3 中实现富文本渲染组件

499 阅读7分钟

富文本渲染是我们切图仔们非常常见需求,特别是在博客、资讯或内容管理系统中。本文将详细介绍如何在 Nuxt 3 中实现一个安全且功能丰富的富文本渲染组件。我们将从基本需求开始,讨论安全性问题,逐步实现一个基础组件,并扩展到可以定制主题和点缀颜色的高级组件

需求分析

在实现富文本渲染组件之前,我们需要明确以下需求:

  1. 安全性:防止跨站脚本攻击(XSS)。
  2. 可扩展性:支持自定义样式和主题。
  3. 易用性:简单易用,易于集成到现有项目中。

安全性问题

在富文本渲染中,用户可以输入 HTML 内容,这些内容可能包含恶意脚本或不安全的标签。为了防止 XSS 攻击,我们需要对用户输入的 HTML 进行清理。我们将使用 DOMPurify 库来确保渲染的内容是安全的。

DOMPurify 是一个用于清理和消毒 HTML 的 JavaScript 库,主要用于防止跨站脚本攻击(XSS)。在富文本渲染中,用户可以输入 HTML 内容,这些内容可能包含恶意脚本或不安全的标签。DOMPurify 通过移除或转义这些不安全的内容,确保渲染的 HTML 是安全的。

具体来说,DOMPurify 的主要功能包括:

  1. 移除恶意脚本:删除或转义 HTML 中的 <script> 标签及其内容,防止执行恶意 JavaScript 代码。
  2. 过滤不安全的属性:移除或转义 HTML 标签中的不安全属性,例如 onloadonclick 等事件处理器,这些属性可能被用来注入恶意代码。
  3. 处理不安全的 URL:过滤掉可能指向恶意网站或包含恶意代码的 URL,例如 javascript: 协议的链接。
  4. 自定义规则:允许开发者根据需要自定义允许或禁止的标签和属性,以满足特定的安全需求。

安装 DOMPurify

首先,在 Nuxt 3 项目中安装 DOMPurify:

npm install dompurify

创建 DOMPurify 插件

plugins 目录下创建一个 DOMPurify 插件文件 dompurify.ts

import DOMPurify from "dompurify";

export default defineNuxtPlugin((nuxtApp) => {
  return {
    provide: {
      domPurify: DOMPurify,
    },
  };
});

实现基础富文本渲染组件

接下来,我们创建一个基础的富文本渲染组件 RichTextRenderer.vue,确保内容在渲染时是安全的。

<template>
  <div v-if="isClient" class="rendered-content" v-html="sanitizedContent"></div>
</template>

<script setup>
const props = defineProps({
  content: {
    type: String,
    default: "",
  },
});

const sanitizedContent = ref("");
const isClient = ref(false);

if (process.client) {
  const { $domPurify } = useNuxtApp();

  watch(
    () => props.content,
    (newContent) => {
      sanitizedContent.value = $domPurify.sanitize(newContent);
    },
    { immediate: true }
  );

  onMounted(() => {
    isClient.value = true;
    sanitizedContent.value = $domPurify.sanitize(props.content);
  });
}
</script>

<style>
.rendered-content {
  box-sizing: border-box;
  padding: 20px;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  line-height: 1.6;
  font-family: Arial, sans-serif;
  transition: all 0.3s ease;
  color: #333;
}

.rendered-content h1,
.rendered-content h2,
.rendered-content h3,
.rendered-content h4,
.rendered-content h5,
.rendered-content h6 {
  margin-top: 1em;
  margin-bottom: 0.5em;
  font-weight: bold;
  color: #333;
}

.rendered-content p {
  margin: 0.5em 0;
  color: #555;
}

.rendered-content ul,
.rendered-content ol {
  margin: 0.5em 0;
  padding-left: 2em;
  color: #555;
}

.rendered-content ul li {
  position: relative;
  padding-left: 1em;
}

.rendered-content ul li::before {
  content: "•";
  position: absolute;
  left: 0;
  color: #333;
}

.rendered-content ol li {
  margin-bottom: 0.5em;
}

.rendered-content code {
  background: #f5f5f5;
  padding: 0.2em 0.4em;
  border-radius: 4px;
  font-family: "Courier New", monospace;
}

.rendered-content pre {
  background: #f5f5f5;
  padding: 1em;
  border-radius: 8px;
  overflow-x: auto;
  font-family: "Courier New", monospace;
  box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1);
}

.rendered-content blockquote {
  border-left: 4px solid #ccc;
  padding: 10px;
  padding-left: 1em;
  margin: 0.5em 0;
  color: #666;
  font-style: italic;
  background: #f9f9f9;
  border-radius: 4px;
}

.rendered-content img {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 1em 0;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}

.rendered-content video {
  width: 75%;
  max-width: 75%;
  height: auto;
  display: block;
  margin: 30px auto;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}

.rendered-content iframe {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 1em 0;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}

.rendered-content img:hover,
.rendered-content video:hover,
.rendered-content iframe:hover {
  transform: scale(1.05);
}

.rendered-content table {
  width: 100%;
  border-collapse: collapse;
  margin: 1em 0;
  background-color: #fafafa;
}

.rendered-content th,
.rendered-content td {
  border: 1px solid #ddd;
  padding: 8px;
}

.rendered-content th {
  background-color: #f2f2f2;
  text-align: left;
}

.rendered-content tr:nth-child(even) {
  background-color: #f9f9f9;
}

.rendered-content tr:hover {
  background-color: #f1f1f1;
}
</style>

使用基础组件

在你的页面或其他组件中使用 RichTextRenderer 组件:

<template>
  <div>
    <RichTextRenderer :content="richTextContent" />
  </div>
</template>

<script setup>

const richTextContent = `
  <h1>标题</h1>
  <p>这是一个段落。</p>
  <ul>
    <li>列表项 1</li>
    <li>列表项 2</li>
  </ul>
  <img src="https://via.placeholder.com/150" alt="示例图片">
`;
</script>

扩展组件:支持自定义主题和点缀颜色

为了使组件更加灵活和可定制,我们可以扩展基础组件,支持自定义主题和点缀颜色。我们将通过 props 传递这些自定义参数。

扩展组件代码

<template>
  <div v-if="isClient" :class="['rendered-content', themeClass]" v-html="sanitizedContent"></div>
</template>

<script setup>
const props = defineProps({
  content: {
    type: String,
    default: "",
  },
  theme: {
    type: String,
    default: "light",
  },
  accentColor: {
    type: String,
    default: "#333",
  },
});

const sanitizedContent = ref("");
const isClient = ref(false);

const themeClass = computed(() => `theme-${props.theme}`);

if (process.client) {
  const { $domPurify } = useNuxtApp();

  watch(
    () => props.content,
    (newContent) => {
      sanitizedContent.value = $domPurify.sanitize(newContent);
    },
    { immediate: true }
  );

  onMounted(() => {
    isClient.value = true;
    sanitizedContent.value = $domPurify.sanitize(props.content);
  });
}
</script>

<style>
.rendered-content {
  box-sizing: border-box;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  line-height: 1.6;
  font-family: Arial, sans-serif;
  transition: all 0.3s ease;
}

.rendered-content.theme-light {
  background: #fff;
  color: #333;
}

.rendered-content.theme-dark {
  background: #333;
  color: #fff;
}

.rendered-content h1,
.rendered-content h2,
.rendered-content h3,
.rendered-content h4,
.rendered-content h5,
.rendered-content h6 {
  margin-top: 1em;
  margin-bottom: 0.5em;
  font-weight: bold;
}

.rendered-content p {
  margin: 0.5em 0;
}

.rendered-content ul,
.rendered-content ol {
  margin: 0.5em 0;
  padding-left: 2em;
}

.rendered-content ul li {
  position: relative;
  padding-left: 1em;
}

.rendered-content ul li::before {
  content: "•";
  position: absolute;
  left: 0;
}

.rendered-content ol li {
  margin-bottom: 0.5em;
}

.rendered-content code {
  background: #f5f5f5;
  padding: 0.2em 0.4em;
  border-radius: 4px;
  font-family: "Courier New", monospace;
}

.rendered-content pre {
  background: #f5f5f5;
  padding: 1em;
  border-radius: 8px;
  overflow-x: auto;
  font-family: "Courier New", monospace;
  box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1);
}

.rendered-content blockquote {
  border-left: 4px solid #ccc;
  padding: 10px;
  padding-left: 1em;
  margin: 0.5em 0;
  font-style: italic;
  background: #f9f9f9;
  border-radius: 4px;
}

.rendered-content img {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 1em 0;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}

.rendered-content video {
  width: 75%;
  max-width: 75%;
  height: auto;
  display: block;
  margin: 30px auto;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}

.rendered-content iframe {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 1em 0;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
}

.rendered-content img:hover,
.rendered-content video:hover,
.rendered-content iframe:hover {
  transform: scale(1.05);
}

.rendered-content table {
  width: 100%;
  border-collapse: collapse;
  margin: 1em 0;
  background-color: #fafafa;
}

.rendered-content th,
.rendered-content td {
  border: 1px solid #ddd;
  padding: 8px;
}

.rendered-content th {
  background-color: #f2f2f2;
  text-align: left;
}

.rendered-content tr:nth-child(even) {
  background-color: #f9f9f9;
}

.rendered-content tr:hover {
  background-color: #f1f1f1;
}
</style>

使用扩展组件

在你的页面或其他组件中使用扩展的 RichTextRenderer 组件:

<template>
  <div>
    <RichTextRenderer :content="richTextContent" theme="dark" accentColor="#ff6600" />
  </div>
</template>

<script setup>
const richTextContent = `
  <h1>标题</h1>
  <p>这是一个段落。</p>
  <ul>
    <li>列表项 1</li>
    <li>列表项 2</li>
  </ul>
  <img src="https://via.placeholder.com/150" alt="示例图片">
`;
</script>

总结

本文详细介绍了如何在 Nuxt 3 中实现一个安全且功能丰富的富文本渲染组件。我们从基础需求和安全性问题入手,逐步实现了一个基础组件,并扩展到支持自定义主题和点缀颜色的高级组件。希望本文能帮助你在 Nuxt 3 项目中实现富文本渲染功能。

附件1——完整组件代码

<template>
  <div
    v-if="isClient"
    :class="['rendered-content', themeClass]"
    :style="customStyles"
    v-html="sanitizedContent"
  ></div>
</template>

<script setup lang="ts">
interface Theme {
  background: string;
  color: string;
  accentColor: string;
  secondaryColor?: string;
  lightestColor?: string;
}

const props = defineProps<{
  content: string;
  theme?: string;
  customBackground?: string;
  customColor?: string;
  customAccentColor?: string;
}>();

const themes: Record<string, Theme> = {
  default: {
    background: "#fff",
    color: "#333",
    accentColor: "",
  },
  purple: {
    background: "#f3f0ff",
    color: "#333", // 保持文字颜色不变
    accentColor: "#DD33FF",
  },
  dark: {
    background: "#202020",
    color: "#fff", // 保持文字颜色不变
    accentColor: "#B491FC",
  },
  teal: {
    background: "#e0f7f5",
    color: "#333", // 保持文字颜色不变
    accentColor: "#1cbbb4",
    secondaryColor: "#0a7eff",
    lightestColor: "#e6ffff",
  },
  coral: {
    background: "#fff5f5",
    color: "#333", // 保持文字颜色不变
    accentColor: "#e6aaaa",
    secondaryColor: "#f7776d",
    lightestColor: "#ffe6e6",
  },
  green: {
    background: "#e6fff5",
    color: "#333", // 保持文字颜色不变
    accentColor: "#00d25e",
    secondaryColor: "#0a7eff",
    lightestColor: "#e6ffe6",
  },
};

const sanitizedContent = ref<string>("");
const isClient = ref<boolean>(false);

const themeClass = computed(() => `theme-${props.theme}`);
const customStyles = computed(() => {
  const theme = props.theme ? themes[props.theme] : themes.default;
  return {
    background: props.customBackground || theme.background,
    color: props.customColor || theme.color,
    "--accent-color": props.customAccentColor || theme.accentColor,
    "--secondary-color": theme.secondaryColor || "",
    "--lightest-color": theme.lightestColor || "",
  } as Record<string, string>;
});

const { $domPurify } = useNuxtApp();

if (import.meta.client) {
  watch(
    () => props.content,
    (newContent) => {
      sanitizedContent.value = $domPurify.sanitize(newContent);
    },
    { immediate: true }
  );

  onMounted(() => {
    isClient.value = true;
    sanitizedContent.value = $domPurify.sanitize(props.content);
  });
}
</script>

<style>
.rendered-content {
  box-sizing: border-box;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  line-height: 1.6;
  font-family: Arial, sans-serif;
  transition: all 0.3s ease;
}

.rendered-content h1,
.rendered-content h2,
.rendered-content h3,
.rendered-content h4,
.rendered-content h5,
.rendered-content h6 {
  margin-top: 1em;
  margin-bottom: 0.5em;
  font-weight: bold;
}

/* 仅在非默认主题时添加发光前缀 */
.theme-purple h1,
.theme-purple h2,
.theme-purple h3,
.theme-purple h4,
.theme-purple h5,
.theme-purple h6,
.theme-dark h1,
.theme-dark h2,
.theme-dark h3,
.theme-dark h4,
.theme-dark h5,
.theme-dark h6,
.theme-teal h1,
.theme-teal h2,
.theme-teal h3,
.theme-teal h4,
.theme-teal h5,
.theme-teal h6,
.theme-coral h1,
.theme-coral h2,
.theme-coral h3,
.theme-coral h4,
.theme-coral h5,
.theme-coral h6,
.theme-green h1,
.theme-green h2,
.theme-green h3,
.theme-green h4,
.theme-green h5,
.theme-green h6,
.custom-theme h1,
.custom-theme h2,
.custom-theme h3,
.custom-theme h4,
.custom-theme h5,
.custom-theme h6 {
  display: flex;
  align-items: center;
}

.theme-purple h1::before,
.theme-purple h2::before,
.theme-purple h3::before,
.theme-purple h4::before,
.theme-purple h5::before,
.theme-purple h6::before,
.theme-dark h1::before,
.theme-dark h2::before,
.theme-dark h3::before,
.theme-dark h4::before,
.theme-dark h5::before,
.theme-dark h6::before,
.theme-teal h1::before,
.theme-teal h2::before,
.theme-teal h3::before,
.theme-teal h4::before,
.theme-teal h5::before,
.theme-teal h6::before,
.theme-coral h1::before,
.theme-coral h2::before,
.theme-coral h3::before,
.theme-coral h4::before,
.theme-coral h5::before,
.theme-coral h6::before,
.theme-green h1::before,
.theme-green h2::before,
.theme-green h3::before,
.theme-green h4::before,
.theme-green h5::before,
.theme-green h6::before,
.custom-theme h1::before,
.custom-theme h2::before,
.custom-theme h3::before,
.custom-theme h4::before,
.custom-theme h5::before,
.custom-theme h6::before {
  content: "";
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: var(--accent-color);
  margin-right: 10px;
  box-shadow: 0 0 10px var(--accent-color);
  transition: transform 0.3s ease;
}

.rendered-content p {
  margin: 0.5em 0;
}

.rendered-content ul,
.rendered-content ol {
  margin: 0.5em 0;
  padding-left: 2em;
}

.rendered-content ul li {
  position: relative;
  padding-left: 1em;
}

.rendered-content ul li::before {
  content: "•";
  position: absolute;
  left: 0;
  color: var(--accent-color);
}

.rendered-content ol li {
  margin-bottom: 0.5em;
}

.rendered-content code {
  background: #f5f5f5;
  padding: 0.2em 0.4em;
  border-radius: 4px;
  font-family: "Courier New", monospace;
}

.rendered-content pre {
  background: #f5f5f5;
  padding: 1em;
  border-radius: 8px;
  overflow-x: auto;
  font-family: "Courier New", monospace;
  box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1);
}

.rendered-content blockquote {
  border-left: 4px solid #ccc;
  padding: 10px;
  padding-left: 1em;
  margin: 0.5em 0;
  font-style: italic;
  background: #f9f9f9;
  border-radius: 4px;
}

.rendered-content img {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 1em 0;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease, box-shadow 0.3s ease;
}

.rendered-content video {
  width: 75%;
  max-width: 75%;
  height: auto;
  display: block;
  margin: 30px auto;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease, box-shadow 0.3s ease;
}

.rendered-content iframe {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 1em 0;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease, box-shadow 0.3s ease;
}

.rendered-content img:hover,
.rendered-content video:hover,
.rendered-content iframe:hover {
  transform: scale(1.05);
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}

.rendered-content table {
  width: 100%;
  border-collapse: collapse;
  margin: 1em 0;
  background-color: #fafafa;
  transition: background-color 0.3s ease, box-shadow 0.3s ease;
  text-align: center;
  border: 1px solid #33333333;
}

.rendered-content th,
.rendered-content td {
  border: 1px solid #ddd;
  padding: 8px;
}

.rendered-content th {
  background-color: #f2f2f2;
  text-align: center;
}

.rendered-content tr:nth-child(even) {
  background-color: #f9f9f9;
}

.rendered-content tr:hover {
  background-color: var(--lightest-color, #f1f1f1);
  box-shadow: none;
}

.rendered-content th:hover,
.rendered-content td:hover {
  background-color: var(--secondary-color, #f1f1f1);
  box-shadow: none;
}

/* 仅在使用非默认主题时生效 */
.theme-purple blockquote,
.theme-dark blockquote,
.theme-teal blockquote,
.theme-coral blockquote,
.theme-green blockquote,
.custom-theme blockquote {
  border-left-color: var(--accent-color);
}

/* 自定义颜色 */
:root {
  --accent-color: #333;
  --secondary-color: #666;
  --lightest-color: #f1f1f1;
}
</style>

附件2——使用示例

<template>
  <div class="r-main">
    <RichTextRenderer :content="a" />
    <!-- 使用预定义主题 -->
    <RichTextRenderer :content="a" theme="purple" />
    <RichTextRenderer :content="a" theme="teal" />
    <RichTextRenderer :content="a" theme="coral" />
    <RichTextRenderer :content="a" theme="green" />

    <!-- 使用自定义颜色 -->
    <RichTextRenderer
      :content="a"
      customBackground="#f0f0f0"
      customColor="#202020"
      customAccentColor="#ff6600"
    />
  </div>
</template>
<script setup lang="ts">
const a = `
<div>
  <h1>欢迎来到我们的博客</h1>
  <p>在这里,我们分享最新的技术文章、教程和资源,帮助您提升编程技能。</p>
  
  <h2>最新文章</h2>
  <p>以下是我们最近发布的一些文章:</p>
  
  <h3>1. 如何使用 Vue 3 构建现代 Web 应用</h3>
  <p>Vue 3 是一个渐进式的 JavaScript 框架,适用于构建用户界面。本文将介绍如何使用 Vue 3 构建一个简单的 Web 应用。</p>
  <ul>
    <li>安装 Vue CLI</li>
    <li>创建一个新项目</li>
    <li>组件的基本用法</li>
    <li>路由和状态管理</li>
  </ul>
  
  <h3>2. 深入理解 JavaScript 异步编程</h3>
  <p>JavaScript 的异步编程模型是前端开发中非常重要的一部分。本文将深入探讨回调、Promise 和 async/await 的使用方法。</p>
  <ol>
    <li>回调函数</li>
    <li>Promise</li>
    <li>async 和 await</li>
  </ol>
  
  <blockquote>
    “编程不仅仅是写代码,更是解决问题的艺术。” — 作者
  </blockquote>
  
  <h2>项目展示</h2>
  <p>我们还会展示一些由社区成员提交的优秀项目:</p>
  
  <h3>项目 1: 个人博客网站</h3>
  <p>这个项目展示了如何使用 Nuxt.js 构建一个静态生成的个人博客网站,具有 SEO 优化和高性能的特点。</p>
  <img src="https://via.placeholder.com/600x400" alt="个人博客网站截图">
  
  <h3>项目 2: 在线聊天应用</h3>
  <p>一个实时在线聊天应用,使用 WebSocket 实现即时通讯功能,前端使用 Vue.js,后端使用 Node.js 和 Express。</p>
  
  <h2>教程视频</h2>
  <p>观看我们最新发布的教程视频,学习更多编程技巧:</p>
  
  <video controls>
    <source src="https://www.w3schools.com/html/mov_bbb.mp4" type="video/mp4">
    您的浏览器不支持 HTML5 视频。
  </video>
  
  <h2>联系我们</h2>
  <p>如有任何问题或建议,请通过以下表格与我们联系:</p>
  
  <table>
    <thead>
      <tr>
        <th>姓名</th>
        <th>电子邮件</th>
        <th>消息</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>张三</td>
        <td>zhangsan@example.com</td>
        <td>我对您的文章非常感兴趣,希望能了解更多细节。</td>
      </tr>
      <tr>
        <td>李四</td>
        <td>lisi@example.com</td>
        <td>请问如何提交我的项目展示?</td>
      </tr>
    </tbody>
  </table>
  
  <h2>关注我们</h2>
  <p>关注我们的社交媒体,获取最新动态:</p>
  <iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</div>


`;
</script>
<style>
.r-main {
  width: 100%;
  max-width: 1100px;
  margin: 0 auto;
}
</style>