Skip to content

Latest commit

 

History

History
1021 lines (616 loc) · 23.7 KB

Uniapp中WebView双向通信与消息封装.md

File metadata and controls

1021 lines (616 loc) · 23.7 KB

Uniapp中WebView双向通信与消息封装


场景需求


之前使用 Vue3 开发了一个手机版网页(非 Uniapp 开发),然后现在有新 Uniapp 项目需要通过 web-view 嵌入该手机版网页。

关键是这个 Uniapp 项目需要发布成不同目标平台:APP、H5、微信小程序!


对于这个 Uniapp 项目而言,它一套代码需要考虑:

  • 发布成 APP 时如何与 WebView 通信
  • 发布成 H5 时如何与 WebView 通信
  • 发布成 微信小程序 时如何与 WebView 通信

对于这个 手机版网页,它一套代码需要考虑:

  • 父级是普通 H5 项目,通过 iframe 标签嵌入,如何与父级通信
  • 父级是Uniapp 项目,通过 web-view 标签嵌入
    • 发布成 APP 时如何与 父级 通信
    • 发布成 H5 时如何与 父级 通信
    • 发布成 微信小程序 时如何与 父级 通信

也就是说:这个 手机版网页 需要兼容上述 4 种 通信方式。


需求就是这些了,下面说一下我最终总结出来的实现方式。


为了方便沟通,我将这个手机版网页 称为 H5端,将 Uniapp 项目称为 Uniapp端

或者我会把 手机版网页成为 子页面,将 Uniapp 项目成为 父级页面


Uniapp端的代码逻辑


先说遇到的坑:

  • Uniapp 官方文档非常不靠谱!!!
  • 百度/谷歌搜出来的介绍 Uniapp WebView 通信的文章大多数也不靠谱!!!
  • 我真的怀疑那些口口声声的教程文章,是否他们当时真的运行起来了!!!

这些不靠谱的地方绝大多数都在于:如何在 Uniapp 中获取 <web-view> 对象,因为绝大多数示例代码都是在 "想象中" 运行的!!!

因为对于不同平台 H5、App、微信小程序 等获取 <web-view> 的方式都不一样。


  • 有些文章中提到的获取函数,实际运行根本不存在
  • 有些都是靠 setTimeout 做延迟的方式去获取的
  • 有些都是靠通过 创建 的方式获取的

我只能说:文章五花八门,但自己实际尝试很多都不行!


废话不多说,直接开始我最终是怎么实现的 。


关键技术1:Uniapp 条件编译处理多端差异

这里使用到的是 Uniapp 的魔法注释。

对于 <script> 标签内的 JS 可以通过下面的方式进行差异化编写代码:

// #ifdef H5
....此处的JS代码只有在编译目标为 H5 时才会生效 
// #endif

// #ifdef APP
....此处的JS代码只有在编译目标为 APP 时才会生效 
// #endif

// #ifdef MP-WEIXIN
....此处的JS代码只有在编译目标为 微信小程序 时才会生效 
// #endif

对于 <template> 中的标签组件可以通过下面的方式进行差异化编写代码:

<!--  #ifdef  H5 -->
<web-view ... />
<!--  #endif -->

<!--  #ifdef  APP -->
<web-view ... />
<!--  #endif -->

...

关于更多条件编译,可查看官方文档:https://zh.uniapp.dcloud.io/tutorial/platform.html


关键技术2:在何时机、如何获取 web-view 对象?

1、在何时机?

只有在确保 <web-view> 中的网页 onLoad 加载完成后 uniapp 才能获取到该 web-view 的实例对象并开始向其发送数据。

网上不少教程里都是通过:

  • 设置 setTimeout 多少秒后去执行获取函数, 那这里就是靠猜测和运气,是玄学
  • 通过 setInterval 每个100 毫秒不断去轮询尝试执行获取函数,直至获取成功

我认为最正确的做法是:当子页面加载完成且一切就绪后,主动向父级页面发送一条消息,然后父级页面再去执行获取 web-view 实例对象的代码。


在 uniapp 端,也就是父级页面,无论我们是否执行了获取 web-view 实例对象的代码,都不影响父级页面接收子页面发过来的消息。只要子页面发送过来消息那就证明子页面此刻一定是 一切准备好 的状态了。

2、如何获取?

对于 H5 通过下面方式添加消息侦听:

window.addEventListener('message', this.getH5Message)

消息处理函数:

getH5Message(event) {
    const curData = event.data.data
    ...
},

使用下面代码获取 web-view:

this.webview = window.document.getElementsByTagName("iframe")[0]

对于 APP 通过下面方式添加消息侦听:

<web-view ... @message="getAppMessage"></web-view>

消息处理函数:

getAppMessage(event) {
    //对于 APP 里的消息事件采用的是数组,我们这里只取该数组中最后一项作为最新接收事件的值
    const curData = event.detail.data[event.detail.data.length - 1]
    ...
},

使用下面代码获取 web-view:

this.webview = this.$scope.$getAppWebview().children()[0]

由于 微信小程序 我这边还没实际开始做,所以获取方式就不贴出来了,自己网上查吧。


关键技术3:如何给子页面发送消息?

通过上面步骤我们已经得到了 web-view 对象,那么发送数据就比较容易了。

向子页面发送消息的方式有很多,例如 调用子页面某个 window 下的函数,这个函数可以是 window 自带的,也可以是我们自己添加的。

但是为了统一,在我这个项目场景中我都是用 postMessage 方式来相互通信的。

且 postMessage 的数据格式为:

{
   type: string
   data: {
       ...
   }
}

通过 type 值来区分消息类型

特别提醒:postMessage() 只能发送字符串数据,所以在发送前需要通过 JSON.stringify() 把数据转为 JSON 字符串。


对于 H5 通过下面方式向子页面发送消息:

this.webview.contentWindow.postMessage(dataStr,"*")

对于 APP 通过下面方式向子页面发送消息:

this.webview.evalJS(`window.postMessage("${dataStr}", "*")`)

完整的代码:

xxx.vue

<template>
    <!--  #ifdef  H5 -->
    <web-view :src="pageSrc"></web-view>
    <!--  #endif -->

    <!--  #ifdef  APP -->
    <web-view :src="pageSrc" :update-title="false" @message="getAppMessage"></web-view>
    <!--  #endif -->
</template>

<script>
export default {
    data() {
        return {
            pageSrc: 'xxx.html?parent=uniapp',
            webview: null,
        }
    },

    onLoad(option) {
        // #ifdef H5
        window.addEventListener('message', this.getH5Message)
        // #endif
    },

    methods: {

        checkWebView() {

            if (this.webview === null) {

                // #ifdef H5
                this.webview = window.document.getElementsByTagName("iframe")[0]
                // #endif

                // #ifdef APP
                this.webview = this.$scope.$getAppWebview().children()[0]
                // #endif

                if (this.webview === null) {
                    uni.showToast({
                        title: '页面挂载发生错误,请刷新重试',
                        icon: 'error'
                    })
                }

            }

        },

        handleRealMessag(curData) {
            const { type, data = {} } = curData

            //console.log('收到webview发来的消息', type, data)

            switch (type) {
                case 'subpageReady':
                    this.checkWebView()
                    break
                case 'xxx':
                    this.sendMessage({
                        type: 'xxxx',
                        ...
                    })
                    break
                default:
                    console.log(`收到未处理的信息: ${JSON.stringify(curData)}`)
                    break
            }
        },

        // #ifdef H5
        getH5Message(event) {

            if (event.data) {
                this.handleRealMessag(event.data.data)
            } else {
                console.error('从H5收到的信息缺少data值,请子项(webview)检查传值内容', event)
                return
            }

        },
        // #endif

        // #ifdef APP
        getAppMessage(event) {

            if (event.detail.data.length === 0) {
                console.error('从APP收到的信息缺少data值,请子项(webview)检查传值内容', event)
                return
            }

            const curData = event.detail.data[event.detail.data.length - 1]
            this.handleRealMessag(curData)

        },
        // #endif


        sendMessage(msg) {

            if(this.webview === null) {
                uni.showToast({
                        title: '页面挂载发生错误,请刷新重试',
                        icon: 'error'
                    })
                return
            }

            const dataStr = JSON.stringify(msg)

            // #ifdef H5
            if(this.webview.contentWindow){
                this.webview.contentWindow.postMessage(dataStr,"*")
            }
            // #endif

            // #ifdef APP
            this.webview.evalJS(`window.postMessage("${dataStr}", "*")`)
            // #endif

        },
    },
}
</script>

代码梳理:

  • 我们通过 Uniapp 条件编译 魔法注释 分别添加了不同的 <web-view> 和对应的事件侦听
  • 无论是 H5 还是 APP 接收到消息,拿到真正的 data 值后,都会交给 handleRealMessag() 统一处理
  • 当子页面一切就绪后,子页面会向父级发送一个 type 值为 'subpageReady' 的消息
  • 此时父页面开始调用执行 checkWebView() 来获取 web-view 实例对象
  • 若后续父页面收到了子页面发过来的其他消息,根据 .type 值来做相应处理,通过除 .type 值以外的其他属性获取参数
  • 若父页面需要向子页面发送消息,数据格式为 { type: xxx, ... },然后通过 sendMessage() 函数向子页面发送消息

特别提醒:

上述代码中 web-view 的 src 值为 xxx.html?parent=uniapp,那这个 URL 参数 parent=uniapp 是用来干什么的?我们下面讲解 H5 端的时候会有说明。


H5端的代码逻辑

说一个大前提:H5 端是我用 vue3 写的,也就是说想添加什么代码都是可以的。

但是如何你 Uniapp 中 web-view 嵌的是别人写的网页,且你无法修改代码,那么可能下面讲的不适合你。


需要解决的第1个问题:如何区分当前所处的环境?

也就是说我们需要先知道 H5 子页面 当前是运行在什么环境中的。


区分 普通 web 环境 还是 Uniapp 环境:

我们可以通过 URL 参数来区分当前是运行在普通 web 中还是运行在 uniapp 中。

  • xxx.html?parent=uniapp 当前为 uniapp 环境
  • URL 参数 parent 为 undefined 或者 xxx.html?parent=web 表示运行在普通的 web 环境中

在 Uniapp 中又如何区分是 Uniapp H5 还是 Uniapp APP:

document.addEventListener('UniAppJSBridgeReady', function () {
    uni.webView.getEnv(function (res) {

        //console.log('当前环境:' + JSON.stringify(res))

        let uniappType = 'null'
        if (res.h5) {
            uniappType = 'h5'
        } else if (res.plus) {
            uniappType = 'plus' // 'plus' 就是 APP 环境
        }
        ....

    })
})

我们该如何引入 uni.webview.x.js

对于普通的 web 项目:

  • 我们是不需要引入 uni.webview.x.js
  • 也不需要 document.addEventListener('UniAppJSBridgeReady' ...)

很简单,我们创建一个名为 uniapp-head.vue 的组件来做这件事。

uniapp-head.vue

<script setup>

const script = document.createElement('script')
script.type = 'text/javascript'
script.src = './uni.webview.1.5.6.js'
document.head.appendChild(script)

document.addEventListener('UniAppJSBridgeReady', function () {

    uni.webView.getEnv(function (res) {

        //console.log('当前环境:' + JSON.stringify(res))

        let uniappType = 'null'
        if (res.h5) {
            uniappType = 'h5'
        } else if (res.plus) {
            uniappType = 'plus'
        }

        ...

        //修改数据状态,告知 uni.webView 已准备完成

    })

})
</script>

<template>

</template>

<style scoped lang='scss'></style>

代码梳理:

  • 我们这个组件实际只用到了 <script> 标签
  • 先向 标签追加了加载 uni.webview.x.js 的标签
  • 然后添加 uni 的 UniAppJSBridgeReady 事件侦听
  • 在侦听中去判断当前是 uniapp 的哪种环境

使用 uniapp-head.vue

我们在主页面的 .vue 中使用该组件。

<script setup >

import { getURLParams } from '@/utils';
import UniappHead from './uniapp-head.vue'

const urlParams = getURLParams()
const parentType = urlParams.parent

</script>

<template>
    <UniappHead v-if='parentType === "uniapp"' />
    <view>
        ...
    </view>
</template>

<style scoped lang='scss'></style>

代码梳理:

  • getURLParams 是我编写的一个获取 url 参数的函数,得到 URL 中 parent 的值
  • <UniappHead v-if='parentType === "uniapp"' /> 这样可以确保传统 web (非 uniapp) 情况下不会去触发执行 uniapp-head.vue 中的 JS

需要解决的第2个问题:什么叫 "子页面准备好了" ?

对于传统 web 而言,核心页面的 onMounted() 函数中即表示 准备好了。

但是对于 uniapp 而言,除了核心页面的 onMounted() 还需要考虑我们 uniapp-head.vue 中添加的 <script type="text/javascript" src="./uni.webview.1.5.6.js"></script> 加载完成并且触发 'UniAppJSBridgeReady' 事件才算准备好了。


所以我们需要使用 pinia 定义几个数据状态:

  • parentType:父级是 web 还是 uniapp

    通过 URL 参数 来获取

  • uniappType:当前 uniapp 环境是 h5 还是 plus(App)

  • pageReady:页面挂载完成

    页面组件的 onMounted 回调函数中我们修改 pageReady 状态值

  • uniappReady:uniapp 环境完成

    'UniAppJSBridgeReady' 事件中我们修改 uniappType、uniappReady 状态值

pinia 全局状态代码片段:

handleReady() {
    //一切准备就绪,此时向父级页面发送 type 值为 "subpageReady" 的消息。
},

checkReady() {
    switch (this.parentType) {
        case 'uniapp':
            if (this.pageReady && this.uniappReady) {
                this.handleReady()
            }
            break
        case 'web':
        default:
            this.handleReady()
            break
    }
},

setParentType(type) {
    this.parentType = type
},

setUniappType(type) {
    this.uniappType = type
},

setUniappReady() {
    this.uniappReady = true
    this.checkReady()
},

setPageReady() {
    this.pageReady = true
    this.checkReady()
}

代码梳理:

  • 无论是 setUniappReady() 还是 setPageReady() 都会去执行一次 checkReady()
  • 在 checkReady() 内部回去判断当前究竟是否一切就绪了,如果是则去执行 handleReady() 函数
  • 上述 handleReady() 函数中的代码暂时没写,稍后我们补上如何向父级页面发送 "子页面已准备就绪"的消息

至此,我们 H5 端的基础环境已经实现了。

那么接下来就剩下 收发 消息了。

先说一下消息数据格式:

由于 Uniapp 中 APP 环境下 .postMessage() 消息事件的值只能在 .data 属性上,为了区分消息类型,我们需要给 data 添加 type 属性值,即 data 的格式为 { type: xxx, ... },而我写的子页面中负责发送消息的 EventDispatcher 中规定的消息数据格式为 { type: xxx, data: { ... } }。

这就出现了一些矛盾,需要我们在消息处理时进行二次加工处理。


我们定义一个名为 ParentMessage 的类 来全局管理收发消息。


首先我们需要一个 JS 的 EventDispatcher 用来 抛出/添加 监听。

EventDispatcher.js

很多 库 都有 EventDispatcher,而我用的来自于:https://github.com/mrdoob/eventdispatcher.js/

class EventDispatcher {

	addEventListener( type, listener ) {

		if ( this._listeners === undefined ) this._listeners = {};

		const listeners = this._listeners;

		if ( listeners[ type ] === undefined ) {

			listeners[ type ] = [];

		}

		if ( listeners[ type ].indexOf( listener ) === - 1 ) {

			listeners[ type ].push( listener );

		}

	}

	hasEventListener( type, listener ) {

		if ( this._listeners === undefined ) return false;

		const listeners = this._listeners;

		return listeners[ type ] !== undefined && listeners[ type ].indexOf( listener ) !== - 1;

	}

	removeEventListener( type, listener ) {

		if ( this._listeners === undefined ) return;

		const listeners = this._listeners;
		const listenerArray = listeners[ type ];

		if ( listenerArray !== undefined ) {

			const index = listenerArray.indexOf( listener );

			if ( index !== - 1 ) {

				listenerArray.splice( index, 1 );

			}

		}

	}

	dispatchEvent( event ) {

		if ( this._listeners === undefined ) return;

		const listeners = this._listeners;
		const listenerArray = listeners[ event.type ];

		if ( listenerArray !== undefined ) {

			event.target = this;

			const array = listenerArray.slice( 0 );

			for ( let i = 0, l = array.length; i < l; i ++ ) {

				array[ i ].call( this, event );

			}

			event.target = null;

		}

	}

}


export { EventDispatcher };

定义我们的全局消息管理类。

ParentMessage.ts

这里直接贴出我的源码,我用的到了 typescript。

import { EventDispatcher } from "./ParentMessage.js";

export type ParentType = 'uniapp' | 'web'
export type UniappType = 'null' | 'h5' | 'plus'
export interface ParentMessageEvent extends Event {
    data: any
}
export interface PostMessageData {
    type: string
    [key: string]: any
}

class ParentMessage extends EventDispatcher {

    private _parentType: ParentType
    private _uniappType: UniappType

    constructor(type: ParentType = 'web') {

        super()

        this._parentType = type
        this._uniappType = 'null'

        window.addEventListener('message', this.handleMessage)

    }

    private handleMessage = (eve: MessageEvent) => {

        if (!eve.data || eve.data === '') return

        const dataObj = JSON.parse(eve.data)

        let type = 'unknown'
        if (typeof dataObj.type === 'string') {
            type = dataObj.type
        }

        const event: ParentMessageEvent = {
            type,
            data: dataObj,
            //@ts-ignore
            target: this
        }

        //@ts-ignore
        this.dispatchEvent(event)

    }

    public postMessage(message: PostMessageData) {

        switch (this._parentType) {
            case 'web':
                if (window.parent) {
                    window.parent.postMessage(message, '*')
                } else {
                    console.error(`window.parent 不存在,无法发送消息`)
                }
                break
            case 'uniapp':

                //@ts-ignore
                if (uni) {

                    //console.log(`webview(${this._uniappType})尝试向uniapp发送消息`, message)

                    switch (this._uniappType) {
                        case 'h5':
                            if (window.parent) {
                                window.parent.postMessage({
                                    type: message.type,
                                    data: {
                                        ...message,
                                        type: message.type
                                    }
                                }, '*')
                            }
                            break
                        case 'plus':
                            //@ts-ignore
                            uni.webView.postMessage({
                                data: {
                                    ...message,
                                    type: message.type
                                }
                            });
                            break
                        default:
                            break
                    }

                } else {
                    console.error('检测到uniapp环境有问题,请刷新重试')
                }
                break
        }

    }

    public get uniappType(): UniappType {
        return this._uniappType
    }

    public set uniappType(type: UniappType) {
        this._uniappType = type
    }

    public dispose() {
        window.removeEventListener('message', this.handleMessage)
    }

}

export default ParentMessage

因为 window.parent.postMessage 与 uni.webView.postMessage 参数格式要求不同,所以在 postMessage() 中针对 uniapp 进行了 消息的 "二次加工处理"。

尽管看上去消息格式 { type: xxx, data: { type: xxx, ... }} 中,同一个 type 被配置了 2 处,但是做到了消息数据格式统一,你可以根据自己实际情况来优化这块。


ParentMessage 代码梳理:

  • 构造函数参数 type 接收当前是 web 项目还是 uniapp 项目
  • 构造函数执行 window.addEventListener('message', this.handleMessage) 用来监听所有的 postMessage 事件(消息)
  • 内部函数 handleMessage 用来处理转化消息对应的 数据值,并通过继承于 EventDispatcher 的 .dispatchEvent() 抛出该消息
  • .uniappType:用于设置当前 uniapp 类型 (h5 或 plus)
  • .postMessage():用于对外 发送消息,抹平不同环境下的差异
  • .dispose():用来销毁注销 postMessage 事件侦听

这样就实现了:添加所有 postMessage 事件的监听、处理转化消息格式、抛出转化后的消息

实现了业务代码解耦。


主页面初始化 ParentMessage

xxx.vue

<script setup lang='ts'>

onMounted(() => {

    //从 URL 参数中获取当前运行环境
    const parentType = getURLParams().parent || 'web'
    
    //初始化 parentMessage 并挂在到 window
    window.parentMessage = new ParentMessage(parentType as ParentType) 
    
    //开始添加各种 消息 处理
    window.parentMessage.addEventListener('doXXAA', (eve) => {
        console.log(eve.data)
    })
    
    window.parentMessage.addEventListener('doXXBB', (eve) => {
        ...
    })
    
    ....
    
    //修改数据状态 pageReady 的值
    ...
    
})

</script>

<template>
...
</template>

<style scoped lang='scss'>
</style>

任何组件或JS 向父级发送消息

无论任何组件 或者 JS 代码块想向父级发送消息,则都可以通过下面方式发送:

window.parentMessage.postMessage({
    type: 'xxxx',
    data : { ... }
})

也就是说对于 任何子组件 或 JS,他们根本不知道自己发送的消息是发给谁的,谁来处理的,他们唯一知道的就是调用 window.parentMessage.postMessage() 即可。


子页面一切准备就绪,需要向父级页面发送 "我已准备好了",对应的函数代码:

handleReady() {
    //配置当前 uniappType 的值类型,若为 "null" 即表示不需要处理
    window.parentMessage.uniappType = this.uniappType
    
    //抛出一个事件,其中 type 值为 'subpageReady',window.parentMessage 内部会将该消息发送给父级页面
    //这条消息我们仅仅有 type 值,没有携带其他参数
    window.parentMessage.postMessage({ type: 'subpageReady' })
},

整体消息通信流程梳理:

父级页面通过内部定义的 sendMessage() 向子页面发送消息,例如 { type: "doXXAA", someId: 123, ... }),会被子页面 window.parentMessage 监听到并转化为内部的事件,然后再抛出。

子页面中凡是添加过该 type 值监听的地方就能得到 eve 以及得到 evet 携带的 .data 参数。

//子页面中某个组件添加 doXXAA 的处理
window.parentMessage.addEventListener('doXXAA', (eve) => {
    console.log(eve.data.someId)
})

以上就是我的分享,本文主要讲了 2 件事:

  • uniapp 如何针对不同编译目标(H5/APP) 正确获取 web-view,以及对应如何发送消息
  • H5 端如何封装 收发消息