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

嶺南燈火發表於2023-04-09


這篇如果 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 cesium@1.104

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 瓦片服務的三維地球:

image

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 檔案:

image

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/ 下(沒有就自己建立一下):

image

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 埠的服務:

image

執行起來和開發時無異。

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']
        }
      }
    }
  }
})

這樣之後打包的產物就略有不同:

image

image

似乎 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 依賴包中的資料說明

image

透過包管理器下載到 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 三種格式的庫檔案,檔名有所不同。

image

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 四大資料夾。

image

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 cesium@1.104

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,開啟瀏覽器發現找不到模組:

image

這是因為在開發模式也把 CesiumJS 外部化了,找不到很正常。

Vite 啟動後會有一個依賴預構建的過程,開啟 node_modules/.vite/deps 目錄,這裡就是預構建的各種程式碼中匯入的依賴包

在開發模式只需配置一下即可避免外部化,而讓 Vite 把 cesium 依賴預構建:

vitePluginExternals({
  cesium: 'Cesium',
}, {
  disableInServe: true, // 開發模式時不外部化
})

執行 pnpm build 後,提升顯著:

image

但是 pnpm preview 時,依然會找不到從 cesium 依賴匯入的類(注意埠,是 preview 預設的 4173):

image

這是因為外部化 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 的一句警告:

image

為了解決這個問題,最好的辦法就是在 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'
        })
      ]
    })
  ],
}

這樣打包時就是絕對完美的訊息了:

image

但是到此為止,仍然有兩個需要 “手動” 的事情待解決:

  • 四大靜態檔案的複製
  • 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 節完整配置後、具備各種註釋和細節,供讀者自己改造學習的版本。

微雲連結

相關文章