教程 - 在 Vue3+Ts 中引入 CesiumJS 的最佳实践@2023


这篇如果 Vue 和 CesiumJS 不发生史诗级的变动,应该不会再有后文了。主要是这类文章没什么营养。

这篇主要修正上篇 https://www.cnblogs.com/onsummer/p/16629036.html 中一些插件的变化,并升级开发服务器的版本。

心急的朋友拉到文末,有示例工程链接下载。

1. 本篇适用范围与目的

1.1. 适用范围

  • 严格使用 Vue3 + TypeScript 的前端项目,包管理器默认使用 pnpm

  • 构建工具使用 Vite4

  • 使用原生 CesiumJS 依赖做应用开发

  • 客户端渲染,因为我不太熟悉 Vue 的服务端渲染,有本篇的介绍后,熟悉 SSR 的读者可以自己接入

  • 单页应用,多页应用也可以参考此法

鉴于国内使用 CesiumJS 的比例大多数为应用开发(粗话即“APICaller”),而非扩展开发(基于源码作新功能封装、打包),所以我默认读者使用 CesiumJS 是通过 npmjs 网站(或镜像站)拉取的依赖,即:

pnpm add cesium@latest

有想修改源码再自己打包的读者,我觉得应该去看我的源码系列博客。

1.2. 目的

在 Vue3 工程中引入 CesiumJS 的最佳方式,并引出地图组件封装的简单经验两则。

这篇文章更倾向于给读者一些原理,而不是提供一套开箱即用的工具,有能力的读者可以根据这篇文章的原理,结合 Vite 或其它打包工具的 API,写一个专属插件。

2. 牛刀小试 - 先看到地球

如果没有快速看到 3D 虚拟地球,我觉得心急的朋友会心急(废话)。

第 2 节不需要知道原理,原理和最佳实践请往下阅读 3、4、5 节。

2.1. 创建 Vue3 - TypeScript 工程并安装 cesium

如果你没有命令行基础,也不懂什么是 NodeJS、npm,不知道 node-package 是什么东西,建议先补补 NodeJS 为基础的前端工具链知识。

直接上命令行(要联网,配好你的 npm 源),请在任意你方便的地方运行:

pnpm create vite

输入你想要的手动选择 Vue、TypeScript 的模板即可,然后进入工程文件夹,我的工程文件夹叫作 v3ts-cesium-2023,所以我接下来要安装 CesiumJS:

cd ./v3ts-cesium-2023
pnpm add [email protected]

pnpm add 会一并把模板的其它依赖下载下来,所以就不用再执行 pnpm install 了。

我在安装 cesium 时指定了版本,是考虑到 很多项目可能不太注意依赖版本管理,所以干脆锁死固定版本。

2.2. 清理不必要的文件并创建三维地球

我移除了 src/assetssrc/components 文件夹,并删除全部 src/style.css 的代码,改写 main.tsApp.vuestyle.css 如下:

// main.ts

import { createApp } from 'vue'
import App from './App.vue'

import './style.css'

declare global {
  interface Window {
    CESIUM_BASE_URL: string
  }
}

createApp(App).mount('#app')

你注意到了,我在 main.ts 中为全局声明了 CESIUM_BASE_URL 变量的类型为 string,这在 App.vue 中就会用到:

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { TileMapServiceImageryProvider, Viewer, buildModuleUrl } from 'cesium'
import 'cesium/Build/CesiumUnminified/Widgets/widgets.css'

const viewerDivRef = ref<HTMLDivElement>()
window.CESIUM_BASE_URL = 'node_modules/cesium/Build/CesiumUnminified/'

onMounted(() => {
  new Viewer(viewerDivRef.value as HTMLElement, {
    imageryProvider: new TileMapServiceImageryProvider({
      url: 'node_modules/cesium/Build/CesiumUnminified/Assets/Textures/NaturalEarthII',
    })
  })
})
</script>

<template>
  <div id="cesium-viewer" ref="viewerDivRef"></div>
</template>

<style scoped>
#cesium-viewer {
  width: 100%;
  height: 100%;
}
</style>

我在 App.vue 组件的 mounted hook 中轻松地创建了 Viewer,语法不再赘述。我做了如下几个点让地球显示出来:

  • Viewer 构造参数传递了 div#cesium-viewer 元素的 ref 值,并将其类型 as HTMLElement,以满足 CesiumJS 的类型
  • 引入 CesiumJS 自己的 css,供 Viewer 的各个内置界面小组件(时间轴等)提供 CSS 样式
  • Viewer 创建了一个 CesiumJS 自带的离线 TMS 瓦片服务,你可能很奇怪为什么路径是 node_modules 起头的,待会解释,这个 TMS 瓦片服务只有 2 级
  • 设定 CESIUM_BASE_URL

带着好奇心,先别急,等我讲完,最后是 style.css,是一些简单的样式:

/* style.css */

html, body {
  padding: 0;
  margin: 0;
}

#app {
  height: 100vh;
  width: 100vw;
}

随后,命令行启动开发服务器:

pnpm dev

在 Vite4 的强大性能加持下,很快就起起来了,这个时候就可以在浏览器看到一个具有两级离线 TMS 瓦片服务的三维地球:

2.3. 中段解疑 - 奇怪的路径

你注意到了,2.2 小节里有两个奇怪的路径:

window.CESIUM_BASE_URL = 'node_modules/cesium/Build/CesiumUnminified/'
new TileMapServiceImageryProvider({
  url: 'node_modules/cesium/Build/CesiumUnminified/Assets/Textures/NaturalEarthII',
})

这是因为 Vite 开发模式下(pnpm devNODE_ENVdevelopment)是直接把工程根路径(即 vite.config.ts 所在的文件夹)映射到 http://localhost:5173/ 这个 URL 上的,所以理所当然填写 CesiumJS 库文件的路径就要从 node_modules 开始写起。

我这里选用的是 CesiumUnminified 版本(未压缩版本)。

CESIUM_BASE_URL 的含义是,项目运行的根网络路径(这里就是指 Vite 开发服务器的默认地址 http://localhost:5173/),加上 CESIUM_BASE_URL 后,在这个拼成的路径就能访问到 CesiumJS 的入口文件,即完整版:

http://localhost:5173/node_modules/cesium/Build/CesiumUnminified/Cesium.js(这个指向的是未压缩版的 IIFE 库文件)

你可以把这个完整地址在启动后粘贴到浏览器的地址栏,然后回车,就能看到 CesiumJS 打包后的库文件源码了。

同理,自带的 TMS 瓦片数据就存放在 http://localhost:5173/node_modules/cesium/Build/CesiumUnminified/Assets/Textures/NaturalEarthII 地址下,TMS 服务的识别方法就是观察网络请求有无一个 tilemapresource.xml 文件:

2.4. 打包部署

有了 2.3 小节的解释,现在要上生产环境了,生产环境也许是 nginx,也许是其它的 Web 服务器,这个时候就没有 node_modules 了,毕竟 Vite 的开发服务器职责已经在 build 后完成。

这个时候就要作出以下修改:

  • 修改 CESIUM_BASE_URL 为生产环境能访问的 CesiumJS 库文件的地址
  • 修改 TileMapServiceImageryProvider 的离线 TMS 路径

在修改之前,需要你把 CesiumJS 的四大静态资源文件夹从 node_modules 中拷贝出来,跟着做就行。

我把 node_modules/cesium/Build/CesiumUnminified/ 这个未压缩版本的文件夹下所有内容,即 AssetsWidgetsWorkersThirdParty 四个文件夹拷贝到 public/libs/cesium/ 下(没有就自己创建一下):

CesiumJS 的正常运行需要这些静态文件,原因在第 3 节会详细说明,先照做。

然后修改 CESIUM_BASE_URL 和离线 TMS 的地址:

window.CESIUM_BASE_URL = 'libs/cesium/'

new TileMapServiceImageryProvider({
  url: 'libs/cesium/Assets/Textures/NaturalEarthII',
})

此时运行 pnpm dev,依旧是正常的,只不过静态文件资源已经从 node_modules/cesium/Build/CesiumUnminified/ 改到了 public/libs/cesium/ 下。

顺带一提,Vite 开发服务器的根路径,除了挂载了工程的根目录,还挂载了工程根目录下的 public 目录,public 目录的作用请自己查阅 Vite 文档。

这个时候就可以使出 pnpm build 然后 pnpm preview 组合了,打包并使用 http 服务预览构建后的产物:

pnpm build && pnpm preview

我的 CPU 是 i5 13600K,在 7 秒多的打包后紧接着就启动了 4173 端口的服务:

运行起来和开发时无异。

2.5. 有限的优化

有人也许对 Vite 等打包工具比较熟悉,可以配置分包(修改 vite.config.ts 中的配置参数)来辨别打包后的产物各自的体积:

import { defineConfig, splitVendorChunkPlugin } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(), splitVendorChunkPlugin()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          cesium: ['cesium']
        }
      }
    }
  }
})

这样之后打包的产物就略有不同:

似乎 splitVendorChunkPlugin() 不添加到 plugins 数组中也可以生效,但是为了尽可能优化打包产物,还是加上了

但是,即便这样,也只是把 cesium 依赖分拆到一个块文件中,并没有实质性改变如下事实:

  • Vite 仍需对毫无修改的 cesium 依赖包打包一次,CesiumJS 已经在发布 npm 包时进行了构建,其虽然有 ESModule 格式的产物,但是并不支持 Tree-Shaking 减小大小,事实上也没有必要,CesiumJS 的内部是高度耦合的三维渲染器、各种算法,这种高度集成的算法产物保持一致是比较好的(或许官方未来可能有改变,但是至少现在没有),所以在我这里这 “7秒多” 的打包时间毫无必要,在其它打包工具也是一样的(Webpack等)

  • 我需要手动复制 node_modules/cesium/Build/CesiumUnminified/ 下的四个静态资源文件夹

  • 对多个发布环境仍需要手动修改 CESIUM_BASE_URL,如果切换到 CDN 或内网已有 CesiumJS 在线库资源,这个改起来就麻烦许多

考虑到真正的项目大概率不会使用自带的离线二级 TMS 瓦片服务,所以不算作可优化的点。

所以,我将费点篇幅,先介绍 CesiumJS 包 的基本知识,再介绍一些现代前端工具的常识,最后再介绍我认为最合理最灵活的引入方式。

授人以渔,你可以根据这篇文章的内容自己写一个方便的 Vite 插件,也可以就此为止,如果你不嫌弃上述三个麻烦事儿。

3. CesiumJS 前置知识

3.1. CesiumJS 依赖包中的资料说明

通过包管理器下载到 node_modules 下的 cesium 依赖,是 CesiumJS 打包好的“包”,它具备如下资料:

  • 不完整的源代码,位于 node_modules/cesium/Source/ 目录下,含一个出口文件 Cesium.js 和一个 TypeScript 类型定义文件 Cesium.d.ts,出口文件导出的所有模块,也就是真正的源码均来自子包 @cesium/engine@cesium/widgets(于 1.100 版本变动,将代码分割于子包中)

  • 打包后的库程序文件,含 IIFEES-ModuleCommonJS 三种格式,每种格式又有压缩代码版本和未压缩版本,分别存放于 node_modules/cesium/Build/Cesium/node_modules/cesium/Build/CesiumUnminified/ 目录下,各种格式各有用途,如果是 CommonJS 环境下,会引用 index.cjs,而如果是 ES-Module 环境下,会引用 index.js;剩下的 Cesium.js 则用在 IIFE 环境下。

  • 无论是不完整的源码,还是打包后的库程序文件,都会附带所需的静态资源文件

应用级别的开发,只需要用到打包后的库程序文件以及 TypeScript 类型定义文件就好了。

我一般选用的是 IIFE 格式里的压缩版本,即 node_modules/cesium/Build/Cesium/Cesium.js,这个库文件只有 3.7 MB,gzip 压缩后可小于 1 MB,体积控制很不错。

3.2. 构建后的 CesiumJS 库组成 - 主库文件与四大文件夹

主库文件在 2.1 小节已经说明,压缩版和未压缩版均含 CommonJSIIFEES-Module 三种格式的库文件,文件名有所不同。

CesiumJS 的源代码(即 node_modules/cesium/Source/ 的出口文件,以及这个出口文件引自的 @cesium/engine@cesium/widgets 子包的代码模块)并不是完整的 cesium 库,cesium 库还包括:

  • 一套 WebWorker,用于参数几何的生成、ktx2 纹理解码、draco 压缩数据解码等多线程任务
  • 一套 css 文件,用于 Viewer 下具有 HTML 界面的内置组件的样式表达,例如时间线等组件
  • 一套静态资源文件,用于构造默认场景和内置组件,例如 SkyBox 背景图、图标、离线的两级 TMS 数据等
  • 一些第三方库,用于 basis 纹理和 draco 数据解码的 WebAssembly 文件以及配套的 WebWorker 文件

仅靠源代码是不能运行起 Cesium 三维地球场景的,必须使用构建版本的 CesiumJS 库。而官方构建后的 CesiumJS 库(即发布在 npm 上的 cesium 包)一定会包含以上四类文件,即 node_modules/cesium/Build/ 下的压缩和未压缩版本文件夹下的 WorkersWidgetsWidgetsAssets 四大文件夹。

3.3. 链接库文件和四大文件夹的 CESIUM_BASE_URL 变量

在 2.2 和 2.3 小节中已经比较完备地解释了 CESIUM_BASE_URL 的作用,它就是告诉已经运行的 CesiumJS 上哪去找四类静态资源。

当然,可以设置私有部署的 CesiumJS 库或者免费的 CDN:

window.CESIUM_BASE_URL = 'http://localhost:8888/cesium/1.103.0/'
window.CESIUM_BASE_URL = 'https://cdn.bootcdn.net/ajax/libs/cesium/1.103.0/'

不再赘述。

4. 现代前端工具的基本常识

4.1. 选择 Vite 的理由

尤雨溪在某次 B 站直播介绍 Vue3 测试版(似乎是2020年)时,在介绍完新的 setup 函数后,带了个货,即 Vite 的最初始版本,应该是 1.0 时代的东西了,那时还和 Vue 是强依赖的,在 Vite2 时才与具体前端框架解耦。

我在第一时间就去体验了 Vite1.0,说实话没什么特别的感觉,还以为是做了一个什么模板。没想到经过 2.0 的积累更新、3.0、4.0 的快速迭代后,现在的 Vite 已经是我替代 Webpack 的主力前端开发工具了(说实话我很少用 Webpack 为底子的各种脚手架、框架)。

Vite 真的很快,上一篇还是 Vite3,现在已经到 Vite4 了,这更新速度...虽然在 API 和配置上基本没什么变化,应该在 4.x 算是稳定了。

4.2. 为什么外部化引入(External)一个库

Vite 和 Webpack 类似,都能把一些依赖无视,不参与打包,一旦某个依赖被配置为“外部的”,即 External 化,就不会打包它了。

社区在普通前端的实践中经常把 Vue、React、Axios 等不需要打包、可以使用高速 CDN 加速的库都外部化了。

CesiumJS 这个体积如此巨大的库(压缩版 + gzip 后主库文件至少也有900+KB)按理说也应该外部化,极大减轻打包时的负担和产物,使用 CDN 还能些许加速首屏性能。

External 化需要一些比较繁琐的配置,如果读者认为不需要外部化,任 Vite 把 CesiumJS 再次打包那几秒钟、十几秒钟也无所谓的话,其实也可以不做这一步。

既然说了最佳化实践,那我就一定要写这一步,万一有人需要呢?

在之后会使用 vite-plugin-externals 插件(注意,有 s 结尾)完成外部化。

4.3. TypeScript 类型提示

没有类型提示还得自己手动确认传值类型是否正确,TS 在静态代码编辑环境借助代码编辑器的各种功能,就可以预先检查出可能存在的错误,最大地规避运行时的问题。

cesium 包自带了类型文件,位于 node_modules/cesium/Source/Cesium.d.ts,你也可以在其 package.json 中找到类型字段。

我们创建工程时,模板已经配置好了 TypeScript,默认情况下不需要我们额外配置什么,正常在组件或 ts 文件中导入 cesium 包的模块即可:

import { Viewer } from 'cesium'

这也是官方推荐的导入方法,这样导入是具备 TS 类型提示的。

噢对了,如果你用的是 VSCode,偶尔你会遇到 TS 类型提示不正常的问题,大多数是这 5 个原因:

  • 如果你在用 Volar 插件来智能提示 .vue 文件,那么你需要去 Vue 官方文档中配置下 “take over” 模式
  • 没有安装 typescript 到开发依赖
  • 安装了 typescript 到开发依赖但是工程没有使用开发依赖的 ts,而使用了 VSCode 自己的 ts,这个用 Ctrl + Shift + P 切换一下 ts 版本即可(搜索“Select Typescript” 或直接搜 “Typescript” 选择版本即可),会写入 .vscode/settings.json 文件
  • 上述问题都排除了,也许是 tsconfig.json 没有包括目标 d.ts 文件
  • 也有可能某个库压根就没有自带 d.ts,也没有对应的类型库

4.4. 开发服务器的路径与代码中的路径问题

这是一个新手问题,新手在开发工具(例如 Webpack、Vite)的滋润下能非常熟练地从各种地方 import 各种各样的资源,例如 ts、js、json、图片图标、less/css/sass 等资源模块。

例如:

import Logo from '@/assets/svg/logo.svg'

这样的路径大概率是配置好 @ 指向工程下的 src 目录。

或者裸模块导入:

import { ref } from 'vue'

这些看似“不就是这样的吗”的导入实际上是开发工具做的努力。

然而,在 GIS、三维这些小众的领域,开发工具就不一定有适配了。例如,你不能把相对目录或配置好的目录下的 glTF 模型导入:

import Duck from './data/duck.gltf'
import Ball from '@/assets/model/duck.glb'

幸运的是对 glTF 模型已经有了 vite 插件,但是我仍然不推荐你这样引入。

同理,CesiumJS 的 3DTiles 数据集也不要这么做,虽然它的入口文件是一个 json 文件,但是瓦片文件打包器并不会帮你处理。

理清楚导入问题后,还有一个新手常犯的问题是把“源码相对路径”当作“运行时的路径”,假设有这么一个代码文件 src/views/home.vue 中创建了一个 3DTiles 数据集对象:

// src/views/home.vue
Cesium3DTileset.fromUrl({
  url: '../assets/tileset.json'
})

有的新手把数据就放在了上一级的 src/assets/tileset.json 路径下。这犯了 2 个低级错误:

  • 认为相对于当前代码文件的 ../assets/tileset.json 数据文件路径在运行时也能正常读取
  • 认为 CesiumJS 会帮你处理路径问题

这点就不说怎么解决了,只求一些新手读者能了解清楚什么是 “源代码文件的相对 URL” 和 “运行时 URL” 这些基本区别。

此处塞一行防爬虫文字,原文出自 @岭南灯火,常驻知乎博客园,其余博客社交平台基本有号,想找原文请劳烦搜索一下~~

5. 教程(原理)正文

与其说是教程,不如说是基于第 2 节的继续优化,优化到最佳实践。

5.1. 使用 create-vite 在命令行创建工程

这个参考 2.1 和 2.2 小节即可。

5.2. 指定版本安装 cesium

指定版本安装在 2.1 小节有说明,若不指定版本安装:

pnpm add cesium

那么在 package.json 中,cesium 依赖的版本号(首次 add 时的最新版)前面就会多一个 ^

{
  "dependencies": {
    "cesium": "^1.104.0"  
  }
}

除非手动 update,即 pnpm update cesium@具体版本,否则 ^ 后面的版本号是不会改变的。

那如果不指定版本安装 cesium 会用哪个版本呢?会用第一次 add 的版本,并且会写进对应包管理器的锁文件中。

  • pnpm 是 pnpm-lock.yaml
  • npm 是 package-lock.json
  • yarn 是 yarn.lock

5.3. 包管理工具锁文件的取舍

这小节可以与 5.2 一起看。锁文件的作用是把各个依赖包的具体版本锁死。

有锁文件的 package 会从锁文件中找版本,否则会按 package.json 中的 “版本要求” 来获取特定版本。

如果 package.json 中各个依赖包的版本都是确定的,项目负责人也能管理起依赖的版本控制,那么其实可以不需要锁文件。

我在本文就配置了 不需要锁文件,且我在安装依赖时明确指定了具体版本(主要是 cesium)。

对于 pnpm 和 npm,只需在工程根目录下创建一个(如果不存在).npmrc 文件,并写入此配置:

package-lock=false

对于 yarn,则是创建 .yarnrc 文件并写入:

--install.no-lockfile true

如果项目有要求,或者版本管理比较差,我建议还是把锁文件留着并提交到 git 记录中,但是 cesium 的版本,我还是强烈建议确定版本安装

pnpm add [email protected]

5.4. 使用插件外部化 CesiumJS

原理、原因在 4.2 小节,这里主要讲配置。

  • 插件① - rollup-plugin-external-globals

外部化依赖有很多插件都可以实现,既然 Vite4 打包时用的是 rollup,用 rollup-plugin-external-globals 插件就可以完成打包时外部化:

pnpm add rollup-plugin-external-globals -D

然后是用法:

import { defineConfig, splitVendorChunkPlugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import externalGlobals from 'rollup-plugin-external-globals'

export default defineConfig({
  plugins: [vue(), splitVendorChunkPlugin()],
  build: {
    rollupOptions: {
      externalGlobals({
        cesium: 'Cesium'
      }),
    },
  },
})

也可以用 Vite 插件:

  • 插件② - vite-plugin-externals(注意有个 s 结尾)
pnpm add vite-plugin-externals -D

用法:

import { defineConfig, splitVendorChunkPlugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteExternalsPlugin } from 'vite-plugin-externals'

export default defineConfig({
  plugins: [
    vue(),
    splitVendorChunkPlugin(),
    vitePluginExternals({
      // key 是要外部化的依赖名,value 是全局访问的名称,这里填写的是 'Cesium'
      // 意味着外部化后的 cesium 依赖可以通过 window['Cesium'] 访问;
      // 支持链式访问,参考此插件的文档
      cesium: 'Cesium',
    })
  ],
})

上面两个插件任选一个均可,只不过 vite-plugin-externals 在开发模式也会起作用,而 rollup-plugin-external-globals 只会在生产模式(NODE_ENV = production 条件,即构建打包时)对 rollup 起作用。

我选用 vite-plugin-externals 插件,因为它两种模式都能起作用。再次启动 pnpm dev,打开浏览器发现找不到模块:

这是因为在开发模式也把 CesiumJS 外部化了,找不到很正常。

Vite 启动后会有一个依赖预构建的过程,打开 node_modules/.vite/deps 目录,这里就是预构建的各种代码中导入的依赖包

在开发模式只需配置一下即可避免外部化,而让 Vite 把 cesium 依赖预构建:

vitePluginExternals({
  cesium: 'Cesium',
}, {
  disableInServe: true, // 开发模式时不外部化
})

执行 pnpm build 后,提升显著:

但是 pnpm preview 时,依然会找不到从 cesium 依赖导入的类(注意端口,是 preview 默认的 4173):

这是因为外部化 CesiumJS 后,便不再打包 cesium 依赖,所以打包后的应用找不到 CesiumJS 的类和 API 了。

怎么办呢?

总结一下现在的进度:

  • 创建了 Vue3 + TypeScript 项目,并已经在第 2 节通过手动拷贝的方式把四个静态资源文件夹拷贝到 public/libs/cesium/ 目录下,配置好了 CESIUM_BASE_URL 让 CesiumJS 能访问到这些静态资源,并成功看到了具有离线 TMS 瓦片的三维地球

  • 使用插件完成了打包外部化 CesiumJS,极大提高了打包速度、极大减小了构建产物的体积

那么现在遇到了什么问题?

  • 打包后的页面因为外部化 cesium 找不到 CesiumJS 库

如何解决问题?

只需打包时把 CesiumJS 的主库文件导入 index.html 不就行了吗?请紧接着 5.5 小节一起解决问题:

5.5. 使用插件自动在 index.html 引入 Cesium.js 库文件

读者可以手动把 node_modules/cesium/Build/Cesium/Cesium.js 这个压缩版的 IIFE 格式库程序文件复制到 public/libs/cesium/ 下,然后在工程入口文件 index.html 中添加一行 script 标签引入库文件:

<head>
  <script src="libs/cesium/Cesium.js"></script>
</head>

但是,如果是自己手动写这个标签,执行打包时会收到 Vite 的一句警告:

为了解决这个问题,最好的办法就是在 Vite 的配置文件中,用插件的办法自动插入这个 script 标签。

有很多插件可以修改 index.html

  • vite-plugin-html
  • vite-plugin-html-config
  • vite-plugin-insert-html

等等,上述三个插件我都有用过,各有特色,按需选择。

我这里以 vite-plugin-insert-html 插件为例,在 index.html<head> 标签下插入这个 script 标签:

import { defineConfig, splitVendorChunkPlugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import { insertHtml, h } from 'vite-plugin-insert-html'

export default defineConfig({
  plugins: [
    vue(),
    splitVendorChunkPlugin(),
    viteExternalsPlugin({
      cesium: 'Cesium',
    }),
    insertHtml({
      head: [
        h('script', {
          src: 'libs/cesium/Cesium.js'
        })
      ]
    })
  ],
}

这样打包时就是绝对完美的消息了:

但是到此为止,仍然有两个需要 “手动” 的事情待解决:

  • 四大静态文件的复制
  • CesiumJS 库文件的复制

巧的是,这些资源文件都可以从 cesium 包内拷贝,压缩版的 node_modules/cesium/Build/Cesium,非压缩版的 node_modules/cesium/Build/CesiumUnminified,请读者紧接着看 5.6 小节:

5.6. 四大静态文件夹与库文件的拷贝(CDN或独立部署了 CesiumJS 库可省略此步)

这里需要一些插件或者 nodejs 脚本来做文件的静态复制。简单起见,就拿 Vite 的静态文件复制插件完成这个目的。

有很多可选插件,静态文件复制的插件在 Webpack 也有,叫作 CopyWebpackPlugin,在 Vite 中我选用 vite-plugin-static-copy 插件:

import { viteStaticCopy } from 'vite-plugin-static-copy'

export default defineConfig({
  plugins: [
    vue(),
    splitVendorChunkPlugin(),
    viteExternalsPlugin({
      cesium: 'Cesium',
    }),
    viteStaticCopy({
      targets: [
        {
          src: 'node_modules/cesium/Build/CesiumUnminified/Cesium.js',
          dest: 'libs/cesium/'
        },
        {
          src: 'node_modules/cesium/Build/CesiumUnminified/Assets/*',
          dest: 'libs/cesium/Assets/'
        },
        {
          src: 'node_modules/cesium/Build/CesiumUnminified/ThirdParty/*',
          dest: 'libs/cesium/ThirdParty/'
        },
        {
          src: 'node_modules/cesium/Build/CesiumUnminified/Workers/*',
          dest: 'libs/cesium/Workers/'
        },
        {
          src: 'node_modules/cesium/Build/CesiumUnminified/Widgets/*',
          dest: 'libs/cesium/Widgets/'
        },
      ]
    }),
    insertHtml({
      head: [
        h('script', {
          src: 'libs/cesium/Cesium.js'
        })
      ]
    }),
  ], // End of plugins
}

这个 target 中很多路径都是相同的,可以通过数组计算完成,这里就留给读者自己改进了。dest 是打包后的根路径的相对路径。

无论你见到的哪个教程,只要用的是 node_modules 下的 cesium 依赖,你都能看到这四个静态文件夹的复制步骤。

5.7. 额外优化 - 使用环境变量配置 CESIUM_BASE_URL 并适配其它配置

至此我认为工程的配置已经满足非常灵活地运行了。它满足了:

  • 无论开发或生产环境,外部化了 CesiumJS,让 Vite 不再打包 cesium 依赖,大大减少打包时间、减少应用代码体积(从构建产物中剥离 cesium 库)

  • 无论开发或生产环境,都 自动复制四个静态资源文件夹、自动在 index.html 注入 CesiumJS 库文件的 script 标签以加载 CesiumJS

但是,一旦改用局域网或已经部署好的 CesiumJS 库(这种情况请自己解决跨域),或者使用 CDN,那么安装在 node_modules 下的 cesium 其实已经没有必要走 5.6 的静态文件复制了,而且注入 index.html 的主库文件需要修改。

我以国内 bootcdn 上的 CesiumJS 为例,既然 Vite 内置了不同环境文件的解析的函数 loadEnv(参考 Vite 官方文档 - 使用环境变量),我就分 developmentproduction 简单讲一讲。

  • 开发模式(NODE_ENV = development),使用 node_modules 下的 cesium 依赖,复制四个静态文件和库文件
  • 生产模式(NODE_ENV = production),使用 bootcdn 上的 CDN 链接

给出最终的 vite.config.ts(注意,默认导出改成了函数):

import { defineConfig, type PluginOption, splitVendorChunkPlugin, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteExternalsPlugin } from 'vite-plugin-externals'
import { insertHtml, h } from 'vite-plugin-insert-html'
import { viteStaticCopy } from 'vite-plugin-static-copy'

export default defineConfig((context) => {
  const mode = context.mode
  const envDir = 'env' // 环境变量文件的文件夹,相对于项目的路径,也可以用 nodejs 函数拼接绝对路径
  const isProd = mode === 'production'

  const env = loadEnv(mode, envDir)
  const cesiumBaseUrl = env['VITE_CESIUM_BASE_URL']
  // 默认 base 是 '/'
  const base = '/'

  const plugins: PluginOption[] = [
    vue(),
    splitVendorChunkPlugin(),
    viteExternalsPlugin({
      cesium: 'Cesium', // 外部化 cesium 依赖,之后全局访问形式是 window['Cesium']
    }),
    insertHtml({
      head: [
        // 生产模式使用 CDN 或已部署的 CesiumJS 在线库链接,开发模式用拷贝的库文件,根据 VITE_CESIUM_BASE_URL 自动拼接
        h('script', {
          // 因为涉及前端路径访问,所以开发模式最好显式拼接 base 路径,适配不同 base 路径的情况
          src: isProd ? `${cesiumBaseUrl}Cesium.js` : `${base}${cesiumBaseUrl}Cesium.js`
        })
      ]
    })
  ]
  if (!isProd) {
    // 开发模式,复制 node_modules 下的 cesium 依赖
    const cesiumLibraryRoot = 'node_modules/cesium/Build/CesiumUnminified/'
    const cesiumLibraryCopyToRootPath = 'libs/cesium/' // 相对于打包后的路径
    const cesiumStaticSourceCopyOptions = ['Assets', 'ThirdParty', 'Workers', 'Widgets'].map((dirName) => {
      return {
        src: `${cesiumLibraryRoot}${dirName}/*`, // 注意后面的 * 字符,文件夹全量复制
        dest: `${cesiumLibraryCopyToRootPath}${dirName}`
      }
    })
    plugins.push(
      viteStaticCopy({
        targets: [
          // 主库文件,开发时选用非压缩版的 IIFE 格式主库文件
          {
            src: `${cesiumLibraryRoot}Cesium.js`,
            dest: cesiumLibraryCopyToRootPath
          },
          // 四大静态文件夹
          ...cesiumStaticSourceCopyOptions
        ]
      }),
    )
  }

  return {
    base,
    envDir,
    mode,
    plugins,
  }
})

为了 ts 能提示 import.meta.env.MODE,需要在 src/vite-env.d.ts 中补充类型定义(参考 Vite 文档):

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APP_TITLE: string
  // 更多环境变量...
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

并且告诉 TypeScript 要用由 vite/client 提供的 import.meta 类型,在 tsconfig.node.jsoncompilerOptions 中添加:

{
  "compilerOptions": {
    "types": ["vite/client"]
  }
}

如果是旧版本的 Vite 创建的模板,你可以添加在 tsconfig.json 对应的位置中。

5.9. 额外优化 - 使用 gzip 预先压缩打包产物

在服务器上使用 gzip 能进一步提升网络传输速度。打包时,使用合适的插件即可预先进行 gzip 打包,我选用的是 vite-plugin-compression 插件:

import compress from 'vite-plugin-compression'

// 使用见插件官方文档

在开发模式这玩意儿没起作用,就不细谈了。

5.8. 如何共享 CesiumJS 的 Viewer 对象

Vue 有 pinia 这个全局状态大杀器,可以把核心的 Viewer 对象送入全局状态中,但是要避免 Vue 的响应式劫持,响应式问题可以通过 Vue3 的 shallowRefshallowReactive 来解决:

<script lang="ts" setup>
import { onMounted, shallowRef, ref } from 'vue'
import { Viewer } from 'cesium'

const viewerDivRef = ref<HTMLDivElement>()
const viewerRef = shallowRef<Viewer>()
onMounted(() => {
  viewerRef.value = new Viewer(viewerDivRef.value as HTMLElement, /* ... */)
})
</script>

或者用 shallowReactive

<script lang="ts" setup>
import { onMounted, shallowReactive, ref } from 'vue'
import { Viewer } from 'cesium'

const viewerDivRef = ref<HTMLDivElement>()
const viewerRef = shallowReactive<{
  viewer: Viewer | null
}>({
  viewer: null
})
onMounted(() => {
  viewerRef.viewer = new Viewer(viewerDivRef.value as HTMLElement, /* ... */)
})
</script>

甚至可以更简单一些:

<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { Viewer } from 'cesium'

const viewerDivRef = ref<HTMLDivElement>()
let viewer: Viewer | null = null
onMounted(() => {
  viewer = new Viewer(viewerDivRef.value as HTMLElement, /* ... */)
})
</script>

当然也可以用 Vue 的 provide/inject 函数来下发、注入子组件,仅适用于地图组件在最顶层的情况:

<!-- 顶层组件下发 Viewer -->
<script lang="ts" setup>
import { onMounted, ref, provide } from 'vue'
import { Viewer } from 'cesium'
import { CESIUM_VIEWER } from '@/symbol'

const viewerDivRef = ref<HTMLDivElement>()
let viewer: Viewer | null = null
onMounted(() => {
  viewer = new Viewer(viewerDivRef.value as HTMLElement, /* ... */)
  provide(CESIUM_VIEWER, viewer)
})
</script>

<!-- 下面是子组件调用 -->
<script lang="ts" setup>
import { inject } from 'vue'
import type { Viewer } from 'cesium'
import { CESIUM_VIEWER } from '@/symbol'

const viewer = inject<Viewer>(CESIUM_VIEWER)
</script>

这个 CESIUM_VIEWER 是一个 Symbol,来自 src/symbol/index.ts

export const CESIUM_VIEWER = Symbol('CESIUM_VIEWER')

如果业务界面组件与地图组件是兄弟组件或父子,那只能用三种方式传递 Viewer 对象:

  • defineExpose
  • 层层事件冒泡至父级组件,或者使用全局事件库(如 mitt)
  • 使用全局状态 pinia 或 vuex

不再展示代码,请读者参考各种途径的官方文档来传递,注意一定要避免响应式劫持

6. 探究 CesiumJS 等库的前端组件封装

这里只是以 Vue 为例讲个思路,在其它前端框架中也适用。

6.1. 以 CesiumJS 等库为主的看板式工程

这种工程有一个特点,就是地图场景会占满浏览器窗口的全部尺寸,并且不可在高度和宽度上出现滚动条。

一般这种就是“XX系统”的原型。这种工程有什么特点呢?那就是地图/三维场景几乎占据绝大多数的功能,大多数时候是浮动在地图场景上的一些 UI 元素在显示数据、发生交互。也就是说,切换的其实是一些界面组件,地图组件几乎不变,反过来看,界面组件大多数时候反而还要去访问地图核心对象,像 CesiumJS 是 Viewer,OpenLayers 是 Map 等。

我的建议是,所有业务界面组件应该作为地图组件的 子组件,在 Vue 中,就有 slot 的设计。

结合前端路由,还能跟随路由切换(RouteView 也应作为 slot 编写在地图组件中) 。

地图组件作为最顶层的组件,可以结合前端组件的生命周期特点,当核心对象创建完成后,才通过条件渲染把子组件打开,在 Vue 中利用 provide/inject 实现地图核心对象的下发和注入。在 React 中使用 useContext 下发也是类似的。

6.2. 后台管理系统式工程

这种通常是表单的数据通过组件的 props 下传给地图,单一地显示上级操作接收来的数据。这种地图组件设计就比较简单,只需设计好 props 的数据结构,在组件挂载时创建核心对象并显示接收到的数据即可。

7. 示例工程下载

留了两个版本,读者可以自己在压缩包中找自己满意的。一个是第 2 节的最简单的,让 Vite 打包 CesiumJS 的版本,做了分 chunk;另一个则是经过第 5 节完整配置后、具备各种注释和细节,供读者自己改造学习的版本。

微云链接

热门相关:超武穿梭