教程 - 深度探讨在 Vue3 中引入 CesiumJS 的最佳方式 _
目录
由于 Vue2 已经进入维护期,且 Vue2 对待组件内的 data 是无差别使用 Object.defineProperties
递归将其劫持的,对于复杂状态的对象会造成严重的 JavaScript 访问路径过长而导致的 性能问题,这个应该是老生常谈了。
Vue3 提供了 markRaw
函数,标记一个对象,令 Vue 不再将其视作 响应式 数据,所以本文基于 Vue3 来介绍如何引入 CesiumJS。
心急的朋友可以直接跳过本文的介绍,拉到文末,有示例工程 zip 下载。
1. 你应该先知道的基础知识
除了 Vue3 和 Vue2 的响应式设计区别外,我认为还需要补充一点知识。
1.1. CesiumJS 的库构成
Cesium 是一个高度集成的重型 JavaScript 库,这是共识。它的源码虽然是 ESModule
格式的,但是并没有直接提供类似 index.js
的出口文件,也不存在子包的概念,只是在 Source
文件夹下简单分了几个大板块文件夹,例如 Source/Renderer
文件夹就是 CesiumJS 中整个渲染器的代码模块。
通常,除了二次修改 CesiumJS 源代码构建自己的分支版本,一般不会在 WebAPP 中直接使用 CesiumJS 的源码。一般使用的是 CesiumJS 的 构建版本,也就是 Build
文件夹下的压缩版或未压缩版库文件。
主库文件有三种格式,ESModule
的是 index.js
,IIFE
的是 Cesium.js
,CommonJS
的是 index.cjs
。除了主库文件外,构成构建版本的 CesiumJS 还有 4 个文件夹下的静态资源:
Assets
文件夹,图片或 JSON 等前端运行时可能用到的资源ThirdParty
文件夹,WebAssembly 等前端运行时可能用到的第三方资源Widgets
文件夹,主要是各个 CesiumJS 自带的界面小部件的 CSS 文件Workers
文件夹,前端运行时用到的 WebWorker 的构建版本(WebWorker 由于一些原因,在前端运行时仍然用 CommonJS 格式加载)
因此,你在任何所谓的教程里面都会看到这四个静态资源文件夹的复制操作,除了 CDN 直接使用的方式。我在这里说清楚,希望你知道原因。
1.2. 选择 Vite3 和 pnpm 的理由
笔者是 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 依赖也很棒。你当然也可以用 npm
和 yarn
。
1.3. 使用 External 模式引入静态库 - 不打包静态库
在 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 小节。
1.4. 切勿什么都 import - 以及页面运行的时候的路径与开发时的路径
在代码中,有一些特殊的关键字、指令会被打包器识别,打包器会帮你把相关的资源打包、转译。
在 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'
})
又如:
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 代码合并、打包、转译成优化后的产物。
2.1. 使用 create-vue 或 vite 模板
为了使用最新的全局状态管理器 pinia
,我选择用 create-vue
这个能替代 @vue/cli
的新版脚手架,完成具备如下开发工具配置的工程创建:
- 使用
pinia
- 使用
typescript
- 使用
eslint
- 使用
prettier
不要再问我这些是什么,这些属于 Vue 和 Web 前端的生态。
创建命令:
pnpm create vue
确保你的网络没有问题,那么你就可以伴随着如下命令行提示创建出与我一样的初始工程:
2.2. 指定版本安装 cesium 依赖
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
这样,以后安装依赖就不会安装到最新版本,以至于项目出现因重大变动导致运行不起来的问题了。
2.3. 不使用锁文件
在 package.json
同级别路径下创建 .npmrc
文件,配置包管理器的行为、参数,使用如下配置即可不产生锁文件:
package-lock=false
这对于严格控制 package.json
中依赖版本的项目,而且不指定包管理器(即允许任意使用 pnpm、yarn、npm 来管理依赖)的项目来说是十分有利的。
2.4. 配置 External 和构建后的 index.html
先安装 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 的静态资源复制。
2.5. 静态资源复制脚本
在 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 的静态文件。
注意 deleteSync
和 copy
函数的目标文件夹路径,我设为了 public/lib/cesium
,与 2.4 小节中 htmlConfig
的配置是一样的。
为了简单起见,vite.config.ts
中配置的 build.assetsDir
我改为了 ./
;否则,deleteSync
和 copy
的目标路径就要手动加上 build.assetsDir
了。例如,默认的 assetsDir 是 assets
,那么目标路径就从 public/lib/cesium
变成了 public/assets/lib/cesium
。
请十分仔细地注意这些路径问题,分清楚 public
文件夹、build.assetsDir
的意义,static-copy.js
文件的 cwd 等,分清楚 NodeJS 脚本和前端运行时的相对路径问题。
2.6. 使用环境变量配置 CESIUM_BASE_URL
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)
/* ... */
}
2.7. 使用全局状态库跨组件共享 Viewer 对象
这一步是可选的,当然,我强烈推荐你做这一步,这对跨组件访问 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 方法:
<template>
<div ref="containerRef" id="cesium-container" class="h500"></div>
</template>
<script lang="ts">
import { ref, unref, defineComponent, onMounted, markRaw } from 'vue';
import VScaleScreen from 'v-scale-screen';
// left
import Brief from './components/brief/index.vue';
import General from './components/general/index.vue';
import Record from './components/record/index.vue';
// center
import Video from './components/video/index.vue';
import Safety from './components/safety/index.vue';
// right
import Alarm from './components/alarm/index.vue';
import Site from './components/site/index.vue';
import {
ArcGisMapServerImageryProvider,
Camera,
Viewer,
Rectangle,
Cartesian3,
Color,
Math,
ScreenSpaceEventHandler,
Cartographic,
ScreenSpaceEventType,
Transforms,
Cesium3DTileset,
WebMapTileServiceImageryProvider,
Matrix4,
Matrix3,
} from 'cesium';
import { useSysStore } from '/@/store/modules/sys';
import emitter from '/@/utils/bus';
import { useGo } from '/@/hooks/web/usePage';
export default defineComponent({
components: {
VScaleScreen,
Brief,
General,
Record,
Video,
Safety,
Alarm,
Site,
},
setup() {
const go = useGo();
const loading = ref(true);
const containerRef = ref<HTMLDivElement>();
const unvisibleCreditRef = ref<HTMLDivElement>();
const sysStore = useSysStore();
const currentLocation = ref<number[]>([]);
const defaultHigh = ref(3000);
const defaultLocation = ref({
x: 117.242316,
y: 34.184651,
z: defaultHigh.value,
});
let viewer;
Camera.DEFAULT_VIEW_RECTANGLE = Rectangle.fromDegrees(
75.0, // 东
0.0, // 南
140.0, // 西
60.0, // 北
);
onMounted(() => {
loadMap();
});
function getLocation() {
currentLocation.value = [unref(defaultLocation).x, unref(defaultLocation).y];
}
function gosystemHandle() {
go('/dashboard/analysis');
}
function loadMap() {
getLocation();
viewer = new Viewer(containerRef.value as HTMLElement, {
animation: false,
timeline: false,
geocoder: false,
homeButton: false,
scene3DOnly: true,
baseLayerPicker: false,
navigationHelpButton: false,
fullscreenButton: false,
infoBox: false,
creditContainer: unvisibleCreditRef.value,
imageryProvider: new WebMapTileServiceImageryProvider({
url: 'http://t0.tianditu.gov.cn/img_w/wmts?tk=c7b67ab07b06c036b2e6caf9c0bad473',
layer: 'img',
style: 'default',
tileMatrixSetID: 'w',
format: 'tiles',
maximumLevel: 18,
}),
msaaSamples: 4,
selectionIndicator: false,
contextOptions: {
requestWebgl2: true,
},
});
viewer.imageryLayers.addImageryProvider(
new WebMapTileServiceImageryProvider({
url: 'http://t0.tianditu.gov.cn/cia_w/wmts?tk=c7b67ab07b06c036b2e6caf9c0bad473',
layer: 'cia',
style: 'default',
tileMatrixSetID: 'w',
format: 'tiles',
maximumLevel: 18,
}),
);
init3Dtitles();
let handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(function (event) {
let ray = viewer.camera.getPickRay(event.position);
let cartesian = viewer.scene.globe.pick(ray, viewer.scene);
let cartographic = Cartographic.fromCartesian(cartesian);
let lng = Math.toDegrees(cartographic.longitude); // 经度
let lat = Math.toDegrees(cartographic.latitude); // 纬度
let alt = cartographic.height; // 高度
let coordinate = {
longitude: Number(lng.toFixed(6)),
latitude: Number(lat.toFixed(6)),
altitude: Number(alt.toFixed(2)),
};
let x = coordinate.longitude;
let y = coordinate.latitude;
addPoint(x, y);
currentLocation.value = [x, y];
}, ScreenSpaceEventType.LEFT_CLICK);
const rawViewer = markRaw(viewer);
sysStore.setCesiumViewer(rawViewer);
addPoint(currentLocation.value[0], currentLocation.value[1]);
}
function addPoint(x, y) {
viewer.entities.removeAll();
let no1 = Cartesian3.fromDegrees(x, y);
viewer.entities.add({
position: no1,
point: {
show: true, // default
color: Color.SKYBLUE, // default: WHITE
pixelSize: 10, // default: 1
outlineColor: Color.YELLOW, // default: BLACK
outlineWidth: 3, // default: 0
},
});
flyTo(x, y);
}
function flyTo(x, y) {
viewer.camera.flyTo({
destination: Cartesian3.fromDegrees(x, y, defaultHigh.value),
orientation: {
heading: Math.toRadians(0), // 水平偏角,默认正北 0
pitch: Math.toRadians(-90), // 俯视角,默认-90,垂直向下
roll: 0, // 旋转角
},
});
}
function init3Dtitles() {
let longitude = 117.242316;
let latitude = 34.184651;
let height = 0;
var tileSet = new Cesium3DTileset({
url: 'http://192.168.1.56/tiles1/tileset.json',
});
viewer.scene.primitives.add(tileSet);
tileSet.readyPromise.then((tileset) => {
tileSetTranslation(tileset, longitude, latitude, height, 0, 0, 0, 1);
});
viewer.zoomTo(tileSet);
}
function tileSetTranslation(
tileset,
longitude,
latitude,
height,
rotateX,
rotateY,
rotateZ,
scale,
) {
//旋转角度设置
var mx = Matrix3.fromRotationX(Math.toRadians(rotateX));
var my = Matrix3.fromRotationY(Math.toRadians(rotateY));
var mz = Matrix3.fromRotationZ(Math.toRadians(rotateZ));
var rotationX = Matrix4.fromRotationTranslation(mx);
var rotationY = Matrix4.fromRotationTranslation(my);
var rotationZ = Matrix4.fromRotationTranslation(mz);
//平移 修改经纬度
var position = Cartesian3.fromDegrees(longitude, latitude, height);
var transform = Transforms.eastNorthUpToFixedFrame(position);
//旋转、平移矩阵相乘
Matrix4.multiply(transform, rotationX, transform);
Matrix4.multiply(transform, rotationY, transform);
Matrix4.multiply(transform, rotationZ, transform);
//缩放 修改缩放比例
var scale1 = Matrix4.fromUniformScale(scale);
Matrix4.multiply(transform, scale1, transform);
//赋值给tileset
tileset._root.transform = transform;
}
setTimeout(() => {
loading.value = false;
}, 1500);
return {
loading,
gosystemHandle,
containerRef,
unvisibleCreditRef,
};
},
});
</script>
<style lang="scss" scoped>
#cesium-gis-container {
z-index: 100;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-image: url('./components/img/map_img.png');
background-size: cover;
background-position: 0 0;
}
</style>
最后,你就可以在兄弟组件中访问到 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://www.cnblogs.com/onsummer/p/16629036.html
- 关于博主: 评论和私信会在第一时间回复。或者直接私信我。
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
- 声援博主: 如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。
更多推荐
所有评论(0)