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

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

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

微雲鏈接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章