前端新世界|Vue3 + Flutter element embedding(内附源码)

2 阅读9分钟

何为新世界:作为前端开发,你不得不知道的 Flutter element embedding

~~~ 源码地址在文末 ~~~

最终效果

Flutter 瀑布流组件有一些瑕疵,加载更多不能由鼠标滑轮触发,当然这个瀑布流组件是 App 上现成的,并没有做桌面端适配

做了什么

Vue 示例

官网以及社区现在有 JS 版本、Angular 版本、React 版本、RN 版本,但没有提供 Vue 版本。

笔者参照这几个版本,构建了 Vue3 + Flutter 的版本,供大家参考。

混合布局

官方的示例好是好,但太官方了,到底我们能用元素插入(Flutter element embedding)的特性做什么,Flutter 官方并没有给出一个指导意见。

笔者结合 Vue 组件和 Flutter 组件,实现一个混合的页面布局:

Vant4 组件库提供搜索组件:

image.png

由 Flutter 提供瀑布流组件:

image.png

实际价值

请注意最终效果上,笔者特意把右侧的实时内存占用情况展示出来:

可以看到,无论是快速滑动加载更多还是怎么滑动,整体内存占用只在 20M+,在内存和卡顿这一点上,可以说远远超出各类前端虚拟列表组件

image.png

💡说实话,从性能上完杀笔者公司主站的瀑布流。

附笔者处理虚拟滚动的问题记录:

<canvas>代替传统的 dom 渲染,这不仅是 Flutter 的方向(wasm native),也是未来的方向,彻底解决 dom 渲染的缺陷及瓶颈问题。

提到 Flutter web,绕不开 SSR、SEO,但 Flutter element embedding 提供的是局部组件,所以不影响整站的 SSR、SEO,甚至可以在页面加载完成后再加载 Flutter 组件替换掉 Web 组件,这样来规避 SEO、SSR 的影响。

总而言之,可以把 Flutter UI 组件当作一个 Web UI 组件来使用,解决 Web 组件解决不了的性能瓶颈。

对 Flutter Web 没信心的同学可以看看郭佬的文章:

  1. Flutter 即将放弃 Html renderer ,你是否支持这个提议?
  2. Flutter Web 的未来,Wasm Native 即将到来

源码分析

整体结构

image.png

可以看到,整体上是一个 Vite + Vue3 标准项目的结构(笔者也是用 Vue Cli 建的项目)。

唯一有区别的地方是多了一个 /flutter 文件目录:

image.png

这里存放着我们 Flutter 组件开发的源码,它也是一个完整的 Flutter 目录结构,也是可以单独运行开发测试的。

Flutter 组件的产物生成在 /public/flutter 中:

image.png

Vue 直接使用的是 Flutter Release 产物,而不能 Flutter Debug,所以在开发上是隔离的,Flutter 组件开发测试完成后,再输出给 Vue 使用。

调试运行

源码已经把 Flutter 产物上传到 git 上了,所以没有 Flutter 环境的同学也可以体验。

项目运行上很简单:

pnpm i

pnpm dev

但如果需要修改 Flutter 组件代码,需要完整的 Flutter 开发环境,这里就略过不提。

Flutter 代码修改后,调用命令pnpm prebuild来重新构建 Flutter 产物即可。

Flutter

整体结构

Flutter 工程只提供视图部分(View、ViewModel、JS 交互通信能力)

image.png

红框标记的文件是从笔者公司 Flutter 多引擎组件库中提出来的一个瀑布流组件,无需细看,只不过写这个示例时想体现 Flutter element embedding 完全可以复用到公司或者个人现有的基建能力,所以直接用现成的组件库。

有看过笔者Flutter 多引擎渲染组件系列的同学可能会注意到,这个组件也完全是可以被 App 当 Flutter UI 组件调用的。

.caches 目录中存放的即为工具链自动生成的辅助代码

依赖说明

  cached_network_image: ^3.3.1
  easy_refresh: ^3.0.4+2
  event_bus: ^2.0.0
  flutter_staggered_grid_view: ^0.6.2
  flutter_svg: ^2.0.9
  web: ^0.5.0

其中web库是必要的。

瀑布流主要由flutter_staggered_grid_view提供,cached_network_image提供图片缓存。这些库都支持 for web,可以放心使用。

刷新以及加载更多由easy_refresh提供,但这个组件在桌面端上并不好用(不支持鼠标滑轮)。

event_bus提供一种方便的事件通信,也可以用状态管理替代。

flutter_svg是 SVG 渲染库,这是因为该示例调用的接口返回的数据中有 SVG 预览元素。

Vue

整体结构

image.png

Vue 代码结构十分简单,一个HomeView视图加载两个视图组件:SearchInput.vueFlutterView.vue,再包括一个网络请求。

SearchInput.vue使用 Vant UI 库的<Search>组件提供搜索框。

FlutterView.vue封装 Flutter 提供的视图,把相关 API 封装成 Vue 的使用方式。

网络请求

网络请求由axios发起,这里调用稿定的模版搜索接口。

image.png

searchTemplateList方法调用接口请求以及拼装 ViewModel 数据。

视图调用

HomeView.vue在本示例中相当一个controller:加载组件以及请求处理。

可以看到,对于使用方来说,完全不用关心 Flutter,一切都是 Vue 组件。

开发解耦

<template>
  <div>
    <SearchInput @search="onSearch" />
    <FlutterView
      ref="flutterListView"
      @initialized="onInitialized"
      @refresh="onRefresh"
      @load-more="onLoadMore"
    />
  </div>
</template>

<script setup lang="ts">
...

const flutterListView = ref<FlutterAppState | null>(null)

var currentNum = ref<number>(1)
var textInput = ref<string>('')

const onInitialized = () => {
  loadData()
}

const onSearch = (text: string) => {
  flutterListView.value?.clear()
  textInput.value = text
  currentNum.value = 1
  loadData()
}

const onRefresh = () => {
  currentNum.value = 1
  loadData()
}

const onLoadMore = () => {
  currentNum.value++
  loadData()
}

const loadData = async () => {
  const res = await searchTemplateList(textInput.value, currentNum.value)
  flutterListView.value?.load(JSON.stringify(res))
}
</script>

关键点

Flutter element embedding 有2个关键点:

  1. 如何把 Flutter 视图显示在一个<div>
  2. 如何跟 JS 通信

Flutter 视图封装成 Vue 组件

FlutterView.vue是本示例封装的 Vue 组件,它的作用有以下3点:

  1. 负责初始化 Flutter engine 并挂载 Flutter 视图。
  2. 封装 Vue 调用 Flutter 的 method。
  3. 封装 Flutter 调用 Vue 的 emit。

这样的好处是把跟 Flutter 相关的操作聚合在一个组件中,无需让外部感知。

还有一点,明显可以看出这些代码都是范式化的,那就是很有可能用工具链自动生成的方式来实现,即可以提高开发效率,又减少维护成本。

初始化

const flutterTarget = ref<HTMLElement>()

const initFlutterApp = async () => {
  const engineInitializer = await new Promise<any>((resolve) => {
    console.log('setup Flutter engine initializer...')
    _flutter.loader.loadEntrypoint({
      entrypointUrl: `${flutterDir}main.dart.js`,
      onEntrypointLoaded: resolve
    })
  })

  console.log('initialize Flutter engine...')
  const appRunner = await engineInitializer?.initializeEngine({
    hostElement: flutterTarget.value,
    assetBase: flutterDir
  })

  console.log('run Flutter engine...')
  await appRunner?.runApp()
}

initFlutterApp()

初始化参照React示例的方式,这里面hostElementassetBaseentrypointUrl缺一不可,笔者当时调这几个参数排查了很久,报错上根本看不出来问题原因:

image.png

这几个玩意儿官方也没说明,因为官方示例中的产物在根目录,而我们产物存放位置是多了 /flutter 这一层。

${flutterDir} = /flutter/配置写在 .env 中。

封装调用 Flutter 方法

const load = (listString: string) => {
  window._appState.load(listString)
}

const clear = () => {
  window._appState.clear()
}

...

defineExpose<FlutterAppState>({
  load,
  clear
})

使用 Vue3 的defineExpose特性封装,如何通信的看下文。

封装 Flutter 的回调

const onInitialized = () => {
  emits('initialized')
}

const onLoadMore = () => {
  emits('loadMore')
}

const onRefresh = () => {
  emits('refresh')
}

window.addEventListener('flutter-initialized', onInitialized)
window.addEventListener('flutter-list-load-more', onLoadMore)
window.addEventListener('flutter-list-refresh', onRefresh)

onUnmounted(() => {
  window.removeEventListener('flutter-initialized', onInitialized)
  window.removeEventListener('flutter-list-load-more', onLoadMore)
  window.removeEventListener('flutter-list-refresh', onRefresh)
})

监听自定义事件,监听后通过 emit 传给父组件。

Vue <-> Flutter 通信

本示例结合了官方示例以及社区React示例的通信方式,再根据笔者近2年一直在做各种跨端通信相关的经验,个人感觉最佳的方式。

Vue 直接调用 Flutter API

dart:js_interop官方的 JS 库十分好用,可以使用@js.JSExport()把类或者方法暴露出去。

@js.JSExport()
class _MyAppState extends State<MyApp> {
  ...
  @override
  void initState() {
    super.initState();
    final export = js.createJSInteropWrapper(this);
    js.globalContext['_appState'] = export;
    ...
  }
  ...
  @js.JSExport()
  void load(String listString) {
    ...
  }
  ...
}

本示例中,把视图及方法暴露出去外,还把整个实例挂载到window._appState上,在 JS 中使用window._appState.load(...)即可调用到 Flutter 的load()方法。

这里还要提一下为什么load(string)方法的参数用的 string?

也是笔者曾经踩过的坑,Flutter 中的 JSON Map 对象和 JS 中的 JSON Map 对象不能看作一谈,都用 JSON String 进行通信,可以规避掉这种类型不一致。

Flutter 发送广播事件给 Vue

而 Flutter 调用 Vue,也可以说视图回调不是采用的官方的addHandler()的方式注入一个回调方法,而是采用社区React示例中发送CustomEvent的方式。

React示例中广播封装方法:

image.png

需要刷新或者加载更多时,调用广播事件:

image.png

这样的好处有两点:

  1. 可以一对多通信,视图发送事件给处理者,可以被多个处理者接收。
  2. 减少内存泄漏风险,addHandler()本质上是回调注入,这有循环引用不释放的风险,而用广播事件通信,不会相互持有,简而言之,开发不用想太多。

而缺点也是共识,字符串作为 Key,维护起来十分困难,但这一点完全可以被 大前端解决方案 · 事件通信 抹平掉。

另外一提,笔者在 Electron 桌面端通信里,主进程向视图发送的也是广播事件,而不是通过 Electron 官方提供的 ipc 方法。

总结

当下,Flutter element embedding 在落地上还是有着诸多问题:

  1. 能不能在同一页面布局多个FlutterView,没有官方示例及说明,笔者也还没有进行试错,简而言之,是否会有 Flutter Web 多引擎这种概念。如果这一点不能,大规模应用空间会变得狭窄。
  2. Flutter Web 上线的厂商寥寥无几,当然合适的工具就更少了。如果认真看完的同学应该能注意到,Flutter element embedding 还有开发上的问题:纯手工的通信层,无论增加组件也好,增加方法也好,Web 跟 Flutter 没有有机联系起来,这点跟Flutter 多引擎渲染组件很类似,也需要构建一个工具来输出FlutterView.vue甚至多个FlutterXXComponent.vue给前端直接使用,提高开发效率。
  3. UI 样式如何统一,毕竟前端习惯于外部修改内部组件的样式,而 Flutter UI 组件没办法通过 css 修改到内部样式。但这一点是比较好解决的,比如前端和 Flutter 使用相同的 design-token(设计令牌),样式都由其提供,无需开发硬编码。
  4. 最关键的一点,你的话语权在公司够不够大,能不能让团队采用 Flutter element embedding【手动狗头】,个人开发或者小公司自己玩是可以,但还是要在有影响力的产品上做体现才能引导更多的开发入坑。

当然,笔者没啥话语权,这个在笔者公司是落地不了的 ~

后续,有机会的话,会实现一套工具链,和多引擎组件一样,直接生成 Vue 端FlutterView.vue

🫱 源码传送门

如果大家对源码有不理解或者要补充的地方,欢迎评论区评论,笔者会解答以及持续完善。

中秋节GIF动图引导在看提示.gif