
上篇我们把豆瓣读书Top250的原始数据抓下来了CSV一打开几百行数据看着确实有成就感。但如果你真拿着这份CSV去做Java后端和可视化马上就会发现一个尴尬的问题评分字段是带引号的文本评价人数后面还带着“人评价”出版信息和作者挤在一栏里拆都拆不开甚至同一个书名出现了两次。这不是爬虫没爬好而是从网页到结构化系统之间本来就有一道绕不开的工序——数据清洗。这一篇是这个系列的第二部分。我们就顺着“爬虫抓到数据之后怎么办”这条线把剩余的半条链路完整打通用pandas把CSV清洗成规整表格建MySQL表把数据存进去再用Spring Boot写一个标准的分页查询API最后在前端用Echarts把250本书变成一眼就能看懂的柱状图、饼图和散点图。完整跑通之后你手里就有一套从数据采集到数据展示的全栈小项目无论是课程设计还是面试演示都是能直接上讲台的东西。1. 先把后半段链路画清楚从CSV到看板的数据流转1.1 全文数据流梳理我一直觉得做项目最忌讳的就是一上来就写代码。先想清楚数据是怎么一步步从源头流到展示层的写起代码来才不慌。这个项目的完整链路是这样豆瓣读书Top250页面 ↓ 爬虫采集第一篇已完成 原始CSV含标题、信息行、评分、评价人数、引言、链接 ↓ pandas 数据清洗 干净的结构化表格作者、出版社、出版日期、价格等字段全部拆出 ↓ SQLAlchemy / pymysql 批量写入 MySQL数据库t_book表带唯一索引防重 ↓ Spring Boot MyBatis-Plus REST API接口支持分页、排序 ↓ 浏览器 fetch 请求 Echarts图表评分柱状图、年份分布饼图、热度散点图这个设计里各层之间通过“契约”衔接爬虫产出CSV清洗后产出标准字段的表格数据库表结构跟清洗结果对齐Java实体类跟表对齐前端字段名跟接口返回值对齐。每一层都不需要关心上一层内部怎么实现的只要遵守好字段约定就行。这也是为什么这套技术栈能顺畅组合在一起的原因。很多人在做类似项目时把精力全放在爬虫和图表上中间的清洗和存储环节草草了事。结果就是前端图表老是取不到数据或者后端查询报字段不存在最后只能回头反复改。我建议你把每一层的输出都单独存一份看一下确认没问题再往下一层走这样排查问题时思路会清晰很多。1.2 技术选型为什么这么定技术栈选型上有人可能会问Python抓完数据为什么不直接用Flask写后端还要引入Java或者说用了Spring Boot为什么还要用Echarts而不是Vue全家桶这里有几个实际原因。先说Python和Java分工的问题。Python在数据清洗这块确实方便pandas处理这种半结构化文本数据比Java要顺手不少代码量至少少一半。但后端接口和数据库操作这块Spring Boot生态太成熟了分页、事务、统一返回、跨域这些都要比Flask开箱即用而且课程设计和企业项目的技术栈更接近。所以这个项目里Python管数据治理Java管接口服务前后端分离各干各的。再说Echarts选择。Echarts是百度开源的可视化库配置简单图表种类多文档中文资料多遇到问题几乎都能搜到答案。如果直接上Vue DataV那种可视化大屏框架对没接触过前端工程化的读者来说学习成本一下子就上去了。用Echarts只需要一个HTML文件、一个script标签引入、几行配置就能出图最大化降低门槛。一套项目做下来爬虫拿到数据、清洗整理、入库、后端提供接口、前端可视化五个环节都亲自走了一遍技术栈又覆盖了Python、SQL、Java、JavaScript这四门主流方向性价比非常高。这是这个标题背后项目的最大价值。2. 数据清洗把爬下来的“毛坯数据”处理成能入库的样子2.1 豆瓣读书Top250原始数据最常见的几种问题如果你运行过第一篇的爬虫打开CSV后大概率会看到类似这样的原始记录字段原始值示例title《活着》\n或 百年孤独 info余华 / 作家出版社 / 2012-8 / 28.00元rating9.4\n或9.4分rating_count345603人评价quote人是为了活着本身而活着。或空值linkhttps://book.douban.com/subject/1082154/归纳下来主要问题就是这几类第一字符串带换行、空格尤其书名和评分这些字段直接入库会导致后续精确匹配和排序出问题。第二info字段把作者、译者、出版社、出版日期、价格全部挤在一行里需要用分隔符拆分。第三评分看起来是小数实际是文本类型而且格式不统一。第四评价人数带着中文单位需要提取纯数字。第五部分记录没有价格有些书的info只有三到四段不能硬套固定下标。第六存在重复记录有的是同一本书的多个版本有的是爬虫重跑产生的。这些在数据治理里叫“脏数据”如果不洗后面所有环节都会跟着出错。你可能会想“我自己看着CSV知道哪行是评分后端不也能用吗”但数据库和图表没有“人眼智能”它们需要的是严格规整、类型正确的数据。2.2 pandas清洗的完整代码与逐行说明这块直接给出可复制的代码。前提是你已经装好了pandas没装就pip install pandas。import re import pandas as pd # 读取第一篇爬下来的CSV注意实际文件名可能不同 df pd.read_csv(douban_top250_raw.csv, encodingutf-8-sig) # 1. 清洗书名去掉换行、前后空格、多余空白 df[title] df[title].astype(str).str.replace(r[\r\n\s], , regexTrue).str.strip() # 2. 清洗评分先去掉“分”字和空白再转成数值类型 df[rating] df[rating].astype(str).str.strip().str.replace(分, , regexTrue) df[rating] pd.to_numeric(df[rating], errorscoerce) # 3. 提取评价人数中的数字 df[rating_count] df[rating_count].astype(str).str.extract(r(\d))[0] df[rating_count] pd.to_numeric(df[rating_count], errorscoerce).astype(Int64) # 4. 拆分信息行作者 / 出版社 / 出版日期 / 价格 def split_info(info_str): if not isinstance(info_str, str): return None, None, None, None parts [p.strip() for p in re.split(r[/], info_str)] parts [p for p in parts if p ! ] if not parts: return None, None, None, None # 最后一段带“元”才当价格处理 price None if 元 in parts[-1]: price_raw parts.pop() price_match re.search(r(\d(?:\.\d)?), price_raw) price float(price_match.group(1)) if price_match else None # 正常结构作者 / 出版社 / 出版日期 if len(parts) 3: pub_date parts.pop() publisher parts.pop() author / .join(parts) elif len(parts) 2: pub_date parts.pop() publisher parts.pop() author None elif len(parts) 1: author parts[0] publisher None pub_date None else: author publisher pub_date None return author, publisher, pub_date, price df[[author, publisher, pub_date, price]] df[info].apply( lambda x: pd.Series(split_info(x)) ) # 5. 去掉没有书名的无效行 df df.dropna(subset[title]) # 6. 按书名作者去重保留第一条 df df.drop_duplicates(subset[title, author], keepfirst) # 7. 只保留需要入库的字段 df df[[title, author, publisher, pub_date, price, rating, rating_count, quote, link]] # 8. 输出最终清洗结果 df.to_csv(douban_top250_clean.csv, indexFalse, encodingutf-8-sig) print(清洗后数据条数:, len(df)) print(df.head())这段代码里有几个容易出错的地方我单独说一下。re.split(r[/], info_str)同时兼容了半角和全角斜杠因为豆瓣页面上两种都出现过。price用正则从“38.5元”这类字符串里提取数字而不是直接强转因为文本里可能混有其他符号。去重的subset是书名作者因为豆瓣上同名的书每年会出多个版本只用书名去重会把不同版本误删。astype(Int64)是pandas里的可空整数类型如果评价人数缺失是NaN转普通int会直接报错Int64可以容忍缺失值。2.3 清洗质量的验证手段清洗完别急着入库先做几个简单检查确认数据质量过关。print(总条数:, len(df)) print(评分为空:, df[rating].isna().sum()) print(评价人数为空:, df[rating_count].isna().sum()) print(作者为空:, df[author].isna().sum()) print(重复数:, df.duplicated(subset[title, author]).sum()) print(价格为空:, df[price].isna().sum()) print(评分范围:, df[rating].min(), -, df[rating].max())正常来说Top250的评分应该是8.0到9.7之间如果出现低于7的八成是清洗逻辑写歪了。另外还要目测一下拆出来的出版社和出版日期是不是正确比如“作家出版社”有没有拆进pub_date里。我自己做的时候就遇到过拆分后把出版社和日期调换的情况因为某些书的info行没有价格导致下标整体偏移。这个验证步骤很多人会跳过我建议还是保留。原因很简单数据一旦入库后面出了问题你要回来排查的代价比在这里多花两分钟大得多。清洗这个环节慢就是快。3. 数据库建模与幂等入库别让脏数据第二次进来3.1 t_book表结构设计与字段类型为什么这样选数据库这一步选型上直接上MySQL就好。免费、占内存小、课程设计环境里基本都能用关键是Spring Boot对MySQL的支持最成熟资料最多。建库建表的SQL如下CREATE DATABASE IF NOT EXISTS douban_books DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE douban_books; CREATE TABLE IF NOT EXISTS t_book ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT 主键, title VARCHAR(255) NOT NULL COMMENT 书名, author VARCHAR(500) DEFAULT NULL COMMENT 作者, publisher VARCHAR(255) DEFAULT NULL COMMENT 出版社, pub_date VARCHAR(50) DEFAULT NULL COMMENT 出版日期, price DECIMAL(8, 2) DEFAULT NULL COMMENT 定价, rating DECIMAL(3, 1) DEFAULT NULL COMMENT 豆瓣评分, rating_count INT DEFAULT NULL COMMENT 评价人数, quote VARCHAR(1000) DEFAULT NULL COMMENT 一句话简介, link VARCHAR(500) DEFAULT NULL COMMENT 详情页链接, created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, UNIQUE KEY uk_title_author (title, author) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT豆瓣读书Top250图书表;每个字段的类型选择都是有讲究的说一下我认为最关键的三处。price为什么用DECIMAL而不是FLOAT因为浮点类型在换算时会有精度损失比如38.5这种数值在二进制里表示不精确存进去再读出来可能变成38.50000001。价格这种对精度敏感的字段DECIMAL是标准答案。pub_date为什么用VARCHAR(50)而不是DATE因为豆瓣上日期格式太杂了有“2007-6”有“2012-8-1”还有只写到年份的。用DATE类型的话这类字符串根本插不进去还得专门写一套格式转换规则纯属给自己加戏。直接用字符串存排序时虽然不能按日期排但这个项目里根本用不到日期排序没必要较真。author为什么给到VARCHAR(500)因为豆瓣的作者字段经常是“余华”这种单作者但也会出现“阿西莫夫 / 葉李華”这种作者加译者的组合一长串下来字符数不少。给500的长度是留足余量的避免后期清理数据时因为长度不够被截断。唯一索引uk_title_author是整个表设计的核心。它的作用是在数据库层面挡住重复数据就算Python那边忘了去重甚至脚本重复跑了十遍同一本书也没办法插两次。很多课程设计项目不建这个索引跑一次爬虫就插一遍数据最后表里好几条一模一样的记录前端图表数据都是虚高的。3.2 Python写MySQL的两种高效方式数据清洗完之后下一步就是入库。Python写MySQL最常见的方式是pymysql手动执行SQL或者用SQLAlchemy的to_sql方法。我推荐优先用to_sql因为代码量少不用手写INSERT语句。先安装依赖pip install sqlalchemy pymysql然后直接调用from sqlalchemy import create_engine engine create_engine( mysqlpymysql://root:你的密码localhost:3306/douban_books?charsetutf8mb4 ) df df.astype(object).where(pd.notnull(df), None) df df.rename(columns{pub_date: pub_date}) df.to_sql( namet_book, conengine, if_existsappend, indexFalse, methodmulti ) print(入库完成)这里有几个关键点必须注意。if_existsappend表示追加写入不会覆盖原有表如果你改来改去要重新导入建议先执行一次DROP TABLE或者手动把表清空。methodmulti会把多条记录拼成一条批量INSERT语句性能比默认的一行一行插入快得多。250条数据不算多但这是一种好习惯爬虫项目扩展到几万条时才不会等得太痛苦。还有个坑DataFrame里的NaN字段不能直接插入数据库MySQL会报错。所以我特意用了astype(object).where(pd.notnull(df), None)这行代码把NaN统一转成Python的None这样to_sql才能正确写入NULL值。如果不想用SQLAlchemy用pymysql直接拼一个批量执行也可以import pymysql conn pymysql.connect( hostlocalhost, userroot, password你的密码, databasedouban_books, charsetutf8mb4 ) cursor conn.cursor() sql ( INSERT INTO t_book (title, author, publisher, pub_date, price, rating, rating_count, quote, link) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE titleVALUES(title) ) rows list(df.itertuples(indexFalse, nameNone)) # 将NaN统一变成None rows [tuple(None if pd.isna(v) else v for v in row) for row in rows] cursor.executemany(sql, rows) conn.commit() cursor.close() conn.close() print(入库完成)这个方案的好处是可以直接用ON DUPLICATE KEY UPDATE进行“有则更新、无则插入”配合唯一索引即使重复爬到了某一本书也不会报错只会静默跳过。第一次做项目的新手可能完全没意识到重跑脚本会导致数据翻倍这里的防御逻辑就是给未来的自己省事。3.3 重复执行、重复爬取时的防重策略防重这件事Python和数据库两头都要做。Python端在清洗阶段就已经用drop_duplicates把重复条目去掉了。但爬虫脚本后续很可能重跑或者你手动补抓了某几本书这时候如果不去重INSERT还是会带上重复数据。所以数据库端那层唯一索引才是真正的兜底防线。当唯一索引遇到重复时插入行为有几种选项。直接用INSERT IGNORE是静默跳过ON DUPLICATE KEY UPDATE是冲突时更新部分字段。如果你希望每次重跑脚本都把最新评分更新进去就选后者如果你希望第一次爬的数据保持不变就选前者。我在教学项目里一般推荐ON DUPLICATE KEY UPDATE理由是Top250榜单本身每天在变评分和评价人数是动态的保留最新数据更有价值。入库之后最好做个查询验证一下数据量SELECT COUNT(*) FROM t_book; SELECT title, rating, rating_count FROM t_book ORDER BY rating_count DESC LIMIT 10;如果数量对得上也没有明显的空值异常数据库这层就算彻底通了。4. Java后端接口Spring Boot从数据库到REST API4.1 项目搭建与依赖配置数据落库之后动手写后端。项目搭建我建议用IDEA的Spring Initializr直接生成。版本选型上如果你本机是JDK 8就选Spring Boot 2.7.x不要手滑选了3.x。Spring Boot 3.x强制要求JDK 17如果你还在用学校机房或者公司老电脑的JDK 8跑起来会直接报错。如果你本机已经装了JDK 17那直接用3.x也没问题API上差别不大。pom.xml里这几组依赖就够用了dependencies !-- Web 支持内置Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus 简化CRUD -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- Lombok 简化实体类代码 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies然后写application.yml这里面的配置项有几个坑是新手专属server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/douban_books?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: autoserverTimezoneAsia/Shanghai必须加MySQL 8.0之后如果不指定时区连接时会报一个时区相关的异常。characterEncodingutf8是防止中文乱码的关键Java这边读出来的中文只要这个参数没配很可能变成一堆问号。map-underscore-to-camel-case把数据库的pub_date自动映射到Java的pubDate这个配置能省掉一大堆TableField注解。4.2 实体类到Controller的四层代码按Spring Boot标准分层走先写实体类package com.example.douban.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; Data TableName(t_book) public class Book { TableId(type IdType.AUTO) private Integer id; private String title; private String author; private String publisher; private String pubDate; private BigDecimal price; private BigDecimal rating; private Integer ratingCount; private String quote; private String link; }这里TableName(t_book)指定表名TableId(type IdType.AUTO)表示主键自增。Data是Lombok注解自动生成getter/setter。如果你不熟悉Lombok也可以自己手写那一堆getter/setter效果一样就是代码啰嗦一点。然后是Mapper接口MyBatis-Plus的BaseMapper已经内置了常用的增删改查方法你只需要把泛型指定成Bookpackage com.example.douban.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.douban.entity.Book; import org.apache.ibatis.annotations.Mapper; Mapper public interface BookMapper extends BaseMapperBook { }接着是Service层这里主要做一个带排序的分页查询package com.example.douban.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.douban.entity.Book; import com.example.douban.mapper.BookMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; Service public class BookService { Autowired private BookMapper bookMapper; public PageBook pageBooks(int page, int size, String sort) { PageBook p new Page(page, size); QueryWrapperBook wrapper new QueryWrapper(); if (rating.equals(sort)) { wrapper.orderByDesc(rating); } else if (ratingCount.equals(sort)) { wrapper.orderByDesc(rating_count); } else { wrapper.orderByDesc(rating_count); } return bookMapper.selectPage(p, wrapper); } }排序这里我专门用数据库列名rating_count而不是Java属性名ratingCount因为QueryWrapper拼SQL时如果配置没配对很容易报“column not found”。直接写列的物理名虽然看着不够“面向对象”但最不容易出错。Controller层就是标准的REST接口package com.example.douban.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.douban.entity.Book; import com.example.douban.service.BookService; import com.example.douban.common.Result; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/books) public class BookController { Autowired private BookService bookService; GetMapping public ResultPageBook list( RequestParam(defaultValue 1) int page, RequestParam(defaultValue 20) int size, RequestParam(defaultValue ratingCount) String sort) { return Result.success(bookService.pageBooks(page, size, sort)); } }统一返回值的Result类很简单package com.example.douban.common; import lombok.Data; Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } }为什么每个接口都要包一层code/message/data因为前端拿到响应之后可以先判断code是不是200再决定要不要渲染数据这样后端出了异常也能以JSON形式返回给前端而不是直接返回一个Tomcat默认的错误页。课程设计里这种统一返回风格也是加分项。4.3 跨域和中文乱码这些绕不开的问题后端跑起来之后前端页面如果和后端不在同一个域名和端口下浏览器默认会拦截跨域请求。最常见的现象是你在浏览器直接访问http://localhost:8080/api/books能拿到数据但页面里fetch就报CORS错误。解决跨域最简单的方式是在Controller类上加注解CrossOrigin(origins *)如果你想正式一点可以写一个全局配置类package com.example.douban.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS); } }实际操作中课程设计阶段用CrossOrigin就够了少写一个类少一个配置。如果你后面想把前端部署到Nginx那跨域问题就交给Nginx反代去解决后端这个放开配置可以删掉。中文乱码这块我前面已经加了不少保险数据库和表用了utf8mb4JDBC连接串加了characterEncodingutf8pandas写库时连接串也带charsetutf8mb4。但如果你拿到数据后还是乱码可以用命令行先跑一条select title from t_book limit 5确认数据本身有没有问题。如果命令行正常、页面乱码那就是前端HTML的编码问题去前端解决。5. Echarts可视化让250本书变成一张能看懂看板5.1 图表选型按数据类型选图到了最出效果的环节。Echarts的图表类型很多但不是所有图表都适合这份数据。我根据项目里几个典型问题选了四种图。评分分布适合柱状图。横轴放书名纵轴放评分一眼能看出哪些书分数最高。评价人数的排名也适合柱状图或者排行式的条形图。年份分布适合饼图。把出版年份分段1990年以前、1990-1999、2000-2009、2010-2019、2020年以后每段一个扇形能直观看出豆瓣高分书在不同年代的分布。评分和评价人数的关系适合散点图。横轴放评价人数纵轴放评分能发现哪些书是“口碑和热度双高”。图书榜单本身就用一个HTML表格展示点击可以在Echarts图之间联动。先给出一个通用的页面骨架!DOCTYPE html html langzh-CN head meta charsetUTF-8 title豆瓣读书Top250数据看板/title script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script style .chart { width: 100%; height: 500px; margin-bottom: 40px; border: 1px solid #eee; } /style /head body div idrateChart classchart/div div idyearChart classchart/div div idscatterChart classchart/div table idbookTable border1 cellspacing0 cellpadding6 stylewidth:100%;border-collapse:collapse; thead tr th书名/thth作者/thth评分/thth评价人数/th /tr /thead tbody/tbody /table script // 图表代码写在这里 /script /body /html这里三个.chart容器都设置了固定高度500px。Echarts有一个很特殊的特性容器如果没有宽度或高度初始化的时候不会报错但图表渲染出来是空的。很多新手遇到“图表不显示”第一个想到的是数据有问题其实90%的情况是CSS没给容器高度。5.2 从后端接口到图表的完整接入先写一个fetch函数把Spring Boot接口的数据拉下来async function loadBooks() { const res await fetch(http://localhost:8080/api/books?page1size250sortratingCount); if (!res.ok) { console.error(接口请求失败:, res.status); return []; } const json await res.json(); if (json.code ! 200) { console.error(接口返回异常:, json.message); return []; } return json.data.records; }页面加载后调用let allBooks []; window.addEventListener(DOMContentLoaded, async () { allBooks await loadBooks(); if (allBooks.length 0) { document.getElementById(bookTable).insertAdjacentHTML(beforeend, trtd colspan4暂无数据/td/tr); return; } renderRatingBar(allBooks); renderYearPie(allBooks); renderScatter(allBooks); renderTable(allBooks); });评分柱状图我选了评价人数Top20的书做横向条形图这样书名能完整显示function renderRatingBar(books) { const chart echarts.init(document.getElementById(rateChart)); const top20 [...books] .sort((a, b) b.ratingCount - a.ratingCount) .slice(0, 20); const names top20.map(b b.title); const counts top20.map(b b.ratingCount); chart.setOption({ title: { text: 评价人数TOP20, left: center }, tooltip: { trigger: axis, axisPointer: { type: shadow } }, grid: { left: 200, right: 40, top: 60, bottom: 40 }, xAxis: { type: value }, yAxis: { type: category, data: names, inverse: true, axisLabel: { width: 180, overflow: truncate } }, series: [{ name: 评价人数, type: bar, data: counts, itemStyle: { color: #4F81BD } }] }); }这里要特别说明两个配置。inverse: true让第一名在顶部显示因为Echarts默认category轴是从下往上的不反转的话评分最高的书反而在最下面。axisLabel.width: 180配合overflow: truncate超出180像素的书名会被截断并显示省略号不然像《明朝那些事儿全集全七册》这么长的书名会把整张图撑变形。年份饼图需要在前端做一个分组聚合function renderYearPie(books) { const chart echarts.init(document.getElementById(yearChart)); const groups {}; books.forEach(b { const year parseInt((b.pubDate || ).slice(0, 4), 10); let range 未知; if (year 1990) range 1990年以前; else if (year 2000) range 1990-1999; else if (year 2010) range 2000-2009; else if (year 2020) range 2010-2019; else range 2020及以后; groups[range] (groups[range] || 0) 1; }); const pieData Object.entries(groups).map(([name, value]) ({ name, value })); chart.setOption({ title: { text: 出版年份分布, left: center }, tooltip: { trigger: item }, legend: { bottom: 0 }, series: [{ name: 出版年份, type: pie, radius: [30%, 60%], data: pieData }] }); }这里(b.pubDate || )很关键。有些书的pubDate可能为空如果不做兜底slice会直接报错整个图表都挂掉。这个饼图用了radius: [30%, 60%]的内外半径画出来是环形图视觉效果比实心饼图好而且中间位置还能额外放个文字标题。个人建议课程设计里多用环形图同样数据显得更专业。散点图用来展示评分和评价人数之间的关系function renderScatter(books) { const chart echarts.init(document.getElementById(scatterChart)); const scatterData books .filter(b b.rating ! null b.ratingCount ! null) .map(b [b.ratingCount, parseFloat(b.rating)]); chart.setOption({ title: { text: 评价人数与评分关系, left: center }, tooltip: { formatter: function (params) { const idx params.dataIndex; const book books.filter(b b.rating ! null b.ratingCount ! null)[idx]; return book ? book.title br/评价人数: book.ratingCount br/评分: book.rating : ; } }, xAxis: { name: 评价人数, type: value }, yAxis: { name: 评分, type: value, min: 8, max: 10 }, dataZoom: [{ type: inside }, { type: slider }], series: [{ type: scatter, symbolSize: 12, data: scatterData, itemStyle: { opacity: 0.6 } }] }); }这个图可以看到一个挺有意思的规律评分最高的书不一定是评价人数最多的。比如《红楼梦》评分高但有些流行书的评价人数更多。这就是数据可视化在做的事——把数据库里的数字变成让人有直观感受的图形。最后加一个简单的表格作为补充数据明细function renderTable(books) { const tbody document.querySelector(#bookTable tbody); const sorted [...books].sort((a, b) b.rating - a.rating); const html sorted.map(b tr td${b.title}/td td${b.author || -}/td td${b.rating ?? -}/td td${b.ratingCount ?? -}/td /tr ).join(); tbody.innerHTML html; }5.3 console报错与页面白屏的排查方向数据可视化这个阶段最常见的不是写不出代码而是页面打开后一片空白也不知道从哪查起。我把实际遇到的问题按排查顺序整理成一张表遇到问题可以对照着处理。现象可能原因处理方式页面白屏console报404后端没启动或接口路径写错先直接浏览器访问接口URL确认能返回JSON页面白屏console报CORS错误跨域被拦截后端加CrossOrigin或CORS配置类接口返回200但图是空的容器宽高为0或.chart没有高度检查CSS中设置明确height比如500px柱状图中文挤在一起容器宽度不够或axisLabel未设置宽grid left调大并设置axisLabel width和overflow饼图没有图例legend配置在series值缺失时失效检查pieData数组里有没有undefined值数据全是undefined实体字段名和JSON字段名对不上在console打印json.data.records和代码里引用的字段一一对照刷新很慢每次刷新请求250条全量数据数据量不大可接受如果后续加数据可考虑后端做聚合统计接口排查逻辑的核心就是“逐层验证”。先确认后端接口能用再确认fetch拿到的数据长什么样最后确认图表配置和数据字段是否匹配。不要一上来就改Echarts配置那样很容易在错误的方向上浪费时间。我之前帮人排查过一个案例图表一直渲染不出来折腾了半小时最后发现是CSS里.chart只写了width: 100%没写height而Echarts要求容器必须要有确定的高度。这个坑极其隐蔽因为初始化不报错、fetch正常、数据也打印得出来但图表就是一个字都不显示。还有一个容易出问题的点是数据格式。Echarts的series.data对数组元素类型有要求柱状图要数字饼图要{name, value}对象。如果你直接把后端返回的字符串类型塞进去图表一样是空的。所以在setOption之前先console.log一眼data看看类型能省下很多排错时间。6. 最后补充几个提升完成度的小细节按上面五章做完项目已经是一个完整的闭环了但如果想让它在课程答辩或者面试里显得更完整我建议再加三个小功能。第一个给后端接口加一个带关键词搜索的查询参数。比如/api/books?keyword百年返回结果按书名模糊匹配。需要改的地方不多Service层QueryWrapper加一个like(title, keyword)就行。这个功能成本低但能体现你对接口设计有思考。第二个前端页面上加一个下拉框用户可以选择按评分排序还是按评价人数排序切换时重新请求后端或在前端重新sort。这能让看板从一张静态图变成一个小工具互动感强很多。第三个保存一份清洗后的CSV放到项目目录里。这个CSV就是你整个数据链路的“证据”答辩时老师问“你的数据是怎么来的”你可以拿出原始CSV、清洗脚本、清洗后的CSV一步一步演示比光靠嘴说扎实得多。我个人在做这个项目时有一个比较深的感触这套全栈链路的难点其实不在任何一个单独的技术点而在环节和环节的衔接处。数据清洗不到位数据库表设计就得跟着返工接口返回字段命名不统一前端图表就得一个字段一个字段去对。只要你在每个环节之间把数据结构定清楚、把字段约定好整个项目跑通其实是一件水到渠成的事。豆瓣读书Top250只是一个载体这套“采集-清洗-入库-接口-可视化”的思路换成招聘网站、电影榜单、电商商品统统适用。