# 富文本编辑器支持小程序跳转
# 背景
公众号文章修改内容后,链接会发生变化。且希望以按钮样式提供跳转,目前微信公众平台无论是打开超链接还是打开小程序,其展示方式并不支持按钮样式,截图如下。
# 前端方案
# 方案一:基于iframe二次开发
通过iframe打开公众号文章,进行再开发。但是发现微信域名做了内容安全策略限制,只能在指定域名下才能在iframe打开。此方案并不可行。
# 方案二:基于tinymce编辑器扩展
# 支持能力
富文本编辑器需要哪些支持能力
- 字体编辑
- 字号编辑
- 格式编辑
- 插入图片
- 支持以图片、文字、按钮展示方式打开超链接或小程序
- 预览
富文本输出内容是什么
富文本如何渲染与通信
# 能力预研
富文本编辑器选型与拓展
采用tinymce编辑器,归结原因是官方持续更新维护,文档说明详尽,功能丰富,易用性,体验较好。 除上述第5点,其余能力tinymce原生已支持。为此需要对编辑器进行扩展,使其支持以图片、文字、按钮展示方式打开超链接或小程序。 实现方式有两种方式,一种是插件扩展,另一种是API扩展。前者代码独立,但有一定上手难度,后者代码冗余,但上手难度低。 预期效果如下,
点击"插入小程序或H5链接",打开如下弹框,
富文本输出
前端通过对富文本生成代码装饰成模板(如下),后端负责生成链接。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>富文本模板</title>
</head>
<body>
<!-- 这里插入生成的富文本,eg -->
<button data-type="applet", data-appid="123456", data-path="pages/index" data-click></button>
</body>
<script>
// 传参处理
function sendParamsToParent(event) {
const e = window.event || event;
const dataset = e.target.dataset;
const data = {};
for (let key in dataset) {
data[key] = dataset[key];
}
window.paraent.postmessage(data, '*');
}
function init(){
// 绑定事件
const nodeList = document.querySelectorAll('*[data-click]');
nodeList.forEach(node => {
node.addEventListener('click', sendParamsToParent);
})
}
window.addEventListener('load', init, false);
</script>
</html>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
有人应该会问,生成过程中植入脚本不也可以吗?一定要生成后插入吗?这是因为tinymce对富文本代码做了保护,不允许最终生成的富文本有script标签,内部做了过滤处理。
富文本渲染与通信
渲染使用iframe打开,通信使用postMessage方式。流程如下,
这里之所以区分跳转类型,是因为在小程序端,webview需要在特定时机才能触发接收消息(微信官方文档说明如下),导致存在回退问题。比如在小程序端,点击iframe里的跳转按钮,打开了某文章H5,这时点击返回按钮,会直接回到打开WebView的上级小程序路径,而不是iframe网页。所以若是在小程序端打开H5,不必传递数据给小程序宿主,直接打开即可。而在APP端,不会出现此回退问题。
# 最终实现
出于资源限制和时间成本,后端是采用数据库存储富文本的方式,前端方案调整如下:
- 富文本输出:输出是一段富文本代码,非富文本链接。
- 富文本渲染与通信:innerHTML方式替代iframe渲染,于是通信过程则少了iframe向父窗口传递数据这一步。
上述方案是可行的,从开发者角度考虑了代码安全、性能、可维护性等层面。但是我们也需要考虑当下一些实际因素去想问题,做出取舍。
# 相关链接与代码片段
tinymce5官网文档:https://www.tiny.cloud/docs/ (opens new window)
react版富文本编辑器:https://github.com/muzhidong/lib-components/tree/component/ReactRichTextEditor/ReactRichTextEditor (opens new window)
加载jweixin库(也可以直接下载,不需要引入代码)
<script id="wx-js" src="//res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
const jsScriptId = "wx-js";
function handleError(e){
const {
target: {
id,
nodeName
}
} = e;
if(id === jsScriptId && nodeName === "SCRIPT"){
const wxJs = document.querySelector(`#${jsScriptId}`);
wxJs.src = "//res2.wx.qq.com/open/js/jweixin-1.6.0.js";
}
};
window.addEventListener('error', handleError, true);
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 事件绑定脚本(富文本以代码形式插入,是需要的,若是以链接呈现,这段功能代码则在前面模板处理)
function init(){
let nodeList;
// 使用定时器是因为富文本是动态插入的,需要轮询访问
let timer = setInterval(() => {
nodeList = document.querySelectorAll('*[data-click]');
nodeList.forEach(node => {
node.addEventListener('click', turn);
})
if (nodeList.length !== 0) {
clearInterval(timer);
timer = null;
}
}, 50);
}
window.addEventListener('load', init, false);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- 事件处理脚本
/* ----- 使用前配置项 ------ */
// 宿主小程序appId
const hostMiniAppId = "";
// 宿主小程序tab页路径数组
const hostMiniAppTabsPath = [];
// 宿主小程序外部小程序中转页
const transferPage = "";
/* ----- 使用前配置项 ------ */
// 跳转类型
const openType = {
APPLET: 'applet',
H5: 'H5',
};
function isMiniProgram() {
return !!navigator.userAgent.match(/miniProgram/);
};
function isApp() {
return !!navigator.userAgent.match(/AppleWebKit.*Mobile.*/);
};
// 跳转处理
function turn(event) {
const e = window.event || event;
const dataset = e.target.dataset;
const data = {};
for (let key in dataset) {
data[key] = dataset[key];
}
if (isMiniProgram()) {
const { type, appid, path, src, name } = data;
switch (type) {
case openType.APPLET:
if (hostMiniAppId === appid) {
// 跳转内部页面
if (hostMiniAppTabsPath.includes(path)) {
wx.miniProgram.switchTab({url: `/${path}`});
} else {
wx.miniProgram.navigateTo({url: `/${path}`});
}
} else {
// 跳转外部小程序
wx.miniProgram.navigateTo({url: `${transferPage}?appid=${appid}&path=${path}&appname=${name}`});
}
break;
case openType.H5:
// 跳转H5
window.location.href = src;
break;
default:
break;
}
} else if (isApp()) {
window.ReactNativeWebView.postMessage(JSON.stringify(data));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
小程序中转页
<!-- H5打开外部小程序中转页 -->
<template>
<block v-if="!!show">
<view class="flex-start-center flex-col container">
<view class="tip">即将跳转{{ appName }}小程序</view>
<view
class="flex-center-center btn"
hover-class="active"
@tap="openMiniProgram"
>确定</view>
</view>
</block>
</template>
<script>
export default {
data() {
return {
// 跳转外部小程序APPID
appName: '',
// 跳转外部小程序路径
appId: '',
// 跳转外部小程序应用名称
path: '',
// 是否显示内容
show: false,
};
},
onLoad(options) {
const {
appid,
path,
appname,
} = options;
this.appId = appid;
this.path = path;
this.appName = appname;
},
onShow(){
if(this.appId === '' && this.path === '') return;
this.openMiniProgram({
fail: () => {
this.show = true;
}
});
},
onHide(){
this.reset();
},
methods: {
openMiniProgram(opts = {}) {
uni.navigateToMiniProgram({
appId: this.appId,
path: this.path,
...opts
});
},
reset(){
this.appName = '';
this.appId = '';
this.path = '';
this.show = false;
}
}
};
</script>
<style lang="less">
page,
.container {
width: 100%;
height: 100%;
background: #ffffff;
overflow: hidden;
}
.btn {
width: 360rpx;
height: 60rpx;
border: 2rpx solid #006d83;
color: #006d83;
font-size: 28rpx;
background: #ffffff;
border-radius: 8rpx;
box-sizing: border-box;
}
.active {
color: #ffffff;
background: #005667;
}
.tip {
padding: 477rpx 0 66rpx;
color: #333333;
line-height: 45rpx;
font-size: 32rpx;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
APP跳转函数
import * as WeChat from 'react-native-wechat-lib';
const openType = {
APPLET: 'applet',
H5: 'H5'
};
function open(data = {}) {
if (!data || Object.keys(data).length === 0) { return; }
const {
type = '',
path = '',
extra = {},
originid = '',
src = '',
} = data;
switch (type) {
case 'navigation':
// 跳转App页面
global.navigation.navigate(path, extra);
break;
case openType.APPLET:
// 跳转小程序
WeChat.launchMiniProgram({
userName: originid,
miniProgramType: 0,
path: `/${path}`
});
break;
case openType.H5:
// 跳转H5
global.navigation.push('WebView', { link: src });
break;
default:
break;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37