[封装07-从0开始封装一个VUE3-UI组件库] Watermark Scrollbar

903 阅读10分钟

Divine-Plus 组件库

usage.png

导航

[react] Hooks

[封装01-设计模式] 设计原则 和 工厂模式(简单抽象方法) 适配器模式 装饰器模式
[封装02-设计模式] 命令模式 元模式 组合模式 代理模式
[封装03-设计模式] Decorator 装饰器模式在前端的应用
[封装04-设计模式] Publish Subscribe 发布订阅模式在前端的应用
[封装05-ElementUI源码01] Row Col Container Header Aside Main Footer
[封装06- Divine-plus] 从0开始封装一个VUE3-UI组件库 初始化
[封装07- Divine-plus] 从0开始封装一个VUE3-UI组件库 Watermark Scrollbar

[React 从零实践01-后台] 代码分割
[React 从零实践02-后台] 权限控制
[React 从零实践03-后台] 自定义hooks
[React 从零实践04-后台] docker-compose 部署react+egg+nginx+mysql
[React 从零实践05-后台] Gitlab-CI使用Docker自动化部署

[源码-webpack01-前置知识] AST抽象语法树
[源码-webpack02-前置知识] Tapable
[源码-webpack03] 手写webpack - compiler简单编译流程
[源码] Redux React-Redux01
[源码] axios
[源码] koa
[源码] vuex
[源码-vue01] data响应式 和 初始化渲染
[源码-vue02] computed 响应式 - 初始化,访问,更新过程
[源码-vue03] watch 侦听属性 - 初始化和更新
[源码-vue04] Vue.set 和 vm.$set
[源码-vue05] Vue.extend

[源码-vue06] Vue.nextTick 和 vm.$nextTick
[源码-vue07] keep-alive

[源码-react01] ReactDOM.render01
[源码-react02] 手写hook调度-useState实现

[部署01] Nginx
[部署02] Docker 部署vue项目
[部署03] gitlab-CI
[部署04] [复习] gitlabCI + docker-compose + ssh免密登录 + 最全Dockerfile
[部署05] Kubernetes01
[部署06] Kubernetes02

[数据结构和算法01] 二分查找和排序
[数据结构和算法02] 回文字符串
[数据结构和算法03] 栈 和 队列
[数据结构和算法04] 链表 和 树

[深入01] 执行上下文
[深入02] 原型链
[深入03] 继承
[深入04] 事件循环
[深入05] 柯里化 偏函数 函数记忆
[深入06] 隐式转换 和 运算符
[深入07] 浏览器缓存机制(http缓存机制)
[深入08] 前端安全
[深入09] 深浅拷贝
[深入10] Debounce Throttle
[深入11] 前端路由
[深入12] 前端模块化
[深入13] 观察者模式 发布订阅模式 双向数据绑定
[深入14] canvas
[深入15] webSocket
[深入16] webpack
[深入17] http 和 https
[深入18] CSS-interview
[深入19] 手写Promise
[深入20] 手写函数
[深入21] 数据结构和算法 - 二分查找和排序
[深入22] js和v8垃圾回收机制
[深入23] JS设计模式 - 代理,策略,单例
[深入24] Fiber01
[深入25] Typescript
[深入26] Drag

[前端学java01-SpringBoot实战] 环境配置和HelloWorld服务
[前端学java02-SpringBoot实战] mybatis + mysql 实现歌曲增删改查
[前端学java03-SpringBoot实战] lombok,日志,部署
[前端学java04-SpringBoot实战] 静态资源 + 拦截器 + 前后端文件上传
[前端学java05-SpringBoot实战] 常用注解 + redis实现统计功能
[前端学java06-SpringBoot实战] 注入 + Swagger2 3.0 + 单元测试JUnit5
[前端学java07-SpringBoot实战] IOC扫描器 + 事务 + Jackson
[前端学java08-SpringBoot实战总结1-7] 阶段性总结
[前端学java09-SpringBoot实战] 多模块配置 + Mybatis-plus + 单多模块打包部署
[前端学java10-SpringBoot实战] bean赋值转换 + 参数校验 + 全局异常处理
[前端学java11-SpringSecurity] 配置 + 内存 + 数据库 = 三种方式实现RBAC
[前端学java12-SpringSecurity] JWT
[前端学java13-SpringCloud] Eureka + RestTemplate + Zuul + Ribbon
[前端学java14-Mybatis Plus] 分页插件 和 乐观锁插件

复习笔记-01
复习笔记-02
复习笔记-03
复习笔记-04

(一) Watermark 组件设计

我们在实现 Watermark 组件时,需要考虑以下问题?
---

1
方式: 在vue3中有哪些方式可以实现水印功能?
- 组件: 我们可以通过组件来实现水印
- 指令: 因为水印和DOM操作强相关,因此可以通过指令的思路来实现

2
区别: 水印图片 和 水印文字 有什么区别?
- 水印图片: 异步,因为 img.onload 异步完成后,我们才可以进行 context.drawImage 绘制
- 水印文字: 同步,直接 context.fillText 即可

3
生成: 如何生成水印?
- 前端: 通过 canvas 画文字和图片实现即可
- 后端: 比如图片可以通过后端来处理
- 区别: 后端一般只针对图片处理,而前端可以给任何资源做水印,范围广

4
防篡改: 如何防止水印被篡改
- 篡改的方式
  - 1.直接通过浏览器调试,删除水印DOM节点
  - 2.修改css,比如透明度
  - 3.修改 属性 等
  - 4.其他方式 ps
- 防篡改
  - 监听: 水印的删除操作,属性修改等
  - 生成: 当监听到上面的操作后,删除水印,再重新生成新的水印
  - api: MutationObserver
  
 5
 清除: 组件卸载时,需要停止观察 MutationObserver
 - 一些清除工作不要忘记
 
 6
 移动端: 移动端存在2/3倍屏
 - window.devicePixelRatio
 - 根据 window.devicePixelRatio 来动态修改水印的大小即可

(二) Scrollbar 组件设计

我们在实现 Watermark 组件时,需要考虑以下问题?
---

1
滚动事件的选择
- mousewheel vs scroll
- 触发频率上的对比

2
滚动 和 拖动 时 滚动条和页面的对应关系
2.1 const ratio =
    (containerDOM.scrollHeight - containerDOM.offsetHeight) /
    (containerDOM.offsetHeight - barDOM.offsetHeight);

2.2 鼠标距离bar顶部的距离 = 鼠标距视口的距离 - bar顶部距离视口的距离
2.3 bar滚动的距离 = 鼠标距视口的距离 - containerDOM距离视口的距离 - 鼠标距离bar顶部的距离


3
hover 显示滚动条的两种方案选择
- css方式
- mouseenter mouseleave 事件方式

4
- 是否支持原生滚动条
- 是否支持横向滚动
- bar是否可配置
- 是否对外暴露事件回调
- 是否一直显示 or hover显示
- 滚动的边界条件
- 如何覆盖原生滚动条

(三) 前置知识

(1) 一些单词

watermark 水印
illegal 非法的
extract 提取 摘录
inset 小图 插入
seed 种子
saturation 饱和度

(2) 需要用到的 vue3 api

1
createVNode
createVNode(type, props, children, patchFlag, dynamicProps, isBlockNode)
作用: 用来创建一个 VNode
--

签名
declare function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props?: (Data & VNodeProps) | null,
  children?: unknown,
  patchFlag?: number,
  dynamicProps?: string[] | null,
  isBlockNode?: boolean
): VNode;

案例
const props = {
    ...options,
    id,
    zIndex: zIndex++,
    onClose: () => {},
    onDestroy: () => {}
};
const vnode = createVNode(Message, props); // Message组件,props组件的props
2
render
render(vnode, container, isSVG)
---

签名
1.export declare const render: RootRenderFunction<Element | ShadowRoot>;
2.export declare type RootRenderFunction<HostElement = RendererElement> = (
  vnode: VNode | null,
  container: HostElement,
  isSVG?: boolean
) => void;

案例
const vnode = createVNode(Message, props);
const container = document.createElement("div");
render(vnode, container);
document.body.appendChild(container?.firstElementChild!);
3
vue2全局属性设置: Vue.prototype.xxx
vue2全局属性获取: this.xxx

vue3全局属性设置: createApp().config.globalProperties.xxx = xxx
vue3全局属性获取: getCurrentInstance().appContext.config.globalProperties.xxx
vue3全局属性获取(开发/生产): getCurrentInstance().proxy
4
vue2 和 vue3 在 message 组件实现上的差异
---

vue2
- let MessageConstructor = Vue.extend(Message);
- instance = new MessageConstructor({ data: options });
- instance.$mount()
- document.body.appendChild(instance.$el)

vue3
- const vnode = createVNode(Message, props);
- const container = document.createElement("div");
- render(vnode, container);
- document.body.appendChild(container?.firstElementChild!);
5
vue 中动态渲染组件的根标签的方式?
---

render 函数
- render函数渲染不同的tag,通过变量tag作为参数
- const view = h( tag, { class, style, ref }, slots.default );

component 内置组件
- 通过 is 属性 动态切换
- <component :is="tag" />
6
defineExpose 需要注意的问题
---

注意点1
- 需求: 通过ref绑定组件,同时要要获取该组件中的DOM
- 解决: 我们需要 ref绑定组件,同时在组件中 ref绑定需要获取的DOM标签,然后defineExose暴露出去

注意点2
- 需求: 具体如何获取
- 解决: refComponent.value.refDiv
- 注意: 
    - defineExpose暴露出来的ref对象,在获取该ref对象时,不需要.value
    - 对: refComponent.value.refDiv
    - 错: refComponent.value.refDiv.value
7
vue3 中 Fragment 组件多根节点需要注意的问题
---

1. 当我们在组件上做 ( 属性透传时 - 不在props和emit中的属性,会自动透传到根元素上 )
  - 如果组件存在多个根节点,将会报错,因为Vue 不知道要将 attribute 透传到哪里
  - Extraneous non-props attributes (age) were passed to component but could not be
    automatically inherited because component renders fragment or text root nodes.

2. 内置组件 "Transition" 只支持一个 ( 根节点的组件 ),多个根节点动画将不生效
  - Component inside "Transition " renders non-element root node that cannot be animated.
  
3. 当在组件上绑定事件时,如果子组件有多个根元素,事件不会触发
  - Extraneous non-emits event listeners (click) were passed to component but could not be
    automatically inherited because component renders fragment or text root nodes. If the
    listener is intended to be a component custom event listener only, declare it using the
    "emits" option.
    
8
getCurrentInstance
- 作用:访问组件内部实例,可以作为在组合式api中获取this的替代方案
- 注意:getCurrentInstance只能在 ( setup 或 生命周期钩子 ) 中使用
- 应用:
  - 获取 router
  - getCurrentInstance().appContext.config.globalProperties.$router
- 问题:
  - 问题:如果要在除了 ( setup 和 生命周期钩子 ) 外使用 ( getCurrentInstance ) 怎么弄?
  - 回答:可以现在 ( setup 中通过 getCurrentInstance() 获取实例,然后再使用 )
- 官网说明:https://v3.cn.vuejs.org/api/composition-api.html#getcurrentinstance
--- 


1.1 获取router
const instance = getCurrentInstance();
const router = instance?.appContext.config.globalProperties.$router;

(3) Canvas

<canvas id="canvas" width="200" height="200" style="border: 1px solid red" ></canvas>
const canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
---

1
指定 width 和 height 有三种方法
- 标准方式:canvas 标签自带的 width 和 height 属性
- css方法: 通过 id 或 class 选择器
- js方式: domTarget.width 和 domTarget.height

2
判断浏览器是否支持 canvas
if (!canvas.getContext) {
    throw new Error("你的浏览器不支持Canvas!");
}

3
常用属性
3.1 drawImage
- context.drawImage(img, x, y, width, height) // ------ 在canvas上绘制图片
- context.drawImage(img图片, x:在画布上放置图像的 x 坐标, y, width图像的宽度, height)
3.2 font
- font: font-style font-variant font-weight font-sizet font-family
- font-style: normal|italic斜体|oblique斜体|inherit;
3.3 toDataURL()
- canvas.toDataURL(type, encoderOptions) // ----------- 返回一个包含图片展示的URI
- type:图片的类型 `image/png`
3.4 fillText
- context.fillText(text,x,y,maxWidth) // -------------- 在画布上绘制填色的文本
- tetx 文本
- xy 坐标
3.5 measure.text(text)
- const text = context.measureText("生成"); // --------- 测量文字的宽度
- console.log("text.width", text.width); // 获取 "生成" 文字的宽度


4
绘制图形
// 矩形
ctx.fillRect(20, 20, 100, 100); // 填充矩形,fillRect(x, y, width, height) xy表示矩形左上角的坐标,原点是左上角00位置
ctx.clearRect(55, 55, 30, 30); // 清除矩形区域,使其清除部分完全透明
ctx.strokeRect(140, 20, 100, 100); // 矩形框
// 三角形
ctx.beginPath(); //------------------------------------------ 一个路径的开始
ctx.moveTo(500, 30); // ----- 起始点
ctx.lineTo(450, 120); // ------ 直线的第二个点
ctx.lineTo(550, 120); // ----- 直线的第三个点
ctx.closePath(); // ----------------------------------------- 一个路径的结束
ctx.lineWidth = 4; // -------- 直线的宽度
ctx.strokeStyle = "red"; // ------ 直线的颜色,需要在绘画前设置
ctx.stroke(); // --------------------------------------------- 描边 (绘制)
ctx.fillStyle = "yellow"; // ------ 填充的颜色,需要在绘画前设置
ctx.fill(); // ----------------------------------------------- 填充 (绘制)
// 直线
ctx.beginPath();
ctx.moveTo(300, 30);
ctx.lineTo(400, 30);
ctx.closePath();
ctx.lineWidth = 2;
ctx.strokeStyle = "blue";
ctx.stroke();
// 圆弧
// arc(x, y, radius, startAngle, endAngle, anticlockwise)
//以x,y为圆心,radius为半径, startAngle和endAngle为角度,anticlockwise是否为逆时针方向的 圆弧(圆)
// startAngle, endAngle代表的是( 弧度 ),而不是角度
// 注意:起始角度为三点钟位置,并且是以弧度计算的
ctx.beginPath();
ctx.arc(60, 200, 40, (90 * Math.PI) / 180, 1.5 * Math.PI, false);
ctx.stroke();
// 圆
ctx.beginPath();
ctx.arc(180, 200, 40, 0, 2 * Math.PI, false);
ctx.fill();


5
绘制图片
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <img
      src="../../../static/images/成都.jpg"
      id="image"
      width="300"
      height="300"
    />
    <button id="button">生成图片</button>

    <div id="imgContainer"></div>
  </body>
  <script>
    window.onload = function () {
      const image = document.getElementById("image");
      const button = document.getElementById("button");
      const imgContainer = document.getElementById("imgContainer");

      button.addEventListener("click", combine, false);
      
      function combine() {
        const canvas = document.createElement("canvas"); // -------- 创建canvas标签
        canvas.width = 500;
        canvas.height = 500;
        canvas.style = "border: 1px solid red";
        document.documentElement.appendChild(canvas); // ------------ 添加到HTML的DOM中

        const context = canvas.getContext("2d"); // ----------------- 获取渲染上下文和绘画功能

        // context.drawImage(img, x, y, width, height)
        // img:图片
        // x:在画布上放置图像的 x 坐标
        // y:在画布上放置图像的 y 坐标
        // width:图像的宽度
        // height:图像的高度
        context.drawImage(image, 50, 55, 400, 400); // ----------------- drawImage() 生成图片
        context.fillStyle = "white";
        context.font = "30px Georgia";
        context.fillText("生成的图片", 100, 100); // ------------------- 填充文字
        
        const text = context.measureText("生成"); // ------------------ 测量文字的宽度
        console.log("text.width", text.width);
        const text2 = context.measureText("生成的图片");
        console.log("text2.width", text2.width);
      }
    };
  </script>
</html>

(4) MutationObserver

MutationObserver
- 作用: 检测 DOM 变化
- 异步: 是 微任务
  - 微任务: promise process.nextTick MutationObserver
  - 宏任务: setTimeout setInterval setImmediate requestAnimationFrame
--- 

<!DOCTYPE html>

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="add">添加节点</button>
    <button id="change">修改属性</button>
    <button id="edit">修改子节点</button>

    <br />
    <button id="stop">停止观察</button>
    <section id="target">
      <div id="target__content">these are some text content 1</div>
      <div id="target__content2">these are some text content 2</div>
    </section>
    <script>
      const targetNode = document.getElementById("target");
      const config = {
        subtree: true, // 监听以 target 为根节点的整个子树,包括子树中所有节点的属性,而不仅仅是针对 target
        childList: true, // 监听 Target节点 新增 和 删除
        attributes: true, // 监听 Target节点 属性
        // attributeFilter: false, // 一个用于声明哪些属性名会被监听的数组。如果不声明该属性,所有属性的变化都将触发通知
        // attributeOldValue: false, // 当为 true 时,记录上一次被监听的节点的属性变化;可查阅监听属性值了解关于观察属性变化和属性值记录的详情。默认值为 false
      };

      const callback = (mutationsList, observer) => {
        console.log("callback running");
        console.log("mutationList", mutationsList);
        console.log("observer", observer);
      };

      const observer = new MutationObserver(callback); // 1. ----------------- 开始观察

      observer.observe(targetNode, config);

      // add
      const Add = document.getElementById("add");
      Add.onclick = () => {
        const span = document.createElement("span");
        span.innerHTML = "this is a span tag";
        targetNode.appendChild(span);
      };

      // change
      const Change = document.getElementById("change");
      Change.onclick = () => {
        targetNode.setAttribute("data-href", "http://baidu.com");
      };

      // edit
      const Edit = document.getElementById("edit");
      const child = document.getElementById("target__content2");
      Edit.onclick = () => {
        child.innerHTML = "these are some text content 3";
      };

      var mutations = observer.takeRecords(); // 3. --------------------------- 返回一个MutationRecord 对象列表,每个对象都描述了应用于 DOM 树某部分的一次改动
      if (mutations) {
        callback(mutations);
      }

      // stop
      const Stop = document.getElementById("stop");
      Stop.onclick = () => {
        observer.disconnect(); // 2. ------------------------------------------ 停止观察
      };
    </script>

  </body>
</html>

(一) 生成水印

import { ExtractPropTypes, reactive } from "vue";
import { watermarkProps } from "../props";

// 1
// 问题
// - 问题: 有些库使用 computed 来保持 ( 当子组件的props来自 父组件的响应式对象时 ) 响应式
// - 场景: 如果这里接受的父组件的 props,是父组件中的 响应式数据时,考虑使用 computed 来保持响应式
// - 说明: 这种情况其实不用考虑,因为父组件即使是props对应的响应式对象,父组件也不会去修改content,fontsize,gap
export const useCreateWatermark = (
  args: Readonly<ExtractPropTypes<typeof watermarkProps>>
) => {
  const { content, fontsize, fontcolor, gap, imgSrc, imgWidth, imgHeight } =
    args;

  const state = reactive({
    watermark: {
      base64: "",
      height: 0,
      width: 0,
    },
  });

  const canvas = document.createElement("canvas");
  if (!canvas.getContext) throw new Error("你的浏览器不支持Canvas!");
  const ctx = canvas.getContext("2d")!;

  const drawImage = () => {
    canvas.width = Number(imgWidth) + Number(gap);
    canvas.height = Number(imgHeight) + Number(gap);

    ctx.translate(canvas.width / 2, canvas.height / 2);
    ctx.rotate((Math.PI / 180) * -45); // 弧度 = ( 2 * Math.PI / 360) * -45

    const img = new Image();
    img.crossOrigin = "anonymous";
    img.referrerPolicy = "no-referrer";
    img.src = imgSrc;
    img.onload = () => {
      ctx.drawImage(img, 0, 0, Number(imgWidth), Number(imgHeight));
      state.watermark = {
        base64: canvas.toDataURL("image/png"),
        width: canvas.width,
        height: canvas.height,
      };
    };
  };

  const drawText = () => {
    const { width } = ctx.measureText(content);
    const canvasSize = Math.max(100, width) + Number(gap);
    canvas.width = canvasSize;
    canvas.height = canvasSize;

    ctx.translate(canvas.width / 2, canvas.height / 2);
    ctx.rotate((Math.PI / 180) * -45); // 弧度 = ( 2 * Math.PI / 360) * -45

    const font = `${fontsize} serif, Arial, sans-serif`;
    ctx.font = font;
    ctx.fillStyle = fontcolor;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(content, 0, 0);

    state.watermark = {
      base64: canvas.toDataURL("image/png"),
      width: canvas.width,
      height: canvas.height,
    };
  };

  imgSrc ? drawImage() : drawText();

  return state;
};

(二) 防篡改

const initObserver = () => {
  const callback = (mutationsList: MutationRecord[]) => {
    const [mutation] = mutationsList;
    // 删除
    for (let i = 0; i < mutation.removedNodes.length; i++) {
      if (mutation.removedNodes[i] === state.div) {
        paint();
      }
    }
    // 属性修改
    if (mutation.target === state.div) {
      paint();
    }
  };
  state.observer = new MutationObserver(callback);
  watermarkRef.value &&
    state.observer.observe(watermarkRef.value, {
      attributes: true,
      childList: true,
      subtree: true,
    });
};

(三) 绘制水印

function paint() {
  if (!watermarkRef.value) return;
  if (state.div) state.div.remove();

  state.div = document.createElement("div");
  state.div.style.backgroundImage = `url(${watermarkState.watermark.base64})`;
  state.div.style.backgroundSize = `${watermarkState.watermark.width} ${watermarkState.watermark.height}`;

  state.div.style.position = "absolute";
  state.div.style.inset = "0";
  state.div.style.zIndex = String(props.zIndex);

  watermarkRef.value.appendChild(state.div);
}