入门与进阶示例演示

入门与进阶示例演示

来源 https://zhuanlan.zhihu.com/p/43717813

本文适合两类人阅读: 一类是没有做过小程序开发,但是想了解小程序整个开发过程及环境和开发中需要注意哪些问题的人,你适合读本文的入门篇,可以帮你节省至少几天的时间。

另一类就是写过小程序,但是想对小程序更深入的了解,并想对你的小程序进行一定程度上的优化的人,那你更适合阅读本文进阶篇,本文给出了一些优化的方向及方法,可供参考。

好了,现在让我们来开启小程序的前生今世探险之旅吧。先看一下目录:

(一) 入门篇
a) 运行环境
b) 开发姿势
c) 一个DEMO
d) 特有的脾气
e) 踩过的坑
(二) 进阶篇
a )原理
b) 性能优化
c) 新的写码姿势
d) 页面间通信

Part1 入门篇

运行环境

一图胜千言,我这就不多废话,先上一个图,来讲明下整个运行的环境流程。

入门与进阶示例演示

总结:小程序前端代码是统一上传到微信服务器,用户访问小程序时,微信客户端自动会去拉取小程序前端所有代码,小程序代码里再调用API从服务器取回数据,并把数据渲染到页面,然后展示给用户。

开发姿势

1. 准备阶段

入门与进阶示例演示

准备阶段流程

注册小程序管理员帐号地址如下:点击进入

关联对应的公众号,可以关联也可以不关联,主要是看业务需求。

关于小程序和公众号的区别,首先他们在管理端登录的平台地址是不一样的,其次可以理解为都是属于微信平台的一个应用,这两个应用可以设置关联,前提是注册公众号与小程序的主体信息(即身份信息)需一致。关联后可以在公众号里引导跳转到小程序,小程序与公众号就成为了一套登录体系。

小程序的名字不可和非同主体的公众号名字一样。

2. 开发体验阶段

入门与进阶示例演示

开发体验示意图

只有在小程序管理后台设置为开发者权限的用户,才可以扫码访问开发版本小程序,同理体验版也只有设置为体验者权限的用户才可以扫码访问体验版小程序。

开发版可以有多个,即一个开发就是提交一个开发版,互不冲突。但是体验版只有一个,即从众多开发版里设置一个版本做为体验版。

3. 完成阶段

入门与进阶示例演示

完成阶段示意图

只有小程序完成发布上线,全体微信用户才可以访问。发布上线是管理员在微信小程序管理后台从体验版或是众多提交的开发版里选一个提交审核成为现网版。

4. 维护&升级阶段

在小程序管理后台可以把当前现网的版本随时回退到老版本,也可以随时挂小程序暂停公告。

小程序每次发布一个新的版本后,当用户访问小程序时,依然访问的是老版本(微信客户端会异步去下载新版本),当小程序生存周期结束后再启动小程序时,就会访问最新版小程序。基础库1.9.9以后,也可以用强制升级接口进行强制升级并启动。接口名为:wx.getUpdateManager(),只在现网版生效。

备注:
当小程序进入后台,客户端会维持一段时间的运行状态,超过一定时间后(目前是5分钟)会被微信主动销毁。
当短时间内(5s)连续收到两次以上收到系统内存告警,会进行小程序的销毁。

一个DEMO

我这里以一个最简单的Demo让大伙快速的了解整个小程序的开发方式,以及编码规则和组成小程序的各个部件说明。至于具体详细的教程,我想小程序官网写得非常详细了,我这里就不再复述。官网详细教程地址为:点击进入

1. 创建一个小程序

在微信开发者工具上创建一个新的项目,填写上你在微信管理端申请的小程序的APPID,界面如下:

入门与进阶示例演示

小程序创建窗口

入门与进阶示例演示

界面注释

2. 文件结构

入门与进阶示例演示

2.1 每个小程序页面都是由四个文件组件(json, wxml, wxss,js)。

2.2 app.js 为整个小程序的入口文件,app.json为整个小程序的全局配置文件,wxss为全局样式文件。

2.3 project.config.json 为项目配置文件

2.4 pages/ 下面为具体的页了,比如index, 代表首页,其由四个文件组件, json文件为可选文件,可有可无。

2.5 utils/目录下存放的是自己写的公共JS

2.6 lib/目录下存入引入外部的一些公共JS文件

2.7 behaviors/ 存放的是小程序自定义的行为JS

2.8 components/ 存放的是小程序自定义的组件

2.9 img/ 存放的是一些ICO图片

3. 生命周期

入门与进阶示例演示

3.1 js为入口文件,每个页面都会经过该页,其onLauch触发条件为第一次冷启动后执行一次,onShow的触发条件为点击退出小程序按钮,然后在没有被回收时,又从任务栏呼起小程序时。由上图知道每个小程序的所有页面都会在第一次启动时全部加载。


3.2 每个页面的路由都需要在app.json里定义,否则找不到该路由。如下:

{
  "pages":[
    "pages/index/index",
    "pages/logs/logs"

  ],
  "window":{
    "backgroundTextStyle":"light",
    "navigationBarBackgroundColor": "#fff",
    "navigationBarTitleText": "WeChat",
    "navigationBarTextStyle":"black"
  },
  "tabBar": {
    "color": "#858585",
    "selectedColor": "#000000",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页",
        "iconPath": "/img/tab_home.png",
        "selectedIconPath": "/img/tab_home-active.png"
      },
      {
        "pagePath": "pages/logs/logs",
        "text": "日志",
        "iconPath": "/img/tab_category.png",
        "selectedIconPath": "/img/tab_category-active.png"
      }
      ]
    }
}

其中pages 就是路由的相关配置; window为小程序窗口风格相关的配置;tabBar为底部导航栏的配置。


3.3 Page页面的onload为第一次加载这个页面时执行,onshow为每次从后台又重新回到前台时会被调用。onReady为整个页面初次渲染完后执行。

3.4 小程序启动方式:冷启动,热启动
a) 小程序初次启动时微信客户端会把小程序整个代码下载到手机里,并访问小程序首页,假如用户已经打开过某小程序,然后在一定时间内再次打开该小程序,此时无需重新启动,只需将后台态的小程序切换到前台,这个过程就是热启动。


b) 冷启动指的是用户首次打开或小程序被微信主动销毁后再次打开的情况,此时小程序需要重新加载启动。(主要场景有:小程序被回收后,小程序被主动销毁后,小程序扫码进入时等)

3.5 一个页的基本代码写法,如下为index.js

Page({

  /**
   * 页面的初始数据
   */
  data: {
    name:’hello word’
  },

  /**
   * 生命周期函数--监听页面加载
   */
  onLoad: function (options) {
  
  },

  /**
   * 生命周期函数--监听页面初次渲染完成
   */
  onReady: function () {
  
  },

  /**
   * 生命周期函数--监听页面显示
   */
  onShow: function () {
  
  },

  /**
   * 生命周期函数--监听页面隐藏
   */
  onHide: function () {
  
  },

  /**
   * 生命周期函数--监听页面卸载
   */
  onUnload: function () { 
  
  },

  /**   * 页面相关事件处理函数--监听用户下拉动作
   */
  onPullDownRefresh: function () {
  
  },

  /**
   * 页面上拉触底事件的处理函数
   */
  onReachBottom: function () {
  
  },

  /**
   * 用户点击右上角分享
   */
  onShareAppMessage: function () {
  
  },
  fun:function () {
   setData(“name”:”这是一个传说”);
 }

})

4. 微信原生API

微信原生API主要是开放微信的原生能力,提供一些H5没有的能力,有网络类,媒体类,文件操作类,数据存储类,位置获取类,设备信息类,界面等接口。接口地址

5. 组件

a). 组件包括两类,一类为微信官网定义好的组件,其中只有<canvas/> <video/> <map/> <textarea/>几个组件为原生渲染,其它组件都是为webview渲染,具体使用方式详见官方教程。

另一类就是开发者自己定义的组件,我这里主要是重点讲下开发者如何自定组件,以及组件与引用他的父页如何做数据交换。

b). 定义一个组件

我们在开发中,总会有这样的场景就是有一个功能包括界面,逻辑在多个地方都需要反复使用到,比如我们开发的是商城,每个商品用户点击购买时,会弹出一个选择规格,尺寸,颜色的层,这个层基本在好多页面和购买行为处都需要使用。

如果不把其写成一个组件,意味着要冗余N份一样的代码。这时候我们就可以把JS+视图抽出来成为一个组件。

定义一个组件:

Component({
  data: { //组件用到的渲染数据,名称不可和properties下的名称相同
  },
  properties: { //对外开放的属性
    isShow: Boolean,
    btns: {
      value:‘‘,
      type: String,
      observer:getinfo
    },
    title: String,
   },
  methods: { //所有的响应方法放在此处
    getinfo: function (newVal, oldVal) {//newVal为新设置的数据,oldVal为老数据 

    }
  }
})

组件里的properties是对外开放的属性,每个属性有三个字段来标明:type表示属性类型,value 表示属性初始值、 observer 表示属性值被更改时的响应函数。

data里的变量用于渲染组件,其实和properties的调用是一样的,都是用http://this.data.xxx来调用,组件获取到父页面传递进来的数据,就可以像页面操作data数据一样的操作了。

c). 父页面传数据到组件

引用页面即父页面比如为home.wxml,数据传递到子页面就是通过properties开放出来的字段传递到组件页,引用时传递,包括开放出来的事件。

  • home.json

需要先在Json配置文件里声明引用的组件

{
  "navigationBarBackgroundColor": "#ffffff",
  "navigationBarTextStyle": "black",
  "navigationBarTitleText": "购物车",
  "backgroundColor": "#ffffff",
  "backgroundTextStyle": "light",
  "usingComponents": {
    "com-test": "/components/a"
  }
}
  • home.js

父页面里获取需要传递给组件的值,通过this.setData渲染到页面,页面再传递给组件。

// pages/home/home.js
Page({

  /**
   * 页面的初始数据
   */
  data: {
    
  },

  /**
   * 生命周期函数--监听页面加载
   */
  onLoad: function (options) {
    this.setData(
      {isShow:true,btns:[], title:‘hello world‘}
    );
  },

  /**
   * 生命周期函数--监听页面初次渲染完成
   */
  onReady: function () {
  
  },

  /**
   * 生命周期函数--监听页面显示
   */
  onShow: function () {
  
  },

  /**
   * 生命周期函数--监听页面隐藏
   */
  onHide: function () {
  
  },

  /**
   * 生命周期函数--监听页面卸载
   */
  onUnload: function () {
  
  },
})
  • home.wxml

用声明里的组件名,开始引入给组件,其中isshow, btns,title都为组件开放出来的数据。

<com-test isShow="{{isShow}}" btns="{{listinfo}}" title="{{title}}" />

d). 组件传数据到父页面

这里推荐使用event的发布,订阅模式来把数据传递给父页面。对于event不熟悉可以参阅进阶篇里的”页面间通信”这一节。

先在父页home.js订阅一个事件, 下面只给出部分代码:

/**
   * 生命周期函数--监听页面初次渲染完成
   */
  onReady: function () {
    getApp().evt.on("info", setinfo);
  },

  setinfo:function (e){
    console.log(e);
  }

即当收到Info这个事件有发布时,就执行setinfo()函数,处理想着数据,setinfo函数为home.js里的一个方法,其入参e,就是发布事件时所传递的参数。

然后在组件里处理完后,发布事件,代码如下:

Component({
  data: { //组件用到的渲染数据,名称不可和properties下的名称相同
  },
  properties: { //对外开放的属性
    isShow: Boolean,
    btns: {
      value: ‘‘,
      type: String,
      observer: getinfo
    },
    title: String,
  },
  methods: { //所有的响应方法放在此处
    getinfo: function (newVal, oldVal) {//newVal为新设置的数据,oldVal为老数据 
      getApp().evt.emit("info", {userinfo:xxx});
    }
  }
})

当组件获取到数据通过emit的方式发布事件,把数据发送出去,而所有监听到info事件的函数都可以正确收到数据。

6. 事件

事件是指视图层到逻辑层的通讯方式,将用户的行为反馈到逻辑层上处理,逻辑层上处理后通过setData把数据又渲染到页面。

目前在界面上绑定一个事件由bind + 事件类型或 catch+事件类型,bind的方式绑定事件不会阻止事件冒泡,catch的方式绑定会阻止事件冒泡。事件类型如下:

入门与进阶示例演示

比如绑定一个点击事件:

<view>{{name}}</view><view bindtap=”fun”></view>

fun为写在逻辑层的响应函数,看上面代码,fun又会把界面上的值变化“这是一个传说”。视图层的值以{{}}包裹。更多的语句参考这一节官方说明说明, 点击进入

7. 行为


7.1 behaviors 是用于组件间代码共享的特性。


7.2 每个 behavior 可以包含一组属性、数据、生命周期函数和方法,组件引用它时,它的属性、数据和方法会被合并到组件中,生命周期函数也会在对应时机被调用。每个组件可以引用多个 behavior 。 behavior 也可以引用其他 behavior 。


7.3 定义

module.exports = Behavior({
  behaviors: [],
  properties: {
    myBehaviorProperty: {
      type: String
    }
  },
  data: {
    myBehaviorData: {}
  },
  attached: function () { },
  methods: {
    myBehaviorMethod: function () { }
  }
})

7.4 组件里引用

在 组件behaviors 属性定义段中将它们逐个列出即可

var myBehavior = require(‘my-behavior‘)
Component({
  behaviors: [myBehavior],
  properties: {
    myProperty: {
      type: String
    }
  },
  data: {
    myData: {}
  },
  attached: function () { },
  methods: {
    myMethod: function () { }
  }
})

8. 存储

8.1.本地数据永久性存储

8.2.同一小程序存储大小限定为10M

8.3.和H5的storage存储无半点毛线关系

8.4.以用户维度来隔离,A不可取到B用户数据

8.5.当存储空间不足,会自动清除好久没使用的小程序缓存

8.6.目前微信API提供存储相关的原生接口有如下:

入门与进阶示例演示

9. 运营

9.1. 扩展快速运营的能力

会不会有这样的场景,就是有时候需要开发一些活动或是运营页在小程序里打开,这时候就需要用到小程序的web-view组件了,而不用每次开发小程序代码然后走发布审核流程,该组件允许加载一个H5地址的页面,并可以在该页面跳回到小程序。

在web-view里打开的H5页暂不支持H5的支付方式,需要支付的话,要重新跳回到小程序页再拉起小程序的支付来完成支付。我这边的写法是编写一个跳转的webview,后台配置相应的运营或是活动地址,获取该地址然后调用公共的webview完成跳转,跳转公共函数如下:

const toWebView = function ({ url = ‘‘, title = ‘‘ }) {
    url = encodeURIComponent(url);
    wx.navigateTo({
        url: `/pages/webView/webView?url=${url}&title=${title}`
    })
}

小程序中转页pages/webView/webView 代码如下:

  • webView.js代码
import _ from ‘../../lib/underscore‘;

Page({

    /**
   * 页面的初始数据
   */
    data: {

    },

    /**
   * 生命周期函数--监听页面加载
   */
    onLoad: function (options) {
        let { url = ‘‘, title = ‘‘ } = options;
        url = decodeURIComponent(url);
        // url末尾增加随机数
        if (url.indexOf(‘?‘) > -1) {
            url += ‘&t=‘ + _.random(0, 999999);
        }else {
            url += ‘?t=‘ + _.random(0, 999999);
        }
        this.setData({ url, title });
        if (!url) {
            wx.navigateBack();
        }
        if (title) {
            wx.setNavigationBarTitle({ title });
        }
  },
})
  • webview.wxml代码:
<web-view wx:if="{{url}}" src="{{url}}"></web-view>

9.2.整个小程序的运营和推广方式,我们一般是二维码扫码,或是和其它小程序合作链接,加入微信推广联盟,直接好友转发小程序页面等方式。

小程序特有的脾气

1.所以api请求必须是https, 在IDE上调试时可以勾选右侧面板上检验HTTPS证书以方便调试,但在手机上则需要在手机上的小程序打开调试模式方可不检验htts证书。

2.小程序跳转h5页必须是以打开webview(小程序有一个打开web-view组件)的方式打开不可跳转到外部H5页。目前H5页不可跳小程序,只有在小程序以web-view组件打开的H5里才可以跳回到小程序。

3.以web-view组件方式打开的H5里没办法用H5的方式来支付。

4.APP可以跳转到小程序,小程序只能被动跳转到APP,不可主动跳转到APP,被动是指只有当APP主动跳入小程序,小程序才可以跳回到APP。

5.小程序是非跨平台的,必须运行在微信客户端里。

6.小程序的渲染方式为webview的方式渲染,而非原生渲染,只有<canvas/> <video/> <map/> <textarea/>几个组件才是原生渲染。

7.小程序目前统一使用rpx单位来隔离机器之间屏幕大小的差异,以达到适配,让开发者更加专注业务。

8.小程序开发不能使用Nodejs的扩展,可能官方是考虑到太大的原因。

9.目前一个小程序不可超过2M,如果小程序做了分包,则所有包加起来不可超过8M,每个包不可超过2M。

10.小程序里请求的API域名需在小程序管理后台添加到域名白名单方可访问,小程序web-view组件打开的H5地址也需在小程序管理后台添加到业务白名单,并生成一个文件上传到业务服务器, 验证通过方可在小程序里打开这个H5地址(在H5里通过ifram,或是跳转涉及到的域名都需加入业务白名单方可访问)。

11.微信开发者工具下JS是跑在chrome内核,IOS下是跑在Jscore内核,安卓下是跑在X5内核。

12.小程序里没有window,document对象,没有cookie的概念。

13.小程序的链接地址不是以https开头,而是类似这样的/pages/xx/ccc/?id=23 , 参数写法和h5是一样的。

14.小程序原生API好多对基础库的支持版本有要求,建议在微信管理后台设置最低基础库为1.9以上,当基础库小于这个版本的用户访问小程序时,微信会提示用户升级微信客户端。

15.小程序的每次的版本发布,都需要经过微信部门的审核通过,才可发布,时间1小时到1天不等。

踩过的坑

1. 就是在写页面的时候,如果页面上有倒计时功能,在小程序onhide后没有停掉倒计时,在iphone下就会触发内存不够,小程序被回收,而在把小程序切回到前台界面上,小程序又没有重新渲染,从而导致白屏。建议在onhide里及时结束倒计时,onshow里再重新启动。

2. 安卓下图片地址如图以//开头,则访问不了。建议后台API返回的地址都带上https

3. 手速很快且页面延迟稍卡的情况下,会重复进入同个页面N次,然后回退时要回退N次才能回退到上一页,建议用一个跳转函数包装下微信的原生页面跳转函数并在里面做点击限速。

4. 如果自己开发的小程序连续更新了N个版本,用户一直没有更新的情况下,突然有一天访问我们小程序,会偶现加载小程序信息超时的错误,从而进入不了小程序。具官方回复是时序出错已修复,但一直时而还会偶现。

5. 在微信上查看小程序的数据及管理小程序,请分别搜索官网的”小程序数据助手”,”小程序开发助手”。

6. 更多官网已知BUG大伙可以在这里查阅,点击进入

Part2 进阶篇

原理

了解整个原理有助于编写高效的代码,先上总预览图:微信加载完小程序后会启动两个线程来分别跑视图层和逻辑层代码,等于两个代码分处于不同的容器。

这就涉及到这两个容器之间的数据通信和交互,目前view层到Server层传递方式为”响应事件”,server层到view层则是”setData”数据。Server层的解析器为jscore, view层的解析器为webview。

入门与进阶示例演示

setData的背后原理图:

入门与进阶示例演示

View始终使用的是一个线程,因为setData不可太频繁,否者就会阻塞,线程被阻塞后,view上的事件也没法响应,表现就会很卡。

通过dom diff算法只把差异的数据更新到虚拟DOM上,因为虚拟DOM其实就是和实际的WXML节点一一对应的关系,也就更新到了相应的wxml上,不会重新渲染,只会渲染被更新部分。

性能优化

1. setData使用注意:

从上面原理图得知,每次执行setData就是一次页面变更,虽然不是重新渲染,但是这个setData的使用如果不合理,非常影响性能,比如setData一次内容太多,就会导致和虚拟DOM里的结构对比时间过长,对于如果首屏加载时间有要求的话,可以尽量只setData可视区域的数据,这样提升对比和渲染效果。

setData也不可太过频繁,因为多次频繁setData数据到webview线程,会导致阻塞,因为webview线程一直编译执行渲染,从而没法响应界面上的事件,也没办法把事件传递到逻辑层JS,所以界面看着就卡顿。当前页面进入后台态后,不应该继续setData操作,因为所有的webview共用的是一个JS线程,他会抢占资源,从而影响当前显示页面的流畅度。

2. 预加载:

入门与进阶示例演示

优化前小程序每个页面的访问都会先启动一个webview来装载,然后再加载页面,webview的启动会耗时大概200-300毫秒,如果在当前页面停留超过2秒,小程序会在后台提前启动webview。

这里的优化主要是立即点击的情况,即当点击的时候去先加载要跳入的页面的API数据与创建webview的时间并行,这样当一进入页面加载时,就可以直接拉数据进行渲染了。从而节省了API加载时间,如上图。

另一种优化是在特定的场景下,即比较明确可以预知到当前用户会访问哪个页面,然后开启一条线程,提前加载下一个页面的API数据,并把加载好的数据发送到要打开的页面处的监听函数处即可,主要是节省下API加载时间,当加载页面时就可以立马渲染数据了。

3. 分包,分小程序:

因为小程序的初次访问都会先从远程下载主包小程序代码到本地手机,然后再运行,因此这个小程序主包的大小就直接影响到下载的速度。下载速度占用时间长了,那整个小程序打开的速度时间就拉长了,为此我们使用小程序分包功能,让主包尽可能的体积最小且可用,这样就可以快速的启动。尽量控制每个包在1M以内(主包和分包)。

如果小程序体积实在过大,可以按功能模块分成多个小程序,小程序之间再设置为互相关联,就可以相互跳转访问了。

4. 减少webview:

我们每个页面的加载都会通过创建一个webview来装载,但是多个webview是共用一个线程的,因为webview过多就会消耗大量内存,为此我们需尽可能保持层级最少,及时调用wx.redirectTo()等函数御载页面,从而控制webview的数量。

新的写码姿势

1. 用小程序开发的同学应该知道,小程序没办法引入node包,只能在小程序IDE工具上开发,假如我们有如下的场景,比如我们的样式是用less写的,比如我们对代码有一套自己的规范需要检测,甚至我们用的是vue代码来写小程序,等等这样的场景,如何实现呢,先上一个代码目录结构图:

入门与进阶示例演示

2. 我这里使用的是gulp来对代码进行编译,编译之后生成目标代码,就是和小程序要求的目录结构一样的了。至于gulp的使用不在本教程说明范围,可以自行百度。

我这里的gulp主要做了三个事:

  • 一是把less编译成浏览器可以识别到的css;
  • 二是对代码编码规范进行检测;
  • 三是把小ICO图标转成base64的图片在样式里引入。

当然你可以做更多的一些事情,反正只要提升你的编码质量和效率的事都可以,甚至你都可以自己写一个对VUE代码转化为小程序代码的工具都成,没有你做不到,只有想不到。生成小程序目标代码后就可以在IDE工具上看效果及微调试了。而写代码一般是在其它的代码编码工具里。

另:推荐好用的工具函数JS,underscore.js 点击进入

页面间通信

有时候在开发过程中会有这样的场景,就是小程序页面A需要和小程序页面B通信,子组件需要和父组件通信,有没有一种快速统一的通信方式呢?这里提供一种方式给大家参考,就是使用订阅和发布模式,引入一个开源的JS封装类,然后就可以用统一的方式愉快的在各个页面以及子与父组件之前愉快的通信了。先讲使用方式,源码附在后面。
使用方式如下:

1. App.js里初始化事件类,假设事件所在的文件名为event.js
const Event = require(‘./lib/event‘);
App({
….
evt:new Event(), //即把这个事件初始化给一个全局变量
})
2. 订阅事件, 比如在A页想知道B页数据变化后,立马做出相应变化
Page({

getxxx:function () {
getApp().evt.on(“aaa”,this. changeinfo); //aaa为事件名
},
changeinfo : function (objxx){ //这里是响应订单事件处理函数
},
onUnload: function() {
getApp().evt.off(); //注销所有当前页面的订阅事件
}
})
3. 发布事件
Page({

setmmm:function() {
getApp().evt.emit(“aaa”, objxx); //aaa为事件名,和订阅名一致,objxx 为需要发送的参数
},
})


event.js代码如下:

class Event{
    on (event, fn, ctx) {
        if (typeof fn != "function") {
            console.error(‘fn must be a function‘)
            return
        }
        this._stores = this._stores || {}

        ;(this._stores[event] = this._stores[event] || []).push({cb: fn, ctx: ctx})
    }
    once (event, fn, ctx) {
        let that = this;
        let newfn = function () {
            let args = Array.prototype.slice.call(arguments);
            fn.apply(this, args);
            that.off(event, newfn);
        }
        this.on(event, newfn, ctx);
    }
    emit (event) {
        this._stores = this._stores || {}
        var store = this._stores[event], args
        if (store) {
            store = store.slice(0)
            args = [].slice.call(arguments, 1)
            for (var i = 0, len = store.length; i < len; i++) {
                store[i].cb.apply(store[i].ctx, args)
            }
        }
    }
    off (event, fn) {
        this._stores = this._stores || {}
        // all
        if (!arguments.length) {
            this._stores = {}
            return
        }
        // specific event