从 DOM 监听到 Canvas 绘制:一套完整的水印实现方案

88 阅读4分钟

🐉打怪升级的水印之旅

在每一个前端开发者的成长轨迹中,总会有一些需求让你印象深刻。实现水印功能的成长史,也是我和产品经理小林和测试小姐姐之间的技术攻防战。

👨‍💻 第一次交手:产品经理的“灵魂拷问?

那天刚进办公室,产品经理小林敲着我工位:

“考虑到信息安全,我们要加一个水印功能,需要在系统页面上标记用户身份,防止泄露。”

听起来简单对吧?于是我写下了第一版水印,简单粗暴,创建个 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()里打下了三个字。

这段“水印打怪”之路,是一次实战经验的积累,代码不只是工具,更是一段段解决问题的智慧结晶。