- 小程序同构方案kbone分析与适配
在微信小程序的开发的过程中,我们会存在小程序和 H5页面共存的场景,而让小程序原生和web h5独立开发,往往会遇到需要两套人力去维护。对开发者而言,加大了工作量成本,对于产品而言,容易出现展示形态同步不及时问题。在这种情况下,我们急需要找到一个既能平衡性能,也能满足快速迭代的方案。 主流的小程序同构方案 web-view 组件 web-view组件是一个承载网页的容器,最简单的方案就是使用原h5的代码,通过 web-view组件进行展示。其优点是业务逻辑无需额外开发与适配,只需要处理小程序特有的逻辑,然后通过jssdk 与原生小程序通信。 使用 webview 加载 h5的问题也非常明显,首先是体验问题,用户见到页面会经过以下环节:加载小程序包,初始化小程序,再加载 webview中的 html页面,然后加载相关资源,渲染h5页面,最后进行展示。最终导致的结果是打开体验非常差。另外其他缺点是小程序对web-view部分特性有限制,比如组件会自动铺满整个小程序页面,不支持自定义导航效果等。 [图片] 静态编译兼容 静态编译是最为主流的小程序同构方案,类似的有 taro, mpvue等。其思路是在构建打包过程,把一种结构化语言,转换成另一种结构化语言。比如,taro 把jsx在构建时进行词法分析,解析代码获取AST,然后将 AST递归遍历生成wxml目标代码。 [图片] 静态编译的好处是非常明显,一套代码,通过编译分别转h5和小程序,兼具性能与跨平台。另一方面,随着这种方案的流行,大家也感受到了其明显的问题,首先,由于小程序本身的限制,比如无法dom操作,js 与 webview 双线程通信等,导致静态编译语法转换,不能做到彻底的兼容,开发体验受制于框架本身的支持程度,相信踩过坑的同学应该非常有痛的感悟。其次,静态编译转换逻辑需要与小程序最新的特性保持同步,不断升级。 小程序运行时兼容方案 静态编译的方案实现了同构,但它只是以一种中间态的结构化语法去编码,非真正的web,牺牲了大量的灵活性。我们来看下另外一种更灵活的方案———运行时兼容。 我们回到小程序本身的限制上来。由于小程序采用双线程机制,内部通过一个 webview 用于承载页面渲染,但小程序屏蔽了它原本的 DOM/BOM接口,自己定义了一套组件规范;另一方面,使用独立的js-core 负责对 javascript 代码进行解析,让页面和js-core之间进行相互通信(setData),从而达到执行与渲染的分离。而浏览器的 DOM接口是大量web得以显示的底层依赖,这也是h5代码无法直接在小程序中运行的主要原因。 那么如何突破小程序对DOM接口的屏蔽呢? 最直接的思路就是用JS实现和仿造一层浏览器环境DOM相关的标准接口,让用户的JS代码可以无感知的自由操作DOM。通过仿造的底层DOM接口,web 代码执行完后,最终生成一层仿造的 DOM树,然后将这棵 DOM 树转换成小程序的wxml构成的DOM树,最后由小程序原生去负责正确的渲染出来。 [图片] kbone kbone 是微信官方出一套小程序运行时兼容方案,目前已经接入的小程序有小程序官方社区,及腾讯课堂新人礼包等。并且有专人维护,反馈及时~~。 kbone方案核心主要有两大模块,第一是miniprogram-render实现了对浏览器环境下dom/bom的仿造,构建dom树,及模拟 web 事件机制。第二个模块是miniprogram-element是原生小程序渲染入口,主要监听仿造dom树的变化,生成对应的小程序的dom 树,另外一个功能是监听原生小程序事件,派发到仿造的事件中心处理。 [图片] DOM/BOM仿造层 DOM、BOM相关的接口模拟,主要是按照web标准构建 widow、document、node节点等相关 api,思路比较清晰,我们简单看下其流程。 首先在用户层有一个配置文件miniprogram.config,里面有必要信息origin、entry等需要配置。在 miniprogram-render 的入口文件createPage方法中,配置会初始化到一个全局cache对象中,然后根据配置初始化 Window 和 Document 这两个重要的对象。Location、Navigator、Screen、History等 BOM 实例都是在 window初始化过程中完成。DOM 节点相关 api 都是在Document 类中初始化。所有生成的节点和对象都会通过全局的pageMap管理,在各个流程中都能获取到。 [图片] 小程序渲染层 miniprogram-element 负责监听仿造DOM仿造的变化,然后生成对应小程序组件。由于小程序中提供的组件和 web 标准并不完全一样,而我们通过 html 生成的 dom 树结构千差万别,如和保证任意的html dom树可以映射到小程序渲染的dom树上呢?kbone 通过小程序自定义组件去做了这件事情。 简单说下什么是自定义组件,既将特定的代码抽象成一个模块,可以组装和复用。以 react 为例,div、span 等标签是原生组件,通过react.Component将div 和 span 组合成一个特定的 react 组件,在小程序中用自带的 view、image 等标签通过Component写法就能组合成小程序自定义组件。 和大部分 web 框架的自定义组件类似,小程序自定义组件也能够自己递归地调用自己,通过将伪造的dom结构数据传给自定义组件作为子组件,然后再递归的调用,直到没有子节点为止,这样就完成了一个小程序 dom 树的生成。 [图片] [图片] [图片] 性能问题 多层dom组合 大量小程序自定义组件会有额外的性能损耗,kbone 在实现时提供了一些优化。其中最基本的一个优化是将多层小程序原生标签作为一个自定义组件。dom 子树作为自定义组件渲染的层级数是可以通过配置传入,理论上层级越多,使用自定义组件数量越少,性能也就越好。 [图片] [图片] [图片] 以上逻辑就是通过DOM_SUB_TREE_LEVEL 层级数对节点过滤,更新后,检测是否还有节点,再触发更新。 节点缓存 在页面onUnload卸载的过程中,kbone会将当前节点放入缓存池中,方便下次初始化的时候优先从缓存中读取。 [图片] [图片] kbone 接入与适配 kbone 作为一种运行时兼容方案,适配成本相对于静态编译方案而言会低很多,总体来说对原代码侵入性非常少,目前接入过程比较顺利(期间遇到的坑,非常感谢作者june第一时间帮忙更新版本) svg资源适配 小程序不支持 svg,对于使用 svg 标签作为图片资源的应用而言,需要从底层适配。在一开始我们想到的方案有通过 肝王的cax进行兼容,但评估后不太靠谱,cax 通过 解析svg 绘制成 canvas,大量 icon会面临比较严重的性能问题。那么最直接暴力的办法就是使用 webpack 构建过程直接把 svg 转 png?后面一位给力的小伙伴想到通过把 svg 标签转成Data URI作为背景图显示,最终实践验证非常可靠,具体可以参考kbone svg 适配。 网络层适配/cookie 微信小程序环境拥有自己定义的一套 wx.request API, web 中的XMLHttpRequest对象是无法直接使用。由于我们代码中使用了 axios,所以在预言阶段直接简单通过axios-miniprogram-adapter进行适配器,后面发现部分业务没有使用 axios,兼容并不够彻底。于是直接从底层构建了一个XMLHttpRequest模块,将web网络请求适配到 wx.request。同时做了 cookie 的自动存取逻辑适配(小程序网络请求默认不带 cookie)。这一层等完善好了看是否能 pull request到 kbone代码仓库中。 差异性 DOM/BOM API 适配 部分web 中的接口在小程序无法完全获得模拟,比如getBoundingClientRect在小程序中只能通过异步的方式实现。类似的有removeProperty、stopImmediatePropagation 等接口在 kbone 中没有实现,performance等web特有的全局变量的需要兼容。这些扩展API可以通过kbone对外暴露的dom/bom 扩展 API进行适配。 [图片] getBoundingClientRect 对于元素的的高度height \offsetHeight获取,我们只能通过$getBoundingClientRect异步接口,如果是body scroll-view 实现的,getBoundingClientRect 返回的是scrollHeight。 滚动 web的全局滚动事件默认是无法触发,需要通过配置windowScroll来监听,启用这个特性会影响性能。 [代码]global: { windowScroll: true }, [代码] 样式适配 标签选择器 kbone 样式有一个坑,就是它会将标签选择器转换成类选择器去适配小程序环境,比如 [代码]span { } => .h5-span{ } [代码] 这样带来的副作用就是选择器的权重会被自动提升,对选择器权重依赖的标签样式需要去手动调整兼容。 其他适配点 注意使用标准的style属性,比如有webkit-transform会不支持,及小程序样式和web差异性兼容等。 [代码] style: { 'WebkitTransform': 'translate(' + x + 'px, 0)' // 正确 // '-webkit-transform': 'translate(' + x + 'px, 0)' 报错 } [代码] 路由适配 在初始化路由阶段,曾经遇到过Redux 更新dom后偶现节点销毁,最终定位到是kbone对Location等BOM实例化过晚,最终在june帮忙及时调整了顺序,更新了一个版本,现最新本所有BOM对象会在业务执行前准备好。 [代码]//初始化dom this.window.$$miniprogram.init() ... //初始化业务 init(this.window, this.document) [代码] 隐式全局变量兼容 在模拟XMLHttpRequest模块的过程中遇到一个问题,什么时候初始化这个对象,我们可以选择在网络请求库初始化前引入它,挂载在仿造的 window 对象下。但仍然会出现一个问题,第三放库直接使用的是XMLHttpRequest 对象,而非通过 window 访问。 [代码]var request = new XMLHttpRequest() // 报错 var request = new window.XMLHttpRequest() // 正确 [代码] 在正常的 web 环境,window 是默认的顶层作用域,而小程序中隐式的使用window 对象则会报错。 为了解决这一问题,可以通过配置文件的globalVars字段,将 XMLHttpRequest 直接进行定义。 [代码] globalVars: [ ['XMLHttpRequest', 'require("libs/xmlhttprequest.js")'] ] [代码] 构建的过程中会在所有依赖前转成如下代码 : [代码] var XMLHttpRequest = require("libs/xmlhttprequest.js") [代码] 这样做解决了隐式访问 window 作用域问题。但又面临另一个问题,那就是xmlhttprequest模块本身内部由依赖仿造window对象,比如 cookie 访问,而此时因为require的模块独立的作用域无法访问到其他模块的仿造window 对象。于是最终通过导入一个 function 传入 window 作用域,然后初始化xmlhttprequest。 [代码] globalVars: [ ['XMLHttpRequest', 'require("libs/xmlhttprequest.js").init(window, document)'] ] [代码] 多端构建 小程序和web端需要的资源及部分逻辑是有差异,通过webpck配置进行差异化处理,具体可以参考文档编写kbone webpack 配置。 大概是这样的区分跨端配置: [图片] 分离打包入口文件: [图片] 小程序打包入口依赖的 dom 节点,需要主动创建。详细示例参照官方demo. [代码]export default function createApp() { initialize(function() { let Root = require('./root/index').default; const container = document.createElement('div') container.id = 'pages'; document.body.appendChild(container); render(<Root />, container) }) } [代码] 由于小程序本身是没有真正userAgent,kbone内部是是根据当前环境进行仿造。 [代码]//miniprogram-render/src/bom/navigator#45 this.$_userAgent = `${this.appCodeName}/${appVersion} (${platformContext}) AppleWebKit/${appleWebKitVersion} (KHTML, like Gecko) Mobile MicroMessenger/${this.$_wxVersion} Language/${this.language}` [代码] 在业务中有需要区分小程序平台的场景,我们可以通过webpack DefinePlugin插件进行注入,然后通过定义变量进行判断。 [代码]if (!process.env.isWxMiniProgram) { render( <Root />, document.getElementById('pages') ); } [代码] 小程序分包 在腾讯文档的小程序中,有一个独立的小程序仓库。 而文档管理列表是另外一个独立的H5项目,嵌入到小程序webview动态加载。通过kbone转原生打包后,这部分代码需要继承到小程序仓库中。 首先我们可以通过脚本,在webpack构建过程,将kbone 编译后的包copy到独立小程序仓库的目录下,合并小程序相关配置,从而实现功能合并。同时通过FileWebpackPlugin过滤掉无用的web平台资源。 这样遇到的问题是主包大小仍然超过限制,最后通过小程序分包可以解决这个问题,将原小程序非首屏页面全部放分包之中,配置preloadRule 字段再预加载分包。 [代码]"subpackages": [ { "root": "packageA", "pages": [ "pages/cat" ] } ] "preloadRule": { "pages/index": { "network": "all", "packages": ["important"] } } [代码] 结 通过对目前各种小程序同构方案的对比与实践,kbone是一种非常值得推荐的新思路,新方法,兼具性能与灵活。唯一不足的地方就是目前仍有不少底层工作需要适配,更多的问题在继续探索中,相信随着不断迭代及采坑后的反馈,kbone会变得越来稳定和成熟。 (最后感谢作者junexie及dntzhang大神的鼎力支持~~也欢迎大家一起参与共建kbone)
2019-12-04 - 云函数开发跳坑经验-报错404011 cloud function execution error
这里写一下这两天踩过的坑,若有大神看此贴,欢迎指点一二,若有写的不周的地方,请谅解 前言:小程序需要在云函数中执行对数据库的新建以及更新操作 我这里用的云函数操作的云数据库,刚开始写的是一个新建的动作,复杂的逻辑劈里啪啦写了一大堆,测试一下并没有报错,但是在我进入云开发去看数据库时傻眼了---并没有刚刚新建的记录。赶紧先把代码挪出来只写一个简简单单的新建函数: 注意一: 云函数开发和普通开发有些区别--普通开发访问数据库这么写:const db = wx.cloud.database() 云函数访问数据库这么写:const db = cloud.database() 注意二:对数据库的操作代码之前要加上 “await” (这里我要吐槽一下官方文档,没有有关于云函数操作数据库的注意事项文档) [图片] 嗯,这样才算能够操作的到数据库 注意三:云函数初始化的时候,千万不要图省事什么都不写: 开始的时候,我以为这么写会去访问默认数据库不需要其他操作 [图片] 那么你可能会和我一样遇到-404011错误 [图片] 原来云函数在初始化的时候,并不会按照app.js中wx.cloud.init里面配置的信息进行位置访问,而是需要在云函数的初始化方法中声明访问位置 [图片] 这个报错影响了我一天,这里也需要提醒一下遇到-404011的各位,遇到这样的问题继续看报错信息不要莽莽撞撞的就去查各种资料,比如我的这个报错信息,提示找不到db or table 所以才意识到初始化的时候需要声明一下,之后再上传并部署就可以了 也许您也还会遇到-404011的其他报错信息,报错信息后面如果提示找不到sdk或者其他的也都有可能,在查百度的时候看到这样一篇文章不错,可以借鉴一下https://blog.csdn.net/New_Yao/article/details/84657774 我的电脑上是没有装node的,但是也可以正常使用,我一直使用的都是云端安装依赖,因为我在看官方文档的时候,并没有说电脑一定要安装node,当然,这个还是要看实际情况
2019-05-14 - 借助小程序云开发实现小程序支付功能(含源码)
我们在做小程序支付相关的开发时,总会遇到这些难题。小程序调用微信支付时,必须要有自己的服务器,有自己的备案域名,有自己的后台开发。这就导致我们做小程序支付时的成本很大。本节就来教大家如何使用小程序云开发实现小程序支付功能的开发。不用搭建自己的服务器,不用有自己的备案域名。只需要简简单单的使用小程序云开发。 老规矩先看效果图: [图片] 本节知识点 1,云开发的部署和使用 2,支付相关的云函数开发 3,商品列表 4,订单列表 5,微信支付与支付成功回调 支付成功给用户发送推送消息的功能会在后面讲解。 下面就来教大家如何借助云开发使用小程序支付功能。 支付所需要用到的配置信息 1,小程序appid 2,云开发环境id 3,微信商户号 4,商户密匙 一,准备工作 1,已经申请小程序,获取小程序 AppID 和 Secret 在小程序管理后台中,【设置】 →【开发设置】 下可以获取微信小程序 AppID 和 Secret。 [图片] 2,微信支付商户号,获取商户号和商户密钥在微信支付商户管理平台中,【账户中心】→【商户信息】 下可以获取微信支付商户号。 [图片] 在【账户中心】 ‒> 【API安全】 下可以设置商户密钥。 [图片] 这里特殊说明下,个人小程序是没有办法使用微信支付的。所以如果想使用微信支付功能,必须是非个人账号(当然个人可以办个体户工商执照来注册非个人小程序账号) 3,微信开发者 IDE https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html 4,开通小程序云开发功能:https://edu.csdn.net/course/play/9604/204526 二,商品列表的实现 效果图如下,由于本节重点是支付的实现,所以这里只简单贴出关键代码。 [图片] wxml布局如下: [代码]<view class="container"> <view class="good-item" wx:for="{{goods}}" wx:key="*this" ontap="getDetail" data-goodid="{{item._id}}"> <view class="good-image"> <image src="{{pic}}"></image> </view> <view class="good-detail"> <view class="title">商品: {{item.name}}</view> <view class="content">价格: {{item.price / 100}} 元 </view> <button class="button" type="primary" bindtap="makeOrder" data-goodid="{{item._id}}" >下单</button> </view> </view> </view> [代码] 我们所需要做的就是借助云开发获取云数据库里的商品信息,然后展示到商品列表,关于云开发获取商品列表并展示本节不做讲解(感兴趣的同学可以翻看我的历史博客,有写过的) 也有视频讲解: https://edu.csdn.net/course/detail/9604 [图片] 三,支付云函数的创建 首先看下我们支付云函数都包含那些内容 [图片] 简单先讲解下每个的用处 config下的index.js是做支付配置用的,主要配置支付相关的账号信息 lib是用的第三方的支付库,这里不做讲解。 重点讲解的是云函数入口 index.js 下面就来教大家如何去配置 1,配置config下的index.js, 这一步所需要做的就是把小程序appid,云开发环境ID,商户id,商户密匙。填进去。 [图片] 2,配置入口云函数 [图片] 详细代码如下,代码里注释很清除了,这里不再做单独讲解: [代码]const cloud = require('wx-server-sdk') cloud.init() const app = require('tcb-admin-node'); const pay = require('./lib/pay'); const { mpAppId, KEY } = require('./config/index'); const { WXPayConstants, WXPayUtil } = require('wx-js-utils'); const Res = require('./lib/res'); const ip = require('ip'); /** * * @param {obj} event * @param {string} event.type 功能类型 * @param {} userInfo.openId 用户的openid */ exports.main = async function(event, context) { const { type, data, userInfo } = event; const wxContext = cloud.getWXContext() const openid = userInfo.openId; app.init(); const db = app.database(); const goodCollection = db.collection('goods'); const orderCollection = db.collection('order'); // 订单文档的status 0 未支付 1 已支付 2 已关闭 switch (type) { // [在此处放置 unifiedorder 的相关代码] case 'unifiedorder': { // 查询该商品 ID 是否存在于数据库中,并将数据提取出来 const goodId = data.goodId let goods = await goodCollection.doc(goodId).get(); if (!goods.data.length) { return new Res({ code: 1, message: '找不到商品' }); } // 在云函数中提取数据,包括名称、价格才更合理安全, // 因为从端里传过来的商品数据都是不可靠的 let good = goods.data[0]; // 拼凑微信支付统一下单的参数 const curTime = Date.now(); const tradeNo = `${goodId}-${curTime}`; const body = good.name; const spbill_create_ip = ip.address() || '127.0.0.1'; // 云函数暂不支付 http 触发器,因此这里回调 notify_url 可以先随便填。 const notify_url = 'http://www.qq.com'; //'127.0.0.1'; const total_fee = good.price; const time_stamp = '' + Math.ceil(Date.now() / 1000); const out_trade_no = `${tradeNo}`; const sign_type = WXPayConstants.SIGN_TYPE_MD5; let orderParam = { body, spbill_create_ip, notify_url, out_trade_no, total_fee, openid, trade_type: 'JSAPI', timeStamp: time_stamp, }; // 调用 wx-js-utils 中的统一下单方法 const { return_code, ...restData } = await pay.unifiedOrder(orderParam); let order_id = null; if (return_code === 'SUCCESS' && restData.result_code === 'SUCCESS') { const { prepay_id, nonce_str } = restData; // 微信小程序支付要单独进地签名,并返回给小程序端 const sign = WXPayUtil.generateSignature({ appId: mpAppId, nonceStr: nonce_str, package: `prepay_id=${prepay_id}`, signType: 'MD5', timeStamp: time_stamp }, KEY); let orderData = { out_trade_no, time_stamp, nonce_str, sign, sign_type, body, total_fee, prepay_id, sign, status: 0, // 订单文档的status 0 未支付 1 已支付 2 已关闭 _openid: openid, }; let order = await orderCollection.add(orderData); order_id = order.id; } return new Res({ code: return_code === 'SUCCESS' ? 0 : 1, data: { out_trade_no, time_stamp, order_id, ...restData } }); } // [在此处放置 payorder 的相关代码] case 'payorder': { // 从端里出来相关的订单相信 const { out_trade_no, prepay_id, body, total_fee } = data; // 到微信支付侧查询是否存在该订单,并查询订单状态,看看是否已经支付成功了。 const { return_code, ...restData } = await pay.orderQuery({ out_trade_no }); // 若订单存在并支付成功,则开始处理支付 if (restData.trade_state === 'SUCCESS') { let result = await orderCollection .where({ out_trade_no }) .update({ status: 1, trade_state: restData.trade_state, trade_state_desc: restData.trade_state_desc }); let curDate = new Date(); let time = `${curDate.getFullYear()}-${curDate.getMonth() + 1}-${curDate.getDate()} ${curDate.getHours()}:${curDate.getMinutes()}:${curDate.getSeconds()}`; } return new Res({ code: return_code === 'SUCCESS' ? 0 : 1, data: restData }); } case 'orderquery': { const { transaction_id, out_trade_no } = data; // 查询订单 const { data: dbData } = await orderCollection .where({ out_trade_no }) .get(); const { return_code, ...restData } = await pay.orderQuery({ transaction_id, out_trade_no }); return new Res({ code: return_code === 'SUCCESS' ? 0 : 1, data: { ...restData, ...dbData[0] } }); } case 'closeorder': { // 关闭订单 const { out_trade_no } = data; const { return_code, ...restData } = await pay.closeOrder({ out_trade_no }); if (return_code === 'SUCCESS' && restData.result_code === 'SUCCESS') { await orderCollection .where({ out_trade_no }) .update({ status: 2, trade_state: 'CLOSED', trade_state_desc: '订单已关闭' }); } return new Res({ code: return_code === 'SUCCESS' ? 0 : 1, data: restData }); } } } [代码] 其实我们支付的关键功能都在上面这些代码里面了。 [图片] 再来看下,支付的相关流程截图 [图片] 上图就涉及到了我们的订单列表,支付状态,支付成功后的回调。 今天就先讲到这里,后面会继续给大家讲解支付的其他功能。比如支付成功后的消息推送,也是可以借助云开发实现的。 由于源码里涉及到一些私密信息,这里就不单独贴出源码下载链接了,大家感兴趣的话,可以私信我,或者在底部留言。单独找我要源码也行(微信2501902696) 视频讲解地址:https://edu.csdn.net/course/detail/24770
2019-06-11