楔子
因为某个项目需求中需要用到 "动态表单(实际上更偏向于低代码)" 的能力,经过了一段时间的 "技术调研" 后选择了我相对熟悉一点的 amis 低代码引擎。
PS:市面上主流的低代码引擎基本上都是大厂开源的,所以技术栈也基本上也都是 React 的,然而我们的项目使用的却是 Vue3 这就很难受了,虽然也找到个别的 Vue 的低代码引擎,但是不是 Vue2 的就是要收费的,而且个人维护也很难有什么保障(当然也有可能有合适的 Vue3 低代码引擎但是我没找到)。
PS:React 技术栈的 amis 怎么样才能用到 Vue3 的项目中呢?我这边采用的是 React 组件转 Vue 组件,虽然 amis 是有 SDK(应该是完整构建的,可以纯 JS 使用不依赖框架) 版本的,但是经过考虑和衡量最终没有选择使用 SDK,现在看来两种方案使用效果实际上是没有本质上的区别大差不差,不过 React 组件转 Vue 组件,技术负担会更重一点。
PS:本文重点应该是 iframe 的使用,以及类似案例的优化思路和方案,不是 amis 的封装使用。
问题
虽然项目的需求得到了一定程度上的满足,但是也衍生出了一些其他的问题,例如 amis-ui 的样式和项目的样式规范不符合而且比较不美观,以及项目加载过慢(经过包装后的 amis 太重,而且公司网络也不太稳定,有时候加载一个几十上百kb的文件都要几十秒)的问题。
样式问题在更新 amis 后以及在配置的时候注意一点得到了一定程度上的缓解,但是问题的本质还是没有好的办法解决方法。
加载过慢的问题在于项目体积太大了,导致初始加载的时候需要加载的网络资源达到 5.7MB ,总加载资源(解压后)达到惊人的 21.2 MB 这实在太夸张了,但是该问题是有较好的方案可以解决的。
解决方案
项目体积过大,首次加载资源过多的解决方法不外乎是:压缩、分包、按需加载、资源加速(CDN)。
经过各种尝试和验证后采用的是 资源加速(CDN) + iframe 来处理,先来说说理由,用 iframe 最大的原因是用来做沙箱机制来隔离 样式污染 的,用 CDN 结合 iframe 做按需加载和资源加速,同时 CDN 也会有浏览器的相关缓存策略不会因为创建实例重复下载同样的资源。
当然也别想的太简单了,具体开发起来还是很复杂的,要考虑的东西和细节也很多,比如:如何与 iframe 进行通讯?如何传递传递对象和方法给 iframe?多实例情况下的多 iframe 如何处理通讯和传参的有效性和可靠性?iframe 的宽高如何自适应?等等
PS:其实之前也尝试过用 iframe 不过当时没有考虑用 CDN 来做资源加载,所以是直接把 node_modules 里面 amis 的 js 文件以及 css 文件读出来,然后再拼接字符串也好,通过 dom 创建标签添加也好都不行,拼接字符串会因为 js 里面的特殊字符而报错,通过 dom 创建标签则是会执行报错,然后就暂时搁置了;现在是打算使用 jsdelivr 的 CDN 链接来做资源加载,正好可以规避上述问题。
具体实施
对 amis 的使用是通过封装过的,并且做成了一个 软件包 的形式上传到了 npm 源上,所以没有搭建项目框架那一类的,当然也不一定要搭建软件包的框架,简简单单当作一个 vue 组件进行开发就完事儿了。
文件主要的基本上只有:AmisFrame.vue
和 iframe.html
; 后面代码示例上会表示文件名的。
第一步:使用 iframe 渲染 amis render
第一步先简简单单用 vue 渲染 iframe,iframe 渲染 amis。
AmisFrame.vue
<template>
<iframe :srcdoc="FrameHtml" :style="freameSystem" />
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import FrameHtml from './iframe.html?raw'
// 组件入参基本是就是直接对应 amis 的渲染器的入参,想了解的可以去看看 amis 的官方文档
const props = defineProps({
schema: {
type: Object,
default: {
type: 'page',
id: 'u:a81fc44c425a',
body: '当前页面没有内容,请在设计器中配置内容',
},
},
config: { type: Object, default: () => ({}) },
props: { type: Object, default: () => ({}) },
})
const renderRef = ref()
const freameSystem = { width: '100%', border: 'none' }
</script>
这里为了方便观看,我把一些有的没有的 meta 标签 都省略了,实际应用建议还是加上为好,并且我一次性把所有需要加载的 CDN 资源都贴上了,其实也就是多了一个 axios 和 normalize.css 而已。
iframe.html
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://cdn.jsdelivr.net/npm/amis@6.0.0/sdk/sdk.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.2/dist/axios.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/normalize.css@8.0.1/normalize.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/amis@6.0.0/sdk/sdk.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/amis@6.0.0/sdk/helper.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/amis@6.0.0/sdk/iconfont.css">
<title>amis</title>
</head>
<body>
<div id="amis-container"></div>
<script type="text/javascript">
// 默认 amis 配置协议
const schemaJson = {
"type": "page",
"id": "u:a81fc44c425a",
"body": "当前页面没有内容,请在设计器中配置内容"
}
window.onload = () => {
const amis = amisRequire('amis/embed')
// 创建 amis 渲染器
let amisScoped = amis.embed('#amis-container', schemaJson)
}
</script>
</body>
</html>
iframe 直接访问的效果大概如下:
第二步:与 iframe 通讯,将参数 "传递" 给 iframe
这里差不多能实现最基础的功能了。
Vue 组件在监听 iframe 加载完成后,通过 postMessage
和 iframe 进行通讯,并传递参数给 iframe,然后还需要对入参使用 watch
进行监听,当入参发生变化的时候需要告知 iframe 进行更新操作;
同时 iframe 还需要和 父级文档
通讯并传递 amis 渲染器的 实例对象
,方便业务需求通过 amis 的实例对象完成一些额外的功能,比如:获取 amis 渲染器内的表单变量、手动触发 amis 渲染器某个组件的事件或者动作等等。
我们来先看看 AmisFrame.vue 文件的变更吧!内容还是挺多的,但是我会取掉旧的注释,并在新增的代码位置上加上新的注释,后续都是如此。
AmisFrame.vue
<template>
<iframe
id="amisFrame" // PS: 添加 id
:srcdoc="FrameHtml"
:style="freameSystem"
@load="onLoad" // PS:监听 iframe 的 load 事件
/>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import FrameHtml from './iframe.html?raw'
const props = defineProps({
schema: {
type: Object,
default: {
type: 'page',
id: 'u:a81fc44c425a',
body: '当前页面没有内容,请在设计器中配置内容',
},
},
config: { type: Object, default: () => ({}) },
props: { type: Object, default: () => ({}) },
})
// 定义参数变量,会明确一点,也可以直接用 props. 甚至省略 props. 不过我个人不太喜欢这种方式。
const amisProps = ref(props.props)
const amisSchema = ref(props.schema)
const amisConfig = ref(props.config)
const renderRef = ref()
let amisScoped = ref() // 用来存储 amis 渲染器实例
const freameSystem = { width: '100%', border: 'none' }
// 监听入参数是否变化
watch(
() => props,
() => {
amisProps.value = props.props
amisSchema.value = props.schema
amisConfig.value = props.config
// 聚合参数
const data = {
schema: amisSchema.value,
props: amisProps.value,
config: amisConfig.value
}
// 发送信息到 iframe
sendMessageToFrame({ type: 'update', data })
},
{ deep: true }
)
// iframe 加载完成
const onLoad = () => {
// 获取 iframe DOM 对象
renderRef.value = document.getElementById('amisFrame')
if (!renderRef.value) throw new Error('无法获取到 iframe DOM 实例!')
// 获取 iframe 的 window 对象
const iframeWindow = renderRef.value.contentWindow
// 聚合参数
const data = {
schema: amisSchema.value,
props: amisProps.value,
config: amisConfig.value
}
// 发送初始化信息
sendMessageToFrame({ type: 'init', data })
// 监听 iframe 发送回来的信息
window.addEventListener('message', (event) => {
// 安全处理
if (event.origin !== window.location.origin) return
// 保存当前 amis 的渲染器实例
amisScoped.value = event.data
})
}
// 定义发送信息到 iframe 的方法,方便复用
const sendMessageToFrame = ({ type, data }) => {
// 判断 iframe DOM 实例和 window 是否存在
if (!renderRef.value || !renderRef.value.contentWindow) return
const iframeWindow = renderRef.value.contentWindow
try {
// 发送消息到 iframe
iframeWindow.postMessage({ type, data }, '*')
} catch (error) {
console.error('无法发送消息到 iframe')
}
}
</script>
在 iframe.html 中,我们需要接收 Vue 组件发送过来的信息和参数,并进行 amis 的渲染器的创建,最终还需要将 amis 渲染器的实例对象返回给 Vue 组件。
这里由于 iframe 的文档结构不会发生变化,所以会省略一些内容,方便观看
iframe.html
<body>
<div id="amis-container"></div>
<script type="text/javascript">
window.onload = () => {
let amisScoped
const amis = amisRequire('amis/embed')
// 监听 父级文档 下发的 postMessage 事件
window.addEventListener('message', (event) => {
const { type, data } = event.data
const { schema, props, config } = data
// 更新状态的时候销毁旧的实例
if (type === 'update') amisScoped.unmount()
// 创建 amis 的渲染器实例
amisScoped = amis.embed('#amis-container', schema, props, config)
// 发送
parent.postMessage({ type, data: amisScoped }, event.origin)
})
}
</script>
</body>
很好这样写完后我们运行的时候,你会发现!!!会报错 !对没错它会报错!报错信息如下:
这个报错是说,你在 postMessage 中发送了无法 clone 的数据导致的,例如:元素对象、函数
等,被 Vue 使用 proxy 劫持过的对象貌似也不行,但是我没验证过,感兴趣的可以去验证一下。
第三步:传递 有效数据
给 iframe
现在仅仅是 postMessage 并不能支撑我们的需求,参数无法有效的传递给 iframe 。
除了 postMessage 以外和 iframe 通讯手段大致还有:url 参数、cookie、LocalStorage、直接操作 iframe 上下文 之类的,其中 cookie、LocalStorage 和操作 iframe 上下文需要是 同源
的情况下才行。
首先不管是否符合使用条件,url、cookie、localStogage 肯定是不行,因为它们只能传递 字符串
依旧需要对 数据
进行序列化处理,那么摆在我们面前的就只有 上下文
了。
我们是通过 vite 的 raw(字符串)
导入 iframe.html 文件的,然后使用 srcdoc
设置 iframe 要渲染的内容,是属于同源范畴的,如果是用 url
的形式导入,在用 src
设置 iframe 要渲染的内容的话,可能是不满足同源范畴的(实际上最终 iframe.html 在打包后会转换成 base64 设置到 src 里面,但是我使用的时候依旧报错,提醒我非同源之类的 iframe 甚至无法渲染,当然这可能和我项目是微前端有关,我并没有去做验证所以不确定这种行为的具体原因,感兴趣的话可以自行验证一下具体行为,我也很好奇(千反田版))。
具体怎么用其实也很简单,在 window
上设置全局变量就好了, iframe 可以通过 parent
获取父级的 window 对象,并 获取数据
和 设置数据
。
在 AmisFrame.vue 中,我们需要 传递参数
给 iframe ,并且 接收
iframe 返回的 amis 渲染器实例对象,所以我们要新增两个方法 setAmisConfig
和 getAmisScoped
方法,并且在和 iframe 通讯之前调用 setAmisConfig 设置参数配置,在接收到 iframe 的信息时调用 getAmisScoped 获取 amis 渲染器实例,对了别忘记吧 postMessage 传递的 data 去掉。
AmisFrame.vue
// 省略 template
<script setup lang="ts">
// 省略未修改的代码
const onLoad = () => {
renderRef.value = document.getElementById('amisFrame')
if (!renderRef.value) throw new Error('无法获取到 iframe DOM 实例!')
const data = {
schema: amisSchema.value,
props: amisProps.value,
config: amisConfig.value,
}
sendMessageToFrame({ type: 'init', data })
window.addEventListener('message', event => {
if (event.origin !== window.location.origin) return
// 这里使用 getAmisScoped 方法
amisScoped.value = getAmisScoped()
})
}
const sendMessageToFrame = ({ type, data }) => {
if (!renderRef.value || !renderRef.value.contentWindow) return
const iframeWindow = renderRef.value.contentWindow
// 在发送消息前设置参数配置
setAmisConfig(data)
try {
iframeWindow.postMessage({ type }, '*')
} catch (error) {
console.error('无法发送消息到 iframe')
}
}
// 设置参数
const setAmisConfig = (data) => {
window.__amis_config = data
}
// 获取 amis 实例
const getAmisScoped = () => {
return window.__amis_scoped
}
</script>
同理,在 iframe.html 中就需要设置 getAmisConfig
和 setAmisCoped
方法并使用了。
iframe.html
<script type="text/javascript">
const schemaJson = { ... }
window.onload = () => {
const amis = amisRequire('amis/embed')
let amisScoped = amis.embed('#amis-container', schemaJson)
// 设置 amis 渲染器实例
const setAmisScoped = () => {
window.parent.__amis_scoped = amisScoped
}
// 获取 amis 参数配置
const getAmisConfig = () => {
return window.parent.__amis_config || {}
}
window.addEventListener('message', (event) => {
const { type } = event.data
// 从 window.parent 获取参数
const { schema, props, config } = getAmisConfig()
if (type === 'update') amisScoped.unmount()
amisScoped = amis.embed('#amis-container', schema, props, config)
// 将 amis 渲染器实例设置到 window.parent
setAmisScoped()
parent.postMessage({ type }, event.origin)
})
}
</script>
好到这里我们的用例就可以正常的运行了。
我的用例 npm 软件包,所以用例代码截个图给你参考一下,不过意义不大,就是一个 button 按钮,点击切换参数配置数据,里面的 Render 就是 AmisFrame 组件。
第四步:多实例
单实例已经实现了,那么如果我们要在一个页面上使用多次 AmisFrame 组件的话会怎么样?定然是 window 下的变量被互相替换,导致取到的 amis 渲染器实例始终是最后一个,甚至 AmisFrame 发送给 iframe 的参数配置都有可能混淆,那要怎么解决呢?。
这个其实也很好解决,利用 "命名空间" 的概念 + "唯一标识符" 即可。
在 AmisFrame 初始化的时候创建一个 key
的字段,并生成一段唯一的 标识字符串
,然后把该字符串传递给 iframe ,再修改我们 获取/设置 参数配置和 amis 实例的方法即可。
因为基础功能实现了,增强的功能就标注具体实现了,不在贴那么多代码了
AmisFrame.vue
// 这里其实可以简单的使用 uuid 的库,不过我不想额外增加体积,而且这个也够用了,毕竟只是当前页面
const key = `id-${Math.random().toString(36).substring(2, 9)}-${Date.now().toString(36)}`
const getAmisScoped = () => {
// 处理微前端的沙箱环境,一般来说不需要这行,这里的微前端框架是 microApp
const _window = window.rawWindow ? window.rawWindow : window
// 在 __amis_scoped 的命名空间里面,通过 key 来获取
return _window.__amis_scoped[key]
}
const setAmisConfig = (config: any) => {
// 处理微前端的沙箱环境,一般来说不需要这行,这里的微前端框架是 microApp
const _window = window.rawWindow ? window.rawWindow : window
// 防止报错
if (!_window.__amis_config) _window.__amis_config = {}
// 在 __amis_config 的命名空间里面,通过 key 来设置
_window.__amis_config[key] = config
}
const onLoad = () => {
renderRef.value = document.getElementById('amisFrame')
if (!renderRef.value) throw new Error('无法获取到 iframe DOM 实例!')
const data = {
schema: amisSchema.value,
props: amisProps.value,
config: amisConfig.value,
}
sendMessageToFrame({ type: 'init', data })
window.addEventListener('message', event => {
// 判断 key 是否一致
if (event.data.key !== key) return
if (event.origin !== window.location.origin) return
amisScoped.value = getAmisScoped()
})
}
const sendMessageToFrame = ({ type, data }: IAmisConfig) => {
if (!renderRef.value || !renderRef.value.contentWindow) return
const iframeWindow = renderRef.value.contentWindow
try {
setAmisConfig(data)
// 发送 key 到 iframe
iframeWindow.postMessage({ type, key }, '*')
} catch (error) {
console.error('无法发送消息到iframe')
}
}
iframe.html
let key
const setAmisScoped = (amisScoped) => {
// 防止报错
if (!parent.__amis_scoped) parent.__amis_scoped = {}
// 通过 key 设置
parent.__speed_amis_scoped[key] = amisScoped
}
const getAmisConfig = () => {
// 通过 key 获取
return parent.__amis_config[key] || {}
}
window.addEventListener('message', function (event) {
// 获取 key
const { type, key: amisKey } = event.data
key = amisKey
// 发送 key
parent.postMessage({ type, key }, event.origin)
}
以上,多实例的功能也支持了,其实还有一个优化点,iframe 发送的 postMessage 是会被父级的所有监听的 message 事件的位置捕获到的,万一有个第三方库也监听了这个事件那就很有可能有冲突了,而且要互相传递 key 进行唯一标识符的确认也有点麻烦的,这里可以使用 MessageChannel
进行单对单的通讯。
MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据,兄弟们可以自己研究一下,我就偷个懒了,写了太多了。
第五步:iframe 的自适应宽高
使用过 iframe 的哥们儿都知道,iframe 的宽高是固定,并不会因为内部渲染的内容而自动撑开,要解决这个问题就需要动态的设置 iframe 的 height 属性,至于宽度简简单单给个 width: 100%
就好了,高度这个东西我们都知道根据用户的交互肯定是会变动的,那么我们要怎么监听这个变动呢?没错就是 MutationObserver
。
MutationObserver 接口提供了监视对 DOM 树所做更改的能力,使用它就能一定程度上监控 iframe 内部渲染内容的高度,得到了高度如何去设置这个文件就很简单了 通讯
就完了是吧!当然 同源
的情况甚至可以直接使用上下文直接在父级里面直接对 iframe 进行监听。
AmisFrame.vue
const onLoad = () => {
renderRef.value = document.getElementById('amisFrame')
if (!renderRef.value) throw new Error('speed: 无法获取到iframe')
const iframeDocument = renderRef.value.contentWindow.document
// 创建 MutationObserver 对象,并设置回调函数
observer.value = new MutationObserver(() => {
// 获取 iframe 内部的 body 元素滚动高度,并设置给 iframe 自身的 height
renderRef.value.style.height = `${iframeDocument.body.scrollHeight}px`
})
// 监听 iframe 内部的 body 元素的 属性变化、子元素变化、子树变化
observer.value.observe(iframeDocument.body, { attributes: true, childList: true, subtree: true })
}
总结
这次的优化提升其实相当的大,优化后的资源加载量只有之前的三分之一左右。
甚至项目的构建时间也从 4分钟 左右降低到了 2分钟 左右的水平。
首屏幕加载效率当然也有很明显的提升,但是由于公司网络不太稳定,也就没有具体的数值来对比了,但是感知是非常明显的。
另外由 amis 是通过 CDN 在 iframe 中加载,天然就是按需引用,且因为只使用的到了 渲染器 ,所以体积也比直接打包的要小非常多,因为打包的会把 编辑器 之类的东西也打包进去,当然也会有一定的取舍,比如 iframe 中为了支持 amis 发起的请求和页面样式重制,需要额外添加 axios 和 normallze.css 的 CDN 链接,不过它们足够小,合起来才 21.9kb 是可以接受的。
当然重点有 iframe 的各种使用姿势,postMassage、命名空间、MessageChannel 以及 MutationObserver。