# 「大众点评点餐」小程序开发经验 03：事件联动

- 作者：知晓程序
- 发布时间：2017-03-23 12:29
- 原文：https://www.ifanr.com/minapp/806866

![](https://s3.ifanr.com/wp-content/uploads/2017/03/Featured-12.jpg)

文 \| 李超

李超，美团点评前端开发工程师，2 年 web 开发经验，现在是美团点评点餐团队的一员。

在我们团队的小程序开发经验系列多篇文章发布以后，你是否对小程序[视图层](http://www.ifanr.com/minapp/803083)、[逻辑层](http://www.ifanr.com/minapp/799963)，以及官方 API 文档有更为深入的学习和了解呢？

**「纸上谈兵」很容易，「打好胜仗」才是关键。**

今天，知晓程序（微信号 zxcx0101）为大家分享，开发「大众点评点餐小程序」菜单页面的过程中，遇到的问题和解决方案。

![](https://s3.ifanr.com/wp-content/uploads/2017/03/dzdpdc-qr.jpg)

### 产品需求与最终效果

![](https://s3.ifanr.com/wp-content/uploads/2017/03/ba2d1be7aee293b09fd3-compressor.gif)

如果你看过我们的系列文章， 应该对我们的产品形态有了初步了解。我们是做点餐菜单服务，菜单需要分类，需要购物车模块，那么典型的「工」字型布局是我们的首选。

大体结构为：顶部商家名称，可能会出现黄色横条提示模块；下方左侧为导航菜单栏；下方右侧为每个菜单分类包含的菜品展示列表；底部可能出现购物车模块。

结合上面的图片，菜单页的结构的交互需求很容易就整理出来了：

- 顶部要求显示商家名称，有分享功能。
- 下方左侧、右侧可分开滚动，滚动左侧不影响右侧，滚动右侧左侧随之联动高亮显示所在的菜单分类。
- 点击下方左侧导航菜单栏，高亮显示被点击的菜单分类，下方右侧对应分类详情模块顶部与右侧滚动区的顶部重合（类似于 HTML 里的锚点功能）。
- 滚动下方右侧菜品分类详情时，当该分类详情模块顶部接触到滚动区域的顶部，左侧对应的导航菜单栏高亮。
- 若左侧高亮的导航菜单不在可视区域： 
    - 当高亮的导航菜单顶部在左侧 `scroll-view` 滚动区上方（被遮住了），则将该高亮导航菜单滚动至将高亮导航栏的顶部与左侧可滚动区域顶部重合（高亮菜单为滚动区的第一个分类）。
    - 当高亮的导航菜单在左侧 `scroll-view` 滚动区可视区下方，将高亮导航菜单滚动到屏幕中央区域。
- 顶部下方可能会出现黄条提示文案模块。
- 底部上方可能会出现购物车模块。
- 顶部黄条提示文案模块吸顶，底部购物车模块吸底。
- 需要适配各种不同机型。

### 关键技术罗列

这里需要指出：**产品在设计成稿之前，我们已经对小程序支持的功能做了细致的调研**，确保可以通过技术手段实现产品需求，才确定 UI 以及交互设计。

从产品兼容性角度出发，我们考虑使用微信小程序的 `rpx` 作为 UI 设计的标准尺寸。

该尺寸和 `rem` 非常类似，不同点在于其对基准尺寸的设定。

`rem` 使用文档根元素设定的尺寸作为基准尺寸，而 `rpx` 使用手机屏幕宽度为基准，决定 `1 rpx` 对应的宽度，该动态尺寸对设备的兼容性更加友好。

**此外，微信还自带** `scroll-view` **UI组件**，并提供一系列组件状态操作接口。

当 `scroll-view` 组件滚动时，会触发 `scroll` 事件。所返回的 `event` 对象各项长度属性，均使用 `px` 作单位。

### 开始开发

菜单的页面结构如下：

```
src
├── menu.html
├── menu.js
├── menu.json
└── menu.less
```

我们在开发中使用工具对文件实时编译：

```
`menu.html`->`menu.wxml`
`menu.less`->`menu.wxss`
```

为方便代码维护以及日常的开发习惯，我们对 Less 语法进行兼容，引入了 `Promise` 组件。

#### WXML 页面布局

```
### menu.html
<page>
  <view class="menu-content">
    <view class="yellow-bar"> // 黄色横条提示模块 </view>
    <scroll-view class="scroll-view-left" height="{{ windowScrollHeight }}" scroll-into-view="{{ leftToView }}" scroll-top="{{ leftScrollTop }}"> // 左侧分类导航 </scroll-view>
    <scroll-view class="scroll-view-right" height="{{ windowScrollHeight }}" scroll-into-view="{{ rightToView }}"> // 右侧分类详情 </scroll-view>
    <view class="cart-bar"> // 购物车模块 </view>
  </view>
</page>
```

这里着重考虑两个 `scroll-view` 结构设计，**左右的布局结构可以使用 CSS 样式属性**`float`**，或者是 CSS 3 的** `flex`**。**

另外，黄条提示和购物车模块，都可以用 `fixed` 属性搞定。

微信官方文档介绍，**使用** `scroll-view` **组件，必须指定高度**。

我们实践时发现，使用 `scroll-view` 可以不指定高度，页面有滚动区存在。**但这么做，滚动时无法触发** `scroll` **事件，也就无法完成联动设计**。

#### 滚动区域检测

在这里，我们需要注意两点：

- 必须使用 `px` 作为单位。
- 必须在 `scroll-view` 上显式的指定其 `height` 属性

在获取滚动区高度 `windowScrollHeight` 之前，我们需要考虑其影响因素：

- 设备高度
- 黄条文案提示模块的存在
- 购物车模块的存在
- 从 `rpx` 到 `px` 的转换

设备高度可以通过微信官方 API `getSystemInfo` 接口进行获取。

那么，该什么时候调用接口？

首先这是一个异步 API 接口，另外其直接受系统权限控制的影响，基于这两点因素，**其结果返回的时机就不是确定的**。

我们可以在小程序启动时在 `onLaunch` 中调用该 API，然后将获取的结果放入到全局变量 `globalData` 中。

而 `globalData` 是挂在在全局 `App` 元素上的属性，对所有页面均可见。

现在来看看，利用系统信息接口获取到的数据是如何的：

```
sysInfo Object {
    errMsg:"getSystemInfo:ok"
    language:"zh_CN"
    model:"iPhone 6"
    pixelRatio:2
    platform:"devtools"
    system:"iOS 10.0.1"
    version:"6.3.9"
    windowHeight:627
    windowWidth:375
}
```

这里的 `windowHeight`, `windowWidth` 指的是屏幕高度和宽度，且使用的单位是 `px`。

在实际代码中，调用系统信息的接口代码就是这个样子：

```
// app.js
// 注意这里的wxp为我们对wx的封装，它继承wx的所有属性，特点是若调起wx的异步api函数将返回一个Promise实例。
 getSysInfo: function() {
        let that = this;
        if(that.globalData && that.globalData.sysInfo 
            && that.globalData.sysInfo.windowHeight) {
            // 将结果封装成Promise，后续可统一使用`then`方法
            return Promise.resolve(that.globalData.sysInfo);
        }
        return wxp.getSystemInfo()
            .then(res => {
                that.globalData.sysInfo = res;
                return res;
            })
            .catch(e => {
                // 可以尝试弹出框或toast
                console.error('[getSystemInfo]', e);
            });
   },

// menu.js 
onLoad: function() {
    app.getSysInfo().then((sysInfo)=> {
        // transform rpx -> px and calculate scroll-view height.
    }
}
```

#### 计算 `fixed` 元素高度

黄条文案提示模块、购物车模块的高度都是已知的。

但大家应该记得这样的设计细节：**所有的元素统一使用** `rpx` **做单位，而这里需要使用** `px` **作单位，必须要进行单位转换**。

#### 从 `rpx` 到 `px` 的转换

```
var yellowBarRpxHeight = 50;  // 黄色文案提示模块高度
var percent = app.data.sysInfo.windowHeight / 375; // 当前设备1rpx对应的px值
var yellowBarHeight = Number(yellowBarRpxHeight * percent).toFixed(2);
```

大家对 375 这个数字是否有疑问呢？该比值是否会受到设备实际像素点的影响呢？实际上，你并不需要担心它。

同样的道理，**我们可以得到购物车模块的高度** `cartBarHeight`。

通过公式：`windowScrollHeight = windowHeight - yellowBarHeight - cartBarHeight`，可以计算得出两个 `scroll-view` 的滚动高度。

#### 左侧菜单栏与右侧菜品栏的联动

首先我们要做到：点击左侧导航菜单栏，右侧定位到对应的分类菜品详情。

通过查看`scroll-view`文档发现可以使用 `scroll-into-view` 属性；该组件自动定位右侧需要滚动到的具体位置。

首先给左侧导航菜单栏绑定 `tap` 事件监听函数，事件触发后获取 `event` 对象象的 `currentTarget` 属性，取出渲染时存放在该节点上的分类 `id`，用此 `id` 作为唯一标识定位右侧分类详情。

然后，设置右侧 `scroll-view` 的 `scroll-into-view` 属性，这时，它会将右侧 `scroll-view `上 `id` 属性值为该值的节点滚动到滚动区域的顶部。

#### 点击事件监听函数

```
    // menu.js
   bindLeftTap (e) {
        // 由于事件是冒泡的，所以不确定点击操作是在哪个元素上触发的，但currentTarget表示当前绑定事件对应的节点，便可准确获取该节点上的dataset
        let dataset = e && e.currentTarget && e.currentTarget.dataset;
        var LEFT_TO_RIGHT_SUFFIX = "l2r-";
        if(!dataset || !dataset.id) return;
        // target
        this.setData({
            highlightCategoryId: dataset.id, // 左侧高亮的导航菜单栏
            rightToView: LEFT_TO_RIGHT_SUFFIX + dataset.id, // 更新右侧的scroll-to-view属性。
        });
    }

```

`LEFT_TO_RIGHT_SUFFIX` 是全局定义的常量，只是为了方便大家阅读，才将其写入函数内部，用作 `id` 拼接，保证唯一性。

在开发阶段，我们曾经尝试直接将获取到的 `id` 作为 `rightToView` 的值，也就是设定右侧 `scroll-view` 的 `scroll-into-view` 属性。

但我们发现，**右侧** `scroll-view` **不会因此滚动到指定的高度**。

我们猜想，可能是因为获取到的 `dataset.id` 是一个数字类型字符串，其内部使用 `===` 方式导致不匹配。

另外需要注意的是，**设置** `scroll-into-view` **引起的滚动操作，同样会触发** `scroll` **事件**。

#### 右侧栏滚动事件与分类栏自动滚动

滑动右侧、让左侧滚动，是整个页面设计最核心的部分。

由于小程序无法获取元素的宽高，位置信息，对滚动右侧实现左侧联动效果带来挑战。

如何准确的获取右侧滚动到的具体分类，并让左侧导航菜单栏相应分类高亮，且在可视的范围内？

在设计阶段，**我们和设计同学确认右侧每个视觉模块固定的高度**，包括菜品模块高度、分类小灰条高度等。

这样，我们就可以根据已有的数据结构计算出每个元素距离文档区顶部的高度。

![](https://s3.ifanr.com/wp-content/uploads/2017/03/214fed6a83b13434653a.jpg)

```
// PER_BAR_HEIGHT 分类小灰条的高度
// PER_ITEM_HEIGHT 单个菜品详情的高度
var sumScrollHeight = 0;
var assistantCategories = spuMenuSet.map(it => {
    let unitHeight = PER_BAR_HEIGHT + (it.spuMenuItemList && it.spuMenuItemList.length ) * PER_ITEM_HEIGHT;
    it.scrollHeight = sumScrollHeight;
    sumScrollHeight += unitHeight;
    return it;
});
```

左侧导航菜单栏高亮分类切换的边界条件为右侧分类菜单详情的分类小灰条顶部与右侧滚动区顶部重合。

我们需要做的，就是**计算出每个分类小灰条距离文档顶部的高度** `scrollHeight`，并在每次滚动事件触发时，**比较当前滚动的高度与分类小灰条的滚动高度** `scrollHeight`。

这样做，就可确定当前在哪个分类菜单详情区域内，从而实现左侧分类导航栏的高亮。

#### 长度单位误差

在测试时发现，有些机型滚动下方右侧 `scroll-view` 时，在边界条件出现时并不会完成左侧导航菜单栏高亮分类的切换，往往存在10-100px的误差。

从产品角度，这种误差是不能容忍的。个人并不确定是什么原因导致误差的出现，但看起来并没有非常好的解决办法。

那么能用什么方案减少误差呢？ **我的实现思路是「人工干预自动校正」。**

仔细分析滚动事件返回的 `event` 对象：

```
Object
    currentTarget:Object
    detail:Object
        deltaX:0
        deltaY:-971
        scrollHeight:24737
        scrollLeft:0
        scrollTop:2409
        scrollWidth:295
        __proto__:Object

    target:Object
        dataset:Object
            __proto__:Object
            id:""
            offsetLeft:0
            offsetTop:38
        __proto__:Object
    timeStamp:13932
    type:"scroll"
    __proto__:Object
```

在这里，我们需要特别留意 `detail` 中的 `scrollHeight`。

**滚动事件会给出整个 `scroll-view` 文档内容的高度**，这个高度值非常关键，我们可以这样计算出来：

`scrollHeight = 单个菜品详情高度 * 菜品总数 + 单个分类小灰条高度 * 分类小灰条总数`。

由于单个菜品详情高度与单个分类小灰条高度的高度比是确定的，所以上面的方程式为一元方程。

我们可以用它计算出单个菜品详情高度，以及单个分类小灰条高度，更新每个分类小灰条距离文档顶部的距离 `scrollTop` 值。

经测试发现，左侧导航菜单栏高亮分类的切换精度非常高，而且兼容性很好。

#### 左侧高亮分类跳错

在实际开发中， 我还发现一个问题： 左侧有分类 A、B、C，点击分类 B，分类 B 高亮，右侧定位到分类 B 的详情区域，随之左侧高亮分类切换到 A 上。

想一想，这是什么原因导致的？

在上面讲解 `scroll-view` 属性时，我提到过一句话：

> 设置 `scroll-into-view` 引起的滚动操作，同样会触发 `scroll` 事件。

这里点击左侧分类，右侧由于 `scroll-into-view` 触发了滚动事件，而相应的滚动事件监听函数函数，计算得出当前高亮的导航菜单栏为 A，更新页面的 `data` 将高亮分类切换到了 A 上。

解决方案有这两种：

- 修改边界条件。
- 限制右侧的 `scroll` 事件函数的执行。

在这里，**我推荐使用第二种方式**，因为在不同机器上存在细微差别，我们无法准确的设置误差范围，元素宽高都是计算得出。

思路：若点击左侧导航菜单栏，设定全局锁定状态，若锁定则不右→左的联动操作，再解除锁定状态。

#### 分类导航栏的可视性

通过上面「右→左」联动，我们已经可以让左侧随着右侧滚动而高亮。

但随之而来的问题是： **左侧也是一个 `scroll-view`，如何保证高亮的分类，刚好在可视区域里（屏幕上）呢？**

监听右侧滚动事件，判断当前在哪一个分类上，确定该分类在左侧 `scroll-view` 的文档高度，判断是否需要滚动左侧 `scroll-view`。

我们可以通过 `scroll-view` 的 `scroll-into-view`，或者在 `scroll-top` 属性里完成滚动。

```
// 这里是伪代码实现
var index = mapId2index(id); //将id转换为对应分类的index值
var perCateHeight = 40; // 左侧每个分类高度为40
var leftScrollTop = 0; // 左侧scroll-view滚动的高度
var windowScrollHeight = 1440; // 这个值为屏幕高度，可通过getSystemInfo获取到
var cHeight = index * perCateHeight; // 当前分类距离文档顶部的scrollTop值
if( cHeight - leftScrollTop - windowScrollHeight > 0) {
    // 高亮的区域在屏幕底部
    leftScrollTop = cHeight - windowScrollHeight / 2; //左侧scroll-view向上滚动半个屏幕高度
    leftToView = null; // 不使用scroll-into-view 属性, 必须置空， 否则会优先应用该属性而不是leftScrollTop
} else if (cHeight - leftScrollTop < 0) {
    // 高亮的区域在屏幕顶部之上，设置scroll-into-view属性
    leftToView = id;
    leftScrollTop = cHeight; // 需要记录下当前scroll-view滚动高度，以便下次使用
} else {
    leftToView = null;
}
```

需要注意的是，若同时设置了 `scroll-into-view` 和 `scroll-top`属性，优先使用 `scroll-into-view` 属性。

因此，如果这里使用 `scroll-top` 属性进行滚动，需要将 `scroll-into-viwe` 属性置空。

#### 其他优化

联动功能开发完之后，我们的这款小程序遇到了性能瓶颈。

经过我们排查，我们发现：**我们复用的 C 端的数据接口中，存在大量无用的对象属性**，而这个数据结构直接作为页面渲染的 `data` 数据。

我们推荐的做法，就是**简化 `data` 数据结构，只存放影响页面渲染的数据**。这样做能够大幅度降低 UI 渲染时间，给用户更加流畅的体验。

### 总结与感受

微信小程序算是这两年非常火的一门新技术了。如何使用已经支持的功能特性来设计、开发产品是保障项目顺利完成的重要环节。

而在开发过程中，专注细节实现、吃透 API 文档，让用户感受到我们开发小程序的诚意是非常重要的，**千万不能粗糙地做产品复制**。

在小程序发布那段时间，总能看到各种对小程序未来的设想，有悲观的，有观望的，也有激进的。

我个人认为，**「赶鸭子上架」的思路并不可取，必须清楚自己的产品定位。**

你的产品是否满足「一次性消费」理念？内容是否不足以吸引用户下载你的 app？小程序是否比你的 HTML 5 更加具有吸引力？

这些都是需要我们进行思考的。

原文地址：https://juejin.im/post/58b8cdefac502e006c04d626

**本文由知晓程序授权转载，关注微信号 zxcx0101，在微信后台回复「点评」，获取大众点评点餐小程序全套开发经验。**

![zxcx&#95;0208](https://s3.ifanr.com/wp-content/uploads/2017/02/zxcx_0208.png)
