简单一个淘宝收藏夹小案例,一步教你优化weex动画

1,923 阅读9分钟

博主时隔两个月又回来了😂 ,入职阿里巴巴以来,先后参与了三个项目的制作:

  1. 淘宝武林大会答题面板
  2. 淘宝直播基地的绑定和签到h5页面
  3. 收藏夹暂存栏 weex 页面

现在淘宝的收藏夹项目也是我在负责。这次重点也是想讲讲淘宝收藏夹中 weex 的动画优化技术——BindingX。主要还是写给新人们的

weex 和 BindingX 扫盲

什么是 weex?

Weex 致力于使开发者能基于通用跨平台的 Web 开发语言和开发经验,来构建 Android、iOS 和 Web 应用。简单来说,在集成了 WeexSDK 之后,你可以使用 JavaScript 语言和前端开发经验来开发移动应用。

通俗点讲就是,js 可以通过 weex 与 native 通信,所以你可以在前端编写 js 代码来开发手机 app 的一部分功能和页面。一般来说在淘宝 app 中的 weex 层主要是一些不依赖手机原生功能 api 调用的静态页面,大部分是展示一些静态数据的。weex对于feeds流的列表展示还是很友好的,例如这次我接手的 weex 淘宝收藏夹项目,关于收藏夹的宝贝内容列表页、宝贝搜索页、宝贝榜单页等一系列静态页面。也可以看看我这次做的暂存栏功能:

什么是 BindingX

BindingX 是解决 weex 和 React Native 上富交互问题的一种解决方案。 它提供了一种称之为 "Expression Binding" 的机制可以在 weex、React Native 上让手势等复杂交互操作以 60fps 的帧率流畅执行,而不会导致卡顿,因而带来了更优秀的用户体验。———— 来自 BindingX 官网:alibaba.github.io/bindingx/gu…

BindingX 的核心思想就是将"交互行为"以表达式的方式描述,并提前预置到 Native,避免在行为触发时 JS 与 native 的频繁通信。———— 来自 BindingX 官网:alibaba.github.io/bindingx/gu…

怎么在 rax 中使用 weex

在这里,我不会讲太多 weex 的基础,大部分我会讲应用相关的,结合weex在rax中的使用给大家一一讲解。

  1. 打开控制台,在控制台中输入:npx rax init [your project name],全局安装了 rax 的同学可以直接使用命令 rax init [your project name]。
  2. 选择 App。
  3. 将 web 和 weex 勾选上。
  4. 作为示例使用我们就选择 spa(单页应用)。
  5. 输入你自己的名字。
  6. 选择你要使用的语言,这里我们选择 JavaScript。
  7. 这里直接回车。(注意:不要引入 react,即不要勾选第三个选项:Compatibility with React)
  8. 这里提示你是否自动安装依赖,我们选择 yes。

至此我们的项目就创建好了,接下来就是我们的实战环节!

收藏夹小案例

为了让大家体验一下 weex 中 BindingX 技术 的魅力我就用大家耳熟能详的一个收藏夹小功能作为演示:收藏夹列表的左滑功能,具体操作相信大家都知道,就是左滑出现三个选项,去店铺、分享、删除。

首先我们先不使用 BindingX 来实战一把,OK,开始切入正题!

未使用BindingX如何实现

我们直接在 src/Home 目录下的 index.jsx 文件中编写我们的代码。首先我们来思考一下,其实要实现这个功能并不难,布局也很简单,一个外包裹容器套一个子容器和一个左滑后出现的容器,这个容器定位是position: absolute;。当我们 touchStart 的时候记录开始点的 x 坐标,然后在 touchEnd 的时候计算 结束点 - 开始点 的值就可以知道滑动的距离,通过正负可以判断滑动的方向,最后我们再让盒子移动到相应的位置即可。

想明白之后我们就可以开始编写代码了:

  1. 先引入我们所需要的依赖:
import { createElement, useEffect, useRef } from 'rax';
// rax中默认的div容器
import View from 'rax-view';
// rax中的文本标签
import Text from 'rax-text';
// 当组件已经被挂载到 DOM 上时,findDOMNode 可用于获取真正的 DOM 元素,以便对 DOM 节点进行操作。
import findDOMNode from 'rax-find-dom-node';
import transition from 'universal-transition';
// rax中监听手势的组件,可用手势有:
// onHorizontalPan  监听水平滑动手势
// onVerticalPan	监听垂直滑动手势
import GestureView from 'rax-gesture-view';
import './index.css';
  1. 其次开始布局,这里就直接上代码了:
  // 储存起始点的X坐标
  let startX = useRef(0);
  // 储存终点 - 起始点的X坐标差
  let moveX = useRef(0);
  // 储存终点的X坐标
  let endX = useRef(0);
  // 是否开启动画
  let animation = useRef(true);
  // 绑定目标移动容器的ref
  const panBoxRef = useRef(null);

 return (
  <View className="container">
    <View ref={deleteBoxRef} className="deleteBox">
      <Text>Delete</Text>
    </View>
    <GestureView ref={panBoxRef} class="box">
      <View className="head">
        <View className="avatar"></View>
      </View>
      <View className="content">
        <Text className="desc">this is container</Text>
      </View>
    </GestureView>
  </View>

css 代码:

.container {
  width: 750;
  height: 360;
  flex: 1;
  background-color: #eeeeee;
}
.box {
  width: 750;
  height: 240;
  background-color: #651fff;
}
.head {
  background-color: #651fff;
  width: 750;
  height: 120;
  flex-direction: row;
  align-items: center;
}
.content {
  width: 750;
  height: 240;
  background-color: #651fff;
  padding-left: 24;
  padding-top: 24;
  padding-right: 24;
  box-sizing: border-box;
}

.avatar {
  width: 96;
  height: 96;
  border-radius: 48;
  background-color: #cddc39;
  margin-left: 36;
  margin-right: 48;
}

.deleteBox {
  width: 200;
  height: 360;
  position: absolute;
  right: 0;
  top: 0;
  background-color: red;
}

接下来你就可以看到布局如下:

布局完成了,那我们就开始实现功能吧。首先对我们的目标容器绑定 ref 和事件,这里我们的目标容器使用的是<GestureView></GestureView>,使用<View></View>也可以,通过 onTouchStart 和 onTouchEnd 事件可以记录下起始点和终点的 x 坐标,相减之后就是水平滑动的手势的距离,和这里用手势监听容器是一个道理:

<GestureView ref={panBoxRef} onHorizontalPan={handleHorizontalPan} class="box">
  ...
</GestureView>

接下来写我们的 handleHorizontalPan 和 控制动画的 transitionShowDelete 方法:

const handleHorizontalPan = (e) => {
  // console.log(e);
  if (e.state === 'start') {
    startX.current = e.changedTouches[0].pageX;
  } else if (e.state === 'end') {
    endX.current = e.changedTouches[0].pageX;
    moveX.current = endX.current - startX.current;
    if (moveX.current < 0) {
      transitionShowDelete(true);
    } else if (moveX.current >= 0) {
      transitionShowDelete(false);
    }
  }
};

/**
 * 具体的transition使用方法可以查看官方文档
 * https://rax.js.org/docs/api/transition
 */
const transitionShowDelete = (shouldShowDelete) => {
  transition(
    findDOMNode(panBoxRef.current),
    {
      // 位移距离你可以使用百分比也可以使用带rpx单位的数字
      transform: shouldShowDelete
        ? `translateX(-${(20 / 75) * 100}%)`
        : 'translateX(0%)',
    },
    {
      timingFunction: 'ease-in-out',
      delay: 0,
      duration: 300,
    },
    () => {
      // 动画执行成功后的回调函数
    }
  );
};

这里我们可以打印一下这个方法的事件参数 e 看看是啥 ?

你会发现 e.state 储存了 touchStart、touchMove、touchEnd 的状态:“start”表示滑动开始,“end”表示滑动结束。至此我们的功能就完成啦!下面是效果图:

但是如果你知道了 weex 的的工作原理后,你就会觉得这并不是一个最佳的做法,接下来我们就来了解一下 weex 和 native 之间的通信过程:

前面我们说了我们可以通过编写 js 代码可以达到使用一些 native 底层的 api 功能,那么这个是怎么实现的呢?接下来我将画一张图简单给大家解答一下。

JS 层负责视觉控制、事件处理;Bridge 层是 JS 层和 Native 层的沟通桥梁,负责指令翻译,传递给 native 层;Native 层负责视觉绘制、事件收集。

由上图我们可以看出我们每一次响应事件,执行交互操作、改变 UI 都需要 js 层和 native 层的频繁通信。

试想一下如果我们在 touch 事件响应时去改变 ui,想让 native 层做一些复杂的交互操作时,JS 层就需要不停得处理从 Native 层收集来的事件然后作出及时的视觉响应,如果响应不及时就会导致视觉卡顿。我们都知道一般手机的屏幕刷新率是 60fps,是我们人肉眼可以接受到的流畅程度,也就是说一次刷新要在 1/60s 内完成才不会让人觉得卡顿。所以如果 JS 层从事件接受、处理、回馈到 Native 绘制新的视图完成超过了 16.67ms 将会出现视觉卡顿。而且如果每一次的视觉更新都需要大量的通讯,也会加大 cpu 的消耗,以至于增加 crash 的风险。

所以你会想到了我们上面那种方法就是这样一种情况:每次 touch 事件触发都需要去和 native 通信一次然后更新视图,这还是没有将 UI 绘制放在onTouchMove中执行,如果是放在onTouchMove中执行那性能肯定会受到很大的影响,视觉的绘制也会明显的感觉到卡顿。不信我们可以来试一下:

这里我们如果要将动画处理操作放在TouchMove事件中,可以通过e.state === 'move'判断,直接修改handleHorizontalPan方法,修改后的代码如下:

const handleHorizontalPan = (e) => {
  // console.log(e);
  if (e.state === 'start') {
    startX.current = e.changedTouches[0].pageX;
  } else if (e.state === 'move') {
    endX.current = e.changedTouches[0].pageX;
    moveX.current = endX.current - startX.current;
    if (moveX.current < 0) {
      transitionShowDelete(true);
    } else if (moveX.current >= 0) {
      transitionShowDelete(false);
    }
  }
};

接下来看看效果:

怎么样,是不是感觉到了很明显的卡顿!那么问题来了,如果我非要在每次 touchMove 的时候去更新视图呢,那又要怎么解决呢?如此一来我们的神器 BindingX 闪亮登场!

使用BindingX如何实现

我们通过上面的扫盲相信大家已经初步了解了一下什么是 BindingX,如果你还不懂 BindingX 的引入的意义所在,没关系可以看看我接下来的讲解:

上图可以看出,如果我们没有引入 BindingX 那么我们要在 touchMove 时渲染视图就需要频繁的 js 和 native 通信,每一次的 touchMove 就是一次通信的性能消耗。而如果我们引入了 BindingX 那么我们就可以通过onTouchStart事件向 native 层注入一个表达式,这个表达式会在 native 层解析,然后执行。

比如说我们注入一个在 X 轴上移动的表达式,那么当我们在 touchStart 注入成功时就可以在每次 touchMove 的时候 native 层自动帮我们更新了视图,然后视图更新成功后会返回给 js 层一个成功的回调。

如果你还不明白,没关系,接下里我们就来实战一下:

先安装我们的依赖:npm i weex-bindingx --save,引入我们需要的依赖项如下:

import { createElement, useEffect, useRef } from 'rax';
// rax中默认的div容器
import View from 'rax-view';
// rax中的文本标签
import Text from 'rax-text';
// 当组件已经被挂载到 DOM 上时,findDOMNode 可用于获取真正的 DOM 元素,以便对 DOM 节点进行操作。
import findDOMNode from 'rax-find-dom-node';
import transition from 'universal-transition';
import Bindingx from 'weex-bindingx';
// 判断当前环境
import { isWeex } from 'universal-env';
import './index.css';

布局也需要改变一下:

return (
  <View className="container">
    <View ref={deleteBoxRef} className="deleteBox">
      <Text>Delete</Text>
    </View>
    <View ref={panBoxRef} onTouchStart={handleTouchStart} class="box">
      <View className="head">
        <View className="avatar"></View>
      </View>
      <View className="content">
        <Text className="desc">this is container</Text>
      </View>
    </View>
  </View>
);

接下里的重头戏是我们的handleTouchStart方法:

useEffect(
  () => {},
  () => {
    // 在组件销毁时BindingX解绑
    Bindingx.unbind({
      token: gesToken.current,
      eventType: 'pan',
    });
  },
  []
);

// 判断当前环境:weex和web上的传入的ref情况不同
const getEl = (panBoxRef) => {
  return isWeex ? findDOMNode(panBoxRef).ref : panBoxRef;
};

const handleTouchStart = (e) => {
  console.log(e);
  if (!animation.current) {
    return;
  }
  startX.current = e.changedTouches[0].pageX;
  let ref = getEl(panBoxRef.current);
  // 向native注入表达式,注入后的返回值有一个token属性用来BindingX的解绑
  let gesTokenObj = Bindingx.bind(
    {
      // 要绑定的元素的锚点
      anchor: ref,
      // 触发动画的事件类型:'pan'为触摸时触发动画
      eventType: 'pan',
      props: [
        {
          element: ref, // 要实现动画的元素的锚点
          property: 'transform.translateX', // 动画的属性
          expression: 'x+0', // 代表在x轴移动
        },
      ],
    },
    (e) => {
      // 注入成功之后的回调,根据注入的表达式的不同e.state的状态代表不同的情况,这里我们注入的是translateX,那么e.state === 'end' 代表移动结束后的状态,就是touchEnd的时候
      console.log(e);
      if (e.state === 'end') {
        moveX.current = e.deltaX;
        animation.current = true;
        if (moveX.current <= -100) {
          anchorToCurrent(-200);
        } else if (moveX.current > -100) {
          anchorToCurrent(0);
        }
      }
    }
  );
  gesToken.current = gesTokenObj.token;
};

// 在touchEnd的时候,根据移动的距离不同,触发一个回弹的效果
const anchorToCurrent = (currentWidth) => {
  let result = Bindingx.bind(
    {
      // 事件类型:timing为根据时间执行动画
      eventType: 'timing',
      exitExpression: 't>600', // 动画结束的时间
      props: [
        {
          element: getEl(panBoxRef.current), // 要实现动画的元素的锚点
          property: 'transform.translateX', // 动画属性
          expression: `easeOutElastic(t, ${moveX.current}, ${
            currentWidth - moveX.current
          }, 600)`,
        },
      ],
    },
    (e) => {
      animation.current = false;
    }
  );
};

至此我们的优化完成,如果还有同学不懂的地方可以查看 BindingX 官方文档,官网还提供了很多动画差值器,可以供我们使用:alibaba.github.io/bindingx/gu…

接下来我们来看看效果:

怎么样,是不是感觉到了如德芙般的丝滑和柔顺!这就是 BindingX 的魅力,让你的 weex 交互动画丝滑般的顺畅!

其实 BindingX 可以用到的地方很多,只要你是以下的交互场景,都可以考虑试试它:

  • 监听 pan 手势,更新 UI。
  • 监听滚动容器(如 List)的 onscroll 事件,更新 UI。
  • 监听设备传感器方向变化,更新 UI。
  • 动画。(即监听设备的每一帧的屏幕刷新回调事件,更新 UI)。

BindingX 还可以实现很多的动画交互,官网上都有例子大家也可以去看看:alibaba.github.io/bindingx/

weex补充

做这方面的补充主要是想给新手们的一些编码建议,这边就简单的列举了一些注意点,想要更详细的可以去官方查看文档。对于我们程序员来说,官方文档是最好的学习助手

weex 的大坑

对于 weex 我们首先要了解到 weex 的一些坑,也就是和我们平常写代码的一些差别。其中可能对于大部分同学来说就是 css 的坑了。这里我整理了一份 css 注意事项可以参考一下,这里主要是 weex 在 rax 中的写法:

  1. border 和 background 不支持组合写法,必须分开编辑,标签和标签不支持 background-image 属性 只支持像素值,不支持相对值,适配以 750 像素为标准
  2. weex 支持 position 定位:relative | absolute |fixed |sticky ,默认值是 relative
  3. weex 不支持 z-index 设置元素层级关系,但是靠后的元素层级越高
  4. 如果定位元素超过容器边界,在 Android 下,超出部分将不可见,原因在于在 Android 端元素 overflow 默认值为 hidden,且 overflow 没有其他值。
  5. background-image 只支持线性渐变:linea-gradient,暂不支持径向渐变:radius-gradient
  6. weex 中 box-shadow 仅仅支持 ios
  7. ios 中 image 组件无法定义一个或多个角的 border-radius,只能直接使用 border-radius 定义圆角,Android 可随意定义
  8. weex 中,flexbox 是默认并且唯一的布局模型,每个元素都默认拥有 display:flex 属性
  9. Weex 盒模型的 box-sizing 默认为 border-box
  10. 子元素的样式不会继承自父元素,比如 color 和 font-size 等样式
  11. 容器的层级嵌套不要超过 14 层,否则容易引发 crash

具体可以查看官方文档:weex.apache.org/zh/docs/sty…

weex 的环境变量

每个 Weex 页面的 JS 上下文中都有一个相互独立的 weex 变量,它可以像全局变量一样使用,不过它在不同页面中是隔离而且只读的。 weex 中的环境变量可以通过 WXEnvironment 使用,这是 weex 内置的一个全局变量 ,主要字段如下: platform——当前的运行平台 appVersion——当前 app 的版本 deviceWidth——设备屏幕宽度 deviceHeight——设备屏幕高度

具体可查看官方文档:weex.apache.org/zh/docs/api…

同时 weex 有自己内置的文档模型对象,weex.document 是当前页面的文档模型对象,可以用来创建和操作 DOM 树中元素。它是 Weex DOM API 规范的一部分,但是它和 W3C 的 DOM 规范中的 document 对象是不同的。 我们可以通过let dom = __weex_require__('@weex-module/dom');引入。 dom API 主要有:

  1. scrollToElement: 将 list 的某个子节点滚动到当前视口
  2. getComponentRect: 获取某个组件的 bounding rect 布局信息
  3. addRule: 添加 font-face rule
  4. getLayoutDirection: 获取某个组件的布局方向(rtl、lrt、inherit)

具体可查看官方文档:weex.apache.org/zh/docs/mod…