<!-- RichTextDiff.vue -->
<template>
<div class="rich-text-diff" v-html="diffedHtml"></div>
</template>
<script>
// 引入字符级差异比对库,这是实现精确比对的基石[citation:2]
import { diffChars } from 'diff';
export default {
name: 'RichTextDiff',
props: {
// 旧版本富文本HTML
oldHtml: {
type: String,
default: ''
},
// 新版本富文本HTML
newHtml: {
type: String,
default: ''
},
// 自定义高亮样式类名(可选)
insClass: {
type: String,
default: 'diff-ins'
},
delClass: {
type: String,
default: 'diff-del'
}
},
data() {
return {
diffedHtml: '' // 存储最终生成的、带有高亮标记的HTML
};
},
watch: {
// 当传入的富文本内容变化时,自动重新计算差异
oldHtml: 'computeDiff',
newHtml: 'computeDiff'
},
mounted() {
this.computeDiff();
},
methods: {
/**
* 主入口:触发整个比对流程
*/
computeDiff() {
if (!this.oldHtml && !this.newHtml) {
this.diffedHtml = '';
return;
}
this.diffedHtml = this.generateDiffHtml(this.oldHtml, this.newHtml);
},
/**
* 生成带差异高亮的最终HTML字符串
* @param {String} oldHtml - 旧HTML
* @param {String} newHtml - 新HTML
* @returns {String} 结果HTML
*/
generateDiffHtml(oldHtml, newHtml) {
// 1. 将字符串解析为独立的DOM树
const oldContainer = this.parseToContainer(oldHtml);
const newContainer = this.parseToContainer(newHtml);
// 2. 从根节点开始,递归执行“保留标签、仅比对文本”的核心算法
const resultNode = this.diffNodeWithStructure(oldContainer, newContainer);
// 3. 将处理后的DOM树转换回HTML字符串
return this.containerToHtml(resultNode);
},
/**
* 将HTML字符串解析为DOM元素(内存中操作,不影响页面)
*/
parseToContainer(html) {
const container = document.createElement('div');
container.innerHTML = html || '';
return container;
},
/**
* 核心算法:递归比对两棵树,保留标签,只diff文本
* @param {Node} oldNode - 旧树节点
* @param {Node} newNode - 新树节点
* @returns {Node} 比对后生成的新节点
*/
diffNodeWithStructure(oldNode, newNode) {
// **情况1: 两个都是文本节点 -> 进行字符级差异比对**
if (this.isTextNode(oldNode) && this.isTextNode(newNode)) {
return this.createTextDiffFragment(oldNode.textContent, newNode.textContent);
}
// **情况2: 两个都是元素节点,且标签名相同 -> 递归比对子节点**
if (this.isElementNode(oldNode) && this.isElementNode(newNode) && oldNode.tagName === newNode.tagName) {
// 克隆新节点的标签和所有属性,作为结果的基础结构
const clonedNode = this.cloneElementWithAttributes(newNode);
// 获取子节点数组,准备并行遍历
const oldChildren = Array.from(oldNode.childNodes);
const newChildren = Array.from(newNode.childNodes);
const maxLen = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLen; i++) {
const oldChild = oldChildren[i] || null;
const newChild = newChildren[i] || null;
let diffChild = null;
if (oldChild && newChild) {
// 递归比对
diffChild = this.diffNodeWithStructure(oldChild, newChild);
} else if (newChild) {
// 新节点独有:将整个新节点结构标记为“新增”
diffChild = this.wrapNodeAsAdded(newChild);
} else if (oldChild) {
// 旧节点独有:将整个旧节点结构标记为“删除”
diffChild = this.wrapNodeAsRemoved(oldChild);
}
if (diffChild) {
clonedNode.appendChild(diffChild);
}
}
return clonedNode; // 返回保留了完整标签结构的结果
}
// **情况3: 节点类型不同或标签不同 -> 视为完全不同的节点块**
return this.createBlockDiffFragment(oldNode, newNode);
},
/**
* 字符级文本比对:使用diffChars生成<ins>/<del>片段
*/
createTextDiffFragment(oldText, newText) {
if (oldText === newText) {
return document.createTextNode(newText);
}
const diffResult = diffChars(oldText, newText);
const fragment = document.createDocumentFragment();
diffResult.forEach(part => {
let node;
if (part.added) {
node = document.createElement('ins');
node.className = this.insClass;
node.textContent = part.value;
} else if (part.removed) {
node = document.createElement('del');
node.className = this.delClass;
node.textContent = part.value;
} else {
node = document.createTextNode(part.value);
}
fragment.appendChild(node);
});
return fragment;
},
/**
* 克隆元素节点及其所有属性(保留样式、类名等)
*/
cloneElementWithAttributes(elementNode) {
const cloned = elementNode.cloneNode(false); // 浅克隆,只克隆标签本身
// 确保保留原始元素的样式类
cloned.classList.add('diff-tag-preserved');
return cloned;
},
/**
* 将节点包裹为“新增”状态
*/
wrapNodeAsAdded(node) {
const ins = document.createElement('ins');
ins.className = `${this.insClass} diff-ins-block`;
ins.appendChild(node.cloneNode(true)); // 深克隆整个子树
return ins;
},
/**
* 将节点包裹为“删除”状态
*/
wrapNodeAsRemoved(node) {
const del = document.createElement('del');
del.className = `${this.delClass} diff-del-block`;
del.appendChild(node.cloneNode(true)); // 深克隆整个子树
return del;
},
/**
* 处理完全不同节点(如<p> vs <div>),并排显示新旧块
*/
createBlockDiffFragment(oldNode, newNode) {
const fragment = document.createDocumentFragment();
if (oldNode) {
fragment.appendChild(this.wrapNodeAsRemoved(oldNode));
}
if (newNode) {
fragment.appendChild(this.wrapNodeAsAdded(newNode));
}
return fragment;
},
/**
* 工具函数:判断是否为文本节点
*/
isTextNode(node) {
return node && node.nodeType === Node.TEXT_NODE;
},
/**
* 工具函数:判断是否为元素节点
*/
isElementNode(node) {
return node && node.nodeType === Node.ELEMENT_NODE;
},
/**
* 将处理后的容器节点转换为HTML字符串
*/
containerToHtml(containerNode) {
const wrapper = document.createElement('div');
wrapper.appendChild(containerNode);
return wrapper.innerHTML;
}
}
};
</script>
<style scoped>
/* 基础容器样式 */
.rich-text-diff {
font-family: inherit;
line-height: 1.6;
}
/* 深度选择器,用于影响v-html内部元素的样式 */
.rich-text-diff ::v-deep .diff-ins {
background-color: #e6ffed; /* 新增文本:浅绿色背景 */
text-decoration: none;
padding: 0 1px;
border-radius: 2px;
}
.rich-text-diff ::v-deep .diff-del {
background-color: #ffe6e6; /* 删除文本:浅红色背景 */
color: #8e3434;
text-decoration: line-through;
padding: 0 1px;
border-radius: 2px;
}
/* 当整个节点块(包含其内部标签)被添加或删除时的样式 */
.rich-text-diff ::v-deep .diff-ins-block {
display: block;
background-color: #f0fff0;
border-left: 3px solid #52c41a;
margin: 4px 0;
padding: 2px 8px;
}
.rich-text-diff ::v-deep .diff-del-block {
display: block;
background-color: #fff0f0;
border-left: 3px solid #ff4d4f;
margin: 4px 0;
padding: 2px 8px;
}
/* 为所有被保留的原始标签添加一个标识,可用于调试 */
.rich-text-diff ::v-deep .diff-tag-preserved {
/* 标签本身被完整保留,无特殊样式 */
}
</style>