想做线上拍卖平台?这款现成源码助你零门槛快速上线!
近年来,电商行业进入存量竞争,传统零售的增长红利逐渐消退,而 直播+拍卖 的模式却在悄然崛起。数据显示,全球 直播电商市场规模已突破 7000 亿美元,而其中拍卖模式因其 高互动性、高成交率,成为新兴电商的流量密码。
无论是珠宝、奢侈品、艺术品,还是房产、二手车,甚至盲盒,拍卖模式都能最大化地调动用户情绪,让商品价值被充分挖掘。一场竞拍,往往能创造普通电商难以企及的高溢价,让卖家和买家都乐在其中。
然而,对于创业者来说,开发一套稳定、专业的拍卖系统,并非易事——高昂的研发成本、漫长的测试周期、复杂的支付与风控体系,往往让人望而却步。
一、有没有一种方式,可以低成本、快速上线拍卖平台,抢占市场先机?
东莞梦幻网络科技,一站式直播拍卖系统,让你低成本抢占市场!
针对创业者的痛点,东莞梦幻网络科技 推出了 「开箱即用」的直播拍卖系统源码,助力企业 低成本、高效率上线自己的直播拍卖平台!
🌟 核心功能亮点:
✅ 直播+竞拍双驱动:用户边看直播,边参与竞价,沉浸感满分
✅ 全网最透明:竞拍全程直播,所有出价公开可见,用户信任度直线上升
✅ 互动玩法多样:弹幕PK、红包雨、连麦砍价……增强用户粘性,留住流量
✅ AR/VR展示技术:360°旋转珠宝展示、房产 VR 看房,让体验感直接拉满
🔹 双模式商城,轻松打造综合电商平台
刺激拍卖:拍卖倒计时+智能加价,助推成交率飙升
常规零售:拍卖+固定价销售双模式,满足不同用户需求
🔹 6大盈利模式,打造持续增长的商业闭环
💰 佣金抽成:每笔成交,平台都能分润
💰 店铺年费:商家入驻,持续稳定收益
💰 广告位招商:商家竞价投放广告,赚取流量红利
💰 会员增值服务:VIP 会员享受独家折扣,提升复购率
💰 竞拍加速道具:用户可付费提升竞拍优势,创造更多收入点
💰 分销裂变:邀请好友参与竞拍,赚取推广收益
二、从0到1,他如何7天搭建拍卖平台,赚到第一桶金?
90后创业者李明,原本只是在某电商平台做二手奢侈品交易,但受限于平台规则和高额抽成,利润一直难以提升。
一次偶然的机会,他了解到直播+拍卖的模式,并看到了商机。但自己组建技术团队开发拍卖系统,至少需要 百万级别的资金投入,这让他望而却步。
直到他接触到了 东莞梦幻网络科技的拍卖系统源码——一次性购买,直接搭建,不到7天就能上线,让他看到了希望。
🚀 短短一个月,他的直播拍卖平台成交额突破 100 万元!
💡 用户粘性大幅提高,90% 参与竞拍的用户都会复购
📈 商家争相入驻,平台流量不断上涨
李明感慨:“如果自己开发,至少要花 6 个月以上,但现在7天就上线了,直接抢占了市场先机。”
三、未来已来,直播+拍卖将是电商的新主流,你还要等多久?
电商的流量越来越贵,传统卖货模式难以突破,而 直播+拍卖 以超高互动性、强成交驱动成为新趋势。
但风口稍纵即逝,真正赚钱的人,都是 “第一个吃螃蟹的人”!
一站式体育直播系统,让你跳过开发,快速抢占市场
面对体育直播平台开发中遇到的高成本和长周期问题,许多创业者往往感到困扰。不过,东莞梦幻网络科技有限公司现在提供了一种全新的解决方案,其精心打造的体育直播系统将帮助创业者们轻松跳过繁琐的开发阶段,直接进入运营快车道。
沉浸式互动体验,构建社交主场
东莞梦幻网络深知互动性对于提升用户体验的重要性,因此在其直播系统中集成了社交聊天室功能,让粉丝能够实时交流,分享观赛心得。此外,趣味竞猜活动不仅增加了观赛的乐趣,还有效点燃了用户的热情。而通过积分商城的设置,用户可以通过参与平台活动获得奖励,从而提升了用户忠诚度,使得这个平台不仅仅是一个观看比赛的地方,更是体育爱好者的社交主场。
专业内容矩阵,吸引真正体育迷
为了增强平台的专业性和吸引力,东莞梦幻网络提供了专家深度解析赛事、独家行业资讯报道以及精准的赛事预测分析等服务。这些高质量的内容不仅有助于树立平台作为专业体育媒体的形象,还能吸引那些对体育充满热情且追求深入见解的忠实用户群体。
多元变现渠道,确保投资回报
东莞梦幻网络的体育直播系统支持多种盈利模式,包括但不限于打赏、会员订阅、VIP特权、广告位精准投放以及赛事周边商城等。这种多元化的收入来源组合为投资者提供了更加稳定和有保障的投资回报途径。
自生长内容生态,持续激发平台活力
为了保证平台内容的新鲜度和多样性,东莞梦幻网络鼓励用户生成内容(UGC),如原创短视频分享、体育社区论坛互动及赛事精彩集锦回放等功能,促进了良性内容循环的发展,使平台始终保持活力。
东莞梦幻网络科技凭借其革命性的体育直播系统,正在改变着行业的游戏规则。无论是初创企业还是寻求转型的传统媒体,都可以借助这一平台快速实现目标,开启属于自己的成功之旅。对于希望在体育直播领域有所建树的企业来说,这是一个不容错过的机会。
React 官方推荐使用 Vite
“技术更替不是一场革命,而是一场漫长的进化过程。”
Hello,大家好,我是 三千。
React 官方已明确建议开发者逐步淘汰 Create React App (CRA) ,转而使用 Vite 等现代框架或工具来创建新项目。
那官方为什么要这样做呢?
一、CRA 被淘汰的背景与原因
- 历史局限性
CRA 诞生于 2016 年,旨在简化 React 项目的初始化配置,但其底层基于 Webpack 和 Babel 的架构在性能、扩展性和灵活性上逐渐无法满足现代开发需求。随着项目规模扩大,CRA 的启动和构建速度显著下降,且默认配置难以优化生产包体积。 - 维护停滞与兼容性问题
React 团队于 2023 年宣布停止积极维护 CRA,且 CRA 的最新版本(v5.0.1)已无法兼容 React 19 等新特性,导致其在生产环境中逐渐不适用。 - 缺乏对现代开发模式的支持
CRA 仅提供客户端渲染(CSR)的默认配置,无法满足服务端渲染(SSR)、静态生成(SSG)等需求。此外,其“零配置”理念限制了路由、状态管理等常见需求的灵活实现。
二、Vite 成为 React 官方推荐的核心优势
- 性能提升
- 开发速度:Vite 基于原生 ESM 模块和 esbuild(Go 语言编写)实现秒级启动与热更新,显著优于 CRA 的 Webpack 打包机制。
- 生产构建:通过 Rollup 优化代码体积,支持 Tree Shaking 和懒加载,减少冗余代码。
- 灵活性与生态兼容
- 配置自由:允许开发者按需定制构建流程,支持 TypeScript、CSS 预处理器、SVG 等特性,无需繁琐的
eject
操作。 - 框架无关性:虽与 React 深度集成,但也可用于 Vue、Svelte 等项目,适应多样化技术栈。
- 配置自由:允许开发者按需定制构建流程,支持 TypeScript、CSS 预处理器、SVG 等特性,无需繁琐的
- 现代化开发体验
- 原生浏览器支持:利用现代浏览器的 ESM 特性,无需打包即可直接加载模块。
- 插件生态:丰富的 Vite 插件(如
@vitejs/plugin-react
)简化了 React 项目的开发与调试。
三、迁移至 Vite 的具体步骤
- 卸载 CRA 依赖
npm uninstall react-scripts
npm install vite @vitejs/plugin-react --save-dev
- 调整项目结构
- 将
index.html
移至项目根目录,并更新脚本引用为 ESM 格式:
<script type="module" src="/src/main.jsx"></script>
- 将
.js
文件扩展名改为.jsx
(如App.js
→App.jsx
)。
- 将
- 配置 Vite
创建vite.config.js
文件:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});
- 更新环境变量
环境变量前缀需从REACT_APP_
改为VITE_
(如VITE_API_KEY=123
)。 - 运行与调试
修改package.json
脚本命令:
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
四、其他官方推荐的 React 框架
- Next.js
- 适用场景:服务端渲染(SSR)、静态生成(SSG)及全栈应用开发。
- 优势:内置路由、API 路由、图像优化等功能,适合企业级应用与 SEO 敏感项目。
- Remix
- 适用场景:嵌套路由驱动的全栈应用,注重数据加载优化与渐进增强。
- 优势:集成数据预加载机制,减少请求瀑布问题。
- Astro
- 适用场景:内容型静态网站(如博客、文档站)。
- 优势:默认零客户端 JS 开销,通过“岛屿架构”按需激活交互组件。
五、总结与建议
- 新项目:优先选择 Vite(轻量级 CSR 项目)或 Next.js(复杂全栈应用)。
- 现有 CRA 项目:逐步迁移至 Vite,或根据需求转向 Next.js/Remix 等框架。
- 学习曲线:Vite 对 React 核心概念干扰较小,适合初学者;Next.js 功能全面但学习成本较高。
React 生态正朝着 “库+框架”协同发展 的方向演进,开发者需结合项目需求选择工具链,以平衡性能、灵活性与开发效率。
结语
以上就是今天与大家分享的全部内容,你的支持是我更新的最大动力,我们下期见!
打工人肝 文章/视频 不易,期待你一键三连的鼓励 !!!
😐 这里是【程序员三千】,💻 一个喜欢捣鼓各种编程工具新鲜玩法的啊婆主。
🏠 已入驻:抖爸爸、b站、小红书(都叫【程序员三千】)
💽 编程/AI领域优质资源推荐 👉 http://www.yuque.com/xiaosanye-o…
来源:juejin.cn/post/7472008189976461346
async/await 必须使用 try/catch 吗?
前言
在 JavaScript 开发者的日常中,这样的对话时常发生:
- 👨💻 新人:"为什么页面突然白屏了?"
- 👨🔧 老人:"异步请求没做错误处理吧?"
async/await 看似优雅的语法糖背后,隐藏着一个关键问题:错误处理策略的抉择。
在 JavaScript 中使用 async/await
时,很多人会问:“必须使用 try/catch 吗?”
其实答案并非绝对,而是取决于你如何设计错误处理策略和代码风格。
接下来,我们将探讨 async/await 的错误处理机制、使用 try/catch 的优势,以及其他可选的错误处理方法。
async/await 的基本原理
异步代码的进化史
// 回调地狱时代
fetchData(url1, (data1) => {
process(data1, (result1) => {
fetchData(url2, (data2) => {
// 更多嵌套...
})
})
})
// Promise 时代
fetchData(url1)
.then(process)
.then(() => fetchData(url2))
.catch(handleError)
// async/await 时代
async function workflow() {
const data1 = await fetchData(url1)
const result = await process(data1)
return await fetchData(url2)
}
async/await 是基于 Promise 的语法糖,它使异步代码看起来更像同步代码,从而更易读、易写。一个 async 函数总是返回一个 Promise,你可以在该函数内部使用 await 来等待异步操作完成。
如果在异步操作中出现错误(例如网络请求失败),该错误会使 Promise 进入 rejected 状态。
async function fetchData() {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
return data;
}
使用 try/catch 捕获错误
打个比喻,就好比铁路信号系统
想象 async 函数是一列高速行驶的列车:
- await 是轨道切换器:控制代码执行流向
- 未捕获的错误如同脱轨事故:会沿着铁路网(调用栈)逆向传播
- try/catch 是智能防护系统:
- 自动触发紧急制动(错误捕获)
- 启动备用轨道(错误恢复逻辑)
- 向调度中心发送警报(错误日志)
为了优雅地捕获 async/await 中出现的错误,通常我们会使用 try/catch 语句。这种方式可以在同一个代码块中捕获同步和异步抛出的错误,使得错误处理逻辑更集中、直观。
- 代码逻辑集中,错误处理与业务逻辑紧密结合。
- 可以捕获多个 await 操作中抛出的错误。
- 适合需要在出错时进行统一处理或恢复操作的场景。
async function fetchData() {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching data:", error);
// 根据需要,可以在此处处理错误,或者重新抛出以便上层捕获
throw error;
}
}
不使用 try/catch 的替代方案
虽然 try/catch 是最直观的错误处理方式,但你也可以不在 async 函数内部使用它,而是在调用该 async 函数时捕获错误。
在 Promise 链末尾添加 .catch()
async function fetchData() {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 调用处使用 Promise.catch 捕获错误
fetchData()
.then(data => {
console.log("Data:", data);
})
.catch(error => {
console.error("Error fetching data:", error);
});
这种方式将错误处理逻辑移至函数调用方,适用于以下场景:
- 当多个调用者希望以不同方式处理错误时。
- 希望让 async 函数保持简洁,将错误处理交给全局统一的错误处理器(例如在 React 应用中可以使用 Error Boundary)。
将 await
与 catch
结合
async function fetchData() {
const response = await fetch('https://api.example.com/data').catch(error => {
console.error('Request failed:', error);
return null; // 返回兜底值
});
if (!response) return;
// 继续处理 response...
}
全局错误监听(慎用,适合兜底)
// 浏览器端全局监听
window.addEventListener('unhandledrejection', event => {
event.preventDefault();
sendErrorLog({
type: 'UNHANDLED_REJECTION',
error: event.reason,
stack: event.reason.stack
});
showErrorToast('系统异常,请联系管理员');
});
// Node.js 进程管理
process.on('unhandledRejection', (reason, promise) => {
logger.fatal('未处理的 Promise 拒绝:', reason);
process.exitCode = 1;
});
错误处理策略矩阵
决策树分析
graph TD
A[需要立即处理错误?] -->|是| B[使用 try/catch]
A -->|否| C{错误类型}
C -->|可恢复错误| D[Promise.catch]
C -->|致命错误| E[全局监听]
C -->|批量操作| F[Promise.allSettled]
错误处理体系
- 基础层:80% 的异步操作使用 try/catch + 类型检查
- 中间层:15% 的通用错误使用全局拦截 + 日志上报
- 战略层:5% 的关键操作实现自动恢复机制
小结
我的观点是:不强制要求,但强烈推荐
- 不强制:如果不需要处理错误,可以不使用
try/catch
,但未捕获的 Promise 拒绝(unhandled rejection)会导致程序崩溃(在 Node.js 或现代浏览器中)。 - 推荐:90% 的场景下需要捕获错误,因此
try/catch
是最直接的错误处理方式。
所有我个人观点:使用 async/await 尽量使用 try/catch。好的错误处理不是消灭错误,而是让系统具备优雅降级的能力。
你的代码应该像优秀的飞行员——在遇到气流时,仍能保持平稳飞行。大家如有不同意见,还请评论区讨论,说出自己的见解。
来源:juejin.cn/post/7482013975077928995
告别龟速删除!前端老司机教你秒删node_modules的黑科技
引言:每个前端的痛——node_modules删除噩梦
“npm install一时爽,删包火葬场。”这几乎是所有Node.js开发者都经历过的痛。尤其是当项目依赖复杂时,动辄几百MB甚至几个G的node_modules
文件夹,手动删除时转圈圈的进度条简直让人抓狂。
如何高效解决这个问题?今天我们就来揭秘几种秒删node_modules的硬核技巧,让你从此告别龟速删除!
一、为什么手动删除node_modules这么慢?
node_modules
的目录结构复杂,层级深、文件数量庞大(比如一个中型项目可能有上万个小文件)。手动删除时,操作系统需要逐个处理这些文件,导致效率极低,尤其是Windows系统表现更差。核心原因包括:
- 文件系统限制:Windows的NTFS和macOS的HFS+对超多小文件的删除并未优化,系统需要频繁更新索引和缓存,资源占用高。
- 权限问题:某些文件可能被进程占用或权限不足,导致删除失败或卡顿。
- 递归删除效率低:系统自带的删除命令(如右键删除)是单线程操作,而
node_modules
的嵌套结构会让递归删除耗时剧增。
二、终极方案:用rimraf实现“秒删”
如果你还在手动拖拽删除,赶紧试试这个Node.js社区公认的神器——rimraf!它的原理是封装了rm -rf
命令,通过减少系统调用和优化递归逻辑,速度提升可达10倍以上。
操作步骤
- 全局安装rimraf(仅需一次):
npm install rimraf -g
- 一键删除:
进入项目根目录,执行:
rimraf node_modules
实测:一个5GB的node_modules
,10秒内删干净!
进阶用法
- 集成到npm脚本:在
package.json
中添加脚本,直接运行npm run clean
:
{
"scripts": {
"clean": "rimraf node_modules"
}
}
- 跨平台兼容:无论是Windows、Linux还是macOS,命令完全一致,团队协作无压力。
三、其他高效删除方案
如果不想安装额外工具,系统原生命令也能解决问题:
1. Windows用户:用命令行暴力删除
- CMD命令:
rmdir /s /q node_modules
/s
表示递归删除,/q
表示静默执行(不弹窗确认)。 - PowerShell(更快):
Remove-Item -Force -Recurse node_modules
2. Linux/macOS用户:终端直接起飞
rm -rf ./node_modules
四、避坑指南:删不干净怎么办?
有时即使删了node_modules
,重新安装依赖仍会报错。此时需要彻底清理残留:
- 清除npm缓存:
npm cache clean --force
- 删除锁文件:
手动移除package-lock.json
或yarn.lock
。 - 重启IDE:确保没有进程占用文件。
五、总结:选对工具,效率翻倍
方案 | 适用场景 | 速度对比 |
---|---|---|
rimraf | 跨平台、大型项目 | ⚡⚡⚡⚡⚡ |
系统命令 | 临时快速操作 | ⚡⚡⚡ |
手动删除 | 极小项目(不推荐) | ⚡ |
推荐组合拳:日常使用rimraf
+脚本,遇到权限问题时切换系统命令。
互动话题
你遇到过最离谱的node_modules
有多大?评论区晒出你的经历!
来源:juejin.cn/post/7477926585087606820
AI 赋能 Web 页面,图像识别超越想象
前言
在信息时代,Web 页面成为我们与世界交互的重要窗口。如今,AI 程序的出现,为 Web 页面带来了新的变革。通过在 Web 页面上实现图片识别,我们即将迈入一个更加智能与便捷的时代。它将赋予网页全新的能力,使其能够理解图片的内容,为用户带来前所未有的体验。让我们一同踏上这充满无限可能的探索之旅。
在信息时代,Web 页面成为我们与世界交互的重要窗口。如今,AI 程序的出现,为 Web 页面带来了新的变革。通过在 Web 页面上实现图片识别,我们即将迈入一个更加智能与便捷的时代。它将赋予网页全新的能力,使其能够理解图片的内容,为用户带来前所未有的体验。让我们一同踏上这充满无限可能的探索之旅。
具体步骤
html部分
我们可以试试通过输入:
main.container>(label.custom-file-upload>input#file-upload)+#image-container+p#status
再按tab键就可以快速生成以下的html框架。
<main class="container">
<label for="file-upload" class="custom-file-upload">
<input type="file" accept="image/*" id="file-upload">
上传图片
label>
<div id="image-container">div>
<p id="status">p>
main>
- 我们选择使用main标签而不是选择div,是因为main比div更具有语义化。main标签可以表示页面的主体内容。
- label标签用于将表单中的输入元素(如文本输入框、单选按钮、复选框等)与相关的文本描述关联起来。label标签通过将 for 属性值设置为输入元素的 id 属性值,建立与输入元素的关联。
- input的type属性值为file,表示是文件输入框;accept属性值为image/*,表示该文件输入框只接收图像类型的文件。
我们可以试试通过输入:
main.container>(label.custom-file-upload>input#file-upload)+#image-container+p#status
再按tab键就可以快速生成以下的html框架。
<main class="container">
<label for="file-upload" class="custom-file-upload">
<input type="file" accept="image/*" id="file-upload">
上传图片
label>
<div id="image-container">div>
<p id="status">p>
main>
- 我们选择使用main标签而不是选择div,是因为main比div更具有语义化。main标签可以表示页面的主体内容。
- label标签用于将表单中的输入元素(如文本输入框、单选按钮、复选框等)与相关的文本描述关联起来。label标签通过将 for 属性值设置为输入元素的 id 属性值,建立与输入元素的关联。
- input的type属性值为file,表示是文件输入框;accept属性值为image/*,表示该文件输入框只接收图像类型的文件。
JavaScript部分
这个部分是这篇文章的重点。
这个部分是这篇文章的重点。
第一部分
首先我们通过JavaScript在远程的transformers库中引入pipleline和env模块。我们需要禁止使用本地的模块,而使用远程的模块。
import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
env.allowLocalModels = false;
接下来我们要读取用户输入的文件,并且输出在页面上。我们可以这样做:
const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')
//添加事件监听器来响应元素上的特定事件
//在fileUpload元素上添加一个change事件监听。当用户更改上传文件时,触发事件处理函数。
fileUpload.addEventListener('change', function (e) {
//e是事件对象
//e.target是触发事件的文件上传元素。
//e.target.files[0]是获取选中的第一个文件
const file = e.target.files[0];
// 新建一个FileReader 对象用于读取文件(异步)
//FileReader 是 JavaScript 中的一个对象,用于异步读取用户选择的文件或输入的内容。
const reader = new FileReader();
//reader.onload就相当于FileReader。onload;FileReader。onload是一个事件,在读取操作完成时触发function(e2)
reader.onload = function (e2) {
//创建一个新的html元素,也就是创建一个img标签
const image = document.createElement('img');
//将image的scr属性的url值改为e2.target.result
image.src = e2.target.result;
//将 image 元素添加作为子节点到 imageContainer 元素中
imageContainer.appendChild(image)
detect(image) // 启动AI任务。是功能模块化,要封装出去
}
//FileReader.readAsDataURL():开始读取指定的 Blob 中的内容。一旦完成,result 属性中将包含一个 data: URL 格式的 Base64 字符串以表示所读取文件的内容。
//用于将指定的文件读取为 DataURL 格式的数据。
reader.readAsDataURL(file)
})
我们先梳理一下我们的思路。
- 我们要对文件输入框里元素进行操作,所以我们要获取那个元素。我们可以创建一个fileUpload对象获取这个元素。
- 我们可以对fileUpload对象添加一个change事件监听。这个监听器实现当用户更改上传文件时,触发事件处理函数function(e)实现读取文件并且在页面输出文件的功能。
- function(e)的功能是用于异步读取用户选择的文件,并且在页面输出它。
- 因为我们要读取整个图片文件,所以我们可以创建一个FileReader对象读取图片文件。在文件读取完成时我们可以用reader.onload实现在文件读取完毕时在id为mage-container的div标签内添加一个img标签输出图片。
- 因为要输出图片就要先知道图片的URL,于是我们用e2.target.result获取图片的URL。
- 因为要在div里添加一个img标签,于是创建一个imageContainer对象获取ID 为 image-container 的元素后用imageContainer.appendChild(image)实现将img标签添加为div的子标签。
- 我们用detect函数封装AI图片识别任务。
首先我们通过JavaScript在远程的transformers库中引入pipleline和env模块。我们需要禁止使用本地的模块,而使用远程的模块。
import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
env.allowLocalModels = false;
接下来我们要读取用户输入的文件,并且输出在页面上。我们可以这样做:
const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')
//添加事件监听器来响应元素上的特定事件
//在fileUpload元素上添加一个change事件监听。当用户更改上传文件时,触发事件处理函数。
fileUpload.addEventListener('change', function (e) {
//e是事件对象
//e.target是触发事件的文件上传元素。
//e.target.files[0]是获取选中的第一个文件
const file = e.target.files[0];
// 新建一个FileReader 对象用于读取文件(异步)
//FileReader 是 JavaScript 中的一个对象,用于异步读取用户选择的文件或输入的内容。
const reader = new FileReader();
//reader.onload就相当于FileReader。onload;FileReader。onload是一个事件,在读取操作完成时触发function(e2)
reader.onload = function (e2) {
//创建一个新的html元素,也就是创建一个img标签
const image = document.createElement('img');
//将image的scr属性的url值改为e2.target.result
image.src = e2.target.result;
//将 image 元素添加作为子节点到 imageContainer 元素中
imageContainer.appendChild(image)
detect(image) // 启动AI任务。是功能模块化,要封装出去
}
//FileReader.readAsDataURL():开始读取指定的 Blob 中的内容。一旦完成,result 属性中将包含一个 data: URL 格式的 Base64 字符串以表示所读取文件的内容。
//用于将指定的文件读取为 DataURL 格式的数据。
reader.readAsDataURL(file)
})
我们先梳理一下我们的思路。
- 我们要对文件输入框里元素进行操作,所以我们要获取那个元素。我们可以创建一个fileUpload对象获取这个元素。
- 我们可以对fileUpload对象添加一个change事件监听。这个监听器实现当用户更改上传文件时,触发事件处理函数function(e)实现读取文件并且在页面输出文件的功能。
- function(e)的功能是用于异步读取用户选择的文件,并且在页面输出它。
- 因为我们要读取整个图片文件,所以我们可以创建一个FileReader对象读取图片文件。在文件读取完成时我们可以用reader.onload实现在文件读取完毕时在id为mage-container的div标签内添加一个img标签输出图片。
- 因为要输出图片就要先知道图片的URL,于是我们用e2.target.result获取图片的URL。
- 因为要在div里添加一个img标签,于是创建一个imageContainer对象获取ID 为 image-container 的元素后用imageContainer.appendChild(image)实现将img标签添加为div的子标签。
- 因为我们要读取整个图片文件,所以我们可以创建一个FileReader对象读取图片文件。在文件读取完成时我们可以用reader.onload实现在文件读取完毕时在id为mage-container的div标签内添加一个img标签输出图片。
- 我们用detect函数封装AI图片识别任务。
第二部分
接下来我们要完成ai任务———检测图片。
我们要通过ai进行对象检测并且获得检测到的元素的参数。
const status = document.getElementById('status');
const detect = async (image) => {
//在id为status的p标签中添加“分析中...请稍等一会”的文本内容
status.textContent = "分析中...请稍等一会"
//object-detection是对象检查任务;Xenova/detr-resnet-50是指定用于对象检测的模型
const detector = await pipeline("object-detection","Xenova/detr-resnet-50") // model 实例化了detector对象
//使用检测器对象对指定的图像源进行检测,并传入了一些参数:
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
//forEach 方法用于遍历数组或类似数组的对象,并对每个元素执行指定的函数
output.forEach(renderBox)
}
分析检测图片和获取检测元素的参数的思路。
- 首先,对图片进行分析需要一些时间,为了让用户知道分析需要时间并且让用户等待,我们可以用我们html中id="status"的p标签输入一段文字。
- 首先我们用document.getElementById('status')获取id="status"的元素,并且用textContent方法动态添加id="status"的p标签的文本内容,然后输出在页面上。
detect异步函数
- 为了实现对象检测我们采用了async/await编写异步代码的方法。使用async关键字来标记一个函数为异步函数,并在其中使用await关键字来等待一个Promise对象的结果。await表达式会暂停当前async function的执行,等待Promise处理完成。
- 创建一个detector对象接收对象分析结果。再创建output对象接收对象分析结果的参数
output.forEach(renderBox)是让output的所有元素遍历进行渲染。
接下来我们要完成ai任务———检测图片。
我们要通过ai进行对象检测并且获得检测到的元素的参数。
const status = document.getElementById('status');
const detect = async (image) => {
//在id为status的p标签中添加“分析中...请稍等一会”的文本内容
status.textContent = "分析中...请稍等一会"
//object-detection是对象检查任务;Xenova/detr-resnet-50是指定用于对象检测的模型
const detector = await pipeline("object-detection","Xenova/detr-resnet-50") // model 实例化了detector对象
//使用检测器对象对指定的图像源进行检测,并传入了一些参数:
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
//forEach 方法用于遍历数组或类似数组的对象,并对每个元素执行指定的函数
output.forEach(renderBox)
}
分析检测图片和获取检测元素的参数的思路。
- 首先,对图片进行分析需要一些时间,为了让用户知道分析需要时间并且让用户等待,我们可以用我们html中id="status"的p标签输入一段文字。
- 首先我们用document.getElementById('status')获取id="status"的元素,并且用textContent方法动态添加id="status"的p标签的文本内容,然后输出在页面上。
detect异步函数
- 为了实现对象检测我们采用了async/await编写异步代码的方法。使用async关键字来标记一个函数为异步函数,并在其中使用await关键字来等待一个Promise对象的结果。await表达式会暂停当前async function的执行,等待Promise处理完成。
- 创建一个detector对象接收对象分析结果。再创建output对象接收对象分析结果的参数
output.forEach(renderBox)是让output的所有元素遍历进行渲染。
第三部分
我们用renderBox函数将框出图片中的物品的区域,并且在区域内输出ai识别得出的物品名称。
function renderBox({ box, label }) {
console.log(box, label);
const { xmax, xmin, ymax, ymin } = box
//创建一个盒子
const boxElement = document.createElement("div");
//动态添加div的类名
boxElement.className = "bounding-box"
//通过执行 Object.assign(boxElement.style, {}),可以将一个空对象的属性合并到 boxElement.style 对象中
//准备为 boxElement 设置新的样式属性
Object.assign(boxElement.style, {
borderColor: '#123123',
borderWidth: '1px',
borderStyle: 'solid',
//最左侧
left: 100 * xmin + '%',
//最上侧
top: 100 * ymin + '%',
//宽度
width: 100 * (xmax - xmin) + "%",
//高度
height: 100 * (ymax - ymin) + "%"
})
const labelElement = document.createElement('span');
labelElement.textContent = label;
labelElement.className = "bounding-box-label"
labelElement.style.backgroundColor = '#000000'
//在图片上增加的物品区域范围内添加物品的种类名称的文本内容
boxElement.appendChild(labelElement);
//在图片上增加物品的区域范围
imageContainer.appendChild(boxElement);
}
分析renderBox函数思路。
- output元素中有许多参数,我们只需要label参数(检测得出的物品名称)和box参数(图片物品的区域参数,分别为xmax、xmin、ymax、ymin)。
- 实现框出图片中的物品的区域(每一个新建的div都表示一个图片物体)
- 首先我们创建{ xmax, xmin, ymax, ymin }对象获取位置信息。
- 通过document.createElement("div")创建一个新的div标签,再用className方法动态得给新建的div标签添加一个bounding-box类。
- 通过
- Object.assign(boxElement.style, { })对新建的div添加css样式,并且通过测量图片物体的最左侧和最上侧以及高度和宽度框出图片物体的区域。
- 再通过imageContainer.appendChild(boxElement)代码添加到id="image-container"的div标签中。
- 实现在图片物体区域添加ai识别得出的label参数文本
- 通过document.createElement('span')创建span标签,再用textContent方法将label内容动态输入新建的span标签
- 再通过 boxElement.appendChild(labelElement)添加到刚刚创建的div标签中,也就是添加到图片物体区域中。
我们用renderBox函数将框出图片中的物品的区域,并且在区域内输出ai识别得出的物品名称。
function renderBox({ box, label }) {
console.log(box, label);
const { xmax, xmin, ymax, ymin } = box
//创建一个盒子
const boxElement = document.createElement("div");
//动态添加div的类名
boxElement.className = "bounding-box"
//通过执行 Object.assign(boxElement.style, {}),可以将一个空对象的属性合并到 boxElement.style 对象中
//准备为 boxElement 设置新的样式属性
Object.assign(boxElement.style, {
borderColor: '#123123',
borderWidth: '1px',
borderStyle: 'solid',
//最左侧
left: 100 * xmin + '%',
//最上侧
top: 100 * ymin + '%',
//宽度
width: 100 * (xmax - xmin) + "%",
//高度
height: 100 * (ymax - ymin) + "%"
})
const labelElement = document.createElement('span');
labelElement.textContent = label;
labelElement.className = "bounding-box-label"
labelElement.style.backgroundColor = '#000000'
//在图片上增加的物品区域范围内添加物品的种类名称的文本内容
boxElement.appendChild(labelElement);
//在图片上增加物品的区域范围
imageContainer.appendChild(boxElement);
}
分析renderBox函数思路。
- output元素中有许多参数,我们只需要label参数(检测得出的物品名称)和box参数(图片物品的区域参数,分别为xmax、xmin、ymax、ymin)。
- 实现框出图片中的物品的区域(每一个新建的div都表示一个图片物体)
- 首先我们创建{ xmax, xmin, ymax, ymin }对象获取位置信息。
- 通过document.createElement("div")创建一个新的div标签,再用className方法动态得给新建的div标签添加一个bounding-box类。
- 通过
- Object.assign(boxElement.style, { })对新建的div添加css样式,并且通过测量图片物体的最左侧和最上侧以及高度和宽度框出图片物体的区域。
- 再通过imageContainer.appendChild(boxElement)代码添加到id="image-container"的div标签中。
- 实现在图片物体区域添加ai识别得出的label参数文本
- 通过document.createElement('span')创建span标签,再用textContent方法将label内容动态输入新建的span标签
- 再通过 boxElement.appendChild(labelElement)添加到刚刚创建的div标签中,也就是添加到图片物体区域中。
JavaScript部分总结
我们将图片识别功能分别分成三个部分实现。首先通过第一部分读取图片,通过第二部分进行ai分析,再通过第三部分输出结果。
我们要有灵活的封装思想。
我们将图片识别功能分别分成三个部分实现。首先通过第一部分读取图片,通过第二部分进行ai分析,再通过第三部分输出结果。
我们要有灵活的封装思想。
css部分
该部分不做过度解释,直接上代码。
.container {
margin: 40px auto;
width: max(50vw, 400px);
display: flex;
flex-direction: column;
align-items: center;
}
.custom-file-upload {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
border: 2px solid black;
padding: 8px 16px;
border-radius: 6px;
}
#file-upload {
display: none;
}
#image-container {
width: 100%;
margin-top: 20px;
position: relative;
}
#image-container>img {
width: 100%;
}
.bounding-box {
position: absolute;
box-sizing: border-box;
}
.bounding-box-label {
position: absolute;
color: white;
font-size: 12px;
}
该部分不做过度解释,直接上代码。
.container {
margin: 40px auto;
width: max(50vw, 400px);
display: flex;
flex-direction: column;
align-items: center;
}
.custom-file-upload {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
border: 2px solid black;
padding: 8px 16px;
border-radius: 6px;
}
#file-upload {
display: none;
}
#image-container {
width: 100%;
margin-top: 20px;
position: relative;
}
#image-container>img {
width: 100%;
}
.bounding-box {
position: absolute;
box-sizing: border-box;
}
.bounding-box-label {
position: absolute;
color: white;
font-size: 12px;
}
效果展示
- 选择图片上传

- 选择图片后进行分析的过程
- 选择图片上传
- 选择图片后进行分析的过程
控制台输出的是e2.target.result的内容。
- 分析结果
代码
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nlp之图片识别title>
<style>
.container {
margin: 40px auto;
width: max(50vw, 400px);
display: flex;
flex-direction: column;
align-items: center;
}
.custom-file-upload {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
border: 2px solid black;
padding: 8px 16px;
border-radius: 6px;
}
#file-upload {
display: none;
}
#image-container {
width: 100%;
margin-top: 20px;
position: relative;
}
#image-container>img {
width: 100%;
}
.bounding-box {
position: absolute;
box-sizing: border-box;
}
.bounding-box-label {
position: absolute;
color: white;
font-size: 12px;
}
style>
head>
<body>
<main class="container">
<label for="file-upload" class="custom-file-upload">
<input type="file" accept="image/*" id="file-upload">
上传图片
label>
<div id="image-container">div>
<p id="status">p>
main>
<script type="module">
// transformers提供js库(https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0)
//从 https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0 导入 pipeline 和 env
import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
//禁止加载本地模型,加载远程模型
env.allowLocalModels = false;
//document.getElementById 是 js 中用于通过元素的 ID 来获取特定 HTML 元素的方法
const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')
//添加事件监听器来响应元素上的特定事件
//在fileUpload元素上添加一个change事件监听。当用户更改上传文件时,触发事件处理函数。
fileUpload.addEventListener('change', function (e) {
//e是事件对象
//e.target是触发事件的文件上传元素。
//e.target.files[0]是获取选中的第一个文件
const file = e.target.files[0];
// 新建一个FileReader 对象用于读取文件(异步)
//FileReader 是 JavaScript 中的一个对象,用于异步读取用户选择的文件或输入的内容。
const reader = new FileReader();
//reader.onload是一个事件,在读取操作完成时触发
reader.onload = function (e2) {
//用createElement创建一个新的html元素
const image = document.createElement('img');
console.log(e2.target.result);
//将image的scr属性的url值改为e2.target.result
image.src = e2.target.result;
//将 image 元素添加作为子节点到 imageContainer 元素中
imageContainer.appendChild(image)
detect(image) // 启动AI任务 功能模块化,封装出去
}
//用于将指定的文件读取为 DataURL 格式的数据。
reader.readAsDataURL(file)
})
//ai任务
const status = document.getElementById('status');
const detect = async (image) => {
//在id为status的p标签中添加“分析中...请稍等一会”的文本内容
status.textContent = "分析中...请稍等一会"
//object-detection是对象检查任务;Xenova/detr-resnet-50是指定用于对象检测的模型
const detector = await pipeline("object-detection","Xenova/detr-resnet-50") // model 实例化了detector对象
//使用检测器对象对指定的图像源进行检测,并传入了一些参数:
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
//forEach 方法用于遍历数组或类似数组的对象,并对每个元素执行指定的函数
output.forEach(renderBox)
}
//封装;渲染盒子
function renderBox({ box, label }) {
console.log(box, label);
const { xmax, xmin, ymax, ymin } = box
//创建一个盒子
const boxElement = document.createElement("div");
//动态添加div的类名
boxElement.className = "bounding-box"
//通过执行 Object.assign(boxElement.style, {}),可以将一个空对象的属性合并到 boxElement.style 对象中
//准备为 boxElement 设置新的样式属性
Object.assign(boxElement.style, {
borderColor: '#123123',
borderWidth: '1px',
borderStyle: 'solid',
//最左侧
left: 100 * xmin + '%',
//最上侧
top: 100 * ymin + '%',
//宽度
width: 100 * (xmax - xmin) + "%",
//高度
height: 100 * (ymax - ymin) + "%"
})
const labelElement = document.createElement('span');
labelElement.textContent = label;
labelElement.className = "bounding-box-label"
labelElement.style.backgroundColor = '#000000'
//在图片上增加的物品区域范围内添加物品的种类名称的文本内容
boxElement.appendChild(labelElement);
//在图片上增加物品的区域范围
imageContainer.appendChild(boxElement);
}
script>
body>
html>
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nlp之图片识别title>
<style>
.container {
margin: 40px auto;
width: max(50vw, 400px);
display: flex;
flex-direction: column;
align-items: center;
}
.custom-file-upload {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
border: 2px solid black;
padding: 8px 16px;
border-radius: 6px;
}
#file-upload {
display: none;
}
#image-container {
width: 100%;
margin-top: 20px;
position: relative;
}
#image-container>img {
width: 100%;
}
.bounding-box {
position: absolute;
box-sizing: border-box;
}
.bounding-box-label {
position: absolute;
color: white;
font-size: 12px;
}
style>
head>
<body>
<main class="container">
<label for="file-upload" class="custom-file-upload">
<input type="file" accept="image/*" id="file-upload">
上传图片
label>
<div id="image-container">div>
<p id="status">p>
main>
<script type="module">
// transformers提供js库(https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0)
//从 https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0 导入 pipeline 和 env
import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
//禁止加载本地模型,加载远程模型
env.allowLocalModels = false;
//document.getElementById 是 js 中用于通过元素的 ID 来获取特定 HTML 元素的方法
const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')
//添加事件监听器来响应元素上的特定事件
//在fileUpload元素上添加一个change事件监听。当用户更改上传文件时,触发事件处理函数。
fileUpload.addEventListener('change', function (e) {
//e是事件对象
//e.target是触发事件的文件上传元素。
//e.target.files[0]是获取选中的第一个文件
const file = e.target.files[0];
// 新建一个FileReader 对象用于读取文件(异步)
//FileReader 是 JavaScript 中的一个对象,用于异步读取用户选择的文件或输入的内容。
const reader = new FileReader();
//reader.onload是一个事件,在读取操作完成时触发
reader.onload = function (e2) {
//用createElement创建一个新的html元素
const image = document.createElement('img');
console.log(e2.target.result);
//将image的scr属性的url值改为e2.target.result
image.src = e2.target.result;
//将 image 元素添加作为子节点到 imageContainer 元素中
imageContainer.appendChild(image)
detect(image) // 启动AI任务 功能模块化,封装出去
}
//用于将指定的文件读取为 DataURL 格式的数据。
reader.readAsDataURL(file)
})
//ai任务
const status = document.getElementById('status');
const detect = async (image) => {
//在id为status的p标签中添加“分析中...请稍等一会”的文本内容
status.textContent = "分析中...请稍等一会"
//object-detection是对象检查任务;Xenova/detr-resnet-50是指定用于对象检测的模型
const detector = await pipeline("object-detection","Xenova/detr-resnet-50") // model 实例化了detector对象
//使用检测器对象对指定的图像源进行检测,并传入了一些参数:
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
//forEach 方法用于遍历数组或类似数组的对象,并对每个元素执行指定的函数
output.forEach(renderBox)
}
//封装;渲染盒子
function renderBox({ box, label }) {
console.log(box, label);
const { xmax, xmin, ymax, ymin } = box
//创建一个盒子
const boxElement = document.createElement("div");
//动态添加div的类名
boxElement.className = "bounding-box"
//通过执行 Object.assign(boxElement.style, {}),可以将一个空对象的属性合并到 boxElement.style 对象中
//准备为 boxElement 设置新的样式属性
Object.assign(boxElement.style, {
borderColor: '#123123',
borderWidth: '1px',
borderStyle: 'solid',
//最左侧
left: 100 * xmin + '%',
//最上侧
top: 100 * ymin + '%',
//宽度
width: 100 * (xmax - xmin) + "%",
//高度
height: 100 * (ymax - ymin) + "%"
})
const labelElement = document.createElement('span');
labelElement.textContent = label;
labelElement.className = "bounding-box-label"
labelElement.style.backgroundColor = '#000000'
//在图片上增加的物品区域范围内添加物品的种类名称的文本内容
boxElement.appendChild(labelElement);
//在图片上增加物品的区域范围
imageContainer.appendChild(boxElement);
}
script>
body>
html>
结尾
整个代码还存在一些不足之处,还需要不断完善。希望我的文章可以帮助到你们。欢迎点赞评论加关注。
来源:juejin.cn/post/7359084330121789452
我的 Electron 客户端被第三方页面入侵了...
问题描述
公司有个内部项目是用 Electron
来开发的,有个功能需要像浏览器一样加载第三方站点。
本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面。
这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。
if (window.top !== window.self) {
window.top.location = window.location;
}
翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。
奇怪的是两者不是 跨域 了吗,为什么 iframe
还可以影响顶级窗口。
先说一下我当时的一些解决办法:
- 用
webview
替换iframe
- 给
iframe
添加sandbox
属性
后续内容就是一点复盘工作。
场景复现(Web端)
一开始怀疑是客户端的问题,所以我用在纯 Web 上进行了一次对比验证。
这里我们新建两个文件:1.html
和 2.html
,我们称之为 页面A 和 页面B。
然后起了两个本地服务器来模拟同源与跨域的情况。
页面A:http://127.0.0.1:5500/1.html
页面B:http://127.0.0.1:5500/2.html
和 http://localhost:3000/2.html
符合同源策略
<body>
<h1>这是页面A</h1>
<!-- 这是同源的情况 -->
<iframe id="iframe" src="http://127.0.0.1:5500/2.html" />
<script>
iframe.onload = () => {
console.log('iframe loaded..')
console.log('子窗口路径', iframe.contentWindow.location.href)
}
</script>
</body>
<body>
<h2>这是页面B</h2>
<script>
console.log('page2...')
console.log(window === window.top)
console.log('顶部窗口路径', window.top.location.href)
</script>
</body>
我们打开控制台可以看到 页面A 和 页面B 是可以 互相访问 到对方窗口的路径。
如果这个时候在 页面B 加上文章开头提到的 代码片段,那么显然页面将会发生变化。
跨域的情况
这时候我们修改 页面A 加载 页面B 的地址,使其不符合同源策略。
理所应当的是,两个页面不能够相互访问了,这才是正常的,否则内嵌第三方页面可以互相修改,那就太不安全了。
场景复现(客户端)
既然 Web 端是符合预期的,那是不是 Electron
自己的问题呢?
我们通过 electron-vite 快速搭建了一个 React模板的electron应用
,版本为:electron@22.3.27
,并且在 App 中也嵌入了刚才的 页面B。
function App(): JSX.Element {
return (
<>
<h1>这是Electron页面</h1>
<iframe id="iframe" src="http://localhost:3000/2.html"/>
</>
)
}
export default App
对不起,干干净净的 Electron
根本不背这个锅,在它身上的表现如同 Web端 一样,也受同源策略的限制。
那么肯定是我的项目里有什么特殊的配置,通过对比主进程的代码,答案终于揭晓。
new BrowserWindow({
...,
webPreferences: {
...,
webSecurity: false // 就是因为它
}
})
Electron 官方文档 里是这么描述 webSecurity
这个配置的。
webSecurity
boolean (可选) - 当设置为false
, 它将禁用同源策略 (通常用来测试网站), 如果此选项不是由开发者设置的,还会把allowRunningInsecureContent
设置为true
. 默认值为true
。
也就是说,Electron
本身是有一层屏障的,但当该属性设置为 false
的时候,我们的客户端将会绕过同源策略的限制,这层屏障也就消失了,因此 iframe
的行为表现得像是嵌套了同源的站点一样。
解决方案
把这个配置去掉,确实是可以解决这个问题,但考虑到可能对其他功能造成的影响,只能采取其他方案。
如文章开头提到的,用 webview
替换 iframe
。
webview
是 Electron
的一个自定义元素(标签),可用于在应用程序中嵌入第三方网页,它默认开启安全策略,直接实现了主应用与嵌入页面的隔离。
因为目前这个需求是仅作展示,不需要与嵌套页面进行交互以及复杂的通信,因此在一开始的开发过程中,并没有使用它,而是直接采用了 iframe
。
而 iframe
也能够实现类似的效果,只需要添加一个 sandbox
属性可以解决。
MDN 中提到,sandbox
控制应用于嵌入在 <iframe>
中的内容的限制。该属性的值可以为空以应用所有限制,也可以为空格分隔的标记以解除特定的限制。
如此一来,就算是同源的,两者也不会互相干扰。
总结
这不是一个复杂的问题,发现后及时修复了,并没有造成很大的影响(还好是自己人用的平台)。
写这篇文章的主要目的是为了记录这次事件,让我意识到在平时开发过程中,把注意力过多的放在了 业务
、样式
、性能
等这些看得见的问题上,可能很少关注甚至忽略了 安全
这一要素,以为前端框架能够防御像 XSS
这样的攻击就能安枕无忧。
谨记,永远不要相信第三方,距离产生美。
如有纰漏,欢迎在评论区指出。
来源:juejin.cn/post/7398418805971877914
Electron 30.0.0
作者: clavin / VerteDinde
译者: ylduang
Electron 30.0.0 已发布! 它包括对 Chromium 124.0.6367.49
、V8 12.4
和 Node.js 20.11.1
的升级。
Electron 团队很高兴发布了 Electron 30.0.0 ! 你可以通过 npm install electron@latest
或者从我们的发布网站下载它。继续阅读此版本的详细信息。
如果您有任何反馈,请在 Twitter 或 Mastodon 上与我们分享,或加入我们的 Discord 社区! Bug 和功能请求可以在 Electron 的问题跟踪器中报告。
重要变化
重点内容
- Windows 现在支持 ASAR 完整性检查 (#40504)
- 启用 ASAR 完整性的现有应用程序如果配置不正确,可能无法在 Windows 上工作。使用 Electron 打包工具的应用应该升级到
@electron/packager@18.3.1
或@electron/forge@7.4.0
。 - 查看我们的 ASAR Integrity 教程 以获取更多信息。
- 启用 ASAR 完整性的现有应用程序如果配置不正确,可能无法在 Windows 上工作。使用 Electron 打包工具的应用应该升级到
- 添加了
WebContentsView
和BaseWindow
主进程模块,废弃并替换BrowserView
(#35658)
BrowserView
现在是WebContentsView
的一个壳,并且旧的实现已被移除。- 查看 我们的 Web Embeds 文档 以便将新的
WebContentsView
API 和其他类似 API 进行比较。
- 实现了对 File System API 的支持 (#41827)
架构(Stack)更新
- Chromium
124.0.6367.49
- Chrome 124 和 DevTools 124 中的新功能
- Chrome 123 和 DevTools 123 中的新功能
- Node
20.11.1
- V8
12.4
Electron 30 将 Chromium 从 122.0.6261.39
升级到 124.0.6367.49
, Node 从 20.9.0
升级到 20.11.1
以及 V8 从 12.2
升级到 12.4
。
新特性
- 在 webviews 中添加了
transparent
网页偏好设置。(#40301) - 在 webContents API 上添加了一个新的实例属性
navigationHistory
,配合navigationHistory.getEntryAtIndex
方法,使应用能够检索浏览历史中任何导航条目的 URL 和标题。(#41662) - 新增了
BrowserWindow.isOccluded()
方法,允许应用检查窗口是否被遮挡。(#38982) - 为工具进程中
net
模块发出的请求添加了代理配置支持。(#41417) - 添加了对
navigator.serial
中的服务类 ID 请求的蓝牙端口的支持。(#41734) - 添加了对 Node.js
NODE_EXTRA_CA_CERTS
命令行标志的支持。(#41822)
重大更改
行为变更:跨源 iframe 现在使用 Permission Policy 来访问功能。
跨域 iframe 现在必须通过 allow
属性指定一个给定 iframe
可以访问的功能。
有关更多信息,请参见 文档。
移除:--disable-color-correct-rendering
命令行开关
此开关从未正式文档化,但无论如何这里都记录了它的移除。Chromium 本身现在对颜色空间有更好的支持,因此不再需要该标志。
行为变更:BrowserView.setAutoResize
在 macOS 上的行为
在 Electron 30 中,BrowserView 现在是围绕新的 WebContentsView API 的包装器。
以前,BrowserView
API 的 setAutoResize
功能在 macOS 上由 autoresizing 支持,并且在 Windows 和 Linux 上由自定义算法支持。
对于简单的用例,比如使 BrowserView 填充整个窗口,在这两种方法的行为上是相同的。
然而,在更高级的情况下,BrowserViews 在 macOS 上的自动调整大小与在其他平台上的情况不同,因为 Windows 和 Linux 的自定义调整大小算法与 macOS 的自动调整大小 API 的行为并不完全匹配。
自动调整大小的行为现在在所有平台上都标准化了。
如果您的应用使用 BrowserView.setAutoResize
做的不仅仅是使 BrowserView 填满整个窗口,那么您可能已经有了自定义逻辑来处理 macOS 上的这种行为差异。
如果是这样,在 Electron 30 中不再需要这种逻辑,因为自动调整大小的行为是一致的。
移除:WebContents
上 context-menu
的 params.inputFormType
属性
WebContents
的 context-menu
事件中 params 对象的 inputFormType
属性已被移除。请改用新的 formControlType
属性。
移除:process.getIOCounters()
Chromium 已删除对这些信息的访问。
终止对 27.x.y 的支持
根据项目的支持政策,Electron 27.x.y 已经达到了支持的终点。我们鼓励开发者将应用程序升级到更新的 Electron 版本。
E30(24 年 4 月) | E31 (24 年 6 月) | E26(24 年 8 月) |
---|---|---|
30.x.y | 31.x.y | 32.x.y |
29.x.y | 30.x.y | 31.x.y |
28.x.y | 29.x.y | 30.x.y |
接下来
在短期内,您可以期待团队继续专注于跟上构成 Electron 的主要组件的开发,包括 Chromium、Node 和 V8。
您可以在此处找到 Electron 的公开时间表。
有关这些和未来变化的更多信息可在计划的突破性变化页面找到。
原文: Electron 30.0.0
来源:juejin.cn/post/7361426249380397068
Sa-Token v1.41.0 发布 🚀,来看看有没有令你心动的功能!
Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、单点登录、OAuth2.0、微服务网关鉴权 等一系列权限相关问题。🔐
目前最新版本 v1.41.0
已推送至 Maven
中央仓库 🎉,大家可以通过如下方式引入:
<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.41.0</version>
</dependency>
该版本包含大量 ⛏️️️新增特性、⛏️底层重构、⛏️️️代码优化 等,下面容我列举几条比较重要的更新内容供大家参阅:
🛡️ 更新点1:防火墙模块新增 hooks 扩展机制
本次更新针对防火墙新增了多条校验规则,之前的规则为:
- path 白名单放行。
- path 黑名单拦截。
- path 危险字符校验。
本次新增规则为:
- path 禁止字符校验。
- path 目录遍历符检测(优化了检测算法)。
- 请求 host 检测。
- 请求 Method 检测。
- 请求 Header 头检测。
- 请求参数检测。
并且本次更新开放了 hooks 机制,允许开发者注册自定义的校验规则 🛠️,参考如下:
@PostConstruct
public void saTokenPostConstruct() {
// 注册新 hook 演示,拦截所有带有 pwd 参数的请求,拒绝响应
SaFirewallStrategy.instance.registerHook((req, res, extArg)->{
if(req.getParam("pwd") != null) {
throw new FirewallCheckException("请求中不可包含 pwd 参数");
}
});
}
文档直达地址:Sa-Token 防火墙 🔗
💡 更新点2:新增基于 SPI 机制的插件体系
之前在 Sa-Token 中也有插件体系,不过都是利用 SpringBoot 的 SPI 机制完成组件注册的。
这种注册机制有一个问题,就是插件只能在 SpringBoot 环境下正常工作,在其它环境,比如 Solon 项目中,就只能手动注册插件才行 😫。
也就是说,严格来讲,这些插件只能算是 SpringBoot 的插件,而非 Sa-Token 框架的插件 🌐。
为了提高插件的通用性,Sa-Token 设计了自己的 SPI 机制,使得这些插件可以在更多的项目环境下正常工作 🚀。
第一步:实现插件注册类,此类需要 implements SaTokenPlugin
接口 👨💻:
/**
* SaToken 插件安装:插件作用描述
*/
public class SaTokenPluginForXxx implements SaTokenPlugin {
@Override
public void install() {
// 书写需要在项目启动时执行的代码,例如:
// SaManager.setXxx(new SaXxxForXxx());
}
}
第二步:在项目的 resources\META-INF\satoken\
文件夹下 📂 创建 cn.dev33.satoken.plugin.SaTokenPlugin
文件,内容为该插件注册类的完全限定名:
cn.dev33.satoken.plugin.SaTokenPluginForXxx
这样便可以在项目启动时,被 Sa-Token 插件管理器加载到此插件,执行插件注册类的 install 方法,完成插件安装 ✅。
文档直达地址:Sa-Token 插件开发指南 🔗
🎛️ 更新点3:重构缓存体系,将数据读写与序列化操作分离
在之前的版本中,Redis 集成通常和具体的序列化方式耦合在一起,这不仅让 Redis 相关插件产生大量的重复冗余代码,也让大家在选择 Redis 插件时严重受限。⚠️
本次版本更新彻底重构了此模块,将数据读写与序列化操作分离,使其每一块都可以单独自定义实现类,做到灵活扩展 ✨,例如:
- 1️⃣ SaTokenDao 数据读写可以选择:RedisTemplate、Redisson、ConcurrentHashMap、Hutool-Timed-Cache 等不同实现类。
- 2️⃣ SaSerializerTemplate 序列化器可以选择:Base64编码、Hex编码、ISO-8859-1编码、JSON序列化等不同方式。
- 3️⃣ JSON 序列化可以选择:Jackson、Fastjson、Snack3 等组件。
所有实现类均可以按需选择,自由搭配,大大提高灵活性🏗️。
⚙️️ 更新点4:SaLoginParameter 登录参数类新增大量配置项
SaLoginParameter (前SaLoginModel) 用于控制登录操作中的部分细节行为,本次新增的配置项有:
- isConcurrent:决定是否允许同一账号多地同时登录(为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)。🌍
- isShare:在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)。🔄
- maxLoginCount:同一账号最大登录数量,超出此数量的客户端将被自动注销,-1代表不限制数量。🚫
- maxTryTimes:在创建 token 时的最高循环次数,用于保证 token 唯一性(-1=不循环尝试,直接使用。⏳
- deviceId:此次登录的客户端设备id,用于判断后续某次登录是否为可信任设备。📱
- terminalExtraData:本次登录挂载到 SaTerminalInfo 的自定义扩展数据。📦
以上大部分配置项在之前的版本中也有支持,不过它们都被定义在了全局配置类 SaTokenConfig 之上,本次更新支持在 SaLoginParameter 中定义这些配置项,
这将让登录策略的控制变得更加灵活。✨
🚪 更新点5:新增 SaLogoutParameter 注销参数类
SaLogoutParameter 用于控制注销操作中的部分细节行为️,例如:
通过 Range
参数决定注销范围 🎯:
// 注销范围: TOKEN=只注销当前 token 的会话,ACCOUNT=注销当前 token 指向的 loginId 其所有客户端会话
StpUtil.logout(new SaLogoutParameter().setRange(SaLogoutRange.TOKEN));
通过 DeviceType
参数决定哪些登录设备类型参与注销 💻:
// 指定 10001 账号,所有 PC 端注销下线,其它端如 APP 端不受影响
StpUtil.logout(10001, new SaLogoutParameter().setDeviceType("PC"));
还有其它参数此处暂不逐一列举,文档直达地址:Sa-Token 登录参数 & 注销参数 🔗
🐞 更新点6:修复 StpUtil.setTokenValue("xxx")
、loginParameter.getIsWriteHeader()
空指针的问题。
这个没啥好说的,有 bug 🐛 必须修复。
fix issue:#IBKSM0 🔗
✨ 更新点7:API 参数签名模块升级
- 1、新增了 @SaCheckSign 注解,现在 API 参数签名模块也支持注解鉴权了。🆕
- 2、新增自定义签名的摘要算法,现在不仅可以 md5 算法计算签名,也支持 sha1、sha256 等算法了。🔐
- 3、新增多应用模式:
多应用模式就是指,允许在对接多个系统时分别使用不同的秘钥等配置项,配置示例如下 📝:
sa-token:
# API 签名配置 多应用模式
sign-many:
# 应用1
xm-shop:
secret-key: 0123456789abcdefg
digest-algo: md5
# 应用2
xm-forum:
secret-key: 0123456789hijklmnopq
digest-algo: sha256
# 应用3
xm-video:
secret-key: 12341234aaaaccccdddd
digest-algo: sha512
然后在签名时通过指定 appid 的方式获取对应的 SignTemplate 进行操作 👨💻:
// 创建签名示例
String paramStr = SaSignMany.getSignTemplate("xm-shop").addSignParamsAndJoin(paramMap);
// 校验签名示例
SaSignMany.getSignTemplate("xm-shop").checkRequest(SaHolder.getRequest());
⚡ 更新点8:新增 sa-token-caffeine 插件,用于整合 Caffeine
Caffeine 是一个基于 Java 的高性能本地缓存库,本次新增 sa-token-caffeine 插件用于将 Caffeine 作为 Sa-Token 的缓存层,存储会话鉴权数据。🚀
这进一步丰富了 Sa-Token 的缓存层插件生态。🌱
<!-- Sa-Token 整合 Caffeine -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-caffeine</artifactId>
<version>1.41.0</version>
</dependency>
🎪 更新点9:新增 sa-token-serializer-features 序列化扩展包
引入此插件可以为 Sa-Token 提供一些有意思的序列化方案。(娱乐向,不建议上生产 🎭)
例如:以base64 编码,采用:元素周期表 🧪、特殊符号 🔣、或 emoji 😊 作为元字符集存储数据 :
📜 完整更新日志
除了以上提到的几点以外,还有更多更新点无法逐一详细介绍,下面是 v1.41.0 版本的完整更新日志:
- core:
- 修复:修复
StpUtil.setTokenValue("xxx")
、loginParameter.getIsWriteHeader()
空指针的问题。 fix: #IBKSM0 - 修复:将
SaDisableWrapperInfo.createNotDisabled()
默认返回值封禁等级改为 -2,以保证向之前版本兼容。 - 新增:新增基于 SPI 的插件体系。 [重要]
- 重构:JSON 转换器模块。 [重要]
- 新增:新增 serializer 序列化模块,控制
Object
与String
的序列化方式。 [重要] - 重构:重构防火墙模块,增加 hooks 机制。 [重要]
- 新增:防火墙新增:请求 path 禁止字符校验、Host 检测、请求 Method 检测、请求头检测、请求参数检测。重构目录遍历符检测算法。
- 重构:重构
SaTokenDao
模块,将序列化与存储操作分离。 [重要] - 重构:重构
SaTokenDao
默认实现类,优化底层设计。 - 新增:
isLastingCookie
配置项支持在全局配置中定义了。 - 重构:
SaLoginModel
->SaLoginParameter
。 [不向下兼容] - 重构:
TokenSign
->SaTerminalInfo
。 [不向下兼容] - 新增:
SaTerminalInfo
新增extraData
自定义扩展数据设置。 - 新增:
SaLoginParameter
支持配置isConcurrent
、isShare
、maxLoginCount
、maxTryTimes
。 - 新增:新增
SaLogoutParameter
,用于控制注销会话时的各种细节。 [重要] - 新增:新增
StpLogic#isTrustDeviceId
方法,用于判断指定设备是否为可信任设备。 - 新增:新增
StpUtil.getTerminalListByLoginId(loginId)
、StpUtil.forEachTerminalList(loginId)
方法,以更方便的实现单账号会话管理。 - 升级:API 参数签名配置支持自定义摘要算法。
- 新增:新增
@SaCheckSign
注解鉴权,用于 API 签名参数校验。 - 新增:API 参数签名模块新增多应用模式。 fix: #IAK2BI, #I9SPI1, #IAC0P9 [重要]
- 重构:全局配置
is-share
默认值改为 false。 [不向下兼容] - 重构:踢人下线、顶人下线默认将删除对应的 token-session 对象。
- 优化:优化注销会话相关 API。
- 重构:登录默认设备类型值改为 DEF。 [不向下兼容]
- 重构:
BCrypt
标注为@Deprecated
。 - 新增:
sa-token-quick-login
支持SpringBoot3
项目。 fix: #IAFQNE、#673 - 新增:
SaTokenConfig
新增replacedRange
、overflowLogoutMode
、logoutRange
、isLogoutKeepFreezeOps
、isLogoutKeepTokenSession
配置项。
- 修复:修复
- OAuth2:
- 重构:重构 sa-token-oauth2 插件,使注解鉴权处理器的注册过程改为 SPI 插件加载。
- 插件:
- 新增:
sa-token-serializer-features
插件,用于实现各种形式的自定义字符集序列化方案。 - 新增:
sa-token-fastjson
插件。 - 新增:
sa-token-fastjson2
插件。 - 新增:
sa-token-snack3
插件。 - 新增:
sa-token-caffeine
插件。
- 新增:
- 单元测试:
- 新增:
sa-token-json-test
json 模块单元测试。 - 新增:
sa-token-serializer-test
序列化模块单元测试。
- 新增:
- 文档:
- 新增:QA “多个项目共用同一个 redis,怎么防止冲突?”
- 优化:补全 OAuth2 模块遗漏的相关配置项。
- 优化:优化 OAuth2 简述章节描述文档。
- 优化:完善 “SSO 用户数据同步 / 迁移” 章节文档。
- 修正:补全项目目录结构介绍文档。
- 新增:文档新增 “登录参数 & 注销参数” 章节。
- 优化:优化“技术求助”按钮的提示文字。
- 新增:新增
preview-doc.bat
文件,一键启动文档预览。 - 完善:完善 Redis 集成文档。
- 新增:新增单账号会话查询的操作示例。
- 新增:新增顶人下线 API 介绍。
- 新增:新增 自定义序列化插件 章节。
- 其它:
- 新增:新增
sa-token-demo/pom.xml
以便在 idea 中一键导入所有 demo 项目。 - 删除:删除不必要的
.gitignore
文件 - 重构:重构
sa-token-solon-plugin
插件。 - 新增:新增设备锁登录示例。
- 新增:新增
更新日志在线文档直达链接:sa-token.cc/doc.html#/m…
🌟 其它
代码仓库地址:gitee.com/dromara/sa-…
框架功能结构图:
来源:juejin.cn/post/7484191942358499368
直观理解时下大热的 MCP 协议
得益于 Cursor 从 v0.45.x 开始支持 Anthropic MCP 协议,最近 MCP server 的概念很火热。我想聊聊对这个协议的感受。
MCP 是什么?
MCP = Model Context Protocol = 模型上下文协议
说白了,它就是个「插件协议」,严谨点加个限定词,「专供 LLM 应用的插件接口协议」。
Anthropic 官方说 MCP 是受微软的 LSP (Language Service Protocol) 的启发而制定,有朋友熟悉 LSP 协议的话,应该马上会发现这两者极为相似。
给不了解 LSP 的朋友介绍一下。VSCode 大家都熟,可以装各种插件。因为 VSCode 是用 JS 写的,插件要运行在 VSCode 之内,所以也必须用 JS 写。
但有一类插件比较特殊:编程语言支持类插件。比如你想在 VSCode 里写 rust,肯定要装 rust 相关插件。可问题是 rust 官方的语言支持(提供错误提示、代码自动补全之类的功能)肯定也是用 rust 写的,无法直接跑在 VSCode 的运行时里。别的语言 C#、Java、Python 情况也一样,怎么办呢?
为了解决这问题,LSP 制定了一套基于 JSON-RPC 2.0 的标准协议。RPC 顾名思义「远程调用」,那些语言工具你爱跑在哪都行,只要你按照这协议,能接受 RPC 请求,能给出正确返回数据格式,那么就能顺利接入 VSCode。
这套协议带来的价值有三个点:
- 这是个开放标准,市面上那么多 IDE 编辑器,都有语言支持需求,大家都用这套标准的话,很快可以形成开放插件生态。
- 把插件和消费它的客户端解耦合了。按照 LSP 标准写插件,你不需要关心你服务的客户端到底是 VSCode 还是 JetBrain 还是 Vim,只要这些客户端支持 LSP,那你的插件都能接入,不需要挨个适配。
- LSP 协议本身预设了很多跟编程语言支持相关的「标准功能」。例如最常见的代码自动补全
"textDocument/completion"
,或者点击跳转到函数定义"textDocument/definition"
等等。这些都是跨语言、广泛存在的需求,是编程语言业界多年积累下来的集体经验。假如你自己哪天创造了个新的编程语言,要写配套的语言支持工具,那么你不用闭门造车,对着 LSP 协议,把里列举的所有「标准功能」挨个实现一遍,这妥妥的就是「语言支持工具界的最佳实践」了。
所以 MCP 到底是什么…
之所以在 LSP 上费这么多字,是希望能借用一个大家熟悉的老概念,快速对 MCP 这个新概念建立起一个直观的认识。
回到 MCP,它也是一个基于 JSON-RPC 2.0 的标准协议,LSP 有的那些优点它也有:
- 开放标准:语言无关,实现无关,有助形成开放生态
- 解耦合:只要客户端支持,你的 MCP Server 都能接入,不用多次适配
- 最佳实践:参考「标准功能」,能借鉴行业集体经验,少走弯路
我认为「标准功能」,官方称为「能力(capability)」,是 MCP 价值比较大的东西,尤其对于开发 LLM 应用的朋友来说,支持这些能力基本上就跟 Cursor 在底层的 agent 工具层面上对齐了。
MCP 不是什么
MCP 不是 agent 框架,MCP 也不是 RAG 框架,它甚至都不是框架!尽管官方有提供 SDK,但 MCP 本身只是一个标准协议,目的是构建一个给 LLM/agent 用的「外接能力插件生态」。
不过 MCP 的标准设计里没有考虑 RAG 能力,是让我比较困惑的点。
能力 Capability
理解这个小节,我建议脑子里可以想着 Cursor 作为「LLM 应用」的范本。
client 端能力
roots
当前项目路径列表,对标 IDE 里的 workspace/project 概念,主要用来通知 server 端更新 resources(见下文)sampling
供 server 调用 client 侧 LLM 的能力
server 端能力
tools
任意的外部工具:计算器、代码运行、搜索引擎之类prompts
提示词模版,设计目的是为了支持类似 Github Copilot Chat 聊天框/
开头的快捷指令
resources
当前项目下有什么资源可访问(主要是文件啦)。Cursor/Cline 聊天框@/foobar.txt
就可以用这项能力来实现
completion
自动补全,快捷指令和资源都需要,提升用户体验logging
给到 client 的 log 信息推送,这个属于杂项,方便 debug 之类
其他
resources
不只能建模文件,也可以建模 git 历史,数据库表等其他资源,只需要 uri 上通过"git://"
或"db://"
来区分即可- 两端都支持自定义能力,通过
experimental
namespace 来暴露。 - 前面提过 MCP 没考虑 RAG 的用例,目前看来似乎可以通过
prompts
+completion
能力来间接实现。
通讯模型
MCP 是一个 client/server 架构的 RPC 协议,需要关注两端的通讯模型。大致可分三段生命周期来看:初始化阶段,运行阶段,结束阶段。不过先铺垫两个前置知识,方便后面的理解。
前置知识
一、实体术语定义
一共有三类实体:host, client, server.
host 指「LLM 应用」的本体,它大概率是个 GUI 程序。在 MCP 的语境下,这就是一个容器,负责管理多个 client 实例,同时要集成 LLM,承接用户交互,特别是各种授权的工作。
每个 client 实例只负责与一个 server 建立有状态的连接,然后进行 RPC 通讯。server 是实际干活的、跑插件的线程,client 是留在 host 内负责 RPC 调用的一段简单的程序。
当前的协议版本下(版本号:2024-11-05
,你没看错,它是用日期来做版本号的)
- host 与 client 是一对多关系
- client 与 server 是一对一关系
这里插一句,「一对一」的奇怪设定是暂时的。目前 MCP 只针对 client/server 都跑在本地的场景设计,官方 SDK 在使用 stdio 为传输信道的时候,更是做了个「由 client fork 子进程来跑 server」的强假设。好在 roadmap 里面有提,支持 remote server 是眼下的第一优先级,预计 2025 上半年会更新相关标准。
二、JSON-RPC 2.0 的三种信息类型
JSON-RPC 2.0 标准有三类 RPC 信息类型:request, response, notification. 注意几个点:
- request 必须有对应 response,
id
要对得上 - response 的
result
和error
字段互斥,同时只可能有其一 - response
error.code
必须是整数,并且协议预留了一批错误码,代表特定含义(类似 HTTP status code) - notification 只比 request 少一个
id
, 并且不要求有对应 response
type JsonRpcRequest = {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: {
[key: string]: unknown;
};
};
type JsonRpcResponse = {
jsonrpc: "2.0";
id: string | number;
// result 与 error 是互斥的
result?: {
[key: string]: unknown;
};
error?: {
code: number;
message: string;
data?: unknown;
};
};
type JsonRpcNotification = {
jsonrpc: "2.0";
method: string;
params?: {
[key: string]: unknown;
};
};
三个通讯生命周期
一、初始化阶段 Initialization
client/server 需要握手协商,交换各自能力(capability)声明,跟 TCP 的三次握手基本一样。
第一次:client 向 server 发送 request,声明 client 侧提供的能力。
Client:「Server 老哥在吗?我能干这些,你能干啥?」
第二次:server 向 client 回复 response,声明 server 侧提供的能力。
Server:「Client 老弟,我在呢,我能干这些,需要干啥活你喊我哈!」
第三次:client 向 server 发送 notification,确认连接建立
Client:「得嘞,那我开始干活了,有事儿我再喊你。」
二、运行阶段 Operation
根据初始化阶段交换的能力声明,两端开始互相发送 RPC 信息。这里展示一段能力调用示例。
// 1. server 在初始化阶段,第二次握手时,向 client 公布自己的 tools 能力
{
"capabilities": {
"tools": {
"listChanged": true
}
}
}
// 2. client 初始化后,主动拉取 tools 列表
// Request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
// 可选参数,list 如果很长,可支持翻页
"cursor": "optional-cursor-value"
}
}
// Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "get_weather",
"description": "Get current weather information for a location",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City name or zip code"
}
},
"required": ["location"]
}
}
],
"nextCursor": "next-page-cursor"
}
}
// 3. client 调用工具
// Request:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"location": "New York"
}
}
}
// Response:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy"
}
],
"isError": false
}
}
// 4. 如果 server 端因为什么原因,可用 tools list 发生变化,应该通知 client 重新拉取
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
}
三、结束阶段 Shutdown
标准只说任何一端(正常来说是 client 端)可以主动断开连接,没有硬性规定这个阶段的具体协议。因为传输层通常会有相关的断联信号,已经够用了,没必要再在上层协议重复建设。
但是实际写落地实现,开发者还是需要做一些处理的,比如 graceful shutdown, 或者错误重启之类的。
总结
目前整个 AI 应用范式没有固定下来,整个业界都在积极探索,摸着石头过河。这个背景下 MCP 相当于把 AI 应用厂商们拉了个大群,一起来总结业界的最佳实践,制定标准推广集体智慧。当前 MCP 的生态发展势头很不错,标准本身更新得也很紧跟潮流。最近当红炸子鸡 Cursor 的加入,可以说是对 MCP 的重大利好,势必会进一步刺激 MCP server(插件)生态的成长。
现在正在做 LLM 相关应用的朋友,我非常推荐拥抱这个协议标准,好处多多。
- 首先协议本身很薄不复杂,看不出有技术上的坑。同时官方也有 SDK 可用,支持的难度不高。
- 其次可以拥抱生态,快速接入第三方插件,增强自身产品竞争力。
- 最后,让自己的应用去支持协议要求,等于是跟进业界最佳实践了,避免闭门造车走死胡同。
如果觉得本文对你有帮助,欢迎转发和关注(微信公众号同名),我会持续分享在开发 multi-agent 系统过程中的第一手经验和心得。
来源:juejin.cn/post/7478841799004700683
我的 Electron 客户端也可以全量/增量更新了
前言
本文主要介绍 Electron
客户端应用的自动更新,包括全量和增量这两种方式。
全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。
增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。
本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。
如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。
前置说明:
- 由于业务场景的限制,本文介绍的更新仅支持
Windows
操作系统,其余操作系统未作兼容处理。 - 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。
- 发布方式限制为
generic
,线上服务需要配置nginx
确保访问到资源文件。
准备工作
脚手架搭建项目
我们通过 electron-vite 快速搭建一个基于 Vite + React + TS
的 Electron
项目。
该模板已经包括了我们需要的核心第三方库:electron-builder
,electron-updater
。
前者是用来打包客户端程序的,后者是用来实现自动更新的。
在项目根目录下,已经自动生成了两份配置文件:electron-builder.yml
和 dev-app-update.yml
。
electron-builder.yml
该文件描述了一些 打包配置,更多信息可参考 官网。
在这些配置项中,publish
字段比较重要,因为它关系到更新源。
publish:
provider: generic // 使用一个通用的 HTTP 服务器作为更新源。
url: https://example.com/auto-updates // 存放安装包和描述文件的地址
provider
字段还有其他可选项,但是本文只介绍 generic
这种方式,即把安装包放在 HTTP 服务器里。
dev-app-update.yml
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-update-demo-updater
其中,updaterCacheDirName
定义下载目录,也就是安装包存放的位置。全路径是C:\Users\用户名\AppData\Local\electron-update-demo-updater
,不配置则在C:\Users\用户名\AppData\Local
下自动创建文件夹,开发环境下为项目名
,生产环境下为项目名-updater
。
模拟服务器
我们直接运行 npm run build:win
,在默认 dist
文件夹下就出现了打包后的一些资源。
其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。
因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。
新建一个文件夹 mockServer
,把打包后的 setup.exe
安装包和 latest.yml
文件粘贴进去,然后通过 serve
命令默认起了一个 http://localhose:3000
的本地服务器。
既然有了存放资源的本地地址,在开发调试的时候,我们更新下 dev-app-update.yml
文件的 url
字段,也就是修改为 http://localhose:3000
。
注:如果需要测试打包后的更新功能,需要同步修改打包配置文件。
全量更新
与主进程文件同级,创建 update.ts
文件,之后我们的更新逻辑将在这里展开。
import { autoUpdater } from 'electron-updater' //核心库
需要注意的是,在我们开发过程中,通过 npm run dev
起来的 Electron
程序其实不能算是打包后的状态。
你会发现在调用 autoUpdater
的一些方法会提示下面的错误:
Skip checkForUpdates because application is not packed and dev update config is not forced
因此我们需要在开发环境去做一些动作,并且手动指定 updateConfigPath
:
// update.ts
if (isDev) {
Object.defineProperty(app, 'isPackaged', {
get: () => true
})
autoUpdater.updateConfigPath = path.join(__dirname, '../../dev-app-update.yml')
}
核心对象 autoUpdater
有很多可以主动调用的方法,也有一些监听事件,同时还有一些属性可以设置。
这里只展示了本人项目场景所需的一些配置。
autoUpdater.autoDownload = false // 不允许自动下载更新
autoUpdater.allowDowngrade = true // 允许降级更新(应付回滚的情况)
autoUpdater.checkForUpdates()
autoUpdater.downloadUpdate()
autoUpdater.quitAndInstall()
autoUpdater.on('checking-for-update', () => {
console.log('开始检查更新')
})
autoUpdater.on('update-available', info => {
console.log('发现更新版本')
})
autoUpdater.on('update-not-available', info => {
console.log('不需要全量更新', info.version)
})
autoUpdater.on('download-progress', progressInfo => {
console.log('更新进度信息', progressInfo)
})
autoUpdater.on('update-downloaded', () => {
console.log('更新下载完成')
})
autoUpdater.on('error', errorMessage => {
console.log('更新时出错了', errorMessage)
})
在监听事件里,我们可以拿到下载新版本 整个生命周期 需要的一些信息。
// 新版本的版本信息(已筛选)
interface UpdateInfo {
readonly version: string;
releaseName?: string | null;
releaseNotes?: string | Array<ReleaseNoteInfo> | null;
releaseDate: string;
}
// 下载安装包的进度信息
interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
在写完这些基本的方法之后,我们需要决定 检验更新 的时机,一般是在应用程序真正启动之后,即 mainWindow
创建之后。
运行项目,预期会提示 不需要全量更新
,因为刚才复制到本地服务器的 latest.yml
文件里的版本信息与本地相同。修改 version
字段,重启项目,主进程就会提示有新版本需要更新了。
频繁启动应用太麻烦,除了应用初次启动时主进程主动帮我们检验更新外,还需要用户 手动触发 版本更新检测,此外,由于产品场景需要,当发现有新版本的时候,需要在 渲染进程 通知用户,包括版本更新时间和更新内容。因此我们需要加上主进程与渲染进程的 IPC通信
来实现这个功能。
其实需要的数据主进程都能够提供了,渲染进程具体的展示及交互方式就自由发挥了,这里简单展示下我做的效果。
1. 发现新版本
2. 无需更新
增量更新
为什么要这么做
其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater
封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。
此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知
的形式,只不过我们更新的不是整个应用程序。
由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron
给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台
等特性的同时,还得忍受 臃肿的安装包 。
带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer
,并不包括 dll
、第三方SDK
等资源。
网上有挺多种增量更新的 解决方案,例如:
- 通过
win.loadURL(一个线上地址)
实现,相当于就套了一层客户端的壳子,与加载的Web
端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用node
去操作一些底层的东西。 - 设置
asar
的归档方式,替换app.asar
或app.asar.unpack
来实现。但后者在我实践过程中存在文件路径不存在的问题。 - 禁用
asar
归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。 - 欢迎补充。
本文我们采用较普遍的 替换asar 来实现。
优化 app.asar 体积
asar
是Electron
提供的一种将多个文件合并成一个文件的类tar
风格的归档格式,不仅可以缓解Windows
下路径名过长的问题, 还能够略微加快一下require
的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)
Electron
应用程序启动的时候,会读取 app.asar.unpacked
目录中的内容合并到 app.asar
的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar
这个文件。
例如:D:\你的安装路径\electron-update-demo\resources
在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked
文件夹。我们不难发现,app.asar
这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar
其实是一种压缩格式,因此我们只要解压看看就知道了。
npm i -g asar // 全局安装
asar e app.asar folder // 解压到folder文件夹
解压后我们不难发现,out
文件夹里的才是我们打包出来的资源,罪魁祸首竟然是 node_modules
,足足有 62.3 MB。
查阅资料得知,Electron
在打包的时候,会把 dependencies 字段里的依赖也一起打包进去。而我们的渲染进程是用 React
开发的,这些第三方依赖早就通过 Vite
等打包工具打到资源文件里了,根本不需要再打包一次,不然我们重新下载的 app.asar
文件还是很大的,因此需要尽可能减少体积。
优化应用程序体积 == 减少 node_modules
文件夹的大小 == 减少需要打包的依赖数量 == 减少 dependencies
中的依赖。
1. 移除 dependencies
最开始我想的是把 package.json
中的 dependencies
全都移到 devDependencies
,打包后的体积果然减小了,但是在测试过程中会发现某些功能依赖包会缺失,比如 @electron/remote
。因此在修改这部分代码的时候,需要特别留意,哪些依赖是需要保留下来的。
由于不想影响 package.json
的版本结构,我只是在写了一个脚本,在 npm i
之后,执行打包命令前修改 devDependencies
就好了。
2. 双 package.json 结构
这是 electron-builder
官网上看到的一种技巧,传送门, 创建 app
文件夹,再创建第二个 package.json
,在该文件中配置我们应用的名称、版本、主进程入口文件等信息。这样一来,electron-builder
在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。
但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试。
这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。
校验增量更新
全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。
首先明确一下 校验的时机,package.json
的 version
字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available
事件。所以我们可以在这个事件的回调函数里来进行校验。
autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })
然后就是 如何校验,我们回过头来看 electron-builder
的打包配置,在 releaseInfo
字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes
来存储更新日志,查阅官网得知还有个 releaseName
好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor
字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)
每次发布新版本的时候,只要不是 Electron自身版本变化
等重大更新,我们都可以通过修改 releaseInfo
的 releaseName
来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 userData
文件夹,用于存储我们应用程序的用户信息,即使程序卸载 了也不会影响里面的资源。在 Windows
下,路径如下:
C:\Users\用户名\AppData\Roaming\electron-update-demo
。
因此,整个 校验流程 就是,在打开程序的时候,autoUpdater
触发 update-not-available
事件,拿到线上 latest.yml
描述的 releaseName
作为热版本号,与本地配置文件(我们命名为 config.json
)里存储的热版本号(我们命名为 hotVersion
)进行对比,若不同就去下载最新的 app.asar
文件。
我们使用 electron-log
来记录日志,代码如下所示。
// 本地配置文件
const localConfigFile = path.join(app.getPath('userData'), 'config.json')
const checkHotVersion = (latestHotVersion: string): boolean => {
let needDownload = false
if (!fs.existsSync(localConfigFile)) {
fs.writeFileSync(localConfigFile, '{}', 'utf8')
log.info('配置文件不存在,已自动创建')
} else {
log.info('监测到配置文件')
}
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
// 对比版本号
if (latestHotVersion !== configData.hotVersion) {
log.info('当前版本不是最新热更版本')
needDownload = true
} else {
log.info('当前为最新版本')
}
} catch (error) {
log.error('读取配置文件时出错', error)
}
return needDownload
}
下载增量更新包
通过刚才的校验,我们已经知道了什么时候该去下载增量更新包。
在开发调试的时候,我们可以把新版本的 app.asar
也放到起了本地服务器的 mockServer
文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejs
的 http
模块去实现,如果是 https
的需要引用 https
模块。
下载到本地的时候,我是放在了与 app.asar
同级目录的 resources
文件夹,我们不能直接覆盖原文件。一是因为进程权限占用的问题,二是为了容错,所以我们需要以另一个名字来命名下载后的文件(这里我们用的是 app.asar-temp
),也就不需要去备份原文件了,代码如下。
const resourcePath = isDev
? path.join('D:\\测试\\electron-update-demo\\resources')
: process.resourcesPath
const localAsarTemp = path.join(resourcePath, 'app.asar-temp')
const asarUrl = 'http://localhost:3000/app.asar'
downloadAsar() {
const fileStream = fs.createWriteStream(localAsarTemp)
http.get(asarUrl, res => {
res.pipe(fileStream)
fileStream
.on('finish', () => {
log.info('asar下载完成')
})
.on('error', error => {
// 删除文件
fs.unlink(localAsarTemp, () => {
log.error('下载出现异常,已中断', error)
})
})
})
}
因此,我们的流程更新为:发现新版本后,下载最新的 app.asar
到 resources
目录,并重命名为 app.asar-temp
。这个时候我们启动项目进行调试,找到记录在本地的日志—— C:\Users\用户名\AppData\Roaming\electron-update-demo\logs
,会有以下的记录:
[2024-09-20 13:49:22.456] [info] 监测到配置文件
[2024-09-20 13:49:22.462] [info] 当前版本不是最新热更版本
[2024-09-20 13:49:23.206] [info] asar下载完成
在看看项目 resources
文件夹,多了一个 app.asar-temp
文件。
至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。
替换 app.asar 文件
好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。
在 Windows
操作系统下,直接替换 app.asar
会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。
- 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。
- 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。
我们有上面两种方案,最终采用了 方案2
。
在主进程监听 app.on('quit')
事件,在应用退出的时候,判断 app.asar
和 app.asar-temp
是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs
在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。
nodejs
可以通过 spawn
、exec
等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs
,因为业务方的机器上不一定有这个环境,而是采用了启动 exe
可执行文件的方式。可能有人问为什么不直接运行 .bat
批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawn
的 windowsHide: true
。
那么如何获得这个 exe
可执行文件呢,其实是通过 bat
文件去编译的,命令如下:
@echo off
timeout /T 1 /NOBREAK
del /f /q /a %1\app.asar
ren %1\app.asar-temp app.asar
我们加了延时,等待父进程完全关闭后,再去执行后续命令。其中 %1
为运行脚本传入的参数,在我们的场景里就是 resources
文件夹的地址,因为每个人的安装地址应该是不一样的,所以需要作为参数传进去。
转换文件的工具一开始用的是 Bat To Exe Converter
下载地址,但是在实际使用的过程中,我的电脑竟然把转换后的 exe
文件 识别为病毒,但在其他同事电脑上没有这种表现,这肯定就不行了,最后还是同事使用 python
帮我转换生成了一份可用的文件(replace.exe
)。
这里我们可以选择不同的方式把 replace.exe
存放到 用户目录 上,要么是从线上服务器下载,要么是安装的时候就已经存放在本地了。我们选择了后者,这需要修改 electron-builder
打包配置,指定 asarUnpack
, 这样就会存放在 app.asar.unpacked
文件夹中,不经常修改的文件都可以放在这里,不会随着增量更新而替换掉。
有了这个替换脚本之后,开始编写子进程相关的代码。
import { spawn } from 'child_process'
cosnt localExePath = path.join(resourcePath, 'app.asar.unpacked/resources/replace.exe')
replaceAsar() {
if (fs.existsSync(localAsar) && fs.existsSync(localAsarTemp)) {
const command = `${localExePath} ${resourcePath}`
log.info(command)
const logPath = app.getPath('logs')
const childOut = fs.openSync(path.join(logPath, './out.log'), 'a')
const childErr = fs.openSync(path.join(logPath, './err.log'), 'a')
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true, // 允许子进程独立
shell: true,
stdio: ['ignore', childOut, childErr]
})
child.on('spawn', () => log.info('子进程触发'))
child.on('error', err => log.error('child error', err))
child.on('close', code => log.error('child close', code))
child.on('exit', code => log.error('child exit', code))
child.stdout?.on('data', data => log.info('stdout', data))
child.stderr?.on('data', data => log.info('stderr', data))
child.unref()
}
}
app.on('quit', () => {
replaceAsar()
})
在这块代码中,创建子进程的配置项比较重要,尤其是路径相关的。因为用户安装路径是不同的,可能会存在 文件夹名含有空格
的情况,比如 Program Files
,这会导致在执行的时候将空格识别为分隔符,导致命令执行失败,因此需要加上 双引号。此外,detached: true
可以让子进程独立出来,也就是父进程退出后可以执行,而shell: true
可以将路径名作为参数传过去。
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true,
shell: true,
stdio: ['ignore', childOut, childErr]
})
但这块有个 疑惑,为什么我的 close
、exit
以及 stdout
都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。
至此,在关闭应用之后,app.asar
就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion
,防止下次又去下载更新包了。
child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})
updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}
增量更新日志提示
既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里。
至于更新内容,我们可以复用 releaseInfo
的 releaseNotes
字段,把更新日志写在这里,增量更新完成后展现给用户就好了。
但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了
按钮,或者关闭 Modal
后,把当前的热更新版本号保存在本地配置文件,记录为 logVersion
。在下次打开程序或者校验更新的时候,我们判断一下 logVersion === hotVersion
,若不同,再去提示更新日志。
日志版本 校验和修改的代码如下所示:
checkLogVersion() {
let needShowLog = false
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion, logVersion } = configData
if (hotVersion !== logVersion) {
log.info('日志版本与当前热更新版本不同,需要提示更新日志')
needShowLog = true
} else {
log.info('日志已是最新版本,无需提示更新日志')
}
} catch (error) {
log.error('读取配置文件失败', error)
}
return needShowLog
}
updateLogVersion() {
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion } = configData
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion, logVersion: hotVersion }, null, 2))
log.info('日志版本已更新')
} catch (error) {
log.error('读取配置文件失败', error)
}
}
读取 config.json
文件的方法自行封装一下,我这里就重复使用了。主进程 ipcMain.on
监听一下用户传递过来的事件,再去调用 updateLogVersion
即可,渲染进程效果如下:
提示增量更新日志
点击 知道了
后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。
当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes
是否为空的逻辑就好了,也做到了 静默更新。
小结
不足之处
本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:
dll
、第三方SDK
等资源的更新。- 增量更新失败后应该通过全量更新 兜底。
- 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。
流程图
针对本文的解决方案,我简单画了一个 流程图。
参考文章
网上其实有不少关于 Electron
自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。
写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。
鸣谢:
来源:juejin.cn/post/7416311252580352034
我的 Electron 客户端也可以全量/增量更新了
前言
本文主要介绍 Electron
客户端应用的自动更新,包括全量和增量这两种方式。
全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。
增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。
本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。
如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。
前置说明:
- 由于业务场景的限制,本文介绍的更新仅支持
Windows
操作系统,其余操作系统未作兼容处理。 - 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。
- 发布方式限制为
generic
,线上服务需要配置nginx
确保访问到资源文件。
准备工作
脚手架搭建项目
我们通过 electron-vite 快速搭建一个基于 Vite + React + TS
的 Electron
项目。
该模板已经包括了我们需要的核心第三方库:electron-builder
,electron-updater
。
前者是用来打包客户端程序的,后者是用来实现自动更新的。
在项目根目录下,已经自动生成了两份配置文件:electron-builder.yml
和 dev-app-update.yml
。
electron-builder.yml
该文件描述了一些 打包配置,更多信息可参考 官网。
在这些配置项中,publish
字段比较重要,因为它关系到更新源。
publish:
provider: generic // 使用一个通用的 HTTP 服务器作为更新源。
url: https://example.com/auto-updates // 存放安装包和描述文件的地址
provider
字段还有其他可选项,但是本文只介绍 generic
这种方式,即把安装包放在 HTTP 服务器里。
dev-app-update.yml
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-update-demo-updater
其中,updaterCacheDirName
定义下载目录,也就是安装包存放的位置。全路径是C:\Users\用户名\AppData\Local\electron-update-demo-updater
,不配置则在C:\Users\用户名\AppData\Local
下自动创建文件夹,开发环境下为项目名
,生产环境下为项目名-updater
。
模拟服务器
我们直接运行 npm run build:win
,在默认 dist
文件夹下就出现了打包后的一些资源。
其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。
因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。
新建一个文件夹 mockServer
,把打包后的 setup.exe
安装包和 latest.yml
文件粘贴进去,然后通过 serve
命令默认起了一个 http://localhose:3000
的本地服务器。
既然有了存放资源的本地地址,在开发调试的时候,我们更新下 dev-app-update.yml
文件的 url
字段,也就是修改为 http://localhose:3000
。
注:如果需要测试打包后的更新功能,需要同步修改打包配置文件。
全量更新
与主进程文件同级,创建 update.ts
文件,之后我们的更新逻辑将在这里展开。
import { autoUpdater } from 'electron-updater' //核心库
需要注意的是,在我们开发过程中,通过 npm run dev
起来的 Electron
程序其实不能算是打包后的状态。
你会发现在调用 autoUpdater
的一些方法会提示下面的错误:
Skip checkForUpdates because application is not packed and dev update config is not forced
因此我们需要在开发环境去做一些动作,并且手动指定 updateConfigPath
:
// update.ts
if (isDev) {
Object.defineProperty(app, 'isPackaged', {
get: () => true
})
autoUpdater.updateConfigPath = path.join(__dirname, '../../dev-app-update.yml')
}
核心对象 autoUpdater
有很多可以主动调用的方法,也有一些监听事件,同时还有一些属性可以设置。
这里只展示了本人项目场景所需的一些配置。
autoUpdater.autoDownload = false // 不允许自动下载更新
autoUpdater.allowDowngrade = true // 允许降级更新(应付回滚的情况)
autoUpdater.checkForUpdates()
autoUpdater.downloadUpdate()
autoUpdater.quitAndInstall()
autoUpdater.on('checking-for-update', () => {
console.log('开始检查更新')
})
autoUpdater.on('update-available', info => {
console.log('发现更新版本')
})
autoUpdater.on('update-not-available', info => {
console.log('不需要全量更新', info.version)
})
autoUpdater.on('download-progress', progressInfo => {
console.log('更新进度信息', progressInfo)
})
autoUpdater.on('update-downloaded', () => {
console.log('更新下载完成')
})
autoUpdater.on('error', errorMessage => {
console.log('更新时出错了', errorMessage)
})
在监听事件里,我们可以拿到下载新版本 整个生命周期 需要的一些信息。
// 新版本的版本信息(已筛选)
interface UpdateInfo {
readonly version: string;
releaseName?: string | null;
releaseNotes?: string | Array<ReleaseNoteInfo> | null;
releaseDate: string;
}
// 下载安装包的进度信息
interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
在写完这些基本的方法之后,我们需要决定 检验更新 的时机,一般是在应用程序真正启动之后,即 mainWindow
创建之后。
运行项目,预期会提示 不需要全量更新
,因为刚才复制到本地服务器的 latest.yml
文件里的版本信息与本地相同。修改 version
字段,重启项目,主进程就会提示有新版本需要更新了。
频繁启动应用太麻烦,除了应用初次启动时主进程主动帮我们检验更新外,还需要用户 手动触发 版本更新检测,此外,由于产品场景需要,当发现有新版本的时候,需要在 渲染进程 通知用户,包括版本更新时间和更新内容。因此我们需要加上主进程与渲染进程的 IPC通信
来实现这个功能。
其实需要的数据主进程都能够提供了,渲染进程具体的展示及交互方式就自由发挥了,这里简单展示下我做的效果。
1. 发现新版本
2. 无需更新
增量更新
为什么要这么做
其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater
封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。
此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知
的形式,只不过我们更新的不是整个应用程序。
由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron
给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台
等特性的同时,还得忍受 臃肿的安装包 。
带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer
,并不包括 dll
、第三方SDK
等资源。
网上有挺多种增量更新的 解决方案,例如:
- 通过
win.loadURL(一个线上地址)
实现,相当于就套了一层客户端的壳子,与加载的Web
端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用node
去操作一些底层的东西。 - 设置
asar
的归档方式,替换app.asar
或app.asar.unpack
来实现。但后者在我实践过程中存在文件路径不存在的问题。 - 禁用
asar
归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。 - 欢迎补充。
本文我们采用较普遍的 替换asar 来实现。
优化 app.asar 体积
asar
是Electron
提供的一种将多个文件合并成一个文件的类tar
风格的归档格式,不仅可以缓解Windows
下路径名过长的问题, 还能够略微加快一下require
的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)
Electron
应用程序启动的时候,会读取 app.asar.unpacked
目录中的内容合并到 app.asar
的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar
这个文件。
例如:D:\你的安装路径\electron-update-demo\resources
在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked
文件夹。我们不难发现,app.asar
这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar
其实是一种压缩格式,因此我们只要解压看看就知道了。
npm i -g asar // 全局安装
asar e app.asar folder // 解压到folder文件夹
解压后我们不难发现,out
文件夹里的才是我们打包出来的资源,罪魁祸首竟然是 node_modules
,足足有 62.3 MB。
查阅资料得知,Electron
在打包的时候,会把 dependencies 字段里的依赖也一起打包进去。而我们的渲染进程是用 React
开发的,这些第三方依赖早就通过 Vite
等打包工具打到资源文件里了,根本不需要再打包一次,不然我们重新下载的 app.asar
文件还是很大的,因此需要尽可能减少体积。
优化应用程序体积 == 减少 node_modules
文件夹的大小 == 减少需要打包的依赖数量 == 减少 dependencies
中的依赖。
1. 移除 dependencies
最开始我想的是把 package.json
中的 dependencies
全都移到 devDependencies
,打包后的体积果然减小了,但是在测试过程中会发现某些功能依赖包会缺失,比如 @electron/remote
。因此在修改这部分代码的时候,需要特别留意,哪些依赖是需要保留下来的。
由于不想影响 package.json
的版本结构,我只是在写了一个脚本,在 npm i
之后,执行打包命令前修改 devDependencies
就好了。
2. 双 package.json 结构
这是 electron-builder
官网上看到的一种技巧,传送门, 创建 app
文件夹,再创建第二个 package.json
,在该文件中配置我们应用的名称、版本、主进程入口文件等信息。这样一来,electron-builder
在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。
但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试。
这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。
校验增量更新
全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。
首先明确一下 校验的时机,package.json
的 version
字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available
事件。所以我们可以在这个事件的回调函数里来进行校验。
autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })
然后就是 如何校验,我们回过头来看 electron-builder
的打包配置,在 releaseInfo
字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes
来存储更新日志,查阅官网得知还有个 releaseName
好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor
字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)
每次发布新版本的时候,只要不是 Electron自身版本变化
等重大更新,我们都可以通过修改 releaseInfo
的 releaseName
来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 userData
文件夹,用于存储我们应用程序的用户信息,即使程序卸载 了也不会影响里面的资源。在 Windows
下,路径如下:
C:\Users\用户名\AppData\Roaming\electron-update-demo
。
因此,整个 校验流程 就是,在打开程序的时候,autoUpdater
触发 update-not-available
事件,拿到线上 latest.yml
描述的 releaseName
作为热版本号,与本地配置文件(我们命名为 config.json
)里存储的热版本号(我们命名为 hotVersion
)进行对比,若不同就去下载最新的 app.asar
文件。
我们使用 electron-log
来记录日志,代码如下所示。
// 本地配置文件
const localConfigFile = path.join(app.getPath('userData'), 'config.json')
const checkHotVersion = (latestHotVersion: string): boolean => {
let needDownload = false
if (!fs.existsSync(localConfigFile)) {
fs.writeFileSync(localConfigFile, '{}', 'utf8')
log.info('配置文件不存在,已自动创建')
} else {
log.info('监测到配置文件')
}
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
// 对比版本号
if (latestHotVersion !== configData.hotVersion) {
log.info('当前版本不是最新热更版本')
needDownload = true
} else {
log.info('当前为最新版本')
}
} catch (error) {
log.error('读取配置文件时出错', error)
}
return needDownload
}
下载增量更新包
通过刚才的校验,我们已经知道了什么时候该去下载增量更新包。
在开发调试的时候,我们可以把新版本的 app.asar
也放到起了本地服务器的 mockServer
文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejs
的 http
模块去实现,如果是 https
的需要引用 https
模块。
下载到本地的时候,我是放在了与 app.asar
同级目录的 resources
文件夹,我们不能直接覆盖原文件。一是因为进程权限占用的问题,二是为了容错,所以我们需要以另一个名字来命名下载后的文件(这里我们用的是 app.asar-temp
),也就不需要去备份原文件了,代码如下。
const resourcePath = isDev
? path.join('D:\\测试\\electron-update-demo\\resources')
: process.resourcesPath
const localAsarTemp = path.join(resourcePath, 'app.asar-temp')
const asarUrl = 'http://localhost:3000/app.asar'
downloadAsar() {
const fileStream = fs.createWriteStream(localAsarTemp)
http.get(asarUrl, res => {
res.pipe(fileStream)
fileStream
.on('finish', () => {
log.info('asar下载完成')
})
.on('error', error => {
// 删除文件
fs.unlink(localAsarTemp, () => {
log.error('下载出现异常,已中断', error)
})
})
})
}
因此,我们的流程更新为:发现新版本后,下载最新的 app.asar
到 resources
目录,并重命名为 app.asar-temp
。这个时候我们启动项目进行调试,找到记录在本地的日志—— C:\Users\用户名\AppData\Roaming\electron-update-demo\logs
,会有以下的记录:
[2024-09-20 13:49:22.456] [info] 监测到配置文件
[2024-09-20 13:49:22.462] [info] 当前版本不是最新热更版本
[2024-09-20 13:49:23.206] [info] asar下载完成
在看看项目 resources
文件夹,多了一个 app.asar-temp
文件。
至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。
替换 app.asar 文件
好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。
在 Windows
操作系统下,直接替换 app.asar
会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。
- 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。
- 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。
我们有上面两种方案,最终采用了 方案2
。
在主进程监听 app.on('quit')
事件,在应用退出的时候,判断 app.asar
和 app.asar-temp
是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs
在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。
nodejs
可以通过 spawn
、exec
等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs
,因为业务方的机器上不一定有这个环境,而是采用了启动 exe
可执行文件的方式。可能有人问为什么不直接运行 .bat
批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawn
的 windowsHide: true
。
那么如何获得这个 exe
可执行文件呢,其实是通过 bat
文件去编译的,命令如下:
@echo off
timeout /T 1 /NOBREAK
del /f /q /a %1\app.asar
ren %1\app.asar-temp app.asar
我们加了延时,等待父进程完全关闭后,再去执行后续命令。其中 %1
为运行脚本传入的参数,在我们的场景里就是 resources
文件夹的地址,因为每个人的安装地址应该是不一样的,所以需要作为参数传进去。
转换文件的工具一开始用的是 Bat To Exe Converter
下载地址,但是在实际使用的过程中,我的电脑竟然把转换后的 exe
文件 识别为病毒,但在其他同事电脑上没有这种表现,这肯定就不行了,最后还是同事使用 python
帮我转换生成了一份可用的文件(replace.exe
)。
这里我们可以选择不同的方式把 replace.exe
存放到 用户目录 上,要么是从线上服务器下载,要么是安装的时候就已经存放在本地了。我们选择了后者,这需要修改 electron-builder
打包配置,指定 asarUnpack
, 这样就会存放在 app.asar.unpacked
文件夹中,不经常修改的文件都可以放在这里,不会随着增量更新而替换掉。
有了这个替换脚本之后,开始编写子进程相关的代码。
import { spawn } from 'child_process'
cosnt localExePath = path.join(resourcePath, 'app.asar.unpacked/resources/replace.exe')
replaceAsar() {
if (fs.existsSync(localAsar) && fs.existsSync(localAsarTemp)) {
const command = `${localExePath} ${resourcePath}`
log.info(command)
const logPath = app.getPath('logs')
const childOut = fs.openSync(path.join(logPath, './out.log'), 'a')
const childErr = fs.openSync(path.join(logPath, './err.log'), 'a')
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true, // 允许子进程独立
shell: true,
stdio: ['ignore', childOut, childErr]
})
child.on('spawn', () => log.info('子进程触发'))
child.on('error', err => log.error('child error', err))
child.on('close', code => log.error('child close', code))
child.on('exit', code => log.error('child exit', code))
child.stdout?.on('data', data => log.info('stdout', data))
child.stderr?.on('data', data => log.info('stderr', data))
child.unref()
}
}
app.on('quit', () => {
replaceAsar()
})
在这块代码中,创建子进程的配置项比较重要,尤其是路径相关的。因为用户安装路径是不同的,可能会存在 文件夹名含有空格
的情况,比如 Program Files
,这会导致在执行的时候将空格识别为分隔符,导致命令执行失败,因此需要加上 双引号。此外,detached: true
可以让子进程独立出来,也就是父进程退出后可以执行,而shell: true
可以将路径名作为参数传过去。
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true,
shell: true,
stdio: ['ignore', childOut, childErr]
})
但这块有个 疑惑,为什么我的 close
、exit
以及 stdout
都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。
至此,在关闭应用之后,app.asar
就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion
,防止下次又去下载更新包了。
child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})
updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}
增量更新日志提示
既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里。
至于更新内容,我们可以复用 releaseInfo
的 releaseNotes
字段,把更新日志写在这里,增量更新完成后展现给用户就好了。
但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了
按钮,或者关闭 Modal
后,把当前的热更新版本号保存在本地配置文件,记录为 logVersion
。在下次打开程序或者校验更新的时候,我们判断一下 logVersion === hotVersion
,若不同,再去提示更新日志。
日志版本 校验和修改的代码如下所示:
checkLogVersion() {
let needShowLog = false
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion, logVersion } = configData
if (hotVersion !== logVersion) {
log.info('日志版本与当前热更新版本不同,需要提示更新日志')
needShowLog = true
} else {
log.info('日志已是最新版本,无需提示更新日志')
}
} catch (error) {
log.error('读取配置文件失败', error)
}
return needShowLog
}
updateLogVersion() {
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion } = configData
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion, logVersion: hotVersion }, null, 2))
log.info('日志版本已更新')
} catch (error) {
log.error('读取配置文件失败', error)
}
}
读取 config.json
文件的方法自行封装一下,我这里就重复使用了。主进程 ipcMain.on
监听一下用户传递过来的事件,再去调用 updateLogVersion
即可,渲染进程效果如下:
提示增量更新日志
点击 知道了
后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。
当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes
是否为空的逻辑就好了,也做到了 静默更新。
小结
不足之处
本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:
dll
、第三方SDK
等资源的更新。- 增量更新失败后应该通过全量更新 兜底。
- 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。
流程图
针对本文的解决方案,我简单画了一个 流程图。
参考文章
网上其实有不少关于 Electron
自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。
写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。
鸣谢:
来源:juejin.cn/post/7416311252580352034
工作7年了,才明白技术的本质不过是工具而已,那么未来的方向在哪里?
前言
Hi 你好,我是东东拿铁,一个正在探索个人IP&副业的后端程序员。
五一过去了,不知道大家有没有好好的放松自己呢?愉快的假期总是这么短暂,打工人重新回到自己的岗位。
我目前工作7年了,这几年来埋头苦干,学习了很多技术,做了不少系统,也解决过不少线上问题。自己虽然在探寻个人IP与副业,自己花了很多时间去思考技术之外的路该怎么走。但转念一想,我宁愿花这么多时间去探索技术之外的路线,但是却从没好好静下来想一下技术本身。
技术到底是什么,你我所处的技术行业为什么会存在,未来的机会在哪里。
因此,我结合自己的工作经历,希望和大家一起聊聊,技术的本质与未来的方向,到底在哪里,才疏学浅,如果内容有误还希望你在评论区指正。
背景
行业现状
互联网行业发展放缓,进入调整阶段,具体表现为市场需求、用户规模、营收利润、创新活力等方面的放缓或下降。
一些曾经风光无限的互联网公司也遭遇了业绩下滑、股价暴跌、裁员潮等困境,你是不是也曾听过互联网的寒冬已至的言论?
其实互联网本身,并没有衰败或消亡,而是因为互联网高速发展的时代过去了。
- 中国经济增速放缓、消费升级趋势减弱、人口红利消失等因素的影响,中国互联网市场的需求增长趋于饱和或下降。
- 用户规模停滞,智能手机普及率饱和,互联网用户规模增长趋于停滞,由增量市场变为存量市场,互联网获客成本越来越高。
- 监管政策收紧,互联网行业规范和监管愈加严格,更加注重合规,因此互联网行业也会收到影响。
供需环境
供需环境变化,应届生要求越来越高,更加注重学历。
社招更是看中学历的同时,开始限制年龄。招聘更看重项目经验,业务经验。五年前,你只要做过一些项目,哪怕不是实际使用的,也很容易拿到offer。而现在企业在看中技术能力的同时,还会关注候选人对与行业的理解,以及以往的工作经验。
技术的本质
先说结论,技术的本质是工具。 我把过去几年的认知变化分成了四个阶段,给大家展示一下我对于技术的认知成长过程。
第一阶段
技术就是应用各类前沿的框架、中间件。
刚毕业时,我就职于一家传统信息企业。谈不上所谓的架构,只需要Spring、Mysql就构建起了我们的所有技术栈。当然,微服务框架更不可能,Redis、MQ在系统中都没使用到。
此时互联网企业已经开始快速发展,抖音诞生区区不过一年。
一线城市的互联网公司,都已经开始使用上了SpringBoot、微服务,还有各类我没有听说过的中间件。
工作环境的闭塞,让我对各类技术有着无限憧憬,因为很多当下难以解决的问题,应用一些新技术、新架构,就能立刻对很多难题降维打击。
举个例子,如果你使用本地缓存,那么集群部署时,你一定要考虑集群的缓存一致性问题,可这个问题如果用上分布式缓存Redis,那么一致性问题迎刃而解。
所以那个时候的我认为,技术就是应用各类中间件,只要用上这些中间件、框架,我就已经走在了技术的前沿。
第二阶段
技术对我而言就是互联网。
半年后,我摆脱传统行业,来到了一个小型互联网公司,用上了不少在我眼中的新技术。
但任何新技术,如果只停留在表面,那么对于使用者来说,就是几个API,几行代码,你很快就会感到厌倦,发现问题也会焦虑,因为不清楚原理,问题就无从排查。
很快,所谓的“新技术”,就不能给我带来成就感了。我开始羡慕那些互联网行业APP,无时无刻都在畅想着,如果我做的产品能够被大家看到并应用,那该是多么有意思的一件事情。
于是我又认为,技术就是做那些被人看见、被人应用的网站、APP。
第三阶段
技术就是高并发、大流量、大数据。
当自己真正负责了某一个APP的后端研发后,很多技术都有机会应用,也能够在AppStore下载自己的APP了,没事刷一刷,看到某一个信息是通过我自己写的代码展示出去,又满足了第二阶段的目标了。
那么我接下来追求的变成了,让更多的人使用我做的产品,起码让我的亲人、朋友也能看到我做的东西。
当然,随之而来的就是日益增长的数据规模和大流量,这些无时无刻都在挑战系统的性能,如何去解决这些问题,成为了我很长一段时间的工作主线。
应对高并发、大流量,我们需要对系统作出各种极致性能的优化。
为了性能优化,还需要了解更多的底层原理,才能在遇到问题时有一个合理的解决方案。
所以,我认为技术就是高并发、大数据,做好这些,才算做好了技术。
第四阶段
经过了传统企业,到互联网公司,再到互联网大厂的一番经历,让我发现技术的本质就是工具,在不同阶段,去解决不同的问题。
在第一阶段,技术解决了各类行业的数据信息化问题,借助各类中间件、架构把具体的需求落地。
在第二阶段、第三阶段,技术解决了业务的规模化问题,因为在互联网,流量迅猛增长,我需要去用技术解决规模化带来的各类的问题,做出性能优化。
当然,技术在其他领域也发挥着作用,比如AI&算法,给予了互联网工具“智能化”的可能,还有比如我们很难接触到的底层框架研发,也就是技术的“技术”,这些底层能力,帮助我们更好的发挥我们的技术能力。
未来机会
大厂仍是最好的选择
即使是在互联网增速放缓、内卷持续严重的今天,即使我选择从大厂离职,但我依然认为大厂是最好的选择。
为什么这么说,几个理由
- 大厂有着更前沿的技术能力,你可以随意选择最适合的工具去解决问题
- 大厂有着更大的数据、流量规模,你所做的工作,天然的就具备规模化的能力
- 大厂有先进的管理方法,你所接触的做事方法、目标管理可能让你疲倦,但工作方法大概率是行业内经过验证的,你不会走弯路,能让你有更快的进步速度
数字化转型
如果你在互联网行业,可能没有听说过这个词,因为在高速发展的互联网行业,本身就是数字驱动的,比如重视数据指标、AB实验等。但在二线、三线城市的计算机行业或者一些传统行业,数字化转型是很大的发展机会。
过去十年,传统行业做的普遍是信息化转型,也就是把线下,需要用纸、笔来完成工作的,转移到系统中。
那什么是数字化转型?
我用我自己的理解说一下,数字化转型就是业务流程精细化管理,数据驱动,实现降本增效。
我目前所在的公司的推进大方向之一,就是数字化转型。因为许多行业的数字化程度非常低,本质而言,就是把数字驱动的能力,带给传统企业,让传统企业也能感受到数字化带来的发展可能。
举个例子,比如一个餐饮系统数字化转型后,一方面可以把用户下单、餐厅接单、开始制作、出餐、上餐线上化,还可以和原材料供应系统打通,当有订单来时,自动检测餐饮的库存信息,库存不足及时提供预警,甚至可以作出订单预测,比如什么时间点,哪类餐品的点单量最高。
当然,数字化转型与互联网有着极大的不同,在互联网行业,你只需要坐在工位,等着产品提出需求就可以了。但是传统行业,你需要深入客户现场,实地查看业务流程,与用户交谈,才能真正的理解客户需求。
或许这样的工作并不炫酷,还需要出差,但在互联网行业饱和的今天,用技术去解决真实世界的问题,也不失为一个很好的选择。
AI&智能化
随着AI快速发展,各类智能化功能已经遍布了我们使用的各类APP,极客时间有了AI自动总结,懂车帝有了智能选车度搜索问题,有时候第一个也会是AI来给我们解答。
任何行业遇上AI都可以再做一遍。
抛开底层算法、模型不谈,但从使用者角度来说,最重要的是如何与行业、场景结合相使用。但是想要做好应用,需要你在行业有着比较深的沉淀,有较深的行业认知。
当然,智能化也不仅限于AI,像上面餐饮系统的例子,如果能够实现订单预测、自动库存管理,其实也是智能化的体现。
终身学习
技术能力
持续精进专业技术能力,相信大家对此都没有疑问。
对于日常使用到的技术,我们需要熟练掌握技术原理,积累使用经验,尤其是线上环境的问题处理经验。
第一个是基础。比如对集合类,并发包,IO/NIO,JVM,内存模型,泛型,异常,反射,等有深入了解,最好是看过源码了解底层的设计。
第二你需要有全面的互联网技术相关知识。从底层说起,你起码得深入了解mysql,redis,nginx,tomcat,rpc,jms等方面的知识。
第三就是编程能力,编程思想,算法能力,架构能力。
在这个过程中,打造自己的技能树,构建自己的技术体系。
对于不断冒出的新技术,我们一方面要了解清楚技术原理,也要了解新技术是为了解决什么问题,诞生于什么背景。
业务能力
前面说到技术是一种工具,解决的是现实世界的问题,如果我们希望更好的发挥技术的作用,那么就需要我们先掌握好业务领域。
互联网领域
如果你想要快速地入门互联网领域的业务,你可以使用AARRR漏斗模型来分析。
AARRR这5个字母分别代表 Acquisition、Activation、Retention、Revenue 和 Refer
五个英文单词,它们分别对应用户生命周期中的 5 个重要环节:获取(Acquisition)、激活(Activation)、留存(Retention)、收益(Revenue)和推荐(Refer)。
AARRR 模型的核心就是以用户为中心,以完整的用户生命周期为指导思想,分析用户在各个环节的行为和数据,以此来发现用户需求以及产品需要改进的地方。
举一个简单的例子,我们以一个互联网手游 LOL来举例:
获取就是用户通过广告、push等形式,了解到了游戏并注册或者登陆。
激活就是用户真正的开始游戏,比如开始了一场匹配。
留存就是用户在7天、30天内,登陆了几次,打了几把比赛,几天登陆一次,每日游戏时常又是多少。
收益,用户购买皮肤了,产生了收益。
推荐,用户邀请朋友,发送到微信群中,邀请了朋友一起开黑。
如果你所在的行业是C端产品,那么这个模型基本可以概括用户的生命周期全流程。
传统行业
传统行业没有比较通用的业务模型,如果想要入手,需要我们从以下三个角度去入手
- 这个行业的商业模式是什么,也就是靠什么赚钱的?比如售卖系统收费,收取服务费等
- 行业的规模如何?头部玩家有哪些?它们的模式有哪些特色?
- 这个行业的客户是谁、用户是谁?有哪些经典的作业场景?业务操作流程是什么样的?
如何获取到这些信息呢?有几种常见的形式
- 权威的行业研究报告,这个比较常见
- 直接关注头部玩家的官网、公众号、官媒
- 深入用户现场
我们以汽车行业来举例
商业模式:整车销售、二手车、汽车租赁等,细分一点,又有传统动力和新能源两种分类。
规模:如下图
头部车企:传统的四大车企一汽、东风、上汽、长安,新势力 特斯拉、蔚小理
经典场景:直接去4S店体验一下汽车销售模式、流程
说在最后
好了,文章到这里就要结束啦,我用我自己工作几年的不同阶段,给你介绍了我对于技术的本质是工具的思考过程,也浅浅的探寻了一下,未来的发展机会在哪里,以及我们应该如何提升自己,很感谢你能看到最后,希望对你有所帮助。
不知道你对于技术是怎么看的,又如何看待当下的市场环境呢?欢迎你在评论区和我分享,也希望你点赞、评论、收藏,让我知道对你有所收获,这对我来说很重要。也欢迎你加我的wx:Ldhrlhy10,一起交流。
本篇文章是第33篇原创文章,2024目标进度33/100,欢迎有趣的你关注我~
来源:juejin.cn/post/7365679089812553769
基于英雄联盟人物的加载动画,奇怪的需求又增加了!
1、背景
前两天老板找到我说有一个需求,要求使用英雄联盟的人物动画制作一个加载中的组件,类似于下面这样:
我定眼一看:这个可以实现,但是需要UI妹子给切图。
老板:UI? 咱们啥时候招的UI !
我:老板,那不中呀,不切图弄不成呀。
老板:下个月绩效给你A。
我:那中,管管管。
2、调研
发动我聪明的秃头,实现这个需求有以下几种方案:
- 切动画帧,没有UI不中❎。
- 去lol客户端看看能不能搞到什么美术素材,3D模型啥的,可能行❓
- 问下 gpt4o,有没有哪个老表收集的有lol英雄的美术素材,如果有那就更得劲了✅。
经过我一番搜索,发现了这个网站:model-viewer,收集了很多英雄联盟的人物模型,模型里面还有各种动画,还给下载。老表,这个需求稳了50%了!
接下来有几种选择:
- 将模型动画转成动画帧,搞成雪碧图,较为麻烦,且动画不支持切换。
- 直接加载模型,将模型放在进度条上,较为简单,支持切换不同动画,而且可以自由过渡。就是模型文件有点大,初始化加载可能耗时较长。但是后续缓存一下就好了。
聪明的我肯定先选第二个方案呀,你糊弄我啊,我糊弄你。
3、实现
web中加载模型可以使用谷歌基于threejs
封装的 model-viewer
, 使用现代的 web component 技术。简单易用。
先初始化一个vue工程
npm create vue@latest
然后将里面的初始化的组件和app.vue里面的内容都删除。
安装model-viewer
依赖:
npm i three // 前置依赖
npm i @google/model-viewer
修改vite.config.js
,将model-viewer
视为自定义元素,不进行编译
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
// 添加以下内容
compilerOptions: {
isCustomElement: (tag) => ['model-viewer'].includes(tag)
}
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
assetsInclude: ['./src/assets/heros/*.glb']
})
新建 src/components/LolProgress.vue
<template>
<div class="progress-container">
<model-viewer
:src="hero.src"
disable-zoom
shadow-intensity="1"
:camera-orbit="hero.cameraOrbit"
class="model-viewer"
:style="heroPosition"
:animation-name="animationName"
:camera-target="hero.cameraTarget"
autoplay
ref="modelViewer"
></model-viewer>
<div
class="progress-bar"
:style="{ height: strokeWidth + 'px', borderRadius: strokeWidth / 2 + 'px' }"
>
<div class="progress-percent" :style="currentPercentStyle"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch, type PropType } from 'vue'
/** 类型 */
interface Hero {
src: string
cameraOrbit: string
progressAnimation: string
finishAnimation: string
finishAnimationIn: string
cameraTarget: string
finishDelay: number
}
type HeroName = 'yasuo' | 'yi'
type Heros = {
[key in HeroName]: Hero
}
const props = defineProps({
hero: {
type: String as PropType<HeroName>,
default: 'yasuo'
},
percentage: {
type: Number,
default: 100
},
strokeWidth: {
type: Number,
default: 10
},
heroSize: {
type: Number,
default: 150
}
})
const modelViewer = ref(null)
const heros: Heros = {
yasuo: {
src: '/src/components/yasuo.glb',
cameraOrbit: '-90deg 90deg',
progressAnimation: 'Run2',
finishAnimationIn: 'yasuo_skin02_dance_in',
finishAnimation: 'yasuo_skin02_dance_loop',
cameraTarget: 'auto auto 0m',
finishDelay: 2000
},
yi: {
src: '/src/components/yi.glb',
cameraOrbit: '-90deg 90deg',
progressAnimation: 'Run',
finishAnimationIn: 'Dance',
finishAnimation: 'Dance',
cameraTarget: 'auto auto 0m',
finishDelay: 500
}
}
const heroPosition = computed(() => {
const percentage = props.percentage > 100 ? 100 : props.percentage
return {
left: `calc(${percentage + '%'} - ${props.heroSize / 2}px)`,
bottom: -props.heroSize / 10 + 'px',
height: props.heroSize + 'px',
width: props.heroSize + 'px'
}
})
const currentPercentStyle = computed(() => {
const percentage = props.percentage > 100 ? 100 : props.percentage
return { borderRadius: `calc(${props.strokeWidth / 2}px - 1px)`, width: percentage + '%' }
})
const hero = computed(() => {
return heros[props.hero]
})
const animationName = ref('')
watch(
() => props.percentage,
(percentage) => {
if (percentage < 100) {
animationName.value = hero.value.progressAnimation
} else if (percentage === 100) {
animationName.value = hero.value.finishAnimationIn
setTimeout(() => {
animationName.value = hero.value.finishAnimation
}, hero.value.finishDelay)
}
}
)
onMounted(() => {
setTimeout(() => {
console.log(modelViewer.value.availableAnimations)
}, 2000)
})
</script>
<style scoped>
.progress-container {
position: relative;
width: 100%;
}
.model-viewer {
position: relative;
background: transparent;
}
.progress-bar {
border: 1px solid #fff;
background-color: #666;
width: 100%;
}
.progress-percent {
background-color: aqua;
height: 100%;
transition: width 100ms ease;
}
</style>
组件非常简单,核心逻辑如下:
- 根据传入的英雄名称加载模型
- 指定每个英雄的加载中的动画,
- 加载100%,切换完成动作进入动画和完成动画即可。
- 额外的细节处理。
最后修改
app.vue
:
<script setup lang="ts">
import { ref } from 'vue'
import LolProgress from './components/LolProgress.vue'
const percentage = ref(0)
setInterval(() => {
percentage.value = percentage.value + 1
}, 100)
</script>
<template>
<main>
<LolProgress
:style="{ width: '200px' }"
:percentage="percentage"
:heroSize="200"
hero="yasuo"
/>
</main>
</template>
<style scoped></style>
这不就完成了吗,先拿给老板看看。
老板:换个女枪的看看。
我:好嘞。
老板:弄类不赖啊小伙,换个俄洛伊的看看。
4、总结
通过本次需求,了解到了 model-viewer
组件。
老板招个UI妹子吧。
在线体验:github-pages
来源:juejin.cn/post/7377217883305279526
我在团队内部提倡禁用单元测试
先说结论:
在绝大部分的业务前端开发中,写单元测试的收益非常小,但是带来的维护成本却非常大,请不要再写单元测试感动自己,为难同事了。
现在很多单元测试的教程都是那种很简单的比如,测个 1+1=2,这需要测吗?下面这段代码已经出现过很多次了,纯纯的误导人。
const sum = (a,b) => a+b
it('1+1=2',()=> {
expect(sum(1,1)).toBe(2)
})
稍微上点复杂度,来写个组件的单测,比如一个用户信息展示组件,叫 UserInfoBlock,支持设置头像的大小,点击名字支持跳转到个人主页,组件代码大致长这样
interface UserInfoBlockProps {
name: string
size: 16 | 24
id: string
icon: string
}
export const UserInfoBlock:FC<UserInfoBlockProps> = (props) => {
const navigate = useNavigate()
return <div
class='xxxxxx'
style={{width: props.size}}
onClick={() => {navigate(`/user/${props.id}`)}}>
<img src={props.icon}/>
{props.name}
</div>
}
然后开始给组件写单测,先测头像大小的功能
import { UserInfoBlock, UserInfoBlockProps } from './user-info-block'
import { fireEvent, render, screen } from '@testing-library/react'
describe('UserInfoBlock 组件单测', () => {
const userInfo:UserInfoBlockProps = {
name: '张三',
icon:'https://xxx.png',
id:'abcd1234',
size: 16
}
const userInfoLarge:UserInfoBlockProps = {
name: '张三',
icon:'https://xxx.png',
id:'abcd1234',
size: 24
}
describe('展示默认头像大小', () => {
const component = render(<UserInfoBlock {...userInfo}/>)
it('img 标签的宽度为 16px', () => {
expect(screen.getByTag('img').style.width).toBe(16)
})
})
describe('展示large头像大小', () => {
const component = render(<UserInfoBlock {...userInfoLarge}/>)
it('img 标签的宽度为 24px', () => {
expect(screen.getByTag('img').style.width).toBe(24)
})
})
})
接下来测一下跳转,因为用了 react-router,所以在渲染组件的时候必须包裹一下 RouterProvider
...
describe('点击可以跳转', () => {
const component = render(<MemoryRouter>
<UserInfoBlock {...userInfoLarge}/>
</MemoryRouter>
)
fireEvent.click(component.div)
it('url 会变成 user/abcd1234', () => {
expect(location.pathname).toBe('user/abcd1234')
})
})
...
这个组件的测试就写完了,看起来挺有用,但实际没啥用。
首先这个测试的收益不高,因为这是一个很简单的组件,五分钟写完,但是写这个测试需要翻倍的时间,因为需要构造数据,之前没有经验不知道要加 MemoryRouter
,jest
测 location
对象不方便,还要 mock
一下。等把这一套搞完了才能跑通,这就好像你疯狂锻炼,练出麒麟臂,就是为了举自拍杆举的稳一点。如果组件内要发请求,要做的准备工作就更加多了。
其次,user/abcd1234
是什么,断言这个没用,因为别人改了链接,你的测试也一样会过,应该断言成功的打开了用户主页,比如断言一个必定会存在的文字expect(screen.getByText('用户详情')).toBeInDocument()
这才能证明真的打开了用户主页。
1+1 什么时候不等于 2。头像设置 16px,什么时候不会是 16 px。什么时候点击不跳转到用户主页。肯定是有人修改了这里的业务逻辑才会这样,只有在做产品需求,代码优化的时候才会去改以前的代码。那么这时,这段测试对接手这段代码的人就是负担。
假设我接到需求,用户主页url 变了,改成 user?id=xxx
。我心想这个简单,一秒钟解决,提了 pr 发现测试挂了,然后我就得把测试也改一下。
如果一段测试代码在我每次修改需求的时候都要改,甚至有的时候还会为了便于测试在业务代码中留一些后门,那这个测试就是纯纯的副作用。
大家肯定深有体会,一般一个模块自己不会出问题,但是多个模块组合起来就很容易出问题了,所以测试的重点不应该是一个组件内部各种细节,而应该测这个组件在被使用的地方是否正确。
举个例子,比如在一个列表里面,使用头像为 24px 的用户组件,然后有一天出 bug 了,说这里变成 16 了。那这会是组件的 bug 吗,这肯定是使用的地方没有用对。所以应该要测试的是这里。如果有个测试断言了在列表中用户头像必须为 24px,即使没有组件的单测,这个组件也不会被改坏,因为上层已经覆盖到了。
什么是好的测试?
- 我认为好的测试是稳定的测试,不仅不需要经常修改,甚至还能扛住代码重构。比如你把项目从
react
完全迁移到了vue
,测试都能跑过。 - 好的测试是可以当作项目文档的,组内来新人了,不需要给他介绍过多,有问题让他自己看测试用例就能理解业务。
那么怎么写出这样的测试呢?答案就是写集成测试,集成测试说白了就是一个超级大的单测,之前测试 render
的是一个组件,现在直接 render(<App/>)
,将路由跳转,请求 mock,fake数据库等等都统一处理。可以将上面写的测试简单的改为
import { creatAppContext, AppContext } from './app-test-context'
describe('S: 测试头像组件', () => {
let _:AppContext
beforeEach(() => {
- = creatAppContext()
})
describe('W: 去用户列表也', () => {
beforeEach(() => {
_.click(screen.getByText('查看用户列表'))
})
it('T: 列表页有十个用户,用户头像是 24px', ()=>{
expect(screen.getAllByTestid('user-item').length).toBe(10)
expect(screen.getAllByTestid('user-item')[0].img.style.width).toBe(24)
})
describe('W: 点击第一个用户', () => {
beforeEach(() => {
_.click(screen.getAllByTestid('user-item')[0])
})
it('T: 打开了用户主页', () => {
expect(screen.getByText('用户详情')).toBeInDocument()
})
})
})
})
关于怎么写好测试可以展开的点实在太多了,就不过多阐述,想要了解的朋友可以查看我的主页,之前专门出过一些列文章详细的讲这些事情。
来源:juejin.cn/post/7478515057510154255
组长说:公司的国际化就交给你了,下个星期给我
从“跑路程序员”到“摸鱼仙人”,我用这插件把国际化的屎山代码盘活了!
tips:
使用有道翻译,朋友们,要去有道官网注册一下,有免费额度,github demo的key已经被用完了。
一、命运的齿轮开始转动
“小王啊,海外业务要上线了,国际化你搞一下,下个月验收。”组长轻描淡写的一句话,让我盯着祖传代码陷入沉思——
(脑补画面:满屏中文硬编码,夹杂着"确定"
、"取消"
、"加载中..."
)
正当我准备打开BOSS直聘时,GitHub Trending上一个项目突然闪现——
auto-i18n-translation-plugins
项目简介赫然写着:“不改代码,三天交付国际化需求,摸鱼率提升300%”
二、极限操作:48小时从0到8国语言
🔧 第1步:安装插件(耗时5分钟)
祖训:“工欲善其事,必先装依赖”
# 如果你是Vite玩家(比如Vue3项目)
npm install vite-auto-i18n-plugin --save-dev
# 如果你是Webpack钉子户(比如React老项目)
npm install webpack-auto-i18n-plugin --save-dev
🔧 第2步:配置插件(关键の10分钟)
Vue3 + Vite の 摸鱼配置:
// vite.config.js
import { defineConfig } from 'vite';
import vitePluginAutoI18n from 'vite-auto-i18n-plugin';
export default defineConfig({
plugins: [
vue(),
vitePluginAutoI18n({
targetLangList: ['en', 'ja', 'ko'], // 要卷就卷8国语言!
translator: new YoudaoTranslator({ // 用有道!不用翻墙!
appId: '你的白嫖ID', // 去官网申请,10秒搞定
appKey: '你的密钥' // 别用示例里的,会炸!
})
})
]
});
🔧 第3步:注入灵魂——配置文件(生死攸关の5分钟)
在项目入口文件(如main.js)的第一行插入:
// 这是插件的生命线!必须放在最前面!
import '../lang/index.js'; // 运行插件之后会自动生成引入即可
三、见证奇迹的时刻
🚀 第一次运行(心脏骤停の瞬间)
输入npm run dev
,控制台开始疯狂输出:
[插件日志] 检测到中文文本:"登录" → 生成哈希键:a1b2c3
[插件日志] 调用有道翻译:"登录" → 英文:Login,日文:ログイン...
[插件日志] 生成文件:lang/index.json(翻译の圣杯)
突然!页面白屏了!
别慌!这是插件在首次翻译时需要生成文件,解决方法:
- 立即执行一次
npm run build
(让插件提前生成所有翻译) - 再次
npm run dev
→ 页面加载如德芙般丝滑
四、效果爆炸:我成了全组の神
1. 不可置信の48小时
当我打开浏览器那一刻——\
我(瞳孔地震):“卧槽…真成了?!”
组长(凑近屏幕):“这…这是你一个人做的?!”(眼神逐渐迷茫)
产品经理(掏出手机拍照):“快!发朋友圈!《我司技术力碾压硅谷!》”
2. 插件の超能力
- 构建阶段:自动扫描所有中文 → 生成哈希键 → 调用API翻译
- 运行时:根据用户语言动态加载对应翻译
- 维护期:改个JSON文件就能更新所有语言版本
副作用:
- 测试妹子开始怀疑人生:“为什么一个bug都找不到?”
- 后端同事偷偷打听:“你这插件…能翻译Java注释吗?”
五、职场生存指南:如何优雅甩锅
🔨 场景1:测试妹子提着40米大刀来了!
问题:俄语翻译把“注册”译成“Регистрация”(原意是“登记处”)
传统应对:
- 熬夜改代码 → 重新打包 → 提交测试 → 被骂效率低
插件玩家:
- 打开
lang/index.json
- 把
Регистрация
改成Зарегистрироваться
(深藏功与名) - 轻描淡写:“这是有道翻译的锅,我手动修正了。”
🔨 场景2:产品经理临时加语言
需求:“老板说下周要加印地语!”
传统灾难:
- 重新配框架 → 人肉翻译 → 测试 → 加班到秃头
插件玩家:
- 配置加一行代码:
targetLangList: ['hi']
- 运行
npm run build
→ 自动生成印地语翻译 - 告诉产品经理:“这是上次预留的技术方案。”(其实只改了1行)
🔨 场景3:组长怀疑你摸鱼
质问:“小王啊,你这效率…是不是有什么黑科技?”
标准话术:
“组长,这都是因为:
- 您制定的开发规范清晰
- 公司技术栈先进(Vue3真香)
- 我参考了国际前沿方案(打开GitHub页面)”
六、高级摸鱼の奥义
🎯 秘籍1:把翻译文件变成团队武器
- 把
lang/index.json
扔给产品经理:“这是国际化核心资产!” - 对方用Excel修改后,你直接
git pull
→ 无需动代码 - 出问题直接甩锅:“翻译是市场部给的,我只负责技术!”
(脑补画面:产品经理在Excel里疯狂改翻译,程序员在刷剧)
🎯 秘籍2:动态加载の神操作
痛点:所有语言打包进主文件 → 体积爆炸!
解决方案:
// 在index.js里搞点骚操作
const loadLanguage = async (lang) => {
const data = await import(`../../lang/${lang}.json`); // 动态加载翻译文件
window.$t.locale(data, 'lang');
};
// 切换语言时调用
loadLanguage('ja'); // 瞬间切换日语,深藏功与名
🎯 秘籍3:伪装成AI大神
- 周会汇报:“我基于AST实现了自动化国际翻译中台”
- 实际:只是配了个插件
- 老板评价:“小王这技术深度,值得加薪!”(真相只有你知道)
七、终局:摸鱼の神,降临!
当组长在庆功会上宣布“国际化项目提前两周完成”时,我正用手机刷着《庆余年2》。
测试妹子:“你怎么一点都不激动?”
我(收起手机):“常规操作,要习惯。”(心想:插件干活,我躺平,这才叫真正的敏捷开发!)
立即行动(打工人自救指南):
- GitHub搜:auto-i18n-translation-plugins(点星解锁摸鱼人生)
- 复制我的配置 → 运行 → 见证魔法
- 加开发者社群:遇到问题发红包喊“大哥救命!”
终极警告:
⚠️ 过度使用此插件可能导致——
- 你的摸鱼时间超过工作时间,引发HR关注
- 产品经理产生“国际化需求可以随便加”的幻觉
- 老板误以为你是隐藏的技术大佬(谨慎处理!)
文末暴击:
“自从用了这插件,我司翻译团队的工作量从3周变成了3分钟——现在他们主要工作是帮我选中午吃啥。” —— 匿名用户の真实反馈
常见问题汇总
来源:juejin.cn/post/7480267450286800911
8年程序员本以为到头了,没想到柳暗花明...
讲一个这两年发生在我身上的故事,一个从程序员到AI产品负责人,一个选择大于努力的故事:
回到2022年,作为一个8年的前端程序员的我,在一家行业头部的企业中勤勤恳恳,兢兢业业。每天与代码为伴,沉浸在React的世界里,整日优化、升级、写框架、写工具,解决点疑难杂症,改改BUG,后来甚至成为了React的contributor。
我的生活和工作都很稳定,技术能力也得到了领导和团队的认可。
但内心深处却隐隐感到一种不安——前端技术的发展似乎进入了瓶颈期,我的职业生涯也仿佛停滞不前。
随着时间的推移,我开始面临两个无法回避的问题:
- 个人能力无法进一步提升:前端技术的更新速度放缓,框架和工具趋于成熟,我能做的事情越来越少了。
- 薪资增长停滞:薪资似乎已经到达了行业的天花板,如果我不能做更多的事情,也再难有突破。
自己是否已经走到了职业生涯的尽头?
面对这个问题,我一度陷入迷茫。跟其他程序员一样,我也尝试学习新的技术栈来缓解心中的这份焦虑,更底层的语言比如Rust、更宽的技术栈比如three.js、学学区块链、学学算法。
但是随之而来的就是,这些东西我学了之后除了做做demo、写写小工具之外,我好像做不了什么大事,我没有动力向着更深的内容走下去,这反而更加加深了我的焦虑。
那时候我32岁,每天都在担心中年危机。担心三年之后我凭什么和体力更好的、钱更少的、拥有同样技术栈的年轻人竞争?【虽然三年后的今天,并没有这样的年轻人出现】
幸运的是,2022年年底,chatGPT爆火全球。一夜之间,所有人都在讨论GPT,我也不例外,按照惯例,我再次把学习AI加入了我的日程。
当时体验了chatGPT之后,我两眼放光,我觉得,只要我努力学习这个技术,我一定可以像当初选择前端开发一样,再次进入一个程序员的风口,于是报课、买书、找行业大佬交流。
就这样过来两三个月......
你以为怎么了?我柳暗花明了? 这是一个前端程序员逆袭转战AI年薪百万的故事? 很不幸,这并不是。
事实是三个月之后,我再次陷入了困境:AI的底层技术,一我根本学不会,二我学了好像也没有用武之地。总不能跟老板说:给我几千万美金我给你撸个大模型?
世界再次灰暗下来......
可是,天无绝人之路,就在我已经准备放弃的时候,我在某个产品群里看到了这样一句话:
“???,这居然没人知道?”
过去三个月的高强度学习,在我看来在业务中找到嵌入AI的场景,甚至为了AI单独新增功能都是非常容易的事情。
AI时代已经来了,GUI到LUI是必然发生的事情,所有企业的产品都将会经历加入AI的功能或者逻辑。如果必须有一人主导这件事情的话,那为什么不能是我?
“汽车干掉马车之后,所有的司机都是之前的马车司机么?”
“计算器干掉算盘之后,所有的会计都是之前的师爷么?”
“安卓干掉塞班之后,所有的安卓工程师都是之前的塞班工程师么?”
既然都不是,那么AI带来全新的交互体验,必须是传统产品去做么?显然不是,这玩意,谁先干算谁的啊。
我决定接受这个挑战,开始把视野放到应用层。开始研究产品的逻辑,尝试找到AI技术与业务需求的结合点。
不会怎么办? 学呗,这流程咱熟,报班、买书、找大佬交流。
一边学,我就一边结识了一些志同道合的伙伴,我们一起讨论、尝试、失败、再尝试。
经历过那一年的朋友应该知道,国内大模型的能力远远没有广告上说的那些效果,底层能力的限制一度成为我们思路中最大的卡点。
不过,我们也总结整理了一大批不同的产品中,不训练自己的大模型的情况下,能够结合AI的逻辑,这里简单放几个例子。
- TOB的管理系统,新用户没办法很好的知道产品中有什么功能,可以直接通过AI助手来唤起、使用对应功能。【我很喜欢这个,典型的GUI升级LUI的场景】
- 产品售卖,通过AI + 埋点的技术,实时分析用户的行为,在恰当的时机向用户推送VIP折扣。
- 电商详情页浏览助手。【页面上的AI助手,也是这两年很多产品已经落地了的选择】
- TOC的评论页面,AI拥有很好的控评能力
- 智能问答产品,【我们认为是受到冲击最大的产品,跟AI比起来,现在市场上所有的智能问答产品,都不够智能。都应该被重做升级!】
- 多模态能力会带来的全新产品。【情感陪伴是目前最为广泛且收益较快的赛道。】
不扯远了,说回我自己。
我的转机出现在我们自己的领导也开始说要结合AI,升级产品能力。
我以最快的速度,做好agent流程的思维导图、MVP(最小可行性产品)。 开会,给领导布道,说服领导们相信我的这套设计。
因为之前就进行过AI相关的内容分享,所以这一步比较容易。一顿操作猛如虎之后,终于获得了领导认可,接到了协助产品负责AI升级的任务,升级过程其实遇到了非常多的困难。
再次吐槽一下那一年的国产大模型,流程稍微多一点,响应时间就巨慢,流程少了,大模型的理解能力又不足。
有时候一个提示词一调就是半天,我甚至一度都要放弃了。不过好在最终我们选择了曲线救国,采用大模型+小模型,配合多专家 + RAG + 变种function call组合的架构,我们在23年底也做出了不错的东西。
上线之后,客户表示了满意。领导表示了满意。团队表示了满意。
水到渠成,后来一切就按部就班了,24年我又虚线负责了两个AI产品的升级和功能迭代。从确定需求是否适用AI实现,到方案选型,到设计agent流程,到功能上的交互设计,组织团队最终交付。
今年开始实线负责多个产品,有了自己的团队负责大模型方向的新产品设计和落地,也有需要横向拉通的部门,负责协助规划旧产品AI方向的升级和产品中AI需求的挖掘。
柳暗花明,未来可期。
结语
虽然我是前端转型到AI产品负责人的,但是如果你问我AI一定会代替掉程序员么?
我的回答是:不会,但是AI一定会让这个岗位所需要的人数减少,无论你是初级岗还是高级岗。
我们要做的就是抓住新的机会、占据有利位置、执行下去,然后等待时代的洪流将我们高高托起。
你看deepseek这不就来了? 我前面两年所做的事情,因为deepseek的出现,再次被抬高了一个维度,这就是时代,选择大于努力。
如果大家对“程序员如何转型AI产品负责人”感兴趣,可以点个赞。如果大家喜欢,后面我再多多分享转型细节。
文章更新列表
来源:juejin.cn/post/7469263585287651340
实现基于uni-app的项目自动检查APP更新
我们平时工作中开发APP时,及时为用户提供应用更新是提升用户体验和保证功能完整性的重要一环。本文将通过一段实际的代码示例,详细介绍如何在基于uni-app框架的项目中实现自动检查应用更新的功能,并引导用户完成升级过程。该功能主要涉及与服务器端交互获取最新版本信息、比较版本号、提示用户升级以及处理下载安装流程。
创建一个checkappupdate.js
文件
这个文件是写升级逻辑处理的文件,可以不创建,直接在App.vue中写,但是为了便于维护,还是单独放出来比较好,可以放在common或者util目录中(App.vue能引入到就行,随意放,根目录也行),App.vue中引入该文件,调用升级函数如下图所示:
js完整代码
为了防止一点点代码写,容易让人云里雾里,先放完整代码,稍后再详细解释,其实看注释也就够了。
//这是服务端请求url配置文件,如果你直接卸载下面的请求中,可以不引入
import configService from '@/common/service/config.service.js'
export default function checkappupdate(param = {}) {
// 合并默认参数
param = Object.assign({
title: "A new version has been detected!",
content: "Please upgrade the app to the latest version!",
canceltext: "No upgrade",
oktext: "Upgrade now"
}, param)
plus.runtime.getProperty(plus.runtime.appid, (widgetInfo) => {
let platform = plus.os.name.toLocaleLowerCase() //Android
let os_version = plus.os.version //13 安卓版本
let vendor = plus.device.vendor //Xiaomi
let url = configService.apiUrl
uni.request({
url: url + '/checkAppUpdate',
method: 'GET',
data: {
platform: platform,
os_version: os_version,
vendor: vendor,
cur_version: widgetInfo.version
},
success(result) {
console.log(result)
let versionCode = parseInt(widgetInfo.versionCode)
let data = result.data ? result.data : null;
// console.log(data);
let downAppUrl = data.url
//判断版本是否需要升级
if (versionCode >= data.versionCode) {
return;
}
//升级提示
uni.showModal({
title: param.title,
content: data.log ? data.log : param.content,
showCancel: data.force ? false : true,
confirmText: param.oktext,
cancelText: param.canceltext,
success: res => {
if (!res.confirm) {
console.log('Cancel the upgrade');
// plus.runtime.quit();
return
}
// if (data.shichang === 1) {
// //去应用市场更新
// plus.runtime.openURL(data.shichangurl);
// plus.runtime.restart();
// } else {
// 开始下载
// 创建下载任务
var dtask = plus.downloader.createDownload(downAppUrl, {
filename: "_downloads/"
},
function (d, status) {
// 下载完成
if (status == 200) {
plus.runtime.install(d.filename, {
force: true
}, function () {
//进行重新启动;
plus.runtime.restart();
}, (e) => {
uni.showToast({
title: 'install fail:' + JSON
.stringify(e),
icon: 'none'
})
console.log(JSON.stringify(e))
});
} else {
this.tui.toast("download fail,error code: " +
status);
}
});
let view = new plus.nativeObj.View("maskView", {
backgroundColor: "rgba(0,0,0,.6)",
left: ((plus.screen.resolutionWidth / 2) - 45) +
"px",
bottom: "80px",
width: "90px",
height: "30px"
})
view.drawText('start download...', {}, {
size: '12px',
color: '#FFFFFF'
});
view.show()
// console.log(dtask);
dtask.addEventListener("statechanged", (e) => {
if (e && e.downloadedSize > 0) {
let jindu = ((e.downloadedSize / e.totalSize) *
100).toFixed(2)
view.reset();
view.drawText('Progress:' + jindu + '%', {}, {
size: '12px',
color: '#FFFFFF'
});
}
}, false);
dtask.start();
// }
},
fail(e) {
console.log(e);
uni.showToast({
title: 'Request error'
})
}
})
}
})
});
}
函数定义:checkappupdate
定义核心函数checkappupdate
,它接受一个可选参数param
,用于自定义提示框的文案等信息。函数内部首先通过Object.assign
合并默认参数与传入参数,以确保即使未传入特定参数时也能有良好的用户体验。
获取应用信息与环境变量
利用plus.runtime.getProperty
获取当前应用的详细信息,包括但不限于应用ID、版本号(version
)和版本号代码(versionCode
),以及设备的操作系统名称、版本和厂商信息。这些数据对于后续向服务器请求更新信息至关重要。
请求服务器检查更新
构建包含平台信息、操作系统版本、设备厂商和当前应用版本号的请求参数,发送GET请求至配置好的API地址/checkAppUpdate
,查询是否有新版本可用。后端返回参数参考下面:
/**
* 检测APP升级
*/
public function checkAppUpdate()
{
$data['versionCode'] = 101;//更新的版本号
$data['url'] = 'http://xxx/app/xxx.apk';//下载地址
$data['force'] = true;//是否强制更新
return json_encode($data);//返回json格式数据到前端
}
比较版本与用户提示
一旦收到服务器响应,解析数据并比较当前应用的版本号与服务器提供的最新版本号。如果存在新版本,使用uni.showModal
弹窗提示用户,展示新版本日志(如果有)及升级选项。此步骤充分考虑了是否强制更新的需求,允许开发者灵活配置确认与取消按钮的文案。
下载与安装新版本
用户同意升级后,代码将执行下载逻辑。通过plus.downloader.createDownload
创建下载任务,并监听下载进度,实时更新进度提示。下载完成后,利用plus.runtime.install
安装新APK文件,并在安装成功后调用plus.runtime.restart
重启应用,确保新版本生效。
用户界面反馈
在下载过程中,通过创建原生覆盖层plus.nativeObj.View
展示一个半透明遮罩和下载进度信息,给予用户直观的视觉反馈,增强了交互体验,进度展示稍微有点丑,可以提自己改改哈。
总结
通过上述步骤,我们实现了一个完整的应用自动检查更新流程,不仅能够有效通知用户新版本的存在,还提供了平滑的升级体验。此功能的实现,不仅提升了用户体验,也为产品迭代和功能优化提供了有力支持。开发者可以根据具体需求调整提示文案、下载逻辑、进度样式等细节,以更好地适配自身应用的特点和用户群体。
来源:juejin.cn/post/7367555191337828361
10年深漂,放弃高薪,回长沙一年有感
大明哥是 2014 年一个人拖着一个行李箱,单身杀入深圳,然后在深圳一干就是 10 年。
10 年深漂,经历过 4 家公司,有 20+ 人的小公司,也有上万人的大厂。
体验过所有苦逼深漂都体验过的难。坐过能把人挤怀孕的 4 号线,住过一天见不到几个小时太阳的城中村,见过可以飞的蟑螂。欣赏过晚上 6 点的晚霞,但更多的是坐晚上 10 点的地铁看一群低头玩手机的同行。
10 年虽然苦、虽然累,但收获还是蛮颇丰的。从 14年的 5.5K 到离职时候的 xxK。但是因为种种原因,于 2023年 9 月份主动离职离开深圳。
回长沙一年,给我的感觉就是:除了钱少和天气外,样样都比深圳好。
生活
在回来之前,我首先跟我老婆明确说明了我要休息半年,这半年不允许跟我提任何有关工作的事情,因为在深圳工作了 10 年真的太累,从来没有连续休息超过半个月的假期。哪怕是离职后我也是无缝对接,这家公司周五走,下家公司周一入职。
回来后做的第一件事情就是登出微信、删除所有闹钟、手机设置全天候的免打扰,全心全意,一心一意地陪女儿和玩,在这期间我不想任何事情,也不参与任何社交,就认真玩,不过顺便考了个驾-照。
首先说消费。
有很多人说长沙是钱比深圳少,但消费不比深圳低。其实不然,我来长沙一年了,消费真的比深圳低不少。工作日我一天的消费基本上可以控制在 40 左右,但是在深圳我一天几乎都要 80 左右。对比
长沙 | 深圳 | |
早 | 5+ | 5+ |
中 | 15 ~ 25 | 25 ~ 35 |
晚 | 10 ~ 15,不加班就回家吃 | 25 ~ 35,几乎天天加班 |
同时,最近几个月我开始带饭了,周一到超时买个百来块的菜,我一个人可以吃两个星期。
总体上,一个月消费长沙比深圳低 1000 左右(带饭后更低了)。
再就是日常的消费。如果你选择去长沙的商城里面吃,那与深圳其实差不多了多少,当然,奶茶方面会便宜一些。但是,如果你选择去吃长沙的本土菜,那就会便宜不少,我跟我朋友吃饭,人均 50 左右,不会超过 70,选择美团套餐会更加便宜,很多餐馆在支持美团的情况下,选择美团套餐,两个人可以控制在 30 ~ 40 之间。而深圳动不动就人均 100+。
当然,在消费这块,其实节约的钱与少的工资,那就是云泥之别,可忽略不计。
再说生活这方面。
在长沙这边我感觉整体上的幸福感比深圳要强蛮多,用一句话说就是:深圳都在忙着赚钱,而长沙都在忙着吃喝玩乐加洗脚。我说说跟我同龄的一些高中和大学同学,他们一毕业就来长沙或者来长沙比较早,所以买房就比较早,尤其是 16 年以前买的,他们的房贷普遍在 3000 左右,而他们夫妻两的工资税后可以到 20000,所以他们这群人周末经常约着一起耍。举两个例子来看看他们的松弛感:
- 晚上 10 点多喊我去吃烧烤,我以为就是去某个夜市撸串,谁知道是开车 40+公里,到某座山的山顶撸串 + 喝酒。这是周三,他们上班不上班我不知道,反正我是不上班。
- 凌晨 3 点多拉我出来撸串
跟他们这群人我算是发现了,大部分的聚会都是临时起意,很少提前约好,主打就是一个随心随意。包括我和同事一样,我们几乎每个月都会出来几次喝酒(我不喜欢喝酒,偶尔喝点啤酒)、撸串,而且每次都是快下班了,某个人提议今晚喝点?完后,各回各家。
上面是好的方面,再说不好的。
长沙最让我受不了的两点就是天气 + 交通。
天气我就不说了,冬天冻死你,夏天热死你。去年完整体验了长沙的整个冬天,是真他妈的冷,虽然我也是湖南人,但确实是把我冻怕了。御寒?不可能的,全靠硬抗。当然,也有神器:火桶子,那是真舒服,我可以在里面躺一整天。
交通,一塌糊涂,尤其是我每天必经的西二环,简直惨不忍睹,尤其是汽车西站那里,一天 24 小时都在堵,尤其是周一和周五,高德地图上面是红的发黑。所以,除非特殊情况,我周一、周五是不开车的,情愿骑 5 公里小电驴去坐地铁。
然后是一大堆违停,硬生生把三车道变成两车道,什么变道不打灯,实线变道,双黄线调头见怪不怪了,还有一大群的小电驴来回穿梭,对我这个新手简直就是恐怖如斯(所以,我开车两个月喜提一血,4S点维修报价 9800+)。
美食我就不说了,简直就是吃货的天堂。
至于玩,我个人觉得长沙市内没有什么好玩的,我反而喜欢去长沙的乡里或者周边玩。所以,我实在是想不通,为什么五一、国庆黄金周长沙是这么火爆,到底火爆在哪里???
还有一点就是,在深圳我时不时就犯个鼻炎,回了长沙一年了我一次都没有,不知道什么原因。
工作
工资,长沙这边的钱是真的少,我现在的年收入连我深圳的三分之一都没有,都快到四分之一了。
当然,我既然选择回来了,就会接受这个低薪,而且在回来之前我就已经做好了心理建设,再加上我没有房贷和车贷,整体上来说,每个月略有结余。
所以,相比以前在深圳赚那么多钱但是无法和自己家人在一起,我更加愿意选择少赚点钱,当然,每个人的选择不同。我也见过很多,受不了长沙的低工资,然后继续回深圳搬砖的。
公司,长沙这边的互联网公司非常少,说是互联网荒漠都不为过。大部分都是传统性的公司,靠国企、外包而活着,就算有些公司说自己是互联网公司,但也不过是披着互联网的羊皮而已。而且在这里绝大多数公司都是野路子的干法,基建差,工作环境也不咋地,福利待遇与深圳的大厂更是没法比,比如社保公积金全按最低档交。年假,换一家公司就清零,我进入公司的时候,我一度以为我有 15 天,一问人事,试用期没有,转正后第一年按 5 天折算,看得我一脸懵逼。
加班,整体上来说,我感觉比深圳加班得要少,当然,大小周,单休的公司也不少,甚至有些公司连五险一金都不配齐,劳动法法外之地名副其实。
同时,这边非常看重学历,一些好的公司,非 985 、211 不要,直接把你门焊死,而这些公司整体上来说工资都很不错,40+ 起码是有的(比如某银行,我的履历完美契合,但就是学历问题而被拒),在长沙能拿这工资,简直就是一种享受,一年就是一套房的首付。
最后,你问我长沙工资这么低,你为什么还选择长沙呢?在工作和生活之间,我只不过选择了生活,仅此而已!!
来源:juejin.cn/post/7457175937736163378
我写了个App,上架 Google Play 一年,下载不到 10 次,于是决定把它开源了
缘起
起初接触某某标签笔记,我被其以标签为核心的卡片笔记模式深深吸引。然而,99 元一年的会员费感觉有点贵了,再加上数据存储在它的服务器,总感觉缺乏一份安全感。
身为 Android 开发者,一个念头在我脑海中闪现:何不亲手写一款属于自己的类似应用?
开发历程
说干就干,最初采用 xml 方式进行开发,慢悠悠的写了几个月写完后。但随着谷歌大力推广 Compose,我决定试试新的技术,发现真香,于是对项目进行 Compose 重构。在 UI 风格上,也经历多次迭代,从最初的随意设计,到遵循谷歌 MD 风格,再到引入 Slat UI,改了无数次,程序员设计 ui 是真的难🥲。
写完后,原本计划在酷安或国内应用市场上架,于是觉得申请软著,反反复复打回,半年后终于申请下来了。结果新政出来了,要公安局备案,还要买服务器。
无奈之下,我选择放弃国内上架计划,转而开通谷歌开发者账号,当时谷歌开发者账号容易弄,交钱绑卡,很快就将应用上架至 Google Play。
现在一年左右了,没做宣传, 10+左右的下载量😓。
现在换了一个朝九晚九的的工作,再加上要带娃,决定将这个项目开源,有兴趣的朋友可以一起维护与开发。
理想与现实的碰撞
从事 Android 开发多年,一直在写公司业务代码,初入行时便怀揣着打造一款个人 App,上架应用市场,甚至有可能获得一点睡后收入的梦想。
开发完成后,我才发现,开发只是整个过程中最简单的环节。
在国内上架应用,软著开公司备案买服务器各种卡流程,很佩服那些上班又自己能这么折腾的开发者。感觉现在国内个人 Android 开发者这条路基本是断了。
Google Play 以前很简单,交钱就行了,现在好像也需要拉人头内测等流程。
App如果不氪金去推广,现在移动互联网已经卷成一片死海,应用市场 App 一大堆,特别是程序员三件套,再去做的话基本凉凉。
如果软件收费,售后问题同样棘手。我在酷安付费购买过多款优秀 App,并加入开发者群,潜水好几年。一旦软件收费,便意味着要对用户负责,面对用户五花八门的需求、各机型适配问题、还有跨平台需求,还有的人无脑喷,管理和维护成本其实非常高。
折腾了这么久,对于大多数人而言,个人开发者之路并不好走,尤其是身处 996 工作环境时,更是难上加难。过去工作稍有空闲,还能抽时间写代码,现在到家都快 10 点了,看到电脑就想吐(真实的心里反应)。
浅谈一下 35 岁程序员焦虑和副业问题,程序员软件三件套是行不通的,因为这些产品非常多,并且大多都是没什么亮点的,大部分估计赚的钱还不够服务器和各种流程的成本。我觉得如果是客户端,2025 年 个人开发者这条路都不建议去走了,前段时间看到很多套壳的 AI 客户端,套完壳容易,推广如果不砸钱,基本没人问津。好不容易推广,你干的过免费的 deepseek 和 kimi 之类的吗🤔
对于 35 岁焦虑, 个人觉得最好的路是找个制造业或者二三线城市的国企银行这种不怎么裁人的企业,躺平式继续 coding。
尾声
开源后,会不定时更新,后续打算试试 KMP 跨平台技术,平常时间大部分带娃,代码写的比较乱,大佬们将就的看。
欢迎提 PR,一起维护。
Github
如果能给一个 star,不胜感激🙏
2025.2.26更新
对于有些人说是AI做的,我贴几张图。最开始在Github,然后迁到Gitee,后面又迁到Github,开源后为了防止敏感信息,把以前的提交记录给清了。
play store发版记录
Gitee 记录
来源:juejin.cn/post/7471630643534512164
Uniapp小程序地图轨迹绘画
轨迹绘画
简介
- 轨迹绘画常用于展示车辆历史轨迹,运动历史记录等,本次案例采用的是汽车案例同时利用腾讯地图API,来实现地图轨迹绘画功能,具体情况根据实际项目变更。
本例是汽车轨迹绘画功能

1.在页面的onReady生命周期中创建map对象
onReady() {
// 创建map对象
this.map = uni.createMapContext('map');
// 获取屏幕高度(此处获取屏幕高度是因为本示例中使用了colorui的cu-custom自定义头部,需根据系统高度来自适应)
uni.getSystemInfo({
success: res => {
this.windowHeight = res.windowHeight;
}
});
},
2.设置轨迹动画事件
页面代码:
<view class="container">
<map id='map' :latitude="latitude" :longitude="longitude" :markers="covers"
:style="{ width: '100%', height: mapHeight + 'px' }" :scale="13" :polyline="polyline">
</map>
<view class="btnBox">
<button :disabled="isDisabled" @click="start" class="cu-btn bg-blue round shadow lg">开始回放</button>
<button @click="pause" class="cu-btn bg-red round shadow lg">暂停</button>
</view>
</view>
逻辑代码:
- 1.轨迹动画的开始事件
start() {
if (this.movementInterval) {
clearInterval(this.movementInterval);
}
this.isStart = true;
this.moveMarker();
},
- 2.轨迹动画的暂停事件
pause() {
this.isStart = false;
this.isDisabled = false;
if (this.movementInterval) {
clearInterval(this.movementInterval);
this.movementInterval = null;
}
},
- 3.轨迹动画移动事件
moveMarker() {
if (!this.isStart) return;
if (this.playIndex >= this.coordinate.length) {
this.playIndex = 0;
uni.showToast({
title: "播放完成",
duration: 1400,
icon: "none",
});
this.isStart = false;
this.isDisabled = false;
return;
}
let datai = this.coordinate[this.playIndex];
this.map.translateMarker({
markerId: 1,
autoRotate: true,
destination: {
longitude: datai.longitude,
latitude: datai.latitude,
},
duration: 700,
complete: () => {
this.playIndex++;
this.moveMarker();
},
});
},
完整代码如下
<!-- 地图轨迹组件 -->
<template>
<view>
<cu-custom class="navBox" bgColor="bg-gradual-blue" :isBack="true">
<block slot="backText">返回</block>
<block slot="content">地图轨迹</block>
</cu-custom>
<view class="container">
<map id='map' :latitude="latitude" :longitude="longitude" :markers="covers"
:style="{ width: '100%', height: mapHeight + 'px' }" :scale="13" :polyline="polyline">
</map>
<view class="btnBox">
<button :disabled="isDisabled" @click="start" class="cu-btn bg-blue round shadow lg">开始回放</button>
<button @click="pause" class="cu-btn bg-red round shadow lg">暂停</button>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
map: null,
movementInterval: null, // 用于存储定时器的引用
windowHeight: 0,
mapHeight: 0,
timer: null,
isDisabled: false,
isStart: false,
playIndex: 1,
id: 0, // 使用 marker点击事件 需要填写id
title: 'map',
latitude: 34.263734,
longitude: 108.934843,
// 标记点
covers: [{
id: 1,
width: 42,
height: 47,
rotate: 270,
latitude: 34.259428,
longitude: 108.947040,
iconPath: 'http://zgonline.top/car.png',
callout: {
content: "鄂A·88888", // <img src="车牌信息" alt="" width="50%" />
display: "ALWAYS",
fontWeight: "bold",
color: "#5A7BEE", //文本颜色
fontSize: "12px",
bgColor: "#ffffff", //背景色
padding: 5, //文本边缘留白
textAlign: "center",
},
anchor: {
x: 0.5,
y: 0.5,
},
}],
// 线
polyline: [],
// 坐标数据
coordinate: [{
latitude: 34.259428,
longitude: 108.947040,
problem: false,
},
{
latitude: 34.252918,
longitude: 108.946963,
problem: false,
},
{
latitude: 34.252408,
longitude: 108.946240,
problem: false,
},
{
latitude: 34.249286,
longitude: 108.946184,
problem: false,
},
{
latitude: 34.248670,
longitude: 108.946640,
problem: false,
},
{
latitude: 34.248129,
longitude: 108.946826,
problem: false,
},
{
latitude: 34.243537,
longitude: 108.946816,
problem: true,
},
{
latitude: 34.243478,
longitude: 108.939003,
problem: true,
},
{
latitude: 34.241218,
longitude: 108.939027,
problem: true,
},
{
latitude: 34.241192,
longitude: 108.934802,
problem: true,
},
{
latitude: 34.241182,
longitude: 108.932235,
problem: true,
},
{
latitude: 34.247227,
longitude: 108.932311,
problem: true,
},
{
latitude: 34.250833,
longitude: 108.932352,
problem: true,
},
{
latitude: 34.250877,
longitude: 108.931756,
problem: true,
},
{
latitude: 34.250944,
longitude: 108.931576,
problem: true,
},
{
latitude: 34.250834,
longitude: 108.929662,
problem: true,
},
{
latitude: 34.250924,
longitude: 108.926015,
problem: true,
},
{
latitude: 34.250802,
longitude: 108.910121,
problem: true,
},
{
latitude: 34.269718,
longitude: 108.909921,
problem: true,
},
{
latitude: 34.269221,
longitude: 108.922366,
problem: false,
},
{
latitude: 34.274531,
longitude: 108.922388,
problem: false,
},
{
latitude: 34.276201,
longitude: 108.923433,
problem: false,
},
{
latitude: 34.276559,
longitude: 108.924004,
problem: false,
},
{
latitude: 34.276785,
longitude: 108.945855,
problem: false,
}
],
posi: {
id: 1,
width: 32,
height: 32,
latitude: 0,
longitude: 0,
iconPath: "http://cdn.zhoukaiwen.com/car.png",
callout: {
content: "鄂A·888888", // 车牌信息
display: "BYCLICK",
fontWeight: "bold",
color: "#5A7BEE", //文本颜色
fontSize: "12px",
bgColor: "#ffffff", //背景色
padding: 5, //文本边缘留白
textAlign: "center",
},
anchor: {
x: 0.5,
y: 0.5,
},
}
}
},
watch: {},
// 分享小程序
onShareAppMessage(res) {
return {
title: '看看这个小程序多好玩~',
};
},
onReady() {
// 创建map对象
this.map = uni.createMapContext('map');
// 获取屏幕高度
uni.getSystemInfo({
success: res => {
this.windowHeight = res.windowHeight;
}
});
},
mounted() {
this.setNavTop('.navBox')
this.polyline = [{
points: this.coordinate,
color: '#025ADD',
width: 4,
dottedLine: false,
}];
},
methods: {
setNavTop(style) {
let view = uni.createSelectorQuery().select(style);
view
.boundingClientRect((data) => {
console.log("tabInList基本信息 = " + data.height);
this.mapHeight = this.windowHeight - data.height;
console.log(this.mapHeight);
})
.exec();
},
start() {
if (this.movementInterval) {
clearInterval(this.movementInterval);
}
this.isStart = true;
this.moveMarker();
},
moveMarker() {
if (!this.isStart) return;
if (this.playIndex >= this.coordinate.length) {
this.playIndex = 0;
uni.showToast({
title: "播放完成",
duration: 1400,
icon: "none",
});
this.isStart = false;
this.isDisabled = false;
return;
}
let datai = this.coordinate[this.playIndex];
this.map.translateMarker({
markerId: 1,
autoRotate: true,
destination: {
longitude: datai.longitude,
latitude: datai.latitude,
},
duration: 700,
complete: () => {
this.playIndex++;
this.moveMarker();
},
});
},
pause() {
this.isStart = false;
this.isDisabled = false;
if (this.movementInterval) {
clearInterval(this.movementInterval);
this.movementInterval = null;
}
},
}
}
</script>
<style lang="scss" scoped>
.container {
position: relative;
}
.btnBox {
width: 750rpx;
position: absolute;
bottom: 60rpx;
z-index: 99;
display: flex;
justify-content: space-around;
}
</style>
来源:juejin.cn/post/7406173972738867227
使用 canvas 实现电子签名
一、引言
电子签名作为数字化身份认证的核心技术之一,已广泛应用于合同签署、审批流程等场景。之前做公司项目时遇到这个需求,于是研究了下,目前前端主要有两种方式实现电子签名:原生Canvason 和 使用signature_pad 依赖库。
本文将基于Vue3 + TypeScript技术栈,深入讲解原生Canvas功能实现方案,并提供完整的可落地代码。
二、原生Canvas实现方案
完整代码:GitHub - seapack-hub/seapack-template: seapack-template框架
实现的逻辑并不复杂,就是使用canvas提供一个画板,让用户通过鼠标或者移动端触屏的方式在画板上作画,最后将画板上的图案生成图片保存下来。
(一) 组件核心结构
需要同时处理 鼠标事件(PC端) 和 触摸事件(移动端),实现兼容的效果。
// PC端 鼠标事件
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', endDrawing);
// 移动端 触摸事件
canvas.addEventListener('touchstart', handleTouchStart);
canvas.addEventListener('touchmove', handleTouchMove);
canvas.addEventListener('touchend', endDrawing);
具体流程:通过状态变量控制绘制阶段:
阶段 | 触发事件 | 行为 |
---|---|---|
开始绘制 | mousedown | 记录起始坐标,标记isDrawing=true |
绘制中 | mousemove | 连续绘制路径(lineTo + stroke) |
结束绘制 | mouseup | 重置isDrawing=false` |
代码实现:
<div class="signature-container">
<canvas
ref="canvasRef"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="endDrawing"
@mouseleave="endDrawing"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="endDrawing"
></canvas>
<div class="controls">
<button @click="clearCanvas">清除</button>
<button @click="saveSignature">保存签名</button>
</div>
</div>
(二) 类型和变量
//类型定义
type RGBColor = `#${string}` | `rgb(${number},${number},${number})`
type Point = { x: number; y: number }
type CanvasContext = CanvasRenderingContext2D | null
// 配置
const exportBgColor: RGBColor = '#ffffff' // 设置为需要的背景色
//元素引用
const canvasRef = ref<HTMLCanvasElement | null>(null)
const ctx = ref<CanvasContext>()
//绘制状态
const isDrawing = ref(false)
const lastPosition = ref<Point>({ x: 0, y: 0 })
(三) 绘制逻辑实现
初始化画布
//初始化画布
onMounted(() => {
if (!canvasRef.value) return
//设置画布大小
canvasRef.value.width = 800
canvasRef.value.height = 400
//获取2d上下文
ctx.value = canvasRef.value.getContext('2d')
if (!ctx.value) return
//初始化 画笔样式
ctx.value.lineWidth = 2
ctx.value.lineCap = 'round'
ctx.value.strokeStyle = '#000' //线条颜色
// 初始填充背景
fillBackground(exportBgColor)
})
//填充背景方法
const fillBackground = (color: RGBColor) => {
if (!ctx.value || !canvasRef.value) return
ctx.value.save()
ctx.value.fillStyle = color
ctx.value.fillRect(0, 0, canvasRef.value.width, canvasRef.value.height)
ctx.value.restore()
}
获取坐标
将事件坐标转换为 Canvas 内部坐标,兼容滚动偏移
//获取坐标点,将事件坐标转换为 Canvas 内部坐标,兼容滚动偏移
const getCanvasPosition = (clientX: number, clientY: number): Point => {
if (!canvasRef.value) return { x: 0, y: 0 }
//获取元素在视口(viewport)中位置
const rect = canvasRef.value.getBoundingClientRect()
return {
x: clientX - rect.left,
y: clientY - rect.top,
}
}
// 获取事件坐标
const getEventPosition = (e: MouseEvent | TouchEvent): Point => {
//TouchEvent 是在支持触摸操作的设备(如智能手机、平板电脑)上,用于处理触摸相关交互的事件对象
if ('touches' in e) {
return getCanvasPosition(e.touches[0].clientX, e.touches[0].clientY)
}
return getCanvasPosition(e.clientX, e.clientY)
}
开始绘制
将 isDrawing 变量值设置为true,表示开始绘制,并获取当前鼠标点击或手指触摸的坐标。
//开始绘制
const startDrawing = (e: MouseEvent | TouchEvent) => {
isDrawing.value = true
const { x, y } = getEventPosition(e)
lastPosition.value = { x, y }
}
绘制中
每次移动时创建新路径,连接上一个点与当前点。
//绘制中
const draw = (e: MouseEvent | TouchEvent) => {
if (!isDrawing.value || !ctx.value) return
//获取当前所在位置
const { x, y } = getEventPosition(e)
//开始新路径
ctx.value.beginPath()
//移动画笔到上一个点
ctx.value.moveTo(lastPosition.value.x, lastPosition.value.y)
//绘制线条到当前点
ctx.value.lineTo(x, y)
//描边路径
ctx.value.stroke()
//更新最后的位置
lastPosition.value = { x, y }
}
结束绘制
将 isDrawing 变量设为false,结束绘制
//结束绘制
const endDrawing = () => {
isDrawing.value = false
}
添加清除和保存方法
//清除签名
const clearCanvas = () => {
if (!ctx.value || !canvasRef.value) return
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
}
//保存签名
const saveSignature = () => {
if (!canvasRef.value) return
const dataURL = canvasRef.value.toDataURL('image/png')
const link = document.createElement('a')
link.download = 'signature.png'
link.href = dataURL
link.click()
}
移动端适配
// 触摸事件处理
const handleTouchStart = (e: TouchEvent) => {
e.preventDefault();
startDrawing(e.touches[0]);
};
const handleTouchMove = (e: TouchEvent) => {
e.preventDefault();
draw(e.touches[0]);
};
(四) 最终效果
来源:juejin.cn/post/7484987385665011762
努力工作,你已经是很了不起的成年人了
1、像潮水一样的焦虑
"我二十三岁,靠自己的努力,全款小米Su7 Ultra。"
"二十七岁,工作之余,副业一个月赚两万。"
"用 DeepSeek 做副业,三天赚了九千"
对的,这些是目前互联网上短视频、问答平台、公众号里再常见不过的文案,衬托之下:
仿佛早上挤地铁四号线、一边走路一边吃热干面、坐在格子间里连喝一口水都要见缝插针、晚上加班结束要迈着飞快的脚步去赶最后一班地铁的你,是一个恬不知耻正在迈向中年的你,像是一个混吃等死毫无作为的废物。
凌晨你回到出租屋,打开朋友圈,看到的却是这样那样的焦虑:
三十天写作训练营、素人短视频日入八百、宝妈靠手作实现财务自由。
这些标题像便利店门口的霓虹灯,在深夜里闪烁得格外刺眼,仿佛在质问每个失眠的成年人:你的人生怎么还没开张?
你躺在床上,闭上眼睛的时候还在脑海里反复复盘着:“我这领个死工资也不是个事儿啊,什么时候能搞个副业,也实现一下财务自由。赚够两百万存款的时候,我要把辞职信拍到领导的脸上。”
在焦虑、自责、懊恼之余,你是否忘了什么事情?
你是否忘了,你其实是一个:
经过完整的高等教育,每天算上通勤,为工作付出超过12小时时间,没有沾染赌毒行为,勤勤恳恳态度认真的年轻人。工作之余,你唯一的爱好可能就是下班后躲在出租屋里打两把游戏。
从任何角度上看,你都是一个努力的,踏实的,积极向上的年轻人。
那么,究竟是谁在引导你否定自己的努力,对自己的状态进行 PUA
呢?
副业如果真那么神奇的话,为什么它还是 “副业” 呢?
了解那些焦虑的本质,你会发现:“副业可能是版本陷阱”。
2,副业可能是版本陷阱
我们 Dota2
玩家有个经常被挂在嘴边的词语,叫 “版本陷阱”,指的是那些版本更新之后,被大家口口相传多么厉害,但是真在职业赛场上被选出来却胜率惨不忍睹的英雄。
很抱歉,我可能要说一句丧气话:
“副业就是这个版本的【版本陷阱】。”
为什么呢?
- 先聊副业本质
副业的本质是跳出主页赛道,恰好进入一条机会更多、收入更高的蓝海赛道;这个机会可能来自于你自身(你恰好擅长某些主业之外的能力),更多的时候来自于时代红利(早几年间你非常擅长制作短视频)。
因此,少量时间+副业可以带来高收入的本质,是个人长项在蓝海领域博取一个创收机会,但是你先问问自己,有没有主业之外领域的优势技能。
- 再说版本环境
在上一条里面我们提到了“蓝海行业”,这一点非常重要,只有某个赛道出现明显的技术缺口或人员缺口时,副业者才有腾挪的空间。但如果一个行业或赛道已经人满为患,你准备拿每天2个小时和那些每天12个小时的团队作战吗?
- 最后分析下“口口相传”的那些人
你以为天天分享副业赚钱的人群:“人生导师、大公无私、先富的来带动后富的。”
实际发那些文案的人群:那些教你"睡后收入"的博主,自己往往在通宵直播卖课;晒着海岛定位说走就走的旅行博主,回程机票可能都是网贷分期。这个魔幻的时代,连贩卖成功学的都在996加班。
而他们兜售焦虑的最后一环,还是希望焦虑者咬上他们那锈迹斑斑的铁钩,成为下一个为“财富自由”付费的“上进者”。
请容许我再说一句丧气话:
绝大多数普通人的搞钱手段就是上班,只有上班。
哪有那么多暴富的机会在前方等着你?哪有那么多“睡后”的收入在梦中等着你?哪有那么一劳永逸又无比轻松的战役,在彼岸等着你。
你之所以相信彼岸的美好,有没有可能是因为你看不穿横在河面上的浓雾?
3,努力上班,已经是很棒的成年人了
每天我总能见到大家在各种空间和途径输出焦虑:
女孩责怪男孩不上进,下班只知道打游戏。
男孩抱怨不该生孩子,没有时间和机会创业搞财富自由。
总有人和自己身边的成功同龄人做比对,反思得出“自己是个废物”的神奇结论。
可是啊同学,我们也只是一群普通人而已。
在步入三十五岁之后,变成互联网行业里的“下岗预备役”之后,我逐渐开始认清了自己的属性和位置,不再苛求自己与众不同。
经过完整的高等教育,每天算上通勤,为工作付出超过12小时时间,没有沾染赌毒行为,勤勤恳恳态度认真的年轻人。工作之余,你唯一的爱好可能就是下班后躲在出租屋里打两把游戏。
每周可以有时间陪家人一起做一些不花钱的娱乐。
每周可以有时间陪孩子梳理一下近期的学习、分享一下思考。
每周有那么几个小时可以做一些让自己从内在感到快乐、感到满足、感到宁静的事情。
你没有去赌博欠下一屁股债,没有去加杠杆投资卖掉父母的房产,没有闷着头贷款创业亏光爹妈的棺材本。
同学,你可能意识不到,但我今天非常想郑重地告诉你:
在成年人这个群体之中,你已经是非常杰出、非常棒的那一批了。
副业焦虑?让它见鬼去吧。
来源:juejin.cn/post/7481187555866804224
微信小程序主包过大终极解决方案
随着小程序项目的不断迭代升级,避免不了体积越来越大。微信限制主包最多2M,然而我们的项目引入了直播插件直接占了1.1M,导致必须采用一些手段去优化。下面介绍一下优化思路和终极解决方案。
1.分包
我相信几乎所有人都能想到的方案,基本上这个方案就能解决问题。具体如何实现可以参照官方文档这里不做过多说明。(基础能力 / 分包加载 / 使用分包 (qq.com)),但是有时候你会发现分包之后好像主包变化不是很大,这是为什么呢?
- 痛点1:通过依赖分析,如果分包中引入了第三方依赖,那么依赖的js仍然会打包在主包中,例如echarts、wxparse、socket.io。这就导致我们即使做了分包处理,但是主包还是很大,因为相关的js都会在主包中的vendor.js
- 痛点2:插件只能在主包中无法分包,例如直播插件直接占据1M
- 痛点3:tabbar页面无法分包,只能在主包内
- 痛点4:公共组件/方法无法分包,只能在主包内
- 痛点5:图片只能在主包内
2.图片优化
图片是最好解决的,除了tabbar用到的图标,其余都放在云上就好了,例如oss和obs。而且放在云上还有个好处就是背景图片无需担心引入不成功。
3.tabbar页面优化
这部分可以采用tabbar页面都在放在一个文件夹下,比如一共有4个tab,那么一个文件夹下就只存放这4个页面。其余tabbar的子页面一律采用分包。
4.独立分包
独立分包是小程序中一种特殊类型的分包,可以独立于主包和其他分包运行。从独立分包中页面进入小程序时,不需要下载主包。当用户进入普通分包或主包内页面时,主包才会被下载。
但是使用的时候需要注意:
- 独立分包中不能依赖主包和其他分包中的内容,包括 js 文件、template、wxss、自定义组件、插件等(使用 分包异步化 时 js 文件、自定义组件、插件不受此条限制)
- 主包中的
app.wxss
对独立分包无效,应避免在独立分包页面中使用app.wxss
中的样式; App
只能在主包内定义,独立分包中不能定义App
,会造成无法预期的行为;- 独立分包中暂时不支持使用插件。
5.终极方案we-script
我们自己写的代码就算再多,其实增加的kb并不大。大部分大文件主要源于第三方依赖,那么有没有办法像webpack中的externals一样,当进入这个页面的时候再去异步加载js文件而不被打包呢(说白了就是CDN)
其实解决方案就是we-script,他允许我们使用CDN方式加载js文件。这样就不会影响打包体积了。
使用步骤
npm install --save we-script
- "packNpmRelationList": [{"packageJsonPath": "./package.json", "miniprogramNpmDistDir":"./dist/"}]
- 点击开发者工具中的菜单栏:工具 --> 构建 npm
"usingComponents": {"we-script": "we-script"}
<we-script src="url1" />
使用中存在的坑
构建后可能会出现依赖报错,解决的方式就是将编译好的文件手动拖入miniprogram_npm文件夹中,主要是三个文件夹:we-script,acorn,eval5
最后成功解决了主包文件过大的问题,只要是第三方依赖,都可以通过这个办法去加载。
感谢阅读,希望来个三连支持下,转载记得标注原文地址~
来源:juejin.cn/post/7355057488351674378
uni-app 接入微信短剧播放器
前言
作为一个 uniapp 初学者,恰巧遇到微信短剧播放器接入的需求,在网上检索许久并没有发现傻瓜式教程。于是总结 uni-app 官网文档及微信开放文档,自行实践后,总结出几个步骤,希望为大家提供些帮助。实践后发现其实确实比较简单,大佬们可能也懒得写文档,那么就由我这个小白大概总结下。本文档仅涉及剧目提审成功后的播放器接入,其余相关问题请参考微信官方文档。
小程序申请插件
参考文档:developers.weixin.qq.com/miniprogram…
首先,需要在小程序后台,申请 appid 为 wx94a6522b1d640c3b 的微信插件,可以在微信小程序管理后台进行添加,路径是 设置 - 第三方设置 - 插件管理 - 添加插件,搜索 wx94a6522b1d640c3b 后进行添加:
uni-app 项目添加微信插件
参考文档:uniapp.dcloud.net.cn/tutorial/mp…
添加插件完成后,在 manifest.json 中,点击 源码视图,找到如下位置并添加红框内的代码,此步骤意在将微信小程序插件引入项目。
/* 添加微短剧播放器插件 */
"plugins" : {
"playlet-plugin" : {
"version" : "latest",
"provider" : "wx94a6522b1d640c3b"
}
}
manifest.json 中完成添加后,需要在 pages.json 中找一个页面(我这边使用的是一个新建的空白页面)挂载组件,挂载方式如下图红框中所示,需注意,这里的组件名称需要与 manifest.json 中定义的一致:
{
"path": "newPage/newPage",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false,
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
},
"mp-weixin": {
"usingComponents": {
"playlet-plugin": "plugin://playlet-plugin/playlet-plugin"
}
}
}
}
挂载空页面是个笨办法,目前我这边尝试如果不挂载的话,会有些问题,有大神知道别的方法可以在评论区指点一下~
App.vue 配置
参考文档:developers.weixin.qq.com/miniprogram…
首先,找个地方新建一个 playerManager.js,我这边建在了 common 文件夹下。代码如下(代码参考微信官方文档给出的 demo):
var plugin = requirePlugin("playlet-plugin");
// 点击按钮触发此函数跳转到播放器页面
function navigateToPlayer(obj) {
// 下面的${dramaId}变量,需要替换成小程序管理后台的媒资管理上传的剧目的dramaId,变量${srcAppid}是提审方appid,变量${serialNo}是某一集,变量${extParam}是扩展字段,可通过
const { extParam, dramaId, srcAppid, serialNo } = obj
wx.navigateTo({
url: `plugin-private://wx94a6522b1d640c3b/pages/playlet/playlet?dramaId=${dramaId}&srcAppid=${srcAppid}&serialNo=${serialNo}&extParam=${extParam || ''}`
})
}
const proto = {
_onPlayerLoad(info) {
const pm = plugin.PlayletManager.getPageManager(info.playerId)
this.pm = pm
// encryptedData是经过开发者后台加密后(不要在前端加密)的数据,具体实现见下面的加密章节
this.getEncryptData({serialNo: info.serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
pm.setCanPlaySerialList({
data: res.encryptedData,
freeList: [{start_serial_no: 1, end_serial_no: 10}], // 1~10集是免费剧集
})
})
pm.onCheckIsCanPlay(this.onCheckIsCanPlay)
// 关于分享的处理
// 开启分享以及withShareTicket
pm.setDramaFlag({
share: true,
withShareTicket: true
})
// 获取分享参数,页面栈只有短剧播放器一个页面的时候可获取到此参数
// 例如从分享卡片进入、从投流广告直接跳转到播放器页面,从二维码直接进入播放器页面等情况
plugin.getShareParams().then(res => {
console.log('getLaunch options query res', res)
// 关于extParam的处理,需要先做decodeURIComponent之后才能得到原值
const extParam = decodeURIComponent(res.extParam)
console.log('getLaunch options extParam', extParam)
// 如果设置了withShareTicket为true,可通过文档的方法获取更多信息
// https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/share.html
const enterOptions = wx.getEnterOptionsSync()
console.log('getLaunch options shareTicket', enterOptions.shareTicket)
}).catch(err => {
console.log('getLaunch options query err', err)
})
// extParam除了可以通过在path传参,还可以通过下面的接口设置
pm.setExtParam('hellotest')
// 分享部分end
},
onCheckIsCanPlay(param) {
// TODO: 碰到不可以解锁的剧集,会触发此事件,这里可以进行扣币解锁逻辑,如果用户无足够的币,可调用下面的this.isCanPlay设置
console.log('onCheckIsCanPlay param', param)
var serialNo = param.serialNo
this.getEncryptData({serialNo: serialNo}).then(res => {
// encryptedData是后台加密后的数据,具体实现见下面的加密章节
this.pm.isCanPlay({
data: res.encryptedData,
serialNo: serialNo,
})
})
},
getEncryptData(obj) {
const { serialNo } = obj
// TODO: 此接口请求后台,返回下面的setCanPlaySerialList接口需要的加密参数
const { srcAppid, dramaId } = this.pm.getInfo()
console.log('getEncryptData start', srcAppid, dramaId, serialNo)
return new Promise((resolve, reject) => {
resolve({
encryptedData: '' // TODO: 此参数需从后台接口获取到
})
})
},
}
function PlayerManager() {
var newProto = Object.assign({}, proto)
for (const k in newProto) {
if (typeof newProto[k] === 'function') {
this[k] = newProto[k].bind(this)
}
}
}
PlayerManager.navigateToPlayer = navigateToPlayer
module.exports = PlayerManager
新建完成后,在 App.vue 中进行组件的配置和引用。
onLaunch: function() {
// playlet-plugin必须和上面的app.json里面声明的插件名称一致
const playletPlugin = requirePlugin('playlet-plugin')
const _onPlayerLoad = (info) => {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
}
// 注册播放器页面的onLoad事件
playletPlugin.onPageLoad(_onPlayerLoad.bind(this))
},
_onPlayerLoad(info) {
var PlayerManager = require('@/common/playerManager.js')
const playerManager = new PlayerManager()
playerManager._onPlayerLoad(info)
},
页面使用
参考文档:developers.weixin.qq.com/miniprogram…
以上所有步骤完成后,就可以开心的使用短剧播放器了。 我这边临时写了个图片的 click 事件测试了一下:
clk() {
// 逻辑处理...获取你的各种参数
// 打开组件中封装的播放器页面
PlayerManager.navigateToPlayer({
srcAppid: 'wx1234567890123456', // 剧目提审方 appid
dramaId: '100001', // 小程序管理后台的媒资管理上传的剧目的 dramaId
serialNo: '1', // 剧目中的某一集
extParam: encodeURIComponent('a=b&c=d'), // 扩展字段,需要encode
})
},
写在最后:
总结完了,其实整体下来不是很难,对我这种前端小白来说检索和整合的过程是比较痛苦的,所以希望下一个接入的朋友可以少检索一些文档吧。
另附一个短剧播放器接口的文档: developers.weixin.qq.com/miniprogram…
文档主要介绍了短剧播放器插件提供的几个接口,在js代码里,插件接口实例通过下面的代码获取
// 名字playlet-plugin必须和app.json里面引用的插件名一致
const playletPlugin = requirePlugin('playlet-plugin')
读书越多越发现自己的无知,Keep Fighting!
欢迎友善交流,不喜勿喷~
Hope can help~
来源:juejin.cn/post/7373473695057428506
后端出身的CTO问:"前端为什么没有数据库?",我直接无语......
😅【现场还原】
"前端为什么没有自己的数据库?把数据存前端不就解决了后端性能问题" ——当CTO抛出这个灵魂拷问时,会议室突然安静得能听见CPU风扇的嗡鸣,在座所有人都无语了。这场因后端性能瓶颈引发的技术博弈,最终以"前端分页查询+本地筛选"的妥协方案告终。
面对现在几乎所有公司的技术leader都是后端出身,有的不懂前端甚至不懂技术,作为前端开发者,我们真的只能被动接受吗?
😣【事情背景】
- 需求:前端展示所有文章的标签列表,用户可以选择标签筛选文章,支持多选,每个文章可能有多个标签,也可能没任何标签。
- 前端观点:针对这种需求,我自然想到用户选中标签后,将标签id传给后端,后端根据id筛选文章列表返回即可。
- 后端观点:后端数据分库分表,根据标签检索数据还要排序分页,有性能瓶颈会很慢,很慢就会导致天天告警。
- 上升决策:由于方案有上述分歧,我们就找来了双方leader决策,双方leader也有分歧,最终叫来了CTO。领导想让我们将数据定时备份到前端,需要筛选的时候前端自己筛选。
CTO语录:
“前端为什么没有数据库?,把数据存前端,前端筛选,数据库不就没有性能压力了”
"现在手机性能比服务器还强,让前端存全量数据怎么了?"
"IndexedDB不是数据库?localStorage不能存JSON?"
"分页?让前端自己遍历数组啊,这不就是你们说的'前端工程化'吗?"
😓【折中方案】
在方案评审会上,我们据理力争:
- 分页请求放大效应:用户等待时间=单次请求延迟×页数
- 内存占用风险:1万条数据在移动端直接OOM
- 数据一致性难题:轮询期间数据更新的同步问题
但现实往往比代码更复杂——当CTO拍板要求"先实现再优化",使用了奇葩的折中方案:
- 前端轮询获取前1000条数据做本地筛选,用户分页获取数据超过1000条后,前端再轮询获取1000条,以此类推。
- 前端每页最多获取50条数据,每次最多并发5个请求(后端要求)
只要技术监控不报错,至于用户体验?慢慢等着吧你......
🖨️【批量并发请求】
既然每页只有50条数据,那我至少得发20个请求来拿到所有数据。显然,逐个请求会让用户等待很长时间,明显不符合前端性能优化的原则。于是我选择了 p-limit 和Promise.all来实现异步并发线程池。通过并发发送多个请求,可以大大减少数据获取的总时间。
import pLimit from 'p-limit';
const limit = pLimit(5); // 限制最多5个并发请求
// 模拟接口请求
const fetchData = (page, pageSize) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`数据页 ${page}:${pageSize}条数据`);
}, 1000);
});
};
// 异步任务池
const runTasks = async () => {
const totalData = 1000; // 总数据量
const pageSize = 50; // 每页容量
const totalPages = Math.ceil(totalData / pageSize); // 计算需要多少页
const tasks = [];
// 根据总页数动态创建请求任务
for (let i = 1; i <= totalPages; i++) {
tasks.push(limit(() => fetchData(i, pageSize))); // 使用pLimit限制并发请求
}
const results = await Promise.all(tasks); // 等待所有请求完成
console.log('已完成所有任务:', results);
};
runTasks();
📑【高效本地筛选数据】
当所有数据都请求回来了,下一步就是进行本地筛选。毕竟后端已经将查询任务分配给了前端,所以我得尽可能让筛选的过程更高效,避免在本地做大量的计算导致性能问题。
1. 使用哈希进行高效查找
如果需要根据某个标签来筛选数据,最直接的做法就是遍历整个数据集,但这显然效率不高。于是我决定使用哈希表(或 Map)来组织数据。这样可以在常数时间内完成筛选操作。
const filterDataByTag = (data, tag) => {
const tagMap = new Map();
data.forEach(item => {
if (!tagMap.has(item.tag)) {
tagMap.set(item.tag, []);
}
tagMap.get(item.tag).push(item);
});
return tagMap.get(tag) || [];
};
const result = filterDataByTag(allData, 'someTag');
console.log(result);
2. 使用 Web Workers 进行数据处理
如果数据量很大,筛选过程可能会比较耗时,导致页面卡顿。为了避免这个问题,可以将数据筛选的过程交给 Web Workers 处理。Web Worker 可以在后台线程运行,避免阻塞主线程,从而让用户体验更加流畅。
const worker = new Worker('worker.js');
worker.postMessage(allData);
worker.onmessage = function(event) {
const filteredData = event.data;
console.log('筛选后的数据:', filteredData);
};
// worker.js
onmessage = function(e) {
const data = e.data;
const filteredData = data.filter(item => item.tag === 'someTag');
postMessage(filteredData);
};
📝【总结】
这场技术博弈给我们带来三点深刻启示:
- 数据民主化趋势:随着WebAssembly、WebGPU等技术的发展,前端正在获得堪比后端的计算能力
- 妥协的艺术:临时方案必须包含演进路径,我们的分页实现预留了切换GraphQL的接口
- 性能新思维:从前端到边缘计算,性能优化正在从"减少请求"转向"智能分发"
站在CTO那句"前端为什么没有数据库"的肩膀上,我们正在构建这样的未来:每个前端应用都内置轻量级数据库内核,通过差异同步策略与后端保持数据一致,利用浏览器计算资源实现真正的端智能。这不是妥协的终点,而是下一代Web应用革命的起点。
后记:三个月后,我们基于SQL.js实现了前端SQL查询引擎,配合WebWorker线程池,使得复杂筛选的耗时从秒级降至毫秒级——但这已经是另一个技术突围的故事了。
来源:juejin.cn/post/7472732247932174388
Vue3 实现最近很火的酷炫功能:卡片悬浮发光
前言
大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~
有趣的动画效果
前几天在网上看到了一个很有趣的动画效果,如下,光会跟随鼠标在卡片上进行移动,并且卡片会有视差的效果
那么在 Vue3 中应该如何去实现这个效果呢?
基本实现思路
其实实现思路很简单,无非就是分几步:
- 首先,卡片是
相对定位
,光是绝对定位
- 监听卡片的鼠标移入事件
mouseenter
,当鼠标进入时显示光 - 监听卡片的鼠标移动事件
mouseover
,鼠标移动时修改光的left、top
,让光跟随鼠标移动 - 监听卡片的鼠标移出事件
mouseleave
,鼠标移出时,隐藏光
我们先在 Index.vue
中准备一个卡片页面,光的CSS效果可以使用filter: blur()
来实现
可以看到现在的效果是这样
实现光源跟随鼠标
在实现之前我们需要注意几点:
- 1、鼠标移入时需要设置卡片
overflow: hidden
,否则光会溢出,而鼠标移出时记得还原 - 2、获取鼠标坐标时需要用
clientX/Y
而不是pageX/Y
,因为前者会把页面滚动距离也算进去,比较严谨
刚刚说到实现思路时我们说到了mouseenter、mousemove、mouseleave
,其实mouseenter、mouseleave
这二者的逻辑比较简单,重点是 mouseover
这个监听函数
而在 mouseover
这个函数中,最重要的逻辑就是:光怎么跟随鼠标移动呢?
或者也可以这么说:怎么计算光相对于卡片盒子的 left 和 top
对此我专门画了一张图,相信大家一看就懂怎么算了
- left = clientX - x - width/2
- height = clientY - y - height/2
知道了怎么计算,那么逻辑的实现也很明了了~封装一个use-light-card.ts
接着在页面中去使用
这样就能实现基本的效果啦~
卡片视差效果
卡片的视差效果需要用到样式中 transform
样式,主要是配置四个东西:
- perspective:定义元素在 3D 变换时的透视效果
- rotateX:X 轴旋转角度
- rotateY:Y 轴旋转角度
- scale3d:X/Y/Z 轴上的缩放比例
现在就有了卡片视差的效果啦~
给所有卡片添加光源
上面只是给一个卡片增加光源,接下来可以给每一个卡片都增加光源啦!!!
让光源变成可配置
上面的代码,总感觉这个 hooks 耦合度太高不太通用,所以我们可以让光源变成可配置化,这样每个卡片就可以展示不同大小、颜色的光源了~像下面一样
既然是配置化,那我们希望是这么去使用 hooks 的,我们并不需要自己在页面中去写光源的dom节点,也不需要自己去写光源的样式,而是通过配置传入 hooks 中
所以 hooks 内部要自己通过操作 DOM 的方式,去添加、删除光源,可以使用createElement、appendChild、removeChild
去做这些事~
完整源码
<!-- Index.vue -->
<template>
<div class="container">
<!-- 方块盒子 -->
<div class="item" ref="cardRef1"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef2"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef3"></div>
</div>
</template>
<script setup lang="ts">
import { useLightCard } from './use-light-card';
const { cardRef: cardRef1 } = useLightCard();
const { cardRef: cardRef2 } = useLightCard({
light: {
color: '#ffffff',
width: 100,
},
});
const { cardRef: cardRef3 } = useLightCard({
light: {
color: 'yellow',
},
});
</script>
<style scoped lang="less">
.container {
background: black;
width: 100%;
height: 100%;
padding: 200px;
display: flex;
justify-content: space-between;
.item {
position: relative;
width: 125px;
height: 125px;
background: #1c1c1f;
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
</style>
// use-light-card.ts
import { onMounted, onUnmounted, ref } from 'vue';
interface IOptions {
light?: {
width?: number; // 宽
height?: number; // 高
color?: string; // 颜色
blur?: number; // filter: blur()
};
}
export const useLightCard = (option: IOptions = {}) => {
// 获取卡片的dom节点
const cardRef = ref<HTMLDivElement | null>(null);
let cardOverflow = '';
// 光的dom节点
const lightRef = ref<HTMLDivElement>(document.createElement('div'));
// 设置光源的样式
const setLightStyle = () => {
const { width = 60, height = 60, color = '#ff4132', blur = 40 } = option.light ?? {};
const lightDom = lightRef.value;
lightDom.style.position = 'absolute';
lightDom.style.width = `${width}px`;
lightDom.style.height = `${height}px`;
lightDom.style.background = color;
lightDom.style.filter = `blur(${blur}px)`;
};
// 设置卡片的 overflow 为 hidden
const setCardOverflowHidden = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardOverflow = cardDom.style.overflow;
cardDom.style.overflow = 'hidden';
}
};
// 还原卡片的 overflow
const restoreCardOverflow = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.style.overflow = cardOverflow;
}
};
// 往卡片添加光源
const addLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.appendChild(lightRef.value);
}
};
// 删除光源
const removeLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.removeChild(lightRef.value);
}
};
// 监听卡片的鼠标移入
const onMouseEnter = () => {
// 添加光源
addLight();
setCardOverflowHidden();
};
// use-light-card.ts
// 监听卡片的鼠标移动
const onMouseMove = (e: MouseEvent) => {
// 获取鼠标的坐标
const { clientX, clientY } = e;
// 让光跟随鼠标
const cardDom = cardRef.value;
const lightDom = lightRef.value;
if (cardDom) {
// 获取卡片相对于窗口的x和y坐标
const { x, y } = cardDom.getBoundingClientRect();
// 获取光的宽高
const { width, height } = lightDom.getBoundingClientRect();
lightDom.style.left = `${clientX - x - width / 2}px`;
lightDom.style.top = `${clientY - y - height / 2}px`;
// 设置动画效果
const maxXRotation = 10; // X 轴旋转角度
const maxYRotation = 10; // Y 轴旋转角度
const rangeX = 200 / 2; // X 轴旋转的范围
const rangeY = 200 / 2; // Y 轴旋转的范围
const rotateX = ((clientX - x - rangeY) / rangeY) * maxXRotation; // 根据鼠标在 Y 轴上的位置计算绕 X 轴的旋转角度
const rotateY = -1 * ((clientY - y - rangeX) / rangeX) * maxYRotation; // 根据鼠标在 X 轴上的位置计算绕 Y 轴的旋转角度
cardDom.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; //设置 3D 透视
}
};
// 监听卡片鼠标移出
const onMouseLeave = () => {
// 鼠标离开移出光源
removeLight();
restoreCardOverflow();
};
onMounted(() => {
// 设置光源样式
setLightStyle();
// 绑定事件
cardRef.value?.addEventListener('mouseenter', onMouseEnter);
cardRef.value?.addEventListener('mousemove', onMouseMove);
cardRef.value?.addEventListener('mouseleave', onMouseLeave);
});
onUnmounted(() => {
// 解绑事件
cardRef.value?.removeEventListener('mouseenter', onMouseEnter);
cardRef.value?.removeEventListener('mousemove', onMouseMove);
cardRef.value?.removeEventListener('mouseleave', onMouseLeave);
});
return {
cardRef,
};
};
结语 & 加学习群 & 摸鱼群
我是林三心
- 一个待过小型toG型外包公司、大型外包公司、小公司、潜力型创业公司、大公司的作死型前端选手;
- 一个偏前端的全干工程师;
- 一个不正经的掘金作者;
- 一个逗比的B站up主;
- 一个不帅的小红书博主;
- 一个喜欢打铁的篮球菜鸟;
- 一个喜欢历史的乏味少年;
- 一个喜欢rap的五音不全弱鸡
如果你想一起学习前端,一起摸鱼,一起研究简历优化,一起研究面试进步,一起交流历史音乐篮球rap,可以来俺的摸鱼学习群哈哈,点这个,有7000多名前端小伙伴在等着一起学习哦 --> 摸鱼沸点
来源:juejin.cn/post/7373867360019742758
都2025年了,还在用Markdown写文档吗?
俗话说得好:“程序员宁愿写 1000 行代码,也不愿意写 10 个字的文档。”
不愿写文档的原因,一方面是咱理科生文采确实不好,另一方面则是文档的更新维护十分麻烦。
每次功能有变更的时候, 时间又急(其实就是懒),很难想得起来同时去更新文档。
特别是文档中代码片段,总是在几天之后(甚至更久),被用户找过来吐槽:“你们也太不专业了,文档里的代码都跑不通。”
作为有素养的程序员,被人说 “不专业”,简直不能忍,一定要想办法解决这个问题。
文档代码化
很多开发者喜欢用语雀,飞书或者钉钉来写文档。
不得不承认,它们的编写和阅读的体验更好,样式也更丰富。
甚至就算是版本管理,语雀,飞书做得也不比 git 差。
不过对于开发者文档,我觉得体验,样式都不是最重要的。毕竟这些都是锦上添花。
更重要的是,文档内容有效性的保证,如果文档上的代码直接复制到本地,都要调试半天才能跑通,那不管它样式再好看开发者都要骂娘了。
所以文档最好就和代码放在同一个仓库中,这样代码功能有更新时,顺便就把文档一起改了。团队前辈 Review 代码时,也能顺便关注下相关的文档是否一同更新。
如果真的一定要搞一个语雀文档,也可以考虑用 Git Action,在分支合并到 master 时触发一次文档到语雀的自动同步。
Markdown 的问题
程序员最常用的代码化文档就是 Markdown 了,估计也是很多开发者的首选,比如我这篇文章就是用 Markdown 写的。
不过 Markdown 文档中的代码示例,也没有经过单元测试的验证,还是会出现文档中代码跑不通的现象。
Python 中有一个叫做 doctest 的工具,能够抽取文档中的所有 python 代码并执行,我们只要在分支合并前,确保被合并分支同时通过了单元测试和 doctest,就能保证文档中的代码示例都是有效的。
在 Java 中我找了半天没有找到类似工具,很多语言(比如 Go, Rust 等等)据我所知也没有类似的工具。
而且对于 Java,文档中给的一般都是不完整的代码片段,无法像 Python 一样直接就能进入命令行执行。
有句俗话 ”单元测试就是最好的文档“。我觉得没必要将单元测试和文档分开,最好的方式就是从单元测试中直接引用部分代码进入文档。
在变更功能时,我们一定也会改单元测试,文档也会同步更新,不需要单独维护。
在合并分支或者发布版本之前,肯定也会有代码门禁执行单元测试,这样就能确保文档中代码示例都是有效的。
目前我发现了一个能解决该问题的方案就是 adoc 文档。
adoc 文档
adoc 的全称是 Asciidoctor, 官网链接。
Github 已经对其实现了原生支持,只要在项目中将 README 文件的后缀改成 README.adoc
,Github 就会按照 adoc 的语法进行解析展示。
adoc 最强悍的能力就是可以对另一个文件的一部分进行引用。以我负责的开源项目 QLExpress 为例。
在单元测试 Express4RunnerTest
中,用 // tag::firstQl[]
和 // end::firstQl[]
圈出一个代码片段:
// import 语句省略...
/**
* Author: DQinYuan
*/
public class Express4RunnerTest {
// 省略...
@Test
public void docQuickStartTest() {
// tag::firstQl[]
Express4Runner express4Runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS);
Map<String, Object> context = new HashMap<>();
context.put("a", 1);
context.put("b", 2);
context.put("c", 3);
Object result = express4Runner.execute("a + b * c", context, QLOptions.DEFAULT_OPTIONS);
assertEquals(7, result);
// end::firstQl[]
}
// 省略...
}
然后在文档 README-source.adoc
中就可以 firstQl
这个 tag 引用代码片段:
=== 第一个 QLExpress 程序
[source,java,indent=0]
----
include::./src/test/java/com/alibaba/qlexpress4/Express4RunnerTest.java[tag=firstQl]
----
include::./src/test/java/com/alibaba/qlexpress4/Express4RunnerTest.java[tag=firstQl]
用于引用 Express4RunnerTest 文件中被 firstQl tag 包围的代码片段,其他的部分,等价于 Markdown 下面的写法:
### 第一个 QLExpress 程序
```java
```
这个 adoc 文档在渲染后,就会用单测中真实的代码片段替换掉 include
所占的位置,如下:
缺点就是 adoc 的语法和 Markdown 相差还挺大的,对以前用 Markdown 写文档的程序员有一定的熟悉成本。但是现在有 AI 啊,我们可以先用 Markdown 把文档写好,交给 Kimi 把它翻译成 Markdown。我对 adoc 的古怪语法也不是很熟悉,并且项目以前的文档都是 Markdown 写,都是 AI 帮我翻译的。
Github 渲染 adoc 文档的坑
我最开始尝试在 Github 上用 README.adoc
代替 README.md
,发现其中的 include
语法并没有生效:
Github 对于 adoc
include
的渲染逻辑还挺诡异的,既不展示引用文件的内容,也没有原样展示 adoc 代码
查询资料发现 Github 根本不支持 adoc 的 include
语法的渲染(参考)。不过好在参考文档中也给了解决方案:
- 源码中用
README-source.adoc
编写文档 - 使用 Git Action 监听
README-source.adoc
文件的变化。如果有变动,则使用asciidoctor
提供的命令行工具先预处理一下include
语法,将引用的内容都先引用进来。再将预处理的后的内容更新到README.adoc
中,这样README.adoc
就都是 Github 支持的语法了,可以直接在 Github 页面上渲染
Github Action 的参考配置如下(QLExpress中的配置文件):
name: Reduce Adoc
on:
push:
paths:
- README-source.adoc
branches: ['**']
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Install Asciidoctor Reducer
run: sudo gem install asciidoctor-reducer
- name: Reduce README
# to preserve preprocessor conditionals, add the --preserve-conditionals option
run: asciidoctor-reducer --preserve-conditionals -o README.adoc README-source.adoc
- name: Commit and Push README
uses: EndBug/add-and-commit@v9
with:
add: README.adoc
添加这个配置后,你会发现很多额外的 Commit,就是 Git Action 在预处理 README-source.adoc
后,对 README.adoc
发起的提交:
至此,就再也不用担心被人吐槽文档不专业啦。
来源:juejin.cn/post/7464247481227100196
URL地址末尾加不加”/“有什么区别
URL 结尾是否带 /
主要影响的是 服务器如何解析请求 以及 相对路径的解析方式,具体区别如下:
1. 基础概念
- URL(统一资源定位符) :用于唯一标识互联网资源,如网页、图片、API等。
- 目录 vs. 资源:
- 以
/
结尾的 URL 通常表示目录,例如:
https://example.com/folder/
- 不以
/
结尾的 URL 通常指向具体的资源(如文件),例如:
https://example.com/file
- 以
2. 带 /
和不带 /
的具体区别
(1)目录 vs. 资源
https://example.com/folder/
- 服务器通常会将其解析为 目录,并尝试返回该目录下的默认文件(如
index.html
)。
- 服务器通常会将其解析为 目录,并尝试返回该目录下的默认文件(如
https://example.com/folder
- 服务器可能会将其视为 文件,如果
folder
不是文件,而是目录,服务器可能会返回 301 重定向到folder/
。
- 服务器可能会将其视为 文件,如果
📌 示例:
- 访问
https://example.com/blog/
- 服务器可能返回
https://example.com/blog/index.html
。
- 服务器可能返回
- 访问
https://example.com/blog
(如果blog
是个目录)
- 服务器可能重定向到
https://example.com/blog/
,再返回index.html
。
- 服务器可能重定向到
(2)相对路径解析
URL 末尾是否有 /
会影响相对路径的解析。
假设 HTML 页面包含以下 <img>
标签:
<img src="image.png">
📌 示例:
- 访问
https://example.com/folder/
- 访问
https://example.com/folder
- 图片路径解析为
https://example.com/image.png
- 可能导致 404 错误,因为
image.png
在folder/
里,而浏览器错误地去example.com/
下查找。
- 图片路径解析为
原因:
- 以
/
结尾的 URL,浏览器会认为它是一个目录,相对路径会基于folder/
解析。 - 不带
/
,浏览器可能认为folder
是文件,相对路径解析可能会出现错误。
(3)SEO 影响
搜索引擎对 https://example.com/folder/
和 https://example.com/folder
可能会视为两个不同的页面,导致 重复内容问题,影响 SEO 排名。因此:
- 网站通常会选择 一种形式 并用 301 重定向 规范化 URL。
- 例如:
https://example.com/folder
自动跳转 到https://example.com/folder/
。- 反之亦然。
(4)API 请求
对于 RESTful API,带 /
和不带 /
可能导致不同的行为:
https://api.example.com/users
- 可能返回所有用户数据。
https://api.example.com/users/
- 可能返回 404 或者产生不同的结果(取决于服务器实现)。
一些 API 服务器对 /
非常敏感,因此最好遵循 API 文档的规范。
3. 总结
URL 形式 | 作用 | 影响 |
---|---|---|
https://example.com/folder/ | 目录 | 通常返回 folder/ 下的默认文件,如 index.html ,相对路径解析基于 folder/ |
https://example.com/folder | 资源(或重定向) | 可能被解析为文件,或者服务器重定向到 folder/ ,相对路径解析可能错误 |
https://api.example.com/data/ | API 路径 | 可能与 https://api.example.com/data 表现不同,具体由 API 设计决定 |
如果你在开发网站,建议:
- 统一 URL 规则,例如所有目录都加
/
或者所有请求都不加/
,然后用 301 重定向 确保一致性。 - 测试 API 的行为,确认带
/
和不带/
是否影响请求结果。
来源:juejin.cn/post/7468112128928350242
用node帮老婆做excel工资表
我是天元,立志做1000个有趣的项目的前端。如果你喜欢的话,请点赞,收藏,转发。评论领取
零花钱+100
勋章
背景
我老婆从事HR的工作,公司有很多连锁店,她需要将所有的门店的工资汇总计算,然后再拆分给各门店请确认,最后再提供给财务发工资。
随着门店数量渐渐增多,渐渐的我老婆已经不堪重负,每天加班都做不完,严重影响夫妻感情生活。
最终花费了2天的时间,完成了整个node程序,她只需要传入工资表,相应的各种表格在10s内自动输出。目前已正式交付,得到了每月零花钱提高100元的重大成果
。
整体需求
- 表格的导入和识别
- 表格的计算(计算公式要代入),表格样式正确
- 最终结果按照门店拆分为工资表
需求示例(删减版)
需求为,根据传入的基本工资及补发补扣,生成总工资表,门店工资表,财务工资表发放表。
工资表中字段为门店,姓名,基本工资,补发补扣,最终工资(基本工资+补发补扣)。最后一行为总计
门店工资表按照每个门店,单独一个表格,字段同工资表。最后一行为总计
工资表
基础工资
补发补扣
技术选型
这次的主力库为exceljs
,官方文档介绍如下
读取,操作并写入电子表格数据和样式到 XLSX 和 JSON 文件。
一个 Excel 电子表格文件逆向工程项目
选择exceljs是因为它支持完整的excel的样式及公式。
安装及目录结构
优先安装exceljs
npm init
yarn add exceljs
创建input,out,src三个文件夹,src放入index.js
package.json增加start脚本
"scripts": {
"start": "node src/index.js"
},
代码相关
导入
通过new Excel.Workbook();
创建一个工作簿,通过workbook.xlsx.readFile
来导入文件, 注意这是个promise
const ExcelJS = require("exceljs");
const path = require("path");
const inputPath = path.resolve(__dirname, "../input");
const outputPath = path.resolve(__dirname, "../out");
const loadInput =async () => {
const workbook = new ExcelJS.Workbook();
const inputFile = await workbook.xlsx.readFile(inputPath + "/工资表.xlsx")
};
loadInput()
数据拆分
通过getWorksheet
Api,我们可以获取到对应的工作表的内容
const loadInput =async () => {
...
// 基本工资
const baseSalarySheet = inputFile.getWorksheet("基本工资");
// 补发补扣
const supplementSheet = inputFile.getWorksheet("补发补扣");
}
然后我们需要进一步的来进行拆分,因为第一行为每个工作表的头,这部分在我们实际数据处理中不会使用,所以通过getRows
来获取实际的内容。
const baseSalaryContent = baseSalarySheet.getRows(
2,
baseSalarySheet.rowCount
);
baseSalaryContent.map((row) => {
console.log(row.values);
});
/**
[ <1 empty item>, '2024-02', '海贼王', '路飞', 12000 ]
[ <1 empty item>, '2024-02', '海贼王', '山治', 8000 ]
[ <1 empty item>, '2024-02', '火影忍者', '鸣人', '6000' ]
[ <1 empty item>, '2024-02', '火影忍者', '佐助', 7000 ]
[ <1 empty item>, '2024-02', '火影忍者', '雏田', 5000 ]
[ <1 empty item>, '2024-02', '一拳超人', '琦玉', 4000 ]
[]
[]
**/
可以看到实际的内容已经拿到了,我们要根据这些内容拼装一下最终便于后续的调用。
我们可以通过 row.getCell
Api获取到对应某一列的内容,例如门店是在B
列,那么我们就可以使用row.getCell('B')
来获取。
因为我们需要拆分门店,所以这里的基本工资,我们以门店为单位,把数据进行拆分
const baseSalary = {};
baseSalaryContent.forEach((row) => {
const shopName = row.getCell("B").value;
if (!shopName) return; // 过滤空行
const name = row.getCell("C").value;
const salary = row.getCell("D").value;
if (!baseSalary[shopName]) {
baseSalary[shopName] = [];
}
baseSalary[shopName].push({
name,
salary,
});
});
这样我们得到了一个以门店名称为key的对象,value为该门店的员工信息数组。利用相同方法,获取补发补扣。因为每个人已经确定了门店,所以后续只需要根据姓名来做key,拆分成一个object即可
// 补发补扣
const supplement = {};
supplementSheet.getRows(2, supplementSheet.rowCount).forEach((row) => {
const name = row.getCell("C").value;
const type = row.getCell("H").value;
let count = row.getCell("D").value;
// 如果为补扣,则金额为负数
if (type === "补扣") {
count = -count;
}
if (!supplement[name]) {
supplement[name] = 0;
}
supplement[name] += count;
});
数据组合
门店工资表
因为每个门店需要独立一张表,所以需要遍历baseSalary
Object.keys(baseSalary).forEach((shopName) => {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("工资表");
// 添加表头
worksheet.addRow([
"序号",
"门店",
"姓名",
"基本工资",
"补发补扣",
"最终工资",
]);
baseSalary[shopName].forEach((employee, index) => {
worksheet.addRow([
index + 1,
shopName,
employee.name,
+employee.salary,
supplement[employee.name] || 0,
+employee.salary + (supplement[employee.name] || 0),
]);
});
});
此时你也可以快进到表格输出
来查看输出的结果,以便随时调整
这样我们就把基本工资已经写入工作表了,但是这里存在问题,最终工资使用的是一个数值,而没有公式。所以我们需要改动下
worksheet.addRow([ index + 1, shopName, employee.name, employee.salary, supplement[employee.name] || 0,
{
formula: `D${index + 2}+E${index + 2}`,
result: employee.salary + (supplement[employee.name] || 0),
},
]);
这里的formula
将对应到公式,而result
是显示的值,这个值是必须写入的,如果你写入了错误的值,会在表格中显示该值,但是双击后,公式重新计算,会替换为新的值。所以这里必须计算正确
合计
依照上方的逻辑,继续添加一行作为合计,但是之前计算的时候,需要添加一个临时变量,记录下合计的相关内容。
const count = [0, 0, 0];
baseSalary[shopName].forEach((employee, index) => {
count[0] += +employee.salary;
count[1] += supplement[employee.name] || 0;
count[2] += +employee.salary + (supplement[employee.name] || 0);
worksheet.addRow([
index + 1,
shopName,
employee.name,
+employee.salary,
supplement[employee.name] || 0,
{
formula: `D${index + 2}+E${index + 2}`,
result: +employee.salary + (supplement[employee.name] || 0),
},
]);
});
然后在尾部添加一行
worksheet.addRow([ "合计", "", "", { formula: `SUM(D2:D${baseSalary[shopName].length + 1})`,
result: count[0],
},
{
formula: `SUM(E2:E${baseSalary[shopName].length + 1})`,
result: count[1],
},
{
formula: `SUM(F2:F${baseSalary[shopName].length + 1})`,
result: count[2],
},
]);
美化
表格的合并,可以使用mergeCells
worksheet.mergeCells(
`A${baseSalary[shopName].length + 2}:C${baseSalary[shopName].length + 2}`
);
这样就合并了我们的最后一行的前三列,接下来我们要给表格添加线条。
对于批量的添加,可以直接使用addConditionalFormatting
,它将在一个符合条件的单元格范围内添加规则
worksheet.addConditionalFormatting({
ref: `A1:F${baseSalary[shopName].length + 2}`,
rules: [
{
type: "expression",
formulae: ["true"],
style: {
border: {
top: { style: "thin" },
left: { style: "thin" },
bottom: { style: "thin" },
right: { style: "thin" },
},
alignment: { vertical: "top", horizontal: "left", wrapText: true },
},
},
],
});
表格输出
现在门店工资表已经拆分完成,我们可以直接保存了,使用xlsx.writeFile
Api来保存文件
Object.keys(baseSalary).forEach((shopName) => {
...
workbook.xlsx.writeFile(outputPath + `/${shopName}工资表.xlsx`);
})
最终效果
相关代码地址
来源:juejin.cn/post/7346421986607087635
蓝牙耳机丢了,我花几分钟写了一个小程序,找到了!
你是否曾经经历过蓝牙耳机不知道丢到哪里去的困扰?特别是忙碌的早晨,准备出门时才发现耳机不见了,整个心情都被影响。幸运的是,随着技术的进步,我们可以利用一些简单的小程序和蓝牙技术轻松找到丢失的耳机。今天,我要分享的是我如何通过一个自制的小程序,利用蓝牙发现功能,成功定位自己的耳机。这不仅是一次有趣的技术尝试,更是对日常生活中类似问题的一个智能化解决方案。
1. 蓝牙耳机丢失的困扰
现代生活中,蓝牙耳机几乎是每个人的必备品。然而,耳机的体积小、颜色常常与周围环境融为一体,导致丢失的情况时有发生。传统的寻找方式依赖于我们对耳机放置地点的记忆,但往往不尽人意。这时候,如果耳机还保持在开机状态,我们就可以借助蓝牙技术进行定位。然而,市场上大部分设备并没有自带这类功能,而我们完全可以通过编写小程序实现。
2. 蓝牙发现功能的原理
蓝牙发现功能是通过设备之间的信号传输进行连接和识别的。当一个蓝牙设备处于开机状态时,它会周期性地广播自己的信号,周围的蓝牙设备可以接收到这些信号并进行配对。这个过程的背后其实是信号的强度和距离的关系。当我们在手机或其他设备上扫描时,能够检测到耳机的存在,但并不能直接告诉我们耳机的具体位置。此时,我们可以通过信号强弱来推测耳机的大概位置。
3. 实现步骤:从构想到实践
有了这个想法后,我决定动手实践。首先,我使用微信小程序作为开发平台,利用其内置的蓝牙接口实现设备扫描功能。具体步骤如下:
- • 环境搭建:选择微信小程序作为平台主要因为其开发简便且自带蓝牙接口支持。
- • 蓝牙接口调用:调用
wx.openBluetoothAdapter
初始化蓝牙模块,确保设备的蓝牙功能开启。 - • 设备扫描:通过
wx.startBluetoothDevicesDiscovery
函数启动设备扫描,并使用wx.onBluetoothDeviceFound
监听扫描结果。 - • 信号强度分析:通过读取蓝牙信号强度(RSSI),结合多次扫描的数据变化,推测设备的距离,最终帮助定位耳机。
在代码的实现过程中,信号强度的变化尤为重要。根据RSSI值的波动,我们可以判断耳机是在靠近还是远离,并通过走动测试信号的变化,逐渐缩小搜索范围。
下面是我使用 Taro 实现的全部代码:
import React, { useState, useEffect } from "react";
import Taro, { useReady } from "@tarojs/taro";
import { View, Text } from "@tarojs/components";
import { AtButton, AtIcon, AtProgress, AtList, AtListItem } from "taro-ui";
import "./index.scss";
const BluetoothEarphoneFinder = () => {
const [isSearching, setIsSearching] = useState(false);
const [devices, setDevices] = useState([]);
const [nearestDevice, setNearestDevice] = useState(null);
const [isBluetoothAvailable, setIsBluetoothAvailable] = useState(false);
const [trackedDevice, setTrackedDevice] = useState(null);
useEffect(() => {
if (isSearching) {
startSearch();
} else {
stopSearch();
}
}, [isSearching]);
useEffect(() => {
if (devices.length > 0) {
const nearest = trackedDevice
? devices.find((d) => d.deviceId === trackedDevice.deviceId)
: devices[0];
setNearestDevice(nearest || null);
} else {
setNearestDevice(null);
}
}, [devices, trackedDevice]);
const startSearch = () => {
const startDiscovery = () => {
setIsBluetoothAvailable(true);
Taro.startBluetoothDevicesDiscovery({
success: () => {
Taro.onBluetoothDeviceFound((res) => {
const newDevices = res.devices.map((device) => ({
name: device.name || "未知设备",
deviceId: device.deviceId,
rssi: device.RSSI,
}));
setDevices((prevDevices) => {
const updatedDevices = [...prevDevices];
newDevices.forEach((newDevice) => {
const index = updatedDevices.findIndex(
(d) => d.deviceId === newDevice.deviceId
);
if (index !== -1) {
updatedDevices[index] = newDevice;
} else {
updatedDevices.push(newDevice);
}
});
return updatedDevices.sort((a, b) => b.rssi - a.rssi);
});
});
},
fail: (error) => {
console.error("启动蓝牙设备搜索失败:", error);
Taro.showToast({
title: "搜索失败,请重试",
icon: "none",
});
setIsSearching(false);
},
});
};
Taro.openBluetoothAdapter({
success: startDiscovery,
fail: (error) => {
if (error.errMsg.includes("already opened")) {
startDiscovery();
} else {
console.error("初始化蓝牙适配器失败:", error);
Taro.showToast({
title: "蓝牙初始化失败,请检查蓝牙是否开启",
icon: "none",
});
setIsSearching(false);
setIsBluetoothAvailable(false);
}
},
});
};
const stopSearch = () => {
if (isBluetoothAvailable) {
Taro.stopBluetoothDevicesDiscovery({
complete: () => {
Taro.closeBluetoothAdapter({
complete: () => {
setIsBluetoothAvailable(false);
},
});
},
});
}
};
const getSignalStrength = (rssi) => {
if (rssi >= -50) return 100;
if (rssi <= -100) return 0;
return Math.round(((rssi + 100) / 50) * 100);
};
const getDirectionGuide = (rssi) => {
if (rssi >= -50) return "非常接近!你已经找到了!";
if (rssi >= -70) return "很近了,继续朝这个方向移动!";
if (rssi >= -90) return "正确方向,但还需要继续寻找。";
return "信号较弱,尝试改变方向。";
};
const handleDeviceSelect = (device) => {
setTrackedDevice(device);
Taro.showToast({
title: `正在跟踪: ${device.name}`,
icon: "success",
duration: 2000,
});
};
return (
<View className="bluetooth-finder">
{isSearching && (
<View className="loading-indicator">
<AtIcon value="loading-3" size="30" color="#6190E8" />
<Text className="loading-text">搜索中...Text>
View>
)}
{nearestDevice && (
<View className="nearest-device">
<Text className="device-name">{nearestDevice.name}Text>
<AtProgress
percent={getSignalStrength(nearestDevice.rssi)}
status="progress"
isHidePercent
/>
<Text className="direction-guide">
{getDirectionGuide(nearestDevice.rssi)}
Text>
View>
)}
<View className="device-list">
<AtList>
{devices.map((device) => (
<AtListItem
key={device.deviceId}
title={device.name}
note={`${device.rssi} dBm`}
extraText={
trackedDevice && trackedDevice.deviceId === device.deviceId
? "跟踪中"
: ""
}
arrow="right"
onClick={() => handleDeviceSelect(device)}
/>
))}
AtList>
View>
<View className="action-button">
<AtButton
type="primary"
circle
onClick={() => setIsSearching(!isSearching)}
>
{isSearching ? "停止搜索" : "开始搜索"}
AtButton>
View>
View>
);
};
export default BluetoothEarphoneFinder;
嘿嘿,功夫不负苦心人,我最终通过自己的小程序找到了我的蓝牙耳机。
我将我的小程序发布到了微信小程序上,目前已经通过审核,可以直接使用了。搜索老码宝箱 即可体验。
顺带还加了非常多的小工具,而且里面还有非常多日常可能会用到的工具,有些还非常有意思。
比如
绘制函数图
每日一言
汇率转换(实时)
BMI 计算
简易钢琴
算一卦
这还不是最重要的
最重要的是,这里的工具是会不断增加的,而且,更牛皮的是,你还可以给作者提需求,增加你想要的小工具,作者是非常欢迎一起讨论的。有朝一日,你也希望你的工具也出现在这个小程序上,被千万人使用吧。
4. 实际应用与优化空间
这个小程序的实际效果超出了我的预期。我能够通过它快速找到丢失的耳机,整个过程不到几分钟时间。然而,值得注意的是,由于蓝牙信号会受到环境干扰,例如墙体、金属物等,导致信号强度并不总是精确。在后续的优化中,我计划加入更多的信号处理算法,例如利用三角定位技术,结合多个信号源来提高定位精度。此外,还可以考虑在小程序中加入可视化的信号强度图,帮助用户更直观地了解耳机的大致方位。
一些思考:
蓝牙耳机定位这个小程序的开发,展示了技术在日常生活中的强大应用潜力。虽然这个项目看似简单,但背后的原理和实现过程非常具有教育意义。通过这次尝试,我们可以看到,借助开源技术和简单的编程能力,我们能够解决许多日常生活中的实际问题。
参考资料:
- 微信小程序官方文档:developers.weixin.qq.com
- 蓝牙信号强度(RSSI)与距离关系的研究:http://www.bluetooth.com
- 个人开发者经验分享: 利用蓝牙发现功能定位设备
来源:juejin.cn/post/7423610485180727332
前端可玩性UP项目:大屏布局和封装
前言
autofit.js 发布马上要一年了,也收获了一批力挺用户,截至目前它在github上有1k 的 star,npm 上有超过 13k 的下载量。
这篇文章主要讲从设计稿到落地开发大屏应用,大道至简,这篇文章能帮助各位潇洒自如的开发大屏。
分析设计稿
分析设计稿之前先吐槽一下大屏这种展现形式,这简直就是自欺欺人、面子工程的最直接的诠释,是吊用没有,只为了好看,如果设计的再不好看啊,这就纯纯是屎。在我的理解中,这就像把PPT放到了web端,仅此而已。
但是王哥告诉我:"你看似没有用的东西,其实都有用,很多想真正做出有用的产品的企业,没钱,就要先把面子工程做好,告诉别人他们要做一件什么事,这样投资人才会看到,后面才有机会发展。"
布局方案
上图展示了一个传统意义上且比较普遍的大屏形态,分为四个部分,分别是
头部
头部经常放标题、功能菜单、时间、天气
左右面板
左右面板承载了各种数字和报表,还有视频、轮播图等等
中间
中间部分一般放地图,这其中又分假地图(一张图片)、图表地图(如echarts)、地图引擎(如:leaflet、mapbox、高德、百度)。或者有的还会放3D场景,一般有专门的同事去做3D场景,然后导入到web端。
大屏的设计通常的分辨率是 1920*1080 的,这也是迄今为止应用最广泛的显示器配置,当然也有基于客户屏幕做的异形分辨率,这就五花八门了。
但是万变不离其宗,分辨率的变化不会影响它的基本结构,根据上面的图,我们可以快速构建结构代码
<div class='Box'>
<div class="header"></div>
<div class="body">
<div class="leftPanel"></div>
<div class="mainMap"></div>
<div class="rightPanel"></div>
</div>
</div>
上面的代码实现了最简单的上下(Box)+左右(body)的布局结构,完全不需要任何定位策略。
要实现上图的效果,只需最简单的CSS即可完成布局。
组件方案
大屏虽然是屎,但是是一种可玩性很强的项目,想的越复杂,做起来就越复杂,想的越简单,做起来就越简单。
可以疯狂封装炫技,因为大屏里面的可玩组件简直太多了,且涵盖的太全了,想怎么玩都可以,包括但不限于 各类图表库的封装(echarts、highCharts、vChart)、轮播图(swiper)、地图引擎、视频库(包括直播流)等等。
如果想简单,甚至可以不用封装,可以看到结构甚至简单到不用CSS几行就可以搭建出基本框架,只把header、leaftPanel、rightPanel、map封装一下就可以了。
这里还有一个误区,就是大家都喜欢把 大型的组件库 拉到大屏里来用,结果做完了发现好像只用了一个 toast 和一个下拉组件,项目打包后却增大了几十倍的体积,其实像这种简单的组件,完全可以手写,或者找小的独立包来用,一方面会减小体积,不至于让项目臃肿,另一方面可以锻炼自己的手写能力,这才是有必要的封装。
适配
目前主流的适配方案,依然是 rem 方案,其原理就是根据根元素的 font-size 自动计算大小,但是此方法需要手动计算 rem 值,或者使用第三方插件如postcss等,但是此方案还有一个弊端,就是无法向下兼容,因为浏览器中最小的文字大小是12px。
vh/vw方案就不再赘述了,原理基本和 rem/em 相似,都涉及到单位的转换。
autofit.js
主要讲一下使用 autofit.js 如何快速实现适配。
不支持的场景
首先 autofit.js 不支持 elementUI(plus)、ant-design等组件库,具体是不支持包含popper.js的组件,popper.js 在计算弹出层位置时,不会考虑 scale 后的元素的视觉大小,所以会造成弹出元素的位置偏移。
其次,不支持 百度地图,百度地图对窗口缩放事件没有任何处理,有同学反馈说,即使使用了resize属性,百度地图在和autofit.js共同使用时,也会有事件热区偏移的问题。而且百度地图使用 bd-09 坐标系,和其他图商不通用,引擎的性能方面也差点意思,个人不推荐在开发中使用百度地图。
然后一些拖拽库,如甘特图插件,可能也不支持,他们在计算鼠标位置时同样没有考虑 scale 后的元素的视觉大小。
用什么单位
不支持的单位:vh、vw、rem、em
让我诧异的是,老有人问我该用什么单位,主要徘徊在 px 和 % 之间,加群的同学多数是因为用了相对单位,导致留白了。不过人各有所长,跟着各位大佬我也学到了很多。
看下图
假如有两个宽度为1000的元素,他们内部都有一个子元素,第一个用百分比设置为 width:50%;left:1%
, 第二个设置为 wdith:500px;left:10px
。此时,只要外部的1000px的容器宽度不变,这两个内部元素在视觉上是一模一样的,且在实际数值上也是一模一样的,他们宽度都为500px,距离左侧10px。
但是如果外部容器变大了,来看一下效果:
在样式不变的情况下,仅改变外部容器大小,差异就出来了,由上图可知,50%的元素依然占父元素的一半,实际宽度变成了 1000px,距离左侧的实际距离变成了 20px。
这当然不难理解,百分比单位是根据 最近的、有确定大小的父级元素计算的。
所以,应该用什么单位其实取决于想做什么,举个例子:在1920*1080
基础上开发,中间的地图写成了宽度为 500px ,这在正常情况下,看起来没有任何问题,它大概占屏幕的 26%,当屏幕分辨率达到4096*2160
时,它在屏幕上只占 12%,看起来就是缩在一角。而当你设置宽度为26%时,无论显示器如何变化,它始终占屏幕26%。
autofit.js 所干的事,就是把1000px 变成了 2000px或者把2000px变成了1000px,并给它设置了一个合适的缩放大小。
图表、图片拉伸
背景或各种图片按需设置 object-fit: cover;
即可
图表如echarts一般推荐使用百分比,且监听窗口变化事件做resize()
结语
再次感慨,大道至简,事情往往没有那么复杂,祝各位前程似锦。
来源:juejin.cn/post/7344625554530779176
我终于从不想上班又不能裸辞的矛盾中挣扎出来了
最近的状态有一种好像一个泄了气的皮球的感觉一样,就是对生活中很多事情都提不起来兴趣。
我希望自己可以多看一点书,但是我不想动;我希望自己可以练习书法,但是我不想动;我希望自己可以学会一门乐器,但是我不想动。
相比上面三点,我更希望的是我可以早上起来不用上班,但是这只是我的希望而已。
这就是我最近的生活状态。
我有一种我的生活仿佛失去了控制的感觉,每一天我的内心好像都有一个小人在不断呐喊,说我不想上班。因为这个声音,我一度非常非常想要裸辞,但是我为什么没有裸辞呢?
还不是因为我买房买车欠了十几万,我到现在才还了两万而已,再加上我每个月还有房贷要还。
然而,当我经常不情愿地做着跟我心里想法相悖的行为的时候,我发现自己常常会做一些小动作来向自己表达抗议和不满。
比如说,我的工作会变得越来越低效,上班的时候会偷偷地摸鱼,还有就是变得越来越容易拖延。
就好像这样的我,可以让那个不想上班的我,取得了一丢丢的小胜利一样。
一旦开始接受自己没有办法辞职,并且还要上个几十年班这样的结果时,就会让人有一种破罐子破摔的想法。
而且随之而来的是一种对未来,对生活的无力感。
这种无力感渐渐地渗透在我生活的方方面面,以至于让我慢慢地对很多东西都提不起兴趣,我生活中的常态就变成了不想动。
但是有趣的事情发生了,有一天我在和我朋友聊天的时候,我的脑子里面突然出现了一个想法,就是我决定两年之后我要实现我不上班的这个目标。
当有了这个想法之后,我就开始认真思考这件事情的可行度。
通过分析我现在收支情况,我把两年之内改成了2026年之前,因为我觉得这样会让我更加信服这个目标的可行性。
同时我把这个想法也拆分成了两个更为具体的目标,其中一个就是我要在2026年之前还完欠的所有钱。
第二个就是我需要给自己存够20万,这20万是不包括投资理财或者基金股票里面的钱,而是我完全可以自由支配的。
毕竟没有人可以在没有工作的情况下,没有收入的情况下。没有存款的情况下,还能保持一个不焦虑的状态。
当我得出了这两个具体的目标之后,我整个人瞬间被一种兴奋的状态填满,我瞬间找到了工作的意义和动力。
也许你会说,我的这个想法对我现在生活根本起不到任何的改变作用。
我依旧还需要每天七点起床,还是要每天重复地去过我两点一线的生活。
但是于我自己而言,当我给我上班的这件事情加了一个两年的期限之后,我突然觉得我的未来,我的生活都充满了希望。
我整个人从不想动的状态,变成了一种被兴奋的状态填满的感觉。
所以,如果你和我一样有一些类似的困扰,有一些你不想做而又不得不做的事情,让你有一种深陷泥潭,无法前进的感觉,那你不妨试一下这个方法。
结合你自己的实际情况,为你不想做这件事情,设计一个期限,这个期限必须要是你认可,你接受,并且你认为你可以在这个截止时间之前完成的。
我想这个决定应该会对你的生活带来一些改变。
来源:juejin.cn/post/7428154034480906278
2025年了,令人唏嘘的Angular,现在怎么样了🚀🚀🚀
迅速崛起和快速退出
时间回到2014年,此时的 Angular 1.x
习得了多种武林秘籍,左手降龙十八掌、右手六脉神剑,哦不,左手双向数据绑定
、右手依赖注入
、上能模块化开发
、下有模板引擎
和 前端路由
, 背后还有Google
这个风头无两的带头大哥做技术背书,可以说集万千功能和宠爱于一身,妥妥的主角光环。
而此时的江湖,B端
开发正尝到了 SPA
的甜头,积极的从传统的 MVC
开发模式转变为更为方便快捷的单页面应用开发模式,
文章同步在公众号:萌萌哒草头将军,欢迎关注!
一拍即合,强大的一站式单页面开发框架Angular
自然而然,就成了公认的武林盟主,江湖一哥。
但是好景不长,2016年9月14日 Angular 2.x
的发布,彻底断送了武林盟主的宝座,
Vue
:大哥,你可是真给机会呀!
2.0
使用ts
彻底重写(最早支持ts
的框架)、放弃了脏检查更新机制,引入了响应式系统、使用现代浏览器标准、加入装饰器语法,和 1.0
完全不兼容。可以从上图看到,此时大家基本上还不太接受ts
!
新手面对陡然升高的学习曲线叫苦连连,已经入坑的开发者因为巨大的迁移工作而怨声载道。
此时,默默耕耘了两年的小弟,Vue
已经拥有完备的本地化文档和丰富的可选生态,而且作为新手你只要会使用html
、css
、javascript
,就可以上手写项目了。
所以,此时的 Vue
振臂一呼:“王侯将相宁有种乎!”,立马新皇加冕!
积极改变,三拜义父的数据驱动
忆往昔峥嵘岁月稠,恰同学少年,风华正茂;书生意气,挥斥方遒。
一转眼,angular 已经发布第19
个大版本了(平均一年两个版本)。
失去武林盟主的Angular
,飘零半生,未逢明主,公若不弃,Angular
愿拜为义父,
从 脏检查机制
到 响应式系统
,再到Signals系统
, Angular
历经沧桑的数据驱动方式可以说是前端发展的缩影。
脏检查机制
脏检查机制
是通过拦截异步操作,http
setTimeout
用户交互事件等,触发变更检测系统,从根组件开始检查组件中数据是否有更新,有更新时,对应的 $scope
变量会被标记为 脏
,然后同步的更新dom
的内容,重新开始变更检查,直到稳定后标记为干净,即通过稳定性检查!
<!DOCTYPE html>
<html lang="en" ng-app="myApp">
<head>
<meta charset="UTF-8">
<title>AngularJS Counter</title>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
</head>
<body ng-controller="CounterController as ctrl">
<h1>Count: {{ ctrl.count }}</h1>
<h2>Double Count: {{ ctrl.doubleCount() }}</h2>
<button ng-click="ctrl.increment()">+1</button>
<script>
angular.module('myApp', [])
.controller('CounterController', function() {
var vm = this;
vm.count = 0;
vm.increment = function() {
vm.count++;
console.log('Latest count:', vm.count);
};
vm.doubleCount = function() {
return vm.count * 2;
};
});
</script>
</body>
</html>
但是这种检查机制存在缺陷,例如,当数据量十分庞大时,就会触发非常多次的脏检查机制
。
响应式系统
响应式系统
没有出现之前,脏检查机制
是唯一的选择,但是响应式系统
凭借快速轻便的特点,立马在江湖上引起了不小的轰动,Angular
也放弃了笨重的脏检查机制采用了响应式系统
!
// app.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<h1>Count: {{ count }}</h1>
<h2>Double Count: {{ doubleCount() }}</h2>
<button (click)="increment()">+1</button>
`,
})
export class AppComponent {
count: number = 0;
increment() {
this.count++;
console.log('Latest count:', this.count);
}
doubleCount() {
return this.count * 2;
}
}
和我们熟知的Vue
的响应式不同,Angular
的响应式采用双向数据流的设计,这也使得它在面对复杂项目时,性能和维护上不如Vue
快捷方便。
所以,为了更好的驾驭双向数据流
的响应式系统,Angular
也是自创了很多绝学,例如:局部变更检测。
该绝学主要招式:组件级变更检测策略
、引入zonejs
、OnPush
策略等。
1. 组件级变更检测策略
每个组件都有自己的更新策略,只有组件的属性和文本发生变化时,才会触发变更检测!
2. 引入zonejs
引入zonejs
拦截http
setTimeout
用户交互事件等异步操作
3. OnPush
策略
默认情况下,整个组件树在变更时更新。
但是开发者可以选择 OnPush
策略,使得组件仅在输入属性发生变化、事件触发或手动调用时才进行变更检测。这进一步大大减少了变更检测的频率,适用于数据变化不频繁的场景。
Signals系统
很快啊,当SolidJS
凭借优异的信号系统在江湖上闯出了响亮的名声,这时,大家才意识到,原来还有更优秀的开发方式!signal
系统的开发方式,也被公认为新一代的武林绝技!
于是,Angular 16
它来了,它带着signal
、memo
、effect
三件套走来了!
当使用signal
时,更新仅仅发生在当前组件。
// app.component.ts
import { Component, signal, effect, memo } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<h1>Count: {{ count() }}</h1>
<h2>Double Count: {{ doubleCount() }}</h2>
<button (click)="increment()">+1</button>
`,
styles: []
})
export class AppComponent {
// 使用 signal 来管理状态
count = signal(0);
// 使用 memo 来计算 doubleCount
doubleCount = memo(() => this.count() * 2);
constructor() {
// 使用 effect 来监听 count 的变化
effect(() => {
console.log('Latest count:', this.count());
});
}
increment() {
// 更新 signal 的值
this.count.set(this.count() + 1);
}
}
总结
Angular
虽然在国内市场一蹶不振,但是在国际市场一直默默耕耘 10
年。它作为一站式解决方案的框架,虽然牺牲了灵活性,但是也为开发者提供了沉浸式开发的选择!
且它不断创新、积极拥抱新技术的精神令人十分钦佩!
今天的内容就这些了,如果你觉得还不错,可以关注我。
如果文章中存在问题,欢迎指正!
来源:juejin.cn/post/7468526097011097654
这个排队系统设计碉堡了
先赞后看,Java进阶一大半
各位好,我是南哥。
我在网上看到某厂最后一道面试题:如何设计一个排队系统?
关于系统设计的问题,大家还是要多多思考,可能这道题考的不是针对架构师的职位,而是关于你的业务设计能力。如果单单只会用开源软件的API,那似乎我们的竞争力还可以再强些。学习设计东西、创作东西,把我们设计的产品给别人用,那竞争力一下子提了上来。
15岁的初中生开源了 AI 一站式 B/C 端解决方案chatnio,该产品在上个月被以几百万的价格收购了。这值得我们思考,程序创造力、设计能力在未来会变得越来越重要。
⭐⭐⭐收录在《Java学习/进阶/面试指南》:https://github/JavaSouth
精彩文章推荐
1.1 数据结构
排队的一个特点是一个元素排在另一个元素的后面,形成条状的队列。List结构、LinkedList链表结构都可以满足排队的业务需求,但如果这是一道算法题,我们要考虑的是性能因素。
排队并不是每个人都老老实实排队,现实会有多种情况发生,例如有人退号,那属于这个人的元素要从队列中删除;特殊情况安排有人插队,那插入位置的后面那批元素都要往后挪一挪。结合这个情况用LinkedList链表结构会更加合适,相比于List,LinkedList的性能优势就是增、删的效率更优。
但我们这里做的是一个业务系统,采用LinkedList这个结构也可以,不过要接受修改、维护起来困难,后面接手程序的人难以理解。大家都知道,在实际开发我们更常用List,而不是LinkedList。
List数据结构我更倾向于把它放在Redis里,有以下好处。
(1)数据存储与应用程序拆分。放在应用程序内存里,如果程序崩溃,那整条队列数据都会丢失。
(2)性能更优。相比于数据库存储,Redis处理数据的性能更加优秀,结合排队队列排完则销毁的特点,甚至可以不存储到数据库。可以补充排队记录到数据库里。
简单用Redis命令模拟下List结构排队的处理。
# 入队列(将用户 ID 添加到队列末尾)
127.0.0.1:6379> RPUSH queue:large user1
127.0.0.1:6379> RPUSH queue:large user2
# 出队列(将队列的第一个元素出队)
127.0.0.1:6379> LPOP queue:large
# 退号(从队列中删除指定用户 ID)
127.0.0.1:6379> LREM queue:large 1 user2
# 插队(将用户 ID 插入到指定位置,假设在 user1 之前插入 user3)
127.0.0.1:6379> LINSERT queue:large BEFORE user1 user3
1.2 业务功能
先给大家看看,南哥用过的费大厨的排队系统,它是在公众号里进行排队。
我们可以看到自己现在的排队进度。
同时每过 10 号,公众号会进行推送通知;如果 10 号以内,每过 1 号会微信公众号通知用户实时排队进度。最后每过 1 号就通知挺人性化,安抚用户排队的焦急情绪。
总结下来,我们梳理下功能点。虽然上面看起来是简简单单的查看、通知,背后可能隐藏许多要实现的功能。
1.3 后台端
(1)排队开始
后台管理员创建排队活动,后端在Redis创建List类型的数据结构,分别创建大桌、中桌、小桌三条队列,同时设置没有过期时间。
// 创建排队接口
@Service
public class QueueManagementServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// queueType为桌型
public void createQueue(String queueType) {
String queueKey = "queue:" + queueType;
redisTemplate.delete(queueKey); // 删除队列,保证队列重新初始化
}
}
(2)排队操作
前面顾客用餐完成后,后台管理员点击下一号,在Redis的表现为把第一个元素从List中踢出,次数排队的总人数也减 1。
// 排队操作
@Service
public class QueueManagementServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 将队列中的第一个用户出队
*/
public void dequeueNextUser(String queueType) {
String queueKey = "queue:" + queueType;
String userId = redisTemplate.opsForList().leftPop(queueKey);
}
}
1.4 用户端
(1)点击排队
用户点击排队,把用户标识添加到Redis队列中。
// 用户排队
@Service
public class QueueServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void enterQueue(String queueType, String userId) {
String queueKey = "queue:" + queueType;
redisTemplate.opsForList().rightPush(queueKey, userId);
log.info("用户 " + userId + " 已加入 " + queueType + " 队列");
}
}
(2)排队进度
用户可以查看三条队列的总人数情况,直接从Redis三条队列中查询队列个数。此页面不需要实时刷新,当然可以用WebSocket实时刷新或者长轮询,但具备了后面的用户通知功能,这个不实现也不影响用户体验。
而用户的个人排队进度,则计算用户所在队列前面的元素个数。
// 查询排队进度
@Service
public class QueueServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public long getUserPositionInQueue(String queueType, String userId) {
String queueKey = "queue:" + queueType;
List<String> queue = redisTemplate.opsForList().range(queueKey, 0, -1);
if (queue != null) {
return queue.indexOf(userId);
}
return -1;
}
}
(3)用户通知
当某一个顾客用餐完成后,后台管理员点击下一号。此时后续的后端逻辑应该包括用户通知。
从三个队列里取出当前用户进度是 10 的倍数的元素,微信公众号通知该用户现在是排到第几桌了。
从三个队列里取出排名前 10 的元素,微信公众号通知该用户现在的进度。
// 用户通知
@Service
public class NotificationServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private void notifyUsers(String queueType) {
String queueKey = "queue:" + queueType;
// 获取当前队列中的所有用户
List<String> queueList = jedis.lrange(queueKey, 0, -1);
// 通知排在10的倍数的用户
for (int i = 0; i < queueList.size(); i++) {
if ((i + 1) % 10 == 0) {
String userId = queueList.get(i);
sendNotification(userId, "您的排队进度是第 " + (i + 1) + " 位,请稍作准备!");
}
}
// 通知前10位用户
int notifyLimit = Math.min(10, queueList.size()); // 避免队列小于10时出错
for (int i = 0; i < notifyLimit; i++) {
String userId = queueList.get(i);
sendNotification(userId, "您已经在前 10 位,准备好就餐!");
}
}
}
这段逻辑应该移动到前面后台端的排队操作。
1.5 存在问题
上面的业务情况,实际上排队人员不会太多,一般会比较稳定。但如果每一条队列人数激增的情况下,可以预见到会有问题了。
对于Redis的List结构,我们需要查询某一个元素的排名情况,最坏情况下需要遍历整条队列,时间复杂度是O(n),而查询用户排名进度这个功能又是经常使用到。
对于上面情况,我们可以选择Redis另一种数据结构:Zset。有序集合类型Zset可以在O(lgn)的时间复杂度判断某元素的排名情况,使用ZRANK命令即可。
# zadd命令添加元素
127.0.0.1:6379> zadd 100run:ranking 13 mike
(integer) 1
127.0.0.1:6379> zadd 100run:ranking 12 jake
(integer) 1
127.0.0.1:6379> zadd 100run:ranking 16 tom
(integer) 1
# zrank命令查看排名
127.0.0.1:6379> zrank 100run:ranking jake
(integer) 0
127.0.0.1:6379> zrank 100run:ranking tom
(integer) 2
# zscore判断元素是否存在
127.0.0.1:6379> zscore 100run:ranking jake
"12"
我是南哥,南就南在Get到你的点赞点赞点赞。
创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️
来源:juejin.cn/post/7436658089703145524
⚔️不让我在控制台上调试,哼,休想🛠️
在 JavaScript 中,使用 debugger
关键字可以在代码执行到该位置时触发断点调试。这可以帮助开发人员进行代码调试和排错。然而,有些网站开发者可能会故意使用 debugger
关键字来阻止调试,从而增加代码的安全性。但仍然有一些方法可以绕过这种防护措施,进行调试和排错。
禁用浏览器debugger
因为 debugger 其实就是对应的一个断点,它相当于用代码显示地声明了一个断点,要解除它,我们只需要禁用这个断点就好了。
禁用全局断点
全局禁用开关位于 Sources
面板的右上角,如下图所示:
点击它,该按钮会被激活,变成蓝色。
这个时候我们再重新点击一下 Resume script execution(恢复脚本执行)按钮,跳过当前断点,页面就不会再进入到无限 debugger 的状态了。
请注意
,禁用所有断点可能会导致你错过一些潜在的问题或错误
,因为代码将会连续执行而不会在可能的问题点停止。因此,在禁用所有断点之前,请确保你已经理解了代码的行为,并且明白在出现问题时该如何调试。
禁用局部断点
尝试使用另一种方法来跳过这个无限 debugger。在 debugger 语句所在的行的行号上单击鼠标右键,此时会出现一个快捷菜单,操作下图所示:
添加条件断点
在JS代码 debugger 行数位置的最左侧点击右键,添加条件断点(满足条件才会进入断点),将条件设置为false,就是条件永远不成立,永远不会断下来。
添加条件断点还可以监视获取一些变量信息,还是挺好用的。
如果是简单的debugger断点,直接用上边的方式就可以,如果是通过定时器触发的debugger断点,就需要进行Hook处理了。
以上的方案执行完毕之后有时候会跳转空页面,这时候只需要在空页面上打开原先地址即可。
先打开控制台
有时候我们一打开网页,就直接进入空页面,控制台上的js和html文件也随之为空,这时候需要在空白页面,或者F12等键无法打开控制台等,这种可以先打开控制台,然后再在空白页面上打开网站即可。
可以在这个网站上试一下。
替换文件
直接使用浏览器开发者工具替换修改js(Sources面板 --> Overrides),或者通过FD工具替换。
这种方式的核心思路,是替换 JS 文件中的 debugger 关键字,并保存为本地文件,在请求返回的时候、通过正则匹配等方式、拦截并替换返回的 JS 代码,以达到绕过 debugger 的目的。也可以直接删掉相关的debugger代码。
具体实现可参考:2024最新版JavaScript逆向爬虫教程-------基础篇之无限debugger的原理与绕过
快捷方案-使用油猴等插件
使用这种方法,就不需要再打 script
断点。直接安装插件即可。
参考文献
2024最新版JavaScript逆向爬虫教程-------基础篇之无限debugger的原理与绕过
来源:juejin.cn/post/7369505226921738278
🔏别想调试我的前端页面代码🔒
这里我们不介绍禁止右键菜单, 禁止F12快捷键
和代码混淆
方案。
无限debugger
- 前端页面防止调试的方法主要是通过不断 debugger 来疯狂输出断点,因为 debugger 在控制台被打开的时候就会执行
- 由于程序被 debugger 阻止,所以无法进行断点调试,所以网页的请求也是看不到的.
基础方案
(() => {
function ban() {
setInterval(() => { debugger; }, 50);
}
try {
ban();
} catch (err) { }
})();
- 将
setInterval
中的代码写在一行,可以禁止用户断点,即使添加logpoint
为false
也无用 - 当然即使有些人想到用左下角的格式化代码,将其变成多行也是没用的
浏览器宽高
根据浏览器宽高、与打开F12后的宽高进行比对,有差值,说明打开了调试,则替换html内容;
- 通过检测窗口的外部高度和宽度与内部高度和宽度的差值,如果差值大于 200,就将页面内容设置为 "检测到非法调试"。
- 通过使用间隔为 50 毫秒的定时器,在每次间隔内执行一个函数,该函数通过创建一个包含
debugger
语句的函数,并立即调用该函数的方式来试图阻止调试器的正常使用。
(() => {
function block() {
if (window.outerHeight - window.innerHeight > 200 || window.outerWidth - window.innerWidth > 200) {
document.body.innerHTML = "检测到非法调试";
}
setInterval(() => {
(function () {
return false;
}
['constructor']('debugger')
['call']());
}, 50);
}
try {
block();
} catch (err) { }
})();
关闭断点,调整空页面
在不打开发者工具的情况下,debugger是不会执行将页面卡住,而恰恰是利用debugger的这一点,如果你打开开发者工具一定会被debugger卡住,那么上下文时间间隔就会增加,在对时间间隔进行判断,就能巧妙的知道绝对开了开发者工具,随后直接跳转到空白页,一气呵成。(文心一言采用方案)
setInterval(function () {
var startTime = performance.now();
// 设置断点
debugger;
var endTime = performance.now();
// 设置一个阈值,例如100毫秒
if (endTime - startTime > 100) {
window.location.href = 'about:blank';
}
}, 100);
第三方插件
disable-devtool
disable-devtool
可以禁用所有一切可以进入开发者工具的方法,防止通过开发者工具进行的代码搬运。
该库有以下特性:
- 支持可配置是否禁用右键菜单
- 禁用 f12 和 ctrl+shift+i 等快捷键
- 支持识别从浏览器菜单栏打开开发者工具并关闭当前页面
- 开发者可以绕过禁用 (url参数使用tk配合md5加密)
- 多种监测模式,支持几乎所有浏览器(IE,360,qq浏览器,FireFox,Chrome,Edge...)
- 高度可配置、使用极简、体积小巧
- 支持npm引用和script标签引用(属性配置)
- 识别真移动端与浏览器开发者工具设置插件伪造的移动端,为移动端节省性能
- 支持识别开发者工具关闭事件
- 支持可配置是否禁用选择、复制、剪切、粘贴功能
- 支持识别 eruda 和 vconsole 调试工具
- 支持挂起和恢复探测器工作
- 支持配置ignore属性,用以自定义控制是否启用探测器
- 支持配置iframe中所有父页面的开发者工具禁用
🦂使用🦂
<script disable-devtool-auto src='https://cdn.jsdelivr.net/npm/disable-devtool'>script>
更多使用方法参见官网:disable-devtool
disable-devtool
console-ban
禁止 F12 / 审查开启控制台,保护站点资源、减少爬虫和攻击的轻量方案,支持重定向、重写、自定义多种策略。
使用
<head>
<script src="https://cdn.jsdelivr.net/npm/console-ban@5.0.0/dist/console-ban.min.js">script>
<script>
// default options
ConsoleBan.init()
// custom options
ConsoleBan.init({
redirect: '/404'
})
script>
head>
在项目中使用:
yarn add console-ban
import { init } from 'console-ban'
init(options)
重定向
ConsoleBan.init({
// 重定向至 /404 相对地址
redirect: '/404',
// 重定向至绝对地址
redirect: 'http://domain.com/path'
})
使用重定向策略可以将用户指引到友好的相关信息地址(如网站介绍),亦或是纯静态 404 页面,高防的边缘计算或验证码等页面。
注:若重定向后的地址可以通过 SPA 路由切换或 pjax 局部加载技术等进行非真正意义上的页面切换,则切换后的控制台监测将不会再次生效,对于 SPA 你可以在路由卫士处重新注册本实例,其他情况请引导至真正的其他页面。
重写
var div = document.createElement('div')
div.innerHTML = '不要偷看啦~'
ConsoleBan.init({
// 重写 body 为字符串
write: ' 不要偷看啦~
',
// 可传入节点对象
write: div
})
重写策略可以完全阻断对网站内容的审查,但较不友好,不推荐使用。
回调函数
ConsoleBan.init({
callback: () => {
// ...
}
})
回调函数支持自定义打开控制台后的策略。
参数
name | required | type | default | description |
---|---|---|---|---|
clear | no | boolean | true | 禁用 console.clear 函数 |
debug | no | boolean | true | 是否开启定时 debugger 反爬虫审查 |
debugTime | no | number | 3000 | 定时 debugger 时间间隔(毫秒) |
redirect | no | string | - | 开启控制台后重定向地址 |
write | no | string 或Element | - | 开启控制台后重写 document.body 内容,支持传入节点或字符串 |
callback | no | Function | - | 开启控制台后的回调函数 |
bfcache | no | boolean | true | 禁用 bfcache 功能 |
注:redirect
、write
、callback
三种策略只能取其一,优先使用回调函数。
参考文章
结语
需要注意的是,这些技术可以增加攻击者分析和调试代码的难度,但无法完全阻止恶意调试。因此,对于一些敏感信息或关键逻辑,最好的方式是在后端进行处理,而不是完全依赖前端来保护。
下篇文章主要介绍如何破解这些禁止调试的方法。
来源:juejin.cn/post/7368313344712179739
DeepSeek引发行业变局,2025 IT人该如何破局抓住机遇
一. 🎯 变局中抓住核心
这个春节被DeepSeek消息狂轰滥炸,很多做IT朋友已经敏锐的意识到 一场变局已经酝酿,整个IT行业都将迎来洗牌重塑。 中小IT企业、个人创业者、普通人该如何面对这场变局,如何不被市场淘汰,如何抓住机遇?
先说结论
2025年,谁能将
🔥技术热点 转换成 🚀业务引擎
谁就能在这场变局中抢得先机
2025年,选择躺平视而不见,以后的路将越来越窄
二. 🧐 AI巨头垄断,小公司别硬刚
头部AI/大模型厂商 (OpenAI、DeepSeek、字节、阿里、百度…)
通过大模型底座控制生态入口
中小IT公司沦为“AI插件开发者”
⬇️
说直白点就是别学大厂烧钱训练大模型
“不要用你搬砖攒下的血汗钱挑战巨头们躺赚的钱袋子”
合理的生存之计是:
- 直接调用低成本接入大厂的大模型能力
- 通过云服务+开源模型聚焦1-2个细分垂直赛道开发领域专属大模型应用
当然你也可以不信邪
学习DeepSeek不走寻常路
十年量化无人问,一朝DS天下知
闷声鼓捣一个大的
三. 🖊️ 产品思维要转变
对于产品现在客户要的不是功能,是智商
产品的设计思路一定是
从功能导向 ➡️ 智能导向
堆功能堆指标是底限,堆智能才是上限
无论是硬件还是软件公司,殊途同归
卖硬件 ➡️ 卖智能,卖软件 ➡️ 卖智能
四. 🔧 定制化服务市场潜力大
虽然AI巨头都推出了N个
行业标准化AI解决方案
以近乎成本价抢占市场
但是,中国客户还是喜欢”定制化“
有数据统计,60%以上的行业需求无法被标准化方案满足
- 中小IT公司:
- 大厂不愿做,我做 📣
- 大厂不屑做,我做 📣
- 大厂不会做,我做 📣
比如,
现在做企业AI应用开发
需要触碰企业长年积累的数据
客户有很强意识👉🏻这是核心资产
所以开发时,就要求定制化+本地化
- 只有定制化,才能构建数据护城河
- 只有定制化,客户对数据隐私才放心
...
也许这不是真理,但却是刚需
总之,客户定制化理由千千万万
这就是IT人的机会
五. 💰 在你懂而别人不懂的领域赚钱
小公司
- 聚焦“AI+垂直场景”做深行业Know-How
- 避免与通用大模型正面竞争
中等公司
- 构建“私有化模型+数据闭环”
- 在特定领域建立技术壁垒
六. 💯 存量市场以稳为主,增量市场探索可能
存量业务
- 用AI改造现有产品和客户场景
- 对于已经稳定的客户和产品应当积极引入 AI 技术进行升级改造
增量市场
- 探索AI原生需求
- 要善于挖掘客户对AI的新需求并及时满足,抢占市场先机
此过程中,有两点需要注意
- 敏捷性 > 规模
- 快速试错、小步快跑的模式比巨额投入更重要
- 场景落地 > 技术炫技
- 能解决具体业务痛点的“60分AI方案”比追求“99分技术指标”更易存活
七. 💥 纯技术团队将面临淘汰
开发团队
- 必须重构开发流程
- 建立“AI+人工”混合开发模式
- 开发流程需和AI工具链深度集成
- 开发不要过重,采用轻量化技术路线
部署和运维团队
- 同样建立“AI+人工”混合运维模式
- 智能运维手段(故障预测、根因分析)将成标配
- 内部要刻意培养AI-Aware工程师
未来技术人员的筛选条件可能不再是年龄、学历、工作经验而是你有没有 AI Awareness
八. 📝 总结
在这场变局中能活好的普通IT公司,AI创业者
不一定是技术最强的
而是最会借力AI
用行业经验+客户积累+AI工具
做巨头看不上的 “小而美”生意 🤩
来源:juejin.cn/post/7468203211725783094
表妹问:前端好玩吗?我说好玩,但表妹接下来的回复看哭了我
表妹问:前端好玩吗?我说好玩,但表妹接下来的回复看哭了我。
是的,回复如下:
这红海血途上,新兵举着 "大前端" 旌旗冲锋,老兵拖着node_modules
残躯撤退。资本织机永不停歇,框架版本更迭如暴君换季,留下满地deprecated
警告如秋后落叶。
其一、夹缝中的苦力
世人都道前端易,不过调接口、改颜色,仿佛稚童搭积木。却不知那屏幕上寸寸像素之间,皆是血泪。产品拍案,需求朝夕三变,昨日之红蓝按钮,今晨便成黑白圆角。UI稿纸翻飞如雪,设计师手持“用户体验”四字大旗,将五更赶工的代码尽数碾碎。后端端坐高台,接口文档空悬如镜花水月,待到交付时辰,方抛来残缺数据。此时节,前端便成了那补天的女娲,于混沌中捏造虚拟对象,用JSON.parse('{"data": undefined}')
这等荒诞戏法,将虚无粉饰成真实。
看这段代码何等悲凉:
// 后端曰:此接口返data字段,必不为空
fetch('api/data').then(res => {
const { data } = res;
render(data[0].children[3].value || '默认值'); // 层层掘墓,方见白骨
});
此乃前端日常——在数据废墟里刨食,用||
与?.
铸成铁锹,掘出三分体面。
其二、技术的枷锁
JavaScript本是脚本小儿,如今却要扛鼎江山。君不见React、Vue、Angular三座大山压顶,每年必有新神像立起。昨日方学得Redux真经,今朝GraphQL又成显学。更有Electron、ReactNative、Flutter诸般法器,教人左手写桌面应用,右手调移动端手势。所谓“大前端”,实乃资本画饼之术,人前跨端写,人后页面仔——既要马儿跑,又言食草易。
且看这跨平台代码何等荒诞:
// 一套代码统治三界(iOS/Android/Web)
<View>
{Platform.OS === 'web' ?
<div onClick={handleWebClick} /> :
<TouchableOpacity onPress={handleNativePress} />
}
</View>
此类缝合怪代码,恰似给长衫打补丁,既失体统,又损性能。待到内存泄漏、渲染卡顿时,众人皆指前端曰:"此子学艺不精!"
何人怜悯前端 node18 react19 逐人老,后端写着 java8 看着 java22 笑。
其三、尊严的消亡
领导提拔,必先问尔可懂SpringBoot、MySQL分库分表?纵使前端用WebGL绘出三维宇宙,用WebAssembly重写操作系统,在会议室里仍是“做界面的”。工资单上数字最是直白——同司后端新人起薪一万五,前端老将苦熬三年方摸得此数。更可笑者,产品经理醉酒时吐真言:"你们不就是改改CSS么?"
再看这可视化代码何等心酸:
// 用Canvas画十万级数据点
ctx.beginPath();
dataPoints.forEach((point, i) => {
if (i % 100 === 0) ctx.stroke(); // 分段渲染防卡死
ctx.lineTo(point.x, point.y);
});
此等精密计算,在他人眼中不过"动画效果",与美工修图无异。待浏览器崩溃,众人皆曰:"定是前端代码劣质!"
技术大会,后端高谈微服务、分布式,高并发,满座掌声如雷,实则系统使用量百十来人也是远矣;前端言及 CSS 栅格、浏览器渲染,众人瞌睡连天。领导抚掌笑曰:“后端者,国之重器;前端者,雕虫小技。” 晋升名单,后端之名列如长蛇,前端者埋没于墙角尘埃。纵使将那界面写出花来,终是 “美工” 二字定终身。
其四、维护者的悲歌
JavaScript本无类型,如野马脱缰。若非经验老道之一,常写出这等代码:
function handleData(data) {
if (data && typeof data === 'object') { // 万能判断
return data.map(item => ({
...item,
newProp: item.id * Math.random() // 魔改数据
}));
}
return []; // 默认返回空阵,埋下百处报错
}
此类代码如瘟疫蔓延,领导却言“这些功能实习生也能写!”,却不顾三月后连作者亦不敢相认,只得下任前端难上加难。
而后端有Type大法,编译检查护体,有Swagger契约,有Docker容器,纵使代码如乱麻,只需扩内存、增实例,便可遮掩性能疮疤。
其五、末路者的自白
诸君且看这招聘启事:"需精通Vue3+TS+Webpack,熟悉React/Node.js,有Electron/小程序经验,掌握Three.js/WebGL者重点考虑。" 薪资却标着"6-8K"。更有机智者发明"全栈"之名,实欲以一人之躯,承三头六臂之劳。
再看这面试题何等荒谬:
// 手写Promise实现A+规范
class MyPromise {
// 三千行后,方知自己仍是蝼蚁
}
此等屠龙之术,入职后唯调API用。恰似逼庖丁解牛,却令其日日杀鸡。
或以使用组件库之经验薪资招之,又以写不好组件库之责裁出。
尾声:铁屋中的叩问
前端者,数字化时代的纺织工也。资本织机日夜轰鸣,框架如梭穿行不息。程序员眼底血丝如网。所谓"全栈工程师",实为包身工雅称;所谓"技术革新",不过剥削新法。
若仍有少年热血未冷,欲投身此业,且听我一言:君有凌云志,何不学Rust/C++,做那操作系统、数据库等真·屠龙技?莫要困在这CSS牢笼中,为圆角像素折腰,为虚无需求焚膏。前端之路,已是红海血途,望后来者三思,三思!
来源:juejin.cn/post/7475351155297402891
Spring 6.0 + Boot 3.0:秒级启动、万级并发的开发新姿势
Spring生态重大升级全景图
一、Spring 6.0核心特性详解
1. Java版本基线升级
- 最低JDK 17:全面拥抱Java模块化特性,优化现代JVM性能
- 虚拟线程(Loom项目):轻量级线程支持高并发场景(需JDK 19+)
// 示例:虚拟线程使用
Thread.ofVirtual().name("my-virtual-thread").start(() -> {
// 业务逻辑
});
- 虚拟线程(Project Loom)
- 应用场景:电商秒杀系统、实时聊天服务等高并发场景
// 传统线程池 vs 虚拟线程
// 旧方案(平台线程)
ExecutorService executor = Executors.newFixedThreadPool(200);
// 新方案(虚拟线程)
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
// 处理10000个并发请求
IntStream.range(0, 10000).forEach(i ->
virtualExecutor.submit(() -> {
// 处理订单逻辑
processOrder(i);
})
);
2. HTTP接口声明式客户端
- @HttpExchange注解:类似Feign的声明式REST调用
@HttpExchange(url = "/api/users")
public interface UserClient {
@GetExchange
List<User> listUsers();
}
应用场景:微服务间API调用
@HttpExchange(url = "/products", accept = "application/json")
public interface ProductServiceClient {
@GetExchange("/{id}")
Product getProduct(@PathVariable String id);
@PostExchange
Product createProduct(@RequestBody Product product);
}
// 自动注入使用
@Service
public class OrderService {
@Autowired
private ProductServiceClient productClient;
public void validateProduct(String productId) {
Product product = productClient.getProduct(productId);
// 校验逻辑...
}
}
3. ProblemDetail异常处理
- RFC 7807标准:标准化错误响应格式
{
"type": "https://example.com/errors/insufficient-funds",
"title": "余额不足",
"status": 400,
"detail": "当前账户余额为50元,需支付100元"
}
- 应用场景:统一API错误响应格式
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail handleProductNotFound(ProductNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setType(URI.create("/errors/product-not-found"));
problem.setTitle("商品不存在");
problem.setDetail("商品ID: " + ex.getProductId());
return problem;
}
}
// 触发异常示例
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable String id) {
return productRepo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
4. GraalVM原生镜像支持
- AOT编译优化:启动时间缩短至毫秒级,内存占用降低50%+
- 编译命令示例:
native-image -jar myapp.jar
二、Spring Boot 3.0突破性改进
1. 基础架构升级
- Jakarta EE 9+:包名javax→jakarta全量替换
- 自动配置优化:更智能的条件装配策略
- OAuth2授权服务器
应用场景:构建企业级认证中心
- OAuth2授权服务器
# application.yml配置
spring:
security:
oauth2:
authorization-server:
issuer-url: https://auth.yourcompany.com
token:
access-token-time-to-live: 1h
定义权限端点
@Configuration
@EnableWebSecurity
public class AuthServerConfig {
@Bean
public SecurityFilterChain authServerFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
return http.build();
}
}
2. GraalVM原生镜像支持
应用场景:云原生Serverless函数
# 打包命令(需安装GraalVM)
mvn clean package -Pnative
# 运行效果对比
传统JAR启动:启动时间2.3s | 内存占用480MB
原生镜像启动:启动时间0.05s | 内存占用85MB
3. 增强监控(Prometheus集成)
- Micrometer 1.10+:支持OpenTelemetry标准
- 全新/actuator/prometheus端点:原生Prometheus格式指标
- 应用场景:微服务健康监测
// 自定义业务指标
@RestController
public class OrderController {
private final Counter orderCounter = Metrics.counter("orders.total");
@PostMapping("/orders")
public Order createOrder() {
orderCounter.increment();
// 创建订单逻辑...
}
}
# Prometheus监控指标示例
orders_total{application="order-service"} 42
http_server_requests_seconds_count{uri="/orders"} 15
三、升级实施路线图
四、新特性组合实战案例
场景:电商平台升级
// 商品查询服务(组合使用新特性)
@RestController
public class ProductController {
// 声明式调用库存服务
@Autowired
private StockServiceClient stockClient;
// 虚拟线程处理高并发查询
@GetMapping("/products/{id}")
public ProductDetail getProduct(@PathVariable String id) {
return CompletableFuture.supplyAsync(() -> {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
// 并行查询库存
Integer stock = stockClient.getStock(id);
return new ProductDetail(product, stock);
}, Executors.newVirtualThreadPerTaskExecutor()).join();
}
}
四、升级实践建议
- 环境检查:确认JDK版本≥17,IDE支持Jakarta包名
- 渐进式迁移:
- 先升级Spring Boot 3.x → 再启用Spring 6特性
- 使用
spring-boot-properties-migrator
检测配置变更
- 性能测试:对比GraalVM原生镜像与传统JAR包运行指标
通过以上升级方案:
- 使用虚拟线程支撑万级并发查询
- 声明式客户端简化服务间调用
- ProblemDetail统一异常格式
- Prometheus监控接口性能
本次升级标志着Spring生态正式进入云原生时代。重点关注:虚拟线程的资源管理策略、GraalVM的反射配置优化、OAuth2授权服务器的定制扩展等深度实践方向。
来源:juejin.cn/post/7476389305881296934
如何优雅的回复面试官问:“你能接受加班吗?”
面试官问:“你能接受加班吗?”我脑袋嗡的一声,余音绕梁三日不绝于耳。
那一刻,我简直觉得自己像被突然砸中脑袋,脑袋里嗡的一声,余音绕梁三日。作为一个职场小白,这种问题简直颠覆了我对面试的认知。于是,我一时心血来潮,脱口而出一句:“领导抗揍吗?” 结果,大家猜到了,面试是上午结束的,Offer是当天中午凉的。
如何巧妙回答
“我认为加班是工作中不可避免的一部分,尤其是在一些特殊项目或紧急情况下。我非常热爱我的工作,并且对公司的发展充满信心,因此我愿意为了团队的成功付出额外的努力。当然,我也注重工作效率和时间管理,尽量在正常工作时间内完成任务。如果确实需要加班,我也会根据公司合理的安排,积极的响应。”
作为一名资深的面试官,今天面对这个问题,坐下来和大家聊聊应该怎么回答呢?面试官究竟喜欢怎样的回答?让我们深入分析一下。
面试官的心理
在职场中,想要出色地应对面试,需要具备敏锐的观察力和理解力。学会细致入微地观察,善于捕捉每一个细微的线索,这样才能在面试中游刃有余。懂的察言观色,方能尽显英雄本色。
面试官的考量点
- 评估工作稳定性
面试官提出“能否接受加班”的问题,旨在深入了解求职者的职业稳定性和对加班安排的适应性。这一评估有助于预测求职者入职后的表现和长期留任的可能性。工作稳定性是企业考量员工的关键指标之一,通过这一问题,面试官能够洞察求职者的职业发展规划及其对未来工作的期望。
- 筛选合适的候选人
通过询问加班的接受度,面试官筛选出那些愿意为达成工作目标而投入额外时间和精力的候选人。这种筛选方式有助于确保团队的整体运作效率和协作精神。合适的候选人不仅能快速融入团队,还能显著提升工作效率。因此,面试官借此问题寻找最匹配岗位需求的员工。
- 了解求职者的价值观
面试官还利用这个问题来探查求职者的价值观和工作态度,以此判断他们是否与公司的文化和核心价值观相契合。员工的价值观和态度对公司的长远发展起着至关重要的作用。通过这一询问,面试官能够确保求职者的个人目标与公司的发展方向保持一致,从而促进整体的和谐与进步。
考察的问题的意义
要理解问题的本质……为什么面试官会提出这样的问题?难道是因为你的颜值过高,引发了他的嫉妒?
- 工作态度
面试官通过询问加班的接受度,旨在评估求职者是否展现出积极的工作态度和强烈的责任心。在许多行业中,加班已成为常态,面试官借此问题了解求职者是否愿意在工作上投入额外的时间和精力。积极的工作态度和责任心是职场成功的关键因素,通过这一问题,面试官能够初步判断求职者是否适应高强度的工作环境。
- 岗位匹配度
特定岗位因其工作性质可能需要频繁加班。面试官通过提出加班相关的问题,旨在了解求职者是否能适应这类岗位的工作强度。由于不同岗位对工作强度的要求各异,面试官希望通过这一问题确保求职者对即将承担的角色有明确的认识,从而防止入职后出现期望不一致的情况。
- 抗压能力
加班往往伴随压力,面试官通过这一问题考察求职者的抗压能力和情绪管理技巧。抗压能力对于职场成功至关重要,面试官借此了解求职者在高压环境下的表现,以判断其是否符合公司的需求。
- 公司文化
面试官还利用这个问题来评估求职者对公司加班文化的接受程度,以此判断其价值观是否与公司相符。公司文化对员工的工作体验和满意度有着深远的影响,面试官希望通过这一问题确保求职者能够认同并融入公司文化。
回答的艺术
“知己知彼,百战不殆。”在面试中,回答问题的关键在于展现出积极和正向的态度。
- 积极态度
在回答有关加班的问题时,表达你对工作的热爱和对公司的忠诚,强调你愿意为了团队的成功而付出额外的努力。这种积极的态度不仅展示了你的职业素养和对工作的热情,还能显著提升面试官对你的好感。
例如:“我非常热爱我的工作,并且对公司的发展充满信心。我相信为了实现公司的目标和团队的成功,适当的加班是可以接受的。”
- 灵活性和效率
强调你在时间管理和工作效率上的能力,表明你在确保工作质量的同时,会尽可能减少加班的需求。灵活性和效率是职场中极为重要的技能,面试官可以通过这个问题了解你的实际工作表现。
例如:“我在工作中注重效率和时间管理,通常能够在规定的工作时间内完成任务。当然,如果有特殊情况需要加班,我也会全力以赴。”
- 平衡工作与生活
适当地提到你对工作与生活平衡的重视,并希望公司在安排加班时能够充分考虑到员工的个人需求。平衡工作与生活是职场人士普遍关注的问题,面试官通过这个问题可以了解你的个人需求和期望。
例如:“我非常重视工作与生活的平衡,希望在保证工作效率的同时,也能有足够的时间陪伴家人和进行个人活动。如果公司能够合理安排加班时间,我会非常乐意配合。”
- 适度反问
在回答时,可以适当地向面试官询问关于公司加班的具体情况,以便更全面地了解公司的加班文化和预期。这样的反问可以展现你的主动性和对公司的兴趣,有助于获取更多信息,做出更加明智的回答。
例如:“请问公司通常的加班情况是怎样的?是否有相关的加班补偿或调休安排?”
最后
所谓士为知己者死,遇良将则冲锋陷阵,择良人则共谋天下。在职场这场没有硝烟的战争中,我们每个人都是一名战士,寻找着属于自己的知己和良将。当面试官提出挑战性问题时,我们不仅要展示自己的能力和才华,更要表现出对工作的热爱和对公司的忠诚。
面对“你能接受加班吗?”这样的问题,我们应以积极的态度、灵活的思维和对工作与生活平衡的重视来回应。这样的回答不仅能展示我们的职业素养,还能让我们在众多求职者中脱颖而出,赢得面试官的青睐。
正如士为知己者死,我们在职场中也要找到那个能理解我们、支持我们的知己;遇良将则冲锋陷阵,我们要在优秀的领导下发挥自己的潜能,为公司的发展贡献力量;择良人则共谋天下,我们要与志同道合的同事共同努力,实现职业生涯的辉煌。
总之一句话,在面试中展现出积极向上的形象,不仅能为我们的职业生涯加分,更能让我们在职场上找到属于自己的价值和归属感。让我们以这句话为指引,勇敢地迎接职场的挑战,书写属于自己的辉煌篇章。
来源:juejin.cn/post/7457211584709066792
DeepSeek R1 对编程语言未来的预测
欢迎关注我的训练营:云原生AI实战营,一个助力 Go 开发者在 AI 时代建立技术竞争力的实战营。
今天分享下 DeepSeek 对于编程语言未来的预测,这个应该也是很多开发者关注的,因为它可能会影响未来的职业选择。
因我自己最近也在关注这方面,所以今天就问了 DeepSeek。DeepSeek 给出的回答跟我的想法很一致,所以今天把 DeepSeek 的回答分享出来。
问题:Go、Java、Rust、Python 语言未来前景如何
我的问题是Go、Java、Rust、Python 语言未来前景如何。之所以问这 4 门语言,是因为这四门语言在后端开发中最受欢迎,各自在其擅长的领域中发光发热。
目前,我觉得 Go 和 Java 在领域上有一定程度或者很大程度的重合。Rust、Python、(Go、Java)有其擅长的领域,交叉点不多。
我想弄明白的是,每门语言是否会在其领域继续发展状态,或者会有蚕食其他领域的现象。通过这些,也想知道未来编程语言的选型为何。
DeepSeek 思考过程
提示:以下是 DeepSeek 的思考过程
好的,用户问的是Go、Java、Rust、Python这四种语言的未来前景如何。我需要逐一分析每种语言的特点、现状以及发展趋势,同时比较它们的优缺点和应用场景。首先,我应该回忆每种语言的主要用途和社区活跃度,然后看看市场需求和技术趋势。
对于 Go 语言,它由Google开发,以高并发和高效编译著称,适合云原生和微服务。近年来Docker、Kubernetes的流行推动了Go的应用,尤其是在后端和DevOps领域。但Go在泛型和生态系统方面可能不如其他语言,所以需要评估这些因素是否会影响其未来。
Java作为老牌企业级语言,稳定性强,广泛应用于大型系统、Android开发和大数据。虽然有人认为Java在下降,但它的庞大现有代码库和企业依赖意味着它仍会长期存在。不过,Java需要面对Kotlin等语言的竞争,尤其是在Android开发方面,还有云原生趋势下轻量级语言的挑战。
Rust以内存安全和性能为卖点,逐渐在系统编程、嵌入式和高性能计算领域崭露头角。被Linux内核和Windows驱动采用,说明其潜力。但Rust的学习曲线陡峭,可能在普及上需要时间,但在对安全性要求高的领域会持续增长。
Python作为脚本语言之王,在数据科学、AI、Web开发等领域占据主导。随着AI的爆发,Python的需求持续增长,但性能问题可能在需要高性能的场景中成为瓶颈。不过,Python的易用性和丰富的库支持使其在教育和小型项目中保持优势。
接下来要考虑的是行业趋势,比如云计算的持续增长对Go有利,AI发展促进Python,系统级需求推动Rust,而企业维护则维持Java。同时,新语言的出现是否会影响这些老牌语言,例如Rust可能替代部分C/C++的领域,而Go可能继续在云原生领域扩展。
用户可能的深层需求是选择学习方向或技术选型,需要明确各语言的优劣势和适用场景,帮助他们做出决策。因此,回答时要突出各语言的优势领域和未来可能的发展方向,以及潜在的风险点,比如Java可能面临市场份额被蚕食,但不会很快消失。
总结部分需要对比各语言,指出它们在不同领域的地位,并给出建议,比如根据项目需求选择合适语言,而不是盲目跟风。同时,强调多语言掌握的重要性,因为现代开发往往需要多种技术结合。
DeepSeek R1 四种语言前景分析
以下是 DeepSeek R1 关于 Go、Java、Rust、Python 四种语言的前景分析,结合技术趋势、市场需求和生态系统发展分析如下:
1. Go (Golang)
优势领域:
- 云原生与微服务:Go 的并发模型(goroutine)和高效编译特性使其成为 Kubernetes、Docker、Istio 等云原生工具的首选语言。
- 高性能后端:适合 API 网关、分布式系统、实时通信等高并发场景(如 Twitch、Uber 的后端)。
- 开发效率:语法简洁,编译速度快,适合快速迭代的工程团队。
未来前景:
- 持续增长:随着云计算的普及,Go 在云原生领域的地位将进一步巩固。
- 企业采用:越来越多公司(如字节跳动、腾讯)将 Go 作为核心后端语言。
- 挑战:泛型支持的完善程度、生态多样性仍需提升。
推荐方向:云原生开发、基础设施工具链、高性能服务端。
2. Java
优势领域:
- 企业级应用:银行、电信、政府等传统行业的核心系统(如 IBM、Oracle 的解决方案)。
- Android 开发:仍占据主导地位(尽管 Kotlin 在 Android 中逐渐取代)。
- 大数据生态:Hadoop、Spark、Flink 等框架依赖 Java 生态。
未来前景:
- 稳定但缓慢衰退:Java 的存量市场庞大,但新项目可能更倾向于现代语言(如 Kotlin、Go)。
- 云原生转型:Quarkus、Spring Native 等框架帮助 Java 适应云原生环境。
- 挑战:冗长的语法、内存消耗较高,对轻量级场景竞争力不足。
推荐方向:传统企业系统维护、Android 底层开发、大数据工程。
3. Rust
优势领域:
- 系统编程:取代 C/C++ 的潜力(如操作系统、嵌入式、浏览器引擎)。
- 内存安全:所有权模型彻底解决内存泄漏和数据竞争问题,适合对安全性要求极高的场景(如区块链、金融系统)。
- 高性能计算:在游戏引擎、实时渲染等领域逐渐应用(如 Discord 的音频服务)。
未来前景:
- 高速增长:被 Linux 内核、Windows 驱动开发等官方支持,逐步进入主流。
- 生态扩展:WebAssembly、区块链(如 Solana)等领域对 Rust 需求激增。
- 挑战:学习曲线陡峭,中小型项目采用成本较高。
推荐方向:系统级开发、安全敏感型应用、区块链底层。
4. Python
优势领域:
- 数据科学与 AI:TensorFlow、PyTorch、NumPy 等库的绝对统治地位。
- 脚本与自动化:运维、测试、爬虫等场景的首选语言。
- 教育与科研:语法简单,适合快速验证算法和原型开发。
未来前景:
- AI 驱动的霸主:随着生成式 AI(如 ChatGPT)的爆发,Python 需求将持续增长。
- 性能瓶颈:在需要高性能的场景(如高频交易)可能被 Rust/Go 取代。
- 生态护城河:庞大的库和社区资源确保其长期不可替代性。
推荐方向:机器学习工程、数据分析、快速原型开发。
综合对比与趋势总结
维度 | Go | Java | Rust | Python |
---|---|---|---|---|
增长潜力 | 高(云原生) | 低(存量市场) | 极高(系统替代) | 高(AI 驱动) |
学习曲线 | 中等 | 中等偏高 | 高 | 低 |
性能 | 高 | 中等 | 极高 | 低 |
适用领域 | 后端/基础设施 | 企业系统/Android | 系统/安全/区块链 | AI/脚本/科研 |
未来 5 年预测:
- Go 和 Rust 将成为增长最快的语言,分别主导云原生和系统级开发。
- Python 因 AI 热潮保持霸主地位,但需警惕性能相关的替代方案。
- Java 缓慢衰退,但仍长期占据企业市场。
建议:
- 根据职业目标选择:
- 追求高薪和前沿技术:优先 Rust 或 Go。
- 稳重型职业发展:Java 或 Python。
- 多语言组合:例如 Python(AI) + Go(后端) 或 Rust(底层) + Python(脚本)。
个人观点
提示:以下是我个人总结,非 DeepSeek 的输出。
DeepSeek R1 对于四种语言的前景分析、未来 5 年预测及给出的建议,其实跟我的想法是完全一致的。
根据 JetBrains2024 的市场调研,Go 在四种语言中的工资是最高的,背后的原因可能是因为云原生的技术门槛。Go 在云原生领域占据霸主地位,而云原生本身技术门槛要比业务 CURD 高很多,所以薪资自然要比其他语言高。其实,这也说明了,越是门槛高、技术含量高的岗位薪资越有竞争力(这是一句废话)。
Python 依托于其强大的生态及数据处理能力,在 AI 领域成为霸主语言。在 AI 时代,程序员不可避免的要去学习、掌握 Python。
Rust 优点是高性能、高安全,但缺点是学习门槛高、开发效率相较于 Go 低。所以 Rust 很适合系统层软件的开发,反倒不适合应用层软件的开发。在一个业务向的企业中,一般系统层会使用云基础设施,非核心的系统层,也可以用 Go 语言开发。当前很多优秀的系统层软件都是基于 Go 语言开发的。
所以,在一个企业中,系统层软件和应用层软件,往往是二选一的关系,也意味着,在编程语言上,也有了明确的选择:应用型企业选择 Go。如果企业核心产品是系统层软件,那么可以选择 Rust。
所以最终的编程语言选择一般是:Python(AI) + Go(后端) 或 Rust(底层) + Python(AI)。
当然,企业完全可以根据需要选择更多的编程技术栈组合。上述只是介绍一种通用情况下的选择建议。
另外,在编程语言选择时,建议主攻一门核心语言,同时根据职业目标补充其他相关语言,或者在不同阶段调整策略。这样既避免单一风险,又保持专业性。
来源:juejin.cn/post/7475609849939410983
央国企求职“性价比分析”:为什么这几年央国企火了?
浣熊say官方网站:hxsay.com/
浣熊say官方星球:hxsay.com/about/我的星球/…
正文
不知道最近大家有没有发现,越来越多的人在职业选择上都偏向与央国企、体制内等稳定性较高的岗位,而放弃了去私企、互联网等工资高但是强度大的工作。
从我身边的人了解到这一趋势不仅仅存在于工作了多年的职场老油条,希望找个地方躺平,在应届毕业生的群体里面也越来越明显。然而放在10年前,也就是2014年的时候谁毕业要是去国企、体制内可能会被笑话没有理想、躺平。
但是这两年风向仿佛突然变化了,公务员、央国企突然之间变得香了起来,似乎打工人也随着年龄的增长突然明白了一个道理,比起靠着燃烧生命加班挣来的卖命钱以及生活在不知道什么时候就会被干掉的压力之下,不如稳定的央国企来得实在。
35岁被毕业和干到退休的收入差距有多大?
首先叠甲,我这里说的国企是垄断央企的二级以上的公司或者省属国企总部,这些国企一般掌握着国家、省级的核心资源,不违法犯罪的情况下大概率是能干到退休的。当然,如果有人跟我杠说什么某某银行科技子公司,某某央企的孙子公司一样末尾淘汰,一样裁员不稳定,那么我只能说你说得都对!
假设我硕士毕业就去国企,然后月薪8k,2个月年终(央企二级公司,省属国企很容易达到),那么一年的收入是14*0.8 = 11.2w,然后男性目前的法定退休年龄是65岁,从25岁~65岁工作40年,总收入为 448w。
假设你硕士毕业就去互联网大厂,然后月薪3w,4个月年终(这里也是取得平均值来估计的),那么一年的收入为48w,然后35岁一般确实是互联网的大限,25~35岁工作10年,总收入为:480w。
其实,大多数情况下互联网大厂拿到3w的也是凤毛麟角,国企8k一个月的其实还是遍地都是,甚至一些省会的公务员都能达到8k/月甚至更多,两者职业生涯的总收入其实是差不多的。而且这里为了公平都没有考虑随着工龄增长工资的增长情况,其实在互联网大厂拿到100w年薪的难度远远大于你在国企熬年限,涨到1.5w。
所以,其实无论是选择私企打工挣钱,还是垄断国企躺平,你整个职业生涯获得的工资性收入都是差不多的,以2024年的世界观来看,很多私企甚至很难稳定拿到3w/月的工资到35岁。
有时候一个裁员毕业潮下来,你就不得不面临重新找工作的窘境,以前经济好的时候且没有AI时候,从事技术研发的人还可以自信的说我有技术走到哪里都不怕 。 如今,AI取代大多数工作岗位已经是明牌的事情了,那些掌握技术的人可能也不得不担忧自己能否快速找到合适自己的工作。
虽然,最近两会有委员提出取消35岁的年龄限制,我其实个人并不看好这个提案,因为本质说社会上的私企卡35岁主要是因为廉价、能加班的年轻人太多了,企业处于成本考虑肯定愿意招聘这些年轻人,那么上了年龄的中年人不能加班就可以滚蛋了。 这个事情不是一个提案就能解决的,除非整个职场氛围得到了改变,所有公司都将老员工视作一种公司财富而不是消耗品的时候,才是35岁年龄其实真的消失的时候。
普通打工人还真的需要考虑当你年龄上来之后,失去手头这份工作之后怎么办,你辛辛苦苦寒窗苦读这么多年,出入的高级写字楼,做的都是产值上千万的项目。突然让你失业在家,跑滴滴,送外卖这个心里落差你能接受吗?
当35岁你在街头送着外卖,跑着滴滴,你在央国企的同学或许已经是一个小领导,你去当公务员的同学现在是一个科长,他们再不济也是个小职工有着稳定的收入,不太为生计发愁,不知道那个时候的同学聚会你还有心情去参加不?
对于打工人来说稳定到底意味着什么?
20多岁的年轻人总觉得世界是自己的,脑子里面全部是幻想,总觉得爽文小说当中的主角是自己,不说大富大贵至少能够在企业混的风生水起,升职加薪,当上领导。
这些愣头青的想法我也有过,但是对大多数没有抱上大腿的人来说,工作2~3年之后就会让你明白这个世界的真实运转规则,很多事情不是下位者能够决定的,无论是在国企还是私企,本质上事情还是上位者说了算。
简单来说就是,领导说你行你就是行,领导说你不行那么就赶紧想办法跑路吧。
这种情况在私企、国企其实都会遇到,大家刻板印象中老是说国企的官僚主义严重,但是其实私企才是官僚主义更加严重的地方,而且比起来国企就是小打小闹。
本质上来说在真的央国企你的领导实际上是没有人事权的,他就算再讨厌你也只能通过调岗、扣绩效等方式来恶心你,但是真的没办法裁掉你。
但是在私企领导其实就是你们这个地方的土皇帝,你让领导不开心或者领导不喜欢你,那么是真的可以裁掉你,可能就是一句话的事你下午就不用来上班了都是有可能的事情。在这种地方,你除了舔领导,拼命加班,拼命卷之外没有任何办法,因为上位者掌握着你的生死存亡。
在这种极度竞争和内卷的环境下,你的全部精力都会投入到工作当中,但是其实你并不参与蛋糕的分配,也就是你卷出来的成果、剩余价值大部份被老板拿走了。同时,高强度的工作还会剥夺你其它的可能,让你没时间陪家人,没时间发展自己的事业,当你不得不开始发展自己的事业的时候,就是你已经失业的时候。
而在央国企的工作情况就会好很多,首先大多数岗位是比较稳定的,你不用过于担心失业裁员的情况发生。其次,至少在项目不忙的时候,你的休息时间是可以保障的,利用这些时间你其实可以选择发展自己的事业,就像刘慈心一样写科幻小说未来说不定能从副业上面赚到你一辈子赚不到的钱。
所以,比起那些完全占用你时间和心智的工作,我其实觉得轻松但是钱不那么多的工作更加适合一个人的长期发展,从一生的尺度上看财富积累未必会比短短的靠25~35这10年间挣到的钱少。
为什么这几年央国企火了?
其实很多在校的学弟、学妹们沟通,我发现现在的孩子比我们当年看得明白很多,也可能是不同的时代背景造就了不同的人的观点。
我们还是学生的时候听到的故事还都是什么王者荣耀100个月年终,互联网财富自由之类的神话,但是疫情的3年互联网和诸多的财富神话跌落神坛,大多数普通人能够保住手头的这份工作就是件值得庆幸的事情了。 即使是去华为、阿里、腾讯这样的大厂也很难有机会再实现当年的财富神话,技术改变世界的思潮也正在慢慢退潮,现在这些大厂也很难让你挣到财富自由的钱,逐渐变成了一份普通工作而已。
当你在校园中搏杀了20几年好不容易拿到了学士、硕士、博士文凭,这些私企会告诉你现实的残酷,你手中的文凭只能帮你到入职的3个月,之后就各凭本事了。 资本是逐利的,中国的企业更加喜欢揠苗助长,没有任何培养一个员工的文化在里面。所谓的培养更多的是PUA,告诉你这儿也不行,哪儿也不行,然后在绩效考核的时候顺利成章的把锅甩给你,来满足组长必须找一个倒霉蛋背绩效的制度要求。 我不否认能力极强者、能卷的人在这种环境中能够获得快速的升职加薪和财富,但并不是每个人都是大神,也不是每个人在做好本职工作之外还有心情去舔领导。
入职央国企能够在很大成都上帮你避免上述的问题,大型的央国企平台很大有足够的时间和资源来让员工成长,对于刚入职的新员工可能前面半年都不会给你安排真正意义上的工作,多数是各种培训,各种学习。 我以前经常听到在国企的同学抱怨又是什么培训、什么学习、什么党会,让人觉得很烦没有意义,但是在我看来每个人都应该感恩这些公司花着钱让你不干活儿的活动,真的不是所有的公司都这么有耐心。 除此之外,很少有央国企会期待从你身上压榨什么,因为大多数央国企从事的都是垄断行业,拥有足够的利润,并且这些利润也并非属于领导或者某个人的,而是属于整个集团,国家。你和领导本质上都一样,只是这个国企的打工人,没必要压榨你太过分,毕竟赚的钱也一分不会到领导自己包里,对你个人的包容性也会更强一些。
所以当经济增长变缓,私企难以让人挣到足以财富自由的钱,大家就会发现其实没有必要去承担那么大的压力只是比稳定的工作多一点儿钱。这个时候一份稳定、有自己业余时间的央国企工作性价比就变得更高了起来。一边可以用工资保障自己的生活,一边开拓自己的事业在副业这个第二战场挣到更多的钱,确实会比在私企打工35被裁要体面得多。
The End
其实对于职业的选择,有一个核心逻辑就是去门槛更高的地方。
有的人说,大厂门槛很高啊,问那么多技术,刷那么多题,也是万里挑一才能进去一个人。
但是,实际上这些东西不算门槛,真正的门槛是把人堵在外面的不可逾越的鸿沟,比如说:如果你本科不是学的临床专业,那么你一辈子都没办法当上医生,除非重新高考!这才是真正意义上的门槛,而无论是多么高深的技术,只要肯学都能够学会的。
所以,大型垄断央国企其实是个门槛更高的地方,好的岗位除了应届生就没有就会进去,同时一旦进去占住坑了也很难被裁掉,除非你自己离职。大家可能经常会听说哪个国企的用自己的业余时间努力学习然后去了互联网大厂的。但是你可能完全没有听过那个私企的毕业没去 "中国烟草" 靠着自己的不懈努力,社招进入了中国烟草。
如果是应届生,尽量去门槛高、稳定的地方,考虑长期发展而不是贪图短时间的利益,这样一来即使你的能力没有那么强,也可以用马拉松的方式跑到最后。
人生是一段长跑,不到最后一刻不知道谁输谁赢,就算是活得比别人长,那么其实你最后也胜利了。
来源:juejin.cn/post/7343161077061992458
小红书创始人瞿芳,武汉人,北京外国语大学毕业,2013 年从外企离职,目前身价 120 亿
大家好,我是二哥呀。
今天我们来聊聊小红书的创始人瞿芳,1985 年出生于湖北武汉,毕业于武汉外国语学校,硕士就读于北京外国语学校。
毕业后进入贝塔斯曼工作,这是一家老牌外企,1835 年就有了,真的非常老,瞿芳在这里工作了 5 年。
瞿芳的执行力也是拉满,2013 年 5 月底离职,6 月赴美寻找风投,7 月初就和老乡毛文超在上海创立了小红书的母公司行吟信息科技有限公司。
长相上我觉得有一点邓丽君的感觉,大家觉得呢?
- 2015-2016 年,瞿芳连续两年被《创业邦》评为“值得关注的女性创业者”;这些年小红书的成长,瞿芳确实功不可没,值得关注。
- 2017 年,瞿芳荣登腾讯“我是创始人”荣耀榜单;小红书背后有阿里和腾讯两家大佬的投资,原来两家是从来不投一家公司的,瞿芳背后的斡旋算是让两家暂时握了手。
- 2020 年,瞿芳入选“中国最具影响力的 30 位商界女性”榜单;目前来看,小红书还处在上升势头,并且流量拉满,瞿芳的身价肯定也会水涨船高。
- 2024 年,瞿芳以 120 亿元的人民币财富位列《2024-胡润榜》的第 433 位;这还是在小红书没有上市的情况下。
瞿芳曾在采访中强调,用户体验和社区氛围是小红书最看重的。
这也是小红书这个平台和微博、抖音最大的区别,你可能在小红书上百粉不到,但发布的内容却会被推荐到平台首页,成为爆款。
微的推荐机制现在也有这种趋势,就是粉丝数越少,反而被推荐的机会越多。
瞿芳认为,品牌与用户的沟通应该从“教学模式”转向“恋爱模式”。
也就是说,我们创作者不能再以老师的角度切入,把读者作为学生来传达信息,而是奔着双方恋爱的方式切入。
更加的纯粹,双方的地位更加的对等。
宝子们,都看到了吧,我爱你们,😄
2013 年的时候,跨境旅游开始兴起,于是,瞿芳就和毛文超一起去找当地的购物达人,把他们的经验编成了一本厚厚的 PDF,书名就叫“小红书”。
这本 PDF 放到网上以后,引起了巨大的反响,一个月的下载量就突破了 50 万次。
尝到了甜头后,瞿芳和毛文超再接再厉,于 2013 年 12 月上线了小红书 App,相当于提供了一个购物的分享平台,注意不是电商平台,而是社区分享平台,让用户来分享自己的购物心得。
这个定位就非常的巧妙。
如果单纯地做电商平台,那么竞争对手多了去,比如说淘宝、天猫、京东,以及拼多多。
但做社区平台的话,当时还没有什么竞争对手,虽然点评和美图秀秀都曾在自己的业务中加入大量的社区内容,并放出豪言,但最终都没有竞争过小红书。
2014 年,小红书就聚集了几百万用户了,于是瞿芳就上线了一款希腊产的清洗液,结果直接被秒光了。
到 2017 年,小红书的营收就突破了 100 亿。
截止到目前,小红书已经发展成为了一个生活社区,基本上你想要的东西,你想找的地方,你想看的美女,小红书上都有。据说,月活用户已经达到了 3 亿。
其中女性用户占比 70%,日均用户搜索渗透率达到 60%,用户生成内容(UGC)占比高达 90%。
根本不需要 KOL。
2025 年 1 月,由于 TikTok 可能会被美国封禁,所以大量的海外用户开始涌入小红书。
中西文化的融合,在此刻显然格外的自然和松弛。
我现在打开小红书,已经很少看到原住民发的东西了,这波算法也被太平洋彼岸的热情感染了。
瞿芳在一次采访中的一段话我觉得很值得分享给大家,我套用一下:
“就像今天手机屏幕前的你们,可能大学生可能是工作党,但不管大家是怎样的身份,回到家里,可能还是会跟家人吃一顿最简单的饭,跟最爱的人一起去做一些有创造性的事情。”
我们要回到生活中去,而不只是活在虚拟世界里。
三分恶面渣逆袭
我这人一向说到做到,每天给大家汇报一下面渣逆袭的进度,这就来。今天修改到第 36 题。
35.你们线上用的什么垃圾收集器?
我们生产环境中采用了设计比较优秀的 G1 垃圾收集器,因为它不仅能满足低停顿的要求,而且解决了 CMS 的浮动垃圾问题、内存碎片问题。
G1 非常适合大内存、多核处理器的环境。
以上是比较符合面试官预期的回答,但实际上,大多数情况下我们可能还是使用的 JDK 8 默认垃圾收集器。
可以通过以下命令查看当前 JVM 的垃圾收集器:
java -XX:+PrintCommandLineFlags -version
UseParallelGC
= Parallel Scavenge + Parallel Old
,表示新生代用Parallel Scavenge
收集器,老年代使用Parallel Old
收集器。
因此你也可以这样回答:
我们系统的业务相对复杂,但并发量并不是特别高,所以我们选择了适用于多核处理器、能够并行处理垃圾回收任务,且能提供高吞吐量的Parallel GC
。
但这个说法不讨喜,你也可以回答:
我们系统采用的是 CMS 收集器,能够最大限度减少应用暂停时间。
内容来源
三分恶的面渣逆袭:javabetter.cn/sidebar/san…
二哥的 Java 进阶之路(GitHub 已有 13000+star):github.com/itwanger/to…
最后,把二哥的座右铭送给大家:没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟。共勉 💪。
来源:juejin.cn/post/7461772464738402342
谈谈在大环境低迷下,找工作和入职三个月后的感受
前言
今天是新公司入职后的三个多月了个人也是如愿的转正了,对于我个人而言此时的心情好像没有三个月前刚拿到offer那样的喜悦和兴奋了,更像一件很普通的事情在你身边发生了吧。从2023年底离职在到2024年初开始找工作中间休息了三个月,找工作到入职花了一个多月,在这个过程中也是第一次真真切切感受到了所谓大环境低迷下的“前端已死的言论”,也给大家分享一下自己入职三个月的个人感受吧。
从上一家公司离职时的个人感受
因为上一家公司的工作性质是人力外包驻场开发,年底客户公司(中国移动成都产业研究院)我所在的项目组不需要外包人员了,个人也是被迫拿了赔偿灰溜溜的走人了。
工作感受:对于这段工作经历我个人还是比较认可的,毕竟这里没有任何工作压力,也不加班,工作量少,有很多时间去学习新东西,做做自己的开源,认识了新的朋友等等。
学历的重要性:在这里面随便拎一个人出来可能就是研究生学历的国企单位,自己真实的意识到了学历的重要性(第一学历小专科的我瑟瑟发抖)。
和优秀的人共事:如果在一个长期压抑低沉消极的环境下工作无论你的性格在怎么积极乐观开朗,可能也很容易被影响到。相反如果是和在一群积极,乐观,开朗,充满自信的环境和人一起工作,相信你也会变得积极,乐观,自信这或许也是我这一段工作经历最大的收获吧。
2023年底找工作的市场就业环境
抱着试一试的心态在boss上更新了自己的简历状态,不出所料软件上面安静的奇怪ps:49入国军的感觉,已读未回可能是很失望的感觉吧,但年底找工作令人绝望的是大多数公司都是未读未回,这也就意味着年底基本上是没有正常公司招聘的了。
大概投了两周简历后终于在智联招聘上约到了一个短期三个月岗位的面试,现场两轮面试通过了,不过最终还是没有选择去。
原因有很多:
- 现场的工作环境个人感觉很压抑,从接待我前台和面试官都能感觉满脸写着疲惫
- 说公司最近在996,你也需要和我们一起
- 招聘岗位和工作内容是threejs开发,薪资却说只能给到普通前端开发的水平
- 人力外包公司hr的反复无常令我恶心,二面通过后hr给我打电话最主要就是聊薪资吧,电话内容也很简单hr:成都大部分前端的薪资是在XX-XX,可能给不到你想要的薪资,可能要往下压个1-2K。我:我提的薪资完全是在你们发布招聘岗位薪资的区间,既然你们给不到为什么要这样写了(有感到被侮辱了)。过了几天之后人力外包的hr又给我电话,说可以在原来提的薪资基础上加1.4K,希望能早点去客户公司入职。
总结:年底招聘的公司基本上没啥好鸟,如果你的经济能力还行的话让自己放松休息一段时间也是不错的选择
2024年初找工作:真实的感受到了大环境的低迷下的市场行情
印象最深刻的是在疫情时期的2021年,那会儿出来找工作boos上会有很多HR主动给你打招呼,一周大概能五六个面试,大专学历也有机会去自研公司
解封之后本以为市场行情会变得回缓,结果大概就是今年可是未来十年行情最好的一年
简单总结一下2024年的成都就业环境大概这样的:
- 只有外包公司会招专科学历
- boss上只给hr发一句打招呼的快捷语,99% 都是已读不回
- 大多数要完简历之后就没有后续了
- 待遇好的公司对于学历的要求更严格了(211,985)
- 给你主动打招呼的基本上都是人力外包公司
截至入职新公司前boss上面的投递状况:沟通了1384个岗位,投递了99份简历,一共约到了 8 家公司的面试
今年找工作的个人感受:不怕面试,就怕没有面试机会
首先说一下个人的一些情况吧,因为在创业小公司待过在技术栈方面个人认为算是比较全面的了
项目经验:做过管理系统(CRM,B2C,ERP,saas等管理系统)、商城和门户网站(响应式,自适应)、移动端(H5,小程序,app)、低代码和可视化(工作流,可视化大屏,3d编辑器)、第三方开发(腾讯IM,企业微信侧边栏)、微前端
项目经历:从0-1搭建过整个大项目的基建工作,封装过项目中很多功能性组件和UI组件二次封装(提高开发效率),接手过屎山代码并重构优化,约定项目的开发规范,处理很多比较棘手的疑难bug和提供相关的技术方案,没有需求概念下的敏捷开发,从0-1的技术调研等
代码方面:写过几个开源项目虽然star数量不多(目前最多一个项目是600+),但在代码规范和可读性方面个人认为还是比较OK的(至少不会写出屎山代码吧)
工作经验(4年):2020毕业至今一直从事前端开发工作
学历:自考本科学历(貌似没啥卵用)
学历确实是我很硬伤的一个点但是没办法,人嘛总归要为年轻时的无知买单吧
在这样的背景下开启了24年的找工作,从2月26号开始投递简历到4月1号拿到offer差不多一个多月左右时间,一共约到了8加公司的面试,平均一周两家公司
大概统计了一下这些公司的面试情况:
公司A:
- 数组哪些方法会触发Vue监听,哪些不会触发监听
- position 有哪些属性
- vue watch和computed的区别,computed和method的区别
- vue的watch是否可以取消? 怎么取消?
- position:absolute, position:fixed那些会脱离文档流
- 如何获取到 pomise 多个then 之后的值
- 常见的http状态码
- 谈谈你对display:flex 弹性盒子属性的了解
- 如何判断一个值是否是数组
- typeof 和instanceof的区别
- es6-es10新增了那些东西
- 离职原因,期望薪资,职业规划
公司B
到现场写了一套笔试题,内容记不清楚了
公司C
- vue router 和route 区别
- 说说重绘和重排
- css 权重
- 项目第一次加载太慢优化
- 谈谈你对vue这种框架理解
- sessionstorage cookie localstorage 区别
- 了解过.css 的优化吗?
- 闭包
- 内存泄漏的产生
- 做一个防重复点击你有哪些方案
- 解释一些防抖和节流以及如何实现
- 说一下你对 webScoket的了解,以及有哪些API
- 说一下你对pomise的理解
- vue2,vue3 中 v-for 和v-if的优先级
- 说说你对canvas的理解
公司D
笔试+面试
- vue 首屏加载过慢如何优化
- 说说你在项目中封装的组件,以及如何封装的
- 后台管理系统权限功能菜单和按钮权限如何实现的
- vue 中的一些项目优化
- 期望薪资,离职原因,
- 其他的记不清楚了
公司E
笔试+面试+和老板谈薪资
1.笔试:八股文
2.面试:主要聊的是项目内容比如项目的一些功能点的实现,和项目的技术点
3.老板谈薪资:首先就是非技术面的常规三件套(离职原因,期望薪资,职业规划),然后就是谈薪资(最终因为薪资给的太低了没有选择考虑这家)
公司F
也是最想去的一家公司,一个偏管理的前端岗位(和面试官聊的非常投缘,而且整个一面过程也非常愉快感受到了十分被尊重)
可惜的是复试的时候因为学历原因,以及一些职业规划和加班出差等方面上没有达到公司的预期也是很遗憾的错过了
一面:
- vue 响应式数据原理
- 说说es6 promise async await 以及 promise A+规范的了解
- 谈谈es6 Map 函数
- 如何实现 list 数据结构转 tree结构
- webScoke api 介绍
- webScoke 在vue项目中如何全局挂载
- vuex 和 pinia 区别
- 谈谈你对微任务和宏任务的了解
- call apply bind 区别
- 前端本地数据存储方式有哪些
- 数组方法 reduce 的使用场景
- 说说你对 css3 display:flex 弹性盒模型 的理解
- vue template 中 {{}} 为什么能够被执行
- threejs 加载大模型有没有什么优化方案
- 离职原因,住的地方离公司有多远,期望薪资
- 你有什么想需要了解的,这个岗位平时的工作内容
二面:
1.我看写过一个Express+Mongoose服务端接口的开源项目,说说你在写后端项目时遇到过的难点
2.介绍一下你写的threejs 3d模型可视化编辑器 这个项目
3.以你的观点说一下你对three.js的了解,以及three.js在前端开发中发挥的作用
4.现在的AI工具都很流行,你有没有使用过AI工具来提高你对开发效率
5.说说你认为AI工具对你工作最有帮助的地方是哪些
6.以你的观点谈谈你对AI的看法,以及AI未来发展的趋势
7.你能接受出差时间是多久
8.你是从去年离职的到今天这这几个月时间,你是去了其他公司只是没有写在简历上吗?
9.说说你的职业规划,离职原因,你的优点和缺点,平时的学习方式
公司G
一共两轮面试,也是最终拿到正式offer入职的公司
一面:
- 主要就是聊了一下简历上写的项目
- 项目的技术难点
- 项目从0-1搭建的过程
- 项目组件封装的过程
- vue2 和 vue3 区别
- vue响应式数据原理
- 对于typescript的熟练程度
- 会react吗? 有考虑学习react吗?
- 说一下你这个three.js3d模型可视化编辑器项目的一个实现思路,为什么会想写这样一个项目
二面:
- 说说了解的es6-es10的东西有哪些
- 说说你对微任务和宏任务的了解
- 什么是原型链
- 什么是闭包,闭包产生的方式有哪些
- vue3 生命周期变化
- vue3 响应式数据原理
- ref 和 reactive 你觉得在项目中使用那个更合适
- 前端跨越方式有哪些
- 经常用的搜索工具有哪些?
- 谷歌搜索在国内能使用吗?你一般用的翻墙工具是哪种?
- 用过ChatGPT工具吗? 有付费使用过吗?
- 你是如何看待面试造航母工作拧螺丝螺丝的?
- 谈谈你对加班的看法?
- 你不能接受的加班方式是什么?
- 为什么会选择自考本科?
- 你平时的学习方式是什么?
- 一般翻墙去外网都会干什么?,外网学习对你的帮助大吗?
- 上一家公司的离职原因是什么,期望薪资是多少, 说说你的职业规划
- 手里有几个offer?
hr电话:
- 大概说了一下面试结果通过了
- 然后就是介绍了一下公司的待遇和薪资情况?
- 问了一下上一家公司的离职原因以及上一家公司的规模情况?
- 手里有几个offer?
- 多久能入职?
因为后面没有别的面试了,再加上离职到在到找工作拿到offer已经有四个月时间没有上班了,最终选择了入职这家公司
入职第三天:我想跑路了!
入职后的第一天,先是装了一下本地电脑环境然后拉了一下项目代码熟悉一下,vue3,react,uniapp 项目都有
崩溃的开始:PC端是一个saas 系统由五个前端项目构成,用的是react umi 的微前端项目来搭建的,也是第一次去接触微前端这种技术栈,要命的是这些项目没有一个是写了readme文档的,项目如何启动以及node.js版本这些只能自己去package.json 文件去查看,在经过一番折腾后终于是把这几个项目给成功跑起来了,当天晚上回家也是专门了解了一下微前端
开始上强度: 入职的第二天被安排做了一个小需求,功能很简单就是改个小功能加一下字段,但是涉及的项目很多,pc端两个项目,小程序两个项目。在改完PC端之后,开始启动小程序项目不出所料又是一堆报错,最终在别的前端同事帮助下终于把小程序项目给启动成功了。
人和代码有一个能跑就行:入职的第三天也从别的同事那里了解到了,之前sass项目组被前端大规模裁员过,原因嘛懂得都懂? 能写出这样一堆屎山代码的人,能不被裁吗?
第一次知道 vue 还可以这样写
对于一个有代码强迫症的人来说,在以后的很长一段时间里要求优化和接触完全是一堆屎山一样代码,真的是很难接受的
入职一个月:赚钱嘛不寒掺
在有了想跑路的想法过后,也开始利用上班的空余时间又去投递简历了,不过现实就是在金三银四的招聘季,boss上面依旧是安静的可怕,在退一步想可能其他公司的情况也和这边差不多,于是最终还是选择接受了现实,毕竟赚钱嘛不寒掺
入职两个月:做完一个项目迭代过后,感觉好多了
在入职的前一个月里,基本上每天都要加班,原因也很简单:
1.全是屎山的项目想要做扩展新功能是非常困难的
2.整个项目的逻辑还是很多很复杂的只能边写项目边熟悉
3.因为裁了很多前端,新人还没招到,但是业务量没有减少只能加班消化
功能上线的晚上,加班到凌晨3点
在开发完一个项目迭代过后也对项目有了一些大概的了解,之后的一些开发工作也变得简单了许多
入职三个月:工作氛围还是很重要滴
在入职三个月后,前端组团队的成员也基本上是组建完成了,一共14人,saas项目组有四个前端,虽然业务量依然很多但是好在有更多的人一起分担了,每周的加班时间也渐渐变少了
在一次偶然间了解到我平时也喜欢打篮球后,我和公司后端组,产品组的同事之间也开始变得有话题了,因为大家也喜欢打球,后来还拉了一个篮球群周末有时间大家也会约出来一起打打球
当你有存在价值后一切的人情世故和人际关系都会变得简单起来
在这个世界上大多数时候除了你的父母等直系亲属和另一半,可能会对你无条件的付出
其余任何人对你尊重和示好,可能都会存在等价的利益交换吧
尤其是在技术研发的岗位,只有当你能够完全胜任这份工作时,并且能够体现出足够的价值时才能够有足够的话语权
入职三个月后的感受
- 公司待遇:虽然是一个集团下面的子公司 (200+人)但待遇只能说一般吧,除了工资是我期望的薪资范围,其他的福利待遇都只能是很一般(私企嘛,懂得都懂)
- 工作强度: 听到过很多从大厂来的新同事抱怨说这边的工作量会很大,对我来说其实都还ok,毕竟之前在极端的高压环境下工作过
- 工作氛围:从我的角度来讲的话,还是很不错的,相处起来也很轻松简单,大家也有很多共同话题,没有之前在小公司上班那么累
大环境低迷下,随时做好被裁掉的准备
从2020年毕业工作以来,最长的一段工作经历是1年4个月,有过三家公司的经历
裁员原因也很简单:创业小公司和人力外包,要么就是小公司经营问题公司直接垮掉,或者就是人力外包公司卸磨杀驴
除非你是在国企单位上班,否则需要随时做好被裁掉的准备
什么都不怕,就怕太安逸了
这句话出自《我的团长我的团》电视剧里面龙文章故意对几十个过江的日本人围而不歼时和虞啸卿的对话,龙文章想通过这几十个日本人将禅达搅得鸡犬不宁,来唤醒还在沉睡在自己温柔乡的我们,因为就在我们放松警惕时日本人就已经将枪口和大炮对准了我们。
或许大家会很不认同这句话吧,如果你的父母给你攒下了足够的资本完全可以把我刚才的话当做放屁,毕竟没有哪一个男生毕业之前的梦想是车子和房子,从事自己喜欢的工作不好吗? 但现实却是你喜欢工作的收入很难让你在这座城市里体面的生活
于我而言前端行业的热爱更多是因为能够给我带来不错的收入所以我选择了热爱它吧,所以保持终身学习的状态也是我需要去做的吧
前端已死?
前端彻底死掉肯定是不会的,在前后端分离模式下的软件开发前端肯定是必不可少的一个岗位,只不过就业环境恶劣下的情况里肯定会淘汰掉很多人,不过35岁之后我还是否能够从事前端行业真的是一个未知数
结语
选择卷或者躺平,只是两种不同的生活态度没有对与错,偶尔躺累了起来卷一下也是可以的,偶尔卷累了躺起了休息一下也是不错的。
在这个网络上到处是人均年收入百万以及各种高质量生活的时代,保持独立思考,如何让自己不被负面情绪所影响才是最重要的吧
来源:juejin.cn/post/7391065678546157577
裸辞后,我活得像个废物,但我终于开始活自己
哈喽,大家好!我是Web大鹅只会叫!下去沉淀了那么久,终于有时间回来给大家写点什么了!
你问我人为什么活着?我哪知道啊?我又不是上帝!释迦牟尼为了解这个问题还跑去出家,结果发现人活着就是为了涅槃——也就是死。所以别问我人生的意义,我也没搞明白!不过我能告诉你一个答案,那就是裸辞后,我终于知道了为什么要活着——那就是为了“活得自由”!
裸辞后,那些走过的路,和你说的“脏话”
2024年8月,我做了一个震惊所有人的决定——裸辞!是的,没错,我就是那种毫不犹豫地辞了职、丢下稳定收入和安稳生活,拿着背包去走四方的“疯子”。放下了每天早起20公里开车上班的压力,放下了无聊的加班、枯燥的开会,放下了所谓的“你要努力争取美好生活”的叮嘱。一切都在“离开”这一刻轻轻拂去,带着一种挥之不去的自由感。
带着亲人的责怪、朋友的疑问、同事的眼神、以及自己满满的疑惑,我开始了这段没有目的的旅行。我不知道我想找什么,但我知道,我不想再活得像以前那样。
我走过了无数地方,南京、湖州、宁波、杭州、义乌、金华、嘉兴、镇江、扬州、苏州、无锡、上海……一路走来,路过了每个风景,每个城市,每个人生。我甚至去了中国最北的漠河,站在寒风凛冽的雪地里,终于明白了一个道理:“你活着,才是最值得庆祝的事。
你知道吗,最让人清醒的,不是远方的景色,而是走出去之后,终于能脱离了那一套“你该做什么”的公式。每天不用设闹钟,不用准时吃饭,不用打卡上班,不用开会骂娘,再也不被地铁里的拥挤挤得喘不过气。生活突然变得宽松,我竟然开始意识到:我一直追求的美好生活,原来只是在为别人拼命。
走出内卷圈子的那一刻,我认为我是世界上最快乐的小孩了,我们渴望着幸福却糟践着现在,你所期望的美好未来却始终都是下一个阶段!你认为你是一个努力拼搏美好未来的人。可是现实比理想残酷的多!你没日没夜的拼搏,却让别人没日没夜的享受!你用尽自己半条命,换来的是下半辈子的病!我在裸辞后就告诉我自己:从今以后你想干什么、就干什么!你就是世界的主人! 嗯~ 爽多了!
走过的路,都在暗示我
我在大兴安岭漠河市的市里住了5天,住在一个一天40元的宾馆、干净、暖和!老板是一个退休的铁道工人。脸和双手都布满了冻伤,他的妻子(大姨)很面善。每天都会在我回来的时候和我聊上几句从前,安排一些极寒天气的注意事项。
有一天我去北极村回来,大姨和我聊了一会。大姨对我讲:“趁年轻、别把自己困起来,出去走走。不像我们,60年没出过这片土地,到头还要葬在这片土地上!”。她说这句话的时候没有忧虑、没有悲伤,却是一种满足感。是啊!60多了,还能追求什么?忙了大半辈子,把孩子都送出了这片土地,自己也没有激情出去走走了,很害怕自己的以后也是这样。
我20多岁的年纪,想的不是努力拼搏挣钱、不是搞事业。却总想着无所事事。我觉得自己像一个没有完全“被时间遗弃”的人,我甚至觉得自己不属于这个时代,这个不知道为了什么而拼命的时代。每走一步都好像在掏空自己积压已久的情绪:压力、焦虑、焦灼,让我很享受这种感觉。然后我想起来一本书里的话:“你活着,不是为了活给别人看。“ 是啊,我们都明白这个大道理,可自己从来没打算让自己脱离这个主线。我开始明白,我这次的旅行不是去寻找什么,而是放下什么!
从别人嘴里听到的“脏话”,其实是自己内心的尖刺
这段时间里,我经常回想起来那些让我神经紧绷的日子。尤其是我对“人” 这个物种越来越敏感的那个时期————‘恶毒、自私、无理、肮脏’。朋友的欺骗、同事的推锅、亲人的折磨都是罪恶的!可是到头来,事情还是发生了。地球还是在自转,太阳一样正常升起落下。这些都没有在你认为的这些琐事中消失。我不明白我还在纠结什么?
事实上,这些乱七八糟的事情并不是指向我个人的,它只是我内心脆弱的反射。是的,我一直在内耗自己罢了,把自己放在了一个焦虑的漩涡里。假装没事、假装坚强,结果别人一句话就能作为击垮我的最后一击。直到有一天,我发现我讨厌的只是我自己。所以我决定我不要去在意别人说什么、做什么,我不要逃避问题,我想听听我内心的想法,我不想让自己认为别人在定义我。
过程的意义:也许就是为了“停一停”
好了,我知道我的文采不好,但是也应该有个结尾。
在这一路上,我认识了很多有趣的人,他们不同风格的服装、不同口音,各式各样的生活方式。也有着各式各样的理想和困惑。有的喜欢在山顶等着日出的奇迹,有的则是想在湖边静静地坐着。而我,就是个迷途的羔羊,没有群体头羊的带领,我穿行在这些不同的路途中,慢慢摸索着向所有方向前进着。
偶尔我也会停下来,坐在湖边吹着风、闭上眼睛,听风,感受这一刻的宁静。然后我会微笑,我认为这个时候的我有了轻松的感觉。生活的答案我在这个时候找到了。
我意识到,未来不是重要的,现在才是应该享受的。我不知道我下一步要去哪里,但是我想先停下来看一看,呼吸一下。停下来不是因为我没有了目的,而是我知道,目的地并不重要,重要的是,我和自己在一起,心里不再有那么多焦虑,不再被过去的焦虑所束缚。
所以,我选择了离开,离开这一切,放下所有的焦虑和期待,享受我自己想要的生活。也许,活着的意义不在于追寻一个遥远的目标,而是过好每一个‘现在’。
来源:juejin.cn/post/7454064311079813132
好人难当,坏人不做
好人难当,以后要多注意了,涨点记性。记录三件事情证明下:
1. 免费劳动
之前和一个同学一起做一个项目,说是创业,不过项目做好了,倒是他家店铺自己用起来了,后来一直让我根据他家的需求进行修改,我也一一的改了,他倒是挺感谢我的,说是请吃饭。不过也一直没请,后面都一年多过去了,还让我免费帮他改需求,我就说没时间,他说没时间的话可以把源码给他,他自己学着修改,我就直接把源码给他了,这个项目辛苦了一个多月,钱一毛也没赚到,我倒是搭进去一台服务器,一年花了三百多吧。现在源码给他就给他了吧,毕竟同学一场。没想到又过了半年,前段时间又找我来改需求了。这个项目他们家自己拿着赚钱,又不给我一毛钱,我相当于免费给他家做了个软件,还要出服务器钱,还要免费进行维护。我的时间是真不值钱啊,真成义务劳动了。我拒绝了,理由是忙,没时间。
总结一下,这些人总会觉得别人帮自己是理所当然的,各种得寸进尺。
2. 帮到底吧
因为我进行了仲裁,有了经验,然后被一个人加了好友,是一个前同事(就是我仲裁的那家公司),然后这哥们各种问题我都尽心回答,本着能帮别人一点就帮别人一点的想法,但是我免费帮他,他仲裁到手多少钱,又不会给我一毛钱。这哥们一个问题接一个,我都做了回答,后来直接要求用我当做和公司谈判的筹码,我严词拒绝了,真不知道这人咋想的,我帮你并没有获得任何好处,你这个要求有点过分了,很简单,他直接把我搬出来和公司谈判,公司肯定会找我,会给我带来麻烦,这人一点也没想这些事。所以之后他再询问有关任何我的经验,我已经不愿意帮他了。
总结一下,这些人更进一步,甚至想利用下帮自己的人,不考虑会给别人带来哪些困扰。
3. 拿你顶缸
最近做了通过一个亲戚接了一个项目,而这个亲戚的表姐是该项目公司的领导,本来觉得都是有亲戚关系的,项目价格之类开始问了,他们没说,只是说根据每个人的工时进行估价,后面我们每个人提交了个人报价,然后还是一直没给明确答复,本着是亲戚的关系,觉得肯定不会坑我。就一直做下去了,直到快做完了,价格还是没有出来,我就直接问了这个价格的事情,第二天,价格出来了,在我报价基础上直接砍半。我当然不愿意了,后来经过各种谈判,我终于要到了一个勉强可以的价格,期间群里谈判也是我一个人在说话,团队的其他人都不说话。后来前端的那人问我价格,我也把过程都实话说了,这哥们也要加价,然后就各种问我,我也啥都告他了。后来这个前端在那个公司领导(亲戚表姐)主动亮明身份,她知道这个前端和那个亲戚关系好,然后这个前端立马不好意思加价了,并且还把锅甩我头上,说是我没有告诉他她是他姐。还说我不地道,我靠,你自己要加价,关我啥事,你加钱也没说要分我啊,另外我给自己加价的时候你也没帮忙说话啊,我告诉你我加价成功了是我好心,也是想着你能加点就加点吧,这时候你为了面子不加了,然后说成要加价的理由是因为我,真是没良心啊。后面还问我关于合同的事情,我已经不愿意回答他了,让他自己找对面公司问去。
总结一下,这些人你帮他了他当时倒是很感谢你,但是一旦结果有变,会直接怪罪到你头上。
4. 附录文章
这个文章说得挺好的《你的善良,要有锋芒》:
你有没有发现,善良的人,心都很软,他们不好意思拒绝别人,哪怕为难了自己,也要想办法帮助身边的人。善良的人,心都很细,他们总是照顾着别人的情绪,明明受委屈的是自己,却第一时间想着别人会不会难过。
也许是习惯了对别人好,你常常会忽略自己的感受。有时候你知道别人是想占你便宜,你也知道别人不是真心把你当朋友,他们只是觉得你好说话,只是看中了你的善良,但是你没有戳穿,你还是能帮就帮,没有太多怨言。
你说你不想得罪人,你说你害怕被孤立,可是有人在乎过你吗?
这个世界上形形色色的人很多,有人喜欢你,有人讨厌你,你没有办法做到对每一个人好,也没办法要求每一个人都是真心爱你。所以你要有自己的选择,与舒服的人相处,对讨厌的人远离,过你自己觉得开心自在的生活就好,没必要为了便利别人,让自己受尽委屈。
看过一段话:善良是很珍贵的,但善良要是没有长出牙齿来,那就是软弱。
你的善良,要有锋芒,不要把时间浪费在不值得的人身上。对爱你的人,倾心相助,对利用你的人,勇敢说不。
愿你的善良,能被真心的人温柔以待。
来源:juejin.cn/post/7455667125798780980
让闲置 Ubuntu 服务器华丽转身为家庭影院
让闲置 Ubuntu 服务器华丽转身为家庭影院
在数字化的时代,家里的设备更新换代频繁,很容易就会有闲置的服务器吃灰。我家里就有一台闲置的 Ubuntu 24.04 服务器,一直放在角落,总觉得有些浪费。于是,我决定让它重新发挥作用,打造一个属于自己的家庭影院。
在数字化的时代,家里的设备更新换代频繁,很容易就会有闲置的服务器吃灰。我家里就有一台闲置的 Ubuntu 24.04 服务器,一直放在角落,总觉得有些浪费。于是,我决定让它重新发挥作用,打造一个属于自己的家庭影院。
一、实现 Windows 与 Ubuntu 服务器文件互通
要打造家庭影院,首先得让本地 Windows 电脑和 Ubuntu 服务器之间能够方便地传输电影文件。我选择安装 Samba 来实现这一目的。
- 安装 Samba:在 Ubuntu 服务器的终端中输入命令
sudo apt-get install samba samba-common
系统会自动下载并安装 Samba 相关的软件包。
- 备份配置文件:为了以防万一,我先将原来的 Samba 配置文件进行备份,执行命令
mv /etc/samba/smb.conf /etc/samba/smb.conf.bak
- 新建配置文件:使用
vim /etc/samba/smb.conf
命令打开编辑器,写入以下配置内容:
[global]
server min protocol = CORE
workgroup = WORKGR0UP
netbios name = Nas
security = user
map to guest = bad user
guest account = nobody
client min protocol = SMB2
server min protocol = SMB2
server smb encrypt = off
[NAS]
comment = NASserver
path = /home/bddxg/nas
public = Yes
browseable = Yes
writable = Yes
guest ok = Yes
passdb backend = tdbsam
create mask = 0775
directory mask = 0775
这里需要注意的是,我计划的媒体库目录是个人目录下的 nas/
,所以 path
是 /home/bddxg/nas
,如果大家要部署的话记得根据自己的实际情况修改为对应的位置。 
- 连接 Windows 电脑:在 Windows 电脑这边基本不需要什么复杂配置,因为在网络里无法直接看到 Ubuntu,我直接在电脑上添加了网络位置。假设服务器地址是
192.168.10.100
,那么添加网络位置就是 \\192.168.10.100\nas
,这样就可以在 Windows 电脑和 Ubuntu 服务器之间传输文件了。
要打造家庭影院,首先得让本地 Windows 电脑和 Ubuntu 服务器之间能够方便地传输电影文件。我选择安装 Samba 来实现这一目的。
- 安装 Samba:在 Ubuntu 服务器的终端中输入命令
sudo apt-get install samba samba-common
系统会自动下载并安装 Samba 相关的软件包。
- 备份配置文件:为了以防万一,我先将原来的 Samba 配置文件进行备份,执行命令
mv /etc/samba/smb.conf /etc/samba/smb.conf.bak
- 新建配置文件:使用
vim /etc/samba/smb.conf
命令打开编辑器,写入以下配置内容:
[global]
server min protocol = CORE
workgroup = WORKGR0UP
netbios name = Nas
security = user
map to guest = bad user
guest account = nobody
client min protocol = SMB2
server min protocol = SMB2
server smb encrypt = off
[NAS]
comment = NASserver
path = /home/bddxg/nas
public = Yes
browseable = Yes
writable = Yes
guest ok = Yes
passdb backend = tdbsam
create mask = 0775
directory mask = 0775
这里需要注意的是,我计划的媒体库目录是个人目录下的 nas/
,所以 path
是 /home/bddxg/nas
,如果大家要部署的话记得根据自己的实际情况修改为对应的位置。
- 连接 Windows 电脑:在 Windows 电脑这边基本不需要什么复杂配置,因为在网络里无法直接看到 Ubuntu,我直接在电脑上添加了网络位置。假设服务器地址是
192.168.10.100
,那么添加网络位置就是\\192.168.10.100\nas
,这样就可以在 Windows 电脑和 Ubuntu 服务器之间传输文件了。
二、安装 Jellyfin 搭建家庭影院
文件传输的问题解决后,接下来就是安装 Jellyfin 来实现家庭影院的功能了。
- 尝试 Docker 安装失败:一开始我选择使用 Docker 安装,毕竟 Docker 有很多优点,使用起来也比较方便。按照官网指南进行操作,在第三步启动 Docker 并挂载本地目录的时候却一直失败。报错信息为:
docker: Error response from daemon: error while creating mount source path '/srv/jellyfin/cache': mkdir /srv/jellyfin: read-only file system.
即使我给
/srv/jellyfin
赋予了777
权限也没有效果。无奈之下,我决定放弃 Docker 安装方式,直接安装 server 版本的 Jellyfin。
- 安装 server 版本的 Jellyfin:在终端中输入命令
curl https://repo.jellyfin.org/install-debuntu.sh | sudo bash
,安装过程非常顺利。
- 配置 Jellyfin:安装完成后,通过浏览器访问
http://192.168.10.100:8096
进入配置页面。在添加媒体库这里,我遇到了一个麻烦,网页只能选择到/home/bddxg
目录,无法继续往下选择到我的媒体库位置/home/bddxg/nas
。于是我向 deepseek 求助,它告诉我需要执行命令:sudo usermod -aG bddxg jellyfin
# 并且重启 Jellyfin 服务
sudo systemctl restart jellyfin
按照它的建议操作后,我刷新了网页,重新配置了 Jellyfin,终于可以正常添加媒体库了。
- 电视端播放:在电视上安装好 Jellyfin apk 客户端后,现在终于可以正常读取 Ubuntu 服务器上的影视资源了,坐在沙发上,享受着大屏观影的乐趣,这种感觉真的太棒了!
通过这次折腾,我成功地让闲置的 Ubuntu 服务器重新焕发生机,变成了一个功能强大的家庭影院。希望我的经验能够对大家有所帮助,也欢迎大家一起交流更多关于服务器利用和家庭影院搭建的经验。
[!WARNING] 令人遗憾的是,目前 jellyfin 似乎不支持
rmvb
格式的影片, 下载资源的时候注意影片格式,推荐直接下载mp4
格式的资源
本次使用到的软件名称和版本如下:
软件名 | 版本号 | 安装命令 |
---|---|---|
samba | Version 4.19.5-Ubuntu | sudo apt-get install samba samba-common |
jellyfin | Jellyfin.Server 10.10.6.0 | curl https://repo.jellyfin.org/install-debuntu.sh | sudo bash |
ffmpeg(jellyfin 内自带) | ffmpeg version 7.0.2-Jellyfin | null |
来源:juejin.cn/post/7476614823883833382
Mybatis接口方法参数不加@Param,照样流畅取值
在 MyBatis 中,如果 Mapper 接口的方法有多个参数,但没有使用 @Param
注解,默认情况下,MyBatis 会将这些参数放入一个 Map
中,键名为 param1
、param2
等,或者使用索引 0
、1
等来访问。以下是具体的使用方法和注意事项。
一、Mapper 接口方法
假设有一个 Mapper 接口方法,包含多个参数但没有使用 @Param
注解:
public interface UserMapper {
User selectUserByNameAndAge(String name, int age);
}
二、XML 文件中的参数引用
在 XML 文件中,可以通过以下方式引用参数:
1. 使用 param1
、param2
等
MyBatis 会自动为参数生成键名 param1
、param2
等:
<select id="selectUserByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{param1} AND age = #{param2}
</select>
2. 使用索引 0
、1
等
也可以通过索引 0
、1
等来引用参数:
<select id="selectUserByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{0} AND age = #{1}
</select>
三、注意事项
- 可读性问题:
- 使用
param1
、param2
或索引0
、1
的方式可读性较差,容易混淆。 - 建议使用
@Param
注解明确参数名称。
- 使用
- 参数顺序问题:
- 如果参数顺序发生变化,XML 文件中的引用也需要同步修改,容易出错。
- 推荐使用
@Param
注解:
- 使用
@Param
注解可以为参数指定名称,提高代码可读性和可维护性。
public interface UserMapper {
User selectUserByNameAndAge(@Param("name") String name, @Param("age") int age);
}
XML 文件:
<select id="selectUserByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{name} AND age = #{age}
</select>
- 使用
四、示例代码
1. Mapper 接口
public interface UserMapper {
User selectUserByNameAndAge(String name, int age);
}
2. XML 文件
<select id="selectUserByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{param1} AND age = #{param2}
</select>
或者:
<select id="selectUserByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{0} AND age = #{1}
</select>
3. 测试代码
SqlSession sqlSession = sqlSessionFactory.openSession();
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectUserByNameAndAge("John", 25);
System.out.println(user);
sqlSession.close();
- 如果 Mapper 接口方法有多个参数且没有使用
@Param
注解,可以通过param1
、param2
或索引0
、1
等方式引用参数。 - 这种方式可读性较差,容易出错,推荐使用
@Param
注解明确参数名称。 - 使用
@Param
注解后,XML 文件中的参数引用会更加清晰和易于维护。
来源:juejin.cn/post/7475643579781333029
Java web后端转Java游戏后端
作为Java后端开发者转向游戏后端开发,虽然核心编程能力相通,但游戏开发在架构设计、协议选择、实时性处理等方面有显著差异。以下从实际工作流程角度详细说明游戏后端开发的核心要点及前后端协作流程:
一、游戏后端核心职责
- 实时通信管理
- 采用WebSocket/TCP长连接(90%以上MMO游戏选择)
- 使用Netty/Mina框架处理高并发连接(单机支撑5W+连接是基本要求)
- 心跳机制设计(15-30秒间隔,检测断线)
- 游戏逻辑处理
- 战斗计算(需在50ms内完成复杂技能伤害计算)
- 状态同步(通过Delta同步优化带宽,减少60%数据传输量)
- 定时器管理(Quartz/时间轮算法处理活动开启等)
- 数据持久化
- Redis集群缓存热点数据(玩家属性缓存命中率需>95%)
- 分库分表设计(例如按玩家ID取模分128个库)
- 异步落库机制(使用Disruptor队列实现每秒10W+写入)
二、开发全流程实战(以MMORPG为例)
阶段1:预研设计(2-4周)
- 协议设计
// 使用Protobuf定义移动协议
message PlayerMove {
int32 player_id = 1;
Vector3 position = 2; // 三维坐标
float rotation = 3; // 朝向
int64 timestamp = 4; // 客户端时间戳
}
message BattleSkill {
int32 skill_id = 1;
repeated int32 target_ids = 2; // 多目标锁定
Coordinate cast_position = 3; // 技能释放位置
}
- 架构设计
graph TD
A[Gateway] --> B[BattleServer]
A --> C[SocialServer]
B --> D[RedisCluster]
C --> E[MySQLCluster]
F[MatchService] --> B
阶段2:核心系统开发(6-8周)
- 网络层实现
// Netty WebSocket处理器示例
@ChannelHandler.Sharable
public class GameServerHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
ProtocolMsg msg = ProtocolParser.parse(frame.text());
switch (msg.getType()) {
case MOVE:
handleMovement(ctx, (MoveMsg)msg);
break;
case SKILL_CAST:
validateSkillCooldown((SkillMsg)msg);
broadcastToAOI(ctx.channel(), msg);
break;
}
}
}
- AOI(Area of Interest)管理
- 九宫格算法实现视野同步
- 动态调整同步频率(近距离玩家100ms/次,远距离500ms/次)
- 战斗系统
- 采用确定性帧同步(Lockstep)
- 使用FixedPoint替代浮点数运算保证一致性
三、前后端协作关键点
- 协议版本控制
- 强制版本校验:每个消息头包含协议版本号
{
"ver": "1.2.3",
"cmd": 1001,
"data": {...}
}
- 调试工具链建设
- 开发GM指令系统:
/debug latency 200 // 模拟200ms延迟
/simulate 5000 // 生成5000个机器人
- 联调流程
- 使用Wireshark抓包分析时序问题
- Unity引擎侧实现协议回放功能
- 自动化测试覆盖率要求:
- 基础协议:100%
- 战斗用例:>85%
四、性能优化实践
- JVM层面
- G1GC参数优化:
-XX:+UseG1GC -XX:MaxGCPauseMillis=50
-XX:InitiatingHeapOccupancyPercent=35
- 网络优化
- 启用Snappy压缩协议(降低30%流量)
- 合并小包(Nagle算法+50ms合并窗口)
- 数据库优化
- 玩家数据冷热分离:
- 热数据:位置、状态(Redis)
- 冷数据:成就、日志(MySQL)
- 玩家数据冷热分离:
五、上线后运维
- 监控体系
- 关键指标报警阈值设置:
- 单服延迟:>200ms
- 消息队列积压:>1000
- CPU使用率:>70%持续5分钟
- 关键指标报警阈值设置:
- 紧急处理预案
- 自动扩容规则:
if conn_count > 40000:
spin_up_new_instance()
if qps > 5000:
enable_rate_limiter()
- 自动扩容规则:
六、常见问题解决方案
问题场景:战斗不同步
排查步骤:
- 对比客户端帧日志与服务端校验日志
- 检查确定性随机数种子一致性
- 验证物理引擎的FixedUpdate时序
问题场景:登录排队
优化方案:
- 令牌桶限流算法控制进入速度
- 预计等待时间动态计算:
wait_time = current_queue_size * avg_process_time / available_instances
通过以上流程,Java后端开发者可逐步掌握游戏开发特性,重点需要转变的思维模式包括:从请求响应模式到实时状态同步、从CRUD主导到复杂逻辑计算、从分钟级延迟到毫秒级响应的要求。建议从简单的棋牌类游戏入手,逐步过渡到大型实时游戏开发。
来源:juejin.cn/post/7475292103146684479