# 富文本编辑器支持小程序跳转

# 背景

公众号文章修改内容后,链接会发生变化。且希望以按钮样式提供跳转,目前微信公众平台无论是打开超链接还是打开小程序,其展示方式并不支持按钮样式,截图如下。 微信公众平台-编辑超链接

微信公众平台-小程序跳转

# 前端方案

# 方案一:基于iframe二次开发

通过iframe打开公众号文章,进行再开发。但是发现微信域名做了内容安全策略限制,只能在指定域名下才能在iframe打开。此方案并不可行。

# 方案二:基于tinymce编辑器扩展

# 支持能力

  • 富文本编辑器需要哪些支持能力

    1. 字体编辑
    2. 字号编辑
    3. 格式编辑
    4. 插入图片
    5. 支持以图片、文字、按钮展示方式打开超链接或小程序
    6. 预览
  • 富文本输出内容是什么

  • 富文本如何渲染与通信

# 能力预研

  • 富文本编辑器选型与拓展

    采用tinymce编辑器,归结原因是官方持续更新维护,文档说明详尽,功能丰富,易用性,体验较好。 除上述第5点,其余能力tinymce原生已支持。为此需要对编辑器进行扩展,使其支持以图片、文字、按钮展示方式打开超链接或小程序。 实现方式有两种方式,一种是插件扩展,另一种是API扩展。前者代码独立,但有一定上手难度,后者代码冗余,但上手难度低。 预期效果如下,

    tinymce

    点击"插入小程序或H5链接",打开如下弹框,

    插入小程序或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>
1
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端,不会出现此回退问题。

    微信官方文档-WebView组件接收消息触发时机

# 最终实现

出于资源限制和时间成本,后端是采用数据库存储富文本的方式,前端方案调整如下:

  • 富文本输出:输出是一段富文本代码,非富文本链接。
  • 富文本渲染与通信:innerHTML方式替代iframe渲染,于是通信过程则少了iframe向父窗口传递数据这一步。

上述方案是可行的,从开发者角度考虑了代码安全、性能、可维护性等层面。但是我们也需要考虑当下一些实际因素去想问题,做出取舍。

# 相关链接与代码片段

<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> 
1
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);
1
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));
  }
}
1
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>

1
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;
  }
}
1
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