小程序动画技术总结

背景

最近团队正在开发一个小程序一番赏平台的项目,在开发过程中,需要很多动画效果的场景,综合考虑了动画渲染的性能效果,因此采用 CSS3+transform 动画和 canvas 来实现

1、canvas 启动按钮

需求

按下充能 按钮后,表盘上的能量值开始自动叠加充能 ,并且中间切换为开启 按钮,当充能到一定比例显示对应的颜色 ,然后充满后就反向释放能量(反向绘制)

效果如下

思路分解

我们看到这个启动按钮拥有进度、色块,底图 这三部分组成,首先色块和进度是动态的,那必然得用 canvas 来绘制;底图也是动态的,可以用canvascreateImage api 动态插入图片来实现。

1.1 绘制圆图和进度

这一步是最核心也是相对复杂的技术点

具体过程我们拆分成下面几个步骤:

1、初始化画布 2、绘制图片 3、绘制进度 4、绘制充能

对应的代码如下:

1、初始化画布

js 复制代码
/**
 * 初始化画布
 * @param res
 */
initCanvas(res: any) {
    const { width, height, node: canvas } = res[0];
    const ctx: WechatMiniprogram.CanvasContext = canvas.getContext('2d');
    // 必须加此段代码否则canvas会拉伸
    const dpr = wx.getSystemInfoSync().pixelRatio;
    canvas.width = width * dpr;
    canvas.height = height * dpr;
    ctx.scale(dpr, dpr);
    // 初始化是充能状态,中间显示充能按钮
    this.renderImage(ctx, canvas, OpenBtnBgUrl.Charge);
}
init() {
    this.createSelectorQuery()
    .select('#start-button')
    .fields({
        node: true,
        size: true,
    })
    .exec((res) => {
        this.initCanvas(res);
    });
}

在小程序的在件实例进入页面节点树(attached)时,执行初始化画布;在组件实例被从页面节点树移除时(detached)执行取消动画帧

js 复制代码
lifetimes: {
    attached() {
        this.init();
    },
    detached() {
        this.data.canvas.cancelAnimationFrame?.(this.data.canvasId);
        this.data.isRunning = false;
    },
}

2、绘制底图

js 复制代码
/**
 * 绘制图片
 * @param ctx canvas上下文对象
 * @param canvas canvas对象
 * @param imgSrc 要绘制的图片地址
 */
renderImage(
ctx: WechatMiniprogram.CanvasContext,
canvas: WechatMiniprogram.Canvas,
imgSrc = ''
) {
    const img = canvas.createImage();
    img.src = imgSrc;
    img.onload = () => {
        // 必须在图片加载完成进行渲染
        this.render(ctx, 0);
    };
    this.data.ctx = ctx;
    this.data.canvas = canvas;
    this.data.img = img;
}

3、绘制进度

js 复制代码
/**
 * 画布绘制进度
 * @param ctx 画布上下文对象
 * @param currentStep 当前绘制的刻度值
 */
render(ctx: WechatMiniprogram.CanvasContext, currentStep: number) {
    const { width, height } = this.data;
    ctx.clearRect(0, 0, width, height);
    this.drawBackground(ctx);
    // 将坐标原点移动到画布中间
    ctx.translate(width / 2, height / 2);
    // 绘制进度
    this.drawSteps(ctx, currentStep);
    // 将坐标原点还原
    ctx.translate(-width / 2, -height / 2);
}

/**
 * 绘制背景
 */
drawBackground(ctx: WechatMiniprogram.CanvasContext) {
    const { width, height } = this.data;
    ctx.drawImage(this.data.img as any, 0, 0, width, height);
}

/**
 * 绘制进度
 */
drawSteps(ctx: WechatMiniprogram.CanvasContext, currentStep = 0) {
    const { defaultColor, radius, activeColor, stepWidth, stepMargin, stepHeight, stepCount } = this.data;
    ctx.save();
    for (let i = 0; i < stepCount * 2; i++) {
        ctx.beginPath();
        ctx.lineWidth = stepWidth / 2;
        if (i < currentStep) {
            // 绿:[0, 1 / 2];黄:(1 / 2, 2 / 3];橙:(2 / 3, 5 / 6];红:(5 / 6, 1]
            ctx.strokeStyle = this.getDrawColor(i, activeColor as StrokeColor);
        } else {
            ctx.strokeStyle = defaultColor;
        }
        if (i % 2 === 0) {
            ctx.moveTo(-(radius - stepMargin), 0);
            ctx.lineTo(-(radius - stepMargin - stepHeight), 0);
            ctx.rotate(((360 / stepCount) * Math.PI) / 180);
        } else {
            ctx.moveTo(-(radius - stepMargin), stepWidth / 2);
            ctx.lineTo(-(radius - stepMargin - stepHeight), stepWidth / 2);
        }
        ctx.stroke();
    }
},

4、绘制充能

绘制过程使用小程序提供的Canvas.requestAnimationFrame(function callback)进行帧渲染, 在下次进行重绘时执行。

4.1 绘制圆

js 复制代码
/**
 * 启动按钮绘制方向
 */
enum DIRECTION {
  /** 顺时针 */
  Positive = 1,
  /** 逆时针 */
  Opposite = -1,
}

/**
 * 启动按钮背景图片
 */
enum OpenBtnBgUrl {
  /** 充能按钮图片 */
  Charge = 'https:xxx/open-btn-bg-charge.png',
  /** 开启按钮图片 */
  Start = 'https:xxx/open-btn-bg-start.png',
}

/**
 * 递归绘制
 */
renderLoop() {
    const { ctx, stepCount, isRunning } = this.data;
    let { lastStep } = this.data;
    // 方向 1: Positive(顺时针) -1:Opposite(逆时针)
    let direction =
    (this.data.renderLoopCount & 1) === 0 ? DIRECTION.Positive : DIRECTION.Opposite;
    // 是否已经画满了
    const isRenderFulled = direction > 0 ? lastStep >= stepCount * 2 : lastStep <= 0;
    if (isRenderFulled) {
        this.data.renderLoopCount += 1;
        direction = (this.data.renderLoopCount & 1) === 0 ? DIRECTION.Positive : DIRECTION.Opposite;
        lastStep = direction > 0 ? 0 : stepCount * 2;
        }
        this.data.canvasId = this.data.canvas.requestAnimationFrame(() => {
        this.renderLoop();
    });
    const nextLastStep = direction > 0 ? lastStep + 1 : lastStep - 1;
    this.render(ctx, nextLastStep);
    this.data.lastStep = nextLastStep;
},

4.2 绘制色块

当前的进度在不同的比例区间,绘制的颜色有所区别:

  • [0, 1 / 2]:绿色
  • (1 / 2, 2 / 3]: 黄色
  • (2 / 3, 5 / 6]: 橙色
  • (5 / 6, 1]: 红色
js 复制代码
/**
 * 绘制颜色
 */
getDrawColor(value: number, defaultColor = StrokeColor.Color2) {
    let drawColor: StrokeColor = defaultColor;
    if (0 <= value && value <= 30) drawColor = StrokeColor.Color1;
    else if (30 < value && value <= 40) drawColor = StrokeColor.Color2;
    else if (40 < value && value <= 50) drawColor = StrokeColor.Color3;
    else if (50 < value && value <= 60) drawColor = StrokeColor.Color4;
    return drawColor;
}
/**
 * 绘制齿轮
 */
drawSteps(ctx: WechatMiniprogram.CanvasContext, currentStep = 0) {
    const { defaultColor, radius, activeColor, stepWidth, stepMargin, stepHeight, stepCount } =
    this.data;
    ctx.save();
    for (let i = 0; i < stepCount * 2; i++) {
    ctx.beginPath();
    ctx.lineWidth = stepWidth / 2;
    if (i < currentStep) {
        // 绿:[0, 1 / 2];黄:(1 / 2, 2 / 3];橙:(2 / 3, 5 / 6];红:(5 / 6, 1]
        ctx.strokeStyle = this.getDrawColor(i, activeColor as StrokeColor);
    } else {
        ctx.strokeStyle = defaultColor;
    }
    if (i % 2 === 0) {
        ctx.moveTo(-(radius - stepMargin), 0);
        ctx.lineTo(-(radius - stepMargin - stepHeight), 0);
        ctx.rotate(((360 / stepCount) * Math.PI) / 180);
    } else {
        ctx.moveTo(-(radius - stepMargin), stepWidth / 2);
        ctx.lineTo(-(radius - stepMargin - stepHeight), stepWidth / 2);
    }
    ctx.stroke();
    }
},

4.3 触发绘制

js 复制代码
/**
 * 触摸开始方法
 * @param {WechatMiniprogram.TouchEvent} event 触摸事件对象
 */
handleTouchStart(event: WechatMiniprogram.TouchEvent) {
  this.data.isRunning = !this.data.isRunning;
  if (!this.data.isRunning) return;
  this.triggerEvent('start');
  const { width, height, radius } = this.data;
  const { x } = event.touches[0] as any;
  const { y } = event.touches[0] as any;
  const translateX = x - width / 2;
  const translateY = y - height / 2;
  const touchCircleX = Math.sqrt(translateX * translateX + translateY * translateY);
  if (touchCircleX <= radius) {
    this.data.lastStep = 0;
    this.renderLoop();
  }
}

1.4 所有代码

模板 wxml

html 复制代码
<canvas
  type="2d"
  id="start-button"
  style="width: {{ width }}px; height: {{ height }}px"
  bindtouchend="handleTouchEnd"
  bindtouchstart="handleTouchStart"
></canvas>

逻辑js

js 复制代码
import { DIRECTION, OpenBtnBgUrl, StrokeColor } from './model';

Component({
  data: {
    isRunning: false,
    canvasId: 0,
    // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
    canvas: {} as WechatMiniprogram.Canvas,
    // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
    ctx: {} as WechatMiniprogram.CanvasContext,
    img: {},
    lastStep: 0,
    renderLoopCount: 0, // 绘制了几圈
  },
  properties: {
    width: {
      type: Number,
      value: 140,
    },
    height: {
      type: Number,
      value: 140,
    },
    radius: {
      type: Number,
      value: 70,
    },
    startAngle: {
      type: Number,
      value: -Math.PI / 2,
    },
    stepCount: {
      type: Number,
      value: 30,
    },
    stepWidth: {
      type: Number,
      value: 8,
    },
    stepHeight: {
      type: Number,
      value: 4,
    },
    stepMargin: {
      type: Number,
      value: 10,
    },
    defaultColor: {
      type: String,
      value: '#658491',
    },
    activeColor: {
      type: String,
      value: StrokeColor.Color2,
    },
  },
  lifetimes: {
    attached() {
      this.init();
    },
    detached() {
      this.data.canvas.cancelAnimationFrame?.(this.data.canvasId);
      this.data.isRunning = false;
    },
  },
  methods: {
    init() {
      this.createSelectorQuery()
        .select('#start-button')
        .fields({
          node: true,
          size: true,
        })
        .exec((res) => {
          this.initCanvas(res);
        });
    },

    /**
     * 绘制图片
     * @param ctx canvas上下文对象
     * @param canvas canvas对象
     * @param imgSrc 要绘制的图片地址
     */
    renderImage(ctx: WechatMiniprogram.CanvasContext, canvas: WechatMiniprogram.Canvas, imgSrc = '') {
      const img = canvas.createImage();
      img.src = imgSrc;
      img.onload = () => {
        // 必须在图片加载完成进行渲染
        this.render(ctx, 0);
      };
      this.data.ctx = ctx;
      this.data.canvas = canvas;
      this.data.img = img;
    },

    /**
     * 初始化画布
     * @param res
     */
    initCanvas(res: any) {
      const { width, height, node: canvas } = res[0];
      const ctx: WechatMiniprogram.CanvasContext = canvas.getContext('2d');
      // 必须加此段代码否则canvas会拉伸
      const dpr = wx.getSystemInfoSync().pixelRatio;
      canvas.width = width * dpr;
      canvas.height = height * dpr;
      ctx.scale(dpr, dpr);
      // 初始化是充能状态,中间显示充能按钮
      this.renderImage(ctx, canvas, OpenBtnBgUrl.Charge);
    },

    /**
     * 画布绘制步骤条
     * @param ctx 画布上下文对象
     * @param currentStep 当前绘制的刻度值
     */
    render(ctx: WechatMiniprogram.CanvasContext, currentStep: number) {
      const { width, height } = this.data;
      ctx.clearRect(0, 0, width, height);
      this.drawBackground(ctx);
      // 将坐标原点移动到画布中间
      ctx.translate(width / 2, height / 2);
      // 绘制进度
      this.drawSteps(ctx, currentStep);
      // 将坐标原点还原
      ctx.translate(-width / 2, -height / 2);
    },

    /**
     * 递归绘制
     */
    renderLoop() {
      const { ctx, stepCount, isRunning } = this.data;
      let { lastStep } = this.data;
      // 方向 1: Positive(顺时针) -1:Opposite(逆时针)
      let direction =
        (this.data.renderLoopCount & 1) === 0 ? DIRECTION.Positive : DIRECTION.Opposite;
      // 是否已经画满了
      const isRenderFulled = direction > 0 ? lastStep >= stepCount * 2 : lastStep <= 0;
      if (isRenderFulled) {
        this.data.renderLoopCount += 1;
        direction = (this.data.renderLoopCount & 1) === 0 ? DIRECTION.Positive : DIRECTION.Opposite;
        lastStep = direction > 0 ? 0 : stepCount * 2;
      }
      this.data.canvasId = this.data.canvas.requestAnimationFrame(() => {
        this.renderLoop();
      });
      const nextLastStep = direction > 0 ? lastStep + 1 : lastStep - 1;
      this.render(ctx, nextLastStep);
      this.data.lastStep = nextLastStep;
      this.triggerEvent('change', { lastStep: nextLastStep * 5, isRunning });
    },

    /**
     * 绘制背景
     */
    drawBackground(ctx: WechatMiniprogram.CanvasContext) {
      const { width, height } = this.data;
      ctx.drawImage(this.data.img as any, 0, 0, width, height);
    },

    /**
     * 绘制颜色
     */
    getDrawColor(value: number, defaultColor = StrokeColor.Color2) {
      let drawColor: StrokeColor = defaultColor;
      if (0 <= value && value <= 30) drawColor = StrokeColor.Color1;
      else if (30 < value && value <= 40) drawColor = StrokeColor.Color2;
      else if (40 < value && value <= 50) drawColor = StrokeColor.Color3;
      else if (50 < value && value <= 60) drawColor = StrokeColor.Color4;
      return drawColor;
    },

    /**
     * 绘制齿轮
     */
    drawSteps(ctx: WechatMiniprogram.CanvasContext, currentStep = 0) {
      const { defaultColor, radius, activeColor, stepWidth, stepMargin, stepHeight, stepCount } =
        this.data;
      ctx.save();
      for (let i = 0; i < stepCount * 2; i++) {
        ctx.beginPath();
        ctx.lineWidth = stepWidth / 2;
        if (i < currentStep) {
          // 绿:[0, 1 / 2];黄:(1 / 2, 2 / 3];橙:(2 / 3, 5 / 6];红:(5 / 6, 1]
          ctx.strokeStyle = this.getDrawColor(i, activeColor as StrokeColor);
        } else {
          ctx.strokeStyle = defaultColor;
        }
        if (i % 2 === 0) {
          ctx.moveTo(-(radius - stepMargin), 0);
          ctx.lineTo(-(radius - stepMargin - stepHeight), 0);
          ctx.rotate(((360 / stepCount) * Math.PI) / 180);
        } else {
          ctx.moveTo(-(radius - stepMargin), stepWidth / 2);
          ctx.lineTo(-(radius - stepMargin - stepHeight), stepWidth / 2);
        }
        ctx.stroke();
      }
    },

    /**
     * 触摸开始方法
     * @param {WechatMiniprogram.TouchEvent} event 触摸事件对象
     */
    handleTouchStart(event: WechatMiniprogram.TouchEvent) {
      this.data.isRunning = !this.data.isRunning;
      if (!this.data.isRunning) return;
      this.triggerEvent('start');
      const { width, height, radius } = this.data;
      const { x } = event.touches[0] as any;
      const { y } = event.touches[0] as any;
      const translateX = x - width / 2;
      const translateY = y - height / 2;
      const touchCircleX = Math.sqrt(translateX * translateX + translateY * translateY);
      if (touchCircleX <= radius) {
        this.data.lastStep = 0;
        this.renderLoop();
      }
    },

    /**
     * 触摸结束方法
     */
    handleTouchEnd() {
      const { stepCount, lastStep, ctx, canvas, isRunning = false } = this.data;
      if (!isRunning) {
        this.triggerEvent('end', { lastStep: lastStep * 5, isRunning });
        return;
      };
      // 点击后变为开启状态,中间显示开启功能的按钮
      this.renderImage(ctx, canvas, OpenBtnBgUrl.Start);
      if (lastStep < stepCount * 2) {
        this.render(ctx, 0);
      } else {
        this.render(ctx, stepCount);
      }
    },
  },
});

1.5 使用方式

html 复制代码
<start-button
  id="start"
  bind:end="onTouchEnd"
  bind:change="onTouchChange"
></start-button>

2、舞台灯光效果

Css3 Animation + 图片序列帧动画实现

主要保留包括以下三部分:

  • 光片上飘(css3 动画,下面详细说明)
  • 星石漂浮(css3 动画)
  • 光效旋转(图片序列帧动画)

2.1 效果如下

2.2 模板 wxml

html 复制代码
 <!-- 光筒圆柱 -->
  <view class="cylinder">

    <!-- 光片上飘区域 -->
    <view class="cylinder-bubble">
      <block wx:if="{{ isRunning }}">
        <block wx:for="{{ lightPieceTotal }}" wx:key="index">
          <view class="cylinder-bubble-item"></view>
        </block>
      </block>
    </view>

    <!-- 中间星石 -->
    <view class="cylinder-diamond {{ lightImgLoaded ? 'cylinder-diamond-animate' : ''}}"></view>

    <!-- 光效旋转区域 -->
    <view class="cylinder-light">
      <view class="cylinder-light-animate {{ lightImgLoaded ? 'cylinder-light-show' : ''}}"></view>
    </view>
  </view>

2.3 样式 css

scss 复制代码
// 开启硬件加速
@mixin hardwareAcceleration {
  opacity: 1;
  transform: translate3d(0, 0, 0);
  backface-visibility: hidden;
  perspective: 1000;
  transform-style: preserve-3d;
}
.cylinder {
  position: relative;
  top: 0;
  width: 100%;
  height: 70%;
  max-height: 1150rpx;
  // 开启硬件加速
  @include hardwareAcceleration;
  // 光片上飘区域
  &-bubble {
    position: absolute;
    left: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;

    &-item {
      position: absolute;
      top: 110%;
      left: 0;
      z-index: 100;
      background-color: $lightColor;
      animation-timing-function: linear;
      animation-iteration-count: infinite;
      transform-style: preserve-3d;

      @for $i from 1 through $count {
        @include bubblePieceStyle(16rpx, 16rpx, $i, 150, 550, 4000, 2000, 85%, 26%);
      }
    }
  }

  // 中间星石
  &-diamond {
    position: absolute;
    top: 50%;
    left: 50%;
    z-index: 12;
    width: 364rpx;
    height: 371rpx;
    background: transparent
      url(https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/kuji-cylinder-diamond.png?imageView2/2/q/60)
      no-repeat center / cover;
    border-radius: 50%;
    opacity: 0;
    transform: translate3d(-50%, -36%, 0);

    &-animate {
      opacity: 1;
      transform: translate3d(-50%, -36%, 0);
      animation: move-frames 5s linear infinite;
      animation-delay: 1.5s;
    }
  }

  // 光效旋转区域
  &-light {
    $imgCount: 65;
    position: absolute;
    z-index: 10;
    width: 100%;
    height: 100%;
    background-image: url(https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/light-cylinder/light-cylinder-default.png?imageView2/2/q/60);
    background-repeat: no-repeat;
    background-position: top center;
    background-size: cover;

    &-animate {
      width: 100%;
      height: 100%;
      visibility: hidden;
      background: inherit;
      background-image: url(https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/light-cylinder/light-cylinder-long-65.png);
      background-position: -750rpx top;
      background-size: #{750 * $imgCount}rpx 1094rpx;
      transform: translate(0, -7%);
      animation: lightAnimation 4s infinite;
      animation-timing-function: steps(1, end);
      will-change: background-position;

      @keyframes lightAnimation {
        @for $i from 1 through $imgCount {
          #{math.div($i * 100%, $imgCount)} {
            background-position: -#{$i * 750}rpx top;
          }
        }
      }
    }

    &-show {
      visibility: visible;
    }
  }
}

3、光片上飘效果

css3 动画实现, 复杂的点在于一些动态值的计算,比如随机位置,随机的动画持续时间和延迟时间,随机的倾斜度和旋转度,并且限制光片上升的区域

3.1 效果如下

3.2 模板 wxml

wxml 复制代码
<view class="cylinder-bubble">
  <block wx:if="{{ isRunning }}">
  <block wx:for="{{ lightPieceTotal }}" wx:key="index">
      <view class="cylinder-bubble-item"></view>
  </block>
  </block>
</view>

3.3 样式 css

scss 复制代码
@mixin bubblePieceStyle(
  $w: 20rpx,
  $h: 20rpx,
  $index: 1,
  // 光片距离左边最小值
  $minLeft: 150,
  // 光片距离左边最大值
  $maxLeft: 550,
  // 持续时间基数
  $baseDuration: 4000,
  // 持续时间系数
  $ratioDuration: 2000
) {
  $left: #{max($minLeft, min(100 + $index * 100 * random(), $maxLeft))}rpx;
  $minDelay: 100; // 最小延迟时间
  $maxDelay: 1000; // 最大延迟时间
  $ratioDelay: 300; // 延迟时间基数

  &:nth-child(#{$index}) {
    left: $left;
    z-index: if(random() > 0.5, 11, 9);
    width: $w;
    height: $h;
    box-shadow: 0 0 5rpx 3rpx $lightColor;
    // 随机偏转
    transform: scale3d(min(0.8, max(0.3, $index * random())), 0.8, 1) skew(
        #{max(-70, -20 * $index * random())}deg,
        #{min(70, 20 * $index * random())}deg
      )
      rotateY(#{min(180, 20 * $index * random())}deg) translate(50%, 50%);
    animation-name: move-frames-#{$index};
    animation-duration: #{$baseDuration + $ratioDuration * random()}ms;
    animation-delay: #{floor(
        random() * ($maxDelay - $minDelay + 1) + $minDelay
      )}ms;

    .bubble-item {
      animation-delay: #{$ratioDelay * random()}ms;
    }
  }

  @keyframes move-frames-#{$index} {
    0% {
      top: 95%;
      opacity: 1;
      transform: scale3d(0.5, 0.5, 0.5) skew(
          #{max(-70, -20 * $index * random())}deg,
          #{min(70, 20 * $index * random())}deg
        )
        rotateY(#{min(180, 20 * $index * random())}deg) translate(50%, 50%);
    }

    50% {
      opacity: 0.8;
      transform: scale3d(0.3, 0.3, 0.3) skew(
          #{max(-70, -20 * $index * random())}deg,
          #{min(70, 20 * $index * random())}deg
        )
        rotateY(#{min(180, 20 * $index * random())}deg) translate(50%, 50%);
    }

    100% {
      top: 30%;
      opacity: 0.1;
      transform: scale3d(0.1, 0.1, 0.1) skew(
          #{max(-70, -20 * $index * random())}deg,
          #{min(70, 20 * $index * random())}deg
        )
        rotateY(#{min(180, 20 * $index * random())}deg) translate(50%, 50%);
    }
  }
}

.cylinder-bubble {
  position: absolute;
  left: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: transparent
    url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-control-yuanzhu.webp?imageMogr2/format/png/interlace/1")
    no-repeat center / cover;

  &-item {
    position: absolute;
    top: -50rpx;
    left: 0;
    z-index: 100;
    background-color: $lightColor;
    animation-timing-function: linear;
    animation-iteration-count: infinite;
    transform-style: preserve-3d;

    @for $i from 1 through $count {
      @include bubblePieceStyle(18rpx, 18rpx, $i);
    }
  }
}

4、光束上升效果

css3 动画实现, 复杂的点在于一些动态值的计算,比如随机位置,随机的动画持续时间和延迟时间,并且限制气泡上飘的区域,会有点限制

4.1 效果如下

4.2 模板 wxml

html 复制代码
<view class="cylinder-light">
  <block wx:for="{{ lightLineTotal }}" wx:key="index">
    <view class="cylinder-light-line"></view>
  </block>
</view>

4.3 样式 scss

scss 复制代码
.cylinder-light {
  position: absolute;
  z-index: 10;
  width: 100%;
  height: 100%;
  transform: rotateZ(180deg);

  &-line {
    position: absolute;
    top: 50%;
    right: 0%;
    width: 6rpx;
    background: linear-gradient(-45deg, $lightColor, rgba(0, 0, 255, 0));
    filter: drop-shadow(0 0 10px rgb(142, 233, 233));
    border-radius: 999rpx;
    box-shadow: 0 1px 4px rgba($lightColor, 0.3), 0 0 40px rgba(
          $lightColor,
          0.1
        ) inset;
    animation: tail 4000ms ease-in-out infinite, shooting 4000ms ease-in-out
        infinite;
    transform-style: preserve-3d;

    &::before,
    &::after {
      position: absolute;
      right: -1rpx;
      bottom: -2rpx;
      width: 10rpx;
      height: 10rpx;
      content: "";
      background: linear-gradient(
        -45deg,
        rgba(0, 0, 255, 0),
        $lightColor,
        rgba(0, 0, 255, 0)
      );
      border-radius: 50%;
      border-radius: 100%;
      transform: translateY(50%) rotateZ(45deg);
      animation: glitter 3000ms ease-in-out infinite;
    }

    &::after {
      transform: translateY(50%) rotateZ(-45deg);
    }

    @for $index from 1 through $count {
      @include lightLineStyle($index, 100, 600);
    }
  }
}
// 光束顶部闪烁效果
@keyframes glitter {
  0% {
    box-shadow: 0 0 18rpx 5rpx $lightColor;
    opacity: 1;
    transform: scale(1);
  }

  25% {
    opacity: 0;
    transform: scale(0.5);
  }

  50% {
    box-shadow: 0 0 18rpx 5rpx $lightColor;
    opacity: 1;
    transform: scale(1);
  }

  75% {
    opacity: 0;
    transform: scale(0.5);
  }

  100% {
    box-shadow: 0 0 14rpx 5rpx $lightColor;
    opacity: 1;
    transform: scale(1);
  }
}

// 光束若隐若现缩放效果
@keyframes tail {
  0% {
    height: 0;
    opacity: 1;
  }

  30% {
    height: 100px;
    opacity: 0.3;
  }

  100% {
    height: 10px;
    opacity: 0;
  }
}

// 光束上升效果
@keyframes shooting {
  0% {
    opacity: 1;
    transform: translateY(-50rpx);
  }

  100% {
    transform: translateY(400rpx);
  }
}

4.4 逻辑 js

js 复制代码
data: {
  lightLineTotal: 10,
},

5、充能纹路动画效果

css3 动画 + 图片序列帧实现

5.1 效果如下

5.2 模板 wxml

html 复制代码
<view class="start-button start-button-bg-animate"> </view>

5.3 样式 css

scss 复制代码
.start-button {
  $imgCount: 31;
  $bgWidth: 750;

  position: relative;
  display: flex;
  flex: 1;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  background: transparent
    url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/start-button/start-button-bg-default.png?imageView2/2/q/60")
    no-repeat center center / contain;

  &-bg-animate,
  &-bg-end {
    background-image: url(https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/start-button/start-button-bg-long-compression.png);
    background-repeat: no-repeat;
    background-position-x: 0;
    background-position-y: center;
    background-size: #{$bgWidth * $imgCount}rpx 459rpx;
    opacity: 1;
  }

  &-bg-animate {
    animation: bgAnimation 1s 1;
    animation-timing-function: steps(1);

    @keyframes bgAnimation {
      @for $i from 1 through $imgCount {
        #{($i * 100%/$imgCount)} {
          background-position-x: -#{$i * $bgWidth}rpx;
        }
      }
    }
  }

  &-bg-end {
    background-position-x: -#{($imgCount - 1) * $bgWidth}rpx;
  }

  &-container {
    display: flex;
    align-items: center;
    justify-content: center;
  }

  &-bg-hidden {
    width: 100%;
    height: 100%;
    visibility: hidden;
    background: transparent
      url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-animation/start-button/start-button-bg-long-compression.png")
      no-repeat center center / contain;
  }
}

6、类故障风格效果

本质上就是通过一张图片的 beforeafter 两个伪元素占位,结合两侧两张相同的图,叠加而成的效果

6.1 效果如下

6.2 模板 wxml

html 复制代码
<view
  class="{{ computed.getResultScoreClass(rewLevel) }}"
  data-img="{{ rewLevel }}"
>
</view>

6.3 样式 css

scss 复制代码
.kuji-result-quality {
  // 入场动画
  @keyframes enter-x-animation {
    from {
      left: -50%;
      opacity: 1;
    }

    to {
      left: 50%;
      opacity: 1;
    }
  }

  // 左边合成
  @keyframes shake-before {
    9% {
      left: -20rpx;
    }

    14% {
      left: -18rpx;
    }

    18% {
      left: -16rpx;
    }

    22% {
      left: -14rpx;
    }

    32% {
      left: -12rpx;
    }

    34% {
      left: -10rpx;
    }

    40% {
      left: -8rpx;
    }

    43% {
      left: -6rpx;
    }

    99% {
      left: 1rpx;
    }
  }

  //右边合成
  @keyframes shake-after {
    9% {
      left: 20rpx;
    }

    14% {
      left: 18rpx;
    }

    18% {
      left: 16rpx;
    }

    22% {
      left: 14rpx;
    }

    32% {
      left: 12rpx;
    }

    34% {
      left: 10rpx;
    }

    40% {
      left: 8rpx;
    }

    43% {
      left: 6rpx;
    }

    99% {
      left: 1rpx;
    }
  }

  // 最后小抖动
  @keyframes debounce {
    10% {
      top: -1.4rpx;
      left: -2.1rpx;
    }

    20% {
      top: 1.4rpx;
      left: -1.2rpx;
    }

    30% {
      left: 1.5rpx;
    }

    40% {
      top: -1.3rpx;
      left: -1.7rpx;
    }

    50% {
      left: 1.2rpx;
    }

    60% {
      top: 2.8rpx;
      left: -2.2rpx;
    }

    70% {
      top: -2px;
      left: 2.1rpx;
    }

    80% {
      top: -1.4rpx;
      left: -1.9rpx;
    }

    90% {
      left: 1.2rpx;
    }

    100% {
      left: -1.2rpx;
    }
  }

  @mixin qualityImg {
    &.kuji-result-quality__R {
      &::before,
      &::after {
        background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_R.png");
      }
    }

    &.kuji-result-quality__SR {
      &::before,
      &::after {
        background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_SR.png");
      }
    }

    &.kuji-result-quality__SSR {
      &::before,
      &::after {
        background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_SSR.png");
      }
    }

    // 全收
    &.kuji-result-quality__ALL {
      &::before,
      &::after {
        background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_ALL_IN.png");
      }
    }
  }

  position: absolute;
  top: 60%;
  left: 50%;
  width: 349rpx;
  height: 177rpx;
  font-family: Raleway, Verdana, Arial;
  color: transparent;
  transform: translate(-50%, -50%) scale(1.2);
  @include qualityImg;

  &::before,
  &::after {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    content: "";
    background: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji_result_level_R.png")
      no-repeat center / cover;
    filter: contrast(130%);
  }

  &::before {
    z-index: 2;
    animation: shake-before 1.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 1s 1, debounce
        0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 2s 1.5;
  }

  &::after {
    z-index: 3;
    animation: shake-after 1.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 1s 1, debounce
        0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 2s 1.5;
  }

  &.enter-x-animation {
    opacity: 0;
    animation: enter-x-animation 1s cubic-bezier(1, -0.03, 1, 0.04) 0.1s;
    animation-fill-mode: forwards;
}

7、卡片的炫光效果

本质上就是在每个边框画一条线,一共四条(上下左右),然后每条线添加动画和渐变色,并使用 before 伪元素来添加滤镜效果,最后整张卡片的边框再加上模糊呼吸灯效果

7.1 效果如下

7.2 模板 wxml

html 复制代码
<view class="kuji-result-card">
  <block wx:for="{{ [1, 2, 3, 4] }}" wx:key="index" wx:item="item">
    <view
      class="kuji-result-animate-line {{ 'kuji-result-animate-line' + item}}"
    ></view>
  </block>
</view>

7.3 样式 scss

7.3.1 外部样式

scss 复制代码
.kuji-result {
  $cardWidth: 300rpx;
  $cardHeight: 414rpx;
  $cardImageWidth: 209rpx;
  $cardImageHeight: 209rpx;

  &-card {
    @include hardwareAcceleration;

    position: relative;
    width: $cardWidth;
    height: $cardHeight;
    margin: 25rpx 15rpx;
    font-size: 32rpx;
    text-align: center;
    background: url("https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-reward-card-bg.png")
      no-repeat center / contain;
    transform-style: preserve-3d;
    perspective: 1000;

    // 进入动画
    &__ani {
      // 结果卡片样式
      @include cardLevelStyle;

      .kuji-result-card-animate-line {
        background-image: none;
      }
    }

    // 四周炫光
    &-animate {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
      clip-path: polygon(10% 0, 100% 0, 100% 100%, 0 100%, 0 9%);
      @include lightLineAnimateStyle;
    }
  }

  &-image {
    display: flex;
    align-items: center;
    justify-content: center;
    width: $cardImageWidth;
    height: $cardImageHeight;
    margin: 20rpx auto 0;

    view,
    .free-image {
      width: $cardImageWidth;
      height: $cardImageHeight;
    }
  }
}

7.3.2 主要的动画效果封装成mixin

scss 复制代码
// 开启硬件加速
@mixin hardwareAcceleration {
  opacity: 1;
  transform: translate3d(0, 0, 0);
  backface-visibility: hidden;
  perspective: 1000;
  transform-style: preserve-3d;
}

// 卡片显示动画效果
@mixin showAnimationStyle($index: 0) {
  &.fadeInBottomRight {
    animation: fadeInBottomRight 0.3s ease-in-out both, breathe-#{$index} 1.5s
        ease-in-out infinite alternate;
  }

  &.fadeInDown {
    animation: fadeInDown, breathe-#{$index} 1.5s ease-in-out infinite alternate;
  }

  &.bounceInUp {
    animation: bounceInUp 1s ease-in-out both, breathe-#{$index} 1.5s
        ease-in-out infinite alternate;
  }

  &.bounceInRight {
    animation: bounceInRight 1s ease-in-out both, breathe-#{$index} 1.5s
        ease-in-out infinite alternate;
  }

  &.bounceInLeft {
    animation: bounceInLeft 1s ease-in-out both, breathe-#{$index} 1.5s
        ease-in-out infinite alternate;
  }
}

// 四周呼吸灯效果
@mixin breatheStyle(
  $shadowColor: rgba(59, 235, 235, 1),
  $shadowWidth: 20rpx,
  $index: 0,
  $bgImageUrl:
    "https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level1.png"
) {
  text-align: center;
  cursor: pointer;
  background-image: url($bgImageUrl);
  border-radius: 10rpx;
  box-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.3);
  // animation: breathe-#{$index} 1.5s ease-in-out infinite alternate;

  @keyframes breathe-#{$index} {
    0% {
      box-shadow: 0 1rpx 2rpx rgba(0, 147, 223, 0.4), 0 1rpx 1rpx rgba(
            0,
            147,
            223,
            0.1
          ) inset;
    }

    100% {
      box-shadow: 0 2rpx $shadowWidth $shadowColor, 0 2rpx $shadowWidth
          $shadowColor inset;
    }
  }
}

// 四周炫光动画
@mixin lightLineAnimateStyle() {
  @include hardwareAcceleration;

  &-line {
    position: absolute;
    display: block;
    background-image: linear-gradient(
      to right,
      #03a9f4,
      #f441a5,
      #ffeb3d,
      #09a8f4
    );
    background-size: 400%;
    border-radius: 50px;
  }

  &-line::before {
    position: absolute;
    top: -5px;
    right: -5px;
    bottom: -5px;
    left: -5px;
    z-index: -1;
    content: "";
    filter: blur(20px);
    background-size: 400%;
    border-radius: 50px;
  }

  &-line1 {
    top: 0;
    left: 0;
    width: 100%;
    height: 4rpx;
    animation: animate1 2.1s linear infinite, sun 8s infinite;
    animation-delay: 0.25s;
  }
  @keyframes sun {
    100% {
      background-position: -400% 0;
    }
  }

  @keyframes animate1 {
    0% {
      left: -100%;
    }

    50%,
    100% {
      left: 100%;
    }
  }

  &-line2 {
    top: -100%;
    right: 0;
    width: 4rpx;
    height: 100%;
    animation: animate2 2.1s linear infinite, sun 8s infinite;
    animation-delay: 0.75s;
  }

  @keyframes animate2 {
    0% {
      top: -100%;
    }

    50%,
    100% {
      top: 100%;
    }
  }

  &-line3 {
    right: 0;
    bottom: 0;
    width: 100%;
    height: 4rpx;
    animation: animate3 2.1s linear infinite, sun 8s infinite;
    animation-delay: 1.25s;
  }

  @keyframes animate3 {
    0% {
      right: -100%;
    }

    50%,
    100% {
      right: 100%;
    }
  }

  &-line4 {
    bottom: -100%;
    left: 0;
    width: 4rpx;
    height: 100%;
    animation: animate4 2.1s linear infinite, sun 8s infinite;
    animation-delay: 1.75s;
  }

  @keyframes animate4 {
    0% {
      bottom: -100%;
    }

    50%,
    100% {
      bottom: 100%;
    }
  }
}

// 结果卡片样式
@mixin cardLevelStyle() {
  $prefix: "kuji-result-card__ani";
  $firstShadowColor: #ffcd00; // 金色(1级)
  $secondShadowColor: #e22eef; // 紫色(2级)
  $thirdShadowColor: #12e1de; // 蓝色(3级)
  $fourthShadowColor: rgba(193, 202, 207, 0.5); // 灰色(4级)

  $cardLevelStyleMap: (
    /* 一级样式*/ level1:
      (
        shadowColor: $firstShadowColor,
        shadowWidth: 30rpx,
        bgImageUrl:
          "https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level1.png",
        bgBorderStartColor: #ffeb3d,
        bgBorderEndColor: $firstShadowColor,
      ),
    /* 二级样式*/ level2:
      (
        shadowColor: $secondShadowColor,
        shadowWidth: 20rpx,
        bgImageUrl:
          "https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level2.png",
        bgBorderStartColor: #f441a5,
        bgBorderEndColor: $secondShadowColor,
      ),
    /* 三级样式*/ level3:
      (
        shadowColor: $thirdShadowColor,
        shadowWidth: 15rpx,
        bgImageUrl:
          "https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level3.png",
        bgBorderStartColor: #03e9f4,
        bgBorderEndColor: $thirdShadowColor,
      ),
    /* 四级样式*/ level4:
      (
        shadowColor: $fourthShadowColor,
        shadowWidth: 15rpx,
        bgImageUrl:
          "https://ip-sph-static-1256936115.file.myqcloud.com/kuji-result/kuji-result-tab-bg-level4.png",
        bgBorderStartColor: #a2a9ad,
        bgBorderEndColor: $fourthShadowColor,
      )
  );

  // 结果卡片样式
  @each $key, $value in $cardLevelStyleMap {
    &.#{$prefix}__#{$key} {
      @include breatheStyle(
        map-get($value, "shadowColor"),
        map-get($value, "shadowWidth"),
        $key,
        map-get($value, "bgImageUrl")
      );
      @include showAnimationStyle($key);

      .kuji-result-card-animate-line {
        background-image: linear-gradient(
          to right,
          map-get($value, "bgBorderStartColor"),
          map-get($value, "bgBorderEndColor")
        );
      }
    }
  }
}

8、旋转 loading 效果

8.1 效果如下

8.2 模板 wxml

html 复制代码
<view class="kuji-result-loading">
  <view class="kuji-result-loading-circle"> </view>
</view>

8.3 样式 css

scss 复制代码
.kuji-result {
  @keyframes spin {
    0% {
      transform: rotate(0deg);
    }

    100% {
      transform: rotate(360deg);
    }
  }

  $innerSpacing: 10rpx;
  $outerSpacing: 30rpx;

  &-loading {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100vh;

    &-circle {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 300rpx;
      height: 300rpx;
      border: 3rpx solid transparent;
      border-top-color: #9370db;
      border-radius: 50%;
      animation: spin 2s linear infinite;

      &::before,
      &::after {
        position: absolute;
        content: "";
        border-radius: 50%;
      }

      &::before {
        top: $innerSpacing;
        right: $innerSpacing;
        bottom: $innerSpacing;
        left: $innerSpacing;
        border: 3px solid transparent;
        border-top-color: #a800a8;
        animation: spin 3s linear infinite;
      }

      &::after {
        top: $outerSpacing;
        right: $outerSpacing;
        bottom: $outerSpacing;
        left: $outerSpacing;
        border: 3px solid transparent;
        border-top-color: #f0f;
        animation: spin 1.5s linear infinite;
      }
    }
  }
}

9、clip-path 绘制替代图片

在这里分享一个很好用的clip-path生成网站,可以直接在线调整形状,然后一键复制,愉快的摸鱼🐟~

9.1 效果如下

9.2 模板 wxml

html 复制代码
<view class="reward-arrow-left-btn"></view>
<view class="reward-arrow-right-btn"></view>

9.3 样式 css

scss 复制代码
.reward-arrow-left,
.reward-arrow-right {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40rpx;
  height: 40rpx;

  &-btn {
    width: 23rpx;
    height: 28rpx;
    background-color: #09fffc;
  }
}

.reward-arrow-left {
  left: 200rpx;

  &-btn {
    clip-path: polygon(100% 0, 80% 50%, 100% 100%, 0 50%);
  }
}

.reward-arrow-right {
  right: 200rpx;

  &-btn {
    clip-path: polygon(0 0, 100% 50%, 0 100%, 20% 50%);
  }
}

10、mask-image 图片遮罩

css mask 属性在使用时类似 background 属性,是多个属性合在一起的简写,可以参见下面的列表:

  • mask-image
  • mask-mode
  • mask-repeat
  • mask-position
  • mask-clip
  • mask-origin
  • mask-size
  • mask-composite

这个案例的效果是 banner 图片滑动到两边后,逐渐模糊隐藏的效果

10.1 效果如下

为了更好的查看效果,特意放慢了交互速度,真机小程序上体验好点

10.2 模板 wxml

html 复制代码
<view class="reward-main">
  <!-- 背景 -->
  <free-image
    class="reward-flash-bg"
    width="366"
    height="330"
    src="https://ip-sph-static-1256936115.file.myqcloud.com//kuji-reward/reward-flash-bg.png"
  />
  <!-- swiper -->
  <swiper
    wx:if="{{ show }}"
    class="swiper-wrapper"
    circular
    current="{{ current }}"
  >
    <block wx:for="{{ data }}" wx:key="index">
      <swiper-item>
        <view class="swiper-item" data-index="{{ index }}">
          <free-image class="swiper-item-image" src="{{ item.detail_image }}" />
        </view>
      </swiper-item>
    </block>
  </swiper>
</view>

10.3 样式 css

本质上就是通过伪元素,在两边滤镜遮罩效果

scss 复制代码
&::before {
  left: 170rpx;
  mask-image: linear-gradient(to right, black 10%, transparent);
}

&::after {
  right: 170rpx;
  background-position: -520rpx -110rpx;
  mask-image: linear-gradient(to left, black 10%, transparent);
}

所有代码如下:

scss 复制代码
.swiper-class {
  &::before,
  &::after {
    position: absolute;
    bottom: 100rpx;
    z-index: 1;
    display: block;
    width: 60rpx;
    height: 400rpx;
    content: "";
    background-image: url("https://ip-sph-static-1256936115.file.myqcloud.com//kuji-reward/reward-theme-bg.png");
    filter: blur(2px);
    background-position: -170rpx -110rpx;
    background-size: 750rpx 640rpx;
  }

  &::before {
    left: 170rpx;
    mask-image: linear-gradient(to right, black 10%, transparent);
  }

  &::after {
    right: 170rpx;
    background-position: -520rpx -110rpx;
    mask-image: linear-gradient(to left, black 10%, transparent);
  }
}

总结

本文总结了小程序动画技术在业务中的应用。为了实现高性能的动画渲染效果,项目采用了 CSS3+transform 动画和 canvas 技术。以下是各种案例的实现思路:

  • 舞台灯光效果:通过 CSS3 Animation 和图片序列帧动画实现光片上飘、星石漂浮和光效旋转效果。
  • 光片上飘效果:使用 CSS3 动画实现,主要难点在于计算随机位置、动画持续时间、延迟时间、倾斜度和旋转度,以及限制光片上升区域。
  • 光束上升效果:使用 CSS3 动画实现,主要难点在于计算随机位置、动画持续时间、延迟时间,以及限制气泡上飘区域。
  • 充能纹路动画效果:结合 CSS3 动画和图片序列帧实现。
  • 类故障风格效果:通过 beforeafter 两个伪元素占位,结合两侧相同的图像叠加而成的效果。
  • 卡片炫光效果:在每个边框画一条线(上下左右四条),为每条线添加动画和渐变色,使用 before 伪元素添加滤镜效果,最后为整张卡片的边框加上模糊呼吸灯效果。
  • clip-path 绘制替代图片:利用 clip-path 生成网站在线调整形状,一键复制实现。
  • mask-image 图片遮罩:使用 css mask 属性实现类似于 background 属性的效果,包括 mask-image、mask-mode 等多个子属性。本案例展示了如何实现 banner 图片在滑动过程中逐渐模糊隐藏的效果。

通过灵活运用这些技巧,可以实现丰富多样的动画效果,提高小程序动画性能,提升用户体验。

相关推荐
吾即是光5 分钟前
Xss挑战(跨脚本攻击)
前端·xss
渗透测试老鸟-九青5 分钟前
通过组合Self-XSS + CSRF得到存储型XSS
服务器·前端·javascript·数据库·ecmascript·xss·csrf
xcLeigh23 分钟前
HTML5实现俄罗斯方块小游戏
前端·html·html5
发现你走远了1 小时前
『VUE』27. 透传属性与inheritAttrs(详细图文注释)
前端·javascript·vue.js
VertexGeek1 小时前
Rust学习(五):泛型、trait
前端·学习·rust
好开心331 小时前
javaScript交互补充(元素的三大系列)
开发语言·前端·javascript·ecmascript
小孔_H1 小时前
Vue3 虚拟列表组件库 virtual-list-vue3 的使用
前端·javascript·学习·list
jokerest1232 小时前
web——upload-labs——第五关——大小写绕过绕过
前端·后端
万物已到极致2 小时前
微信小程序:vant组件库安装步骤
微信小程序·小程序
钢铁小狗侠2 小时前
前端(3)——快速入门JaveScript
前端