1
作者:张利涛,视频课程《微信小程序教学》、《基于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
...

 小程序的运行过程

  1. 我们在微信上打开一个小程序
    微信客户端在打开小程序之前,会把整个小程序的代码包下载到本地。
  2. 微信 App 从微信服务器下载小程序的文件包
    为了流畅的用户体验和性能问题,小程序的文件包不能超过 2M。另外要注意,小程序目录下的所有文件上传时候都会打到一个包里面,所以尽量少用图片和第三方的库,特别是图片。
  3. 解析 app.json 配置信息初始化导航栏,窗口样式,包含的页面列表
  4. 加载运行 app.js
    初始化小程序,创建 app 实例
  5. 根据 app.json,加载运行第一个页面初始化第一个 Page
  6. 路由切换
    以栈的形式维护了当前的所有页面。最多 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 的源码就会发现,上述的过程可以简化描述如下:

  1. 订阅:监听状态————保存对应的回调
  2. 发布:状态变化————执行回调函数
  3. 同步视图:回调函数同步数据到视图

第三步:同步视图,在 React 中,State 发生变化后会触发 Render 来更新视图。

而小程序中,如果我们通过 setData 改变 data,同样可以更新视图。

所以,我们实现小程序组件通信的思路如下:

  1. 观察者模式/发布订阅模式
  2. 装饰者模式/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实战项目教学(含视频)| 课程大纲介绍

沪江iKcamp出品微信小程序教学共5章16小节汇总(含视频)


iKcamp
3.8k 声望666 粉丝

iKcamp由热爱原创和热翻译的小伙伴发起,成员来自美团点评、沪江等。成立于2016年7月,"iK"代表布兰登·艾克(JavaScript之父)。 追随JavaScript这门语言所秉持的精神,崇尚开放和自由的我们一同工作、分享、创作...