原文:https://pantao.parcmg.com/pre…
在做 React Native 应用时,如果需要在 App 里面内嵌 H5 页面,那么 H5 与 App 之间可以通过 Webview 的 PostMessage 功能实现实时的通讯,但是在小程序里面,虽然也提供了一个 webview
组件,但是,在进行 postMessage
通讯时,官方文档里面给出了一条很变态的说明:
网页向小程序
postMessage
时,会在特定时机(小程序后退、组件销毁、分享)触发并收到消息。e.detail = {data}
,data
是多次postMessage
的参数组成的数组
这里面已经说的很明白了,不管我们从 H5 页面里面 postMessage
多少次,小程序都是收不到的,除非:
- 用户做了回退到上一页的操作
- 组件销毁
- 用户点击了分享
这里面其实我没有完全说对,官方其实说的是 小程序后退 ,并没有说是用户做回退操作,经过我的实测,确实人家表达得很清楚了,我们通过微信官方的 SDK 调起的回退也是完全可行的:
wx.miniProgram.navigateBack()
大体思路
从上面的分析和实测中我们可以知道,要实现无需要用户操作即可完成的通讯,第三种情况我们是完全不需要考虑了的,那么来仔细考虑第 1 和第 2 种场景。
第 1 种方式:回退
当我们想通过网页向小程序发送数据,同时还可以回退到上一个页面时,我们可以在 wx.miniProgram.postMessage
之后,立马调用一次 wx.miniProgram.navigateBack()
,此时小程序的操作是:
- 处理
postMessage
信息 - 回退到上一页
我们在处理 postMessage
的时候做一些特殊操作,可以将这些数据保存下来
第 2 种方式:组件销毁
这是我感觉最合适的一种方式,可以让小程序拿到数据,同时还保留在当前页面,只需要销毁一次 webview
即可,大概的流程就是:
- 小程序
postMessage
- 小程序
navigateTo
将小程序页面导向一个特殊的页面 - 小程序的那个特殊页面立马回退到
webview
所在的页面 -
webview
所在的页面的onShow
里面,做一次处理,将webview
销毁,然后再次打开 - 触发
onMessage
拿到数据 - H5 页面再次被打开
这种方式虽然变态,但是至少可以做到实时拿到数据,同时还保留在当前 H5 页面,唯一需要解决的是,在做这整套操作前,H5 页面需要做好状态的缓存,要不然,再次打开之后,H5 的数据就清空了。
第 1 种方式:通过回退,将数据提交给小程序之后传递给 webview
的上一页面
这种方式实现起来其实是很简单的,我们现在新建两个页面:
sandbox/canvas-by-webapp/index.js
const app = getApp();
Page({
data: {
url: '',
dimension: null,
mime: '',
},
handleSaveTap: function() {
wx.navigateTo({
url: '/apps/browser/index',
events: {
receiveData: data => {console.log('receiveData from web browser:', data);
if (typeof data === 'object') {const { url, mime, dimension} = data;
if (url && mime && dimension) {
this.setData({
url,
dimension,
mime,
});
this.save(data);
}
}
}
}
})
},
save: async function({url, mime, dimension}) {
try {await app.saveImages([url]);
app.toast('保存成功!');
} catch (error) {console.log(error);
app.toast(error.message || error);
}
},
});
上面的代码中,核心点,就在于 wx.navigateTo
调用时,里面的 events
参数,这是用来进行与 /apps/browser/index
页面通讯,接收数据用的。
apps/browser/index.js
我省略了绝大多数与本文无关的代码,保存最主要的三个:
Page({onLoad() {if (this.getOpenerEventChannel) {this.eventChannel = this.getOpenerEventChannel();
}
},
handleMessage: function(message) {const { action, data} = message;
if (action === 'postData') {if (this.eventChannel) {this.eventChannel.emit('receiveData', data);
}
}
},
handlePostMessage: function(e) {const { data} = e.detail;
if (Array.isArray(data)) {
const messages = data.map(item => {
try {const object = JSON.parse(item);
this.handleMessage(object);
return object;
} catch (error) {return item;}
});
this.setData({messages: [...messages],
});
}
},
})
其实,onLoad
方法中,我们使用了自微信 SDK 2.7.3
版本开始提供的 getOpenerEventChannel
方法,它可以创建一个与上一个页面的事件通讯通道,这个我们会在 handleMessage
中使用。
handlePostMessage
就是被 bindmessage
至 webview
上面的方法,它用于处理从 H5 页面中 postMessage
过来的消息,由于小程序是将多次 postMessage
的消息放在一起发送过来的,所以,与其它的 Webview 不同点在于,我们拿到的是一个数组:e.detail.data
,handlePostMessage
的作用就是遍历这个数组,取出每一条消息,然后交由 handleMessage
处理。
handleMessage
在拿到 message
对象之后,将 message.action
与 message.data
取出来(* 这里需要注意,这是我们在 H5 里面的设计的一种数据结构,你完全可以在自己的项目中设计自己的结构),根据 action
作不同的操作,我在这里面的处理是,当 action === 'postData'
时,就通过 getOpenerEventChannel
得到的消息通道 this.eventChannel
将数据推送给上一级页面,也就是 /sandbox/canvas-by-webapp
,但是不需要自己执行 navigateBack
,因为这个需要交由 H5
页面去执行。
H5 页面的实现
我的 H5 主要就是使用 html2canvas
库生成 Canvas 图(没办法,自己在小程序里面画太麻烦了),但是这个不在本文讨论过程中,我们就当是已经生成了 canvas
图片了,将其转为 base64
文本了,然后像下面这样做:
wx.miniProgram.postMessage({
data: JSON.stringify({
action: 'postData',
data: 'BASE 64 IMAGE STRING'
})
});
wx.miniProgram.navigateBack()
将数据 postMessage
之后,立即 navigateBack()
,来触发一次回退,也就触发了 bindmessage
事件。
使用销毁 webview
实现实时通讯
接下来,咱就开始本文的重点了,比较变态的方式,但是也没想到更好的办法,所以,大家将就着交流吧。
H5 页面的改变
wx.miniProgram.postMessage({
data: JSON.stringify({
action: 'postData',
data: 'BASE 64 IMAGE STRING'
})
});
wx.miniProgram.navigateTo('/apps/browser/placeholder');
H5 页面只是将 wx.miniProgram.navigateBack()
改成了 wx.miniProgram.navigateTo('/apps/browser/placeholder')
,其它的事情就先都交由小程序处理了。
/apps/browser/placeholder
这个页面的功能其实很简单,当打开它了之后,做一点点小操作,立马回退到上一个页面(就是 webview
所在的页面。
Page({data: { loading: true},
onLoad(options) {const pages = getCurrentPages();
const webviewPage = pages[pages.length - 2];
webviewPage.setData(
{shouldReattachWebview: true},
() => {app.wechat.navigateBack();
}
);
},
});
我们一行一行来看:
const pages = getCurrentPages();
这个可以拿到当前整个小程序的页面栈,由于这个页面我们只允许从小程序的 Webview
页面过来,所以,它的上一个页面一定是 webview
所在的页面:
const webviewPage = pages[pages.length - 2];
拿到 webviewPage
这个页面对象之后,调用它的方法 setData
更新一个值:
webviewPage.setData(
{shouldReattachWebview: true},
() => {app.wechat.navigateBack();
}
);
shouldReattachWebview
这个值为 true
的时候,表示需要重新 attach
一次 webview
,这个页面的事件现在已经做完了,回到 webview
所在的页面
apps/browser/index.js
页面
我同样只保留最核心的代码,具体的逻辑,我就直接写进代码里面了。
Page({
data: {
shouldReattachWebview: false, // 是否需要重新 attach 一次 webview 组件
webviewReattached: false, // 是否已经 attach 过一次 webview 了
hideWebview: false // 是否隐藏 webview 组件
},
onShow() {
// 如果 webview 需要重新 attach
if (this.data.shouldReattachWebview) {
this.setData(
{
// 隐藏 webview
hideWebview: true,
},
() => {
this.setData(
{
// 隐藏之后立马显示它,此时完成一次 webview 的销毁,拿到了 postMessage 中的数据
hideWebview: false,
webviewReattached: true,
},
() => {
// 拿到数据之后,处理 canvasData
this.handleCanvasData();}
);
}
);
}
},
// 当 webview 被销毁时,该方法被触发
handlePostMessage: function(e) {const { data} = e.detail;
if (Array.isArray(data)) {
const messages = data.map(item => {
try {const object = JSON.parse(item);
this.handleMessage(object);
return object;
} catch (error) {return item;}
});
this.setData({messages: [...messages],
});
}
},
// 处理每一条消息
handleMessage: function(message) {const {action, data} = message
// 如果 saveCanvas action
if (action === 'saveCanvas') {
// 将数据先缓存进 Snap 中
const {canvasData} = this.data;
// app.checksum 是我自己封装的方法,计算任何数据的 checksum,我拿它来当作 key
// 这可以保证同一条数据只会被处理一次
const snapKey = app.checksum(data);
// 只要未处理过的数据,才需要再次数据
if (canvasData[snapKey] !== true) {if (canvasData[snapKey] === undefined) {
// 将数据从缓存进 `snap` 中
// 这也是我自己封装的一个方法,可以将数据缓存起来,并且只能被读取一次
app.snap(snapKey, data);
// 设置 canvasData 中 snapKey 字段为 `false`
canvasData[snapKey] = false;
this.setData({canvasData,});
}
}
}
},
// 当 webview 被重新 attach 之后,canvas 数据已经被保存进 snap 中了,handleCanvasData: async function handleCanvasData() {const { canvasData} = this.data;
// 从 canvasData 中拿到所有的 key,并过滤到已经处理过的数据
const keys = Object.keys(canvasData).filter(key => canvasData[key] === false);
if (keys.length === 0) {return;}
for (let i = 0; i < keys.length; i += 1) {
try {const key = keys[i];
const {url} = app.snap(key);
// 通过自己封装的方法,将 url(也就是 Base64 字符)保存至相册
const saved = await app.saveImages(url);
// 更新 canvasData 对象
canvasData[key] = true
this.setData({canvasData})
console.log('saved:', saved);
} catch (error) {app.toast(error.message);
return;
}
}
},
})
对应的 index.wxml
文件内容如下:
<web-view src="{{src}}" wx:if="{{src}}" bindmessage="handlePostMessage" wx:if="{{!hideWebview}}" />
流程回顾与总结
- 打开
webview
页面,打开h5
- h5 页面生成
canvas
图,并转为base64
字符 - 通过
wx.miniProgram.postMessage
将base64
发送给小程序 - 调用
wx.miniProgram.navigateTo
将页面导向一个特殊页面 - 在特殊页面中,将
webview
所在页面的shouldReattachWebview
设置为true
- 在特殊页面中回退至
webview
所在页面 -
webview
所在页面的onShow
事件被触发 - 在
onShow
事件检测shouldReattachWebview
是否为true
,若为true
- 将
hideWebview
设置为true
,引起web-view
组件的销毁 -
handlePostMessage
被触发,解析所有的message
之后交给handleMessage
逐条处理 -
handleMessage
发现action === 'saveCanvas'
的事件,拿到data
- 根据
data
计算checksum
,以checksum
为key
缓存下来数据,并将这个checksum
保存到canvasData
对象中 - 此时
hideWebview
被onShow
里面setData
的回调中的setData
重新置为false
,web-view
重新加attach
,H5 页面重新加载 -
webview
重新attach
之后,this.handleCanvasData
被触发, -
handleCanvasData
检测是否有需要保存的canvas
数据,如果有,保存,修改canvasData
状态
整个流程看旧去很繁琐,但是写起来其实还好,这里面最主要的是需要注意,数据去重,微信的 postMessage
里面拿到的永远 都是 H5 页面从被打开到关闭的所有数据。