作者:张利涛,视频课程《微信小程序教学》、《基于Koa2搭建Node.js实战项目教学》主编,沪江前端架构师
本文原创,转载请注明作者及出处
小程序和 H5 区别
我们不一样,不一样,不一样。
运行环境 runtime
首先从官方文档可以看到,小程序的运行环境并不是浏览器环境:
小程序框架提供了自己的视图层描述语言 WXML 和 WXSS,以及基于 JavaScript 的逻辑层框架,并在视图层与逻辑层间提供了数据传输和事件系统,可以让开发者可以方便的聚焦于数据与逻辑上。
小程序的视图层目前使用 WebView 作为渲染载体,而逻辑层是由独立的 JavascriptCore 作为运行环境。在架构上,WebView 和 JavascriptCore 都是独立的模块,并不具备数据直接共享的通道。当前,视图层和逻辑层的数据传输,实际上通过两边提供的 evaluateJavascript 所实现。即用户传输的数据,需要将其转换为字符串形式传递,同时把转换后的数据内容拼接成一份 JS 脚本,再通过执行 JS 脚本的形式传递到两边独立环境。
而 evaluateJavascript 的执行会受很多方面的影响,数据到达视图层并不是实时的。同一进程内的 WebView 实际上会共享一个 JS VM,如果 WebView 内 JS 线程正在执行渲染或其他逻辑,会影响 evaluateJavascript 脚本的实际执行时间,另外多个 WebView 也会抢占 JS VM 的执行权限;另外还有 JS 本身的编译执行耗时,都是影响数据传输速度的因素。
而所谓的运行环境,对于任何语言的运行,它们都需要有一个环境——runtime。浏览器和 Node.js 都能运行 JavaScript,但它们都只是指定场景下的 runtime,所有各有不同。而小程序的运行环境,是微信定制化的 runtime。
大家可以做一个小实验,分别在浏览器环境和小程序环境打开各自的控制台,运行下面的代码来进行一个 20 亿次的循环:
var k
for (var i = 0; i < 2000000000; i++) {
k = i
}
浏览器控制台下运行时,当前页面是完全不能动,因为 JS 和视图共用一个线程,相互阻塞。
小程序控制台下运行时,当前视图可以动,如果绑定有事件,也会一样触发,只不过事件的回调需要在 『循环结束』 之后。
视图层和逻辑层如果共用一个线程,优点是通信速度快(离的近就是好),缺点是相互阻塞。比如浏览器。
视图层和逻辑层如果分处两个环境,优点是相互不阻塞,缺点是通信成本高(异地恋)。比如小程序的 setData
,通信一次就像是写情书!
所以,严格来说,小程序是微信定制的混合开发模式。
在 JavaScript 的基础上,小程序做了一些修改,以方便开发小程序。
- 增加 App 和 Page 方法,进行程序和页面的注册。【增加了 Component】
- 增加 getApp 和 getCurrentPages 方法,分别用来获取 App 实例和当前页面栈。
- 提供丰富的 API,如微信用户数据,扫一扫,支付等微信特有能力。【调用原生组件:Cordova、ReactNative、Weex 等】
- 每个页面有独立的作用域,并提供模块化能力。
- 由于框架并非运行在浏览器中,所以 JavaScript 在 web 中一些能力都无法使用,如 document,window 等。【小程序的 JsCore 环境】
- 开发者写的所有代码最终将会打包成一份 JavaScript,并在小程序启动的时候运行,直到小程序销毁。类似 ServiceWorker,所以逻辑层也称之为 App Service。
与传统的 HTML 相比,WXML 更像是一种模板式的标签语言
从实践体验上看,我们可以从小程序视图上看到 Java FreeMarker 框架、Velocity、smarty 之类的影子。
小程序视图支持如下
数据绑定 {{}}
列表渲染 wx:for
条件判断 wx:if
模板 tempalte
事件 bindtap
引用 import include
可在视图中应用的脚本语言 wxs
...
Java FreeMarker 也同样支持上述功能。
数据绑定 ${}
列表渲染 list指令
条件判断 if指令
模板 FTL
事件 原生事件
引用 import include 指令
内建函数 比如『时间格式化』
可在视图中应用的脚本语言 宏 marco
...
小程序的运行过程
我们在微信上打开一个小程序
微信客户端在打开小程序之前,会把整个小程序的代码包下载到本地。微信 App 从微信服务器下载小程序的文件包
为了流畅的用户体验和性能问题,小程序的文件包不能超过 2M。另外要注意,小程序目录下的所有文件上传时候都会打到一个包里面,所以尽量少用图片和第三方的库,特别是图片。解析 app.json 配置信息初始化导航栏,窗口样式,包含的页面列表
加载运行 app.js
初始化小程序,创建 app 实例根据 app.json,加载运行第一个页面初始化第一个 Page
路由切换
以栈的形式维护了当前的所有页面。最多 5 个页面。出栈入栈
解决小程序接口不支持 Promise 的问题
小程序的所有接口,都是通过传统的回调函数形式来调用的。回调函数真正的问题在于他剥夺了我们使用 return 和 throw 这些关键字的能力。而 Promise 很好地解决了这一切。
那么,如何通过 Promise 的方式来调用小程序接口呢?
查看一下小程序的官方文档,我们会发现,几乎所有的接口都是同一种书写形式:
wx.request({
url: "test.php", //仅为示例,并非真实的接口地址
data: {
x: "",
y: ""
},
header: {
"content-type": "application/json" // 默认值
},
success: function(res) {
console.log(res.data)
},
fail: function(res) {
console.log(res)
}
})
所以,我们可以通过简单的 Promise 写法,把小程序接口装饰一下。代码如下:
wx.request2 = (option = {}) => {
// 返回一个 Promise 实例对象,这样就可以使用 then 和 throw
return new Promise((resolve, reject) => {
option.success = res => {
// 重写 API 的 success 回调函数
resolve(res)
}
option.fail = res => {
// 重写 API 的 fail 回调函数
reject(res)
}
wx.request(option) // 装饰后,进行正常的接口请求
})
}
上述代码简单的展现了如何把一个请求接口包装成 Promise 形式。但在实战项目中,可能有多个接口需要我们去包装处理,每一个都单独包装是不现实的。这时候,我们就需要用一些技巧来处理了。
其实思路很简单:我们把需要 Promise 化的『接口名字』存放在一个『数组』中,然后对这个数组进行循环处理。
这里我们利用了 ECMAScript5 的特性 Object.defineProperty 来重写接口的取值过程。
let wxKeys = [
// 存储需要Promise化的接口名字
"showModal",
"request"
]
// 扩展 Promise 的 finally 功能
Promise.prototype.finally = function(callback) {
let P = this.constructor
return this.then(
value => P.resolve(callback()).then(() => value),
reason =>
P.resolve(callback()).then(() => {
throw reason
})
)
}
wxKeys.forEach(key => {
const wxKeyFn = wx[key] // 将wx的原生函数临时保存下来
if (wxKeyFn && typeof wxKeyFn === "function") {
// 如果这个值存在并且是函数的话,进行重写
Object.defineProperty(wx, key, {
get() {
// 一旦目标对象访问该属性,就会调用这个方法,并返回结果
// 调用 wx.request({}) 时候,就相当于在调用此函数
return (option = {}) => {
// 函数运行后,返回 Promise 实例对象
return new Promise((resolve, reject) => {
option.success = res => {
resolve(res)
}
option.fail = res => {
reject(res)
}
wxKeyFn(option)
})
}
}
})
}
})
注: Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象。
用法也很简单,我们把上述代码保存在一个 js 文件中,比如 utils/toPromise.js,然后在 app.js 中引入就可以了:
import "./util/toPromise"
App({
onLoad() {
wx
.request({
url: "http://www.weather.com.cn/data/sk/101010100.html"
})
.then(res => {
console.log("come from Promised api, then:", res)
})
.catch(err => {
console.log("come from Promised api, catch:", err)
})
.finally(res => {
console.log("come from Promised api, finally:")
})
}
})
小程序组件化开发
小程序从 1.6.3 版本开始,支持简洁的组件化编程
官方支持组件化之前的做法
// 组件内部实现
export default class TranslatePop {
constructor(owner, deviceInfo = {}) {
this.owner = owner;
this.defaultOption = {}
}
init() {
this.applyData({...})
}
applyData(data) {
let optData = Object.assign(this.defaultOption, data);
this.owner && this.owner.setData({
translatePopData: optData
})
}
}
// index.js 中调用
translatePop = new TranslatePop(this);
translatePop.init();
实现方式比较简单,就是在调用一个组件时候,把当前环境的上下文 content 传递给组件,在组件内部实现 setData 调用。
应用官方支持的方式来实现
官方组件示例:
Component({
properties: {
// 这里定义了innerText属性,属性值可以在组件使用时指定
innerText: {
type: String,
value: "default value"
}
},
data: {
// 这里是一些组件内部数据
someData: {}
},
methods: {
// 这里是一个自定义方法
customMethod: function() {}
}
})
结合 Redux 实现组件通信
在 React 项目中 Redux 是如何工作的
单一数据源
整个应用的 state 被储存在一棵 object tree 中,并且这个 object tree 只存在于唯一一个 store 中。
State 是只读的
惟一改变 state 的方法就是触发 action,action 是一个用于描述已发生事件的普通对象
使用纯函数来执行修改
为了描述 action 如何改变 state tree ,你需要编写 reducers。
Props 传递 —— Render 渲染
如果你有看过 Redux 的源码就会发现,上述的过程可以简化描述如下:
- 订阅:监听状态————保存对应的回调
- 发布:状态变化————执行回调函数
- 同步视图:回调函数同步数据到视图
第三步:同步视图,在 React 中,State 发生变化后会触发 Render 来更新视图。
而小程序中,如果我们通过 setData 改变 data,同样可以更新视图。
所以,我们实现小程序组件通信的思路如下:
- 观察者模式/发布订阅模式
- 装饰者模式/Object.defineProperty (Vuejs 的设计路线)
在小程序中实现组件通信
先预览下我们的最终项目结构:
├── components/
│ ├── count/
│ ├── count.js
│ ├── count.json
│ ├── count.wxml
│ ├── count.wxss
│ ├── footer/
│ ├── footer.js
│ ├── footer.json
│ ├── footer.wxml
│ ├── footer.wxss
├── pages/
│ ├── index/
│ ├── ...
│ ├── log/
│ ├── ...
├── reducers/
│ ├── counter.js
│ ├── index.js
│ ├── redux.min.js
├── utils/
│ ├── connect.js
│ ├── shallowEqual.js
│ ├── toPromise.js
├── app.js
├── app.json
├── app.wxss
1. 实现『发布订阅』功能
首先,我们从 cdn 或官方网站获取 redux.min.js,放在结构里面
创建 reducers 目录下的文件:
// /reducers/index.js
import { createStore, combineReducers } from './redux.min.js'
import counter from './counter'
export default createStore(combineReducers({
counter: counter
}))
// /reducers/counter.js
const INITIAL_STATE = {
count: 0,
rest: 0
}
const Counter = (state = INITIAL_STATE, action) => {
switch (action.type) {
case "COUNTER_ADD_1": {
let { count } = state
return Object.assign({}, state, { count: count + 1 })
}
case "COUNTER_CLEAR": {
let { rest } = state
return Object.assign({}, state, { count: 0, rest: rest+1 })
}
default: {
return state
}
}
}
export default Counter
我们定义了一个需要传递的场景值 count
,用来代表例子中的『点击次数』,rest
代表『重置次数』。
然后在 app.js 中引入,并植入到小程序全局中:
//app.js
import Store from './reducers/index'
App({
Store,
})
2. 利用 『装饰者模式』,对小程序的生命周期进行包装,状态发生变化时候,如果状态值不一样,就同步 setData
// 引用了 react-redux 中的工具函数,用来判断两个状态是否相等
import shallowEqual from './shallowEqual'
// 获取我们在 app.js 中植入的全局变量 Store
let __Store = getApp().Store
// 函数变量,用来过滤出我们想要的 state,方便对比赋值
let mapStateToData
// 用来补全配置项中的生命周期函数
let baseObj = {
__observer: null,
onLoad() { },
onUnload() { },
onShow() { },
onHide() { }
}
let config = {
__Store,
__dispatch: __Store.dispatch,
__destroy: null,
__observer() {
// 对象中的 super,指向其原型 prototype
if (super.__observer) {
super.__observer()
return
}
const state = __Store.getState()
const newData = mapStateToData(state)
const oldData = mapStateToData(this.data || {})
if (shallowEqual(oldData, newData)) {// 状态值没有发生变化就返回
return
}
this.setData(newData)
},
onLoad() {
super.onLoad()
this.__destroy = this.__Store.subscribe(this.__observer)
this.__observer()
},
onUnload() {
super.onUnload()
this.__destroy && this.__destroy() & delete this.__destroy
},
onShow() {
super.onShow()
if (!this.__destroy) {
this.__destroy = this.__Store.subscribe(this.__observer)
this.__observer()
}
},
onHide() {
super.onHide()
this.__destroy && this.__destroy() & delete this.__destroy
}
}
export default (mapState = () => { }) => {
mapStateToData = mapState
return (options = {}) => {
// 补全生命周期
let opts = Object.assign({}, baseObj, options)
// 把业务代码中的 opts 配置对象,指定为 config 的原型,方便『装饰者调用』
Object.setPrototypeOf(config, opts)
return config
}
}
调用方法:
// pages/index/index.js
import connect from "../../utils/connect"
const mapStateToProps = (state) => {
return {
counter: state.counter
}
}
Page(connect(mapStateToProps)({
data: {
innerText: "Hello 点我加1哦"
},
bindBtn() {
this.__dispatch({
type: "COUNTER_ADD_1"
})
}
}))
最终效果展示:
项目源码地址:
https://github.com/ikcamp/xcx-redux
直播视频地址:
https://www.cctalk.com/v/15137361643293
iKcamp官网:https://www.ikcamp.com
iKcamp新课程推出啦~~~~~开始免费连载啦~每周2更共11堂iKcamp课|基于Koa2搭建Node.js实战项目教学(含视频)| 课程大纲介绍