likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 6

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

最终效果

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

做了什么

Vue 示例

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

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

混合布局

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

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

Vant4 组件库提供搜索组件:

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

由 Flutter 提供瀑布流组件:

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

实际价值

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

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

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

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

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

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

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

源码分析

整体结构

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

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

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

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

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

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

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

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 交互通信能力)

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

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

.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

整体结构

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

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

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

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

网络请求

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

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

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缺一不可,笔者当时调这几个参数排查了很久,报错上根本看不出来问题原因:

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

这几个玩意儿官方也没说明,因为官方示例中的产物在根目录,而我们产物存放位置是多了 /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示例中广播封装方法:

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

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

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

这样的好处有两点:

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

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

总结

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

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

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

🫱 源码传送门

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

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