作者:张利涛,视频课程《微信小程序教学》、《基于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实战项目教学(含视频)| 课程大纲介绍