作者:李大
主題
- 本文的目標在於簡單介紹一下我們在開發小程序時的前端開發流程。
前端功能
- 前端的功能在於給數據提供一個合適的容器,並提供用戶-界面-后端的交互支持。
- 據此,可以簡單地把前端開發划分為
- 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
}