查看原文
其他

【第1551期】编写chameleon跨端组件的正确姿势

滴滴内部用户龚磊 前端早读课 2019-05-23

前言

今日早读文章由滴滴@龚磊投稿分享。

@龚磊,滴滴出行前端开发工程师,chameleon早期用户,热爱前端,关注前端工程化、跨端技术

正文从这开始~~

编写chameleon跨端组件的正确姿势(上篇)

在chameleon项目中我们实现一个跨端组件一般有两种思路:使用第三方组件封装与基于chameleon语法统一实现。本篇是编写chameleon跨端组件的正确姿势系列文章的上篇,以封装一个跨端的indexlist组件为例,首先介绍如何优雅的使用第三方库封装跨端组件,然后给出编写chameleon跨端组件的建议。使用chameleon语法统一实现跨端组件请关注本文下面的《编写chameleon跨端组件的正确姿势(下篇)》

依靠强大的多态协议,chameleon项目中可以轻松使用各端的第三方组件封装自己的跨端组件库。基于第三方组件可以利用现有生态迅速实现需求,但是却存在很多缺点,例如多端第三方组件本身的功能与样式差异、组件质量得不到保证以及绝大部分组件并不需要通过多态组件差异化实现,这样反而提升了长期的维护成本;使用chameleon语法统一实现则可以完美解决上述问题,并且扩展一个新的端时现有组件可以直接运行。本文的最后也会详细对比一下两种方案的优劣。

因此,建议将通过第三方库实现跨端组件库作为临时方案,从长期维护的角度来讲,建议开发者使用chameleon语法统一实现绝大部分跨端组件,只有一些特别复杂并且已有成熟第三方库或者框架能力暂时不支持的组件,才考虑使用第三方组件封装成对应的跨端组件。

由于本文上篇介绍的是使用第三方库封装跨端组件, 因此示例的indexlist组件采用第三方组件封装来实现, 通过chameleon统一实现跨端组件的方法可以看本文的下篇。

最终实现的indexlist效果图:

前期准备

使用各端第三方组件实现chameleon跨端组件需要如下前期准备:

项目初始化

创建一个新项目 cml-demo

cml init project

进入项目

cd cml-demo

组件设计

开发一个模块时我们首先应该根据功能确定其输入与输出,对应到组件开发上来说,就是要确定组件的属性和事件,其中属性表示组件接受的输入,而事件则表示组件在特定时机对外的输出。

为了方便说明,本例暂时实现一个具备基础功能的indexlist。一个indexlist组件至少应该在用户选择某一项时抛出一个onselect事件,传递用户当前所选中项的数据;至少应该接受一个datalist,作为其渲染的数据源,这个datalist应该是一个类似于以下结构的对象数组:

const dataList = [
{
name
: '阿里',
pinYin
: 'ali',
py
: 'al'
}, {
name
: '北京',
pinYin
: 'beijing',
py
: 'bj'
},
.....
]
寻找第三方组件库

由于本文介绍的是如何使用第三方库封装跨端组件,因此在确定组件需求以及实现思路后去寻找符合要求的第三方库。在开发之前,作者调研了目前较为流行的各端组件库,推荐如下:

  • web端:

    • cube-ui

    • vux

    • mint-ui

    • vant

  • wx端:

    • iview weapp

    • vant weapp

    • weui

  • weex端:

    • weex-ui


除了上述组件库之外,开发者也可以根据自己的实际需求去寻找经过包装之后符合预期的第三方库。截止文章编写时,作者未找到较成熟的支付宝及百度小程序第三方库,因此暂时先实现web、微信小程序以及weex端,这也体现出了使用第三方库扩展跨端组件的局限性:当没有成熟的对应端第三方库时,无法完成该端的组件开发;而使用chameleon语法统一实现则可以解决上述问题,扩展新的端时已有组件能够直接运行,无需额外扩展。 本文在实现indexlist组件时分别使用了cube-ui, iview weapp以及weex-ui, 以下会介绍具体的开发过程。

组件开发

初始化

创建多态组件

cml init component

选择“多态组件”, 并输入组件名字“indexlist”, 完成组件的创建, 创建之后的组件位于src/components/indexlist文件夹下。

接口校验

多态组件中的.interface文件利用接口校验语法对组件的属性和事件进行类型定义,保证各端的属性和事件一致。确定了组件的属性与事件之后就开始编写.interface文件, 修改src/components/indexlist/indexlist.interface:

type eventDetail = {
name
: String,
pinYin
: String,
py
: String
}
type arrayItem
= {
name
: String,
pinYin
: String,
py
: String
}
type arr
= [arrayItem];

interface IndexlistInterface {
dataList
: arr,
onselect
(eventDetail: eventDetail): void
}

具体的interface文件语法可以参考 《接口校验语法》(https://cmljs.org/doc/framework/polymorphism/check.html), 本文不再赘述。

web端组件开发

安装cube-ui

npm i cube-ui -S

在src/components/indexlist/indexlist.web.cml的json文件中引入cube-ui的indexlist组件

"base": {
"usingComponents": {
"cube-index-list": "cube-ui/src/components/index-list/index-list"
}
}

修改src/components/indexlist/indexlist.web.cml中的模板代码,引用cube-ui的indexlist组件:

<view class="index-list-wrapper">
<cube-index-list
:
data="list"
@
select="onItemSelect"
/>
</view>

修改src/components/indexlist/indexlist.web.cml中的js代码, 根据cube-ui文档将数据处理成符合其组件预期的结构, 并向上抛出onselect事件:

const words = ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"];

class Indexlist implements IndexlistInterface {
props
= {
dataList
: {
type
: Array,
default() {
return []
}
}
}

data
= {
list
: [],
}

methods
= {

initData
() {
const cityData = [];
words
.forEach((item, index) => {
cityData
[index] = {};
cityData
[index].items = [];
cityData
[index].name = item;
});
this.dataList.forEach((item) => {
let firstName = item.pinYin.substring(0, 1).toUpperCase();
let index = words.indexOf(firstName);
cityData
[index].items.push(item)
});
this.list = cityData;
},

onItemSelect
(item) {
this.$cmlEmit('onselect', item);
}
}

mounted
() {
this.initData();
}
}
export default new Indexlist();

编写必要的样式:

.index-list-wrapper {
width
: 750cpx;
height
: 1200cpx;
}

以上便使用cube-ui完成了web端indexlist组件的开发,效果如下:

weex端组件开发

安装weex-ui

npm i weex-ui -S

在src/components/indexlist/indexlist.weex.cml的json文件中引入weex-ui的wxc-indexlist组件:

"base": {
"usingComponents": {
"wex-indexlist": "weex-ui/packages/wxc-indexlist"
}
}

修改src/components/indexlist/indexlist.weex.cml中的模板代码,引用weex-ui的wxc-indexlist组件:

<view class="index-list-wrapper">
<wex-indexlist
:
normal-list="list"
@
wxcIndexlistItemClicked="onItemSelect"
/>
</view>

修改src/components/indexlist/indexlist.weex.cml中的js代码:


class Indexlist implements IndexlistInterface {
props
= {
dataList
: {
type
: Array,
default() {
return []
}
}
}
data
= {
list
: [],
}

mounted
() {
this.initData();
}

methods
= {
initData
() {
this.list = this.dataList;
},

onItemSelect
(e) {
this.$cmlEmit('onselect', e.item);
}
}
}
export default new Indexlist();

编写必要样式,此时发现weex端与web端有部分重复样式,因此将样式抽离出来创建indexlist.less,在web端与weex端的cml文件中引入该样式

<style lang="less">
@import './indexlist.less';
</style>

indexlist.less文件内容:

.index-list-wrapper {
width
: 750cpx;
height
: 1200cpx;
}

以上便使用weex-ui完成了weex端indexlist组件的开发,效果如下:

wx端组件编写

根据iview weapp文档, 首先到Github下载iview weapp代码,将dist目录拷贝到项目的src目录下,然后在src/components/indexlist/indexlist.wx.cml的json文件中引入iview的index与index-item组件:

"base": {
"usingComponents": {
"i-index":"/iview/index/index",
"i-index-item": "/iview/index-item/index"
}
},

修改src/components/indexlist/indexlist.wx.cml中的模板代码,引用iview的index与index-item组件:

<view class="index-list-wrapper">
<i-index
height="1200rpx"
>
<i-index-item
wx:for="{{cities}}"
wx:for-index="index"
wx:key="{{index}}"
wx:for-item="item"
name="{{item.key}}"
>
<view
class="index-list-item"
wx:for="{{item.list}}"
wx:for-index="in"
wx:key="{{in}}"
wx:for-item="it"
c-bind:tap="onItemSelect(it)"
>
<text>{{it.name}}</text>
</view>
</i-index-item>
</i-index>
</view>

修改src/components/indexlist/indexlist.wx.cml中的js代码, 根据iview weapp文档将数据处理成符合其组件预期的结构, 并向上抛出onselect事件:

const words = ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"];

class Indexlist implements IndexlistInterface {
props
= {
dataList
: {
type
: Array,
default() {
return []
}
}
}

data
= {
cities
: []
}

methods
= {
initData
() {
let storeCity = new Array(26);
words
.forEach((item,index)=>{
storeCity
[index] = {
key
: item,
list
: []
};
});
this.dataList.forEach((item)=>{
let firstName = item.pinYin.substring(0,1).toUpperCase();
let index = words.indexOf(firstName);
storeCity
[index].list.push(item);
});
this.cities = storeCity;
},
onItemSelect
(item) {
this.$cmlEmit('onselect', item);
}
}

mounted
() {
this.initData();
}

}

export default new Indexlist();

编写必要样式:

@import 'indexlist.less';
.index-list {
&-item {
height
: 90cpx;
padding
-left: 20cpx;
justify
-content: center;
border
-bottom: 1cpx solid #F7F7F7
}
}

以上便使用iview weapp完成了wx端indexlist组件的开发, 效果如下:

组件使用

修改src/pages/index/index.cml文件里面的json配置,引用创建的indexlist组件

"base": {
"usingComponents": {
"indexlist": "/components/indexlist/indexlist"
}
},

修改src/pages/index/index.cml文件中的模板部分,引用创建的indexlist组件

<view class="page-wrapper">
<indexlist
dataList="{{dataList}}"
c-bind:onselect="onItemSelect"
/>
</view>

其中dataList是一个对象数组,表示组件要渲染的数据源。具体结构为:

const dataList = [
{
name
: '阿里',
pinYin
: 'ali',
py
: 'al'
}, {
name
: '北京',
pinYin
: 'beijing',
py
: 'bj'
},
.....
]
开发总结

根据上述例子可以看出,chameleon项目可以轻松结合第三方库封装自己的跨端组件库。使用第三方组件封装跨端组件库的步骤大致如下:

  • 跨端组件设计

  • 根据实际需求引入合适的第三方组件

  • 根据第三方组件文档,将数据处理成符合预期的结构,并在适当时机抛出事件

  • 编写必要样式

一些思考

根据组件多态文档, 像indexlist.web.cml、indexlist.wx.cml与indexlist.weex.cml的这些文件是灰度区, 它们是唯一可以调用下层端能力的CML文件,这里的下层端能力既包含下层端组件,例如在web端和weex端的.vue文件等;也包含下层端的api,例如微信小程序的wx.pageScrollTo等。这一层的存在是为了调用下层端代码,各端具体的逻辑实现应该在下层来实现, 这种规范的好处是显而易见的: 随着业务复杂度的提升,各个下层端维护的功能逐渐变多,其中通用的部分又可以通过普通cml文件抽离出来被统一调用,这样可以保证差异化部分始终是最小集合,灰度区是存粹的;如果将业务逻辑都放在了灰度区,随着功能复杂度的上升,三端通用功能/组件就无法达到合理的抽象,导致灰度层既有相同功能,又有差异化部分,这显然不是开发者愿意看到的场景。

在灰度区的模板、逻辑、样式和json文件中分别具有如下规则:

模板

调用下层组件时,既可以使用chameleon语法,也可以使用各端原生语法;在灰度区chameleon编译器不会编译各个端原生语法,例如v-for,bindtap等。建议在模板部分仍然使用chameleon模板语法,只有在实现对应平台不支持的语法(例如web端v-html等)时才使用原生语法。

引用下层全局组件时需要添加origin-前缀,这样可以“告诉”chameleon编译器是在引用下层的原生组件,chameleon编译器就不会对其进行处理了。这种做法同时解决了组件命名冲突问题,例如在微信小程序端引用<origin-button>表示调用小程序原生的button组件而不是chameleon内置的button组件。

逻辑

在script逻辑代码中,除了编写普通cml逻辑代码之外,开发者还可以使用下层端的全局变量和任意方法,包括生命周期函数。这种机制保证开发者可以灵活扩展各端特有功能,而不需要依赖多态接口。

样式

既可以使用cmss语法也可以使用下层端的css语法。

json文件
  • *web.cml:base.usingComponents可以引入普通cml组件和任意.vue扩展名组件。

  • *wx.cml:base.usingComponents可以引入普通cml组件和普通微信小程序组件。

  • *weex.cml:base.usingComponents可以引入普通cml组件和任意.vue扩展名组件。


在各端对应的灰度区文件中均可以根据上述规范使用各端的原生语法,但是为了规范仍然建议使用chameleon体系的语法规则。总体来说,灰度区可以认为是chameleon体系与各端原生组件/方法的衔接点,向下使用各端功能/组件,向上通过多态协议提供各端统一的调用接口。

编写chameleon跨端组件的正确姿势(下篇)

在chameleon项目中我们实现一个跨端组件一般有两种思路:1.使用第三方组件封装 2.基于chameleon语法统一实现。

在《编写chameleon跨端组件的正确姿势(上篇)》中, 我们介绍了如何使用第三方库封装跨端组件,但是绝大多数组件并不需要那样差异化实现,绝大多数情况下我们推荐使用chameleon语法统一实现跨端组件。本篇是编写chameleon跨端组件的正确姿势系列文章的下篇,与上篇给出的示例相同,本篇也以封装一个跨端的indexlist组件为例,首先介绍如何使用chameleon语法统一实现一个跨端组件,然后对比两种组件开发方式并给出开发建议。

最终效果

以下效果依次为weex端、web端、支付宝小程序端、微信小程序端以及百度小程序端:



开发

项目初始化

创建一个新项目 cml-demo

cml init project

进入项目

cd cml-demo
组件创建
cml init component

选择“普通组件”, 并输入组件名字“indexlist”, 完成组件的创建, 创建之后的组件位于src/components/indexlist文件夹下。

组件设计

为了方便说明,本例暂时实现一个具备基础功能的indexlist组件。从功能方面讲,indexlist组件主要由两部分组成,主列表区域和索引区域。在用户点击组件右侧索引时,主列表能够快速定位到对应区域;在用户滑动组件主列表时,右侧索引跟随滑动不停切换当前索引项。从输入输出方面讲,组件至少应该在用户选择某一项时抛出一个onselect事件,传递用户当前所选中项的数据;至少应该接受一个datalist,作为其渲染的数据源,这个datalist应该是一个类似于以下结构的对象数组:

const dataList = [
{
name
: '阿里',
pinYin
: 'ali',
}, {
name
: '北京',
pinYin
: 'beijing',
},
.....
]

主要数据结构设计

根据设计的组件功能与输入输出, 我们开始设计数据结构。

indexlist组件右侧的索引列对应的数据结构为一个数组,其中的每一项表示一个索引,具体结构如下:

this.shortcut = [ 'A', 'B', 'C', ....]

indexlist组件的主列表区域对应的数据结构也是一个数组,其中的每一项表示一个子列表区域(例如以首字母a开头的子列表)。下面我们考虑每一个子列表区域中至少应该包含的字段:

  • 一个name字段,表示该子列表区域的名称;

  • 一个items字段,该字段也是一个数组,数组中的每一项表示该子列表区域的每一项;

  • 一个offsetTop, 表示该子列表区域距离主列表顶部的距离,通过该字段实现点击右侧索引时能够通过滚动相应距离快速定位到该子列表;

  • 一个totalHeight字段,表示该子列表区域的所占的高度,通过该字段与offsetTop字段可以确定每个子列表所在的高度范围, 以此实现右侧索引跟随滑动不停切换当前索引项


由上面分析可得主列表区域数据结构如下:

this.list = [
{
name
: "B",
items
:[
{
name
: "北京",
pinYin
: "beijing"
},
{
name
: "包头",
pinYin
: "baotou"
}
...
],
offsetTop
: 190,
totalHeight
: 490
},
....
]

功能实现

从前文可知,输入组件的datalist具有如下结构:

const dataList = [
{
name
: '阿里',
pinYin
: 'ali',
}, {
name
: '北京',
pinYin
: 'beijing',
},
.....
]

可以发现该datalist结构是扁平并且缺乏很多信息(例如totalHeight等)的,因此首先要从输入数据中整理出来所需的数据结构,修改src/components/indexlist/indexlist.cml的js部分:

initData() {
// get shortcut
this.dataList.forEach(item => {
if (item.pinYin) {
let firstName = item.pinYin.substring(0, 1);
if (item.pinYin && this.shortcut.indexOf(firstName.toUpperCase()) === -1) {
this.shortcut.push(firstName.toUpperCase());
};
};
});

// handle input data
const cityData = this.shortcut.map(item => ({items:[], name: item}));
this.dataList.forEach((item) => {
let firstName = item.pinYin.substring(0, 1).toUpperCase();
let index = this.shortcut.indexOf(firstName);
cityData
[index].items.push(item);
});

// calculate item offsetTop && totalHeight
cityData
.forEach((item, index) => {
let arr = cityData.slice(0, index);
item
.totalHeight = this.itemNameHeight + item.items.length * this.itemContentHeight;
item
.offsetTop = arr.reduce((total, cur) => (total + this.itemNameHeight + cur.items.length * this.itemContentHeight), 0);
});
this.list = cityData;
},

这样我们就拿到了主列表数组this.list与索引列表数组this.shortcut, 然后根据数组结构编写模板内容。模板内容分为两大部分,一个是主列表区域,修改src/components/indexlist/indexlist.cml文件模板部分:

<scroller
height="{{-1}}"
class="index-list-wrapper"
scroll-top="{{offsetTop}}"
c-bind:onscroll="handleScroll"
>
<view
c-for="{{list}}"
c-for-item="listitem"
class="index-list-item"
>
<view class="index-list-item-name" style="{{compItemNameHeight}}">
<text class="index-list-item-name-text">{{listitem.name}}</text>
</view>
<view
c-for="{{listitem.items}}"
c-for-item="subitem"
class="index-list-item-content"
style="{{compItemContentHeight}}"
c-bind:tap="handleSelect(subitem)"
>
<text class="index-list-item-content-text"> {{subitem.name}}</text>
</view>
</view>
</scroller>

其中scroller是一个chameleon提供的内置滚动组件,其属性值scrolltop表示当前滚动的距离,onscroll表示滚动时触发的事件。在主列表这一部分,我们要实现如下功能:

  • 在滚动时,右侧索引不停切换当前索引项的功能

  • 点击列表中的每一项时,向外抛出onselect事件


修改src/components/indexlist/indexlist.cml文件js部分:

handleScroll(e) {
let { scrollTop } = e.detail;
scrollTop
= Math.ceil(scrollTop);
this.activeIndex = this.list.findIndex(item => scrollTop >= item.offsetTop && scrollTop < item.totalHeight + item.offsetTop )
},
handleSelect
(e) {
this.$cmlEmit('onselect', e)
}

当前激活的索引(this.activeIndex)经过计算得到,规则为:如果当前scroller滚动的距离在对应子列表所在的高度范围内,则认为该索引是激活的。

另一部分是索引区域,修改src/components/indexlist/indexlist.cml文件模板部分,增加索引区域模板内容:

<view
class="short-cut-wrapper"
style="{{compScwStyle}}"
>
<view
c-for="{{shortcut}}"
class="short-cut-item"
c-bind:tap="scrollToItem(item)"
>
<text class="short-cut-item-text" style="{{activeIndex === index ? 'color:orange' : ''}}">{{item}}</text>
</view>
</view>

在索引区域,我们要实现点击索引值主列表能够快速定位到对应区域,修改src/components/indexlist/indexlist.cml文件js部分:

scrollToItem(shortcut) {
let { offsetTop } = this.list.find(item => item.name === shortcut);
this.offsetTop = offsetTop;
}

索引区域应该定位在视窗右侧并且上下居中。由于chameleon暂时不支持在css中使用百分比,因此我们通过chameleon-api提供的对外接口获取屏幕视窗高度,然后使用js计算得到位置, 配合部分css来实现索引区域定位在视窗右侧居中。修改src/components/indexlist/indexlist.cml文件js部分:


// computed
compScwStyle
() {
return `top:${this.viewportHeight / 2}cpx`
}

// method
async getViewportHeight
() {
let res = await cml.getSystemInfo();
this.viewportHeight = res.viewportHeight;
},

至此便通过chameleon语法统一实现了一个跨端indexlist组件,该组件直接可以在web、weex、微信小程序、支付宝小程序与百度小程序五个端运行。为了方便描述,上述代码只是简单介绍了组件实现的核心代码,跳过了样式和一些逻辑细节,完整代码可以在 https://github.com/GongLLei/cml-indexlist 查看。以下是web与weex端的在线预览地址,其余小程序端可以下载代码运行dist目录下的对应代码查看效果。

组件使用

修改src/pages/index/index.cml文件里面的json配置,引用创建的indexlist组件

"base": {
"usingComponents": {
"indexlist": "/components/indexlist/indexlist"
}
},

修改src/pages/index/index.cml文件中的模板部分,引用创建的indexlist组件

<view class="page-wrapper">
<indexlist
dataList="{{dataList}}"
c-bind:onselect="onItemSelect"
/>
</view>

其中dataList是一个对象数组,表示组件要渲染的数据源

一些思考

本篇文章主要介绍了如何通过chameleon语法实现跨端组件。对比本文上篇介绍的通过第三方库封装的方法可以发现,两种方式是完全不同的,现详细对比一下这两种实现方式的优势与劣势, 并给出开发建议:

基于第三方库实现

优势

可利用已有生态迅速完成跨端组件

劣势
  • 组件的实现依赖第三方库,如果没有成熟的对应端第三方库则无法完成该端组件开发

  • 由于各端第三方组件存在差异,封装的跨端组件样式与功能存在差异

  • 第三方组件升级时,要对应调整跨端组件的实现,维护成本较大

  • 第三方组件库质量不能得到保证

开发建议

将基于各端第三方组件封装跨端组件库的方法作为临时方案

对于特别复杂并且已有成熟第三方库或者框架能力暂时不支持的组件,可以考虑使用第三方组件封装成对应的跨端组件,例如图表组件、地图组件等等

基于chameleon统一实现

优势
  • 新的端接入时,能够直接运行

  • 一般情况下,不存在各端样式与功能差异

  • 绝大部分组件不需要各端差异化实现,使用chameleon语法实现开发与维护成本更低

  • 能够导出原生组件供多端使用

劣势
  • 从零搭建时间与技术成本较高

  • 开发建议

  • 从长期维护的角度来讲,建议使用chameleon生态来统一实现跨端组件库

  • 如果仅仅是各端api层面的不同,建议使用多态接口抹平差异,而不使用多态组件


关于本文
作者:@龚磊
原文:https://mp.weixin.qq.com/s/N-d-icYWqMBmOXQCw8U4Pg

最后,为你推荐


【第1521期】Chameleon跨端框架—一个理想主义团队的开源作品

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存