作者:李大
主题
- 本文的目标在于简单介绍一下我们在开发小程序时的前端开发流程。
前端功能
- 前端的功能在于给数据提供一个合适的容器,并提供用户-界面-后端的交互支持。
- 据此,可以简单地把前端开发划分为
- UI实现
- 交互逻辑实现
- 后端接口对接
- 下文举例分别描述上面三个过程
UI实现
- 我们在实现UI的过程中使用先设计原型而后根据原型进行实现的方式
- 下面以beta阶段先加入的讨论区功能实现为例进行说明
- 使用墨刀进行原型设计,由PM完成
- 对照原型进行页面拆解
- 可以看到此页面可以分割为,顶部的提示语句,和中间的横向滚动卡片以及下面的输入框,只有中间可以滚动,上下都是固定的
- UI实现
- 接下来便挨个实现所有元素即可,原型上的各种参数(距离、大小、字号等等)都是可以测量
- 使用的工具基本就是微信小程序支持的一套布局逻辑语言
- 例如上面的图标根据测量值即可在css中定义样式为
.icon{
width:78rpx;
height:78rpx;
border-radius: 100%;
margin-left:26rpx;
margin-top:40rpx;
}
- 如法炮制,结合flex layout将卡片拆解为行、列,再在各级下分别完成布局调整,最终即可完成整个卡片的布局
- 经过细微设计调整,实现的效果、完整的xml和css如下
<view style="background:{{'#FAFAFA'}}">
<view class="dicussion_title" id="title_bar">这里是 {{club_name}} 的讨论区</view>
<view class='swiper_container' style='height: {{swiperHeight + "px"}}'>
<swiper indicator-dots="{{indicatorDots}}" autoplay="{{autoplay}}" circular="{{circular}}" vertical="{{vertical}}" interval="{{interval}}" duration="{{duration}}" style='width:100%;height:100%' current="{{question_id}}" next-margin='28rpx' previous-margin='28rpx' bindchange='onSlideChanged' >
<block wx:for="{{qa_list}}" wx:for-index="q_index">
<swiper-item>
<scroll-view scroll-y style='height: {{swiperHeight}}px' bindscroll="scroll" scroll-top='{{scrollTop}}' scroll-with-animation='{{true}}' scroll-into-view='{{scroll_into_view}}'>
<view class="qa_list" id='qa_list_border'>
<view class="qa_container">
<view>
<image src="{{qa_list[q_index].q_raiser_icon}}" class='icon'></image>
</view>
<view class="vertical_flex">
<view class="user_name_text">{{qa_list[q_index].q_raiser_name}}</view>
<view class="qa_text qa_text_base">{{qa_list[q_index].ques_text}}</view>
</view>
</view>
<view class="ques_time">
<view>
<image src="/images/icons/qa/question.png" class='q_icon'></image>
</view>
<view class="ques_time_text">提问于 {{qa_list[q_index].ques_time}}</view>
</view>
<view class="div_line_full"></view>
<view wx:if="{{qa_list[q_index].ans_list.length==0}}">
<view class="no_ans_text">暂时无人理会,你能帮帮TA吗?</view>
</view>
<view wx:for="{{qa_list[q_index].ans_list}}" wx:for-index="i" wx:for-item="ans">
<view class="qa_container" id="qa_mark">
<view>
<image src="{{ans.a_raiser_icon}}" class='icon'></image>
</view>
<view class="vertical_flex">
<view class="user">
<view class="user_name_text">{{ans.a_raiser_name}}</view>
<view style="margin-top:36rpx;margin-left:12rpx" wx:for="{{ans.special_tag}}" wx:for-item="tag">
<van-tag wx:if="{{tag == '置顶'}}" color="#F44336" size="club_tag">
<text class="tag-font">置顶</text>
</van-tag>
<van-tag wx:if="{{tag != '置顶'}}" color="#42A5F5" size="club_tag">
<text class="tag-font">{{tag}}</text>
</van-tag>
</view>
</view>
<view class="ans_time_text qa_text_base">{{ans.ans_time}}</view>
<view class="qa_text qa_text_base">{{ans.ans_text}}</view>
</view>
</view>
<view class="like" data-q_index='{{q_index}}' data-a_index='{{i}}' bindtap='likeBtnClicked'>
<view>
<image src="/images/icons/qa/liked.png" class='like_icon' wx:if="{{ans.liked}}"></image>
<image src="/images/icons/qa/like.png" class='like_icon' wx:if="{{!ans.liked}}"></image>
</view>
<view class='like_cnt'>{{ans.like}}</view>
</view>
<view class=" div_line " wx:if="{{i != qa_list[q_index].ans_list.length - 1}}"></view>
</view>
</view>
<view class="bottom_margin_large"></view>
</scroll-view>
</swiper-item>
</block>
</swiper>
</view>
<view class="commenter" id='comment_bar' style='bottom:{{commenter_position}}px'>
<view class="input_place">
<input placeholder="快来讨论这个问题吧,4~40字" value="{{ans_input}}" style='width:466rpx;margin-top:4rpx;' cursor-spacing="32rpx" bindinput='inputTyping' adjust-position="{{false}}" bindfocus="focused" bindblur="blurred"></input>
</view>
<view class="submit_btn" bindtap='submitAnswer' data-question_id='{{q_index}}'>发送</view>
</view>
</view>
/*commenter*/
.commenter{
position: fixed;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
align-items: center;
margin-right: 0rpx;
margin-top:0rpx;
margin-bottom:0rpx;
background-color:#FFFFFF;
border-top-color: #BBBBBB;
border-top-width: 2rpx;
border-top-style: solid;
}
.input_place{
margin-left: 16rpx;
width:530rpx;
height:64rpx;
border-radius: 64rpx;
font-size:28rpx;
display: flex;
flex-direction: row;
flex-wrap:wrap;
justify-content: flex-start;
align-items: center;
background-color: #EEEEEE;
color: #888484;
padding-left:32rpx;
border-style:solid;
border-width:16rpx;
border-color:#FFFFFF;
}
.submit_btn{
margin-left: 4rpx;
margin-right: 24rpx;
width: 120rpx;
height: 64rpx;
display: flex;
flex-direction: row;
flex-wrap:wrap;
justify-content: center;
align-items: center;
background-color: #F44336;
color: #FFFFFF;
font-size: 28rpx;
border-radius: 32rpx;
}
/*commenter end*/
.swiper_container{
overflow: hidden;
position: fixed;
width:100%;
background-color: #FAFAFA;
}
.icon{
width:78rpx;
height:78rpx;
border-radius: 100%;
margin-left:26rpx;
margin-top:40rpx;
}
.vertical_flex{
display: flex;
flex-direction: column;
justify-content: flex-start;
flex-wrap: nowrap;
}
.user_name_text{
font-size:30rpx;
font-weight: 500;
margin-top:45rpx;
margin-left:14rpx;
}
.qa_time{
margin-left:18rpx;
color:#AAAAAA;
font-size:18rpx;
}
.qa_list{
background: #fff;
margin-top:10rpx;
margin-left:12rpx;
margin-right:12rpx;
margin-bottom:20rpx;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 24rpx;
}
.border_backup{
border-radius: 16rpx;border-color: #BBBBBB;border-width: 2rpx;border-style: solid;
}
.first_qa_top_margin{
margin-top:20rpx;
}
.regular_top_margin{
margin-top:32rpx;
}
.dicussion_title{
display: flex;
justify-content: center;
font-size:26rpx;
color:#939090;
padding-top:26rpx;
font-weight: 400;
}
.qa_container{
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: flex-start;
}
.qa_text_base{
margin-left : 18rpx;
margin-right: 36rpx;
}
.qa_text{
margin-top:14rpx;
font-size: 26rpx;
font-weight: 500;
line-height: 36rpx;
}
.ques_time{
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-end;
align-items: center;
margin-right: 45rpx;
margin-top:28rpx;
margin-bottom:8rpx;
}
.q_icon{
margin-top:8rpx;
width: 40rpx;
height: 40rpx;
border-radius: 0%;
}
.ques_time_text{
margin-left:8rpx;
color:#888484;
font-size:22rpx;
}
.ans_time_text{
color:#888484;
font-size:18rpx;
}
.zero_ans_text{
margin-top:14rpx;
font-size: 24rpx;
line-height: 45rpx;
color: #BBBBBB;
}
.div_line_full{
height: 2rpx;
width: 100%;
background-color:#EEEEEE;
}
.div_line{
height: 2rpx;
width: 90%;
background-color:#EEEEEE;
margin-left: 5%;
}
.like{
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-end;
align-items: center;
margin-right:40rpx;
margin-bottom: 14rpx;
}
.like_cnt{
font-size: 30rpx;
color: #101010;
margin-left:6rpx;
}
.like_icon{
margin-top:4rpx;
width: 36rpx;
height: 36rpx;
border-radius: 0%;
}
.user{
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: center;
}
.tag-font{
font-size: 24rpx;
color: #FFFFFF;
}
.no_ans_text{
display: flex;
justify-content: center;
font-size:24rpx;
color:#BBBBBB;
margin-top:32rpx;
font-weight: 400;
margin-bottom: 24rpx;
}
::-webkit-scrollbar {
width: 0;
height: 0;
color: transparent;
}
.bottom_margin_large{
background-color: #FAFAFA;
width : 100%;
height : 80rpx;
}
- 交互逻辑实现
- 准确来说,交互逻辑的一部分是由组件提供的,并不是从头开始实现的,因此选择一个合适的组件往往能大量减少工作量。
- 原型设计中我们希望卡片是可以横向滚动的,卡片内部是可以上下滚动看到更多信息的,要实现这两个逻辑,翻阅了较多官方和第三方的组件库后,我们使用了swiper组件和scroll-view组件
- swiper组件是一个轮播图容器,容器中装了一个list,可以进行左右滑动,虽然一般都装的是图片,但经过一定设置是可以在其中填入我们上一部分中实现的卡片的。
- scroll-view定义了页面的可滚动区域,需要指定height作为滚动范围的长度
- 滚动条细节实现如下
<scroll-view scroll-y style='height: {{swiperHeight}}px' bindscroll="scroll" scroll-top='{{scrollTop}}' scroll-with-animation='{{true}}' scroll-into-view='{{scroll_into_view}}'>
</scroll-view>
- 可以看到height里引用了js里的变量,即我们使用了动态获取页面元素的大小计算出卡片的大小从而精确设置可滚动范围,对应的js函数如下
wx.getSystemInfo({
success: function (res) {
wx.createSelectorQuery().select('#title_bar').boundingClientRect(function (rect) {
var title_bar_bottom = rect.bottom
that.setData({
scrollHeight: res.windowHeight - title_bar_bottom,
swiperHeight: res.windowHeight - title_bar_bottom - comment_bar_height - 10
})
}).exec();
}
});
-
其他交互逻辑的实现也很类似,都是通过js和xml数据动态绑定设置实现
- 在此页面实现的逻辑如下
- swiper容器左右滑动时离开当前卡片时将当前卡片自动滚回顶部,通过scroll-top属性实现
- 点赞按钮按下时图标变黑,其后的数字+1,通过bindtap函数实现
- 用户点击输入框后输入框位移到键盘顶部,离开输入框后移回底部,通过动态获取键盘高度从而设置输入框离底部距离实现
- 用户评论后当前卡片自动滚动到此条评论,通过scroll-to-view动态寻找最新评论而后滚动实现
- 没有评论时提示“暂时无人理会”,通过渲染前确认对应数据列表长度是否为0实现
-
开发中笔者参考的组件库有WeUI,Vant,WuxUI,WussUI。每次需要实现新的交互逻辑时都先翻阅一下组件库寻找可以套用的交互模式,或者抽取多个组件的部分进行嵌套使用改造,最终拼凑实现出一个完整的功能。
- 下面再举一个较复杂的例子展示组件的嵌套使用
- 在管理员管理页面中,我们有这样的交互逻辑:
- 右下角悬浮一个+按钮,点击后从顶部弹出搜索框蒙层,在搜索框中搜索用户,进行管理员添加
- 截图如下
- 这样的逻辑中使用了popup,search-bar和一些布局的库元素,后续还将加入确认对话框(点击添加后弹出),是一个较复杂的嵌套布局了。xml部分如下,可以看到,wux库的popup组件抽象了顶部弹出逻辑,weui-searchbar抽取了搜索部分,最后绑定到悬浮按钮上通过Bindtap完成呼出逻辑。整体实现代。
<wux-popup position="top" visible="{{ pop_up_tab_visible }}" bind:close="pop_up_tab_set_invisible">
<view class="weui-search-bar ">
<view class="weui-search-bar__form">
<view class="weui-search-bar__box">
<icon class="weui-icon-search_in-box" type="search" size="14"></icon>
<input type="text" class="weui-search-bar__input" placeholder="输入用户id搜索" value="{{inputVal}}" focus="{{inputShowed}}" bindinput="inputTyping" />
<view class="weui-icon-clear" wx:if="{{inputVal.length > 0}}" bindtap="clearInput">
<icon type="clear" size="14"></icon>
</view>
</view>
<label class="weui-search-bar__label" hidden="{{inputShowed}}" bindtap="showInput">
<icon class="weui-icon-search" type="search" size="14"></icon>
<view class="weui-search-bar__text">输入用户id搜索</view>
</label>
</view>
<view class="weui-search-bar__cancel-btn" hidden="{{!inputShowed}}" bindtap="hideInput" style='font-size:30rpx;padding-top:5rpx'>取消</view>
</view>
<view class="weui-cells searchbar-result" style='border-radius:16rpx' wx:if="{{inputVal.length > 0}}">
<view class="weui-cells_after-title">
<block wx:for="{{search_result_list}}" wx:for-index="key">
<view class="weui-cells {{i==0? 'weui-cells_after-title' : ''}}" style='margin-top:0rpx;margin-bottom:0rpx;'>
<view class="weui-cell" style='background-color:#f6f6f6' >
<view class="weui-cell__hd" style="position: relative;margin-right: 20rpx;">
<image src="{{item.usr_icon}}" style="width: 60rpx; height: 60rpx; display: block; border-radius:50%;" bindtap='jumpToUserDetailFromSearchResult' data-usr_index="{{key}}" />
</view>
<view class="vertical_split">
<view class="weui-cell__bd" bindtap='jumpToUserDetailFromSearchResult' data-usr_index="{{key}}">
<view class="username" style="font-size: 26rpx;">{{item.name}}</view>
<view style="font-size: 18rpx;color: #888888;">学号:{{item.student_id}}</view>
</view>
<view class="btn remove_btn search_result_btn" data-usr_index="{{key}}" bindtap='added'>添加</view>
</view>
</view>
</view>
</block>
<view class="bottom_margin" style='background-color:#f6f6f6;height:16rpx' ></view>
</view>
</view>
</wux-popup>
</view>
- 最后,交互逻辑中还要定义页面跳转逻辑,即点击某个view后跳转到什么页面,要传递什么数据。接受页面要对应地解析传来的数据,在onLoad时完成初始化逻辑。
- 以社团页面中点击简略信息跳转到社团详情页的跳转逻辑为例:
//club_main.js
jumpToClubDetailDescription: function (e) {
var club_id = e.currentTarget.dataset.club_id
wx.navigateTo({
url: '/pages/club_detail/club_detail?club_id=' + club_id
})
},
//club_detail.js
onLoad: function(options) {
let that = this
var club_id = options.club_id
that.setData({
club_id:club_id
})
that.request_club_detail(that, club_id)
//省略许多其他与此处展示无关的初始化逻辑
}
后端接口对接
- 没有后端提供的数据,前端布局就是空壳。许多逻辑交互也是需要前后端配合的。
- 此部分逻辑就很简单纯粹了,无非就是根据前后端商议好的接口通过request进行数据请求,解析,绑定
- 由于我们有用户系统,我们首先将request进行了一层cookies封装,而后再进行具体接口的数据请求
- 例:获取用户关注的所有社团的简略信息列表后绑定到followed_club_list中,而后xml中对应地进行解析渲染
request_followed_club_list: function (that) {
util.$get('/clubs/followed')
.then((res) => {
var followed_club_list = res.data.data.clubs_list
for (var i = 0; i < followed_club_list.length; i++) {
followed_club_list[i].icon_url = baseImageServerUrl + followed_club_list[i].icon_url
}
that.setData({
followed_club_list: followed_club_list
})
})
},
- request封装如下
function baseRequest(params, method) {
let promise = new Promise((resolve, reject) => {
let url = params.url
let data = params.data
wx.request({
url: url.indexOf('http') !== -1 ? url : baseUrl + url,
data: data,
method: method,
header: {
'content-type': 'application/json',
'cookie': wx.getStorageSync('cookie')
},
success(res) {
if (res.data.success) {
resolve(res)
} else if (res.data.code === status.STATUS_CODE.ACCESS_NOT_LOGIN) {
wx.redirectTo({
url: '/pages/login/login?not_first=true',
})
} else {
resolve(res)
}
},
fail(res) {
reject(res)
}
})
})
return promise
}
function $get(url, data = '') {
let params = {
url: url,
data: data
}
return baseRequest(params, 'GET')
}
function $post(url, data) {
let params = {
url: url,
data: data
}
return baseRequest(params, 'POST')
}
function $put(url, data) {
let params = {
url: url,
data: data
}
return baseRequest(params, 'PUT')
}
function $delete(url, data = '') {
let params = {
url: url,
data: data
}
return baseRequest(params, 'DELETE')
}
module.exports = {
formatTime: formatTime,
$get: $get,
$post: $post,
$put: $put,
$delete: $delete
}