简介
本我的项目的一个教育培训服务 APP。提供在线浏览机构信息、名师风采和课程预约订购等性能。
我的项目前端应用了 avm.js
多端开发技术,可同时编译为 Android
& iOS
App
以及微信小程序;后端应用 APICloud
数据云 3.0 云函数自定义接口。
技术要点
本我的项目在开发过程中,在“能拆就拆”的思维下,对我的项目进行细粒度的组件化拆解。能够从中理解到组件拆分逻辑和一些操作技巧,对自定义组件进行坚固。
效果图
![上传中 …]()
源码目录构造介绍
我的项目源码在 github 仓库的 widget
目录下。残缺源码请查看:https://github.com/apicloudco…
其中该目录下的文件构造如下:
┌─component/ // 我的项目公共组件目录
│ ├─img/ // 组件专用素材
│ ├─a-card.stml // [根底组件]卡片组件
│ ├─a-cell.stml // [根底组件]单元格组件
│ ├─a-cell-group.stml // [根底组件]单元格容器组件
│ ├─a-header.stml // [根底组件]头部导航组件
│ ├─a-section.stml // [根底组件]章节组件
│ ├─a-tab.stml // [根底组件]选项卡组件
│ ├─a-tabs.stml // [根底组件]选项卡容器组件
│ ├─b-course.stml // [业务组件]课程详情组件
│ ├─b-notice.stml // [业务组件]揭示面板组件
│ ├─c-course-list.stml // [组合组件]课程列表页
├─images/ // 图片素材图标资源目录
├─pages/ // 新版的 AVM 页面目录
│ ├─course-detail/
│ │ └─course-detail.stml // 课程详情页
│ ├─course-list/
│ │ └─course-list.stml // 课程列表页
│ ├─course-pay/
│ │ └─course-pay.stml // 购买课程页
│ ├─course-preorder/
│ │ └─course-preorder.stml // 预约课程页
│ ├─order-detail/
│ │ └─order-detail.stml // 用户订单详情页
│ ├─order-list/
│ │ └─order-list.stml // 用户订单列表页
│ ├─pay-result/
│ │ └─pay-result.stml // 下单 (领取) 后果页
│ ├─play-video/
│ │ └─play-video.stml // 视频播放页
│ ├─preorder-detail/
│ │ └─preorder-detail.stml // 用户预约详情页
│ ├─preorder-list/
│ │ └─preorder-list.stml // 用户预约列表页
│ ├─tab-home/
│ │ └─tab-home.stml // tab 页 -0 入口主页
│ ├─tab-course/
│ │ └─tab-course.stml // tab 页 -1 课程分类列表
│ ├─tab-user/
│ │ └─tab-user.stml // tab 页 -2 用户主页
├─script/ // JavaScript 脚本目录
│ ├─UserManager.js // 用户数据管理类
│ └─req.js // 我的项目申请交互文件
└─config.xml // 利用配置文件
开发详情
TabBar 的组织
如果有留意到 APICloud
官网的 github
上前几个模板我的项目源码的同学们曾经对 TabBar
的实现很相熟了。
通过在我的项目根目录下定义 app.json
来创立一个 Tabbar
的主页构造。在这个文件中,能够定义一些主页构造的具体参数。包含每个 Tab
页面的门路、名称和底部导航图标资源信息。
如果须要适配小程序的原生 Tabbar
构造,这将是最佳的抉择。如果我的项目没有小程序的适配打算,也能够应用原有的 FrameGroup
来更加深度自定义相干切换行为和逻辑。
网络申请 req.js
个别我的项目的数据都是须要和服务器进行通信取得。通过本地申请库拿到相干数据并进行解决和渲染到界面。为了对立不便进行申请(会话、缓存和异样等),将在 req.js
中来解决相应的逻辑。具体封装形式和实现能够依据集体团队爱好或者是接口通信规定来开发,本我的项目中的逻辑仅供参考。
tab 页 -0 入口主页
主页构造非常简单,分为四个局部。
- 头部导航栏
- 头部轮播图
- 中部分类滑动栏
- 下部名师卡片滑动栏
- 底部
对于咱们
富文本
自定义组件:a-header 头部导航栏
头部的导航栏很容易被忽视。这里咱们自定义了一个 a-header
根底组件。具体实现是在 components/a-header.stml
中。
在这个组件中,template
局部定义了具体的 UI
构造。
<template>
(isApp() &&
<safe-area class="a-header">
...
</safe-area>
)
</template>
其中 (condition && <tag />)
这种书写形式能够用来实现 v-if
的成果。condition
就是一个布尔值,具体求值是来自一个函数。因为小程序和 WEB
中不须要这个头部,只有 APP
端才须要渲染。能够在相干函数中定义具体的渲染根据,实现“条件渲染”的成果。
a-header
组件的职责就是为了显示头部导航栏,最重要的因素就是“题目”文本、左右侧的按钮和事件。
通过自定义参数 title
和 leftIcon
等传入相干配置。进而在模板中获值渲染。还有一些场景参数,通过对 leftIcon
的不同类型和取值来实现是否须要返回按钮的实现。
在组件的办法中有两个点击事件:
methods: {onClickLeft()
{if (this.props.onClickLeft) {this.props.onClickLeft();
} else {api.closeWin();
}
}
,
onClickRight()
{this.props.onClickRight && this.props.onClickRight();
}
}
这两个办法用于响应头部导航栏的左右点击。左侧点击有一个外部判断:看看是否有传入自定义事件,有的话,执行传入的事件;反之,执行默认逻辑:敞开窗口。
右侧点击的事件没有默认逻辑,只须要判断是否自定义右侧逻辑即可。
头部幻灯片
头部导航的图片信息来自网络申请数据:
function getHomeData() {GET('i_alls/home').then(data => {
this.data.homeData = data;
api.setPrefs({key: 'course_category', value: data.course_category});
})
}
获取到数据当前应用一个 swiper
组件来展现这个轮播图:
<swiper autoplay circular class="main__swiper" style="margin: 10px 0;"
v-if="homeData.banners">
<swiper-item v-for="(item,index) in homeData.banners" class="main__swiper--item">
![](item.cover)
</swiper-item>
</swiper> <swiper autoplay circular class="main__swiper" style="margin: 10px 0;"
v-if="homeData.banners">
<swiper-item v-for="(item,index) in homeData.banners" class="main__swiper--item">
<img :src="item.cover" class="main__swiper--img"/>
</swiper-item>
</swiper>
应用能够滚动的 scroll-view
组件来渲染分类菜单和名师团队两处构造:
<scroll-view class="main__menu" scroll-x v-if="homeData.course_category"
:style="'height:'+(api.winWidth/4+20)+'px;'">
<view class="main__menu--item" v-for="item in homeData.course_category" @click="goto(item)"
:style="'width:'+api.winWidth/4+'px;'">
![](item.image)
<text class="main__menu--item-text">{{item.name}}</text>
</view>
</scroll-view>
<a-section title="名师团队" v-if="homeData.teacher_teams" class="main__teachers">
<scroll-view class="main__teacher" scroll-x>
<view class="main__teacher--item" v-for="item in homeData.teacher_teams" @click="test">
![](item.thumb)
<text class="main__teacher--item-name">{{item.name}}</text>
<text class="main__teacher--item-introduction">{{item.introduction}}</text>
</view>
</scroll-view>
</a-section><scroll-view class="main__menu" scroll-x v-if="homeData.course_category"
:style="'height:'+(api.winWidth/4+20)+'px;'">
<view class="main__menu--item" v-for="item in homeData.course_category" @click="goto(item)"
:style="'width:'+api.winWidth/4+'px;'">
<img :src="item.image" class="main__menu--item-img"/>
<text class="main__menu--item-text">{{item.name}}</text>
</view>
</scroll-view>
<a-section title="名师团队" v-if="homeData.teacher_teams" class="main__teachers">
<scroll-view class="main__teacher" scroll-x>
<view class="main__teacher--item" v-for="item in homeData.teacher_teams" @click="test">
<img :src="item.thumb" class="main__teacher--item-img"/>
<text class="main__teacher--item-name">{{item.name}}</text>
<text class="main__teacher--item-introduction">{{item.introduction}}</text>
</view>
</scroll-view>
</a-section>
自定义组件:a-section 章节组件
通过整体扫视我的项目设计稿,发现我的项目中存在大量的反复元素,来强化章节局部的概念。因为这类反复呈现且行为对立的局部,就须要思考整顿演绎成组件。演绎进去便于保护和进步代码复用性,在结构复杂的大型项目下显得尤为无效。
<template>
extendsClassStyleEvents.call(this,
<view class="a-section">
{this.props.title &&
<view class="a-section__header">
<view class="a-section__header--solid"></view>
<text class="a-section__header--text">{this.props.title}</text>
</view>
}
<view class="a-section__content">
{this.props.children}
</view>
</view>
)
</template>
实际上模板也能够应用自定义函数来包裹起来,来实现一些自定义行为解决。例如下面的 extendsClassStyleEvents
:
/**
* 继承父组件的 class、style 和 on 事件
* @param VNode
* @returns {*}
*/
function extendsClassStyleEvents(VNode) {this.props.class && (VNode.attributes.class += ' ' + this.props.class);
this.props.style && (VNode.attributes.style = this.props.style);
Object.values(this.props)
.filter(item => typeof item === 'function' && item.name.startsWith('on'))
.forEach(ev => VNode.attributes[ev.name] = ev);
return VNode;
}
值得一提的是,该组件中应用了一个 {this.props.children}
的片段,也能够看做是传值的一种,但这个值不是来自于属性,而是 双标签
的外部内容,适宜传递模板类参数。
“对于咱们”的富文本渲染
首页上的“对于咱们”这一节是应用的富文本组件:rich-text
。应用办法很简略,传入 nodes
节点即可。
<a-section title="对于咱们" v-if="homeData.aboutus" class="main__about">
<text class="main__about--text">{{homeData.aboutus[0].value }}</text>
</a-section>
tab 页 -1 课程列表
页面级复用 c-course-list
通过察看我的项目,存在两个高度类似的页面:tab-1
的课程列表和从 tab-0
首页点击分类进去的页面是很类似的构造。甚至能够看做是同一页面的不同场景关上。在之前的 APICloud 1.0
版本中,咱们能够间接应用同一个 html
文件来关上不同的页面。然而在以后我的项目下,有一个页面是作为主页 TabBar
页面之一来关上的,只写一个页面不能实现。
这时候,又确实遇到页面统一的状况,间接复制文件必定是能够实现的。然而须要保护两处,必定是不够优雅的。
所以能够思考将整个页面提取进去到组件 c-course-list
中,而后别离在不同的路由页面中去调用即可。
自定义可切换的 tab 栏
在设计 tab
组件的时候,咱们能够先将应用的构造模仿进去,相当于是做一个组件结构设计草图:
<a-tabs>
<a-tab title="tab 名称 1"></a-tab>
<a-tab title="tab 名称 2"></a-tab>
<a-tab title="tab 名称 3"></a-tab>
</a-tabs>
而后再去创立一个组件文件,去具体实现。这个组件的非凡之处在于:应用了两层组件渲染。a-tabs
作为一个组件容器,用于接管参数,解决数据散发等。每一个 tab
页是自定义一个 a-tab
子页来接管具体页面内容,和定义页面名称(title
)。
上面是 a-tabs
界面模板局部:
<template>
extendsClassStyleEvents.call(this,
<view class="a-tabs">
<view class={mixedClass('a-tabs__nav',{'a-tabs__nav-scroll':this.scrollNav})}
style="flex-flow: row nowrap;justify-content: space-around;height: 44px;align-items: center;flex-shrink: 0;">
...// 顶部导航
<view class="a-tabs__nav--line" style={this.lineStyle}></view>
</view>
<swiper autoplay={false} circular={false} :current={this.props.param.current}
onchange={this.handleSwiperChange} class="a-tabs__content">
<swiper-item v-for="tab in this.props.children">
<scroll-view scroll-y="true" class="a-tabs__content--scroll-view">
{tab}
</scroll-view>
</swiper-item>
</swiper>
</view>
)
</template>
同样地,应用了 extendsClassStyleEvents
来继承事件、款式和 class
。留神到第二个 view
标签的 class
用一个 mixedClass
函数来示意的。
/**
* 混合 class 类
* @param cls
* @param extra
* @returns {string}
*/
function mixedClass(cls, extra) {let classList = [cls];
Object.entries(extra)
.forEach(([key, val]) => val && classList.push(key));
return classList.join(' ');
}
该函数是用来解决不同的条件所须要渲染不同的 class
的。当然,也能够在模板中写三元表达式来实现。
a-tabs
的顶部栏目局部须要渲染出该组件内有多少个子级 a-tab
,用来渲染子级的 title
。并为其绑定点击事件 handleNavClick
,实现点击切换 tab
:
<view class={mixedClass('a-tabs__nav--item',{'a-tabs__nav--item-scroll':this.scrollNav})}
onClick={this.handleNavClick.bind(this,index)}
v-for="(item,index) in this.props.children">
<text :class="'a-tabs__nav-text'
+ (index===this.props.param.current?'a-tabs__nav-text---active':'')">
{item.attributes.title}
</text>
</view>
底部则对应应用一个 swiper
组件来解决具体页面的展现。swiper
切换的时候,也就是扭转了以后 tab
的页面显示,须要将其通过事件 handleSwiperChange
反馈到业务中,实现数据状态同步。
<swiper autoplay={false} circular={false} :current={this.props.param.current}
onchange={this.handleSwiperChange} class="a-tabs__content">
<swiper-item v-for="tab in this.props.children">
<scroll-view scroll-y="true" class="a-tabs__content--scroll-view">
{tab}
</scroll-view>
</swiper-item>
</swiper>
tab-2 用户主页
用户主页构造也很简略:
- 用户信息面板
- 用户操作菜单
用户数据处理
下面的用户信息面板是应用了一个 view
联合 v-if
判断用户信息是否登录:
<view class="user-panel" v-if="userData" @click="logout">
![](../../images/icon__tab--user-1.png)
<text class="user-name">{{userData.name}}</text>
</view>
<view class="user-panel" v-else @click="doLogin">
![](../../images/icon__tab--user-0.png)
<text class="user-name"> 请登录 </text>
</view><view class="user-panel" v-if="userData" @click="logout">
<img src="../../images/icon__tab--user-1.png" alt=""class="user-avatar">
<text class="user-name">{{userData.name}}</text>
</view>
<view class="user-panel" v-else @click="doLogin">
<img src="../../images/icon__tab--user-0.png" alt=""class="user-avatar">
<text class="user-name"> 请登录 </text>
</view>
而 userData
来自于数据域中:
this.UM = new UserManager();
this.data.userData = this.UM.data;
代码中的 UserManager
是一个用户数据管理类。通过封装数据存储的模式来对立治理用户数据行为。
export default class UserManager {
userDataKey = 'USER-DATA';
get data() {
const userData = api.getPrefs({
key: this.userDataKey,
sync: true
})
if (userData) {return JSON.parse(userData);
}
return null;
}
set data(value) {
api.setPrefs({
key: this.userDataKey,
value
})
}
logout() {
api.removePrefs({key: this.userDataKey});
return this._data;
}
}
另外,也能够应用 Object.defineProperty
来实现一个数据拦挡,保留到本地偏好数据。更进一步还能够应用Proxy
和 Reflect
来实现观察者模式,配合播送事件等,让用户数据更加智能。
用户菜单 cell 单元格组件
上面的用户菜单是十分常见的一个单元格构造。
<a-cell title="我的预约" value="好的" link="preorder-list"
imgIcon="../../images/icon__user-cell--alarm.png"/>
<a-cell title="我的线上课订单"
link="../order-list/order-list.stml"
imgIcon="../../images/icon__user-cell--order.png"/>
单元格肩负项目名称、我的项目值、图标和点击跳转等性能。
<template>
extendsClassStyleEvents.call(this,
<view class={mixedClass('a-cell__root',{['a-cell__root--type-'+(this.props.type||'default')]:true})}
onclick={this.handleCellClick}>
<view class="a-cell">
{this.props.imgIcon&&![]({this.props.imgIcon} class=)}
<view class={mixedClass('a-cell__main',{['a-cell__main--type-'+(this.props.type||'default')]:true})}>
<text class="a-cell__title--text-title">{this.props.title}</text>
<text class="a-cell__title--text-value">{this.props.value}</text>
</view>
{this.props.link&&![](../../components/img/icon__a-cell-arrow-right.png)}
</view>
{this.props.children.length!==0 &&
<view class="a-cell__content">
{this.props.children}
</view>
}
</view>
)
</template><template>
extendsClassStyleEvents.call(this,
<view class={mixedClass('a-cell__root',{['a-cell__root--type-'+(this.props.type||'default')]:true})}
onclick={this.handleCellClick}>
<view class="a-cell">
{this.props.imgIcon&&<img src={this.props.imgIcon} class="a-cell__icon--img"/>}
<view class={mixedClass('a-cell__main',{['a-cell__main--type-'+(this.props.type||'default')]:true})}>
<text class="a-cell__title--text-title">{this.props.title}</text>
<text class="a-cell__title--text-value">{this.props.value}</text>
</view>
{this.props.link&&<img src="../../components/img/icon__a-cell-arrow-right.png" class="a-cell__link--arrow"/>}
</view>
{this.props.children.length!==0 &&
<view class="a-cell__content">
{this.props.children}
</view>
}
</view>
)
</template>
在模板中,有大量的条件判断:
- 依据
this.props.imgIcon
的有无,来决定是否渲染我的项目图标; - 依据
this.props.link
的有无,来决定是否渲染右侧的链接小箭头; - 依据
this.props.children.length
的子级长度来判断是否须要子容器来显示外部内容。
同时,为其绑定一个 handleCellClick
点击事件来解决 this.props.link
传来的跳转路由行为:
function handleCellClick(ev) {if (this.props.link) {let options = {};
if (typeof this.props.link === 'string') {if (this.props.link.endsWith('.stml')) {options.name = this.props.link.split('/').pop().replace('.stml', '');
options.url = this.props.link;
} else {
options.name = this.props.link;
options.url = `../${this.props.link}/${this.props.link}.stml`;
}
} else {options = this.props.link;}
console.log(['a-cell:link', JSON.stringify(options)]);
api.openWin(options);
} else if (this.props.onClick) {this.props.onClick(ev);
}
}
这段代码别离解决了 this.props.link
的取值的各种状况。能让其反对残缺的同 api.openWin
的对象参数。也能够反对带有 stml
的自定义门路参数。大多状况下,听从我的项目构造标准,页面都在 pages
下的二级目录中,所以还能够简化成一个字符串:让他既作为页面名称,也能够作为门路寻址参数,进步组件应用便捷性。
其余的二级页面
// TODO 其余的二级页面和主页的组件和构造组织形式大同小异。具体能够参考我的项目源代码进行学习钻研。
总结和反馈
本我的项目更多的是为大家展示了
- 组件的高级应用办法:诸如应用条件渲染、引入自定义函数对模板节点进行解决和继承,以及非凡节点
children
等应用。 - 组件的设计流程:例如实现
a-tabs
,对于简单的组件能够先定义应用外观,而后反向填充细节逻辑。 - 组件的设计准则:多出反复的页面构造就须要思考提炼和演绎。设计进去的组件须要易用、简洁。否则会因为组件而导致了解沟通应用成本增加。