整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

CSS3 Grid布局实现Loading动画效果

击右上方红色按钮关注“web秀”,让你真正秀起来

前言

以前说页面是动态,基本都是说数据是从数据库查询的,不是写死在html代码里面的。现在的说页面动态,一般会问:你是说数据,还是效果动态?

CSS3 Grid布局实现Loading动画效果

好的前端动画效果,能给用户带来非常舒适的体验效果,更甚者,有用户觉的你这个动画效果非常nice,反复操作,就为看你这个动画。停留时间,预览量上了,带来的收益也不是一丁点吧。

当然也不用为了动画,而额外的来制作动画效果。比如一个弹框,可以直接渐变出现的,你还加了飞了一圈出现,那就是不必要的动画了。

所以恰大好处的动画效果,能带来非常不错的效果。

下面我们来学习如果只做一些简单的动画效果:

CSS3 Grid布局实现Loading动画效果

grid布局

CSS3 Grid布局实现Loading动画效果

上图的动画,就是一个简单的loading加载效果,而且是一个3x3的九宫格。是因为旋转才变成一个菱形的样子。我们先来画出3x3的九宫格:

html

<div class="loading">
 <span></span>
 <span></span>
 <span></span>
 <span></span>
 <span></span>
 <span></span>
 <span></span>
 <span></span>
 <span></span>
</div>

这里用9个span来做每个格子元素。

css

body {
 margin: 0;
 height: 100vh; /*=100%*/
 display: flex; /*flex布局*/
 align-items: center; /*flex布局:垂直居中*/
 justify-content: center; /*flex布局:水平居中*/
 background-color: black;
}
.loading {
 width: 10em;
 height: 10em;
 display: grid; /*grid布局*/
 grid-template-columns: repeat(3, 1fr);
 grid-gap: 0.5em; /*grid 每个item之间的间距*/
}
/**
* --name 是css中定义变量的方式
* 可以直接用 var(--name) 使用
*/
.loading span {
 background-color: var(--color); /*背景颜色*/
}
.loading span:nth-child(2n+2) {
 /*n=0: 2*/
 /*n=1: 4*/
 /*n=2: 6*/
 /*n=3: 8*/
 /*n=4: 10(不存在)*/
 --color: #f13f84;
}
.loading span:nth-child(4n+3) {
 /*n=0: 3*/
 /*n=1: 7*/
 /*n=2: 11(不存在)*/
 --color: #46b0ff;
}
.loading span:nth-child(4n+1) {
 /*n=0: 1*/
 /*n=1: 5*/
 /*n=2: 9*/
 /*n=3: 13(不存在)*/
 --color: #44bb44;
}

CSS3 Grid布局实现Loading动画效果

grid-template-columns: 该属性是基于 网格列. 的维度,去定义网格线的名称和网格轨道的尺寸大小。

repeat: 表示网格轨道的重复部分,以一种更简洁的方式去表示大量而且重复列的表达式。

有了九宫格布局后,我们直接旋转这个loading元素,制作动画。

CSS3动画

.loading {
 ...
 transform: rotate(45deg); /*旋转45°*/
}
.loading span {
 background-color: var(--color);
 /**
 * 动画名字是blinking
 * 动画整个时间是2s
 * 每个元素的执行延时时间 var(--delay)
 * 动画的速度曲线 由慢到快 ease-in-out
 * 永久执行 infinite
 */
 animation: blinking 2s var(--delay) ease-in-out infinite;
 animation-fill-mode: backwards;
}
/**
* 每个元素执行动画延时时间变量
*/
.loading span:nth-child(7) {
 --delay: 0s;
}
.loading span:nth-child(4n+4) {
 --delay: 0.2s;
}
.loading span:nth-child(4n+1) {
 --delay: 0.4s;
}
.loading span:nth-child(4n+2) {
 --delay: 0.6s;
}
.loading span:nth-child(3) {
 --delay: 0.8s;
}
/**
* 动画效果
*/
@keyframes blinking {
 0%, 20% {
 transform: rotate(0deg) scale(0);
 }
 40%, 80% {
 /*
 * 旋转一圈rotate(1turn)[转、圈(Turns)。一个圆共1圈]
 * 缩放 scale 如果大于1就代表放大;如果小于1就代表缩小
 */
 transform: rotate(1turn) scale(1);
 }
 100% {
 transform: rotate(2turn) scale(0);
 }
}

animation语法

animation: name duration timing-function delay iteration-count direction;

1、animation-name 规定需要绑定到选择器的 keyframe 名称。

2、animation-duration 规定完成动画所花费的时间,以秒或毫秒计。

3、animation-timing-function 规定动画的速度曲线。

4、animation-delay 规定在动画开始之前的延迟。

5、animation-iteration-count 规定动画应该播放的次数。

6、animation-direction 规定是否应该轮流反向播放动画。

CSS3 Grid布局实现Loading动画效果

动画的速度曲线

1、linear 规定以相同速度开始至结束的过渡效果(等于 cubic-bezier(0,0,1,1))。(匀速)

2、ease 规定慢速开始,然后变快,然后慢速结束的过渡效果(cubic-bezier(0.25,0.1,0.25,1))(相对于匀速,中间快,两头慢)。

3、ease-in 规定以慢速开始的过渡效果(等于 cubic-bezier(0.42,0,1,1))(相对于匀速,开始的时候慢,之后快)。

4、ease-out 规定以慢速结束的过渡效果(等于 cubic-bezier(0,0,0.58,1))(相对于匀速,开始时快,结束时候间慢,)。

5、ease-in-out 规定以慢速开始和结束的过渡效果(等于 cubic-bezier(0.42,0,0.58,1))(相对于匀速,(开始和结束都慢)两头慢)。

6、cubic-bezier(n,n,n,n) 在 cubic-bezier 函数中定义自己的值。可能的值是 0 至 1 之间的数值。

CSS3 Grid布局实现Loading动画效果

总结

CSS3动画基础知识可以看看 《如何快速上手基础的CSS3动画》 这篇文章,里面用更小的示例,讲述了CSS3动画的每个属性。CSS3动画,无外乎就是animation、transform、transition等属性的使用,记住他们每个的作用特效就可以了。


喜欢小编或者觉得小编文章对你有帮助的,可以点击一波关注哦!

最近有个图表需求,怎么配置也配置不好,十分头疼。所以想借着这个问题手写实现一个交互体验还不错的曲线图,支持开场动画、自动根据父盒子宽度适配、比echarts更全的配置项,分区线段的可以更好的自定义等。 效果如下

更新一波! github.com/ccj-007/spa… 有兴趣的可以看看源码
同时已经发布到了npm库 www.npmjs.com/package/spa…

起源

  visualMap: {
    type: 'piecewise',
    show: false,
    dimension: 0,
    seriesIndex: 0,
    pieces: [
      {
        gt: 1,
        lt: 3,
        color: 'rgba(0, 0, 180, 0.4)'
      },
      {
        gt: 5,
        lt: 7,
        color: 'rgba(0, 0, 180, 0.4)'
      }
    ]
  },

这里摘抄的是echarts官网的示例,颜色无法更高的自定义程度, 这种情况想做渐变不太行, 但是分区是实现了,手动狗头,让我们看看另外一个示例

  series: [
    {
      type: 'line',
      smooth: 0.6,
      symbol: 'none',
      lineStyle: {
        color: '#5470C6',
        width: 5
      },
      markLine: {
        symbol: ['none', 'none'],
        label: { show: false },
        data: [{ xAxis: 1 }, { xAxis: 3 }, { xAxis: 5 }, { xAxis: 7 }]
      },
      areaStyle: {},
      data: [
        ['2019-10-10', 200],
        ['2019-10-11', 560],
        ['2019-10-12', 750],
        ['2019-10-13', 580],
        ['2019-10-14', 250],
        ['2019-10-15', 300],
        ['2019-10-16', 450],
        ['2019-10-17', 300],
        ['2019-10-18', 100]
      ]
    }
  ]

这种情况分区的线段颜色有了,但是渐变却不能区分,只能统一一个区域的渐变。

所以在我们平常开发,折线图或者曲线图一般用用echarts绰绰有余了,但是总有这么几个配置让人抓狂,比如要分区,分区的填充色要做渐变、线段要渐变、要支持hover并更改填充色、label更新等。虽然用echarts的markArea能实现一部分,但是看着那几个抓狂的api,既然追求完美落地,那就硬着头皮手写个吧

曲线图

分析思路

我们从总的canvas绘图思路来看,首先要分成3层,红色区域代表辅助层(轴、标注、辅助线、图例等)、绿色区域图表层(折线、曲线等)、蓝色区域标签层(label数据展示卡片等)。为什么要分层,就是为了后期管理图层能更容易,不然做个动画、清理画布也是很麻烦的事情。

如何做适配

这里有个细节就是canvas一定要设置width、height而不是canvas.style.width,在窗口缩放场景下会有问题。这是最关键的一点,其次我们传入的axisX和axisY的data一定要知道他只是个份数,我们要映射到的是份数,这样比如1000px宽的屏幕,我们取10份是100px、500px的屏幕,取10份是50px。我们一般只要考虑宽度的缩放。

考虑缩放

  // 计算 Y 轴坐标比例尺 ratioY
  maxY = Math.max.apply(null, concatData);
  minY = Math.min.apply(null, concatData);
  rangeY = maxY - minY;
  // 数据和坐标范围的比值
  ratioY = (height - 2 * margin) / rangeY;
  // 计算 X 轴坐标比例尺和步长
  count = concatData.length;
  rangeX = width - 2 * margin;
  xk = 1, xkVal = xk * margin
  dataLen = data.length
  ratioX = rangeX / (count - dataLen);
  stepX = ratioX;

绘制坐标轴

/**
 * 绘制坐标轴
 */
function drawAxis() {
  ctx.beginPath();
  ctx.moveTo(margin, margin);
  ctx.lineTo(margin, height - margin);
  ctx.lineTo(width - margin + 2, height - margin);
  ctx.setLineDash([3, 3])
  ctx.strokeStyle = '#aaa'
  ctx.stroke();
  ctx.setLineDash([1])
  const yLen = newOpt.axisY.data.length
  const xLen = newOpt.axisX.data.length

  // 绘制 Y 轴坐标标记和标签
  for (let i = 0; i < yLen; i++) {
    let y = (rangeY * i) / (yLen - 1) + minY;
    let yPos = height - margin - (y - minY) * ratioY;

    if (i) {
      ctx.beginPath();
      ctx.moveTo(margin, yPos);
      ctx.lineTo(width - margin, yPos);
      ctx.strokeStyle = '#ddd'
      ctx.stroke();
    }

    ctx.beginPath();
    ctx.stroke();
    newYs = []
    for (const val of options.axisY.data) {
      newYs.push(options.axisY.format(val))
    }
    ctx.fillText(newYs[i] + '', margin - 15 - options.axisY.right, yPos + 5);
    firstEnding && axisYList.push(yPos + 5)
  }

  // 绘制 X 轴坐标标签
  for (let i = 0; i < xLen; i++) {
    let x = i * stepX;
    let xPos = (margin + x);
    if (i) {
      ctx.beginPath();
      ctx.moveTo(xPos, height - margin);
      ctx.lineTo(xPos, margin);
      ctx.strokeStyle = '#ddd'
      ctx.stroke();
    }
    newXs = []
    for (const val of options.axisX.data) {
      newXs.push(options.axisX.format(val))
    }
    ctx.fillText(newXs[i], xPos - 1, height - margin + 10 + options.axisX.top);
    firstEnding && axisXList.push(xPos - 1)
  }
}

绘制曲线入口

/**
 * 绘制单组曲线
 * @param data 
 */
function drawLine(data: any) {
  const { points, id, rgba, lineColor, hoverRgba } = data
  startAreaX = endAreaX
  startAreaY = endAreaY
  // 分割区
  if (firstEnding) {
    areaList.push({ x: startAreaX, y: startAreaY })
  }

  function darwColorOrLine(lineMode: boolean) {
    // 绘制折线
    ctx.beginPath();
    ctx.moveTo(id ? margin + endAreaX - xkVal : margin + endAreaX, height - margin - (points[0] - minY) * ratioY);
    ctx.lineWidth = 2
    ctx.setLineDash([0, 0])

    let x = 0, y = 0, translateX = 0
    if (id) {
      translateX -= 20
    }
    for (let i = 0; i < points.length; i++) {
      x = i * stepX + margin + endAreaX + translateX
      y = height - margin - (points[i] - minY) * ratioY;

      let x0 = (i - 1) * stepX + margin + endAreaX + translateX;
      let y0 = height - margin - (points[i - 1] - minY) * ratioY;
      let xc = x0 + stepX / 2;
      let yc = (y0 + y) / 2;
      if (i === 0) {
        prePointPosX = x
        prePointPosY = y
        ctx.lineTo(x, y);
        // 这里需要提前考虑是否是线、还是曲线
        if (!(prePointPosX === x && prePointPosY === y)) {
          pointList.push({ type: 'line', start: { x: prePointPosX, y: prePointPosY }, end: { x: x, y: y } })
        }
      } else {
        ctx.bezierCurveTo(xc, y0, xc, y, x, y);
        pointList.push({ type: 'curve', start: { x: prePointPosX, y: prePointPosY }, end: { x: x, y: y }, control1: { x: xc, y: y0 }, control2: { x: xc, y: y } })
      }
      prePointPosX = x
      prePointPosY = y
      if (i === points.length - 1) {
        endAreaX = x
        endAreaY = y

        if (firstEnding && id === newOpt.data.length - 1) {
          areaList.push({ x: x, y: y })
        }
      }
    }
    ctx.strokeStyle = lineColor
    ctx.stroke()

    lineMode && ctx.beginPath()

    // 右侧闭合点
    ctx.lineTo(endAreaX, height - margin)
    // 左侧闭合点
    ctx.lineTo(margin + startAreaX, height - margin)
    let startClosePointX = id ? startAreaX : margin + startAreaX
    // 交接闭合点
    ctx.lineTo(startClosePointX, height - margin)
    ctx.strokeStyle = 'transparent'
    lineMode && ctx.stroke();
  }
  darwColorOrLine(false)
  // 渐变
  const gradient = ctx.createLinearGradient(200, 110, 200, 290);

  if (isHover && areaId === id) {
    gradient.addColorStop(0, `rgba(${hoverRgba[1][0]}, ${hoverRgba[1][1]}, ${hoverRgba[1][2]}, 1)`);
    gradient.addColorStop(1, `rgba(${hoverRgba[0][0]}, ${hoverRgba[0][1]}, ${hoverRgba[0][2]}, 1)`);
  } else {
    gradient.addColorStop(0, `rgba(${rgba[1][0]}, ${rgba[1][1]}, ${rgba[1][2]}, 1)`);
    gradient.addColorStop(1, `rgba(${rgba[0][0]}, ${rgba[0][1]}, ${rgba[0][2]}, 0)`);
  }

  ctx.fillStyle = gradient;
  ctx.fill();
}
/**
 * 绘制所有组的曲线
 */
function startDrawLines() {
  const { data, series } = newOpt
  for (let i = 0; i < data.length; i++) {
    drawLine({ points: data[i], id: i, rgba: series[i].rgba, hoverRgba: series[i].hoverRgba, lineColor: series[i].lineColor })
  }
  firstEnding = false  //由于是不断绘制,我们需要得到第一次渲染完的我们想要的数组,防止数据被污染
}

这里要注意的是我们的分区一定是线段的闭合,然后通过fillStyle填充颜色。所以你需要在结束点后再lineTo做3次到我的起始点。addColorStop来做渐变。

绘制贝塞尔曲线

x = i * stepX + margin + endAreaX + translateX
y = height - margin - (points[i] - minY) * ratioY;
let x0 = (i - 1) * stepX + margin + endAreaX + translateX;
let y0 = height - margin - (points[i - 1] - minY) * ratioY;
let xc = x0 + stepX / 2;
let yc = (y0 + y) / 2;
// ....
ctx.bezierCurveTo(xc, y0, xc, y, x, y);

具体api不过多阐述,但是我们要知道一个控制点我们的曲线是只有一个方向的,如果两个控制点,意味着我们曲线可以最多有2个方向。而我们的图表是分上下需要平滑过渡过去的,这个时候必须用两个控制点的。

bezierCurveTo原理

要想实现bezierCurveTo,其实就是计算得到路径经过的所有点,而这个更方便我们后期在路径上的点的获取。下面的计算会比较复杂,其实就是套用三次贝塞尔曲线的公式罢了

function getBezierCurvePoints(startX: number, startY: number, cp1X: number, cp1Y: number, cp2X: number, cp2Y: number, endX: number, endY: number, steps: number) {
  let points = [];

  // 使用二次贝塞尔曲线近似三次贝塞尔曲线
  let q1x = startX + (cp1X - startX) * 2 / 3;
  let q1y = startY + (cp1Y - startY) * 2 / 3;
  let q2x = endX + (cp2X - endX) * 2 / 3;
  let q2y = endY + (cp2Y - endY) * 2 / 3;

  // 采样曲线上的所有点
  for (let i = 0; i <= steps; i++) {
    let t = i / steps;
    let x = (1 - t) * (1 - t) * (1 - t) * startX +
      3 * t * (1 - t) * (1 - t) * q1x +
      3 * t * t * (1 - t) * q2x +
      t * t * t * endX;
    let y = (1 - t) * (1 - t) * (1 - t) * startY +
      3 * t * (1 - t) * (1 - t) * q1y +
      3 * t * t * (1 - t) * q2y +
      t * t * t * endY;

    points.push({ x: +x.toFixed(2), y: +y.toFixed(2) });
  }

  return points;
}

三次贝塞尔曲线的公式原理

公式的推导数学好的大佬可以研究研究,但是他的计算过程还是要知道的,我们可以看到t的值从0到1代表曲线的开始端点和结束端点。t控制着Q1、Q2、Q3的百分比的分别在p1p2、p2p3、p3p4线段的位置,同理也是r1、r2对应的位置,然后再得出r1和r2中的相对位置。所以根本就是t的偏移量在不断划分的线段中的位置。t从0到1的所有的点的集合就是构造曲线的集合。

同时我们根据这个原理,通过Ramer Douglas Peucker 算法可以得出线段的细分,控制曲线是否圆滑。

如何实现点在路径上游走

我们之前能得到曲线上的所有点,只要计算我的clientX是否在路径点的集合中对应的那个点筛选出来,然后在遮罩层绘制一个圆圈以及辅助线。

function getAllPoints(segments: PointList) {
  let points = [];
  let lastPoint = null;

  // 遍历所有线段的控制点和终点,将这些点的坐标存储到数组中
  for (let i = 0; i < segments.length; i++) {
    let segment = segments[i];
    let pointsCount = 50; // 点的数量
    // 如果是直线,则使用lineTo方法连接线段的终点
    if (segment.type === "line") {
      let x0 = segment.start.x;
      let y0 = segment.start.y;
      let x1 = segment.end.x;
      let y1 = segment.end.y;
      for (let j = 0; j <= pointsCount; j++) {
        let t = j / pointsCount;
        let x = x0 + (x1 - x0) * t;
        let y = y0 + (y1 - y0) * t;
        points.push({ x: +x.toFixed(2), y: +y.toFixed(2) });
      }
      // 如果是曲线,则使用贝塞尔曲线的方法绘制曲线,并将曲线上的所有点的坐标存储到数组中
    } else if (segment.type === "curve") {
      let x0 = segment.start.x;
      let y0 = segment.start.y;
      let x1 = segment.control1.x;
      let y1 = segment.control1.y;
      let x2 = segment.control2.x;
      let y2 = segment.control2.y;
      let x3 = segment.end.x;
      let y3 = segment.end.y;
      const point = getBezierCurvePoints(x0, y0, x1, y1, x2, y2, x3, y3, pointsCount)
      points.push(...point);
    }
    // 更新线段的起点
    lastPoint = segment.end;
  }
  return points
}

label的数据计算、区间的计算

我们可以看到填充色和label的数值都已经变化,这里需要监听下全局的坐标是否在图表内,如果在内部,就计算pointX = clientX - dom.offsetLeft - dom.margin, y坐标同理。

/**
 * label显示
 * @param clientX 
 * @param clientY 
 */
function drawTouchPoint(clientX: number, clientY: number) {
  cx = clientX, cy = clientY

  // 计算当前区间位置
  for (let i = 0; i < areaList.length - 1; i++) {
    const pre = areaList[i].x;
    const after = areaList[i + 1].x;

    if (cx > pre && cx < after) {
      areaId = i
    }
  }
  // 计算交叉位置,得到对应的x轴位置,从option的data中取对应的title
  for (let i = 0; i < axisXList.length - 1; i++) {
    const pre = axisXList[i];
    const after = axisXList[i + 1];
    if (cx > pre && cx < after) {
      curInfo.x = i
    }
  }
  for (let i = 0; i < axisYList.length - 1; i++) {
    const max = axisYList[i];
    const min = axisYList[i + 1];
    if (cy < max && cy > min) {
      curInfo.y = i + 1
    }
  }

  let crossPoint = pathPoints.find((item: Pos) => {
    const orderNum = .5
    if (Math.abs(item.x - clientX) <= orderNum) {
      return item
    }
  }) as Pos | undefined
  if (crossPoint && canvas) {
    dotCtx.clearRect(0, 0, canvas.width, canvas.height);

    dotCtx.beginPath()
    dotCtx.setLineDash([2, 4]);
    dotCtx.moveTo(crossPoint.x, margin)
    dotCtx.lineTo(crossPoint.x, height - margin)
    dotCtx.strokeStyle = '#000'
    dotCtx.stroke()

    drawArc(dotCtx, crossPoint.x, crossPoint.y, 5)

    //label
    if (!isLabel) {
      labelDOM = document.createElement("div");
      labelDOM.id = 'canvasTopBox'
      labelDOM.innerHTML = ""
      container && container.appendChild(labelDOM)
      isLabel = true
    } else {
      if (labelDOM) {
        let t = crossPoint.y + labelDOM.offsetHeight > canvas.height - margin ? canvas.height - margin - labelDOM.offsetHeight : crossPoint.y - labelDOM.offsetHeight * .5
        labelDOM.style.left = crossPoint.x + 20 + 'px'
        labelDOM.style.top = t + 'px'
        labelDOM.innerHTML = `
         <div class='label'>
           <div class='label-left' style='backGround: ${newOpt.series[areaId].lineColor}'>
           </div>
          <div class='label-right'>
            <div class='label-text'>人数:${newYs[curInfo.y]} </div>
            <div class='label-text'>订单数:${newXs[curInfo.x]} </div>
          </div>
         </div>
        `
      } else {
      }
    }
  }
}

遮罩动画

核心原理就是通过clearRect,下面的代码是从右向左遮罩,所以这里可以直接transfrom: rotate(-180deg)就可以了。

function drawAnimate() {
  markCtx.clearRect(0, 0, width, height);

  markCtx.fillStyle = "rgba(255, 255, 255, 1)"
  markCtx.fillRect(0, 0, width, height);

  markCtx.clearRect(
    (width - maskWidth),
    (height - maskHeight),
    maskWidth,
    maskHeight
  );

  // 更新遮罩区域大小
  maskWidth += 20;
  maskHeight += 20;
  if (maskWidth < width) {
    animateId = requestAnimationFrame(drawAnimate);
  } else {
    cancelAnimationFrame(animateId)
    watchEvent()
  }
}

option配置入口

export const options = {
  layout: {
    w: 0,
    h: 0,
    root: '#container',
    m: 30
  },
  data: [[40, 60, 40, 80, 10, 50, 80, 0, 50, 30, 20], [20, 30, 60, 40, 30, 10, 30, 20, 0, 30, 40, 20], [20, 30, 20, 40, 20, 10, 10, 30, 0, 30, 50, 20]],
  axisX: {
    data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
    format(param: string | number) {
      return param + 'w'
    },
    top: 4,
  },
  axisY: {
    data: [0, 20, 40, 60, 80],
    format(param: string | number) {
      return param + '人'
    },
    right: 10,
  },
  series: [
    {
      rgba: [[55, 162, 255], [116, 21, 219]],
      hoverRgba: [[55, 162, 255], [116, 21, 219]],
      lineColor: 'blue'
    },
    {
      rgba: [[255, 0, 135], [135, 0, 157]],
      hoverRgba: [[255, 0, 135], [135, 0, 157]],
      lineColor: 'purple'
    },
    {
      rgba: [[255, 190, 0], [224, 62, 76]],
      hoverRgba: [[255, 190, 0], [224, 62, 76]],
      lineColor: 'orange'
    }
  ]
}


总结

canvas的核心就是点的处理,在一些曲线衔接、路径的获取会比较复杂,同时如何管理好图层是很重要的,本曲线图底部是辅助图层不做变化,曲线是需要做动画的话,最好就单独做个图层,顶部在来个遮罩做标签等元素,为了更方便做自定义,我们也没必要用canvas绘制,直接dom或svg渲染就行。


原文链接:https://juejin.cn/post/7224886702883258424
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Web 开发中,有时我们需要实现不同页面之间的数据传递和事件触发,比如一个页面打开了另一个页面,然后在新的页面中操作后需要更新原来的页面的内容。这种场景在电商、支付、社交等领域都很常见,那么如何用js来实现不同页面之间的交互呢?本文提供几种常见的方法供大家学习参考!

一、localStorage

在 Web Storage 中,每一次将一个值存储到本地存储时,都会触发一个 storage 事件,通过 localStorage 结合 window.addEventListener('storage', cb) 完成 A、B 标签页间通信。

// A标签页
localStorage.setItem('send-msg', JSON.stringify({
    name: 'hzd',
    age: '18',
}))

// B标签页
window.addEventListener('storage', (data) => {
    try {
        console.log(data)
        const msg = JSON.parse(data.newValue)
    } catch (err) {
        // 处理错误
    }
})


在控制台打印一下 data 的值,可以看到挺多信息:

二、BroadcastChannel

BroadcastChannel 通信方式的原理就是一个命名管道,它允许让指定的同源下浏览器不同的窗口来订阅它。

每个 BroadcastChannel 对象都需要使用一个唯一的名称来标识通道,这个名称在同一域名下的不同页面之间必须是唯一的,它允许同一域名下的不同页面之间进行通信。

通过 postMessage 方法,一个页面可以将消息发送到频道中,而其他页面则可以监听 message 事件来接收这些消息。通过这种方式是短线了一种实时通信的机制,可以在不同的页面之间传递信息,实现页面间的即时交流。如下图所示:

// A页面
const bc = new BroadcastChannel("test_channel");
bc.postMessage("This is a test message.");


// B页面
const bc = new BroadcastChannel("test_channel");
bc.onmessage = (event) => {
  console.log(event);
};


三、postMessage

postMessage 是 H5 引入的 API,该方法允许来自不同源的脚本采用异步方式进行有效的通信,可以实现跨文本文档、多窗口、跨域消息传递,多用于窗口间数据通信,这也使它成为跨域通信的一种有效的解决方案。

下面看两个简单的使用例子:

示例一:

// 发送端:

<button id="btn">发送消息</button>

<script>
  let device = window.open('http://localhost:63342/signal_communication/postMessage/receive.html')

  document.getElementById('btn').addEventListener('click', event => {
    device.postMessage('发送一条消息')
  })
</script>


// 接收端:

<script>
  window.addEventListener('message', event => {
    console.log(event)
  })
</script>


示例二:

// 发送端:

<div>
    <input id="text" type="text" value="Runoob" />
    <button id="sendMessage" >发送消息</button>
</div>
<iframe id="receiver" src="https://c.runoob.com/runoobtest/postMessage_receiver.html" width="300" height="360">
    <p>你的浏览器不支持 iframe。</p>
</iframe>
<script>
window.onload = function() {
    let receiver = document.getElementById('receiver').contentWindow;
    let btn = document.getElementById('sendMessage');
    btn.addEventListener('click', function (e) {
        e.preventDefault();
        let val = document.getElementById('text').value;
        receiver.postMessage("Hello "+val+"!", "https://c.runoob.com");
    });
}
</script>


// 接收端:

<div id="recMessage">Hello World!</div>

<script>
window.onload = function() {
    let messageEle = document.getElementById('recMessage');
    window.addEventListener('message', function (e) {  // 监听 message 事件
        alert(e.origin);
        if (e.origin !== "https://www.runoob.com") {  // 验证消息来源地址
            return;
        }
        messageEle.innerHTML = "从"+ e.origin +"收到消息: " + e.data;
    });
}
</script>


四、SharedWorker

SharedWorker 是一种在 Web 浏览器中使用的 Web API,它允许不同的浏览上下文,如不同的浏览器标签页之间共享数据和执行代码。它可以用于在多个浏览上下文之间建立通信通道,以便它们可以共享信息和协同工作。

与普通的 Worker 不同,SharedWorker 可以在多个浏览上下文中实例化,而不仅限于一个单独的浏览器标签页或框架。这使得多个浏览上下文可以共享同一个后台线程,从而更有效地共享数据和资源,而不必在每个标签页或框架中都创建一个独立的工作线程。

<!-- a.html -->
<script>
  let index = 0;
  const worker = new SharedWorker("worker.js");

  setInterval(() => {
    worker.port.postMessage(`moment ${index++}`);
  }, 1000);
</script>

<!-- b.html -->
<script>
  const worker = new SharedWorker("worker.js");

  worker.port.start();
  setInterval(() => {
    worker.port.postMessage("php是世界上最好的语言");
  }, 1000);

  worker.port.onmessage = function (e) {
    if (e.data) {
      console.log(e.data);
    }
  };
</script>


创建一个 worker.js 文件,并编写以下代码:

let data = "";

self.onconnect = (e) => {
  const port = e.ports[0];

  port.onmessage = function (e) {
    if (e.data === "php是世界上最好的语言") {
      port.postMessage(data);
      data = "";
    } else {
      data = e.data;
    }
  };
};


最终代码运行效果如下图所示:

五、Service Worker

Service Worker 它是一种服务工作线程,是一种在浏览器背后运行的脚本,用于处理网络请求和缓存等任务。它是一种在浏览器与网络之间的中间层,允许开发者拦截和控制页面发出的网络请求,以及管理缓存,从而实现离线访问、性能优化和推送通知等功能。

它在浏览器背后独立运行与网页分开,这意味着即使用户关闭了网页,Service Worker 仍然可以运行。可以用于实现推送通知功能。它可以注册为推送消息的接收者,当服务器有新的通知要发送时,Service Worker 可以显示通知给用户,即使网页没有打开。

要想使用,首先我们创建两个不同的 html 文件分别代表不同的页面,创建一个 Service Worker 文件,并且使用 live server 开启一个本地服务器:

<!-- a.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      navigator.serviceWorker.register("worker.js").then(() => {
        console.log("注册成功");
      });

      setInterval(() => {
        navigator.serviceWorker.controller.postMessage({
          value: `moment ${new Date()}`,
        });
      }, 3000);

      navigator.serviceWorker.onmessage = function (e) {
        console.log(e.data.value);
      };
    </script>
  </body>
</html>

<!-- b.html -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      navigator.serviceWorker.register("worker.js").then(() => {
        console.log("注册成功");
      });

      setInterval(() => {
        navigator.serviceWorker.controller.postMessage({
          value: `moment ${new Date()}`,
        });
      }, 3000);

      navigator.serviceWorker.onmessage = function (e) {
        console.log(e.data.value);
      };
    </script>
  </body>
</html>


创建一个 worker.js 文件并编写以下代码:

// worker.js
self.addEventListener("message", function (e) {
  e.waitUntil(
    self.clients.matchAll().then(function (clients) {
      if (!clients || clients.length === 0) {
        return;
      }
      clients.forEach(function (client) {
        client.postMessage(e.data);
      });
    })
  );
});


最终代码运行如下图所示:

你所编写的 Service Worker 将遵守以下生命周期:

  1. 注册: 在网页的 JavaScript 代码中调用 navigator.serviceWorker.register() 方法来注册一个 Service Worker
  2. 安装: 当 Service Worker 文件被下载并首次运行时,会触发 install 事件。在 install 事件中,你可以缓存静态资源,如 HTML、CSS、JavaScript 文件,以便在离线时使用
  3. 激活: 安装成功后,Service Worker 并不会立即接管页面的网络请求。它需要等到之前的所有页面都关闭,或者在下次页面加载时才会激活()
  4. 就是因为编写的 worker 代码不生效,一直刷新都不生效,直到我关机重启了才生效的......
  5. 控制: 一旦 Service Worker 被激活,它就开始控制在其作用域内的页面。它可以拦截页面发出的网络请求,并根据缓存策略返回缓存的内容
  6. 更新: 当你更新 Service Worker 文件并再次注册时,会触发一个新的 install 事件。你可以在新的 install 事件中更新缓存,然后在下次页面加载时进行激活,以确保新的 Service Worker 被使用
  7. 解除注册: 如果你不再需要,可以通过调用 navigator.serviceWorker.unregister() 来解除注册

六、IndexDB

IndexedDB 是一种在浏览器中用于存储和管理大量结构化数据的 Web API。它提供了一种持久性存储解决方案,允许 Web 应用程序在客户端存储数据,以便在不同会话、页面加载或浏览器关闭之间保留数据。

与传统的 cookie 或 localStorage 等存储方式不同,IndexedDB 更适合存储复杂的、结构化的数据,例如对象、数组、键值对等。这使得它特别适用于应用程序需要存储大量数据、执行高级查询或支持离线工作的情况。

要实现跨标签通信,如下代码所示:

<!-- a.html -->
<script>
  let index = 0;
  // 打开或创建 IndexedDB 数据库
  const request = indexedDB.open("database", 1);

  request.onupgradeneeded = (event) => {
    const db = event.target.result;
    const objectStore = db.createObjectStore("dataStore", {
      keyPath: "key",
    });
  };

  request.onsuccess = (event) => {
    const db = event.target.result;
    const transaction = db.transaction(["dataStore"], "readwrite");
    const objectStore = transaction.objectStore("dataStore");

    // 存储数据

    objectStore.put({ key: "supper", value: `moment` });

    transaction.oncomplete = () => {
      db.close();
    };
  };
</script>

<!-- b.html -->
<script>
  // 打开相同的 IndexedDB 数据库
  const request = indexedDB.open("database", 1);

  request.onsuccess = (event) => {
    const db = event.target.result;
    const transaction = db.transaction(["dataStore"], "readonly");
    const objectStore = transaction.objectStore("dataStore");

    // 获取数据
    const getRequest = objectStore.get("supper");

    getRequest.onsuccess = (event) => {
      const data = event.target.result;
      if (data) {
        console.log(data.value);
      }
    };

    transaction.oncomplete = () => {
      db.close();
    };
  };
</script>


最终代码运行如下图所示:

七、cookie

<!-- a.html -->
<script>
  let index = 0;
  setInterval(() => {
    document.cookie = `supper=moment ${index++}`;
  }, 1000);
</script>

<!-- b.html -->
<script>
  console.log("cookie 的值为: ", document.cookie);

  setInterval(() => {
    console.log("cookie 的值发生了变化: ", document.cookie);
  }, 1000);
</script>


具体代码运行效果如下图所示:


作者:前端掘金者H
链接:https://juejin.cn/post/7268602250653319202