富文本渲染是我们切图仔们非常常见需求,特别是在博客、资讯或内容管理系统中。本文将详细介绍如何在 Nuxt 3 中实现一个安全且功能丰富的富文本渲染组件。我们将从基本需求开始,讨论安全性问题,逐步实现一个基础组件,并扩展到可以定制主题和点缀颜色的高级组件
需求分析
在实现富文本渲染组件之前,我们需要明确以下需求:
- 安全性:防止跨站脚本攻击(XSS)。
- 可扩展性:支持自定义样式和主题。
- 易用性:简单易用,易于集成到现有项目中。
安全性问题
在富文本渲染中,用户可以输入 HTML 内容,这些内容可能包含恶意脚本或不安全的标签。为了防止 XSS 攻击,我们需要对用户输入的 HTML 进行清理。我们将使用 DOMPurify 库来确保渲染的内容是安全的。
DOMPurify 是一个用于清理和消毒 HTML 的 JavaScript 库,主要用于防止跨站脚本攻击(XSS)。在富文本渲染中,用户可以输入 HTML 内容,这些内容可能包含恶意脚本或不安全的标签。DOMPurify 通过移除或转义这些不安全的内容,确保渲染的 HTML 是安全的。
具体来说,DOMPurify 的主要功能包括:
- 移除恶意脚本:删除或转义 HTML 中的
<script>标签及其内容,防止执行恶意 JavaScript 代码。 - 过滤不安全的属性:移除或转义 HTML 标签中的不安全属性,例如
onload、onclick等事件处理器,这些属性可能被用来注入恶意代码。 - 处理不安全的 URL:过滤掉可能指向恶意网站或包含恶意代码的 URL,例如
javascript:协议的链接。 - 自定义规则:允许开发者根据需要自定义允许或禁止的标签和属性,以满足特定的安全需求。
安装 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>