• 带你玩转小程序开发实践|含直播回顾视频


    作者:张利涛,视频课程《微信小程序教学》、《基于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小节汇总(含视频)

  • 相关阅读:
    Virtualbox + centos7 实现网络互ping
    什么?https://start.spring.io访问不了,本地搭建一个不就行了
    关系型数据库设计三大范式到底是什么?
    ClickHouse集群搭建(二)
    ClickHouse集群搭建(一)
    适合初学者入门Java程序
    ffmpeg 修改视频封面
    重温于娟对癌症的认知
    Ubuntu18.04 安装jdk1.8
    提交本地代码到github (commit to remote repo)
  • 原文地址:https://www.cnblogs.com/ikcamp/p/8079018.html
Copyright © 2020-2023  润新知