微信小程序不用 webview 加载 web 页面

735 阅读7分钟

以前经常接收一些营销类的业务,这些业务的特点是:

  • 几个简单的网页(商品列表或者是一些小游戏类的互动页面)
  • 覆盖多种客端(H5、各类APP、微信小程序等)
  • 有原生能力调用的诉求(例如:呼起分享)
  • 全屏 UI 的诉求(游戏互动类业务通常都是按全屏UI设计的)

这些业务都是以 h5 的形式交付上线的,各个端的接入方式统一为 webview。但是微信小程序的 webview 组件存在诸多限制:

  1. 会自动铺满小程序页面

  2. 无法自定义导航头

  3. 无法调用小程序的 API

  4. 无法与小程序逻辑层直接通信

  5. 个人类型的小程序不支持使用:developers.weixin.qq.com/miniprogram…

也就是说投放到微信小程序端的页面都必须在功能与用户体验上做双降级。大部分情况下,微信小程序才是流量的大头,其它端更多只是象征性的投放,在业务投放的角度上看,更希望是流量大头的终端有更好的用户体验。

一直在思考能否把 web端的页面直接渲染为小程序的页面
作为 Taro 开发框架的使用者,上面的问题就是 Taro 框架的初心:一套代码多端运行。但这套代码不存在于微信小程序的本地包内,而是存放在包外呢?

什么是「包内」、「包外」?

微信小程序是以「主包」+「分包」的形式把代码打包发布的,同理地,在微信客户端也是以「主包」+「分包」的形式加载程序并渲染呈现的。「主包』+「分包」我称之为「包内」****,在这之外的环境我称之为「包外」(例如:腾讯云的 cos )。

1. 运行包外代码

运行包外代码就两步:下载代码 & 运行代码。下载代码没问题,但是运行代码需要调用「eval」函数,而微信小微信以安全为由明令禁止使用「eval」和「new Function」。

虽然存在「eval/Function」的替代工具的:eval5、estime、evil-eval。但是微信出台了《关于禁止小程序JavaScript解释器使用规范要求》,要求 2022.07.06 之后禁止使用内置 javascript 解释器,想动态运行包外 javascript 代码是不可能的了。

2. 零代码的启发

前端零代码有两种产出模式:

2.1 构建包模式

2.2 DSL模式

第一种模式通常是面向开发的,因为在产出 js构建包后,通常还会有部署&发布构建包的流程。

第二种模式通常是面向业务运营人员(非技术人员),因为它通常量级比较轻,按需在零代码平台上编排好界面与配置好功能块,点发布后即会产生一份新的 dsl 文件(通常都是 json 文件)。

DSL模式的工作原理如下:

  • 在编排端(服务端)与终端(web或者小程序)各有一套一一对应的UI组件与功能模块;
  • 用户在编排端使用UI组件与功能模块把界面和相关功能项搭建出来,再编译产生出 DSL 文件;
  • DSL 的功能一般就两个:UI组件的布局 & 功能模块配置(例如页面分享文案);
  • 在终端按照 DSL 的描述,调用本地UI组件与功能模块将应用还原

DSL模式既做到了一套代码多端运行,又实现了包外存储。

3. DSL MAKER

假设在零代码的「DSL模式」上,将「UI组件」、「功能模块」改为:

  1. 基础组件(例如微信小程序的:view, image 等)
  2. Javascript/Typescript 语句
  3. 终端API(例如:微信小程序API)
  4. 终端全局方法或对象
  5. 前端框架(Vue 或 React)

理论上这样的零代码平台可以搭建出页面,并产出对应的 dsl 文件,但这样的零代码平台不存在,也不应该存在,因为它要编排的东西太底层、太零碎了。在这里最适合的方式是使用 React 或 Vue 开发页面,然后把页面转译为 dsl。当然需要一个工具来完成 React/Vue 页面转译为 dsl ---- DSL MAKER。

使用「DSL MAKER」简单实现一个页面:

import { useMemoizedFn } from "ahooks";
import React, { memo, useEffect } from "react";
import { useShow, useHide, useShareAppMessage, currentEnv } from '../../utils';
import { Button } from "../../base-components";

export default memo(() => {
  const handleClick = useMemoizedFn((e) => {
    navigate('/page-c/', { pageTitle: '页面C' });
  });
  const handleBack = useMemoizedFn(() => {
    if (currentEnv === 'wechat-miniprogram') {
      wx.navigateBack();
    } else {
      history.back();
    }
  });
  const handleGetUserInfo = useMemoizedFn((e) => {
    console.log('=======>>>> handleGetUserInfo', e);
  });
  const handleChooseAvatar = (e) => {
    console.log('=====头像信息是:', e);
  };
  useShow(() => {
    console.log('page-a显示');
  });
  useHide(() => {
    console.log('page-a隐藏');
  });
  useShareAppMessage(({ from }) => {
    console.log('----from:', from);
    return {
      title: '分享的页面是 PAGE-A'
    }
  });
  useEffect(() => {
    wx?.setNavigationBarColor({ frontColor: '#ffffff', backgroundColor: '#ffffff' });
  }, []);
  return (
    <div style={{
      backgroundColor: '#efefef',
      minHeight: '100vh',
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'flex-start',
      alignItems: 'center'
    }}>
      <div style={{
        position: 'relative',
        width: '100vw',
        height: `calc(100vw * 0.5625)`,
        paddingTop: 88,
        boxSizing: 'border-box'
      }}>
        <img
          src={require('../../images/header.png')}
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            width: '100%',
            height: '100%'
          }}
        />
      </div>
      <div style={{ fontSize: 14, color: '#999', margin: 12 }}>
        自定义头部的页面
      </div>
      <Button onClick={handleClick} style={{ marginTop: 12 }}>
        前往「page-c」
      </Button>
      <Button onClick={handleBack} style={{ marginTop: 12 }}>返回上一页</Button>
      <Button
        onGetUserInfo={handleGetUserInfo}
        open-type="getUserInfo"
        type="primary"
        style={{ marginTop: 12 }}
      >获取用户信息</Button>
      <Button
        open-type="chooseAvatar"
        type="primary"
        style={{ marginTop: 12 }}
        onChooseAvatar={handleChooseAvatar}
      >获取用户头像</Button>
    </div>
  );
});

4. DSL 处理器

有了将网页转换为 DSL 的工具后,在小程序端需要有将 DSL 转换为目标应用的组件 ---- 「DSL 处理器」,框架设计如下:

处理器的最核心就两个部件: 解析器和渲染器。

解析器的核心是把 dsl 转化为一个解析模块(resolved module)以提供给外部(通常是指渲染器)调用。

渲染器的核心其实就是「动态渲染组件」。

**所谓动态渲染组件?
**组件在逻辑层被完整定义,并且最终在视图层上被渲染出来

选对第三方开发框架,「动态渲染组件」是可以完美解决的。目前流行的小程序第三框架有:wepy、mpvuew、Taro、uni-app、kbone、alita、remax 等。

能实现「动态渲染组件」的开发框架有:Taro(3.x 以上)、 kbone 和 remax

Taro 3.x 与 remax 是通过 react-reconciler 实现了一个小程序渲染器,而 Kbone 内部实现了轻量级的 DOM 和 BOM API,把 DOM 更改绑定到小程序的视图更改,这两种技术有各自的优缺点。

如果是选择一个小程序的开发框架的话,我会首选 Taro,因为它大而全并且社区活跃度高,生态完整;其次我会选 remax,因为它更轻量级,对原生小程序的生态支持更友好;最后我会选 kbone,因为 kbone 在生态和性能上都比较不上前二者。

如果是选择「渲染器」的话,我会选择 kbone,原因很简单,Taro & remax 相对而言都太重了,以 Taro 为例,「动态渲染组件」依赖于「Taro 运行时」,如下:

渲染器必须在 Taro 运行时上工作(整个小程序都是 Taro 构建出来的,或者是 Taro 构建出来的独立分包)。面向 Taro/Remax 生态来做渲染器,自然是没问题的,但是我的初心是面向微信小程序原生态来实现渲染器。而基于 kbone 可以生成一个独立完整的渲染器,如下:

这个渲染器就是一个原生的自定义小程序组件,理论上它可以在 Remax + Taro 环境上运行。

5. DEMO

现在已经实现了「像网页一样加载微信小程序页面」,当实现了这一步之后,很自然就可以想到「组件」也可以从包外获取。

包外组件:

import { useMemoizedFn } from "ahooks";
import React, { memo } from "react";

export default memo(() => {
  const handleClick = useMemoizedFn(() => {
    wx?.showToast({ title: '看到提示了吗?', icon: 'none', duration: 4000 })
  });
  return (
    <div
      style={{
        width: 100,
        height: 100,
        borderRadius: '100%',
        backgroundColor: 'yellow',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        alignItems: 'center'
      }}
      onClick={handleClick}
    >
      圆形
    </div>
  );
});

这个是 kbone 作为渲染内核带来的好处:可以被任何支持原生小程序组件的环境引用。

「DSL MAKER」除了可以产出组件或页面,它也支持 SDK:

export default function() {
  wx?.showToast({ icon: 'success', title: 'sdk的结果就是显示 toast' });
}

以下是通过加载 DSL 方式动态加载包外页面(或组件)的 DEMO: