今天进入正题,看看下面效果的小程序是怎么实现的:

项目地址https://github.com/dongweiming/weapp-zhihulive

PS: 本文是假设你已经看过微信小程序的官方文档、demo 甚至已经动手写过小程序,否则建议先去翻翻再来看。

设计目录结构

我在上一节 知乎 Live 全文搜索之微信小程序实战(一) /) 介绍了组件化,今天就是要实施了。首先我们考虑一个只有 index 页面的小程序的目录结构是怎么样的:

├── app.js  // 全局的脚本文件
├── app.json  // 全局的配置文件
├── app.wxss  // 全局的样式文件
├── pages
│   ├── index
│   │   ├── index.js // 脚本文件
│   │   ├── index.json // 组件的配置文件
│   │   ├── index.wxml  // 页面结构文件
│   │   ├── rating.png  // 还有其他的图片..
│   │   └── index.wxss  // 样式表文件

pages 目录下有个 index 目录,存放了名字叫做 index,后缀为 js/json/wxml/wxss 的四个文件。这样做的好处是:

  1. index 目录下存放了页面组件所需要的各种资源, 就近维护 。如果是 React,还得通过使用各种 loader,用 import 的方式来用,所以我喜欢小程序的处理方式。
  2. 当某天不再需要 index 这个页面,或者要替换成其他的组件,直接把 index 目录删掉 / 替换就完事了。

接着我们基于 Live 搜索,思考下如果页面变的复杂,重要元素多的场景:

  1. 需要 Live、User、Topic 三大元素。
  2. 有些内容是可以重复被利用的,比如评分(就是大家熟悉的星星,5 星满分,4.5 星次之...)在 Live 详情页的效果比较大,而在搜索页由于区域小所以小了很多,但是本质上内容是一样的,只不过样式不同。
  3. 有些内容在不同页面重复出现,比如 Live,在 topic 详情页、用户详情页、发现页都有,而且一样。

那么:

  1. 评分是一个独立的区域,可以视作一个组件。
  2. 组件与组件之间应该可以 自由组合 ,所以组件的粒度要细,细到一个组件就是做一件事。
  3. 单个评分组件拿出来是无意义的,只有和 Live 信息汇合起来才是一个完整页面。

所以重新定义目录结构吧:

App
├── components
│   ├── hot
│   ├── live
│   ├── user
│   └── widget
├── images
│   └── rating
├── pages
│   ├── explore
│   ├── hot
│   ├── live
│   ├── search
│   ├── topic
│   └── users
└── utils

现在 pages 的每个子目录下后缀为 js 的文件就是页面逻辑。比如 pages/users/users.js 存放了 /users/users 的页面逻辑。

我新增了 3 个目录:

  1. utils。存放一些用得到的功能性的函数,如和后端通信的 api.js,一会我们详细再看。
  2. images。存放公共的静态图片资源。
  3. components。组件目录,我们把抽象的元素都放在这里,比如评分是一个组件。组件目录下有这些文件:
components/
├── hot
│   ├── hot.wxml
│   └── hot.wxss
├── live
│   ├── live.wxml
│   ├── live.wxss
│   ├── live_middle.wxml
│   ├── live_middle.wxss
│   ├── live_small.wxml
│   └── live_small.wxss
├── user
│   ├── user.wxml
│   ├── user.wxss
│   ├── user_small.wxml
│   └── user_small.wxss
└── widget
    ├── rating.wxml
    └── rating.wxss

widget 是更小层级的组件,也是本项目最小的组件单元了。它会被其他的如 user/live/hot 引用,而 user/live/hot 最后又会被 pages 下对应的模板引用。

捋一个组件化的例子

首先感受一下发现页:

发现页是 live 的集合。每个 live 都是一个 card,看上面的 components 目录,live 有三种组件,发现页用的是 user 类型的。

pages/explore.wxml 文件的内容是:

{% raw %}
<import src="../../components/live/live.wxml" />
<scroll-view class="list" style="height: {{ windowHeight }}px; width: {{ windowWidth }}px;" scroll-y="true" bindscrolltolower="loadMore" lower-threshold="800">
  <block wx:for="{{ lives }}" wx:for-item="live" wx:key="live.id">
    <template is="m-live" data="{{live: live}}" />
  </block>
  <view class="loading">
    正在加载...
  </view>
</scroll-view>
{% endraw %}

我简单介绍下这段模板的含义:

  1. import 语句对于写 Python,尤其是会 Mako 的同学非常好理解,就是引入文件。
  2. scroll-view 是「可滚动视图区域」容器,小程序自带的组件,我们可以上下滑动。
  3. bindscrolltolower="loadMore" 绑定了一个滚动到底部 / 右边就会触发的事件,会执行 loadMore 函数,之后会讲到。
  4. {% raw %}<block wx:for="{{lives}}">{% endraw %} 表示一个 for 循环的块。
  5. wx:for-item="live" 表示循环的每个元素赋值为 live。
  6. {% raw %}<template is="m-live" data="{{live: live}}" />{% endraw %} 表示找名字叫做 m-live 的模板渲染,传入的参数是 live,值为上面循环 for-item 指定的那个变量‘live’。

上面用 import 通过一个相对路径引入了../../components/live/live.wxml 这个模板文件,它里面就会有这个叫做 m-live 的模板,我们看看它的内容:

{% raw %}
<import src="../../components/widget/rating.wxml" />
<template name="m-live">
  <view class="m-live" bindtap="onViewTap" data-id="{{ live.id }}" data-type="live">
    <image class="cover" src="{{ live.cover }}" mode="aspectFill"></image>
    <view class="info">
      <text class="h2">{{ live.subject }}</text>
      <view class="rating">
        <template is="m-rating" data="{{count: live.feedback_score, size: 's'}}" />
        <view catchtap="onViewTap" data-id="{{ live.speaker.id }}" data-type="user" class="user">
          <image class="avatar" src="{{ live.speaker.avatar_url }}"></image>
          <view class="name">by {{ live.speaker.name }}</view>
        </view>
      </view>
      <view class="detail">
        <view>
          <text>开始时间{{ live.starts_at }}</text>
        </view>
      </view>
      <view class="bottom">{{ live.seats_taken }}参与 / {{ live.liked_num }}喜欢 / {{ live.speaker_message_count }}个回答
      </view>
    </view>
  </view>
</template>
{% endraw %}

这个模板中还用到了 import,引入了更小的组件单元 rating.wxml,也用到了{% raw %}<template is="m-rating" data="{{count: live.feedback_score, size: 's'}}" />{% endraw %}这样的方式来调用 rating.wxml 中的名为 m-rating 的模板。用这样的方式就实现了组件化,也保证了模板的整洁。

这个模板中还有个 bindtap="onViewTap",相当于给这个 div 加了一个事件绑定,bindtap 表示当用户点击该组件的时候会在该页面对应的 Page 中找到相应的事件处理函数 onViewTap。

剩下的就是铺页面结构了,具体想展示什么数据,页面想设计成什么就铺成对应的 view,有一点,标签你就当

理解好了。

数据绑定

现在大家对组件化的用法有了些了解,我们再看看视图和模板之间怎么做数据绑定的。

如果你熟悉 jinja2 或者 Mustache,想必对于{% raw %}{{ windowHeight }}{% endraw %}的语法很亲切。对,相当于将变量包起来。 那么其中的 windowWidth、lives、windowHeight 这些是哪里来的呢?

答案是「WXML 中的动态数据均来自对应 Page 的 data」,在这里就是 pages/explore.js 里面:

{% raw %}
const App = getApp(); // sdk提供的
const api = require('../../utils/api.js');  // 用require语句加载模块,相对路径
const util = require('../../utils/util.js');

const formatTime = util.formatTime;

Page({  // Page是sdk提供的
  data: {  // 模板用到的数据都在data下
    lives: [], 
    start: 0,
    limit: 20,
    loading: false,
    windowWidth: App.systemInfo.windowWidth,
    windowHeight: App.systemInfo.windowHeight,
  },
  onLoad() {  // Page自带的生命周期函数,监听页面加载。其他的生命周期可以看官方文档啦
    this.loadMore(); // 加载页面就会通过loadMore加载数据
  },
  onPullDownRefresh() { // 另外一个Page自带的生命周期函数,监听用户下拉动作
    this.loadMore(null, true);
  },
  loadMore(e, needRefresh) {
     ...// 加载数据的逻辑,下面再聊
  },
  onViewTap(e) { // 模板绑定的bindtap/catchtap事件就会执行这个函数
    const ds = e.currentTarget.dataset; // 查找模板对应div的数据集,也就是那些'data-'开头的属性,比如上面的「data-id="{{ live.id }}" data-type="live"」,这里会能找到ds.id和ds.type了
    const t = ds['type'] === 'live' ? 'live/live' : 'users/user' // ds.type 和 ds['type'] 一样的用
    wx.navigateTo({ // 是sdk提供的一个界面API,表示在新窗口打开页面
      url: `../${t}?id=${ds.id}`,
    });
  },
});
{% endraw %}

loadMore

实现一个简单的加载数据的逻辑,首先是在 data 下面初始化:

Page({
  data: {
    lives: [],
    start: 0,
    limit: 20,
    loading: false,
    ....

start/limit 就是用来翻页的参数,后端拿到的结果合并到 lives 中,loading 检查是不是已经请求过而减少后端压力:

loadMore(e, needRefresh) {
    const self = this;
    const loading = self.data.loading;
    const data = {
      start: self.data.start,
    };
    if (loading) {
      return;
    }
    self.setData({
      loading: true,
    });
    api.explore({
      data,
      success: (res) => {
        let lives = res.data.rs;
        lives.map((live) => {
          const item = live;
          item.starts_at = formatTime(new Date(item.starts_at * 1000), 1);
          return item;
        });
        if (needRefresh) {
          wx.stopPullDownRefresh();
        } else {
          lives = self.data.lives.concat(lives);
        }
        self.setData({
          lives: lives,
          start: self.data.start + self.data.limit,
          loading: false,
        });
      },
    });
  },

有几点需要注意:

  1. 说一下 const self = this; 这样的用法的原因:this 是一个特殊的对象,这个 this 对象是在运行时基于执行环境绑定的,即在全局对象中,this 指向的是 window 对象;在自定义函数中,this 对象指向的是调用这个函数的对象。常用的做法是在函数内赋值给一个私有的 self(叫 that 或者其他也没有关系),这样就能保证在外部调用的时候使用的是函数对象了。
  2. setData 就类似于 React 的 setState。
  3. api.explore 是封装好的 api 方法,后面会提到实现的原理。

小程序怎么和后端交互

我们后端提供 JSON API 返回需要的数据,在小程序中如何来调用呢?小程序提供了 wx.request 这个方法来发起 HTTP(S)请求。但是需要注意它不容许使用 referer,所以图片不是直接从知乎获取,而是先下载到本地,再由本地来 serve 的。

我们基于 wx.request 封装一下:

const apiURL = 'http://localhost:8300/api/v1';

const wxRequest = (params, url) => {
  wx.request({
    url,
    method: params.method || 'GET',
    data: params.data || {},
    header: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    success(res) {
      if (params.success) {
        params.success(res);
      }
    },
    fail(res) {
      if (params.fail) {
        params.fail(res);
      }
    },
    complete(res) {
      if (params.complete) {
        params.complete(res);
      }
    },
  });
};

const explore = (params) => {
  wxRequest(params, `${apiURL}/explore`);
};
const getLiveInfoById = (params) => {
  wxRequest({ success: params.success }, `${apiURL}/live/${params.data.id}`);
};

module.exports = {
  explore,
  getLiveInfoById,
  ...
};

由于这个项目都是一些获取资源的需求,一律用的 GET,实现的就比较简单了。使用这个模块里面的那些函数就可以实现请求后端对应接口了。

组合这些页面

上面展示的是一个组件的实现。多个组件是路由的呢?在小程序中是靠全局配置文件 app.json 实现的:

{
  "pages": [
    "pages/explore/explore",
    "pages/search/search",
    "pages/users/users",
    "pages/users/user",
    "pages/hot/weekly",
    "pages/topic/hot_topics",
    "pages/live/live",
    "pages/topic/topic",
    "pages/hot/monthly"
  ],
  "window": {
    "backgroundTextStyle": "dark",
    "navigationBarBackgroundColor": "#4abdcc",
    "navigationBarTitleText": "知乎Live",
    "navigationBarTextStyle": "white",
    "enablePullDownRefresh": true
  },
  "tabBar": {
    "color": "#b0b0b0",
    "selectedColor": "#4abdcc",
    "borderStyle": "white",
    "backgroundColor": "#fff",
    "list": [
      {
        "pagePath": "pages/explore/explore",
        "iconPath": "images/explore_normal.png",
        "selectedIconPath": "images/explore_pressed.png",
        "text": "发现"
      },
      {
        "pagePath": "pages/hot/weekly",
        "iconPath": "images/hot_normal.png",
        "selectedIconPath": "images/hot_pressed.png",
        "text": "热门"
      },
      {
        "pagePath": "pages/search/search",
        "iconPath": "images/search-off.png",
        "selectedIconPath": "images/search-on.png",
        "text": "搜索"
      }
    ]
  },
  "debug": true
}

其中:

  • pages。设置页面路径,指定小程序由哪些页面组成。每一项代表对应页面的【路径 + 文件名】信息,数组的第一项代表小程序的初始页面。
  • tabBar。设置底部 tab 的表现。
  • window。设置页面路径。如状态栏、导航条、标题、窗口背景色等。

小程序的一些经验

上面就是我对小程序的玩法的理解了。说几个我开发中遇到的坑儿吧:

事件冒泡

发现页的 Live 上有 2 种事件:点击头像会去用户详情页,其他位置会去 Live 详情页。事件绑定在组件上,触发事件就会执行逻辑层中对应的事件处理函数。想象一下,如果点击用户头像会发生什么:

  1. 进入用户详情页
  2. 然后再进入 Live 详情页

由于小程序的事件封装,我们不能使用 e.stopPropagation () 这样的方式阻止事件冒泡,所以在用户这个 view 上不能绑定 bindtap,而是要用 catchtap。在前面展示 live.wxml 的时候,你可以没注意到这点,我简化下:

{% raw %}
<view class="m-live" bindtap="onViewTap" data-id="{{ live.id }}" data-type="live">
...
<view class="info">
  ..
  <view class="rating">
    ..
    <view catchtap="onViewTap" data-id="{{ live.speaker.id }}" data-type="user" class="user">
...
{% endraw %}

绑定到同一个事件函数上:

onViewTap(e) {
  const ds = e.currentTarget.dataset;
  const t = ds['type'] === 'live' ? 'live/live' : 'users/user'
  wx.navigateTo({
    url: `../${t}?id=${ds.id}`,
  });
},

抽象 Page

我没有使用额外的辅助工具开发,不能使用继承的方式用 Page:

export default class Index extends Page {

  data = {
    userInfo: {}
  };
  bindViewTap () {
    console.log('Button Clicked');
  };
  onLoad() {
    console.log('OnLoad');
  };
}

这很不爽。你想啊,一些页面的 LoadMore、Onload 其实差不多。尤其是 7 天热门和 30 天热门这 2 个页面,不一样的只是调用了 api.getHotByWeekly 和 api.getHotByMonthly,重复代码是不是有点不能忍?仔细看一下 Page 其实是接受了一个字典,每个其中的 data 和其他生命周期函数以及自定义方法都是键值对的方式传进去。那么这有点简单了,我写个函数:

export function gen_page(type) {
  return {
    data: {
      lives: [],
      ...
    },
    onLoad() {
      ...
      api[`getHotBy${type}ly`]({
        success: (res) => {
        ...
        }
      });
    },
    onChangeTab(e) {
      const ds = e.currentTarget.dataset;
      if (type == 'Month' && ds.type == 'Week') {
        wx.navigateBack('../hot/weekly');
      } else {
        wx.navigateTo({
          url: `../hot/${ds.type.toLowerCase()}ly`,
        });
      }
    },
  },
  ...
}

需要注意一点,onChangeTab 中对于 Tab 切换的时间的处理用到 wx.navigateBack,我觉得这也是小程序实现的一个不好的地方,我需要特殊处理「这种打开了页面 A(navigateTo),然后打开页面 B(navigateTo),再用 navigateTo 就不能回到 A 了」的问题,得用 navigateBack。

这样,调用的时候就简单了,比如 7 天的:

const lib = require('./lib.js');

Page(lib.gen_page('Week'));

这是「知乎 Live 全文搜索的小程序」专题的最后一篇啦,感谢大家坚持阅读..