知乎Live全文搜索之微信小程序实战(二)
/ / / 阅读数:4620今天进入正题,看看下面效果的小程序是怎么实现的:
项目地址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 的四个文件。这样做的好处是:
- index 目录下存放了页面组件所需要的各种资源, 就近维护 。如果是 React,还得通过使用各种 loader,用 import 的方式来用,所以我喜欢小程序的处理方式。
- 当某天不再需要 index 这个页面,或者要替换成其他的组件,直接把 index 目录删掉 / 替换就完事了。
接着我们基于 Live 搜索,思考下如果页面变的复杂,重要元素多的场景:
- 需要 Live、User、Topic 三大元素。
- 有些内容是可以重复被利用的,比如评分(就是大家熟悉的星星,5 星满分,4.5 星次之...)在 Live 详情页的效果比较大,而在搜索页由于区域小所以小了很多,但是本质上内容是一样的,只不过样式不同。
- 有些内容在不同页面重复出现,比如 Live,在 topic 详情页、用户详情页、发现页都有,而且一样。
那么:
- 评分是一个独立的区域,可以视作一个组件。
- 组件与组件之间应该可以 自由组合 ,所以组件的粒度要细,细到一个组件就是做一件事。
- 单个评分组件拿出来是无意义的,只有和 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 个目录:
- utils。存放一些用得到的功能性的函数,如和后端通信的 api.js,一会我们详细再看。
- images。存放公共的静态图片资源。
- 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 %} |
我简单介绍下这段模板的含义:
- import 语句对于写 Python,尤其是会 Mako 的同学非常好理解,就是引入文件。
- scroll-view 是「可滚动视图区域」容器,小程序自带的组件,我们可以上下滑动。
- bindscrolltolower="loadMore" 绑定了一个滚动到底部 / 右边就会触发的事件,会执行 loadMore 函数,之后会讲到。
{% raw %}<block wx:for="{{lives}}">{% endraw %}
表示一个 for 循环的块。- wx:for-item="live" 表示循环的每个元素赋值为 live。
{% 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,想必对于 答案是「WXML 中的动态数据均来自对应 Page 的 data」,在这里就是 pages/explore.js 里面: 实现一个简单的加载数据的逻辑,首先是在 data 下面初始化: start/limit 就是用来翻页的参数,后端拿到的结果合并到 lives 中,loading 检查是不是已经请求过而减少后端压力: 有几点需要注意: 我们后端提供 JSON API 返回需要的数据,在小程序中如何来调用呢?小程序提供了 wx.request 这个方法来发起 HTTP(S)请求。但是需要注意它不容许使用 referer,所以图片不是直接从知乎获取,而是先下载到本地,再由本地来 serve 的。 我们基于 wx.request 封装一下: 由于这个项目都是一些获取资源的需求,一律用的 GET,实现的就比较简单了。使用这个模块里面的那些函数就可以实现请求后端对应接口了。 上面展示的是一个组件的实现。多个组件是路由的呢?在小程序中是靠全局配置文件 app.json 实现的: 其中: 上面就是我对小程序的玩法的理解了。说几个我开发中遇到的坑儿吧: 发现页的 Live 上有 2 种事件:点击头像会去用户详情页,其他位置会去 Live 详情页。事件绑定在组件上,触发事件就会执行逻辑层中对应的事件处理函数。想象一下,如果点击用户头像会发生什么: 由于小程序的事件封装,我们不能使用 e.stopPropagation () 这样的方式阻止事件冒泡,所以在用户这个 view 上不能绑定 bindtap,而是要用 catchtap。在前面展示 live.wxml 的时候,你可以没注意到这点,我简化下: 绑定到同一个事件函数上: 我没有使用额外的辅助工具开发,不能使用继承的方式用 Page: 这很不爽。你想啊,一些页面的 LoadMore、Onload 其实差不多。尤其是 7 天热门和 30 天热门这 2 个页面,不一样的只是调用了 api.getHotByWeekly 和 api.getHotByMonthly,重复代码是不是有点不能忍?仔细看一下 Page 其实是接受了一个字典,每个其中的 data 和其他生命周期函数以及自定义方法都是键值对的方式传进去。那么这有点简单了,我写个函数: 需要注意一点,onChangeTab 中对于 Tab 切换的时间的处理用到 wx.navigateBack,我觉得这也是小程序实现的一个不好的地方,我需要特殊处理「这种打开了页面 A(navigateTo),然后打开页面 B(navigateTo),再用 navigateTo 就不能回到 A 了」的问题,得用 navigateBack。 这样,调用的时候就简单了,比如 7 天的: 这是「知乎 Live 全文搜索的小程序」专题的最后一篇啦,感谢大家坚持阅读..数据绑定
{% raw %}{{ windowHeight }}{% endraw %}
的语法很亲切。对,相当于将变量包起来。
那么其中的 windowWidth、lives、windowHeight 这些是哪里来的呢?{% 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
Page({
data: {
lives: [],
start: 0,
limit: 20,
loading: false,
....
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,
});
},
});
},
const self = this;
这样的用法的原因:this 是一个特殊的对象,这个 this 对象是在运行时基于执行环境绑定的,即在全局对象中,this 指向的是 window 对象;在自定义函数中,this 对象指向的是调用这个函数的对象。常用的做法是在函数内赋值给一个私有的 self(叫 that 或者其他也没有关系),这样就能保证在外部调用的时候使用的是函数对象了。小程序怎么和后端交互
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,
...
};
组合这些页面
{
"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
}
小程序的一些经验
事件冒泡
{% 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
export default class Index extends Page {
data = {
userInfo: {}
};
bindViewTap () {
console.log('Button Clicked');
};
onLoad() {
console.log('OnLoad');
};
}
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`,
});
}
},
},
...
}
const lib = require('./lib.js');
Page(lib.gen_page('Week'));