🐉打怪升级的水印之旅
在每一个前端开发者的成长轨迹中,总会有一些需求让你印象深刻。实现水印功能的成长史,也是我和产品经理小林和测试小姐姐之间的技术攻防战。
👨💻 第一次交手:产品经理的“灵魂拷问?
那天刚进办公室,产品经理小林敲着我工位:
“考虑到信息安全,我们要加一个水印功能,需要在系统页面上标记用户身份,防止泄露。”
听起来简单对吧?于是我写下了第一版水印,简单粗暴,创建个 div
,设置 background-image
,搞定收工。
结果提测当天,就收到了测试小姐姐的回怼:“审查元素就可以将水印直接删掉了,你这不是在糊弄事吗?”
这不是被打脸了吗,我嘴上笑嘻嘻,心里直咬牙:“好,我就不信我搞不定你”
⚔️ 我的反击:用代码编写一层“盔甲”
于是,我决定用canvas + MutationObserver
实现一个防删除、防修改的“战斗级”水印方法。
✅ 我的核心目标:
- 支持多行文字
- 支持旋转、换行、透明度控制
- 被删掉时能自动恢复
- 样式被修改时自动还原
- 浏览器尺寸变化时自动适配
👨💻 技术细节复盘:基于 canvas + MutationObserver 的水印实现
整个代码主要分为以下几个部分:
🧱 1. 基础配置和类型判断
const watermark = {
hasDelete: false,
setWatermark: null,
removeWatermark: null,
startWatermark: null
};
let watermarkId = 'watermark';
// 水印的参数配置
const markOptions = {
// 水印块宽度
width: 240,
// 水印块高度
height: 140,
// 水印区域top距离
top: '0px',
// 水印区域left距离
left: '0px',
// 旋转角度
rotateDeg: 25,
// 字体大小、风格
font: '16px PingFangSC-Regular',
// 字体颜色 rgb | 16进制字符串
color: '#666',
// 透明度
opacity: '0.2',
// 层级
zIndex: '100000',
// 对齐方式
textAlign: 'center',
// 垂直对齐方式
textBaseline: 'Middle',
// 是否换行偏
isLineFeed: false,
// 换行偏的宽度,默认画布的宽度
lineFeedWidth: 0,
// 行高
lineHeight: 20,
};
// 判断类型
const objectToString = Object.prototype.toString;
const toTypeString = (val) => objectToString.call(val);
const isObject = (val) => toTypeString(val) === '[object Object]';
const isArray = (val) => Array.isArray(val);
🔍 2. 水印监控机制(防止被删除或篡改)
/** 监听水印被删除后,执行重新添加水印操作 */
const observerBodyNode = (cb) => {
const markNode = document.getElementById(watermarkId);
// 获取水印的父节点
const targetNode = markNode.parentNode;
const obs = new MutationObserver(() => {
if (watermark.hasDelete) return;
const nowWatermark = document.getElementById(watermarkId);
if (!nowWatermark) cb && cb();
});
obs.observe(targetNode, { childList: true });
};
/** 监听水印style被修改后,执行重新设置样式 */
const observerNodeStyle = ({ initStyle }) => {
const targetNode = document.getElementById(watermarkId);
const obs = new MutationObserver(() => {
const nowWatermark = document.getElementById(watermarkId);
if (!nowWatermark) return;
if (nowWatermark.className) {
nowWatermark.className = '';
}
const nodeId = nowWatermark.getAttribute('id');
if (nodeId !== watermarkId) {
nowWatermark.setAttribute(watermarkId);
}
const nowStyle = nowWatermark.getAttribute('style');
if (nowStyle !== initStyle) {
nowWatermark.setAttribute('style', initStyle);
}
});
obs.observe(targetNode, {
attributes: true,
});
};
🖋️ 3. 绘制文本(支持多行自动换行)
const drawText = (context, text, x, y, maxWidth, lineHeight) => {
const lines = [];
let line = '';
const words = text.split('');
let testWidth, word;
for (let i = 0; i < words.length; i++) {
word = words[i];
testWidth = context.measureText(line + word).width;
if (testWidth > maxWidth) {
lines.push(line);
line = word;
} else {
line += word;
}
}
if (line.length) {
lines.push(line);
}
for (let i = 0; i < lines.length; i++) {
context.fillText(lines[i], x, y);
y += lineHeight;
}
return lines.length || 0;
};
🎨 4. 渲染水印节点
/**
* 加载水印
* @param {Object} options
*/
const loadWatermark = (options) => {
let _options = {};
if (isObject(options)) {
_options = Object.assign({}, markOptions, options.markOptions);
} else {
_options = markOptions;
}
if (document.getElementById(watermarkId)) {
document.body.removeChild(document.getElementById(watermarkId));
}
const canvasContent = document.createElement('canvas');
// 设置画布大小
canvasContent.width = _options.width;
canvasContent.height = _options.height;
const ctx = canvasContent.getContext('2d');
// 设置水印旋转角度
ctx.rotate(-((_options.rotateDeg * Math.PI) / 180));
ctx.font = _options.font;
ctx.fillStyle = _options.color;
ctx.textAlign = _options.textAlign || 'center';
ctx.textBaseline = _options.textBaseline || 'Middle';
const { markTextList } = options;
let textLine = 0;
if (isArray(markTextList)) {
for (let index = 0; index < markTextList.length; index++) {
const nowLineHeight = _options.lineHeight;
const x = _options.x || canvasContent.width / 2;
const y = _options.y || canvasContent.height + textLine * nowLineHeight;
const nowText = markTextList[index] || '';
if (_options.isLineFeed) {
const nowLine = drawText(
ctx,
nowText,
x,
y,
_options.lineFeedWidth || canvasContent.width,
nowLineHeight
);
textLine = textLine + nowLine;
} else {
textLine = textLine + 1;
ctx.fillText(nowText, x, y);
}
}
}
// 生成水印遮罩
const div = document.createElement('div');
div.id = watermarkId;
div.style.pointerEvents = 'none';
div.style.top = _options.top;
div.style.left = _options.left;
div.style.opacity = _options.opacity;
div.style.position = 'fixed';
div.style.zIndex = _options.zIndex;
div.style.width = `${document.documentElement.clientWidth}px`;
div.style.height = `${document.documentElement.clientHeight}px`;
div.style.background = `url(${canvasContent.toDataURL('image/png')}) left top repeat`;
document.body.appendChild(div);
// 获取当前水印元素style
const initStyle = div.getAttribute('style');
// 监听水印元素style被修改
observerNodeStyle({ initStyle });
// 监听水印元素被删除
observerBodyNode(() => {
document.body.appendChild(div);
});
};
🔁 5. 封装 API
/** 添加水印 */
watermark.setWatermark = (options = {}) => {
const node = document.getElementById(watermarkId);
if (!node) {
watermarkId = options.markId || watermarkId;
watermark.hasDelete = false;
loadWatermark(options);
}
};
/** 移除水印 */
watermark.removeWatermark = () => {
const node = document.getElementById(watermarkId);
if (node) {
watermark.hasDelete = true;
document.body.removeChild(node);
}
};
/** 防抖函数 */
function debounce(fn,delay = 300){
let timeId;
return function(...args){
if (timeId) clearTimeout(timeId);
timeId = setTimeout(()=> {
fn.apply(this, args);
}, delay)
}
}
/** 开启水印 */
watermark.startWatermark = (options) => {
const { setWatermark, removeWatermark } = watermark;
setWatermark(options);
window.onresize = debounce(() => {
removeWatermark();
setWatermark(options);
}, 300);
};
export default watermark
🎉 6. 具体使用
/** 使用移除水印 */
watermark.removeWatermark();
/** 使用添加水印 */
watermark.startWatermark({
markId: 'watermark',
markTextList: ['用户ID:666', 'Confidential'],
markOptions: {},
});
🏁 结语:被膜拜的时刻
最终将这份实现水印的方案提测,产品经理小林和测试小姐姐不管怎么删、怎么修改样式,都没能干掉水印, 后来,测试小姐姐看着控制台,沉默了几秒,忽然说道:
“....你这水印,有点东西啊~”
我端起手中的咖啡☕️喝了一口,笑了笑,没说话;
她望着我,半开玩笑地说:
“可以给我你的微信吗?我请你吃饭,你教教我怎么写的。”
我没说话,打开了控制台,在console.log()
里打下了三个字。
这段“水印打怪”
之路,是一次实战经验的积累,代码不只是工具,更是一段段解决问题的智慧结晶。