「前端添加水印」你真的了解全面吗?

 
 

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群

背景

在古茗日常业务中,经常会给加盟商下发各种资料,例如:奶茶的配方、设备的清洗、卫生的标准等等等。这些资料都是一些内部资料,从信息安全维度不能被泄露和盗取出去。所以会给下发的资料加上水印。这些资料可能是纯文本,也可能是文本加图片的。因此,我们要做好以下两个方面:

  • 通过对页面增加水印,可以从系统级别防止别人盗取我们的页面信息

  • 通过对单独的图片加水印 - 防止图片保存时没有水印

页面水印

方案设计

实现页面水印的方式有很多,可以看一些常用页面加水印的方案,具体如下:

  • 方案一:fixed 定位的 div 元素,重复渲染 div 元素来添加水印。会创建很多无关的 DOM 元素

  • 方案二:fixed 定位 canvas 元素,重复填充水印。始终会创建一个无关的 canvas 元素

  • 方案三:canvas + 伪类。不会创建无关元素,且兼容性好

  • 方案四:svg + 伪类。不会创建无关元素,但兼容性略差于 canvas

这些方案,都有一个通用的缺点,那就是将元素删掉,或者将类名删掉,都能去除页面水印。

基于实现成本和安全性维度的考虑,最终方案选型:方案三,同时增加了通过MutationObserver - Web API 接口参考 | MDN 解决了删除类名导致水印删除的问题。

核心功能点:

  • 把签名信息,通过 Canvas 生成背景图

  • 利用伪类将背景图添加到需要生成水印的区域上

  • 通过 MutationObserver , 解决了删除类名导致水印删除的问题

代码实现

把签名信息,通过Canvas生成背景图
  • 利用 Canvas 来绘制背景图,背景内容为水印的内容

  • 通过 toDataURL 将 Canvas 转换成图片,格式为 image/png

interface IImgOptions {
  content: string[]; // 水印的内容,可传递多个水印
  canvasHeight: number; // 画布的高度
  canvasWidth: number; // 画布的宽度
}
const createImgBase = (options: IImgOptions) => {
  const { content, canvasHeight, canvasWidth } = options;
  const canvas = document.createElement('canvas'); // 创建一个画布
  const ctx = canvas.getContext('2d');
  // 设置画布的宽高
  canvas.width = canvasHeight;
  canvas.height = canvasWidth;
  if (ctx) {
    ctx.rotate((-10 * Math.PI) / 180); // 偏移一点距离
    ctx.fillStyle = 'rgba(100,100,100,0.2)'; // 设置绘制的颜色
    ctx.font = '40px'; // 设置字体的大小
    // 遍历水印内容
    content.forEach((text, index) => {
      ctx.fillText(text, 10, 30 * (index + 1)); // 拉开30的间距
    });
  }
  return canvas.toDataURL('image/png'); // 转换程data url,可供img直接使用
};
利用伪类将背景图添加到整个页面上
  • 给需要添加水印元素添加一个对应的伪元素,将第一步通过 Canvas 生成的 data url 作为背景

  • 创建一个 style 元素,将伪元素放在 style.innerHTML 中,然后 appendChild 到 head 中,此时,页面水印就加完了

const genWaterMark = ({
  content,
  className,
  canvasHeight = 140,
  canvasWidth = 150,
}) => {
  const dataURL = createImgBase({ content, canvasHeight, canvasWidth });
  const defaultStyle = document.createElement('style');
  defaultStyle.innerHTML = `.${className}::after {
    content: '';
    display: block;
    width: 100%;
    height: 100vh;
    background-image: url(${dataURL});
    background-repeat: repeat;
    pointer-events: none;
    position: fixed;
    top: 0;
    left: 0;
  }`;
  document.head.appendChild(defaultStyle);
};


// 使用方式
const Content = () => {
    useEffect(() => {
    genWaterMark({
      content: [userName, userPhone, '内部机密材料', '严禁外泄!'],
      className: 'my-page-container',
    });
  }, []);
  return (
    <div className="my-page-container" id="my-page-container">
      <div className="my-info">
        <div className="title">这是测试标题</div>
        <div className="content">
          // ...我想这是机密内容 * n
        </div>
      </div>
    </div>
  )
}

// css样式
.my-page-container {
  height: calc(100vh - 104px);
  overflow: hidden;

  .my-info {
    display: flex;
    flex: 1;
    flex-direction: column;
    height: 100%;
    padding: 24px;
    overflow-y: auto;

    // .title & .content 一些不重要的css
  }

页面效果如下:脱敏处理,截图未展示姓名和手机号。

3df4793bd6bdd77ad5ca07ef1a11cfa0.png
利用MutationObserver,防止被人删除className
const listenerDOMChange = (className: string) => {
  const targetNode = document.querySelector(`.${className}`);
  const observer = new MutationObserver((mutationsList) => {
    for (let mutation of mutationsList) {
      if (mutation.type === 'attributes' && mutation.attributeName === 'class' && targetNode) { // 监听属性并且属性名为class的变更
        const curClassVal = targetNode.getAttribute('class') || '';
        if (curClassVal.indexOf(className) === -1) { // 监听到className被删除了,手动加回去
          targetNode.setAttribute('class', `${className} ${curClassVal}`);
        }
      }
    }
  });

  observer.observe(targetNode as Node, {
    attributes: true,
  });
};
const genWaterMark = ({
  content,
  className,
  canvasHeight = 140,
  canvasWidth = 150,
}: IWaterMark) => {
  // 监听class的变更
  listenerDOMChange(className);
  const dataURL = createImgBase({ content, canvasHeight, canvasWidth });
  const defaultStyle = document.createElement('style');
  // 省略
  document.head.appendChild(defaultStyle);
};
注意点

注意点①:

  • 问题:

    上述方案的水印是占据整个页面的,但有些水印期望是在特定区域的。

  • 解决方案:

    利用定位,实现在特定区域增加水印

// 通过设置position: absolute来实现
const genWaterMark = ({
  content,
  className,
  canvasHeight = 140,
  canvasWidth = 150,
}: IWaterMark) => {
  const dataURL = createImgBase({ content, canvasHeight, canvasWidth });
  const defaultStyle = document.createElement('style');
  defaultStyle.innerHTML = `.${className}::after {
    content: '';
    display: block;
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: 100%;
    background-image: url(${dataURL});
    background-repeat: repeat;
    pointer-events: none;
  }`;
  document.head.appendChild(defaultStyle);
};

// 使用方式
const Content = () => {
  useEffect(() => {
    genWaterMark({
      content: [userName, userPhone, '内部机密材料', '严禁外泄!'],
      className: 'wait-task-wrap',
    });
  }, []);
  return (
    <View className="my-page-container" id="my-page-container">
      // ...一些不重要的代码
      <View className="wait-task-wrap"></View>
    </View>
  )
}

// css样式
.wait-task-wrap {
  // 一些不重要的样式
  position: relative;
}

页面效果如下:

7b3003e2764a5fb8a324b82d78abe985.png

3、图片水印

3.1 方案设计

在资料中,存在很多图片,但页面水印,对图片你来说就我们要对图片进行预览并且支持保存。此时页面背景水印就没有用啦,我们下载下来的图片还是不带水印的。针对这种现象,我们有以下一些常用的解决方案

  • 方案一:服务端添加水印,安全,但是服务端压力大且性能慢

  • 方案二:借助 oss 添加水印,简便但是不通用

  • 方案三:canvas 方案,安全但性能慢

本文着重介绍后两种前端添加水印的方式。

代码实现

借助oss
将oss地址转成带水印的oss地址
// oss水印中的文字进行url安全的base64编码
const getSafeBase64Code = (name: string) => {
  return window
    .btoa(unescape(encodeURIComponent(name)))
    .replace(/+/g, '-')
    .replace(//+/g, '_');
};

const genOSSImageWaterMark = (imgSrc: string) => {
  const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');

  return `${imgSrc}?x-oss-process=image/watermark,text_${getSafeBase64Code(
    `${userName}-${userPhone}`
  )},rotate_325,t_10,color_000000,size_30,fill_1,g_nw,x_30,y_30`;
};

// 使用
const ImageWaterMark = () => {
  return (
    <Image
      src={genOSSImageWaterMark('xxx图片地址xxx')}
    />
  )
}

页面效果如下:

6b92f0ad638f11dcdf5beb6b271605f7.png0660d0b808f49266aef62a93c17a4a54.png

注意点

注意点①

  • 问题:有些图片某些区域是透明的,导致透明的区域上不了色。(效果如图一)

  • 解决方案:ui 告诉我们,png 图片导出默认是透明的,但是 jpg 默认会将透明的地方填充白色的背景,所以,我们查阅对图片进行格式转换的参数说明及实例_对象存储-阿里云帮助中心文档得出,只需要加上 x-oss-process=image/format,jpg, 对之前的 genOSSImageWaterMark 进行改造,对非 jpg 的图片都转成 jpg 的图片

const genOSSImageWaterMark = (imgSrc: string) => {
  const imgType = imgSrc.split('.').slice(-1)[0];
  const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');

  return `${imgSrc}?${
    imgType !== 'jpg' ? 'x-oss-process=image/format,jpg,' : ''
  }x-oss-process=image/watermark,text_${getSafeBase64Code(
    `${userName}-${userPhone}`
  )},rotate_325,t_10,color_000000,size_30,fill_1,g_nw,x_30,y_30`;
};

效果如下:

224de386334dd7196bd805b298b5ba68.png

注意点②

  • 问题:字体写死,导致水印在大图上特别小,小图上特别大。(效果如图二)

  • 解决方案:根据图片比,计算字体大小。

interface IImageProps {
  width: number;
  height: number;
}
// 获取图片的宽高
const getImageWH = async (src): Promise<IImageProps> => {
  const img = new Image();
  img.src = src;
  await new Promise((resolve) => (img.onload = resolve));  // 等图片加载完
  return new Promise((resolve) => {
    resolve({
      width: img.width,
      height: img.height,
    });
  });
};
const genOSSImageWaterMark = async (imgSrc: string) => {
  const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');
  const imgType = imgSrc.split('.').slice(-1)[0];
  const { width, height } = await getImageWH(imgSrc);
  const min = Math.min(width, height);
  // 根据官网上的测试图片,宽度为400,设置字体为10,水印展示效果很好,所以图片比为40
  const size = Math.ceil(min / 40);
  const src = `${imgSrc}?${
    imgType !== 'jpg' ? 'x-oss-process=image/format,jpg,' : ''
  }x-oss-process=image/watermark,text_${getSafeBase64Code(
    `${userName}-${userPhone}`
  )},rotate_325,t_100,color_ff0000,size_${size},fill_1,g_nw,x_30,y_30`;
  return src;
};

效果如下:

feab470216e58b163c34fc047b69d972.pngdd37ddd584bb2ea9093b4544099a8899.png

注意点③

  • 问题:

    用户直接将后缀删了,水印也就没了

  • 解决方式:

    oss 设置安全级别,不带水印不可访问

通过canvas给图片增加水印

技术方案设计

  • 图片路径转成 canvas

  • canvas 添加水印

  • canvas 转成 img

代码实现

const genOSSImageWaterMark = async (imgSrc: string) => {
  const canvas = document.createElement('canvas');
  // ① 图片路径转成canvas
  await imgSrc2Canvas(canvas, imgSrc);
  // ② canvas添加水印
  addWatermark(canvas);

  // ③ canvas转成img
  return canvas.toDataURL('image/png');
};

使用

const genOSSImageWaterMark = async (imgSrc: string) => {
  const canvas = document.createElement('canvas');
  await imgSrc2Canvas(canvas, imgSrc);
  addWatermark(canvas);

  return canvas.toDataURL('image/png');
};
图片路径转成canvas
const imgSrc2Canvas = (cav: HTMLCanvasElement, imgSrc: string) => {
  return new Promise(async (resolve) => {
    const image = new Image();
    image.src = imgSrc;
    // ① 为图片设置crossOrigin属性,防止Failed to execute 'toDataURL' on 'HTMLCanvasElement'
    image.setAttribute('crossOrigin', 'anonymous');
    // ② 解决渲染图片为透明图层
    await new Promise((resolve) => (image.onload = resolve));
    cav.width = image.width;
    cav.height = image.height;
    const ctx = cav.getContext('2d');
    if (ctx) {
      ctx.drawImage(image, 0, 0);
    }
    resolve(cav);
  });
};
canvas添加文字水印
  • 通过二维数组的渲染,来填充文本

    • 通过画布的宽度以及水印的宽度来计算 X 轴的渲染次数

    • 通过画布的宽度以及你想打印的疏密程度来计算 Y 轴的渲染次数

c5b35487d541407908f906036e40e58f.png
const addWatermark = async (canvas, imgSrc) => {
  const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = 'rgba(100,100,100,0.2)'; // 字体颜色
  ctx.font = `24px serif`;
  ctx.translate(0, 0);
  ctx.rotate((5 * Math.PI) / 180); // 旋转角度

  const repeatX = Math.floor(canvas.width / 240); // 100 为每个水印的基本宽度
  const repeatY = Math.floor(canvas.height / 150);
  for (let i = 0; i < repeatX; i++) {
    for (let j = 1; j < repeatY; j++) {
      ctx.fillText(`${userName}-${userPhone}`, 240 * 2 * i, 150 * j); // 控制水印的疏密
    }
  }
};

页面效果如下:

da3a683a46cbfbe74fe9a281f870b877.png
注意点

注意点①:

  • 问题:页面报错如下

243443522d6e34a394f6a0581be4053c.png
  • 原因:当 img 元素的 src 不符合同源准则时,会阻止读取 canvas 的内容。因为此时 img 元素放在 canvas 中时,canvas 元素会被标记为被污染的,而在被污染的 canvas 中调用 toDataUrl 将会报错

  • 解决方案:

// 为image设置crossOrigin属性
image.setAttribute('crossOrigin', 'anonymous');

注意点②:

  • 问题:渲染的图片为透明的图片

35894652160270d7ac0b23a2451d6151.png
  • 原因:图片还未渲染完,就返回了 canvas。

  • 解决方案:等图片渲染完了,再开始画到 canvas 中

await new Promise((resolve) => (image.onload = resolve));

总结

本文主要讲了两个话题:页面水印 & 图片水印。页面水印很简单,基本上就是利用 canvas 渲染水印,再利用伪类将 canvas 的水印渲染在特定的区域。图片相对而言会复杂一些,在渲染水印之前,得先把图片渲染上去,针对大图,性能可能会慢一点。所以,如果对水印要求不是很严格并且图片是存储在 oss 的,那利用 oss 来加水印也不失为一种好选择。但如果从安全性来考虑,那肯定是服务端加水印会更合适一点。

最后

Node 社群

 
 

我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

b5a9d5eaea9c1bdbe022bb115effe161.png

“分享、点赞、在看” 支持一下
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值