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

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

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

设计目录结构

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

1
2
3
4
5
6
7
8
9
10
├── 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信息汇合起来才是一个完整页面。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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。组件目录,我们把抽象的元素都放在这里,比如评分是一个组件。组件目录下有这些文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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文件的内容是:

1
2
3
4
5
6
7
8
9
10
11
{% 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. 表示一个for循环的块。
  5. wx:for-item=”live” 表示循环的每个元素赋值为live。
  6. 表示找名字叫做m-live的模板渲染,传入的参数是live,值为上面循环for-item指定的那个变量‘live’。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{% 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,也用到了这样的方式来调用rating.wxml中的名为m-rating的模板。用这样的方式就实现了组件化,也保证了模板的整洁。

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

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

理解好了。

数据绑定

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{% 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下面初始化:

1
2
3
4
5
6
7
Page({
data: {
lives: [],
start: 0,
limit: 20,
loading: false,
....

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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封装一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
{
"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的时候,你可以没注意到这点,我简化下:

1
2
3
4
5
6
7
8
9
10
{% 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 %}

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

1
2
3
4
5
6
7
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:

1
2
3
4
5
6
7
8
9
10
11
12
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和其他生命周期函数以及自定义方法都是键值对的方式传进去。那么这有点简单了,我写个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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天的:

1
2
3
const lib = require('./lib.js');
Page(lib.gen_page('Week'));

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