教程 - 深度探讨在 Vue3 中引入 CesiumJS 的最佳方式
阅读原文时间:2023年07月09日阅读:3

目录


由于 Vue2 已经进入维护期,且 Vue2 对待组件内的 data 是无差别使用 Object.defineProperties 递归将其劫持的,对于复杂状态的对象会造成严重的 JavaScript 访问路径过长而导致的 性能问题,这个应该是老生常谈了。

Vue3 提供了 markRaw 函数,标记一个对象,令 Vue 不再将其视作 响应式 数据,所以本文基于 Vue3 来介绍如何引入 CesiumJS。

心急的朋友可以直接跳过本文的介绍,拉到文末,有示例工程 zip 下载。

1. 你应该先知道的基础知识

除了 Vue3 和 Vue2 的响应式设计区别外,我认为还需要补充一点知识。

Cesium 是一个高度集成的重型 JavaScript 库,这是共识。它的源码虽然是 ESModule 格式的,但是并没有直接提供类似 index.js 的出口文件,也不存在子包的概念,只是在 Source 文件夹下简单分了几个大板块文件夹,例如 Source/Renderer 文件夹就是 CesiumJS 中整个渲染器的代码模块。

通常,除了二次修改 CesiumJS 源代码构建自己的分支版本,一般不会在 WebAPP 中直接使用 CesiumJS 的源码。一般使用的是 CesiumJS 的 构建版本,也就是 Build 文件夹下的压缩版或未压缩版库文件。

主库文件有三种格式,ESModule 的是 index.jsIIFE 的是 Cesium.jsCommonJS 的是 index.cjs。除了主库文件外,构成构建版本的 CesiumJS 还有 4 个文件夹下的静态资源:

  • Assets 文件夹,图片或 JSON 等前端运行时可能用到的资源
  • ThirdParty 文件夹,WebAssembly 等前端运行时可能用到的第三方资源
  • Widgets 文件夹,主要是各个 CesiumJS 自带的界面小部件的 CSS 文件
  • Workers 文件夹,前端运行时用到的 WebWorker 的构建版本(WebWorker 由于一些原因,在前端运行时仍然用 CommonJS 格式加载)

因此,你在任何所谓的教程里面都会看到这四个静态资源文件夹的复制操作,除了 CDN 直接使用的方式。我在这里说清楚,希望你知道原因。

笔者是 Vite 1.0 的首批用户。尤雨溪第一次介绍 Vite 是在 Vue 3.0 测试版网络会议上,只是作为一个很小的“玩具”介绍了一下,当时的 Vite 还是与 Vue 强关联的,后来到了 Vite 2.0 才解耦合。简单的说,Vite 3.0 对 Vite 2.x 并不是破坏性更新,只是考虑到 NodeJS 12.x 已经 EOL 了,索性 3.0 就不再支持 NodeJS 12.x,其余特性笔者没特别了解。

简单的说,使用 Vite 作为开发服务器和打包工具,不外乎几个原因:

  • esbuild 速度有目共睹
  • 中文文档齐全
  • 是 cli 的官方指定继任者

对于开项目,我有几点建议:

  • 如果你只是写一个小的项目,可以用 Vite 官方模板;如果是 Vue3 项目,直接使用 create-vue 脚手架或者安东尼小哥的 vitesse 模板工程替代 @vue/cli 即可;这条也适用于想更多自定义的项目、团队;
  • 如果你需要开箱支持的文件式路由、SSR、全栈开发等特性,请使用 Nuxt

简单起见,我将使用 create-vue 来演示。

最后说明为什么用 pnpm —— 它速度足够快,也有效缩小了 node_modules 的体积,对付 peer 依赖也很棒。你当然也可以用 npmyarn

在 1.1 小节我已经说明了 CesiumJS 库的构成,有一个库文件,以及 4 个静态资源文件夹。

由于 npm 下载的 cesium 包中已经有官方打包好的 构建版本 库了,没有必要让 Vite 再次将 CesiumJS 源代码再次打包,而应将其作为外部依赖,也就是配置 Vite 的 external 项,不打包,使用 CDN 或 public 文件夹下的库程序、资源。

当然,这是对官方库没有任何修改、直接使用的前提;如果想二次修改 CesiumJS 源代码,无论是自己打包,还是使用 npm-patch,上述方法便不再需要参考。

在 Vite 中,需要借助两个社区插件完成 CesiumJS 的外部化:

  • vite-plugin-externals
  • vite-plugin-html-config

前者告诉 Vite 什么 dependencies 不参与打包,后者告诉 Vite 打包后的产物哪些 dependencies 需要在页面入口 html 文件中随 public 目录(或 CDN)引入。

具体配置过程参考 2.4 小节。

在代码中,有一些特殊的关键字、指令会被打包器识别,打包器会帮你把相关的资源打包、转译。

在 Webpack 时代,你就见过使用 import 指令引入 css 文件或图片:

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

Webpack 本身只能处理 import 进来的 JavaScript 文件,对于其它的资源,则使用各种 Loader 完成打包处理过程。

Vite 则开箱支持了众多 Web 前端的资源的导入。但是,3D 领域的模型文件就没有支持,不能通过 import 命令导入,除非安装了处理对应文件格式的插件。像下面的导入指令,Vite 并不会帮你处理:

import CarModel from '@/assets/data/model.glb'

并且会在启动时给你报错。

另一个问题是要明白,当前工程的路径 ≠ 运行时的路径。运行时又分开发运行时、打包后的运行时。

所以,在一些 API 需要传递资源路径时,请一定要确保在运行时它是可以被浏览器正确请求到的,例如:

new Cesium3DTileset({
  // vite 等打包器并不会帮你处理这个路径,Cesium 在发出请求也不会
  url: '@/assets/tilesets/tileset.json'
  // 或下面的例子,运行时的
  url: './data/tileset.json'
})

又如:

new Cesium3DTileset({
  // 或下面的例子,运行时的基础地址是 http://localhost:5173,
  // 那么前端发起请求就会是 http://localhost:5173/data/tileset.json
  url: './data/tileset.json'
})

最后,我认为 CompositionAPI 和 OptionAPI 并不是本文讨论的重点,但是我会使用 setup-script + CompositionAPI 来介绍。

顺便,既然都 Vue3 了,那 TypeScript 肯定是少不了的。

2. 一步一步教你创建项目

请确保你的机器安装了 NodeJS,版本最好使用 LTS(写文的时候,推荐 16+ 版本),以及 node 包管理工具能正常在你的命令行环境(Windows - powershell/cmd/gitbash,macOS 和 Linux 应该是有自带的 shell)使用。

我的包管理工具是 pnpm

虽然但是,我不是很想说 NodeJS 于前端的关系,请读者自行了解 NodeJS 包以及其包管理工具。这里简单说明:NodeJS 是 vite 或 webpack 开发时的程序服务器(简称开发服务器,devServer)的基石,就像 jdk 于 Spring 框架一样。运行你的页面代码的仍旧是浏览器,打包器(vite 内置的是 rollup,webpack 自己就是)会把你写的 Vue 单文件组件、ts 代码合并、打包、转译成优化后的产物。

为了使用最新的全局状态管理器 pinia,我选择用 create-vue 这个能替代 @vue/cli 的新版脚手架,完成具备如下开发工具配置的工程创建:

  • 使用 pinia
  • 使用 typescript
  • 使用 eslint
  • 使用 prettier

不要再问我这些是什么,这些属于 Vue 和 Web 前端的生态。

创建命令:

pnpm create vue

确保你的网络没有问题,那么你就可以伴随着如下命令行提示创建出与我一样的初始工程:

node 包管理器安装依赖包,如果不指定版本,会默认当前版本以及以上的版本都可以运行,也就是会在 package.json 的依赖列表中的版本号前加一个 ^ 号:

{
  "dependencies": {
    "cesium": "^1.96.0"
  }
}

但是,CesiumJS 每个月都会更新,而且时不时会有重大变动,我的建议是手动锁死版本,而不是依赖锁文件(pnpm 是 pnpm-lock.yaml,npm 是 package-lock.json,yarn 是 yarn.lock)。

pnpm add cesium@1.96.0

这样,以后安装依赖就不会安装到最新版本,以至于项目出现因重大变动导致运行不起来的问题了。

package.json 同级别路径下创建 .npmrc 文件,配置包管理器的行为、参数,使用如下配置即可不产生锁文件:

package-lock=false

这对于严格控制 package.json 中依赖版本的项目,而且不指定包管理器(即允许任意使用 pnpm、yarn、npm 来管理依赖)的项目来说是十分有利的。

先安装 Vite 插件:

然后,在 vite.config.ts 中修改 Vite 的配置:

import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'

import htmlConfig from 'vite-plugin-html-config'
import { viteExternalsPlugin } from 'vite-plugin-externals'

// https://vitejs.dev/config/
export default ({ mode: VITE_MODE }: { mode: string }) => {
  const env = loadEnv(VITE_MODE, process.cwd())
  console.log('VITE_MODE: ', VITE_MODE)
  console.log('ENV: ', env)

  const plugins = [vue()]

  const externalConfig = viteExternalsPlugin({
    cesium: 'Cesium'
  })
  const htmlConfigs = htmlConfig({
    headScripts: [
      {
        src: './lib/cesium/Cesium.js'
      }
    ],
    links: [
      {
        rel: 'stylesheet',
        href: './lib/cesium/Widgets/widgets.css'
      }
    ]
  })
  plugins.push(
    externalConfig,
    htmlConfigs
  )

  return defineConfig({
    root: './',
    build: {
      assetsDir: './',
      minify: ['false'].includes(env.VITE_IS_MINIFY) ? false : true
    },
    plugins: plugins,
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url))
      }
    }
  })
}

注意到导出的是一个函数,与 Vite 初始化的配置文件直接使用 import { defineConfig } from 'vite' 函数定义的是略有区别的。这个函数的参数是一个类型为 { mode: string } 的对象,参考:配置 Vite | Vite 官方中文文档

之后在 2.6 会详细说明这个 mode 有什么用,这里先略过。

这小节主要是对这两个插件的配置:

const plugins = [vue()]

const externalConfig = viteExternalsPlugin({/* ... */})
const htmlConfigs = htmlConfig({/* ... */})

plugins.push(
  externalConfig,
  htmlConfigs
)

return defineConfig({
  /* ... */
  plugins: plugins,
})

这两个插件的用法和用途,就不详细说明了,简单说明:

vite-plugin-external 插件的 key 是 dependencies 的名称,value 是打包后代码全局访问的变量名称(作为 Namespace),即 cesium 依赖在打包后在 window.Cesium 上访问。

vite-plugin-html-config 插件中,如果像我一样是从 node_modules 中复制的 CesiumJS 库文件,而不是填写的 CDN 外链,那么打包后页面运行时,静态库文件的相对路径是从 defineConfig 中的 root 起算的。

在 2.5 小节会讲到 CesiumJS 的静态资源复制。

在 1.1 小节中已详细说明了 CesiumJS 的静态资源的 4 个文件夹。由于此示例工程使用 node_modules 下的 CesiumJS,也即 node_modules/cesium/Build/Cesium 或未压缩版的 node_modules/cesium/Build/CesiumUnminified,并且 Vite 构建时会把 public 文件夹下的资源原封不动复制到发布文件夹下,所以需要借助 NodeJS 文件操作 API 复制这些资源到 public 文件夹下。

如果你使用 CDN 上的 CesiumJS,而不是 node_modules 下的 CesiumJS 依赖,就不需要这一步,但是还是得配置 CESIUM_BASE_URL,告诉前端运行时的 CesiumJS 相对路径起源于哪里(参考 2.6 小节)。

这个脚本可以放置于 scripts/ 目录下,方便起见,我放在了项目根目录。

复制我使用 recursive-copy 包,删除文件我使用 del 包,都作为 devDependencies 安装。

import copy from 'recursive-copy'
import {
  deleteSync
} from 'del'

const baseDir = `node_modules/cesium/Build/CesiumUnminified`
const targets = [
  'Assets/**/*',
  'ThirdParty/**/*',
  'Widgets/**/*',
  'Workers/**/*',
  'Cesium.js',
]

deleteSync(targets.map((src) => `public/lib/cesium/${src}`))
copy(baseDir, `public/lib/cesium`, {
  expand: true,
  overwrite: true,
  filter: targets
})

然后,我在 package.json 的 scripts 中添加了两个命令:

{
  "scripts": {
    "postinstall": "node static-copy.js",
    "static-copy": "node static-copy.js"
  }
}

postinstall 会在 pnpm install 后自动执行静态资源复制,static-copy 则允许手动升级 cesium 包后更新 public 文件夹下 CesiumJS 的静态文件。

注意 deleteSynccopy 函数的目标文件夹路径,我设为了 public/lib/cesium,与 2.4 小节中 htmlConfig 的配置是一样的。

为了简单起见,vite.config.ts 中配置的 build.assetsDir 我改为了 ./;否则,deleteSynccopy 的目标路径就要手动加上 build.assetsDir 了。例如,默认的 assetsDir 是 assets,那么目标路径就从 public/lib/cesium 变成了 public/assets/lib/cesium

请十分仔细地注意这些路径问题,分清楚 public 文件夹、build.assetsDir 的意义,static-copy.js 文件的 cwd 等,分清楚 NodeJS 脚本和前端运行时的相对路径问题。

CESIUM_BASE_URL 告诉 CesiumJS 在前端运行时相对哪个路径访问那 4 个文件夹下的静态资源,与 2.4、2.5 小节中的路径配置十分相关,请务必读懂 2.4、2.5 小节中的路径配置。

当然,如果你使用的是 CDN 上的 CesiumJS 库,那么这个环境变量配置就要配置成 CDN 的基础路径。例如,https://unpkg.com/cesium@1.96.0/Build/Cesium/Cesium.js 对应的 CESIUM_BASE_URL 就是 https://unpkg.com/cesium@1.96.0/Build/Cesium

考虑到我使用的是 node_modules 下的包,复制到 public 文件夹下,所以我在环境变量文件 .env 中指定的 CESIUM_BASE_URL 是一个相对于工程运行时的地址:

VITE_CESIUM_BASE_URL = './lib/cesium'

随 Vite 启动工程后,在入口文件 src/main.ts 中将 CesiumJS 的前端运行时基路径挂在至全局:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './main.css'

Object.defineProperty(globalThis, 'CESIUM_BASE_URL', {
  value: import.meta.env.VITE_CESIUM_BASE_URL
})

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

为了便于类型提示,我将 VITE_CESIUM_BASE_URL 的类型写在了工程根目录下的 env.d.ts 文件中:

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

interface ImportMetaEnv {
  VITE_CESIUM_BASE_URL: string
}

这是使用 TypeScript 的 interface 补全 import.meta.env 的类型定义。

为了让 TypeScript 识别这个类型声明文件,还得在 tsconfig.json 中配置类型文件路径,把 env.d.ts 添加进来:

{
  "include": [
    "env.d.ts",
    "src/**/*",
    "./vite.config.*"
  ]
}

环境变量是 Vite 的功能,参考:环境变量和模式 | Vite 官方中文文档

在 2.4 小节有完整的 vite.config.ts 配置文件,其中默认导出的是一个函数,函数参数的意义已经在 2.4 中有官方参考资料。

下面这几行代码就是在启动工程时,让 Vite 加载与 vite.config.ts 同路径下的环境变量文件,并读取里面的环境变量:

export default ({ mode: VITE_MODE }: { mode: string }) => {
  // 根据当前 mode 读取对应文件中的环境变量
  const env = loadEnv(VITE_MODE, process.cwd())

  // 在控制台打印出来
  console.log('VITE_MODE: ', VITE_MODE)
  console.log('ENV: ', env)

  /* ... */
}

这一步是可选的,当然,我强烈推荐你做这一步,这对跨组件访问 Viewer 很有帮助。

作为替代方案,你可以使用 Vue 的 provide / inject API,穿透传递 Viewer 给所有子组件,对兄弟组件就无能为力了(可以借助 EventBus,略麻烦,不再赘述)。

首先,是在 src/main.ts 中让 Vue 实例安装 pinia 状态管理库:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './main.css'

/* ... */

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

然后,是创建状态存储器,位于 src/store/sys.ts

import { defineStore } from 'pinia'
import { Viewer } from 'cesium'

export interface SysStore {
  cesiumViewer: Viewer | null
}

export const useSysStore = defineStore({
  id: 'sys',
  state: (): SysStore => ({
    cesiumViewer: null
  }),
  actions: {
    setCesiumViewer(viewer: Viewer) {
      this.cesiumViewer = viewer
    }
  }
})

紧接着,是在 App.vue 中使用 Vue 的 markRaw API,将 Viewer 对象标记为非响应式,避免 Vue 响应式劫持产生的访问性能问题,并调用 store 对应的 set 方法:

import { ref, onMounted, markRaw } from 'vue'
import { ArcGisMapServerImageryProvider, Camera, Viewer, Rectangle } from 'cesium'
import { useSysStore } from '@/store/sys'

const containerRef = ref<HTMLDivElement>()
const unvisibleCreditRef = ref<HTMLDivElement>()

const sysStore = useSysStore()

onMounted(() => {
  const viewer = new Viewer(containerRef.value as HTMLElement)

  const rawViewer = markRaw(viewer)
  sysStore.setCesiumViewer(rawViewer)
})

最后,你就可以在兄弟组件中访问到 Viewer 了:

<!-- BrotherComponent.vue -->

<template>
  <button @click="onClick">控制台打印 viewer</button>
</template>

<script setup lang='ts'>
import { useSysStore } from '@/store/sys'
const sysStore = useSysStore()

const onClick = () => {
  // 也可以写 getter,但我觉得这样就足够说明问题了
  console.log(sysStore.$state.cesiumViewer)
}
</script>

3. 伸手的看过来 - 工程下载

由于篇幅原因,有些文章中的代码会省略、简化,工程的源码、配置可能与上述有细微差别,请自行了解。

https://share.weiyun.com/ndkxAeIv

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器

你可能感兴趣的文章