水印组件的发展史

前言

功能背景

广告后台业务需求,页面需要增加水印,实现水印的全局覆盖,以此来保证数据的安全性。因功能实现难度较高,涉及功能点复杂,所以记录一篇文章,来讲解具体的实现。

说明

本文讲解了web前端实现全局页面增加水印的方式,拓展介绍了图片增加自定义水印的方法。基本实现了高安全度的水印,保证了页面数据的安全性。

目标

  1. 基础:完成不影响页面功能操作,且安全系数高的页面水印(不可通过控制台删除和控制样式) -- 对应文章第一部分
  2. 拓展:可增加到图片上的水印,可自定义(可旋转,缩放,拖拽,不限制数量,可为文字或图片的水印) -- 对应文章第二部分
  3. 难点方法:将web前端代码变成canvas的方法 -- 对应文章第三部分

说明

  1. 本文通过循序渐进的方式讲解,所以重复代码可能有点多。
  2. 本文尽可能针对难点性问题讲解,最终所有代码会呈现在底部github链接

第一部分:页面全局水印

效果展示

演示地址

https://little-littleprogrammer.github.io/npm-components/dist/#/

地址里还有其他的组件,有兴趣的可以看下啊

实现的功能

1、实现页面全局水印,水印斜向平铺布满整个页面
2、不可通过F12的控制台进行对水印canvas元素的删除和样式的更改

具体的实现

说明:
1、本文通过循序渐进的方式讲解,所以重复代码可能有点多。
2、本文尽可能针对难点性问题讲解,最终所有代码会呈现在底部github链接中

水印斜向平铺部分

1、sass写法,简化了css样式的代码
2、先将文字每行铺满,沾满全屏(尽可能多的设置一行多个文字,多行,最后超出的部分设置溢出隐藏)

<template>
    <div ref="ref-watermark">
        <div class="qm-watermark">
            <h1 v-for="item in 16" :key="'line'+item"><span v-for="i in 60" :key="'name'+i">{{name}}</span></h1>
        </div>
    </div>
</template>

3、设置fixed固定在所有图层的最前面,设置pointer-events: none;使水印图层可以点击穿透,从而不影响水印图层后面的操作逻辑

<style lang="scss" scoped>
 .qm-watermark { // 水印
     position: fixed;
     z-index: 99999;
     top: 0;
     bottom: 0;
     left: 0;
     right: 0;
     pointer-events: none;
     opacity: 0.1;
     font-size: 14px;
     font-family: Cursive, serif;
     overflow: hidden;
     span {
         margin-right: 180px;
     }
 }
</style>

4、对每一行文字设置不同的旋转点,进行旋转,并且设置text-indent进行负向衍生

<style lang="scss" scoped>
 .qm-watermark { // 水印
     $e:17;
     @for $i from 0 to $e {
         h1:nth-child(#{$i}){
             white-space: nowrap;
             transform-origin: (18% * ($i - 1)) 1%;
             transform: rotate(-20deg);
             text-indent: (-200px * ($i - 1));
         }
     }
 }
</style>

生成canvas部分

大致原理:将html代码转化成svg代码,再转化成canvas
具体的实现请看如何将web代码变成canvas元素

监听控制台元素删除部分

实现的效果

当通过控制台删除元素时,会跳到特殊页面

实现原理

1、水印组件添加slot,将页面元素通过slot插入水印组件中,设置slot默认布局为请勿删除和隐藏水印!!!

 <div ref="ref-watermark">
    <div class="qm-watermark">
        <h1 v-for="item in 16" :key="'line'+item"><span v-for="i in 60" :key="'name'+i">{{name}}</span></h1>
    </div>
    <slot :name="content">
        <div class="error-warning">
            <img id="forbidImg" src="../assets/images/forbidden.jpg" alt="" srcset="">
            <p>请勿删除和隐藏水印!!!</p>
        </div>
    </slot>
</div>

2、通过js自带的MutationObserver对象(监听元素变化)

3、设置监听元素为水印元素

listen_dom($dom) { //
   // 观察器的配置(需要观察什么变动)
    const config = { attributes: true, childList: true, subtree: true };
    // 当观察到变动时执行的回调函数
    const callback = (mutationsList, observer) => {
        for (const mutation of mutationsList) {
            if (mutation.removedNodes[0] === $dom) {
                this.content = '';
                // 停止观察
                observer.disconnect();
            }
        }
    };
    // 创建一个观察器实例并传入回调函数
    const observer = new MutationObserver(callback);
    // 以上述配置开始观察目标节点
    observer.observe(this.$refs['ref-watermark'], config);
}

4、当监听到删除的元素为水印canvas时,设置slot为空,并停止观察

for (const mutation of mutationsList) {
   if (mutation.removedNodes[0] === $dom) {
        this.content = '';
        // 停止观察
        observer.disconnect();
    }
}

监听控制台元素样式更改和隐藏部分

实现的效果

  1. 设置display,visible,opacity并不会生效
  2. 通过控制台隐藏元素

实现原理

  1. 也是通过MutationObserver对象,设置观察器的配置,让其只监听canvas的style和class属性
    {
        attributes: true, // 将其配置为侦听属性更改,
        attributeFilter: ['style', 'class'] // 监听style属性
    }
    
  2. 当监听到css属性,display,visible,opacity发生变化时,则将其强制设置为以下部分,从而达到无法通过样式消除元素的效果
     if (mutation.type == 'attributes') {
      	console.log('css changed', mutation);
        $canvas.style.display = 'block';
        $canvas.style.opacity = '1';
        $canvas.style.visibility = 'visible';
    }
    
  3. 当隐藏元素,监听到类名的改变时,则设置slot为空,展示默认部分
     if (mutation.type == 'attributes' && mutation.attributeName === 'class') {
        console.log('className changed', mutation);
        this.content = '';
        // 停止观察
        observer.disconnect();
    }
    

第二部分:图片水印的实现

效果展示

演示地址:

https://little-littleprogrammer.github.io/npm-components/dist/#/waterMark

(地址里还有其他小组件,有兴趣的可以看下)

实现的功能

  • 水印样式可自定义 -- 斜向平铺以及自定义
  • 自定义样式可自定义水印的个数,颜色,大小,可以实现水印旋转,拖拽
  • 可导出为png格式的图片,文件名为第一个水印的名称
  • 可添加图片水印,图片水印可实现旋转拖拽和缩放

具体实现

选择图片部分

  1. 选择文件后,通过get_file_data方法,将图片转化为base64
  2. 根据图片的大小,去控制result-container和img-container容器的大小,当超过浏览器宽度时,限制图片为浏览器宽度
<template>
   <input type="file" accept="image/*" @change="get_file_data" />
   <div class="result-container" ref="ref-result-container">
		<div class="img-container" ref="ref-img-container">
   		<img :src="imgUrl" alt />
	</div>
</div>
</template>
get_file_data(data) {
    const fileReader = new FileReader();
    const $resultContainer = this.$refs['ref-result-container'];
    const $imgContainer = this.$refs['ref-img-container'];
    fileReader.onload = (e) => {
        this.imgUrl = e.target.result;
        const _image = new Image();
        _image.src = this.imgUrl;
        _image.onload = (e) => {
            if (e.path[0].width > document.body.clientWidth) {
                $resultContainer.style.width =
                    document.body.clientWidth + 'px';
                $imgContainer.style.width =
                    document.body.clientWidth - 40 + 'px';
            } else {
                $resultContainer.style.width = e.path[0].width + 'px';
                $imgContainer.style.width = e.path[0].width - 40 + 'px';
            }
        };
    };
    // readAsDataURL
    fileReader.readAsDataURL(data.target.files[0]);
    fileReader.onerror = () => {
        new Error('blobToBase64 error');
    };
},

水印样式部分

layout控制样式,1是斜向平铺,2是自定义样式(自定义样式可添加文字水印和图片水印)

1、斜向平铺
最终效果:

斜向平铺样式主要为css控制,我这里使用的笨办法,设置很多行,一行很多个,对不同的行设置不同的旋转点,旋转。最后溢出的部分overflow:hidden

<div v-else class="qm-watermark">
    <p v-for="item in 16" :key="'line'+item">
        <span v-for="i in 60" :key="'name'+i">{{markList[0].username}}</span>
    </p>
</div>
.qm-watermark {
    // 水印
    position: absolute;
    z-index: 99999;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    pointer-events: none;
    font-family: Cursive, serif;
    overflow: hidden;
    $e: 17;
    @for $i from 0 to $e {
        p:nth-child(#{$i}) {
            white-space: nowrap;
            transform-origin: (18% * ($i - 1)) 1%;
            transform: rotate(-20deg);
            text-indent: (-10px * ($i - 1));
        }
    }
    span {
        margin-right: 30px;
    }
}

2、自定义水印部分
自定义样式的难点主要在于水印字体的旋转移动缩放

<div v-for="item in markList" :key="item.index">
    <li>
        <div>
            <span>姓名:</span>
            <input v-model="item.username" type="text" />
        </div>
    </li>
    <li>
        <button
            v-if="item.index===markList.length-1 && item.index !==0"
            :disabled="form.layout!=='2'"
            :class="{'disabled':form.layout!=='2'}"
            @click="del_water_mark"
        >删除水印</button>
        <button
            v-if="form.layout=='2'&&item.index===markList.length-1"
            :class="{'disabled':form.layout!=='2'}"
            @click="add_water_mark"
        >添加水印</button>
    </li>
</div>
<script>
data() {
	return {
		markList: [
		    {
		        index: 0,
		        username: '',
		        color: '#000000',
		        size: 14
		    }
		],
	}
}

</script>
methods:{
	add_water_mark() {
       this.keyIndex++;
        this.markList.push({
            index: this.keyIndex,
            username: '',
            color: '#000000',
            size: 14
        });
    },
    del_water_mark() {
        this.keyIndex--;
        this.markList.pop();
    }
}

旋转部分

效果

旋转部分逻辑:

  • 右下角旋转图标为svg图标(生成图片是不会存在,在变成canvas的时候,进行了过滤)
  • 旋转图标添加mousedown事件,点击后,为document添加mousemovemouseup事件,鼠标弹起时移除
  • 当有多个水印时,如何判断点击的是哪个水印,在通过rotate_name(e)方法中通过$event的属性,就能取到父组件与祖父组件,从而判断出来改变的是那个元素。
  • 通过get_origin_transform($dom)方法获取旋转中心点(因为移动后会改变旋转中心点,所以要每次点击都要获取),然后再通过get_rotate_deg(e)方法,获取到鼠标的点,旋转中心点,通过一下算法,计算出旋转值,通过$dom.style.transform设置旋转

代码


  rotate_name(e) {
      this._parentNode =
          e.target.tagName.toLowerCase() === 'svg'
              ? e.target.parentNode
              : e.target.parentNode.parentNode;
      this._grentParentNode =
          e.target.tagName.toLowerCase() === 'svg'
              ? e.target.parentNode.parentNode
              : e.target.parentNode.parentNode.parentNode;
      document.addEventListener('mousemove', this.get_rotate_deg);
      document.addEventListener('mouseup', () => {
          document.removeEventListener('mousemove', this.get_rotate_deg);
      });
  },
  get_rotate_deg(e) {
      const $dom = this._parentNode;
      const $domParent = this._grentParentNode;
      const transformOption = this.get_origin_transform($domParent);
      // 中心点的计算是根据浏览器视口的计算,offset是根据页面元素的位置,所以要减去卷动值
      const positonByHtml = {
          centerX: Methods.offset($dom).left + $dom.clientWidth / 2 - document.documentElement.scrollLeft,
          centerY: Methods.offset($dom).top + $dom.clientHeight / 2 - document.documentElement.scrollTop
      };
      let x = e.clientX;
      let y = e.clientY;
      const origin = {
          x:
              +positonByHtml.centerX +
              parseInt(transformOption.translateX || 0),
          y:
              +positonByHtml.centerY +
              parseInt(transformOption.translateY || 0)
      }; // 先手动指定当前中心点,也可以根据当前元素的left+width/2 的到x  top+height/2 得到y值
      // 计算出当前鼠标相对于元素中心点的坐标
      x = x - origin.x; // 因为x大于origin.x 是在y轴右边,直接减就行了
      y = origin.y - y; // 但是y如果要在x轴上方,它是比origin.y要小的,所以这里就需要反过来

      // 然后计算就可以了
      const deg = (Math.atan2(y, x) / Math.PI) * 180;
      const option = {
          moveX: 0,
          moveY: 0,
          deg: -deg || 0
      };
      this.set_transform($dom, option);
  },
  get_origin_transform($dom) {
      let old = $dom.style.transform;
      const transformOption = {};
      if (old) {
          old = old.split(' ');
          old.forEach((item) => {
              transformOption[item.replace(/\((.*)\)/, '')] =
                  item.replace(/[^0-9-]/g, '');
          });
      }
      return transformOption;
  },
  set_transform(dom, option) {
      Methods.css(dom, {
          transform: `rotate(${option.deg}deg) translateX(${option.moveX}px) translateY(${option.moveY}px)`
      });
  },

具体算法

  const positonByHtml = {
      centerX: Methods.offset($dom).left + $dom.clientWidth / 2 - document.documentElement.scrollLeft,
      centerY: Methods.offset($dom).top + $dom.clientHeight / 2 - document.documentElement.scrollTop
  };
  let x = e.clientX;
  let y = e.clientY;
  const origin = {
      x:
          +positonByHtml.centerX +
          parseInt(transformOption.translateX || 0),
      y:
          +positonByHtml.centerY +
          parseInt(transformOption.translateY || 0)
  }; // 先手动指定当前中心点,也可以根据当前元素的left+width/2 的到x  top+height/2 得到y值
  // 计算出当前鼠标相对于元素中心点的坐标
  x = x - origin.x; // 因为x大于origin.x 是在y轴右边,直接减就行了
  y = origin.y - y; // 但是y如果要在x轴上方,它是比origin.y要小的,所以这里就需要反过来

  // 然后计算就可以了
  const deg = (Math.atan2(y, x) / Math.PI) * 180;

说明,旋转点的计算是根据浏览器的可视高度计算的,所以用offsetTop+$dom.clientHeight(水印的一半)计算的值要在减去滚轮的scrollY才计算的精准

移动部分

移动部分代码逻辑

  • 通过javascript存在的drag属性去控制
  • dragstart的时候记录鼠标起始点,记录后开始监听drag时间,通过鼠标移动的距离-减去起始点+之前移动的距离来计算计算
  • transformOption为记录之前移动的距离和旋转度数,防止第二次拖动或旋转的时候,水印复位
  • 因为css3的旋转和移动都记录在一个属性上,没办法拆分,移动和旋转之前,一定一定要把之前的状态带上
    drag_handle(e) {
      for (let i = 0; i < this.markList.length; i++) {
          const $dom = this.$refs['ref-name'][i];
          const transformOption = this.get_origin_transform($dom);
          const startX = e.clientX;
          const startY = e.clientY;
          $dom.ondrag = (e) => {
              e.preventDefault();
              const option = {
                  moveX:
                      e.clientX -
                      startX +
                      parseInt(transformOption.translateX || 0),
                  moveY:
                      e.clientY -
                      startY +
                      parseInt(transformOption.translateY || 0),
                  deg: transformOption.rotate || 0
              };
              this.set_transform($dom, option);
          };
          $dom.ondragover = (e) => {
              e.preventDefault();
          };
      }
  },
  get_origin_transform($dom) {
      let old = $dom.style.transform;
      const transformOption = {};
      if (old) {
          old = old.split(' ');
          old.forEach((item) => {
              transformOption[item.replace(/\((.*)\)/, '')] =
                  item.replace(/[^0-9-]/g, '');
          });
      }
      return transformOption;
  },
  set_transform(dom, option) {
      Methods.css(dom, {
          transform: `rotate(${option.deg}deg) translateX(${option.moveX}px) translateY(${option.moveY}px)`
      });
  },

设置transform属性

  moveX:
   e.clientX -
      startX +
      parseInt(transformOption.translateX || 0),
  moveY:
      e.clientY -
      startY +
      parseInt(transformOption.translateY || 0),
  deg: transformOption.rotate || 0

缩放部分

效果图:

实现的功能:

  • 点击左上角的缩放图标,可进行拉伸
  • 可以自定义个数

缩放部分代码逻辑

  • 首先获取图标的第一个兄弟元素(img),通过e.target.previousSibling获取,以及图标的父元素(rotate-name)
  • 获取父元素的宽高,以及刚开始的鼠标坐标
  • 算法:开始时的鼠标坐标 - 移动的鼠标坐标 + 父元素的宽高

限制了图片最小缩放为30*30

具体代码

 scale_img(e) {
     // 缩放图片
     this._parentNode =
         e.target.tagName.toLowerCase() === 'svg'
             ? e.target.previousSibling
             : e.target.parentNode.previousSibling;
     const _grentParentNode =
         e.target.tagName.toLowerCase() === 'svg'
             ? e.target.parentNode
             : e.target.parentNode.parentNode;
     this._pos = {
         w: _grentParentNode.offsetWidth,
         h: _grentParentNode.offsetHeight,
         x: e.clientX,
         y: e.clientY
     };
     document.addEventListener('mousemove', this.get_scale);
     document.addEventListener('mouseup', () => {
         document.removeEventListener('mousemove', this.get_scale);
     });
 },
 get_scale(e) {
     // 缩放图片的mousemove事件,给图片设置宽高
     e.preventDefault();
     const $dom = this._parentNode;
     // 设置图片的最小缩放为30*30
     const w = Math.max(30, this._pos.x - e.clientX + this._pos.w - 20);
     const h = Math.max(30, this._pos.y - e.clientY + this._pos.h);
     $dom.style.width = w + 'px';
     $dom.style.height = h + 'px';
 },

颜色选择部分

效果图

实现的功能:

  • 此功能单独抽了一个vue文件,可以在其他有需要的地方使用
  • 可以通过内外两个部分,共同去决定颜色
  • 颜色为双向绑定,视图可通过vm改变模型,模型也可通过vm改变视图

API

参数 说明 类型 默认值
defaultColor 默认的颜色 String #FFFFFF
targetElem 要绑定到哪个输入框上 String null

Events API

事件名称 说明 回调参数
onChange 颜色改变的回调 绑定的颜色

tips

  1. 需要在输入框绑定focus事件,当focus时,执行组件的openPicker事件
  2. 当直接在输入框输入颜色时,要执行组件的updateValue方法,同步更新颜色选择器

使用方式

<color-picker
   ref="ref-colorPicker"
   :color="item.color"
   :targetElem="'#color-input'+item.index"
   @onChange="(color)=>{ set_form_color(color, item, index)}"
></color-picker>
set_form_color(color, item, index) {
    item.color = color;
    const $dom =
        this.form.layout === '1'
            ? this.$refs['ref-img-container']
            : this.$refs['ref-name'][index];
    $dom.style.color = color;
},

具体的逻辑可以查看源码,实现起来挺复杂的,感谢万能的百度

接下来是核心部分

生成图片的部分

效果图

点击生成图片,便生成了canvas

使用方式

  • 方法被单独抽出,可以实现自定义的代码转化成canvas
  • 方法有两个参数,第一个参数为传入的dom元素,第二个参数为设置到canvas上的css属性
  • 调用后,通过.then方法可以获取到生成图片的base64

具体的逻辑

  • 大体逻辑为:将html代码读取,转化为svg,最后在转成canvas(也可以使用html2canvas)
  • 定义一个str缓存,用于存放生成的dom。
  • 根据传入的dom,遍历里面的所有元素。并且遍历所有元素的css属性,最后拼接起来
  • 通过'data:image/svg+xml;base64,' + window.btoa(unescape(encodeURIComponent(data)))将svg字符串变成svg格式的base64
  • 最后通过ctx.drawImage(img, 0, 0);绘制到canvas上

说明
1、最后绘制上去后,要remove掉传入的元素,并把canvas上树,替代原来的元素
2、在绘制svg的时候,对img以及svg,path标签进行了特殊处理,svg,path直接忽视跳过,img除了读取css属性,还要读取src属性,并且读取的src属性必须转化为base64格式

详细的使用方式和原理请看 如何将web代码变成canvas元素

导出图片

通过 <a :href="url" :download="markList[0].username+'.png'">导出</a>导出,href为图片的base64地址,下载名称为,第一个水印的名称

第三部分 html2canvas的简单实现

实现的功能

  1. 将传入的html元素变成canvas
  2. 最后返回一个promise对象,对象为canvas元素

使用方式

参数1: 要转换的dom元素
参数2: 要设置给canvas的配置{ width: 100, height: 100, style: {}}
callback:promise对象(canvas元素)

 Methods.htmlTocanvas($dom, option).then(($canvas) => {
     // 监听元素
     this.listen_dom($canvas);
     this.listen_css($canvas);
 });

具体的实现

  1. 先将传入的dom元素解析,转化成字符串格式,并并入到svg规格下,插入到<foreignObject></foreignObject>标签中
async function get_svg_dom_string(element) { // 将html代码嵌入svg
   const $dom = await render_dom(element, true);
   return `
           <svg xmlns="http://www.w3.org/2000/svg" width = "${options.width}" height = "${options.height}">
               <foreignObject width="100%" height="100%">
                    ${$dom}
               </foreignObject>\n
           </svg>
       `;
}
  1. 递归调用标签生成dom字符串

说明

  1. 对img标签特殊处理,因为img标签不仅要有style属性,还要有src属性,并且svg只支持base64格式的src
  2. 对svg以及path标签进行过滤,意思是,不允许dom字符串含有svg格式的icon
async function render_dom(element, isTop) { // 递归调用获取子标签
    const tag = element.tagName.toLowerCase();
    let str = `<${tag} `;
    let flag = true;
    // 最外层的节点要加xmlns命名空间
    isTop && (str += `xmlns="http://www.w3.org/1999/xhtml" `);
    if (str === '<img ') { // img标签特殊处理
        flag = false;
        let base64Img = '';
        if (element.src.length > 30000) { // 判断src属性是不是base64, 是的话不用处理,不是的话,转换base64
            base64Img = element.src;
        } else {
            base64Img = await getBase64Image(element.src);
        }
        str += `src="${base64Img}" style="${get_element_styles(element)}" />\n`;
    } else if (str.includes('svg') || str.includes('path')) {
        flag = false;
        str = '';
    } else {
        str += `style="${get_element_styles(element)}">\n`;
    }
    if (element.children.length) {
        for (const el of element.children) {
            str += await render_dom(el);
        }
    } else {
        str += element.innerHTML;
    }
    if (flag) {
        str += `</${tag}>\n`;
    }
    return str;
}

3、遍历读取到的元素的css属性,并赋值到style上

 function get_element_styles(element) { // 获取标签的所有样式
   const css = window.getComputedStyle(element);
    let style = '';
    for (const key of css) {
        if (key === '-webkit-locale') {
            style += '';
        } else {
            style += `${key}:${css[key]};`;
        }
    }
    return style;
}

4、最后声明一个new Image(), 将svg转化成base64格式并赋值到img.src上,因为img.onload是一个异步的,所以返回一个promise对象

async function init_main() { // 主方法
   const data = await get_svg_dom_string(dom);
   // const DOMURL = window.URL || window.webkitURL || window;
   const img = new Image();
   img.src = 'data:image/svg+xml;base64,' + window.btoa(unescape(encodeURIComponent(data)));
   // const svg = new Blob([data], {type: 'image/svg+xml;charset=utf-8'});
   // const url = DOMURL.createObjectURL(svg);
   // img.setAttribute('crossOrigin', 'anonymous');
   // img.src = url;
   return new Promise(resolve => {
       img.onload = function() { // 最终生成的canvas
           ctx.drawImage(img, 0, 0);
           const parentNode = dom.parentNode;
           parentNode.insertBefore($canvas, dom); // 将canvas插入原来的位置
           parentNode.removeChild(dom); // 最终移除页面中被转换的代码
           resolve($canvas);
       };
   });
}

开发时遇到的难点与解决方式

  • img标签附带src属性,需要特殊处理,并且src属性为base64,原因如下:

    • 代码中的拼接方式只会拼接style属性,对于img这种特殊的标签需要特殊处理
    • src指向的是本地的图片地址,转化为字符串后,读取不到,所以要转化为base64
  • 使用将拼接的svg字符串变成blob格式,赋值给new Image().src属性的话,会造成跨域的问题,会导致$canvas.toDataURL()方法无法使用,所以要使用img.src = 'data:image/svg+xml;base64,' + window.btoa(unescape(encodeURIComponent(data)));这种方式

  • 部分情况下,对于css样式-webkit-locale要特殊处理,要将其忽视掉,原因如下:

    • 读取css样式并赋值到style上的时候,-webkit-locale属性会将字符串中断,导致后面的样式读取不到

遗留的问题

  1. 对特殊标签无法处理(带其他属性的标签),需要在方法中进行拓展
  2. 通过window.getComputedStyle(element);获取的css属性太多,加上base64,拼接后会导致字符串很长

gitHub地址:

https://github.com/Little-LittleProgrammer/npm-components