一次千万级流量的 618 电商 H5 活动页干货分享

18,512 阅读21分钟

前言

快一年没写文章了,也不知道水平变成什么样了,最近做了一个活动页的需求,加班熬夜了20多天,感觉有些内容可以总结一下,就写出来分享了。文章较长,希望大家耐心看完,万一某段代码对大家有用处呢?本文所有代码都在 github 仓库里面,欢迎拿去直接使用。

文章较长,建议收藏~

笔者个人实话实说,是第一次做活动页相关的前端开发工作,所以也算是初体验之后的总结:

  • 第一:活动类经历会短期内提升一个业务前端的技术水平

    如果你从来没写过活动类型的需求,第一次做,你会对前端特别是 C 端很多场景有更深的理解,比如你会真正去写复杂的交互逻辑(每一个动作即使是一个简单的 click 事件也会有交互效果)等等,写完之后,短时间内可能你和我一样也会有一些总结。

  • 第二:活动类(618、双十一)这种,对于一个前端来说更大的是身体上的考验

    年中活动从五一开始到五月底,只休息了一天,工作时间基本稳定在早十晚十二,加班熬夜写页面效果,优化用户体验,与其说是技术上的挑战,更准确的是身体和意志力的挑战。

  • 第三:连贯性的活动场页面功能的实现,更多的是 CV 操作

    通常来说一个 APP 的活动是贯穿一整年的,情人节 -> 妇女节 -> 520 -> 年中(618) -> 七夕 -> 双十一 -> 春节 等等,每家有差异,但是总体来说都差不多。一年有这么多的活动,但是其实你的活动页面的整体玩法和长相基本上也是贯穿下来的,也就是说,你第一个活动开发完了,基本效果就完成了,剩下的几场活动前端要做的就是复制粘贴(ctrlC + ctrlV)以及样式主题颜色的小调整。所以,活动前端如果贯穿一年去做的话,也真的挺枯燥无聊的。

    总结下来就是,活动相关的 H5 需求短期内能提升一个前端对开发 C 端页面的理解以及很多与用户交互的体验层面的技术点和相关,但是长期的话会略显枯燥,心理和身体都是一个不小的负担。

上面算是从一个业务前端的角度给大家分析了一下做活动页 H5 需求的一些体验,接下来来给大家简单分享一下最近封闭了一个月在前端活动页上的一些思考和技术总结。下面有一个小 demo,贯穿了我本篇文章要介绍的内容:

2021-06-14 20.35.11.gif

下面还有个二维码,大家可以手机浏览器扫一扫看一看,虽然做的挺简陋,但是文章里的功能都简单地实现了:

图片.png

Demo地址_https://activity-demo.vercel.app/

Demo_github_地址

I - 页面适配

页面适配这个问题一直以来都是 H5 开发人员无法逃避的一个话题 —— 如何用最少的代码适配各种尺寸的屏幕以及二倍屏和三倍屏。业界也有很多方案:比如 CSS Media Queryrem 布局 以及 vw/vh布局 等。选择一个合适的适配方案,可以让我们在开发的过程中更专注于业务逻辑代码而少关注一些 UI 布局方面的兼容问题,一段代码就可以适配各种屏幕,无需额外代码兼容,既能节约开发的时间,又能提升开发的效率。

所以,我这边针对页面适配这里总结出来的简洁干货方案就是 —— 固定设备宽度为 UI 稿宽度,开发全部按照UI稿尺寸进行编写,将缩放适配等工作交由浏览器自动完成。

核心代码如下,可以看到,相比其他方案来说,真的可以算得上是极其的简洁好用:

var $_DESIGN_LAYOUT_WIDTH = 414
<meta name="viewport" content="width=$_DESIGN_LAYOUT_WIDTH ,user-scalable=no,viewport-fit=cover">

// 375 设计稿
<meta name="viewport" content="width=375, user-scalable=no, viewport-fit=cover">

// 414 设计稿
<meta name="viewport" content="width=414, user-scalable=no, viewport-fit=cover">

简单看一下效果:

  • 使用正常的 viewport 标签编写的 H5 代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Old</title>
  <style>
    p {
      font-size: 14px;
    }
  </style>
</head>
<body>
  <h2>页面标题</h2>
  <p>
    - 第一:活动类经历会短期内提升一个业务前端的技术水平

    笔者之前从来没做过C端的活动页,这次做过之后有一些简单的技术感触,稍后会跟大家简单聊聊,短短十几天里,单独一个活动能给一个业务前端带来哪些成长。

    <br />
    - 第二:活动类(618、双十一)这种,对于一个前端来说更大的是身体上的考验

    笔者就职于国内 TOP3 的短视频电商领域,年中活动从五一开始到五月底,只休息了一天,工作时间基本稳定在早十晚十二。

  </p>
</body>
</html>

图片.png

  • 使用此方法的 viewport 标签编写的代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=414,user-scalable=no,viewport-fit=cover">
  <title>New</title>
  <style>
    p {
      font-size: 14px;
    }
  </style>
</head>
<body>
  <h2>页面标题</h2>
  <p>
    - 第一:活动类经历会短期内提升一个业务前端的技术水平
    笔者之前从来没做过C端的活动页,这次做过之后有一些简单的技术感触,稍后会跟大家简单聊聊,短短十几天里,单独一个活动能给一个业务前端带来哪些成长。
    <br/>
    - 第二:活动类(618、双十一)这种,对于一个前端来说更大的是身体上的考验
    笔者就职于国内 TOP3 的短视频电商领域,年中活动从五一开始到五月底,只休息了一天,工作时间基本稳定在早十晚十二。
  </p>
</body>
</html>

图片.png

小结

从上面两个方案的截图对比可以看得出来,相同布局,第一个方案的文字大小是固定的,我们设置的是多少 px 就是多少 px,而第二个方案,浏览器则帮助我们对页面整体进行了等比缩放,这样做可以让我们的代码只要遵循一套UI规范来开发,那么在不同尺寸分辨率的手机上,视觉效果都是相同的。对于大量文字类平铺页面可能还看不太出来区别,但是如果是活动页,各种精细的布局以及动画效果的需求,页面布局长得如果不一样开发人员就需要额外大量的工作投入到适配环节,耗时又费力。

优点

  • 设置简单 —— 只需要一行代码,与其他适配方式相比,大大解决了代码量和计算量。
  • 无需考虑适配 —— 缩放和兼容交给浏览器,开发人员完全按UI稿进行开发即可。
  • 还原精准 —— 绝对等比例缩放,可以精准还原视觉稿。
  • 方便测试 —— 在 PC 端即可完成大部分测试,手机端只需酌情调整一些细节即可。

缺点

没有完美的方案,有优点就一定存在缺点。上面的方案同样也存在一些其他的问题,比如:

  • 像素丢失 —— 对于一些分辨率较低的低端手机(此类问题常见于低端 Android 机,非2x/3x屏幕手机),可能设备像素还未达到指定的 viewport 宽度,此时屏幕的渲染可能就不准确了,比较常见的是边框“消失”了。(考虑低端机的占比,此类问题就可以忽略)

  • 缩放失效 —— 某些安卓机不能正常的根据 meta 标签中 width 的值来缩放 viewport,此时就需要需要搭配 initial-scale 来进行缩放了(同理,大部分手机都没问题)。

因此,综合来看,个人还是比较倾向这套方案的,优点 >>(远大于) 缺点。 有的小伙伴可能会说了,就这?就这么简单?感觉也不像是一个方案。能用非常简单的代码解决一件事,真的有必要看这套方案是否高大上吗?难道所有人都看不懂的方案实现出来才是高级 NB 的?所以我想说的是,大道至简,越是简单越是能解决问题的,就是好方案。 这里总结的只是笔者个人建议大家使用或者说个人觉得比较好的方案,至于其他方案比如 rem 布局等等,网上有很多好文章,这里就不做对比和扩展了,感兴趣的去查一下就行了。

II - 拖拽特效

拖拽这个功能对于前端来说肯定不陌生,比如基于 HTML5 的拖放 API(drag && drap) 实现一套拖拽或者直接使用社区熟知的 react-dnd 等等。这边给大家分享一个这边封装实现的拖拽 api,,先看效果:

2021-06-08 18.05.12.gif

实现的功能

  • 1 - 开箱即用,直接引入即可使用。无论是 VueReact 还是 HTML5,感兴趣的可以基于自己的框架进行二次封装。

  • 2 - 实现了移动端的拖拽动画效果,应该还算是比较流畅。

  • 3 - 进行了边界回弹,比如目前我这边只允许停靠在左右两侧 20px 的位置,不能在中间

实现思路

移动端的拖拽,使用 touch 事件 + requestAnimationFrame 进行实现。

具体代码

拖拽代码 github 地址

/**
 * 为了确保兼容性,建议使用 requestAnimationFrame 的 polyfill 版本 raf
 * 如果是ES6,可以使用 import/export 进行导入导出
 */
const raf = window.requestAnimationFrame;
/**
 * 封装拖拽函数
 * @param $ele 需要拖拽的元素
 * @param adsorb { x = 20, y = 80 } 上下左右吸附的边距
 */
export default function draggable($ele: HTMLElement, adsorb = { x: 20, y: 80 }) {
  if (!$ele) {
    throw new Error('必须是可拖拽元素');
  }
  // 开始时候的位置
  let startX = 0;
  let startY = 0;

  // 移动过程中的 left 和 top,其实通过这俩参数,就能确定元素位置
  let left = 0;
  let top = 0;

  // 屏幕的宽高
  const cw = document.documentElement.clientWidth;
  const ch = document.documentElement.clientHeight;

  // 获取到元素自身的宽高
  const { width, height } = $ele.getBoundingClientRect();

  /**
   * 开始拖拽的事件
   */
  $ele.addEventListener(
    'touchstart',
    function (event: TouchEvent) {
      startX = event.targetTouches[0].pageX;
      startY = event.targetTouches[0].pageY;

      top = $ele.offsetTop;
      left = $ele.offsetLeft;

      event.preventDefault();
    },
    false
  );

  /**
   * 拖拽过程中的事件
   */
  $ele.addEventListener(
    'touchmove',
    function (event: TouchEvent) {
      // 偏移距离
      const offsetX = event.targetTouches[0].pageX - startX;
      const offsetY = event.targetTouches[0].pageY - startY;

      $ele.style.top = `${top + offsetY}px`;
      $ele.style.left = `${left + offsetX}px`;
      $ele.style.right = 'auto';
      $ele.style.bottom = 'auto';

      event.preventDefault();
    },
    false
  );

  function touchDone(event: TouchEvent) {
    const dx = event.changedTouches[0].pageX - startX;
    const dy = event.changedTouches[0].pageY - startY;

    const ty = top + dy;
    const tx = left + dx;

    $ele.style.top = `${ty}px`;
    $ele.style.left = `${tx}px`;
    $ele.style.right = 'auto';
    $ele.style.bottom = 'auto';

    const adsorb_safe_x = cw - width - adsorb.x;
    const adsorb_safe_y = ch - height - adsorb.y;

    raf(() => {
      let nx;
      let ny = ty;

      if (tx + width / 2 < cw / 2) {
        nx = adsorb.x;
      } else {
        nx = adsorb_safe_x;
      }

      if (ty < adsorb.y) {
        ny = adsorb.y;
      } else if (ty > adsorb_safe_y) {
        ny = adsorb_safe_y;
      }

      $ele.style.webkitTransition = `left ${MOVE_ANIM_INTER}ms ease-in-out, top ${MOVE_ANIM_INTER}ms ease-in-out`;
      $ele.style.transition = `left ${MOVE_ANIM_INTER}ms ease-in-out, top ${MOVE_ANIM_INTER}ms ease-in-out`;

      const onAnimationDone = () => {
        $ele.style.webkitTransition = $ele.style.transition = 'none';
        $ele.removeEventListener('webkitTransitionEnd', onAnimationDone, false);
        $ele.removeEventListener('transitionend', onAnimationDone, false);
      };

      $ele.addEventListener('webkitTransitionEnd', onAnimationDone, false);
      $ele.addEventListener('transitionend', onAnimationDone, false);
      $ele.style.top = `${ny}px`;
      $ele.style.left = `${nx}px`;
    });
  }

  $ele.addEventListener('touchend', touchDone, true);
  $ele.addEventListener('touchcancel', touchDone, true);
}

III - 动画效果

【注意】: 因为场景比较特殊,所以只是简单的做了一个效果,不能把完整的活动效果原封不动的搬过来,丑点就丑点,大家别介意,主要是讲一些技术细节。

CSS3 - 红包动效

2021-06-08 21.54.37.gif

上面提到过,这里其实就是一个 CSS3 动画效果,没什么技术可言,但是需要和上面的拖拽特效来看,因为红包消失的时候,要消失到挂件的位置,因此需要计算挂件位置与红包消失的路线。这里还挺有意思的,没技术含量,但是有很多计算逻辑在这里,大家可以按照自己的想法随意组装,我这就算是抛砖引玉吧。

实现的具体代码在 Demo 仓库里,感兴趣的可以去看看具体的实现逻辑

Lottie Web 动画效果

这里没啥可讲的,只是给大家提供一些动画的思路,前端可以有很多个方案制作和播放动画。具体参考官网:lottie-web

lottie 运行动画依赖一个 json 文件,这个文件一般是动画设计师为我们生成或者个人开发者自己通过软件制作。如下面这个动画的 json,大概长这个样子:

图片.png

2021-06-08 23.17.13.gif

useEffect(() => {
    lottieRef.current = lottie.loadAnimation({
      container: document.getElementById('lottie') as HTMLElement,
      renderer: 'svg',
      loop: true,
      autoplay: false,
      path: 'https://labs.nearpod.com/bodymovin/demo/markus/halloween/markus.json'
    })
}, [])

function togglePlay() {
    if (!playing) {
      setPlaying(true);
      lottieRef.current.play();
      return;
    }
    setPlaying(false);
    lottieRef.current.pause();
}

看起来,效果非常的不错,如果公司配有专业的动画设计师,这套 littie 的方案是非常不错的~

SVG 路径动效

svg 路径动画效果,这里算是给大家开拓一个新的动画方案思路吧,用来制作一些关于路径类型需求的动画效果,非常好用~比如:进度条、loading 动画以及时间轴等效果等。

同样,这里下方给大家简单的写了一下 SVG 动画,这边实现的是一个简单的折线进度条动画,以此为基础可以进行各种扩充,比如 SVG 实现 loading icon 的动画效果、实现 完成度百分比 的动画效果以及 复杂曲线时间轴 动画效果等等。

2021-06-10 17.25.40.gif

<svg
    style={svgStyle}
    className="map"
    width="300px"
    height="202px"
    viewBox="-2 0 302 202"
    version="1.1"
    xmlns="http://www.w3.org/2020/svg"
  >
    <defs>
      <linearGradient id="line_1" x1="0%" x2="95.616%" y1="100%" y2="100%">
        <stop offset="0%" stopColor="#9396FF" />
        <stop offset="50.831%" stopColor="#A685FF" />
        <stop offset="100%" stopColor="#E695FC" />
      </linearGradient>
      <linearGradient id="line_2" x1="11.042%" x2="79.574%" y1="0%" y2="100%">
        <stop offset="0%" stopColor="#E094FC" />
        <stop offset="100%" stopColor="#E371FF" />
      </linearGradient>
    </defs>
    <g fill="none" fillRule="evenodd">
      <path
        d="M 2 3 L 300 3 L 300 202 L 2 202"
        strokeWidth="4"
        stroke="#fff"
        opacity=".1"
        strokeLinecap="round"
      />
      <path
        d="M 2 3 L 300 3 L 300 202 L 2 202"
        strokeOpacity="1"
        strokeDasharray="800"
        strokeDashoffset="800"
        stroke="url(#line_1)"
        strokeWidth="4"
        strokeLinecap="round"
      >
        <animate
          id="mapAnimate"
          attributeName="stroke-dashoffset"
          begin="1s"
          dur="500ms"
          from="800"
          to={offset}
          fill="freeze"
          calcMode="linear"
        />
      </path>
      <g transform="translate(1)">
        <circle fill="#E294FC" opacity=".201" cx="-1" cy="2" r="6">
          <animateMotion
            begin="1s"
            dur={time + "ms"}
            repeatCount={repeatCount}
            fill="freeze"
            calcMode="linear"
            path="M 2 3 L 300 3 L 300 202 L 2 202"
          />
        </circle>
        <circle fill="url(#line_2)" cx="-1" cy="2" r="5">
          <animateMotion
            begin="1s"
            dur={time + "ms"}
            repeatCount={repeatCount}
            fill="freeze"
            calcMode="linear"
            path="M 2 3 L 300 3 L 300 202 L 2 202"
          />
        </circle>
      </g>
    </g>
  </svg>

从上面效果可以看到, SVG 也是可以实现比较复杂的动画特效,但是更偏向于路径。同时,有的小伙伴可能会问了,这个看起来 CSS3 也可以实现,一个 gif 图可能更方便。不错,一般普通场景的话可能一个 gif 图更方便,但是如果是路线的动画效果会根据时间和进度动态变更,就需要代码来计算了,那么 100 个点就要 100 个 gif 图,就得不偿失了。因此,还是那句话,可能有 100 个方案实现一个需求,作为开发要选择的是最合适的那个方案。

小结

这里只是帮助大家扩展思路,平时我们开发过程中一方面很少用到动画效果,此外我们脑海里的动画可能也只能想到CSS3canvasraf 等方案,其实从上面可以得知还可以有其他的很多方式,就比如 APNGSVG 以及 lottie-web等。具体使用场景根据自身情况使用,理论上一个需求各个方案都是可以实现的,就看复杂度和实现成本了,选择最合适的方案才会事半功倍。

IV - 下拉刷新

下拉刷新这个功能怎么说呢,如果你是一个 Hybrid APP 的开发者,理论上 APP 端能力应该就帮助实现了这个功能。前端开发应该通过 js-bridge 能力直接调用下拉刷新的功能获取良好的体验。但是由于某种原因,我这边的 APP 没有帮助前端实现,或者说兼容性不是很好,所以就需要前端自己来写一份下拉刷新的功能了。废话不多说,还是先上效果:

2021-06-13 18.51.04.gif

从上图可以看到,功能简单地实现了,不过考虑到后续的可扩展性,方便用户使用,笔者这边对代码进行了简易封装,封装过后的代码提供了基本的逻辑功能,以及一些额外能力的扩展。比如,从上图可以看得出来,body 的背景色是蓝色,代码设置的默认值是白色等等,为了更匹配颜色,简单进行一下配置,并且,有时候不希望下拉刷新整个页面,而是刷新某个接口的数据,也是可以做的:

import pullRefresh from 'pull-refresh.ts'

useEffect(() => {
    function _refreshListener() {
      swal("", {
        // @ts-ignore
        buttons: false,
        timer: 1000,
        title: '页面刷新成功,刷新接口数据',
        text: '',
      });
    }
    const pl = new PullRefresh({
      refreshListener: _refreshListener,
      refreshStyleConfig: {
        color: '#fff',
        fontSize: '14px',
        backgroundColor: 'dodgerblue',
      }
    });
    // 还可以手动设置是否开启还是关闭下拉刷新功能
    // pl.setEnabled(false);
}, [])

效果如下:

2021-06-13 19.19.23.gif

核心代码如下,pull-refresh 下拉刷新

interface IPullRefreshConfig {
  $_ele?: HTMLElement;
  enabled?: boolean;
  refreshListener?: () => void;
  refreshStyleConfig?: Record<string, string>;
}

enum EPullDirection {
  'unkonw' = 0,
  'down' = 1,
  'up' = 2
}

function __default_refresh_listener() {
  location.reload();
}

const __default_refresh_style_config = {
  color: '#000',
  fontSize: '12px',
  backgroundColor: 'rgba(255, 255, 255, 1)', // 刷新容器的背景色
}

export default class PullRefresh {
  constructor(config: IPullRefreshConfig = {}) {
    this.$_ele = config.$_ele || document.body;
    this.refreshListener = config.refreshListener || __default_refresh_listener;
    this.refreshStyleConfig = config.refreshStyleConfig || __default_refresh_style_config;
    // 初始化,就可以更新了
    this.init();
  }
  // 下拉刷新的那个容器
  $_ele: HTMLElement;

  // 下拉刷新是否可用
  enabled: boolean = (window as any).__pull_refresh_enabled || false;

  // 刷新函数
  refreshListener: () => void;

  // 下拉刷新过程中的位置函数
  position =  {
    start_x: 0, // 开始触碰的位置 x
    start_y: 0, // 开始触碰的位置 y
    end_x: 0, // 结束的位置 x
    end_y: 0, // 结束的位置 y
    direction: EPullDirection.unkonw, // 手指移动的方向
    scroll_on_top: true, // 滚动条是否在最顶部,在最顶部才能下拉刷新
  }

  // 是否在 loading 中,初始化 false
  loading: boolean = false;

  // 刷新过程中的容器
  refreshContainer: HTMLElement | null = null;

  // 下拉过程中的 timer
  timer: any;

  // 刷新的样式配置
  refreshStyleConfig: any = __default_refresh_style_config;

  setEnabled(flag: boolean) {
    this.enabled = flag;
    (window as any)._setPullRefreshEnabled(flag);
  }

  setRefreshListener(fn: any) {
    this.refreshListener = fn;
    console.log(this, 999999)
  }

  setLoading(flag: boolean) {
    this.loading = flag;
  }

  setRefreshContainer(dom: HTMLElement) {
    this.refreshContainer = dom;
  }

  checkScrollIsOnTop() {
    const top = document.documentElement.scrollTop || document.body.scrollTop;
    return top <= 0;
  }

  // 初始化 touchstart 事件
  initTouchStart() {
    const _self = this;
    _self.$_ele.addEventListener('touchstart', function(e) {
      // 如果下拉刷新被禁用,那么直接返回
      if (!_self.enabled) return;
      Object.assign(_self.position, {
        scroll_on_top: _self.checkScrollIsOnTop(),
        start_x: e.touches[0].pageX,
        start_y: e.touches[0].pageY,
      });
    });
  }

  // 初始化 touchmove 事件
  initTouchMove() {
    const _self = this;
    _self.$_ele.addEventListener('touchmove', function(e) {
      // 存储移动过程中的偏移
      const { start_x, start_y, scroll_on_top } = _self.position;
      const offsetY = e.touches[0].pageY - start_y;
      const offsetX = e.touches[0].pageX - start_x;
      console.log(_self.position);
      // 方向向下才是刷新
      if (offsetY > 150 && offsetY > Math.abs(offsetX)) {
        _self.position.direction = EPullDirection.down
      } else if (offsetY < 0 && Math.abs(offsetY) > Math.abs(offsetX)) {
        _self.position.direction = EPullDirection.up
      } else {
        _self.position.direction = EPullDirection.unkonw
      }
      if (
        !_self.enabled || // 如果被禁用了,直接返回
        _self.loading || // 如果在 loading 过程中,直接返回
        !scroll_on_top || // 如果不是在最顶部下拉的,直接返回
        _self.position.direction !== EPullDirection.down // 方向不是向下,直接返回
      ) return;
      console.log('达到了下拉阈值: ', offsetY);
      _self.setLoading(true);
      Object.assign(_self.$_ele.style, {
        transform: 'translate3d(0, 100px, 0)',
        transition: 'all ease .5s',
      });
      (_self.refreshContainer as HTMLElement).innerHTML = "下拉刷新内容...";
    });
  }

  // 初始化 touchmove 事件
  initTouchEnd() {
    const _self = this;
    _self.$_ele.addEventListener('touchend', function() {
      if (!_self.enabled) return;
      const { scroll_on_top, direction } = _self.position;
      // 没在顶部或者没有触发 loading,end 不做任何操作
      if (!scroll_on_top || direction !== EPullDirection.down || !_self.loading) return;
      (_self.refreshContainer as HTMLElement).innerHTML = '<div class="refresh-icon"></div>';
      _self.timer = setTimeout(function() {
        if (_self.timer) clearTimeout(_self.timer);
        (_self.refreshContainer as HTMLElement).innerHTML = '';
        Object.assign(_self.$_ele.style, {
          transform: 'translate3d(0, 0, 0)',
          transition: 'all cubic-bezier(.21,1.93,.53,.64) 0.5s'
        });
        _self.setLoading(false);
        _self.position.direction = EPullDirection.unkonw;
        setTimeout(() => {
          // 开始刷新
          _self.refreshListener();
          setTimeout(() => {
            // 特殊处理,要不然会使得 dom 里的 fixed 布局会失效
            Object.assign(_self.$_ele.style, {
              transform: '',
              transition: ''
            });
          }, 500)
        });
      }, 1000);
    })
  }

  /**
   * 初始化下拉刷新的样式
   */
  initRefreshStyle(cssStr: string = '') {
    if (document.getElementById('pull_refresh__style') && cssStr.length > 0) {
      (document.getElementById('pull_refresh__style') as HTMLElement).innerHTML = cssStr;
      return;
    }
    const styleDom = document.createElement('style');
    styleDom.id = 'pull_refresh__style';
    styleDom.innerHTML = `
      .pull_refresh__container {
        position: absolute;
        width: 100%;
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100px;
        line-height: 100px;
        color: ${this.refreshStyleConfig.color};
        font-size: ${this.refreshStyleConfig.fontSize};
        text-align: center;
        left: 0;
        top: 0;
        background-color: ${this.refreshStyleConfig.backgroundColor};
        transform: translate3d(0, -100px, 0);
      }
      div.refresh-icon {
        border: 2px solid rgba(126, 126, 126, 0.2);
        border-top-color: #fff;
        border-radius: 50%;
        width: 26px;
        height: 26px;
        animation: spin 1s linear infinite;
      }
      @keyframes spin {
        to {
        transform: rotate(360deg);
        }
      }
    `;
    document.head.appendChild(styleDom);
  }

  /**
   * 这里特别对普通的 H5 和常用的 SPA 单页应用做了处理
   * 普通 H5 可以直接使用 body 作为下拉刷新容器
   * SPA 可以使用 #app 作为下拉刷新容器
   */
  initRefreshContainer() {
    const refreshDom = document.createElement('div');
    refreshDom.classList.add('pull_refresh__container');
    // 如果不存在第一个元素,那么直接往里面插入
    if (!this.$_ele.firstElementChild) {
      this.$_ele.appendChild(refreshDom);
      return;
    }
    // 存在第一个元素,往第一个元素之前插入
    this.$_ele.insertBefore(refreshDom, this.$_ele.firstElementChild);
    // 初始化下拉刷新容器
    setTimeout(() => {
      this.setRefreshContainer(refreshDom);
    }, 0);
  }

  init() {
    (window as any)._setPullRefreshEnabled = function(flag: boolean) { 
      (window as any).__pull_refresh_enabled = flag;
    }
    this.setEnabled(true);
    // 初始化处理页面样式和结构,new Class 的过程中就可以完成
    this.initRefreshStyle();
    this.initRefreshContainer();
    this.initTouchStart();
    this.initTouchMove();
    this.initTouchEnd();
  }
}

这里其实可以考虑封装一个小的 npm 包,之所以没有封装,出于两方面考虑。一方面:功能定制化比较高,比如下拉刷新的内容以及如何展示(icon,文案,长度以及触发时机等)每一个需求每一个页面都不一样,直接拿代码过来改更方便;另一方面,一般的 App 都帮我们提供了下拉刷新的功能,其实没必要自己去定制开发,这里只是写完做一个简单总结,如果大家真的有需求,可以直接拿来改吧改吧就能用。目前只是一个简单的效果 Demo,后续可能会不断更新更多的功能,进行更高的定制化,争取能达到我个人觉得可以发 npm 包的状态,感兴趣的可以一起共建,优化体验。

V - 移动端 H5 活动页的注意事项

上面介绍了几个在这次活动开发过程中总结的几个技术点,有很多地方是借鉴了大佬的一些方案并进行了一些改造。当然有的小伙伴可能会说了,这些不都是基础的前端知识吗?也没什么可拿出来讲的。这也是我想说的,为啥活动会锻炼人,上面的内容你可能看一下效果就知道用的什么方案,但是当涉及到真正的需求的时候,来思考一下,如果你不是一个写活动页面的前端,平时业务中你会有如下的场景?

  • 没事会写一个红包在那转来转去?

  • 会去实现一个拖拽的挂件?(滴滴/美团等等的红包挂件都是不可拖拽的,说明这个功能因人而异因产品而异并不是刚需的)

  • 红包消失不是直接隐藏而是消失到可拖拽的挂件位置?

  • 如果你的端内不支持下拉刷新,这个功能你会怎么实现?

所以,你知道这个效果是怎么做的和你能不能把它做出来符合产品要求的效果,是两个概念。 这边踩坑过后的感触以及目的就是 —— 如果大家遇到类似的需求相似的效果的时候可以节省些时间,把节约下来的时间学习其他技术或者多改两个 Bug,是不是更有意义。

下面有几个开发过程中的时候遇到的一些前端坑问题以及解决方案,在这里也简单总结分享一下,强调一下,不是需求,而是移动端开发过程中实实在在遇到的几个坑点

1 - 移动端性能和动画的优化方案 —— CSS 硬件加速

在做移动端开发的时候,前端开发大部分可能用的是浏览器开发,然后最后到真机上调试,但是有时候会发现,在 PC 上开发效果完全没问题,但是在真机上效果就不生效了,具体是什么原因呢?其实可能的原因有很多,不过我这边统一遇到了下面这两个 —— 3D动画不生效页面抖动

  • 案例一:红包动效

上图的红包动画效果翻滚出来大家应该看到了,核心代码如下:

.red__packet_rotate {
    -webkit-transform: scale(0.1) rotateY(270deg);
    transform: scale(0.1) rotateY(270deg);
}

.red__packet_rotate_active {
    -webkit-transform: scale(1) rotateY(0) translate3d(0px, 0px);
    transform: scale(1) rotateY(0) translate3d(0px, 0px);
    -webkit-transform-origin: center center;
    transform-origin: center center;
    -webkit-transition: transform .6s cubic-bezier(0.33333333, 0, 0.66666667, 1);
    transition: transform .6s cubic-bezier(0.33333333, 0, 0.66666667, 1);
}

使用的是 translate3d(0, 0),不过看一下代码可以看得出来,Z 轴并没有用上,那么是不是写成 translate(0, 0)就可以了,我在开发的时候就没在意,写的就是 translate(0, 0),在浏览器实现的效果是一模一样很完美的,但是当在手机上的时候,发现是下面这个效果:

1623584838337638.gif

可以看到红包没有翻转(但是如果是浏览器,是正常效果的),调试了很久结果发现了上面的问题,所以需要加上 translate3d,算是经验之谈了,避免大家踩坑。

  • 案例二:下拉刷新抖动

下拉刷新这一块逻辑也是一样,具体就不细说了,下拉刷新采用的过渡效果也是 CSS3 动画效果,如果不开启硬件加速,在浏览器预览没有任何问题,但是在手机上,上下抖动的特别厉害,上面也说过了,可以加一些代码开启 CSS 硬件加速来避免抖动~

上面这两个问题统一的处理办法其实就是需要通过下面这些代码,开启 CSS 硬件加速

// 1 - 采用 transform: translateZ(0)
body {
    -webkit-transform: translateZ(0);
    -moz-transform: translateZ(0);
    -ms-transform: translateZ(0);
    -o-transform: translateZ(0);
    transform: translateZ(0);
}

// 2 - 采用 transform: translate3d(0, 0, 0)

body {
    -webkit-transform: translate3d(0, 0, 0);
    -moz-transform: translate3d(0, 0, 0);
    -ms-transform: translate3d(0, 0, 0);
    transform: translate3d(0, 0, 0);
}

// 3 - 在 Chrome 和 Safari 中,当使用 CSS 变化或动画时,我们可能会看到闪烁的效果

body {
    -webkit-backface-visibility: hidden;
    -moz-backface-visibility: hidden;
    -ms-backface-visibility: hidden;
    backface-visibility: hidden;

    -webkit-perspective: 1000;
    -moz-perspective: 1000;
    -ms-perspective: 1000;
    perspective: 1000;
}

总结起来就是,如果你在写移动端,又涉及到了动画效果的时候,开启硬件加速就对了~

2 - 移动端的点击穿透以及解决办法

  • 案例一:红包蒙层滚动穿透

先来直接看效果,页面 body 元素是下拉刷新的容器,上面提到过了,使用的是 touch 事件进行实现,当红包弹层出来的时候,我们不希望页面还能滚动,但是实际效果如下:

2021-06-15 15.12.31.gif

直接说解决方案:

1 - html 或 body overflow: hidden

弹层出现的时候增加下面这段代码,消失的时候移除。

html {
    overflow: hidden;
}

优点: 直接简单,PC 端和移动端都适用。

缺点: 总体来说体验会不好,因为如果用户事先已经滚动了页面,然后弹层出来,设置完上面的属性后滚动条消失,当弹层消失的时候,滚动条再出现,页面就会有一个抖动过程。

2 - passive 属性实现。

// 为元素添加事件监听,阻止穿透
(document.getElementById('coupon_wrap') as any).addEventListener(
  'touchmove',
  function(e: any) {
    e.preventDefault()
  },
  { passive: false } // //  禁止 passive 效果
);

关于 passive: false 这个属性的说明,大家可以看使用 passive 改善的滚屏性能

优点: 移动端效果良好,通过兼容方式 passive 可以适配大部分移动端场景。

缺点: 目前来看在移动端比较完美,PC 端除了 touch 事件,如果想兼容的更好还需要监听 mouse 事件等,比较麻烦。

移动端,个人建议使用第二个方案,体验好,也比较容易,不会影响其他布局相关的内容,只针对弹层进行处理。

3 - 移动端 fixed 布局相关问题

  • 案例一:下拉刷新底部 fixed 布局不 fixed 了

正常的 fixed 布局:

2021-06-13 23.57.46.gif

不正常的 fixed 布局:

2021-06-14 00.05.24.gif

可以看到,fixed 布局丢失效果了,为啥会这样呢?说实话,如果不是在开发过程中遇到这个 Bug,我确实不知道 fixed 布局还会被影响,最后查了一下,不由得感叹,CSS 世界可能比 JS 世界细节还要多得多。并且,MDN的文档是真的牛逼,YYDS

图片.png

【原因】: 在开发过程中,因为某个效果使得 body 标签增加 transform 属性,因此导致 fixed 布局消失了,最后的解决办法就是,当 body 效果完成后把 transform 属性清空即可。

总结

希望大家在每一个需求开始之前有自己的方案架构的设计和考虑,每一个需求结束过后有自己的技术沉淀和总结。日积月累,这些知识会变成你的财富。

这边有一个小仓库,起名为 useful-kit,目的就是把平时开发中用到的又不是那么容易发成 npm 包的小功能总结整理起来,方便业务使用,简单改造就能 work 的那种,感兴趣的可以共建~

useful-kit

之前因为种种原因确实把写作荒废了,这次决定重新开始写,从下半年开始希望能一直坚持下去了,最后感兴趣的可以留言随时沟通交流~