文件上传——springboot大文件分片多线程上传功能,前端显示弹出上传进度框
一、项目搭建
-
创建 Spring Boot 项目: 创建一个新的 Spring Boot 项目,添加 Web 依赖。
-
添加依赖: 在
pom.xml
文件中添加以下依赖:
<dependency><groupId>commons-fileupload</groupId><artifactId>commons-fileupload</artifactId><version>1.4</version>
</dependency>
<dependency><groupId>commons-io</groupId><artifactId>commons-io</artifactId><version>2.11.0</version>
</dependency>
二、后端实现
-
配置 MultipartResolver: 在 Spring Boot 配置类中添加以下代码:
@Configuration
public class MyWebAppConfigurer implements WebMvcConfigurer {@Beanpublic MultipartResolver multipartResolver() {CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver();multipartResolver.setMaxUploadSize(-1); // 设置最大上传大小,-1 表示无限制return multipartResolver;}
}
-
创建 FileUploadService: 创建一个服务类,用于处理文件上传逻辑:
@Service
public class FileUploadService {private String uploadDir = "upload/"; // 设置上传目录public String initUpload(String fileName, long fileSize, int chunkSize) {// 1. 生成任务 ID (UUID)String fileId = UUID.randomUUID().toString();//这里根据实际情况考虑到断点续传的功能,同一个文件生成的标识要一样,后期可以根据这个判断文件上传进度// 2. 创建临时目录: uploadDir/fileIdFile dir = new File(uploadDir, fileId);if (!dir.exists()) {dir.mkdirs();}// 3. 返回 fileIdreturn fileId;}public String uploadChunk(String fileId, int chunkIndex, int totalChunks, MultipartFile file) throws IOException {String fileUrl="";// 1. 获取分片文件String fileName = file.getOriginalFilename();// 2. 保存分片到临时目录: uploadDir/fileId/chunkIndexFile chunkFile = new File(uploadDir, fileId + "/" + chunkIndex);file.transferTo(chunkFile);// 检查所有分片是否已上传完成if (allChunksUploaded(fileId, totalChunks)) {String fileName=datePath()+"/"+fileId;//这里可以不用文件名,如果需要用,则要在controller上传请求增加一个fileName参数// 合并分片fileUrl = mergeChunks(fileId, fileName);}// 3. 校验分片 MD5 (可选)return fileUrl;}//判断所有的分片是否都上传完毕private boolean allChunksUploaded(String fileId, int totalChunks) {for (int i = 0; i < totalChunks; i++) {File chunkFile = new File(uploadDir + fileName + ".chunk" + i);if (!chunkFile.exists()) {return false;}}return true;}public String mergeChunks(String fileId, String fileName) throws IOException {// 1. 获取所有分片文件File dir = new File(uploadDir, fileId);File[] chunkFiles = dir.listFiles();// 2. 按顺序合并分片File mergedFile = new File(uploadDir, fileName);try (FileOutputStream fos = new FileOutputStream(mergedFile, true)) {for (File chunkFile : chunkFiles) {try (FileInputStream fis = new FileInputStream(chunkFile)) {IOUtils.copy(fis, fos);}}}// 3. 删除临时目录FileUtils.deleteDirectory(dir);// 4. 校验文件 MD5 (可选)// 5. 返回文件存储路径return uploadDir + fileName;}/*** 日期路径 即年/月/日 如2018/08/08*/public static final String datePath(){Date now = new Date();return DateFormatUtils.format(now, "yyyy/MM/dd");}
}
-
创建 FileUploadController: 创建一个控制器类,用于处理文件上传请求:
要在分片上传的基础上实现断点续传,需要在服务端记录每个文件的上传进度,并在客户端请求上传时返回已上传的分片信息。
- 使用数据库或其他存储机制记录每个文件的上传进度。
- 可以使用以下信息标识一个上传任务:
- fileId: 全局唯一标识符,例如 UUID
- 同一个文件的标识是一样的,这样保证接这上次的进度继续上传。
@RestController
public class FileUploadController {@Autowiredprivate FileUploadService fileUploadService;private final ExecutorService executorService = Executors.newFixedThreadPool(5); // 线程池大小可配置private final Map<String, Set<Integer>> uploadProgress = new HashMap<>(); // 使用内存存储上传进度,实际应用中建议使用数据库@PostMapping("/upload/init")public ResponseEntity<String> initUpload(@RequestParam("fileName") String fileName,@RequestParam("fileSize") long fileSize,@RequestParam("chunkSize") int chunkSize) {String fileId = fileUploadService.initUpload(fileName, fileSize, chunkSize);return ResponseEntity.ok(fileId);}@PostMapping("/upload/chunk")public ResponseEntity<Void> uploadChunk(@RequestParam("fileId") String fileId,@RequestParam("chunkIndex") int chunkIndex, @RequestParam("totalChunks") int totalChunks,@RequestParam("file") MultipartFile file) throws IOException {String filePath = "";try {// 获取或创建上传进度记录Set<Integer> uploadedChunks = uploadProgress.computeIfAbsent(fileId, k -> new HashSet<>());// 如果分片已上传,则跳过if (uploadedChunks.contains(chunkIndex)) {return ResponseEntity.ok(new UploadResponse(uploadedChunks));}// 使用线程池处理每个分片上传executorService.execute(() -> {try {filePath = fileUploadService.uploadChunk(fileId, chunkIndex, totalChunks, file);} catch (IOException e) {// 处理异常,例如记录日志或返回错误信息e.printStackTrace();}});return ResponseEntity.status(HttpStatus.ACCEPTED).body(new UploadResponse(uploadedChunks, filePath));} catch (Exception e) {return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error uploading chunk.");}//return ResponseEntity.ok().build();}}// 用于响应上传请求,携带已上传分片信息
class UploadResponse {Set<Integer> uploadedChunks;String filePath;public UploadResponse(Set<Integer> uploadedChunks, String filePath) {this.uploadedChunks = uploadedChunks;this.filePath = filePath;}public UploadResponse(Set<Integer> uploadedChunks) {this.uploadedChunks = uploadedChunks;}// ... getter setter 方法 ...
}
三、前端实现
-
HTML 页面: 创建一个简单的 HTML 页面,包含文件选择按钮、上传进度条和相关信息展示区域。
-
JavaScript 代码: 使用 JavaScript 实现文件分割、分片上传、合并请求和上传进度展示等功能。
// 选择文件
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', (event) => {const file = event.target.files[0];// ... 文件分割、上传逻辑
});// 文件分割
const chunkSize = 4 * 1024 * 1024; // 4MB
const chunks = sliceFile(file, chunkSize);const totalChunks = Math.ceil(file.size / chunkSize);// 初始化上传
const fileId = await initUpload(file.name, file.size, chunkSize);// 并发上传分片
const uploadPromises = chunks.map((chunk, index) => uploadChunk(fileId, index, totalChunks, chunk)
);// 上传完成合并得到文件url
await Promise.all(uploadPromises);async function uploadChunk(fileId, chunkIndex, totalChunks, chunk) {// ... 创建 FormData ...const formData = new FormData();formData.append('file', chunk);formData.append('chunkIndex', chunkIndex);formData.append('totalChunks', totalChunks);formData.append('fileId', fileId); // 添加 fileId 参数const response = await fetch('/upload', {method: 'POST',body: formData,});//...处理响应数据
}function sliceFile(file, chunkSize) {let chunks = [];let count = Math.ceil(file.size / chunkSize);for (let i = 0; i < count; i++) {let offset = i * chunkSize;let chunk = file.slice(offset, offset + chunkSize + 1);chunks.push(chunk);}return chunks;
}
四、上传进度展示
-
后端: 在
FileUploadService
中添加方法,根据fileId
返回已上传分片数量或计算上传进度百分比。 -
前端: 使用
setInterval
定时请求后端获取上传进度,并更新进度条。
前端html代码使用示例:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>文件上传</title>
</head>
<body>
<h1>大文件分片上传</h1>
<input type="file" id="fileInput">
<button id="uploadBtn">上传</button>
<div>上传进度:<progress id="progressBar" value="0" max="100"></progress> <span id="progressText">0%</span></div>
<script>const fileInput = document.getElementById('fileInput');const uploadBtn = document.getElementById('uploadBtn');const progressBar = document.getElementById('progressBar');const progressText = document.getElementById('progressText');uploadBtn.addEventListener('click', uploadFile);async function uploadFile() {const file = fileInput.files[0];if (!file) {alert('请选择文件');return;}const chunkSize = 4 * 1024 * 1024; // 4MBconst fileId = await initUpload(file.name, file.size, chunkSize);const chunks = sliceFile(file, chunkSize);let uploadedChunks = 0;const uploadPromises = chunks.map((chunk, index) => {return uploadChunk(fileId, index, chunks.length, chunk).then(() => {uploadedChunks++;updateProgress(uploadedChunks / chunks.length);});});await Promise.all(uploadPromises);await mergeChunks(fileId);alert('上传完成!');}function sliceFile(file, chunkSize) {const chunks = [];let offset = 0;while (offset < file.size) {chunks.push(file.slice(offset, offset + chunkSize));offset += chunkSize;}return chunks;}async function initUpload(fileName, fileSize, chunkSize) {const response = await fetch('/upload/init', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ fileName, fileSize, chunkSize })});return await response.text();}async function uploadChunk(fileId, chunkIndex, totalChunks, chunk) {const formData = new FormData();formData.append('fileId', fileId);formData.append('chunkIndex', chunkIndex);formData.append('totalChunks', totalChunks);formData.append('file', chunk);await fetch('/upload/chunk', {method: 'POST',body: formData});}async function mergeChunks(fileId) {await fetch(`/upload/merge?fileId=${fileId}`);}function updateProgress(progress) {progressBar.value = progress * 100;progressText.textContent = Math.round(progress * 100) + '%';}
</script>
</body>
</html>
五、存储上传进度
使用 Redis 存储上传进度
-
Redis 数据结构:
- 使用 Hash 结构存储每个文件的上传进度,key 为 fileId和chunkIndex,chunkIndex 为分片索引,value 为 true 或 false,表示分片是否已上传。
-
代码实现:
- 注入
RedisTemplate
:
@Autowired
private RedisTemplate<String, String> redisTemplate;
- 修改
uploadChunk
方法:
private void uploadChunk(String fileId, int chunkIndex, int totalChunks, MultipartFile file) throws IOException {// ... 保存分片文件 ...// 更新上传进度到 RedisString chunkKey = fileId + ":" + chunkIndex;redisTemplate.opsForValue().set(chunkKey, "true");// 检查所有分片是否已上传完成if (redisTemplate.opsForHash().size(fileId) == totalChunks) {// ... 合并分片 ...// 清除上传进度redisTemplate.delete(fileId);}
}
- 添加
/upload/progress
接口:
@GetMapping("/upload/progress")
public ResponseEntity<UploadResponse> getUploadProgress(@RequestParam("identifier") String identifier
) {Set<String> uploadedChunks = redisTemplate.keys(identifier + ":*");Set<Integer> uploadedChunkIndices = uploadedChunks.stream().map(s -> Integer.parseInt(s.substring((identifier + ":").length()))).collect(Collectors.toSet());return ResponseEntity.ok(new UploadResponse(uploadedChunkIndices));
}
- 前端js使用
// ... 其他代码 ...
// 选择文件
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', (event) => {const file = event.target.files[0];// ... 文件分割、上传逻辑
});// 文件分割
const chunkSize = 4 * 1024 * 1024; // 4MB
const chunks = sliceFile(file, chunkSize);
const totalChunks = Math.ceil(file.size / chunkSize);// 初始化上传
const fileId = await initUpload(file.name, file.size, chunkSize);async function uploadFile(chunks) {// 获取已上传的分片信息const uploadedChunks = await getUploadedChunks(fileId, file.name);// ... 根据 uploadedChunks 调整分片上传逻辑 ...if(...){//获取uploadedChunks为分片的索引,表示当前文件上传的进度,根据uploadedChunks的具体数据类型去取for (let i = (uploadedChunks); i < chunks.length; i++) {//const start = i * chunkSize;//const end = Math.min(start + chunkSize, file.size);//const chunk = file.slice(start, end);const chunk = chunks[i];await uploadChunk(fileId, i, totalChunks, chunk);}}else{// 并发上传分片chunks.map((chunk, index) => uploadChunk(fileId, index, totalChunks, chunk));}
}async function getUploadedChunks(fileId, fileName) {const response = await fetch(`/upload/progress?identifier=${fileId}&fileName=${fileName}`);const data = await response.json();return data.uploadedChunks || [];
}async function uploadChunk(fileId, chunkIndex, totalChunks, chunk) {// ... 创建 FormData ...const formData = new FormData();formData.append('file', chunk);formData.append('chunkIndex', chunkIndex);formData.append('totalChunks', totalChunks);formData.append('fileId', fileId); // 添加 fileId 参数const response = await fetch('/upload', {method: 'POST',body: formData,});//...处理响应数据
}function sliceFile(file, chunkSize) {let chunks = [];let count = Math.ceil(file.size / chunkSize);for (let i = 0; i < count; i++) {let offset = i * chunkSize;let chunk = file.slice(offset, offset + chunkSize + 1);chunks.push(chunk);}return chunks;
}
重新上传获取进度:
- 用户重新选择同一个文件上传时,需要生成相同的 f
ileId
。 - 在前端上传前,调用
/upload/progress
接口,传入 fileId 获取已上传的分片信息。 - 根据返回的已上传分片信息,跳过已上传的分片,继续上传剩余分片。
六、注意事项
- 以上代码示例省略了部分细节,例如异常处理、MD5 校验等,请根据实际情况进行完善。
- 前端代码需要根据您使用的 JavaScript 框架进行调整。
- 建议您先学习 Spring Boot 文件上传、JavaScript 文件操作和 AJAX 等前端相关知识。
希望这些更详细的步骤和代码片段能够帮助您更好地理解和实现 Spring Boot 断点续传、多线程分片上传功能! 如果您还有其他问题,请随时提出。
相关文章:
文件上传——springboot大文件分片多线程上传功能,前端显示弹出上传进度框
一、项目搭建 创建 Spring Boot 项目: 创建一个新的 Spring Boot 项目,添加 Web 依赖。 添加依赖: 在 pom.xml 文件中添加以下依赖: <dependency><groupId>commons-fileupload</groupId><artifactId>commons-fileupload</artifactId&…...

每日学术速递8.2
1.A Scalable Quantum Non-local Neural Network for Image Classification 标题: 用于图像分类的可扩展量子非局部神经网络 作者: Sparsh Gupta, Debanjan Konar, Vaneet Aggarwal 文章链接:https://arxiv.org/abs/2407.18906 摘要&#x…...

SAP-PLM创建物料主数据接口
FUNCTION zplm_d_0001_mm01. *"---------------------------------------------------------------------- *"*"本地接口: *" EXPORTING *" VALUE(EX_TOTAL) TYPE CHAR4 *" VALUE(EX_SUCCESSFUL) TYPE CHAR4 *" …...

超声波眼镜清洗机哪个品牌好?四款高性能超声波清洗机测评剖析
对于追求高生活质量的用户来说,眼镜的清洁绝对不能马虎。如果不定期清洁眼镜,时间久了,镜片的缝隙中会积累大量的灰尘和细菌,眼镜靠近眼部,对眼部健康有很大影响。在这种情况下,超声波清洗机显得尤为重要。…...

卸载Windows软件的正确姿势,你做对了吗?
前言 今天有小伙伴突然问我:她把软件都卸载了,但是怎么软件都还在运行? 这个问题估计很多小伙伴都是遇到过的,对于电脑小白来说,卸载Windows软件真的真的真的是一件很难的事情。所以,今天咱们就来讲讲&am…...

WEB前端14-Element UI(学生查询表案例/模糊查询/分页查询)
Vue2-Element UI 1.可重用组件的开发 可重用组件 我们一般将可重复使用的组件放在components目录之下,以便父组件的灵活调用 <!--可重用组件一般与css密切相关,使用可重用组件的目的是,将相似的组件放在一起,方便使用-->…...

使用swiftui自定义圆形进度条实现loading
实现的代码如下: // // LoadingView.swift // SwiftBook // // Created by Song on 2024/8/2. //import SwiftUIstruct LoadingView: View {State var process 0.5var body: some View {VStack(spacing: 20) {ZStack {Circle().stroke(.gray.opacity(0.3), lin…...
C# 设计模式之抽象工厂模式
总目录 前言 工厂方法模式是为了克服简单工厂模式的缺点而设计出来的,简单工厂模式的工厂类随着产品类的增加需要增加额外的代码,而工厂方法模式每个具体工厂类只完成单个实例的创建,所以它具有很好的可扩展性。但是在现实生活中,…...

Javascript前端面试基础(八)
window.onload和$(document).ready区别 window.onload()方法是必须等到页面内包括图片的所有元素加载完毕后才能执行$(document).ready()是DOM结构绘制完毕后就执行,不必等到加载完毕 window.onload 触发时机:window.onload 事件会在整个页面…...

R 语言学习教程,从入门到精通,R的安装与环境的配置(2)
1、R的安装与环境的配置 R语言是一款完全免费且开源的软件,它的开源许可证是GNU通用公共许可证(GPL),这意味着任何人都可以自由地使用、复制、修改和发布R语言的源代码,甚至可以将其用于商业用途。 和python等其他语言…...

Python批量下载音乐功能
Python批量下载音乐功能 Python批量下载音乐,调用API接口,同时下载歌曲和歌词 先安排一下要用的模块,导入进来。 import re import json import requests目录结构 下载音乐 Awking_Class.pymusic.txt 文件文件写的是音乐名字,使用换行分割 new_music 注意这个 ne…...

用 Bytebase 实现批量、多环境、多租户数据库的丝滑变更
Bytebase 提供了多种功能来简化批量变更管理,适用于多环境或多租户情况。本教程将指导您如何使用 部署配置 和 数据库组 在不同场景下进行数据库批量变更。 默认流水线 vs 部署配置 图片数据库 vs 数据库组 1. 准备 请确保已安装 Docker,如果本地没有重…...

java之方法引用 —— ::
目录 一、简介 二、引用静态方法 1.格式 2.示例 编辑 3.条件解析 三、引用成员方法 1.格式 2.示例 四、引用构造方法 1.格式 2.示例 五、类名引用成员方法 1.格式 2.略微不同的方法引用规则 3.示例 六、引用数组的构造方法 1.格式 2.示例 一、简介 方…...

「测试线排查的一些经验-上篇」 后端工程师
文章目录 端口占用脚本失灵线上部署项目结构模版配置文件生效 一般产品研发过程所使用的环境可分为: 研发环境-dev测试环境-test生产环境-prod 软件开发中,完整测试环境包括:UT、IT、ST、UAT UT Unit Test 单元测试 IT System Integration …...
AOSP12_BatteryStats统计电池数据信息
前言 BatteryStats模块主要用于设备在电池供电是系统对各个模块电量使用的统计,Android提供的Battery Historain工具就是对此模块统计的数据进行解析和展示。 一 BatteryStats模块类图 模块主要类图如下:见根目录的模块类图 BatteryStats:抽象类,本模块的核心类,主要定…...

【Android Studio】UI 布局
文章目录 view布局LinearLayout view 在Android开发中,View是一个非常重要的概念,它是所有用户界面组件的基类。View类及其子类构成了Android应用中的用户界面。每个View都占用屏幕上的一个矩形区域,并可以响应用户输入(如触摸、按…...

虚拟机Windows server忘记密码解决方法
原理 utilman.exe是Windows辅助工具管理器程序,虽然它本身不是一个关键的系统进程,但通过修改这个文件,用户可以访问一些有用的UI设置。在某些情况下,比如忘记密码需要重置时,通过修改utilman.exe文件为c…...

【香橙派系列教程】(六)嵌入式SQLite数据库
【六】嵌入式SQLite数据库 文章目录 【六】嵌入式SQLite数据库1.简介2.SQLite数据库安装3.SQLite命令用法1.创建数据库2.创建和查看表格3.插入查看数据(记录)4.删除更改数据(记录) 4.SQLite编程操作1.打开/创建数据库的C接口2.创建…...
深入探讨PHP8的新特性与性能优化
本文由 ChatMoney团队出品 随着互联网技术的飞速发展,PHP作为后端开发领域的热门语言也在不断演进。近期,PHP8的发布引起了广泛关注。本文将为您详细介绍PHP8的新特性以及性能优化,并通过具体示例帮助您更好地理解和应用这些新特性。 一、PH…...

2024年06月 Scratch 图形化(四级)真题解析#中国电子学会#全国青少年软件编程等级考试
Scratch图形化等级考试(1~4级)全部真题・点这里 一、单选题(共10题,共30分) 第1题 运行下列程序,输入单词“PLAY”,最后角色说?( ) A:LY4AP B:AP4LY C:YA4PL D:PL4AY 答案:B 根据程序分析可知,首先获取单词字符数,然后奇数位的字母放在字符数左侧,偶数位…...

从零开始打造 OpenSTLinux 6.6 Yocto 系统(基于STM32CubeMX)(九)
设备树移植 和uboot设备树修改的内容同步到kernel将设备树stm32mp157d-stm32mp157daa1-mx.dts复制到内核源码目录下 源码修改及编译 修改arch/arm/boot/dts/st/Makefile,新增设备树编译 stm32mp157f-ev1-m4-examples.dtb \stm32mp157d-stm32mp157daa1-mx.dtb修改…...
【决胜公务员考试】求职OMG——见面课测验1
2025最新版!!!6.8截至答题,大家注意呀! 博主码字不易点个关注吧,祝期末顺利~~ 1.单选题(2分) 下列说法错误的是:( B ) A.选调生属于公务员系统 B.公务员属于事业编 C.选调生有基层锻炼的要求 D…...

ABAP设计模式之---“简单设计原则(Simple Design)”
“Simple Design”(简单设计)是软件开发中的一个重要理念,倡导以最简单的方式实现软件功能,以确保代码清晰易懂、易维护,并在项目需求变化时能够快速适应。 其核心目标是避免复杂和过度设计,遵循“让事情保…...

保姆级教程:在无网络无显卡的Windows电脑的vscode本地部署deepseek
文章目录 1 前言2 部署流程2.1 准备工作2.2 Ollama2.2.1 使用有网络的电脑下载Ollama2.2.2 安装Ollama(有网络的电脑)2.2.3 安装Ollama(无网络的电脑)2.2.4 安装验证2.2.5 修改大模型安装位置2.2.6 下载Deepseek模型 2.3 将deepse…...
【Go语言基础【13】】函数、闭包、方法
文章目录 零、概述一、函数基础1、函数基础概念2、参数传递机制3、返回值特性3.1. 多返回值3.2. 命名返回值3.3. 错误处理 二、函数类型与高阶函数1. 函数类型定义2. 高阶函数(函数作为参数、返回值) 三、匿名函数与闭包1. 匿名函数(Lambda函…...

免费PDF转图片工具
免费PDF转图片工具 一款简单易用的PDF转图片工具,可以将PDF文件快速转换为高质量PNG图片。无需安装复杂的软件,也不需要在线上传文件,保护您的隐私。 工具截图 主要特点 🚀 快速转换:本地转换,无需等待上…...

DeepSeek源码深度解析 × 华为仓颉语言编程精粹——从MoE架构到全场景开发生态
前言 在人工智能技术飞速发展的今天,深度学习与大模型技术已成为推动行业变革的核心驱动力,而高效、灵活的开发工具与编程语言则为技术创新提供了重要支撑。本书以两大前沿技术领域为核心,系统性地呈现了两部深度技术著作的精华:…...

在 Visual Studio Code 中使用驭码 CodeRider 提升开发效率:以冒泡排序为例
目录 前言1 插件安装与配置1.1 安装驭码 CodeRider1.2 初始配置建议 2 示例代码:冒泡排序3 驭码 CodeRider 功能详解3.1 功能概览3.2 代码解释功能3.3 自动注释生成3.4 逻辑修改功能3.5 单元测试自动生成3.6 代码优化建议 4 驭码的实际应用建议5 常见问题与解决建议…...
在golang中如何将已安装的依赖降级处理,比如:将 go-ansible/v2@v2.2.0 更换为 go-ansible/@v1.1.7
在 Go 项目中降级 go-ansible 从 v2.2.0 到 v1.1.7 具体步骤: 第一步: 修改 go.mod 文件 // 原 v2 版本声明 require github.com/apenella/go-ansible/v2 v2.2.0 替换为: // 改为 v…...

相关类相关的可视化图像总结
目录 一、散点图 二、气泡图 三、相关图 四、热力图 五、二维密度图 六、多模态二维密度图 七、雷达图 八、桑基图 九、总结 一、散点图 特点 通过点的位置展示两个连续变量之间的关系,可直观判断线性相关、非线性相关或无相关关系,点的分布密…...