1. 项目概述
在个人博客中添加"每日一句"功能是个提升用户体验的好方法。这个功能每天展示不同的中英文双语名言警句,既能增加网站的内容丰富度,又能给访客带来一些思考和启发。我最近在自己的博客上实现了这个功能,经过几周的运行测试,效果相当不错。
这个功能的实现涉及前后端多个技术点,包括API代理、数据缓存、响应式设计和交互优化等。最核心的是采用了双层缓存架构:服务端使用ConcurrentHashMap缓存API返回的数据,客户端使用localStorage存储当天的句子,这样能最大程度减少对第三方API的调用,提高响应速度。
2. 功能需求与技术选型
2.1 核心功能需求
在设计这个功能时,我确定了以下几个核心需求:
- 每日自动更新:句子内容需要每天自动更换,不需要人工干预
- 高可用性:即使第三方API不可用,也要有备用方案保证功能正常
- 性能优化:避免重复请求API,减少网络开销
- 响应式设计:在各种设备上都能良好显示
- 交互体验:长句子支持展开/收起,方便阅读
2.2 技术选型分析
基于上述需求,我选择了以下技术栈:
后端:Spring Boot + RestTemplate
- Spring Boot提供了快速开发REST API的能力
- RestTemplate适合简单的HTTP请求场景
前端:原生JavaScript + CSS3
- 不依赖任何框架,减少资源加载
- CSS3提供了丰富的样式和动画效果
数据源:有道词典每日一句API
- 提供高质量的中英文双语内容
- 接口相对稳定
缓存策略:
- 服务端:ConcurrentHashMap
- 客户端:localStorage
选择这些技术主要考虑以下几点:
- 轻量级,不会给博客增加太多负担
- 成熟稳定,减少维护成本
- 性能良好,响应速度快
3. 后端实现详解
3.1 API接口设计
后端API设计遵循RESTful风格,返回JSON格式数据:
@GetMapping(value = "/api/daily-quote", produces = "application/json;charset=UTF-8") @ResponseBody public Map<String, String> getDailyQuote() { // 实现逻辑... }接口返回的数据结构包含三个字段:
en:英文句子cn:中文翻译success:是否成功获取数据
这样设计的好处是:
- 统一了API响应格式
- 前端可以方便地判断请求是否成功
- 支持后续扩展更多字段
3.2 缓存策略实现
3.2.1 ConcurrentHashMap缓存
服务端使用ConcurrentHashMap作为内存缓存:
private static final ConcurrentHashMap<String, Map<String, String>> quoteCache = new ConcurrentHashMap<>(); String today = java.time.LocalDate.now().toString(); if (quoteCache.containsKey(today)) { return quoteCache.get(today); }选择ConcurrentHashMap的原因:
- 线程安全:支持多线程并发访问,适合Web应用场景
- 高性能:内存操作速度快,减少IO开销
- 简单易用:不需要额外依赖,Spring环境原生支持
缓存键使用当前日期字符串,这样每天会自动生成新的缓存条目,旧数据会被自动覆盖。
3.2.2 API请求实现
调用有道词典API的代码:
String apiUrl = "https://dict.youdao.com/infoline/style/cardList?mode=publish&client=mobile&style=daily"; HttpHeaders headers = new HttpHeaders(); headers.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); headers.add("Accept", "application/json, text/plain, */*"); headers.add("Host", "dict.youdao.com"); HttpEntity<String> requestEntity = new HttpEntity<>(headers); ResponseEntity<String> responseEntity = restTemplate.exchange( apiUrl, HttpMethod.GET, requestEntity, String.class );这里有几个关键点:
- 请求头伪装:模拟浏览器请求,避免被识别为爬虫
- 异常处理:需要对各种异常情况进行捕获和处理
- 超时设置:建议添加合理的超时时间,避免请求卡死
3.3 数据解析与处理
API返回的是JSON数组,我们需要解析第一个元素:
JsonNode rootNode = objectMapper.readTree(responseStr); if (rootNode.isArray() && rootNode.size() > 0) { JsonNode firstItem = rootNode.get(0); String en = firstItem.get("title").asText().trim(); String cn = firstItem.get("summary").asText().trim(); result.put("en", en); result.put("cn", cn); result.put("success", "true"); quoteCache.put(today, result); }解析时需要注意:
- 检查JSON结构是否符合预期
- 对文本内容进行trim处理,去除多余空格
- 处理可能的null值情况
3.4 降级策略实现
为了保证功能的高可用性,必须实现降级策略:
} catch (Exception e) { log.error("获取每日一句失败", e); } // 所有方案都失败时返回备用数据 result.put("en", "The best time to plant a tree was 10 years ago. The second best time is now."); result.put("cn", "种一棵树最好的时间是十年前,其次是现在。"); result.put("success", "false");降级策略的设计要点:
- 捕获所有可能的异常
- 记录错误日志便于排查问题
- 提供有意义的默认内容
- 通过success字段告知前端这是降级数据
4. 前端实现详解
4.1 HTML结构设计
前端HTML结构保持简洁:
<div class="daily-quote" id="daily-quote"> <div class="daily-quote-content"> <div class="daily-quote-en" id="quote-en">Loading...</div> <div class="daily-quote-cn" id="quote-cn">正在获取每日一句...</div> </div> </div>设计考虑:
- 使用语义化的class命名
- 提供有意义的加载状态提示
- 保持结构简单,便于样式控制
4.2 CSS样式实现
4.2.1 基础样式
.daily-quote { display: flex; flex-direction: column; justify-content: center; padding: 16px 20px; height: 80px; width: 360px; min-width: 360px; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 16px; border: 1px solid #bae6fd; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(14, 165, 233, 0.06); position: relative; overflow: hidden; } /* 引号装饰 */ .daily-quote::before { content: '"'; position: absolute; top: 8px; left: 12px; font-size: 3rem; color: rgba(14, 165, 233, 0.15); font-family: Georgia, serif; line-height: 1; pointer-events: none; }样式设计要点:
- 使用渐变色背景提升视觉效果
- 添加微妙的阴影和边框增强层次感
- 使用伪元素添加装饰性引号
- 设置合理的过渡动画
4.2.2 文字截断与展开
.daily-quote-en { font-size: 1.05rem; color: #0369a1; font-weight: 500; line-height: 1.4; font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; position: relative; transition: all 0.3s ease; } /* 悬浮/展开状态 */ .daily-quote-en:hover, .daily-quote-en.expanded { white-space: normal; overflow: visible; text-overflow: clip; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); padding: 4px 8px; border-radius: 6px; box-shadow: 0 4px 12px rgba(14, 165, 233, 0.15); z-index: 10; }文字处理技巧:
- 使用text-overflow实现省略号效果
- 通过改变white-space属性实现展开/收起
- 添加悬停状态提升交互体验
- 使用z-index确保展开内容不被遮挡
4.3 JavaScript交互实现
4.3.1 数据获取与缓存
(function fetchDailyQuote() { var quoteEn = document.getElementById('quote-en'); var quoteCn = document.getElementById('quote-cn'); // 检查元素是否存在,不存在则重试 if (!quoteEn || !quoteCn) { setTimeout(fetchDailyQuote, 500); return; } // 检查本地缓存 var cachedDate = localStorage.getItem('dailyQuoteDate'); var today = new Date().toDateString(); if (cachedDate === today) { var cachedData = localStorage.getItem('dailyQuote'); if (cachedData) { try { var data = JSON.parse(cachedData); quoteEn.textContent = data.en; quoteCn.textContent = data.cn; return; } catch (e) { console.error('解析缓存数据失败', e); } } } // 调用后端接口 fetch('/api/daily-quote') .then(function(response) { if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); }) .then(function(data) { if (data && data.en && data.cn) { quoteEn.textContent = data.en; quoteCn.textContent = data.cn; // 缓存到 localStorage localStorage.setItem('dailyQuote', JSON.stringify({ en: data.en, cn: data.cn })); localStorage.setItem('dailyQuoteDate', today); } }) .catch(function(error) { console.error('Error:', error); // 使用备用数据 quoteEn.textContent = "The best time to plant a tree was 20 years ago..."; quoteCn.textContent = "种一棵树最好的时间是十年前,其次是现在。"; }); })();缓存策略实现要点:
- 首先检查本地是否有当天的缓存
- 有缓存则直接使用,减少网络请求
- 无缓存或缓存过期才请求后端API
- 请求成功后将数据缓存到localStorage
- 所有环节都有错误处理和降级方案
4.3.2 点击展开功能
// 移动端点击展开/收起 function toggleExpand(element) { element.classList.toggle('expanded'); } quoteEn.addEventListener('click', function(e) { e.stopPropagation(); toggleExpand(quoteEn); }); quoteCn.addEventListener('click', function(e) { e.stopPropagation(); toggleExpand(quoteCn); }); // 点击其他地方收起 document.addEventListener('click', function() { quoteEn.classList.remove('expanded'); quoteCn.classList.remove('expanded'); });交互设计考虑:
- 点击句子展开/收起完整内容
- 点击其他区域自动收起
- 使用stopPropagation防止事件冒泡
- 通过classList动态添加/移除类名
5. 双层缓存架构详解
5.1 缓存层次结构
┌─────────────────────────────────────────┐ │ 客户端 localStorage │ │ - 当天数据缓存 │ │ - 减少重复请求 │ └─────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────┐ │ 服务端 ConcurrentHashMap │ │ - 当天数据缓存 │ │ - 减少第三方 API 调用 │ └─────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────┐ │ 第三方 API │ │ - 有道词典每日一句 │ └─────────────────────────────────────────┘5.2 缓存策略对比
| 缓存层 | 存储位置 | 有效期 | 作用 |
|---|---|---|---|
| localStorage | 浏览器 | 1天 | 减少页面刷新后的请求 |
| ConcurrentHashMap | 服务端内存 | 1天 | 减少第三方API调用 |
这种双层缓存架构的优势:
- 减少网络请求:大部分情况下只需要访问本地缓存
- 降低API压力:多个用户共享同一个服务端缓存
- 提升响应速度:内存操作远快于网络请求
- 增强稳定性:即使一方缓存失效,另一方仍可工作
6. 响应式设计实现
6.1 断点设置
/* 桌面端 */ .daily-quote-en { font-size: 1.05rem; } .daily-quote-cn { font-size: 1rem; } /* 平板端 (max-width: 768px) */ @media (max-width: 768px) { .daily-quote-en { font-size: 1rem; } .daily-quote-cn { font-size: 0.95rem; } } /* 手机端 (max-width: 480px) */ @media (max-width: 480px) { .daily-quote-en { font-size: 0.95rem; } .daily-quote-cn { font-size: 0.9rem; } }响应式设计要点:
- 根据屏幕宽度调整字体大小
- 设置合理的断点(768px和480px)
- 使用相对单位(rem)便于缩放
6.2 布局适配
.daily-quote { width: 360px; min-width: 360px; } @media (max-width: 768px) { .daily-quote { width: 100%; max-width: 340px; } } @media (max-width: 480px) { .daily-quote { width: 100%; max-width: 100%; } }布局调整策略:
- 桌面端固定宽度
- 平板端适当缩小
- 手机端全宽度显示
- 使用max-width防止元素过大
7. 完整代码实现
7.1 后端完整代码
@RestController public class DailyQuoteController { private static final ConcurrentHashMap<String, Map<String, String>> quoteCache = new ConcurrentHashMap<>(); @Autowired private RestTemplate restTemplate; @Autowired private ObjectMapper objectMapper; @GetMapping(value = "/api/daily-quote", produces = "application/json;charset=UTF-8") @ResponseBody public Map<String, String> getDailyQuote() { String today = java.time.LocalDate.now().toString(); // 1. 检查缓存 if (quoteCache.containsKey(today)) { return quoteCache.get(today); } Map<String, String> result = new HashMap<>(); String apiUrl = "https://dict.youdao.com/infoline/style/cardList?mode=publish&client=mobile&style=daily"; try { HttpHeaders headers = new HttpHeaders(); headers.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); headers.add("Accept", "application/json, text/plain, */*"); HttpEntity<String> requestEntity = new HttpEntity<>(headers); ResponseEntity<String> response = restTemplate.exchange( apiUrl, HttpMethod.GET, requestEntity, String.class); if (response.getStatusCode() == HttpStatus.OK) { JsonNode rootNode = objectMapper.readTree(response.getBody()); if (rootNode.isArray() && rootNode.size() > 0) { JsonNode item = rootNode.get(0); result.put("en", item.get("title").asText().trim()); result.put("cn", item.get("summary").asText().trim()); result.put("success", "true"); quoteCache.put(today, result); return result; } } } catch (Exception e) { log.error("获取每日一句失败", e); } // 备用数据 result.put("en", "The best time to plant a tree was 10 years ago. The second best time is now."); result.put("cn", "种一棵树最好的时间是十年前,其次是现在。"); result.put("success", "false"); return result; } }7.2 前端完整代码
<div class="daily-quote" id="daily-quote"> <div class="daily-quote-content"> <div class="daily-quote-en" id="quote-en">Loading...</div> <div class="daily-quote-cn" id="quote-cn">正在获取每日一句...</div> </div> </div> <script> (function fetchDailyQuote() { var quoteEn = document.getElementById('quote-en'); var quoteCn = document.getElementById('quote-cn'); if (!quoteEn || !quoteCn) { setTimeout(fetchDailyQuote, 500); return; } // 检查本地缓存 var cachedDate = localStorage.getItem('dailyQuoteDate'); var today = new Date().toDateString(); if (cachedDate === today) { var cachedData = localStorage.getItem('dailyQuote'); if (cachedData) { try { var data = JSON.parse(cachedData); quoteEn.textContent = data.en; quoteCn.textContent = data.cn; return; } catch (e) { console.error('解析缓存数据失败', e); } } } // 点击展开功能 function toggleExpand(el) { el.classList.toggle('expanded'); } quoteEn.addEventListener('click', function(e) { e.stopPropagation(); toggleExpand(quoteEn); }); quoteCn.addEventListener('click', function(e) { e.stopPropagation(); toggleExpand(quoteCn); }); document.addEventListener('click', function() { quoteEn.classList.remove('expanded'); quoteCn.classList.remove('expanded'); }); // 获取数据 fetch('/api/daily-quote') .then(r => r.json()) .then(data => { if (data.en && data.cn) { quoteEn.textContent = data.en; quoteCn.textContent = data.cn; localStorage.setItem('dailyQuote', JSON.stringify({ en: data.en, cn: data.cn })); localStorage.setItem('dailyQuoteDate', today); } }) .catch(() => { quoteEn.textContent = "The best time to plant a tree was 20 years ago..."; quoteCn.textContent = "种一棵树最好的时间是十年前,其次是现在。"; }); })(); </script>8. 技术亮点与扩展思路
8.1 技术亮点总结
- 双层缓存架构:服务端和客户端双重缓存极大提升了性能
- 完善的降级策略:从API调用到前端展示都有备用方案
- 优雅的交互设计:文字截断与展开功能提升了用户体验
- 响应式布局:完美适配各种设备屏幕
- 轻量级实现:不依赖大型框架,加载速度快
8.2 扩展思路
这个功能还有不少可以扩展的方向:
多数据源支持:
- 接入多个名言API,随机或轮询使用
- 当主API不可用时自动切换到备用API
历史记录功能:
- 将每日句子保存到数据库
- 提供历史句子查询界面
用户自定义:
- 允许用户收藏喜欢的句子
- 支持用户提交自己的句子
社交分享:
- 添加分享到社交媒体的功能
- 生成带有句子和博客链接的分享图片
主题切换:
- 提供多种显示主题供用户选择
- 根据时间自动切换白天/夜间模式
在实际使用中,我发现这个功能确实能提升博客的互动性。很多访客会特意来看当天的句子,有些还会在评论区分享自己的感悟。这种小而美的功能,往往能带来意想不到的效果。