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
我的 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
基于英雄联盟人物的加载动画,奇怪的需求又增加了!
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
组长说:公司的国际化就交给你了,下个星期给我
从“跑路程序员”到“摸鱼仙人”,我用这插件把国际化的屎山代码盘活了!
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
实现基于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
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
BOE(京东方)绵阳“零碳工厂”探访活动圆满落幕 树立显示产业绿色转型新标杆
2025年3月13日,BOE(京东方)“零碳工厂”探访活动在绵阳成功举办,此次活动邀请KOL及媒体代表齐聚京东方绵阳第6代柔性AMOLED生产线,深度探访国内显示行业首个“零碳工厂”。通过实地观摩与技术交流,BOE(京东方)全方位展示了其在绿色制造领域的突破性成果——从100%可再生能源覆盖到全流程碳足迹管理,从技术创新驱动减排到低碳模式行业复制,见证了公司多年来在可持续发展领域的持续投入与引领作用,为全球显示产业绿色转型提供了可借鉴的范本。
BOE(京东方)绵阳第6代柔性AMOLED生产线
定义“零碳工厂”:科技与绿色的深度融合
“零碳工厂”的诞生,源于BOE(京东方)对技术创新与可持续发展融合的极致追求。作为全球半导体显示行业首家实现“碳中和”的工厂,BOE(京东方)绵阳第6代柔性AMOLED生产线通过三大核心路径达成“零碳”目标:
100%绿电覆盖:2023年,工厂通过采购水电、风电及光伏绿电,实现生产用电可再生能源化,年间接减排量达51万吨;
全流程碳管理:从原材料采购到生产环节,通过碳足迹监测与优化分析减碳重点方向,减少产品迭代升级带来的碳排放影响,实现碳排放透明化管控,目前,BOE(京东方)的产品碳足迹已处于行业领先水平;
技术减排降碳:以技术创新驱动节能减排,通过优化生产工艺持续发力,例如延长NF3气体清洗周期、空压机节能改造、洁净室智能照明调控等技术,推动2023年直接碳排放减少3255吨,间接减排3677吨。
京东方绵阳区域总经理曹成熙表示,“科技+绿色”的完美融合代表了显示行业的未来方向,“零碳”不仅是目标,更是企业责任与技术能力的双重体现。BOE(京东方)通过“绿电覆盖+碳足迹管理+技术降碳”的创新模式,率先实现生产环节的“碳中和”。我们希望通过实践,为高精尖制造业探索一条可复制、可推广的绿色路径。
硬核科技:从减碳到赋能的升级
BOE(京东方)绵阳第6代柔性AMOLED生产线不仅通过一系列技术革新显著降低能耗,更以“绿色效能”反哺生产效率,实现环境效益与经济效益双赢,推动全行业协同升级。
屋顶光伏
由京东方能源投建的31.7MW分布式光伏项目,利用了BOE(京东方)绵阳第6代柔性AMOLED生产线23万平方米的屋顶面积,总面积可达32个标准足球场大小。BOE(京东方)通过“自发自用”模式,将屋顶光伏所发绿电在生产线20KV侧全部消纳。数据显示,绵阳京东方屋面光伏项目年均发电量可达2400万kWh,每年可减少二氧化碳排放 1.28 万吨,进一步减少对传统能源的依赖,提升绿色竞争力。
能源管理体系
能源管理体系采用IDC工业数据中心,拥有强大的数据整合和数据分析能力,通过对全公司各个生产环节的能源消耗进行实时追踪,包括生产制造、设备运转等各方面,系统分析并改善节能措施,2024年提出60余项节能措施,累计节电3263万kWh,减碳1.75万吨。
技术普惠赋能行业升级
BOE(京东方)绵阳第6代柔性AMOLED生产线的“光伏自建”、“自由冷却项目”等创新模式正快速复制至BOE(京东方)其他产线。以“自由冷却项目”为例,其年节电量580万kWh、减少二氧化碳排放3112吨,为高耗能洁净生产领域提供了可推广的低成本减碳方案,彰显了技术创新性与行业普惠性。
长期主义:从企业到生态的共振
可持续发展理念是BOE(京东方)多年来融入企业文化血脉中的基因。通过“绿色管理、绿色产品、绿色制造、绿色循环、绿色投资、绿色行动”六大路径,BOE(京东方)构建了覆盖全价值链的低碳体系。截至目前,BOE(京东方)旗下18家工厂获评“国家级绿色工厂”,并拥有显示行业唯一1家国家级“无废工厂”,1家“灯塔工厂”,2家“零碳工厂”及4家“绿色供应链”认证企业。此外,BOE(京东方)还积极响应SBTi及CDP等相关国际倡议,旗下已有7家工厂加入科学碳目标倡议组织(SBTi)。BOE(京东方)更是已连续14年发布可持续发展报告,在社会责任、公司治理等领域也取得了卓越成绩,持续推动产业向高端化、智能化、绿色化进阶。
作为全球领先的物联网创新企业,BOE(京东方)正以“屏之物联”战略为指引,加速推动技术、场景与绿色生态的深度融合。面向2050年碳中和目标,BOE(京东方)将持续践行“科技+绿色”理念,携手行业伙伴构建更真实、更高端、更智能、更绿色的产业生态,共同构建可持续发展的美好未来,为全球绿色发展贡献中国智慧与力量。
关于BOE(京东方):
京东方科技集团股份有限公司(BOE)是全球领先的物联网创新企业,为信息交互和人类健康提供智慧端口产品和专业服务。作为全球半导体显示产业的龙头企业,BOE(京东方)带领中国显示产业破局“少屏”困境,实现了从0到1的跨越。如今,全球每四个智能终端就有一块显示屏来自BOE(京东方)。在“屏之物联”战略引领下,BOE(京东方)凭借“1+4+N+生态链”业务架构,将超高清液晶显示、柔性显示、MLED、微显示等领先技术广泛应用于交通、金融、艺术、零售、教育、办公、医疗等多元场景,赋能千行百业。目前,BOE(京东方)的子公司遍布全球近20个国家和地区,拥有超过5000家全球生态合作伙伴。更多详情可访问BOE(京东方)官网:https://www.boe.com.cn
收起阅读 »微信小程序主包过大终极解决方案
随着小程序项目的不断迭代升级,避免不了体积越来越大。微信限制主包最多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
BOE(京东方)珠海晶芯COB量产交付仪式举办 赋能MLED产业新发展
3月10日,BOE(京东方)在珠海京东方晶芯科技有限公司成功举办主题为“屏曜万象 量产启航”的COB量产交付仪式,全球来自美国、欧洲、日韩、东南亚等在内的50余位海外客户齐聚一堂,这一重要时刻标志着BOE(京东方)在MLED显示技术领域取得重要里程碑。通过整合上游芯片、中游封装与下游应用资源,BOE(京东方)构建了行业领先的“芯片-封装-显示”全链条智能制造能力,打造最短产业链生态环境。这一布局不仅加速了MLED产业链的协同创新与全球拓展,更推动了BOE(京东方)向COB产业领先企业快速迈进,为其在全球高端显示市场的布局奠定了坚实基础。
BOE(京东方)执行委员会副主席、首席运营官王锡平在开场致辞中表示,BOE(京东方)始终坚持创新驱动发展,全面开启“屏之物联”企业升维战略。MLED业务是BOE(京东方)“1+4+N+生态链”的高潜航道之一,此次珠海晶芯项目量产交付,是公司打造 MLED 产业集群的重要战略举措。这不仅是全球显示产业迈向未来的重要一步,更是BOE(京东方)推动产业链升级的关键里程碑。我们期待与全球合作伙伴携手同行,共同推动显示行业向更智能、更绿色、更可持续的未来迈进!
BOE(京东方)执行委员会副主席、首席运营官 王锡平
作为中国半导体显示领域首个技术品牌,BOE(京东方)首创、领先行业的高端玻璃基新型 LED 显示技术品牌α-MLED,致力于为全球客户提供领先的新型LED显示系统及解决方案。BOE(京东方)通过室内平台、户外平台、创新应用平台三大产品平台,打造COB、COG、SMD、Micro LED高竞争力产品线,全面满足多元化的市场需求。据悉,珠海京东方晶芯MLED生产线坐落在华发平沙电子电器产业园,从去年6月启动到量产仅用9个月的时间,彰显了BOE(京东方)高效执行力和产业链协同优势,也为MLED技术的产业化进程树立了新的标杆。
BOE(京东方)在MLED领域持续布局,目前已推出P0.3 Micro LED 产品、COG LTPS P0.5 MLED 显示产品、COB P0.9-1.5全系列产品等,同时背光技术在电视、显示器、笔记本电脑、电竞、车载多领域进行布局;此外还将BOE(京东方)独有的ADS Pro显示技术与Mini LED背光相结合,极大提升高速运动画面清晰度,以媲美OLED的画质表现引领行业实现极致视觉体验。此次BOE(京东方)量产的COB产品,以至臻光学、至湛热学、至简结构三大核心技术为着力点,全方位打造MLED产品差异化技术优势和至臻显示效果。在至臻光学方面,通过高亮度(最高2000nit)、高色域(110% NTSC)、超低功耗(同亮度下功耗减半)、以及高对比度、亮态一致性、暗态一致性等优势,为用户提供更清晰、真实、沉浸的视觉体验;在至湛热学方面,关注环境与人的双向友好,打造冷屏卓越体验,提升产品性能的同时实现节能低功耗;在至简结构方面,实现结构标准化、便捷化和创新设计,提高生产效率的同时确保产品稳定性和长久耐用性。此外,BOE(京东方)独创的“无色缝墨色管控工艺”更是打破行业壁垒,无需模组目视筛选即可实现整屏墨色一致性,打造MLED画质最优、一致性更佳的高端显示解决方案。值得一提的是,BOE(京东方)已在埃及打造全球最大COB小间距LED室内显示屏,屏幕面积达600㎡,采用BOE MLED COB P1.2产品,分辨率高达67680×5670,是目前全球分辨率最高的COB大屏,为埃及城市建设注入科技动能。
BOE(京东方)副总裁、MLED业务CEO刘毅表示,珠海京东方晶芯的成功量产,不仅标志着BOE(京东方)在MLED COB领域取得了重要突破,更是我们推动显示技术革新、引领产业升级的关键里程碑。此次项目落地也离不开珠海以及华发集团等各方合作伙伴的全力配合和支持。我们期待与全球合作伙伴携手共进,共同推动MLED技术的创新与应用,为商业显示、高端电视、车载显示等领域提供更优质、更高效的显示解决方案,助力全球客户实现价值提升,深化与全球伙伴构建开放、合作、共赢的产业生态。
BOE(京东方)副总裁、MLED业务CEO 刘毅
值得关注的是,BOE(京东方)MLED业务持续深耕全产业链生态布局,依托珠海京东方晶芯的量产和设备技术升级,首发主推直显新品BYH-COB MLED系列,在同期举办的深圳ISLE 2025展会上,该系列的Pro版和Ultra版产品首次在国内发布,同时BOE(京东方)还携电影屏、球形屏、背光等创新产品重磅亮相,全面展示MLED技术的突破性应用。其中,BYH-COB Pro P0.9采用大板模组设计,单模组尺寸达14.5英寸,有效减少拼缝数量,提升画面整体性,广泛应用于会议室、商超、展厅等场景。该系列中的BOE-BYH ULTRA作为BOE MLED COB定制化高端产品,凭借独创光学膜封装技术与自研高端表面防眩光反射膜材,在环境光10lux条件下实现高达20000:1的对比度,为目前行业领先。
在显示无处不在的时代,BOE(京东方)以“屏”为核心,持续推动显示技术的革新与突破。面向未来,BOE(京东方)将始终秉持“屏之物联”战略,聚焦Mini/Micro LED技术迭代升级,与全球生态伙伴深化创新合作,构建更为完整的产业链生态布局,以更加卓越的显示解决方案,为全球客户创造更大价值!
关于BOE(京东方):
京东方科技集团股份有限公司(BOE)是全球领先的物联网创新企业,为信息交互和人类健康提供智慧端口产品和专业服务。作为全球半导体显示产业的龙头企业,BOE(京东方)带领中国显示产业破局“少屏”困境,实现了从0到1的跨越。如今,全球每四个智能终端就有一块显示屏来自BOE(京东方)。在“屏之物联”战略引领下,BOE(京东方)凭借“1+4+N+生态链”业务架构,将超高清液晶显示、柔性显示、MLED、微显示等领先技术广泛应用于交通、金融、艺术、零售、教育、办公、医疗等多元场景,赋能千行百业。目前,BOE(京东方)的子公司遍布全球近20个国家和地区,拥有超过5000家全球生态合作伙伴。更多详情可访问BOE(京东方)官网。
收起阅读 »后端出身的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
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
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
⚔️不让我在控制台上调试,哼,休想🛠️
在 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
表妹问:前端好玩吗?我说好玩,但表妹接下来的回复看哭了我
表妹问:前端好玩吗?我说好玩,但表妹接下来的回复看哭了我。
是的,回复如下:
这红海血途上,新兵举着 "大前端" 旌旗冲锋,老兵拖着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
这个中国亲戚关系计算器让你告别“社死”
大家好,我是 Java陈序员
。
由于为了生活奔波,常年在外,导致很多关系稍疏远的亲戚之间来往并不多。
因此节假日回家时,往往会搞不清楚哪位亲戚应该喊什么称呼,很容易“社死”。
今天给大家介绍一个亲戚关系计算器,让你快速的计算出正确的亲戚称谓!
关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。
项目介绍
relationship
—— 中国亲戚关系计算器,只需简单的输入即可算出称谓。
输入框兼容了不同的叫法,你可以称呼父亲为:“老爸”、“爹地”、“老爷子”等等,方便不同地域的习惯叫法。
快捷输入按键,只需简单的点击即可完成关系输入,算法还支持逆向查找称呼哦~
功能特色:
- 使用别称查询:姥姥的爸爸的老窦 = 外曾外曾祖父
- 使用合称查询:姐夫的双亲 = 姊妹姻父 / 姊妹姻母
- 大小数字混合查询:大哥的二姑妈的七舅姥爷 = 舅曾外祖父
- 不限制祖辈孙辈跨度查询:舅妈的婆婆的外甥的姨妈的侄子 = 舅表舅父
- 根据年龄推导可能性:哥哥的表姐 = 姑表姐 / 舅表姐
- 根据语境确认性别:老婆的女儿的外婆 = 岳母
- 支持古文式表达:吾父之舅父 = 舅爷爷
- 解析某称谓关系链:七舅姥爷 = 妈妈的妈妈的兄弟
- 算两个亲戚间的合称关系:奶奶 + 外婆 = 儿女亲家
项目地址:
https://github.com/mumuy/relationship
在线体验:
https://passer-by.com/relationship/
移动端体验地址:
https://passer-by.com/relationship/vue/
功能体验
1、关系找称呼
2、称呼找关系
3、两者间关系
4、两者的合称
安装使用
1、直接引入安装
<script src="https://passer-by.com/relationship/dist/relationship.min.js">
获取全局方法 relationship
.
2、使用 npm 包管理安装
安装依赖:
npm install relationship.js
包引入:
// CommonJS 引入
const relationship = require("relationship.js");
// ES Module 引入
import relationship from 'relationship.js';
3、使用方法:唯一的计算方法 relationship
.
- 选项模式
relationship(options)
构造函数:
var options = {
text:'', // 目标对象:目标对象的称谓汉字表达,称谓间用‘的’字分隔
target:'', // 相对对象:相对对象的称谓汉字表达,称谓间用‘的’字分隔,空表示自己
sex:-1, // 本人性别:0表示女性,1表示男性
type:'default', // 转换类型:'default'计算称谓,'chain'计算关系链,'pair'计算关系合称
reverse:false, // 称呼方式:true对方称呼我,false我称呼对方
mode:'default', // 模式选择:使用setMode方法定制不同地区模式,在此选择自定义模式
optimal:false, // 最短关系:计算两者之间的最短关系
};
代码示例:
// 如:我应该叫外婆的哥哥什么?
relationship({text:'妈妈的妈妈的哥哥'});
// => ['舅外公']
// 如:七舅姥爷应该叫我什么?
relationship({text:'七舅姥爷',reverse:true,sex:1});
// => ['甥外孙']
// 如:舅公是什么亲戚
relationship({text:'舅公',type:'chain'});
// => ['爸爸的妈妈的兄弟', '妈妈的妈妈的兄弟', '老公的妈妈的兄弟']
// 如:舅妈如何称呼外婆?
relationship({text:'外婆',target:'舅妈',sex:1});
// => ['婆婆']
// 如:外婆和奶奶之间是什么关系?
relationship({text:'外婆',target:'奶奶',type:'pair'});
// => ['儿女亲家']
- 语句模式
relationship(exptession)
参数 exptession 句式可以为:xxx是xxx的什么人、xxx叫xxx什么、xxx如何称呼xxx等。
代码示例:
// 如:舅妈如何称呼外婆?
relationship('舅妈如何称呼外婆?');
// => ['婆婆']
// 如:外婆和奶奶之间是什么关系?
relationship('外婆和奶奶之间是什么关系?');
// => ['儿女亲家']
4、其他 API
// 获取当前数据表
relationship.data
// 获取当前数据量
relationship.dataCount
// 用户自定义模式
relationship.setMode(mode_name,mode_data)
最后
推荐的开源项目已经收录到 GitHub
项目,欢迎 Star
:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/
大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!
来源:juejin.cn/post/7344573753538330678
实现抖音 “视频无限滑动“效果
前言
在家没事的时候刷抖音玩,抖音首页的视频怎么刷也刷不完,经常不知不觉的一刷就到半夜了😅
不禁感叹道 "垃圾抖音,费我时间,毁我青春😅"
这是我的 模仿抖音 系列文章的第二篇,本文将一步步实现抖音首页 视频无限滑动
的效果,干货满满
第一篇:200行代码实现类似Swiper.js的轮播组件
第三篇:Vue 路由使用介绍以及添加转场动画
第四篇:Vue 有条件路由缓存,就像传统新闻网站一样
第五篇:Github Actions 部署 Pages、同步到 Gitee、翻译 README 、 打包 docker 镜像
如果您对滑动原理不太熟悉,推荐先看我的这篇文章:200行代码实现类似Swiper.js的轮播组件
最终效果
在线预览:dy.ttentau.top/
Github地址:github.com/zyronon/dou…
实现原理
无限滑动的原理和虚拟滚动的原理差不多,要保持 SlideList
里面永远只有 N
个 SlideItem
,就要在滑动时不断的删除和增加 SlideItem
。
滑动时调整 SlideList
的偏移量 translateY
的值,以及列表里那几个 SlideItem
的 top
值,就可以了
为什么要调整 SlideList
的偏移量 translateY
的值同时还要调整 SlideItem
的 top
值呢?
因为 translateY
只是将整个列表移动,如果我们列表里面的元素是固定的,不会变多和减少,那么没关系,只调整 translateY
值就可以了,上滑了几页就减几页的高度,下滑同理
但是如果整个列表向前移动了一页,同时前面的 SlideItem
也少了一个,,那么最终效果就是移动了两页...因为 塌陷
了一页
这显然不是我们想要的,所以我们还需要同时调整 SlideItem
的 top
值,加上前面少的 SlideItem
的高度,这样才能显示出正常的内容
步骤
定义
virtualTotal
:页面中同时存在多少个 SlideItem
,默认为 5
。
//页面中同时存在多少个SlideItem
virtualTotal: {
type: Number,
default: () => 5
},
设置这个值可以让外部组件使用时传入,毕竟每个人的需求不同,有的要求同时存在 10
条,有的要求同时存在 5
条即可。
不过同时存在的数量越大,使用体验就越好,即使用户快速滑动,我们依然有时间处理。
如果只同时存在 5
条,用户只需要快速滑动两次就到底了(因为屏幕中显示第 3
条,刚开始除外),我们可能来不及添加新的视频到最后
render
:渲染函数,SlideItem
内显示什么由render
返回值决定
render: {
type: Function,
default: () => {
return null
}
},
之所以要设定这个值,是因为抖音首页可不只有视频,还有图集、推荐用户、广告等内容,所以我们不能写死显示视频。
最好是定义一个方法,外部去实现,我们内部去调用,拿到返回值,添加到 SlideList
中
list
:数据列表,外部传入
list: {
type: Array,
default: () => {
return []
}
},
我们从 list
中取出数据,然后调用并传给 render
函数,将其返回值插入到 SlideList中
初始化
watch(
() => props.list,
(newVal, oldVal) => {
//新数据长度比老数据长度小,说明是刷新
if (newVal.length < oldVal.length) {
//从list中取出数据,然后调用并传给render函数,将其返回值插入到SlideList中
insertContent()
} else {
//没数据就直接插入
if (oldVal.length === 0) {
insertContent()
} else {
// 走到这里,说明是通过接口加载了下一页的数据,
// 为了在用户快速滑动时,无需频繁等待请求接口加载数据,给用户更好的使用体验
// 这里额外加载3条数据。所以此刻,html里面有原本的5个加新增的3个,一共8个dom
// 用户往下滑动时只删除前面多余的dom,等滑动到临界值(virtualTotal/2+1)时,再去执行新增逻辑
}
}
}
)
用 watch
监听 list
是因为它一开始不一定有值,通过接口请求之后才有值
同时当我们下滑 加载更多
时,也会触发接口请求新的数据,用 watch
可以在有新数据时,多添加几条到 SlideList
的最后面,这样用户快速滑动也不怕了
如何滑动
这里就不再赘述,参考我的这篇文章:200行代码实现类似Swiper.js的轮播组件
滑动结束
判断滑动的方向
当我们向上滑动时,需要删除最前面的 dom
,然后在最后面添加一个 dom
下滑时反之
slideTouchEnd(e, state, canNext, (isNext) => {
if (props.list.length > props.virtualTotal) {
//手指往上滑(即列表展示下一条视频)
if (isNext) {
//删除最前面的 `dom` ,然后在最后面添加一个 `dom`
} else {
//删除最后面的 `dom` ,然后在最前面添加一个 `dom`
}
}
})
手指往上滑(即列表展示下一条视频)
- 首先判断是否要加载更多,快到列表末尾时就要加载更多数据了
- 再判断是否符合
腾挪
的条件,即当前位置要大于half
,且小于列表长度减half
。 - 在最后面添加一个
dom
- 删除最前面的
dom
- 将所有
dom
设置为最新的top
值(原因前面有讲,因为删除了最前面的dom
,导致塌陷一页,所以要加上删除dom
的高度)
let half = (props.virtualTotal - 1) / 2
//删除最前面的 `dom` ,然后在最后面添加一个 `dom`
if (state.localIndex > props.list.length - props.virtualTotal && state.localIndex > half) {
emit('loadMore')
}
//是否符合 `腾挪` 的条件
if (state.localIndex > half && state.localIndex < props.list.length - half) {
//在最后面添加一个 `dom`
let addItemIndex = state.localIndex + half
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addItemIndex}']`)
if (!res) {
slideListEl.value.appendChild(getInsEl(props.list[addItemIndex], addItemIndex))
}
//删除最前面的 `dom`
let index = slideListEl.value
.querySelector(`.${itemClassName}:first-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()
slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}
手指往下滑(即列表展示上一条视频)
逻辑和上滑都差不多,不过是反着来而已
- 再判断是否符合
腾挪
的条件,和上面反着 - 在最前面添加一个
dom
- 删除最后面的
dom
- 将所有
dom
设置为最新的top
值
//删除最后面的 `dom` ,然后在最前面添加一个 `dom`
if (state.localIndex >= half && state.localIndex < props.list.length - (half + 1)) {
let addIndex = state.localIndex - half
if (addIndex >= 0) {
let res = slideListEl.value.querySelector(`.${itemClassName}[data-index='${addIndex}']`)
if (!res) {
slideListEl.value.prepend(getInsEl(props.list[addIndex], addIndex))
}
}
let index = slideListEl.value
.querySelector(`.${itemClassName}:last-child`)
.getAttribute('data-index')
appInsMap.get(Number(index)).unmount()
slideListEl.value.querySelectorAll(`.${itemClassName}`).forEach((item) => {
_css(item, 'top', (state.localIndex - half) * state.wrapper.height)
})
}
其他问题
为什么不直接用 v-for
直接生成 SlideItem
呢?
如果内容不是视频就可以。要删除或者新增时,直接操作 list
数据源,这样省事多了
如果内容是视频,修改 list
时,Vue
会快速的替换 dom
,正在播放的视频,突然一下从头开始播放了😅😅😅
如何获取 Vue
组件的最终 dom
有两种方式,各有利弊
- 用
Vue
的render
方法
- 优点:只是渲染一个
VNode
而已,理论上讲内存消耗更少。 - 缺点:但我在开发中,用了这个方法,任何修改都会刷新页面,有点难蚌😅
- 优点:只是渲染一个
- 用
Vue
的createApp
方法再创建一个Vue
的实例
- 和上面相反😅
import { createApp, onMounted, reactive, ref, render as vueRender, watch } from 'vue'
/**
* 获取Vue组件渲染之后的dom元素
* @param item
* @param index
* @param play
*/
function getInsEl(item, index, play = false) {
// console.log('index', cloneDeep(item), index, play)
let slideVNode = props.render(item, index, play, props.uniqueId)
const parent = document.createElement('div')
//TODO 打包到线上时用这个,这个在开发时任何修改都会刷新页面
if (import.meta.env.PROD) {
parent.classList.add('slide-item')
parent.setAttribute('data-index', index)
//将Vue组件渲染到一个div上
vueRender(slideVNode, parent)
appInsMap.set(index, {
unmount: () => {
vueRender(null, parent)
parent.remove()
}
})
return parent
} else {
//创建一个新的Vue实例,并挂载到一个div上
const app = createApp({
render() {
return <SlideItem data-index={index}>{slideVNode}</SlideItem>
}
})
const ins = app.mount(parent)
appInsMap.set(index, app)
return ins.$el
}
}
总结
原理其实并不难。主要是一开始可能会用 v-for
去弄,折腾半天发现不行。v-for
不行,就只能想想怎么把 Vue
组件搞到 html
里面去,又去研究如何获取 Vue
组件的最终 dom
,又查了半天资料,Vue
官方文档也不写,还得去翻 api
,麻了
结束
以上就是文章的全部内容,感谢看到这里,希望对你有所帮助或启发!创作不易,如果觉得文章写得不错,可以点赞收藏支持一下,也欢迎关注我的公众号 前端张余让,我会更新更多实用的前端知识与技巧,期待与你共同成长~
来源:juejin.cn/post/7361614921519054883
autohue.js:让你的图片和背景融为一体,绝了!
需求
先来看这样一个场景,拿一个网站举例
这里有一个常见的网站 banner 图容器,大小为为1910*560,看起来背景图完美的充满了宽度,但是图片原始大小时,却是:
它的宽度只有 1440,且 background-size 设置的是 contain ,即等比例缩放,那么可以断定它两边的蓝色是依靠背景色填充的。
那么问题来了,这是一个 轮播banner,如果希望添加一张不是蓝色的图片呢?难道要给每张图片提前标注好背景颜色吗?这显然是非常死板的做法。
所以需要从图片中提取到图片的主题色,当然这对于 js 来说,也不是什么难事,市面上已经有众多的开源库供我们使用。
探索
首先在网络上找到了以下几个库:
- color-thief 这是一款基于 JavaScript 和 Canvas 的工具,能够从图像中提取主要颜色或代表性的调色板
- vibrant.js 该插件是 Android 支持库中 Palette 类的 JavaScript 版本,可以从图像中提取突出的颜色
- rgbaster.js 这是一段小型脚本,可以获取图片的主色、次色等信息,方便实现一些精彩的 Web 交互效果
我取最轻量化的 rgbaster.js(此库非常搞笑,用TS编写,npm 包却没有指定 types) 来测试后发现,它给我在一个渐变色图片中,返回了七万多个色值,当然,它准确的提取出了面积最大的色值,但是这个色值不是图片边缘的颜色,导致设置为背景色后,并不能完美的融合。
另外的插件各位可以参考这几篇文章:
- 文章1:blog.csdn.net/weixin_4299…
- 文章2:juejin.cn/post/684490…
- 文章3:http://www.zhangxinxu.com/wordpress/2…
可以发现,这些插件主要功能就是取色,并没有考虑实际的应用场景,对于一个图片颜色分析工具来说,他们做的很到位,但是在大多数场景中,他们往往是不适用的。
在文章 2 中,作者对比了三款插件对于图片容器背景色的应用,看起来还是 rgbaster 效果好一点,但是我们刚刚也拿他试了,它并不能适用于颜色复杂度高的、渐变色的图片。
思考
既然又又又没有人做这件事,正所谓我不入地狱谁入地狱,我手写一个
整理一下需求,我发现我希望得到的是:
- 图片的主题色(面积占比最大)
- 次主题色(面积占比第二大)
- 合适的背景色(即图片边缘颜色,渐变时,需要边缘颜色来设置背景色)
这样一来,就已经可以覆盖大部分需求了,1+2 可以生成相关的 主题 TAG、主题背景,3 可以使留白的图片容器完美融合。
开搞
⚠⚠ 本小节内容非常硬核,如果不想深究原理可以直接跳过,文章末尾有用法和效果图 ⚠⚠
思路
首先需要避免上面提到的插件的缺点,即对渐变图片要做好处理,不能取出成千上万的颜色,体验太差且实用性不强,对于渐变色还有一点,即在渐变路径上,每一点的颜色都是不一样的,所以需要将他们以一个阈值分类,挑选出一众相近色,并计算出一个平均色,这样就不会导致主题色太精准进而没有代表性。
对于背景色,需要按情况分析,如果只是希望做一个协调的页面,那么大可以直接使用主题色做渐变过渡或蒙层,也就是类似于这种效果
但是如果希望背景与图片完美衔接,让人看不出图片边界的感觉,就需要单独对边缘颜色取色了。
最后一个问题,如果图片分辨率过大,在遍历像素点时会非常消耗性能,所以需要降低采样率,虽然会导致一些精度上的丢失,但是调整为一个合适的值后应该基本可用。
剩余的细节问题,我会在下面的代码中解释
使用 JaveScript 编码
接下来我将详细描述 autohue.js 的实现过程,由于本人对色彩科学
不甚了解,如有解释不到位或错误,还请指出。
首先编写一个入口主函数,我目前考虑到的参数应该有:
export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions)
type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
interface autoColorPickerOptions {
/**
* - 降采样后的最大尺寸(默认 100px)
* - 降采样后的图片尺寸不会超过该值,可根据需求调整
* - 降采样后的图片尺寸越小,处理速度越快,但可能会影响颜色提取的准确性
**/
maxSize?: number
/**
* - Lab 距离阈值(默认 10)
* - 低于此值的颜色归为同一簇,建议 8~12
* - 值越大,颜色越容易被合并,提取的颜色越少
* - 值越小,颜色越容易被区分,提取的颜色越多
**/
threshold?: number | thresholdObj
}
概念解释 Lab ,全称:
CIE L*a*b
,CIE L*a*b*
是CIE XYZ
色彩模式的改进型。它的“L”(明亮度),“a”(绿色到红色)和“b”(蓝色到黄色)代表许多的值。与XYZ比较,CIE L*a*b*
的色彩更适合于人眼感觉的色彩,正所谓感知均匀
然后需要实现一个正常的 loadImg 方法,使用 canvas 异步加载图片
function loadImage(imageSource: HTMLImageElement | string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
let img: HTMLImageElement
if (typeof imageSource === 'string') {
img = new Image()
img.crossOrigin = 'Anonymous'
img.src = imageSource
} else {
img = imageSource
}
if (img.complete) {
resolve(img)
} else {
img.onload = () => resolve(img)
img.onerror = (err) => reject(err)
}
})
}
这样我们就获取到了图片对象。
然后为了图片过大,我们需要进行降采样处理
// 利用 Canvas 对图片进行降采样,返回 ImageData 对象
function getImageDataFromImage(img: HTMLImageElement, maxSize: number = 100): ImageData {
const canvas = document.createElement('canvas')
let width = img.naturalWidth
let height = img.naturalHeight
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height)
width = Math.floor(width * scale)
height = Math.floor(height * scale)
}
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法获取 Canvas 上下文')
}
ctx.drawImage(img, 0, 0, width, height)
return ctx.getImageData(0, 0, width, height)
}
概念解释,降采样:降采样(Downsampling)是指在图像处理中,通过减少数据的采样率或分辨率来降低数据量的过程。具体来说,就是在保持原始信息大致特征的情况下,减少数据的复杂度和存储需求。这里简单理解为将图片强制压缩为 100*100 以内,也是 canvas 压缩图片的常见做法。
得到图像信息后,就可以对图片进行像素遍历处理了,正如思考中提到的,我们需要对相近色提取并取平均色,并最终获取到主题色、次主题色。
那么问题来了,什么才算相近色,对于这个问题,在 常规的 rgb 中直接计算是不行的,因为它涉及到一个感知均匀的问题
概念解释,感知均匀:XYZ系统和在它的色度图上表示的两种颜色之间的距离与颜色观察者感知的变化不一致,这个问题叫做感知均匀性(perceptual uniformity)问题,也就是颜色之间数字上的差别与视觉感知不一致。由于我们需要在颜色簇中计算出平均色,那么对于人眼来说哪些颜色是相近的?此时,我们需要把 sRGB 转化为 Lab 色彩空间(感知均匀的),再计算其欧氏距离,在某一阈值内的颜色,即可认为是相近色。
所以我们首先需要将 rgb 转化为 Lab 色彩空间
// 将 sRGB 转换为 Lab 色彩空间
function rgbToLab(r: number, g: number, b: number): [number, number, number] {
let R = r / 255,
G = g / 255,
B = b / 255
R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92
G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92
B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92
let X = R * 0.4124 + G * 0.3576 + B * 0.1805
let Y = R * 0.2126 + G * 0.7152 + B * 0.0722
let Z = R * 0.0193 + G * 0.1192 + B * 0.9505
X = X / 0.95047
Y = Y / 1.0
Z = Z / 1.08883
const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
const fx = f(X)
const fy = f(Y)
const fz = f(Z)
const L = 116 * fy - 16
const a = 500 * (fx - fy)
const bVal = 200 * (fy - fz)
return [L, a, bVal]
}
这个函数使用了看起来很复杂的算法,不必深究,这是它的大概解释:
- 获取到 rgb 参数
- 转化为线性 rgb(移除 gamma矫正),常量 0.04045 是sRGB(标准TGB)颜色空间中的一个阈值,用于区分非线性和线性的sRGB值,具体来说,当sRGB颜色分量大于0.04045时,需要通过 gamma 校正(即采用
((R + 0.055) / 1.055) ^ 2.4
)来得到线性RGB;如果小于等于0.04045,则直接进行线性转换(即R / 12.92
) - 线性RGB到XYZ空间的转换,转换公式如下:
X = R * 0.4124 + G * 0.3576 + B * 0.1805
Y = R * 0.2126 + G * 0.7152 + B * 0.0722
Z = R * 0.0193 + G * 0.1192 + B * 0.9505
- 归一化XYZ值,为了参考白点(D65),标准白点的XYZ值是
(0.95047, 1.0, 1.08883)
。所以需要通过除以这些常数来进行归一化 - XYZ到Lab的转换,公式函数:const f = (t: number) => (t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116)
- 计算L, a, b 分量
L:亮度分量(表示颜色的明暗程度)
L = 116 * fy - 16
a:绿色到红色的色差分量
a = 500 * (fx - fy)
b:蓝色到黄色的色差分量
b = 200 * (fy - fz)
接下来实现聚类算法
/**
* 对满足条件的像素进行聚类
* @param imageData 图片像素数据
* @param condition 判断像素是否属于指定区域的条件函数(参数 x, y)
* @param threshold Lab 距离阈值,低于此值的颜色归为同一簇,建议 8~12
*/
function clusterPixelsByCondition(imageData: ImageData, condition: (x: number, y: number) => boolean, threshold: number = 10): Cluster[] {
const clusters: Cluster[] = []
const data = imageData.data
const width = imageData.width
const height = imageData.height
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!condition(x, y)) continue
const index = (y * width + x) * 4
if (data[index + 3] === 0) continue // 忽略透明像素
const r = data[index]
const g = data[index + 1]
const b = data[index + 2]
const lab = rgbToLab(r, g, b)
let added = false
for (const cluster of clusters) {
const d = labDistance(lab, cluster.averageLab)
if (d < threshold) {
cluster.count++
cluster.sumRgb[0] += r
cluster.sumRgb[1] += g
cluster.sumRgb[2] += b
cluster.sumLab[0] += lab[0]
cluster.sumLab[1] += lab[1]
cluster.sumLab[2] += lab[2]
cluster.averageRgb = [cluster.sumRgb[0] / cluster.count, cluster.sumRgb[1] / cluster.count, cluster.sumRgb[2] / cluster.count]
cluster.averageLab = [cluster.sumLab[0] / cluster.count, cluster.sumLab[1] / cluster.count, cluster.sumLab[2] / cluster.count]
added = true
break
}
}
if (!added) {
clusters.push({
count: 1,
sumRgb: [r, g, b],
sumLab: [lab[0], lab[1], lab[2]],
averageRgb: [r, g, b],
averageLab: [lab[0], lab[1], lab[2]]
})
}
}
}
return clusters
}
函数内部有一个 labDistance 的调用,labDistance 是计算 Lab 颜色空间中的欧氏距离的
// 计算 Lab 空间的欧氏距离
function labDistance(lab1: [number, number, number], lab2: [number, number, number]): number {
const dL = lab1[0] - lab2[0]
const da = lab1[1] - lab2[1]
const db = lab1[2] - lab2[2]
return Math.sqrt(dL * dL + da * da + db * db)
}
概念解释,欧氏距离:Euclidean Distance,是一种在多维空间中测量两个点之间“直线”距离的方法。这种距离的计算基于欧几里得几何中两点之间的距离公式,通过计算两点在各个维度上的差的平方和,然后取平方根得到。欧氏距离是指n维空间中两个点之间的真实距离,或者向量的自然长度(即该点到原点的距离)。
总的来说,这个函数采用了类似 K-means 的聚类方式,将小于用户传入阈值的颜色归为一簇,并取平均色(使用 Lab 值)。
概念解释,聚类算法:Clustering Algorithm 是一种无监督学习方法,其目的是将数据集中的元素分成不同的组(簇),使得同一组内的元素相似度较高,而不同组之间的元素相似度较低。这里是将相近色归为一簇。
概念解释,颜色簇:簇是聚类算法中一个常见的概念,可以大致理解为 "一类"
得到了颜色簇集合后,就可以按照count大小来判断哪个是主题色了
// 对全图所有像素进行聚类
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)
现在我们已经获取到了主题色、次主题色 🎉🎉🎉
接下来,我们继续计算边缘颜色
按照同样的方法,只是把阈值设小一点,我这里直接设置为 1 (threshold.top 等都是1)
// 分别对上、右、下、左边缘进行聚类
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor
const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor
const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor
const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor
这样我们就获取到了上下左右四条边的颜色 🎉🎉🎉
这样大致的工作就完成了,最后我们将需要的属性导出给用户,我们的主函数最终长这样:
/**
* 主函数:根据图片自动提取颜色
* @param imageSource 图片 URL 或 HTMLImageElement
* @returns 返回包含主要颜色、次要颜色和背景色对象(上、右、下、左)的结果
*/
export default async function colorPicker(imageSource: HTMLImageElement | string, options?: autoColorPickerOptions): Promise<AutoHueResult> {
const { maxSize, threshold } = __handleAutoHueOptions(options)
const img = await loadImage(imageSource)
// 降采样(最大尺寸 100px,可根据需求调整)
const imageData = getImageDataFromImage(img, maxSize)
// 对全图所有像素进行聚类
let clusters = clusterPixelsByCondition(imageData, () => true, threshold.primary)
clusters.sort((a, b) => b.count - a.count)
const primaryCluster = clusters[0]
const secondaryCluster = clusters.length > 1 ? clusters[1] : clusters[0]
const primaryColor = rgbToHex(primaryCluster.averageRgb)
const secondaryColor = rgbToHex(secondaryCluster.averageRgb)
// 定义边缘宽度(单位像素)
const margin = 10
const width = imageData.width
const height = imageData.height
// 分别对上、右、下、左边缘进行聚类
const topClusters = clusterPixelsByCondition(imageData, (_x, y) => y < margin, threshold.top)
topClusters.sort((a, b) => b.count - a.count)
const topColor = topClusters.length > 0 ? rgbToHex(topClusters[0].averageRgb) : primaryColor
const bottomClusters = clusterPixelsByCondition(imageData, (_x, y) => y >= height - margin, threshold.bottom)
bottomClusters.sort((a, b) => b.count - a.count)
const bottomColor = bottomClusters.length > 0 ? rgbToHex(bottomClusters[0].averageRgb) : primaryColor
const leftClusters = clusterPixelsByCondition(imageData, (x, _y) => x < margin, threshold.left)
leftClusters.sort((a, b) => b.count - a.count)
const leftColor = leftClusters.length > 0 ? rgbToHex(leftClusters[0].averageRgb) : primaryColor
const rightClusters = clusterPixelsByCondition(imageData, (x, _y) => x >= width - margin, threshold.right)
rightClusters.sort((a, b) => b.count - a.count)
const rightColor = rightClusters.length > 0 ? rgbToHex(rightClusters[0].averageRgb) : primaryColor
return {
primaryColor,
secondaryColor,
backgroundColor: {
top: topColor,
right: rightColor,
bottom: bottomColor,
left: leftColor
}
}
}
还记得本小节一开始提到的参数吗,你可以自定义 maxSize(压缩大小,用于降采样)、threshold(阈值,用于设置簇大小)
为了用户友好,我还编写了 threshold 参数的可选类型:number | thresholdObj
type thresholdObj = { primary?: number; left?: number; right?: number; top?: number; bottom?: number }
可以单独设置主阈值、上下左右四边阈值,以适应更个性化的情况。
autohue.js 诞生了
名字的由来:秉承一贯命名习惯,auto 家族成员又多一个,与颜色有关的单词有好多个,我取了最短最好记的一个 hue(色相),也比较契合插件用途。
此插件已在 github 开源:GitHub autohue.js
npm 主页:NPM autohue.js
在线体验:autohue.js 官方首页
安装与使用
pnpm i autohue.js
import autohue from 'autohue.js'
autohue(url, {
threshold: {
primary: 10,
left: 1,
bottom: 12
},
maxSize: 50
})
.then((result) => {
// 使用 console.log 打印出色块元素s
console.log(`%c${result.primaryColor}`, 'color: #fff; background: ' + result.primaryColor, 'main')
console.log(`%c${result.secondaryColor}`, 'color: #fff; background: ' + result.secondaryColor, 'sub')
console.log(`%c${result.backgroundColor.left}`, 'color: #fff; background: ' + result.backgroundColor.left, 'bg-left')
console.log(`%c${result.backgroundColor.right}`, 'color: #fff; background: ' + result.backgroundColor.right, 'bg-right')
console.log(`%clinear-gradient to right`, 'color: #fff; background: linear-gradient(to right, ' + result.backgroundColor.left + ', ' + result.backgroundColor.right + ')', 'bg')
bg.value = `linear-gradient(to right, ${result.backgroundColor.left}, ${result.backgroundColor.right})`
})
.catch((err) => console.error(err))
最终效果
复杂边缘效果
纵向渐变效果(这里使用的是 left 和 right 边的值,可能使用 top 和 bottom 效果更佳)
纯色效果(因为单独对边缘采样,所以无论图片内容多复杂,纯色基本看不出边界)
突变边缘效果(此时用css做渐变蒙层应该效果会更好)
横向渐变效果(使用的是 left 和 right 的色值),基本看不出边界
参考资料
- zhuanlan.zhihu.com/p/370371059
- baike.baidu.com/item/%E5%9B…
- baike.baidu.com/item/%E6%A0…
- zh.wikipedia.org/wiki/%E6%AC…
- blog.csdn.net/weixin_4256…
- zh.wikipedia.org/wiki/K-%E5%…
- blog.csdn.net/weixin_4299…
- juejin.cn/post/684490…
番外
Auto 家族的其他成员
- Auto-Plugin/autofit.js autofit.js 迄今为止最易用的自适应工具
- Auto-Plugin/autolog.js autolog.js 轻量化小弹窗
- Auto-Plugin/autouno autouno 直觉的UnoCSS预设方案
- Auto-Plugin/autohue.js 本品 一个自动提取图片主题色让图片和背景融为一体的工具
来源:juejin.cn/post/7471919714292105270
停止在TS中使用.d.ts文件
看到Matt Pocock 在 X 上的一个帖子提到不使用 .d.ts
文件的说法。
你赞同么?是否也应该把 .d.ts
文件都替换为 .ts
文件呢?
我们一起来看看~
.d.ts
文件的用途
首先,我们要澄清的是,.d.ts
文件并不是毫无用处的。
.d.ts
文件的用途主要用于为 JavaScript 代码提供类型描述。
.d.ts
文件是严格的蓝图,用于表示你的源代码可以使用的类型。最早可以追溯到2012年。其设计受到头文件、接口描述语言(IDL)、JSDoc 等实践的启发,可以被视为 TypeScript 版本的头文件。
.d.ts
文件只能包含声明,所以让我们通过一个代码示例来看看声明和实现之间的区别。假设我们有一个函数,它将两个数字相加:
// 声明 (.d.ts)
export function add(num1: number, num2: number): number;
// 实现 (.ts)
export function add(num1: number, num2: number): number {
return num1 + num2;
}
正如你所见,add
函数的实现实际上展示了加法的执行过程,并返回结果,而声明则没有。
那么 .d.ts
文件在实践中是如何使用的呢?
假设我们有一个 add
函数,分别在两个文件中存储声明和实现:add.d.ts
和 add.js
。
现在我们创建一个新文件 index.js
,它将实际使用 add
函数:
import { add } from "./x";
const result = add(1, 4);
console.log(result); // 输出:5
请注意,在这个 JS 文件中,add
函数具有类型安全性,因为函数在 add.d.ts
中被标注了类型声明。
替换方案 .ts
文件
我们已经了解了 .d.ts
文件的工作原理以及它们的用途。Matt 之所以认为不需要.d.ts
文件,是因为它也可以放在一个 .ts
文件中直接创建带有类型标注的实现。也就是说,拥有一个包含声明和实现的单个 add.ts
文件,等同于分别定义了 add.d.ts
和 add.js
文件。
这意味着你无需担心将声明文件与其对应的实现文件分开组织。
不过,针对类库,将 .d.ts
文件与编译后的 JavaScript 源代码一起使用,比存储 .ts
文件更高效,因为你真正需要的只是类型声明,以便用户在使用你的库时能够获得类型安全性。
这确实没错,需要强调的是,更推荐自动生成。通过更改 package.json
和 tsconfig.json
文件中的几个设置,从 .ts
文件自动生成 .d.ts
文件:
- tsconfig.json:确保添加
declaration: true
,以支持.d.ts
文件的生成。
{
"compilerOptions": {
"declaration": true,
"target": "ES6",
"module": "commonjs",
"outDir": "./dist",
"strict": true
},
"include": ["src/**/*"]
}
- package.json:确保将
types
属性设置为生成的.d.ts
文件,该文件位于编译后的源代码旁边。
{
"name": "stop using d.ts",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc"
}
}
结论
.d.ts
文件中可以做到的一切,都可以在 .ts
文件中完成。
在 .ts
文件中使用 declare global {}
语法时,花括号内的内容将被视为全局环境声明,这本质上就是 .d.ts
文件的工作方式。
所以即使不使用.d.ts
文件,也可以拥有全局可访问的类型。.ts
文件在功能上既可以包含代码实现,又可以包含类型声明,而且还能实现全局的类型声明,从而在开发过程中可以更加方便地管理和使用类型,避免了在.d.ts
文件和.ts
文件之间进行复杂的协调和组织,提高了开发效率和开发体验。
另外需要注意的是,在大多数项目中,开发者会在 TypeScript 的配置文件(tsconfig.json)中将 skipLibCheck 选项设置为 true。skipLibCheck 的作用是跳过对库文件(包括 .d.ts 文件)的类型检查。当设置为 true 时,TypeScript 编译器不会对这些库文件进行严格的类型检查,从而加快编译速度。但这也会影响项目中自己编写的 .d.ts 文件。这意味着,即使 .d.ts 文件中定义的类型存在错误,TypeScript 编译器也不会报错,从而失去了类型安全性的保障。
而我们直接使用 .ts
文件,就不会有这个问题了,同事手动编写 .d.ts
文件,也会更加安全和高效。
因此,.d.ts
文件确实没有必要编写。在 99% 的情况下,.ts
文件更适合使用,可以改善开发体验,并降低库代码中出现类型错误的可能性。
怎么样??你同意他的看法么?
来源:juejin.cn/post/7463817822474682418
前端适配:你一般用哪种方案?
前言
最近在公司改bug,突然发现上一个前端留下的毛病不少,页面存在各种适配问题,为此甲方爸爸时常提出宝贵意见!
你的页面是不是时常是这样:
侧边栏未收缩时:
收缩后:
这样(缩小挤成一坨):
又或是这样:
那么废话不多说,今天由我不是程序猿kk为大家讲解一些前端必备知识:适配工作。
流式布局
学会利用相对单位(例如百分比,vh或是vw),而不是只会用px一类固定单位设计布局,前言中提到的收缩后多出一大块空白,就是由于写死了宽度,例如1000px或是89vw,那么当侧边栏进行收缩,右边内容宽度还是只有89个vw,因此我们可以将其更改为100%,这样不论侧边栏是否收缩,内容都会占满屏幕的全部。
.map {
width: 100%;
height: 90vh;
position: relative;
}
rem和第三方插件
什么是rem
rem
与em不同,rem会根据html的根节点字体大小进行变换,例如1rem就是一个字体大小那么大,比如根大小font size
为12px,那么1rem即12px,大家可以在网上寻找单位换算工具进行换算(从设计稿的px到rem)或是下载相关插件例如gulp-px3rem
,这样在不同分辨率,不同缩放比的电脑下都能够轻松应对了。
使用
第三方插件,例如做移动端适配的flexible.js,lib-flexible库
,其核心原理就是rem,我们需要做的就是根据不同屏幕计算出不同的fontsize,而页面中元素都是用rem做单位,据此实现了自适应
源码:
;(function(win, lib) {
var doc = win.document;
var docEl = doc.documentElement;
var metaEl = doc.querySelector('meta[name="viewport"]');
var flexibleEl = doc.querySelector('meta[name="flexible"]');
var dpr = 0;
var scale = 0;
var tid;
var flexible = lib.flexible || (lib.flexible = {});
if (metaEl) {
console.warn('将根据已有的meta标签来设置缩放比例');
var match = metaEl.getAttribute('content').match(/initial-scale=([d.]+)/);
if (match) {
scale = parseFloat(match[1]);
dpr = parseInt(1 / scale);
}
} else if (flexibleEl) {
var content = flexibleEl.getAttribute('content');
if (content) {
var initialDpr = content.match(/initial-dpr=([d.]+)/);
var maximumDpr = content.match(/maximum-dpr=([d.]+)/);
if (initialDpr) {
dpr = parseFloat(initialDpr[1]);
scale = parseFloat((1 / dpr).toFixed(2));
}
if (maximumDpr) {
dpr = parseFloat(maximumDpr[1]);
scale = parseFloat((1 / dpr).toFixed(2));
}
}
}
if (!dpr && !scale) {
var isAndroid = win.navigator.appVersion.match(/android/gi);
var isIPhone = win.navigator.appVersion.match(/iphone/gi);
var devicePixelRatio = win.devicePixelRatio;
if (isIPhone) {
// iOS下,对于2和3的屏,用2倍的方案,其余的用1倍方案
if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {
dpr = 3;
} else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
dpr = 2;
} else {
dpr = 1;
}
} else {
// 其他设备下,仍旧使用1倍的方案
dpr = 1;
}
scale = 1 / dpr;
}
docEl.setAttribute('data-dpr', dpr);
if (!metaEl) {
metaEl = doc.createElement('meta');
metaEl.setAttribute('name', 'viewport');
metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
if (docEl.firstElementChild) {
docEl.firstElementChild.appendChild(metaEl);
} else {
var wrap = doc.createElement('div');
wrap.appendChild(metaEl);
doc.write(wrap.innerHTML);
}
}
function refreshRem(){
var width = docEl.getBoundingClientRect().width;
if (width / dpr > 540) {
width = 540 * dpr;
}
var rem = width / 10;
docEl.style.fontSize = rem + 'px';
flexible.rem = win.rem = rem;
}
win.addEventListener('resize', function() {
clearTimeout(tid);
tid = setTimeout(refreshRem, 300);
}, false);
win.addEventListener('pageshow', function(e) {
if (e.persisted) {
clearTimeout(tid);
tid = setTimeout(refreshRem, 300);
}
}, false);
if (doc.readyState === 'complete') {
doc.body.style.fontSize = 12 * dpr + 'px';
} else {
doc.addEventListener('DOMContentLoaded', function(e) {
doc.body.style.fontSize = 12 * dpr + 'px';
}, false);
}
refreshRem();
flexible.dpr = win.dpr = dpr;
flexible.refreshRem = refreshRem;
flexible.rem2px = function(d) {
var val = parseFloat(d) * this.rem;
if (typeof d === 'string' && d.match(/rem$/)) {
val += 'px';
}
return val;
}
flexible.px2rem = function(d) {
var val = parseFloat(d) / this.rem;
if (typeof d === 'string' && d.match(/px$/)) {
val += 'rem';
}
return val;
}
})(window, window['lib'] || (window['lib'] = {}));
大家如果对相关原理感兴趣,可以阅读:flexible.js如何实现rem自适应-前端开发博客
在实际开发中应用场景不同效果不同,因此不能写死px。
在PC端适配我们可以自动转换rem适配方案(postcss-pxtorem、amfe-flexible),这里以vue3+vite为例子。事实上amfe-flexible是lib-flexible的升级版。
注意: 行内样式px不会转化为rem
npm install postcss postcss-pxtorem --save-dev // 我试过了反正我报错了,版本太高 大家可以指定5.1.1
npm install postcss-pxtorem@^5.1.1
npm i amfe-flexible --save
记得在main.js中引入amfe-flexible
import "amfe-flexible"
相关配置
媒体查询
通过查询不同的宽度来执行不同的css代码,最终以达到界面的配置。
在 CSS 中使用 @media
查询来检测屏幕宽度。当屏幕宽度小于 1024px 时,增加 margin-top
以向下移动表格。
.responsive-table {
transition: margin-top 0.3s; /* 添加过渡效果 */
}
@media (max-width: 1024px) {
.responsive-table {
margin-top: 200px; /* 向下移动的距离 */
}
}
弹性布局
创建一个响应式的卡片布局,当屏幕宽度减小时,卡片会自动换行。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flexbox Example</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin: 0;
height: 100vh;
background-color: #f0f0f0;
}
.card-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
width: 90%;
}
.card {
background-color: white;
border: 1px solid #ccc;
border-radius: 5px;
padding: 20px;
margin: 10px;
flex: 1 1 300px; /* 基于300px,允许增长和收缩 */
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: transform 0.3s;
}
.card:hover {
transform: translateY(-5px);
}
</style>
</head>
<body>
<div class="card-container">
<div class="card">Card 1</div>
<div class="card">Card 2</div>
<div class="card">Card 3</div>
<div class="card">Card 4</div>
<div class="card">Card 5</div>
</div>
</body>
</html>
小结
还是多提一嘴,应该不会有小伙伴把字体大小的单位也用rem吧?
来源:juejin.cn/post/7431999862919446539
制作一个页面时,需要兼容PC端和手机端,你是要分别做两个页面还是只做一个页面自适应?为什么?说说你的理由
在制作一个页面时,如何兼容PC端和手机端是一个重要的设计决策。这个决策通常有两个选择:分别制作两个页面,或者只制作一个自适应页面。以下是我选择后者的理由,以及如何实现自适应设计的相关内容。
选择自适应设计的理由
- 提高开发效率
制作一个自适应页面可以显著提高开发效率。开发者只需编写一次代码,就可以在不同设备上展示同样的内容,而不必为每个设备维护多个代码库。这意味着在后续的更新和维护中,相比于维护两个独立的页面,自适应页面的工作量会大幅减少。 - 一致的用户体验
用户在不同设备上访问同一网站时,能够获得一致的用户体验是极其重要的。自适应设计可以确保在不同屏幕尺寸和分辨率下,用户看到的内容和布局尽可能一致,避免用户在不同设备上获取不同信息的困惑。 - SEO优化
使用单一的自适应页面有助于SEO优化。搜索引擎更倾向于索引和排名内容一致的网站,而不是将相同内容分散在多个页面上。使用自适应设计可以集中流量,提高网站在搜索引擎中的权重。 - 成本效益
维护两个独立的页面不仅需要更多的时间和人力成本,还可能导致代码重复,增加出错的可能性。自适应设计能够通过一个代码库降低开发和维护成本。 - 响应式设计的灵活性
现代的 CSS 和 JavaScript 技术(例如 Flexbox 和 CSS Grid)使得创建自适应布局变得更加简单和灵活。媒体查询(Media Queries)可以帮助开发者根据设备的特性(如宽度、高度、分辨率)调整布局和样式,从而提供最佳的用户体验。
如何实现自适应设计
- 使用媒体查询
媒体查询是实现自适应设计的核心。它允许你根据设备的屏幕尺寸和特性应用不同的样式。例如:
/* 默认样式 */
.container {
width: 100%;
padding: 20px;
}
/* 针对手机的样式 */
@media (max-width: 600px) {
.container {
padding: 10px;
}
}
/* 针对平板的样式 */
@media (min-width: 601px) and (max-width: 900px) {
.container {
padding: 15px;
}
}
- 使用流式布局
使用流式布局可以使元素在不同的屏幕上根据可用空间自动调整大小。例如,使用百分比而不是固定像素值来设置宽度:
.box {
width: 50%; /* 宽度为父容器的一半 */
height: auto; /* 高度自动适应内容 */
}
- 灵活的图片和媒体
为了确保图片和视频在不同设备上显示良好,使用max-width: 100%
来确保媒体不会超出其容器的宽度:
img {
max-width: 100%;
height: auto; /* 保持图片的纵横比 */
}
- 测试和优化
在开发完成后,确保在不同设备和浏览器上进行测试。可以使用 Chrome 的开发者工具模拟不同的设备,以检查自适应设计的效果。同时,收集用户反馈并进行必要的优化。
总结
在制作兼容PC端和手机端的页面时,选择制作一个自适应页面是更为高效和经济的方案。它不仅能提高开发效率和维护成本,还能提供一致的用户体验,增强SEO效果。此外,现代的CSS技术使得实现自适应设计变得更加简单。因此,采用自适应设计是现代Web开发的最佳实践之一。
来源:juejin.cn/post/7476010111887949861
别让这6个UI设计雷区毁了你的APP!
一款成功的APP不仅仅取决于其功能性,更取决于用户体验,这其中,UI设计又至关重要。优秀的UI设计能够为用户带来直观、愉悦的交互体验,甚至让用户“一见钟情”,从而大大提高产品吸引力。
然而,有很多设计师在追求创新与美观的同时,往往会不经意间“踩雷”,忽略了一些普通用户更在意的问题和痛点。本文,笔者将谈谈UI设计中的常见误区,同时展示了优秀的UI设计例子,还分享一些行业内推荐的设计工具,希望能助你在设计的道路上更加得心应手~
UI设计常见误区
1、过度设计
设计大师 Dieter Rams 说过一句话:“好的设计是尽可能简单的设计。”
不过,说起来容易做起来难。产品经理和设计师总是不自觉地让产品过于复杂,今天想在产品中再添加一个功能,明天想在页面上再增加一个元素。
尤其是新手设计师,总希望通过增加视觉元素、动画效果和复杂的交互来提升用户体验。然而,这种做法往往适得其反,一个简洁、清晰且直观的界面一定比一个装饰过多、功能复杂的界面更能吸引用户。设计师应该专注于APP的核心功能,避免不必要的元素,确保设计美观又实用。
简约风接单APP界面
http://www.mockplus.cn/example/rp/…
2、忽视用户反馈
有时候,设计师可能过于相信自己的设计,忽略了用户的意见和反馈。这种情况下,即使设计再怎么创新或美观,如果不符合用户的实际需求和使用习惯,也难以获得用户的认可。
毕竟,UI设计不是艺术创作,用户反馈是设计迭代过程中的宝贵资源,它可以帮助设计师了解用户的真实体验,了解设计的不足,从而进行针对性的优化改进。
FARFETCH APP界面
http://www.mockplus.cn/example/rp/…
3、色彩搭配不合适
色彩搭配不合适是UI设计中一个常见的误区。有的设计师会自动倾向于选择那些自己喜欢的颜色,而不是基于用户的偏好和产品特征、背景等来选择颜色。有时,颜色的过度使用或不恰当的搭配会分散用户的注意力,甚至造成视觉疲劳。
另外,为了让用户能看清UI设计的各个方面,需要有足够的对比度。这就是为什么黑色文字配上白色背景效果那么好 —— 文字非常清晰,易于理解。
插画风APP界面
http://www.mockplus.cn/example/rp/…
4、忽略可访问性
对一个APP来说,所有用户,无论是有视觉障碍的人还是老年用户,都应该能够轻松地使用APP。设计如果不考虑这些用户群体的需要,会无意中排除一大部分潜在用户。
为了提高APP的可访问性,设计师应该考虑到字体大小、颜色对比度、语音读出功能等,确保所有用户都能舒适地使用。
社交类APP界面
http://www.mockplus.cn/example/rp/…
5、布局空滤不全面
有的产品界面过多,布局考虑不够全面几乎是常常发生的事。布局不合适有很多种情况,比如元素过于密集、排版杂乱、组件没有对齐、文本大小间距不合适等等。这种情况会导致信息展示不清晰或用户操作不便,非常影响用户的使用体验和效率。
一个美观舒适的布局应该合理利用空间,确保信息的层次清晰,充分考虑元素的大小和间距,让界面既清晰又易于操作,用户可以轻松地找到他们关心的内容。
想要避免这一误区的,在设计初期就需要考虑用户的需求和行为模式,不断迭代优化布局设计,找到最佳的解决方案。
加密货币钱包APP界面
http://www.mockplus.cn/example/rp/…
了解了上述UI设计的常见误区后,接下来是选择合适的设计工具来提升设计效率和质量。目前,市面上有很多优秀的UI设计工具,但以下几款因其强大的功能和良好的用户体验受到设计师的广泛推荐,一起来看看!
UI工具推荐
1、摹客 DT
摹客DT(http://www.mockplus.cn/dt)是一款国内很多UI设计师都爱用的工具,它提供了一整套完整的专业矢量编辑功能,丰富的图层样式选项,可以轻松创建高质量的App设计,并且还能在线实时协同,搞定团队资产管理难题不是问题。
主要功能点和亮点:
1)所有功能完全免费(参与它们的小活动还能永久免费使用),包含所有高级功能、导出能力等;
2)颜色、文本样式、图层样式都可以保存为资源,复用到其他设计元素;
3)支持导出SVG、JPG、PNG、webP、PDF格式文件,适配不同使用场景;
4)具有完善的团队协作和项目管理能力,改变传统设计流程,降低成本,提升效率。
**价格:**完全免费
**学习难度:**简单,新手上手无难度
**使用环境:**Web/客户端/Android/iOS
**推荐理由:**摹客DT,是一款更适合中国设计师的UI/UX设计工具。与一些老牌设计工具(Photoshop、XD等),它的学习门槛更低,上手更简单,社区和实时客户支持更迅速友好;作为Web端工具,摹客DT支持多人实时编辑,大大提高了团队设计效率。
推荐评级:⭐⭐⭐⭐⭐
2、Figma
Figma(http://www.figma.com/)是现在最流行的UI设…
主要功能点及亮点:
1)丰富的功能和工具:提供了全面的工具和功能来设计界面,包括矢量图形工具、网格和布局指导等。
2)设计组件和样式库:支持创建可复用的设计组件和样式库,节省时间和工作量。
3)插件生态系统:有丰富的插件生态系统,可以通过插件扩展功能,增加额外的设计工具和集成,以满足特定需求。
**价格:**提供免费版和付费版(12美元/月起)
**学习难度:**对新手相对友好,操作简单。
**使用环境:**Figma是基于Web的平台,通过浏览器即可使用。
推荐理由:
Figma设计功能强大,提供了丰富的插件和集成,还有有一个庞大的社区交流学习,是一款非常适合团队协作的工具。遗憾的是,Figma作为一款国外设计工具,在国内使用还是存在一定的劣势,比如网络访问限制、数据隐私和安全、本地化支持等,所以只能给到四星。
推荐评级:⭐⭐⭐⭐
3、Sketch
Sketch(http://www.sketch.com/)是一款专业的UI/U…
主要功能及亮点:
- 1)矢量编辑:直观的矢量编辑工具和可编辑的布尔运算,支持形状绘制、路径编辑、填充和描边等常见的矢量操作,使用户能够轻松创建和编辑各种设计元素。
- 2)设计规范库:设计师可以在sketch中把常用的颜色、图层样式存为规范,也可以将已经设计好的矢量图标存到规范库,在整个设计中复用它们,提高工作效率。
3)Sketch支持众多插件和第三方资源,使得其生态系统非常丰富,能够更好地满足设计师的需求。
**价格:**标准订阅 12/月/人(按月付费)
**使用环境:**macOS操作系统
推荐理由:
Sketch被广泛应用于移动应用、网页设计和用户界面设计等领域。它友好的界面、丰富的设计资源使设计师们能够快速创建出符合良好用户体验和视觉效果要求的设计。可惜的是,它只支持macOS系统,限制了部分用户使用。
**推荐评级:**⭐⭐⭐⭐
4、Adobe XD
Adobe XD(helpx.adobe.com/support/xd.…
主要功能及亮点:
1)丰富的设计和布局功能:设计师可以借助这些功能,轻松创建响应式设计,预览在不同设备上的效果,确保设计的一致性和灵活性。
2)丰富的交互动画和过渡效果:可以创建十分流畅的交互体验,用来更好地展示APP或网站的交互逻辑。
3)共享和协作:支持团队协作,用户可以轻松地共享设计文件,协同编辑,以及通过实时协作功能进行设计评审。
**价格:**提供免费试用,提供付费订阅 $9.99/月
**学习难度:**中
**使用环境:**Windows、macOS
**推荐理由:**Adobe XD与Adobe Creative Cloud紧密集成,十分方便同时使用Adobe其他软件的用户。不过有个问题是,Adobe xd已经官宣停止更新,只向老用户提供服务了,如果是之前没有使用Adobe XD的用户,最好选择其他产品。
**推荐评级:**⭐️⭐️⭐️
五、Principle
Principle是一款专门用于UI/UX设计的软件,它的设计理念是想要让设计师能够轻松创建出高质量、富有吸引力的产品界面效果。
主要功能及亮点:
1)高级动画控制:软件支持创建复杂的动画效果,包括过渡、转换和多状态动画。这对于展示复杂的交互过程和动态效果非常有用。
2)实时预览:Principle允许设计师实时预览他们的工作成果,这意味着可以即时查看和测试动画效果,确保设计的交互体验符合预期。
3)组件和重复使用:设计师可以创建可重复使用的组件,这大大提高了工作效率,特别是在处理具有相似元素或布局的多个界面时。
价格:$129
**学习难度:**中
**使用环境:**MacOS
推荐理由:
设计师通过Principle可以快速地制作出具有丰富交互的产品界面设计,并且它能和Sketch进行无缝链接,可以快速导入你在Sketch里已经做好的图,
推荐评级:⭐️⭐️⭐️⭐️
好的UI设计不仅仅是视觉上的享受,更是能让用户能够轻松、愉快地使用APP。避免上述UI设计误区,选择合适的设计工具,可以帮助我们设计出既美观又实用的产品。
希望本文章能为UI设计师/准UI设计师们有所帮助,创作出更多优秀的设计作品~
看到这里的读者朋友有福啦!本人吐血搜集整理,全网最全产品设计学习资料!
只要花1分钟填写**问卷**就能免费领取以下超值礼包:
1、产品经理必读的100本书 包含:产品思维、大厂案例、技能提升、数据分析、项目管理等各类经典书籍,从产品入门到精通一网打尽! 2、UI/UX设计师必读的115本书 包含:UI/UX设计、平面设计、版式设计、设计心理学、设计思维等各类经典书籍,看完你就是设计大神! 3、30G互联网人知识礼包 包含:
- 10GPM礼包,产品案例、大咖分享、行业报告等应有尽有
- 10GUI/UE资源,优秀设计案例、资料包、源文件免费领
- 5G运营资料包,超全产品、电商、新媒体、活动等运营技能
- 5G职场/营销资料包,包含产品设计求职面试、营销增长等
4、50G热门流行的AI学习大礼包
包含:AI绘画、AIGC精选课程、AI职场实用教程等
5、30G职场必备技能包
包含:精选PPT模板、免费可商用字体包、各岗位能力模型、Excel学习资料、求职面试、升职加薪、职场写作和沟通等实用资源。
礼包资源持续更新,互联网行业知识一网打尽!礼包领取地址:
来源:juejin.cn/post/7356535808931627046
后端:没空,先自己 mock 去
前言
后端开发忙,不给你接口?
后端抱怨你在测试过程中,频繁的给脏数据?
后端修个接口很慢没法测试?

有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!
后端开发忙,不给你接口?
后端抱怨你在测试过程中,频繁的给脏数据?
后端修个接口很慢没法测试?
有了 mockjs ,这些问题将迎刃而解。不要 998,pnpm i 带回家!
真这么丝滑?
请看我的使用方式:
当后端接口无法满足要求,且不能及时更改时。例如后端返回
{
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
}
}
],
}
但我此时希望增加一个 user_type
来确定页面的展示。
那我就直接起一个文件:user.js
,把刚才的响应 copy 过来,并追加改动
myMock('/api/v1/user', 'post', () => {
return {
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
"user_type": "admin",
}
}
],
}
});
如此一来,这个请求就被无缝替换为了我们的 mock,可以随便测试了。
请看我的使用方式:
当后端接口无法满足要求,且不能及时更改时。例如后端返回
{
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
}
}
],
}
但我此时希望增加一个 user_type
来确定页面的展示。
那我就直接起一个文件:user.js
,把刚才的响应 copy 过来,并追加改动
myMock('/api/v1/user', 'post', () => {
return {
"err_no": 0,
"err_msg": "success",
"data": [
{
"comment_id": "7337487924836287242",
"user_info": {
"user_name": "陈陈陈_",
"user_type": "admin",
}
}
],
}
});
如此一来,这个请求就被无缝替换为了我们的 mock,可以随便测试了。
如何接入 mockjs
有的同学就要问了,主播主播,你的 mockjs 确实很方便,怎么接入比较好呀。别急,我们一步一步来
- 安装 mockjs
pnpm i mockjs
如果是使用 ts 的同学,可能需要额外安装 type 类型包:@types/mockjs
- 新建一个 mock 文件夹,在 mock/index.ts 放入基本路径
// 各种 mock 的文件,视条件而定,我这里有俩文件就引入了俩
import './login/user.js';
import './model/model.js';
有的同学就要问了,主播主播,你的 mockjs 确实很方便,怎么接入比较好呀。别急,我们一步一步来
- 安装 mockjs
pnpm i mockjs
如果是使用 ts 的同学,可能需要额外安装 type 类型包:@types/mockjs
- 新建一个 mock 文件夹,在 mock/index.ts 放入基本路径
// 各种 mock 的文件,视条件而定,我这里有俩文件就引入了俩
import './login/user.js';
import './model/model.js';
并且在你的项目入口 ts 中引入 mock/index.ts
import './mock/index'; // 引入 mock 配置
- 导出一个 myMock 方法,并追加一个 baseUrl 方便直接联动你的 axios
import { ENV_TEST } from '@/api/config/interceptor';
import Mock from 'mockjs';
export const myMock = (
path: string,
method: 'get' | 'post',
callback: (options: any) => any
) => {
Mock.mock(`${ENV_TEST}${path}`, method, callback);
};
如此一来,你就可以在 mock 文件夹下去搞了,比如:
我想新增一个服务模块的各类接口的 mock,那么我就新增一个 service 文件夹,在其下增加一个 index.ts,并对对应路径进行 mock
myMock('/api/v1/service', 'get', () => {
return {
code: 0,
msg: 'hello service',
data: null,
};
});
另外,别忘了在 mock/index.ts 引入文件
不显示在 network 中?
需要说明的是,这样走 mock 是不会触发真正的请求的,相当于 xhr 直接被 mock 拦截了下来并给了你返回值。所以你无法在 network 中看到你的请求。
这是个痛点,目前比较好的解决方案还是起一个单独的服务来 mock。但这样也就意味着,需要另起一个项目来单独做 mock,太不优雅了。
有没有什么办法,既可以走上述简单的mock,又可以在需要的时候起一个服务来查看 network,并且不需要额外维护两套配置呢?
有的兄弟,有的。
import express from 'express';
import bodyParser from 'body-parser';
import Mock from 'mockjs';
import './login/user.js';
import './model/model.js';
import { ENV_TEST } from './utils/index.js';
const app = express();
const port = 3010;
// 使用中间件处理请求体和CORS
app.use(bodyParser.json());
// 设置CORS头部
app.use(( _ , res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
next();
});
// 设置Mock路由的函数
const setupMockRoutes = () => {
const mockApis = Mock._mocked || {};
// 遍历每个Mock API,并生成对应的路由
Object.keys(mockApis).forEach((key) => {
const { rurl, rtype, template } = mockApis[key];
const route = rurl.replace(ENV_TEST, ''); // 去掉环境前缀
// 根据请求类型(GET, POST, 等)设置路由
app[rtype.toLowerCase()](route, (req, res) => {
const data =
typeof template === 'function' ? template(req.body || {}) : template;
res.json(Mock.mock(data)); // 返回模拟数据
});
});
};
// 设置Mock API路由
setupMockRoutes();
// 启动服务器
app.listen(port, () => {
process.env.NODE_ENV = 'mock'; // 设置环境变量
console.log(`Mock 服务已启动,访问地址: http://localhost:${port}`);
});
直接在 mock 文件夹下追加这个启动文件,当你需要看 network 的时候,将环境切换为 mock 环境即可。本质是利用了 Mock._mocked
可以拿到所有注册项,并用 express 起了一个后端服务响应这些注册项来实现的。
在拥有了这个能力的基础上,我们就可以调整我们的命令
"scripts": {
"dev": "cross-env NODE_ENV=test vite",
"mock": "cross-env NODE_ENV=mock vite & node ./src/mock/app.js"
},
顺便贴一下我的 env 配置:
export const ENV_TEST = 'https://api-ai.com/fuxi';
export const ENV_MOCK = 'http://localhost:3010/';
let baseURL: string = ENV_TEST;
console.log('目前环境为:' + process.env.NODE_ENV);
switch (process.env.NODE_ENV) {
case 'mock':
baseURL = ENV_MOCK;
break;
case 'test':
baseURL = ENV_TEST;
break;
case 'production':
break;
default:
baseURL = ENV_TEST;
break;
}
export { baseURL };
这样一来,如果你需要看 network ,就 pnpm mock,如果不需要,就直接 pnpm dev,完全不需要其他心智负担。
三个字:
参数相关
具体的 api 可查阅:github.com/nuysoft/Moc… 相关的文章也非常多,就不展开说明了。
如果这篇文章对你有帮助,不妨点个赞吧~
来源:juejin.cn/post/7460091261762125865
官方回应无虚拟DOM版Vue为什么叫Vapor
相信很多人和我一样,好奇无虚拟DOM
版的Vue
为什么叫Vue Vapor
。之前看过一个很新颖的观点:Vue1
时代就没有虚拟DOM
,而那个时代恰好处于前端界的第一次工业革命,也就是以声明式代替命令式的写法。所以无虚拟DOM
版Vue让人感觉梦回Vue1
,于是就采取了Vapor
这个名字。
Vapor是蒸汽的意思,第一次工业革命开创了以机器代替手工劳动的时代,该革命以蒸汽机作为动力机被广泛使用为标志。
不过这个说法并非来自官方,虽然乍一听还挺有道理的,但总感觉官方并不一定是这么想的。事实也的确如此,在官方的Vue Conf
中,Vue Vapor
的作者出面说明了Vapor
这个名字的含义:
由于无虚拟DOM
的特性,纯Vapor
模式下可以去掉很多代码,比如VDom Diff
。所以Vue Vapor
的包体积可以做的更加的轻量化,像水蒸气一样轻。
(前面那段话是官方说的,这段话是我说的)当然不是说
Vapor
模式就不需要diff
算法了,我看过同为无虚拟DOM
框架的Svelte
和Solid
源码,无虚拟DOM
只是不需要vDom
间的Diff
算法了,列表之间还是需要diff
的。毕竟再怎么编译,你从后端获取到的数组,编译器也不可能预测得到。
那具体能轻量多少呢?官方给出的数据是比虚拟DOM
版Vue
小33.6%
:
Vapor
的名字除了想表示轻量化之外还有一个另外一个原因,那就是Solid
。可能有人会说这关Solid
什么事啊?实际上Vapor
的灵感正是来自于Solid
(尤雨溪亲口承认)而Solid
代表固体:
为了跟Solid
有个趣味联动,那无虚拟DOM
就是气体好了:
以上就是Vue Vapor
作者告诉大家为什么叫Vapor
的两大原因。
性能
之前都说虚拟DOM
是为了性能,怎么现在又反向宣传了?无虚拟DOM
怎么又逆流而上成为了性能的标杆了呢?这个话题我们留到下一篇文章去讲,本篇文章我们只看数据:
从左到右依次为:
- 原生
JS
:1.01 Solid
:1.09Svelte
:1.11- 无虚拟
DOM
版Vue
:1.24 - 虚拟
DOM
版Vue
:1.32 React
:1.55
数字越小代表性能越高,但无论再怎么高都不可能高的过手动优化过的原生JS
,毕竟无论什么框架最终打包编译出来的还是JS
。不过框架的性能其实已经挺接近原生的了,尤其是以无虚拟DOM
著称的Solid
和Svelte
。但无虚拟DOM
版Vue
和虚拟DOM
版Vue
之间并没有拉开什么很大的差距,1.24
和1.32
这两个数字证明了其实二者的性能差距并不明显,这又是怎么一回事呢?
一个原因是Vue3
本来就做了许多编译优化,包括但不限于静态提升、大段静态片段将采用innerHTML
渲染、编译时打标记以帮助虚拟DOM
走捷径等… 由于本来的性能就已经不错了,所以可提升的空间自然也没多少。
看完上述原因肯定有人会说:可提升的空间没多少可以理解,那为什么还比同为无虚拟DOM
的Solid
和Svelte
差那么多?如果Vapor
和Solid
性能差不多的话,那可提升空间小倒是说得过去。但要是像现在这样差的还挺多的话,那这个理由是不是就有点站不住脚了?之所以会出现这样的情况是因为Vue Vapor
的作者在性能优化和快速实现之间选择了后者,毕竟尤雨溪第一次公布要开始做无虚拟DOM
版Vue
的时间是在2020
年:
而如今已经是2025
年了。也就是说如果一个大一新生第一次满心欢喜的听到无虚拟DOM
版Vue
的消息后,那么他现在大概率已经开始毕业工作了都没能等到无虚拟DOM
版Vue
的发布。这个时间拖的有点太长了,甚至从Vue2
到Vue3
都没用这么久。
所以现在的首要目标是先实现出来一版能用的,后面再慢慢进行优化。我们工作不也是这样么?先优先把功能做出来,优化的事以后再说。那么具体该怎么优化呢:
现在的很多渲染逻辑继承自原版Vue3
,但无虚拟DOM
版Vue
可以采用更优的渲染逻辑。而且现在的Vapor
是跟着原版Vue
的测试集来做的,这也是为了实现和原版Vue
一样的行为。但这可能并不是最优解,之后会慢慢去掉一些不必要的功能,尤其是对性能产生较大影响的功能。
往期精彩文章:
来源:juejin.cn/post/7477104460452872202
Netflix 删除了 React ?
Netflix 删除了 React
"Netflix 删除了 React,网站加载时间减少了 50%!"
这条爆炸性新闻在推特炸开了锅。这不禁让人想起半年前"微软应该禁止所有新项目使用 React" 的新闻 - 两大科技巨头相继 "抛弃" React,难道这预示着什么?
"React 心智负担重、性能差、bundle 体积大...". 一时间,各种唱衰 React 的声音此起彼伏。有人甚至信誓旦旦地表示:"只要像 Netflix 一样干掉 React,你的网站性能立马提升 50%!"
这条发布于 2024 年 10 月 27 日的推文有着惊人的 150 万浏览量。但是,这大概率是 AI
生成的假新闻。
事实上,我们去 Netflix
的官网打开 react-devtools
,发现他们依然在使用 React
构建他们的网站。
Netflix 的真实案例
这篇 AI
生成的假新闻灵感来自 2017 年 Netflix
工程师在 hack news
上发布的一篇文章 - Netflix: Removing client-side React.js improved performance by 50%
他直接移除了这篇文章最重要的部分 - client-side React.js
, 也就是客户端的 React.js
代码。
实际的情况是,Netflix
团队在 2017 年的时候在使用 React
构建他们的 landing page
。
为什么在一个简单的 landing page
上要使用 React
呢?因为在 landing page
上
Netflix
需要处理大量的AB 测试
- 支持近 200 个国家的本地化
- 根据用户设备、地理位置等因素动态调整内容
- 需要服用现有的
React
组件
基于上述需求的考虑,Netflix
团队选择了使用 React
来构建他们的 landing page
。
为了优化页面加载性能,他们采用了服务端渲染的方案。同时,为了保证后续交互的流畅性,系统会预先加载(pre-fetch
)后续流程所需的 React/Redux
相关代码。
从架构上看,这个 landing page
本质上仍然是一个单页面应用(SPA
),保持了 SPA
快速响应的优势。不同之处在于首次访问时,页面内容是由服务端直接生成的,这样可以显著提升首屏加载速度。
这样做的缺点
显然,Netflix
在 2017 年这么做是有原因的,他们当时的确也没有更好的方案。在 2025 年的今天,
再来回顾这个方案,显然有以下缺点:
数据重复获取
在首屏渲染时,服务端需要获取数据来生成 HTML,而在客户端激活(hydration)后,为了保持交互性,往往又需要重新获取一遍相同的数据。
这种重复的数据获取不仅浪费资源,还可能带来不必要的性能开销。
客户端代码体积膨胀
因为本质上,Netflix
的 landing page
是一个还是一个 SPA
,那么不可避免的,所有可能的 UI
状态都需要打包,
即使用户只需要其中的一部分代码。例如,在一个很常见的 tabs
页面
<Tabs
defaultActiveKey="1"
items={[
{
label: 'Tab 1',
key: '1',
children: 'Tab 1',
},
{
label: 'Tab 2',
key: '2',
children: 'Tab 2',
disabled: true,
},
]}
/>
即使用户只点击了 Tab 1
, 即使 Tab 2
没有被渲染,但是 Tab 2
的代码也会被打包。
如何解决这些问题
React Server Components (RSC)
为上述问题提供了优雅的解决方案:
避免数据重复获取
使用 RSC
,组件可以直接在服务器端获取数据并渲染,客户端只接收最终的 HTML
结果。不再需要在客户端重新获取数据。
智能代码分割
RSC
允许我们选择性地决定哪些组件在服务器端运行,哪些在客户端运行。例如:
function TabContent({ tab }: { tab: string }) {
// 这部分代码只在服务器端运行,不会打包到客户端
return <div>{tab} 内容</div>
}
// 客户端组件
'use client'
function TabWrapper({ children }) {
const [activeTab, setActiveTab] = useState('1')
return (
<div>
{/* Tab 切换逻辑 */}
{children}
</div>
)
}
在这个例子中:
TabContent
的所有可能状态都在服务器端预渲染- 只有实际需要交互的
TabWrapper
会发送到客户端 - 用户获得了更小的
bundle
体积和更快的加载速度
这不就是 PHP?
经常会看到有人说:"Server Components 不就是重新发明了 PHP 吗?都是在服务端生成 HTML。"
显然,PHP 与现在的 Server Components
在开发体验上有本质的区别。
1. 细粒度的服务端-客户端混合
与 PHP 不同,RSC 允许我们在组件级别决定渲染位置,用一个购物车的例子来说明:
// 服务端组件
function ProductDetails({ id }: { id: string }) {
// 在服务器端获取数据和渲染
const product = await db.products.get(id);
return <div>{product.name}</div>;
}
// 客户端组件
'use client'
function AddToCart({ productId }: { productId: string }) {
// 在客户端处理交互
return <button onClick={() => addToCart(productId)}>加入购物车</button>;
}
// 混合使用
function ProductCard({ id }: { id: string }) {
return (
<div>
<ProductDetails id={id} />
<AddToCart productId={id} />
</div>
);
}
这种设计充分利用了服务端和客户端各自的优势 - 在服务端可以直接访问数据库获取 ProductDetails
所需的数据,而在客户端则能更好地处理 AddToCart
这样的用户交互。这不仅提升了性能,也让代码结构更加清晰合理。
2. 保持组件的可复用性
RSC
最强大的特性之一是组件的可复用性不受渲染位置的影响:
// 这个组件可以在服务端渲染
function UserProfile({ id }: { id: string }) {
return <ProfileCard id={id} />;
}
// 同样的组件也可以在客户端动态加载
'use client'
function UserList() {
const [selectedId, setSelectedId] = useState(null);
return selectedId ? <ProfileCard id={selectedId} /> : null;
}
因为都是 React
组件,区别仅仅是渲染位置的不同,同一个组件可以:
- 在服务端预渲染时使用
- 在客户端动态加载时使用
- 在流式渲染中使用
这种统一的组件模型是 PHP 等传统服务端渲染所不具备的。
3. 智能的序列化
RSC
还提供了智能的序列化机制,可以自动将组件的 props
和 state
序列化,从而在服务端和客户端之间传递。
避免了重复获取数据的问题。
// 服务端组件
async function Comments({ postId }: { postId: string }) {
// 1. 获取评论数据
const comments = await db.comments.list(postId);
// 2. 传递给客户端组件
return <CommentList initialComments={comments} />;
}
// 客户端组件
'use client'
function CommentList({ initialComments }) {
// 3. 直接使用服务端数据,无需重新请求
const [comments, setComments] = useState(initialComments);
return (
// 渲染评论列表
);
}
4. 渐进式增强
RSC
还提供了渐进式增强的能力,可以在服务端和客户端之间无缝过渡。
- 首次访问时返回完整的 HTML
- 按需加载客户端交互代码
- 保持应用的可访问性
这让我们能够构建既有良好首屏体验,又能提供丰富交互的现代应用,完美解决了在 2017 年 Netflix
所提出的问题。
总结
通过对上面这些案例的分析,我们可以看出
1. 不要轻信网络传言
网络上充斥着各种技术传言。虽然像上面这种完全虚构的假新闻容易识破,但有些传言会巧妙地利用真实数据和案例,通过夸张的描述来误导技术选型和决策,这类信息需要我们格外谨慎分辨。
例如:
svelte 放弃 TypeScript 改用 JSdoc 进行类型检查
这个确实是一个真的新闻,但是并不代表着 Typescript
的没落,实际上
- Svelte 团队选择 JSDoc 是为了减少编译时间
- 这是针对框架源码的优化,而不是面向使用者的建议
- Svelte 依然完整支持 TypeScript,用户代码可以继续使用 .ts/.ts
tauri 打包后的体积比 electron 小多了,我们应该放弃 electron 使用 tauri
技术选型不能仅仅看单一指标。虽然 tauri
的打包体积确实小于 electron
,但在开发体验、性能、稳定性、生态和社区支持等关键维度上都存在明显短板。
如果你尝试用 tauri
开发复杂应用,很可能会因为生态不完善、社区支持不足而陷入困境。当你遇到问题去 GitHub
寻找解决方案时,看到许多库已经一年未更新,就会明白为什么大多数团队仍在选择 electron
。
2. 历史的选择
2017 年的 Netflix 面临着复杂的业务需求,他们选择了当时最佳的解决方案 - 服务端渲染 + 客户端激活。这个方案虽然解决了问题,但也带来了一些困扰:
- 数据需要在服务端和客户端重复获取
- JavaScript bundle 体积过大
3. RSC 带来的改变
React Server Components
为这些历史遗留问题带来了全新的解决思路:
- 服务端渲染与客户端渲染完美融合
- 智能的代码分割,最小化客户端 bundle 体积
- 数据获取更高效,避免重复请求
- 渐进式增强,提供流畅的用户体验
4. 技术演进的启示
从 Netflix
2017 年的实践到今天的 RSC
,我们可以看到:
- 技术方案在不断进化,过去的最佳实践可能已不再适用
- RSC 不是简单的"回归服务端",而是开创了全新的开发模式
- 性能与开发体验不再是非此即彼的选择
RSC
代表了现代前端开发的新趋势 - 既保持了 React
强大的组件化能力,又通过创新的架构设计解决了历史难题。这让我们终于可以在性能和开发体验之间找到完美平衡。
来源:juejin.cn/post/7459029441039794211
一次失败的UI规范制定
前言
在公司中,前端使用了统一的组件element-ui,但是有一些页面大家并没有达成共识,造成了不同的团队开发出来的页面不同,公司决定在24年8月的迭代进行统一调整。在这个中间,我遇到了很多意料之外的问题,希望未来的你遇到了类似的问题,可以尽量避免
为什么会产生这个问题
这个问题我也思考过,大概有以下原因
- 我们有4个产品经理,每个人负责不同的模块,都有各自的风格,造成页面不统一
- 没有一个严格的UI规范,前端开发和测试并没有一个可以参考标准页面
- 22年至今,项目都在疯狂的迭代功能,并没有人停下来看看之前做的功能有哪些问题,我们该怎么去优化
项目背景
参与人:UI设计师、前端开发、产品(主要负责审核,并没参与讨论)、测试
牵头人:UI设计师
职责:找出问题点,整理为在线文档
解决者:前端
职责:整理问题点、改公告组件、输出文档
主要问题如下
- 弹窗(Dialog)组件大小不一。宽度有400px、700px、800px、940px、1250px、50%、60%等,用户在操作的时候,会感觉到页面时大时小,不统一
- 表格样式很多(Table)。搜索的列未对齐、分页码数未统一、表格后的操作栏千奇百怪、表头的标题有的进行了换行等
- 颜色的乱用。颜色有很多,有各种颜色的红色
- 弹窗表单的按钮位置不同。有的取消在左,有的取消在右面。
- 等等一些小问题就不一一列举了
弹窗组件大小不一
弹窗大小不统一部分截图
800px
600px
1180px
解决方案
我们在私服中clone了一份element-ui,直接修改了源码
默认制定了三个尺寸的size,small(600px), medium(900px),large(1200px),满足基本的需求。当需要支持特殊组件的时候,也可以直接设置Width满足需求
表格不统一
部分截图
上方的截图有几个问题
- 搜索条件(查找人员)没有和新增按钮对齐
- 离职和删除的按钮是比较敏感的操作,但是夹在了一堆按钮中间
- 操作按钮有的有icon,有的没icon,看着些许的混乱
进行修改后效果如下,页面看着更加的工整
解决方案如下
- 搜索条件的第一个在组件内直接进行计算宽度,防止业务端进行二次修改,如果是自定义的搜索条件,并且设置了宽度,需要手动进行修改
- 一些危险的操作:比如离职、删除统一放在最后,并且每个操作按钮都要有icon
表格按钮的调整
调整前
调整后
解决方案如下
表格后的按钮去除所有的icon,直接在组件内进行拦截,默认展示三个按钮,多余的放在dropDown内,当然也支持业务端设置显示几个按钮
核心部分代码如下
分页数据不统一
调整前
调整后
解决方案
分页条数统一改为(20,50,100)
考虑的因素为:之前的页面有15一页的。当页面过高的时候,会撑不满一个页面,造成table的页面空白,不太美观
弹窗中,下方的操作栏的按钮位置不统一
调整前
调整后
解决方案
所有的按钮都是取消在左,保存在右面,同时,confirm组件,在element-ui的代码中直接修改调整位置,减少业务端工作量
颜色的乱用
部分截图
解决方案
在主应用的根节点 使用css变量,在每个子应用的页面直接使用根节点的样式,避免子应用的颜色出现各种奇奇怪怪的颜色。
使用的地方
等等
当然还有一些基础颜色的修改、按钮最小宽度的修改、一些表格间距的调整等。这些和设计师、产品达成共识后,进行修改就行了,也就不一一列举了
交付给测试
- 我这边开发的差不多了,输出了一个文档,别的前端开发按照着文档进行开发。
- 测试按照文档进行编写测试用例
不好搞了
测试这边疯狂提bug。
还有一个小小的背景
测试这边其实是有一个绩效考核:bug提的越多,绩效越高
但是,我们这边项目上线的时候,其实还有一个要求,bug不能超过9个
这个UI规范制定,到这个功能的提测,只有10天就项目上线了。
有的功能并没有做,但是测试觉得不合理,就提了这个bug(比如表单的label也没有对齐,但是这个迭代并没有去做修复),造成我们前端开发这边bug超级多
同时,开发这边也会有绩效考核,bug越多绩效就会越低,会扣除工资,大家心态崩溃了,改的速度根本赶不上提的速度。
当然,也有部分功能是我这边测试不充分,造成业务端不好去实现
找领导协助
这个过程是个很折磨人的过程, 开发同事在抱怨测试乱提、测试也在提本次优化范围外的bug,内心也很着急
- 重新定义了测试的范围,超过范围的问题可以先记着,不再提bug,方便后续进行修改
- 前端开发的bug都先指派给我,我看看能否从底层解决,我解决后再转给研发经理(研发经理没有此项的考核),当底层解决不了,业务端解决后,也指派给我,这样前端的开发就没有这么暴躁了
如果再来一次UI规范的升级我会怎么做
- 先在每个团队中找一个人配合我,先升级页面后,进行灰度测试
- 在决定我们这个页面要成什么样子的时候,一定要拉上产品。设计师在做设计稿的时候,虽然每次都说了找产品的领导确认过了,但是领导还是比较忙, 并没有很多精力来做这件事情,可以找他们组内的一个资历比较深的产品一起讨论下。开发、UI设计师站的角度不同,一定要需要产品来参与讨论,做的页面才会更加的易用
- 限制测试的范围。 测试测着测试着会发散思维,导致改不完的bug,最终会导致开发没有了心态
- UI标准的功能,越早出来越好,越大后期需要投入的人力越多
来源:juejin.cn/post/7456685819047608355
uni-app 实现好看易用的抽屉效果
往期文章推荐:
一. 前言
我之前使用 uni-app
和 uniCloud
开发了一款软考刷题应用,在这个应用中,我使用了抽屉组件来实现一些功能,切换题库,如下图所示:
在移动应用开发中,抽屉(Drawer
)是一种常见的界面设计模式,这个组件可以在需要侧边导航或者额外信息展示的地方使用。它允许用户通过侧滑的效果打开一个菜单或额外的内容区域。
这种设计不仅能够节省屏幕空间,还能提供一种直观的交互方式。
例如,在电商应用中,可以将购物车或分类列表放在抽屉里;在新闻阅读器中,可以放置频道选择等;而在有题记刷题软件中,我主要用于题库的选择功能。
本文将介绍如何使用 uni-app 框架来实现一个简单的抽屉组件:DrawerWindow
。文末提供完整的代码示例,让你能够轻松地在 uni-app 中实现抽屉效果。
二. 实现分析
Vue 组件的结构通常由三个主要部分组成:模板(<template>
)、脚本(<script>
)和样式(<style>
),标准的的单文件组件(SFC
)结构。
uni-app 也是如此,在这个组件中,我们也将使用 Vue 的单文件组件(SFC
)结构,这意味着我们将在一个 .vue
文件中同时包含模板、脚本和样式。
接下来我们按照这个格式来简单实现一下。
1. 模板页面 (<template>
)
首先,模版页面是很简单的部分,我们需要创建一个基础的 Vue 组件,该组件包含了主页面、抽屉内容和关闭按钮三个部分。以下是组件的模板代码:
<template>
<view class="drawer-window-wrap">
<scroll-view scroll-y class="DrawerPage" :class="{ show: modalName === 'viewModal' }">
<!-- 主页面 -->
<slot></slot>
</scroll-view>
<!-- 关闭抽屉 -->
<view class="DrawerClose" :class="{ show: modalName === 'viewModal' }" @tap="hide">
<u-icon name="backspace"></u-icon>
</view>
<!-- 抽屉页面 -->
<scroll-view scroll-y class="DrawerWindow" :class="{ show: modalName === 'viewModal' }">
<slot name="drawer"></slot>
</scroll-view>
</view>
</template>
在模板部分,我们主要定义了三个主要元素:主页面、关闭按钮和抽屉页面。每个元素都有一个class
绑定,这个绑定会根据 modalName
的状态来决定是否添加 .show
类。
- 主页面 (
<scroll-view class="DrawerPage">
):
- 这个滚动视图代表应用的主要内容区域。
- 当抽屉打开时,它会被缩小并移向屏幕右侧。
- 提供默认插槽
<slot></slot>
,允许父组件传递自定义内容到这个位置。
- 关闭按钮 (
<view class="DrawerClose">
):
- 位于屏幕右侧的一个透明背景层,当点击时触发
hide()
方法来关闭抽屉。 - 包含了一个图标
<u-icon name="backspace"></u-icon>
,这里使用的是 uView UI 库中的图标组件。你可以选用其他组件库里的图标或者图片。
- 位于屏幕右侧的一个透明背景层,当点击时触发
- 抽屉页面 (
<scroll-view class="DrawerWindow">
):
- 这是抽屉本身的内容区域,通常包含菜单或其他附加信息。
- 同样地,定义特有的插槽名称,
<slot name="drawer"></slot>
允许从外部插入特定的内容。 - 抽屉默认是隐藏的,并且当显示时会有动画效果。
在这里,我们主要使用了 <slot>
元素来定义可以插入自定义内容的位置。modalName
属性用来控制抽屉的状态。
2. 逻辑处理 (<script>
)
接下来,逻辑处理其实也很简单,主要会定义打开和关闭抽屉的方法:
<script>
export default {
data() {
return {
modalName: null
}
},
methods: {
// 打开抽屉
show() {
this.modalName = 'viewModal';
},
// 关闭抽屉
hide() {
this.modalName = null;
}
}
}
</script>
- 数据 (
data
):
modalName
: 用于控制抽屉状态的数据属性。当它的值为'viewModal'
时,表示抽屉处于打开状态;否则,抽屉是关闭的。
- 方法 (
methods
):
show()
: 将modalName
设置为'viewModal'
,从而通过 CSS 样式控制抽屉显示。hide()
: 将modalName
重置为null
,控制抽屉隐藏。
当调用 show()
方法时,modalName
被设置为 'viewModal'
,这会触发 CSS 中的 .show
类,从而显示抽屉;反之,调用 hide()
方法则会隐藏抽屉。
3. 样式设计 (<style>
)
在这个组件中,其实要做好的在样式部分,主要是显示抽屉的动画部分。在主页面,我们主要定义了三个主要的样式类:主页面、关闭按钮和抽屉页面。
- 主页面样式 (
DrawerPage
):
- 初始状态下占据整个屏幕宽度和高度。
- 当抽屉打开时(即有
.show
类),页面会缩小并移动到屏幕右侧 85%的位置,同时增加阴影效果以模拟深度。
- 关闭按钮样式 (
DrawerClose
):
- 默认情况下是不可见且不响应用户交互的。
- 当抽屉打开时,按钮变为可见并可点击,提供了一种关闭抽屉的方式。
- 抽屉页面样式 (
DrawerWindow
):
- 初始状态下位于屏幕左侧外侧,不显示也不响应交互。
- 当抽屉打开时,抽屉平滑滑入屏幕内,变得完全可见且可以与用户互动。
- 动画与过渡
- 所有的
.show
类都带有transition: all 0.4s;
,这使得任何属性的变化都会有一个 0.4 秒的平滑过渡效果。 - 抽屉和主页面的
transform
属性被用来控制它们的位置和大小变化。 opacity
和pointer-events
属性确保在不需要时抽屉不会影响用户的操作。
- 所有的
如下代码所示,我们主要添加一些 CSS 样式来实现平滑的过渡效果以及视觉上的美观:
<style lang="scss">
// 省略其他样式...
.DrawerPage.show,
.DrawerWindow.show,
.DrawerClose.show {
transition: all 0.4s;
}
.DrawerPage.show {
transform: scale(0.9, 0.9) translateX(85vw);
box-shadow: 0 0 60rpx rgba(0, 0, 0, 0.2);
}
.DrawerWindow.show {
transform: scale(1, 1) translateX(0%);
opacity: 1;
pointer-events: all;
}
.DrawerClose.show {
width: 15vw;
color: #fff;
opacity: 1;
pointer-events: all;
}
</style>
以上的这些样式确保了当抽屉显示或隐藏时有流畅的动画效果,并且在不需要的时候不会影响用户的交互。
三. 完整代码
1. 完整抽屉组件,复制可使用
<template>
<view class="drawer-window-wrap">
<scroll-view scroll-y class="DrawerPage" :class="modalName == 'viewModal' ? 'show' : ''">
<!-- 主页面 -->
<slot></slot>
</scroll-view>
<!-- 关闭抽屉 -->
<view class="DrawerClose" :class="modalName == 'viewModal' ? 'show' : ''" @tap="hide()">
<u-icon name="backspace"></u-icon>
</view>
<!-- 抽屉页面 -->
<scroll-view scroll-y class="DrawerWindow" :class="modalName == 'viewModal' ? 'show' : ''">
<slot name="drawer"></slot>
</scroll-view>
</view>
</template>
<script>
export default {
data() {
return {
modalName: null
}
},
methods: {
// 打开抽屉
show() {
this.modalName = 'viewModal'
},
// 关闭抽屉
hide() {
this.modalName = null
}
}
}
</script>
<style lang="scss">
page {
width: 100vw;
overflow: hidden !important;
}
.DrawerPage {
position: fixed;
width: 100vw;
height: 100vh;
left: 0vw;
background-color: #f1f1f1;
transition: all 0.4s;
}
.DrawerPage.show {
transform: scale(0.9, 0.9);
left: 85vw;
box-shadow: 0 0 60rpx rgba(0, 0, 0, 0.2);
transform-origin: 0;
}
.DrawerWindow {
position: absolute;
width: 85vw;
height: 100vh;
left: 0;
top: 0;
transform: scale(0.9, 0.9) translateX(-100%);
opacity: 0;
pointer-events: none;
transition: all 0.4s;
background-image: linear-gradient(45deg, #1cbbb4, #2979ff) !important;
}
.DrawerWindow.show {
transform: scale(1, 1) translateX(0%);
opacity: 1;
pointer-events: all;
}
.DrawerClose {
position: absolute;
width: 40vw;
height: 100vh;
right: 0;
top: 0;
color: transparent;
padding-bottom: 50rpx;
display: flex;
align-items: flex-end;
justify-content: center;
background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.01), rgba(0, 0, 0, 0.6));
letter-spacing: 5px;
font-size: 50rpx;
opacity: 0;
pointer-events: none;
transition: all 0.4s;
}
.DrawerClose.show {
opacity: 1;
pointer-events: all;
width: 15vw;
color: #fff;
}
</style>
2. 在父组件中使用抽屉组件
在父组件中,可以通过以下简单的代码使用它,你可以继续进行丰富:
<template>
<drawer-window ref="drawerWindow">
<view class="main-container" @click="$refs.drawerWindow.show()">
主页面,点击打开抽屉
</view>
<view slot="drawer" class="drawer-container"> 抽屉页面 </view>
</drawer-window>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped>
.main-container,
.drawer-container {
font-weight: 700;
font-size: 20px;
text-align: center;
color: #333;
padding-top: 100px;
}
</style>
以上代码的实现效果如下图所示:
四. 小程序体验
以上的组件,来源于我独立开发的软考刷题小程序中的效果,想要体验或软考刷题的掘友可以参考以下文章,文末获取:
五. 结语
通过以上步骤,我们已经构建了一个基本的抽屉组件。当然,你也可以根据具体的应用场景对这个组件进行进一步的定制和优化。
来源:juejin.cn/post/7417374536670707727
个人或个体户,如何免费使用微信小程序授权登录
需求
个人或个体户,如何免费使用微信小程序授权,快速登录进系统内部?
微信授权登录好处:
- 不用自己开发一个登录模块,节省开发和维护成本
- 安全性得到了保障,安全验证完全交由腾讯验证,超级可靠哇
可能有的人会问,为何不用微信公众号授权登录?原因很简单,因为一年要300元,小公司得省钱啊!
实现步骤说明
所有的步骤里包含四个对象,分别是本地后台
、本地微信小程序
、本地网页
、以及第三方微信后台
本地后台
调用微信后台
的https://api.weixin.qq.com/cgi-bin/token
接口,get
请求,拿到返回的access_token
;本地后台
根据拿到的access_token
,调用微信后台
的https://api.weixin.qq.com/wxa/getwxacodeunlimit
接口,得到二维码图片文件,将其输出传递给本地网页
显示本地微信小程序
扫本地网页
的二维码图片,跳转至小程序登录页面,通过wx.login
方法,在success
回调函数内得到code
值,并将该值传递给本地后台
本地后台
拿到code
值后,调用微信后台
的https://api.weixin.qq.com/sns/jscode2session
接口,get
请求,得到用户登录的openid
即可。
注意点:
- 上面三个微信接口
/cgi-bin/token
、/getwxacodeunlimit
、/jscode2session
必须由本地后台
调用,微信小程序那边做了前端限制;
本地网页
如何得知本地微信小程序
已扫码呢?
本地微信小程序
将code
,通过A接口
,将值传给后台,后台拿到openid
后,再将成功结果返回给本地微信小程序
;同时,本地网页
不断地轮询A接口
,等待后台拿到openid
后,便显示登录成功页面。
微信小程序核心代码
Page({
data: {
theme: wx.getSystemInfoSync().theme,
scene: "",
jsCode: "",
isLogin: false,
loginSuccess: false,
isChecked: false,
},
onLoad(options) {
const that = this;
wx.onThemeChange((result) => {
that.setData({
theme: result.theme,
});
});
if (options !== undefined) {
if (options.scene) {
wx.login({
success(res) {
if (res.code) {
that.setData({
scene: decodeURIComponent(options.scene),
jsCode: res.code,
});
}
},
});
}
}
},
handleChange(e) {
this.setData({
isChecked: Boolean(e.detail.value[0]),
});
},
formitForm() {
const that = this;
if (!this.data.jsCode) {
wx.showToast({
icon: "none",
title: "尚未微信登录",
});
return;
}
if (!this.data.isChecked) {
wx.showToast({
icon: "none",
title: "请先勾选同意用户协议",
});
return;
}
wx.showLoading({
title: "正在加载",
});
let currentTimestamp = Date.now();
let nonce = randomString();
wx.request({
url: `A接口?scene=${that.data.scene}&js_code=${that.data.jsCode}`,
header: {},
method: "POST",
success(res) {
wx.hideLoading();
that.setData({
isLogin: true,
});
if (res.statusCode == 200) {
that.setData({
loginSuccess: true,
});
} else {
if (res.statusCode == 400) {
wx.showToast({
icon: "none",
title: "无效请求",
});
} else if (res.statusCode == 500) {
wx.showToast({
icon: "none",
title: "服务内部错误",
});
}
that.setData({
loginSuccess: false,
});
}
},
fail: function (e) {
wx.hideLoading();
wx.showToast({
icon: "none",
title: e,
});
},
});
},
});
scene
为随机生成的8位数字
本地网页核心代码
let isInit = true
function loginWx() {
isInit = false
refreshQrcode()
}
function refreshQrcode() {
showQrLoading = true
showInfo = false
api.get('/qrcode').then(qRes => {
if (qRes.status == 200) {
imgSrc = `${BASE_URL}${qRes.data}`
pollingCount = 0
startPolling()
} else {
showToast = true
toastMsg = '二维码获取失败,请点击刷新重试'
showInfo = true
}
}).finally(() => {
showQrLoading = false
})
}
// 开始轮询
// 1000毫秒轮询一次
function startPolling() {
pollingInterval = setInterval(function () {
pollDatabase()
}, 1000)
}
function pollDatabase() {
if (pollingCount >= maxPollingCount) {
clearInterval(pollingInterval)
showToast = true
toastMsg = '二维码已失效,请刷新'
showInfo = true
return
}
pollingCount++
api.get('/result').then(res => {
if (res.status == 200) {
clearInterval(pollingInterval)
navigate('/os', { replace: true })
} else if (res.status == 408) {
clearInterval(pollingInterval)
showToast = true
toastMsg = '二维码已失效,请刷新'
showInfo = true
}
})
}
html的部分代码如下所示
<button class="btn" on:click={loginWx}>微信登录</button>
<div id="qrcode" class="relative mt-10">
{#if imgSrc}
<img src={imgSrc} alt="二维码图片"/>
{/if}
{#if showQrLoading}
<div class="mask absolute top-0 left-0 w-full h-full z-10">
<Loading height="12" width="12"/>
</div>
{/if}
</div>
尾声
若需要完整代码,或想知道如何申请微信小程序
,欢迎大家关注或私信我哦~~
附上网页微信授权登录动画、以及小程序登录成功后的截图
来源:juejin.cn/post/7351649413401493556
基于uniapp带你实现了一个好看的轮播图组件
背景
最近,朋友说在做uniapp
微信小程序项目时接到一个需求,需要实现一个类似上图的轮播图效果,问我有没有什么好的实现方案,他也和我说了他的方案,比如让产品直接把图片切成两部分再分别进行上传,但是我觉得这个方案不够灵活,当每次修改banner
图片时,都得让产品把图切好再分别上传。下文将探究一个更好的实现思路。
需求分析
由文章顶部的gif
动图,我们可以看出每次执行轮播动画时,只会裁剪图片的中间部分进行滚动,图片的其余部分保持不变,等待轮播动画执行完成后,再淡化背景图片切换到下一张轮播图。
从中可得出两点关键信息:
1.两种相同图片堆叠在一起,一张背景图(大图),一张轮播图(小图);
2.需要对图片中间部分进行裁剪,并且定位到刚好能够和背景图重合得区域;
根据以上得出的信息,我们还需解决两个疑问:
1.如何对图片进行裁剪?
2.图片裁剪后如何定位和背景图重合的区域?
前端裁剪图片可以使用canvans
,但是兼容性不好,太麻烦!还有没有好一点的方法呢?当然有,参考css
中的雪碧图进行图片裁剪显示!!但还是有些麻烦,还有没有简单的方式呢?有,那就是使用css
属性overflow: hidden;
进行图片裁剪,下文也主要是讲这个方案。
开始实现
vi-swiper.vue
<template>
<view class="v-banner" :style="[boxStyle]">
<swiper class="v-swiper" autoplay indicator-dots circular
@animationfinish="onAnimationfinish"
>
<swiper-item class="v-swiperi-tem" v-for="(url,index) in list" :key="index">
<image class="v-img" :src="url" mode="scaleToFill"></image>
</swiper-item>
</swiper>
</view>
</template>
<script>
export default {
props: {
// 当前索引
value: {
type: Number,
default: 0
},
// 轮播图列表
list: {
type: Array,
default: () => []
}
},
computed: {
boxStyle() {
return {
backgroundImage: `url(${this.list[this.value]})`,
// 开启background-image转场动画
transition: '1s background-image'
}
}
},
methods: {
// 轮播图动画结束后更新底部更新图索引
onAnimationfinish(e) {
this.$emit('input', e.detail.current)
}
}
}
</script>
<style lang="scss">
/*sass变量,用于动态计算*/
$swiperWidth: 650rpx;
$swiperHeight: 350rpx;
$verticalPadding: 60rpx;
$horizontalPadding: 50rpx;
$imgWidth: $swiperWidth + $horizontalPadding * 2;
$imgHeight: $swiperHeight + $horizontalPadding * 2;
.v-banner {
/* 因为需要根据内边距动态调节背景图宽高,所以设为行内块 */
display: inline-block;
// 背景图铺满容器
background-size: 100% 100%;
padding: $verticalPadding $horizontalPadding;
.v-swiper {
height: $swiperHeight;
width: $swiperWidth;
// 裁剪图片
overflow: hidden;
.v-swiperi-tem {
.v-img {
width: $imgWidth;
height: $imgHeight;
}
}
}
}
</style>
以上代码主要实现思路是让底部背景图大小和轮播图大小相同使两种重合,尺寸相等才能重合。swiper
轮播图容器组件固定宽高,使用overflow: hidden;
来裁剪内部图片, 然后给底部背景图容器使用padding
内边距来撑开容器,达到两种图片堆叠的效果;图片转场通过transition
设置动画。
以上组件页面显示效果如下:
发现两张图片还没有重合在一起,原因是两张图片虽然大小一致了,但是位置不对,如下图所示:
那么由上图描绘的信息可知,想要两张图重合,那么需要把轮播图分别向上和向左移动对应的内边距距离即可,我们可以通过给轮播图设置负的外边距实现
,样式如下:
.v-img {
...
// 使两张图片重合
margin-top: -$verticalPadding;
margin-left: -$horizontalPadding;
}
效果如下图所示:
到这我们实现了图片的裁剪和重合,已经实现了最终效果。完整的代码会在文章结尾附上。
另外,我已经把这个组件发布到了uniapp插件市场
,并且做了相应封装,可灵活定制轮播图大小及相关样式,感兴趣的可以点击这里:
vi-swiper轮播图,跳转到文档查阅源码或使用。
总结
这个堆叠轮播图效果实现起来不难,主要是要找对思路,一句话概括就是两张大小相等的图片进行重合,轮播图容器对图片进行裁剪。
完整代码
vi-swiper.vue
<template>
<view class="v-banner" :style="[boxStyle]">
<swiper class="v-swiper" autoplay indicator-dots circular
@animationfinish="onAnimationfinish"
>
<swiper-item class="v-swiperi-tem" v-for="(url,index) in list" :key="index">
<image class="v-img" :src="url" mode="scaleToFill"></image>
</swiper-item>
</swiper>
</view>
</template>
<script>
export default {
props: {
// 当前索引
value: {
type: Number,
default: 0
},
// 轮播图列表
list: {
type: Array,
default: () => []
}
},
computed: {
boxStyle() {
return {
backgroundImage: `url(${this.list[this.value]})`,
// 开启background-image转场动画
transition: '1s background-image'
}
}
},
methods: {
// 轮播图动画结束后更新底部更新图索引
onAnimationfinish(e) {
this.$emit('input', e.detail.current)
}
}
}
</script>
<style lang="scss">
/*sass变量,用于动态计算*/
$swiperWidth: 650rpx;
$swiperHeight: 350rpx;
$verticalPadding: 60rpx;
$horizontalPadding: 50rpx;
$imgWidth: $swiperWidth + $horizontalPadding * 2;
$imgHeight: $swiperHeight + $horizontalPadding * 2;
.v-banner {
/* 因为需要根据内边距动态调节背景图宽高,所以设为行内块 */
display: inline-block;
// 背景图铺满容器
background-size: 100% 100%;
padding: $verticalPadding $horizontalPadding;
.v-swiper {
height: $swiperHeight;
width: $swiperWidth;
// 裁剪图片
overflow: hidden;
.v-swiperi-tem {
.v-img {
width: $imgWidth;
height: $imgHeight;
margin-top: -$verticalPadding;
margin-left: -$horizontalPadding;
}
}
}
}
</style>
来源:juejin.cn/post/7377245069474021412
React:我做出了一个违背祖训的决定!
React 的 useEffect
,大家都熟吧?这玩意儿就像个万金油,啥副作用都能往里塞:取数据、搞订阅、手动操作 DOM……反正渲染完了,它帮你擦屁股。它帮你擦屁股。
但是!React 团队最近搞了个大新闻,他们居然要对 useEffect
动刀子了!而且,这次的改动,用他们的话说,简直是——“违背祖训”!“违背祖训”!
useEffect
要变身?实验性 CRUD 支持来了!
新的 useEffect
签名,整合了以前一个实验性的 Hook useResourceEffect
的功能,现在长这样:
function useEffect(
create: (() => (() => void) | void) | (() => {...} | void | null),
createDeps: Array<mixed> | void | null,
update?: ((resource: {...} | void | null) => void) | void,
updateDeps?: Array<mixed> | void | null,
destroy?: ((resource: {...} | void | null) => void) | void,
): void
是不是看得一脸懵逼?别慌,我来给你翻译翻译。
以前的 useEffect
,创建和清理都挤在一个函数里,跟两口子似的,难舍难分。举个栗子:
useEffect(() => {
// 创建阶段:发起请求
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(response => response.json())
.then(data => setData(data));
// 清理阶段:取消请求
return () => {
controller.abort();
};
}, [someDependency]);
看到了吧?创建(发起请求)和清理(取消请求)都得写在一个函数里。
现在好了,React 团队直接把它们拆散了!新签名里,创建、更新、销毁,各司其职,清清楚楚:
create
: 专门用来造东西(比如,发个请求,整个订阅)。createDeps
:create
的跟屁虫,它们一变,create
就得重新执行。update
(可选): 想更新?找它!它会拿着create
造出来的东西,给你更新。updateDeps
(可选):update
的小弟,它们一变,update
就得带着老东西,重新来过。destroy
: 可选的销毁时候的回调。
“祖宗之法不可变”?React:我就变!
自从 Hook 在 2016 年推出,到现在已经九年了!九年啊!“组合优于继承”、“函数式编程”,这些 React 的“祖训”,各路大神、大V,哪个没给你讲过几百遍?哪个没给你讲过几百遍?
useEffect
把创建和清理揉在一起,也算是“组合”的一种体现,深入人心。可现在呢?React 团队居然亲手把它拆了!这……这简直是自己打自己的脸啊!
不过,话说回来,这种拆分,对于那些复杂的副作用,确实更清晰、更好管理。以前,你可能得在一个 useEffect
里写一堆 if...else
,现在,你可以把它们放到不同的阶段,代码更清爽,逻辑更分明。
注意!前方高能预警!
这个 CRUD 功能,现在还是个“试验品”,React 团队还没打算把它放出来。你要是头铁,非要试试,记得先去把 feature flag 打开。不然,你会看到这个:
useEffect CRUD overload is not enabled in this build of React.
重要的事情说三遍:这都是猜的!猜的!猜的!猜的!猜的!猜的!
现在,关于这个新特性,React 团队还没放出任何官方文档或者 RFC。所以,这篇文章,你看看就好,别太当真。它就是基于代码瞎猜的。等官方消息出来了,咱们再好好研究!
来源:juejin.cn/post/7470819965014474771
uni-app初体验,如何实现一个外呼APP
起因
2024年3月31日,我被公司裁员了。
2024年4月1日,果断踏上了回家的路,决定先休息一个星期。晚上回到了郑州,先跟一起被裁的同事在郑州小聚一下,聊聊后面的打算。第二天下午回家。
2024年4月8日,知道现在的大环境不好,不敢错过“金三银四”,赶忙回上海开始找工作。结果环境比预想的还要差啊,以前简历放开就有人找,现在每天投个几十封都是石沉大海。。。
2024年4月15日,有个好朋友找我,想让我给他们公司开发一个“拨号APP”(主要原因其实是这个好哥们想让我多个赚钱门路😌),主要的功能就是在他们的系统上点击一个“拨号”按钮,然后员工的工作手机上就自动拨打这个号码。
可行性分析
涉及到的修改:
- 系统前后端
- 拨号功能的APP
拿到这个需求之后,我并没有直接拒绝或者同意,而是先让他把他公司那边的的源代码发我了一份,大致看了一下使用的框架,然后找一些后端的朋友看有没人有人一起接这个单子;而我自己则是要先看下能否实现APP的功能(因为我以前从来没有做过APP!!!)。
我们各自看过自己的东西,然后又沟通了一番简单的实现过程后达成了一致,搞!
因为我这边之前的技术栈一直是VUE,所以决定使用uni-app实现,主要还是因为它的上手难度会低很多。
第一版
需求分析
虽说主体的功能是拨号,但其实是隐含很多辅助性需求的,比如拨号日志、通时通次统计、通话录音、录音上传、后台运行,另外除了这些外还有额外的例如权限校验、权限引导、获取手机号、获取拨号状态等功能需要实现。
但是第一次预算给的并不高,要把这些全部实现显然不可能。因此只能简化实现功能实现。
- 拨号APP
- 权限校验
- 实现部分(拨号、录音、文件读写)
- ❌权限引导
- 查询当前手机号
- 直接使用input表单,由用户输入
- 查询当前手机号的拨号任务
- 因为后端没有socket,使用setTimeout模拟轮询实现。
- 拨号、录音、监测拨号状态
- 根据官网API和一些安卓原生实现
- 更新任务状态
- 告诉后端拨号完成
- ❌通话录音上传
- ❌通话日志上传
- ❌本地通时通次统计
- 程序运行日志
- 其他
- 增加开始工作、开启录音的状态切换
- 兼容性,只兼容安卓手机即可
- 权限校验
基础设计
一个input框来输入用户手机号,一个开始工作的switch,一个开启录音的切换。用户输入手机号,点击开始工作后开启轮询,轮询到拨号任务后就拨号同时录音,同时监听拨号状态,当挂断后结束录音、更新任务状态,并开启新一轮的轮询。
开干
虽然本人从未开发过APP,但本着撸起袖子就是干的原则,直接打开了uni-app的官网就准备开怼。
1、下载 HbuilderX。
2、新建项目,直接选择了默认模板。
3、清空 Hello页面,修改文件名,配置路由。
4、在vue文件里写主要的功能实现,并增加 Http.js
、Record.js
、PhoneCall.js
、Power.js
来实现对应的模块功能。
⚠️关于测试和打包
运行测试
在 HbuilderX 中点击“运行-运行到手机或模拟器-运行到Android APP基座”会打开一个界面,让你选择运行到那个设备。这是你有两种选择:
- 把你手机通过USB与电脑连接,然后刷新列表就可以直接运行了。
- 很遗憾,可能是苹果电脑与安卓手机的原因,插上后检测不出设备😭。。。
- 安装Android Studio,然后通过运行内置的模拟器来供代码运行测试。
- 这种很麻烦,要下载很久,且感觉测试效果并不好,最好还是用windows电脑连接手机的方法测试。
关于自定义基座和标准基座的差别,如果你没有买插件的话,直接使用基准插座就好。如果你要使用自定义基座,就首先要点击上图中的制作自定义基座,然后再切换到自定义基座执行。
但是不知道为什么,我这里一直显示安装自定义基座失败。。。
打包测试
除了以上运行测试的方法外,你还有一种更粗暴的测试方法,那就是打包成APP直接在手机上安装测试。
点击“发行-原生APP 云打包”,会生成一个APK文件,然后就可以发送到手机上安装测试。不过每天打包的次数有限,超过次数需要购买额外的打包服务或者等第二天打包。
我最终就是这样搞得,真的我哭死,我可能就是盲调的命,好多项目都是盲调的。
另外,在打包之前我们首先要配置manifest.json
,里面包含了APP的很多信息。比较重要的一个是AppId,一个是App权限配置。参考uni-app 权限配置、Android官方权限常量文档。以下是拨号所需的一些权限:
// 录制音频
<uses-permission android:name="android.permission.RECORD_AUDIO" />
// 修改音频设置
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
// 照相机
<uses-permission android:name="android.permission.CAMERA" />
// 写入外部存储
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
// 读取外部存储
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
// 读取电话号码
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
// 拨打电话
<uses-permission android:name="android.permission.CALL_PHONE" />
// 呼叫特权
<uses-permission android:name="android.permission.CALL_PRIVILEGED" />
// 通话状态
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
// 读取拨号日志
<uses-permission android:name="android.permission.READ_CALL_LOG" />
// 写入拨号日志
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
// 读取联系人
<uses-permission android:name="android.permission.READ_CONTACTS" />
// 写入联系人
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
// 读取SMS?
<uses-permission android:name="android.permission.READ_SMS" />
// 写入设置
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
// 唤醒锁定?
<uses-permission android:name="android.permission.WAKE_LOCK" />
// 系统告警窗口?
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
// 接受完整的引导?
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
⚠️权限配置这个搞了很长时间,即便现在有些权限还是不太清楚,也不知道是不是有哪些权限没有配置上。。。
⚠️权限校验
1、安卓 1
好像除了这样的写法还可以写"scope.record"
或者permission.CALL_PHONE
。
permision.requestAndroidPermission("android.permission.CALL_PHONE").then(res => {
// 1 获得权限 2 本次拒绝 -1 永久拒绝
});
2、安卓 2
plus.android.requestPermissions(["android.permission.CALL_PHONE"], e => {
// e.granted 获得权限
// e.deniedPresent 本次拒绝
// e.deniedAlways 永久拒绝
});
3、uni-app
这个我没测试,AI给的,我没有用这种方法。有一说一,百度AI做的不咋地。
// 检查权限
uni.hasPermission({
permission: 'makePhoneCall',
success() {
console.log('已经获得拨号权限');
},
fail() {
// 示例:请求权限
uni.authorize({
scope: 'scope.makePhoneCall',
success() {
console.log('已经获得授权');
},
fail() {
console.log('用户拒绝授权');
// 引导用户到设置中开启权限
uni.showModal({
title: '提示',
content: '请在系统设置中打开拨号权限',
success: function(res) {
if (res.confirm) {
// 引导用户到设置页
uni.openSetting();
}
}
});
}
});
}
});
✅拨号
三种方法都可以实现拨号功能,只要有权限,之所以找了三种是为了实现APP在后台的情况下拨号的目的,做了N多测试,甚至到后面搞了一份原生插件的代码不过插件加载当时没搞懂就放弃了,不过到后面才发现原来后台拨号出现问题的原因不在这里,,具体原因看后面。
另外获取当前设备平台可以使用let platform = uni.getSystemInfoSync().platform;
,我这里只需要兼容固定机型。
1、uni-app API
uni.makePhoneCall({
phoneNumber: phone,
success: () => {
log(`成功拨打电话${phone}`);
},
fail: (err) => {
log(`拨打电话失败! ${err}`);
}
});
2、Android
plus.device.dial(phone, false);
3、Android 原生
写这个的时候有个小插曲,当时已经凌晨了,再加上我没有复制,是一个个单词敲的,结果竟然敲错了一个单词,测了好几遍都没有成功。。。还在想到底哪里错了,后来核对一遍才发现😭,control cv才是王道啊。
// Android
function PhoneCallAndroid(phone) {
if (!plus || !plus.android) return;
// 导入Activity、Intent类
var Intent = plus.android.importClass("android.content.Intent");
var Uri = plus.android.importClass("android.net.Uri");
// 获取主Activity对象的实例
var main = plus.android.runtimeMainActivity();
// 创建Intent
var uri = Uri.parse("tel:" + phone); // 这里可修改电话号码
var call = new Intent("android.intent.action.CALL", uri);
// 调用startActivity方法拨打电话
main.startActivity(call);
}
✅拨号状态查询
第一版用的就是这个获取状态的代码,有三种状态。第二版的时候又换了一种,因为要增加呼入、呼出、挂断、未接等状态的判断。
export function getCallStatus(callback) {
if (!plus || !plus.android) return;
let maintest = plus.android.runtimeMainActivity();
let Contexttest = plus.android.importClass("android.content.Context");
let telephonyManager = plus.android.importClass("android.telephony.TelephonyManager");
let telManager = plus.android.runtimeMainActivity().getSystemService(Contexttest.TELEPHONY_SERVICE);
let receiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
onReceive: (Contexttest, intent) => {
plus.android.importClass(intent);
let phoneStatus = telManager.getCallState();
callback && callback(phoneStatus);
//电话状态 0->空闲状态 1->振铃状态 2->通话存在
}
});
let IntentFilter = plus.android.importClass("android.content.IntentFilter");
let filter = new IntentFilter();
filter.addAction(telephonyManager.ACTION_PHONE_STATE_CHANGED);
maintest.registerReceiver(receiver, filter);
}
⚠️录音
录音功能这个其实没啥,都是官网的API,无非是简单的处理一些东西。但是这里有一个大坑!
一坑
就是像通话录音这种涉及到的隐私权限很高,正常的这种录音是在通话过程中是不被允许的。
二坑
后来一次偶然的想法,在接通之后再开启录音,发现就可以录音了。
但随之而来的是第二个坑,那就是虽然录音了,但是当播放的时候发现没有任何的声音,还是因为保护隐私的原因,我当时还脱离代码专门试了试手机自带的录音器来在通话时录音,发现也不行。由此也发现uni的录音本身也是用的手机录音器的功能。
三坑
虽然没有声音,但是我还是试了下保存,然后就发现了第三个坑,那就是虽然获取了文件权限,但是现在手机给的读写权限都是在限定内的,录音所在的文件夹是无权访问的。。。
另辟蹊径
其实除了自己手动录音外还可以通过手机自带的通话录音来实现,然后只要手机去读取录音文件并找到对应的那个就可以了。思路是没啥问题,不过因为设置通话录音指引、获取录音文件都有问题,这一版本就没实现。
// 录音
var log = console.log,
recorder = null,
// innerAudioContext = null,
isRecording = false;
export function startRecording(logFun = console.log) {
if (!uni.getRecorderManager || !uni.getRecorderManager()) return logFun('不支持录音!');
log = logFun;
recorder = uni.getRecorderManager();
// innerAudioContext = uni.createInnerAudioContext();
// innerAudioContext.autoplay = true;
recorder.onStart(() => {
isRecording = true;
log(`录音已开始 ${new Date()}`);
});
recorder.onError((err) => {
log(`录音出错:${err}`);
console.log("录音出错:", err);
});
recorder.onInterruptionBegin(() => {
log(`检测到录音被来电中断...`);
});
recorder.onPause(() => {
log(`检测到录音被来电中断后尝试启动录音..`);
recorder.start({
duration: 10 * 60 * 1000,
});
});
recorder.start({
duration: 10 * 60 * 1000,
});
}
export function stopRecording() {
if (!recorder) return
recorder.onStop((res) => {
isRecording = false;
log(`录音已停止! ${new Date()}`); // :${res.tempFilePath}
// 处理录制的音频文件(例如,保存或上传)
// powerCheckSaveRecord(res.tempFilePath);
saveRecording(res.tempFilePath);
});
recorder.stop();
}
export function saveRecording(filePath) {
// 使用uni.saveFile API保存录音文件
log('开始保存录音文件');
uni.saveFile({
tempFilePath: filePath,
success(res) {
// 保存成功后,res.savedFilePath 为保存后的文件路径
log(`录音保存成功:${res.savedFilePath}`);
// 可以将res.savedFilePath保存到你的数据中,或者执行其他保存相关的操作
},
fail(err) {
log(`录音保存失败! ${err}`);
console.error("录音保存失败:", err);
},
});
}
运行日志
为了更好的测试,也为了能实时的看到执行的过程,需要一个日志,我这里就直接渲染了一个倒序的数组,数组中的每一项就是各个函数push的字符串输出。简单处理。。。。嘛。
联调、测试、交工
搞到最后,大概就交了个这么玩意,不过也没有办法,一是自己确实不熟悉APP开发,二是满共就给了两天的工时,中间做了大量的测试代码的工作,时间确实有点紧了。所幸最起码的功能没啥问题,也算是交付了。
第二版
2024年05月7日,老哥又找上我了,想让我们把他们的这套东西再给友商部署一套,顺便把这个APP再改一改,增加上通时通次的统计功能。同时也是谈合作,如果后面有其他的友商想用这套系统,他来谈,我们来实施,达成一个长期合作关系。
我仔细想了想,觉得这是个机会,这块东西的市场需求也一直有,且自己现在失业在家也有时间,就想着把这个简单的功能打磨成一个像样的产品。也算是做一次尝试。
需求分析
- ✅拨号APP
- 登录
- uni-id实现
- 权限校验
- 拨号权限、文件权限、自带通话录音配置
- 权限引导
- 文件权限引导
- 通话录音配置引导
- 获取手机号权限配置引导
- 后台运行权限配置引导
- 当前兼容机型说明
- 拨号
- 获取手机号
- 是否双卡校验
- 直接读取手机卡槽中的手机号码
- 如果用户不会设置权限兼容直接input框输入
- 拨号
- 全局拨号状态监控注册、取消
- 支持呼入、呼出、通话中、来电未接或挂断、去电未接或挂断
- 获取手机号
- 录音
- 读取录音文件列表
- 支持全部或按时间查询
- 播放录音
- ❌上传录音文件到云端
- 读取录音文件列表
- 通时通次统计
- 云端数据根据上面状态监控获取并上传
- 云端另写一套页面
- 本地数据读取本机的通话日志并整理统计
- 支持按时间查询
- 支持呼入、呼出、总计的通话次数、通话时间、接通率、有效率等
- 云端数据根据上面状态监控获取并上传
- 其他
- 优化日志显示形式
- 封装了一个类似聊天框的组件,支持字符串、Html、插槽三种显示模式
- 在上个组件的基础上实现权限校验和权限引导
- 在上两个组件的基础上实现主页面逻辑功能
- 增加了拨号测试、远端连接测试
- 修改了APP名称和图标
- 打包时增加了自有证书
- 优化日志显示形式
- 登录
中间遇到并解决的一些问题
关于框架模板
这次重构我使用了uni中uni-starter + uni-admin 项目模板。整体倒还没啥,这俩配合还挺好的,就只是刚开始不知道还要配置东西一直没有启动起来。
建立完项目之后还要进uniCloud/cloudfunctions/common/uni-config-center/uni-id
配置一个JSON文件来约定用户系统的一些配置。
打包的时候也要在manifest.json
将部分APP模块配置进去。
还搞了挺久的,半天才查出来。。
类聊天组件实现
- 设计
- 每个对话为一个无状态组件
- 一个图标、一个名称、一个白底的展示区域、一个白色三角
- 内容区域通过类型判断如何渲染
- 根据前后两条数据时间差判断是否显示灰色时间
- 参数
- ID、名称、图标、时间、内容、内容类型等
- 样式
- 根据左边右边区分发送接收方,给与不同的类名
- flex布局实现
样式实现这里,我才知道原来APP和H5的展示效果是完全不同的,个别地方需要写两套样式。
关于后台运行
这个是除了录音最让我头疼的问题了,我想了很多实现方案,也查询了很多相关的知识,但依旧没效果。总体来说有以下几种思路。
- 通过寻找某个权限和引导(试图寻找到底是哪个权限控制的)
- 通过不停的访问位置信息
- 通过查找相应的插件、询问GPT、百度查询
- 通过程序切入后台之后,在屏幕上留个悬浮框(参考游戏脚本的做法)
- 通过切入后台后,发送消息实现(没测试)
测试了不知道多少遍,最终在一次无意中,终于发现了如何实现后台拨号,并且在之后看到后台情况下拨号状态异常,然后又查询了应用权限申请记录,也终于知道,归根到底能否后台运行还是权限的问题。
关于通话状态、通话记录中的类型
这个倒还好,就是测试的问题,知道了上面为啥异常的情况下,多做几次测试,就能知道对应的都是什么状态了。
通话状态:呼入振铃、通话中(呼入呼出)、通话挂断(呼入呼出)、来电未接或拒绝、去电未接或拒接。
通话日志:呼入、呼出、未接、语音邮件、拒接
交付
总体上来说还过得去,相比于上次简陋的东西,最起码有了一点APP的样子,基本上该有的功能也基本都已经实现了,美中不足的一点是下面的图标没有找到合适的替换,然后录音上传的功能暂未实现,不过这个也好实现了。
后面的计划
- 把图标改好
- 把录音文件是否已上传、录音上传功能做好
- 把APP的关于页面加上,对接方法、使用方法和视频、问题咨询等等
- 原本通话任务、通时通次这些是放在一个PHP后端的,对接较麻烦。要用云函数再实现一遍,然后对外暴露几个接口,这样任何一个系统都可以对接这个APP,而我也可以通过控制云空间的跨域配置来开放权限
- 把数据留在这边之后,就可以再把uni-admin上加几个页面,并且绑定到阿里云的云函数前端网页托管上去
- 如果有可能的话,上架应用商店,增加上一些广告或者换量联盟之类的东西
- 后台运行时,屏幕上加个悬浮图标,来电时能显示个振铃啥的
- 增加拨号前的校验,对接平台,对于经常拉黑电销的客户号码进行过滤
大致的想法就这些了,如果这个产品能继续卖下去,我就会不断的完善它。
最后
现在的行情真的是不好啊,不知道有没有大哥给个内推的机会,本人大专计算专业、6.5年Vue经验(专精后台管理、监控大屏方向,其他新方向愿意尝试),多个0-1-2项目经验,跨多个领域如人员管理、项目管理、产品设计、软件测试、数据爬虫、NodeJS、流程规范等等方面均有了解,工作稳定不经常跳,求路过的大哥给个内推机会把!
😂被举报标题党了,换个名字。
来源:juejin.cn/post/7368421971384860684
无构建和打包,浏览器直接吃上Vue全家桶?
Vue 是易学易用,性能出色,适用场景丰富的 Web 前端渐进式框架;常用来开发单页面应用。
主流开发方式-编译打包
用脚手架工具 create-vue 可以快速通过 npm create vue@latest
命令 来定制化新建一个 Vite 驱动的 Vue 单页面应用项目。
这是常规的使用 Vue 的方式。当然也可以从 Vite 那边入手。
我们新建一个项目 vue-demo
来试试,选上 Vue-Router 和 Pinia, 其余的不选:
访问 http://localhost:5173/
, 正常打开:
初始化的模板,用上了 Vue-Router,有两个路由, '/'
, '/about'
;那 Pinia 呢?可以看到依赖已经安装了引入了,给了一个 demo 了
我们来用一下 Pinia, 就在about路由组件里面用下吧:
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const store = useCounterStore()
const { count, doubleCount } = storeToRefs(store)
const { increment } = store
</script>
<template>
<div class="about">
<h1>{{ count }}</h1>
<h1>{{ doubleCount }}</h1>
<button @click="increment">+1</button>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
这就是 Vue + Vue-Router + Pinia 全家桶在 打包构建工具 Vite 驱动下的开发方式。
Vite 开发阶段不打包,但会预构建项目的依赖,需要哪个资源会在请求的时候编译,而项目上线则需要打包。
完美对吧!但你有没有注意到,官网除了介绍这种方式,还介绍了 “Using Vue from CDN”:
也就是说,可以 HTML 文件里面直接用上 Vue 的对吧?那我还想要 Vue-Router、 Pinia、Axios、 Element-Plus 呢?怎么全部直接用,而不是通过npm install xxx 在需要构建打包的项目里面用?
如何直接吃上 Vue 全家桶
我们将会从一个 HTML 文件开始,用浏览器原生的 JavaScript modules 来引入 Vue 、引入 Vue-Router,、引入 Pinia、引入 Axios, 并且构建一个类似工程化的目录结构,但不需要打包,JS 是 ES modules 语法;而项目的运行,只需要用npx serve -s
在当前项目目录起一个静态文件服务器,然后浏览器打开即可。
HTML 文件引入 Vue
找个空文件夹,我们新建一个 index.html
:
把 Vue 文档代码复制过来:
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<div id="app">{{ message }}</div>
<script type="module">
import { createApp, ref } from 'vue'
createApp({
setup() {
const message = ref('Hello Vue!')
return {
message
}
}
}).mount('#app')
</script>
当前目录下执行下npx serve -s
打开看看
没问题。
但是经常写页面的朋友都知道,肯定得拆分组件,不然全写一个页面不好维护,这点官网也给了例子:
照猫画虎,我们拆分一下:
新建 src/app.js
文件,如下内容:
import { ref } from 'vue'
export default {
setup() {
const count = ref(0)
return { count }
},
template: `<div @click="count++">Count is: {{ count }}</div>`
}
然后在 index.html
引入:
<script type="module">
import { createApp, ref } from 'vue'
import App from './src/app.js'
createApp(App).mount('#app')
</script>
刷新下页面看看:
Vue 成功引入并使用了。但还有遗憾,就是app.js
"组件"的 template 部分是字符串,没有高亮,不利于区分:
关于这点,官网也说了,如果你使用 VS Code, 那你可以安装插件 es6-string-html
,用 /*html*/实现高亮:
我们来试试看:
至此,我们可以相对舒服地使用 Vue 进行组件开发了。
HTML 文件引入、Vue 集成 Vue-Router
项目如果有不同的页面,就需要 Vue-Router 了, Vue-Router官网同样有网页直接引入的介绍:
我们来试一下,先在 Import Maps 添加 vue-router
的引入:
然后写个使用 Vue-Router 的demo: 新建两个路由组件:src/view/home.js
, src/view/about.js
, 在 HTML 文件中引入:
src/app.js
作为根组件,放个 RouterLink、RouterView 组件:
然后我们刷新下页面,看看是否正常生效:
很遗憾,没有生效,控制台报错了:
意思是声明的 vue-router 模块,没有导出我们引用到的方法 createRouter
;这说明,Vue-Router 打包的默认文件,并不是默认的 ES Modules 方式,我们得找找对应的构建产物文件才行;
这对比 Vue 的引入,Vue 引入的是构建产物中的 “esm-browser” 后缀的文件:
那么斗胆猜测下,Vue-Router 同样也有 esm 的构建产物,我们引入下该文件,应该就可以了。
但是怎么知道 Vue-Router 的构建产物有哪些?难道去翻官方的构建配置吗?不用,我们找个 npm 项目,然后npm install vue-router
,在 node_mudules/xxx
翻看就知道了。
我们上面正好有个 vue-demo, 使用了 Vue-Router。我们看看:
我们改下 Import Maps 里面 vue-router
的映射:
刷新下页面看看:
还是有报错:
@vue/devtools-api
我们并没有引入,报了这个错,斗胆猜测是 vue-router 中使用的,该模块应该是属于外部模块,我们看看网络里面响应的文件验证下:
确实如此,那么 Import Maps 也补充下引入这个模块,我们先翻看该模块的 npm 包看看,确定下路径:
Import Maps 里面引入:
再刷新下页面试试:
至此,我们成功地在 HTML 文件中引入,在 Vue 中集成了 Vue-Router。
下面我们来看 Pinia 的
但在这之前,我们来整理下现在的目录划分吧。
新建 src/router/index.js
文件,将路由相关的逻辑放到这里:
在index.html
引入 router:
然后type=module
的 script 里面的内容也可以抽离出来到单独的文件里面:
新建 main.js
文件,将内容搬过去并引入:
页面刷新下,正常运行。
HTML 文件引入、Vue 集成 Pinia
有了上面引入 Vue-Router 的经验,我们就知道了,引入其他的库也是相同的套路。我们去之前的脚手架工具生成的项目 vue-demo 的依赖里面翻看一下,Pinia 包的构建产物是如何的,然后在现在的 esm 项目里面引入吧:
我们在项目里面使用一下 Pinia, 在main.js
里面引入 Pinia:
import { createApp, ref } from 'vue'
import App from './src/app.js'
import router from './src/router/index.js'
import { createPinia } from 'pinia'
const app = createApp(App)
app.use(createPinia())
app.use(router)
.mount('#app')
新建 src/stores/useCounterStore.js
文件,填入如下内容:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export default defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
const increment = () => {
count.value++
}
return { count, doubleCount, increment }
})
即如下:
之后我们在 src/view/home.js
组件里面使用一下这个 store:
import useCounterStore from "../stores/useCounterStore.js"
import { storeToRefs } from 'pinia'
export default {
setup() {
const store = useCounterStore()
const { count, doubleCount } = storeToRefs(store)
const { increment } = store
return { count, doubleCount, increment }
},
template: /*html*/`<div>
<h1>Home</h1>
<p>{{ count }}</p>
<p>{{ doubleCount }}</p>
<button @click="increment">+1</button>
</div>`
}
我们刷新页面看看,报错了, 缺了一个模块 vue-demi
我们确认一下,在响应的 Pinia 库中确实有对这模块的引入
那么我们也引入一下吧,我们翻看需要的库的文件路径,注意这里的 esm 模块是 .mjs 后缀文件
再刷新看看:
至此,我们就在 HTML 文件中直接引入 Vue, 集成了 Vue-Router、Pinia。
HTML 文件引入 Axios
接下来,我们来看看网络请求库 Axios。
网络请求, 原生的 fetch API 可以胜任,但是对于项目的网络请求,最好有统一的拦截器处理,而 Axios 已经有了一套可行的方案,所以我项目开发一般会用 Axios。本节不讲Axios封装,只介绍在原生 HTML 文件中直接引入和使用 Axios。
要以 ESM 方式引入 Axios,我们得知道 Axios esm 模块的路径。我们在上述的工程化项目 vue-demo 中安装和查看路径
我们在 Import Maps 添加引入
我们添加 src/mock/test.json
文件,里面存放JSON 数据,然后用 axios 请求试试看:
我们在 src/view/about.js
组件里面使用一下 Axios 来获取 mock 数据,并且显示到页面上,代码如下:
import axios from 'axios'
import { ref } from 'vue'
export default {
setup() {
const mockData = ref(null)
axios.get('/src/mock/test.json').then(res => {
mockData.value = res.data
})
return { mockData }
},
template: /*html*/`<div>
<h1>About</h1>
<pre>
{{ mockData }}
</pre>
</div>`
}
刷新看看:
没有问题,可以正常使用,至于 Axios 如何封装得适合项目,这里就不展开了。
CSS 样式解决方案
但目前为止,我们几乎没有写样式,但这种纯 ESM 项目,我们应该怎么写样式呢?
用打包构建工具的项目,一般都有 CSS 的预构建处理工具,比如 Less, Scss等;但实际开发中,大部分就使用一下嵌套而已;
现在最新的浏览器已经支持 CSS 嵌套了:
还有 CSS 模块化的兼容性也完全没问题:
那么此 ESM 项目我这里给一个建议的方案,读者欢迎评论区留言提供其他方案。
新建 src/style/index.css
文件,键入如下样式:
body {
background-color: aqua;
}
在 index.html
文件中引入该样式:
刷新看看是否生效
项目中该怎么进行组件的 CSS 样式隔离呢?这里就建议 采用 ESM 的类名样式方案咯,这里不展开讲,只给一个样式目录参考。建议如下:
将样式放在 src/style
下面,按照组件的目录进行放置,然后在src/style/index.css
引入:
效果如下:
样式中,我使用了CSS模块化语法和嵌套语法,都生效了。
HTML 文件引入、Vue 集成 Element-Plus
最后,我们再引入组件库吧。我这里使用 Element-Plus
官网可以看到也是支持直接引入的,要注意的是得引入其样式
我们在上面工程化项目 vue-demo 里面安装下 Element-Plus 的 npm 包看看 esm 文件的位置(.mjs后缀文件一般就是esm模块):
在 index.html
文件里面引入样式,在 Import Maps 里面引入 element-plus:
然后在 main.js
里把所有 element-plus 组件注册为全局组件并在 src/view/home.js
使用下 Button 组件:
效果如下:
至此,我们在项目中集成了 Element-Plus 组件库了。
其他优化
以上所有的库,都可以在网络的响应里面,复制到本地,作为本地文件引入,这样加载速度更快,没有网络延迟问题。
总结
我们先按照 Vue 官方文档使用了常规的项目开发方式创建了一个项目。
然后我们提出了一个想法:能否直接在 HTML
文件中使用 Vue 及其全家桶?
答案是可行的,因为几乎所有的库都提供了 ESM 的构建文件,而现今的浏览器也都支持 ESM 模块化了。
我们也探讨和实践了 CSS 模块化 和 CSS 嵌套,用在了 demo 中作为 esm 项目的样式方案。
最后我们在项目中集成了 Element-Plus 组件库。
至此,我们可以点题了:无打包构建,浏览器确实能吃上 Vue 全家桶了。但这并不是说,可以在真实项目中这样使用,兼容性就不说了,还有项目的优化,一般得打包构建中做:比如 Tree Shaking、代码压缩等。但如果是一些小玩具项目,可以试试这么玩。无构建和打包,浏览器跑的代码就是你写的源码了。
本文示例代码地址:gitee.com/GumplinGo/1…
来源:juejin.cn/post/7399094428343959552
蔚来面试题:计算白屏时间
深入理解白屏时间及其优化策略
在前端性能优化中,白屏时间(First Paint Time)是一个非常重要的指标。它指的是从用户输入网址并按下回车键,到浏览器开始渲染页面内容的时间段。在这段时间内,用户看到的只是一个空白页面,因此白屏时间的长短直接影响了用户的体验。本文将详细探讨白屏时间的定义、影响因素、测量方法以及优化策略,并结合代码示例进行说明。
什么是白屏时间?
白屏时间是指从用户发起页面请求到浏览器首次开始渲染页面内容的时间。具体来说,白屏时间包括以下几个阶段:
- DNS解析:浏览器将域名解析为IP地址。
- 建立TCP连接:浏览器与服务器建立TCP连接(三次握手)。
- 发起HTTP请求:浏览器向服务器发送HTTP请求。
- 服务器响应:服务器处理请求并返回响应数据。
- 浏览器解析HTML:浏览器解析HTML文档并构建DOM树。
- 浏览器渲染页面:浏览器根据DOM树和CSSOM树生成渲染树,并开始渲染页面。
- 页面展示第一个标签:浏览器首次将页面内容渲染到屏幕上。
白屏时间的长短直接影响了用户对网站的第一印象。如果白屏时间过长,用户可能会感到不耐烦,甚至直接关闭页面。因此,优化白屏时间是前端性能优化的重要目标之一。
白屏时间的影响因素
白屏时间的长短受到多种因素的影响,主要包括以下几个方面:
- 网络性能:网络延迟、带宽、DNS解析时间等都会影响白屏时间。如果网络状况不佳,DNS解析和TCP连接建立的时间会变长,从而导致白屏时间增加。
- 服务器性能:服务器的响应速度、处理能力等也会影响白屏时间。如果服务器响应缓慢,浏览器需要等待更长的时间才能接收到HTML文档。
- 前端页面结构:HTML文档的大小、复杂度、外部资源的加载顺序等都会影响白屏时间。如果HTML文档过大或包含大量外部资源,浏览器需要更长的时间来解析和渲染页面。
- 浏览器性能:浏览器的渲染引擎性能、缓存机制等也会影响白屏时间。不同浏览器的渲染性能可能存在差异,导致白屏时间不同。
如何测量白屏时间?
测量白屏时间的方法有多种,下面介绍两种常用的方法:基于时间戳的方法和基于Performance API的方法。
方法一:基于时间戳的方法
在HTML文档的<head>
标签中插入JavaScript代码,记录页面开始加载的时间戳。然后在<head>
标签解析完成后,记录另一个时间戳。两者的差值即为白屏时间。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>白屏时间计算</title>
<script>
// 记录页面开始加载的时间
window.pageStartTime = Date.now();
</script>
<link rel="stylesheet" href="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-assets/ionicons/2.0.1/css/ionicons.min.css~tplv-t2oaga2asx-image.image">
<link rel="stylesheet" href="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-assets/asset/fw-icon/1.0.9/iconfont.css~tplv-t2oaga2asx-image.image">
<script>
// head 解析完成后,记录时间
window.firstPaint = Date.now();
console.log(`白屏时间:${firstPaint - pageStartTime}ms`);
</script>
</head>
<body>
<div class="container"></div>
</body>
</html>
方法二:基于Performance API的方法
使用Performance API可以更精确地测量白屏时间。Performance API提供了PerformanceObserver
接口,可以监听页面的首次绘制(first-paint
)事件。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" href="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-assets/ionicons/2.0.1/css/ionicons.min.css~tplv-t2oaga2asx-image.image">
<link rel="stylesheet" href="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-assets/asset/fw-icon/1.0.9/iconfont.css~tplv-t2oaga2asx-image.image">
<!-- 只是为了让白屏时间更长一点 -->
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.5.13/vue.global.js"></script>
</head>
<body>
<h1>Hello, World!</h1>
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_61ae954a0c4c41dba37b189a20423722@000000_oswg66502oswg900oswg600_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_9e1df42e783841e79ff021cda5fc6ed4@000000_oswg41322oswg1026oswg435_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_0376475b9a6a4dcab3f7b06a1b339cfc@5888275_oswg287301oswg729oswg545_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_e3213623ab5c46da8a6f9c339e1bd781@5888275_oswg1251766oswg1080oswg810_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_919d4445116f4efda326f651619b4c69@5888275_oswg169476oswg598oswg622_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<img
src="https://img.36krcdn.com/hsossms/20250217/v2_0457ccbedb984e2897c6d94815954aae@5888275_oswg383406oswg544oswg648_img_000?x-oss-process=image/format,jpg/interlace,1"
alt="">
<script>
// 性能 观察器 观察者模式
const observer = new PerformanceObserver((list) => {
// 获取所有的 性能 指标
const entries = list.getEntries();
for(const entry of entries) {
// body 里的第一个 标签的渲染
// 'first-paint' 表示页面首次开始绘制的时间点,也就是白屏结束的时间点
if(entry.name === 'first-paint') {
const whiteScreenTime = entry.startTime;
console.log(`白屏时间:${whiteScreenTime}ms`);
}
}
})
// 首次绘制 first-paint
// 首次内容绘制 first-contentful-paint 事件
// observe 监听性能指标
// buffered 属性设置为 true,表示包含性能时间线缓冲区中已经记录的相关事件
// 这样即使在创建 PerformanceObserver 之前事件已经发生,也能被捕获到
observer.observe({ type: 'paint', buffered: true });
</script>
</body>
</html>
总结
白屏时间是前端性能优化中的一个重要指标,直接影响用户的体验。通过理解白屏时间的定义、影响因素以及测量方法,开发者可以有针对性地进行优化。
来源:juejin.cn/post/7475652009103032358
不得不安利的富文本编辑器,太赞了!
hello,大家好,我是徐小夕。之前和大家分享了很多可视化,零代码和前端工程化的最佳实践,最近也在迭代可视化文档知识引擎Nocode/WEP
。在研究文档编辑器的时候也发现了很多优秀的开源项目,从中吸取了很多先进的设计思想。
接下来就和大家分享一款由facebook开源的强大的富文本编辑器——Lexical。目前在github
上已有 17.7k star
。
github地址: https://github.com/facebook/lexical
往期精彩
Lexical 基本介绍
Lexical 是一个可扩展的 JavaScript 文本编辑器框架,聚焦于可靠性、可访问性和性能。旨在提供一流的开发人员体验,因此我们可以轻松地进行文档设计和构建功能。
结合高度可扩展的架构,Lexical 允许开发人员创建独特的文本编辑体验,功能可以二次扩展,比如支持多人协作,定制文本插件等。
demo演示
我们可以使用它实现类似 Nocode/WEP
文档引擎的编辑体验。
我们可以轻松的选中文本来设置文本样式:
同时还能对文本内容进行评论:
当然插入表格和代码等区块也是支持的:
接下来就和大家一起分享以下它的设计思路。
设计思想
Lexical 的核心是一个无依赖的文本编辑器框架,允许开发人员构建强大、简单和复杂的编辑器表面。Lexical 有几个值得探索的概念:
- 编辑器实例:编辑器实例是将所有内容连接在一起的核心。我们可以将一个 contentEditable DOM 元素附加到编辑器实例,并注册侦听器和命令。最重要的是,编辑器允许更新其 EditorState。我们可以使用 createEditor() API 创建编辑器实例,但是在使用框架绑定(如@lexical/react)时,通常不必担心,因为这会为我们自动处理。
- 编辑器状态:编辑器状态是表示要在 DOM 上显示的内容的底层数据模型。编辑器状态包含两部分:
- Lexical 节点树
- Lexical 选择对象
- 编辑器状态一旦创建就是不可变的,为了更新它,我们必须通过 editor.update(() => {...}) 来完成。但是,也可以使用节点变换或命令处理程序“挂钩”到现有更新中 - 这些处理程序作为现有更新工作流程的一部分被调用,以防止更新的级联/瀑布。我们还可以使用 editor.getEditorState() 检索当前编辑器状态。
- 编辑器状态也完全可序列化为 JSON,并可以使用 editor.parseEditorState() 轻松地将其序列化为编辑器。
- 读取和更新编辑器状态:当想要读取和/或更新 Lexical 节点树时,我们必须通过 editor.update(() => {...}) 来完成。也可以通过 editor.getEditorState().read(() => {...}) 对编辑器状态进行只读操作。
Lexical的设计模型如下:
这里为了大家更直观的了解它的使用,我分享一个相对完整的代码案例:
import {$getRoot, $getSelection, $createParagraphNode, $createTextNode, createEditor} from 'lexical';
// 第一步,创建编辑器实例
const config = {
namespace: 'MyEditor',
theme: {
...
},
onError: console.error
};
const editor = createEditor(config);
// 第二步,更新编辑器内容
editor.update(() => {
const root = $getRoot();
const selection = $getSelection();
// 创建段落节点
const paragraphNode = $createParagraphNode();
// 创建文本节点
const textNode = $createTextNode('Hello world');
// 添加文本节点到段落
paragraphNode.append(textNode);
// 插入元素
root.append(paragraphNode);
});
通过以上两步,我们就实现了文本编辑器的创建和更新,是不是非常简单?
如果大家对这款编辑器感兴趣,也欢迎在github上学习使用,也欢迎在留言区和我交流反馈。
github地址: https://github.com/facebook/lexical
最后
后续我还会持续迭代 Nocode/WEP
项目, 让它成为最好用的可视化 + AI知识库,同时也会持续迭代和分享H5-Dooring零代码搭建平台, 如果你也感兴趣,欢迎随时交流反馈。
往期精彩
来源:juejin.cn/post/7377662459921006629
慎重!小公司要不要搞低代码?
慎重!小公司到底要不要搞自己的低代码?
同学们好,我想结合自己的亲身经历,谈谈我对低代码开发的看法,讨论下人手和精力本就有限的小公司到底要不要搞低代码(中大厂无论资源还是KPI,并不在讨论范围)。
我对低代码最直白的理解
通过可视化拖拽来快速搭建某个场景的工具,以实现降本增效
市面低代码有哪些?
某个场景这个词很广泛,我们根据某个场景设计了各种低代码平台
单一场景
- 用来在线设计图片

- 用来搭建H5页

- 用来搭建商城

- 用来搭建问卷调查

- 用来搭建Form表单

- 审批流管理系统


全场景
除了上述单一场景低代码,还有一种并不是只想做工具。而是要做全场景、无限自由度的通用型低代码平台。
其中代表作,肯定大家都很熟悉,阿里的lowcode-engine。

什么是低代码毒瘤?
就是不少低代码平台用户(技术)的使用反馈
- 代码一句话的事,要搭建一整条逻辑链
- 再完美、再丰富的业务物料库,并不能覆盖所有业务,实际上每有新业务都是伴随大量的新业务物料开发
- 解决BUG时超难debug,你只能根据逻辑链去慢慢检查节点逻辑
- 很容易形成孤岛,你接手的别人屎山代码还能直接阅读代码理解,你接手的屎山低代码平台怎么捋?
- 我想干的是技术,入职干几年JSP我人都会废掉,更别说拖拽逻辑、拖拽组件开发页面,逼我辞职!(真实经历,导致从后端转前端,后文有详述)
我眼中的低代码
回到开头,我理解的低代码

它就应该像一把手术刀(工具),为消除某个病瘤(某个场景),精准、简单、快捷的解决问题(降本增效)。
而不是造一个可视化的编辑器,先用可视化编辑器先去构造场景,然后再在构造的场景上开发,这在我看来是本末倒置。
强如lowcode-engine,阿里一个团队****开发了几年,都定义了schema协议标准,大家使用都是吐嘈声一片。可见这不是技术原因,而是设计原因。从为业务提效的工具改为了提效程序员的编辑器。
切忌!不要为了一口醋,包一顿饺子。
我认为低代码以程序员为用户去设计低代码产品注定会失败,这几年低代码毒瘤的评价就是一场大型的社会实验,这就是用户(程序员)最真实的反馈。
我理想中的的低代码:
- 用户:产品、运营、不懂技术的普通用户
- 功能: 简单、快速、稳定的搭建某一场景
- 目的:实现场景业务的降本增效
- 槽点:原本目的是让非程序员通过平台能简单、快速新增固定场景业务,现在却是想开发一个可视化搭建编辑器取代程序员??
我的结论是,如果那么复杂的场景,物料拖来拖去,逻辑链上百个节点,不如cursor一句话...
这是我的黑历史,也是我的来时路
转行前端:低代码熟练工最早受害者
我2017年大学毕业,原本学的是Java,在南京面试并入职了一家公司做后端开发。
当时公司招聘了大量应届毕业生,我本以为是因为业务发展迅速,需要大量研发人员。然而入职后才发现,公司后端开发并不使用代码开发,而是通过公司自研的一个逻辑编辑器进行开发。这个编辑器采用拖拽节点、搭建逻辑链的方式来实现后端业务。我们平时写的一句代码,实际上就是一条逻辑链,独立的方法构成一个独立的父节点,节点之间再相互串联。之所以招聘这么多人,是因为公司离职率极高,每年大约只有20%的人能留下来。公司通过这种方式,逐年筛选出逻辑编辑器的熟练工。
我干了两个月后,实在无法适应,准备离职。但当时招聘季已经结束,只能暂时忍耐。转机出现在公司的低代码平台——它只支持后端开发,前端仍然需要编写代码。前端组也在招人,于是我谎称自己会前端,成功转到了前端组。但实际上,我当时只会一点Vue基础,完全不懂前端开发,只能从头学起。最终,我从后端彻底转成了前端开发。
在大半年后,我跳槽去了另一家公司。就在我准备离职时,公司其他部门的前端组也开发出了类似的低代码平台。我试用过,虽然非常难用,很多操作反人类,但公司也打算仿照后端的模式,每年招聘前端应届生,逐年筛选出熟练工。
可以说,我们这波人是国内最早被低代码迫害的那批开发者。因为我亲身经历过,所以我很明确地告诉大家:有些公司开发和推广低代码平台的目的,并不是为了提升业务效率,而是为了替换掉研发人员,转而使用一些廉价的低代码平台的熟练工!
这简直从根源上实现了节流,对他们来说也是增效。
开源之旅:构建我理解的低代码平台
了解我的同学可能知道,我是低代码开源项目Mall-Cook和云搭的作者,既然我已受过低代码的迫害,那为什么还要开发低代码?
因为我想还原可视化拖拽搭建降本增效原本的魅力。
我的的研究很明确,就是开发普通人(产品、运营、不管会不会技术的普通人)在某些场景(H5、问卷、图片、商城等)能简单、快速搭建的工具(有用的才算工具,如果只是KPI产品,合格的软件我认为都不算)
五年磨一剑,三代铸巅峰
我公司是一家做文旅的小公司,而公司的业务恰好是我低代码项目落地的最佳场景。
在过去的五年,我独立开发了三代低代码项目,在项目我都会开发完成后。都自荐接入公司的实际项目中,通过用户实际使用的反馈,不断的优化和扩展。
H5-Generate
我自研第一代低代码平台,当时仿照鲁班花了3个月自己搞了一个H5生成器,用来搭建生成活动页H5。
最初的试水之作,现在看来很简陋、使用体验也一般,也没信心开源出来献丑。不过我接入公司文旅小程序,支持了我们当时拳头产品数百个活动页的搭建。

Mall-Cook
自研第二代低代码平台,突破只能搭建H5的桎梏,支持搭建H5、小程序、APP任意端页面搭建。
开源地址: 链接

Mall-Cook旨在开发一个供运营、产品快速搭建商城的可视化平台。其实现了可视化页面搭建、组件流水线式标准接入、搭建页面多端生成(H5、小程序、APP)、运营/产品低学习成本维护等特点。

Mall-Cook是我承上启下的开发项目,在项目开发完成后,在当时我还是比较满意的。
所以把项目进行了开源,并向公司自荐由Mall-Cook替换掉H5-Generate,支持公司后续项目的可视化搭建需求
Mall-Cook在开源和公司都取得了很不错的成绩,真正让普通人去做了部分研发需求做的工作,真做到了我所希望的降本提效。

云搭
自研第三代低代码平台,大成之作,云搭万物,触手可及!
云搭平台: 链接
开源地址: 链接
介绍文章: 链接

云搭是一款功能强大的可视化搭建解决方案,它支持零代码搭建小程序、H5、问卷、图文文章等多种应用,致力于提供一套简单、便捷、专业、可靠的多场景可视化搭建平台。
我愿景是让所有用户(无论会不会技术的普通人),使用云搭可以简单、便捷搭建各种应用。

平台功能
- 使用uni-app渲染器支持H5、小程序、APP的多端渲染
- 开发自定义表单系统,支持表单-列表-详情页的整链路设计方案
- 结合多端渲染与自定义表单系统,云搭设计了小程序、H5、问卷、图文文章多种使用场景
- 开发嵌套布局,提供卡片、tab等容器组件,让页面支持无限层级嵌套布局
- 内置图片实时编辑,给用户更多自由设计空间
- 开发数据分析模块,多维度统计分析问卷、表单数据
- 开发资源社区,共享用户创建的应用模板
- 内置图片库,提供1000+图片资源
通过一代代的产品,解读我眼中的低代码
我对低代码的理解是通过可视化拖拽来快速搭建某个场景的工具
那我设计云搭的理想就是,通过可视化拖拽来快速搭建多个场景的工具库
回到当初那句话,这几年一步步走来,我始终坚信实践是检验真理的唯一标准,我理想国也从未变过...
小公司到底要不要搞自己的低代码?
- 我们公司是做文旅的,活动、电商等天然就满足可视化搭建工具的增效。如果公司业务类似的部分简单场景,可以github找个相关项目或者自研个简单的工具来提效
- 如果用来搭建管理后台页面,我的意见直接是直接否掉。我的亲身例子就是,不要像我那样最后受不了煎熬,只能离职。包括我们公司只是在后台封装了通用业务组件和CURD Hooks来提效开发,新页面直接CV然后改需求,真的我感觉搞来搞去不如不如cursor一句话。
小公司不是那些中大厂,是不会成立项目组来做这些。在人力和精力有限的情况下,如果是固定场景的话,可以找市面上成熟的平台仿照开发,如果是想用lowcode-engine来打造公司通用型平台,直接拒掉...
真实案例
除了我司,我再举个真实例子(大道理谁都会说,我始终坚信实践是检验真理的唯一标准)
古茗的前端团队
古茗在面对门店几百张菜单,经常更新的业务现状
开发门店菜单智能化平台搭建电子菜单,切实的实现增效

还是我那句话,它就应该像一把手术刀(工具),为消除某个病瘤(某个场景),精准、简单、快捷的解决问题(降本增效)。
不为解决实际问题开发它干嘛?不如不做...
巅峰看到虚假的拥护,黄昏见证真正的忠诚
我从低代码还未大火时便开始研究,见证了它的崛起与沉寂。巅峰时,无数人追捧,仿佛它是解决一切问题的灵丹妙药;而如今,热潮退去,许多人选择离开,我还是孜孜不倦的探索我的眼中的低代码。
写这篇文章就是想对低代码祛魅,拨开层层糖衣看看它真实的模样。它没外界吹捧的那么无所不能,但也并未一无是处。
一去数年,我仍在低代码的道路上独自求索,构建自己的理想国。
诸君共勉 ~
来源:juejin.cn/post/7468621394736922662
为了解决内存泄露,我把 vue 源码改了
前言
彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个 bug
了
但是排查内存泄露在前端领域属于比较冷门的领域了
这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历
本文涉及技术栈
- vue2
彦祖们,好久不见,最近一直忙于排查单位业务的终端内存泄露问题,已经吃了不下 10 个 bug
了
但是排查内存泄露在前端领域属于比较冷门的领域了
这篇文章笔者将带你一步步分享业务实践中遇到的内存泄露问题以及如何修复的经历
本文涉及技术栈
- vue2
场景复现
如果之前有看过我文章的彦祖们,应该都清楚
笔者所在的单位有一个终端叫做工控机(类似于医院挂号的终端),没错!所有的 bug 都源自于它😠
因为内存只有 1G
所以一旦发生内存泄露就比较可怕
不过没有这个机器 好像也不会创作这篇文章😺
如果之前有看过我文章的彦祖们,应该都清楚
笔者所在的单位有一个终端叫做工控机(类似于医院挂号的终端),没错!所有的 bug 都源自于它😠
因为内存只有 1G
所以一旦发生内存泄露就比较可怕
不过没有这个机器 好像也不会创作这篇文章😺
复现 demo
彦归正传,demo 其实非常简单,只需要一个最简单的 vue2 demo 就可以了
- App.vue
<script>
import Test from './test.vue'
export default {
name: 'App',
components: {
Test
},
data () {
return {
render: false
}
}
}
script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
style>
- test.vue
<script>
export default {
name: 'Test',
data () {
return {
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>
彦归正传,demo 其实非常简单,只需要一个最简单的 vue2 demo 就可以了
- App.vue
<script>
import Test from './test.vue'
export default {
name: 'App',
components: {
Test
},
data () {
return {
render: false
}
}
}
script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
style>
- test.vue
<script>
export default {
name: 'Test',
data () {
return {
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>
复现流程
以下流程建议彦祖们在 chrome 无痕模式
下执行
- 我们点击
render
按钮渲染 test
组件,此时我们发现 dom
节点的个数来到了 2045

考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板

500ms
后(定时器执行完成后,如果没复现可以把 500ms 调整为 1000ms, 1500ms
),我们点击 destroy
按钮- 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)
以下流程建议彦祖们在 chrome 无痕模式
下执行
- 我们点击
render
按钮渲染test
组件,此时我们发现dom
节点的个数来到了2045
考虑到有彦祖可能之前没接触过这块面板,下图展示了如何打开此面板
500ms
后(定时器执行完成后,如果没复现可以把500ms 调整为 1000ms, 1500ms
),我们点击destroy
按钮- 我们点击面板这里的强制回收按钮(发现节点并没有回收,已发生内存泄露)
如果你的浏览器是最新的 chrome
,还能够点击这里的 已分离的元素
(detached dom),再点击录制
我们会发现此时整个 test
节点已被分离
问题分析
那么问题到底出在哪里呢?
vue 常见泄露场景
笔者搜遍了全网,网上所说的不外乎以下几种场景
1.未清除的定时器
2.未及时解绑的全局事件
3.未及时清除的 dom 引用
4.未及时清除的 全局变量
5.console 对引用类型变量的劫持
好像第一种和笔者的场景还比较类似,但是仔细看看代码好像也加了
beforeDestroy () {
clearTimeout(this.timer)
}
这段代码啊,就算不加,timer
执行完后,事件循环也会把它回收掉吧
同事提供灵感
就这样笔者这段代码来回测试了半天也没发现猫腻所在
这时候同事提供了一个想法说"total 更新的时候是不是可以提供一个 key
"
改了代码后就变成了这样了
- test.vue
<script>
export default {
name: 'Test',
data () {
return {
renderKey: 0,
total: 1000
}
},
mounted () {
this.timer = setTimeout(() => {
this.total = 10000
this.renderKey = Date.now()
}, 500)
},
beforeDestroy () {
clearTimeout(this.timer)
}
}
script>
神奇的事情就这样发生了,笔者还是按以上流程测试了一遍,直接看结果吧
我们看到这个 DOM
节点曲线,在 destroy
的时候能够正常回收了
问题复盘
最简单的 demo
问题算是解决了
但是应用到实际项目中还是有点困难
难道我们要把每个更新的节点都手动加一个 key
吗?
其实仔细想想,有点 vue
基础的彦祖应该了解这个 key
是做什么的?
不就是为了强制更新组件吗?
等等,强制更新组件?更新组件不就是 updated
吗?
updated
涉及的不就是八股文中我们老生常谈的 patch
函数吗?(看来八股文也能真有用的时候😺)
那么再深入一下, patch
函数内部不就是 patchVnode
其核心不就是 diff
算法吗?
首对首比较,首对尾比较,尾对首比较,尾对尾比较
这段八股文要是个 vuer
应该都不陌生吧?😺
动手解决
其实有了问题思路和想法
那么接下来我们就深入看看 vue
源码内部涉及的 updated
函数到底在哪里吧?
探索 vue 源码
我们找到 node_modules/vue/vue.runtime.esm.js
我们看到了 _update
函数真面目,其中有个 __patch__
函数,我们再重点查看一下
createPatchFunction
最后 return 了这个函数
我们最终来看这个 updateChildren
函数
其中多次出现了上文中所提到的八股文,每个都用 sameVnode
进行了对比
- function sameVnode
function sameVnode (a, b) {
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}
果然这里我们看到了上文中 key
的作用
key
不一样就会认作不同的 vnode
那么就会强制更新节点
对应方案
既然找到了问题的根本
在判定条件中我们是不是直接加个 || a.text !== b.text
强制对比下文本节点不就可以了吗?
修改 sameVnode
看下我们修改后的 sameVnode
function sameVnode (a, b) {
if(a.text !== b.text) return false // 文本不相同 直接 return
return (a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error))));
}
方案效果
让我们用同样的代码来测试下
测试了几次发现非常的顺利,至此我们本地的修改算是完成了
如何上线?
以上的方案都是基于本地开发的,那么我们如何把代码应用到线上呢?
其他开发者下载的 vue
包依旧是 老的 sameVnode
啊
不慌,接着看
patch-package
对比了好几种方式,最终我们选择了这个神器
其实使用也非常简单
1.npm i patch-package
2.修改 node_modules/vue
源码
3.在根目录执行 npx patch-package vue
(此时如果报错,请匹配对应 node 版本的包)
我们会发现新增了一个这样的文件
4.我们需要在package.json
scripts
新增以下代码
- package.json
"scripts": {
+"postinstall":"patch-package"
}
至此上线后,其他开发者执行 npm i
后便能使变动的补丁生效了
优化点
其实我们的改造还有一定的进步空间,比如说在指定节点上新增一个 attribute
在函数内部判断这个 attribute
再 return false
这样就不用强制更新每个节点了
当然方式很多种,文章的意义在于解决问题的手段和耐心
写在最后
最后再次感谢同事 juejin.cn/user/313102… 的提供的灵感和协助
感谢彦祖们的阅读
个人能力有限
如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟
来源:juejin.cn/post/7460431444630011919
ArcoDesign,字节跳动又一开源力作,企业级UI开源库,一个字“牛”!
大家好,我是程序视点的小二哥!
今天给大家分享的是:ArcoDesign
。
它是字节跳动在稀土开发者大会上开源的企业级设计UI开源库。
关于 ArcoDesign
ArcoDesign
主要解决在打造中后台应用
时,让产品设计和开发无缝连接,提高质量和效率。
目前 ArcoDesign
主要服务于字节跳动
旗下中后台产品的体验设计和技术实现,打磨沉淀 3 年之后开源。现主要由字节跳动 GIP UED 团队和架构前端团队联合共同构建及维护。
ArcoDesign
的亮点
- 提供系统且
全面的设计规范和资源
,覆盖产品设计、UI 设计以及后期开发
React
和Vue
同步支持。同时提供了React
和Vue
两套 UI 组件库。Vue
组件库基于Vue 3.0
开发,并配详细的上手文档。
- 支持一键开启
暗黑模式
,主题无缝切换
// 设置为暗黑主题
document.body.setAttribute('arco-theme', 'dark')
// 恢复亮色主题
document.body.removeAttribute('arco-theme');
- 提供了
最佳实践 Arco Pro
,整理了常见的页面场景,帮助用户快速初始化项目和使用页面模板,从 0 到 1 搭建中后台应用
体验和使用建议
ArcoDesign
官方介绍和文档写得很磅礴,内容超多,格局很大。
针对前端开发者来说,有三点想法:
- 一个设计系统同时提供目前最流行的
React
和Vu
e框架各提供一套 UI 组件库,综合性很强(官方考虑很全面)。 ArcoDesign UI
组件库的使用文档很详尽,上手简单,代码例子充足,使用体验和AntDesign
、Element UI
类似。前端开发者入手成本低
。
ArcoDesign
提供的这套组件设计风格很时尚新潮,配色鲜明,细节处理优雅,细微的交互动效让人很舒服,不需要投入太多的设计工作就可以搭建一个品质很高的应用。
当然,在资源设计方面,也有友好的对接。对于设计能力强的团队,ArcoDesign
也提供了很多快速且精准的样式定制工具。
其他
官网还有很多特性的说明,作为一个介绍文章没法展开篇幅说明,总的来说,ArcoDesign
是一个可用性很强的中后台应用设计系统
。更多内容请查阅官方网站。
ArcoDesign
官方地址
arco.design/
写在最后
【程序视点】助力打工人减负,从来不是说说而已!
后续小二哥会继续详细分享更多实用的工具和功能。持续关注,这样就不会错过之后的精彩内容啦!~
如果这篇文章对你有帮助的话,别忘了【一键三连】支持下哦~
来源:juejin.cn/post/7462197664886636596
纯前端也能实现 OCR?
前言
前端时间有一个 OCR 的需求,原本考虑调用现成的 OCR 接口,但由于只是做一个我个人使用的工具,花钱购买 OCR 接口显得有些奢侈。于是就想着找找是否有现成的库可以自己部署或直接使用,结果发现了一个可以在纯前端实现 OCR 的库——Tesseract.js。
Tesseract.js
Tesseract.js 是一个基于 Google Tesseract OCR 引擎的 JavaScript 库,利用 WebAssembly 技术将的 OCR 引擎带到了浏览器中。它完全运行在客户端,无需依赖服务器,适合处理中小型图片的文字识别。
主要特点
- 多语言支持:支持多种语言文字识别,包括中文、英文、日文等。
- 跨平台:支持浏览器和 Node.js 环境,灵活应用于不同场景。
- 开箱即用:无需额外依赖后端服务,直接在前端实现 OCR 功能。
- 自定义训练数据:支持加载自定义训练数据,提升特定场景下的识别准确率。
安装
通过 npm 安装
npm install tesseract.js
通过 CDN 引入
<script src="https://unpkg.com/tesseract.js@latest/dist/tesseract.min.js"></script>
基本使用
以下示例展示了如何使用 Tesseract.js 从图片中提取文字:
import Tesseract from 'tesseract.js';
Tesseract.recognize(
'image.png', // 图片路径
'chi_sim', // 识别语言(简体中文)
{
logger: info => console.log(info), // 实时输出进度日志
}
).then(({ data: { text } }) => {
console.log('识别结果:', text);
});
示例图片
运行结果
可以看到,虽然识别结果不完全准确,但整体准确率较高,能够满足大部分需求。
更多用法
1. 多语言识别
Tesseract.js 支持多语言识别,可以通过字符串或数组指定语言代码:
// 通过字符串的方式指定多语言
Tesseract.recognize('image.png', 'eng+chi_sim').then(({ data: { text } }) => {
console.log('识别结果:', text);
});
// 通过数组的方式指定多语言
Tesseract.recognize('image.png', ['eng','chi_sim']).then(({ data: { text } }) => {
console.log('识别结果:', text);
});
eng+chi_sim
表示同时识别英文和简体中文。Tesseract.js 内部会将字符串通过 split
方法分割成数组:
const currentLangs = typeof langs === 'string' ? langs.split('+') : langs;
2. 处理进度日志
可以通过 logger
回调函数查看任务进度:
Tesseract.recognize('image.png', 'eng', {
logger: info => console.log(info.status, info.progress),
});
输出示例:
3. 自定义训练数据
如果需要识别特殊字符,可以加载自定义训练数据:
const worker = await createWorker('语言文件名', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false, // 是否对来自远程的训练数据进行 gzip 压缩
langPath: '/path/to/lang-data' // 自定义训练数据路径
});
[!warning] 注意:
- 第一个参数为加载自定义训练数据的文件名,不带后缀。
- 加载自定义训练数据的文件后缀名必须为
.traineddata
。
- 如果文件名不是
.traineddata.gzip
,则需要设置gzip
为false
。
举例:
const worker = await createWorker('my-data', OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
gzip: false,
langPath: 'http://localhost:5173/lang',
});
加载效果:
4. 通过前端上传图片
通常,图片是通过前端让用户上传后进行解析的。以下是一个简单的 Vue 3 示例:
<script setup>
import { createWorker } from 'tesseract.js';
async function handleUpload(evt) {
const files = evt.target.files;
const worker = await createWorker("chi_sim");
for (let i = 0; i < files.length; i++) {
const ret = await worker.recognize(files[i]);
console.log(ret.data.text);
}
}
</script>
<template>
<input type="file" @change="handleUpload" />
</template>
完整示例
下面提供一个简单的 OCR 示例,展示了如何在前端实现图片上传、文字识别以及图像处理。
代码
<!--
* @Author: zi.yang
* @Date: 2024-12-10 09:15:22
* @LastEditors: zi.yang
* @LastEditTime: 2025-01-14 08:06:25
* @Description: 使用 tesseract.js 实现 OCR
* @FilePath: /vue-app/src/components/HelloWorld.vue
-->
<script setup lang="ts">
import { ref } from 'vue';
import { createWorker, OEM } from 'tesseract.js';
const uploadFileName = ref<string>("");
const imgText = ref<string>("");
const imgInput = ref<string>("");
const imgOriginal = ref<string>("");
const imgGrey = ref<string>("");
const imgBinary = ref<string>("");
async function handleUpload(evt: any) {
const file = evt.target.files?.[0];
if (!file) return;
uploadFileName.value = file.name;
imgInput.value = URL.createObjectURL(file);
const worker = await createWorker("chi_sim", OEM.DEFAULT, {
logger: info => console.log(info.status, info.progress),
});
const ret = await worker.recognize(file, { rotateAuto: true }, { imageColor: true, imageGrey: true, imageBinary: true });
imgText.value = ret.data.text || '';
imgOriginal.value = ret.data.imageColor || '';
imgGrey.value = ret.data.imageGrey || '';
imgBinary.value = ret.data.imageBinary || '';
}
// 占位符 svg
const svgIcon = encodeURIComponent('<svg t="1736901745913" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4323" width="140" height="140"><path d="M804.9 243.4c8.1 0 17.1 10.5 17.1 24.5v390.9c0 14-9.1 24.5-17.3 24.5H219.3c-8 0-17.3-10.7-17.3-24.5V267.9c0-14 9.1-24.5 17.3-24.5h585.6m0-80H219.3c-53.5 0-97.3 47-97.3 104.5v390.9c0 57.3 43.8 104.5 97.3 104.5h585.4c53.5 0 97.3-47 97.3-104.5V267.9c0-57.5-43.7-104.5-97.1-104.5z" fill="#5E9EFC" p-id="4324"></path><path d="M678.9 294.5c28 0 50.6 22.7 50.6 50.6 0 28-22.7 50.6-50.6 50.6s-50.6-22.7-50.6-50.6c0-28 22.7-50.6 50.6-50.6z m-376 317.6l101.4-215.7c6-12.8 24.2-12.8 30.2 0l101.4 215.7c5.2 11-2.8 23.8-15.1 23.8H318c-12.2 0-20.3-12.7-15.1-23.8z" fill="#5E9EFC" p-id="4325"></path><path d="M492.4 617L573 445.7c4.8-10.1 19.2-10.1 24 0L677.6 617c4.1 8.8-2.3 18.9-12 18.9H504.4c-9.7 0-16.1-10.1-12-18.9z" fill="#5E9EFC" opacity=".5" p-id="4326"></path></svg>');
const placeholder = 'data:image/svg+xml,' + svgIcon;
</script>
<template>
<div class="custom-file-upload">
<label for="file-upload" class="custom-label">选择文件</label>
<span id="file-name" class="file-name">{{ uploadFileName || '未选择文件' }}</span>
<input id="file-upload" type="file" @change="handleUpload" />
</div>
<div class="row">
<div class="column">
<p>输入图像</p>
<img alt="原图" :src="imgInput || placeholder">
</div>
<div class="column">
<p>旋转,原色</p>
<img alt="原色" :src="imgOriginal || placeholder">
</div>
<div class="column">
<p>旋转,灰度化</p>
<img alt="灰度化" :src="imgGrey || placeholder">
</div>
<div class="column">
<p>旋转,二值化</p>
<img alt="二进制" :src="imgBinary || placeholder">
</div>
</div>
<div class="result">
<h2>识别结果</h2>
<p>{{ imgText || '暂无结果' }}</p>
</div>
</template>
<style scoped>
/* 隐藏原生文件上传按钮 */
input[type="file"] {
display: none;
}
/* 自定义样式 */
.custom-file-upload {
display: inline-block;
cursor: pointer;
margin-bottom: 30px;
}
.custom-label {
padding: 10px 20px;
color: #fff;
background-color: #007bff;
border-radius: 5px;
display: inline-block;
font-size: 14px;
cursor: pointer;
}
.custom-label:hover {
background-color: #0056b3;
}
.file-name {
margin-left: 10px;
font-size: 14px;
color: #555;
}
.row {
display: flex;
width: 100%;
justify-content: space-around;
}
.column {
width: 24%;
padding: 5px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
text-align: center;
min-height: 100px;
}
.column > p {
margin: 0 0 10px 0;
padding: 5px;
border-bottom: 1px solid #ccc;
font-weight: 600;
}
.column > img {
width: 100%;
}
.result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
}
.result > h2 {
margin: 0;
}
.result > p {
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
font-size: 16px;
line-height: 1.5;
color: #333;
margin: 10px 0;
}
</style>
实现效果
资源加载失败
Tesseract.js 在运行时需要动态加载三个关键文件:Web Worker
、wasm
和 训练数据
。由于默认使用的是 jsDelivr CDN,国内用户可能会遇到网络加载问题。为了解决这个问题,可以通过指定 unpkg CDN 来加速资源加载:
const worker = await createWorker('chi_sim', OEM.DEFAULT, {
langPath: 'https://unpkg.com/@tesseract.js-data/chi_sim/4.0.0_best_int',
workerPath: 'https://unpkg.com/tesseract.js/dist/worker.min.js',
corePath: 'https://unpkg.com/tesseract.js-core/tesseract-core-simd-lstm.wasm.js',
});
如果需要离线使用,可以将这些资源下载到本地,并将路径指向本地文件即可。
结语
Tesseract.js 是目前前端领域较为成熟的 OCR 库,适合在无需后端支持的场景下快速实现文字识别功能。通过合理的图片预处理和优化,可以满足大部分中小型应用的需求。
相关链接
- Tesseract.js 文档: tesseract-ocr.github.io/
- Tesseract.js Demo: tesseract.projectnaptha.com/
来源:juejin.cn/post/7459791088791797786