普通视图

发现新文章,点击刷新页面。
今天 — 2025年5月19日掘金 前端

让文字飞起来!EasyVoice文本转语音神器:cpolar内网穿透实验室第608成功挑战

NO.608  EasyVoice-1.jpg

软件名称:EasyVoice

操作系统支持:飞牛云fnOS

软件介绍: 在这个信息爆炸的时代,内容消费者们常常面临一个棘手的问题:如何高效地获取和消化大量文字信息。无论是想快速获取知识、提升学习效率,还是希望将创意转化为生动的声音,EasyVoice都能成为你的得力助手。

NO.608  EasyVoice-2.jpg

EasyVoice的出色功能

  • 高质量语音输出:让文字活起来,享受自然流畅的聆听体验。
  • 本地部署:通过Docker和Node.js一键安装,数据安全无忧,适合企业或开发者使用。
  • 流式传输:无论文本有多长,都能立即播放,打破了传统转换工具的限制。

实用场景大揭秘

  1. 听小说不停歇:将长篇小说一键转为有声书,即使在忙碌的工作日也能享受阅读的乐趣。
  2. 配音创作自由:轻松为视频或演示文稿添加专业级语音,提升内容质量。
  3. 学习效率翻倍:将笔记和教材转换为语音材料,在通勤路上也能高效学习。

NO.608  EasyVoice-3.jpg

cpolar内网穿透技术带来的便利 通过cpolar的内网穿透技术,用户可以轻松突破局域网限制,使EasyVoice在本地部署后依然能够方便地与外部服务互动。这对于需要在内部网络环境中使用该工具的开发者和企业来说,是一个巨大的优势。

总结 EasyVoice不仅仅是一款文本转语音软件,更是内容消费和创作方式的一次革命。无论你是渴望提升学习效率、还是希望赋予创意生动的声音,EasyVoice都能轻松满足你的需求。

NO.608  EasyVoice-4.jpg

如何在飞牛云fnOS中安装EasyVoice并实现内网穿透,请参考下面教程:

1. 环境准备

本例中在Windows系统使用VMware Workstation安装的fnOS虚拟机,系统版本为V0.8.41。如果不知道如何在虚拟机中安装,可以参考这篇文章:VMware中安装飞牛云(fnOS) NAS系统 如果您想要在x86架构的物理机中安装,可以访问飞牛私有云 fnOS官网下载镜像文件然后使用U盘写入镜像后,进入bios设置U盘启动后像装Windows系统一样安装即可。

EasyVoice项目地址:github.com/cosin2077/e…

启动fnOS系统后,能看到Web UI管理界面的地址:http://192.168.184.130:5666 在浏览器中打开:

image-20250509105552969

2. Docker部署与运行

首先,点击Docker-Compose-新增项目:

image-20250513103247093

在弹出的创建项目窗口中,填写项目名称:easyvoice(可自定义):

image-20250513104035416

点击路径后,在docke文件夹内新建一个名为EasyVoice的项目路径,点击确定:

image-20250513103409909

然后点击创建 docke-compose.yml ,将下面的代码粘贴到输入框:

services:
  easyvoice:
    image: cosincox/easyvoice:latest
    restart: unless-stopped
    container_name: easyvoice
    ports:
      - "9549:3000"
    environment:
      - DEBUG=true
      - OPENAI_BASE_URL=https://openrouter.ai/api/v1/
    volumes:
      - ./audio:/app/audio

image-20250513103939267

勾选创建项目后立即启动,点击确定,自动构建容器:

image-20250513104145965

等待构建完成后,在容器中,能看到easyvoice已经正常启动了:

image-20250513104430536

在浏览器中访问fnOS飞牛nas主机地址加端口号9549: http://192.168.184.130:9549 就能看到EasyVoice的Web UI管理界面了:

image-20250513104605241

3. 简单使用测试

点击立即体验:

image-20250513110831119

在跳转的文本转语音页面,我们可以在左侧手动输入文本或上传txt格式的文本文件来添加需要转换的内容:

image-20250513111012628

而在右侧是对语音进行设置的选项,包括语言、性别、配音角色、语速、音量、音调等多种设置:

image-20250513112139608

输入文字后,点击生成语音:

image-20250513111432386

速度非常快,资源占用也很少,不需要什么性能就可以轻松生成语音:

image-20250513111505721

生成的音频可以直接播放,也可以下载到本地:

image-20250513111605877

再测试一下拖拽文件或点击上传一个txt格式小说试试:

image-20250513114218758

随着需要转换成语音的文字字数增多,生成的时间也会增加:

image-20250513114237849

等待转换结束后,可以看到,一个多小时的文本量也能正常转换成音频:

image-20250513114803797

除了预设语音功能,EasyVoice目前还增加了实验性功能的AI推荐,可以通过AI将需要转换为语音的文字智能推荐不同的角色语音。如果想体验这个功能,我们可以在上边通过docker-compose创建容器时,在代码中的环境变量里添加需要调用的本地大模型地址(本例中的地址为ollama部署的主机IP+端口号)与要使用的模型名称即可:

b44ccf9ead8f60d0bbc18659d17da606

实际测试后确实能分角色朗读,但并不会新增角色语音,也是调用预设语音中的角色进行转换。而且根据不同的模型能力,实际得到的结果也不相同,支持函数调用的模型似乎效果更好一些,还是可以期待后续的优化的。

image-20250513163006659

image-20250513163310851

4. 安装内网穿透

我们现在已经实现了在本地fnOS飞牛云NAS中部署了EasyVoice进行文本转语音,并能在在同一局域网内向其他人分享这个工具的链接在浏览器中进行体验了。但如果你想自己或是异地好友和同事也能远程使用你在本地飞牛云NAS中部署的EasyVoice服务该怎么办呢?很简单,只要安装一个cpolar内网穿透工具就能轻松实现远程访问内网主机中部署的服务了,节约成本,提高效率,接下来介绍一下如何安装cpolar内网穿透。

cpolar官网地址: www.cpolar.com

4.1 开启ssh连接安装cpolar

首先打开飞牛云NAS设置界面,开启ssh 连接,端口默认为22即可,开启后,我们就可以ssh 连接飞牛云NAS执行命令:

853d0e568b7879cca312f7b18d4fbb4.png

然后我们通过输入飞牛云NAS的IP地址ssh远程连接进去,因为fnOS是基于Linux 内核开发的,所以我们可以按照cpolar的Linux安装方法进行安装:

image-20250225152553263

连接后执行下面cpolar Linux 安装命令:

sudo curl https://get.cpolar.sh | sh

再次输入飞牛云nas的密码确认后即可自动安装

安装完成后,执行下方命令查看cpolar服务状态:(如图所示即为正常启动)

sudo systemctl status cpolar

image-20250225153049854

Cpolar安装和成功启动服务后,在浏览器上输入飞牛云主机IP加9200端口即:【http://localhost:9200】访问Cpolar管理界面,使用官网注册的账号登录,登录后即可看到配置界面,接下来在web界面配置即可:

image.png

4.2 创建公网地址

登录cpolar web UI管理界面后,点击左侧仪表盘的隧道管理——创建隧道:

  • 隧道名称:可自定义,本例使用了: easyvoice 注意不要与已有的隧道名称重复
  • 协议:http
  • 本地地址:9549
  • 域名类型:随机域名
  • 地区:选择China Top

image-20250513134512469

创建成功后,打开左侧在线隧道列表,可以看到刚刚通过创建隧道生成了两个公网地址,使用上面的任意一个公网地址在浏览器中访问就可以实现随时随地远程使用你在本地部署的EasyVoice来文本转语音了!

image-20250513134634179

使用cpolar生成的公网地址,无需自己准备云服务器,无公网IP也能轻松搞定跨网络环境远程访问本地服务!

image-20250513134726744

小结

为了方便演示,我们在上边的操作过程中使用cpolar生成的HTTP公网地址隧道,其公网地址是随机生成的。这种随机地址的优势在于建立速度快,可以立即使用。然而,它的缺点是网址是随机生成,这个地址在24小时内会发生随机变化,更适合于临时使用。

如果有长期使用本地飞牛云NAS中部署的EasyVoice文本转语音工具,或者异地访问与使用其他本地部署的服务的需求,但又不想每天重新配置公网地址,还想让公网地址好看又好记并体验更多功能与更快的带宽,那我推荐大家选择使用固定的二级子域名方式来配置公网地址。

5. 配置固定公网地址

接下来演示如何为EasyVoice文本转语音服务配置固定的HTTP公网地址,该地址不会变化,无需每天重复修改服务器地址。

配置固定http端口地址需要将cpolar升级到专业版套餐或以上。

登录cpolar官网,点击左侧的预留,选择保留二级子域名,设置一个二级子域名名称,点击保留,保留成功后复制保留的二级子域名名称:

image-20250513135011703

保留成功后复制保留成功的二级子域名的名称: myeasyv,大家可以设置自己喜欢的名称。

image-20250513135032230

返回Cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道:easyvoice,点击右侧的编辑:

image-20250513135152962

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名:myeasyv

点击更新(注意,点击一次更新即可,不需要重复提交)

image-20250513135246942

更新完成后,打开在线隧道列表,此时可以看到公网地址已经发生变化,地址名称也变成了固定的二级子域名名称的域名:

image-20250513135340358

使用上面的任意一个固定的二级子域名公网地址在浏览器中访问,可以看到成功打开EasyVoice文本转语音的Web UI管理界面,现在开始就不用每天都更换随机公网地址来远程访问本地nas中部署的服务了。

image-20250513135611778

同样可以使用AI推荐功能:

image-20250513164000852

总结

在现代数字时代,随着智能语音技术的快速发展,文本转语音(TTS)工具在各类应用场景中发挥着重要作用。本文分享了如何在fnOS飞牛NAS中本地部署EasyVoice文本转语音工具,并结合cpolar内网穿透工具配置固定不变的二级子域名公网地址,实现随时随地远程访问本地部署服务。

通过本教程的完整部署,您已经成功构建了一个可远程访问的本地语音合成服务。该方案不仅解决了传统内网服务的访问限制问题,还通过容器化部署实现了服务的快速扩展和维护。在实际应用中,建议根据具体需求调整性能参数,例如增加GPU加速支持以提升语音合成速度。

这样行云流水般提交代码的体验真是爽爆了!

作者 风度前端
2025年5月19日 11:44

在项目开发中,要问使用最多的终端命令是什么,那就是git相关的命令,对于最频繁操作的 git 命令,我在《Mac键指如飞攻略之终端alias配置》这篇文章里提到了使用 alias 来简化命令,使得对于频繁操作的提交命令如git commitgit push等整合成了一个命令,如:

gci msg // 暂存代码并提交到本地仓库

gcp msg // 暂存代码并提交到本地仓库同时推送到远程仓库

也就是说通过gcigcp就可以实现本地代码的快速推动到远程的仓库,这篇文章里所做的只是对于 git 命令拼写的简化,也就是减少了对于提交代码操作所需要的输入 git 命令字符的数量。

但存在的痛点仍然还有:

  • 经过alias后的git提交命令仍然需要键盘手输
  • 提交的commit信息还是要手输

之前一直都没有太好的解决方式,直到发现GitHub Copilot更新后在代码管理工具里提供了使用 ai 生成commit信息后,以上的两个痛点才得到了解决。

经过一番探索,最终实现了,无需键盘字符输入无鼠标点击的全键盘操作提交代码,彻底抛弃提交代码命令,极大的提升代码提交的效率。

这里先附上最终实现的效果图:

可以看到整个提交代码的流程我都是没有输入任何命令的,完全都是使用快捷键来实现的,整个提交代码的操作非常的行云流水。其整个操作大致经过了如下四个步骤:

  • 使用快捷键从资源管理器切换到源代码管理器
  • 使用GitHub Copilot生成commit信息的快捷键
  • 使用提交代码的快捷键
  • 使用推送到远程仓库的快捷键

下面我就说下具体的实现步骤:

使用快捷键从资源管理器切换到源代码管理器

vscode 中活动侧边栏都有系统内置的快捷键,分别是

  • 切换到资源管理器⌘ + ⇧ + e
  • 切换到全局搜索⌘ + ⇧ + f
  • 切换到源代码管理器⌘ + ⌃ + g
  • 切换到扩展⌘ + ⇧ + x

在一个步骤,我就是从资源管理器的目录,使用切换源代码管理器的快捷键切换到源代码管理 tab,此时焦点聚焦在了源代码提交的输入框内。

使用GitHub Copilot生成commit信息的快捷键

使用⌘ + ⇧ + x切换到扩展,进入 vscode 扩展商店,搜索Copilot,安装GitHub Copilot,不需要配置,登录自己的github账号就行

安装完成后我们再使用⌘ + ⌃ + g来切换回源代码管理器,就可以看到在输入框的右侧多了一个小✨的小图标,点击后就会调用github copilot提供的智能生成commit信息功能:

当然点击生成 commit 信息不是我们想要的操作,会打断我们整个的无鼠标提交代码流程,这个命令默认是没有配置快捷键的,图上显示的快捷键是我配置后展示的,下面我们就为这个命令匹配快捷键。

打开 vscode 键盘快捷键配置(不知道怎么进入的,可以看我往期文章),搜索:github copilot,第三个就是我们的目标匹配结果,为其设置快捷键⌘ + ⌃ + m,之所以如此设置,是因为m代表了message的含义,表示我们使用这个快捷键来生成提交信息,⌘+⌃的修饰符组合,也不会与其他快捷键冲突,这个快捷键配置可以放心食用。

使用提交代码的快捷键

生成提交信息后,下一步就是将代码提交到远程仓库,土办法当然是点击源代码管理器搜索框下面的提交按钮,但是鼠标操作自然是我们所摒弃的,这个提交操作不需要额外的快捷键配置,直接使用 ⌘+enter就会提交到本地仓储。

使用推送到远程仓库的快捷键

完成了本地仓储的提交,下面一步就是提交到远程仓库,这一步骤 vscode 没有默认的快捷键,但是内置的源代码管理器却提供了相关的命令,所以我们只需要给给相关的命令绑定快捷键就行,同样的打开vscode 键盘快捷键配置,搜索推送,看到Git:推送这个命令就是我们想要的结果,设置快捷键⌘ + ⌃ + p。这样设置的原因是,p代表了push的含义,而且⌘ + ⌃的修饰符组合也和第二步骤保持了一致,使得整个操作的快捷键记都非常统一,且易于记忆。

总结

经过上述的四个步骤,我们就可以实现一次行云流水般的无鼠标点击的提交代码操作,从提交信息到推送到远程仓库全程键盘操作,几秒内完成。

若能熟练掌握这套快捷键,各位coder的提交代码的速度将无人能及。

Vue集成开源的低代码表单设计器教程-兼容Element Plus/Ant Design/Vant,支持PC/移动端

作者 无懈可击
2025年5月19日 11:32

FcDesigner 是一款基于Vue的开源低代码可视化表单设计器工具,通过数据驱动表单渲染。可以通过拖拽的方式快速创建表单,提高开发者对表单的开发效率,节省开发者的时间。并广泛应用于在政务系统、OA系统、ERP系统、电商系统、流程管理等领域。

源码地址: Github | Gitee | 文档 | 在线演示

本项目采用 Vue 和 ElementPlus/ElementUI/AntDesignVue 进行页面构建,内置多语言解决方案,支持二次扩展开发,支持自定义组件扩展。

项目分为设计器 form-create-designer 和 渲染器 form-create,用户可以通过可视化界面快速高效地创建表单,并输出为JSON。并且通过加载JSON,渲染器可以渲染并输出相应的表单。

  • @form-create/designer ElementPlus/ElementUI表单设计器 💻

  • @form-create/antd-designer AntDesignVue表单设计器(Vue3) 💻

  • @form-create/vant-designer Vant移动端表单设计器(Vue3) 📱

Element UI 版本表单设计器

https://view.form-create.com/img/example.jpg

本项目采用 Vue2.7 和 Element UI 进行页面构建,内置多语言解决方案,支持二次扩展开发,支持自定义组件扩展。演示站

安装

要开始使用 @form-create/designer,首先需要将其安装到您的项目中。可以通过 npm 安装:

npm install @form-create/designer@^1
npm install @form-create/element-ui@^2.7
npm install element-ui

如已安装旧版本渲染器,请执行以下命令更新至最新版:

npm update @form-create/element-ui@^2.7

请检查当前 Vue 版本,若版本低于 2.7,请执行以下升级命令:

npm update vue@^2.7

引入

Node.js 引入

对于 Node.js 项目,您需要通过 npm 安装相关依赖,并在您的项目中引入并配置它们。

import Vue from 'vue';
import FcDesigner from '@form-create/designer';
import ELEMENT from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
// 使用 Element UI
Vue.use(ELEMENT);
// 使用 form-create 和 designer
Vue.use(FcDesigner);
Vue.use(FcDesigner.formCreate);

CDN 引入

如果您希望通过 CDN 方式引入 FcDesigner,请确保先引入 Vue.js 和 Element UI。然后引入 @form-create/element-ui@form-create/designer,并在 Vue 实例中进行配置。

<!-- 引入 Vue.js -->
<script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>
<!-- 引入 Element UI 样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- 引入 Element UI -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<!-- 引入 form-create 和 designer -->
<script src="https://unpkg.com/@form-create/element-ui/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/designer/dist/index.min.js"></script>
<div id="app">
    <fc-designer height="100vh"></fc-designer>
</div>
<script>
    Vue.use(FcDesigner);
    Vue.use(FcDesigner.formCreate);
    new Vue().$mount('#app');
</script>

使用

在 Vue 组件中,您可以像下面这样使用 fc-designer 组件:

<template>
    <fc-designer ref="designer" height="100vh" />
</template>

Element Plus版本表单设计器

@form-create/designer 支持 Vue 3 环境,以下是如何在 Vue 3 项目中安装和使用该库的指南。

演示站

安装

首先,安装 @form-create/designer 的 Vue 3 版本:

npm install @form-create/designer@^3
npm install @form-create/element-ui@^3
npm install element-plus

如已安装旧版本渲染器,请执行以下命令更新至最新版:

npm update @form-create/element-ui@^3

引入

Node.js 引入

对于使用 Node.js 的项目,按照以下步骤在您的 Vue 3 项目中引入并配置:

import { createApp } from 'vue';
import FcDesigner from '@form-create/designer';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
// 创建 Vue 应用
const app = createApp(App);
// 使用 Element Plus 和 FcDesigner
app.use(ElementPlus);
app.use(FcDesigner);
app.use(FcDesigner.formCreate);
// 挂载应用
app.mount('#app');

CDN 引入

如果您选择使用 CDN,可以按照以下步骤在 HTML 文件中引入相关依赖:

<!-- 引入 Element Plus 样式 -->
<link href="https://unpkg.com/element-plus/dist/index.css" rel="stylesheet" />
<!-- 引入 Vue 3 -->
<script src="https://unpkg.com/vue"></script>
<!-- 引入 Element Plus -->
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<!-- 引入 form-create 和 designer -->
<script src="https://unpkg.com/@form-create/element-ui@next/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/designer@next/dist/index.umd.js"></script>
<div id="app">
    <fc-designer height="100vh"></fc-designer>
</div>
<script>
    const { createApp } = Vue;
    const app = createApp({});
    app.use(ElementPlus);
    app.use(FcDesigner);
    app.use(FcDesigner.formCreate);
    app.mount('#app');
</script>

使用

在 Vue 3 组件中,您可以通过以下方式使用 fc-designer 组件:

<template>
    <fc-designer ref="designer" height="100vh" />
</template>
<script setup>
    import { ref } from 'vue';
    // 可以在此处获取设计器实例或进行其他操作
    const designer = ref(null);
</script>

AntDesignVue 版本PC端表单设计器

演示站

本项目采用 Vue3.0 和 Ant Design Vue 进行页面构建,内置多语言解决方案,支持二次扩展开发,支持自定义组件扩展。

https://view.form-create.com/img/example.gif

安装

首先,安装 @form-create/antd-designer

npm install @form-create/antd-designer@^3
npm install @form-create/ant-design-vue@^3
npm install ant-design-vue

如已安装旧版本渲染器,请执行以下命令更新至最新版:

npm update @form-create/ant-design-vue@^3

引入

Node.js 引入

对于使用 Node.js 的项目,按照以下步骤在您的 Vue 3 项目中引入并配置:

import FcDesigner from '@form-create/antd-designer'
import antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
// 创建 Vue 应用
const app = createApp(App);
app.use(antd)
app.use(FcDesigner)
app.use(FcDesigner.formCreate)
// 挂载应用
app.mount('#app');

CDN 引入

如果您选择使用 CDN,可以按照以下步骤在 HTML 文件中引入相关依赖:

<link rel="stylesheet" href="https://unpkg.com/ant-design-vue@4/dist/reset.css"></link>
<link rel="stylesheet" href="https://fastly.jsdelivr.net/npm/vant@4/lib/index.css"></link>
<!-- 引入 Vue 及所需组件 -->
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
<script src="https://unpkg.com/dayjs/plugin/customParseFormat.js"></script>
<script src="https://unpkg.com/dayjs/plugin/weekday.js"></script>
<script src="https://unpkg.com/dayjs/plugin/localeData.js"></script>
<script src="https://unpkg.com/dayjs/plugin/weekOfYear.js"></script>
<script src="https://unpkg.com/dayjs/plugin/weekYear.js"></script>
<script src="https://unpkg.com/dayjs/plugin/advancedFormat.js"></script>
<script src="https://unpkg.com/dayjs/plugin/quarterOfYear.js"></script>
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/ant-design-vue@4/dist/antd.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/vant@4/lib/vant.min.js"></script>


<!-- 引入 form-create 及 fcDesigner -->
<script src="https://unpkg.com/@form-create/ant-design-vue@^3/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/vant@^3/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/antd-designer@^3/dist/index.umd.js"></script>


<div id="app">
    <fc-designer height="100vh"></fc-designer>
</div>
<!-- 挂载组件 -->
<script>
    // 创建 Vue 应用实例
    const app = Vue.createApp({});
    // 挂载 AntDesignVue
    app.use(antd);
    // 挂载 fcDesignerPro 组件
    app.use(FcDesigner);
    // 挂载 formCreate
    app.use(FcDesigner.formCreate);
    // 挂载 Vue 应用
    app.mount('#app');
</script>

使用

在 Vue 3 组件中,您可以通过以下方式使用 fc-designer 组件:

<template>
    <fc-designer ref="designer" height="100vh" />
</template>
<script setup>
    import { ref } from 'vue';
    // 可以在此处获取设计器实例或进行其他操作
    const designer = ref(null);
</script>

移动端表单设计器

demo1

演示站

本项目采用 Vue3.0 和 ElementPlus 进行移动端页面构建,移动端使用的是vant4.0版本,内置多语言解决方案,支持二次扩展开发,支持自定义组件扩展。

安装

首先,安装 @form-create/vant-designer

npm install @form-create/vant-designer@^3
npm install @form-create/element-ui@^3
npm install @form-create/vant@^3
npm install element-plus
npm install vant

如已安装旧版本渲染器,请执行以下命令更新至最新版:

npm update @form-create/element-ui@^3
npm update @form-create/vant@^3

引入

Node.js 引入

对于使用 Node.js 的项目,按照以下步骤在您的 Vue 3 项目中引入并配置:

import FcDesignerMobile from '@form-create/vant-designer'
import ELEMENT from 'element-plus';
import vant from 'vant';
import 'vant/lib/index.css';
import 'element-plus/dist/index.css';
// 创建 Vue 应用
const app = createApp(App);
app.use(ELEMENT)
app.use(vant)
app.use(FcDesignerMobile)
app.use(FcDesignerMobile.formCreate)
// 挂载应用
app.mount('#app');

CDN 引入

如果您选择使用 CDN,可以按照以下步骤在 HTML 文件中引入相关依赖:

<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css"></link>
<link rel="stylesheet" href="https://unpkg.com/vant@4/lib/index.css"/>
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<script src="https://unpkg.com/vant@4/lib/vant.min.js"></script>
<script src="https://unpkg.com/@form-create/element-ui@next/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/vant@next/dist/form-create.min.js"></script>
<script src="https://unpkg.com/@form-create/vant-designer@next/dist/index.umd.js"></script>
<div id="app">
    <fc-designer-mobile height="100vh"></fc-designer-mobile>
</div>
<script>
    const { createApp } = Vue;
    const app = createApp({});
    app.use(ElementPlus);
    app.use(vant);
    app.use(FcDesignerMobile);
    app.use(FcDesignerMobile.formCreate);
    app.mount('#app');
</script>

使用

在 Vue 3 组件中,您可以通过以下方式使用 fc-designer 组件:

<template>
    <fc-designer-mobile ref="designer" height="100vh" />
</template>
<script setup>
    import { ref } from 'vue';
    // 可以在此处获取设计器实例或进行其他操作
    const designer = ref(null);
</script>

Cesium 轨迹巡航效果

2025年5月19日 11:12

Cesium 轨迹巡航效果

话不多说,先上图!

Jietu20250519-101743-HD.gif

在这篇文章中,我将分享如何使用 Cesium 实现一个动态的无人机巡航轨迹效果。通过该功能,我们可以模拟无人机沿着指定路径飞行,并实时展示其位置、速度、高度等信息。以下是功能的详细介绍和实现过程。

实现思路

  1. 路径绘制

  2. 动态飞行

  3. 状态更新

    • 在飞行过程中,通过 Cesium 的 JulianDate 和 Cartesian3 获取无人机的实时位置。
    • 计算无人机的速度、高度、飞行距离等信息,并通过回调函数实时更新。

核心代码

以下是实现无人机巡航轨迹的核心代码片段:

1. 路径绘制
const positions = [];
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);

// 鼠标左键点击添加路径点
handler.setInputAction(function (movement) {
  const cartesian = viewer.scene.pickPosition(movement.position);
  if (cartesian) {
    positions.push(cartesian);
    if (positions.length >= 2) {
      drawPolyline(positions);
    }
  }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);

// 绘制路径
function drawPolyline(positions) {
  viewer.entities.add({
    polyline: {
      positions: new Cesium.CallbackProperty(() => positions, false),
      material: new Cesium.PolylineGlowMaterialProperty({
        glowPower: 0.1,
        color: Cesium.Color.YELLOW
      }),
      width: 10,
      clampToGround: true
    }
  });
}
2. 动态飞行
const sampledPosition = new Cesium.SampledPositionProperty();
const start = Cesium.JulianDate.fromDate(new Date());
const stop = Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate());

// 添加路径点和时间点
positions.forEach((position, index) => {
  const time = Cesium.JulianDate.addSeconds(start, index * 10, new Cesium.JulianDate());
  sampledPosition.addSample(time, position);
});

// 添加无人机模型
viewer.entities.add({
  position: sampledPosition,
  orientation: new Cesium.VelocityOrientationProperty(sampledPosition),
  model: {
    uri: "/GroundVehicle.glb",
    scale: 1.0
  }
});

// 设置时间范围
viewer.clock.startTime = start.clone();
viewer.clock.stopTime = stop.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP;
viewer.clock.multiplier = 10;
3. 状态更新
function updateStatus(time) {
  const position = sampledPosition.getValue(time);
  const cartographic = Cesium.Cartographic.fromCartesian(position);
  const longitude = Cesium.Math.toDegrees(cartographic.longitude);
  const latitude = Cesium.Math.toDegrees(cartographic.latitude);
  const height = cartographic.height;

  console.log(`经度: ${longitude}, 纬度: ${latitude}, 高度: ${height}`);
}
viewer.clock.onTick.addEventListener(updateStatus);
完整代码
📄 dynamicObject.js
import core from "./Core.js";
import getPosition from "./getPosition.js";

/**
 * 创建浏览对象。
 * @constructor xp
 * @time 2022-12-25
 * @param {*} viewer
 * @param {*} cesium
 */
function dynamicObject(viewer, cesium) {
  this._viewer = viewer;
  this._cesium = cesium;
  this._core = new core();
  this._getPosition = new getPosition(this._viewer, this._cesium);
  this._entityFly = null;
}

/**
 * 这个方法用于创建浏览对象
 * @returns {Promise.<Object>} 返回一个Cesium的对象。
 *
 */
// var ploylinejl = {
//   polyline: {},
//   cameraRoll: null,
//   cameraPitch: null,
//   cameraPosition: null,
//   cameraHeading: null,
//   positions: [],
//   distance: [],
//   Totaltime: "",
//   dsq: null
// };
dynamicObject.prototype.executeFlycesium = function (method) {
  //设置浏览路径
  // var polylines = {};
  var _this = this;
  var PolyLinePrimitive = (function () {
    function execute(positions) {
      this.options = {
        polyline: {
          show: true,
          positions: [],
          material: new _this._cesium.PolylineGlowMaterialProperty({
            glowPower: 0.1,
            color: _this._cesium.Color.YELLOW
          }),
          width: 10,
          clampToGround: true
        }
      };
      this.positions = positions;
      this._init();
    }

    execute.prototype._init = function () {
      var _self = this;
      var _update = function () {
        return _self.positions;
      };
      //实时更新polyline.positions
      this.options.polyline.positions = new _this._cesium.CallbackProperty(
        _update,
        false
      );
      this.flycesium = _this._viewer.entities.add(this.options);
      _this.item = this.flycesium;
    };
    return execute;
  })();
  var handler = (this.handler = new _this._cesium.ScreenSpaceEventHandler(
    _this._viewer.scene.canvas
  ));
  var positions = [];
  // var flyceium = null;
  var distance = 0;
  var poly = undefined;
  var ploylinejl = {
    polyline: {},
    cameraRoll: null,
    cameraPitch: null,
    cameraPosition: null,
    cameraHeading: null,
    positions: [],
    distance: [],
    Totaltime: ""
  };
  //导入tool提示框
  var tooltip = this._core.CreateTooltip();
  //设置鼠标样式
  this._core.mouse(this._viewer.container, 1, window.SmartEarthRootUrl);
  handler.setInputAction(function (movement) {
    var cartesian = _this._getPosition.getMousePosition(movement);
    if (_this._core.getBrowser().pc === "pc" && positions.length == 0) {
      positions.push(cartesian.clone());
    }
    positions.push(cartesian);
    if (positions.length >= 2) {
      if (!_this._cesium.defined(poly)) {
        poly = new PolyLinePrimitive(positions);
      }
      distance = _this._core.getSpaceDistancem(positions, _this._cesium);
    }
  }, this._cesium.ScreenSpaceEventType.LEFT_CLICK);
  //鼠标移动
  handler.setInputAction(function (movement) {
    tooltip.showAt(movement.endPosition, "左键开始,右键结束!");
    var cartesian = _this._getPosition.getMousePosition(movement);
    if (positions.length >= 2) {
      if (!_this._cesium.defined(poly)) {
        poly = new PolyLinePrimitive(positions);
      } else {
        if (cartesian) {
          positions.pop();
          //cartesian.y += (1 + Math.random());
          positions.push(cartesian);
        }
      }
      distance = _this._core.getSpaceDistancem(positions, _this._cesium);
    }
  }, this._cesium.ScreenSpaceEventType.MOUSE_MOVE);
  //单击鼠标右键结束画线
  handler.setInputAction(function () {
    _this.end();
  }, this._cesium.ScreenSpaceEventType.RIGHT_CLICK);

  this.end = function (type) {
    handler.destroy();
    tooltip.show(false);
    //设置鼠标样式
    _this._core.mouse(_this._viewer.container, 0);

    _this.end = undefined;
    _this._viewer.entities.remove(_this.item);

    if (type === "cancel" || positions.length < 2) {
      return;
    }
    distance = _this._core.getSpaceDistancem(positions, _this._cesium);

    ploylinejl.polyline = poly;
    ploylinejl.positions = positions;
    ploylinejl.distance = parseFloat(distance);
    //转化成浏览对象
    _this.setFlycesium(ploylinejl, function (flyceium) {
      _this.flyceium = flyceium;
      _this.ploylinejl = ploylinejl;
      if (typeof method == "function") {
        method(flyceium);
      }
    });
  };
  return this;
};
/**
 * 设置获取浏览对象
 */
dynamicObject.prototype.setFlycesium = function (drawHelper, callback) {
  var _this = this;
  var coordinates = [];
  // var position = null;
  // var heading = null;
  // var pitch = null;
  // var roll = null;
  var maxHeight = 0;
  for (var i = 0; i < drawHelper.positions.length; i++) {
    var cartographic = _this._cesium.Cartographic.fromCartesian(
      drawHelper.positions[i]
    ); //世界坐标转地理坐标(弧度)
    var point = [
      (cartographic.longitude / Math.PI) * 180,
      (cartographic.latitude / Math.PI) * 180,
      cartographic.height
    ]; //地理坐标(弧度)转经纬度坐标
    //console.log(point);
    coordinates.push(point);
  }
  this._core.getPmfxPro(
    drawHelper.positions,
    25,
    0,
    _this._cesium,
    _this._viewer,
    data => {
      maxHeight = data.max;
      var time;
      time = (drawHelper.distance / 50).toFixed(1);
      var pathsData = {
        id: _this._core.getuid(),
        name: "新建路线",
        distance: drawHelper.distance,
        showPoint: false,
        showLine: true,
        showModel: true,
        isLoop: false,
        Totaltime: Math.round(time),
        speed: 50,
        height: (maxHeight + 200).toFixed(2),
        pitch: -20,
        range: 100,
        mode: 0,
        url: "/GroundVehicle.glb",
        geojson: {
          // orientation: {heading: heading, pitch: pitch, roll: roll},
          // position: position,
          geometry: { type: "LineString", coordinates: coordinates }
        }
      };
      callback && callback(pathsData);
    }
  );
};
/**
 * 开始浏览
 */
dynamicObject.prototype.Start = function (data, url, funs) {
  var _this = this;
  // var pathsData = data.geojson;
  if (!data.Totaltime) {
    data.Totaltime = 3000;
  }
  if (_this._entityFly) {
    _this.exit();
  }
  // _this._viewer.camera.setView({
  //     destination: pathsData.position,
  //     orientation: pathsData.orientation,
  // });
  fun = funs;
  setTimeout(function () {
    _this.executeFly3D(data, url);
  }, 200);
  return this;
};
var entityFly = null;
var entityModel = null;
//var start;
var fun = null;
var entityhd = {
  start: null,
  time: null,
  longitude: 0,
  latitude: 0,
  cameraHeight: 100,
  //timedifference: null,
  speed: 50,
  multiplier: 1,

  position: 0
};
// var stop;
var velocityVector, velocityVectorProperty, velocityOrientationProperty;
var AngleProperty, property;
var wheelAngle = 0;

/**
 * 播放路径动画
 * @param {Object} data 数据
 * @param {Object} data.geojson 路线数据
 * @param {Number} [data.lineHeight] 路线高度,默认贴地
 * @param {Boolean} [data.isLoop=false] 是否循环播放
 * @param {String} [url] 模型路径
 */
dynamicObject.prototype.executeFly3D = function (data, url) {
  var _this = this;
  var pathsData = data.geojson;
  velocityVector = new _this._cesium.Cartesian3();
  AngleProperty = new _this._cesium.SampledProperty(Number);
  property = new _this._cesium.SampledPositionProperty();

  if (pathsData && pathsData.geometry) {
    var positionA = pathsData.geometry.coordinates;
    var position = [];
    var position1 = [];
    if (positionA.length > 0) {
      for (var i = 0; i < positionA.length; i++) {
        var x = positionA[i][0];
        var y = positionA[i][1];
        var z = positionA[i][2];
        data.lineHeight !== void 0 && (z = data.lineHeight);
        position1.push(x, y, z);
        position.push({ x: x, y: y, z: z });
      }
    } else {
      return;
    }

    _this._viewer.clock.clockRange = data.isLoop
      ? _this._cesium.ClockRange.LOOP_STOP
      : _this._cesium.ClockRange.CLAMPED; //Loop at the end
    _this._viewer.clock.multiplier = data.multiplier || 1;
    _this._viewer.clock.canAnimate = false;
    _this._viewer.clock.shouldAnimate = true; //设置时间轴动态效果
    entityhd.distance = data.distance;
    entityhd.cameraHeight = data.height;
    entityhd.lineHeight = data.lineHeight;
    entityhd.pitch = data.pitch;
    entityhd.range = data.range;
    entityhd.speed = data.speed || 50;
    entityhd.Totaltime = data.distance / entityhd.speed;

    entityhd.start = _this._cesium.JulianDate.fromDate(new Date());
    entityhd.stop = _this._cesium.JulianDate.addSeconds(
      entityhd.start,
      entityhd.Totaltime,
      new _this._cesium.JulianDate()
    );
    //Make sure viewer is at the desired time.
    _this._viewer.clock.startTime = entityhd.start.clone();
    _this._viewer.clock.stopTime = entityhd.stop.clone();
    _this._viewer.clock.currentTime = entityhd.start.clone();

    var _position = _this.computeCirclularFlight(position);
    entityhd.position = _position;
    entityhd.degrees = position;
    velocityOrientationProperty = new _this._cesium.VelocityOrientationProperty(
      _position
    );

    var mode = {};
    if (url !== "") {
      mode = {
        show: _this._cesium.defaultValue(data.showModel, true),
        scale: _this._cesium.defaultValue(data.modelScale, 1),
        uri: url
      };
    } else {
      // const modelUrl = Cesium.buildModuleUrl(
      //   "Assets/GltfModels/CesiumAir/Cesium_Air.glb"
      // );
      mode = {
        show: _this._cesium.defaultValue(data.showModel, true),
        scale: _this._cesium.defaultValue(data.modelScale, 1)
        // uri: modelUrl
      };
    }
    if (data.modelData) {
      mode = _this._core.extend(mode, data.modelData);
    }
    changeFlyView = function () {};
    entityFly = _this._viewer.entities.add({
      //Set the entity availability to the same interval as the simulation time.
      availability: new _this._cesium.TimeIntervalCollection([
        new _this._cesium.TimeInterval({
          start: entityhd.start,
          stop: entityhd.stop
        })
      ]),
      position: _position,
      //Show the path as a pink line sampled in 1 second increments.
      polyline: {
        clampToGround: entityhd.lineHeight === void 0,
        positions: Cesium.Cartesian3.fromDegreesArrayHeights(position1),
        show: _this._cesium.defaultValue(data.showLine, true),
        material: new _this._cesium.PolylineGlowMaterialProperty({
          glowPower: 0.1,
          color: _this._cesium.Color.YELLOW
        }),
        width: 10
      },
      label: {
        text: new _this._cesium.CallbackProperty(updateSpeedLabel, false),
        font: "20px sans-serif",
        showBackground: false,
        distanceDisplayCondition: new _this._cesium.DistanceDisplayCondition(
          0.0,
          100.0
        ),
        eyeOffset: new _this._cesium.Cartesian3(0, 3.5, 0)
      }
    });
    // console.log(_position);

    entityModel = _this._viewer.entities.add({
      availability: new _this._cesium.TimeIntervalCollection([
        new _this._cesium.TimeInterval({
          start: entityhd.start,
          stop: entityhd.stop
        })
      ]),
      position: _position,
      orientation: velocityOrientationProperty,
      point: {
        show: _this._cesium.defaultValue(data.showPoint, false),
        color: _this._cesium.Color.RED,
        outlineColor: _this._cesium.Color.WHITE,
        outlineWidth: 2,
        pixelSize: 10
      },
      model: mode,
      billboard: data.image,
      viewFrom:
        data.viewFrom || new _this._cesium.Cartesian3(500.0, 500.0, 500.0)
    });
    entitymodels = entityFly;
    _this._viewer.trackedEntity = entityModel;
    _this._entityFly = entityFly;

    data.mode && _this.changeFlyMode(data.mode);

    // setTimeout(function () {
    //     // _this._viewer.camera.zoomOut(500.0);//缩小地图,避免底图没有数据
    //     _this._viewer.camera.zoomOut(300.0);//缩小地图,避免底图没有数据
    // }, 100);
  } else {
    return;
  }

  function updateSpeedLabel(time) {
    //alert(time);
    //entityhd.time = time;
    // var de = entitymodels;
    // var camera = _this._viewer.camera;
    if (
      _this._viewer.clock.clockRange !== 2 &&
      Cesium.JulianDate.equals(
        _this._viewer.clock.currentTime,
        _this._viewer.clock.stopTime
      )
    ) {
      entityFly.label.text = "";
      _this.exit();
      if (fun != null && typeof fun === "function") {
        fun("end");
      }
      changeFlyView = function () {};
      return;
    }
    try {
      var position = entitymodels.position.getValue(
        _this._viewer.clock.currentTime
      );
      var cartographic = _this._cesium.Cartographic.fromCartesian(position);
      //经度
      entityhd.longitude = _this._cesium.Math.toDegrees(cartographic.longitude);
      //纬度
      entityhd.latitude = _this._cesium.Math.toDegrees(cartographic.latitude);
      if (entityhd.lineHeight === void 0) {
        let height1 = _this._viewer.scene.sampleHeight(cartographic, [
          entityModel,
          entitymodels
        ]);
        let height2 = _this._viewer.scene.globe.getHeight(cartographic);
        entityModel.position = _this._cesium.Cartesian3.fromRadians(
          cartographic.longitude,
          cartographic.latitude,
          height2 > height1 ? height2 : height1
        );
      }
    } catch (er) {
      console.log(er);
    }
    try {
      velocityVectorProperty.getValue(time, velocityVector);
      changeFlyView(time);
      var metersPerSecond = _this._cesium.Cartesian3.magnitude(velocityVector);
      var kmPerHour = Math.round(metersPerSecond * 3.6);
      kmPerHour += " km/h";
      //已漫游时间
      entityhd.time = _this._cesium.JulianDate.secondsDifference(
        time,
        entityhd.start
      );
      //已漫游比例
      entityhd.ratio = entityhd.time / entityhd.Totaltime;
      //已漫游距离
      entityhd.distanceTraveled = entityhd.ratio * entityhd.distance;
      //运行速度
      entityhd.speed = kmPerHour;
      //漫游高程
      entityhd.height = cartographic.height;
      //地面高程
      entityhd.globeHeight = _this._viewer.scene.globe.getHeight(cartographic);

      if (fun != null && typeof fun === "function") {
        fun(entityhd);
      }
    } catch (er) {
      console.log(er);
    }
    return "";
  }
};

// var hpr = new Cesium.HeadingPitchRoll();
var flyPosition;
// var Quaternion = new Cesium.Quaternion();
// var _heading = 0;
var entitymodels = null;

//改变视角
var changeFlyView;

function getHeading(time) {
  wheelAngle = AngleProperty.getValue(time);
  entityhd.heading = wheelAngle;
}

function getFlyPosition(position) {
  var cartographic = Cesium.Cartographic.fromCartesian(position);
  var lon = Cesium.Math.toDegrees(cartographic.longitude);
  var lat = Cesium.Math.toDegrees(cartographic.latitude);
  return Cesium.Cartesian3.fromDegrees(lon, lat, entityhd.cameraHeight || 100);
}

/**
 * 显示点
 */
dynamicObject.prototype.showPoint = function (isShow) {
  entityModel && entityModel.point && (entityModel.point.show = isShow);
};

/**
 * 显示线
 */
dynamicObject.prototype.showLine = function (isShow) {
  entityFly && entityFly.polyline && (entityFly.polyline.show = isShow);
};

/**
 * 显示模型
 */
dynamicObject.prototype.showModel = function (isShow) {
  entityModel && entityModel.model && (entityModel.model.show = isShow);
};

//飞行高度
dynamicObject.prototype.setFlyHeight = function (height) {
  entityhd.cameraHeight = height;
};

//飞行距离
dynamicObject.prototype.setFlyDistance = function (distance) {
  entityhd.range = distance;
};

//飞行俯仰角
dynamicObject.prototype.setFlyPitch = function (pitch) {
  entityhd.pitch = pitch;
};

//飞行模式
dynamicObject.prototype.changeFlyMode = function (index) {
  var _this = this;
  switch (index) {
    case 0:
      changeFlyView = function () {};
      _this.BindingModel(true);
      break;
    case 1:
      this.BindingModel(false);
      changeFlyView = function (time) {
        getHeading(time);
        _this.exeuteVisualAngle(
          _this._cesium.Math.toRadians(entityhd.heading),
          _this._cesium.Math.toRadians(entityhd.pitch),
          entityhd.range
        );
      };
      break;
    case 2:
      this.BindingModel(false);
      changeFlyView = function (time) {
        getHeading(time);
        flyPosition = _this._entityFly.position.getValue(
          _this._viewer.clock.currentTime
        );
        if (!flyPosition) return;
        flyPosition = getFlyPosition(flyPosition);
        _this._viewer.camera.setView({
          destination: flyPosition,
          orientation: {
            heading: _this._cesium.Math.toRadians(entityhd.heading),
            pitch: _this._cesium.Math.toRadians(-90),
            roll: 0.0
          }
        });
      };
      break;
  }
};

/**
 * 加速
 */
dynamicObject.prototype.faster = function () {
  this._viewer.animation.viewModel.faster();
};

/**
 * 减速
 */
dynamicObject.prototype.slower = function () {
  // 倍率减
  this._viewer.animation.viewModel.slower();
};

/**
 * 设置倍数
 */
dynamicObject.prototype.setMultiplier = function (multiplier) {
  this._viewer.clock.multiplier = parseFloat(multiplier);
};

/**
 * 是否暂停
 */
dynamicObject.prototype.isPause = function (isPause) {
  var clockViewModel = this._viewer.clockViewModel;
  clockViewModel.shouldAnimate = !isPause;
};

/**
 * 结束飞行
 */
dynamicObject.prototype.exit = function () {
  this.isPause(true);
  this._viewer.clock.multiplier = 1;
  this.executeSignout();
  this.BindingModel(false);
  this._viewer.entities.remove(entityFly);
  this._viewer.entities.remove(entityModel);
  entityFly = null;
  entityModel = null;
  this._entityFly = null;
};

//lable回调函数
dynamicObject.prototype.updateSpeedLabel = function () {
  //if (fun && typeof fun === 'function') {
  //    fun(_this._entityFly);
  //}
  //this.entityhd = {
  //    start: null,
  //    time: null
  //};
  //this.entityhd.time = time;
  //if (this.fun != null && typeof fun === 'function') {
  //    fun(_this._entityFly);
  //}
  //return "";
};

//添加时间位置样本
dynamicObject.prototype.computeCirclularFlight = function (position) {
  var _this = this;
  velocityVectorProperty = new _this._cesium.VelocityVectorProperty(
    property,
    false
  );
  var _time, time, _position, _position1;
  for (var i = 0; i < position.length; i++) {
    if (i === 0) {
      //起点
      time = _this._cesium.JulianDate.addSeconds(
        entityhd.start,
        0,
        new _this._cesium.JulianDate()
      );
      _position = _this._cesium.Cartesian3.fromDegrees(
        position[0].x,
        position[0].y,
        entityhd.lineHeight
      );

      property.addSample(time, _position);
      //计算两点方位角
      wheelAngle = _this._core.TwoPointAzimuth(
        position[0].x,
        position[0].y,
        position[1].x,
        position[1].y
      );
      AngleProperty.addSample(time, wheelAngle);
    }
    try {
      if (i > 0 && i != position.length - 1) {
        _position = new _this._cesium.Cartesian3(
          property._property._values[i * 3 - 3],
          property._property._values[i * 3 - 2],
          property._property._values[i * 3 - 1]
        );
        _position1 = _this._cesium.Cartesian3.fromDegrees(
          position[i].x,
          position[i].y,
          _this._cesium.defaultValue(entityhd.lineHeight, position[i].z)
        );

        var positions = [
          _this._cesium.Cartographic.fromCartesian(_position),
          _this._cesium.Cartographic.fromCartesian(_position1)
        ];
        var a = new _this._cesium.EllipsoidGeodesic(positions[0], positions[1]);
        var long = a.surfaceDistance;
        time = _this._cesium.JulianDate.addSeconds(
          property._property._times[i - 1],
          0.5,
          new _this._cesium.JulianDate()
        );
        _time = _this._cesium.JulianDate.addSeconds(
          property._property._times[i - 1],
          long / entityhd.speed,
          new _this._cesium.JulianDate()
        );

        property.addSample(_time, _position1);
        //计算两点方位角
        wheelAngle = _this._core.TwoPointAzimuth(
          position[i - 1].x,
          position[i - 1].y,
          position[i].x,
          position[i].y
        );
        AngleProperty.addSample(time, wheelAngle);
        AngleProperty.addSample(_time, wheelAngle);
      }
    } catch (e) {
      console.log(e);
    }
  }
  return property;
};
/**
 * 暂停浏览
 */
dynamicObject.prototype.executePauseFly3DPaths = function () {
  var clockViewModel = this._viewer.clockViewModel;
  if (clockViewModel.shouldAnimate) {
    clockViewModel.shouldAnimate = false;
  } else if (this._viewer.clockViewModel.canAnimate) {
    clockViewModel.shouldAnimate = true;
  }
};

/**
 * 添加对象
 */
dynamicObject.prototype.changeModel = function (url) {
  entityModel.model.uri = url;
};
//浏览方式飞向视点
/**
 * 获取视野点
 */
dynamicObject.prototype.PointView = function () {
  var originalCameraLocation = {
    position: Viewer.camera.position.clone(),
    orientation: {
      heading: Viewer.camera.heading,
      pitch: Viewer.camera.pitch,
      roll: Viewer.camera.roll
    }
  };
  return originalCameraLocation;
};
/**
 * 开始浏览。
 * @param {Paths}
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.PlayPaths = function () {
  var that = this;
  setInterval(function () {
    viewer.camera.setView({
      // Cesium的坐标是以地心为原点,一向指向南美洲,一向指向亚洲,一向指向北极州
      // fromDegrees()方法,将经纬度和高程转换为世界坐标
      destination: that._cesium.Cartesian3.fromDegrees(117.48, 30.67, 15000.0),
      orientation: {
        // 指向
        heading: that._cesium.Math.toRadians(90, 0),
        // 视角
        pitch: that._cesium.Math.toRadians(-90),
        roll: 0.0
      }
    });
  }, 2000);
};
/**
 * 绑定模型
 * @param {binding} 是否绑定。
 */
dynamicObject.prototype.BindingModel = function (binding) {
  if (binding) {
    this._viewer.trackedEntity = entityModel;
  } else {
    this._viewer.trackedEntity = undefined;
    this._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
  }
};
/**
 * 改变视角
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.exeuteVisualAngle = function (
  viewHeading,
  viewPitch,
  viewRange
) {
  var hpRange = { heading: null, pitch: null, range: null };
  hpRange.heading = viewHeading || this._cesium.Math.toRadians(90);
  hpRange.pitch = viewPitch || this._cesium.Math.toRadians(0);
  hpRange.range = viewRange || 1000;
  var center = this._entityFly.position.getValue(
    this._viewer.clock.currentTime
  );
  if (!center) return;
  center = getFlyPosition(center);
  var hpRanges = new this._cesium.HeadingPitchRange(
    hpRange.heading,
    hpRange.pitch,
    hpRange.range
  );
  //if (center) this._viewer.camera.lookAt(center, hpRange);
  this._viewer.camera.lookAt(center, hpRanges);
};
/**
 * 是否显示路线
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.Pathshow = function (route) {
  this._entityFly.polyline.show = route;
};
/**
 * 是否显示点
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.Pointshow = function (route) {
  entityModel._point.show = route;
};
/**
 * 是否显示模型
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.Modelshow = function (route) {
  entityModel._model.show = route;
};
/**
 * 向前飞行漫游路径
 * @returns {Object} 返回一个json对象。
 */
dynamicObject.prototype.executePlayForwardFly3DPaths = function () {
  var clockViewModel = this._viewer.clockViewModel;
  var multiplier = clockViewModel.multiplier;
  if (multiplier < 0) {
    clockViewModel.multiplier = -multiplier;
  }
  clockViewModel.shouldAnimate = true;
};
/**
 * 向后飞行漫游路径
 */
dynamicObject.prototype.executePlayReverseFly3DPaths = function () {
  var clockViewModel = this._viewer.clockViewModel;
  var multiplier = clockViewModel.multiplier;
  if (multiplier > 0) {
    clockViewModel.multiplier = -multiplier;
  }
  clockViewModel.shouldAnimate = true;
};
/**
 * 退出飞行漫游路径
 */
dynamicObject.prototype.executeSignout = function () {
  var start = this._cesium.JulianDate.fromDate(new Date());
  this._viewer.clock.startTime = start.clone();
  var stop = this._cesium.JulianDate.addSeconds(
    start,
    86400,
    new this._cesium.JulianDate()
  );
  this._viewer.clock.stopTime = stop.clone();
  //this.cesiumViewer.entities.remove(this.entityFly);
};

/**
 * 结束当前操作
 */
dynamicObject.prototype.forceEndHanlder = function () {
  if (this.handler) {
    this.handler.destroy();
    this.handler = undefined;
  }
};
export default dynamicObject;

📄 Core.js
/**
 * 工具类
 * @constructor xp
 * @alias Core
 * @constructor
 *
 */
function Core () { }

//根据经纬度获取高度
Core.prototype.getHeightsFromLonLat = function (
  positions,
  Cesium,
  Viewer,
  callback
) {
  var camera = Viewer.camera;
  var heights = [];
  if (
    Viewer.scene &&
    Viewer.scene.terrainProvider &&
    Viewer.scene.terrainProvider._layers
  ) {
    //根据经纬度计算出地形高度。
    var promise = Cesium.sampleTerrainMostDetailed(
      Viewer.terrainProvider,
      positions
    );
    // var cameraHeight = camera.positionCartographic.height;
    Cesium.when(promise, function (updatedPositions) {
      updatedPositions.forEach(function (item) {
        heights.push(item.height);
      });
      if (typeof callback === "function") {
        callback(heights);
      }
    });
  } else {
    positions.forEach(function (p) {
      heights.push(Viewer.scene.globe.getHeight(p));
    });
    if (typeof callback === "function") {
      callback(heights);
    }
  }
};

/**
 * 创建鼠标Tooltip提示框。
 *
 * @param {*} [styleOrText] 提示框样式或文本内容
 * @param {String} [styleOrText.origin='center'] 对齐方式(center/top/bottom)
 * @param {String} [styleOrText.color='black'] 提示框颜色(black/white/yellow)
 * @param {String} [styleOrText.id=undefined] 提示框唯一id(可选)
 * @param {Object} position 显示位置
 * @param {Boolean} show 是否显示(如果为true,styleOrText必须为显示的文本内容)
 * @returns {Tooltip} Tooltip提示框。
 *
 * @example
 * sgworld.Core.CreateTooltip('这里是提示信息', {x:500, y:500}, true);
 * 或
 * tooltip = sgworld.Core.CreateTooltip();
 * tooltip.showAt({x:500, y:500}, '这里是提示信息');
 *
 * tooltip.show(false); //隐藏提示框
 * tooltip.show(true); //显示提示框
 */
Core.prototype.CreateTooltip = function (styleOrText = {}, position, show) {
  var style, _x, _y, _color, id;
  if (typeof styleOrText === "object") {
    style = styleOrText;
  }
  if (style && style.origin) {
    style.origin === "center" && ((_x = 15), (_y = -12));
    style.origin === "top" && ((_x = 15), (_y = -44));
    style.origin === "bottom" && ((_x = 15), (_y = 20));
  } else {
    (_x = 15), (_y = 20);
  }
  if (style && style.color) {
    style.color === "white" &&
      (_color = "background: rgba(255, 255, 255, 0.8);color: black;");
    style.color === "black" &&
      (_color = "background: rgba(0, 0, 0, 0.5);color: white;");
    style.color === "yellow" &&
      (_color =
        "color: black;background-color: #ffcc33;border: 1px solid white;");
  } else {
    _color = "background: rgba(0, 0, 0, 0.5);color: white;";
  }
  if (style && style.id) {
    id = "toolTip" + style.id;
  } else {
    id = "toolTip";
  }

  var tooltip = document.getElementById(id);

  if (!tooltip) {
    // 创建一个新的 div 元素
    var elementbottom = document.createElement("div");
    // 将元素添加到 .cesium-viewer 容器中
    var cesiumViewer = document.querySelector(".cesium-viewer");
    if (cesiumViewer) {
      cesiumViewer.appendChild(elementbottom);
    }

    // 构建 HTML 字符串
    var html =
      '<div id="' +
      id +
      '" style="display: none;pointer-events: none;position: absolute;z-index: 1000;opacity: 0.8;border-radius: 4px;padding: 4px 8px;white-space: nowrap;font-family:黑体;color:white;font-weight: bolder;font-size: 14px;' +
      _color +
      '"></div>';

    // 创建一个临时容器来解析 HTML 字符串
    var tempDiv = document.createElement("div");
    tempDiv.innerHTML = html;

    // 将解析后的第一个子元素(即 tooltip)添加到 .cesium-viewer 容器中
    if (cesiumViewer) {
      cesiumViewer.appendChild(tempDiv.firstElementChild);
    }

    // 获取刚刚创建的 tooltip 元素
    tooltip = document.getElementById(id);
  }
  if (show) {
    tooltip.innerHTML = styleOrText;
    tooltip.style.left = position.x + _x + "px";
    tooltip.style.top = position.y + _y + "px";
    tooltip.style.display = "block";
  } else {
    tooltip.style.display = "none";
  }
  return {
    tooltip: tooltip,
    style: style,
    showAt: function (position, text) {
      this.tooltip.innerHTML = text;
      if (this.style && this.style.origin) {
        this.style.origin === "center" &&
          ((_x = 15), (_y = -this.tooltip.offsetHeight / 2));
        this.style.origin === "top" &&
          ((_x = 15), (_y = -this.tooltip.offsetHeight - 20));
        this.style.origin === "bottom" && ((_x = 15), (_y = 20));
      } else {
        (_x = 15), (_y = -this.tooltip.offsetHeight / 2);
      }
      this.tooltip.style.left = position.x + _x + "px";
      this.tooltip.style.top = position.y + _y + "px";
      this.tooltip.style.display = "block";
    },
    show: function (show) {
      if (show) {
        this.tooltip.style.display = "block";
      } else {
        this.tooltip.style.display = "none";
      }
    }
  };
};

/**
 * 修改鼠标样式。
 *
 * @param {DOM} container html DOM节点
 * @param {Number} [cursorstyle=0] 鼠标类型(0为默认,1为使用cur图标)
 * @param {String} url cur图标路径。
 *
 * @example
 * sgworld.Core.mouse(Viewer.container, 1, 'draw.cur');
 */
Core.prototype.mouse = function (container, cursorstyle, url) {
  if (cursorstyle == 1) {
    container.style.cursor = "url(" + url + "),auto";
  } else {
    container.style.cursor = "default";
  }
};

// 判断是否为手机浏览器
Core.prototype.getBrowser = function () {
  var ua = navigator.userAgent.toLowerCase();
  var btypeInfo = (ua.match(/firefox|chrome|safari|opera/g) || "other")[0];
  if ((ua.match(/msie|trident/g) || [])[0]) {
    btypeInfo = "msie";
  }
  var pc = "";
  var prefix = "";
  var plat = "";
  //如果没有触摸事件 判定为PC
  var isTocuh =
    "ontouchstart" in window ||
    ua.indexOf("touch") !== -1 ||
    ua.indexOf("mobile") !== -1;
  if (isTocuh) {
    if (ua.indexOf("ipad") !== -1) {
      pc = "pad";
    } else if (ua.indexOf("mobile") !== -1) {
      pc = "mobile";
    } else if (ua.indexOf("android") !== -1) {
      pc = "androidPad";
    } else {
      pc = "pc";
    }
  } else {
    pc = "pc";
  }
  switch (btypeInfo) {
    case "chrome":
    case "safari":
    case "mobile":
      prefix = "webkit";
      break;
    case "msie":
      prefix = "ms";
      break;
    case "firefox":
      prefix = "Moz";
      break;
    case "opera":
      prefix = "O";
      break;
    default:
      prefix = "webkit";
      break;
  }
  plat =
    ua.indexOf("android") > 0 ? "android" : navigator.platform.toLowerCase();
  return {
    version: (ua.match(/[\s\S]+(?:rv|it|ra|ie)[\/: ]([\d.]+)/) || [])[1], //版本
    plat: plat, //系统
    type: btypeInfo, //浏览器
    pc: pc,
    prefix: prefix, //前缀
    isMobile: pc == "pc" ? false : true //是否是移动端
  };
};

//空间距离测量用米
Core.prototype.getSpaceDistancem = function (positions, Cesium) {
  var distance = 0;
  for (var i = 0; i < positions.length - 1; i++) {
    var point1cartographic = Cesium.Cartographic.fromCartesian(positions[i]);
    var point2cartographic = Cesium.Cartographic.fromCartesian(
      positions[i + 1]
    );
    /**根据经纬度计算出距离**/
    var geodesic = new Cesium.EllipsoidGeodesic();
    geodesic.setEndPoints(point1cartographic, point2cartographic);
    var s = geodesic.surfaceDistance;
    //console.log(Math.sqrt(Math.pow(distance, 2) + Math.pow(endheight, 2)));
    //返回两点之间的距离
    s = Math.sqrt(
      Math.pow(s, 2) +
      Math.pow(point2cartographic.height - point1cartographic.height, 2)
    );
    distance = distance + s;
  }
  return distance.toFixed(2);
};

//根据位置(Cartographic)获取3DTiles和Primitives高度
Core.prototype.get3DTileOrPrimitivesHeights = function (position, Viewer) {
  return Viewer.scene.sampleHeight(position);
};

//剖面分析
Core.prototype.getPmfxPro = function (
  _positions,
  pointSum1,
  cyjj,
  Cesium,
  viewer,
  methond
) {
  let _this = this;
  //起止点相关信息
  let pmx = {
    gcs: [],
    min: 99999,
    max: 0,
    juli: 0.0,
    cys: 0
  };
  let positions = [];
  let pointNum = [];
  //获取总间隔点数和距离
  for (let i = 0; i < _positions.length - 1; i++) {
    let julifr = _this.getSpaceDistancem(
      [_positions[i], _positions[i + 1]],
      Cesium
    );
    julifr = parseFloat(julifr);
    pmx.juli += julifr;
    if (cyjj == 0) {
    } else {
      pointSum1 = parseInt(julifr / cyjj);
    }
    pointNum.push(pointSum1);
    pmx.cys += pointSum1;
  }
  let startAnalyse = () => {
    pointNum.forEach((num, i) => {
      let startPoint = _positions[i];
      let endPoint = _positions[i + 1];
      //起点
      let scartographic = Cesium.Cartographic.fromCartesian(startPoint);
      let slongitude = Cesium.Math.toDegrees(scartographic.longitude);
      let slatitude = Cesium.Math.toDegrees(scartographic.latitude);

      //终点
      let ecartographic = Cesium.Cartographic.fromCartesian(endPoint);
      let elongitude = Cesium.Math.toDegrees(ecartographic.longitude);
      let elatitude = Cesium.Math.toDegrees(ecartographic.latitude);

      let pointSum = num; //取样点个数
      let addXTT =
        Cesium.Math.lerp(slongitude, elongitude, 1.0 / pointSum) - slongitude;
      let addYTT =
        Cesium.Math.lerp(slatitude, elatitude, 1.0 / pointSum) - slatitude;

      let Cartesian;

      i === 0 && positions.push(scartographic);
      for (let j = 0; j < pointSum; j++) {
        let longitude = slongitude + (j + 1) * addXTT;
        let latitude = slatitude + (j + 1) * addYTT;
        Cartesian = Cesium.Cartesian3.fromDegrees(longitude, latitude);
        positions.push(Cesium.Cartographic.fromCartesian(Cartesian));
      }
    });

    positions.push(
      Cesium.Cartographic.fromCartesian(_positions[_positions.length - 1])
    );

    let heightArr = [];
    pmx.allPoint = positions;
    this.getHeightsFromLonLat(positions, Cesium, viewer, function (data) {
      if (data) {
        heightArr = data;
        let changeDepthTest = viewer.scene.globe.depthTestAgainstTerrain;
        viewer.scene.globe.depthTestAgainstTerrain = true;
        for (let i = 0; i < heightArr.length; i++) {
          let modelHeight = _this.get3DTileOrPrimitivesHeights(
            positions[i],
            viewer
          );
          if (modelHeight !== undefined) {
            heightArr[i] = modelHeight;
          }
          let he = heightArr[i].toFixed(2);
          if (parseFloat(he) < parseFloat(pmx.min)) {
            pmx.min = parseFloat(he);
          }
          if (parseFloat(he) > parseFloat(pmx.max)) {
            pmx.max = parseFloat(he);
          }
          pmx.gcs.push(he);
        }
        viewer.scene.globe.depthTestAgainstTerrain = changeDepthTest;
        methond && typeof methond == "function" && methond(pmx);
      }
    });
  };
  if (pmx.cys > 1000) {
    layuiLayer &&
      layuiLayer.msg("当前采样点数过多,是否继续分析?", {
        time: 0,
        btn: ["继续", "取消"],
        btnAlign: "c",
        yes: index => {
          layuiLayer.close(index);
          setTimeout(() => {
            startAnalyse();
          }, 10);
        },
        btn2: () => {
          methond && typeof methond == "function" && methond(pmx);
        }
      });
  } else {
    setTimeout(() => {
      startAnalyse();
    }, 10);
  }
};

/**
 * 获取uuid
 */
Core.prototype.uuid = function (len, radix) {
  var chars =
    "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".split("");
  var uuid = [],
    i;
  var uuid = [],
    i;
  radix = radix || chars.length;

  if (len) {
    // Compact form
    for (i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)];
  } else {
    // rfc4122, version 4 form
    var r;

    // rfc4122 requires these characters
    uuid[8] = uuid[13] = uuid[18] = uuid[23] = "-";
    uuid[14] = "4";

    // Fill in random data.  At i==19 set the high bits of clock sequence as
    // per rfc4122, sec. 4.1.5
    for (i = 0; i < 36; i++) {
      if (!uuid[i]) {
        r = 0 | (Math.random() * 16);
        uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r];
      }
    }
  }

  return uuid.join("");
};

Core.prototype.getuid = function () {
  // var idStr = Date.now().toString(36);
  // idStr += Math.random().toString(36).substr(3);
  // return idStr;

  // return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
  //     var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
  //     return v.toString(16);
  // });

  return this.uuid(8, 16);
};

/**
 * 对象参数合并
 * @param {Object} o 对象
 * @param {Object} n 被合并的对象
 * @param {Boolean} [override=false] 是否覆盖原属性值
 * @param {Boolean} [mergeTheSame=false] 是否只合并相同属性
 */
Core.prototype.extend = function (
  o,
  n,
  override = false,
  mergeTheSame = false
) {
  for (var key in n) {
    if (mergeTheSame) {
      if (o.hasOwnProperty(key)) {
        o[key] = n[key];
      }
    } else {
      if (!o.hasOwnProperty(key) || override) {
        o[key] = n[key];
      }
    }
  }
  return o;
};

/**
 * 两点方位角
 * @param {number} lon1 起点经度
 * @param {number} lat1 起点纬度
 * @param {number} lon2 终点经度
 * @param {number} lat2 终点纬度
 */
Core.prototype.TwoPointAzimuth = function (lon1, lat1, lon2, lat2) {
  var result = 0.0;
  var getRad = function (d) {
    return (d * Math.PI) / 180.0;
  };

  var ilat1 = Math.round(0.5 + lat1 * 360000.0);
  var ilat2 = Math.round(0.5 + lat2 * 360000.0);
  var ilon1 = Math.round(0.5 + lon1 * 360000.0);
  var ilon2 = Math.round(0.5 + lon2 * 360000.0);

  lat1 = getRad(lat1);
  lon1 = getRad(lon1);
  lat2 = getRad(lat2);
  lon2 = getRad(lon2);

  if (ilat1 === ilat2 && ilon1 === ilon2) {
    return result;
  } else if (ilon1 === ilon2) {
    if (ilat1 > ilat2) result = 180.0;
  } else {
    var c = Math.acos(
      Math.sin(lat2) * Math.sin(lat1) +
      Math.cos(lat2) * Math.cos(lat1) * Math.cos(lon2 - lon1)
    );
    var A = Math.asin((Math.cos(lat2) * Math.sin(lon2 - lon1)) / Math.sin(c));
    result = (A * 180) / Math.PI;
    if (ilat2 > ilat1 && ilon2 > ilon1) {
    } else if (ilat2 < ilat1 && ilon2 < ilon1) {
      result = 180.0 - result;
    } else if (ilat2 < ilat1 && ilon2 > ilon1) {
      result = 180.0 - result;
    } else if (ilat2 > ilat1 && ilon2 < ilon1) {
      result += 360.0;
    }
  }
  return result;
};
export default Core;
📄 getPosition.js
/**
 *
 * 获取位置。
 * xp
 * @alias getPosition
 * @constructor
 *
 */

function getPosition (viewer, cesium) {
  this._viewer = viewer;
  this._cesium = cesium;
}

getPosition.prototype.getPosition = function () {
  return this._viewer.camera.position;
};

getPosition.prototype.getDegrees = function () {
  var cartographic = this._viewer.camera.positionCartographic;
  var Degrees = {
    lon: this._cesium.Math.toDegrees(cartographic.longitude),
    lat: this._cesium.Math.toDegrees(cartographic.latitude),
    height: cartographic.height
  };

  return Degrees;
};
/**
 * 获取鼠标当前世界坐标
 * @param {object} [movement] 鼠标屏幕位置
 * @param {Array/Object} [objectsToExclude] 排除的实体对象
 * @param {number} [type] 类型(0为模型优先,1为地形优先),默认模型优先
 * @param {boolean} [isAdsorption] true|false 是否吸附,默认否
 * @param {number} [distance] 吸附半径,默认30
 **/
getPosition.prototype.getMousePosition = function (
  movement,
  objectsToExclude,
  type,
  isAdsorption,
  distance
) {
  var mousePosition = movement.endPosition || movement.position || movement;
  type === undefined && (type = 0);
  isAdsorption = this._cesium.defaultValue(isAdsorption, false);
  this.defaultDepthTest === undefined &&
    (this.defaultDepthTest =
      !!this._viewer.scene.globe.depthTestAgainstTerrain);

  var ray, cartesian, _cartesian, feature;
  //this.isObjectsToExcludeShow(objectsToExclude, false);
  var width = isAdsorption ? (distance ? distance : 30) : 1;
  if (type !== 0) {
    //地形优先
    //开启深度检测
    this._viewer.scene.globe.depthTestAgainstTerrain = true;
    ray = this._viewer.camera.getPickRay(mousePosition);
    ray && (cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene));
    //isAdsorption && (mousePosition = this.getAdsorptionPosition(mousePosition, objectsToExclude));

    if (!objectsToExclude || objectsToExclude.length === 0) {
      feature = this._viewer.scene.pick(mousePosition);
      if (feature && feature.id && !this.id3DGraphic(feature.id)) {
        feature = undefined;
      }
    } else {
      feature = this._viewer.scene.drillPick(
        mousePosition,
        objectsToExclude.length,
        width,
        width
      );
      feature = this.getNotExcludedObj(feature, objectsToExclude);
    }

    if (feature && !isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      _cartesian = this._viewer.scene.pickPosition(mousePosition);
      if (_cartesian) {
        cartesian = _cartesian;
      }
    } else {
      cartesian &&
        isAdsorption &&
        (_cartesian = this._getAdsorptionPosition(
          mousePosition,
          feature,
          distance
        )); //吸附
      if (_cartesian) {
        cartesian = _cartesian;
      }
    }
  } else {
    //模型优先
    if (!objectsToExclude || objectsToExclude.length === 0) {
      feature = this._viewer.scene.pick(mousePosition);
      if (feature && feature.id && !this.id3DGraphic(feature.id)) {
        feature = undefined;
      }
    } else {
      feature = this._viewer.scene.drillPick(
        mousePosition,
        (objectsToExclude &&
          objectsToExclude.length &&
          objectsToExclude.length + 1) ||
        1,
        width,
        width
      );
      feature = this.getNotExcludedObj(feature, objectsToExclude);
    }
    if (feature && !isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      cartesian = this._viewer.scene.pickPosition(mousePosition);
    } else if (feature && isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      cartesian = this._viewer.scene.pickPosition(mousePosition);
      _cartesian = this._getAdsorptionPosition(
        mousePosition,
        feature,
        distance
      ); //吸附
      if (_cartesian) {
        cartesian = _cartesian;
      }
    } else {
      //开启深度检测
      this._viewer.scene.globe.depthTestAgainstTerrain = true;
      ray = this._viewer.camera.getPickRay(mousePosition);
      ray &&
        (cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene));
    }
  }
  this._viewer.scene.globe.depthTestAgainstTerrain = !!this.defaultDepthTest;
  this.defaultDepthTest = undefined;

  // console.log(cartesian);
  if (!cartesian) {
    console.log("未拾取到坐标!");
    return;
  }

  return cartesian;
};
/**
 * 获取鼠标当前经纬度
 * @param {object} [movement] 鼠标屏幕位置
 * @param {Array/Object} [objectsToExclude] 排除的实体对象
 * @param {number} [type] 类型(0为模型优先,1为地形优先),默认模型优先
 * @param {boolean} [isAdsorption] true|false 是否吸附,默认否
 * @param {number} [distance] 吸附半径,默认30
 */
getPosition.prototype.getMouseDegrees = function (
  movement,
  objectsToExclude,
  type,
  isAdsorption,
  distance
) {
  var mousePosition = movement.endPosition || movement.position || movement;
  type === undefined && (type = 0);
  isAdsorption = this._cesium.defaultValue(isAdsorption, false);
  this.defaultDepthTest === undefined &&
    (this.defaultDepthTest =
      !!this._viewer.scene.globe.depthTestAgainstTerrain);

  var ray, cartesian, _cartesian, feature;
  //this.isObjectsToExcludeShow(objectsToExclude, false);
  var width = isAdsorption ? (distance ? distance : 30) : 1;
  if (type !== 0) {
    //地形优先
    //开启深度检测
    this._viewer.scene.globe.depthTestAgainstTerrain = true;
    ray = this._viewer.camera.getPickRay(mousePosition);
    ray && (cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene));
    //isAdsorption && (mousePosition = this.getAdsorptionPosition(mousePosition, objectsToExclude));

    if (!objectsToExclude || objectsToExclude.length === 0) {
      feature = this._viewer.scene.pick(mousePosition);
      if (feature && feature.id && !this.id3DGraphic(feature.id)) {
        feature = undefined;
      }
    } else {
      feature = this._viewer.scene.drillPick(
        mousePosition,
        (objectsToExclude &&
          objectsToExclude.length &&
          objectsToExclude.length + 1) ||
        1,
        width,
        width
      );
      feature = this.getNotExcludedObj(feature, objectsToExclude);
    }
    if (feature && !isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      _cartesian = this._viewer.scene.pickPosition(mousePosition);
      if (_cartesian) {
        cartesian = _cartesian;
      }
    } else {
      cartesian &&
        isAdsorption &&
        (_cartesian = this._getAdsorptionPosition(
          mousePosition,
          feature,
          distance
        )); //吸附
      if (_cartesian) {
        cartesian = _cartesian;
      }
    }
  } else {
    //模型优先
    if (!objectsToExclude || objectsToExclude.length === 0) {
      feature = this._viewer.scene.pick(mousePosition);
      if (feature && feature.id && !this.id3DGraphic(feature.id)) {
        feature = undefined;
      }
    } else {
      feature = this._viewer.scene.drillPick(
        mousePosition,
        (objectsToExclude &&
          objectsToExclude.length &&
          objectsToExclude.length + 1) ||
        1,
        width,
        width
      );
      feature = this.getNotExcludedObj(feature, objectsToExclude);
    }
    if (feature && !isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      cartesian = this._viewer.scene.pickPosition(mousePosition);
    } else if (feature && isAdsorption) {
      this._viewer.scene.pick(mousePosition);
      cartesian = this._viewer.scene.pickPosition(mousePosition);
      _cartesian = this._getAdsorptionPosition(
        mousePosition,
        feature,
        distance
      ); //吸附
      if (_cartesian) {
        cartesian = _cartesian;
      }
    } else {
      //开启深度检测
      this._viewer.scene.globe.depthTestAgainstTerrain = true;
      ray = this._viewer.camera.getPickRay(mousePosition);
      ray &&
        (cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene));
    }
  }
  this._viewer.scene.globe.depthTestAgainstTerrain = !!this.defaultDepthTest;
  this.defaultDepthTest = undefined;

  if (!cartesian) {
    console.log("未拾取到坐标!");
    return;
  }

  var cartographic = this._cesium.Cartographic.fromCartesian(cartesian);
  return {
    lon: this._cesium.Math.toDegrees(cartographic.longitude),
    lat: this._cesium.Math.toDegrees(cartographic.latitude),
    height: cartographic.height
  };
};

//判断是否是三维图形
getPosition.prototype.id3DGraphic = function (graphic) {
  let threeD = true;
  if (graphic.polyline || graphic.point || graphic.label || graphic.billboard) {
    threeD = false;
  } else if (graphic.polygon && graphic.polygon.extrudedHeight == undefined) {
    threeD = false;
  } else if (
    graphic.rectangle &&
    graphic.rectangle.extrudedHeight == undefined
  ) {
    threeD = false;
  } else if (graphic.ellipse && graphic.ellipse.extrudedHeight == undefined) {
    threeD = false;
  } else if (graphic.corridor && graphic.corridor.extrudedHeight == undefined) {
    threeD = false;
  }
  return threeD;
};

//吸附坐标-屏幕坐标
getPosition.prototype.getAdsorptionPosition = function (
  mousePosition,
  objectsToExclude
) {
  var dis = 5; //吸附半径
  var ave = 3; //采样数
  var object = this._viewer.scene.drillPick(
    mousePosition,
    (objectsToExclude &&
      objectsToExclude.length &&
      objectsToExclude.length + 1) ||
    3,
    dis + 3,
    dis + 3
  ); //3为默认拾取范围
  var need = false;
  for (var i = 0; i < object.length; i++) {
    if (object[i] && !this.isExcluded(object[i], objectsToExclude)) {
      need = true;
      break;
    }
  }

  if (need) {
    object = this._viewer.scene.pick(mousePosition, 1, 1);
    if (object && !this.isExcluded(object, objectsToExclude)) {
      return mousePosition;
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x + i, y: mousePosition.y },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x + i, y: mousePosition.y };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x + i, y: mousePosition.y + i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x + i, y: mousePosition.y + i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x, y: mousePosition.y + i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x, y: mousePosition.y + i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x - i, y: mousePosition.y + i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x - i, y: mousePosition.y + i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x - i, y: mousePosition.y },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x - i, y: mousePosition.y };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x - i, y: mousePosition.y - i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x - i, y: mousePosition.y - i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x, y: mousePosition.y - i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x, y: mousePosition.y - i };
      }
    }
    for (var i = dis / ave; i <= dis; i += dis / ave) {
      object = this._viewer.scene.pick(
        { x: mousePosition.x + i, y: mousePosition.y - i },
        1,
        1
      );
      if (object && !this.isExcluded(object, objectsToExclude)) {
        return { x: mousePosition.x + i, y: mousePosition.y - i };
      }
    }
  }
  return mousePosition;
};

//吸附坐标
getPosition.prototype._getAdsorptionPosition = function (
  mousePosition,
  feature,
  distance
) {
  var dis = distance ? distance : 30; //吸附半径

  if (feature) {
    feature = this.getFeature(feature);
    var _PosArr = [];
    var CanvasCoordinates;
    for (var i = 0; i < feature.position.length; i++) {
      CanvasCoordinates = this._viewer.scene.cartesianToCanvasCoordinates(
        feature.position[i]
      );
      if (CanvasCoordinates) {
        CanvasCoordinates.index = i;
        _PosArr.push(CanvasCoordinates);
      }
    }
    var compare = function (obj1, obj2) {
      var val1 = obj1.x;
      var val2 = obj2.x;
      if (val1 < val2) {
        return -1;
      } else if (val1 > val2) {
        return 1;
      } else {
        return 0;
      }
    };
    _PosArr = _PosArr.sort(compare);
    if (_PosArr && _PosArr.length > 1) {
      //二分法算最接近下标
      var n = Math.log(_PosArr.length) / Math.log(2);
      var m = 0;
      var maxn = _PosArr.length;
      var minn = 0;
      var zd = -1;

      for (var i = 0; i < n; i++) {
        m = Math.floor((maxn + minn) / 2);
        if (mousePosition.x - _PosArr[m].x > dis) {
          minn = m;
        } else if (mousePosition.x - _PosArr[m].x < -dis) {
          maxn = m;
        } else if (Math.abs(mousePosition.x - _PosArr[m].x) < dis) {
          zd = m;
          break;
        }
      }
      if (zd !== -1) {
        for (var i = m; i < maxn; i++) {
          if (Math.abs(mousePosition.x - _PosArr[i].x) > dis) {
            maxn = i;
            break;
          }
        }
        for (var i = m; i > minn; i--) {
          if (Math.abs(mousePosition.x - _PosArr[i].x) > dis) {
            minn = i + 1;
            break;
          }
        }
        for (var i = minn; i < maxn; i++) {
          if (Math.abs(mousePosition.y - _PosArr[i].y) < dis) {
            return feature.position[_PosArr[i].index];
          }
        }
      }
    }
    if (_PosArr && _PosArr.length === 1) {
      if (
        Math.abs(mousePosition.x - _PosArr[0].x) < dis &&
        Math.abs(mousePosition.y - _PosArr[0].y) < dis
      ) {
        return feature.position[0];
      }
    }
  }
};

getPosition.prototype.getFeature = function (obj) {
  var position;
  var data = {
    position: [],
    object: []
  };
  if (obj && obj.id) {
    if (obj.id instanceof this._cesium.Entity) {
      var entity = obj.id;
      if (entity.billboard) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "billboard",
          feature: entity.billboard
        });
      }
      if (entity.box) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "box",
          feature: entity.box
        });
      }
      if (entity.corridor) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "corridor",
          feature: entity.corridor
        });
      }
      if (entity.cylinder) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "cylinder",
          feature: entity.cylinder
        });
      }
      if (entity.ellipse) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "ellipse",
          feature: entity.ellipse
        });
      }
      if (entity.ellipsoid) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "ellipsoid",
          feature: entity.ellipsoid
        });
      }
      if (entity.label) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "label",
          feature: entity.label
        });
      }
      if (entity.model) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "model",
          feature: entity.model
        });
      }
      if (entity.path) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "path",
          feature: entity.path
        });
      }
      if (entity.plane) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "plane",
          feature: entity.plane
        });
      }
      if (entity.point) {
        position = entity.position.getValue(this._viewer.clock.currentTime);
        data.position.push(position);
        data.object.push({
          type: "point",
          feature: entity.point
        });
      }
      if (entity.polygon) {
        position = entity.polygon.hierarchy.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position.positions);
        data.object.push({
          type: "polygon",
          feature: entity.polygon
        });
      }
      if (entity.polyline) {
        position = entity.polyline.positions.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position);
        data.object.push({
          type: "polyline",
          feature: entity.polyline
        });
      }
      if (entity.polylineVolume) {
        position = entity.polylineVolume.positions.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position);
        data.object.push({
          type: "polylineVolume",
          feature: entity.polylineVolume
        });
      }
      if (entity.rectangle) {
        position = entity.rectangle.coordinates.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position);
        data.object.push({
          type: "rectangle",
          feature: entity.rectangle
        });
      }
      if (entity.wall) {
        position = entity.wall.positions.getValue(
          this._viewer.clock.currentTime
        );
        data.position = data.position.concat(position);
        data.object.push({
          type: "wall",
          feature: entity.wall
        });
      }
    }
  }
  if (obj && obj.primitive) {
    if (obj.primitive instanceof this._cesium.Model) {
      position = obj.primitive.positionObj;
      data.position.push(position);
      data.object.push({
        type: "model",
        feature: obj.primitive
      });
    }
  }
  return data;
};

//是否包含对象
getPosition.prototype.isExcluded = function (object, objectsToExclude) {
  if (
    !this._cesium.defined(object) ||
    !this._cesium.defined(objectsToExclude) ||
    objectsToExclude.length === 0
  ) {
    return false;
  }
  return (
    objectsToExclude.indexOf(object) > -1 ||
    objectsToExclude.indexOf(object.primitive) > -1 ||
    objectsToExclude.indexOf(object.id) > -1
  );
};

//获取不包含的对象
getPosition.prototype.getNotExcludedObj = function (
  objectOrArr,
  objectsToExclude
) {
  if (objectOrArr.length === 0) {
    return false;
  } else if (
    !this._cesium.defined(objectsToExclude) ||
    objectsToExclude.length === 0
  ) {
    return objectOrArr;
  }
  for (var i = 0; i < objectOrArr.length; i++) {
    if (objectOrArr[i] && !this.isExcluded(objectOrArr[i], objectsToExclude)) {
      return objectOrArr[i];
    }
  }
  return false;
};

//控制对象显隐
getPosition.prototype.isObjectsToExcludeShow = function (
  objectsToExclude,
  isShow
) {
  if (
    !this._cesium.defined(objectsToExclude) ||
    objectsToExclude.length === 0
  ) {
    return;
  }
  if (objectsToExclude instanceof Array) {
    objectsToExclude.forEach(function (item) {
      item.show = isShow;
    });
  } else {
    objectsToExclude.show = isShow;
  }
};
export default getPosition;
调用方法
  
   const _dynamicObject = new dynamicObject(window.viewer, Cesium);
  
  _dynamicObject.executeFlycesium(data => {
    data.showPoint = true;
    data.showLine = true;
    data.mode = 2; // 飞行模式
    _dynamicObject.Start(data, data.url);
  });

🔥Jmeter(四十六) - 从入门到精通高级篇 - Jmeter之网页图片爬虫-下篇(详解教程)

作者 北京_宏哥
2025年5月19日 11:11

1.简介

上一篇介绍了爬取文章,这一篇宏哥就简单的介绍一下,如何爬取图片然后保存到本地电脑中。网上很多漂亮的壁纸或者是美女、妹子,想自己收藏一些,挨个保存太费时间,那你可以利用爬虫然后批量下载。

2.爬虫原理

其实这个和上一篇都是一样的道理,宏哥在啰嗦一遍。Jmeter 的爬虫原理其实很简单,就是对网页提交一个请求,然后把返回的所有 href 提取出来,利用 ForEach 控制器去实现 url 遍历。这样解释是不是很清晰?下面宏哥就来简单介绍一下如何操作。

3.牛刀小试

宏哥这里以一个图片网站为例给小伙伴或者童鞋们演示用过程和步骤。

该网站为动态。网址:unsplash.com/

3.1开始实战

1、因为是动态网站,所以获取网页内容后,很多图片找不到,使用浏览器F12功能,分析网站的请求,得出:

网页动态加载请求:unsplash.com/napi/photos…

图片下载请求:unsplash.com/photos/xxx/…

2、我们开始使用jmeter爬取网站上的图片。打开Jemter,新建线程组,如下图所示:

3、在线程组里添加一个http请求(sampler->http请求),协议为https,服务器名称输入网址,路径输入路径,在路径中将1参数化,修改1为${id},如下图所示:

4、添加参数化文件,配置元件->CSV数据文件设置,设置文件路径,编码、变量、间隔符等,如下图所示:

5、我们现在需要把图片id提取出来,利用强大的正则表达式提取。先分析下网页请求返回的数据,因此宏哥添加一个察看结果树,运行Jmeter,如下图所示:

6、返回结果,宏哥粘贴并且格式化,如下图所示:

[{
    "id": "nV8K0uguyiw",
    "created_at": "2020-07-01T18:52:47-04:00",
    "updated_at": "2021-05-23T16:16:03-04:00",
    "promoted_at": null,
    "width": 10920,
    "height": 5880,
    "color": "#c0c0c0",
    "blur_hash": "LCFrS10evKpc.S0KM_-;^+E1E1%L",
    "description": null,
    "alt_description": "man in green zip up jacket beside woman in black shirt",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1593643946890-b5b85ade6451?ixid=MnwxMjA3fDF8MXxhbGx8MXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1593643946890-b5b85ade6451?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDF8MXxhbGx8MXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1593643946890-b5b85ade6451?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDF8MXxhbGx8MXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1593643946890-b5b85ade6451?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDF8MXxhbGx8MXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1593643946890-b5b85ade6451?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDF8MXxhbGx8MXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/nV8K0uguyiw",
        "html": "https://unsplash.com/photos/nV8K0uguyiw",
        "download": "https://unsplash.com/photos/nV8K0uguyiw/download",
        "download_location": "https://api.unsplash.com/photos/nV8K0uguyiw/download?ixid=MnwxMjA3fDF8MXxhbGx8MXx8fHx8fDJ8fDE2MjE4NDA5ODg"
    },
    "categories": [],
    "likes": 499,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": {
        "impression_urls": ["https://secure.insightexpressai.com/adServer/adServerESI.aspx?script=false\u0026bannerID=8281547\u0026rnd=[timestamp]\u0026gdpr=\u0026gdpr_consent=\u0026redir=https://secure.insightexpressai.com/adserver/1pixel.gif", "https://secure.insightexpressai.com/adServer/adServerESI.aspx?script=false\u0026bannerID=8468538\u0026rnd=[timestamp]\u0026DID=mobADID\u0026redir=https://secure.insightexpressai.com/adserver/1pixel.gif"],
        "tagline": "Designed to be the Best",
        "tagline_url": "http://www.dell.com/xps",
        "sponsor": {
            "id": "2DC3GyeqWjI",
            "updated_at": "2021-05-24T03:12:03-04:00",
            "username": "xps",
            "name": "XPS",
            "first_name": "XPS",
            "last_name": null,
            "twitter_username": "Dell",
            "portfolio_url": "http://www.dell.com/xps",
            "bio": "Designed to be the best, with cutting edge technologies, exceptional build quality, unique materials and powerful features.",
            "location": null,
            "links": {
                "self": "https://api.unsplash.com/users/xps",
                "html": "https://unsplash.com/@xps",
                "photos": "https://api.unsplash.com/users/xps/photos",
                "likes": "https://api.unsplash.com/users/xps/likes",
                "portfolio": "https://api.unsplash.com/users/xps/portfolio",
                "following": "https://api.unsplash.com/users/xps/following",
                "followers": "https://api.unsplash.com/users/xps/followers"
            },
            "profile_image": {
                "small": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
                "medium": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
                "large": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
            },
            "instagram_username": "dell",
            "total_collections": 0,
            "total_likes": 0,
            "total_photos": 22,
            "accepted_tos": true,
            "for_hire": false
        }
    },
    "user": {
        "id": "2DC3GyeqWjI",
        "updated_at": "2021-05-24T03:12:03-04:00",
        "username": "xps",
        "name": "XPS",
        "first_name": "XPS",
        "last_name": null,
        "twitter_username": "Dell",
        "portfolio_url": "http://www.dell.com/xps",
        "bio": "Designed to be the best, with cutting edge technologies, exceptional build quality, unique materials and powerful features.",
        "location": null,
        "links": {
            "self": "https://api.unsplash.com/users/xps",
            "html": "https://unsplash.com/@xps",
            "photos": "https://api.unsplash.com/users/xps/photos",
            "likes": "https://api.unsplash.com/users/xps/likes",
            "portfolio": "https://api.unsplash.com/users/xps/portfolio",
            "following": "https://api.unsplash.com/users/xps/following",
            "followers": "https://api.unsplash.com/users/xps/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1600096866391-b09a1a53451aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "dell",
        "total_collections": 0,
        "total_likes": 0,
        "total_photos": 22,
        "accepted_tos": true,
        "for_hire": false
    }
}, {
    "id": "rfgR_SbTC40",
    "created_at": "2021-05-19T10:35:26-04:00",
    "updated_at": "2021-05-24T02:48:01-04:00",
    "promoted_at": "2021-05-24T02:48:01-04:00",
    "width": 3488,
    "height": 5232,
    "color": "#595959",
    "blur_hash": "LFBM*_nOt6tRt8%Ns:Rj0KtlM{Ri",
    "description": null,
    "alt_description": "black ceramic mug on table",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621434913400-21cc05e8c461?ixid=MnwxMjA3fDB8MXxhbGx8Mnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621434913400-21cc05e8c461?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8Mnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621434913400-21cc05e8c461?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8Mnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621434913400-21cc05e8c461?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8Mnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621434913400-21cc05e8c461?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8Mnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/rfgR_SbTC40",
        "html": "https://unsplash.com/photos/rfgR_SbTC40",
        "download": "https://unsplash.com/photos/rfgR_SbTC40/download",
        "download_location": "https://api.unsplash.com/photos/rfgR_SbTC40/download?ixid=MnwxMjA3fDB8MXxhbGx8Mnx8fHx8fDJ8fDE2MjE4NDA5ODg"
    },
    "categories": [],
    "likes": 9,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "JF1D9mtlosI",
        "updated_at": "2021-05-24T02:57:03-04:00",
        "username": "farzadmohamadi",
        "name": "Farzad Mohamadi",
        "first_name": "Farzad",
        "last_name": "Mohamadi",
        "twitter_username": null,
        "portfolio_url": null,
        "bio": null,
        "location": null,
        "links": {
            "self": "https://api.unsplash.com/users/farzadmohamadi",
            "html": "https://unsplash.com/@farzadmohamadi",
            "photos": "https://api.unsplash.com/users/farzadmohamadi/photos",
            "likes": "https://api.unsplash.com/users/farzadmohamadi/likes",
            "portfolio": "https://api.unsplash.com/users/farzadmohamadi/portfolio",
            "following": "https://api.unsplash.com/users/farzadmohamadi/following",
            "followers": "https://api.unsplash.com/users/farzadmohamadi/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1621171864819-d610eadcd8bdimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1621171864819-d610eadcd8bdimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1621171864819-d610eadcd8bdimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": null,
        "total_collections": 0,
        "total_likes": 12,
        "total_photos": 9,
        "accepted_tos": true,
        "for_hire": false
    }
}, {
    "id": "cfMW036jByI",
    "created_at": "2020-05-06T15:53:04-04:00",
    "updated_at": "2021-05-24T01:27:02-04:00",
    "promoted_at": "2021-05-24T01:27:02-04:00",
    "width": 3456,
    "height": 5184,
    "color": "#8ca6d9",
    "blur_hash": "LN9a{zMvROjEo~tSV?RiH;x^V?e.",
    "description": null,
    "alt_description": "low angle photography of high rise building",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1588794651085-41fe77330f3e?ixid=MnwxMjA3fDB8MXxhbGx8M3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1588794651085-41fe77330f3e?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8M3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1588794651085-41fe77330f3e?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8M3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1588794651085-41fe77330f3e?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8M3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1588794651085-41fe77330f3e?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8M3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/cfMW036jByI",
        "html": "https://unsplash.com/photos/cfMW036jByI",
        "download": "https://unsplash.com/photos/cfMW036jByI/download",
        "download_location": "https://api.unsplash.com/photos/cfMW036jByI/download?ixid=MnwxMjA3fDB8MXxhbGx8M3x8fHx8fDJ8fDE2MjE4NDA5ODg"
    },
    "categories": [],
    "likes": 40,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "ftB4m4H6ILo",
        "updated_at": "2021-05-24T03:02:09-04:00",
        "username": "hugoclb",
        "name": "Hugo Coulbouée",
        "first_name": "Hugo",
        "last_name": "Coulbouée",
        "twitter_username": "hug0clb",
        "portfolio_url": null,
        "bio": "🏔️ Annecy, 74\r\n🙋🏼‍♂️ 19 Ans, Autodidacte  📷 Canon EOS 1200d x 50mm",
        "location": "Annecy",
        "links": {
            "self": "https://api.unsplash.com/users/hugoclb",
            "html": "https://unsplash.com/@hugoclb",
            "photos": "https://api.unsplash.com/users/hugoclb/photos",
            "likes": "https://api.unsplash.com/users/hugoclb/likes",
            "portfolio": "https://api.unsplash.com/users/hugoclb/portfolio",
            "following": "https://api.unsplash.com/users/hugoclb/following",
            "followers": "https://api.unsplash.com/users/hugoclb/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1588794575070-f8694808367aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1588794575070-f8694808367aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1588794575070-f8694808367aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "hugoclb",
        "total_collections": 0,
        "total_likes": 40,
        "total_photos": 39,
        "accepted_tos": true,
        "for_hire": true
    }
}, {
    "id": "ycnvnL4beLo",
    "created_at": "2021-05-23T14:11:36-04:00",
    "updated_at": "2021-05-24T00:30:02-04:00",
    "promoted_at": "2021-05-24T00:30:02-04:00",
    "width": 2160,
    "height": 3240,
    "color": "#d9d9c0",
    "blur_hash": "LcLpXC%LMxrr_MaexaR*%gRkS#bb",
    "description": null,
    "alt_description": "strawberry juice in clear drinking glass",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621792907789-666f0e69ea03?ixid=MnwxMjA3fDB8MXxhbGx8NHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621792907789-666f0e69ea03?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8NHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621792907789-666f0e69ea03?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8NHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621792907789-666f0e69ea03?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8NHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621792907789-666f0e69ea03?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8NHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/ycnvnL4beLo",
        "html": "https://unsplash.com/photos/ycnvnL4beLo",
        "download": "https://unsplash.com/photos/ycnvnL4beLo/download",
        "download_location": "https://api.unsplash.com/photos/ycnvnL4beLo/download?ixid=MnwxMjA3fDB8MXxhbGx8NHx8fHx8fDJ8fDE2MjE4NDA5ODg"
    },
    "categories": [],
    "likes": 22,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "JUx0LN7P8_o",
        "updated_at": "2021-05-24T03:11:59-04:00",
        "username": "bartoshevicz",
        "name": "Adam Bartoszewicz",
        "first_name": "Adam",
        "last_name": "Bartoszewicz",
        "twitter_username": null,
        "portfolio_url": "https://www.instagram.com/bartoshevicz/",
        "bio": "Hi, I'm a foodie passionate and food photography is becoming my whole life! I hope you're gonna enjoy my delicious work. Join me on my Instagram profile for more and more!",
        "location": "Białystok, Poland",
        "links": {
            "self": "https://api.unsplash.com/users/bartoshevicz",
            "html": "https://unsplash.com/@bartoshevicz",
            "photos": "https://api.unsplash.com/users/bartoshevicz/photos",
            "likes": "https://api.unsplash.com/users/bartoshevicz/likes",
            "portfolio": "https://api.unsplash.com/users/bartoshevicz/portfolio",
            "following": "https://api.unsplash.com/users/bartoshevicz/following",
            "followers": "https://api.unsplash.com/users/bartoshevicz/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1621607600495-1fd693951525image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1621607600495-1fd693951525image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1621607600495-1fd693951525image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "bartoshevicz",
        "total_collections": 0,
        "total_likes": 41,
        "total_photos": 26,
        "accepted_tos": true,
        "for_hire": false
    }
}, {
    "id": "5fx4r4qcdXA",
    "created_at": "2021-05-23T13:40:14-04:00",
    "updated_at": "2021-05-24T02:56:53-04:00",
    "promoted_at": "2021-05-24T00:27:02-04:00",
    "width": 5149,
    "height": 3433,
    "color": "#8c8c73",
    "blur_hash": "L9A0XYIC9dxt}@f9ohJA9aX8%1oe",
    "description": null,
    "alt_description": "white and blue smoke illustration",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621791554700-35b52803f596?ixid=MnwxMjA3fDB8MXxhbGx8NXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621791554700-35b52803f596?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8NXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621791554700-35b52803f596?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8NXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621791554700-35b52803f596?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8NXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621791554700-35b52803f596?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8NXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/5fx4r4qcdXA",
        "html": "https://unsplash.com/photos/5fx4r4qcdXA",
        "download": "https://unsplash.com/photos/5fx4r4qcdXA/download",
        "download_location": "https://api.unsplash.com/photos/5fx4r4qcdXA/download?ixid=MnwxMjA3fDB8MXxhbGx8NXx8fHx8fDJ8fDE2MjE4NDA5ODg"
    },
    "categories": [],
    "likes": 33,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "ogQykx6hk_c",
        "updated_at": "2021-05-24T03:22:05-04:00",
        "username": "pawel_czerwinski",
        "name": "Pawel Czerwinski",
        "first_name": "Pawel",
        "last_name": "Czerwinski",
        "twitter_username": null,
        "portfolio_url": "http://paypal.me/pmcze",
        "bio": "If you'd like to support me, you can consider a donation ❤ In case you have any questions about how you can use the photos, please read https://help.unsplash.com/en/collections/1463188-unsplash-license 👍 ||| www.instagram.com/pmcze",
        "location": "Poland",
        "links": {
            "self": "https://api.unsplash.com/users/pawel_czerwinski",
            "html": "https://unsplash.com/@pawel_czerwinski",
            "photos": "https://api.unsplash.com/users/pawel_czerwinski/photos",
            "likes": "https://api.unsplash.com/users/pawel_czerwinski/likes",
            "portfolio": "https://api.unsplash.com/users/pawel_czerwinski/portfolio",
            "following": "https://api.unsplash.com/users/pawel_czerwinski/following",
            "followers": "https://api.unsplash.com/users/pawel_czerwinski/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1592328433409-d9ce8a5333eaimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1592328433409-d9ce8a5333eaimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1592328433409-d9ce8a5333eaimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "pmcze",
        "total_collections": 21,
        "total_likes": 29930,
        "total_photos": 1118,
        "accepted_tos": true,
        "for_hire": false
    }
}, {
    "id": "DTPY6b0RMRk",
    "created_at": "2021-05-21T00:09:13-04:00",
    "updated_at": "2021-05-24T00:50:37-04:00",
    "promoted_at": null,
    "width": 8688,
    "height": 5792,
    "color": "#f3f3f3",
    "blur_hash": "LeH2cgIUs:-:~qofRjt7xctQWAWC",
    "description": null,
    "alt_description": "woman using Surface laptop",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621570072965-b25917de6ec9?ixid=MnwxMjA3fDF8MXxhbGx8Nnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621570072965-b25917de6ec9?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDF8MXxhbGx8Nnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621570072965-b25917de6ec9?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDF8MXxhbGx8Nnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621570072965-b25917de6ec9?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDF8MXxhbGx8Nnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621570072965-b25917de6ec9?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDF8MXxhbGx8Nnx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/DTPY6b0RMRk",
        "html": "https://unsplash.com/photos/DTPY6b0RMRk",
        "download": "https://unsplash.com/photos/DTPY6b0RMRk/download",
        "download_location": "https://api.unsplash.com/photos/DTPY6b0RMRk/download?ixid=MnwxMjA3fDF8MXxhbGx8Nnx8fHx8fDJ8fDE2MjE4NDA5ODg"
    },
    "categories": [],
    "likes": 5,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": {
        "impression_urls": ["https://secure.insightexpressai.com/adServer/adServerESI.aspx?script=false\u0026bannerID=8742296\u0026rnd=[timestamp]\u0026redir=https://secure.insightexpressai.com/adserver/1pixel.gif"],
        "tagline": "Original by design",
        "tagline_url": null,
        "sponsor": {
            "id": "N-JSeSTCz68",
            "updated_at": "2021-05-24T02:56:58-04:00",
            "username": "surface",
            "name": "Surface",
            "first_name": "Surface",
            "last_name": null,
            "twitter_username": "surface",
            "portfolio_url": "http://surface.com",
            "bio": "Follow us @Surface. #OriginalByDesign",
            "location": null,
            "links": {
                "self": "https://api.unsplash.com/users/surface",
                "html": "https://unsplash.com/@surface",
                "photos": "https://api.unsplash.com/users/surface/photos",
                "likes": "https://api.unsplash.com/users/surface/likes",
                "portfolio": "https://api.unsplash.com/users/surface/portfolio",
                "following": "https://api.unsplash.com/users/surface/following",
                "followers": "https://api.unsplash.com/users/surface/followers"
            },
            "profile_image": {
                "small": "https://images.unsplash.com/profile-1587651800415-20eed2ec0209image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
                "medium": "https://images.unsplash.com/profile-1587651800415-20eed2ec0209image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
                "large": "https://images.unsplash.com/profile-1587651800415-20eed2ec0209image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
            },
            "instagram_username": "surface",
            "total_collections": 0,
            "total_likes": 0,
            "total_photos": 141,
            "accepted_tos": true,
            "for_hire": false
        }
    },
    "user": {
        "id": "N-JSeSTCz68",
        "updated_at": "2021-05-24T02:56:58-04:00",
        "username": "surface",
        "name": "Surface",
        "first_name": "Surface",
        "last_name": null,
        "twitter_username": "surface",
        "portfolio_url": "http://surface.com",
        "bio": "Follow us @Surface. #OriginalByDesign",
        "location": null,
        "links": {
            "self": "https://api.unsplash.com/users/surface",
            "html": "https://unsplash.com/@surface",
            "photos": "https://api.unsplash.com/users/surface/photos",
            "likes": "https://api.unsplash.com/users/surface/likes",
            "portfolio": "https://api.unsplash.com/users/surface/portfolio",
            "following": "https://api.unsplash.com/users/surface/following",
            "followers": "https://api.unsplash.com/users/surface/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1587651800415-20eed2ec0209image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1587651800415-20eed2ec0209image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1587651800415-20eed2ec0209image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "surface",
        "total_collections": 0,
        "total_likes": 0,
        "total_photos": 141,
        "accepted_tos": true,
        "for_hire": false
    }
}, {
    "id": "TxgPq_TRXtQ",
    "created_at": "2021-05-22T16:33:09-04:00",
    "updated_at": "2021-05-23T23:57:01-04:00",
    "promoted_at": "2021-05-23T23:57:01-04:00",
    "width": 1889,
    "height": 2700,
    "color": "#26260c",
    "blur_hash": "LEC7TPl9tkNe-oi^tkoy?]IBIBxt",
    "description": null,
    "alt_description": "black and brown car steering wheel",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621715070889-7bcdef6fdcf9?ixid=MnwxMjA3fDB8MXxhbGx8N3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621715070889-7bcdef6fdcf9?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8N3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621715070889-7bcdef6fdcf9?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8N3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621715070889-7bcdef6fdcf9?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8N3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621715070889-7bcdef6fdcf9?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8N3x8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/TxgPq_TRXtQ",
        "html": "https://unsplash.com/photos/TxgPq_TRXtQ",
        "download": "https://unsplash.com/photos/TxgPq_TRXtQ/download",
        "download_location": "https://api.unsplash.com/photos/TxgPq_TRXtQ/download?ixid=MnwxMjA3fDB8MXxhbGx8N3x8fHx8fDJ8fDE2MjE4NDA5ODg"
    },
    "categories": [],
    "likes": 23,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "bCBUCaMNruM",
        "updated_at": "2021-05-24T03:12:07-04:00",
        "username": "kapsan",
        "name": "Christian Casapu",
        "first_name": "Christian",
        "last_name": "Casapu",
        "twitter_username": null,
        "portfolio_url": null,
        "bio": null,
        "location": "Chisinau, R.Moldova",
        "links": {
            "self": "https://api.unsplash.com/users/kapsan",
            "html": "https://unsplash.com/@kapsan",
            "photos": "https://api.unsplash.com/users/kapsan/photos",
            "likes": "https://api.unsplash.com/users/kapsan/likes",
            "portfolio": "https://api.unsplash.com/users/kapsan/portfolio",
            "following": "https://api.unsplash.com/users/kapsan/following",
            "followers": "https://api.unsplash.com/users/kapsan/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1579130062098-16c790ae3ccdimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1579130062098-16c790ae3ccdimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1579130062098-16c790ae3ccdimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "kapsann",
        "total_collections": 0,
        "total_likes": 37,
        "total_photos": 10,
        "accepted_tos": true,
        "for_hire": true
    }
}, {
    "id": "m3mYumV2lag",
    "created_at": "2021-05-23T12:32:40-04:00",
    "updated_at": "2021-05-23T23:03:01-04:00",
    "promoted_at": "2021-05-23T23:03:01-04:00",
    "width": 3690,
    "height": 5535,
    "color": "#f3f3f3",
    "blur_hash": "L]JuP^t6a#j]_4s:j@a}oJs:jZWV",
    "description": "Three men standing in green field in front of Grand Teton mountain range",
    "alt_description": null,
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621787211915-83d2cbcf8946?ixid=MnwxMjA3fDB8MXxhbGx8OHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621787211915-83d2cbcf8946?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8OHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621787211915-83d2cbcf8946?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8OHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621787211915-83d2cbcf8946?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8OHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621787211915-83d2cbcf8946?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8OHx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/m3mYumV2lag",
        "html": "https://unsplash.com/photos/m3mYumV2lag",
        "download": "https://unsplash.com/photos/m3mYumV2lag/download",
        "download_location": "https://api.unsplash.com/photos/m3mYumV2lag/download?ixid=MnwxMjA3fDB8MXxhbGx8OHx8fHx8fDJ8fDE2MjE4NDA5ODg"
    },
    "categories": [],
    "likes": 20,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "MpbJ8qGW8bo",
        "updated_at": "2021-05-24T03:02:11-04:00",
        "username": "jonahbrown",
        "name": "Jonah Brown",
        "first_name": "Jonah",
        "last_name": "Brown",
        "twitter_username": "jonahbrown24",
        "portfolio_url": "https://www.youtube.com/channel/UCxkaphHTf-hlcrh6-Q6FPLg/videos",
        "bio": "Michigan based filmmaker that likes to snap photos between takes.",
        "location": "Kalamazoo, MI",
        "links": {
            "self": "https://api.unsplash.com/users/jonahbrown",
            "html": "https://unsplash.com/@jonahbrown",
            "photos": "https://api.unsplash.com/users/jonahbrown/photos",
            "likes": "https://api.unsplash.com/users/jonahbrown/likes",
            "portfolio": "https://api.unsplash.com/users/jonahbrown/portfolio",
            "following": "https://api.unsplash.com/users/jonahbrown/following",
            "followers": "https://api.unsplash.com/users/jonahbrown/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1605920877157-3a1ac2126072image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1605920877157-3a1ac2126072image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1605920877157-3a1ac2126072image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "joenuh",
        "total_collections": 0,
        "total_likes": 5,
        "total_photos": 124,
        "accepted_tos": true,
        "for_hire": true
    }
}, {
    "id": "Pu9uW0IHNEg",
    "created_at": "2021-05-22T16:47:43-04:00",
    "updated_at": "2021-05-24T03:21:57-04:00",
    "promoted_at": "2021-05-23T20:36:01-04:00",
    "width": 3225,
    "height": 4837,
    "color": "#f3f3f3",
    "blur_hash": "LvNA6m-;M{of9FR%R%WB0JofxbWB",
    "description": null,
    "alt_description": "woman in brown jacket standing beside white wall",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621716456281-f6d0ce70764f?ixid=MnwxMjA3fDB8MXxhbGx8OXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621716456281-f6d0ce70764f?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8OXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621716456281-f6d0ce70764f?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8OXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621716456281-f6d0ce70764f?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8OXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621716456281-f6d0ce70764f?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8OXx8fHx8fDJ8fDE2MjE4NDA5ODg\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/Pu9uW0IHNEg",
        "html": "https://unsplash.com/photos/Pu9uW0IHNEg",
        "download": "https://unsplash.com/photos/Pu9uW0IHNEg/download",
        "download_location": "https://api.unsplash.com/photos/Pu9uW0IHNEg/download?ixid=MnwxMjA3fDB8MXxhbGx8OXx8fHx8fDJ8fDE2MjE4NDA5ODg"
    },
    "categories": [],
    "likes": 17,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "ftB4m4H6ILo",
        "updated_at": "2021-05-24T03:02:09-04:00",
        "username": "hugoclb",
        "name": "Hugo Coulbouée",
        "first_name": "Hugo",
        "last_name": "Coulbouée",
        "twitter_username": "hug0clb",
        "portfolio_url": null,
        "bio": "🏔️ Annecy, 74\r\n🙋🏼‍♂️ 19 Ans, Autodidacte  📷 Canon EOS 1200d x 50mm",
        "location": "Annecy",
        "links": {
            "self": "https://api.unsplash.com/users/hugoclb",
            "html": "https://unsplash.com/@hugoclb",
            "photos": "https://api.unsplash.com/users/hugoclb/photos",
            "likes": "https://api.unsplash.com/users/hugoclb/likes",
            "portfolio": "https://api.unsplash.com/users/hugoclb/portfolio",
            "following": "https://api.unsplash.com/users/hugoclb/following",
            "followers": "https://api.unsplash.com/users/hugoclb/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1588794575070-f8694808367aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1588794575070-f8694808367aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1588794575070-f8694808367aimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "hugoclb",
        "total_collections": 0,
        "total_likes": 40,
        "total_photos": 39,
        "accepted_tos": true,
        "for_hire": true
    }
}, {
    "id": "G_iXK9l8l7Q",
    "created_at": "2021-05-21T14:20:27-04:00",
    "updated_at": "2021-05-23T20:21:02-04:00",
    "promoted_at": "2021-05-23T20:21:02-04:00",
    "width": 3998,
    "height": 4997,
    "color": "#262626",
    "blur_hash": "LUA^UYt7Rjxu_NofRjxu_3ofV@t7",
    "description": null,
    "alt_description": "green trees on brown wooden bridge during daytime",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621620844630-ecd55a95b7ff?ixid=MnwxMjA3fDB8MXxhbGx8MTB8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621620844630-ecd55a95b7ff?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTB8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621620844630-ecd55a95b7ff?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTB8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621620844630-ecd55a95b7ff?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTB8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621620844630-ecd55a95b7ff?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTB8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/G_iXK9l8l7Q",
        "html": "https://unsplash.com/photos/G_iXK9l8l7Q",
        "download": "https://unsplash.com/photos/G_iXK9l8l7Q/download",
        "download_location": "https://api.unsplash.com/photos/G_iXK9l8l7Q/download?ixid=MnwxMjA3fDB8MXxhbGx8MTB8fHx8fHwyfHwxNjIxODQwOTg4"
    },
    "categories": [],
    "likes": 43,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "piOet34tl5o",
        "updated_at": "2021-05-24T03:07:10-04:00",
        "username": "nathanmcdine",
        "name": "Nathan McDine",
        "first_name": "Nathan",
        "last_name": "McDine",
        "twitter_username": "nathanmcdine",
        "portfolio_url": "https://www.nathanmcdine.co.uk",
        "bio": "Photographer | Marketer 📸\r\n If you like my content, be sure to check out my Instagram (@nathanmcdine).",
        "location": "UK",
        "links": {
            "self": "https://api.unsplash.com/users/nathanmcdine",
            "html": "https://unsplash.com/@nathanmcdine",
            "photos": "https://api.unsplash.com/users/nathanmcdine/photos",
            "likes": "https://api.unsplash.com/users/nathanmcdine/likes",
            "portfolio": "https://api.unsplash.com/users/nathanmcdine/portfolio",
            "following": "https://api.unsplash.com/users/nathanmcdine/following",
            "followers": "https://api.unsplash.com/users/nathanmcdine/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1621620707567-e2050adbc1eaimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1621620707567-e2050adbc1eaimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1621620707567-e2050adbc1eaimage?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "nathanmcdine",
        "total_collections": 2,
        "total_likes": 245,
        "total_photos": 143,
        "accepted_tos": true,
        "for_hire": true
    }
}, {
    "id": "k94wMXMHFbE",
    "created_at": "2021-05-23T01:27:15-04:00",
    "updated_at": "2021-05-24T01:25:07-04:00",
    "promoted_at": "2021-05-23T20:06:02-04:00",
    "width": 5584,
    "height": 8368,
    "color": "#d9d9d9",
    "blur_hash": "LvHMDpM{j]ay_4RkbFj[X9W=ayay",
    "description": null,
    "alt_description": "green lake near mountain during daytime",
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621747609281-38853764c986?ixid=MnwxMjA3fDB8MXxhbGx8MTF8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621747609281-38853764c986?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTF8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621747609281-38853764c986?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTF8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621747609281-38853764c986?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTF8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621747609281-38853764c986?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTF8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/k94wMXMHFbE",
        "html": "https://unsplash.com/photos/k94wMXMHFbE",
        "download": "https://unsplash.com/photos/k94wMXMHFbE/download",
        "download_location": "https://api.unsplash.com/photos/k94wMXMHFbE/download?ixid=MnwxMjA3fDB8MXxhbGx8MTF8fHx8fHwyfHwxNjIxODQwOTg4"
    },
    "categories": [],
    "likes": 63,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "NyI9CHJbx1I",
        "updated_at": "2021-05-24T03:02:12-04:00",
        "username": "visuallert",
        "name": "Jonas Allert",
        "first_name": "Jonas",
        "last_name": "Allert",
        "twitter_username": null,
        "portfolio_url": "http://visenda.com",
        "bio": "hi, I'm looking forward to share awesomeness here!",
        "location": "ulm, germany",
        "links": {
            "self": "https://api.unsplash.com/users/visuallert",
            "html": "https://unsplash.com/@visuallert",
            "photos": "https://api.unsplash.com/users/visuallert/photos",
            "likes": "https://api.unsplash.com/users/visuallert/likes",
            "portfolio": "https://api.unsplash.com/users/visuallert/portfolio",
            "following": "https://api.unsplash.com/users/visuallert/following",
            "followers": "https://api.unsplash.com/users/visuallert/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-1601380918797-a7236651e5f0image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-1601380918797-a7236651e5f0image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-1601380918797-a7236651e5f0image?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": "visuallert",
        "total_collections": 3,
        "total_likes": 124,
        "total_photos": 120,
        "accepted_tos": true,
        "for_hire": true
    }
}, {
    "id": "rMXsuun3CuQ",
    "created_at": "2021-05-20T11:09:09-04:00",
    "updated_at": "2021-05-23T20:00:01-04:00",
    "promoted_at": "2021-05-23T20:00:01-04:00",
    "width": 6720,
    "height": 4480,
    "color": "#d9a6a6",
    "blur_hash": "LIKc*1wG0f9s^hVr-oxrScWBr=t3",
    "description": null,
    "alt_description": null,
    "urls": {
        "raw": "https://images.unsplash.com/photo-1621523133136-cea844f32bdf?ixid=MnwxMjA3fDB8MXxhbGx8MTJ8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1",
        "full": "https://images.unsplash.com/photo-1621523133136-cea844f32bdf?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTJ8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=85",
        "regular": "https://images.unsplash.com/photo-1621523133136-cea844f32bdf?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTJ8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=1080",
        "small": "https://images.unsplash.com/photo-1621523133136-cea844f32bdf?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTJ8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=400",
        "thumb": "https://images.unsplash.com/photo-1621523133136-cea844f32bdf?crop=entropy\u0026cs=tinysrgb\u0026fit=max\u0026fm=jpg\u0026ixid=MnwxMjA3fDB8MXxhbGx8MTJ8fHx8fHwyfHwxNjIxODQwOTg4\u0026ixlib=rb-1.2.1\u0026q=80\u0026w=200"
    },
    "links": {
        "self": "https://api.unsplash.com/photos/rMXsuun3CuQ",
        "html": "https://unsplash.com/photos/rMXsuun3CuQ",
        "download": "https://unsplash.com/photos/rMXsuun3CuQ/download",
        "download_location": "https://api.unsplash.com/photos/rMXsuun3CuQ/download?ixid=MnwxMjA3fDB8MXxhbGx8MTJ8fHx8fHwyfHwxNjIxODQwOTg4"
    },
    "categories": [],
    "likes": 20,
    "liked_by_user": false,
    "current_user_collections": [],
    "sponsorship": null,
    "user": {
        "id": "M8EEMP5UPD8",
        "updated_at": "2021-05-24T02:41:56-04:00",
        "username": "colincyruz",
        "name": "Colin Michel",
        "first_name": "Colin",
        "last_name": "Michel",
        "twitter_username": null,
        "portfolio_url": null,
        "bio": null,
        "location": null,
        "links": {
            "self": "https://api.unsplash.com/users/colincyruz",
            "html": "https://unsplash.com/@colincyruz",
            "photos": "https://api.unsplash.com/users/colincyruz/photos",
            "likes": "https://api.unsplash.com/users/colincyruz/likes",
            "portfolio": "https://api.unsplash.com/users/colincyruz/portfolio",
            "following": "https://api.unsplash.com/users/colincyruz/following",
            "followers": "https://api.unsplash.com/users/colincyruz/followers"
        },
        "profile_image": {
            "small": "https://images.unsplash.com/profile-fb-1621522556-3815074e04d0.jpg?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=32\u0026w=32",
            "medium": "https://images.unsplash.com/profile-fb-1621522556-3815074e04d0.jpg?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=64\u0026w=64",
            "large": "https://images.unsplash.com/profile-fb-1621522556-3815074e04d0.jpg?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=faces\u0026cs=tinysrgb\u0026fit=crop\u0026h=128\u0026w=128"
        },
        "instagram_username": null,
        "total_collections": 0,
        "total_likes": 0,
        "total_photos": 9,
        "accepted_tos": true,
        "for_hire": false
    }
}]

6、从上边返回的是json格式,第一个id就是图片id。我们可以利用id提取数据, "id":"(.*?)" ,括号里就是需要提取的数据。如下图所示:

7、从上图我们可以清楚地看出正则匹配的总数是26,远远超过了图片总数12张,因此提取的还有其他的id。我们继续分析,看到在user里也有一个id,这个id并不是图片的id,用这个id去下载图片,会报404错误,我们用刚才的正则会把这个id提取出来。继续分析数据,发现download链接,没个图片除了id不一样,其他都一样,user里没有这个链接,我们用这个链接提取数据 **"download":"unsplash.com/photos/(.*?…

8、从上图我们看到提取匹配总数是12,正确,因此我们需要添加一个正则提取器,将以上的正则表达式填写到正则提取器中,记得匹配数字填-1,把所有匹配的都提取出来,如下图所示:

9、我们添加一个sampler->Debug Sampler,查看一下是否真的取出我们想要的数据了,如下图所示:

10、保存后,运行Jmeter,点击察看结果树,查看取出的数据,说明id已经被我们取出来了,如下图所示:

11、接下来需要用我们的foreach控制器了 ,对所有的id进行遍历,在控制器里输入变量名称,就是正则表达式里的变量名,如下图所示:

12、在foreach控制器下面再添加一个http请求,用于下载图片  ,就是我们开头写明的下载图片请求,设置与第一个http请求一致,记得将id参数化,用表达式里变量名即可,如下图所示:

13、发送图片下载请求后,我们使用beanshell将图片保存到本地,在请求下,添加后置处理器->BeanShell PostProcessor,编写保存图片脚本,如下图所示:

14、保存图片脚本参考代码如下:

import java.io.*;
import java.text.SimpleDateFormat;
import java.util.Date;

Date date = new Date();
date.getDate();
SimpleDateFormat df = new SimpleDateFormat("yyyymmdd-HHmmss");
String formate = df.format(date);
//string name = vars.get("imgid");
byte[] result = prev.getResponseData();  //这个是获取到请求返回的数据,prev是获取上个请求的返回
String file_name = "E:\photos\" + formate + ".jpg"; //代表存放文件的位置和文件名
File file = new File(file_name);
FileOutputStream out = new FileOutputStream(file);
out.write(result);
out.close();

15、下面我们就可以见证奇迹的时刻了,运行完毕后,察看结果树可以看到运行结果,存放路径中,可以看到下载的图片了,如下图所示:

4.小结

1.在保存到本地图片要创建你代码里写的路径,或者代码中判断有就不创建,没有就创建。
2.主要你的正则表达式是否真的提取到了你需要的数据。
3.细心地小伙伴看到我这里只有12张,但是参数可以循环3次,36张,这是怎么回事了,那是因为宏哥的线程组是1,所以就是12张了。
4.熟练地掌握正则表达式的用法。这里最重要的一步就是正则表达式提取我们想要的关键数据

好了,关于Jmeter爬虫就到这里吧,其实和上一篇的内容也差不多少。

🦀 Rust独家秘笈:所有权与借用精解,让你写出“不可能”出错的代码!💪

作者 土豆1250
2025年5月19日 10:32

Rust 最核心也最具特色的功能之一就是其所有权(Ownership)系统。它与借用(Borrowing)规则相结合,能够在编译时保证内存安全和线程安全,而无需垃圾回收器(GC)或运行时开销。对于习惯了 C/C++ 手动管理内存或 Java/Python 自动垃圾回收的开发者来说,这套机制初看起来可能有些陌生,但一旦理解,你就会发现它的强大之处。

核心概念:所有权 (Ownership)

Rust 中的所有权系统围绕以下三个核心规则构建:

  1. 每个值在 Rust 中都有一个变量,称为其所有者(Owner)。
  2. 一次只能有一个所有者。
  3. 当所有者离开作用域(Scope)时,该值将被丢弃(Dropped)。

1. 作用域与资源释放

与许多语言类似,Rust 中的变量也有作用域。当变量离开作用域时,Rust 会自动调用一个特殊的函数 drop,该函数允许值的所有者释放其占用的资源(例如内存、文件句柄等)。

{
    let s = String::from("hello"); // s 进入作用域,分配内存于堆上
    // 使用 s
} // s 离开作用域,s 的内存被自动释放 (drop 被调用)
  // 这里 s 不再有效

2. "Move" 语义:所有权的转移

对于存储在堆上的数据(如 String, Vec<T>),当我们将一个变量赋值给另一个变量,或者将值作为参数传递给函数时,所有权会发生转移(Move)。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // s1 的所有权转移给了 s2

    // println!("{}", s1); // 编译错误!s1 不再拥有数据,它已经被 "move" 了
    println!("{}", s2); // 正确,s2 现在是 "hello" 的所有者
}

这种行为与 C++ 中的移动构造函数/移动赋值类似,但 Rust 在编译期就强制执行。与 Python 等语言中变量只是标签,赋值是让新标签指向同一对象不同,Rust 的 Move 意味着原变量失效,以防止“悬垂指针”或“二次释放”等问题。

函数参数传递与所有权

当将拥有堆数据的变量传递给函数时,所有权同样会转移:

fn takes_ownership(some_string: String) { // some_string 获得所有权
    println!("{}", some_string);
} // some_string 离开作用域,内存被释放

fn main() {
    let s = String::from("world");
    takes_ownership(s); // s 的所有权转移到 takes_ownership 函数的 some_string 参数
    // println!("{}", s); // 编译错误!s 不再有效
}

如果函数需要“归还”所有权,它可以从函数返回值:

fn takes_and_gives_back(a_string: String) -> String { // 获得所有权
    // ... 做一些事情 ...
    a_string // 返回所有权
}

fn main() {
    let s1 = String::from("test");
    let s2 = takes_and_gives_back(s1);
    // s1 不再有效,s2 现在拥有数据
    println!("{}", s2);
}

虽然这种方式可行,但频繁地转移和归还所有权会显得很繁琐。这时,借用就派上用场了。

3. Copy Trait 与栈上数据

对于一些简单类型,它们完全存储在栈上,复制它们的成本很低(例如整数、浮点数、布尔值、字符,以及只包含这些类型的元组)。这些类型可以实现 Copy Trait。

如果一个类型实现了 Copy Trait,那么在赋值或传参时,会进行一次“按位复制”(bitwise copy),而不是转移所有权。原变量在赋值后仍然有效。

fn main() {
    let x = 5;    // i32 实现了 Copy Trait
    let y = x;    // y 是 x 的一个副本

    println!("x = {}, y = {}", x, y); // x 和 y 都有效
}

常见的 Copy 类型:

  • 所有整数类型,如 u32, i64
  • 布尔类型 bool
  • 所有浮点类型,如 f32, f64
  • 字符类型 char
  • 元组,当且仅当其所有元素都实现了 Copy Trait。
  • 不可变引用 &T (我们稍后会讲到)。

注意:如果一个类型实现了 Drop Trait (自定义资源清理逻辑),它就不能实现 Copy Trait。因为如果允许 Copy,那么就会有多个变量指向同一份需要清理的资源,可能会导致重复释放。

4. Clone Trait:显式复制

对于堆上数据,如果确实需要创建一份独立的深拷贝(Deep Copy),而不是转移所有权,可以使用 Clone Trait。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // s2 是 s1 的一个深拷贝

    println!("s1 = {}, s2 = {}", s1, s2); // s1 和 s2 都有效,且指向不同的内存区域
}

String 类型实现了 Clone Trait,但没有实现 Copy Trait,因为复制堆上数据通常是相对昂贵的操作,Rust 希望你明确地表示这个意图。

核心概念:借用 (Borrowing) 与引用 (References)

如果我们只是想让函数或其他代码段临时访问数据,而不获取其所有权,就可以使用“借用”。借用通过“引用”(References)来实现。引用允许你使用值但不获取其所有权。

Rust 的借用规则是其内存安全的核心:

  1. 共享不可变 (Shared Immutable): 在任何给定时间,你可以拥有多个对某一数据的不可变引用(&T)。
    • 这意味着你可以随便读,但不能修改数据。
  2. 可变不共享 (Mutable Exclusive): 在任何给定时间,你只能拥有一个对某一数据的可变引用(&mut T)。
    • 这意味着如果你有一个可变引用,那么不能有其他任何引用(可变或不可变)指向该数据。

这套规则由编译器在编译时强制执行,确保了以下两点:

  • 没有数据竞争 (Data Races): 因为你不能同时拥有一个可变引用和任何其他引用(可变或不可变)到同一数据。
  • 没有悬垂引用 (Dangling References): 编译器会确保引用指向的数据在引用有效期内始终有效(通过生命周期机制,大部分情况下由编译器自动推断)。

1. 不可变引用 (&T)

fn calculate_length(s: &String) -> usize { // s 是对 String 的一个不可变引用
    s.len()
} // s 离开作用域,但它并不拥有所引用的数据,所以什么也不会发生

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // 传递 s1 的不可变引用

    println!("The length of '{}' is {}.", s1, len); // s1 仍然有效
}

calculate_length 函数中,s 是一个引用,它指向 s1 拥有的 String 数据。当 calculate_length 返回后,s1 仍然拥有数据,并且可以继续使用。

可以有多个不可变引用:

fn main() {
    let s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    // let r3 = &mut s; // 编译错误!不能在有不可变引用的同时创建可变引用

    println!("{} and {}", r1, r2);
}

2. 可变引用 (&mut T)

可变引用允许你修改所借用的数据。

fn change(some_string: &mut String) { // some_string 是对 String 的一个可变引用
    some_string.push_str(", world");
}

fn main() {
    let mut s = String::from("hello"); // 注意:s 必须是 mut
    change(&mut s); // 传递 s 的可变引用

    println!("{}", s); // 输出 "hello, world"
}

关键点:

  • 要创建可变引用,原始变量必须是可变的(用 mut 关键字声明)。
  • 在特定作用域内,对特定数据只能有一个可变引用。
fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    // let r2 = &mut s; // 编译错误!不能有第二个可变引用
    // let r3 = &s;    // 编译错误!不能在有可变引用的同时创建不可变引用

    r1.push_str("!");
    println!("{}", r1); // 或 println!("{}", s);
}

这种限制在编译时就防止了数据竞争。例如,如果两个指针同时尝试修改同一块数据,结果可能是未定义的。Rust 的借用检查器阻止了这种情况的发生。

3. 引用的作用域与生命周期 (Lifetimes)

编译器会确保引用永远不会比它所指向的数据活得更久,这就是生命周期的概念。大多数情况下,编译器可以自动推断生命周期(称为生命周期省略规则)。

一个常见的会引发生命周期问题的场景是返回一个指向函数内部数据的引用:

// fn dangle() -> &String { // 编译错误!
//     let s = String::from("hello");
//     &s // 返回对 s 的引用
// } // s 在这里离开作用域并被 drop,其内存被释放

fn main() {
    // let reference_to_nothing = dangle();
}

上面的 dangle 函数会编译失败,因为 s 在函数结束时被销毁,那么返回的引用就会指向无效的内存(悬垂引用)。Rust 编译器通过生命周期分析阻止了这种情况。

对于更复杂的场景,可能需要显式标注生命周期,但这超出了快速入门的范围。通常,遵循借用规则,编译器会帮你处理好大部分情况。

应用场景详解

1. 函数调用时的参数传递

回顾一下:

  • 值传递 (所有权转移): fn foo(s: String)

    • s 获得所有权。
    • 原变量在调用后失效(除非类型实现了 Copy Trait)。
    • 适用于函数需要完全控制或消耗数据的情况。
  • 不可变借用 (引用传递): fn foo(s: &String)

    • s 是一个不可变引用。
    • 函数可以读取数据,但不能修改。
    • 原变量在调用后仍然有效且可访问。
    • 可以同时存在多个不可变借用。
    • 适用于函数只需要读取数据的情况。
  • 可变借用 (引用传递): fn foo(s: &mut String)

    • s 是一个可变引用。
    • 函数可以读取和修改数据。
    • 原变量在调用后仍然有效(并且可能已被修改)。
    • 在可变借用期间,不能有其他任何(可变或不可变)引用指向该数据。
    • 适用于函数需要修改数据的情况。

选择哪种方式?

  • 默认优先考虑不可变借用 (&T)。
  • 如果需要修改数据,使用可变借用 (&mut T)。
  • 只有当函数确实需要获取数据的所有权时(例如,将其存储到结构体中,或者转换它并返回新的拥有所有权的值),才使用值传递 (T)。

2. 匿名闭包 (Closures) 捕获变量

闭包是 Rust 中强大的特性,它们可以捕获其环境中的变量。闭包如何捕获变量与其实现的三种 Fn Trait 相关,这直接关联到所有权和借用:

  • FnOnce: 闭包通过(所有权)捕获变量。这意味着闭包会消耗掉被捕获的变量。这类闭包只能被调用一次。

    • 如果闭包体将捕获的变量移出闭包,则它必须是 FnOnce
    • 例如:let s = String::from("hello"); let c = || drop(s); c();
  • FnMut: 闭包通过可变引用&mut T)捕获变量。这意味着闭包可以修改被捕获的变量。这类闭包可以被多次调用。

    • 如果闭包体修改了捕获的变量,但没有移出,则它至少是 FnMut
    • 例如:let mut s = String::from("hello"); let mut c = || s.push_str("!"); c(); println!("{}", s);
  • Fn: icrobialm通过不可变引用&T)捕获变量。这意味着闭包只能读取被捕获的变量。这类闭包可以被多次调用,甚至可以并发调用。

    • 如果闭包体只读取捕获的变量,则它可以是 Fn
    • 例如:let s = String::from("hello"); let c = || println!("{}", s); c();

Rust 编译器会根据闭包如何使用捕获的变量来自动推断最合适的 Fn Trait。

move 关键字与闭包

有时,我们希望闭包强制获取其捕获变量的所有权,即使闭包体本身可能只需要引用。这在多线程或异步编程中尤其重要,可以确保闭包在与原始变量的作用域分离后仍然有效。这时可以使用 move 关键字。

use std::thread;

fn main() {
    let data = vec![1, 2, 3];

    // 使用 move 关键字,闭包会获取 data 的所有权
    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", data);
        // data 在这里被 drop
    });

    // println!("{:?}", data); // 编译错误!data 的所有权已经转移到线程中了

    handle.join().unwrap();
}

如果不使用 move,闭包默认会尝试借用 data。但新线程可能比 main 函数的 data 活得更久,导致悬垂引用。move 强制闭包取得 data 的所有权,解决了这个问题。

3. 异步函数与多线程

Rust 的所有权和借用系统是其并发安全性的基石。

SendSync Trait

  • Send: 一个类型如果实现了 Send Trait,意味着它的所有权可以安全地从一个线程转移到另一个线程。大多数基本类型和拥有 Send 类型字段的复合类型都是 Send 的。Rc<T>(引用计数指针,非线程安全)不是 Send 的。
  • Sync: 一个类型如果实现了 Sync Trait,意味着它的不可变引用(&T)可以安全地在多个线程之间共享。如果 TSend 的,那么 &T 通常是 Sync 的。Mutex<T>Sync 的(即使 T 不是 Sync,只要 TSend),因为它提供了同步机制。RefCell<T>(内部可变性,非线程安全)不是 Sync 的。

当你在线程间传递数据或在 async 代码块中使用 await 时,编译器会检查相关类型是否实现了 Send 和/或 Sync,以确保线程安全。

Arc<T>:原子引用计数

Rc<T> 用于在单线程中共享数据所有权。当需要在多线程中共享所有权时,需要使用 Arc<T> (Atomic Reference Counted)。Arc<T>Rc<T> 类似,但其引用计数是原子操作,因此是线程安全的。Arc<T> 要求 T 必须是 Send + Sync

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(String::from("shared data"));

    let mut handles = vec![];

    for i in 0..3 {
        let data_clone = Arc::clone(&data); // 增加引用计数,每个线程得到一个 Arc 指针
        let handle = thread::spawn(move || {
            println!("Thread {}: {}", i, data_clone);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
    println!("Original data still accessible: {}", data);
}

Mutex<T>:互斥锁

如果需要在多个线程间共享并修改数据,可以使用 Mutex<T> (Mutual Exclusion)。Mutex<T> 确保一次只有一个线程可以访问其内部的数据。

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // 使用 Arc 来允许多个线程拥有 Mutex 的所有权
    // 使用 Mutex 来同步对 Vec 的访问
    let counter = Arc::new(Mutex::new(vec![0]));

    let mut handles = vec![];

    for i in 0..5 {
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num_vec = counter_clone.lock().unwrap(); // 获取锁,如果其他线程持有则阻塞
            num_vec[0] += 1;
            println!("Thread {} incremented counter to: {}", i, num_vec[0]);
            // 当 num_vec (MutexGuard) 离开作用域时,锁会自动释放
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final counter value: {:?}", counter.lock().unwrap()[0]);
}

lock() 方法会返回一个 MutexGuard,它是一个智能指针,实现了 DerefDerefMut,允许你访问被 Mutex 保护的数据。当 MutexGuard 离开作用域时,锁会自动释放。这种基于作用域的资源管理(RAII)是 Rust 安全性的一个重要方面。

异步 async/await

async 函数中,当跨越 .await 点时,所有被异步任务持有的状态(包括局部变量和捕获的变量)必须是 Send 的,因为异步任务可能会在不同的线程上恢复执行。

async fn my_async_function(data: String) { // data 拥有所有权
    println!("Processing: {}", data);
    // ... 一些异步操作 ...
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    println!("Finished processing: {}", data); // data 仍然有效
}

// 如果需要共享数据,通常会用 Arc
async fn shared_async_function(data: Arc<String>) {
    println!("Accessing shared: {}", data);
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    println!("Finished accessing shared: {}", data);
}

#[tokio::main]
async fn main() {
    let s = String::from("async example");
    my_async_function(s).await;
    // s 在这里不再有效,所有权已转移

    let shared_s = Arc::new(String::from("shared async"));
    let task1 = shared_async_function(Arc::clone(&shared_s));
    let task2 = shared_async_function(Arc::clone(&shared_s));

    tokio::join!(task1, task2); // 并发执行两个任务
    println!("Main sees: {}", shared_s);
}

move 关键字在 async 块或返回 Future 的闭包中也经常使用,以确保捕获的变量在 Future 的整个生命周期内都有效。

总结与上手建议

Rust 的所有权和借用系统是其核心竞争力,它提供了内存安全和线程安全,而没有垃圾回收的开销。

  • 所有权:每个值有唯一所有者,所有者离开作用域则值被销毁。赋值或传参(对堆数据)会导致所有权转移 (Move)。
  • 借用:通过引用 (&T&mut T) 临时访问数据而不获取所有权。
    • 共享不可变:任意多个 &T
    • 可变不共享:仅一个 &mut T,且此时不能有任何 &T
  • Copy Trait:用于栈上数据,赋值时发生位拷贝,原变量仍有效。
  • Clone Trait:用于显式创建数据的深拷贝。

给有经验开发者的上手建议:

  1. 拥抱编译器:Rust 编译器(特别是其借用检查器 borrow checker)是你最好的朋友。它的错误信息初看起来可能很吓人,但通常非常精确,并会指导你如何修复问题。
  2. 优先不可变:默认使用不可变变量和不可变引用。只在确实需要修改时才使用 mut
  3. 明确数据流:思考数据在你的程序中是如何流动、谁拥有它、谁在何时需要访问它。
  4. 从小处着手:先尝试在简单函数和数据结构中运用所有权和借用规则。
  5. clone() 是你的朋友(初期):当你对所有权和生命周期感到困惑,并且编译器报错时,尝试使用 .clone() 来解决问题。这虽然可能不是性能最优的方案,但可以让你先让代码跑起来,之后再逐步优化,理解为什么需要 clone 以及如何避免它。
  6. 理解String vs &strString 是拥有所有权的堆分配字符串,而 &str (字符串切片) 是对字符串数据的不可变引用。这是学习所有权和借用的一个很好的具体例子。
  7. 逐步深入:一旦掌握了基础,再逐步学习生命周期注解、Rc/ArcRefCell/Mutex 等更高级的概念。

虽然上手初期可能会遇到一些挑战,但一旦你内化了 Rust 的这些核心概念,你就能编写出既高效又安全的代码。祝你在 Rust 的学习旅程中一切顺利!

计算机图形学中的投影矩阵详解

作者 Mintopia
2025年5月19日 10:31
在计算机图形学领域,投影矩阵是将三维空间中的物体转换到二维屏幕上的关键工具。它能帮助我们模拟真实世界的视觉效果,让用户在平面显示器上看到立体的图形场景。下面我们将深入探讨投影矩阵的原理与实现。 一、投

Three.js-硬要自学系列28 (tweenjs创建动画、相机运动动画、相机飞行、模型放大预览)

2025年5月19日 10:14

本章主要学习知识点

  • 学习如何使用tweenjs创建动画
  • 使用tweenjs实现相机运动动画
  • 掌握如何实现相机飞行靠近观察设备
  • 实现点击模型放大预览

tweenjs创建动画

Tween.js 创建动画主要是通过「补间动画」自动计算属性变化的中间状态,让物体平滑过渡。

在tweenjs中我们只要告诉它物体当前状态和目标状态,它会自动帮我们计算中间过程

简单几步便可创建动画

初始化场景

包括mesh、camera、renderer等

const geometry = new THREE.SphereGeometry( 1, 32, 32 );
const material = new THREE.MeshBasicMaterial( { color: 'deeppink' } );
cube = new THREE.Mesh( geometry, material );
// 将网格添加到场景中
scene.add( cube );
创建动画对象

这里我们改变Mesh的位置、大小、颜色

const tween = new TWEEN.Tween(cube.position)
.to({x: 10, y: 10, z: 1}, 2000)
.start()
const tween2 = new TWEEN.Tween(cube.scale)
.to({x: 2, y: 2, z: 2}, 2000)
.start()
const tween3 = new TWEEN.Tween(cube.material.color)
.to({r: 1, g: .56, b: 0}, 2000)
.start()
动画刷新
renderer.render( scene, camera );
requestAnimationFrame( animate );
tween.update();
tween2.update();
tween3.update();

213.gif

tweenjs相机运动动画

相机运动动画主要是让相机属性(如位置、视角)按设定轨迹平滑过渡。

下面是一个简单的示例

const tween = new TWEEN.Tween(camera.position)
.to({x: 10, y: 20, z: -16}, 3000)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(() => {
camera.lookAt(0,0,0)
})
.start()

2.gif

相机飞行靠近观察设备

通过补间动画让相机位置和视角平滑过渡到目标设备附近,已实现靠近观察设备

导入两个模型,一个电力控制器,一个人物模型

loader.load( 'model/electrical_box_-_free/scene.gltf', function ( gltf ) {
    scene.add( gltf.scene );
    eleModName = gltf.scene.name;
})
loader.load( 'model/stand_person/scene.gltf', function ( gltf2 ) {
    scene.add( gltf2.scene );
    gltf2.scene.position.set(5,-6,2)
    gltf2.scene.scale.set(0.5,0.5,0.5)
    personModName = gltf2.scene.name;
})

image.png

创建几个按钮,用来实现点击不同的按钮观察不同的模型

document.getElementById('A').addEventListener('click',function () {
    const A = scene.getObjectByName(eleModName);

    const pos = new THREE.Vector3()
    A.getWorldPosition(pos);
    const pos2 = pos.clone().set(0,0,4)
    createCameraTween(pos2,controls.target)
})

document.getElementById('B').addEventListener('click',function () {
    const B = scene.getObjectByName(personModName);

    const pos = new THREE.Vector3()
    B.getWorldPosition(pos);
    const pos2 = pos.clone().set(7,-4,10)
    createCameraTween(pos2,controls.target)

})
document.getElementById('C').addEventListener('click',function () {
    const cameraPos = new THREE.Vector3(1,2,12)
    createCameraTween(cameraPos,controls.target)
})

在查看效果之前,先来看下createCameraTween该方法

function createCameraTween(endPos, endTarget) {
   tween = new TWEEN.Tween({
        x: camera.position.x,
        y: camera.position.y,
        z: camera.position.z,
        // 相机开始指向的目标观察点
        tx: controls.target.x,
        ty: controls.target.y,
        tz: controls.target.z
    }).to({
        x: endPos.x,
        y: endPos.y,
        z: endPos.z,
        tx: endTarget.x,
        ty: endTarget.y,
        tz: endTarget.z
    }, 1000).onUpdate(function (object) {
        camera.position.set(object.x,object.y,object.z)
        // camera.lookAt(object.tx,object.ty,object.tz)
        controls.target.set(object.tx,object.ty,object.tz)
        controls.update()
    }).start();
}

这个函数用于创建动画,传入相机位置与控制器指向目标对象,当我们点击不同按钮时, 相机将平滑移动到指定观察位置

423.gif

点击模型放大预览

要实现该效果其实也就是控制相机运动,只是加上了射线检测。

window.addEventListener('click', (event) => {
    // 计算鼠标位置
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    // 更新射线
    raycaster.setFromCamera(mouse, camera);

    // 计算射线与模型的交点
    const intersects = raycaster.intersectObjects(scene.children, true);
    console.log(intersects);

    if (intersects.length > 0) {
        const object = intersects[0].object;
        if(object.isMesh){
            const pos = new THREE.Vector3();
            object.getWorldPosition(pos);
            const pos2 = pos.clone().addScalar(1);
            createCameraTween(pos2, controls.target);
        } 
    }
});

4235.gif

以上案例均可在案例中心查看体验

THREE 案例中心

image.png

HarmonyOS Next代理提醒开发:打造「零漏触达」的智能通知系统

作者 lyc233333
2025年5月19日 10:03

哈喽!我是小L,那个在鸿蒙通知领域「让提醒更有温度」的女程序员~ 你知道吗?正确的代理提醒设计能让用户留存率提升25%!今天就来揭秘鸿蒙代理提醒的「精准触达法则」——类型选择、交互设计、权限管理、跨设备同步四大核心能力,让你的提醒既「恰到好处」又「温暖贴心」!

一、代理提醒的「三维坐标系」

(一)类型选择矩阵

场景 倒计时提醒 日历提醒 闹钟提醒 组合策略
会议倒计时(10分钟) 单触发+强提醒
每月账单(每月1日) 月循环+静默通知
每日喝水(9:00-18:00) 周循环+多设备同步
跨年活动(2024.12.31) 日期触发+倒计时预热

(二)ReminderRequest核心参数

// 基础提醒参数(通用)
interface BaseReminder {
    reminderType: ReminderType; // 类型(必填)
    title: string; // 标题(必填,≤30字)
    content: string; // 内容(必填,≤100字)
    notificationId: number; // 唯一通知ID(必填)
    slotType: SlotType; // 通知渠道(必填,如社交/系统/警示)
    wantAgent: WantAgent; // 点击通知跳转目标(必填)
}

// 倒计时提醒参数
interface TimerReminder extends BaseReminder {
    reminderType: ReminderType.REMINDER_TYPE_TIMER;
    triggerTimeInSeconds: number; // 触发时间(秒,≤30天)
    expiredContent?: string; // 过期文案
}

// 日历提醒参数
interface CalendarReminder extends BaseReminder {
    reminderType: ReminderType.REMINDER_TYPE_CALENDAR;
    dateTime: DateTime; // 具体日期时间(年/月/日/时/分/秒)
    repeatMonths?: number[]; // 重复月份(1-12,如[1,3,5])
    repeatDays?: number[]; // 重复日期(1-31)
}

// 闹钟提醒参数
interface AlarmReminder extends BaseReminder {
    reminderType: ReminderType.REMINDER_TYPE_ALARM;
    hour: number; // 小时(0-23)
    minute: number; // 分钟(0-59)
    daysOfWeek?: number[]; // 重复星期(1-7,1=周一)
    snoozeTimes?: number; // 贪睡次数(默认0)
    timeInterval?: number; // 贪睡间隔(秒,默认300)
}

二、交互设计:让提醒「懂场景,有温度」

(一)通知样式优化

1. 富媒体通知

const reminder: CalendarReminder = {
    // ...基础参数
    largeIcon: 'icon_event.png', // 大图标(192x192px)
    smallIcon: 'icon_event_small.png', // 小图标(48x48px)
    progress: { // 进度条(如任务完成度)
        current: 60,
        total: 100,
        show: true
    },
    actions: [ // 交互按钮
        {
            type: ActionButtonType.ACTION_BUTTON_TYPE_DEEP_LINK,
            title: '查看详情',
            deepLink: 'app://event/detail/123'
        },
        {
            type: ActionButtonType.ACTION_BUTTON_TYPE_SNOOZE,
            title: '稍后提醒',
            snoozeTime: 30 * 60 // 30分钟后重试
        }
    ]
};

2. 多语言适配

// 根据系统语言自动切换文案
const lang = ohos.i18n.getSystemLanguage();
const reminderContent = {
    'zh-CN': '会议即将开始',
    'en-US': 'Meeting starts soon',
    'ja-JP': '会議が始まります'
}[lang] || 'Reminder';

(二)智能免打扰

// 检测用户状态(如驾驶/睡眠)自动调整提醒方式
const userStatus = deviceManager.getUserStatus();
if (userStatus === UserStatus.DRIVING) {
    reminder.slotType = SlotType.SYSTEM_ALERT; // 驾驶模式强提醒
    reminder.ringDuration = 10; // 延长响铃时间
} else if (userStatus === UserStatus.SLEEPING) {
    reminder.slotType = SlotType.QUIET_NOTIFICATION; // 静默通知
    reminder.vibrationPattern = []; // 关闭振动
}

三、跨设备提醒:构建「无缝触达」网络

(一)多端同步策略

// 主设备创建提醒时同步到其他设备
function publishCrossDeviceReminder(reminder: BaseReminder, devices: string[]) {
    devices.forEach(deviceId => {
        const crossReminder = {
            ...reminder,
            targetDeviceId: deviceId, // 目标设备ID
            syncType: SyncType.SYNC_TYPE_REALTIME // 实时同步
        };
        reminderAgentManager.publishReminderToDevice(crossReminder);
    });
}

// 场景:手机创建提醒,同步到手表/平板
publishCrossDeviceReminder(timerReminder, ['watch-123', 'pad-456']);

(二)设备能力适配

// 根据设备类型调整提醒形式
const deviceInfo = deviceManager.getDeviceInfo();
if (deviceInfo.type === DeviceType.WATCH) {
    reminder.content = reminder.content.slice(0, 50) + '...'; // 手表限制50字
    reminder.actions = [{ type: ActionButtonType.ACTION_BUTTON_TYPE_CLOSE }]; // 仅保留关闭按钮
} else if (deviceInfo.type === DeviceType.TV) {
    reminder.showOnLockScreen = true; // 电视锁屏显示
    reminder.priority = NotificationPriority.HIGH; // 高优先级
}

四、权限与合规:「安全第一」的底线思维

(一)权限申请全流程

  1. 配置文件声明

    "reqPermissions": [
        {
            "name": "ohos.permission.PUBLISH_AGENT_REMINDER",
            "reason": "需要发送会议提醒"
        }
    ]
    
  2. 用户授权引导

    function requestReminderPermission() {
        permission.requestPermissionsFromUser([Permission.PUBLISH_AGENT_REMINDER])
            .then((result) => {
                if (result[0].granted) {
                    publishReminder(); // 授权后发布提醒
                } else {
                    showPermissionGuide(); // 引导用户到设置页授权
                }
            });
    }
    
  3. 华为官方申请(如需)

    • 邮件主题:【代理提醒权限申请】应用名称-包名
    • 必备信息:
      应用场景:每日健康打卡提醒,频率1次/天,通知标题:"该打卡啦",通知内容:"今日运动目标完成50%"
      

(二)隐私保护实践

  1. 敏感信息加密

    // 提醒内容含敏感数据时加密传输
    const encryptedContent = crypto.encrypt(reminder.content, secretKey);
    reminder.content = encryptedContent;
    // 触发时解密(在ExtensionAbility中)
    const decryptedContent = crypto.decrypt(encryptedContent, secretKey);
    
  2. 用户数据 anonymization
    避免在提醒中包含用户隐私信息(如联系方式、地址),如需展示使用脱敏处理

五、实战案例:「智能日程管理」全场景实现

场景描述:

用户在手机上创建会议提醒,自动同步到手表,并在会议开始前1小时、10分钟触发渐进式提醒

实现步骤:

1. 创建多级提醒

// 主提醒(会议开始时)
const mainReminder: CalendarReminder = {
    reminderType: ReminderType.REMINDER_TYPE_CALENDAR,
    dateTime: { year: 2024, month: 5, day: 20, hour: 14, minute: 0 },
    // ...其他参数
};

// 预提醒(提前1小时)
const preReminder: TimerReminder = {
    reminderType: ReminderType.REMINDER_TYPE_TIMER,
    triggerTimeInSeconds: 3600, // 提前1小时
    // ...其他参数,内容为"会议将在1小时后开始"
};

2. 多设备同步

const devices = await deviceManager.getPairedDevices(DeviceType.WATCH);
devices.forEach(deviceId => {
    // 手表端提醒简化内容
    const watchReminder = {
        ...mainReminder,
        content: mainReminder.content.slice(0, 30) + '...',
        targetDeviceId: deviceId
    };
    reminderAgentManager.publishReminderToDevice(watchReminder);
});

3. 贪睡逻辑

// 处理用户点击贪睡按钮
reminderAgentManager.on('snooze', (reminderId) => {
    const snoozeTime = 10 * 60; // 10分钟后重试
    reminderAgentManager.scheduleSnooze(reminderId, snoozeTime);
});

六、避坑指南:代理提醒的「红线清单」

(一)系统限制规避

  1. 频率限制
    同一应用每分钟最多发送10条提醒,单日最多100条(可通过getReminderQuota查询)

  2. 内容规范

    • 标题/内容禁止包含敏感词(如广告、政治用语)
    • 通知图标需符合《HarmonyOS设计规范》,禁止使用动态图标
  3. 用户体验

    • 避免在22:00-08:00期间发送强提醒(除非紧急)
    • 提供「不再提醒」选项,允许用户永久关闭某类提醒

(二)异常场景处理

  1. 权限被拒

    if (!permission.hasPermission(Permission.PUBLISH_AGENT_REMINDER)) {
        router.pushUrl('app://settings/reminder-permission'); // 跳转权限设置页
        return;
    }
    
  2. 设备离线

    // 发送到离线设备时自动缓存,上线后补发
    reminderAgentManager.enableOfflineDelivery(reminderId, true);
    
  3. 重复提醒

    // 按天去重(同一日期同一类型提醒仅保留最新)
    reminder.replaceMode = ReminderReplaceMode.REPLACE_MODE_KEEP_LATEST;
    

七、冷知识:代理提醒的「未来进化」

(一)AI智能提醒

  • 基于用户行为预测提醒时机(如通勤时间自动触发路况提醒)
  • 自然语言解析创建提醒:"提醒我明天上午10点开会"

(二)跨应用提醒聚合

// 合并来自同一开发者的提醒(如电商应用的促销通知)
reminder.groupKey = 'com.example.shopping';

(三)环境感知提醒

// 检测到用户进入商场时触发优惠券提醒
locationManager.on('enterGeofence', (geofenceId) => {
    if (geofenceId === 'mall-123') {
        publishReminder(shopCouponReminder);
    }
});

最后提醒:代理提醒的「温度公式」

用户满意度 = 提醒精准度 × 交互友好度 ÷ 打扰频率

  • 精准度:基于时间、地点、设备的三维触发
  • 友好度:清晰的信息层级+便捷的操作路径
  • 打扰频率:尊重用户作息,提供灵活的设置选项

想知道如何用鸿蒙实现「提醒通知的用户行为分析」?关注我,下次带你解锁新技能!要是觉得文章有用,快分享给团队里的产品经理,咱们一起让提醒「有价值,不打扰」! 😉

TypeScript enum 枚举的完美替代品: enum-plus

2025年5月19日 10:02

在 TypeScript 开发中,枚举(enum)是一种常用的类型,用于表示一组命名常量。然而,原生的 TypeScript enum 存在诸多限制,使得它在实际开发中并不如人意。本文将深入探讨原生 enum 的痛点,并介绍一个强大的替代解决方案enum-plus,它如何弥补这些不足并提供更多实用功能。

😭 TypeScript 原生 enum 的痛点

1. 缺乏显示文本支持

原生 enum 只能定义简单的键值对映射,无法为枚举项添加友好的显示文本。这在需要在 UI 中展示枚举值时非常不便。

// 原生 enum 的定义方式
enum Status {
  Active = 0,
  Pending = 1,
  Rejected = 2,
}

// 在 UI 中展示时,需要额外维护一个映射关系
const statusTextMap = {
  [Status.Active]: '活跃',
  [Status.Pending]: '待处理',
  [Status.Rejected]: '已拒绝',
};

// 使用时需要手动转换
function getStatusText(status: Status): string {
  return statusTextMap[status] || '未知状态';
}

这种方式存在明显问题:

  • 显示文本与枚举定义分离,维护成本高
  • 添加新枚举项时容易忘记更新文本映射
  • 类型安全性不够,如果忘记映射某个值,TypeScript 不会提醒

2. 无法优雅地遍历枚举

原生 enum 没有提供原生方法来获取所有枚举项或遍历枚举。虽然有一些变通方法,但都不够优雅。

// 遍历数字枚举的变通方法
function getStatusArray() {
  return Object.keys(Status)
    .filter((key) => !isNaN(Number(key)))
    .map((key) => Number(key));
}

// 这种方法既不优雅也不类型安全
const statusArray = getStatusArray(); // [0, 1, 2]

3. 与 UI 组件集成困难

原生 enum 难以直接用于 UI 组件,如下拉菜单、单选框等,通常需要额外的转换逻辑:

// 为 Select 组件生成选项,需要编写冗长的转换代码
function getStatusOptions() {
  return Object.keys(Status)
    .filter((key) => isNaN(Number(key)))
    .map((key) => ({
      value: Status[key as keyof typeof Status],
      label: statusTextMap[Status[key as keyof typeof Status]],
    }));
}

// 使用方式
<Select options={getStatusOptions()} />;

4. 缺乏国际化/本地化支持

如果你的应用需要支持多语言,使用原生 enum 会变得更加麻烦:

// 为不同语言维护多个映射表
const statusTextMapEN = {
  [Status.Active]: 'Active',
  [Status.Pending]: 'Pending',
  [Status.Rejected]: 'Rejected'
};

const statusTextMapZH = {
  [Status.Active]: '活跃',
  [Status.Pending]: '待处理',
  [Status.Rejected]: '已拒绝'
};

// 使用时需要根据当前语言选择不同的映射表
function getLocalizedStatusText(status: Status, lang: 'en' | 'zh'): string {
  const map = lang === 'en' ? statusTextMapEN : statusTextMapZH;
  return map[status] || 'Unknown';
}

5. 无法扩展自定义属性

如果需要为枚举项添加额外属性(如图标、颜色、权限等),原生 enum 无法满足:

// 需要为每个属性维护单独的映射
enum Status {
  Active = 0,
  Pending = 1,
  Rejected = 2,
}

const statusColorMap = {
  [Status.Active]: 'green',
  [Status.Pending]: 'orange',
  [Status.Rejected]: 'red',
};
const statusIconMap = {
  [Status.Active]: 'check-circle',
  [Status.Pending]: 'clock-circle',
  [Status.Rejected]: 'close-circle',
};

// 使用时需要手动维护映射关系
function getStatusInfo(status: Status) {
  return {
    color: statusColorMap[status],
    icon: statusIconMap[status],
  };
}

这种方式不仅冗长,而且容易出错,尤其是在添加新枚举项时。

🔥 enum-plus:一个全面增强的枚举解决方案

面对原生 enum 的这些局限性,enum-plus 应运而生。它在保持与原生 enum 完全兼容的同时,提供了一系列增强功能。

enum-plus 是一个 TypeScript 库,旨在提供更强大、更灵活的枚举解决方案。它通过简单的 API 设计,解决了原生 enum 的痛点,并提供了更多实用功能。

enum-plus 允许你在定义枚举时直接添加显示文本、国际化支持、自定义属性等,极大地简化了枚举的使用和维护。

1. 内置显示文本支持

enum-plus 允许你在定义枚举时直接添加显示文本,无需额外的映射关系:

import { Enum } from 'enum-plus';

const Status = Enum({
  Active: { value: 0, label: '活跃' },
  Pending: { value: 1, label: '待处理' },
  Rejected: { value: 2, label: '已拒绝' },
} as const);

// 直接获取显示文本
Status.label(0); // '活跃'
Status.label(Status.Active); // '活跃'

这种方式不仅简洁,而且类型安全,添加新枚举项时不需要担心忘记更新文本映射。

2. 便捷的枚举遍历

enum-plus 提供了内置的方法来获取所有枚举项和键,遍历枚举变得简单而优雅:

// 获取所有枚举项
Status.items.forEach((item) => {
  console.log(`${item.key}: ${item.value} - ${item.label}`);
});

// 获取所有枚举键
Status.keys; // ['Active', 'Pending', 'Rejected']

// 检查值是否存在于枚举中
Status.has(0); // true
Status.has('Active'); // true

3. 与 UI 组件无缝集成

enum-plus 允许你直接将枚举用于 UI 组件,无需额外的转换逻辑:

// React + Ant Design
import { Select } from 'antd';
import { ProFormCheckbox, ProFormRadio, ProFormSelect, ProFormTreeSelect } from '@ant-design/pro-components';

// 一行代码集成,无需额外转换
<Select options={Status.items} />

// 或者添加全部选项
<Select options={Status.toSelect({ firstOption: true })} />

// 适配不同组件库的格式
<Table columns={[{ filters: Status.toFilter() }]} />
<Menu items={Status.toMenu()} />

// Ant Design Pro 组件
<ProFormSelect valueEnum={Week.toValueMap()} />; // 下拉框
<ProFormCheckbox valueEnum={Week.toValueMap()} />; // 复选框
<ProFormRadio.Group valueEnum={Week.toValueMap()} />; // 单选框
<ProFormTreeSelect valueEnum={Week.toValueMap()} />; // 树选择

除了 Ant Design,enum-plus 还支持其他常见的 UI 组件库,如 Element Plus、Vant 等,极大地提高了开发效率。

4. 本地化/国际化支持

enum-plus 内置了国际化支持,可以轻松地为不同语言提供枚举项的显示文本:

import i18next from 'i18next';

// 设置本地化函数
Enum.localize = (key?: string) => i18next.t(key);

const Status = Enum({
  Active: { value: 0, label: 'status.active' },
  Pending: { value: 1, label: 'status.pending' },
  Rejected: { value: 2, label: 'status.rejected' },
} as const);

// 自动返回当前语言的翻译文本
Status.label(0); // 根据当前语言返回翻译后的文本

5. 自定义字段扩展

enum-plus 允许你为枚举项添加自定义字段,如图标、颜色等,方便在 UI 中使用:

const Status = Enum({
  Active: { value: 0, label: '活跃', color: 'green', icon: 'check-circle' },
  Pending: { value: 1, label: '待处理', color: 'orange', icon: 'clock-circle' },
  Rejected: { value: 2, label: '已拒绝', color: 'red', icon: 'close-circle' },
} as const);

// 使用时可以直接获取自定义字段
Status.raw(0).color; // 'green'
Status.raw(Status.Active).icon; // 'check-circle'

// 也可以直接显示一个枚举的徽标
<Badge color={Status.raw(0).color} text={Status.label(0)} />;

💡 enum-plus 的其它优势

1. 更好的类型安全

enum-plus 提供了精确的类型推断,可以缩小变量的取值范围,防止无效赋值:

// 使用 valueType 缩小变量类型范围
type StatusType = typeof Status.valueType; // 0 | 1 | 2

// 无效值会在编译时报错
const status: typeof Status.valueType = 5; // 类型错误!

2. 支持 JSDoc 注释与智能提示

enum-plus 支持 JSDoc 注释,光标悬浮时可以直接看到枚举项的注释,以及枚举值而无需跳转离开光标位置,提高阅读代码的便捷性和效率。

const Status = Enum({
  /** 账户处于活跃状态 */
  Active: { value: 0, label: '活跃' },
  /** 账户等待审核 */
  Pending: { value: 1, label: '待处理' },
  /** 账户已被拒绝 */
  Rejected: { value: 2, label: '已拒绝' },
} as const);

// 光标悬停在 Status.Pending 上会显示注释和值提示

3. 动态创建枚举

支持从 API 数据中动态创建枚举,非常适合后端驱动的配置:

// 从API获取数据
const statusData = await fetchStatusTypes();
// [{ id: 1, name: 'active', displayName: '活跃' }, ...]

// 映射字段创建枚举
const Status = Enum(statusData, {
  getValue: 'id',
  getLabel: 'displayName',
  getKey: 'name',
});

4. 全局扩展机制

enum-plus 允许通过全局扩展机制添加自定义方法,方便在项目扩展一些自定义方法

// 扩展自定义方法
Enum.extends({
  getActiveItems(this) {
    return this.items.filter((item) => item.raw.isActive);
  },
  toDropdown(this) {
    return this.items.map((item) => ({
      key: item.value,
      label: item.label,
      icon: item.raw.icon,
    }));
  },
});

// 所有枚举实例都可以使用这些方法
Status.getActiveItems();
Status.toDropdown();

5. 零依赖与轻量级

enum-plus 是一个零依赖的库,gzip 压缩后仅 2KB+,不会增加项目的体积负担。

6. 跨框架兼容性

完全支持各种前端框架(React、Vue、Angular)和流行的 UI 库(Ant Design、Element Plus、Material-UI 等)。

7. 支持服务端渲染

完全兼容 Node.js 环境,支持 SSR(服务端渲染)。

总结

TypeScript 原生 enum 虽然简单,但在实际开发中存在诸多局限性,尤其是在构建复杂企业应用时。enum-plus 作为一个轻量级增强库,在保持完全兼容原生语法的同时,解决了这些痛点,并提供了更多实用功能。

无论是枚举与 UI 集成、国际化支持、还是自定义扩展,enum-plus 都提供了简洁而强大的解决方案。通过减少样板代码和提高类型安全,它可以显著提升开发效率和代码质量。

如果你正在使用 TypeScript 开发前端应用,尤其是那些有复杂枚举需求的项目,强烈推荐尝试 enum-plus,体验它带来的便利。

源码与文档

了解更多或开始使用 enum-plus,请访问其 GitHub 仓库: github.com/shijistar/e…


希望这篇文章能帮助你了解 enum-plus 及其优势。

如果你喜欢这个项目,欢迎在 GitHub 上给项目点个 Star (⭐),可以让更多开发者发现它!

如果有任何问题或建议,欢迎在 GitHub 上提出 issue 或贡献代码。

Cursor工作流探索第三篇:Project Rules 团队项目应用实践

作者 咚咚咚ddd
2025年5月19日 09:57

User Rules 配置

全局适用于 Cursor 环境。在设置中定义并始终应用。

## 通用规则

*   Always respond in 中文
*   Please reply in a concise style. Avoid unnecessary repetition
    or filler language.

## 代码输出要求

*   每次修改后自行验证,检查运行时报错
*   及时解决所有编译和运行时警告

Project Rules 配置

项目规则存在于 中.cursor/rules。mdc文件格式。您可以使用路径模式来限定规则的范围,手动调用,或根据相关性进行添加。

  • Project Rules作用:
    • 对代码库的特定领域知识进行编码
    • 自动化特定于项目的工作流程或模板
    • 标准化风格或架构决策

Rules 结构

规则类型 描述
Always 始终包含在模型上下文中
Auto Attached 当引用与 glob 模式匹配的文件时包含
Agent Requested 规则可供AI使用,由AI决定是否纳入。必须提供描述
Manual 仅在明确提及使用时才包含@ruleName

image_322.png

Rules示例:

@service-template.ts当规则被触发时,引用的文件将作为附加上下文包含在内

description: RPC Service boilerplate
globs:
alwaysApply: false
------------------

*   Use our internal RPC pattern when defining services
*   Always use snake\_case for service names.

@service-template.ts

配置方法

设置,rules中选择add new rules,会自动穿件.cursor/rules文件

image_338.png

生成rules

可以使用命令直接在对话中生成规则/Generate Cursor Rules; 会结合当前对话窗上下文生成响应多份rules可供选择和使用。

image_343.png

Project Rules实践

Cursor Rules配置社区推荐:

开发规范 Rules

  • 作用:根据各个项目技术栈规范结合团队规范,约束代码习惯。
  • cursor自建github仓库测试github.com/HaiDong-Onc…

代码模板 Rules

作用:前端页面模板,样式结构模板约束

## 此规则定义了 React 组件的结构:

### React 组件应该遵循以下布局:

- 组件作为命名导出
    @component-react-template.tsx

自动化工作流

作用:将一些日常开发工作中重复的工作流实现自动化,例如:

  • 创建命令打包发版工作流:
    • web端切master,拉代码,打包,打包成功发钉钉通知提示;
    • 小程序端自动打包,自动上传指定小程序平台,返回版本号+平台名+版本说明+打包人名获取git name并通知到指定钉钉群
  • git提交工作流:自动拉去代码提交代码,涉及到冲突提示手动merge
  • 写页面工作流:新建页面到指定位置,询问指定目录,创建路由,引入组件到指定页,页面开发,还原度自测,优化,检查报错,解决报错和异常,输出页面等待人工检查。设置模板文件夹,创建页面文件模板。
  • 页面测试工作流:调用MCP工具,检查页面性能,提出优化建议,手动确定方案后,执行优化方案并复测,性能对比上一次输出报告

web和H5打包自动化

## 规则描述

此规则可自动执行应用程序项目启动、打包测试环境、打包发布工作流程:

## 当我要求打线上包时:

按顺序执行以下命令

*   检查有无未提交代码变更文件,
      需要让开发者手动确认是否继续执行
*   git checkout master
*   git pull
*   npm run build
*   从控制台获取日志
*   获取到打包成功或失败状态给出提醒

## 当我要求打测试包时:

按顺序执行以下命令

*   如要求打包到测试1,执行npm run test1
*   如要求打包到测试2,执行npm run test2
*   打包其他命令以此类推
*   打包后检查控制台是否有报错信息
*   如果有报错信息则给出修改方案

## 当我要求启动项目时

*   react项目执行: npm run start
*   vue项目执行: npm run dev

## 此规则有助于从代码生成文档:

### 通过以下方式帮助我起草文档:

*   提取代码注释
*   分析 代码逻辑
*   在项目外层markdown文件夹下创建并生成 Markdown 文档

uniapp小程序打包自动化

## 规则描述

此规则可自动执行 uniapp 小程序打包发布工作流程

## 当我要求打包小程序线上环境时:

按顺序执行以下命令

*   检查有无未提交代码
*   如果有未提交代码,展示变更文件,
      需要让开发者手动确认是否继续执行
*   git checkout master
*   git pull
*   要求打包支付宝执行:npm run build:mp-alipay
*   要求打包微信执行:npm run build:mp-weixin
*   要求打包头条或抖音执行:npm run build:mp-toutiao
*   检查terminal是否有报错
*   获取到打包成功或失败状态给出提醒

## 当我要求打包小程序测试时:

按顺序执行以下命令

*   要求打包支付宝执行:npm run build:mp-alipay

*   要求打包微信执行:npm run build:mp-weixin

*   要求打包头条或抖音执行:npm run build:mp-toutiao

*   检查terminal是否有报错

*   如果有报错信息则给出修改方案检查

rules文件命名规范

来源于cursor社区:
经过反复尝试,发现这个组织系统效果最好。使用三位数字格式,并按照如下规则进行分组:

## 说明

*   核心规则:001-099
*   积分规则:100-199
*   模式/角色规则:200-299

## 例子:

### 核心规则:

*   “001-核心-安全.mdc”
*   “015-核心-日志记录.mdc”

### 积分规则:

*   “100-API-集成.mdc”
*   “110-CLI-Handler.mdc”

### 模式/角色规则:

*   “200-文件-模式-规则.mdc”
*   “210-数据-验证.mdc”

rules效果测试

使用rules工作流打包支付宝线上包:

image_456.png

image_458.png

Cursor Rules Idea:

项目级rules管理协同

  • 每个项目创建.cursor/rules文件,并设置初始rules,前端代码规范文档,项目返回,当前框架规范(如:vue规范),提交该文件供所有人使用和更新。(完成测试)
  • 根据rules命名规范,整理一套rules公共仓库: 创建github cursor rules项目迭代更新。(完成创建和部分rules提交)
  • 测试实践:cursor rules 自建github仓库地址:github.com/HaiDong-Onc…

建立项目公共库文档

  • 思路:建立项目级的markdown文件夹,通过统一的rules约束文档规范,每完成公共函数,公共类,公共组件的开发后,自动生成对应的使用说明和案例。之后的开发中cursor自动优先阅读文档调用已有方法,或者在已有组件基础上迭代扩展新功能,避免重复造轮子。
  • 示例:在util.js, public.js, common下完成的组件等,自动生成markdown/md文档;然后后续开发中如果涉及到公共函数,公共组件封装,先要求cursor去public文档下查找有没有同类组件或方法,优先复用已有方法和组件。手动开发或者也可以先检索参考公共文档优先复用再开发。

前端自动化测试

  • 提测前要求cursor回溯测试:代码规范,代码质量,可能存在的隐患。然后根据cursor提出的修改意见逐行确认。rules依据:项目中的project rules。(测试可行)
  • 单元测试:对于复杂模块,输入模块主要功能描述,要求cursor生成jest断言,主动执行单元测试,返回测试报告;根据测试报告反馈,手动决策是否继续优化功能模块。rules依据:编写单元测试工作流rules;(测试可行)
  • 前端功能测试:对于简单功能逻辑测试,利用浏览器MCP工具,描述测试流程,提供测试数据,cursor主动执行功能测试(如:样式自动截图测试还原度,表单输入,表单提交,点击,滚动等单个平台功能实测)。(测试可行,但浏览器mcp工具操作浏览器过程较慢)

Three.js-硬要自学系列27 (关键帧动画、动画播放、解析外部模型动画、模拟机械拆装动画、变形动画、骨骼动画)

2025年5月19日 09:38

本章主要学习知识点

  • 了解什么是关键帧动画
  • 掌握动画的各种控制方法
  • 学会如何解析并播放外部模型自带的动画

关键帧动画

关键帧动画就像给 3D 模型制作“动态剧本”——通过在时间轴上设定关键动作点,让模型自动完成从起点到终点的平滑过渡。

在时间轴上选择几个关键时间点,定义模型在这些时刻的状态(如位置、颜色、旋转角度等)。例如:

  • 第 0 秒:模型在原点,颜色为红色
  • 第 3 秒:模型移动到 (10,0,0),颜色变为蓝色

Three.js会根据关键帧之间的状态自动计算中间过渡效果,我们无需手动逐帧设置

下面是一个简单的示例

先创建关键帧轨道

const times = [0,1,3,5];
const values = [0,0,0, 10,0,0, 0,0,10, 0,0,0];
const keyframeTrack = new THREE.KeyframeTrack(
    'cube.position', // 属性名称
    times, // 时间序列
    values, // 属性值序列
);
// 创建一个颜色关键帧轨道
const colorKeyframeTrack = new THREE.ColorKeyframeTrack(
    'cube.material.color', // 属性名称
    [0.5,2,4], // 时间序列
    [0.2,1,0.4, 0.3,0.2,0.5, 0.1,0.4,0.8], // 属性值序列
);

然后,我们需要用到AnimationClip创建动画剪辑, 将多个关键帧轨道打包成一个动画片段

const clip = new THREE.AnimationClip(
    'Action', // 动画名称
    5, // 动画持续时间
    [keyframeTrack,colorKeyframeTrack] // 关键帧轨道数组
);

创建动画混合器(AnimationMixer),这相当于动画播放器,控制动画的播放逻辑

const mixer = new THREE.AnimationMixer(cube);

最后播放动画

const action = mixer.clipAction(clip);
// 播放动画
action.play();

不要忘记在循环动画函数中更新动画混合器,推进动画时间轴

const frameT = clock.getDelta();
mixer.update(frameT);

231.gif

动画播放控制

Three.js 提供了stop停止,play播放,paused暂停,timeScale调速等方法来控制播放

在页面上添加一个面板,实现上述功能看看

stopBtn.addEventListener('click', () => {
    action.stop();
})
playBtn.addEventListener('click', () => {
    action.play();
})
pauseBtn.addEventListener('click', () => {
    if(action.paused){
        action.paused = false;
        pauseBtn.innerText = 'PAUSE';
    } else {
        action.paused = true;
        pauseBtn.innerText = 'CONTINUE';
    }
})
speedBtn.addEventListener('click', () => {
    action.timeScale = 2;
})

1.gif

逐帧播放

通过设置动画的time来实现逐帧播放,这很简单

const next = document.getElementById('next');
next.addEventListener('click', () => {
    action.time += 0.1;
})

效果如下

23.gif

解析外部模型动画

要解析外模模型动画,必须保证模型自带动画 , 我们导入模型,打印naimations便可查看模型是否自带动画

loader.load('model/animated_butterflies/scene.gltf', function (gltf) {
    scene.add(gltf.scene);
    console.log(gltf.naimations);
    //包含关键帧动画的模型作为参数创建一个播放器
    const mixer = new THREE.AnimationMixer(gltf.scene);
    //获取gltf.animations[0]的第一个clip动画对象
    const action = mixer.clipAction(gltf.animations[0]);
    action.play()

    const clock = new THREE.Clock();
    function loop() {
        const delta = clock.getDelta();
        mixer.update(delta);
        requestAnimationFrame(loop);
    }
    loop()

}, undefined, function (error) {
    console.error(error);
});

image.png

效果如下

3.gif

模拟机械拆装动画

加载自带动画的模型,通过gui可视化修改参数查看效果

const duration = action.getClip().duration;
const gui = new GUI();
gui.add(action, 'paused').name('暂停');
gui.add(action, 'play').name('播放');
gui.add(action, 'stop').name('停止');

gui.add(action, 'clampWhenFinished').name('结束是否停止');
gui.add(action, 'repetitions', 0, 10).name('重复次数');
gui.add(action, 'timeScale', 0, 10).name('时间缩放');
gui.add(action, 'time',0,duration).step(0.01).name('拖动');   

4.gif

变形动画

变形动画(Morph Animation)是一种通过改变模型顶点位置实现形状过渡的技术,类似于“魔法面团”被捏成不同造型的效果。

变形原理

  • 顶点变形
    每个 3D 模型由顶点(Vertex)构成,变形动画通过预定义多组顶点坐标(称为变形目标),在不同目标之间平滑过渡。例如:

    • 人脸模型定义「笑」和「哭」两组顶点数据
    • 通过权重控制从「哭」到「笑」的渐变效果
  • 权重混合
    每个变形目标有一个权重值(0到1),控制其对最终形状的影响。例如:

    • 权重 0:完全保持原始形状
    • 权重 1:完全变为目标形状
    • 权重 0.5:原始和目标各占一半

定义原始几何体和目标几何体

const geometry = new THREE.BoxGeometry( 1, 1, 1 );
const target1 = new THREE.BoxGeometry( 1, 2, 1 ).attributes.position;
const target2 = new THREE.BoxGeometry( 0.2, 1, 0.2 ).attributes.position;
geometry.morphAttributes.position = [target1, target2]; // 变形目标数组

创建模型并设置权重

const material = new THREE.MeshBasicMaterial( { color: 'deeppink' } );
cube = new THREE.Mesh( geometry, material );
cube.morphTargetInfluences[ 0 ] = 0.5;  // 变形目标权重
cube.morphTargetInfluences[ 1 ] = 0.5;

生成变形动画

cube.name = 'Box';
const KF1 = new THREE.KeyframeTrack('Box.morphTargetInfluences[0]', [0, 1], [0, 1]);
const KF2 = new THREE.KeyframeTrack('Box.morphTargetInfluences[1]', [0, 3], [0, 1]);
const clip = new THREE.AnimationClip('t', 3, [KF1, KF2]);

const mixer = new THREE.AnimationMixer(cube);
const action = mixer.clipAction(clip);
action.play();
action.loop = THREE.LoopOnce;
action.clampWhenFinished = true; 

45.gif

骨骼操作

在three.js 中 通过new THREE.Bone()创建骨骼对象,

const Bone1 = new THREE.Bone(); // 创建骨骼Bone1
const Bone2 = new THREE.Bone(); // 创建骨骼Bone2
const Bone3 = new THREE.Bone(); // 创建骨骼Bone3
// 设置骨骼的父子关系
Bone1.add(Bone2);
Bone2.add(Bone3);
// 设置骨骼的初始位置
Bone2.position.y = 5;
Bone3.position.y = 2;
Bone1.position.set(5,0,5);

// 骨骼关节旋转
Bone1.rotateX(Math.PI / 4);
Bone2.rotateX(Math.PI / 4);

const group = new THREE.Group();
group.add(Bone1);

接下来我们添加gui进行参数控制

const gui = new GUI();
gui.add(Bone1.rotation, 'x', 0, Math.PI / 3 ).name('关节1');
gui.add(Bone2.rotation, 'x', 0, Math.PI / 3 ).name('关节2');

234.gif

骨骼动画

先看效果

3123.gif

这是一个全骨骼控制案例,细致到手指头,下面是代码

const loader = new GLTFLoader();
loader.load('model/bone_boi/scene.gltf', gltf => {
    scene.add(gltf.scene);
    // 创建一个骨骼助手,用于显示骨骼动画
    const skeletonHelper = new THREE.SkeletonHelper(gltf.scene);
    scene.add(skeletonHelper);
    let boneArr = [];
    const gui = new GUI();
    gltf.scene.traverse((child) => {
        if(child.isMesh){
            console.log(child.skeleton);
            console.log(child.skeleton.bones);
            boneArr = child.skeleton.bones;
            for(let i = 0; i < boneArr.length; i++){
                gui.add(boneArr[i].rotation, 'x').min(-10).max(10).step(0.01).name(boneArr[i].name + 'x');
                gui.add(boneArr[i].rotation, 'y').min(-10).max(10).step(0.01).name(boneArr[i].name + 'x');
                gui.add(boneArr[i].rotation, 'z').min(-10).max(10).step(0.01).name(boneArr[i].name + 'x');
            }             
        }
    })
    // 将模型设置在原点
    const boundingBox = new THREE.Box3().setFromObject(gltf.scene);
    const center = boundingBox.getCenter(new THREE.Vector3());
    gltf.scene.position.x += (gltf.scene.position.x - center.x);
    gltf.scene.position.y += (gltf.scene.position.y - center.y);
    gltf.scene.position.z += (gltf.scene.position.z - center.z);
})

以上案例均可在案例中心查看体验

THREE 案例中心

image.png

可视化编辑器(schema设计)

作者 AKclown
2025年5月19日 09:14

前言

在上一篇动态表单设计文章详细讲解了动态表单的设计、具体的数据轮流转。接下来讲解一下我的问卷可视化编辑器的schema是如何设计的。

Schema 拆分设计

首先来看看动态表单设计的定义schema 55e4d959dd4e40ba86dabee01433d154~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.jpg 属于大包大揽的设计,这种设计随着编辑器功能变多,schema会变得非常臃肿庞大会导致:

  1. 整个项目加载变慢
  2. 修改维护变得异常困难
  3. 组件之间schema变动相互影响

针对如上问题,我针对编辑器的schema设计进行了优化,让schema像乐高积木一样模块化、可复用、按需求加载

1. 按组件拆分schema

components/SO(单选)
├── config.js      # 组件schema定义
├── config.vue     # 配置区域视图组件
├── index.js       # 物料列表schema定义 (图片、组件名称、分类等)
└── index.vue      # 编辑器区域视图组件

SO的config.js定义单选按钮组件属性、MSS的config.js定义矩阵多选子题组件属性。其他更多组件以此类推,实现了每个组件之间的模块化、可扩展性 image.png

2. 公共部分抽离

把重复的公用的属性定义到PublicConfigClass中。 image.png 在其他schema中只需要通过extend继承这个PublicConfigClass基类既可以。我们还可以在当前组件覆盖掉基类属性值,例如version=1.1.0表示使用基类默认的version=1.0.0 image.png 实现复用性以及可扩展性,,假如想额外的定义样式的公共属性只需要在声明PulicStylesConfig即可。

3. 物料列表schema与编辑器schema分离

动态表单设计会把物料列表的schema耦合到编辑器schema中,例如物料的图片地址、类别等是不需要在渲染引擎体现的,因此进行了拆分 image.pngimage.png

4. 动态加载组件以及配置信息(按需加载)

// 双击物料组件将组件添加到编辑区
const dblclickHandle = async (item) => {
  // 动态注册组件
  componentInstall(item.viewKey, fetchViewComponent(item));
  componentInstall(item.conKey, fetchConfigComponent(item));
  // 创建新组建
  const newComponent = await createComponent(item);
  ...
};

动态的加载schema文件

// $ 配置信息缓存,提升组件加载速度
const componentCache = new Map();
/** 获取config.js文件 */
const loadConfig = (packageName, categoryName, keyName) => {
    const key = packageName + categoryName + keyName
    if (!componentCache.has(key)) {
        componentCache.set(key, import(`./components/${packageName}/${categoryName}/${keyName}/config.js`))
    }
    return componentCache.get(key)
}

/** 获取目标的配置信息 */
export const createComponent = async (targetData) => {
    const { category, key } = targetData

    const config = await loadConfig(targetData.package, category, key)
    return new config.default()
}

动态加载物料的组件(视图/配置)

// 遍历获取所有题库的组件
const configModules = import.meta.glob('./components/**/config.vue', { eager: true });
const indexModules = import.meta.glob('./components/**/index.vue', { eager: true });

/**
 * 获取组件
 * @param {string} name 
 * @param {boolean} isView 
 */
const fetchComponent = (name, isView) => {
    // 判断是加载的是config还是index组件
    const module = isView ? indexModules : configModules
    for (const key in module) {
        const urlSplit = key.split('/')
        const componentName = urlSplit[urlSplit.length - 2]
        // 找到对应组件的文件
        if (componentName === name) {
            return module[key]
        }
    }
}

/** 获取展示组件 */
export const fetchViewComponent = (item) => {
    const { key } = item
    return fetchComponent(key, true).default
}

/** 获取配置组件 */
export const fetchConfigComponent = (item) => {
    const { key } = item
    return fetchComponent(key, false).default
}
  • 总结
    经过如上四个步骤优化,使得编辑器具备
  1. 加载更快: 只加载当前需要的Schema
  2. 更好维护性: 修改单选组件定义不会影响到其他组件定义
  3. 更易扩展: 新增组件只需要添加一组config.jsconfig.vueindex.jsindex.vue即可
  4. class定义schema优点: 使得schema支持继承和组合、new实例不影响模版schema数据等

schema体积优化

在针对复杂的组卷时,其会包含很多题目组件会导致整张试卷的schema过于庞大,而Schema拆分设计来提升了可维护性加载效率以及可扩展性,但没有真正的减少schema的体积,下面是我对schema的一些体积优化考量

  1. 极致简化属性名
// 优化前(32字节)
{
  "fontSize": "16px",
  "fontWeight": "bold"
}

// 优化后(18字节,减少43%)
{
  "fs": "16px",  // fontSize → fs
  "fw": "bold"   // fontWeight → fw
}
  1. 枚举值数字化
// 优化前(48字节)
{
  "alignment": ["left", "center", "right"],
  "size": ["small", "medium", "large"]
}

// 优化后(26字节,减少45%)
{
  "align": [0, 1, 2],  // 0=left, 1=center, 2=right
  "sz": [1, 2, 3]      // 1=small, 2=medium, 3=large
}
  1. 结构扁平化
// 优化前(多层嵌套)
{
  "style": {
    "text": {
      "color": "#333",
      "size": "14px"
    }
  }
}

// 优化后(扁平结构)
{
  "textColor": "#333",  // style.text.color → textColor
  "textSize": "14px"    // style.text.size → textSize
}
  1. 使用数组代替对象
// 优化前(每个对象有键名)
{
  "items": [
    { "id": 1, "type": "text" },
    { "id": 2, "type": "image" }
  ]
}

// 优化后(固定顺序的数组)
{
  "items": [
    [1, "text"],   // [id, type]
    [2, "image"]
  ]
}
  1. schema字典化
// 建立字典(服务端与客户端共享)
const DICT = [
  'fontSize', 'fontWeight', 'color', 
  'width', 'height', 'text'
];

// 传输时用索引代替键名
{
  "0": "16px",  // 0=fontSize
  "1": "bold"   // 1=fontWeight
}

这些方式都是根据失去可读性来实现缩减schema体积大小的方式。(如果你有更好的方案也可以分享给我哦,谢谢)

schema中的version

在单个组件的schema定义中会存在version版本属性 image.png 该属性主要用于确定加载当前组件的具体版本,因为我们的组件会被迭代升级,下一次schema的改动可能会不兼容当前的schema,因此为了不影响此前已经构建好的组件数据,引入了version的理念动态加载远程不同版本的物料组件,实现多版本共存 image.png

总结

通过上面的步骤,我们对schema拆分设计如何优化schema体积以及引入schema中version理念有了认知,此次设计到的代码源码,后期计划会进行编辑器出码编辑器的可插拔设计,希望这篇文章对大家有所帮助也祝大家万事如意

初识 Vue 3 之 `computed` 的用法详解

2025年5月19日 08:37

初识 Vue 3 之 computed 的用法详解

在 Vue 3 中,computed 是构建响应式界面的核心工具之一,它允许我们基于已有数据动态计算出新的值,同时具备高效的缓存机制。本文将从基础概念到实际应用,逐步解析 computed 的用法,并通过丰富的 JavaScript 示例帮助你掌握这一重要特性。


一、初识 computed

1. 什么是 computed

computed 是 Vue 提供的计算属性,用于根据依赖的数据动态生成新的值。它的核心特点:

  • 缓存性:仅当依赖的数据发生变化时,才会重新计算。
  • 简洁性:无需手动管理依赖关系,Vue 会自动追踪。
  • 响应式:计算结果会随着依赖数据的变化自动更新。

2. 为什么需要 computed

假设有一个需求:根据用户的姓名和年龄生成问候语。如果直接在模板中拼接字符串,代码会重复且难以维护;而使用 computed 可以集中管理逻辑,提升代码可读性和性能。


二、基础用法:选项式 API

1. 基本语法

在 Vue 2 风格的选项式 API 中,computed 定义在 computed 属性下,每个计算属性是一个函数:

const app = Vue.createApp({
  data() {
    return {
      firstName: 'John',
      lastName: 'Doe',
      age: 30
    };
  },
  computed: {
    fullName() {
      return this.firstName + ' ' + this.lastName;
    },
    isAdult() {
      return this.age >= 18;
    }
  }
});
app.mount('#app');

2. 在模板中使用

<div id="app">
  <p>Full Name: {{ fullName }}</p>
  <p>Is Adult: {{ isAdult }}</p>
</div>

3. 缓存机制验证

修改 firstNamelastName 时,fullName 会重新计算;但修改其他无关数据(如 age)时,fullName 不会重新计算。


三、组合式 API 中的 computed

1. 基本语法

在 Vue 3 的组合式 API 中,computed 需要通过 import { computed } from 'vue' 引入:

import { ref, computed } from 'vue';

export default {
  setup() {
    const firstName = ref('John');
    const lastName = ref('Doe');
    const age = ref(30);

    // 定义计算属性
    const fullName = computed(() => firstName.value + ' ' + lastName.value);
    const isAdult = computed(() => age.value >= 18);

    return { firstName, lastName, age, fullName, isAdult };
  }
};

2. 优势对比

  • 更灵活:可以轻松与其他组合式 API(如 ref, reactive)结合。
  • this:直接操作局部变量,避免上下文混淆。

四、进阶特性

1. 依赖多个响应式数据

import { ref, computed } from 'vue';

export default {
  setup() {
    const price = ref(100);
    const quantity = ref(2);
    const discount = ref(0.1); // 10% 折扣

    // 计算总价:价格 × 数量 × (1 - 折扣)
    const total = computed(() => price.value * quantity.value * (1 - discount.value));

    return { price, quantity, discount, total };
  }
};

2. 嵌套计算属性

const app = Vue.createApp({
  data() {
    return { a: 1, b: 2, c: 3 };
  },
  computed: {
    sum() { return this.a + this.b + this.c; },
    doubleSum() { return this.sum * 2; } // 依赖 sum 的计算结果
  }
});

3. 与 watch 结合使用

import { ref, computed, watch } from 'vue';

export default {
  setup() {
    const count = ref(0);
    const doubleCount = computed(() => count.value * 2);

    // 监听 doubleCount 的变化
    watch(doubleCount, (newVal) => {
      console.log('Double Count:', newVal);
    });

    return { count, doubleCount };
  }
};

五、实际应用场景

场景 1:表单实时验证

import { ref, computed } from 'vue';

export default {
  setup() {
    const username = ref('');
    const password = ref('');

    // 验证用户名和密码是否符合规则
    const isValid = computed(() => {
      return username.value.length > 3 && password.value.length >= 6;
    });

    return { username, password, isValid };
  }
};

场景 2:动态表格过滤

import { ref, computed } from 'vue';

export default {
  setup() {
    const items = ref([
      { id: 1, name: 'Apple', category: 'Fruit' },
      { id: 2, name: 'Carrot', category: 'Vegetable' }
    ]);
    const filter = ref('');

    // 根据关键词过滤数据
    const filteredItems = computed(() => {
      return items.value.filter(item => item.name.includes(filter.value));
    });

    return { filter, filteredItems };
  }
};

六、常见问题与注意事项

1. 避免在 computed 中修改依赖数据

// 错误示例:会导致无限循环
const count = ref(0);
const doubleCount = computed(() => {
  count.value += 1; // 修改依赖数据
  return count.value * 2;
});

2. 正确处理异步操作

computed 是同步的,如果需要处理异步逻辑,应使用 watchmethods

3. 区分 computedmethods

  • computed:基于依赖缓存结果,适合频繁访问的计算。
  • methods:每次调用都会执行,适合无缓存需求的操作。

七、总结

  • 核心价值computed 通过自动化依赖追踪和缓存,大幅提升开发效率和性能。
  • 最佳实践
    • 确保计算属性是纯函数(无副作用)。
    • 优先使用组合式 API 的 computed,提升代码灵活性。
    • 避免在计算属性中修改依赖数据。

通过本文的示例和分析,相信你已掌握 computed 的基础用法和进阶技巧。在实际项目中,合理使用 computed 能让你的代码更简洁、高效且易于维护!

ESLint配置终极指南:如何定制团队专属代码规范?

作者 Jimaks
2025年5月19日 08:20

为什么你的团队需要ESLint?

  • 开发者的噩梦:
    ✅ 代码风格不一致(空格党 vs Tab党)
    ✅ 低级错误频出(未定义变量、错误语法)
    ✅ Code Review效率低下(50%时间花在格式争论)

image.png 📊 数据说话:

问题类型 修复成本(分钟/次) 年频次(中型团队)
代码格式问题 3-5 1200+
潜在逻辑缺陷 15-30 200+

一、基础配置三部曲

1. 快速上手指南

# 现代项目必备
npm install eslint --save-dev
npx eslint --init

💡 选择配置时:

  • 新手团队:选「Answer questions about your style」
  • 成熟团队:直接继承大厂规范(推荐Airbnb配置)

2. 配置文件的三种打开方式

配置方式 适用场景 示例
注释配置 临时覆盖规则 /* eslint-disable no-console */
.eslintrc 项目级规范 JSON/JS/YAML格式配置文件
package.json 小型项目/微服务 配置在eslintConfig字段中

3. 核心规则定制技巧

规则设置的三层境界

  1. "off" → 彻底关闭规则
  2. "warn" → 开发时提醒(适合过渡期)
  3. "error" → 阻断提交(核心规范必须启用)

🔥 高频必改规则示例

rules: {
  "semi": ["error", "always"],         // 必须加分号
  "indent": ["error", 2],             // 2空格缩进
  "quotes": ["error", "single"],      // 单引号派
  "arrow-parens": ["warn", "as-needed"] // 箭头函数参数按需加括号
}

🔍 规则查找秘籍

  1. 访问 ESLint官方规则列表
  2. 使用VS Code的ESLint插件实时查看规则说明
  3. 在命令行运行 npx eslint --print-config . 查看当前配置

二、团队协作配置黑科技

🔥 历史项目迁移急救包
👉 痛点:老代码动辄上千条lint错误怎么办?

1. 渐进式改造方案

// 1. 启用核心规则(先修复致命错误)
"rules": {
  "no-debugger": "error",
  "no-alert": "error"
}

// 2. 分模块改造(按目录逐步推进)
overrides: [{
  files: ["src/modules/payment/**/*.js"],
  rules: {
    "quotes": "error",
    "semi": "error"
  }
}]

// 3. 旧文件豁免策略(.eslintignore)
legacy/**/*.js
*.min.js

📌 迁移路线图

graph TD
    A[全量扫描] --> B{错误数量>1000?}
    B -->|是| C[按目录分阶段修复]
    B -->|否| D[全量修复]
    C --> E[每周处理2个模块]
    D --> F[提交时自动修复]

2. 自动化修复五连击

招式名称 操作路径 适用场景
命令行轰炸 eslint --fix 批量处理基础语法问题
IDE实时修复 VS Code快速修复快捷键 开发时即时修正
CI/CD拦截 流水线加入lint检查 防止坏代码进入生产环境
自定义修复脚本 通过AST修改代码逻辑 处理复杂规则(如代码结构)
Prettier联合作战 先格式后lint 样式与逻辑问题分离处理

💡 实战代码示例:

// package.json
"scripts": {
  "lint": "eslint . --ext .js,.jsx",
  "lint:fix": "eslint . --ext .js,.jsx --fix",
  "precommit": "lint-staged"
}

3. Git Hooks防御体系

配置四步曲

  1. 安装husky + lint-staged
npm install husky lint-staged --save-dev
npx husky install
  1. 创建预提交钩子
npx husky add .husky/pre-commit "npx lint-staged"
  1. 配置渐进式检查
// package.json
"lint-staged": {
  "*.{js,jsx}": [
    "eslint --fix --max-warnings 0",
    "prettier --write"
  ]
}
  1. 设置零容忍模式
// 核心规则必须阻断提交
"rules": {
  "no-unused-vars": "error" // 改为error级别
}

🚨 异常处理锦囊

# 紧急情况临时绕过(慎用!)
git commit --no-verify -m "紧急热修复"

三、规范落地生存指南

🛠️ 定制规则集的黄金法则

// 团队特色配置示例(金融行业)
module.exports = {
  rules: {
    // 安全类(资金操作必须双人校验)
    "no-eval": "error",
    "no-magic-numbers": ["error", { "ignore": [0, 1] }],
    
    // 业务类(金额必须格式化为千分位)
    "custom/amount-format": ["error", {
      "ignore": ["TEST_MODE"] 
    }]
  }
}

🔧 规则优先级金字塔

graph TD
    A[安全规范] --> B[业务逻辑]
    B --> C[代码质量]
    C --> D[格式风格]

1. VS Code 终极配置方案

团队配置同步三件套

  1. 推荐扩展清单(.vscode/extensions.json)
{
  "recommendations": [
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "mrmlnc.vscode-scss"
  ]
}
  1. 统一编辑器设置(.vscode/settings.json)
{
  "editor.formatOnSave": true,
  "eslint.validate": ["javascript", "typescript"],
  "eslint.codeAction.showDocumentation": {
    "enable": true
  }
}
  1. 共享代码片段(.vscode/javascript.code-snippets)
{
  "React Component": {
    "prefix": "rc",
    "body": [
      "import PropTypes from 'prop-types'",
      "",
      "const ${1:Component} = ({ ${2:prop} }) => (",
      "  <div>${3:content}</div>",
      ")",
      "",
      "${1}.propTypes = {",
      "  ${2}: PropTypes.${4:string}",
      "}"
    ]
  }
}

2. 规范审计四维评估法

评估维度 检查工具 健康指标 整改方案
规则覆盖率 eslint-plugin-sonarjs ≥85% 新增定制规则
错误修复率 ESLint stats ≥90% 加强CI/CD卡点
规范认知度 匿名问卷 ≥75分(百分制) 组织专项培训
新人适应度 Git历史分析 首次PR通过率≥70% 优化onboarding文档

3. 规范迭代进化论

PDCA循环模型

graph LR
    P[Plan] --> D[Do]
    D --> C[Check]
    C --> A[Act]
    A --> P

季度优化示例

// Q1:解决历史债务
"no-restricted-syntax": ["error", "WithStatement"]

// Q2:提升代码质量
"complexity": ["error", { "max": 10 }]

// Q3:适配新技术栈
"react-hooks/exhaustive-deps": "error"

// Q4:业务定制规则
"custom/feature-flag-check": "error"

🎯 终极目标达成

journey
    title ESLint规范成熟度演进
    section 野蛮生长
        代码风格自由: 5: 团队
    section 基础规范
        统一格式: 3: 团队
        基础校验: 4: 团队
    section 质量管控
        复杂度控制: 4: 团队
        安全规则: 3: 团队
    section 业务驱动
        定制规则: 5: 团队
        自动化治理: 4: 团队

💡 规范管理三大心法

  1. 灰度发布:新规则先设置warn级别,观察两周后再升级
  2. 数据驱动:定期分析lint错误类型分布(饼图示例)
  3. 柔性执法:对新人前三次PR只警告不阻断

📌 附:规范健康度看板示例

指标项 当前值 趋势 健康阈值
规则覆盖率 88% ↑3% ≥85%
错误修复率 92% ≥90%
平均修复时间 1.2h ↓0.3h ≤2h

💬 终章思考
当技术债清理进度与业务需求冲突时,
你会选择「阶段性妥协」还是「技术优先」?
这个抉择将定义团队的技术价值观!




🌟 让技术经验流动起来

▌▍▎▏ 你的每个互动都在为技术社区蓄能 ▏▎▍▌
点赞 → 让优质经验被更多人看见
📥 收藏 → 构建你的专属知识库
🔄 转发 → 与技术伙伴共享避坑指南

点赞 ➕ 收藏 ➕ 转发,助力更多小伙伴一起成长!💪

💌 深度连接
点击 「头像」→「+关注」
每周解锁:
🔥 一线架构实录 | 💡 故障排查手册 | 🚀 效能提升秘籍

【升级打怪实录】uniapp 打包为 apk,自定义导航栏自适应状态栏高度

2025年5月19日 07:33

需求

uniapp 内嵌 webview 加载 h5,最终打包为 apk,顶部导航栏由 h5 内部实现。

uniapp 打包为 apk 内部加载 webview 自定义状态栏.png

问题

不同手机状态栏高度不一致,自定义的导航栏顶部需要预留出手机状态栏的高度。

实现

在 uniapp 中做兼容处理。

<view class="content">
  <web-view src="https://xxxx.com/"></web-view>
</view>

0. uniapp pages.json 中设置自定义导航栏

    "navigationStyle": "custom"
  1. 获取 webview

    // 获取 webview
    getWebview() {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const currentWebview = this.$scope.$getAppWebview();
          const webview = currentWebview.children()[0];
          if (webview) {
            resolve(webview)
          } else {
            reject('获取webview失败')
          }
        }, 100)
      })
    }
    

    为什么不采用 plus.webview.currentWebview() 获取 webview?

    因为该方法获取到的 webview 是 uniapp 框架内部自带的 webview,这个 webview 负责运行 UniApp 的框架逻辑(如生命周期管理、API 调用、事件分发等),不直接渲染页面内容。

  2. 获取系统状态栏高度并设置 webview 预留该高度。

    setWebviewTop(webview) {
      return new Promise((resolve, reject) => {
        // 获取系统状态栏高度
        const statusBarHeight = uni.getSystemInfoSync().statusBarHeight;
        const querys = uni.createSelectorQuery().in(this);    
        querys.select('.content').boundingClientRect(res => {
          console.log('res:', res);
          if (res) {
            const height = res.height - statusBarHeight;
            // 设置 WebView
            webview.setStyle({
                top: statusBarHeight,
            });
            resolve('设置成功')
          } else {
            reject('设置失败')
          }
        }).exec();
      })
    }
    
  3. 方法调用

    onReady() {
      // #ifdef APP-PLUS
      this.getWebview().then(webview => {
        this.setWebviewTop(webview).then(() => {
            console.log('设置成功')
          }).catch(err => {
            console.log('设置失败: ', err)
          })
      })
      // #endif
    }
    

完整 demo

🦀 Rust工程师养成记 Day2-掌握Rust基础语法

作者 哈希茶馆
2025年5月19日 07:24

Rust简介

虽然大家来学 Rust 的应该都或多或少知道一些 Rust 的背景,但我还是要稍微介绍一下这门语言:

Rust 是由 Mozilla 团队打造的系统编程语言,巧妙平衡了硬件级性能控制与内存安全保障。其独特的所有权机制无需垃圾回收,成为C/C++的有力替代方案。2015年发布的1.0正式版确立稳定基础,采用六周迭代周期持续进化。该语言融合函数式编程特性,兼具底层控制能力和高级开发效率,在保证极致性能的同时显著降低编码复杂度

本节我就参照learnxinyminutes这个网站的相关Rust内容和大家一起过一下Rust的基本语法,为后面的学习做一下铺垫。

🔥 关注我的公众号「哈希茶馆」一起交流更多开发技巧

新建项目

开始肯定是利用我们上节做的模版直接一键三连🫣新建一个项目,边学边实践啦!

cargo generate EditYJ/rust-template
cd project-name
pre-commit install

基础

注释

Rust中的注释和Javascript基本一样,有单行注释,多行注释。他还有文档类型的注释,用来生成文档使用。

// 这是一个注释。行注释看起来像这样...
// 并且可以扩展到多行,像这样。

/* ...这是多行注释 */

/// 文档注释看起来像这样,并支持Markdown语法。
/// # 例如
///
/// ```
/// let five = 5
/// ```

函数 (Functions)

函数在大部分语言中都是很重要的一环,Rust中也不例外,fn关键词用来声明函数,入参和返回值都需要严格声明类型,它使用->声明返回值类型,最后的返回值可以不写return

// `i32` 是有符号 32 位整数类型(32-bit signed integers)
#[allow(dead_code)]
fn add2(x: i32, y: i32) -> i32 {
    // 隐式返回 (如果是这种返回形式需要不写分号)
    x + y
    // 声明返回需要加分号
// return x + y;
}

细心的同学可能会观察到这个函数上面还有一个#[allow(dead_code)]的标记,这个是Rust中的,宏可以对Rust中的函数和变量做一些标注,经过编译器展开后会对这些被标注的函数和变量增加一些额外的修饰代码,后面我们还会去重点了解这一特性的。

每个Rust程序都会有一个函数,那就是主函数,打开我们新建的项目src/main.rs看看主函数的摸样

不可变量和可变量(let, let mut)

Rust中的变量声明以let开头,例如:

let x: i32 = 1;

let mut mutable_x: i32 = 1;

从这两句代码我们可以看出一些Rust声明变量的特点:

  1. 第一行以let开头,代表x不可变量,作用类似于Javascript中的const
  2. 第二行以let mut开头,代表mutable_x可变量,作用类似于Javascript中的let

之所以这样设计,是因为Rust设计者想让开发者在声明变量的时候尽量声明不可变量,在有需求的时候再加上mut使其变为可变量,这样设计是为了减少程序中一些变量在不可预知的条件下被改变,这个对于一个健壮的应用程序而言是非常重要的。

类型推导

// 类型推导
let implicit_x = 1;
let implicit_f = 1.3;

大部分情况中,Rust 编译器都会推导变量类型,所以不必把类型显式写出来。如果你在vscode中装了rust-analyzer插件,推导的类型大概会显示成下面这样:

字符串类型(String, &str)

我们可以直接声明一个字符串的字面量,&str表示是这个字面量的引用类型

// 字符串字面量
let x: &str = "hello world!";

// 输出
println!("{}", x);

我们cargo run一下,运行一下这个main函数

可以看到按照预想中的样子,输出了hello world,注意代码中的println!println后面加了一个!代表他是一个宏调用,他在编译的时候会被展开成一长串代码,你现在可以当做这个某几行代码的简写,后面我们在详细讲解这个东西。

我们还可以将&str通过to_string方法转为String类型的变量,String类型的变量会被分配到上:

// 在堆上分配空间的字符串
let s: String = "hello world".to_string();

// 可以通过&符号拿到他的不可变引用
let s_slice: &str = &s;

String是一个具有所有权的类型,所有权是Rust中一个重要的概念,后面我们会详细解释一下这方面的知识,现在我们来看一个例子,先简单了解一下所有权带给你的疑惑感:

fn main() {
let s: String = "hello world".to_string();
let s2 = s;
println!("{}", s);
}

这段代码对于有编程经验的人来说再简单不过了,大部分人应该都会认为最后的结果肯定是打印出了hello world。但在Rust中,这段代码连编译都不会通过的,他会报错:

Rust编译器还贴心的为我们提供了解决方案,这边我就不多赘述了,着急的同学可以先搜一搜相关的资料,理解一下这边为什么会出错。

数组 (Vectors, arrays)

我们可以声明一个长度固定的数组,他会被放在上,[i32; 4]第二个参数代表数组的固定长度,这里这个数组的长度固定是4:

// 长度固定的数组 (array)
let four_ints: [i32; 4] = [1, 2, 3, 4];

我们还可以声明一个可变长数组Vec,他会被分配在上面,如果你要后续改变它的值,注意要加上mut,注意vec!println!一样也是一个宏调用,用来声明一个可变长数组,就像下面这段代码一样:

// 变长数组 (vector)
let mut vector: Vec<i32> = vec![1, 2, 3, 4];
vector.push(5);

当然也可以像上面的字符串一样拿到他的不可变视图slice: &[i32]

// 变长数组 (vector)
let mut vector: Vec<i32> = vec![1, 2, 3, 4];
vector.push(5);

let slice: &[i32] = &vector;
// 使用 `{:?}` 按调试样式输出
println!("{:?} {:?}", vector, slice); // [1, 2, 3, 4, 5] [1, 2, 3, 4, 5]

聪明的你一定想到了,那Vec是不是和String一样是一个拥有所有权的类型呢,答案是对的,这边我们只需先记住这一点。

元组 (Tuples)

元组是固定大小长度的一组值,可以是不同类型,他可以通过解构获取内部的值,也可以通过索引拿到某个位置的值:

// 内部可以是不同的值
let x: (i32, &str, f64) = (1, "hello", 3.4);

// 解构 `let`
let (a, b, c) = x;
println!("{} {} {}", a, b, c); // 1 hello 3.4

// 索引
println!("{}", x.1); // hello

类型

结构体(Sturct)

结构体我个人觉得很像为Typescript中的type,学过c/c++的同学对这个应该很熟悉,他在Rust中有两种形态,一种是有名字的结构体,还有一种叫匿名结构体

// 结构体(Sturct)
struct Point {
x: i32,
y: i32,
}
let origin: Point = Point { x: 0, y: 0 };

// 匿名成员结构体,又叫“元组结构体”(‘tuple struct’)
struct Point2(i32, i32);
let origin2 = Point2(0, 0);

从上面代码我们可以看到,通过结构体可以直接初始化一个对应类型的变量。

枚举(enum)

枚举在Rust下是非常强大的,在Rust下你可以将枚举理解为是多个不同类型的一个集合,其他语言中的枚举大部分只是状态的集合,类似于Rust下面这样的写法:

enum Direction {
Left,
Right,
Up,
Down,
}
let up = Direction::Up;

这段代码大家如果有编程基础的话应该都很熟悉,他就是枚举了上下左右四个状态,但是Rust的强大之处在于它还可以枚举不同的类型:

enum OptionalI32 {
AnI32(i32),
Nothing,
}

let two: OptionalI32 = OptionalI32::AnI32(2);
let nothing = OptionalI32::Nothing;

现在你可能还看不出这种方式的强大之处,这种用法在Rust构建的应用程序中非常常见,使用率是很高的,后面我们遇到了再详细体会一下这个形式的好处。

泛型 (Generics)

熟悉JavaTypeScript的同学对于泛型一定不陌生,听说Golang也加入的对泛型的支持,泛型对于我们的同类型行为代码的抽取是很重要的,类似大部分语言,Rust的泛型也是写在<T>内的,支持多个:

struct Foo<T> { bar: T }

// 这个在标准库里面有实现,叫 `Option`
enum Optional<T> {
SomeVal(T),
NoVal,
}

// 方法 (Methods) //
impl<T> Foo<T> {
// 方法需要一个显式的 `self` 参数
fn get_bar(self) -> T {
self.bar
}
}
let a_foo = Foo { bar: 1 };
println!("{}", a_foo.get_bar()); // 1

上面这段代码展示了几种泛型可以放的位置,这里出现了一个新的关键字implimpl这段代码的作用类似于像Java,Javascript中的类,他在向类中添加方法。这里他对于Foo结构体添加了一个get_bar的方法,这个方法接受的参数selfself代表Foo的实例对象,可以看到后面两行代码做了使用示例。

接口(Trait)

trait乍一看你可能觉得很陌生,其实不然,他在其他的编程语言里面很常见,Java和Typescript中叫他interface,这么一说,你是不是恍然大悟了。其实接口的作用就是对一个数据结构所能做的行为做定义。

trait Frobnicate<T> {
fn frobnicate(self) -> Option<T>;
}
impl<T> Frobnicate<T> for Foo<T> {
fn frobnicate(self) -> Option<T> {
Some(self.bar)
}
}

let another_foo = Foo { bar: 1 };
println!("{:?}", another_foo.frobnicate()); // Some(1)

上面的代码中trait Frobnicate定义了一个方法frobnicate。使用impl...for...关键字对Foo做了trait Frobnicate的实现,最后Foo的实例another_foo就可以调用frobnicate方法了。

模式匹配 (Pattern matching)

Rust中的模式匹配要比其他语言强大不少,先看看他和上面的枚举结合是怎么使用的:

enum OptionalI32 {
AnI32(i32),
Nothing,
}

let foo = OptionalI32::AnI32(1);
match foo {
OptionalI32::AnI32(n) => println!("it’s an i32: {}", n),
OptionalI32::Nothing  => println!("it’s nothing!"),
}

这种用法,想象大家已经看到这个枚举和模式匹配结合的厉害了,他们居然可以通过对类型的匹配,提取内部的值做处理,反正我在JavaScript中没见过这种搞法,js中只能对普通的值做匹配。

Rust中的模式匹配还可以精细到对对象内部的值做匹配,并且还可以添加一些逻辑,大家看下面这段代码:

struct FooBar { x: i32, y: OptionalI32 }
let bar = FooBar { x: 15, y: OptionalI32::AnI32(32) };

match bar {
FooBar { x: 0, y: OptionalI32::AnI32(0) } =>
println!("The numbers are zero!"),
FooBar { x: n, y: OptionalI32::AnI32(m) } if n == m =>
println!("The numbers are the same"),
FooBar { x: n, y: OptionalI32::AnI32(m) } =>
println!("Different numbers: {} {}", n, m),
FooBar { x: _, y: OptionalI32::Nothing } =>
println!("The second number is Nothing!"),
}

可以看到match的第二个匹配还加入了对n和m判断的匹配。

看的仔细的小伙伴可能发现了,这些match代码段中居然没有break的身影,那是因为match在Rust中的设计是自动跳出的,一旦某个分支匹配成功并执行完毕,整个match表达式就会结束,不会继续检查后续分支。哈哈!这个特性倒是我喜欢的,相信大家在平常开发中总是或多或少会被这个break坑过。

流程控制 (Control flow)

最后我们说一说Rust中的流程控制

for 循环

简单的for循环我们可以这样写

let array = [1, 2, 3];
for i in array {
println!("{}", i);
}
// 输出:
// 1
// 2
// 3

还可以对一个区间做遍历

for i in 0u32..10 {
print!("{} ", i);
}
println!("");
// 输出 `0 1 2 3 4 5 6 7 8 9 `

0u32..10是一个范围表达式,表示从09(不包括10)的整数序列。0u32表示一个无符号32位整数(u32类型),值为0..10表示范围的上限是10,但不包括10。所以最后输出的最大值只是到了9。

if

if在Rust中也有特殊用法,我们先看看他的一般用法:

if 1 == 1 {
println!("Maths is working!");
} else {
println!("Oh no...");
}

上述代码在我们的眼中应该没什么亮点,但是Rust的if是可以当表达式的,他可以这样使用:

// `if` 可以当表达式
let value = if true {
"good"
} else {
"bad"
};

这个就可以通过条件判断对value赋值。

while循环

while循环和其他语言中应该差别不大,Rust中还提供了loop关键字,专门处理无限循环的情况

// `while` 循环
while 1 == 1 {
println!("The universe is operating normally.");
}
// 无限循环
loop {
println!("Hello!");
}

总结

今天我们一起过了一下Rust中的基本语法,如果大家对着文档敲了一遍上述的代码的话,相信你对Rust已经有了一个初步的了解,大家如果有什么问题可以留言给我进一步交流哦!

下节预告

下节我们将用Rust做一个简单的猜价格的小例子,巩固一下今天学的基本语法,现在感觉不错的同学可以乘胜追击,自己尝试实现看看!

🔥 关注我的公众号「哈希茶馆」一起交流更多开发技巧

JS迭代的魔力:让普通对象也拥有数组般的解构能力

2025年5月19日 06:42

一道解构的题目 ✨

在 JavaScript 中,我们经常使用数组的解构赋值来方便地提取元素:

const arr = [3, 4];
const [a, b] = arr;
console.log(a, b); // 3 4

这种简洁的语法背后,是数组内置的迭代器(Iterator)在起作用。一个对象如果拥有 [Symbol.iterator] 这个属性,并且该属性的值是一个返回迭代器对象的函数,那么它就是可迭代的(Iterable)。数组正是如此。

然而,对于普通的对象,例如 { a: 3, b: 4 },直接进行数组解构赋值是会报错的:

var [a, b] = {
  a: 3,
  b: 4,
};
console.log(a, b); // 报错 TypeError: {(intermediate value)(intermediate value)} is not iterable

错误信息清晰地告诉我们:这个对象不是可迭代的。 那么,有没有办法让普通对象也拥有像数组一样的迭代能力,从而实现解构赋值呢?

答案是肯定的!我们可以通过给 Object.prototype 添加一个 [Symbol.iterator] 属性来实现。

💡 让对象可迭代:改写 Object.prototype

Symbol.iterator 是一个内置的 Symbol,它作为一个属性名用于表示一个对象的默认迭代器。通过给 Object.prototype 添加这个属性,我们可以让所有普通对象都具备迭代的能力。

来看这段代码:

Object.prototype[Symbol.iterator] = function* () {
  yield* Object.values(this);
};

// 让下面的代码成立
var [a, b] = {
  a: 3,
  b: 4,
};
console.log(a, b); // 3 4

这里,我们将一个生成器函数赋值给了 Object.prototype[Symbol.iterator]

  • 生成器函数 (function*):生成器函数是一种特殊的函数,它可以通过 yield 关键字暂停执行并在后续恢复执行,并且可以多次返回(“产出”)值。生成器函数返回一个迭代器对象。
  • yield* 表达式yield* 后面可以跟一个可迭代对象(比如数组、字符串、另一个生成器),它会将可迭代对象的所有值依次“产出”。
  • Object.values(this)Object.values(this) 会返回当前对象 (this) 的所有属性值组成的一个新数组。

所以,这段代码的含义是:当我们对一个普通对象进行迭代时,实际上是在迭代该对象的所有属性值yield* Object.values(this) 就把这些属性值一个接一个地“产出”给迭代过程。

通过这样做,我们就成功地让普通对象变得可迭代了。现在,当我们对 { a: 3, b: 4 } 进行数组解构赋值 var [a, b] = { a: 3, b: 4 }; 时,JavaScript 引擎会查找该对象的 [Symbol.iterator] 属性,并调用它返回的迭代器。迭代器会依次返回 34,然后将它们分别赋值给 ab

📌 迭代器的基础回顾

为了更好地理解上面的代码,我们简单回顾一下迭代器的基础知识:

  • 可迭代对象 (Iterable):拥有 [Symbol.iterator] 属性的对象。
  • 迭代器对象 (Iterator):由可迭代对象的 [Symbol.iterator] 方法返回的对象。迭代器对象必须有一个 next() 方法。
  • next() 方法:调用迭代器的 next() 方法会返回一个包含两个属性的对象:
    • value:迭代的当前值。
    • done:一个布尔值,表示迭代是否完成。false 表示还有更多值,true 表示迭代已结束。

以数组为例:

const arr = [3, 4];
const iter = arr[Symbol.iterator](); // 获取数组的迭代器

console.log(iter.next()); // { value: 3, done: false }
console.log(iter.next()); // { value: 4, done: false }
console.log(iter.next()); // { value: undefined, done: true }

通过 next() 方法,我们可以一步步地遍历可迭代对象中的值。

⚠️ 注意事项

虽然通过修改 Object.prototype 可以实现一些有趣的功能,但在实际开发中需要非常谨慎。直接修改内置对象的原型可能会对其他代码或第三方库产生不可预测的影响,导致难以调试的问题。

这种修改原型的方式更适合于学习和理解迭代器的工作原理,或者在非常特殊的场景下(且能确保不会产生副作用)使用。

总结

通过为 Object.prototype 添加 [Symbol.iterator] 属性,我们让普通对象具备了迭代能力,从而可以使用数组的解构赋值语法来提取属性值。这展示了迭代器在 JavaScript 中的强大作用和灵活性。虽然这种直接修改原型的方式需要谨慎使用,但理解其背后的原理对于深入掌握 JavaScript 的迭代机制至关重要。

React Hooks 的优势和使用场景

2025年5月19日 06:26

React Hooks 是 React 16.8 引入的一项革命性特性,它彻底改变了开发者编写 React 组件的方式。Hooks 的核心优势在于它提供了一种更简洁、更灵活的方式来管理组件的状态和生命周期,同时解决了类组件中的一些常见问题。

1. 代码简洁性

Hooks 使得组件的代码更加简洁和易于理解。在类组件中,状态管理和生命周期方法通常分散在不同的方法中,导致代码逻辑难以追踪。而使用 Hooks,开发者可以将相关的逻辑集中在一起,使得代码更加模块化和可维护。

// 类组件
class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
  }

  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

// 函数组件 + Hooks
function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

2. 逻辑复用

在类组件中,复用逻辑通常需要使用高阶组件(HOC)或渲染属性(Render Props),这些模式虽然有效,但会导致组件树变得复杂和难以理解。Hooks 提供了一种更直接的方式来复用逻辑,通过自定义 Hook,开发者可以将组件逻辑提取到可重用的函数中。

// 自定义 Hook
function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

// 使用自定义 Hook
function Example() {
  const [count, setCount] = useState(0);
  useDocumentTitle(`You clicked ${count} times`);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

3. 更好的性能优化

Hooks 提供了更细粒度的控制,使得性能优化更加容易。例如,useEffect 允许开发者指定依赖项,只有当这些依赖项发生变化时,才会执行副作用。这避免了不必要的渲染和计算,从而提高了应用的性能。

function Example({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]); // 仅在 userId 变化时执行

  if (!user) {
    return <div>Loading...</div>;
  }

  return <div>{user.name}</div>;
}

4. 更直观的状态管理

在类组件中,状态管理通常需要使用 this.setState,这可能会导致状态更新逻辑变得复杂。Hooks 提供了 useStateuseReducer,使得状态管理更加直观和灵活。

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>
        Click me
      </button>
    </div>
  );
}

5. 更灵活的生命周期管理

Hooks 提供了 useEffect 来替代类组件中的生命周期方法,如 componentDidMountcomponentDidUpdatecomponentWillUnmountuseEffect 允许开发者在函数组件中执行副作用,并且可以更灵活地控制这些副作用的执行时机。

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 相当于 componentDidMount 和 componentDidUpdate
    console.log('Component mounted or updated');

    return () => {
      // 相当于 componentWillUnmount
      console.log('Component will unmount');
    };
  }, [count]); // 仅在 count 变化时执行

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

6. 更好的 TypeScript 支持

Hooks 与 TypeScript 的结合更加自然,因为函数组件更容易进行类型推断和类型检查。这使得在 TypeScript 项目中使用 Hooks 更加方便和安全。

interface User {
  id: number;
  name: string;
}

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  if (!user) {
    return <div>Loading...</div>;
  }

  return <div>{user.name}</div>;
}

7. 更少的样板代码

Hooks 减少了类组件中的样板代码,如构造函数、this 绑定等。这使得开发者可以更专注于业务逻辑,而不是繁琐的语法。

// 类组件
class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={this.handleClick}>
          Click me
        </button>
      </div>
    );
  }
}

// 函数组件 + Hooks
function Example() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

8. 更易于测试

Hooks 使得组件的测试更加容易,因为函数组件更容易进行单元测试。开发者可以单独测试每个 Hook,而不需要模拟整个组件实例。

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount(count + 1);
  return { count, increment };
}

// 测试
test('useCounter', () => {
  const { result } = renderHook(() => useCounter());
  act(() => {
    result.current.increment();
  });
  expect(result.current.count).toBe(1);
});

9. 更符合函数式编程思想

Hooks 鼓励开发者使用函数式编程的思想来编写组件,这使得代码更加纯粹和可预测。函数式编程的不可变性和纯函数特性有助于减少副作用和 bug 的产生。

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

10. 更广泛的应用场景

Hooks 不仅适用于状态管理,还可以用于处理副作用、上下文、引用等。这使得 Hooks 可以应用于各种复杂的场景,如表单处理、动画、数据获取等。

function Form() {
  const [name, setName] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`Hello, ${name}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

总结

React Hooks 提供了一种更现代、更简洁的方式来编写 React 组件。它解决了类组件中的许多问题,如代码冗余、逻辑复用困难、性能优化复杂等。通过 Hooks,开发者可以更专注于业务逻辑,编写出更高效、更易维护的代码。无论是新项目还是现有项目,Hooks 都是一个值得尝试和深入学习的特性。

❌
❌