业务开发时收到一个需求,需要生成一个缩略图让用户拖拽快速移动,本以为应该有很多现成的库来使用,一番搜索后并没有找到,于是自己实现了一下js-minimap, 快速生成缩略图,支持监听内容区域变化自动更新,也就是上面文章封面的效果。
实现原理
首先思考基本实现过程其实还是比较清晰简单的:
- 根据比例生成内容区域的缩略图
- 获取当前内容区域的整体宽度(
scrollWidth)、可见宽度(clientWidth)、当前滚动距离(scrollLeft) - 根据2中显示区域的信息,计算出缩略图中阴影区域(标记可见范围)的宽高及位置
- 监听内容区域滚动事件,重新计算缩略图阴影区域的位置
- 缩略图阴影区域添加拖拽功能,修改内容区域的滚动距离
其中生成缩略图是比较麻烦的地方,其他步骤都是比较常规逻辑
生成缩略图
生成缩略图就是也截屏,一开始想到的是html2canvas,但是html2canvas在截屏的时候只能截出可视区域的范围,所以又换了一个dom-to-image-more,这个库fork自dom-to-image,修复了一些bug(比如内容区有加载失败的图片会导致截屏失败)。
import domToImage from 'dom-to-image-more'
domToImage
.toPng(container, {
width: container.scrollWidth,
height: container.scrollHeight
})
.then((dataUrl) => {
const img = new Image()
img.className = 'minimap-preview'
img.src = dataUrl
img.style.width = mapWidth + 'px'
img.style.height = mapHeight + 'px'
mapContainer.appendChild(img)
resolve()
})
.catch(function (error) {
console.error('oops, something went wrong!', error)
reject(error)
})
原理和html2canvas类似,都是从顶层节点开始递归clone节点和样式。
dom-to-image-more核心代码:
function cloneNode(node, filter, root) {
if (!root && filter && !filter(node)) return Promise.resolve();
return Promise.resolve(node)
.then(makeNodeCopy)
.then(function (clone) {
return cloneChildren(node, clone, filter);
})
.then(function (clone) {
return processClone(node, clone);
});
function makeNodeCopy(node) {
// 将canvas转为image
if (node instanceof HTMLCanvasElement)
return util.makeImage(node.toDataURL());
return node.cloneNode(false);
}
// 克隆子节点
function cloneChildren(original, clone, filter) {
var children = original.childNodes;
if (children.length === 0) return Promise.resolve(clone);
return cloneChildrenInOrder(clone, util.asArray(children), filter).then(
function () {
return clone;
}
);
// 递归
function cloneChildrenInOrder(parent, children, filter) {
var done = Promise.resolve();
children.forEach(function (child) {
done = done
.then(function () {
return cloneNode(child, filter);
})
.then(function (childClone) {
if (childClone) parent.appendChild(childClone);
});
});
return done;
}
}
function processClone(original, clone) {
if (!(clone instanceof Element)) return clone;
return Promise.resolve()
.then(cloneStyle)
.then(clonePseudoElements)
.then(copyUserInput)
.then(fixSvg)
.then(function () {
return clone;
});
// 使用getComputedStyle克隆样式。
function cloneStyle() {
copyStyle(window.getComputedStyle(original), clone.style);
function copyStyle(source, target) {
if (source.cssText) target.cssText = source.cssText;
else copyProperties(source, target);
function copyProperties(source, target) {
util.asArray(source).forEach(function (name) {
target.setProperty(
name,
source.getPropertyValue(name),
source.getPropertyPriority(name)
);
});
}
}
}
// clone伪类样式
function clonePseudoElements() {
[":before", ":after"].forEach(function (element) {
clonePseudoElement(element);
});
function clonePseudoElement(element) {
var style = window.getComputedStyle(original, element);
var content = style.getPropertyValue("content");
if (content === "" || content === "none") return;
var className = util.uid();
clone.className = clone.className + " " + className;
var styleElement = document.createElement("style");
styleElement.appendChild(
formatPseudoElementStyle(className, element, style)
);
clone.appendChild(styleElement);
function formatPseudoElementStyle(className, element, style) {
var selector = "." + className + ":" + element;
var cssText = style.cssText
? formatCssText(style)
: formatCssProperties(style);
return document.createTextNode(selector + "{" + cssText + "}");
function formatCssText(style) {
var content = style.getPropertyValue("content");
return style.cssText + " content: " + content + ";";
}
function formatCssProperties(style) {
return util.asArray(style).map(formatProperty).join("; ") + ";";
function formatProperty(name) {
return (
name +
": " +
style.getPropertyValue(name) +
(style.getPropertyPriority(name) ? " !important" : "")
);
}
}
}
}
}
// 处理输入框内容
function copyUserInput() {
if (original instanceof HTMLTextAreaElement) { clone.innerHTML = original.value; }
if (original instanceof HTMLInputElement) { clone.setAttribute("value", original.value); }
}
// 处理svg
function fixSvg() {
if (!(clone instanceof SVGElement)) return;
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
...
}
}
}
计算可视区域范围及位置
获取内容区域(container)的可视宽高、滚动区域宽高、滚动距离。
private getDomSize() {
const { options } = this
const { container } = options
const {
clientWidth,
clientHeight,
scrollWidth,
scrollHeight,
scrollLeft,
scrollTop,
} = container
return {
containerWidth: clientWidth,
containerHeight: clientHeight,
containerScrollWidth: scrollWidth,
containerScrollHeight: scrollHeight,
containerScrollLeft: scrollLeft,
containerScrollTop: scrollTop,
}
}
计算内容区域可视范围占总大小的比例,计算出在缩略图中可视区容器(mapSelector)的大小
private renderMapSelector() {
const { mapSelector, mapWidth, mapHeight } = this
const {
containerWidth,
containerHeight,
containerScrollWidth,
containerScrollHeight,
} = this.getDomSize()
const mapSelectorWidth = (containerWidth / containerScrollWidth) * mapWidth
const mapSelectorHeight =
(containerHeight / containerScrollHeight) * mapHeight
mapSelector.style.width = mapSelectorWidth + 'px'
mapSelector.style.height = mapSelectorHeight + 'px'
}
计算mapSelector的位置
private setMapSelectorPosition() {
const { mapSelector, mapWidth, mapHeight } = this
const {
containerScrollWidth,
containerScrollHeight,
containerScrollLeft,
containerScrollTop,
} = this.getDomSize()
const left = containerScrollLeft * (mapWidth / containerScrollWidth)
const top = containerScrollTop * (mapHeight / containerScrollHeight)
mapSelector.style.left = left + 'px'
mapSelector.style.top = top + 'px'
}
监听滚动及缩略图拖拽
给内容容器添加scroll的事件监听,重新设置mapSelector的位置(setMapSelectorPosition),即可让mapSelector跟随内容区域可视区域变化而移动。
在拖拽mapSelector时,可直接设置内容容器的滚动位置,这样在更新内容容器的可视区范围的同时,也自动更新了mapSelector的位置。
兼容内容区域变化
MutationObserver可以自动监听DOM节点及其子节点的变化,如新增/删除节点、文本变化、大小变化...。因此可以使用MutationObserver监听内容区域,自动更新缩略图。
private observeContainer() {
const observer = new MutationObserver(
// 防抖
lodashThrottle((mutationList: MutationRecord[]) => {
this.reset()
}, 30),
)
observer.observe(this.options.container, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
})
}
待优化:采用增量方式优化缩略图的更新
如上所说的dom-to-image和html2canvas的工作原理,生成截屏的过程中都会遍历所有子节点再重新渲染出来,这必然是一个很耗费性能的操作,页面约复杂耗时越长,并且每次截屏都是全量更新,当页面变化较快时,如果一直做更新操作,就会卡顿了。
后面又看了一下web录屏方案rrweb的工作原理,录屏就是不停的截屏组成视频,而在截屏时也是遍历子节点,那么如果要保证24帧率,必然会导致卡顿。rrweb的解决方案是,只在开始录屏时完全遍历节点,后续使用MutationObserver、EventListener等,监听DOM的变化,根据变化的内容增量更新,只记录和更新发生了变化的部分,这就大大减少的性能的损耗。
目前我的这个工具还没有实现增量更新,因此在使用时,如果页面更新较为频繁导致卡顿,需要设置observe=false,手动控制更新。后续如果有时间的话,会考虑按照此方案进行一下优化。