相信做過微信小程序的都知道,官方給出的微信web開發工具上根本就無法加載node_modules包,即使可以加載,node_modules動輒幾十M的大小,小程序的代碼限制在1M以內,微信小程序的三個不足:
1無法調用npm包
2無法使用babel轉嗎
3無法重用組件(像react那樣重用組件功能)
接下來給大家介紹一個相對完整的微信開發解決方案:
Labrador:(目前最新版本為:0.6.12)
github地址:https://github.com/maichong/labrador
特點:
1,使用Labrador框架可以使微信開發者工具支持加載海量NPM包
2,支持ES6/7標準代碼,使用async/await能夠有效避免回調地獄
3,組件重用,對微信小程序框架進行了二次封裝,實現了組件重用和嵌套
4,自動化測試,非常容易編寫單元測試腳本,不經任何額外配置即可自動化測試
5,使用Editor Config及ESLint標準化代碼風格,方便團隊協作
當然了也有缺點,你看完會發現缺點
首先系統全局安裝nodejs和Labrador命令行工具。
npminstall -g labrador-cli
查看當前labrador版本
labrador -V
新建一個目錄,初始化項目
labrador create mylabrador# 初始化labrador項目 mylabrador是你的項目名字
用Egret Wing3(這個IDE更適合開發微信小程序),打開labradordemo這個項目,
開啟代碼自動轉換功能
labrador watch
然后用微信開發著工具打開labradordemo項目下面的dist文件
這個里面不需要做任何的編碼工作,在下面的src目錄作修改,會自動同步到微信開發者工具上面
在src/pages/index新增一個index.json文件,主要內容為設置頁面的title
{
"navigationBarTitleText": "主頁",
"enablePullDownRefresh": false
}
然后保存,會同步到微信開發者工具
labrador庫對全局的wx變量進行了封裝,將所有wx對象中的異步方法進行了Promise支持, 除了同步的方法,這些方法往往以on*、create*、stop*、pause*、close*開頭或以*Sync結尾。在如下代碼中使用labrador庫。
importwx, { Component, PropTypes }from'labrador';
wx.wx;// 原始的全局 wx 對象
wx.app;// 和全局的 getApp() 函數效果一樣,代碼風格不建議粗暴地訪問全局對象和方法
wx.currentPages// 對全局函數 getCurrentPages() 優雅的封裝
Component;// Labrador 自定義組件基類
PropTypes;// Labrador 數據類型校驗器集合
wx.login;// 封裝后的微信登錄接口
wx.getStorage;// 封裝后的讀取緩存接口
//... 更多請參見 https://mp.weixin.qq.com/debug/wxadoc/dev/api/
我們建議不要再使用wx.getStorageSync()等同步阻塞方法,而在async函數中使用await wx.getStorage()異步非阻塞方法提高性能,除非遇到特殊情況。
app.js文件
import request from 'al-request';
import { setStore } from 'labrador-redux';
import { sleep } from './utils/utils';
import store from './redux';
if (__DEV__) {
console.log('當前為開發環境');
}
// 向labrador-redux注冊store
setStore(store);
export default class {
async onLaunch() {
try {
await sleep(100);
await request('api/start');
} catch (error) {
console.error(error);
}
this.timer();
}
async timer() {
while (true) {
console.log('hello');
await sleep(10000);
}
}
}
代碼中全部使用ES6/7標準語法。代碼不必聲明use strict,因為在編譯時,所有代碼都會強制使用嚴格模式。
代碼中并未調用全局的App()方法,而是使用export語法默認導出了一個類,在編譯后,Labrador會自動增加App()方法調用,所有請勿手動調用App()方法。這樣做是因為代碼風格不建議粗暴地訪問全局對象和方法。
Labrador的自定義組件,是基于微信小程序框架的組件之上,進一步自定義組合,擁有邏輯處理、布局和樣式。
項目中通用自定義組件存放在src/compontents目錄,一個組件一般由三個文件組成,*.js、*.xml和*.less分別對應微信小程序框架的js、wxml和wxss文件。在Labardor項目源碼中,我們特意采用了xml和less后綴以示區別。如果組件包含單元測試,那么在組件目錄下會存在一個*.test.js的測試腳本文件。
0.6 版本后,支持*.sass和*.scss格式樣式文件。
自定義組件示例
下面是一個簡單的自定義組件代碼實例:
邏輯src/compontents/todo/todo.js
import { Component, PropTypes } from 'labrador-immutable';
const { string, bool, func } = PropTypes;
class Todo extends Component {
static propTypes = {
id: string,
title: string,
createdAt: string,
finished: bool,
finishedAt: string,
onRemove: func,
onRestore: func,
onFinish: func
};
constructor(props) {
super(props);
this.state = {
icon: props.finished ? 'success_circle' : 'circle',
className: props.finished ? 'todo-finished' : ''
};
}
onUpdate(props) {
this.setState({
icon: props.finished ? 'success_circle' : 'circle',
className: props.finished ? 'todo-finished' : ''
});
}
handleRemove() {
this.props.onRemove(this.props.id);
}
handleFinish() {
if (this.props.finished) {
this.props.onRestore(this.props.id);
} else {
this.props.onFinish(this.props.id);
}
}
}
export default Todo;
自定義組件的邏輯代碼和微信框架中的page很相似,最大的區別是在js邏輯代碼中,沒有調用全局的Page()函數聲明頁面,而是用export語法導出了一個默認的類,這個類必須繼承于Component組件基類。
相對于微信框架中的page,Labrador自定義組件擴展了propTypes、defaultProps、onUpdate()、setState()、children()等方法和屬性,children()方法返回當前組件中的子組件集合,此選項將在下文中敘述。
Labrador的目標是構建一個可以重用、嵌套的自定義組件方案,在現實情況中,當多個組件互相嵌套組合,就一定會遇到父子組件件的數據和消息傳遞。因為所有的組件都實現了setState方法,所以我們可以使用this._children.foobar.setState(data)或this.parent.setState(data)這樣的代碼調用來解決父子組件間的數據傳遞問題,但是,如果項目中出現大量這樣的代碼,那么數據流將變得非常混亂。
我們借鑒了 React.js 的思想,為組件增加了 props 機制。子組件通過this.props得到父組件給自己傳達的參數數據。父組件怎樣將數據傳遞給子組件,我們下文中敘述。
onUpdate生命周期函數是當組件的props發生變化后被調用,類似React.js中的componentWillReceiveProps所以我們可以在此函數體內監測props的變化。
組件定義時的propTypes靜態屬性是對當前組件的props參數數據類型的定義。defaultProps選項代表的是當前組件默認的各項參數值。propTypes、defaultProps選項都可以省略,但是強烈建議定義propTypes,因為這樣可以使得代碼更清晰易懂,另外還可以通過Labrador自動檢測props值類型,以減少BUG。為優化性能,只有在開發環境下才會自動檢測props值類型。
編譯時默認是開發環境,當編譯時候采用-m參數才會是生產模式,在代碼中任何地方都可以使用魔術變量__DEV__來判斷是否是開發環境。
組件向模板傳值需要調用setState方法,換言之,組件模板能夠讀取到當前組件的所有內部狀態數據。
0.6版本后,Component基類中撤銷了setData方法,新增了setState方法,這樣做并不是僅僅為了像React.js,而是在老版本中,我們將所有組件樹的內部狀態數據和props全存放在page.data中,在組件更新時產生了大量的setData遞歸調用,為了優化性能,必須將組件樹的狀態和page.data進行了分離。
布局src/compontents/todo/todo.xml
{{props.title}}
刪除
XML布局文件和微信WXML文件語法完全一致,只是擴充了兩個自定義標簽和,下文中詳細敘述。
使用{{}}綁定變量時,以props.*或state.*開頭,即XML模板文件能夠訪問組件對象的props和state。
樣式src/compontents/todo/todo.less
@import 'al-ui';
.todo {
background: #fff;
font-size: @font-size-medium;
}
.todo-icon {
margin-right: 10px;
}
.todo-finished {
background: @color-page;
}
.todo-finished-title {
.gray;
text-decoration: line-through;
}
雖然我們采用了LESS文件,但是由于微信小程序框架的限制,不能使用LESS的層級選擇及嵌套語法。但是我們可以使用LESS的變量、mixin、函數等功能方便開發。
頁面
我們要求所有的頁面必須存放在pages目錄中,每個頁面的子目錄中的文件格式和自定義組件一致,只是可以多出一個*.json配置文件。
頁面示例
下面是默認首頁的示例代碼:
邏輯src/pages/index/index.js
import wx, { Component, PropTypes } from 'labrador-immutable';
import { bindActionCreators } from 'redux';
import { connect } from 'labrador-redux';
import Todo from '../../components/todo/todo';
import * as todoActions from '../../redux/todos';
import { sleep } from '../../utils/utils';
const { array, func } = PropTypes;
class Index extends Component {
static propTypes = {
todos: array,
removeTodo: func,
restoreTodo: func,
createTodo: func,
finishTodo: func
};
state = {
titleInput: '',
finished: 0
};
children() {
let todos = this.props.todos || [];
let unfinished = [];
let finished = [];
if (todos.length) {
unfinished = todos.filter((todo) => !todo.finished);
finished = todos.asMutable()
.filter((todo) => todo.finished)
.sort((a, b) => (a.finishedAt < b.finishedAt ? 1 : -1))
.slice(0, 3);
}
return {
list: unfinished.map((todo) => ({
component: Todo,
key: todo.id,
props: {
...todo,
onRemove: this.handleRemove,
onRestore: this.handleRestore,
onFinish: this.handleFinish
}
})),
finished: finished.map((todo) => ({
component: Todo,
key: todo.id,
props: {
...todo,
onRemove: this.handleRemove,
onRestore: this.handleRestore,
onFinish: this.handleFinish
}
}))
};
}
onUpdate(props) {
let nextState = {
finished: 0
};
props.todos.forEach((todo) => {
if (todo.finished) {
nextState.finished += 1;
}
});
this.setState(nextState);
}
async onPullDownRefresh() {
await sleep(1000);
wx.showToast({ title: '刷新成功' });
wx.stopPullDownRefresh();
}
handleCreate() {
let title = this.state.titleInput;
if (!title) {
wx.showToast({ title: '請輸入任務' });
return;
}
this.props.createTodo({ title });
this.setState({ titleInput: '' });
}
handleInput(e) {
this.setState({ titleInput: e.detail.value });
}
handleRemove = (id) => {
this.props.removeTodo(id);
};
handleFinish = (id) => {
this.props.finishTodo(id);
};
handleRestore = (id) => {
this.props.restoreTodo(id);
};
handleShowFinished() {
wx.navigateTo({ url: 'finished' });
}
handleShowUI() {
wx.navigateTo({ url: '/pages/ui/index' });
}
}
export default connect(
({ todos }) => ({ todos }),
(dispatch) => bindActionCreators({
createTodo: todoActions.create,
removeTodo: todoActions.remove,
finishTodo: todoActions.finish,
restoreTodo: todoActions.restore,
}, dispatch)
)(Index);
頁面代碼的格式和自定義組件的格式一模一樣,我們的思想是頁面也是組件。
js邏輯代碼中同樣使用export default語句導出了一個默認類,也不能手動調用Page()方法,因為在編譯后,pages目錄下的所有js文件全部會自動調用Page()方法聲明頁面。
我們看到組件類中,有一個對象方法children(),這個方法返回了該組件依賴、包含的其他自定義組件,在上面的代碼中頁面包含了三個自定義組件list、title和counter,這個三個自定義組件的key分別為list、motto和counter。
children()返回的每個組件的定義都包含兩個屬性,component屬性定義了組件類,props屬性定義了父組件向子組件傳入的props屬性對象。
頁面也是組件,所有的組件都擁有一樣的生命周期函數onLoad, onReady, onShow, onHide, onUnload,onUpdate 以及setState函數。
componets和pages兩個目錄的區別在于,componets中存放的組件能夠被智能加載、重用,pages目錄中的組件在編譯時自動加上Page()調用,所以,pages目錄中的組件不能被其他組件調用,否則將出現多次調用Page()的錯誤。如果某個組件需要重用,請存放在componets目錄或打包成NPM包。
注意雖然頁面也是組件,雖然頁面的代碼格式和組件一模一樣,但是運行時,getCurrentPages()得到的頁面對象page并非pages目錄中聲明的頁面對象,page.root才是pages目錄中聲明的頁面對象,才是組件樹的最頂端。這里我們用了組合模式而非繼承模式。
注意所有組件的生命周期函數支持async,但默認是普通函數,如果函數體內沒有異步操作,我們建議采用普通函數,因為async函數會有一定的性能開銷,并且無法保證執行順序。當聲明周期函數內需要異步操作,并且【不關心】各個生命周期函數的執行順序時,可以采用async函數。
布局src/pages/index/index.xml
已完成
3}}" class="padding-h-xxlarge padding-top-large">
查看全部已完成
總數 {{props.todos.length}} 已完成
{{state.finished}}
當前沒有任務
請在下方輸入框中填入新任務然后點擊新增
bindinput="handleInput"/>
新增
Powered by Labrador
AL UI
XML布局代碼中,使用了Labrador提供的標簽,此標簽的作用是導入一個自定義子組件的布局文件,標簽有兩個屬性,分別為key(必選)和name(可選,默認為key的值)。key與js邏輯代碼中的組件key對應,name是組件的目錄名。key用來綁定組件JS邏輯對象的children中對應的數據,name用于在src/componets和node_modules目錄中尋找子組件模板。
樣式src/pages/index/index.less
@import 'al-ui';
@import 'todo';
.todo-list {
}
LESS樣式文件中,我們使用了@import語句加載所有子組件樣式,這里的@import 'list'語句按照LESS的語法,會首先尋找當前目錄src/pages/index/中的list.less文件,如果找不到就會按照Labrador的規則智能地嘗試尋找src/componets和node_modules目錄中的組件樣式。
接下來,我們定義了.motto-title-text樣式,這樣做是因為mottokey 代表的title組件的模板中(src/compontents/title/title.xml)有一個view 屬于title-text類,編譯時,Labrador將自動為其增加一個前綴motto-,所以編譯后這個view所屬的類為title-text motto-title-text(可以查看dist/pages/index/index.xml)。那么我們就可以在父組件的樣式代碼中使用.motto-title-text來重新定義子組件的樣式。
Labrador支持多層組件嵌套,在上述的實例中,index包含子組件list和title,list包含子組件title,所以在最終顯示時,index頁面上回顯示兩個title組件。
自定義組件列表
邏輯src/components/list/list.js
importwx, { Component }from'labrador';
importTitlefrom'../title/title';
importItemfrom'../item/item';
import{ sleep }from'../../utils/util';
exportdefaultclassListextendsComponent{
constructor(props){
super(props);
this.state={
items:[
{ id:1, title:'Labrador'},
{ id:2, title:'Alaska'}
]
};
}
children(){
return{
title:{
component:Title,
props:{ text:'The List Title'}
},
listItems:this.state.items.map((item)=>{
return{
component:Item,
key:item.id,
props:{
item:item,
title:item.title,
isNew:item.isNew,
onChange:(title)=>{this.handleChange(item, title) }
}
};
})
};
}
asynconLoad() {
awaitsleep(1000);
this.setState({
items:[{ id:3, title:'Collie', isNew:true}].concat(this.data.items)
});
}
handleChange(item, title) {
letitems=this.state.items.map((i)=>{
if(item.id==i.id){
returnObject.assign({},i,{ title });
}
returni;
});
this.setState({ items });
}
}
在上邊代碼中的children()返回的listItems子組件定義時,是一個組件數組。數組的每一項都是一個子組件的定義,并且需要指定每一項的key屬性,key屬性將用于模板渲染性能優化,建議將唯一且不易變化的值設置為子組件的key,比如上邊例子中的id。
模板src/components/list/list.xml
在XML模板中,調用標簽即可自動渲染子組件列表。和標簽類似,同樣也有兩個屬性,key和name。Labrador編譯后,會自動將標簽編譯成wx:for循環。
自動化測試
我們規定項目中所有后綴為*.test.js的文件為測試腳本文件。每一個測試腳本文件對應一個待測試的JS模塊文件。例如src/utils/util.js和src/utils/utils.test.js。這樣,項目中所有模塊和其測試文件就全部存放在一起,方便查找和模塊劃分。這樣規劃主要是受到了GO語言的啟發,也符合微信小程序一貫的目錄結構風格。
在編譯時,加上-t參數即可自動調用測試腳本完成項目測試,如果不加-t參數,則所有測試腳本不會被編譯到dist目錄,所以不必擔心項目會肥胖。
普通JS模塊測試
測試腳本中使用export語句導出多個名稱以test*開頭的函數,這些函數在運行后會被逐個調用完成測試。如果test測試函數在運行時拋出異常,則視為測試失敗,例如代碼:
// src/util.js
// 普通項目模塊文件中的代碼片段,導出了一個通用的add函數
exportfunctionadd(a, b) {
returna+b;
}
// src/util.test.js
// 測試腳本文件代碼片段
importassertfrom'assert';
//測試 util.add() 函數
exportfunctiontestAdd(exports) {
assert(exports.add(1,1)===2);
}
代碼中testAdd即為一個test測試函數,專門用來測試add()函數,在test函數執行時,會將目標模塊作為參數傳進來,即會將util.js中的exports傳進來。
自定義組件測試
自定義組件的測試腳本中可以導出兩類測試函數。第三類和普通測試腳本一樣,也為test*函數,但是參數不是exports而是運行中的、實例化后的組件對象。那么我們就可以在test函數中調用組件的方法或則訪問組件的props和state屬性,來測試行為。另外,普通模塊測試腳本是啟動后就開始逐個運行test*函數,而組件測試腳本是當組件onReady以后才會開始測試。
自定義組件的第二類測試函數是以on*開頭,和組件的生命周期函數名稱一模一樣,這一類測試函數不是等到組件onReady以后開始運行,而是當組件生命周期函數運行時被觸發。函數接收兩個參數,第一個為組件的對象引用,第二個為run函數。比如某個組件有一個onLoad測試函數,那么當組件將要運行onLoad生命周期函數時,先觸發onLoad測試函數,在測試函數內部調用run()函數,繼續執行組件的生命周期函數,run()函數返回的數據就是生命周期函數返回的數據,如果返回的是Promise,則代表生命周期函數是一個異步函數,測試函數也可以寫為async異步函數,等待生命周期函數結束。這樣我們就可以獲取run()前后兩個狀態數據,最后對比,來測試生命周期函數的運行是否正確。
第三類測試函數與生命周期測試函數類似,是以handle*開頭,用以測試事件處理函數是否正確,是在對應事件發生時運行測試。例如:
// src/components/counter/counter.test.js
exportfunctionhandleTap(c, run) {
letnum=c.data.num;
run();
letstep=c.data.num-num;
if(step!==1) {
thrownewError('計數器點擊一次應該自增1,但是自增了'+step);
}
}
生命周期測試函數和事件測試函數只會執行一次,自動化測試的結果將會輸出到Console控制臺。
項目配置文件
labrador create命令在初始化項目時,會在項目根目錄中創建一個.labrador項目配置文件,如果你的項目是使用 labrador-cli 0.3 版本創建的,可以手動增加此文件。
配置文件為JSON5格式,默認配置為:
{
"define":{
"API_ROOT":"http://localhost:5000/"
},
"npmMap":{
"lodash-es":"lodash"
},
"uglify":{
"mangle": [],
"compress": {
"warnings":false
}
},
"classNames": {
"text-red":true
},
"env":{
"development": {},
"production": {
"define":{
"API_ROOT":"https://your.online.domain/"
}
}
}
}