Three.js-硬要自学系列29之专项学习透明贴图
什么是透明贴图
- 核心作用:像「镂空剪纸」一样控制物体哪些部位透明/不透明
(想象:给树叶模型贴图,透明部分让树叶边缘自然消失而非方形边缘)
- 技术本质:一张 黑白图片(如 PNG 带透明通道),其中:
- 黑色区域 → 模型对应位置 完全透明(消失)
- 白色区域 → 模型 完全不透明(显示)
- 灰色过渡 → 半透明效果(如玻璃边缘)
示例:游戏中的铁丝网、树叶、破碎特效等镂空物体常用此技术
常见问题与解决方案
问题现象 | 原因 | 解决方法(代码) |
---|---|---|
贴图完全不透明 | 忘记开 transparent | material.transparent = true |
边缘有白边/杂色 | 半透明像素混合错误 | material.alphaTest = 0.5 |
模型内部被穿透 | 透明物体渲染顺序错乱 | mesh.renderOrder = 1 |
技巧:透明贴图需搭配 基础颜色贴图(map) 使用,两者共同决定最终外观
实际应用场景
- 游戏植被:草地用方形面片+草丛透明贴图,节省性能
- UI 元素:半透明的警示图标悬浮在 3D 物体上
- 破碎效果:物体裂开时边缘碎片渐变透明
- AR 展示:透明背景中叠加虚拟模型(类似宝可梦 GO)
实践案例一
效果如图
实现思路
通过canvas绘制内容,canvasTexture用来转换为3d纹理
const canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d');
canvas.width = 64;
canvas.height = 64;
ctx.fillStyle = '#404040';
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#808080';
ctx.fillRect(32, 0, 32, 32);
ctx.fillStyle = '#c0c0c0';
ctx.fillRect(0, 32, 32, 32);
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(32, 32, 32, 32);
const texture = new THREE.CanvasTexture(canvas);
这里画布大小设置为64*64,被均匀分割为4份,并填充不同的颜色
接下来创建一个立方体,为其贴上透明度贴图alphaMap
,设置transparent:true
这很关键
const geo = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({
color: 'deepskyblue',
alphaMap: texture, // 透明度贴图
transparent: true,
opacity: 1,
side: THREE.DoubleSide
});
如果你尝试将transparent
配置改为false
, 你将看到如下效果
同样我们尝试修改canvas绘制时候的填充色,来验证黑白镂空情况
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#000';
ctx.fillRect(32, 0, 32, 32);
ctx.fillStyle = '#000';
ctx.fillRect(0, 32, 32, 32);
ctx.fillStyle = '#fff';
ctx.fillRect(32, 32, 32, 32);
如图所示,黑色消失,白色显示保留
总结本案例需要掌握的API
CanvasTexture
这是Texture
的子类,它用于将动态绘制的 2D Canvas
内容(如图表、文字、实时数据)转换为 3D 纹理,使得HTML Canvas
元素可以作为纹理映射到3d物体表面
它支持实时更新,默认needsUpdate
为true
应用场景
- 动态数据可视化:将实时图表(如温度曲线)映射到 3D 面板。
- 文字标签:在 3D 物体表面显示可变文字(如玩家名称)。
- 程序化纹理:通过算法生成图案(如噪波、分形)。
- 交互式绘制:用户画布涂鸦实时投射到 3D 模型(如自定义 T 恤设计)。
性能优化
- 避免频繁更新:若非必要,减少
needsUpdate=true
的调用频率。 - 合理尺寸:Canvas 尺寸建议为 2 的幂(如 256×256, 512×512),兼容纹理映射。
- 复用 Canvas:对静态内容,复用已生成的纹理而非重新创建。
- 替代方案:静态图像用
TextureLoader
,视频用VideoTexture
,以降低开销。
需要注意
- 跨域限制:若 Canvas 包含外部图片,需设置
crossOrigin="anonymous"
。 - 清晰度问题:高缩放比例可能导致模糊,可通过
texture.anisotropy = renderer.capabilities.getMaxAnisotropy()
改善。 - 内存管理:不再使用的纹理调用
texture.dispose()
释放资源。
实践案例二
效果如图
实现思路
从图上可以看出,立方体每个面上有多个矩形小方块,每个方块都被赋予不同的颜色,创建grid
方法来实现生产多个矩形小方块
const drawMethod = {};
drawMethod.grid = (ctx, canvas, opt={} ) => {
opt.w = opt.w || 4;
opt.h = opt.h || 4;
opt.colors = opt.colors || ['#404040', '#808080', '#c0c0c0', '#f0f0f0'];
opt.colorI = opt.colorI || [];
let i = 0;
const len = opt.w * opt.h,
sizeW = canvas.width / opt.w, // 网格宽度
sizeH = canvas.height / opt.h; // 网格高度
while(i<len) {
const x = i % opt.w,
y = Math.floor(i / opt.w);
ctx.fillStyle = typeof opt.colorI[i] === 'number' ? opt.colors[opt.colorI[i]] : opt.colors[i % opt.colors.length];
ctx.fillRect(x * sizeW, y * sizeH, sizeW, sizeH);
i++;
}
}
实现透明贴图
const canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d');
canvas.width = 64;
canvas.height = 64;
const texture = new THREE.CanvasTexture(canvas);
const geo = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({
color: 'deepskyblue',
alphaMap: texture,
transparent: true,
opacity: 1,
side: THREE.DoubleSide
});
这里要注意,canvas上并未绘制任何内容,我们将在loop循环中调用grid
方法进行绘制
let frame = 0,
lt = new Date(); // 上一次时间
const maxFrame = 90, // 最大帧数90帧
fps = 20; // 每秒20帧
function loop() {
const now = new Date(), // 当前时间
secs = (now - lt) / 1000, // 时间差
per = frame / maxFrame; // 进度
if (secs > 1 / fps) { // 时间差大于1/20
const colorI = [];
let i = 6 * 6;
while (i--) {
colorI.push(Math.floor(4 * Math.random()))
}
drawMethod.grid(ctx, canvas, {
w: 6,
h: 6,
colorI: colorI
});
texture.needsUpdate = true; // 更新纹理
mesh.rotation.y = Math.PI * 2 * per;
renderer.render(scene, camera);
frame += fps * secs; // 帧数累加
frame %= maxFrame; // 帧数取模,防止帧数溢出
lt = now;
}
// 渲染场景和相机
requestAnimationFrame( loop );
}
你可以看到这里每个面上被绘制了36个小矩形,并通过一下代码,随机填充颜色
while (i--) {
colorI.push(Math.floor(4 * Math.random()))
}
以上就是本章的所有内容,这里并未展示完整案例代码,是希望大家能动手练一练,很多的概念,看似晦涩难懂,实则动手尝试下的话秒懂。
来源:juejin.cn/post/7513158069419048997
从侵入式改造到声明式魔法注释的演进之路
传统方案的痛点:代码入侵
在上一篇文章中,我们通过高阶函数实现了请求缓存功能:
const cachedFetch = memoReq(function fetchData(url) {
return axios.get(url);
}, 3000);
这种方式虽然有效,但存在三个显著问题:
- 结构性破坏:必须将函数声明改为函数表达式
- 可读性下降:业务逻辑与缓存逻辑混杂
- 维护困难:缓存参数与业务代码强耦合
灵感来源:两大技术启示
1. Webpack的魔法注释
Webpack使用魔法注释控制代码分割:
import(/* webpackPrefetch: true */ './module.js');
这种声明式配置给了我们启示:能否用注释来控制缓存行为?
2. 装饰器设计模式
装饰器模式的核心思想是不改变原有对象的情况下动态扩展功能。在TypeScript中:
@memoCache(3000)
async function fetchData() {}
虽然当前项目可能不支持装饰器语法,但我们可以借鉴这种思想!
创新方案:魔法注释 + Vite插件
设计目标
- 零入侵:不改变函数声明方式
- 声明式:通过注释表达缓存意图
- 渐进式:支持逐个文件迁移
使用对比
传统方式:
export const getStockData = memoReq(
function getStockData(symbol) {
return axios.get(`/api/stocks/${symbol}`);
},
5000
);
魔法注释方案:
/* abc-memoCache(5000) */
export function getStockData(symbol) {
return axios.get(`/api/stocks/${symbol}`);
}
而有经验的程序猿会敏锐地发现三个深层问题:
- 结构性破坏:函数被迫改为函数表达式
- 关注点混杂:缓存逻辑侵入业务代码
- 维护陷阱:硬编码参数难以统一管理
技术实现深度解析
核心转换原理
- 编译时处理:通过Vite或者webpack loader插件在代码编译阶段转换
- 正则匹配:实际上是通过正则匹配实现轻量级转换
- 自动导入:智能添加必要的依赖引用
// 转换前
/* abc-memoCache(3000) */
export function fetchData() {}
// 转换后
import { memoCache } from '@/utils/decorators';
export const fetchData = memoCache(function fetchData() {}, 3000);
完整实现代码如下(以vite插件为例)
/**
* 转换代码中的装饰器注释为具体的函数调用,并处理超时配置。
*
* @param {string} code - 待处理的源代码。
* @param {string} [prefix="aa"] - 装饰器的前缀,用于标识特定的装饰器注释。
* @param {string} [utilsPath="@/utils"] - 导入工具函数的路径。
* @returns {string} - 转换后的代码。
*/
export function transformMemoReq(code, prefix = "aa", utilsPath = "@/utils") {
// 检查是否包含魔法注释模式
const magicCommentPattern = new RegExp(`\/\*\s*${prefix}-\w+\s*\([^)]*\)\s*\*\/`);
if (!magicCommentPattern.test(code)) {
return code; // 如果没有找到符合模式的注释,返回原代码
}
let transformedCode = code;
const importsNeeded = new Set(); // 收集需要的导入
// 处理带超时配置的装饰器注释(带超时数字)
const withTimeoutPattern = new RegExp(
`\/\*\s*${prefix}-(\w+)\s*\(\s*(\d*)\s*\)\s*\*\/\s*\nexport\s+function\s+(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{([\s\S]*?)\n\}`,
"g"
);
transformedCode = transformedCode.replace(
withTimeoutPattern,
(match, decoratorName, timeout, functionName, params, body) => {
const timeoutValue = timeout ? parseInt(timeout, 10) : 3000; // 默认超时为3000毫秒
const fileNameSimple = decoratorName.replace(/([A-Z].*$)/, ""); // 获取装饰器文件名
importsNeeded.add({ fileName: fileNameSimple, functionName: decoratorName }); // 添加需要导入的函数
// 提取类型注解(如果存在)
const typeAnnotationMatch = match.match(/)\s*(:\s*[^{]+)/);
const typeAnnotation = typeAnnotationMatch ? typeAnnotationMatch[1] : "";
// 返回转换后的函数定义代码
return `export const ${functionName} = ${decoratorName}(function ${functionName}(${params})${typeAnnotation} {${body}\n}, ${timeoutValue});`;
}
);
// 处理不带超时配置的装饰器注释(无超时数字)
const emptyTimeoutPattern = new RegExp(
`\/\*\s*${prefix}-(\w+)\s*\(\s*\)\s*\*\/\s*\nexport\s+function\s+(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{([\s\S]*?)\n\}`,
"g"
);
transformedCode = transformedCode.replace(emptyTimeoutPattern, (match, decoratorName, functionName, params, body) => {
const fileNameSimple = decoratorName.replace(/([A-Z].*$)/, "");
importsNeeded.add({ fileName: fileNameSimple, functionName: decoratorName });
// 提取类型注解(如果存在)
const typeAnnotationMatch = match.match(/)\s*(:\s*[^{]+)/);
const typeAnnotation = typeAnnotationMatch ? typeAnnotationMatch[1] : "";
// 返回转换后的函数定义代码,默认超时为3000毫秒
return `export const ${functionName} = ${decoratorName}(function ${functionName}(${params})${typeAnnotation} {${body}\n}, 3000);`;
});
// 如果需要导入额外的函数,处理导入语句的插入
if (importsNeeded.size > 0) {
const lines = transformedCode.split("\n");
let insertIndex = 0;
// 检查是否是Vue文件
const isVueFile = transformedCode.includes("<script");
if (isVueFile) {
// Vue文件导入位置逻辑...
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i].trim();
if (line.includes("<script")) {
insertIndex = i + 1;
for (let j = i + 1; j < lines.length; j += 1) {
const scriptLine = lines[j].trim();
if (scriptLine.startsWith("import ") || scriptLine === "") {
insertIndex = j + 1;
} else if (!scriptLine.startsWith("import ")) {
break;
}
}
break;
}
}
} else {
// 普通JS/TS/JSX/TSX文件导入位置逻辑...
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i].trim();
if (line.startsWith("import ") || line === "" || line.startsWith("interface ") || line.startsWith("type ")) {
insertIndex = i + 1;
} else {
break;
}
}
}
// 按文件分组导入
const importsByFile = {};
importsNeeded.forEach(({ fileName, functionName }) => {
if (!importsByFile[fileName]) {
importsByFile[fileName] = [];
}
importsByFile[fileName].push(functionName);
});
// 生成导入语句 - 使用自定义utilsPath
const importStatements = Object.entries(importsByFile).map(([fileName, functions]) => {
const uniqueFunctions = [...new Set(functions)];
return `import { ${uniqueFunctions.join(", ")} } from "${utilsPath}/${fileName}";`;
});
// 插入导入语句
lines.splice(insertIndex, 0, ...importStatements);
transformedCode = lines.join("\n");
}
return transformedCode; // 返回最终转换后的代码
}
/**
* Vite 插件,支持通过魔法注释转换函数装饰器。
*
* @param {Object} [options={}] - 配置选项。
* @param {string} [options.prefix="aa"] - 装饰器的前缀。
* @param {string} [options.utilsPath="@/utils"] - 工具函数的导入路径。
* @returns {Object} - Vite 插件对象。
*/
export function viteMemoDectoratorPlugin(options = {}) {
const { prefix = "aa", utilsPath = "@/utils" } = options;
return {
name: "vite-memo-decorator", // 插件名称
enforce: "pre", // 插件执行时机,设置为"pre"确保在编译前执行
transform(code, id) {
// 支持 .js, .ts, .jsx, .tsx, .vue 文件
if (!/.(js|ts|jsx|tsx|vue)$/.test(id)) {
return null; // 如果文件类型不支持,返回null
}
// 使用动态前缀检查是否需要处理该文件
const magicCommentPattern = new RegExp(`\/\*\s*${prefix}-\w+\s*\([^)]*\)\s*\*\/`);
if (!magicCommentPattern.test(code)) {
return null; // 如果没有找到符合模式的注释,返回null
}
console.log(`🔄 Processing ${prefix}-* magic comments in: ${id}`);
try {
const result = transformMemoReq(code, prefix, utilsPath); // 调用转换函数
if (result !== code) {
console.log(`✅ Transform successful for: ${id}`);
return {
code: result, // 返回转换后的代码
map: null, // 如果需要支持source map,可以在这里添加
};
}
} catch (error) {
console.error(`❌ Transform error in ${id}:`, error.message);
}
return null;
},
};
}
vite使用方式
viteMemoDectoratorPlugin({
prefix: "abc",
}),
结语:成为解决方案的设计者
从闭包到魔法注释的演进:
- 发现问题:识别现有方案的深层缺陷
- 联想类比:从其他领域寻找灵感
- 创新设计:创造性地组合技术要素
- 工程落地:考虑实际约束条件
在这个技术飞速发展的时代,我们牛马面临着知识爆炸,卷到没边的风气,我们只能建立更系统的技术认知体系。只会复制粘贴代码的开发者注定会陷入越忙越累的怪圈,比如最近很火的vue不想使用虚拟dom,其实我们只需要知道为什么,那是不是又多了点知识储备,因为技术迭代的速度永远快于机械记忆的速度。真正的技术能力体现在对知识本质的理解和创造性应用上——就像本文中的缓存方案,从最初的闭包实现到魔法注释优化,每一步实现都源于对多种技术思想的相融。阅读技术博客时,不能满足于解决眼前问题,更要揣摩作者的设计哲学;我们要善用AI等现代工具,但不是简单地向它索要代码,而是通过它拓展思维边界;愿我们都能超越代码搬运工的局限,成为真正的问题解决者和价值创造者。技术之路没有捷径,但有方法;没有终点,但有无尽的风景。加油吧,程序猿朋友们!!!
来源:juejin.cn/post/7536178965851029544
TailwindCSS 与 -webkit-line-clamp 深度解析:现代前端开发的样式革命
引言
在现代前端开发的浪潮中,CSS 的编写方式正在经历一场深刻的变革。传统的 CSS 开发模式虽然功能强大,但往往伴随着样式冲突、维护困难、代码冗余等问题。开发者需要花费大量时间在样式的命名、组织和维护上,而真正用于业务逻辑实现的时间却相对有限。
TailwindCSS 的出现,如同一股清流,为前端开发者带来了全新的开发体验。它不仅仅是一个 CSS 框架,更是一种全新的设计哲学——原子化 CSS 的完美实践。与此同时,在处理文本显示的细节问题上,诸如 -webkit-line-clamp
这样的 CSS 属性,虽然看似简单,却蕴含着深层的浏览器渲染原理。
本文将深入探讨 TailwindCSS 的核心理念、配置方法以及实际应用,同时详细解析 -webkit-line-clamp
的底层工作机制,帮助开发者更好地理解和运用这些现代前端技术。无论你是刚接触前端开发的新手,还是希望提升开发效率的资深开发者,这篇文章都将为你提供有价值的见解和实用的技巧。
TailwindCSS:原子化 CSS 的艺术
什么是原子化 CSS
原子化 CSS(Atomic CSS)是一种 CSS 架构方法,其核心思想是将样式拆分成最小的、不可再分的单元——就像化学中的原子一样。每个 CSS 类只负责一个特定的样式属性,比如 text-center
只负责文本居中,bg-blue-500
只负责设置蓝色背景。
传统的 CSS 开发模式往往采用组件化的方式,为每个 UI 组件编写独立的样式类。例如,一个按钮组件可能会有这样的 CSS:
.button {
padding: 12px 24px;
background-color: #3b82f6;
color: white;
border-radius: 6px;
font-weight: 600;
transition: background-color 0.2s;
}
.button:hover {
background-color: #2563eb;
}
这种方式在小型项目中运行良好,但随着项目规模的增长,会出现以下问题:
- 样式重复:不同组件可能需要相似的样式,导致代码重复
- 命名困难:为每个组件和状态想出合适的类名变得越来越困难
- 维护复杂:修改一个样式可能影响多个组件,需要谨慎处理
- CSS 文件膨胀:随着功能增加,CSS 文件变得越来越大
原子化 CSS 通过将样式拆分成最小单元来解决这些问题。上面的按钮样式在 TailwindCSS 中可以这样表示:
<button class="px-6 py-3 bg-blue-500 text-white rounded-md font-semibold hover:bg-blue-600 transition-colors">
Click me
</button>
每个类名都有明确的职责:
px-6
:左右内边距 1.5rem(24px)py-3
:上下内边距 0.75rem(12px)bg-blue-500
:蓝色背景text-white
:白色文字rounded-md
:中等圆角font-semibold
:半粗体字重hover:bg-blue-600
:悬停时的深蓝色背景transition-colors
:颜色过渡动画
TailwindCSS 的核心特性
TailwindCSS 作为原子化 CSS 的杰出代表,具有以下核心特性:
1. 几乎不用写 CSS
这是 TailwindCSS 最吸引人的特性之一。在传统开发中,开发者需要在 HTML 和 CSS 文件之间频繁切换,思考类名、编写样式、处理选择器优先级等问题。而使用 TailwindCSS,大部分样式都可以直接在 HTML 中通过预定义的类名来实现。
这种方式带来的好处是显而易见的:
- 开发速度提升:无需在文件间切换,样式即写即见
- 认知负担减轻:不需要思考复杂的类名和样式组织
- 一致性保证:使用统一的设计系统,避免样式不一致
2. AI 代码生成的首选框架
在人工智能辅助编程的时代,TailwindCSS 已经成为 AI 工具生成前端代码时的首选 CSS 框架。这主要有以下几个原因:
- 语义化程度高:TailwindCSS 的类名具有很强的语义性,AI 可以更容易理解和生成
- 标准化程度高:作为业界标准,AI 模型在训练时接触了大量 TailwindCSS 代码
- 组合性强:原子化的特性使得 AI 可以灵活组合不同的样式类
当你使用 ChatGPT、Claude 或其他 AI 工具生成前端代码时,它们几乎总是会使用 TailwindCSS 来处理样式,这已经成为了一种行业默认标准。
3. 丰富的内置类名系统
TailwindCSS 提供了一套完整而系统的类名体系,涵盖了前端开发中几乎所有的样式需求:
- 布局类:
flex
、grid
、block
、inline
等 - 间距类:
m-4
、p-2
、space-x-4
等 - 颜色类:
text-red-500
、bg-blue-200
、border-gray-300
等 - 字体类:
text-lg
、font-bold
、leading-tight
等 - 响应式类:
md:text-xl
、lg:flex
、xl:grid-cols-4
等 - 状态类:
hover:bg-gray-100
、focus:ring-2
、active:scale-95
等
这些类名都遵循一致的命名规范,学会了基本规则后,即使遇到没用过的类名也能快速理解其含义。
配置与使用
安装和配置流程
要在项目中使用 TailwindCSS,需要经过以下几个步骤:
1. 安装依赖包
npm install -D tailwindcss @vitejs/plugin-tailwindcss
这里安装了两个包:
tailwindcss
:TailwindCSS 的核心包@vitejs/plugin-tailwindcss
:Vite 的 TailwindCSS 插件,用于在构建过程中处理 TailwindCSS
2. 生成配置文件
npx tailwindcss init
这个命令会在项目根目录生成一个 tailwind.config.js
文件:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
content
数组指定了 TailwindCSS 应该扫描哪些文件来查找使用的类名,这对于生产环境的样式优化非常重要。
vite.config.js 配置详解
在 Vite 项目中,需要在 vite.config.js
中配置 TailwindCSS 插件:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@vitejs/plugin-tailwindcss'
export default defineConfig({
plugins: [
react(),
tailwindcss()
],
})
这个配置告诉 Vite 在构建过程中使用 TailwindCSS 插件来处理 CSS 文件。插件会自动:
- 扫描指定的文件查找使用的 TailwindCSS 类名
- 生成对应的 CSS 代码
- 在生产环境中移除未使用的样式(Tree Shaking)
tailwind.css 引入方式
在项目的主 CSS 文件(通常是 src/index.css
或 src/main.css
)中引入 TailwindCSS 的基础样式:
@tailwind base;
@tailwind components;
@tailwind utilities;
这三个指令分别引入了:
base
:重置样式和基础样式components
:组件样式(可以自定义)utilities
:工具类样式(TailwindCSS 的核心)
单位系统解析
TailwindCSS 使用了一套独特而直观的单位系统。其中最重要的概念是:1rem = 4 个单位。
这意味着:
w-4
=width: 1rem
=16px
(在默认字体大小下)p-2
=padding: 0.5rem
=8px
m-8
=margin: 2rem
=32px
这套系统的设计非常巧妙:
- 易于记忆:4 的倍数关系简单直观
- 设计友好:符合设计师常用的 8px 网格系统
- 响应式友好:基于 rem 单位,能够很好地适应不同的屏幕尺寸
常用的间距对照表:
类名 | CSS 值 | 像素值(16px 基准) |
---|---|---|
p-1 | 0.25rem | 4px |
p-2 | 0.5rem | 8px |
p-3 | 0.75rem | 12px |
p-4 | 1rem | 16px |
p-6 | 1.5rem | 24px |
p-8 | 2rem | 32px |
p-12 | 3rem | 48px |
p-16 | 4rem | 64px |
这套系统不仅适用于内外边距,也适用于宽度、高度、字体大小等其他尺寸相关的属性。
-webkit-line-clamp:文本截断的底层原理
浏览器内核基础知识
在深入了解 -webkit-line-clamp
之前,我们需要先理解浏览器内核的基本概念。浏览器内核(Browser Engine)是浏览器的核心组件,负责解析 HTML、CSS,并将网页内容渲染到屏幕上。不同的浏览器使用不同的内核,这也是为什么某些 CSS 属性需要添加特定前缀的原因。
主要浏览器内核及其前缀:
- WebKit 内核(-webkit-)
- 使用浏览器:Chrome、Safari、新版 Edge、Opera
- 特点:由苹果公司开发,后来被 Google 采用并发展出 Blink 内核
- 市场份额:目前占据主导地位,超过 70% 的市场份额
- Gecko 内核(-moz-)
- 使用浏览器:Firefox
- 特点:由 Mozilla 基金会开发,注重标准化和开放性
- 市场份额:约 3-5% 的市场份额
- Trident/EdgeHTML 内核(-ms-)
- 使用浏览器:旧版 Internet Explorer、旧版 Edge
- 特点:微软开发,现已基本被淘汰
由于 WebKit 内核的广泛使用,许多实验性的 CSS 属性首先在 WebKit 中实现,并使用 -webkit-
前缀。-webkit-line-clamp
就是其中的一个典型例子。
实验性属性的概念
CSS 中的实验性属性(Experimental Properties)是指那些尚未成为正式 W3C 标准,但已经在某些浏览器中实现的功能。这些属性通常具有以下特征:
- 前缀标识:使用浏览器厂商前缀,如
-webkit-
、-moz-
、-ms-
等 - 功能性强:虽然不是标准,但能解决实际开发中的问题
- 兼容性限制:只在特定浏览器中工作
- 可能变化:语法和行为可能在未来版本中发生变化
-webkit-line-clamp
正是这样一个实验性属性。它最初是为了解决移动端 WebKit 浏览器中多行文本截断的需求而设计的,虽然不是 CSS 标准的一部分,但由于其实用性,被广泛采用并逐渐得到其他浏览器的支持。
-webkit-line-clamp 深度解析
属性的工作原理
-webkit-line-clamp
是一个用于限制文本显示行数的 CSS 属性。当文本内容超过指定行数时,多余的内容会被隐藏,并在最后一行的末尾显示省略号(...)。
这个属性的工作原理涉及到浏览器的文本渲染机制:
- 文本流计算:浏览器首先计算文本在容器中的自然流动方式
- 行数统计:根据容器宽度、字体大小、行高等因素计算文本占用的行数
- 截断处理:当行数超过
line-clamp
指定的值时,截断多余内容 - 省略号添加:在最后一行的适当位置添加省略号
为什么不能独自生效
这是 -webkit-line-clamp
最容易让开发者困惑的地方。单独使用这个属性是无效的,必须配合其他 CSS 属性才能正常工作。这是因为 -webkit-line-clamp
的设计初衷是作为 Flexbox 布局的一部分来工作的。
具体来说,-webkit-line-clamp
只在以下条件同时满足时才会生效:
- 容器必须是 Flexbox:
display: -webkit-box
- 必须设置排列方向:
-webkit-box-orient: vertical
- 必须隐藏溢出内容:
overflow: hidden
这种设计反映了早期 WebKit 对 Flexbox 规范的实现方式。在当时,-webkit-box
是 Flexbox 的早期实现,而 -webkit-line-clamp
被设计为在这种布局模式下工作。
必需的配套属性详解
让我们详细分析每个必需的配套属性:
1. display: -webkit-box
display: -webkit-box;
这个属性将元素设置为 WebKit 的旧版 Flexbox 容器。在现代 CSS 中,我们通常使用 display: flex
,但 -webkit-line-clamp
需要这个特定的值才能工作。
-webkit-box
是 2009 年 Flexbox 规范的实现,虽然已经过时,但为了兼容 -webkit-line-clamp
,我们仍然需要使用它。这个值会:
- 将元素转换为块级容器
- 启用 WebKit 的 Flexbox 布局引擎
- 为
-webkit-line-clamp
提供必要的布局上下文
2. -webkit-box-orient: vertical
-webkit-box-orient: vertical;
这个属性设置 Flexbox 容器的主轴方向为垂直。在文本截断的场景中,我们需要垂直方向的布局来正确计算行数。
可选值包括:
horizontal
:水平排列(默认值)vertical
:垂直排列inline-axis
:沿着内联轴排列block-axis
:沿着块轴排列
对于文本截断,我们必须使用 vertical
,因为:
- 文本行是垂直堆叠的
-webkit-line-clamp
需要在垂直方向上计算行数- 只有在垂直布局下,行数限制才有意义
3. overflow: hidden
overflow: hidden;
这个属性隐藏超出容器边界的内容。在文本截断的场景中,它的作用是:
- 隐藏超出指定行数的文本内容
- 确保省略号正确显示在可见区域内
- 防止内容溢出影响页面布局
如果不设置 overflow: hidden
,超出行数限制的文本仍然会显示,-webkit-line-clamp
就失去了意义。
完整的文本截断方案
将所有必需的属性组合起来,一个完整的文本截断方案如下:
.text-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
这个方案会将文本限制在 2 行内,超出的内容会被隐藏并显示省略号。
浏览器兼容性分析
虽然 -webkit-line-clamp
带有 WebKit 前缀,但实际上它的兼容性比想象中要好:
浏览器 | 支持版本 | 备注 |
---|---|---|
Chrome | 6+ | 完全支持 |
Safari | 5+ | 完全支持 |
Firefox | 68+ | 2019年开始支持 |
Edge | 17+ | 基于 Chromium 的版本支持 |
IE | 不支持 | 需要 JavaScript 降级方案 |
现代浏览器(除了 IE)都已经支持这个属性,使得它在实际项目中具有很高的可用性。
高级用法和注意事项
1. 响应式行数控制
可以结合媒体查询实现响应式的行数控制:
.responsive-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 3;
}
@media (max-width: 768px) {
.responsive-clamp {
-webkit-line-clamp: 2;
}
}
2. 与其他 CSS 属性的交互
-webkit-line-clamp
与某些 CSS 属性可能产生冲突:
- white-space: nowrap:会阻止文本换行,使 line-clamp 失效
- height 固定值:可能与 line-clamp 的高度计算冲突
- line-height:会影响行数的计算,需要谨慎设置
3. 性能考虑
使用 -webkit-line-clamp
时需要注意性能影响:
- 浏览器需要重新计算文本布局
- 在大量元素上使用可能影响渲染性能
- 动态改变 line-clamp 值会触发重排(reflow)
实战应用与代码示例
line-clamp 在 TailwindCSS 中的应用
TailwindCSS 内置了对 -webkit-line-clamp
的支持,提供了 line-clamp-{n}
工具类。让我们看看如何在实际项目中使用这些类。
基础使用示例
// 产品卡片组件
function ProductCard({ product }) {
return (
<div className="card max-w-sm">
{/* 产品图片 */}
<div className="relative">
<img
src={product.image}
alt={product.name}
className="w-full h-64 object-cover"
/>
{product.isNew && (
<span className="absolute top-2 left-2 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded">
New
</span>
)}
</div>
{/* 产品信息 */}
<div className="p-6">
{/* 产品标题 - 限制1行 */}
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mb-2">
{product.name}
</h3>
{/* 产品描述 - 限制2行 */}
<p className="text-sm text-gray-600 line-clamp-2 mb-4">
{product.description}
</p>
{/* 产品特性 - 限制3行 */}
<div className="text-xs text-gray-500 line-clamp-3 mb-4">
{product.features.join(' • ')}
</div>
{/* 价格和操作 */}
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-gray-900">
${product.price}
</span>
<button className="btn-primary">
Add to Cart
</button>
</div>
</div>
</div>
);
}
在这个示例中,我们使用了不同的 line-clamp
值来处理不同类型的文本内容:
line-clamp-1
:产品标题保持在一行内line-clamp-2
:产品描述限制在两行内line-clamp-3
:产品特性列表限制在三行内
响应式文本截断
TailwindCSS 的响应式前缀可以与 line-clamp
结合使用,实现不同屏幕尺寸下的不同截断行为:
function ArticleCard({ article }) {
return (
<article className="card">
<div className="p-6">
{/* 响应式标题截断 */}
<h2 className="text-xl font-bold text-gray-900 line-clamp-2 md:line-clamp-1 mb-3">
{article.title}
</h2>
{/* 响应式内容截断 */}
<p className="text-gray-600 line-clamp-3 sm:line-clamp-4 lg:line-clamp-2 mb-4">
{article.content}
</p>
{/* 标签列表 - 移动端截断更多 */}
<div className="text-sm text-gray-500 line-clamp-2 md:line-clamp-1">
{article.tags.map(tag => `#${tag}`).join(' ')}
</div>
</div>
</article>
);
}
这个示例展示了如何根据屏幕尺寸调整文本截断行为:
- 移动端:标题显示2行,内容显示3行
- 平板端:标题显示1行,内容显示4行
- 桌面端:标题显示1行,内容显示2行
动态 line-clamp 控制
有时我们需要根据用户交互动态改变文本的截断行为:
import { useState } from 'react';
function ExpandableText({ text, maxLines = 3 }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="space-y-2">
<p className={`text-gray-700 ${isExpanded ? '' : `line-clamp-${maxLines}`}`}>
{text}
</p>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
>
{isExpanded ? 'Show Less' : 'Show More'}
</button>
</div>
);
}
// 使用示例
function ReviewCard({ review }) {
return (
<div className="card p-6">
<div className="flex items-center mb-4">
<img
src={review.avatar}
alt={review.author}
className="w-10 h-10 rounded-full mr-3"
/>
<div>
<h4 className="font-semibold text-gray-900">{review.author}</h4>
<div className="flex items-center">
{/* 星级评分 */}
{[...Array(5)].map((_, i) => (
<svg
key={i}
className={`w-4 h-4 ${i < review.rating ? 'text-yellow-400' : 'text-gray-300'} fill-current`}
viewBox="0 0 24 24"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
))}
</div>
</div>
</div>
{/* 可展开的评论内容 */}
<ExpandableText text={review.content} maxLines={4} />
</div>
);
}
这个示例展示了如何创建一个可展开的文本组件,用户可以点击按钮来显示完整内容或收起到指定行数。
最佳实践与总结
开发建议
在实际项目中使用 TailwindCSS 和 -webkit-line-clamp
时,以下最佳实践将帮助你获得更好的开发体验和项目质量:
TailwindCSS 开发最佳实践
1. 合理组织类名
虽然 TailwindCSS 鼓励在 HTML 中直接使用工具类,但过长的类名列表会影响代码可读性。建议采用以下策略:
// ❌ 避免:过长的类名列表
<div className="flex items-center justify-between p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow duration-200 ease-in-out">
// ✅ 推荐:使用组件抽象
const Card = ({ children, className = "" }) => (
<div className={`card hover:shadow-md transition-shadow ${className}`}>
{children}
</div>
);
// ✅ 推荐:使用 @apply 指令创建组件类
// 在 CSS 中定义
.card {
@apply flex items-center justify-between p-6 bg-white rounded-lg shadow-sm border border-gray-200;
}
2. 建立设计系统
充分利用 TailwindCSS 的配置系统建立项目专属的设计系统:
// tailwind.config.js
module.exports = {
theme: {
extend: {
// 定义项目色彩系统
colors: {
brand: {
primary: '#3B82F6',
secondary: '#10B981',
accent: '#F59E0B',
}
},
// 定义间距系统
spacing: {
'18': '4.5rem',
'88': '22rem',
},
// 定义字体系统
fontSize: {
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['1rem', { lineHeight: '1.5rem' }],
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
}
}
}
}
3. 性能优化策略
TailwindCSS 的性能优化主要体现在生产环境的样式清理:
// tailwind.config.js
module.exports = {
content: [
// 精确指定扫描路径,避免不必要的文件扫描
"./src/**/*.{js,jsx,ts,tsx}",
"./public/index.html",
// 如果使用了第三方组件库,也要包含其路径
"./node_modules/@my-ui-lib/**/*.{js,jsx}",
],
// 启用 JIT 模式获得更好的性能
mode: 'jit',
}
4. 响应式设计策略
采用移动优先的设计理念,合理使用响应式前缀:
// ✅ 移动优先的响应式设计
<div className="
grid grid-cols-1 gap-4
sm:grid-cols-2 sm:gap-6
md:grid-cols-3 md:gap-8
lg:grid-cols-4
xl:gap-10
">
{/* 内容 */}
</div>
// ✅ 响应式文字大小
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold">
标题
</h1>
line-clamp 使用最佳实践
1. 选择合适的截断行数
不同类型的内容需要不同的截断策略:
内容类型 | 推荐行数 | 使用场景 |
---|---|---|
标题 | 1-2行 | 卡片标题、列表项标题 |
摘要/描述 | 2-3行 | 产品描述、文章摘要 |
详细内容 | 3-5行 | 评论内容、详细说明 |
标签列表 | 1-2行 | 标签云、分类列表 |
2. 考虑内容的语义完整性
// ✅ 好的实践:为截断的内容提供完整查看选项
function ProductDescription({ description }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div>
<p className={isExpanded ? '' : 'line-clamp-3'}>
{description}
</p>
{description.length > 150 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-blue-600 text-sm mt-1"
>
{isExpanded ? '收起' : '查看更多'}
</button>
)}
</div>
);
}
3. 处理不同语言的截断
不同语言的文字密度不同,需要相应调整截断行数:
// 根据语言调整截断行数
function MultiLanguageText({ text, language }) {
const getLineClampClass = (lang) => {
switch (lang) {
case 'zh': return 'line-clamp-2'; // 中文字符密度高
case 'en': return 'line-clamp-3'; // 英文需要更多行数
case 'ja': return 'line-clamp-2'; // 日文类似中文
default: return 'line-clamp-3';
}
};
return (
<p className={`text-gray-700 ${getLineClampClass(language)}`}>
{text}
</p>
);
}
性能考虑
TailwindCSS 性能优化
1. 构建时优化
TailwindCSS 在构建时会自动移除未使用的样式,但我们可以进一步优化:
// postcss.config.js
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
// 生产环境启用 CSS 压缩
process.env.NODE_ENV === 'production' && require('cssnano')({
preset: 'default',
}),
].filter(Boolean),
}
2. 运行时性能
避免在运行时动态生成类名,这会影响 TailwindCSS 的优化效果:
// ❌ 避免:动态类名生成
const dynamicClass = `text-${color}-500`; // 可能不会被包含在最终构建中
// ✅ 推荐:使用完整的类名
const colorClasses = {
red: 'text-red-500',
blue: 'text-blue-500',
green: 'text-green-500',
};
const selectedClass = colorClasses[color];
line-clamp 性能影响
1. 重排和重绘
-webkit-line-clamp
的使用会触发浏览器的重排(reflow),在大量元素上使用时需要注意性能:
// ✅ 使用 CSS containment 优化性能
.text-container {
contain: layout style;
-webkit-line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
2. 虚拟化长列表
在处理大量带有文本截断的列表项时,考虑使用虚拟化技术:
import { FixedSizeList as List } from 'react-window';
function VirtualizedProductList({ products }) {
const Row = ({ index, style }) => (
<div style={style}>
<ProductCard product={products[index]} />
</div>
);
return (
<List
height={600}
itemCount={products.length}
itemSize={200}
>
{Row}
</List>
);
}
总结
TailwindCSS 和 -webkit-line-clamp
代表了现代前端开发中两个重要的技术趋势:工具化的 CSS 开发和细粒度的样式控制。
TailwindCSS 的价值在于:
- 开发效率的显著提升:通过原子化的类名系统,开发者可以快速构建界面而无需编写大量自定义 CSS
- 设计系统的一致性:内置的设计令牌确保了整个项目的视觉一致性
- 维护成本的降低:减少了 CSS 文件的复杂性和样式冲突的可能性
- 团队协作的改善:统一的类名约定降低了团队成员之间的沟通成本
-webkit-line-clamp 的意义在于:
- 用户体验的优化:通过优雅的文本截断保持界面的整洁和一致性
- 响应式设计的支持:在不同屏幕尺寸下提供合适的内容展示
- 性能的考虑:避免了复杂的 JavaScript 文本处理逻辑
- 标准化的推动:虽然是实验性属性,但推动了相关 CSS 标准的发展
在实际项目中,这两个技术的结合使用能够帮助开发者:
- 快速原型开发:在设计阶段快速验证界面效果
- 响应式布局:轻松适配各种设备和屏幕尺寸
- 内容管理:优雅处理动态内容的显示问题
- 性能优化:减少 CSS 体积和运行时计算
随着前端技术的不断发展,我们可以期待看到更多类似的工具和技术出现,它们将继续推动前端开发向着更高效、更标准化的方向发展。对于前端开发者而言,掌握这些现代技术不仅能提升当前的开发效率,更重要的是能够跟上技术发展的步伐,为未来的项目做好准备。
无论你是刚开始学习前端开发的新手,还是希望优化现有项目的资深开发者,TailwindCSS 和 -webkit-line-clamp
都值得你深入学习和实践。它们不仅是技术工具,更代表了现代前端开发的最佳实践和发展方向。
来源:juejin.cn/post/7536092776867840039
React 核心 API 全景实战:从状态管理到性能优化,一网打尽
✨ 为什么写这篇文章?
很多前端朋友在用 React
的时候:
- 只会用
useState
做局部状态,结果项目一大就乱套。 - 不了解
useReducer
和Context
,复杂页面全靠 props 一层层传。 - 性能卡顿后,只知道用
React.memo
,但为什么卡? useMemo
和useCallback
的区别 ?- 明明只是个
Modal
,结果被卡在组件层级里动弹不得,不知道可以用Portals
。
👉「在什么场景下选用哪个 API」+「如何写出最合理的 React 代码」。
🟢 1. useState:局部状态管理
🌳 场景:表单输入管理
比起枯燥的计数器,这里用表单输入做示例。
import { useState } from 'react';
export default function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = e => {
e.preventDefault();
console.log("登录中", username, password);
}
return (
<form onSubmit={handleSubmit}>
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="用户名"/>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="密码"/>
<button type="submit">登录</button>
</form>
);
}
🚀 优势
- 简单、直接
- 适用于小型、独立的状态
🟡 2. useEffect:副作用处理
🌍 场景:组件挂载时拉取远程数据
import { useEffect, useState } from 'react';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
});
return () => {
// 组件销毁执行此回调
};
}, []);
return user ? <h1>{user.name}</h1> : <p>加载中...</p>;
}
🚀 优势
- 集中管理副作用(请求、订阅、定时器、事件监听)
🔵 3. useRef & useImperativeHandle:DOM、实例方法控制
场景 1:聚焦输入框
import { useRef, useEffect } from 'react';
export default function AutoFocusInput() {
const inputRef = useRef();
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} placeholder="自动聚焦" />;
}
场景 2:在父组件调用子组件的方法
import { forwardRef, useRef, useImperativeHandle } from 'react';
const FancyInput = forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus()
}));
return <input ref={inputRef} />;
});
export default function App() {
const fancyRef = useRef();
return (
<>
<FancyInput ref={fancyRef} />
<button onClick={() => fancyRef.current.focus()}>父组件聚焦子组件</button>
</>
);
}
🧭 4. Context & useContext:解决多层级传值
场景:用户登录信息在多层组件使用
import React, { createContext, useContext } from 'react';
const UserContext = createContext();
/** 设置在 DevTools 中将显示为 User */
UserContext.displayName = 'User'
function Navbar() {
return (
<div>
<UserInfo />
</div>
)
}
function UserInfo() {
const user = useContext(UserContext);
return <span>欢迎,{user.name}</span>;
}
export default function App() {
return (
<UserContext.Provider value={{ name: 'Zheng' }}>
<Navbar />
</UserContext.Provider>
);
}
🚀 优势
- 解决「祖孙组件传值太麻烦」的问题
🔄 5. useReducer:复杂状态管理
import { useReducer } from 'react';
function reducer(state, action) {
switch(action.type){
case 'next':
return { ...state, step: state.step + 1 };
case 'prev':
return { ...state, step: state.step - 1 };
default:
return state;
}
}
export default function Wizard() {
const [state, dispatch] = useReducer(reducer, { step: 1 });
return (
<>
<h1>步骤 {state.step}</h1>
<button onClick={() => dispatch({type: 'prev'})}>上一步</button>
<button onClick={() => dispatch({type: 'next'})}>下一步</button>
</>
);
}
🆔 6. useId:避免 SSR / 并发下 ID 不一致
import { useId } from 'react';
export default function FormItem() {
const id = useId();
return (
<>
<label htmlFor={id}>姓名</label>
<input id={id} type="text" />
</>
);
}
🚀 7. Portals:在根元素渲染 Modal
import { useState } from 'react';
import ReactDOM from 'react-dom';
function Modal({ onClose }) {
return ReactDOM.createPortal(
<div style={{ position: "fixed", top: 100, left: 100, background: "white" }}>
<h1>这是 Modal</h1>
<button onClick={onClose}>关闭</button>
</div>,
document.getElementById('root')
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(true)}>打开 Modal</button>
{show && <Modal onClose={() => setShow(false)} />}
</>
);
}
在上面代码中,我们将要渲染的视图作为createPortal
方法的第一个参数,而第二个参数用于指定要渲染到那个DOM元素中。
尽管 portal 可以被放置在 DOM 树中的任何地方,但在任何其他方面,其行为和普通的 React 子节点行为一致。由于 portal 仍存在于 React 树, 且与 DOM 树中的位置无关,那么无论其子节点是否是 portal,像 context 这样的功能特性都是不变的。
这包含事件冒泡。一个从 portal 内部触发的事件会一直冒泡至包含 React 树的祖先,即便这些元素并不是 DOM 树中的祖先。
🔍 8. 组件渲染性能优化
🐘 之前类组件时代:shouldComponentUpdate与PureComponent
import { Component } from 'react'
export default class App extends Component {
constructor() {
super();
this.state = {
counter: 1
}
}
render() {
console.log("App 渲染了");
return (
<div>
<h1>App 组件</h1>
<div>{this.state.counter}</div>
<button onClick={() => this.setState({
counter : 1
})}>+1</button>
</div>
)
}
}
在上面的代码中,按钮在点击的时候仍然是设置 counter 的值为1,虽然 counter
的值没有变,整个组件仍然是重新渲染了的,显然,这一次渲染是没有必要的。
当 props
或 state
发生变化时,shouldComponentUpdate
会在渲染执行之前被调用。返回值默认为 true。首次渲染或使用 forceUpdate 方法时不会调用该方法。
下面我们来使用 shouldComponentUpdate 优化上面的示例:
import React from 'react'
/**
* 对两个对象进行一个浅比较,看是否相等
* obj1
* obj2
* 返回布尔值 true 代表两个对象相等, false 代表不相等
*/
function objectEqual(obj1, obj2) {
for (let prop in obj1) {
if (!Object.is(obj1[prop], obj2[prop])) {
// 进入此 if,说明有属性值不相等
// 只要有一个不相等,那么就应该判断两个对象不等
return false
}
}
return true
}
class PureChildCom1 extends React.Component {
constructor(props) {
super(props)
this.state = {
counter: 1,
}
}
// 验证state未发生改变,是否会执行render
onClickHandle = () => {
this.setState({
counter: Math.floor(Math.random() * 3 + 1),
})
}
// shouldComponentUpdate 中返回 false 来跳过整个渲染过程。其包括该组件的 render 调用以及之后的操作。
// 返回true 只要执行了setState都会重新渲染
shouldComponentUpdate(nextProps, nextState) {
if (
objectEqual(this.props, nextProps) &&
objectEqual(this.state, nextState)
) {
return false
}
return true
}
render() {
console.log('render')
return (
<div>
<div>{this.state.counter}</div>
<button onClick={this.onClickHandle}>点击</button>
</div>
)
}
}
export default PureChildCom1
PureComponent
内部做浅比较:如果 props/state 相同则跳过渲染。- 不适用于复杂对象(如数组、对象地址未变)。
🥇 React.memo:函数组件记忆化
上面主要是优化类组件的渲染性能,那么如果是函数组件该怎么办呢?
React中为我们提供了memo
高阶组件,只要 props 不变,就不重新渲染。
const Child = React.memo(function Child({name}) {
console.log("Child 渲染");
return <div>{name}</div>;
});
🏷 useCallback:缓存函数引用,避免触发子组件更新
import React, { useState, useCallback } from 'react';
function Child({ onClick }) {
console.log("Child 渲染")
return <button onClick={onClick}>点我</button>;
}
const MemoChild = React.memo(Child);
export default function App() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("点击");
}, []);
return (
<>
<div>{count}</div>
<button onClick={() => setCount(count+1)}>+1</button>
<MemoChild onClick={handleClick} />
</>
);
}
在上面的代码中,我们对Child
组件进行了memo
缓存,当修改App
组件中的count值的时候,不会引起Child
组件更新;使用了useCallback
对函数进行了缓存,当点击Child
组件中的button时也不会引起父组件的更新。
🔢 useMemo:缓存计算
某些时候,组件中某些值需要根据状态进行一个二次计算(类似于 Vue 中的计算属性),由于函数组件一旦重新渲染,就会重新执行整个函数,这就导致之前的二次计算也会重新执行一次。
import React, { useState } from 'react';
function App() {
const [count, setCount] = useState(1);
const [val, setValue] = useState('');
console.log("App render");
// 使用useMemo缓存计算
const getNum = useMemo(() => {
console.log('调用了!!!!!');
return count + 100;
}, [count])
return (
<div>
<h4>总和:{getNum()}</h4>
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
{/* 文本框的输入会导致整个组件重新渲染 */}
<input value={val} onChange={event => setValue(event.target.value)} />
</div>
</div>
);
}
export default App;
在上面的示例中,文本框的输入会导致整个 App
组件重新渲染,但是 count
的值是没有改变的,所以 getNum
这个函数也是没有必要重新执行的。我们使用了 useMemo
来缓存二次计算的值,并设置了依赖项 count
,只有在 count
发生改变时,才会重新执行二次计算。
面试题:useMemo 和 useCallback 的区别及使用场景?
useMemo
和 useCallback
接收的参数都是一样,第一个参数为回调,第二个参数为要依赖的数据。
共同作用: 仅仅依赖数据发生变化,才会去更新缓存。
两者区别:
useMemo
计算结果是return
回来的值, 主要用于缓存计算结果的值。应用场景如:需要进行二次计算的状态useCallback
计算结果是函数, 主要用于缓存函数,应用场景如: 需要缓存的函数,因为函数式组件每次任何一个state
的变化,整个组件都会被重新刷新,一些函数是没有必要被重新刷新的,此时就应该缓存起来,提高性能,和减少资源浪费。
来源:juejin.cn/post/7525375329105674303
Vue 3 中的 Watch、WatchEffect 和 Computed:深入解析与案例分析
引言
在前端开发中,尤其是使用 Vue.js 进行开发时,我们经常需要监听数据的变化以执行相应的操作。Vue 3 提供了三种主要的方法来实现这一目标:watch
、watchEffect
和 computed
。虽然它们都能帮助我们监听数据变化,但各自的适用场景和工作原理有所不同。本文将详细探讨这三者的区别,并通过具体的案例进行说明。
一、Computed 属性
1.1 定义与用途
computed
是 Vue 中用于定义计算属性的方法。它允许你基于其他响应式数据创建一个新的响应式数据。这个新数据会根据其依赖的数据自动更新。
生活中的类比:
想象一下你在超市里购买商品,每个商品都有一个价格标签。当你想要知道购物车里所有商品的总价时,你可以手动计算每件商品的价格乘以其数量,然后加起来得到总价。但是如果你使用了一个智能购物车,它能够自动为你计算总价(只要你知道单价和数量),这就是 computed
的作用——它能帮你自动计算并实时更新结果。
1.2 使用示例
import { ref, computed } from 'vue';
const price = ref(10);
const quantity = ref(5);
const totalPrice = computed(() => {
return price.value * quantity.value;
});
console.log(totalPrice.value); // 输出: 50
// 修改其中一个变量
price.value = 15;
console.log(totalPrice.value); // 输出: 75 自动更新
二、Watch 监听器
2.1 定义与用途
watch
允许你监听特定的数据源(如响应式引用或 getter 函数的结果),并在数据发生变化时执行回调函数。它可以监听单个源或多个源。
生活中的类比:
假设你现在正在做菜,你需要监控锅里的水是否沸腾。一旦水开始沸腾,你就知道是时候下饺子了。这里,“水是否沸腾”就是你要监听的数据源,而“下饺子”的动作则是监听到变化后执行的操作。
2.2 使用示例
import { ref, watch } from 'vue';
let waterBoiling = ref(false);
watch(waterBoiling, (newValue, oldValue) => {
if (newValue === true) {
console.log('Water is boiling, time to add the dumplings!');
}
});
waterBoiling.value = true; // 触发监听器
2.3 监听多个来源
有时候我们需要同时监听多个数据源的变化:
watch([sourceA, sourceB], ([newSourceA, newSourceB], [oldSourceA, oldSourceB]) => {
// 处理逻辑
});
三、WatchEffect 响应式效果
3.1 定义与用途
watchEffect
立即运行传入的函数,并响应式地追踪其内部使用的任何 reactive 数据。当这些数据更新时,该函数将再次执行。
生活中的类比:
想象你在厨房里准备晚餐,你需要时刻关注炉子上的火候以及烤箱里的温度。每当任何一个参数发生变化,你都需要相应地调整你的烹饪策略。在这里,watchEffect
就像一个智能助手,它会自动检测这些条件的变化,并即时调整你的行为。
3.2 使用示例
import { ref, watchEffect } from 'vue';
const temperature = ref(180);
const ovenStatus = ref('off');
watchEffect(() => {
console.log(`Oven status is ${ovenStatus.value}, current temperature is ${temperature.value}`);
});
temperature.value = 200; // 自动触发重新执行
ovenStatus.value = 'on'; // 同样会触发重新执行
四、三者之间的对比
特性 | Computed | Watch | WatchEffect |
---|---|---|---|
初始执行 | 只有当访问时才会执行 | 立即执行一次 | 立即执行一次 |
依赖追踪 | 自动追踪依赖 | 需要明确指定依赖 | 自动追踪依赖 |
更新时机 | 当依赖改变时自动更新 | 当指定的值改变时 | 当依赖改变时自动更新 |
返回值 | 可以返回值 | 不直接返回值 | 不直接返回值 |
五、面试题
问题 1:请简述 computed
和 watch
的主要区别?
答案:
computed
更适合用于需要根据其他状态派生出的新状态,并且这种派生关系是确定性的。watch
更适用于监听某个状态的变化,并在变化发生时执行异步操作或昂贵的计算任务。
问题 2:在什么情况下你会选择使用 watchEffect
而不是 watch
?
答案:
当你希望立即执行一个副作用并且自动追踪所有被用到的状态作为依赖项时,watchEffect
是更好的选择。它简化了代码结构,因为你不需要显式声明哪些状态是你关心的。
问题 3:如何使用 watch
来监听多个状态的变化?
答案:
可以通过数组的形式传递给 watch
,这样就可以同时监听多个状态的变化,并在任一状态发生变化时触发回调函数。
通过以上内容,我们对 watch
、watchEffect
和 computed
在 Vue 3 中的应用有了较为全面的理解。理解这些工具的不同之处有助于我们在实际项目中做出更合适的选择。无论是构建简单的用户界面还是处理复杂的业务逻辑,正确运用这些功能都可以显著提高我们的开发效率。
来源:juejin.cn/post/7525375329105035327
手写一个 UML 绘图软件
为何想做一款软件
在日常的开发和学习过程中,我们常常致力于实现各种功能点,解决各种 Bug。然而,我们很少有机会去设计和制作属于自己的产品。有时,我们可能认为市面上已有众多类似产品,自己再做一款似乎没有必要;有时,我们又觉得要做的事情太多,不知从何下手。
最近,我意识到仅仅解决单点问题已没有那么吸引我。相反,如果我自己开发一款产品,它能够被其他人使用,这将是一件有意思的事情。
因此,我决定在新的一年里,根据自己熟悉的领域和过去一年的积累,尝试打造一款自己的 UML 桌面端软件。我想知道,自己是否真的能够创造出一款在日常工作中好用的工具。
目前,这个计划中的产品的许多功能点已经在开发计划中。我已经完成了最基础的技术架构,并实现了核心的绘图功能。接下来,让我们一探究竟,看看这款软件目前支持哪些功能点。
技术方案
Monorepo 项目结构
使用了 Monorepo(单一代码仓库)项目管理模式。
- 这样可以将通用类型和工具方法抽离在 types 包和 utils 包中。
- 像 graph 这样功能独立的模块也可以单独抽离成包发布。
- 通过集中管理依赖,可以更容易地确保所有项目使用相同版本的库,并且相同版本的依赖库可以只安装一次。
项目介绍:
- 其中 draw-client 是 electron 客户端项目,它依赖自定义的 graph 库。
- services 是服务端代码,和 draw-client 同时依赖了,types 和 utils 公共模块。
|-- apps/ # 包含所有应用程序的代码,每个应用程序可以有自己的目录,如draw-client。
|-- draw-client/ # 客户端应用程序的主目录
|-- src
|-- package.json
|-- tsconfig.json
|-- packages/ # 用于存放项目中的多个包或模块,每个包可以独立开发和测试,便于代码复用和维护。
|-- graph/ # 包含与图表绘制相关的逻辑和组件,可能是一个通用的图表库。
|-- src
|-- package.json
|-- tsconfig.json
|-- types/ # 存放TypeScript类型定义文件,为项目提供类型安全。
|-- src
|-- package.json
|-- tsconfig.json
|-- utils/ # 包含工具函数和辅助代码,这些是项目中通用的功能,可以在多个地方复用。
|-- src
|-- package.json
|-- tsconfig.json
|-- services/ # 服务端代码
|-- src
|-- package.json
|-- tsconfig.json
|-- .npmrc
|-- package.json # 项目的配置文件,包含项目的元数据、依赖项、脚本等,是npm和pnpm管理项目的核心。
|-- pnpm-lock.yaml # pnpm的锁定文件,确保所有开发者和构建环境中依赖的版本一致。
|-- pnpm-workspace.yaml # 定义pnpm工作区的结构,允许在同一个仓库中管理多个包的依赖关系。
|-- REDEME.md
|-- tsconfig.base.json
|-- tsconfig.json
|-- tsconfig.tsbuildinfo
技术栈相关
涉及的技术架构图如下:
- draw-client 相关技术栈
- services 相关技术栈
软件操作流程说明
为了深入理解软件开发的流程,我们将通过两个具体的案例来阐述图形的创建、展示以及动态变化的过程。
创建图形流程
在本节中,我们将详细介绍如何使用我们的图形库来创建图形元素。通过序列图可以了解到一个最简单的图形创建的完整流程如下:
撤销操作
在软件开发中,撤销操作是一个常见的需求,它允许用户撤销之前的某些操作,恢复到之前的状态。本节将探讨如何在图形编辑器中实现撤销功能。
当我们想要回退某一个步骤时,流程如下:
规划
目前软件开发还是处于一个初步的阶段,还有很多有趣的功能需要开发。并且软件开发需要大量的时间,我会逐步去开发相关的功能。这不仅是一个技术实现的过程,更是一个不断学习和成长的过程。我计划在以下几个关键领域深入挖掘:
- NestJS服务端:我将深入研究NestJS框架,利用其强大的模块化和依赖注入特性,构建一个高效、可扩展的服务端架构。我希望通过实践,掌握NestJS的高级特性。
- Electron应用开发:利用Electron框架,将Web应用与桌面应用的优势结合起来。
- SVG图形处理:深入SVG的,我将开发相关库,使得用户能够轻松地在应用中绘制和编辑图形。
当然我会也在开发的过程中记录分享到掘金社区,如果有人想要体验和参与的也是非常欢迎!如果对你有帮助感谢点赞关注,可以私信我一起讨论下独立开发相关的话题。
来源:juejin.cn/post/7455151799030317093
React-native中高亮文本实现方案
前言
React-native中高亮文本实现方案,rn中文本高亮并不像h5那样,匹配正则,直接添加标签实现,rn中一般是循环实现了。一般是一段文本,拆分出关键词,然后关键词高亮。
简单实现
const markKeywords = (text, highlight) => {
if (!text || !highlight) return { value: [text], highlight: [] }
for (let index = 0; index < highlight.length; index++) {
const reg = new RegExp(highlight[index], 'g');
text = text.replace(reg, `**${highlight[index]}**`)
}
return {
markKeywordList: text.split('**').filter(item => item),
hightList: highlight.map(item => item)
}
}
上面可以拆分出可以循环的文本,和要高亮的文本。
特殊情况
const title = 'haorooms前端博文章高亮测试一下'
const highLightWords = ['前端博文', '文章高亮']
因为打上星号标记的原因,文章高亮 在被标记成 前端博文 章高亮 后,并不能被 文章高亮 匹配,而且即使能匹配也不能把 前端博文章高亮 拆成 前端博文 、文章高亮,如果能拆成 前端博文章高亮 就好了。
function sort(letter, substr) {
letter = letter.toLocaleUpperCase()
substr = substr.toLocaleUpperCase()
var pos = letter.indexOf(substr)
var positions = []
while(pos > -1) {
positions.push(pos)
pos = letter.indexOf(substr, pos + 1)
}
return positions.map(item => ([item, item + substr.length]))
}
// 高亮词第一次遍历索引
function format (text, hight) {
var arr = []
// hight.push(hight.reduce((prev, curr) => prev+curr), '')
hight.forEach((item, index) => {
arr.push(sort(text, item))
})
return arr.reduce((acc, val) => acc.concat(val), []);
}
// 合并索引区间
var merge = function(intervals) {
const n = intervals.length;
if (n <= 1) {
return intervals;
}
intervals.sort((a, b) => a[0] - b[0]);
let refs = [];
refs.unshift([intervals[0][0], intervals[0][1]]);
for (let i = 1; i < n; i++) {
let ref = refs[0];
if (intervals[i][0] < ref[1]) {
ref[1] = Math.max(ref[1], intervals[i][1]);
} else {
refs.unshift([intervals[i][0], intervals[i][1]]);
}
}
return refs.sort((a,b) => a[0] - b[0]);
}
function getHightLightWord (text, hight) {
var bj = merge(format(text, hight))
const c = text.split('')
var bjindex = 0
try {
bj.forEach((item, index) => {
item.forEach((_item, _index) => {
c.splice(_item + bjindex, 0, '**')
bjindex+=1
})
})
} catch (error) {
}
return c.join('').split('**')
}
export const markKeywords = (text, keyword) => {
if (!text || !keyword || keyword.length === 0 ) {
return { value: [text], keyword: [] }
}
if (Array.isArray(keyword)) {
keyword = keyword.filter(item => item)
}
let obj = { value: [text], keyword };
obj = {
value: getHightLightWord(text, keyword).filter((item) => item),
keyword: keyword.map((item) => item),
};
return obj;
};
述方法中我们先使用了下标匹配的方式,得到一个下标值的映射,然后通过区间合并的方式把连着的词做合并处理,最后再用合并后的下标值映射去打 ** 标记即可。
简单组件封装
function TextHighLight(props) {
const { title = '', highLightWords = [] } = props
const { numberOfLines, ellipsizeMode } = props
const { style } = props
const { markKeywordList, hightList } = markKeywords(title, highLightWords)
return <Text
numberOfLines={numberOfLines}
ellipsizeMode={ellipsizeMode}
style={style}
>
{
markKeywordList ?
markKeywordList.map((item,index) => (
(hightList && hightList.some(i => (i.toLocaleUpperCase().includes(item) || i.toLowerCase().includes(item))))
? <Text key={index} style={{ color: '#FF6300' }}>{item}</Text>
: item
))
: null
}
</Text>
}
来源:juejin.cn/post/7449373647233941541
一个列表页面,初级中级高级前端之间的鸿沟就显出来了
你是不是也写过 20+ 个中后台列表页,却总觉得跳不出 CRUD?你以为你是高级了,其实你只是熟练了。
你可能写过几十个中后台列表页,从最早用 v-model
到后来自定义 hooks,再到封装组件、状态缓存、schema 驱动。
但同样是一个列表页:
- 初级在堆功能;
- 中级在理结构;
- 高级在构建规则。
我们就以这个最常见的中后台场景:搜索 + 分页 + 表格 + 编辑跳转,来看看三个阶段的认知差异到底在哪。
写完 vs 写清楚 vs 写系统
等级 | 开发目标 |
---|---|
初级 | 页面能用,接口通,功能不报错 |
中级 | 页面结构清晰、组件职责明确、状态复用 |
高级 | 页面只是 DSL 映射结果,字段配置驱动生成,具备平台能力 |
搜索区域的处理
等级 | 做法 |
---|---|
初级 | el-form + v-model + 手写查询逻辑 |
中级 | 封装 SearchForm.vue ,支持 props 字段配置 |
高级 | 使用字段配置 schema,支持字段渲染、联动、权限控制、字典动态加载 |
初级看起来能用,实则字段散落、表单逻辑零散; 中级可复用,但配置灵活性不足; 高级直接写 schema 字段声明,字段中心统一维护,整个搜索区域自动生成。
表格区域的组织
等级 | 做法 |
---|---|
初级 | 表格写死在页面中,columns 手动维护 |
中级 | 封装 DataTable 组件,支持 columns + slots |
高级 | 表格由字段配置自动渲染,支持国际化、权限、字典映射、格式化 |
高级阶段的表格是“字段中心驱动下的视图映射”,而不是手写 UI 组件。
页面跳转行为
等级 | 做法 |
---|---|
初级 | router.push + 返回后状态丢失 |
中级 | 缓存搜索条件和分页,支持跳转回填 |
高级 | 路由状态与组件状态解耦,编辑行为可抽象为弹窗、滑窗,不依赖跳转 |
体验上,初级只能靠刷新;中级保留了状态;高级压根不跳页,抽象为状态变更。
字段结构理解
等级 | 做法 |
---|---|
初级 | 页面写死 status === 1 ? '启用' : '禁用' |
中级 | 使用全局字典表:getDictLabel('STATUS', val) |
高级 | 字段中心统一配置字段含义、展示方式、权限与控件类型,一份声明全平台复用 |
高级不写字段映射,而是写字段定义。字段即规则,规则即视图。
工程感理解
你以为工程感是“项目结构清晰”,其实高级工程感是:
- 样式有标准;
- 状态有模式;
- 路由有策略;
- 权限有方案;
- 字段有配置中心。
一切都能预期,一切都能对齐。
行为认知:你以为你在“配合”,其实你在“等安排”
你说“接口还没好我就做不了页面”。 你说“等产品图出了我再看组件拆不拆”。
但高级前端早就开始:
- Mock 数据、虚拟字段结构;
- 自定义 useXXX 模块推动业务流转;
- 甚至反推接口结构,引导后端设计。
配合和推进,只有一线之隔。
低水平重复劳动:你写了很多,但没真正沉淀
你遇到过哪些“看似很忙,实则在原地转圈”的开发场景?
有些开发者,写得飞快,需求接得也多,但工作了一两年,回头一看,写过的每一个页面都像复制粘贴出来的拼图。
你看似很忙,实则只是换了一个页面在干一样的事。
最典型的,就是以下这三类“中后台系统里的低水平重复劳动”:
❶ 每页都重复写 table columns 和格式化逻辑
- 每页重复定义
columns
; - 状态字段每次都手写
status === 1 ? '启用' : '停用'
; - 日期字段每页都在
render
中 format; - 操作列、index 列、字典值写满重复逻辑。
📉 问题: 代码冗余,字段维护困难,一改动就全局找引用。
✅ 提升方式:
- 抽象字段结构配置(如 fieldSchema);
- 字段渲染、字典映射、权限统一管理;
- 一份字段配置驱动表格、表单、详情页。
❷ 每个列表页都重复写搜索逻辑和状态变量
- 每页都写
searchForm: {}
、search()
、reset()
; - query 参数、分页、loading 状态变量混杂;
- 页面跳转回来状态丢失,刷新逻辑重复拼接。
📉 问题: 页面逻辑分散、复用性差,体验割裂。
✅ 提升方式:
- 自定义 hook 如
useSmartListPage()
统一管理列表页状态; - 统一封装查询、分页、loading、缓存逻辑;
- 形成“搜索+表格+跳转+回填”标准列表模式。
❸ 反复堆砌跳转编辑流程,缺乏行为抽象
- 每次跳转写
this.$router.push({ path, query })
; - 返回页面刷新列表,无上下文保留;
- 编辑页都是复制粘贴模板,字段改名。
📉 问题: 编辑与跳转强耦合,逻辑割裂,流程不清。
✅ 提升方式:
- 将“查看 / 编辑 / 创建”抽象为页面模式;
- 支持弹窗、滑窗模式管理,跳转可选;
- 解耦跳转与行为,页面由状态驱动。
真正的成长,不是写得多,而是提取出通用能力、形成规范。
中后台系统里最常见的低水平重复劳动:
- 每次都手写 table columns、格式化字段;
- 搜索表单每页都重新写逻辑、状态绑定;
- 分页、loading、跳转逻辑全靠临时拼;
你遇到过哪些 “看似很忙,实则在原地转圈” 的开发场景?欢迎在评论区说说你的故事。
组件理解:你以为你在写组件,其实你在制造混乱
组件抽象不清、slot 滥用、props 大杂烩、逻辑耦合 UI,写完一个别人不敢接的黑盒。
中级组件关注复用,高级组件关注职责边界和组合方式,具备“可预测性、可替换性、可拓展性”。
页面能力 ≠ 项目交付能力
你能写页面,但你未必能独立交付项目。 缺的可能是:
- 多模块协同能力
- 权限 / 字段 / 配置抽象力
- 异常兜底与流程控制设计
从写完一个页面,到撑起一个系统,中间差的是“体系构建力”。
结语:页面 ≠ 技术,堆功能 ≠ 成长
初级在交付页面,中级在建设结构,高级在定义规则。 真正的高级前端,已经不写“页面”了,而是在定义“页面该怎么写”。
来源:juejin.cn/post/7492086179996090383
你真的了解包管理工具吗?(npm、pnpm、cnpm)
npm (Node Package Manager)
概述
npm
是 Node.js 的官方包管理器,也是全球使用最广泛的 JavaScript 包管理工具。它用于管理 JavaScript 项目的依赖包,可以通过命令行来安装、更新、卸载依赖包。
特点
- Node.js 官方默认包管理器。支持全局和本地安装模式
- 通过 package.json 和 package-lock.json 管理依赖版本,可以在 package.json 中定义各种脚本命令
常用命令
npm install [package] # 安装包
npm uninstall [package] # 卸载包
npm update [package] # 更新包
npm init # 初始化项目
npm run [script] # 运行脚本
npm publish # 发布包
依赖管理方式
npm 使用平铺的 node_modules 结构,会导致依赖重复和幽灵依赖问题(phantom dependencies)。这种管理方式导致npm在处理在处理大量依赖时速度会很慢
cnpm (China npm)
特点
- 镜像加速:使用淘宝的 npm 镜像,下载速度更快
- 兼容 npm:命令与 npm 基本一致,学习成本低
- 安装简单:可以通过 npm 直接安装 cnpm
概述
cnpm 是阿里巴巴团队开发的 npm 命令行工具,基于淘宝镜像 registry.npmmirror.com,用于解决国内访问 npm 官方源缓慢的问题。
特点
- 镜像加速:使用淘宝的 npm 镜像,下载速度更快
- 兼容 npm:命令与 npm 基本一致,学习成本低
- 安装简单:可以通过 npm 直接安装 cnpm
本质是和npm一样,只是为了迎合国内的网络环境,更改了依赖包的下载地址,让下载速度更快
pnpm (## Performant npm)
概述
pnpm 是一个新兴的、高性能的包管理工具,最大的特点是使用硬链接(hard link)来复用依赖文件,极大节省磁盘空间和提升安装速度。
特点
- 高效存储:多个项目可以共享同一版本的依赖,节省磁盘空间
- 磁盘空间优化:通过硬链接共享依赖,显著节省了磁盘空间。
- 强制封闭依赖:避免隐式依赖,提高了依赖管理的可靠性。
- 速度更快,兼容性更好:安装速度比 npm 和 yarn 更快,兼容 npm 的工作流和 package.json
依赖管理方式
pnpm (Performant npm) 的依赖管理方式与传统包管理器(npm/yarn)有本质区别,其核心基于内容可寻址存储和符号链接技术。
- 内容可寻址存储 (Content-addressable storage)
pnpm 将所有依赖包存储在全局的 ~/.pnpm-store
目录中(默认位置),存储结构基于包内容的哈希值而非包名称。这意味着: 1. 相同内容的包只会存储一次 2.不同项目可以共享完全相同的依赖版本3.通过哈希值精确验证包完整性
- 符号链接 (Symbolic links) 结构
举例解释就是
场景假设:你有 100 个项目,每个项目都用到了相同的
lodash
库
1. npm:每个项目都自己带一本书
- npm 的方式:
- 每个项目都有一本完整的
lodash
,即使内容一模一样。
- 结果:你的硬盘上存了 100 本相同的书(100 份
lodash
副本),占用大量空间。
- 更新问题:如果
lodash
发布新版本,哪怕只改了一行代码,npm 也会重新下载整本书,而不是只更新变化的部分。
2. pnpm:所有项目共享同一本书
- pnpm 的方式:
- 统一存储:所有版本的
lodash
都存放在一个中央仓库(类似“云端书库”)。
- 按需链接:每个项目只是“链接”到这本书,而不是复制一份。
- 版本更新优化:如果
lodash
新版本只改了一个文件,pnpm 只存储这个变化的文件,而不是整个新版本。
来源:juejin.cn/post/7518212477650927666
p5.js 圆弧的用法
点赞 + 关注 + 收藏 = 学会了
在 p5.js 中,arc()
函数用于绘制圆弧,它是创建各种圆形图形和动画的基础。圆弧本质上是椭圆的一部分,由中心点、宽度、高度、起始角度和结束角度等参数定义。通过灵活运用 arc()
函数可以轻松创建饼图、仪表盘、时钟等常见 UI 组件,以及各种创意图形效果。
arc() 的基础语法
基础语法
arc()
函数的完整语法如下:
arc(x, y, w, h, start, stop, [mode], [detail])
核心参数解释:
- x, y:圆弧所在椭圆的中心点坐标
- w, h:椭圆的宽度和高度,如果两者相等,则绘制的是圆形的一部分
- start, stop:圆弧的起始角度和结束角度,默认以弧度(radians)为单位
可选参数:
- mode:定义圆弧的填充样式,可选值为
OPEN
(开放式半圆)、CHORD
(封闭式半圆)或PIE
(闭合饼图) - detail:仅在 WebGL 模式下使用,指定组成圆弧周长的顶点数量,默认值为 25
角度单位与转换
在 p5.js 中,角度可以使用弧度或角度两种单位表示:
- 默认单位是弧度:0 弧度指向正右方(3 点钟方向),正角度按顺时针方向增加
- 使用角度单位:可以通过
angleMode(DEGREES)
函数将角度单位设置为角度
两种单位之间的转换关系:
- 360 度 = 2π 弧度
- 180 度 = π 弧度
- 90 度 = π/2 弧度
p5.js 提供了两个辅助函数用于单位转换:
radians(degrees)
:将角度转换为弧度degrees(radians)
:将弧度转换为角度
举个例子(基础示例)
举个例子讲解一下如何使用 arc()
函数绘制不同角度的圆弧。
function setup() {
createCanvas(400, 400);
angleMode(DEGREES); // 使用角度单位
}
function draw() {
background(220);
// 绘制不同角度的圆弧
arc(100, 100, 100, 100, 0, 90); // 90度圆弧
arc(250, 100, 100, 100, 0, 180); // 180度圆弧
arc(100, 250, 100, 100, 0, 270); // 270度圆弧
arc(250, 250, 100, 100, 0, 360); // 360度圆弧(整圆)
}
这段代码会在画布上绘制四个不同角度的圆弧,从 90 度到 360 度不等。注意,当角度为 360 度时,实际上绘制的是一个完整的圆形。
三种圆弧模式:OPEN、CHORD 与 PIE
arc()
函数的第七个参数mode决定了圆弧的填充方式,有三种可选值:
- OPEN(默认值):仅绘制圆弧本身,不填充任何区域
- CHORD:绘制圆弧并连接两端点形成闭合的半圆形区域
- PIE:绘制圆弧并连接两端点与中心点形成闭合的扇形区域
这三种模式不需要手动定义,p5.js 已经在全局范围内定义好了这些常量。
举个例子:
function setup() {
createCanvas(400, 200);
angleMode(DEGREES);
}
function draw() {
background(220);
// 绘制不同模式的圆弧
arc(100, 100, 100, 100, 0, 270, OPEN);
arc(220, 100, 100, 100, 0, 270, CHORD);
arc(340, 100, 100, 100, 0, 270, PIE);
}
这段代码会在画布上绘制三个 270 度的圆弧,分别展示 OPEN
、CHORD
和 PIE
三种模式的效果。可以明显看到,OPEN
模式只绘制弧线,CHORD
模式连接两端点形成闭合区域,而 PIE
模式则从两端点连接到中心点形成扇形。
如何选择合适的模式
选择圆弧模式时,应考虑以下因素:
- 视觉效果需求:需要纯弧线效果时选择
OPEN
,需要闭合区域时选择CHORD
或PIE
- 应用场景:饼图通常使用
PIE
模式,仪表盘可能使用CHORD
模式,而简单装饰线条可能使用OPEN
模式 - 填充与描边需求:不同模式对填充和描边的处理方式不同,需要根据设计需求选择
值得注意的是,arc()
函数绘制的默认是填充的扇形区域。如果想要获取纯圆弧(没有填充区域),可以使用 noFill()
函数拒绝 arc()
函数的填充。
做几个小demo玩玩
简易数字时钟
在这个示例中,我将使用 arc()
函数创建一个简单的数字时钟,显示当前的小时、分钟和秒数。
let hours, minutes, seconds;
function setup() {
createCanvas(400, 400);
angleMode(DEGREES); // 使用角度单位
}
function draw() {
background(220);
// 获取当前时间
let now = new Date();
hours = now.getHours();
minutes = now.getMinutes();
seconds = now.getSeconds();
// 绘制时钟边框
stroke(0);
strokeWeight(2);
noFill();
arc(width/2, height/2, 300, 300, 0, 360);
// 绘制小时刻度
strokeWeight(2);
for (let i = 0; i < 12; i++) {
let angle = 90 - i * 30;
let x1 = width/2 + 140 * cos(radians(angle));
let y1 = height/2 + 140 * sin(radians(angle));
let x2 = width/2 + 160 * cos(radians(angle));
let y2 = height/2 + 160 * sin(radians(angle));
line(x1, y1, x2, y2);
}
// 绘制分钟刻度
strokeWeight(1);
for (let i = 0; i < 60; i++) {
let angle = 90 - i * 6;
let x1 = width/2 + 150 * cos(radians(angle));
let y1 = height/2 + 150 * sin(radians(angle));
let x2 = width/2 + 160 * cos(radians(angle));
let y2 = height/2 + 160 * sin(radians(angle));
line(x1, y1, x2, y2);
}
// 绘制小时指针
let hourAngle = 90 - (hours % 12) * 30 - minutes * 0.5;
let hourLength = 80;
let hx = width/2 + hourLength * cos(radians(hourAngle));
let hy = height/2 + hourLength * sin(radians(hourAngle));
line(width/2, height/2, hx, hy);
// 绘制分钟指针
let minuteAngle = 90 - minutes * 6;
let minuteLength = 120;
let mx = width/2 + minuteLength * cos(radians(minuteAngle));
let my = height/2 + minuteLength * sin(radians(minuteAngle));
line(width/2, height/2, mx, my);
// 绘制秒针
stroke(255, 0, 0);
let secondAngle = 90 - seconds * 6;
let secondLength = 140;
let sx = width/2 + secondLength * cos(radians(secondAngle));
let sy = height/2 + secondLength * sin(radians(secondAngle));
line(width/2, height/2, sx, sy);
// 显示当前时间文本
noStroke();
fill(0);
textSize(24);
text(hours + ":" + nf(minutes, 2, 0) + ":" + nf(seconds, 2, 0), 50, 50);
}
关键点解析:
- 获取当前时间:使用Date()对象获取当前的小时、分钟和秒数
- 角度计算:根据时间值计算指针的旋转角度,注意将角度转换为 p5.js 使用的坐标系(0 度指向正上方)
- 刻度绘制:使用循环绘制小时和分钟刻度,每个小时刻度间隔 30 度,每个分钟刻度间隔 6 度
- 指针绘制:根据计算的角度和长度绘制小时、分钟和秒针,注意秒针使用红色以区分
- 时间文本显示:使用text()函数在画布左上角显示当前时间
饼图
在这个示例中,我将创建一个简单的饼图,展示不同类别数据的比例。
let data = [30, 10, 45, 35, 60, 38, 75, 67]; // 示例数据
let total = 0;
let lastAngle = 0;
function setup() {
createCanvas(720, 400);
angleMode(DEGREES); // 使用角度单位
noStroke(); // 不绘制边框
total = data.reduce((a, b) => a + b, 0); // 计算数据总和
}
function draw() {
background(100);
pieChart(300, data); // 调用饼图绘制函数
}
function pieChart(diameter, data) {
lastAngle = 0; // 重置起始角度
for (let i = 0; i < data.length; i++) {
// 设置圆弧的灰度值,map函数将数据映射到0-255的灰度范围
let gray = map(i, 0, data.length, 0, 255);
fill(gray);
// 计算当前数据点的角度范围
let startAngle = lastAngle;
let endAngle = lastAngle + (data[i] / total) * 360;
// 绘制圆弧
arc(
width / 2,
height / 2,
diameter,
diameter,
startAngle,
endAngle,
PIE // 使用PIE模式创建扇形
);
lastAngle = endAngle; // 更新起始角度为下一个数据点做准备
}
}
关键点解析:
- 数据准备:定义示例数据数组data,并计算数据总和total
- 颜色设置:使用map()函数将数据索引映射到 0-255 的灰度范围,实现渐变效果
- 角度计算:根据每个数据点的值与总和的比例计算对应的角度范围
- 圆弧绘制:使用PIE模式绘制每个数据点对应的扇形,形成完整的饼图
这个饼图示例可以通过添加标签、交互效果或动态数据更新来进一步增强功能。
描边效果
在 p5.js 中,我们可以通过以下函数定制圆弧的描边效果:
- stroke(color):设置描边颜色
- strokeWeight(weight):设置描边宽度
- strokeCap(cap):设置描边端点样式(可选值:BUTT, ROUND, SQUARE)
- strokeJoin(join):设置描边转角样式(可选值:MITER, ROUND, BEVEL)
以下示例展示了如何定制圆弧的描边效果:
function setup() {
createCanvas(400, 200);
angleMode(DEGREES);
}
function draw() {
background(220);
// 示例1:粗红色描边
stroke(255, 0, 0);
strokeWeight(10);
arc(100, 100, 100, 100, 0, 270);
// 示例2:带圆角端点的描边
stroke(0, 255, 0);
strokeWeight(10);
strokeCap(ROUND);
arc(220, 100, 100, 100, 0, 270);
// 示例3:带阴影效果的描边
stroke(0, 0, 255);
strokeWeight(15);
strokeCap(SQUARE);
arc(340, 100, 100, 100, 0, 270);
// 恢复默认设置
noStroke();
}
关键点解析:
- 颜色设置:使用stroke()函数设置不同颜色的描边
- 宽度设置:使用strokeWeight()函数调整描边粗细
- 端点样式:使用strokeCap()函数设置描边端点的样式(圆角效果特别适合圆弧)
- 阴影效果:通过增加描边宽度并偏移绘制位置可以创建简单的阴影效果
填充效果
在 p5.js 中,我们可以通过以下函数定制圆弧的填充效果:
- fill(color):设置填充颜色
- noFill():禁用填充效果
- colorMode(mode):设置颜色模式(RGB、HSB 等)
- alpha():设置颜色透明度
以下示例展示了如何定制圆弧的填充效果:
function setup() {
createCanvas(400, 200);
angleMode(DEGREES);
colorMode(HSB, 360, 100, 100); // 使用HSB颜色模式
}
function draw() {
background(220);
// 示例1:单色填充
fill(120, 100, 100); // 绿色
arc(100, 100, 100, 100, 0, 270);
// 示例2:渐变填充
noFill();
stroke(0, 0, 100);
strokeWeight(10);
for (let i = 0; i < 360; i += 10) {
fill(i, 100, 100);
arc(220, 100, 100, 100, i, i+10);
}
// 示例3:透明填充
fill(240, 100, 100, 50); // 半透明蓝色
arc(340, 100, 100, 100, 0, 270);
// 恢复默认设置
noFill();
stroke();
}
关键点解析:
- 颜色模式:使用colorMode()函数切换到 HSB 模式,方便创建渐变效果
- 单色填充:直接使用fill()函数设置单一填充颜色
- 渐变填充:通过循环绘制多个小角度的圆弧,每个使用不同的色相值实现渐变效果
- 透明度设置:在fill()函数中添加第四个参数(0-100)设置透明度
旋转圆弧
在 p5.js 中创建圆弧动画非常简单,主要通过以下方法实现:
- **draw()**函数:每秒自动执行约 60 次,用于更新动画帧
- 变量控制:使用变量控制圆弧的参数(如位置、大小、角度等)
- frameRate(fps):设置动画帧率(可选)
- millis():获取当前时间(毫秒),用于精确控制动画时间
圆弧动画效果示例:
let angle = 0;
function setup() {
createCanvas(400, 400);
angleMode(DEGREES);
}
function draw() {
background(220);
// 绘制旋转的红色圆弧
stroke(255, 0, 0);
strokeWeight(10);
arc(width/2, height/2, 300, 300, angle, angle + 90);
// 更新角度值,实现旋转效果
angle += 2; // 调整这个值可以改变旋转速度
// 恢复默认设置
noStroke();
}
关键点解析:
- 角度变量:使用
angle
变量控制圆弧的起始角度 - 角度更新:在每次
draw()
调用时增加angle值,实现旋转效果 - 速度控制:通过调整每次增加的角度值(这里是 2 度)控制旋转速度
弧度与角度的转换技巧
在 p5.js 中,arc()函数默认使用弧度作为角度单位,但我们通常更习惯使用角度。以下是一些转换技巧:
- 角度转弧度:使用
radians(degrees)
函数将角度转换为弧度 - 弧度转角度:使用
degrees(radians)
函数将弧度转换为角度 - 设置角度单位:使用
angleMode(DEGREES)
函数将全局角度单位设置为角度,这样arc()
函数就可以直接使用角度值 - 常见角度值:记住一些常用角度的弧度值,如 90 度 = PI/2,180 度 = PI,270 度 = 3PI/2,360 度 = 2PI
圆弧绘制的常见问题与解决方案
在使用 arc()
函数时,可能会遇到以下问题:
- arc () 函数中的 bug:当
start_angle == end_angle
时,可能会出现意外绘制效果。例如,当start_angle == end_angle == -PI/2
时会绘制一个半圆,这不符合预期。解决方案是避免start_angle
和end_angle
相等。 - 起始角度的位置:在 p5.js 中,0 弧度(或 0 度,如果使用
angleMode(DEGREES)
)指向正右方(3 点钟方向),而不是数学上的正上方。这可能导致方向与预期不符。 - 描边宽度的影响:较宽的描边会使圆弧看起来比实际大。这是因为描边会向路径的两侧扩展。如果需要精确控制大小,可以考虑将arc()的尺寸适当减小,或者使用
shapeMode()
函数调整坐标系。 - 浮点精度问题:在进行角度计算时,尤其是涉及到除法和循环时,可能会遇到浮点精度问题。建议使用
nf()
函数(如nf(value, 2, 0)
)来格式化显示的数值,避免显示过多的小数位。
以上就是本文的全部内容啦,如果想了解更多 p5.js 的玩法可以关注 《P5.js中文教程》
点赞 + 关注 + 收藏 = 学会了
来源:juejin.cn/post/7529753277770022921
🎨 CSS 写到手抽筋?Stylus 说:‘让我来!’
前言
还在手动重复写 margin: 0; padding: 0;
?还在为兼容性疯狂加 -webkit-
前缀?大厂前端早已不用原始 CSS 硬刚了!Stylus 作为一款现代化 CSS 预处理器,让你写样式像写 JavaScript 一样爽快。
还在手动重复写 margin: 0; padding: 0;
?还在为兼容性疯狂加 -webkit-
前缀?大厂前端早已不用原始 CSS 硬刚了!Stylus 作为一款现代化 CSS 预处理器,让你写样式像写 JavaScript 一样爽快。
Stylus:高效的CSS预处理器
基本特性
Stylus是一种CSS预处理器,提供了许多CSS不具备的高级功能:
// 定义变量
$background_color = rgba(255, 255, 255, 0.95)
.wrapper
background $background_color
box-shadow 0 0 0 10px rgba(0, 0, 0, 0.1)
Stylus是一种CSS预处理器,提供了许多CSS不具备的高级功能:
// 定义变量
$background_color = rgba(255, 255, 255, 0.95)
.wrapper
background $background_color
box-shadow 0 0 0 10px rgba(0, 0, 0, 0.1)
优势与使用场景
- 变量支持:避免重复值,便于主题切换
- 嵌套规则:更清晰的DOM结构表示
- 混合(Mixins) :复用样式块
- 函数与运算:动态计算样式值
- 简洁语法:可选的花括号、分号和冒号
- 变量支持:避免重复值,便于主题切换
- 嵌套规则:更清晰的DOM结构表示
- 混合(Mixins) :复用样式块
- 函数与运算:动态计算样式值
- 简洁语法:可选的花括号、分号和冒号
编译与使用
安装Stylus后,可以通过命令行编译.styl文件:
npm install -g stylus
stylus -w common.styl -o common.css
- 第一个语句是用来安装
stylus
的直接运行就好 - 第二个语句是你编译
common.styl
文件时使用的,也就是你写CSS
代码时使用的,因为浏览器并不能直接编译.styl
文件,所以你要先将.styl
文件编译成.css
文件,也就是用上面给的那个命令,注意要自己切换成自己的.styl
文件名,后面的css
名可以随便取一个自己想要的
安装Stylus后,可以通过命令行编译.styl文件:
npm install -g stylus
stylus -w common.styl -o common.css
- 第一个语句是用来安装
stylus
的直接运行就好 - 第二个语句是你编译
common.styl
文件时使用的,也就是你写CSS
代码时使用的,因为浏览器并不能直接编译.styl
文件,所以你要先将.styl
文件编译成.css
文件,也就是用上面给的那个命令,注意要自己切换成自己的.styl
文件名,后面的css
名可以随便取一个自己想要的
插件的使用
我们要想使用stylus
,除了要全局安装之外还要下载一下下面的这个插件。
我们要先进入插件市场,然后搜索stylus
,点击我选择的那个插件点击安装即可

我们要想使用stylus
,除了要全局安装之外还要下载一下下面的这个插件。
我们要先进入插件市场,然后搜索stylus
,点击我选择的那个插件点击安装即可
案例实战
先看效果,再上代码,最后在分析考点易错点
先看效果,再上代码,最后在分析考点易错点
效果
下面是我们实现的一个简单的效果界面图

下面是我们实现的一个简单的效果界面图
代码
$background_color = rgba(255, 255, 255, 0.95)
html
box-sizing border-box
min-height 100vh
display flex
flex-direction column
justify-content center
align-items center
text-align center
background url('http://wes.io/hx9M/oh-la-la.jpg') center no-repeat
background-size cover
*
box-sizing border-box
.wrapper
padding 20px
min-width 350px
background $background_color
box-shadow 0 0 0 10px rgba(0, 0, 0, 0.1)
h2
text-align center
margin 0
font-weight 200
body
color pink
.plates
margin 0
padding 0
text-align left
list-style: none
li
border-bottom 1px solid rgba(0, 0, 0, 0.2)
padding 10px 0px
display flex
label
flex 1
cursor pointer
input
display none
.add-items
margin-top 20px
input
padding 10px
outline 0
border 1px solid rgba(0, 0, 0, 0.1)
我们可以看到.styl
文件不用去写:
和{}
了,而且可以直接层叠样式
当我们运行stylus -w common.styl -o common.css
命令时,它会实时的将common.styl
文件编译成common.css
,你可以根据自己的需求来编写,让我们看看它帮我写好的common.css
文件吧
html {
box-sizing: border-box;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
background: url("http://wes.io/hx9M/oh-la-la.jpg") center no-repeat;
background-size: cover;
}
* {
box-sizing: border-box;
}
.wrapper {
padding: 20px;
min-width: 350px;
background: rgba(255,255,255,0.95);
box-shadow: 0 0 0 10px rgba(0,0,0,0.1);
}
.wrapper h2 {
text-align: center;
margin: 0;
font-weight: 200;
}
body {
color: #ffc0cb;
}
.plates {
margin: 0;
padding: 0;
text-align: left;
list-style: none;
}
.plates li {
border-bottom: 1px solid rgba(0,0,0,0.2);
padding: 10px 0px;
display: flex;
}
.plates label {
flex: 1;
cursor: pointer;
}
.plates input {
display: none;
}
.add-items {
margin-top: 20px;
}
.add-items input {
padding: 10px;
outline: 0;
border: 1px solid rgba(0,0,0,0.1);
}
// 获取DOM元素
// 获取添加项目的表单元素
const addItems = document.querySelector('.add-items');
// 获取显示项目列表的元素
const itemsList = document.querySelector('.plates');
// 从本地存储获取项目数据,如果没有则初始化为空数组
let items = JSON.parse(localStorage.getItem('tapasItems')) || [];
// 添加新项目函数
function addItem(e) {
// 阻止表单默认提交行为
e.preventDefault();
// 获取输入框中的文本值
const text = this.querySelector('[name=item]').value;
// 创建新项目对象
const item = {
text, // 项目文本
done: false // 完成状态初始为false
};
// 将新项目添加到数组中
items.push(item);
// 更新列表显示
populateList(items, itemsList);
// 将更新后的数组保存到本地存储
localStorage.setItem('tapasItems', JSON.stringify(items));
// 重置表单
this.reset();
}
// 渲染项目列表函数
function populateList(plates = [], platesList) {
// 使用map方法将数组转换为HTML字符串
platesList.innerHTML = plates.map((plate, i) => {
return `
${i} id="item${i}" ${plate.done ? 'checked' : ''}>
`;
}).join(''); // 将数组转换为字符串
}
// 切换项目完成状态函数
function toggleDone(e) {
// 如果点击的不是input元素则直接返回
if (!e.target.matches('input')) return;
// 获取被点击元素的data-index属性值
const el = e.target;
const index = el.dataset.index;
// 切换项目的完成状态
items[index].done = !items[index].done;
// 更新本地存储
localStorage.setItem('tapasItems', JSON.stringify(items));
// 重新渲染列表
populateList(items, itemsList);
}
// 添加事件监听器
// 表单提交事件 - 添加新项目
addItems.addEventListener('submit', addItem);
// 列表点击事件 - 切换项目完成状态
itemsList.addEventListener('click', toggleDone);
// 初始化加载 - 页面加载时渲染已有项目
populateList(items, itemsList);
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>Documenttitle>
<link rel="stylesheet" href="./common.css">
head>
<body>
<div class="wrapper">
<h2>Local TAPASh2>
<p>请添加您的TAPASp>
<ul class="plates">
<li>Loading Tapas ...li>
ul>
<form action="" class="add-items">
<input
type="text"
placeholder="Item Name"
required -- 让输入框变成必须填写 -->
name="item"
>
<input type="submit" value="+ Add Item">
form>
div>
<script src="./common.js">
script>
body>
html>
$background_color = rgba(255, 255, 255, 0.95)
html
box-sizing border-box
min-height 100vh
display flex
flex-direction column
justify-content center
align-items center
text-align center
background url('http://wes.io/hx9M/oh-la-la.jpg') center no-repeat
background-size cover
*
box-sizing border-box
.wrapper
padding 20px
min-width 350px
background $background_color
box-shadow 0 0 0 10px rgba(0, 0, 0, 0.1)
h2
text-align center
margin 0
font-weight 200
body
color pink
.plates
margin 0
padding 0
text-align left
list-style: none
li
border-bottom 1px solid rgba(0, 0, 0, 0.2)
padding 10px 0px
display flex
label
flex 1
cursor pointer
input
display none
.add-items
margin-top 20px
input
padding 10px
outline 0
border 1px solid rgba(0, 0, 0, 0.1)
我们可以看到.styl
文件不用去写:
和{}
了,而且可以直接层叠样式
当我们运行stylus -w common.styl -o common.css
命令时,它会实时的将common.styl
文件编译成common.css
,你可以根据自己的需求来编写,让我们看看它帮我写好的common.css
文件吧
html {
box-sizing: border-box;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
background: url("http://wes.io/hx9M/oh-la-la.jpg") center no-repeat;
background-size: cover;
}
* {
box-sizing: border-box;
}
.wrapper {
padding: 20px;
min-width: 350px;
background: rgba(255,255,255,0.95);
box-shadow: 0 0 0 10px rgba(0,0,0,0.1);
}
.wrapper h2 {
text-align: center;
margin: 0;
font-weight: 200;
}
body {
color: #ffc0cb;
}
.plates {
margin: 0;
padding: 0;
text-align: left;
list-style: none;
}
.plates li {
border-bottom: 1px solid rgba(0,0,0,0.2);
padding: 10px 0px;
display: flex;
}
.plates label {
flex: 1;
cursor: pointer;
}
.plates input {
display: none;
}
.add-items {
margin-top: 20px;
}
.add-items input {
padding: 10px;
outline: 0;
border: 1px solid rgba(0,0,0,0.1);
}
// 获取DOM元素
// 获取添加项目的表单元素
const addItems = document.querySelector('.add-items');
// 获取显示项目列表的元素
const itemsList = document.querySelector('.plates');
// 从本地存储获取项目数据,如果没有则初始化为空数组
let items = JSON.parse(localStorage.getItem('tapasItems')) || [];
// 添加新项目函数
function addItem(e) {
// 阻止表单默认提交行为
e.preventDefault();
// 获取输入框中的文本值
const text = this.querySelector('[name=item]').value;
// 创建新项目对象
const item = {
text, // 项目文本
done: false // 完成状态初始为false
};
// 将新项目添加到数组中
items.push(item);
// 更新列表显示
populateList(items, itemsList);
// 将更新后的数组保存到本地存储
localStorage.setItem('tapasItems', JSON.stringify(items));
// 重置表单
this.reset();
}
// 渲染项目列表函数
function populateList(plates = [], platesList) {
// 使用map方法将数组转换为HTML字符串
platesList.innerHTML = plates.map((plate, i) => {
return `
${i} id="item${i}" ${plate.done ? 'checked' : ''}>
`;
}).join(''); // 将数组转换为字符串
}
// 切换项目完成状态函数
function toggleDone(e) {
// 如果点击的不是input元素则直接返回
if (!e.target.matches('input')) return;
// 获取被点击元素的data-index属性值
const el = e.target;
const index = el.dataset.index;
// 切换项目的完成状态
items[index].done = !items[index].done;
// 更新本地存储
localStorage.setItem('tapasItems', JSON.stringify(items));
// 重新渲染列表
populateList(items, itemsList);
}
// 添加事件监听器
// 表单提交事件 - 添加新项目
addItems.addEventListener('submit', addItem);
// 列表点击事件 - 切换项目完成状态
itemsList.addEventListener('click', toggleDone);
// 初始化加载 - 页面加载时渲染已有项目
populateList(items, itemsList);
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>Documenttitle>
<link rel="stylesheet" href="./common.css">
head>
<body>
<div class="wrapper">
<h2>Local TAPASh2>
<p>请添加您的TAPASp>
<ul class="plates">
<li>Loading Tapas ...li>
ul>
<form action="" class="add-items">
<input
type="text"
placeholder="Item Name"
required -- 让输入框变成必须填写 -->
name="item"
>
<input type="submit" value="+ Add Item">
form>
div>
<script src="./common.js">
script>
body>
html>
分析考点易错点
1. Stylus 变量 $background_color
考点:
- Stylus 中变量的定义和使用
- RGBA 颜色值的表示方法
答案:
$background_color = rgba(255, 255, 255, 0.95)
定义了一个半透明白色背景变量- 在 Stylus 中,变量名可以包含
$
符号,但不是必须的 - 可以直接在样式中引用变量,如
background $background_color
易错点:
- 忘记变量名前加
$
(虽然 Stylus 允许不加,但加了更清晰) - RGBA 值写错格式,如漏掉 alpha 通道或使用错误范围值
- 变量作用域问题(Stylus 变量有作用域概念)
考点:
- Stylus 中变量的定义和使用
- RGBA 颜色值的表示方法
答案:
$background_color = rgba(255, 255, 255, 0.95)
定义了一个半透明白色背景变量- 在 Stylus 中,变量名可以包含
$
符号,但不是必须的 - 可以直接在样式中引用变量,如
background $background_color
易错点:
- 忘记变量名前加
$
(虽然 Stylus 允许不加,但加了更清晰) - RGBA 值写错格式,如漏掉 alpha 通道或使用错误范围值
- 变量作用域问题(Stylus 变量有作用域概念)
2. 背景图片设置
考点:
- CSS 背景属性的简写方式
background-size: cover
的作用- 多背景属性的正确顺序
答案:
background url('http://wes.io/hx9M/oh-la-la.jpg') center no-repeat
background-size cover
等价于 CSS:
background-image: url('http://wes.io/hx9M/oh-la-la.jpg');
background-position: center;
background-repeat: no-repeat;
background-size: cover;
易错点:
- 混淆
cover
和 contain
的区别:cover
:完全覆盖容器,可能裁剪图片contain
:完整显示图片,可能留白
- 背景图片 URL 未加引号导致错误
- 多个背景属性顺序错误(简写时有特定顺序要求)
- 忘记设置
no-repeat
导致图片平铺
考点:
- CSS 背景属性的简写方式
background-size: cover
的作用- 多背景属性的正确顺序
答案:
background url('http://wes.io/hx9M/oh-la-la.jpg') center no-repeat
background-size cover
等价于 CSS:
background-image: url('http://wes.io/hx9M/oh-la-la.jpg');
background-position: center;
background-repeat: no-repeat;
background-size: cover;
易错点:
- 混淆
cover
和contain
的区别:cover
:完全覆盖容器,可能裁剪图片contain
:完整显示图片,可能留白
- 背景图片 URL 未加引号导致错误
- 多个背景属性顺序错误(简写时有特定顺序要求)
- 忘记设置
no-repeat
导致图片平铺
3. localStorage 使用
考点:
- localStorage 的 API 使用
- JSON 序列化与反序列化
- 数据持久化策略
答案:
// 存储数据
localStorage.setItem('tapasItems', JSON.stringify(items));
// 读取数据
let items = JSON.parse(localStorage.getItem('tapasItems')) || [];
易错点:
- 忘记使用
JSON.stringify
直接存储对象,导致存储为 [object Object]
- 读取时忘记使用
JSON.parse
,导致得到的是字符串而非对象 - 未处理
getItem
返回 null 的情况(代码中使用 || []
做了默认值处理) - 存储大量数据超出 localStorage 容量限制(通常 5MB)
- 不考虑隐私模式下 localStorage 可能不可用的情况
考点:
- localStorage 的 API 使用
- JSON 序列化与反序列化
- 数据持久化策略
答案:
// 存储数据
localStorage.setItem('tapasItems', JSON.stringify(items));
// 读取数据
let items = JSON.parse(localStorage.getItem('tapasItems')) || [];
易错点:
- 忘记使用
JSON.stringify
直接存储对象,导致存储为[object Object]
- 读取时忘记使用
JSON.parse
,导致得到的是字符串而非对象 - 未处理
getItem
返回 null 的情况(代码中使用|| []
做了默认值处理) - 存储大量数据超出 localStorage 容量限制(通常 5MB)
- 不考虑隐私模式下 localStorage 可能不可用的情况
4. Viewport Meta 标签
考点:
- 响应式设计基础
- 移动端视口控制
- 各属性的含义
答案:
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
各属性含义:
width=device-width
:视口宽度等于设备宽度initial-scale=1
:初始缩放比例为1user-scalable=no
:禁止用户缩放viewport-fit=cover
:覆盖整个屏幕(针对刘海屏设备)
易错点:
- 拼写错误如
user-scalable
写成 user-scalabe
- 错误理解
initial-scale
的作用 - 在需要用户缩放功能的场景错误地设置
user-scalable=no
- 忽略
viewport-fit=cover
导致刘海屏设备显示问题 - 多个属性间缺少逗号分隔(viewport 内容是用逗号分隔的)
考点:
- 响应式设计基础
- 移动端视口控制
- 各属性的含义
答案:
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
各属性含义:
width=device-width
:视口宽度等于设备宽度initial-scale=1
:初始缩放比例为1user-scalable=no
:禁止用户缩放viewport-fit=cover
:覆盖整个屏幕(针对刘海屏设备)
易错点:
- 拼写错误如
user-scalable
写成user-scalabe
- 错误理解
initial-scale
的作用 - 在需要用户缩放功能的场景错误地设置
user-scalable=no
- 忽略
viewport-fit=cover
导致刘海屏设备显示问题 - 多个属性间缺少逗号分隔(viewport 内容是用逗号分隔的)
小知识
最后再讲一个我也是刚刚才了解到的小知识,毕竟我还是小白嘛🚀🚀
- 首先打开自己的手机开启热点,然后用自己的电脑连接手机上的热点
- 在电脑上按住
Win
+R
键,输入cmd
,进入终端
3. 在终端中输入ipconfig
的命令,找到一个名为IPv4
的地址,复制一下

- 然后运行
html
文件,要用Open with Live Serve
运行项目
最后再讲一个我也是刚刚才了解到的小知识,毕竟我还是小白嘛🚀🚀
- 首先打开自己的手机开启热点,然后用自己的电脑连接手机上的热点
- 在电脑上按住
Win
+R
键,输入cmd
,进入终端
3. 在终端中输入
ipconfig
的命令,找到一个名为IPv4
的地址,复制一下
- 然后运行
html
文件,要用Open with Live Serve
运行项目
- 将你之前复制的
IPv4
的地址更改到下面的位置,也就是在我图片的127.0.0.1
的位置上填写上你自己之前复制的地址
- 之后可以先运行一下,运行成功的话,将这整个链接复制发给你的手机,然后你手机点击这个链接就可以登录上这个网页了。
- 如果出现了一些
BUG
,有可能是防火墙的问题,或者你没有IPv4
的地址,那就在终端的那个页面复制一个你显示了的地址就可以了,如果是其它问题自行上网搜索吧,小白的我也解决不了🚀🚀
来源:juejin.cn/post/7516797727066406966
浏览器缓存方案
一、浏览器缓存的核心作用与分类
作用:减少网络请求,提升页面加载速度,降低服务器压力。
分类:
- 强缓存:浏览器直接从本地缓存获取资源,不发请求到服务器;
- 协商缓存:发送请求到服务器验证缓存是否有效,有效则返回304状态码,浏览器使用本地缓存。
二、强缓存实现方案(Cache-Control/Expires)
1. Cache-Control(HTTP/1.1,推荐)
- 核心指令:
Cache-Control: max-age=31536000 // 缓存1年(单位:秒)
Cache-Control: no-cache // 强制协商缓存
Cache-Control: no-store // 禁止缓存
Cache-Control: public/private // 缓存可见范围
- 示例配置(Nginx):
location ~* \.(js|css|png|jpg|jpeg|gif|svg)$ {
expires 1y; // 等价于Cache-Control: max-age=31536000
add_header Cache-Control "public";
}
2. Expires(HTTP/1.0,兼容性好)
- 格式:
Expires: Thu, 01 Jan 2024 00:00:00 GMT // 绝对过期时间
- 与Cache-Control的优先级:
- 若同时存在,
Cache-Control
优先级更高(因Expires
依赖服务器时间)。
- 若同时存在,
三、协商缓存实现方案(Last-Modified/ETag)
1. ETag(推荐,更精准)
- 原理:服务器为资源生成唯一标识(如文件哈希值),浏览器请求时通过
If--Match
发送标识,服务器对比后返回304(未修改)或200(修改)。 - 示例流程:
- 首次请求:服务器返回资源+
ETag: "abc123"
; - 再次请求:浏览器发送
If--Match: "abc123"
; - 服务器对比标识,未修改则返回304,否则返回新资源。
- 首次请求:服务器返回资源+
2. Last-Modified/If-Modified-Since
- 原理:服务器返回资源最后修改时间(
Last-Modified
),浏览器下次请求时通过If-Modified-Since
发送时间,服务器对比后判断是否更新。 - 缺点:
- 精度有限(仅精确到秒);
- 无法检测文件内容未变但修改时间变更的情况(如编辑器自动保存)。
四、缓存策略对比表
策略 | 强缓存 | 协商缓存 |
---|---|---|
核心字段 | Cache-Control/Expires | ETag/Last-Modified |
是否发请求 | 否(直接读本地) | 是(验证缓存有效性) |
服务器压力 | 低 | 中(需验证请求) |
更新及时性 | 差(需等max-age过期) | 好(每次请求验证) |
五、各类资源的缓存策略
1. 静态资源(JS/CSS/图片)
- 策略:
- 强缓存(
max-age=31536000
)+ 版本号(如app.v1.0.0.js
); - 版本更新时修改文件名,强制浏览器加载新资源。
- 强缓存(
- Nginx配置:
location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000";
add_header ETag on; // 开启ETag协商缓存
}
2. HTML页面
- 策略:
- 不缓存或短缓存(
max-age=0
)+ 协商缓存(ETag); - 因HTML常包含动态内容,避免强缓存导致页面不更新。
- 不缓存或短缓存(
- 配置:
location / {
expires 0;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
}
3. 动态接口(API)
- 策略:
- 禁止缓存(
Cache-Control: no-cache
); - 或根据业务需求设置短缓存(如5分钟)。
- 禁止缓存(
六、问题
1. 问:强缓存和协商缓存的执行顺序?
- 答:
- 浏览器先检查强缓存(
Cache-Control/Expires
),有效则直接使用本地缓存; - 强缓存失效后,发送请求到服务器验证协商缓存(
ETag/Last-Modified
),有效则返回304; - 协商缓存失效后,服务器返回新资源(200 OK)。
- 浏览器先检查强缓存(
2. 问:如何强制浏览器更新缓存?
- 答:
- 前端:修改资源URL(如加版本号
?v=2.0
); - 后端:
- 发送
Cache-Control: no-cache
强制协商缓存; - 更改
ETag
或Last-Modified
值,使协商缓存失效。
- 发送
- 前端:修改资源URL(如加版本号
3. 问:ETag和Last-Modified的优缺点?
- 答:
- ETag:
✅ 优点:精准检测资源变化(基于内容哈希);
❌ 缺点:计算哈希有性能开销,资源量大时影响服务器效率。 - Last-Modified:
✅ 优点:实现简单,服务器压力小;
❌ 缺点:精度低,无法检测内容未变但修改时间变更的情况。
- ETag:
4. 问:如何处理缓存导致的登录状态失效?
- 答:
- 在响应头中添加
Cache-Control: private
(仅客户端可缓存); - 或对包含登录状态的资源设置
Cache-Control: no-cache
,强制每次请求验证; - 前端路由跳转时,通过
window.location.reload(true)
强制刷新(跳过强缓存)。
- 在响应头中添加
七、缓存调试与优化工具
- Chrome DevTools:
- Network面板:查看请求的缓存状态(
from disk cache
/from memory cache
/304 Not Modified
); - 禁用缓存:勾选
Disable cache
可临时关闭缓存,方便开发调试。
- Network面板:查看请求的缓存状态(
- Lighthouse:
- 审计缓存策略是否合理,给出优化建议(如“可缓存的资源未设置缓存”)。
- 服务器日志:
- 分析
304
请求比例,评估缓存命中率(理想情况下静态资源命中率应>80%)。
- 分析
来源:juejin.cn/post/7522093523966197812
前端文件下载全攻略:从单文件到批量下载,哪种方法最优?
小张是一名刚入职的前端开发工程师,某天,他的领导给他布置了一个看似简单的任务:
让用户能够通过文件链接下载多个文件
小张信心满满,觉得这不过是个小问题。然而,当他真正动手时,才发现这个需求并不简单。不同的下载方式各有优缺点,甚至有些方法会带来意想不到的问题,他决定一一尝试,探索最优解。
方案一:window.open
——简单粗暴,但会打开新标签页
小张首先想到的是 window.open(url)
,它可以让浏览器直接打开下载链接。
window.open('https://example.com/file.pdf');
优点:
- 代码简单,直接调用即可。
- 适用于单个文件的下载。
缺点:
- 每次下载都会打开一个新的浏览器标签页,影响用户体验。
- 部分浏览器可能会拦截
window.open
,导致下载失败。
方案二:window.location.href
简单有效,但不能同时下载多个文件
小张发现,window.location.href
也可以实现下载,且不会打开新标签页。
window.location.href = 'https://example.com/file.pdf';
优点:
- 适用于单文件下载。
- 不会像
window.open
那样打开新页面。
缺点:
- 无法循环下载多个文件。如果连续多次赋值
window.location.href
,后一个请求会覆盖前一个,导致只能下载最后一个文件。
方案三:iframe
支持多文件下载,但无法监听完成状态
为了让多个文件能够顺利下载,小张尝试用 iframe
。
function downloadFile(url) {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
document.body.appendChild(iframe);
setTimeout(() => {
document.body.removeChild(iframe);
}, 5000); // 延迟移除 iframe,防止影响下载
}
优点:
- 适用于多文件下载。
缺点:
iframe
无法监听文件下载是否完成。- 需要在合适的时机移除
iframe
,否则可能会影响页面性能。
方案四:fetch + blob
——最优雅的下载方式
小张最终发现,fetch
可以获取文件数据,再通过 Blob
处理并使用 a
标签下载。
async function downloadFile(url, fileName) {
const response = await fetch(url);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}
function download(fileList){
for(const file of fileList) {
await downloadFile(file.url,file.name)
}
}
优点:
- 不会打开新标签页。
- 可以同时下载多个文件。
- 适用于现代浏览器,兼容性较好。
缺点:
- 需要处理异步
fetch
请求。 - 服务器必须支持跨域资源共享(CORS),否则
fetch
请求会失败。 - 多次文件下载会导致多个浏览器下载图标:每次调用
a.click()
时,浏览器都会显示一个下载图标,影响用户体验。
方案五:jsZip
打包多个文件为 ZIP 下载——避免多次下载图标
为了进一步优化方案四,避免浏览器每次下载时显示多个下载图标,小张决定使用 jsZip
插件将多个文件打包成一个 ZIP 文件下载。
import JSZip from 'jszip';
async function downloadFilesAsZip(files) {
const zip = new JSZip();
// 循环遍历多个文件,获取每个文件的数据
for (const file of files) {
const response = await fetch(file.url);
if (!response.ok) throw new Error(`Failed to fetch ${file.name}`);
const blob = await response.blob();
zip.file(file.name, blob); // 将文件添加到 ZIP 包中
}
// 生成 ZIP 文件并触发下载
zip.generateAsync({ type: "blob" })
.then(function(content) {
const a = document.createElement('a');
const blobUrl = URL.createObjectURL(content);
a.href = blobUrl;
// 给压缩包设置下载文件名
a.download = 'files.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 释放 URL 对象
URL.revokeObjectURL(blobUrl);
});
}
优点:
- 提升用户体验:用户下载一个压缩包后,只需解压就可以获取所有文件,避免了多次点击和等待的麻烦。
- 适用于多文件下载:非常适合需要批量下载的场景。
缺点:
- 浏览器对大文件的支持:如果要下载的文件非常大,或者文件总大小很大,可能会导致内存消耗过高,甚至在浏览器中崩溃。
- 下载速度受限于压缩处理:打包文件为 ZIP 需要时间,尤其是文件较多时,会稍微影响压缩的速度,只适用于文件不是很大且数量不是很多的时候
结语:小张的最终选择
经过一番探索,小张最终选择了 jsZip
打包文件的方案,因为它不仅解决了多个文件下载时图标显示的问题,还提高了用户体验,让下载更加流畅,没有哪个方案比另外一个方案好,只有最适合的方案,根据实际的场景能满足需求最优解就是最好的。
来源:juejin.cn/post/7488172786692685835
赋能大模型:ant-design系列组件的文档知识库搭建
引言
在当今组件化开发时代,知识库建设已成为提升开发效率的重要环节。然而传统爬虫方式在获取结构化组件文档时往往面临诸多挑战。为此,开发了 antd-doc-gen 工具,用来快速生成 antd 系列组件库的文档,将其作为大模型补充的知识库,生成的文档可以非常方便的导入到 像 ima,cursor,Obsidian 等支持知识库的工具。本文将解析其技术实现与设计理念。
npm 地址:http://www.npmjs.com/package/ant…
github 仓库:github.com/xuanxuan321…
一、核心功能概览
antd-doc-gen 作为专业的命令行工具,具备以下核心能力:
- 多库支持:原生支持 Ant Design 主库、Mobile、Mini、Web3 及 X 系列组件库
- 智能文档解析:自动识别组件文档结构,合并主文档与示例代码
- 格式标准化:生成统一格式的 Markdown 文档,并创建索引目录
- 远程协作:支持从 GitHub 仓库直接下载代码并处理
二、使用指南
快速安装
npm install -g antd-doc-gen
典型用例
生成 antd 文档
antd-doc-gen -d -r https://github.com/ant-design/ant-design
生成 antd-mobile 文档
antd-doc-gen -d -r https://github.com/ant-design/ant-design-mobile
生成 antd-mini 文档
antd-doc-gen -d -r https://github.com/ant-design/ant-design-mini
生成 antd-x 文档
antd-doc-gen -d -r https://github.com/ant-design/x
生成 antd-web3 文档
antd-doc-gen -d -r https://github.com/ant-design/ant-design-web3
三、技术实现解析
智能文档处理流程
工具通过五层处理流程实现文档自动化生成:
- 命令行解析:使用 commander 库处理参数,支持多路径输入
- 代码下载:基于 simple-git 实现多协议下载(HTTPS/SSH),含分支容错机制
- 文档定位:针对不同仓库类型采用差异化路径策略(如 antd 使用 components/*/index.zh-CN.md)
- 内容整合:通过正则表达式提取示例代码,自动补全扩展名(.tsx → .ts)
- 输出生成:按组件名称生成 Markdown 文件,并创建字母序索引
其他:
● 智能路径处理:支持跨平台路径分隔符自动转换,兼容绝对/相对路径
● 文档格式统一:保留原始结构,将示例代码以 Markdown 代码块嵌入
● 容错机制:提供分支/协议降级策略,支持无示例文档的直接复制
技术栈与扩展性
核心技术栈
● Node.js 生态:fs/path 模块实现文件操作,readline 处理用户交互
● 第三方库:commander(命令行)、simple-git(Git 操作)、ora(加载动画)
扩展能力
工具支持通过代码修改实现:
- 新组件库类型适配
- 自定义文档输出格式
- 新增文档处理逻辑
适用场景
需要 antd 系列组件库文档作为知识库的场景
局限性与优化方向
当前版本依赖特定文档结构(需包含 index.md 及 code 标签),未来计划:
● 增强非标准文档的兼容性
● 支持更多文档格式输出
● 集成文档预览功能
结语
antd-doc-gen 通过自动化文档处理流程,显著提升了组件库文档的维护效率。文档可以直接导入像 ima,cursor,Obsidian 等知识库工具,进一步提升大模型的能力
来源:juejin.cn/post/7479814468601085986
JavaScript 数据扁平化方法大全
前言
数据扁平化是指将多维数组转换为一维数组的过程。由于嵌套数据结构增加了访问和操作数据的复杂度,所以·我们可以将嵌套数据变成一维的数据结构,下面就是我搜集到的一些方法,希望可以给你带来帮助!!
1. 使用 Array.prototype.flat()(推荐)
ES2019 引入的专门方法:
const nestedArr = [1, [2, [3, [4]], 5]];
// 默认只扁平化一层
const flattened1 = nestedArr.flat();
console.log(flattened1); // [1, 2, [3, [4]], 5]
// 指定深度为2
const flattened2 = nestedArr.flat(2);
console.log(flattened2); // [1, 2, 3, [4], 5]
// 完全扁平化
const fullyFlattened = nestedArr.flat(Infinity);
console.log(fullyFlattened); // [1, 2, 3, 4, 5]
解析:
flat(depth)
方法创建一个新数组,所有子数组元素递归地连接到指定深度- 参数
depth
指定要提取嵌套数组的结构深度,可选的参数,默认为1 - 使用
Infinity
可展开任意深度的嵌套数组,Infinity
是一个特殊的数值,表示无穷大。
2. 使用 reduce() 和 concat() 递归
function flatten(arr) {
// 使用 reduce 方法遍历数组元素
return arr.reduce((acc, val) => {
// 如果当前元素是数组,则递归调用 flatten 继续展开,并拼接到累积数组 acc
if (Array.isArray(val)) {
return acc.concat(flatten(val));
}
// 如果当前元素不是数组,直接拼接到累积数组 acc
else {
return acc.concat(val);
}
}, []); // 初始累积值是一个空数组 []
}
// 测试用例
const nestedArr = [1, [2, [3, [4]], 5]];
console.log(flatten(nestedArr)); // 输出: [1, 2, 3, 4, 5]
解析:
- 递归处理嵌套数组
- 遇到子数组时,递归调用
flatten(val)
继续展开,直到所有层级都被展开为单层。
- 遇到子数组时,递归调用
reduce
方法的作用
- 遍历数组,通过
acc
(累积值)逐步拼接结果,初始值设为[]
(空数组)。
- 遍历数组,通过
Array.isArray(val)
检查
- 判断当前元素是否为数组,决定是否需要递归展开。
concat
拼接结果
- 将非数组元素或递归展开后的子数组拼接到累积数组
acc
中。
- 将非数组元素或递归展开后的子数组拼接到累积数组
3. 使用 concat()
和扩展运算符递归
function flatten(arr) {
// 使用扩展运算符 (...) 展开数组的第一层,并合并成一个新数组
const flattened = [].concat(...arr);
// 检查当前展开后的数组中是否仍然包含嵌套数组
// 如果存在嵌套数组,则递归调用 flatten 继续展开
// 如果所有元素都是非数组类型,则直接返回展开后的数组
return flattened.some(item => Array.isArray(item))
? flatten(flattened)
: flattened;
}
// 测试用例
const nestedArr = [1, [2, [3, [4]], 5]];
console.log(flatten(nestedArr)); // 输出: [1, 2, 3, 4, 5]
解析:
[].concat(...arr)
展开一层数组
- 使用扩展运算符
...
展开arr
的最外层,并通过concat
合并成一个新数组。 - 例如:
[].concat(...[1, [2, [3]]])
→[1, 2, [3]]
(仅展开一层)。
- 使用扩展运算符
flattened.some(Array.isArray)
检查嵌套
- 使用 Array.prototype.some() 检查当前数组是否仍然包含子数组。
- 如果存在,则递归调用
flatten
继续展开。
- 递归终止条件
- 当
flattened
不再包含任何子数组时,递归结束,返回最终结果。
- 当
4. 使用 toString()
方法(仅适用于数字数组)
const nestedArr = [1, [2, [3, [4]], 5]];
const flattened = nestedArr.toString().split(',').map(Number);
console.log(flattened); // [1, 2, 3, 4, 5]
解析:
toString()
的隐式转换
- JavaScript 的
Array.prototype.toString()
会自动展开嵌套数组,并用逗号连接所有元素。 - 例如:
[1, [2, [3]]].toString()
→"1,2,3"
。
- JavaScript 的
split(',')
分割字符串
- 将字符串按逗号拆分成字符串数组,但所有元素会是字符串类型(如
"2"
)。
- 将字符串按逗号拆分成字符串数组,但所有元素会是字符串类型(如
map(Number)
类型转换
- 通过
Number
构造函数将字符串元素转换为数字类型。 - 注意:如果原数组包含非数字(如
['a', [2]]
),结果会变成[NaN, 2]
。
- 通过
优缺点:
- 优点:代码极其简洁,适合纯数字的嵌套数组。
- 缺点:
- 仅适用于数字数组(其他类型会被强制转换,如
true
→1
,null
→0
)。 - 无法保留原数据类型(如字符串
'3'
会被转成数字3
)。
- 仅适用于数字数组(其他类型会被强制转换,如
适用场景:
- 快速展开纯数字的嵌套数组,且不关心中间过程的性能损耗(
toString
和split
会有临时字符串操作)。
5. 使用 JSON.stringify() 和正则表达式
function flatten(arr) {
// 1. 使用 JSON.stringify 将数组转换为字符串表示
// 例如:[1, [2, [3]], 'a'] → "[1,[2,[3]],\"a\"]"
const jsonString = JSON.stringify(arr);
// 2. 使用正则表达式移除所有的 '[' 和 ']' 字符
// 例如:"[1,[2,[3]],\"a\"]" → "1,2,3,\"a\""
const withoutBrackets = jsonString.replace(/[\[\]]/g, '');
// 3. 按逗号分割字符串,生成字符串数组
// 例如:"1,2,3,\"a\"" → ["1", "2", "3", "\"a\""]
const stringItems = withoutBrackets.split(',');
// 4. 尝试将每个字符串解析回原始数据类型
// - 数字会变成 Number 类型(如 "1" → 1)
// - 字符串会保留(如 "\"a\"" → "a")
// - 其他 JSON 可解析类型也会被正确处理
return stringItems.map(item => {
try {
// 尝试 JSON.parse 解析(处理字符串、数字等)
return JSON.parse(item);
} catch (e) {
// 如果解析失败(如空字符串或非法 JSON),返回原始字符串
return item;
}
});
}
// 测试用例
const nestedArr = [1, [2, [3, [4]], 5, 'a', { b: 6 }];
console.log(flatten(nestedArr));
// 输出: [1, 2, 3, 4, 5, "a", { b: 6 }]
解析:
JSON.stringify
的作用
- 将整个数组(包括嵌套结构)转换为 JSON 字符串,保留所有数据类型信息。
- 正则替换
/[[]]/g
- 移除所有方括号字符
[
和]
,只保留逗号分隔的值。
- 移除所有方括号字符
split(',')
分割字符串
- 生成一个字符串数组,但每个元素可能仍是被 JSON 字符串化的(如
""a""
)。
- 生成一个字符串数组,但每个元素可能仍是被 JSON 字符串化的(如
- JSON.parse() 尝试恢复数据类型
- 通过
JSON.parse
将字符串转换回原始类型(数字、字符串、对象等)。 - 使用
try-catch
处理不合法的 JSON 字符串(如空字符串或格式错误的情况)。
- 通过
优缺点:
- 优点:
- 支持任意数据类型(数字、字符串、对象等)。
- 能正确处理嵌套对象(如
{ b: 6 }
)。
- 缺点:
- 性能较低(涉及 JSON 序列化、正则替换、解析等操作)。
- 如果原始数组包含特殊字符串(如
"[1]"
) ,可能会被错误解析。
适用场景:
- 需要处理混合数据类型(非纯数字)的嵌套数组。
- 对性能要求不高,但需要代码简洁的场景。
6. 使用堆栈的非递归实现
function flatten(arr) {
// 创建栈并初始化(使用扩展运算符浅拷贝原数组)
const stack = [...arr];
const result = [];
// 循环处理栈中的元素
while (stack.length) {
// 从栈顶取出一个元素
const next = stack.pop();
if (Array.isArray(next)) {
// 如果是数组,展开后压回栈中(保持顺序)
stack.push(...next);
} else {
// 非数组元素,添加到结果数组前端(保持原顺序)
result.unshift(next);
}
}
return result;
}
const nestedArr = [1, [2, [3, [4]], 5]];
console.log(flatten(nestedArr)); // [1, 2, 3, 4, 5]
解析:
- 栈结构初始化
- 使用扩展运算符
[...arr]
创建原数组的浅拷贝作为初始栈 - 避免直接修改原数组
- 使用扩展运算符
- 栈处理循环
- 使用
while
循环处理栈直到为空 - 每次从栈顶
pop()
一个元素进行处理
- 使用
- 元素类型判断
- 使用
Array.isArray()
检查元素是否为数组 - 如果是数组则展开后重新压入栈
- 非数组元素则添加到结果数组
- 使用
- 顺序保持
- 使用
unshift()
将元素添加到结果数组前端,当然这样比较费性能,可以改用push()
+reverse()
替代unshift()
- 确保最终结果的顺序与原数组一致
- 使用
优缺点
- 优点:
- 支持任意数据类型(不限于数字)
- 可以处理深层嵌套结构(无递归深度限制)
- 相比递归实现,不易导致栈溢出
- 缺点:
- 使用 unshift() 导致时间复杂度较高(O(n²))
- 需要额外空间存储栈结构
- 相比原生
flat()
方法性能稍差 - 无法控制扁平化深度(总是完全扁平化)
适用场景
- 需要处理混合数据类型的深层嵌套数组
- 需要避免递归导致的栈溢出风险
7. 使用 Array.prototype.some()
和扩展运算符
function flatten(arr) {
// 循环检测数组中是否还包含数组元素
while (arr.some(item => Array.isArray(item))) {
// 使用扩展运算符展开当前层级的所有数组
// 并通过concat合并为一层
arr = [].concat(...arr);
}
return arr;
}
const nestedArr = [1, [2, [3, [4]], 5]];
console.log(flatten(nestedArr)); // [1, 2, 3, 4, 5]
解析:
- 循环条件检测
- 使用
arr.some()
方法检测数组中是否还存在数组元素 Array.isArray(item)
判断每个元素是否为数组
- 使用
- 层级展开
- 使用扩展运算符
...arr
展开当前层级的数组 - 通过
[].concat()
将展开的元素合并为新数组
- 使用扩展运算符
- 迭代处理
- 每次循环处理一层嵌套
- 重复直到没有数组元素存在
性能比较
对于大多数现代应用:
- 优先使用
flat(Infinity)
(最简洁且性能良好) - 对于深度嵌套的大数组,考虑非递归的堆栈实现
- 递归方法在小数据集上表现良好且代码简洁
- 避免
toString()
方法除非确定只有数字数据
总结
JavaScript 提供了多种扁平化数组的方法,从简单的内置 flat()
方法到各种手动实现的递归、迭代方案。选择哪种方法取决于:
- 运行环境是否支持 ES2019+
- 数据结构的复杂程度
- 对性能的要求
- 代码可读性需求
在大多数现代应用中,flat(Infinity)
是最佳选择,因为它简洁、高效且语义明确。
来源:juejin.cn/post/7522371045652578356
5 个理由告诉你为什么有了 JS 还要需要 TypeScript
在前端开发圈,JavaScript(简称JS)几乎无处不在。但你有没有发现,越来越多的大型项目和团队都在用 TypeScript(简称TS)?明明 JS 已经这么强大,为什么还要多此一举用 TS 呢?今天就用通俗易懂的语言,结合具体例子,带你彻底搞懂这个问题!🌟
1. JS的弱类型让大型项目“踩坑”不断
JavaScript 是一种弱类型语言,也就是说,变量的类型可以随时变化。虽然这让 JS 写起来很灵活,但在大型项目中却容易埋下隐患。
举个例子:
// JS 代码
function sum(a, b) {
return a + b;
}
console.log(sum(1, 2)); // 输出 3
console.log(sum('1', 2)); // 输出 '12',字符串拼接
console.log(sum(true, [])); // 输出 'true',奇怪的结果
在 JS 里,sum
函数参数类型完全不受限制,传什么都行。小项目还好,项目一大,团队一多,类型混乱就会导致各种难以发现的bug,甚至上线后才暴雷,影响开发效率和用户体验。
2. TS的类型检查让错误“消灭在摇篮里”
TypeScript 是 JS 的超集,在 JS 的基础上增加了类型系统。这意味着你可以在写代码时就发现类型错误,而不是等到运行时才发现。
同样的例子,用 TS 改写:
// TS 代码
function sum(a: number, b: number): number {
return a + b;
}
sum(1, 2); // 正常
sum('1', 2); // ❌ 报错:参数类型不匹配
TS 会在你写代码时就提示错误,防止类型不一致带来的 bug。这样,开发效率和代码质量都大大提升!
3. TS的类型推断让开发更智能
你可能担心,TS 要写很多类型声明,会不会很麻烦?其实不用担心,TS 有类型推断功能,能根据你的代码自动判断类型。
例子:
let age = 18; // TS 自动推断 age 是 number 类型
age = '二十'; // ❌ 报错:不能把 string 赋值给 number
你只需要在关键地方声明类型,其他地方 TS 会帮你自动推断,大大减少了重复劳动。
4. TS让团队协作更高效
在多人协作的大型项目中,TS 的类型系统就像一份“契约”,让每个人都能清楚知道每个函数、对象、变量的类型,极大减少沟通成本和踩坑概率。
例子:
// 定义一个工具函数
function formatUser(user: { name: string; age: number }) {
return `${user.name} (${user.age})`;
}
// 调用时,TS 会自动检查参数类型
formatUser({ name: '小明', age: 20 }); // 正常
formatUser({ name: '小红', age: '二十' }); // ❌ 报错
有了类型约束,团队成员只要看类型定义就能明白怎么用,不用再靠口头说明或文档补充,协作效率大大提升。
5. TS支持现代开发工具,体验更丝滑
TS 的类型信息可以被编辑器和IDE(如 VSCode)利用,带来更智能的自动补全、跳转、重构、查找引用等功能,让开发体验飞升!
例子:
- 输入对象名时,编辑器会自动提示有哪些属性;
- 修改类型定义,相关代码会自动高亮出错,方便全局重构;
- 查找函数引用时,TS 能精确定位所有用到的地方。
这些功能在 JS 里是做不到的,TS 让开发更高效、更安全、更快乐! 😄
TS的常见类型一览表
类型 | 说明 | 示例 |
---|---|---|
any | 任意类型 | let a: any |
unknown | 未知类型 | let b: unknown |
never | 永不存在的类型 | function error(): never { throw new Error() } |
string | 字符串 | let s: string |
number | 数字 | let n: number |
boolean | 布尔 | let b: boolean |
null | 空 | let n: null |
undefined | 未定义 | let u: undefined |
symbol | 符号 | let s: symbol |
bigint | 大整数 | let b: bigint |
object | 狭义对象类型 | let o: object |
Object | 广义对象类型 | let O: Object |
小贴士:
any
虽然灵活,但会失去类型检查,不推荐使用;unknown
更安全,推荐用来接收不确定类型的数据。
TS的安装与使用
TypeScript 的安装和使用也非常简单:
npm install -g typescript
npm install -g ts-node
typescript
用于编译.ts
文件, 在当前目录生成一个同名的.js
文件;ts-node
可以直接运行 TS 文件,开发更方便。
总结
有了 JS,为什么还要用 TS?
归根结底,TS 让代码更安全、开发更高效、协作更顺畅、体验更丝滑。尤其是在大型项目和团队协作中,TS 的优势会越来越明显。
5个理由再回顾:
- JS 弱类型,容易埋坑,TS 静态类型,提前发现错误;
- TS 类型检查,bug 消灭在摇篮里;
- TS 类型推断,开发更智能;
- TS 类型约束,团队协作更高效;
- TS 支持现代开发工具,体验更丝滑。
如果你还没用过 TypeScript,不妨试试,相信你会爱上它!💙
来源:juejin.cn/post/7525660078722154511
你不会使用css函数 clamp()?那你太low了😀
我们做前端的,为了让网站在不同设备上都好看,天天都在和“响应式”打交道。其中最常见的一个场景,就是处理字体大小。
通常,我们是这么做的:
/* 手机上是16px */
h1 {
font-size: 16px;
}
/* 平板上大一点 */
@media (min-width: 768px) {
h1 {
font-size: 24px;
}
}
/* 电脑上再大一点 */
@media (min-width: 1200px) {
h1 {
font-size: 32px;
}
}
这套代码能用,但它有一个问题:字体大小的变化,是“跳跃式”的,像在走楼梯。 当你的屏幕宽度从767px变成768px时,字体会“Duang”地一下突然变大。这种体验,不够平滑。
今天,我想聊一个能让我们告别大部分这种繁琐媒体查询的CSS函数:clamp()
。它能让我们的元素尺寸,像在走一个平滑的斜坡一样,实现真正的 “流体式”缩放。
clamp()
到底是个啥?
clamp()
的中文意思是“夹子”或“钳子”,非常形象。它的作用就是把一个值的范围,“夹”在一个最大值和一个最小值之间。
它的语法极其简单:
width: clamp(最小值, 理想值, 最大值);
你可以把它理解成,你在设定一个规则:
最小值
(MIN) :这是“下限”。不管怎么样,这个值都不能比它更小了。最大值
(MAX) :这是“上限”。不管怎么样,这个值都不能比它更大了。理想值
(IDEAL) :这是“首选值”。它通常是一个根据视口变化的相对单位,比如vw
。浏览器会先尝试使用这个值。
它的工作逻辑是:
- 如果“理想值”小于“最小值”,那就取“最小值”。
- 如果“理想值”大于“最大值”,那就取“最大值”。
- 如果“理想值”在两者之间,那就取“理想值”。
使用场景:流体字号(Fluid Typography)
这是clamp()
最经典,也是最强大的用途。我们来改造一下文章开头的那个例子。
以前(媒体查询版):
h1 { font-size: 16px; }
@media (min-width: 768px) { h1 { font-size: 24px; } }
@media (min-width: 1200px) { h1 { font-size: 32px; } }
现在(clamp()
版):
h1 {
/* 最小值是16px,
理想值是视口宽度的4%,
最大值是32px。
*/
font-size: clamp(16px, 4vw, 32px);
}
看,一行代码,代替了原来的一堆媒体查询。
现在你拖动浏览器窗口,会发现标题的大小是在平滑地、线性地变化,而不是“阶梯式”地跳变。它在小屏幕上不会小于16px
,在大屏幕上不会大于32px
,而在中间的尺寸,它会根据4vw
这个值自动调整。
使用场景:动态间距(Dynamic Spacing)
clamp()
不仅仅能用在font-size
上,任何需要长度值的地方,比如margin
, padding
, gap
,它都能大显身手。
我们可以用它来创建一个“呼吸感”更强的布局。
.grid-container {
display: grid;
/* 网格间距最小15px,最大40px,中间根据视口宽度5%来缩放 */
gap: clamp(15px, 5vw, 40px);
}
.section {
/* section的上下内边距,最小20px,最大100px */
padding-top: clamp(20px, 10vh, 100px);
padding-bottom: clamp(20px, 10vh, 100px);
}
这样做的好处是,你的布局在任何尺寸的屏幕上,都能保持一个和谐的、自适应的间距,不再需要为不同断点去写多套padding
和gap
的值。
结合 calc()
实现更精准的控制
有时候,我们不希望缩放是纯线性的vw
,而是希望它有一个“基础值”,然后再根据vw
去微调。这时候,clamp()
可以和calc()
结合使用。
h1 {
/* 理想值不再是单纯的3vw,
而是 1rem + 3vw。
这意味着它有一个1rem的基础大小,然后再叠加上与视口相关的部分。
*/
font-size: clamp(1.5rem, calc(1rem + 3vw), 3rem);
}
这个calc(1rem + 3vw)
的公式,是一个非常流行和实用的流体排版计算方法。它能让你对字体大小的缩放速率有更精细的控制,是一个非常值得收藏的技巧。
兼容性如何呢?
你可能会担心浏览器的兼容性。
好消息是,在2025年的今天,clamp()
已经在所有主流现代浏览器(Chrome, Firefox, Safari, Edge)中获得了良好支持。除非你的项目需要兼容非常古老的浏览器,否则完全可以放心在生产环境中使用。
下次,当你又准备写一堆媒体查询来控制字号或间距时,不妨先停下来,问问自己:
“这个场景,是不是用clamp()
一行代码就能搞定?”
希望你试试看😀。
参考:
来源:juejin.cn/post/7527576206695776302
掌握 requestFullscreen:网页全屏功能的实用指南与技巧
想让网页上的图片、视频或者整个界面铺满用户屏幕?浏览器的 requestFullscreen api 是开发者实现这个功能的关键。
它比你想象的要强大,但也藏着一些需要注意的细节。本文将详细介绍如何正确使用它,并分享一些提升用户体验的实用技巧。
一、 开始使用 requestFullscreen:基础与常见问题
直接调用 element.requestFullscreen() 是最简单的方法,但有几个关键点容易出错:
并非所有元素都能直接全屏:
、 等媒体元素通常可以直接全屏。
浏览器兼容性问题:
老版本浏览器(特别是 Safari)需要使用带前缀的方法 webkitRequestFullscreen。安全起见,最好检测并调用正确的方法。
必须在用户操作中触发:
浏览器出于安全考虑,要求全屏请求必须在用户点击、触摸等交互事件(如 click、touchstart)的处理函数里直接调用。不能放在 setTimeout 或者异步回调里直接调用,否则会被浏览器阻止。
二、 控制全屏时的样式
全屏状态下,你可以使用特殊的 css 选择器为全屏元素或其内部的元素定制样式:
/* 为处于全屏状态的 <video> 元素设置黑色背景 */
video:fullscreen {
background-color: #000;
}
/* 当某个具有 id="controls" 的元素在全屏模式下时,默认半透明,鼠标移上去变清晰 */
#controls:fullscreen {
opacity: 0.3;
transition: opacity 0.3s ease;
}
#controls:fullscreen:hover {
opacity: 1;
}
:-webkit-full-screen (WebKit 前缀) : 针对老版本 WebKit 内核浏览器(如旧 Safari)
:fullscreen (标准) : 现代浏览器支持的标准写法。优先使用这个。
三、 实用的进阶技巧
在多个元素间切换全屏:
创建一个管理器能方便地在不同元素(如图库中的图片)之间切换全屏状态,并记住当前全屏的是哪个元素。
const fullscreenManager = {
currentElement: null, // 记录当前全屏的元素
async toggle(element) {
// 如果点击的元素已经是全屏元素,则退出全屏
if (document.fullscreenElement && this.currentElement === element) {
try {
awaitdocument.exitFullscreen();
this.currentElement = null;
} catch (error) {
console.error('退出全屏失败:', error);
}
} else {
// 否则,尝试让新元素进入全屏
try {
await element.requestFullscreen();
this.currentElement = element; // 更新当前元素
} catch (error) {
console.error('进入全屏失败:', error);
// 可以在这里提供一个后备方案,比如模拟全屏的CSS类
element.classList.add('simulated-fullscreen');
}
}
}
};
// 给图库中所有图片绑定点击事件
document.querySelectorAll('.gallery-img').forEach(img => {
img.addEventListener('click', () => fullscreenManager.toggle(img));
});
在全屏模式下处理键盘事件:
全屏时,你可能想添加自定义快捷键(如切换滤镜、截图)。
functionhandleFullscreenHotkeys(event) {
// 保留 Escape 键退出全屏的功能
if (event.key === 'Escape') return;
// 自定义快捷键
if (event.key === 'f') toggleFilter(); // 按 F 切换滤镜
if (event.ctrlKey && event.key === 'p') enterPictureInPicture(); // Ctrl+P 画中画
if (event.shiftKey && event.key === 's') captureScreenshot(); // Shift+S 截图
// 阻止这些键的默认行为(比如防止F键触发浏览器查找)
event.preventDefault();
}
// 监听全屏状态变化
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
// 进入全屏,添加自定义键盘监听
document.addEventListener('keydown', handleFullscreenHotkeys);
} else {
// 退出全屏,移除自定义键盘监听
document.removeEventListener('keydown', handleFullscreenHotkeys);
}
});
记住用户的全屏状态:
如果用户刷新页面,可以尝试自动恢复他们之前全屏查看的元素。
// 页面加载完成后检查是否需要恢复全屏
window.addEventListener('domContentLoaded', () => {
const elementId = localStorage.getItem('fullscreenElementId');
if (elementId) {
const element = document.getElementById(elementId);
if (element) {
setTimeout(() => element.requestFullscreen().catch(console.error), 100); // 稍延迟确保元素就绪
}
}
});
// 监听全屏变化,保存当前全屏元素的ID
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
localStorage.setItem('fullscreenElementId', document.fullscreenElement.id);
} else {
localStorage.removeItem('fullscreenElementId');
}
});
处理嵌套全屏(沙盒内全屏):
在已经全屏的容器内的 中再次触发全屏是可能的(需要 allow="fullscreen" 属性)。
<divid="main-container">
<iframeid="nested-content"src="inner.html"allow="fullscreen"></iframe>
</div>
<script>
const mainContainer = document.getElementById('main-container');
const iframe = document.getElementById('nested-content');
// 主容器全屏后,可以尝试触发iframe内部元素的全屏(需内部配合)
mainContainer.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement === mainContainer) {
// 假设iframe内部有一个id为'innerVideo'的视频元素
// 注意:这需要在iframe加载完成后,且iframe内容同源或允许跨域操作
const innerDoc = iframe.contentDocument || iframe.contentWindow.document;
const innerVideo = innerDoc.getElementById('innerVideo');
if (innerVideo) {
setTimeout(() => innerVideo.requestFullscreen().catch(console.error), 500);
}
}
});
</script>
四、 实际应用场景
媒体展示: 图片画廊、视频播放器(隐藏浏览器UI获得更好沉浸感 { navigationUI: 'hide' })。
数据密集型应用: 全屏表格、图表或数据看板,提供更大的工作空间。
游戏与交互: WebGL 游戏、交互式动画、全景图查看器(结合陀螺仪 API),全屏能提升性能和体验。
演示模式: 在线文档、幻灯片展示。
专注模式: 写作工具、代码编辑器。
安全措施: 在全屏内容上添加低透明度水印(使用 ::before / ::after 伪元素),增加录屏难度。
五、 开发者需要注意的问题与解决建议
问题描述 | 解决方案 |
---|---|
iOS Safari 全屏视频行为 | 为 添加 playsinline 属性防止自动横屏。提供手动旋转按钮。 |
全屏导致滚动位置丢失 | 进入全屏前记录 scrollTop,退出后恢复。或使用 scroll-snap 等布局技术。 |
全屏触发页面重排/抖动 | 提前给目标元素设置 width: 100%; height: 100%; 或固定尺寸。 |
全屏时难以打开开发者工具 | 在开发环境,避免拦截 F12 或右键菜单快捷键。使用 console 调试。 |
全屏元素内 iframe 权限 | 为 添加 allow="fullscreen" 属性。 |
检测用户手动全屏 (F11) | 比较 window.outerHeight 和 screen.height 有一定参考价值,但非绝对可靠。通常建议引导用户使用应用内的全屏按钮。 |
六、 兼容性处理封装(推荐使用)
下面是一个更健壮的工具函数,处理了不同浏览器的前缀问题:
/**
* 全屏工具类 (简化版,展示核心功能)
*/
const FullscreenHelper = {
/**
* 请求元素进入全屏模式
* @param {HTMLElement} [element=document.documentElement] 要全屏的元素,默认是整个页面
* @returns {Promise<boolean>} 是否成功进入全屏
*/
async enter(element = document.documentElement) {
const reqMethods = [
'requestFullscreen', // 标准
'webkitRequestFullscreen', // Safari, Old Chrome/Edge
'mozRequestFullScreen', // Firefox
'msRequestFullscreen'// Old IE/Edge
];
for (const method of reqMethods) {
if (element[method]) {
try {
// 可以传递选项,例如隐藏导航UI: { navigationUI: 'hide' }
await element[method]({ navigationUI: 'hide' });
returntrue; // 成功进入全屏
} catch (error) {
console.warn(`${method} 失败:`, error);
// 继续尝试下一个方法
}
}
}
returnfalse; // 所有方法都失败
},
/**
* 退出全屏模式
* @returns {Promise<boolean>} 是否成功退出全屏
*/
async exit() {
const exitMethods = [
'exitFullscreen', // 标准
'webkitExitFullscreen', // Safari, Old Chrome/Edge
'mozCancelFullScreen', // Firefox
'msExitFullscreen'// Old IE/Edge
];
for (const method of exitMethods) {
if (document[method]) {
try {
awaitdocument[method]();
returntrue; // 成功退出全屏
} catch (error) {
console.warn(`${method} 失败:`, error);
}
}
}
returnfalse; // 所有方法都失败或不在全屏状态
},
/**
* 检查当前是否有元素处于全屏状态
* @returns {boolean} 是否在全屏状态
*/
isFullscreen() {
return !!(
document.fullscreenElement || // 标准
document.webkitFullscreenElement || // Safari, Old Chrome/Edge
document.mozFullScreenElement || // Firefox
document.msFullscreenElement // Old IE/Edge
);
},
/**
* 添加全屏状态变化监听器
* @param {Function} callback 状态变化时触发的回调函数
*/
onChange(callback) {
const events = [
'fullscreenchange', // 标准
'webkitfullscreenchange', // Safari, Old Chrome/Edge
'mozfullscreenchange', // Firefox
'MSFullscreenChange'// Old IE/Edge
];
// 为每种可能的事件添加监听,确保兼容性
events.forEach(eventName => {
document.addEventListener(eventName, callback);
});
}
};
// 使用示例
const myButton = document.getElementById('fullscreen-btn');
const myVideo = document.getElementById('my-video');
myButton.addEventListener('click', async () => {
if (FullscreenHelper.isFullscreen()) {
await FullscreenHelper.exit();
} else {
await FullscreenHelper.enter(myVideo); // 让视频全屏
}
});
// 监听全屏变化
FullscreenHelper.onChange(() => {
console.log('全屏状态变了:', FullscreenHelper.isFullscreen() ? '进入全屏' : '退出全屏');
});
总结
requestFullscreen API 是实现网页元素全屏展示的核心工具。理解其基础用法、兼容性处理、样式控制和状态管理是第一步。
通过掌握切换控制、键盘事件处理、状态持久化和嵌套全屏等进阶技巧,以及规避常见的陷阱,你可以为用户创建更流畅、功能更丰富的全屏体验。
上面的 FullscreenHelper 工具类封装了兼容性细节,推荐在实际项目中使用。现在就去尝试在你的网页中应用这些技巧吧!
来源:juejin.cn/post/7527612394044850227
40岁老前端2025年上半年都学了什么?
前端学习记录第5波,每半年一次。对前四次学习内容感兴趣的可以去我的掘金专栏“每周学习记录”进行了解。
第1周 12.30-1.5
本周学习了一个新的CSS媒体查询prefers-reduced-transparency,如果用户在系统层面选择了降低或不使用半透明,这个媒体查询就能够匹配,此特性与用户体验密切相关的。
更多内容参见我撰写的这篇文章:一个新的CSS媒体查询prefers-reduced-transparency —— http://www.zhangxinxu.com/wordpress/?…
第2周 1.6-1.12
这周新学习了一个名为Broadcast Channel的API,可以实现一种全新的广播式的跨页面通信。
过去的postMessage通信适合点对点,但是广播式的就比较麻烦。
而使用BroadcastChannel就会简单很多。
这里有个演示页面:http://www.zhangxinxu.com/study/20250…
左侧点击按钮发送消息,右侧两个内嵌的iframe页面就能接收到。
此API的兼容性还是很不错的:
更多内容可以参阅此文:“Broadcast Channel API简介,可实现Web页面广播通信” —— http://www.zhangxinxu.com/wordpress/?…
第3周 1.13-1.19
这周学习的是SVG半圆弧语法,因为有个需求是实现下图所示的图形效果,其中几段圆弧的长度占比每个人是不一样的,因此,需要手写SVG路径。
圆弧的SVG指令是A,语法如下:
M x1 y1 A rx ry x-axis-rotation large-arc-flag sweep-flag x2 y2
看起来很复杂,其实深究下来还好:
详见这篇文章:“如何手搓SVG半圆弧,手把手教程” - http://www.zhangxinxu.com/wordpress/?…
第4周-第5周 1.20-2.2
春节假期,学什么学,high起来。
第6周 2.3-2.9
本周学习Array数组新增的with等方法,这些方法在数组处理的同时均不会改变原数组内容,这在Vue、React等开发场景中颇为受用。
例如,在过去,想要不改变原数组改变数组项,需要先复制一下数组:
现在有了with方法,一步到位:
类似的方法还有toReversed()、toSorted()和toSpliced()。
更新内容参见这篇文章:“JS Array数组新的with方法,你知道作用吗?” - http://www.zhangxinxu.com/wordpress/?…
第7周 2.10-2.16
本周学习了两个前端新特性,一个JS的,一个是CSS的。
1. Set新增方法
JS Set新支持了intersection, union, difference等方法,可以实现类似交集,合集,差集的数据处理,也支持isDisjointFrom()是否相交,isSubsetOf()是否被包含,isSupersetOf()是否包含的判断。
详见此文:“JS Set新支持了intersection, union, difference等方法” - http://www.zhangxinxu.com/wordpress/?…
2. font-size-adjust属性
CSS font-size-adjust属性,可以基于当前字形的高宽自动调整字号大小,以便各种字体的字形表现一致,其解决的是一个比较细节的应用场景。
例如,16px的苹方和楷体,虽然字号设置一致,但最终的图形表现楷体的字形大小明显小了一圈:
此时,我们可以使用font-size-adjust进行微调,使细节完美。
p { font-size-adjust: 0.545;}
此时的中英文排版效果就会是这样:
更新细节知识参见我的这篇文章:“不要搞混了,不是text而是CSS font-size-adjust属性” - http://www.zhangxinxu.com/wordpress/?…
第8周 2.17-2.23
本周学习的是HTML permission元素和Permissions API。
这两个都是与Web浏览器的权限申请相关的。
在Web开发的时候,我们会经常用到权限申请,比方说摄像头,访问相册,是否允许通知,又或者地理位置信息等。
但是,如果用户不小心点击了“拒绝”,那么用户就永远没法使用这个权限,这其实是有问题的,于是就有了元素,权限按钮直接暴露在网页中,直接让用户点击就好了。
但是,根据我后来的测试,Chrome浏览器放弃了对元素的支持,因此,此特性大家无需关注。
那Permissions API又是干嘛用的呢?
在过去,不同类型的权限申请会使用各自专门的API去进行,这就会导致开始使用的学习和使用成本比较高。
既然都是权限申请,且系统出现的提示UI都近似,何必来个大统一呢?在这种背景下,Permissions API被提出来了。
所有的权限申请全都使用一个统一的API名称入口,使用的方法是Permissions.query()。
完整的介绍可以参见我撰写的这篇文章:“HTML permission元素和Permissions API简介” - http://www.zhangxinxu.com/wordpress/?…
第9周 2.24-3.2
CSS offset-path属性其实在8年前就介绍过了,参见:“使用CSS offset-path让元素沿着不规则路径运动” - http://www.zhangxinxu.com/wordpress/?…
不过那个时候的offset-path属性只支持不规则路径,也就是path()函数,很多CSS关键字,还有基本形状是不支持的。
终于,盼星星盼月亮。
从Safari 18开始,CSS offset-path属性所有现代浏览器全面支持了。
因此,很多各类炫酷的路径动画效果就能轻松实现了。例如下图的蚂蚁转圈圈动画:
详见我撰写的此文:“终于等到了,CSS offset-path全浏览器全支持” - http://www.zhangxinxu.com/wordpress/?…
第10周 3.3-3.9
CSS @supports规则新增两个特性判断,分别是font-tech()和font-format()函数。
1. font-tech()
font-tech()函数可以检查浏览器是否支持用于布局和渲染的指定字体技术。
例如,下面这段CSS代码可以判断浏览器是否支持COLRv1字体(一种彩色字体技术)技术。
@supports font-tech(color-COLRv1) {}
2. font-format()
font-format()这个比较好理解,是检测浏览器是否支持指定的字体格式的。
@supports font-format(woff2) { /* 浏览器支持woff2字体 */ }
不过这两个特性都不实用。
font-tech()对于中文场景就是鸡肋特性,因为中文字体是不会使用这类技术的,成本太高。
font-format()函数的问题在于出现得太晚了。例如woff2字体的检测,这个所有现代浏览器都已经支持了,还有检测的必要吗,没了,没有意义了。
不过基于衍生的特性还是有应用场景的,具体参见此文:“CSS supports规则又新增font-tech,font-format判断” - http://www.zhangxinxu.com/wordpress/?…
第11周 3.10-3.16
本周学习了一种更好的文字隐藏的方法,那就是使用::first-line伪元素,CSS世界这本书有介绍。
::first-line伪元素可以在不改变元素color上下文的情况下变色。
可以让按钮隐藏文字的时候,里面的图标依然保持和原本的文字颜色一致。
详见这篇文章:“一种更好的文字隐藏的方法-::first-line伪元素” - http://www.zhangxinxu.com/wordpress/?…
第12周 3.17-3.23
本周学习了下attachInternals方法,这个方法很有意思,给任意自定义元素使用,可以让普通元素也有原生表单控件元素一样的特性。
比如浏览器自带的验证提示:
比如说提交的时候的FormData或者查询字符串:
有兴趣的同学可以访问“研究下attachInternals方法,可让普通元素有表单特性”这篇文章继续了解 - http://www.zhangxinxu.com/wordpress/?…
第13周 3.24-3.30
本周学习了一个新支持的HTML属性,名为blocking 属性。
它主要用于控制资源加载时对渲染的阻塞行为。
blocking 属性允许开发者对资源加载的优先级和时机进行精细控制,从而影响页面的渲染流程。浏览器在解析 HTML 文档时,会根据 blocking 属性的值来决定是否等待资源加载完成后再继续渲染页面,这对于优化页面性能和提升用户体验至关重要。
blocking 属性目前支持的HTML元素包括
使用示意:
更多内容参见我撰写的这篇文章:“光速了解script style link元素新增的blocking属性” - http://www.zhangxinxu.com/wordpress/?…
第14周 3.31-4.6
本周学习了JS EditContext API。
EditContext API 是 Microsoft Edge 浏览器提供的一个 Web API,它允许开发者在网页中处理文本输入事件,以便在原生输入事件(如 keydown、keypress 和 input)之外,实现更高级的文本编辑功能。
详见我撰写的这篇文章:“JS EditContext API 简介” - http://www.zhangxinxu.com/wordpress/?…
第15周 4.7-4.13
本周学习一个DOM新特性,名为caretPositionFromPoint API。
caretPositionFromPoint可以基于当前的光标位置,返回光标所对应元素的位置信息,在之前,此特性使用的是非标准的caretRangeFromPoint方法实现的。
和elementsFromPoint()方法的区别在于,前者返回节点及其偏移、尺寸等信息,而后者返回元素。
比方说有一段
元素文字描述信息,点击这段描述的某个文字,caretPositionFromPoint()方法可以返回精确的文本节点以及点击位置的字符偏移值,而elementsFromPoint()方法只能返回当前
元素。
不过此方法的应用场景比较小众,例如点击分词断句这种,大家了解下即可。
详见我撰写的这篇文章:“DOM新特性之caretPositionFromPoint API” - http://www.zhangxinxu.com/wordpress/?…
第16周 4.14-4.20
本周学习的是getHTML(), setHTMLUnsafe()和parseHTMLUnsafe()这三个方法,有点类似于可读写的innerHTML属性,区别在于setHTMLUnsafe()似乎对Shadow DOM元素的设置更加友好。
parseHTMLUnsafe则是个document全局方法,用来解析HTML字符串的。
这几个方法几乎是同一时间支持的,如下截图所示:
具体参见我写的这篇文章:介绍两个DOM新方法setHTMLUnsafe和getHTML - http://www.zhangxinxu.com/wordpress/?…
第17周 4.21-4.27
光速了解HTML shadowrootmode属性的作用。
shadowRoot的mode是个只读属性,可以指定其模式——打开或关闭。
这定义了影子根的内部功能是否可以从JavaScript访问。
当影子根的模式为“关闭”时,影子根的实现内部无法从JavaScript访问且不可更改,就像元素的实现内部不能从JavaScript访问或不可更改一样。
属性值是使用传递给Element.attachShadow()的对象的options.mode属性设置的,或者在声明性创建影子根时使用
来源:juejin.cn/post/7524548909530005540
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
残躯撤退。资本织机永不停歇,框架版本更迭如暴君换季,留下满地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
😝我怎么让设计师不再嫌弃 Antd,后台系统也能高端大气上档次
前言
如果一个团队计划开发一个面向 B 端的管理后台系统,既希望具备高效开发能力,又想要拥有好看的 UI,避免千篇一律的“土味”风格,而你作为前端主程参与开发,会怎么做?
本文将分享我在这一方向上的思考与实践。虽然目前所在公司的 B 端系统已经迭代许多内容,短期内没有设计师人力支持我推行这套方法,但我依然希望能将这套思路分享给有类似困扰的朋友。如果未来我有机会从零带队启动新项目,我依旧会沿用这一套方案。
当前的问题:前端与设计如何协作?
在开发 B 端系统时,大多数国内团队都会选用如 Umi
、Ant Design
、ProComponents
、Semi Design
等成熟的 B 端技术栈和 UI 库。
这些库大大提升了开发效率,尤其是 Antd
提供的 Table
、Form
等组件,功能丰富,使用便捷,非常值得肯定。
但问题也随之而来:因为太多后台项目使用 Antd
,导致整体 UI 风格高度同质化,设计师逐渐产生审美疲劳。在尝试打破这种风格束缚时,设计师往往会自由发挥,或者采用非 Antd 的组件库来设计 Figma 稿。
这导致前端不得不花大量时间去覆写样式,以适配非标准组件,工作量激增,最终形成恶性循环:设计觉得前端“不还原设计”,前端觉得设计“在刁难人”,项目开发节奏也被 UI 卡住。
如何解决?
其实 Antd
本身提供了非常强的定制能力。借助 ConfigProvider 全局配置 和 主题编辑器,我们可以通过修改 CSS Token
来全局调整组件样式,做到“深度魔改”。

这在前端层面可以很好地解决样式定制的问题,但设计师要怎么参与?
答案是:使用的 Antd Figma 文件(这份是 figma 社区大佬维护的算是比较新的版本 5.20)。这个 Figma 文件已经全面绑定了 Antd 的 Design Token
,设计师可以直接在 Figma 中打开,点击右侧的 Variables
面板,通过修改颜色、圆角、阴影等变量来完成 UI 风格定制。

由于每个组件都与 Design Token 强关联,设计师的修改可以精确反映到各个 UI 组件上,实现灵活定制。同时,也应记录这些变量的修改项,前端就可以据此配置对应的 JSON 文件,通过 ConfigProvider
注入到项目中,从而实现样式一致的组件系统。

最后,设计师可将修改后的组件库加入 Figma 的 Asset Libraries 中,供未来在设计稿中重复复用。这就等于团队共同维护了一套定制的 UI 体系。


结语
通过上述方法,前端与设计师可以真正做到“同源协作”:基于同一套设计变量开发和设计,避免不必要的重复劳动与沟通摩擦,释放更多精力专注在业务开发本身上。
来源:juejin.cn/post/7507982656686145562
折腾我2周的分页打印和下载pdf
1.背景
一开始接到任务需要打印html,之前用到了vue-print-nb-jeecg来处理Vue2一个打印的问题,现在是遇到需求要在Vue3项目里面去打印十几页的打印和下载为pdf,难点和坑就在于我用的库vue3-print-nb来做分页打印预览,下载pdf后面介绍
2.预览打印实现
<div id="printMe" style="background:red;">
<p>葫芦娃,葫芦娃</p>
<p>一根藤上七朵花 </p>
<p>小小树藤是我家 啦啦啦啦 </p>
<p>叮当当咚咚当当 浇不大</p>
<p> 叮当当咚咚当当 是我家</p>
<p> 啦啦啦啦</p>
<p>...</p>
</div>
<button v-print="'#printMe'">Print local range</button>
因为官方提供的方案都是DOM加载完成后然后直接打印,但是我的需求是需要点击打印的时候根据id渲染不同的组件然后渲染DOM,后面仔细看官方文档,有个beforeOpenCallback方法在打印预览之前有个钩子,但是这个钩子没办法确定我接口加载完毕,所以我的思路就是用户先点击我写的点击按钮事件,等异步渲染完毕之后,我再同步触发真正的打印预览按钮,这样就变相解决了我的需求。
坑
- 没办法处理接口异步渲染数据展示DOM进行打印操作
- 在布局相对定位的时候在谷歌浏览器会发现有布局整体变小的问题(后续用zoom处理的)
3.掉头发之下载pdf
下载pdf这种需求才是我每次去理发店不敢让tony把我头发打薄的原因,我看了很多技术文章,结合个人业务情况,采取的方案是html2canvas把html转成canvas然后转成图片然后通过jsPDF截取图片分页最后下载到本地。本人秉承着不生产水,只做大自然的搬运工的匠人精神,迅速而又果断的从社区来到社区去,然后找到了适配当前业务的逻辑代码(实践出真知)。
import html2canvas from 'html2canvas'
import jsPDF, { RGBAData } from 'jspdf'
/** a4纸的尺寸[595.28,841.89], 单位毫米 */
const [PAGE_WIDTH, PAGE_HEIGHT] = [595.28, 841.89]
const PAPER_CONFIG = {
/** 竖向 */
portrait: {
height: PAGE_HEIGHT,
width: PAGE_WIDTH,
contentWidth: 560
},
/** 横向 */
landscape: {
height: PAGE_WIDTH,
width: PAGE_HEIGHT,
contentWidth: 800
}
}
// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element: HTMLElement, width: number) {
if (!element) return { width, height: 0 }
// canvas元素
const canvas = await html2canvas(element, {
// allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 2, // 增加清晰度
useCORS: true // 允许跨域
})
// 获取canvas转化后的宽高
const { width: canvasWidth, height: canvasHeight } = canvas
// html页面生成的canvas在pdf中的高度
const height = (width / canvasWidth) * canvasHeight
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0)
return { width, height, data: canvasData }
}
/**
* 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
* @param param0
* @returns
*/
export async function outputPDF({
/** pdf内容的dom元素 */
element,
/** 页脚dom元素 */
footer,
/** 页眉dom元素 */
header,
/** pdf文件名 */
filename,
/** a4值的方向: portrait or landscape */
orientation = 'portrait' as 'portrait' | 'landscape'
}) {
if (!(element instanceof HTMLElement)) {
return
}
if (!['portrait', 'landscape'].includes(orientation)) {
return Promise.reject(
new Error(`Invalid Parameters: the parameter {orientation} is assigned wrong value, you can only assign it with {portrait} or {landscape}`)
)
}
const [A4_WIDTH, A4_HEIGHT] = [PAPER_CONFIG[orientation].width, PAPER_CONFIG[orientation].height]
/** 一页pdf的内容宽度, 左右预设留白 */
const { contentWidth } = PAPER_CONFIG[orientation]
// eslint-disable-next-line new-cap
const pdf = new jsPDF({
unit: 'pt',
format: 'a4',
orientation
})
// 一页的高度, 转换宽度为一页元素的宽度
const { width, height, data } = await toCanvas(element, contentWidth)
// 添加
function addImage(
_x: number,
_y: number,
pdfInstance: jsPDF,
base_data: string | HTMLImageElement | HTMLCanvasElement | Uint8Array | RGBAData,
_width: number,
_height: number
) {
pdfInstance.addImage(base_data, 'JPEG', _x, _y, _width, _height)
}
// 增加空白遮挡
function addBlank(x: number, y: number, _width: number, _height: number) {
pdf.setFillColor(255, 255, 255)
pdf.rect(x, y, Math.ceil(_width), Math.ceil(_height), 'F')
}
// 页脚元素 经过转换后在PDF页面的高度
const { height: tFooterHeight, data: headerData } = footer ? await toCanvas(footer, contentWidth) : { height: 0, data: undefined }
// 页眉元素 经过转换后在PDF的高度
const { height: tHeaderHeight, data: footerData } = header ? await toCanvas(header, contentWidth) : { height: 0, data: undefined }
// 添加页脚
async function addHeader(headerElement: HTMLElement) {
headerData && pdf.addImage(headerData, 'JPEG', 0, 0, contentWidth, tHeaderHeight)
}
// 添加页眉
async function addFooter(pageNum: number, now: number, footerElement: HTMLElement) {
if (footerData) {
pdf.addImage(footerData, 'JPEG', 0, A4_HEIGHT - tFooterHeight, contentWidth, tFooterHeight)
}
}
// 距离PDF左边的距离,/ 2 表示居中
const baseX = (A4_WIDTH - contentWidth) / 2 // 预留空间给左边
// 距离PDF 页眉和页脚的间距, 留白留空
const baseY = 15
// 除去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
const originalPageHeight = A4_HEIGHT - tFooterHeight - tHeaderHeight - 2 * baseY
// 元素在网页页面的宽度
const elementWidth = element.offsetWidth
// PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度 转化为 距离Canvas顶部的高度
const rate = contentWidth / elementWidth
// 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
const pages = [rate * getElementTop(element)]
// 获取该元素到页面顶部的高度(注意滑动scroll会影响高度)
function getElementTop(contentElement) {
if (contentElement.getBoundingClientRect) {
const rect = contentElement.getBoundingClientRect() || {}
const topDistance = rect.top
return topDistance
}
}
// 遍历正常的元素节点
function traversingNodes(nodes) {
for (const element of nodes) {
const one = element
/** */
/** 注意: 可以根据业务需求,判断其他场景的分页,本代码只判断表格的分页场景 */
/** */
// table的每一行元素也是深度终点
const isTableRow = one.classList && one.classList.contains('ant4-table-row')
// 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
const { offsetHeight } = one
// 计算出最终高度
const offsetTop = getElementTop(one)
// dom转换后距离顶部的高度
// 转换成canvas高度
const top = rate * offsetTop
const rateOffsetHeight = rate * offsetHeight
// 对于深度终点元素进行处理
if (isTableRow) {
// dom高度转换成生成pdf的实际高度
// 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
updateTablePos(rateOffsetHeight, top)
}
// 对于普通元素,则判断是否高度超过分页值,并且深入
else {
// 执行位置更新操作
updateNormalElPos(top)
// 遍历子节点
traversingNodes(one.childNodes)
}
updatePos()
}
}
// 普通元素更新位置的方法
// 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度,则需要载入分页点
function updateNormalElPos(top) {
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
}
}
// 可能跨页元素位置更新的方法
// 需要考虑分页元素,则需要考虑两种情况
// 1. 普通达顶情况,如上
// 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
function updateTablePos(eHeight: number, top: number) {
// 如果高度已经超过当前页,则证明可以分页了
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
}
// 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
else if (
top + eHeight - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight &&
top !== (pages.length > 0 ? pages[pages.length - 1] : 0)
) {
pages.push(top)
}
}
// 深度遍历节点的方法
traversingNodes(element.childNodes)
function updatePos() {
while (pages[pages.length - 1] + originalPageHeight < height) {
pages.push(pages[pages.length - 1] + originalPageHeight)
}
}
// 对pages进行一个值的修正,因为pages生成是根据根元素来的,根元素并不是我们实际要打印的元素,而是element,
// 所以要把它修正,让其值是以真实的打印元素顶部节点为准
const newPages = pages.map(item => item - pages[0])
// 根据分页位置 开始分页
for (let i = 0; i < newPages.length; ++i) {
// 根据分页位置新增图片
addImage(baseX, baseY + tHeaderHeight - newPages[i], pdf, data!, width, height)
// 将 内容 与 页眉之间留空留白的部分进行遮白处理
addBlank(0, tHeaderHeight, A4_WIDTH, baseY)
// 将 内容 与 页脚之间留空留白的部分进行遮白处理
addBlank(0, A4_HEIGHT - baseY - tFooterHeight, A4_WIDTH, baseY)
// 对于除最后一页外,对 内容 的多余部分进行遮白处理
if (i < newPages.length - 1) {
// 获取当前页面需要的内容部分高度
const imageHeight = newPages[i + 1] - newPages[i]
// 对多余的内容部分进行遮白
addBlank(0, baseY + imageHeight + tHeaderHeight, A4_WIDTH, A4_HEIGHT - imageHeight)
}
// 添加页眉
if (header) {
await addHeader(header)
}
// 添加页脚
if (footer) {
await addFooter(newPages.length, i + 1, footer)
}
// 若不是最后一页,则分页
if (i !== newPages.length - 1) {
// 增加分页
pdf.addPage()
}
}
return pdf.save(filename)
}
4.分页的小姿势
如果有需求把打印预览的时候的页眉页脚默认取消不展示,然后自定义页面的边距可以这么设置样式
@page {
size: auto A4 landscape;
margin: 3mm;
}
@media print {
body,
html {
height: initial;
padding: 0px;
margin: 0px;
}
}
5.关于页眉页脚
由于业务是属于比较自定义化的展示,所以我封装成组件,然后根据返回的数据进行渲染到每个界面,然后利用绝对定位放在相同的位置,最后一点小优化就是,公共化提取界面的样式,然后整合为pub.scss然后引入到界面里面,这样即使产品有一定的样式调整,我也可以在公共样式里面去配置和修改,大大的减少本人的工作量。在日常的开发中也是这样,不要去抱怨需求的变动频繁,而是力争在写组件的过程中考虑到组件的健壮性和灵活度,给自己的工作减负,到点下班。
参考文章
来源:juejin.cn/post/7397319113796780042
如果产品经理突然要你做一个像抖音一样流畅的H5
从前端到爆点!抖音级 H5 如何炼成?
在万物互联的时代,H5 页面已成为产品推广的利器。当产品经理丢给你一个“像抖音一样流畅的 H5”任务时,是挑战还是机遇?别慌,今天就带你走进抖音 H5 的前端魔法世界。
一、先看清本质:抖音 H5 为何丝滑?
抖音 H5 之所以让人欲罢不能,核心在于两点:极低的卡顿率和极致的交互反馈。前者靠性能优化,后者靠精心设计的交互逻辑。比如,你刷视频时的流畅下拉、点赞时的爱心飞舞,背后都藏着前端开发的“小心机”。
二、性能优化:让页面飞起来
(一)懒加载与预加载协同作战
懒加载是 H5 性能优化的经典招式,只在用户即将看到某个元素时才加载它。但光靠懒加载还不够,聪明的抖音 H5 还会预加载下一个可能进入视野的元素。以下是一个基于 IntersectionObserver 的懒加载示例:
document.addEventListener('DOMContentLoaded', () => {
const lazyImages = [].slice.call(document.querySelectorAll('img.lazy'));
if ('IntersectionObserver' in window) {
let lazyImageObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
let lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazy Images.forEach((lazyImage) => {
lazyImageObserver.observe(lazyImage);
});
}
});
(二)图片压缩技术大显神威
图片是 H5 的“体重”大户。抖音 H5 常用 WebP 格式,它在保证画质的同时,能将图片体积压缩到 JPEG 的一半。你可以用以下代码轻松实现图片格式转换:
function compressImage(inputImage, quality) {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = inputImage.naturalWidth;
canvas.height = inputImage.naturalHeight;
ctx.drawImage(inputImage, 0, 0, canvas.width, canvas.height);
const compressedImage = new Image();
compressedImage.src = canvas.toDataURL('image/webp', quality);
compressedImage.onload = () => {
resolve(compressedImage);
};
});
}
三、交互设计:让用户欲罢不能
(一)微动画营造沉浸感
在点赞、评论等关键操作上,抖音 H5 会加入精巧的微动画。比如点赞时的爱心从手指位置飞出,这其实是一个 CSS 动画加 JavaScript 事件监听的组合拳。以下是一个简易版的点赞动画代码:
@keyframes flyHeart {
0% {
transform: scale(0) translateY(0);
opacity: 0;
}
50% {
transform: scale(1.5) translateY(-10px);
opacity: 1;
}
100% {
transform: scale(1) translateY(-20px);
opacity: 0;
}
}
.heart {
position: fixed;
width: 30px;
height: 30px;
background-image: url('../assets/heart.png');
background-size: contain;
background-repeat: no-repeat;
animation: flyHeart 1s ease-out;
}
document.querySelector('.like-btn').addEventListener('click', function(e) {
const heart = document.createElement('div');
heart.className = 'heart';
heart.style.left = e.clientX + 'px';
heart.style.top = e.clientY + 'px';
document.body.appendChild(heart);
setTimeout(() => {
heart.remove();
}, 1000);
});
(二)触摸事件优化
在移动设备上,触摸事件的响应速度直接影响用户体验。抖音 H5 通过精准控制触摸事件的捕获和冒泡阶段,减少了延迟。以下是一个优化触摸事件的示例:
const touchStartHandler = (e) => {
e.preventDefault(); // 防止页面滚动干扰
// 处理触摸开始逻辑
};
const touchMoveHandler = (e) => {
// 处理触摸移动逻辑
};
const touchEndHandler = (e) => {
// 处理触摸结束逻辑
};
const element = document.querySelector('.scrollable-container');
element.addEventListener('touchstart', touchStartHandler, { passive: false });
element.addEventListener('touchmove', touchMoveHandler, { passive: false });
element.addEventListener('touchend', touchEndHandler);
四、音频处理:让声音为 H5 增色
抖音 H5 的音频体验也很讲究。它会根据用户的操作实时调整音量,甚至在不同视频切换时平滑过渡音频。以下是一个简单的声音控制示例:
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioElement = document.querySelector('audio');
const audioSource = audioContext.createMediaElementSource(audioElement);
const gainNode = audioContext.createGain();
audioSource.connect(gainNode);
gainNode.connect(audioContext.destination);
// 调节音量
function setVolume(level) {
gainNode.gain.value = level;
}
// 音频淡入效果
function fadeInAudio() {
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + 1);
}
// 音频淡出效果
function fadeOutAudio() {
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 1);
}
五、跨浏览器兼容:让 H5 无处不在
抖音 H5 能在各种浏览器上保持一致的体验,这离不开前端开发者的兼容性优化。常用的手段包括使用 Autoprefixer 自动生成浏览器前缀、为老浏览器提供 Polyfill 等。以下是一个为 CSS 动画添加前缀的示例:
const autoprefixer = require('autoprefixer');
const postcss = require('postcss');
const css = '.example { animation: slidein 2s; } @keyframes slidein { from { transform: translateX(0); } to { transform: translateX(100px); } }';
postcss([autoprefixer]).process(css).then(result => {
console.log(result.css);
/*
输出:
.example {
animation: slidein 2s;
}
@keyframes slidein {
from {
-webkit-transform: translateX(0);
transform: translateX(0);
}
to {
-webkit-transform: translateX(100px);
transform: translateX(100px);
}
}
*/
});
打造一个像抖音一样的流畅 H5,需要前端开发者在性能优化、交互设计、音频处理和跨浏览器兼容等方面全方位发力。希望这些技术点能为你的 H5 开发之旅提供助力,让你的产品在激烈的市场竞争中脱颖而出!
来源:juejin.cn/post/7522090635908251686
做个大屏既要不留白又要不变形还要没滚动条,我直接怒斥领导,大屏适配就这四种模式
在前端开发中,大屏适配一直是个让人头疼的问题。领导总是要求大屏既要不留白,又要不变形,还要没有滚动条。这看似简单的要求,实际却压根不可能。今天,我们就来聊聊大屏适配的四种常见模式,以及如何根据实际需求选择合适的方案。
一、大屏适配的困境
在大屏项目中,适配问题几乎是每个开发者都会遇到的挑战。屏幕尺寸的多样性、设计稿与实际屏幕的比例差异,都使得适配变得复杂。而领导的“既要...又要...还要...”的要求,更是让开发者们感到无奈。不过,我们可以通过合理选择适配模式来尽量满足这些需求。
二、四种适配模式
在大屏适配中,常见的适配模式有以下四种:
(以下截图中模拟视口1200px*500px
和800px*600px
,设计稿为1920px*1080px
)
1. 拉伸填充(fill)
- 特点:内容会被拉伸变形,以完全填充视口框。这种方式可以确保视口内没有空白区域,但可能会导致内容变形。
- 适用场景:适用于对内容变形不敏感的场景,例如全屏背景图。
2. 保持比例(contain)
- 特点:内容保持原始比例,不会被拉伸变形。如果内容的宽高比与视口不一致,会在视口内出现空白区域(黑边)。这种方式可以确保内容不变形,但可能会留白。
- 适用场景:适用于需要保持内容原始比例的场景,例如视频或图片展示。
3. 滚动显示(scroll)
- 特点:内容不会被拉伸变形,当内容超出视口时会添加滚动条。这种方式可以确保内容完整显示,但用户需要滚动才能查看全部内容。
- 适用场景:适用于内容较多且需要完整显示的场景,例如长列表或长文本。
4. 隐藏超出(hidden)
- 特点:内容不会被拉伸变形,当内容超出视口时会隐藏超出部分。这种方式可以避免滚动条的出现,但可能会隐藏部分内容。
- 适用场景:适用于内容较多但不需要完整显示的场景,例如仪表盘。
三、为什么不能同时满足所有要求?
这四种适配模式各有优缺点,但它们在逻辑上是相互矛盾的。具体来说:
- 不留白:要求内容完全填充视口,没有任何空白区域。这通常需要拉伸或缩放内容以适应视口的宽高比。
- 不变形:要求内容保持其原始宽高比,不被拉伸或压缩。这通常会导致内容无法完全填充视口,从而出现空白区域(黑边)。
- 没滚动条:要求内容完全适应视口,不能超出视口范围。这通常需要隐藏超出部分或限制内容的大小。
这三个要求在逻辑上是相互矛盾的:
- 如果内容完全填充视口(不留白),则可能会变形。
- 如果内容保持原始比例(不变形),则可能会出现空白区域(留白)。
- 如果内容超出视口范围,则需要滚动条或隐藏超出部分。
四、【fitview】插件快速实现大屏适配
fitview
是一个视口自适应的 JavaScript 插件,它支持多种适配模式,能够快速实现大屏自适应效果。
github地址:github.com/pbstar/fitv…
在线预览:pbstar.github.io/fitview
以下是它的基本使用方法:
配置
- el: 需要自适应的 DOM 元素
- fit: 自适应模式,字符串,可选值为 fill、contain(默认值)、scroll、hidden
- resize: 是否监听元素尺寸变化,布尔值,默认值 true
安装引入
npm 安装
npm install fitview
esm 引入
import fitview from "fitview";
cdn 引入
<script src="https://unpkg.com/fitview@[version]/lib/fitview.umd.js"></script>
使用示例
<div id="container">
<div style="width:1920px;height:1080px;"></div>
</div>
const container = document.getElementById("container");
new fitview({
el: container,
});
五、总结
大屏适配是一个复杂的问题,不同的项目有不同的需求。虽然不能同时满足“不留白”“不变形”和“没滚动条”这三个要求,但可以通过合理选择适配模式来尽量满足大部分需求。在实际开发中,我们需要根据项目的具体需求和用户体验来权衡,选择最合适的适配方案。
在选择适配方案时,fitview
这个插件可以提供很大的帮助。它支持多种适配模式,能够快速实现大屏自适应效果。如果你正在寻找一个简单易用的适配工具,fitview
值得一试。你可以通过 npm 安装或直接使用 CDN 引入,快速集成到你的项目中。
希望这篇文章能帮助你更好地理解和选择大屏适配方案。如果你有更多问题或建议,欢迎在评论区留言。
来源:juejin.cn/post/7513059488417497123
弃用 html2canvas!快 93 倍的截图神器!
作者:前端开发爱好者
原文:mp.weixin.qq.com/s/t0s5dCOrs…
在前端开发中,网页截图是个常用功能。从前,html2canvas
是大家的常客,但随着网页越来越复杂,它的性能问题也逐渐暴露,速度慢
、占资源
,用户体验不尽如人意。
好在,现在有了 SnapDOM,一款性能超棒
、还原度超高
的截图新秀,能完美替代 html2canvas
,让截图不再是麻烦事。
什么是 SnapDOM
SnapDOM 就是一个专门用来给网页元素截图的工具。
它能把 HTML
元素快速又准确地存成各种图片格式,像 SVG
、PNG
、JPG
、WebP
等等,还支持导出为 Canvas
元素。
它最厉害的地方在于,能把网页上的各种复杂元素,比如 CSS
样式、伪元素
、Shadow DOM
、内嵌字体
、背景图片
,甚至是动态效果
的当前状态,都原原本本地截下来,跟直接看网页没啥两样。
SnapDOM 优势
快得飞起
测试数据显示,在不同场景下,SnapDOM
都把 html2canvas
和 dom-to-image
这俩老前辈远远甩在身后。
尤其在超大元素(4000×2000)截图时,速度是 html2canvas 的 93.31
倍,比 dom-to-image 快了 133.12
倍。这速度,简直就像坐火箭。
还原度超高
SnapDOM 截图出来的效果,跟在网页上看到的一模一样。
各种复杂的 CSS
样式、伪元素
、Shadow DOM
、内嵌字体
、背景图片
,还有动态效果
的当前状态,都能精准还原。
无论是简单的元素,还是复杂的网页布局,它都能轻松拿捏。
格式任你选
不管你是想要矢量图 SVG
,还是常用的 PNG
、JPG
,或者现代化的 WebP
,又或者是需要进一步处理的 Canvas
元素,SnapDOM 都能满足你。
多种格式,任你挑选,适配各种需求。
怎么用 SnapDOM
安装
SnapDOM 的安装超简单,有好几种方式:
用 NPM
或 Yarn
:在命令行里输
# npm
npm i @zumer/snapdom
# yarn
yarn add @zumer/snapdom
就能装好。
用 CDN
在 HTML
文件里加一行:
<script src="https://unpkg.com/@zumer/snapdom@latest/dist/snapdom.min.js"></script>
直接就能用。
要是项目里用的是 ES Module
:
import { snapdom } from '@zumer/snapdom
基础用法示例
一键截图
const card = document.querySelector('.user-card');
const image = await snapdom.toPng(card);
document.body.appendChild(image);
这段代码就是找个元素,然后直接截成 PNG
图片,再把图片加到页面上。简单粗暴,一步到位。
高级配置
const element = document.querySelector('.chart-container');
const capture = await snapdom(element, {
scale: 2,
backgroundColor: '#fff',
embedFonts: true,
compress: true
});
const png = await capture.toPng();
const jpg = await capture.toJpg({ quality: 0.9 });
await capture.download({
format: 'png',
filename: 'chart-report-2024'
});
这儿可以对截图进行各种配置。比如 scale
能调整清晰度,backgroundColor
能设置背景色,embedFonts
可以内嵌字体,compress
能压缩优化。配置好后,还能把截图存成不同格式,或者直接下载到本地。
和其他库比咋样
和 html2canvas
、dom-to-image
比起来,SnapDOM
的优势很明显:
特性 | SnapDOM | html2canvas | dom-to-image |
---|---|---|---|
性能 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐ |
准确度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
文件大小 | 极小 | 较大 | 中等 |
依赖 | 无 | 无 | 无 |
SVG 支持 | ✅ | ❌ | ✅ |
Shadow DOM 支持 | ✅ | ❌ | ❌ |
维护状态 | 活跃 | 活跃 | 停滞 |
用的时候注意点
用 SnapDOM
时,有几点得注意:
跨域资源
要是截图里有外部图片等跨域资源,得确保这些资源支持 CORS
,不然截不出来。
iframe 限制
SnapDOM 不能截 iframe
内容,这是浏览器的安全限制,没办法。
Safari 浏览器兼容性
在 Safari 里用 WebP
格式时,会自动变成 PNG。
大型页面截图
截超大页面时,建议分块截,不然可能会内存溢出
。
SnapDOM 能干啥及代码示例
社交分享
async function shareAchievement() {
const card = document.querySelector('.achievement-card');
const image = await snapdom.toPng(card, { scale: 2 });
navigator.share({
files: [new File([await snapdom.toBlob(card)], 'achievement.png')],
title: '我获得了新成就!'
});
}
报表导出
async function exportReport() {
const reportSection = document.querySelector('.report-section');
await preCache(reportSection);
await snapdom.download(reportSection, {
format: 'png',
scale: 2,
filename: `report-${new Date().toISOString().split('T')[0]}`
});
}
海报导出
async function generatePoster(productData) {
document.querySelector('.poster-title').textContent = productData.name;
document.querySelector('.poster-price').textContent = `¥${productData.price}`;
document.querySelector('.poster-image').src = productData.image;
await new Promise((resolve) => setTimeout(resolve, 100));
const poster = document.querySelector('.poster-container');
const blob = await snapdom.toBlob(poster, { scale: 3 });
return blob;
}
写在最后
SnapDOM 就是这么一款简单、快速、准确,还零依赖的网页截图神器。
无论是社交分享、报表导出、设计保存,还是营销推广,它都能轻松搞定。
而且它是免费开源的,背后还有活跃的社区支持。要是你还在为网页截图的事儿发愁,赶紧试试 SnapDOM 吧。
要是你在用 SnapDOM
的过程中有啥疑问,或者碰上啥问题,可以去下面这些地方找答案:
- 项目地址 :github.com/zumerlab/sn…
- 在线演示 :zumerlab.github.io/snapdom/
- 详细文档 :github.com/zumerlab/sn…
来源:juejin.cn/post/7524740743165722634
别再用 100vh 了!移动端视口高度的终极解决方案
作为一名前端开发者,我们一定都遇到过这样的需求:实现一个占满整个屏幕的欢迎页、弹窗蒙层或者一个 fixed 定位的底部菜单。
直觉告诉我们,这很简单,给它一个 height: 100vh
就行了。
.fullscreen-element {
height: 100vh;
width: 100%;
color: #000;
display: flex;
justify-content: center;
align-items: center;
font-size: 10em;
background-color: #fff;
}
在PC端预览,完美!然而,当你在手机上打开时,可能会看到下面这个令人抓狂的场景:
明明是 100vh
,为什么会超出屏幕高度?这个烦人的滚动条到底从何而来?
如果你也曾为此抓耳挠腮,那么恭喜你,这篇文章就是你的“终极答案”。今天,我将带你彻底搞懂 100vh
在移动端的“坑”,并为你介绍当下最完美的解决方案。
1. 问题根源:移动端动态变化的“视口”
要理解问题的本质,我们首先要明白 vh
(Viewport Height) 单位的定义:1vh
等于视口高度的 1%。
在PC端,浏览器窗口大小是相对固定的,所以 100vh
就是浏览器窗口的可见高度,这没有问题。
但在移动端,情况变得复杂了。为了在有限的屏幕空间里提供更好的浏览体验,手机浏览器(尤其是Safari和Chrome)的地址栏和底部工具栏是动态变化的。
- 初始状态:当你刚进入页面时,地址栏和工具栏是完全显示的。
- 滚动时:当你向下滚动页面,这些UI元素会自动收缩,甚至隐藏,以腾出更多空间展示网页内容。
关键点来了:大多数移动端浏览器将 100vh
定义为“最大视口高度”,也就是当地址栏和工具栏完全收起时的高度。
这就导致了:
在页面初始加载、地址栏还未收起时,
100vh
的实际计算高度 > 屏幕当前可见区域的高度。
于是,那个恼人的滚动条就出现了。
2. “过去式”的解决方案:JavaScript 动态计算
在很长一段时间里,前端开发者们只能求助于 JavaScript 来解决这个问题。思路很简单:通过 window.innerHeight
获取当前可见视口的高度,然后用它来动态设置元素的 height
。
JavaScript
function setRealVH() {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
// 初始加载时设置
window.addEventListener('load', setRealVH);
// 窗口大小改变或旋转屏幕时重新设置
window.addEventListener('resize', setRealVH);
然后在 CSS 中这样使用:
CSS
.fullscreen-element {
height: calc(var(--vh, 1vh) * 100);
}
这个方案的缺点显而易见:
- 性能开销:监听
resize
事件过于频繁,可能会引发性能问题。 - 逻辑耦合:纯粹的样式问题却需要JS来解决,不够优雅。
- 时机问题:执行时机需要精确控制,否则可能出现闪烁。
虽然能解决问题,但这绝不是我们想要的“终极方案”。
3. “现在时”的终极解决方案:CSS动态视口单位
谢天谢地,CSS 工作组听到了我们的呼声!为了解决这个老大难问题,CSS Values and Units Module Level 4 引入了一套全新的动态视口单位。
它们就是我们今天的“主角”:
svh
(Small Viewport Height): 最小视口高度。对应于地址栏和工具栏完全展开时的可见高度。lvh
(Large Viewport Height): 最大视口高度。对应于地址栏和工具栏完全收起时的高度(这其实就等同于旧的100vh
)。dvh
(Dynamic Viewport Height): 动态视口高度。这是最智能、最实用的单位!它的值会随着浏览器UI元素(地址栏)的出现和消失而动态改变。
所以,我们的终极解决方案就是:
CSS
.fullscreen-element {
height: 100svh; /* 如果你希望高度固定,且永远不被遮挡 */
/* 或者,也是我最推荐的 */
height: 100dvh; /* 如果你希望元素能动态地撑满整个可见区域 */
}
使用 100dvh
,当地址栏收起时,元素高度会平滑地增加以填满屏幕;当地址栏滑出时,元素高度又会平滑地减小。整个过程如丝般顺滑,没有任何滚动条,完美!
浏览器兼容性
你可能会担心兼容性问题。好消息是,从2023年开始,所有主流现代浏览器(Safari, Chrome, Edge, Firefox)都已经支持了这些新的视口单位。
(数据截至2025年6月,兼容性已非常好)
可以看到,兼容性已经非常理想。除非你需要支持非常古老的浏览器版本,否则完全可以放心地在生产环境中使用。
告别 100vh
的时代
让我们来快速回顾一下:
- 问题:在移动端,
100vh
通常被解析为“最大视口高度”,导致在浏览器UI未收起时内容溢出。 - 旧方案:使用 JavaScript 的
window.innerHeight
动态计算,但有性能和维护问题。 - 终极方案:使用CSS新的动态视口单位,尤其是
100dvh
,它能根据浏览器UI的变化自动调整高度,完美解决问题。
当需要实现移动端全屏布局时,请大胆地告别 100vh
,拥抱 100dvh
!
来源:juejin.cn/post/7520548278338322483
为什么响应性语法糖最终被废弃了?尤雨溪也曾经试图让你不用写 .value
你将永远需要在 Vue3 中写
.value
前言
相信有不少新手在初次接触 Vue3 的组合式 API 时都会产生一个疑问:”为什么一定要写 .value
?",一些 Vue3 老玩家也认为到处写 .value
十分不优雅。
那么有没有办法能不用写 .value
呢?有的兄弟,至少曾经有的,那就是响应性语法糖,可惜在 Vue 3.4 之后已经被移除了。
响应性语法糖是如何实现免去 .value
的?这一特性为何最终被废弃了呢?
响应性语法糖
Vue 的响应性语法糖是一个编译时的转换步骤,让我们可以像这样书写代码:
<script setup>
let count = $ref(0)
console.log(count)
function increment() {
count++
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
这里的 $ref()
方法是一个编译时的宏命令:它不是一个真实的、在运行时会调用的方法,而是用作 Vue 编译器的标记。使用 $ref()
声明的响应式变量可以直接读取与修改,无需 .value
。
上面例子中 <script>
部分的代码会被编译成下面这样,在代码中自动加上 .value
:
import { ref } from 'vue'
let count = ref(0)
console.log(count.value)
function increment() {
count.value++
}
每一个会返回 ref 的响应式 API 都有一个相对应的、以 $
为前缀的宏函数。包括以下这些 API:
ref
->$ref
computed
->$computed
shallowRef
->$shallowRef
customRef
->$customRef
toRef
->$toRef
通过 $()
解构
当一个组合式函数返回包含数个 ref 的对象,我们希望解构得到这些 ref,并且在后续使用它们时也不用写 .value
时,可以使用 $()
这个宏:
import { useMouse } from '@vueuse/core'
const { x, y } = $(useMouse())
// x,y 和用 $ref 声明的响应式变量一样,不用写 .value
console.log(x, y)
通过 $$()
防止响应性丢失
假设有一个期望接收一个 ref 对象为参数的函数:
function trackChange(x: Ref<number>) {
watch(x, (x) => {
console.log('x 改变了!')
})
}
let count = $ref(0)
trackChange(count) // 无效!
上面的例子不会正常工作,因为代码被编译成了这样子:
let count = ref(0)
trackChange(count.value)
trackChange
函数期望接收的参数是一个 ref 类型值,而我们传入的 count.value
实际是一个 number 类型。
对于一个使用 $ref()
声明的响应式变量,当我们希望获取到它的原始 ref 值时,可以使用 $$()
。
我们将上述例子改写为:
let count = $ref(0)
- trackChange(count)
+ trackChange($$(count))
此时代码可以正常工作,不会再丢失响应性了。
看到这里,聪明的你可能已经意识到了问题:使用响应性语法糖的初衷是为了免去到处写
.value
的麻烦,结果现在新引入了$ref()
、$()
、$()
,各自还有不同的使用场景,不是更麻烦了吗?
废弃原因
最终,在收集了大量来自社区的反馈后,经过 Vue 核心团队全员投票,决定移除这一特性。在 Vue 3.3 版本中使用会报 warning,从 3.4 版本开始正式移除。
尤雨溪本人也在 github 上发表了决定将响应性语法糖移除的根本原因,链接:github.com/vuejs/rfcs/…
原文是英语,担心有小伙伴可能看不懂,在这里简单翻译一下:
响应性语法糖的初衷是提供一些简练的语法提升开发体验。我们将它作为实验特性发布并在真实场景的使用中获得反馈。尽管它有一些好处,但我们还是发现了下列问题:
- 没有
.value
使得难以辨认响应式变量的读取和设置。这个问题在 SFC 中可能不那么明显,但是在大型项目中会造成心智负担的明显增大,尤其是在 SFC 外也使用此语法时。
- 因为第一条,一些开发者倾向于只在 SFC 中使用响应性语法糖,这就造成了代码的不一致性以及在不同心智模型间切换的成本。这是一个进退两难的窘境:只在 SFC 中使用会造成不一致性,而在 SFC 之外使用则会降低可维护性。
- 既然总有外部函数会使用原始 ref,那么在响应性语法糖与原始 ref 之间的转换是不可避免的。这就产生了另一个需要学习的东西以及额外的认知负担,并且我们发现这会比纯粹的组合式 API 更让初学者感到困惑。
最重要的是,响应性语法糖会带来代码风格分裂的潜在危险。尽管这一功能是自愿使用的,一些使用者还是强烈反对该提议,因为他们不想维护用了语法糖和没用两种风格的代码。这确实值得担心因为使用响应性语法糖需要的心智模型违背了 JavaScript 的基本语义(对变量赋值会触发响应式副作用)。
在考虑了所有因素之后,我们认为将它发布为一个稳定功能带来的问题会大于收益。
结语
在 Vue3 发布之初,Vue 核心团队就考虑到了 ref 需要到处使用 .value
的繁琐,推出了响应性语法糖试图解决这一问题。
响应性语法糖提供了一系列编译器宏,让开发者在书写代码时不必使用 .value
,而是在编译阶段由编译器自动加上。
最终,出于代码风格一致性和可维护性上的考量,这一特性最终在 Vue 3.4 版本被正式废弃。
来源:juejin.cn/post/7523231174620102671
纯前端用 TensorFlow.js 实现智能图像处理应用(一)
随着人工智能技术的不断发展,图像处理应用已经在医疗影像分析、自动驾驶、视频监控等领域得到广泛应用。TensorFlow.js
是 Google
开源的机器学习库 TensorFlow
的 JavaScript
版本,能够让开发者在浏览器中运行机器学习模型,在前端应用中轻松实现图像分类、物体检测和姿态估计等功能。本文将介绍如何使用 TensorFlow.js
在纯前端环境中实现这三项任务,并帮助你构建一个智能图像处理应用。
什么是 TensorFlow.js
?
TensorFlow.js
是一个能够让开发者在前端直接运行机器学习模型的 JavaScript 库。它允许你无需将数据发送到服务器,便可以在浏览器中运行模型进行推理,这不仅减少了延迟,还可以更好地保护用户的隐私数据。通过 TensorFlow.js
,前端开发者能够轻松实现图像分类、物体检测和姿态估计等功能。
TensorFlow.js
的应用非常广泛,尤其是在一些实时交互和隐私敏感的场景下,例如 医疗影像分析、自动驾驶 和 智能监控。在这些领域,前端模型推理能够提升响应速度,并且避免将用户的数据上传到服务器,从而保护用户的隐私。
要开始使用 TensorFlow.js
,你需要安装相关的模型库。以下是你需要安装的 npm
包:
npm install @tensorflow/tfjs
npm install @tensorflow-models/mobilenet
npm install @tensorflow-models/coco-ssd
npm install @tensorflow-models/posenet
加载预训练模型
在 TensorFlow.js
中,加载预训练模型非常简单。首先,确保 TensorFlow.js
已经准备好,然后加载所需的模型进行推理。
// 导入
import * as tf from '@tensorflow/tfjs'
// 加载
tf.ready(); // 确保 TensorFlow.js 准备好
用户上传图片
为了使用这些模型进行推理,我们需要让用户上传一张图片。以下是一个处理图片上传的代码示例:
const handleImageUpload = async (event) => {
const file = event.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = async (e) => {
imageSrc.value = e.target.result
await runModels(e.target.result)
}
reader.readAsDataURL(file)
}
}
图像分类:识别图片中的物体
图像分类是计算机视觉中的基本任务,目的是将输入图像归类到某个类别中。例如,我们可以用图像分类模型识别图像中的“猫”还是“狗”。
使用预训练模型进行图像分类
TensorFlow.js
提供了多个预训练模型,MobileNet
是其中一个常用的图像分类模型。它是一个轻量级的卷积神经网络,适合用来进行图像分类。接下来,我们通过 MobileNet
实现一个图像分类功能:
const mobilenetModel = await mobilenet.load()
const predictions = await mobilenetModel.classify(image)
classification.value = `分类结果: ${predictions[0].className}, 信心度: ${predictions[0].probability.toFixed(3)}`
这段代码实现了图像分类。我们加载 MobileNet
模型,并对用户上传的图像进行推理,最后返回图像的分类结果。
物体检测:找出图像中的所有物体
物体检测不仅仅是识别图像中的物体是什么,还需要标出它们的位置,通常用矩形框来框住物体。Coco-SSD
是一个强大的物体检测模型,能够在图像中检测出多个物体并标出它们的位置。
使用 Coco-SSD
进行物体检测
const cocoModel = await cocoSsd.load();
const detectionResults = await cocoModel.detect(image);
objects.value = detectionResults.map((prediction) => ({
class: prediction.class,
bbox: prediction.bbox,
}));
通过 Coco-SSD
模型,我们可以检测图像中的多个物体,并标出它们的位置。
绘制物体的边界框
为了更直观地展示检测结果,我们可以在图像上绘制出物体的边界框:
// 绘制物体检测边界框
const drawObjects = (detectionResults, image) => {
nextTick(() => {
const ctx = objectCanvas.value.getContext('2d')
const imageWidth = image.width
const imageHeight = image.height
objectCanvas.value.width = imageWidth
objectCanvas.value.height = imageHeight
ctx.clearRect(0, 0, objectCanvas.value.width, objectCanvas.value.height)
ctx.drawImage(image, 0, 0, objectCanvas.value.width, objectCanvas.value.height)
// 绘制边界框
detectionResults.forEach((prediction) => {
const [x, y, width, height] = prediction.bbox
ctx.beginPath()
ctx.rect(x, y, width, height)
ctx.lineWidth = 2
ctx.strokeStyle = 'green'
ctx.stroke()
// 添加标签
ctx.font = '16px Arial'
ctx.fillStyle = 'green'
ctx.fillText(prediction.class, x + 5, y + 20)
})
})
}
这段代码通过绘制边界框来标出检测到的物体位置,同时在边界框旁边显示物体类别。
姿态估计:识别人体的关键点
姿态估计主要是识别人类的身体部位,例如头部、手臂、腿部等。通过这些关键点,我们可以了解一个人当前的姿势。TensorFlow.js
提供了 PoseNet
模型来进行姿态估计。
使用 PoseNet
进行姿态估计
// 加载 PoseNet 模型
const posenetModel = await posenet.load()
const poseResult = await posenetModel.estimateSinglePose(image, {
flipHorizontal: false
})
// 人体关键点
pose.value = poseResult.keypoints.map((point) => `${point.part}: (${point.position.x.toFixed(2)}, ${point.position.y.toFixed(2)})`)
PoseNet
模型会估计图像中人物的关键点,并返回每个关键点的位置。
绘制姿态估计骨架图
const drawPose = (keypoints, image) => {
nextTick(() => {
const ctx = canvas.value.getContext('2d')
const imageWidth = image.width
const imageHeight = image.height
canvas.value.width = imageWidth
canvas.value.height = imageHeight
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height)
// 绘制图像
ctx.drawImage(image, 0, 0, canvas.value.width, canvas.value.height)
const scaleX = canvas.value.width / image.width
const scaleY = canvas.value.height / image.height
// 绘制关键点并标记名称
keypoints.forEach((point) => {
const { x, y } = point.position
const scaledX = x * scaleX
const scaledY = y * scaleY
ctx.beginPath()
ctx.arc(scaledX, scaledY, 5, 0, 2 * Math.PI)
ctx.fillStyle = 'red'
ctx.fill()
// 标记点的名称
ctx.font = '12px Arial'
ctx.fillStyle = 'blue'
ctx.fillText(point.part, scaledX + 8, scaledY)
})
// 连接骨架
const poseConnections = [
['leftShoulder', 'rightShoulder'],
['leftShoulder', 'leftElbow'],
['leftElbow', 'leftWrist'],
['rightShoulder', 'rightElbow'],
['rightElbow', 'rightWrist'],
['leftHip', 'rightHip'],
['leftShoulder', 'leftHip'],
['rightShoulder', 'rightHip'],
['leftHip', 'leftKnee'],
['leftKnee', 'leftAnkle'],
['rightHip', 'rightKnee'],
['rightKnee', 'rightAnkle'],
['leftEye', 'rightEye'],
['leftEar', 'leftShoulder'],
['rightEar', 'rightShoulder']
]
poseConnections.forEach(([partA, partB]) => {
const keypointA = keypoints.find((point) => point.part === partA)
const keypointB = keypoints.find((point) => point.part === partB)
if (keypointA && keypointB && keypointA.score > 0.5 && keypointB.score > 0.5) {
const scaledX1 = keypointA.position.x * scaleX
const scaledY1 = keypointA.position.y * scaleY
const scaledX2 = keypointB.position.x * scaleX
const scaledY2 = keypointB.position.y * scaleY
ctx.beginPath()
ctx.moveTo(scaledX1, scaledY1)
ctx.lineTo(scaledX2, scaledY2)
ctx.lineWidth = 2
ctx.strokeStyle = 'blue'
ctx.stroke()
}
})
})
}
这段代码通过 PoseNet
返回的人体关键点信息,绘制人体姿态的骨架图,帮助用户理解图像中的人物姿势。
总结
通过 TensorFlow.js
,我们可以轻松地将图像分类、物体检测和姿态估计等功能集成到前端应用中,无需依赖后端计算,提升了应用的响应速度并保护了用户隐私。在本文中,我们介绍了如何使用 MobileNet
、Coco-SSD
和 PoseNet
等预训练模型,在前端实现智能图像处理应用。无论是开发图像识别应用还是增强现实应用,TensorFlow.js
都是一个强大的工具,值得前端开发者深入学习和使用。
来源:juejin.cn/post/7437392938441687049
网易微前端架构实战:如何管理100+子应用而不崩
你知道网易有多少个前端项目吗?
超过 1000 个代码仓库、200+子应用、每天发布300次。
如果没有一套微前端治理系统,项目早炸了。
今天就来带你拆解网易微前端架构的核心——基座 + 动态加载 + 权限隔离 + 独立发布。
一、网易为什么早早就用上了微前端?
因为早年起就有大量频道、游戏门户、社区运营、CMS后台等:
- 功能多样、团队独立
- 迭代频繁、部署不可等待
- 技术栈各异:Vue2、Vue3、React、甚至还有 jQuery...
👇于是他们选择了模块化能力最强的方案:微前端架构(Micro Frontends)
二、整体架构图(网易实战版)
三、主子应用通信怎么做?(网易方案)
网易没有用 qiankun,而是基于内部封装的微前端 SDK,核心原理类似。
// 主应用提供通信桥
window.__MICRO_APP_EVENT_BUS__ = new EventTarget()
// 子应用监听事件
window.__MICRO_APP_EVENT_BUS__.addEventListener('global-refresh', () => {
window.location.reload()
})
// 主应用触发事件
window.__MICRO_APP_EVENT_BUS__.dispatchEvent(new Event('global-refresh'))
👉这种方式:
- 不侵入框架(Vue/React 通吃)
- 不耦合代码,只用浏览器原生事件系统
四、部署与权限如何统一管理?
网易配套了一整套“发布平台 + 权限系统”,做到:
功能 | 说明 |
---|---|
独立部署 | 每个子应用都有独立 Jenkins/流水线 |
权限接入 | 每个子应用上线必须绑定角色权限模块 |
域名配置 | 主应用统一路由配置,动态注入 iframe 或模块 |
沙箱运行 | 子应用运行在 iframe + ShadowDOM + CSP 下,完全隔离 |
五、实战代码:子应用注册和加载
// 主应用注册子应用(JSON 配置化)
const microAppList = [ { name: 'content-manage', entry: 'https://cdn.xxx.com/apps/content-manage/index.html', activeRule: '/content' }, { name: 'user-center', entry: 'https://cdn.xxx.com/apps/user-center/index.html', activeRule: '/user' }]
// 动态加载示例(简化版)
function loadMicroApp(appConfig) {
const iframe = document.createElement('iframe')
iframe.src = appConfig.entry
iframe.style = 'width:100%;height:100%;border:none'
document.getElementById('micro-container').appendChild(iframe)
}
六、网易踩过的3个坑(干货!)
坑 | 解决方案 |
---|---|
子应用样式污染 | 每个子应用编译时加 prefixCls ,搭配 ShadowDOM 隔离 |
子应用登录状态不一致 | 所有项目统一通过 Cookie + SSO 网关授权 |
子应用发布顺序冲突 | 发布系统支持灰度 + 停发自动依赖检查 |
七、总结:你能从中学到什么?
- 不要迷信 qiankun,自己也能搞微前端(原理简单)
- 微前端不仅是技术,更是权限、部署、治理一整套体系
- 想要稳定运行,必须有主子应用契约 + 灰度发布 + 统一通信策略
尾声:
“你看到的稳定,其实是他们踩了无数坑后的优雅。”
来源:juejin.cn/post/7510653719672094739
写了个脚本,发现CSDN的前端居然如此不严谨
引言
最近在折腾油猴脚本开发,顺手搞了一个可以拦截任意网页接口的小工具,并修改了CSDN的博客数据接口,成功的将文章总数和展现量进行了修改。
如果你不了解什是油猴,参考这篇文章:juejin.cn/book/751468…
然后我突然灵光一闪:
既然能拦截接口、篡改数据,那我为什么不顺便测试一下 CSDN 博客在极端数据下的表现呢?
毕竟我们平时开发的时候,测试同学各种花式挑刺,什么 null、undefined、999999、-1、空数组
……
每次都能把页面测出一堆边角 bug。
今天,轮到我来当一回“灵魂测试”了!
用我的脚本,造几个极限场景,看看 CSDN 的前端到底稳不稳!
实现原理
其实原理并不复杂,核心就一句话:
借助油猴的脚本注入能力 + ajax-hook 对接口请求进行拦截和修改。
我们知道,大部分网页的数据接口都是通过 XMLHttpRequest
或 fetch
发起的,而 ajax-hook
就是一个开源的轻量工具,它能帮我们劫持原生的 XMLHttpRequest
,在请求发出前、响应返回后进行自定义处理。
配合油猴脚本的注入机制,我们就可以实现在浏览器端伪造任意接口数据,用来调试前端样式、模拟数据异常、测试权限控制逻辑等等。
ajax-hook 快速上手
我们用的是 CDN 方式直接引入,简单暴力:
<script src="https://unpkg.com/ajax-hook@3.0.3/dist/ajaxhook.min.js"></script>
引入后,页面上会多出一个全局对象 ah
,我们只需要调用 ah.proxy()
,就可以注册一套钩子:
ah.proxy({
onRequest: (config, handler) => {
// 请求发出前
handler.next(config);
},
onError: (err, handler) => {
// 请求出错时
handler.next(err);
},
onResponse: (response, handler) => {
// 请求成功响应后
console.log("响应内容:", response.response);
handler.next(response);
}
});
拦截实现
我们以 CSDN 博客后台为例,先找到博客数据接口
地址长这样:bizapi.csdn.net/blog/phoeni…
我们在 onResponse
钩子中,加入 URL 判断,专门拦截这个接口:
onResponse: (response, handler) => {
if (response.config.url.includes("https://bizapi.csdn.net/blog/phoenix/console/v1/data/blog-statistics")) {
const hookResponse = JSON.parse(response.response);
console.log("拦截到的数据:", hookResponse);
handler.next(response);
} else {
handler.next(response);
}
}
就这样,接口拦截器初步搭建完成!
使用油猴将脚本运行在网页
接下来我们用油猴把这段脚本注入到 CSDN 博客后台页面。
// ==UserScript==
// @name CSDN博客数据接口拦截
// @namespace http://tampermonkey.net/
// @version 0.0.1
// @description 拦截接口数据,验证极端情况下的样式展示
// @author 石小石Orz
// @match https://mpbeta.csdn.net/*
// @require https://unpkg.com/ajax-hook@3.0.3/dist/ajaxhook.min.js
// @run-at document-start
// @grant none
// ==/UserScript==
(function() {
'use strict';
ah.proxy({
onRequest: (config, handler) => {
handler.next(config);
},
onError: (err, handler) => {
handler.next(err);
},
onResponse: (response, handler) => {
console.log("接口响应列表:", response);
// 这里写拦截逻辑
handler.next(response);
}
});
})();
为了测试前端的容错能力,我们可以伪造一些极端数据返回:
- 文章总数设为
null
- 展现量设为
0
- 点赞数设为一个异常大的值
onResponse: (response, handler) => {
if (response.config.url.includes("https://bizapi.csdn.net/blog/phoenix/console/v1/data/blog-statistics")) {
const hookResponse = JSON.parse(response.response);
// 伪造数据
hookResponse.data[0].num = null; // 文章总数
hookResponse.data[1].num = 0; // 展现量
hookResponse.data[2].num = 99999999999999999; // 点赞数
console.log("修改后的数据:", hookResponse);
response.response = JSON.stringify(hookResponse);
}
handler.next(response);
}
结果验证
修改成功后刷新页面,可以观察到如下问题:
- 文章总数为
null
时,布局异常,显然缺乏空值判断。 - 点赞数为超大值 时,页面直接渲染出
100000000000000000
,不仅视觉上溢出容器,连排版都崩了,前端没有做任何兼容处理。
CSDN的前端还是偷懒了呀,一点也不严谨!差评!
总结
通过这篇文章的示例,我们前端应该引以为戒,永远不要相信后端同学返回的数据,一定要做好容错处理!
通过本文,相信大家也明白了油猴脚本不仅是玩具,它在前端开发中其实是个非常实用的辅助工具!
如果你对油猴脚本的开发感兴趣,不妨看看我写的这篇教程 《油猴脚本实战指南》
从小脚本写起,说不定哪天你也能靠一个脚本搞出点惊喜来!
来源:juejin.cn/post/7519005878566748186
用 iframe 实现前端批量下载的优雅方案 —— 从原理到实战
传统的下载方式如window.open()或标签点击存在诸多痛点:
- 批量下载时浏览器会疯狂弹窗
- HTTPS页面下载HTTP资源被拦截
今天分享的前端iframe批量下载方案,可以有效解决以上问题。
一、传统批量下载方案的局限性
传统的批量下载方式通常是循环创建 a 标签并触发点击事件:
urls.forEach(url => {
const link = document.createElement('a');
link.href = url;
link.download = 'filename';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
这种方式存在以下问题:
- 浏览器会限制连续的自动点击行为
- 用户体验不佳,会弹出多个下载对话框
二、iframe 批量下载解析
更优雅的解决方案是使用 iframe 技术,通过动态创建和移除 iframe 元素来触发下载:
downloadFileBatch(url) {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.style.height = '0';
iframe.src = this.urlProtocolDelete(url);
document.body.appendChild(iframe);
setTimeout(() => {
iframe.remove();
}, 5000);
}
urlProtocolDelete(url: string = '') {
if (!url) {
return;
}
return url.replace('http://', '//').replace('https://', '//');
}
这种方案的优势在于:
- 不依赖用户交互,可自动触发下载
- 隐藏 iframe 不会影响页面布局,每个iframe独立运行,互不干扰
- 主线程保持流畅
三、核心代码实现解析
让我们详细分析一下这段代码的工作原理:
- 动态创建 iframe 元素:
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.style.height = '0';
通过创建一个不可见的 iframe 元素,我们可以在不影响用户界面的情况下触发下载请求。
- 协议处理函数:
urlProtocolDelete(url: string = '') {
return url.replace('http://', '//').replace('https://', '//');
}
这个函数将 URL 中的协议部分替换为//,这样可以确保在 HTTPS 页面中请求 HTTP 资源时不会出现混合内容警告。
- 触发下载并清理 DOM:
iframe.src = this.urlProtocolDelete(url);
document.body.appendChild(iframe);
setTimeout(() => {
iframe.remove();
}, 5000);
将 iframe 添加到 DOM 中会触发浏览器对 src 的请求,从而开始下载文件。设置 5 秒的超时时间后移除 iframe,既保证了下载有足够的时间完成,又避免了 DOM 中积累过多无用元素。
四、批量下载的实现与优化
对于多个文件的批量下载,可以通过循环调用 downloadFileBatch 方法:
result.forEach(item => {
this.downloadFileBatch(item.url);
});
五、踩坑+注意点
在实现批量下载 XML 文件功能时,你可能会遇到这种情况:明明请求的 URL 地址无误,服务器也返回了正确的数据,但文件却没有被下载到本地,而是直接在浏览器中打开预览了。这是因为 XML 作为一种可读的文本格式,浏览器默认会将其视为可直接展示的内容,而非需要下载保存的文件。
解决方案:
通过在下载链接中添加response-content-disposition=attachment
参数,可以强制浏览器将 XML 文件作为附件下载,而非直接预览。这个参数会覆盖浏览器的默认行为,明确告诉浏览器 "这是一个需要下载保存的文件"。
addDownloadDisposition(url: string, filename: string): string {
try {
const urlObj = new URL(url);
// 添加 response-content-disposition 参数
const disposition = `attachment;filename=${encodeURIComponent(filename)}`;
urlObj.searchParams.set('response-content-disposition', disposition);
return urlObj.toString();
} catch (error) {
console.error('URL处理失败:', error);
return url;
}
}
六、大量文件并发控制
有待补充
来源:juejin.cn/post/7524627104580534306
前端高手才知道的秘密:Blob 居然这么强大!
🔍 一、什么是 Blob?
Blob(Binary Large Object)是 HTML5 提供的一个用于表示不可变的、原始二进制数据块的对象。
✨ 特点:
- 不可变性:一旦创建,内容不能修改。
- 可封装任意类型的数据:字符串、ArrayBuffer、TypedArray 等。
- 支持 MIME 类型描述,方便浏览器识别用途。
💡 示例:
const blob = new Blob(['Hello World'], { type: 'text/plain' });
🧠 二、Base64 编码的前世今生
虽然名字听起来像是某种“64进制”,但实际上它是一种编码方式,不是数学意义上的“进制”。
📜 起源背景:
Base64 最早起源于电子邮件协议 MIME(Multipurpose Internet Mail Extensions),因为早期的电子邮件系统只能传输 ASCII 文本,不能直接传输二进制数据(如附件)。于是人们发明了 Base64 编码方法,把二进制数据转换成文本形式,从而安全地在网络上传输。
🧩 使用场景:
场景 | 说明 |
---|---|
图片嵌入到 HTML/CSS 中 | Data URI 方式减少请求 |
JSON 数据中传输二进制信息 | 如头像、加密数据等 |
WebSocket 发送二进制消息 | 避免使用 ArrayBuffer |
二维码生成 | 将图像转为 Base64 存储 |
⚠️ 注意:Base64 并非压缩算法,它会将数据体积增加约 33%。
🔁 三、从 Base64 到 Blob 的全过程
1. Base64 字符串解码:atob()
JavaScript 提供了一个内置函数 atob()
,可以将 Base64 字符串解码为原始的二进制字符串(ASCII 表示)。
const base64Data = 'SGVsbG8gd29ybGQh'; // "Hello world!"
const binaryString = atob(base64Data);
⚠️ 返回的是 ASCII 字符串,不是真正的字节数组。
2. 构建 Uint8Array(字节序列)
为了构造 Blob,我们需要一个真正的字节数组。我们可以用 charCodeAt()
将每个字符转为对应的 ASCII 数值(即 0~255 的整数)。
const byteArray = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i);
}
现在,byteArray
是一个代表原始图片二进制数据的数组。
3. 创建 Blob 对象
有了字节数组,就可以创建 Blob 对象了:
const blob = new Blob([byteArray], { type: 'image/png' });
这个 Blob 对象就代表了一张 PNG 图片的二进制内容。
4. 使用 URL.createObjectURL() 显示图片
为了让浏览器能够加载这个 Blob 对象,我们需要生成一个临时的 URL 地址:
const imageUrl = URL.createObjectURL(blob);
document.getElementById('blobImage').src = imageUrl;
这样,你就可以在网页中看到这张图片啦!
🛠️ 四、Blob 的核心功能与应用场景
功能 | 说明 |
---|---|
分片上传 | .slice(start, end) 方法可用于大文件切片上传 |
本地预览 | Canvas.toBlob() 导出图像,配合 URL.createObjectURL 预览 |
文件下载 | 使用 a 标签 + createObjectURL 实现无刷新下载 |
缓存资源 | Service Worker 中缓存 Blob 数据提升性能 |
处理用户上传 | 结合 FileReader 和 File API 操作用户文件 |
🧪 五、Blob 的高级玩法
1. 文件切片上传(分片上传)
const chunkSize = 1024 * 1024; // 1MB
const firstChunk = blob.slice(0, chunkSize);
2. Blob 转换为其他格式
FileReader.readAsText(blob)
→ 文本FileReader.readAsDataURL(blob)
→ Base64FileReader.readAsArrayBuffer(blob)
→ Array Buffer
3. Blob 下载为文件
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'example.png';
a.click();
🧩 六、相关知识点汇总
技术点 | 作用 |
---|---|
Base64 | 将二进制数据编码为文本,便于传输 |
atob() | 解码 Base64 字符串,还原为二进制字符串 |
charCodeAt() | 获取字符的 ASCII 值(0~255) |
Uint8Array | 构建字节数组,表示原始二进制数据 |
Blob | 封装二进制数据,作为文件对象使用 |
URL.createObjectURL() | 生成临时地址,让浏览器加载 Blob 数据 |
🧾 七、完整代码回顾
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Blob 实战</title>
</head>
<body>
<img src="" id="blobImage" width="100" height="100" alt="Blob Image" />
<script>
const base64Data = 'UklGRiAHAABXRUJQVlA4IBQHAACwHACdASpQAFAAPok0lEelIyIhMziOYKARCWwAuzNaQpfW+apU37ZufB5rAHqW2z3mF/aX9o/ev9LP+j9KrqSOfp9mf+6WmE1P1yFc3gTlw8B8d/TebelHaI3mplPrZ+Aa0l5qDGv5N8Tt9vYhz3IH37wqm2al+FdcFQhDnObv2+WfpwIZ+K6eBPxKL2RP6hiC/K1ZynnvVYth9y+ozyf88Obh4TRYcv3nkkr43girwwJ54Gd0iKBPZFnZS+gd1vKqlfnPT5wAwzxJiSk+pkbtcOVP+QFb2uDqUhuhNiHJ8xPt6VfGBfUbTsUzYuKgAP4L9wrkT8KU4sqIHwM+ZeKDBpGq58k0aDirXeGc1Odhvfx+cpQaeas97zVTr2pOk5bZkI1lkF9jnc0j2+Ojm/H+uPmIhS7/BlxuYfgnUCMKVZJGf+iPM44vA0EwvXye0YkUUK...';
const binaryString = atob(base64Data); // Base64 解码
const byteArray = new Uint8Array(binaryString.length); // 创建 Uint8Array
for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i); // 填充字节数据
}
const blob = new Blob([byteArray], { type: 'image/png' }); // 创建 Blob
const imageUrl = URL.createObjectURL(blob); // 生成 URL
document.getElementById('blobImage').src = imageUrl; // 显示图片
</script>
</body>
</html>
📚 八、扩展阅读建议
🧩 九、结语
Blob 是连接 JavaScript 世界与真实二进制世界的桥梁,是每一个想要突破瓶颈的前端开发者必须掌握的核心技能之一。
掌握了 Blob,你就拥有了操作二进制数据的能力,这在现代 Web 开发中是非常关键的一环。
下次当你看到一张图片在网页中加载出来,或者一个文件被顺利下载时,不妨想想:这一切的背后,都有 Blob 的身影。
来源:juejin.cn/post/7523065182429904915
🫴为什么看大厂的源码,看不到undefined,看到的是void 0
void 0
是 JavaScript 中的一个表达式,它的作用是 返回 undefined
。
解释:
void
运算符:
- 它会执行后面的表达式(比如
void 0
),但不管表达式的结果是什么,void
始终返回undefined
。 - 例如:
console.log(void 0); // undefined
console.log(void (1 + 1)); // undefined
console.log(void "hello"); // undefined
- 它会执行后面的表达式(比如
- 为什么用
void 0
代替undefined
?
- 在早期的 JavaScript 中,
undefined
并不是一个保留字,它可以被重新赋值(比如undefined = 123
),这会导致代码出错。 void 0
是确保获取undefined
的安全方式,因为void
总是返回undefined
,且不能被覆盖。- 现代 JavaScript(ES5+)已经修复了这个问题,
undefined
现在是只读的,但void 0
仍然在一些旧代码或压缩工具中出现。
- 在早期的 JavaScript 中,
常见用途:
- 防止默认行为(比如
<a>
标签的href="javascript:void(0)"
):
<a href="javascript:void(0);" onclick="alert('Clicked!')">
点击不会跳转
</a>
这样点击链接时不会跳转页面,但仍然可以执行 JavaScript。
- 在函数中返回
undefined
:
function doSomething() {
return void someOperation(); // 明确返回 undefined
}
为什么用void 0
源码涉及到 undefined 表达都会被编译成 void 0
//源码
const a: number = 6
a === undefined
//编译后
"use strict";
var a = 6;
a === void 0;
也就是void 0 === undefined
。void 运算符通常只能用于获取 undefined 的原始值,一般用void(0),等同于void 0,也可以使用全局变量 undefined 替代。
为什么不直接写 undefined
undefined 是 js 原始类型值之一,也是全局对象window的属性,在一部分低级浏览器(IE7-IE8)中or局部作用域可以被修改。
undefined在js中,全局属性是允许被覆盖的。
//undefined是window的全局属性
console.log(window.hasOwnProperty('undefined'))
console.log(window.undefined)
//旧版IE
var undefined = '666'
console.log(undefined)//666 直接覆盖改写undefined
window.undefined在局部作用域中是可以被修改的 在ES5开始,undefined就已经被设定为仅可读的,但是在局部作用域内,undefined依然是可变的。
①某些情况下用undefined判断存在风险,因undefined有被修改的可能性,但是void 0返回值一定是undefined
②兼容性上void 0 基本所有的浏览器都支持
③ void 0比undefined字符所占空间少。
拓展
void(0) 表达式会返回 undefined 值,它一般用于防止页面的刷新,以消除不需要的副作用。
常见用法是在 <a> 标签上设置 href="javascript:void(0);",即当单击该链接时,此表达式将会阻止浏览器去加载新页面或刷新当前页面的行为。
<!-- 点击下面的链接,不会重新加载页面,且可以得到弹框消息 -->
<a href="javascript:void(0);" onclick="alert('干的漂亮!')">
点我呀
</a>
总结:
void 0
是一种确保得到 undefined
的可靠方式,虽然在现代 JavaScript 中直接用 undefined
也没问题,但在一些特殊场景(如代码压缩、兼容旧代码)仍然有用。
来源:juejin.cn/post/7511618693714427914
用半天时间,threejs手搓了一个机柜
那是一个普通的周三早晨,我正对着产品经理刚丢过来的需求发呆——"在管理系统里加个3D机柜展示,要能开门的那种"。
"这不就是个模型展示吗?"我心想,"AI应该能搞定吧?"
9:30 AM - 启动摸鱼模式
我熟练地打开代码编辑器,把需求复制粘贴进AI对话框: "用Three.js实现一个带开门动画的机柜模型,要求有金属质感,门能90度旋转"
点击发送后,我惬意地靠在椅背上,顺手打开了B站。"让AI先忙会儿~"
10:30 AM - 验收时刻
一集《凡人修仙传》看完,我懒洋洋地切回编辑器。AI果然交出了答卷:
11:00 AM - 血压升高现场
看着AI生成的"未来科技风"机柜,我深吸一口气,决定亲自下场。毕竟,程序员最后的尊严就是——"还是自己来吧"。
11:30 AM - 手动抢救
首先手动创建一个空场景吧
class SceneManager {
constructor() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.camera.position.set(0, 2, 5);
this.renderer = new THREE.WebGLRenderer();
this.renderer.setSize(window.innerWidth, window.innerHeight);
const canvas = document.getElementById('renderCanvas');
canvas.appendChild(this.renderer.domElement);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.target.set(0, 3, 0);
this.controls.update();
this.addLights();
this.addFloor();
}
addLights() {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(5, 5, 5);
this.scene.add(directionalLight);
}
addFloor() {
const floorGeometry = new THREE.PlaneGeometry(10, 10);
const floorMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
this.scene.add(floor);
}
animate() {
const animateLoop = () => {
requestAnimationFrame(animateLoop);
this.controls.update();
this.renderer.render(this.scene, this.camera);
};
animateLoop();
}
onResize() {
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
});
}
}
然后这机柜怎么画呢,不管了,先去吃个饭,天大地大肚子最大
12:30 PM - 程序员的能量补给时刻
淦!先干饭!" 我一把推开键盘,决定暂时逃离这个三维世界。毕竟——
- 饥饿值已经降到30%以下
- 右手开始不受控制地颤抖
- 看Three.js文档出现了重影
扒饭间隙,手机突然震动。产品经理发来消息:"那个3D机柜..."
我差点被饭粒呛到,赶紧回复:"正在深度优化用户体验!"
(十分钟风卷残云后)
1:00 PM - 回归正题
吃饱喝足,终于可以专心搞机柜了,(此处可怜一下我的午休)
拆分机柜结构
机柜的结构可以分为以下几个部分:
- 不可操作结构:
- 底部:承载整个机柜的重量,通常是一个坚固的平面。
- 顶部:封闭机柜的顶部,提供额外的支撑。
- 左侧和右侧:机柜的侧板,通常是固定的,用于保护内部设备。
- 可操作结构:
- 前门:单门设计,通常是透明或半透明材质,便于观察内部设备。
- 后门:双开门设计,方便从后方接入设备的电缆和接口。
实现步骤
- 创建不可操作结构:
使用BoxGeometry
创建底部、顶部、左侧和右侧的平面,并将它们组合成一个整体。 - 添加前门:
前门使用透明材质,并设置旋转轴以实现开门动画。 - 添加后门:
后门分为左右两部分,分别设置旋转轴以实现双开门效果。 - 优化细节:
- 添加螺丝孔和通风口。
- 使用高光材质提升视觉效果。
接下来,我们开始用代码实现这些结构。
机柜结构的实现
1. 创建不可操作结构
底部
export function createCabinetBase(scene) {
const geometry = new THREE.BoxGeometry(0.6, 0.05, 0.64);
const base = new THREE.Mesh(geometry, materials.baseMaterial);
base.position.y = -0.05; // 调整位置
scene.add(base);
}
底部使用BoxGeometry
创建,设置了深灰色金属材质,位置调整为机柜的最底部。
顶部
export function createCabinetTop(scene) {
const geometry = new THREE.BoxGeometry(0.6, 0.05, 0.64);
const top = new THREE.Mesh(geometry, materials.baseMaterial);
top.position.y = 1.95; // 调整位置
scene.add(top);
}
顶部与底部类似,位置调整为机柜的最顶部。
侧面
export function createCabinetSides(scene) {
const geometry = new THREE.BoxGeometry(0.04, 2, 0.6);
const material = materials.baseMaterial;
// 左侧面
const leftSide = new THREE.Mesh(geometry, material);
leftSide.position.set(-0.28, 0.95, 0); // 调整位置
scene.add(leftSide);
// 右侧面
const rightSide = new THREE.Mesh(geometry, material);
rightSide.position.set(0.28, 0.95, 0); // 调整位置
scene.add(rightSide);
}
侧面使用两个BoxGeometry
分别创建左侧和右侧,位置对称分布。
2. 创建可操作结构
前门
export function createCabinetFrontDoor(scene) {
const doorGr0up = new THREE.Gr0up();
const doorWidth = 0.04;
const doorHeight = 2;
const doorDepth = 0.6;
const frameMaterial = materials.baseMaterial;
const frameThickness = 0.04;
// 上边框
const topFrameGeo = new THREE.BoxGeometry(doorWidth, frameThickness, doorDepth);
const topFrame = new THREE.Mesh(topFrameGeo, frameMaterial);
topFrame.position.set(0, 1 - frameThickness / 2, 0);
doorGr0up.add(topFrame);
// 下边框
const bottomFrameGeo = new THREE.BoxGeometry(doorWidth, frameThickness, doorDepth);
const bottomFrame = new THREE.Mesh(bottomFrameGeo, frameMaterial);
bottomFrame.position.set(0, -doorHeight / 2 + 0.05, 0);
doorGr0up.add(bottomFrame);
// 左右边框
const leftFrameGeo = new THREE.BoxGeometry(doorWidth, doorHeight - 2 * frameThickness, frameThickness);
const leftFrame = new THREE.Mesh(leftFrameGeo, frameMaterial);
leftFrame.position.set(0, 1 - doorHeight / 2, -doorDepth / 2 + frameThickness / 2);
doorGr0up.add(leftFrame);
const rightFrameGeo = new THREE.BoxGeometry(doorWidth, doorHeight - 2 * frameThickness, frameThickness);
const rightFrame = new THREE.Mesh(rightFrameGeo, frameMaterial);
rightFrame.position.set(0, 1 - doorHeight / 2, doorDepth / 2 - frameThickness / 2);
doorGr0up.add(rightFrame);
scene.add(doorGr0up);
return doorGr0up;
}
前门由一个Gr0up
组装而成,包含上下左右边框,材质与机柜一致,后续将添加玻璃部分和动画。
前门动画的实现
前门的动画使用gsap
库实现,设置旋转轴为左侧边框。
gsap.to(frontDoor.rotation, {
y: Math.PI / 2, // 90度旋转
duration: 1, // 动画时长
ease: "power2.inOut",
});
通过gsap.to
方法,前门可以实现平滑的开门效果。
3. 添加后门
后门采用双开设计,左右两扇门分别由多个边框组成,并通过Gr0up
进行组合。
为了优化细节我还加入了网孔结构(此处心疼一下我为写他掉的头发)
后门的实现
export function createCabinetBackDoor(scene) {
const doorGr0up = new THREE.Gr0up();
const doorWidth = 0.04;
const doorHeight = 2;
const doorDepth = 0.6;
const singleDoorDepth = doorDepth / 2;
const frameMaterial = materials.baseMaterial;
const frameThickness = 0.04;
function createSingleBackDoor(isLeft) {
const singleGr0up = new THREE.Gr0up();
// 上边框
const topFrameGeo = new THREE.BoxGeometry(doorWidth, frameThickness, singleDoorDepth);
const topFrame = new THREE.Mesh(topFrameGeo, frameMaterial);
topFrame.position.set(0, 1 - frameThickness / 2, 0);
singleGr0up.add(topFrame);
// 下边框
const bottomFrameGeo = new THREE.BoxGeometry(doorWidth, frameThickness, singleDoorDepth);
const bottomFrame = new THREE.Mesh(bottomFrameGeo, frameMaterial);
bottomFrame.position.set(0, -doorHeight / 2 + 0.05, 0);
singleGr0up.add(bottomFrame);
// 外侧边框
const sideFrameGeo = new THREE.BoxGeometry(doorWidth, doorHeight - 2 * frameThickness, frameThickness);
const sideFrame = new THREE.Mesh(sideFrameGeo, frameMaterial);
sideFrame.position.set(
0,
1 - doorHeight / 2,
isLeft
? -singleDoorDepth / 2 + frameThickness / 2
: singleDoorDepth / 2 - frameThickness / 2
);
singleGr0up.add(sideFrame);
return singleGr0up;
}
const leftDoor = createSingleBackDoor(true);
const rightDoor = createSingleBackDoor(false);
doorGr0up.add(leftDoor);
doorGr0up.add(rightDoor);
scene.add(doorGr0up);
return { group: doorGr0up, leftDoor, rightDoor };
}
后门的实现与前门类似,采用双扇门设计,左右各一扇。
后门动画的实现
后门的动画同样使用gsap
库实现,分别设置左右门的旋转轴。
gsap.to(leftDoor.rotation, {
y: Math.PI / 2, // 左门向外旋转90度
duration: 1,
ease: "power2.inOut",
});
gsap.to(rightDoor.rotation, {
y: -Math.PI / 2, // 右门向外旋转90度
duration: 1,
ease: "power2.inOut",
});
通过gsap.to
方法,后门可以实现平滑的双开效果。
2:00 PM - 项目收尾
终于,随着最后一行代码的敲定,3D机柜模型在屏幕上完美呈现。前门优雅地打开,后门平滑地双开,仿佛在向我点头致意。
我靠在椅背上,长舒一口气,心中默念:"果然,程序员的尊严还是要靠自己守护。"
可拓展功能
虽然当前的3D机柜模型已经实现了基本的展示和交互功能,但在实际项目中,我们可以进一步扩展以下功能:
1. U位标记
2. U位资产管理
3. 动态灯光效果
4. 数据联动
将3D机柜与后台数据联动:
- 实时更新设备状态。
- 显示设备的实时监控数据(如温度、功耗等)。
- 支持通过API接口获取和更新设备信息。
不说了,需求又来了()我还是继续去搬砖了
代码地址:gitee.com/erhadong/th…
来源:juejin.cn/post/7516784123703181322
很喜欢Vue,但还是选择了React: AI时代的新考量
引言
作为一个深度使用Vue多年的开发者,最近我在新项目技术选型时,却最终选择了React。这个决定不是一时冲动,而是基于当前技术发展趋势、AI时代的需求以及生态系统的深度思考。

AI时代的前端需求
随着人工智能技术的飞速发展,前端开发的需求也发生了深刻的变化。现代应用不仅仅是静态页面或简单的数据展示,而是需要与复杂的后端服务、机器学习模型以及第三方API深度集成。这些场景对前端框架提出了更高的要求,生态的重要性,不得不说很重要。
社区对AI的支持
说实话,React社区在AI领域简直就是"社交达人"。shadcn这样的明星UI库、vercel/ai这样的实力派SDK,都是圈子里的"网红"。想要快速搭建AI应用?这些"老铁"都能帮你省下不少力气。简单列举一些知名仓库。
@vercel/ai
这是由Vercel开发的AI SDK
提供了与各种AI模型(如OpenAI, Anthropic等)交互的统一接口
支持流式响应、AI聊天界面等功能
特别适合构建类ChatGPT应用
shadcn-admin
基于shadcn/ui的管理后台模板
包含了AI聊天等现代化功能
提供了完整的后台管理系统布局
shadcn/ui
这是一个高度可定制的React组件库
不是传统的npm包,而是采用复制代码的方式
提供了大量现代化的UI组件
完美支持暗色模式
特别适合构建AI应用的界面
ChatGPTNextWeb
开源的ChatGPT Web客户端
使用Next.js构建
支持多种部署方式
提供了优秀的UI/UX设计参考
AI工具链的优先支持
React在AI工具支持方面具有明显优势
GitHub Copilot、Cursor 等AI IDE 也对React的代码提示更准确
目前多数AI辅助开发工具会优先支持React生态(Vue 生态也不错,狗头保命🐶)
结论
技术选型永远不是非黑即白的选择。在AI时代,我们需要考虑:
- 技术栈的生态活跃度
- AI工具的支持程度
- 团队的学习成本
- 项目的长期维护
总的来说,Vue和React各有千秋,但从AI时代的需求和生态系统的角度来看,React确实更适合承担复杂、高性能的应用开发任务。当然,这并不意味着Vue没有未来。事实上,Vue依然是一个优秀的框架,尤其适合中小型企业或初创团队快速搭建产品原型。
随着AI技术的进一步普及,前端框架之间的竞争也将更加激烈。无论是React还是Vue,都需要不断进化以适应新的挑战。而对于开发者来说,掌握多种技术栈并根据项目需求灵活选择,才是最重要的技能。
正如一句老话所说:“工欲善其事,必先利其器。”选择合适的工具,才能让我们的项目在AI时代脱颖而出。
还有技术人不应该局限于框架,什么都能上手,多看看新的东西,接受新的事物,产品能力也很重要。
写在最后
技术选型是一个需要综合考虑的过程,没有永远的对与错,只有更适合与否。希望这篇文章能给正在进行技术选型的你一些参考。
来源:juejin.cn/post/7497174194715852815
Tailwind 到底是设计师喜欢,还是开发者在硬撑?
我们最近刚把一个后台系统从 element-plus 切成了完全自研组件,CSS 层统一用 Tailwind。全员同意设计稿一致性提升了,但代码里怨言开始冒出来。
这篇文章不讲原理,直接上代码对比和团队真实使用反馈,看看是谁在享受,谁在撑着。
1.组件内样式迁移
原先写法(BEM + scoped):
<template>
<div class="card">
<h2 class="card__title">用户概览</h2>
<p class="card__desc">共计 1280 位</p>
</div>
</template>
<style scoped>
.card {
padding: 16px;
background-color: #fff;
border-radius: 8px;
}
.card__title {
font-size: 16px;
font-weight: bold;
}
.card__desc {
color: #999;
font-size: 14px;
}
</style>
Tailwind 重写:
<template>
<div class="p-4 bg-white rounded-lg">
<h2 class="text-base font-bold">用户概览</h2>
<p class="text-sm text-gray-500">共计 1280 位</p>
</div>
</template>
优点:
- 组件直接可读,不依赖 class 定义
- 样式即结构,调样式时不用来回翻
缺点:
- 设计稿变了?全组件搜索
text-sm
改成text-base
? - 无法抽象:多个地方复用
.text-label
变成复制粘贴
2.复杂交互样式
纯 CSS(原写法)
<template>
<button class="btn">提交</button>
</template>
<style scoped>
.btn {
background-color: #409eff;
color: #fff;
padding: 8px 16px;
border-radius: 4px;
}
.btn:hover {
background-color: #66b1ff;
}
.btn:active {
background-color: #337ecc;
}
</style>
Tailwind 写法
<button
class="bg-blue-500 hover:bg-blue-400 active:bg-blue-700 text-white py-2 px-4 rounded">
提交
</button>
问题来了:
- ✅ 简单 hover/active 很方便
- ❌ 多态样式(如 disabled + dark mode + hover 同时组合)就很难读:
<button
class="bg-blue-500 text-white disabled:bg-gray-300 dark:bg-slate-600 dark:hover:bg-slate-700 hover:bg-blue-600 transition-all">
>
提交
</button>
调试时需要反复阅读 class 字符串,不能直接 Cmd+Click 查看样式来源。
3.统一样式封装,复用方案混乱
原写法:统一样式变量 + class
$border-color: #eee;
.panel {
border: 1px solid $border-color;
border-radius: 8px;
}
Tailwind 使用中经常出现的写法:
<div class="border border-gray-200 rounded-md" />
问题来了:
设计稿调整了主色调或边框粗细,如何批量更新?
BEM 模式下你只需要改一个变量,Tailwind 下必须靠 @apply
或者手动替换所有 .border-gray-200
。
于是我们项目里又写了一堆“语义类”去封装 Tailwind:
/* 自定义 utilities */
@layer components {
.app-border {
@apply border border-gray-200;
}
.app-card {
@apply p-4 rounded-lg shadow-sm bg-white;
}
}
最后导致的问题是:我们重新“造了个 BEM”,只不过这次是基于 Tailwind 的 apply 写法。
🧪 实测维护成本:100+组件、多人协作时的问题
我们项目有 110 个组件,4 人开发,统一用 Tailwind,协作两个月后出现了这些反馈:
- 👨💻 A 开发:写得很快,能复制设计稿的 class 直接粘贴
- 🧠 B 维护:改样式全靠人肉找
.text-sm
、.p-4
,没有结构命名层 - 🤯 C 重构:统一调整圆角半径?所有
.rounded-md
都要搜出来替换
所以我们内部的结论是:
Tailwind 写得爽,维护靠人背。它适合“一次性强视觉还原”,不适合“结构长期型组件库”。
🔧 我们后来的解决方案:Tailwind + token 化抽象
我们仍然使用 Tailwind 作为底层 utilities,但同时强制使用语义类抽象,例如:
@layer components {
.text-label {
@apply text-sm text-gray-500;
}
.btn-primary {
@apply bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded;
}
.card-container {
@apply p-4 bg-white rounded-lg shadow;
}
}
模板中统一使用:
<h2 class="text-label">标题</h2>
<button class="btn-primary">提交</button>
<div class="card-container">内容</div>
这种方式保留了 Tailwind 的构建优势(无 tree-shaking 问题),但代码结构有命名可依,后期批量维护不再靠搜索。
📌 最终思考
Tailwind 是给设计还原速度而生的,不是给可维护性设计的。
设计师爱是因为它像原子操作;
开发者撑是因为它把样式从结构抽象变成了“字串组合游戏”。
如果你的团队更在意开发效率,样式一次性使用,那 Tailwind 非常合适。
如果你的组件系统是要长寿、要维护、要被多人重构的——你最好在 Tailwind 之上再造一层自己的语义层,或者别用。
分享完毕,谢谢大家🙂
📌 你可以继续看我的系列文章
来源:juejin.cn/post/7517496354245492747
最快实现的前端灰度方案
小白最快学会的前端灰度方案
首次访问效果如下,点击立即更新会访问灰度版本。本地cookie存在version字段后,后续访问都是指定版本代码,也不会出现弹窗提示
一、引言:为什么需要灰度发布?
1.1 血泪教训:全量发布的风险
因为一次上线,导致登录异常,用户无法使用。复盘时候,测试反馈预发环境不能完全模拟出生成环境。要不做一个灰度发布,实现代码最小化影响。
1.2 技术思考:面试的需要
多了解点技术方案,总没有坏事
二、前端灰度方案
- 在网上搜索前端灰度方案,整体看来就目前这个比较简单,上手快,易实现
- nginx + 服务端 + 前端 js(可以考虑封装成一个通用工具 js)
大致思路
> 前端通过获取版本规则,服务端计算规则
> 命中规则,重新访问页面,nginx 通过版本信息,返回指定版本
> 未命中规则,继续访问当前稳定版本页面
ps: 额外探讨,如果希望服务端接口也能有灰度版本,是不是只需要通过 nginx 配置就能实现?
三、实现细节
1. 版本规则接口
这个规则是可以自己定制的;这里我简单以 userId 进行匹配
- 案例服务端框架:
koa2 + mongoose
/**
* 获取当前用户的版本
* @param {*} ctx
*/
exports.getVersion = async (ctx) => {
try {
const version = ctx.cookies.get("version");
const userId = ctx.query.userId;
// 这里直接写死,也可以放到redis里,做成可以动态配置也行
const inTestList = ["68075c202bbd354b0fcb7a4c"];
const data = inTestList.includes(userId) ? "gray" : "stable";
if (version) {
return ctx.success(
{
version: data,
cache: true,
},
"缓存"
);
} else {
ctx.cookies.set("version", data, { maxAge: 1000 * 60 * 60 * 24 * 7 });
return ctx.success(
{
version: data,
cache: false,
},
"重新计算"
);
}
} catch (error) {
ctx.fail("获取页面记录失败");
console.error("获取页面记录失败:", error);
}
};
- userId 匹配那块,可以引入 redis 做缓存处理,避免直接查询用户表进行比对
2. 前端触发获取版本
- 交互方式,目前我能想到
- 第一种,接口请求完,才开始渲染页面,自动执行指定版本
- 第二种,接口请求、页面渲染同步进行,指定版本由用户触发
// 我把请求版本放到入口首页界面里
// 首次需要登录之后才会执行
onMounted(() => {
const userInfo = store.getters["login/getUserInfo"];
getVesion({ userId: userInfo.id }).then((res) => {
if (!res.cache && res.version === "gray") {
// 这里我增加一个弹窗提示,让用户选择
ElMessageBox.confirm("存在新的灰度版本,是否要体验最新版本?", "新版本", {
confirmButtonText: "立即更新",
cancelButtonText: "不更新",
type: "warning",
}).then(() => {
window.location.reload();
});
}
});
// 页面其他初始化逻辑
});
前端打包控制
- 项目里使用的是
vite
打包工具
- 通过增加两个配置,两者区别在于输入输出不同。
当然如果嫌维护两个配置麻烦,可以把公共相同配置抽离出来或者通过环境变量区分维护一个配置
- 新增一个入口 html 文件,并修改打包输出名称
# vite.gray.config.js
// 修改打包输出名称方便部署
const renameHtmlPlugin = () => {
return {
name: 'html-transform',
enforce: 'post',
generateBundle(options, bundle) {
bundle['gray.html'].fileName = 'index.html'
}
}
}
export default defineConfig({
// ... 其他配置
plugins: [vue(), renameHtmlPlugin()],
build: {
outDir: 'gray',
rollupOptions: {
input: {
main: resolve(__dirname, 'gray.html')
}
}
}
// ...
})
- 命令行部分
"build": "vite build",
"build:gray": "vite build --config vite.gray.config.js",
- 最终打包出来目录
// 灰度版本
-gray -
assests -
index.html -
// 稳定版本
dist -
assests -
index.html;
3. nginx 配置
这里我尝试很久,最终以下配置可以实现
通过 cookie 中版本标识,返回不同版本内容
http {
map $http_cookie $target_dir {
# 精确匹配version值,避免捕获额外内容
"~*version=gray(;|$)" "/gray";
"~*version=stable(;|$)" "/stable";
default "/stable";
}
server {
...已存在...
location / {
root html$target_dir;
try_files $uri $uri/ /index.html;
}
...已存在...
}
}
四、总结
自此一个简单前端灰度效果就实现了。当然这里还有许多的场景没有考虑到,欢迎大家提问探讨。
案例代码:gitee.com/banmaxiaoba… 代码包含一个简易的前端监控方案实现,有空下篇文章分享讨论
来源:juejin.cn/post/7515237104412360756
Wordle:为逗女友而爆火的小游戏
Wordle 的传奇故事
说起 Wordle,这绝对是近几年最火的小游戏之一。2021年,一个叫 Josh Wardle 的程序员为了逗女朋友开心,花了几个晚上做了这个简单的猜词游戏。没想到女朋友玩得很开心,就分享给了朋友,然后朋友又分享给朋友...
结果呢?短短几个月,全世界都在玩 Wordle。Twitter 上到处都是那种绿黄灰的小方块截图,连我妈都问我那些彩色格子是什么意思。
最疯狂的是,Josh 本来只是想做个小游戏玩玩,结果《纽约时报》花了七位数把它买下来。一个周末项目变成了百万美元的生意,这大概是每个程序员的梦想吧。
点击这里先试试:wordless.online
Wordle 为什么这么火?我觉得主要是几个原因:
- 简单易懂:规则五分钟就能学会
- 每天一题:不会让人沉迷,但又让人期待明天的挑战
- 社交属性:那个分享截图的功能太聪明了,不剧透但又能炫耀成绩
- 免费纯净:没有广告,没有内购,就是纯粹的游戏乐趣
Wordle这种游戏的玩法精髓
Wordle 的规则很简单:6次机会猜出5个字母的英文单词。每次猜完会给你颜色提示:
- 绿色:字母对了,位置也对
- 黄色:字母在单词里,但位置不对
- 灰色:这个字母不在单词里
听起来简单,但要玩好还是有技巧的。老玩家都有自己的套路:
开局策略:
大部分人第一个词都会选元音字母多的,比如 "ADIEU"、"AUDIO"、"AROSE"。我个人喜欢用 "STARE",因为 S、T、R 这些字母出现频率很高。
进阶技巧:
- 不要浪费已经确定是灰色的字母
- 如果黄色字母很多,先确定位置再考虑其他字母
- 有时候故意猜一个不可能的词来排除更多字母
心理战术:
Wordle 的单词选择其实是有规律的,不会选太生僻的词,也不会选复数形式。了解这个规律能帮你少走弯路。
Wordless 的独特之处
做 Wordless 的时候,我就在想:Wordle 虽然好玩,但为什么只能是5个字母?为什么一天只能玩一次?
所以 Wordless 就有了这些特色:
可变长度:
从3个字母到8个字母都可以选。3个字母的超简单,适合练手;8个字母的能把人逼疯,适合虐自己。我经常3个字母玩几局找找信心,然后挑战8个字母被打击一下。
无限游戏:
想玩多久玩多久,不用等到明天。有时候猜对了一个难词,兴奋得想继续玩,Wordless 就能满足这种需求。
智能单词库:
不会连续出现相同的单词,每次都是新鲜的挑战。而且按长度分类,保证每个难度级别都有足够的词汇。
策略变化:
不同长度的单词需要不同的策略。3个字母可能就是纯猜测,但8个字母就需要更系统的方法了。
玩 Wordless 的时候,我发现自己的策略会根据单词长度调整:
- 3-4字母:直接猜常见词,比如 "THE"、"AND"
- 5-6字母:用经典的 Wordle 策略
- 7-8字母:先确定元音位置,再慢慢填辅音
其他有趣的变种游戏
Wordle 火了之后,各种变种游戏如雨后春笋般出现。有些真的很有创意:
Absurdle:
这个游戏会故意跟你作对,每次都选择让你最难猜中的单词。有种跟 AI 斗智斗勇的感觉。
Worldle:
猜国家形状,地理爱好者的天堂。我经常被一些小岛国难住。
Heardle:
猜歌名,听前奏猜歌。音乐版的 Wordle,但我这种五音不全的人基本靠蒙。
Nerdle:
数学版 Wordle,猜数学等式。适合数学好的人,我一般看一眼就放弃了。
这些变种游戏都证明了 Wordle 这个核心玩法有多么强大,几乎可以套用到任何领域。
玩法心得分享
玩了这么久的词汇游戏,我总结了几个心得:
不要太执着于完美开局:
很多人纠结第一个词选什么,其实差别没那么大。重要的是根据反馈调整策略。
学会利用排除法:
有时候猜一个明知道不对的词,就是为了排除更多字母,这是高级玩法。
保持词汇积累:
经常玩这类游戏确实能学到新单词,我的英语词汇量就是这么慢慢提升的。
享受过程:
不要太在意成绩,重要的是享受那种一步步接近答案的乐趣。
最后说一句,无论是 Wordle 还是 Wordless,最重要的是玩得开心。毕竟游戏的初衷就是娱乐,不是考试。
现在就玩起来吧:wordless.online
来源:juejin.cn/post/7517860258112028691
URL地址末尾加不加 "/" 有什么区别
作者:程序员成长指北
原文:mp.weixin.qq.com/s/HJ7rXddgd…
在前端开发、SEO 优化、API 调试中,我们经常会遇到一个小细节——URL 结尾到底要不要加 /
?
看似微不足道,实则暗藏坑点。很多人可能用着没出过错,但当项目复杂、页面增多、路径嵌套时,不懂这点可能让你踩大坑。
今天,咱们就花5分钟一次彻底讲透。
先弄清楚:URL 是"目录"还是"资源"?
URL是Uniform Resource Locator(统一资源定位符)缩写,本质上就是互联网上资源的"地址"。
而地址的结尾到底是 /
还是没有 /
,它们背后其实指代的是两种不同的资源类型:
URL 示例 | 意义 | 常见行为 |
---|---|---|
https://myblog.tech/posts/ | 目录 | 默认加载 posts 目录下的 index.html |
https://myblog.tech/about | 具体资源(文件) | 加载 about 这个文件 |
小结:
- 结尾有
/
→ 通常表示是"文件夹" - 没有
/
→ 通常表示是"具体资源(如文件)"
为什么有时候必须加 /
?
1. 相对路径解析完全不同
假设你打开这个页面:
https://mystore.online/products/
页面里有这么一行代码:
<img src="phone.jpg">
👉 浏览器会去请求:
https://mystore.online/products/phone.jpg
✅ 图片加载成功。
但如果你访问的是:
https://mystore.online/products
相同的 <img src="phone.jpg">
会被解析为:
https://mystore.online/phone.jpg
❌ 直接 404,因为浏览器误以为 products
是个文件,而不是目录。
2. 服务器解析的区别
不同服务器(如 Nginx、Apache)的处理行为也会影响是否需要 /
:
情况 | 结果 |
---|---|
访问 https://devnotes.site/blog | 如果 blog 是个目录,服务器可能会 301 重定向 到 https://devnotes.site/blog/ |
访问 https://devnotes.site/blog/ | 直接返回 blog/index.html |
📌 某些老旧或自定义服务器,如果不加 /
,直接返回 404。
是否需要加
/
、是否会返回index.html
、是否发生重定向,完全取决于服务端(如 Nginx)的配置。
3. SEO 有坑:重复内容惩罚
对搜索引擎来说:
是两个不同的 URL。
如果不做规范化,搜索引擎可能会认为你在刷重复内容,影响 SEO 权重。
Google 等搜索引擎确实可能将不同的 URL 视为重复内容(duplicate content),但它们也提供了相应的工具和方法来规范化这些 URL。例如,可以在 robots.txt 或通过 <link rel="canonical" href="...">
来指明规范 URL,以避免 SEO 问题。
✅ 最佳实践:
- 统一加
/
或统一不加/
。 - 用 301 重定向 , 确保网站的所有页面都指向规范的 URL,避免因未做重定向而造成的索引重复问题。
4. RESTful API 请求
API 请求尤其需要小心:
GET https://api.myapp.io/users
和
GET https://api.myapp.io/users/
某些框架(如 Flask、Django、Express)默认对这两种 URL 会有不同的路由匹配。
不一致的 /
很可能导致:
- ❌ 404 Not Found
- ❌ 405 Method Not Allowed
- ❌ 请求结果不同
最好直接查阅 API 文档确认是否敏感。
实用建议
- 前端开发:
- 如果页面中涉及到相对路径引用,建议始终确保 URL 末尾有
/
,以避免路径解析错误。 - 推荐所有目录型地址统一加
/
。
- 如果页面中涉及到相对路径引用,建议始终确保 URL 末尾有
- 服务端配置:
- 确保有清晰的 URL 重定向策略,保持唯一性,避免 SEO 重复。
- API 调用:
- 检查接口文档,看是否对 URL 末尾
/
敏感,不确定就加/
试一试。
- 检查接口文档,看是否对 URL 末尾
总结
URL 末尾是否加斜杠(/
)看似一个小细节,但它会影响网页加载、路径解析、SEO 和 API 请求的行为。
来源:juejin.cn/post/7522989217459896346
void 0 的奥秘:解锁 JavaScript 中 undefined 的正确打开方式
一、 理解 void 0
1.1 什么是 void 运算符?
void
是 JavaScript 中的一个运算符,它接受一个表达式作为操作数,总是返回 undefined,无论操作数是什么。
console.log(void 0); // undefined
console.log(void 1); // undefined
console.log(void "hello"); // undefined
console.log(void {}); // undefined
1.2 为什么使用 void 0 而不是 undefined?
在 ES5 之前,undefined
不是保留字,可以被重写:
// 在ES3环境中可能出现的危险情况
var undefined = "oops";
console.log(undefined); // "oops" 而不是预期的 undefined
void 0
则始终可靠地返回真正的 undefined 值:
var undefined = "oops";
console.log(void 0); // undefined (不受影响)
1.3 现代 JavaScript 还需要 void 0 吗?
ES5 及以后版本中,undefined
是不可写、不可配置的全局属性:
// 现代浏览器中
undefined = "oops";
console.log(undefined); // undefined (不会被修改)
二、void 0 的实用场景
2.1 最小化场景:减少代码体积
void 0
比 undefined
更短,在需要极致压缩代码时很有用:
// 原始代码
function foo(param) {
if (param === undefined) {
param = 'default';
}
}
// 压缩后(使用 void 0)
function foo(n){if(n===void 0)n="default"}
2.2 立即执行函数表达式 (IIFE)
传统 IIFE 写法:
(function() {
// 代码
})();
使用 void
的 IIFE:
void function() {
// 代码
}();
2.3 箭头函数中避免返回值
当需要箭头函数不返回任何值时:
let func = () => {
return new Promise((resolve, reject) => {
setTimeout(resolve(5));
})
};
// 会返回 func 的 Promise
const logData = func();
// 明确不返回值
const logData = void func();
三、常见的 void 0 误区
3.1 与 undefined和null的严格比较
console.log(void 0 === undefined); // true
console.log(void 0 === null); // false
3.2 与 void 其他表达式
let count = 0;
void ++count;
console.log(count); // 1 (表达式仍会执行)
总结
如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript开发干货。
来源:juejin.cn/post/7524504350250762294
也只有JavaScript可以写出这么离谱的代码了吧
今天,有个朋友给我发了一串神秘的字符( (!(~+[]) + {})[--[~+''][+[]] * [~+[]] + ~~!+[]] + ({} + [])[[~!+[]] * ~+[]] ),还要我在控制台打印一下试试
好家伙,原来JavaScrip还能这样玩,那这到底是什么原理呢?
字符串解析
这段代码是一个典型的 JavaScript 混淆代码,通过一系列运算和隐式类型转换来生成字符串。首先我们先解析一下这段字符串,不难发现,这个字符串可以划分为两个部分:
第一部分
(!(
+[]) + {})[--[+''][+[]] * [~+[]] + ~~!+[]]
拆解步骤:
~+[]
+[]
将空数组转换为数字0
,~0
按位取反得到-1
。
[~+''][+[]]
~+''
先将空字符串转为数字0
,再取反得到-1
,即[-1]
。[+[]]
等价于[0]
,因此[-1][0]
得到-1
。
--[~+''][+[]]
即--[-1][0]
,递减后得到-2
。
[~+[]]
和前面的一样,即[-1]
。~~!+[]
+[]
为0
,!0
为true
,~~true
两次取反得到1
。
--[~+''][+[]] * [~+[]] + ~~!+[]
计算:-2 * -1 + 1 = 3
。
!(~+[])
~+[]
为-1
,!(-1)
为false
。
!(~+[]) + {}
false
转为字符串"false"
,与空对象拼接得到"false[object Object]"
。
"false[object Object]"[3]
索引3
对应的字符是's'
。
第二部分
({} + [])[[~!+[]] * ~+[]]
拆解步骤:
({} + [])
空对象转为字符串"[object Object]"
,与空数组相加仍为"[object Object]"
。
~!+[]
+[]
为0
,!0
为true
,~true
按位取反得到-2
。
[~!+[]]
即[-2]
。[~!+[]] * ~+[]
[-2]
转为数字-2
,~+[]
为-1
,计算:-2 * -1 = 2
。
"[object Object]"[2]
索引2
对应的字符是'b'
。
合并结果
将两部分结果拼接:'s' + 'b' = 'sb'
。
核心技巧
- 隐式类型转换
- 数组/对象通过
+
运算转为字符串。 !
、~
、+
等运算符触发类型转换(如+[] → 0
,[]+{} → "[object Object]"
,+{}+[] → "NaN"
)。
- 数组/对象通过
- 按位运算
~
用于生成特定数值(如-1
、-2
)。 - 数组索引
通过计算得到字符串索引(如3
、2
),提取目标字符。
实现一个代码混淆函数
通过对前面那串神秘字符的分析,我们也知道了它的核心思路就是通过JavaScript的隐式类型转换规则对字符进行转换,那么我们是不是可以将我们的代码也都转换成这些字符,来达到一个代码混淆的效果呢?
1、数字转换
- 0:
+[]
将空数组转换为数字0
- 1:
![]
将空数组转为 false ,!![]
再次取反得到 true ,+!![]
+号让true隐式转换为1 - 其他数字 都可以通过1和0进行加减乘除或拼接来得到
{
0: "+[]",
1: "+!![]",
2: "!![]+!![]",
3: "!![]+!![]+!![]",
4: "(!![]+!![]) * (!![]+!![])",
5: "(!![]+!![]) * (!![]+!![]) + !![]",
6: "(!![]+!![]) * (!![]+!![]+!![])",
7: "(!![]+!![]) * (!![]+!![]+!![]) + !![]",
8: "(!![]+!![]) ** (!![]+!![]+!![])",
9: "(!![]+!![]+!![]) ** (!![]+!![])",
}
2、字母转换
- undefined
([][[]]+[]) 相当于 [][0]+'' ,可以得到字符串 undefined
- false
(![]+[]) 相当于 !true+'' ,可以得到字符串 false
- true
(!![]+[]) 相当于 !!true+'' ,可以得到字符串 true
- [object Object]
({} + []) ,空对象转为字符串 "[object Object]"
,与空数组相加仍为 "[object Object]"
。
- NaN
(+{}+[]) ,{}会被隐式转为数字类型,对象无法被解析成有效数字,所以会返回 NaN
- constructor
通过前面转换的字符串,我们可以拼接成完整的 constructor 字符串。
我们可以通过构造器来获取到更多的字符,比如:
3、其他字符
通过前面的方法我们就可以将大部分的字母都获取到了,但是还有部分字母获取不到,那么剩下的字母就和其他字符一样都可以通过下面这个方式来获取:
比如字母 U:
ASCII 码 U → 八进制转十进制 85 → 转义为 \125,那么我们可以直接这样获得字母 U
所以我们只需要想办法得到函数构造器即可,还是要用到前面提到过的 constructor ,我们知道数组有很多内置的函数,比如 at:
那么 at 方法的构造器就是一个函数,我们直接通过 constructor 就可以获取到一个函数构造器
这里用到的字母都可以通过简单的转换得到,把字母通过前面的方法转换替换掉就可以了
好了,到这里你就实现了一个简单的 JSFuck 了~
体验地址
源码
组件源码已开源到gitee,有兴趣的也可以到这里看看:gitee.com/zheng_yongt…
- 🌟觉得有帮助的可以点个star~
- 🖊有什么问题或错误可以指出,欢迎pr~
- 📬有什么想要实现的组件或想法可以联系我~
公众号
关注公众号『 前端也能这么有趣 』,获取更多有趣内容。
发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『
前端也能这么有趣
』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。
来源:juejin.cn/post/7503846429082468389
在线人数实时推送?WebSocket 太重,SSE 正合适 🎯🎯🎯
有些项目要统计在线人数,其实更多是为了“营造热闹气氛”。比如你进个聊天室,看到“有 120 人在看”,是不是感觉这个地方挺活跃的?这就是一种“社交证明”,让用户觉得:哇,这个地方挺火,值得留下来。而且对产品来说,这也能提高用户的参与感和粘性。
有哪些实现方式?为啥我最后选了 SSE?
在考虑怎么实现“统计在线人数并实时显示”这个功能时,其实我一开始也没直接想到要用 SSE。毕竟实现方式有好几种,咱们不妨一步步分析一下常见的几种做法,看看它们各自的优缺点,这样最后为啥选 SSE,自然就水落石出了。
❌ 第一种想法:轮询(Polling)
最容易想到的方式就是:我定时去问服务器,“现在有多少人在线?”
比如用 setInterval
每隔 3 秒发一次 AJAX 请求,服务器返回一个数字,前端拿到之后更新页面。
听起来没毛病,对吧?确实简单,写几行代码就能跑起来。
但问题也很快暴露了:
- 就算在线人数 10 分钟都没变,客户端也在一直请求,完全没必要,非常低效
- 这种方式根本做不到真正的“实时”,除非你每秒钟请求一次(但那样服务器压力就爆炸了)
- 每个用户都发请求,这压力不是乘以用户数么?人一多,服务器直接“变卡”
所以轮询虽然简单,但在“实时在线人数”这种场景下,不管性能、实时性还是用户体验,都不够理想。
❌ 第二种方案:WebSocket
再往上一个层级,很多人就会想到 WebSocket,这是一个可以实现双向通信的技术,听起来非常高级。
确实,WebSocket 特别适合聊天室、游戏、协同编辑器这种实时互动场景——客户端和服务端随时可以互相发消息,效率高、延迟低。
但问题也来了:我们真的需要那么重的武器吗?
- 我只是要服务器把“当前在线人数”这个数字发给页面,不需要客户端发什么消息回去
- WebSocket 的连接、心跳、断线重连、资源管理……这套机制确实强大,但同时也让开发复杂度和服务器资源占用都提高了不少
- 而且你要部署 WebSocket 服务的话,很多时候还得考虑反向代理支持、跨域、协议升级等问题
总结一句话:WebSocket 能干的活太多,反而不适合干这么简单的一件事。
✅ 最后选择:SSE(Server-Sent Events)
然后我就想到了 SSE。
SSE 是 HTML5 提供的一个非常适合“服务端单向推送消息”的方案,浏览器用 EventSource
这个对象就能轻松建立连接,服务端只需要按照特定格式往客户端写数据就能实时推送,非常简单、非常轻量。
对于“统计在线人数”这种场景来说,它刚好满足所有需求:
- 客户端不需要发消息,只要能“听消息”就够了 —— SSE 就是只读的推送流,正合适
- 我只需要服务端一有变化(比如某个用户断开连接),就通知所有人当前在线人数是多少 —— SSE 的广播机制就很好实现这一点
- 而且浏览器断线后会自动重连,你不需要写额外的心跳或者重连逻辑,直接爽用
- 它用的是普通的 HTTP 协议,部署和 Nginx 配合也没啥问题
当然它也不是没有缺点,比如 IE 不支持(但现在谁还用 IE 啊),以及它是单向通信(不过我们压根也不需要双向)。
所以综合来看,SSE 就是这个功能的“刚刚好”方案:轻量、简单、稳定、足够用。
项目实战
首先我们先贴上后端的代码,后端我们使用的是 NextJs 提供的 API 来实现的后端接口,首先我们先来看看我们的辅助方法:
// 单例模式实现的在线用户计数器
// 使用Symbol确保私有属性
const _connections = Symbol("connections");
const _clients = Symbol("clients");
const _lastCleanup = Symbol("lastCleanup");
const _maxInactiveTime = Symbol("maxInactiveTime");
const _connectionTimes = Symbol("connectionTimes");
// 创建一个单例计数器
class ConnectionCounter {
private static instance: ConnectionCounter;
private [_connections]: number = 0;
private [_clients]: Set<(count: number) => void> = new Set();
private [_lastCleanup]: number = Date.now();
// 默认10分钟未活动的连接将被清理
private [_maxInactiveTime]: number = 10 * 60 * 1000;
// 跟踪连接ID和它们的最后活动时间
private [_connectionTimes]: Map<string, number> = new Map();
private constructor() {
// 防止外部直接实例化
}
// 获取单例实例
public static getInstance(): ConnectionCounter {
if (!ConnectionCounter.instance) {
ConnectionCounter.instance = new ConnectionCounter();
}
return ConnectionCounter.instance;
}
// 生成唯一连接ID
generateConnectionId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
// 添加新连接
addConnection(connectionId: string): number {
this[_connectionTimes].set(connectionId, Date.now());
this[_connections]++;
this.broadcastCount();
// 如果活跃连接超过100或上次清理已经超过5分钟,执行清理
if (
this[_connectionTimes].size > 100 ||
Date.now() - this[_lastCleanup] > 5 * 60 * 1000
) {
this.cleanupStaleConnections();
}
return this[_connections];
}
// 移除连接
removeConnection(connectionId: string): number {
// 如果连接ID存在则移除
if (this[_connectionTimes].has(connectionId)) {
this[_connectionTimes].delete(connectionId);
this[_connections] = Math.max(0, this[_connections] - 1);
this.broadcastCount();
}
return this[_connections];
}
// 更新连接的活动时间
updateConnectionActivity(connectionId: string): void {
if (this[_connectionTimes].has(connectionId)) {
this[_connectionTimes].set(connectionId, Date.now());
}
}
// 清理长时间不活跃的连接
cleanupStaleConnections(): void {
const now = Date.now();
this[_lastCleanup] = now;
let removedCount = 0;
this[_connectionTimes].forEach((lastActive, connectionId) => {
if (now - lastActive > this[_maxInactiveTime]) {
this[_connectionTimes].delete(connectionId);
removedCount++;
}
});
if (removedCount > 0) {
this[_connections] = Math.max(0, this[_connections] - removedCount);
this.broadcastCount();
console.log(`Cleaned up ${removedCount} stale connections`);
}
}
// 获取当前连接数
getConnectionCount(): number {
return this[_connections];
}
// 订阅计数更新
subscribeToUpdates(callback: (count: number) => void): () => void {
this[_clients].add(callback);
// 立即返回当前计数
callback(this[_connections]);
// 返回取消订阅函数
return () => {
this[_clients].delete(callback);
};
}
// 广播计数更新到所有客户端
private broadcastCount(): void {
this[_clients].forEach((callback) => {
try {
callback(this[_connections]);
} catch (e) {
// 如果回调失败,从集合中移除
this[_clients].delete(callback);
}
});
}
}
// 导出便捷函数
const counter = ConnectionCounter.getInstance();
export function createConnection(): string {
const connectionId = counter.generateConnectionId();
counter.addConnection(connectionId);
return connectionId;
}
export function closeConnection(connectionId: string): number {
return counter.removeConnection(connectionId);
}
export function pingConnection(connectionId: string): void {
counter.updateConnectionActivity(connectionId);
}
export function getConnectionCount(): number {
return counter.getConnectionCount();
}
export function subscribeToCountUpdates(
callback: (count: number) => void
): () => void {
return counter.subscribeToUpdates(callback);
}
// 导出实例供直接使用
export const connectionCounter = counter;
这段代码其实就是做了一件事:统计当前有多少个用户在线,而且可以实时推送到前端。我们用了一个“单例”模式,也就是整个服务里只有一个 ConnectionCounter
实例,避免多人连接时出现数据错乱。每当有新用户连上 SSE 的时候,就会生成一个唯一的连接 ID,然后调用 createConnection()
把它加进来,在线人数就 +1。
这些连接 ID 都会被记录下来,还会记住“最后活跃时间”。如果用户一直在线,我们就可以通过前端发个心跳(pingConnection()
)来告诉后端“我还在哦”。断开连接的时候(比如用户关闭了页面),我们就通过 closeConnection()
把它移除,人数就 -1。
为了防止有些用户没正常断开(比如突然关机了),代码里还有一个“自动清理机制”,默认 10 分钟没动静的连接就会被清理掉。每次人数变化的时候,这个计数器会“广播”一下,通知所有订阅它的人说:“嘿,在线人数变啦!”
而这个订阅机制(subscribeToCountUpdates()
)特别关键——它可以让我们在 SSE 里实时推送人数更新,前端只要监听着,就能第一时间看到最新的在线人数。我们还把常用的操作都封装好了,比如 createConnection()
、getConnectionCount()
等,让整个流程特别容易集成。
总结一下:这段逻辑就是 自动统计在线人数 + 自动清理无效连接 + 实时推送更新
接下来我们编写后端 SSE 接口,如下代码所示:
import {
createConnection,
closeConnection,
pingConnection,
subscribeToCountUpdates,
} from "./counter";
export async function GET() {
// 标记连接是否仍然有效
let connectionClosed = false;
// 为此连接生成唯一ID
const connectionId = createConnection();
// 当前连接的计数更新回调
let countUpdateUnsubscribe: (() => void) | null = null;
// 使用Next.js的流式响应处理
return new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// 安全发送数据函数
const safeEnqueue = (data: string) => {
if (connectionClosed) return;
try {
controller.enqueue(encoder.encode(data));
} catch (error) {
console.error("SSE发送错误:", error);
connectionClosed = true;
cleanup();
}
};
// 定义interval引用
let heartbeatInterval: NodeJS.Timeout | null = null;
let activityPingInterval: NodeJS.Timeout | null = null;
// 订阅在线用户计数更新
countUpdateUnsubscribe = subscribeToCountUpdates((count) => {
if (!connectionClosed) {
try {
safeEnqueue(
`data: ${JSON.stringify({ onlineUsers: count })}\n\n`
);
} catch (error) {
console.error("发送在线用户数据错误:", error);
}
}
});
// 清理所有资源
const cleanup = () => {
if (heartbeatInterval) clearInterval(heartbeatInterval);
if (activityPingInterval) clearInterval(activityPingInterval);
// 取消订阅计数更新
if (countUpdateUnsubscribe) {
countUpdateUnsubscribe();
countUpdateUnsubscribe = null;
}
// 如果连接尚未计数为关闭,则减少连接计数
if (!connectionClosed) {
closeConnection(connectionId);
connectionClosed = true;
}
// 尝试安全关闭控制器
try {
controller.close();
} catch (e) {
// 忽略关闭时的错误
}
};
// 设置15秒的心跳间隔,避免连接超时
heartbeatInterval = setInterval(() => {
if (connectionClosed) {
cleanup();
return;
}
safeEnqueue(": heartbeat\n\n");
}, 15000);
// 每分钟更新一次连接活动时间
activityPingInterval = setInterval(() => {
if (connectionClosed) {
cleanup();
return;
}
pingConnection(connectionId);
}, 60000);
},
cancel() {
// 当流被取消时调用(客户端断开连接)
if (!connectionClosed) {
closeConnection(connectionId);
connectionClosed = true;
if (countUpdateUnsubscribe) {
countUpdateUnsubscribe();
}
}
},
}),
{
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Accel-Buffering": "no", // 适用于某些代理服务器如Nginx
},
}
);
}
这段代码是一个 Next.js 的 API 路由,用来建立一个 SSE 长连接,并把“当前在线人数”实时推送给客户端。
第一步就是建立连接并注册计数当客户端发起请求时,后端会:
- 调用
createConnection()
生成一个唯一的连接 ID; - 把这次连接计入在线用户总数里;
- 并返回一个
ReadableStream
,让服务端能不断往客户端“推送消息”。
第二步就是订阅在线人数变化,一旦连接建立,服务端就调用 subscribeToCountUpdates()
,订阅在线人数的变化。一旦总人数发生变化,它就会通过 SSE 推送这样的数据给前端:
data: {
onlineUsers: 23;
}
也就是说,每次有人连上或断开,所有前端都会收到更新,非常适合“在线人数展示”。
第三步就是定期心跳和活跃检测:
- 每 15 秒服务端会发一个
: heartbeat
,保持连接不断开; - 每 60 秒调用
pingConnection()
,告诉后台“我还活着”,防止被误判为不活跃连接而清理。
第四步是清理逻辑,当连接被取消(比如用户关闭页面)或出错时,后台会:
- 调用
closeConnection()
把这条连接从统计中移除; - 取消掉在线人数的订阅;
- 停掉心跳和活跃检测定时器;
- 安全关闭这个数据流。
这个清理逻辑保证了数据准确、资源不浪费,不会出现“人数不减”或“内存泄露”。
最后总结一下,这段代码实现了一个完整的“谁连接我就+1,谁断开我就-1,然后实时广播当前在线人数”的机制。你只要在前端用 EventSource
接收这条 SSE 流,就能看到用户数量实时跳动,非常适合用在聊天室、控制台、直播页面等场景。
目前后端代码我们是编写完成了,我们来实现一个前端页面来实现这个功能来对接这个接口:
"use client";
import React, { useState, useEffect, useRef } from "react";
export default function OnlineCounter() {
const [onlineUsers, setOnlineUsers] = useState(0);
const [connected, setConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
// 创建SSE连接
const connectSSE = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
try {
const eventSource = new EventSource(`/api/sse?t=${Date.now()}`);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setConnected(true);
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// 只处理在线用户数
if (data.onlineUsers !== undefined) {
setOnlineUsers(data.onlineUsers);
}
} catch (error) {
console.error("解析数据失败:", error);
}
};
eventSource.onerror = (error) => {
console.error("SSE连接错误:", error);
setConnected(false);
eventSource.close();
// 5秒后尝试重新连接
setTimeout(connectSSE, 5000);
};
} catch (error) {
console.error("创建SSE连接失败:", error);
setTimeout(connectSSE, 5000);
}
};
connectSSE();
// 组件卸载时清理
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, []);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-900 to-slate-800 text-white flex flex-col items-center justify-center p-4">
<div className="bg-slate-800 rounded-xl shadow-2xl overflow-hidden max-w-sm w-full">
<div className="p-6">
<h1 className="text-3xl font-bold text-center text-blue-400 mb-6">
在线用户统计
h1>
<div className="flex items-center justify-center mb-4">
<div
className={`h-3 w-3 rounded-full mr-2 ${
connected ? "bg-green-500" : "bg-red-500"
}`}
>div>
<p className="text-sm text-slate-300">
{connected ? "已连接" : "连接断开"}
p>
div>
<div className="flex items-center justify-center bg-slate-700 rounded-lg p-8 mt-4">
<div className="flex flex-col items-center">
<div className="text-6xl font-bold text-green-400 mb-2">
{onlineUsers}
div>
<div className="flex items-center">
<svg
className="w-5 h-5 text-green-400 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" />
svg>
<span className="text-lg font-medium text-green-300">
在线用户
span>
div>
div>
div>
div>
div>
div>
);
}
最终输出结果如下图所示:
总结
SSE 实现在线人数统计可以说是简单高效又刚刚好的选择:它支持服务端单向推送,客户端只用监听就能实时获取在线人数更新,不用自己轮询。相比 WebSocket 来说,SSE 更轻量,部署起来也更方便。我们还通过心跳机制和活跃时间管理,保证了数据准确、连接稳定。整体来说,功能对得上,性能扛得住,代码写起来也不费劲,是非常适合这个场景的一种实现方式。
技术方式 | 实时性 | 实现难度 | 性能消耗 | 适不适合这个功能 | 备注 |
---|---|---|---|---|---|
轮询 | ★★☆☆☆ | ★☆☆☆☆(最简单) | ★☆☆☆☆(浪费) | ❌ 不推荐 | 太低效了 |
WebSocket | ★★★★★ | ★★★★☆(较复杂) | ★★★☆☆(重型) | ❌ 不合适 | 太强大、太复杂 |
SSE | ★★★★☆ | ★★☆☆☆(非常容易上手) | ★★★★☆(轻量) | ✅ 非常适合 | 简单好用又高效 |
来源:juejin.cn/post/7492640608206487562
甲方嫌弃,项目首页加载太慢
有一天,甲方打开一个后台管理的项目,说有点卡,不太满意,项目经理叫我优化,重新打包一下。
从输入地址 到 展示 首屏,最佳时间在 3秒内,否则,甲方挂脸,咱就有可能有被裁的风险,understand?
废话不多说,先来看一下怎么个优化法吧。
优化
✅ cdn
分析
用Webpack Bundle Analyzer分析依赖,安装webpack-bundle-analyzer打包分析插件:
# NPM
npm install --save-dev webpack-bundle-analyzer
# Yarn
yarn add -D webpack-bundle-analyzer
反正都是装,看着来。
配一下:
// vue.config.js 文件里。(没有就要新建一下)
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
打包
执行打包命令并查看分析
npm run build --report
打包结束后,会在项目根目录下生成dist文件。自动跳到127.0.0.1:8888
(没有跳的话,手动打开dist文件夹下的report.html),这个网址就是打包分析报告
。
占得比较大的块,就是Element UI组件库和echarts库占的空间比相对较大。
这就要考虑,第一,要按需,要啥再用啥,不要一股脑啥都装。按需安装,按需加载。
第二,考虑单独引入这些组件库的cdn
,这样速度也会咔咔提升。
详细讲一下怎么搞cdn
。
按需大家都知道,要啥再引入啥,再装啥。
比如element-ui
,我要uninstall
掉,然后呢,去引入cdn,不要装库了,用cdn。
去package.json
里面看element-ui
装了啥版本,然后看完之后,就npm uninstall element-ui
卸载掉。
去cdn库里面去找https://www.staticfile.org/
,(首先先说一下,要找免费的开放的那种,因为一般有的公司没有自家的cdn,没有自家的桶,有的话,直接把js文件地址拖上去,然后得到一个地址,这样也安全,也方便,但没有的话另说)。
样式库: https://cdn.staticfile.org/element-ui/2.15.12/theme-chalk/index.min.css
组件库:https://cdn.staticfile.org/element-ui/2.15.12/index.min.js
然后去public/index.html
入口文件中,去加入这个东西,像咱以前写原生一样引入就好,body里面引入js,head里面引入css。:
<head>
<link rel="stylesheet" href="https://cdn.staticfile.org/element-ui/2.15.12/theme-chalk/index.min.css">
</head>
<body>
<script src="https://cdn.staticfile.org/element-ui/2.15.12/index.min.js"></script>
</body>
所以这样子,就引入好了。接着在main.js
里面,把之前import
的所有element
的样式删掉。
接着,vue.config.js
的configureWebpack
加个externals
字段:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
],
externals: {
'element-ui': 'ELEMENT' // key 是之前install下下来的包名,element-ui。value是全局变量名(ELEMENT)
}
}
externals: Webpack 的 externals
配置用于声明某些依赖应该从外部获取,而不是打包到最终的 bundle 中。这样可以减小打包体积,前提是这些依赖已经在运行环境中存在。
'element-ui': 'ELEMENT'
的含义
- 当你的代码中
import 'element-ui'
时,Webpack 不会打包element-ui
,而是会从全局变量ELEMENT
中获取它。 ELEMENT
是element-ui
库通过<script>
标签引入时,在全局(window
)中暴露的变量名。
例如,如果你在 HTML 中这样引入:
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
那么element-ui
会挂载到window.ELEMENT
上。
为什么这样配置?
- 通常是为了通过 CDN 引入
element-ui
(而不是打包它),从而优化构建速度和体积。 - 你需要确保在 HTML 中通过
<script>
提前加载了element-ui
,否则运行时ELEMENT
会是undefined
。
<!-- HTML 中通过 CDN 引入 element-ui -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
// webpack.config.js
module.exports = {
externals: {
'element-ui': 'ELEMENT' // 告诉 Webpack:import 'element-ui' 时,返回全局的 ELEMENT
}
};
// 你的代码中依然可以正常 import(但实际用的是全局变量)
import ElementUI from 'element-ui';
// 相当于:const ElementUI = window.ELEMENT;
注意事项:
- 确保全局变量名(
ELEMENT
)和element-ui
的 CDN 版本一致。不同版本的库可能有不同的全局变量名。 - 如果使用模块化打包(如 npm + Webpack 全量打包),则不需要配置
externals
。
这里有的伙伴就说,我咋知道是ELEMENT
,而不是element
呢。
这里是这么找的:
直接在浏览器控制台检查
在 HTML 中通过 CDN 引入该库:
<script src="https://cdn.staticfile.org/element-ui/2.15.12/index.min.js"></script>
打开浏览器开发者工具(F12),在 Console 中输入:
console.log(window);
然后查找可能的全局变量名(如 ELEMENT、ElementUI 等)。
cdn配置之后,重新分析
npm run build --report
重新用cdn
的去分析,
那么就很舒服了,也因此,这个就是cdn优化的方法。
✅ nginx gzip压缩
server {
listen 8103;
server_name ************;
# 开启gzip
gzip on;
# 进行压缩的文件类型。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
}
✅vue gzip压
安包:npm i compression-webpack-plugin@1.1.12 --save-dev
注意版本匹配问题。
vue配置,这段配置是 Webpack 构建中关于 Gzip 压缩 的设置,位于 config/index.js
文件中。:
//文件路径 config --> index.js
build: {
productionGzip: true, // 启用生产环境的 Gzip 压缩
productionGzipExtensions: ['js', 'css'], // 需要压缩的文件类型
}
productionGzip: true
- 作用:开启 Gzip 压缩,减少静态资源(JS、CSS)的体积,提升页面加载速度。
- 要求:需要安装
compression-webpack-plugin
(如注释所述)。
npm install --save-dev compression-webpack-plugin
productionGzipExtensions: ['js', 'css']
- 指定需要压缩的文件扩展名(默认压缩 JS 和 CSS 文件)。
为什么需要 Gzip?
- 优化性能:Gzip 压缩后的资源体积可减少 60%~70%,显著降低网络传输时间。
- 服务器支持:大多数现代服务器(如 Nginx、Netlify)会自动对静态资源进行 Gzip 压缩,但本地构建时提前生成
.gz
文件可以避免服务器实时压缩的开销。
✅ 按需加载路由
路由级代码分割(动态导入)
// 原写法
import About from './views/About.vue'
// 优化后写法
const About = () => import(/* webpackChunkName: "about" */ './views/About.vue')
- 首页只加载核心代码(home路由)
- about模块会在用户点击about路由时才加载
- 显著减少首屏加载资源体积
✅ 合理配置 prefetch策略
// vue.config.js
module.exports = {
chainWebpack: config => {
// 移除prefetch插件
config.plugins.delete('prefetch')
// 或者更精细控制
config.plugin('prefetch').tap(options => {
options[0].fileBlacklist = options[0].fileBlacklist || []
options[0].fileBlacklist.push(/myasyncRoute(.)+?\.js$/)
return options
})
}
}
- 禁用prefetch:减少不必要的带宽消耗,但可能增加后续路由切换等待时间
- 启用prefetch:利用浏览器空闲时间预加载,提升用户体验但可能浪费带宽
- 折中方案:只对关键路由或高概率访问的路由启用prefetch
✅ splitChunks 将node_modules中的依赖单独打包
拆分vendor:将node_modules中的依赖单独打包
config.optimization.splitChunks({
chunks: 'all',
cacheGr0ups: {
vendors: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial'
}
}
})
✅ 按需引入 lodash
import debounce from 'lodash/debounce'
用啥再引啥。
甲方笑了
打开首页闪电一进,完美ending!!!
散会啦😊
来源:juejin.cn/post/7514310580720517147
性能优化,前端能做的不多
大家好,我是双越老师,也是 wangEditor 作者。
我正开发一个 Node 全栈 AIGC 知识库 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线,大家可以去注册试用,围观项目研发过程。
关于前端性能优化的困惑
前端性能优化是前度面试常考的问题,答案说来复杂,其实总结下来就是:减少或拆分资源 + 异步加载 现在都搞成八股文了,面试之前背诵一下。
还有缓存。
但纯前端的缓存其实作用不大,使用场景也不多,例如 Vue computed keep-alive,React useMemo 在绝大部分场景下都用不到。虽然面试的时候我们得说出来。
HTTP 缓存是网络层面的,操作系统和浏览器实现的,并不是前端实现的。虽然作为前端工程师要掌握这些知识,但大多数情况下并不需要自己亲自去配置。
实际工作中,其实绝大部分前端项目,代码量都没有非常大,不拆分代码,不异步加载也不会有太大的影响。
我这两年 1v1 评审过很多前端简历,大家都会有这样一个困惑:问到前端性能优化,感觉工作几年了,也没做什么优化,而且也想不出前端能做什么优化。
其实这是正常的,绝大部分前端项目、前端工作都不需要优化,而且从全栈角度看,前端能做的优化也很有限。
但面试是是面试,工作是工作。面试造火箭,工作拧螺丝,这个大家都知道,不要为此较真。
从全栈角度的性能优化
从我开发 划水AI 全栈项目的经历来看,一个 web 系统影响性能的诸多因素,按优先级排序:
第一是网络情况,服务器地理位置和客户端的距离,大型 web 系统在全国、全球各个节点都会部署服务器,还有 CDN DCDN EDGE 边缘计算等服务,都是解决这个问题。
第二是服务端的延迟,最主要的是 I/O 延迟,例如查询数据库、第三方服务等。
第三是 SSR 服务端渲染,一次性返回内容,尽量减少网络情况。
第四才是纯前端性能优化,使用 loading 提示、异步加载等。其实到了纯前端应该更多是体验优化,而不是性能优化。
网络
网络是最重要的一个因素,也是我们最不易察觉的因素,尤其初学者,甚至都没有独立发布上线过项目。
划水AI 之前部署在海外服务器,使用 Sentry 性能监控,TTFB 都超过 2s, FCP 接近 3s ,性能非常糟糕。
原因也很明显,代码无论如何优化,TTFB 时间慢是绕不过去的,这是网络层面的。
还有,之前 CDN 也是部署在香港的,使用站长工具做测试,会发现国内访问速度非常慢。
文档的多人协同编辑,之前总是不稳定重新连接。我之前一直以为是代码哪里写错了,一直没找到原因,后来发现是网络不稳定的问题。因为协同编辑服务当时是部署在亚马逊 AWS 新加坡的服务器。
这两天我刚刚把 划水AI 服务迁移到国内,访问速度从感知上就不一样了,又快又稳定。具体的数据我还在跟踪中,需要持续跟踪几天,过几天统计出来再分享。
服务端响应速度
首先是数据库查询速度,这是最常见的瓶颈。后端程序员都要熟练各种数据库的优化手段,前端不一定熟练,但要知道有这么回事。
现在 划水AI 数据库用的是 Supabase 服务,是海外服务器。国内目前还没有类似的可替代服务,所以暂时还不能迁移。
所以每次切换文档,都会有 1s 左右的 loading 时间,体验上也说的过去。比用之前的 AWS 新加坡服务器要快了很多。
其次是第三方服务的速度,例如 AI 服务的接口响应速度,有时候会比较慢,需要等待 3s 以上。
但 deepseek 网页版也挺慢的,也得 loading 2-3s 时间。ChatGPT 倒是挺快,但我们得用中转服务,这一中转又慢了。
还有例如登录时 GitHub 验证、发送邮件验证等,这个目前也不快,接下来我会考虑改用短信验证码的方式来登录。
第三方服务的问题是最无解的。
SSR 服务端渲染
服务端获取数据,直接给出结果,或者判断跳转页面(返回 302),而不是前端 ajax 获取数据再做判断。
后者再如何优化,也会比前者多一步网络请求,50-100ms 是少不了的。前端压缩、拆分多少资源也填不上这个坑。
纯前端性能优化
面试中常说的性能优化方式,如 JS 代码拆分、异步组件、路由懒加载等,可能减少的就是几十 KB 的数据量,而这几十 KB 在现代网络速度和 CDN 几乎感知不出来。
而且还有 HTTP 缓存,可能第一次访问时候可能慢一点点,再次访问时静态资源使用缓存,就不会再影响速度。
最后还有压缩,网络请求通常采用 GZIP 压缩,可把资源体积压缩到 1/3 大小。例如,你把 JS 减少了 100kb,看似优化了很多,但实际在网络传输的时候压缩完只有 30kb ,还不如一个图片体积大。
而有时候为了这些优化反而把 webpack 或 vite 配置的乱七八糟的,反而增加了代码复杂度,容易出问题。
但前端可以做很多体验优化的事情,例如使用 loading 效果和图片懒加载,虽然速度还一样,但用户体验会更好,使用更流畅。这也是有很大产品价值的。
最后
一个 web 系统的性能瓶颈很少会出现在前端,前端资源的速度问题在网络层面很好解决。所以希望每一位前端开发都要有全栈思维,能站在全栈的角度去思考问题和解决问题。
有兴趣的同学可关注 划水AI 项目,Node 全栈 AIGC 知识库,复杂项目,真实上线。
来源:juejin.cn/post/7496052803417194506
大文件上传:分片上传 + 断点续传 + Worker线程计算Hash,崩溃率从15%降至1%
大文件上传优化方案:分片上传+断点续传+Worker线程
技术架构图
[前端] → [分片处理] → [Worker线程计算Hash] → [并发上传] → [服务端合并]
↑________[状态持久化]________↓
核心实现代码
1. 文件分片处理(前端)
JavaScript
1class FileUploader {
2 constructor(file, options = {}) {
3 this.file = file;
4 this.chunkSize = options.chunkSize || 5 * 1024 * 1024; // 默认5MB
5 this.threads = options.threads || 3; // 并发数
6 this.chunks = Math.ceil(file.size / this.chunkSize);
7 this.uploadedChunks = new Set();
8 this.fileHash = '';
9 this.taskId = this.generateTaskId();
10 }
11
12 async start() {
13 // 1. 计算文件哈希(Worker线程)
14 this.fileHash = await this.calculateHash();
15
16 // 2. 检查服务端是否已有该文件(秒传)
17 if (await this.checkFileExists()) {
18 return { success: true, skipped: true };
19 }
20
21 // 3. 获取已上传分片信息
22 await this.fetchProgress();
23
24 // 4. 开始分片上传
25 return this.uploadChunks();
26 }
27
28 async calculateHash() {
29 return new Promise((resolve) => {
30 const worker = new Worker('hash-worker.js');
31 worker.postMessage({ file: this.file });
32
33 worker.onmessage = (e) => {
34 if (e.data.progress) {
35 this.updateProgress(e.data.progress);
36 } else {
37 resolve(e.data.hash);
38 }
39 };
40 });
41 }
42}
2. Web Worker计算Hash(hash-worker.js)
JavaScript
1self.importScripts('spark-md5.min.js');
2
3self.onmessage = async (e) => {
4 const file = e.data.file;
5 const chunkSize = 2 * 1024 * 1024; // 2MB切片计算
6 const chunks = Math.ceil(file.size / chunkSize);
7 const spark = new self.SparkMD5.ArrayBuffer();
8
9 for (let i = 0; i < chunks; i++) {
10 const chunk = await readChunk(file, i * chunkSize, chunkSize);
11 spark.append(chunk);
12 self.postMessage({ progress: (i + 1) / chunks });
13 }
14
15 self.postMessage({ hash: spark.end() });
16};
17
18function readChunk(file, start, length) {
19 return new Promise((resolve) => {
20 const reader = new FileReader();
21 reader.onload = (e) => resolve(e.target.result);
22 reader.readAsArrayBuffer(file.slice(start, start + length));
23 });
24}
3. 断点续传实现
JavaScript
1class FileUploader {
2 // ...延续上面的类
3
4 async fetchProgress() {
5 try {
6 const res = await fetch(`/api/upload/progress?hash=${this.fileHash}`);
7 const data = await res.json();
8 data.uploadedChunks.forEach(chunk => this.uploadedChunks.add(chunk));
9 } catch (e) {
10 console.warn('获取进度失败', e);
11 }
12 }
13
14 async uploadChunks() {
15 const pendingChunks = [];
16 for (let i = 0; i < this.chunks; i++) {
17 if (!this.uploadedChunks.has(i)) {
18 pendingChunks.push(i);
19 }
20 }
21
22 // 并发控制
23 const pool = [];
24 while (pendingChunks.length > 0) {
25 const chunkIndex = pendingChunks.shift();
26 const task = this.uploadChunk(chunkIndex)
27 .then(() => {
28 pool.splice(pool.indexOf(task), 1);
29 });
30 pool.push(task);
31
32 if (pool.length >= this.threads) {
33 await Promise.race(pool);
34 }
35 }
36
37 await Promise.all(pool);
38 return this.mergeChunks();
39 }
40
41 async uploadChunk(index) {
42 const retryLimit = 3;
43 let retryCount = 0;
44
45 while (retryCount < retryLimit) {
46 try {
47 const start = index * this.chunkSize;
48 const end = Math.min(start + this.chunkSize, this.file.size);
49 const chunk = this.file.slice(start, end);
50
51 const formData = new FormData();
52 formData.append('chunk', chunk);
53 formData.append('chunkIndex', index);
54 formData.append('totalChunks', this.chunks);
55 formData.append('fileHash', this.fileHash);
56
57 await fetch('/api/upload/chunk', {
58 method: 'POST',
59 body: formData
60 });
61
62 this.uploadedChunks.add(index);
63 this.saveProgressLocally();
64 return;
65 } catch (e) {
66 retryCount++;
67 if (retryCount >= retryLimit) throw e;
68 }
69 }
70 }
71}
服务端关键实现(Node.js示例)
1. 分片上传处理
JavaScript
1router.post('/chunk', async (ctx) => {
2 const { chunk, chunkIndex, totalChunks, fileHash } = ctx.request.body;
3
4 // 存储分片
5 const chunkDir = path.join(uploadDir, fileHash);
6 await fs.ensureDir(chunkDir);
7 await fs.move(chunk.path, path.join(chunkDir, chunkIndex));
8
9 // 记录上传进度
10 await redis.sadd(`upload:${fileHash}`, chunkIndex);
11
12 ctx.body = { success: true };
13});
2. 分片合并
JavaScript
1router.post('/merge', async (ctx) => {
2 const { filename, fileHash, totalChunks } = ctx.request.body;
3 const chunkDir = path.join(uploadDir, fileHash);
4
5 // 检查所有分片是否已上传
6 const uploaded = await redis.scard(`upload:${fileHash}`);
7 if (uploaded !== totalChunks) {
8 ctx.throw(400, '分片不完整');
9 }
10
11 // 合并文件
12 const filePath = path.join(uploadDir, filename);
13 const writeStream = fs.createWriteStream(filePath);
14
15 for (let i = 0; i < totalChunks; i++) {
16 const chunkPath = path.join(chunkDir, i.toString());
17 await pipeline(
18 fs.createReadStream(chunkPath),
19 writeStream,
20 { end: false }
21 );
22 }
23
24 writeStream.close();
25 await redis.del(`upload:${fileHash}`);
26 ctx.body = { success: true };
27});
性能优化对比
优化措施 | 上传时间(1GB文件) | 内存占用 | 崩溃率 |
---|---|---|---|
传统单次上传 | 失败 | 1.2GB | 100% |
基础分片上传 | 8分32秒 | 300MB | 15% |
本方案(优化后) | 3分15秒 | 150MB | 0.8% |
异常处理机制
- 网络中断:
- 自动重试3次
- 记录失败分片
- 切换备用上传域名
- 服务端错误:
- 500错误自动延迟重试
- 400错误停止并报告用户
- 本地存储异常:
- 降级使用内存存储
- 提示用户保持页面打开
部署建议
- 前端:
- 使用Service Worker缓存上传状态
- IndexedDB存储本地进度
- 服务端:
- 分片存储使用临时目录
- 定时清理未完成的上传(24小时TTL)
- 支持跨域上传
- 监控:
- 记录分片上传成功率
- 监控平均上传速度
- 异常报警机制
该方案已在生产环境验证,支持10GB以上文件上传,崩溃率稳定在0.8%-1.2%之间。
来源:juejin.cn/post/7490781505582727195
5张卡片的魔法秀:Flex布局+Transition实现高级展开动效
前言
在这篇技术博客中,我将详细解析一个流行的卡片展开效果实现方案,这个效果在GitHub最受欢迎的50个项目中占有一席之地。我们将从布局、CSS样式到JavaScript交互进行全面讲解。
让我们先来瞅瞅大概的动画效果吧🚀🚀🚀
项目概述
这个项目展示了一组卡片,默认状态下所有卡片均匀分布,当用户点击某个卡片时,该卡片会展开显示更多内容,同时其他卡片会收缩。这种交互方式在图片展示、产品特性介绍等场景非常实用。
HTML结构分析
构建一个初始的框架可以用一行代码解决:.container>(.qq-panel>h3.qq-panel__title)*5
,然后其他的背景属性什么的慢慢加
<div class="container">
<div class="qq-panel qq-panel_active" style="background-image: url('https://images.unsplash.com/photo-1558979158-65a1eaa08691?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1350&q=80')">
<h3 class="qq-panel__title">Explore The World</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1572276596237-5db2c3e16c5d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1350&q=80')">
<h3 class="qq-panel__title">Wild Forest</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1507525428034-b723cf961d3e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1353&q=80')">
<h3 class="qq-panel__title">Sunny Beach</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1551009175-8a68da93d5f9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1351&q=80')">
<h3 class="qq-panel__title">City on Winter</h3>
</div>
<div class="qq-panel" style="background-image: url('https://images.unsplash.com/photo-1549880338-65ddcdfd017b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1350&q=80')">
<h3 class="qq-panel__title">Mountains - Clouds</h3>
</div>
</div>
- 使用BEM命名规范(Block Element Modifier)命名类名
- 卡片背景图片通过内联样式设置,便于动态更改
- 初始状态下第一个卡片有
qq-panel_active
类,这个类是用来区分有没有点击的,初始状态下,只有第一张卡片是被点击的
CSS样式详解
全局重置与基础设置
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
*
选择器应用于所有元素- 重置margin和padding为0,消除浏览器默认样式差异
box-sizing: border-box
让元素尺寸计算更符合直觉:
- 传统模式 (content-box) :
width: 100px
仅指内容宽度
- 实际占用宽度 = 100px + padding + border
- 容易导致布局溢出
- border-box模式:
width: 100px
包含内容、padding和border
- 实际占用宽度就是设定的100px
- 内容区自动收缩:内容宽度 = 100px - padding - border
为什么更直观:
- 你设想的100px就是最终显示的100px
- 不需要做加减法计算实际占用空间
- 特别适合响应式布局(百分比宽度时不会因padding而溢出)
这就是为什么现代CSS重置通常首选border-box
。
弹性布局与居中
body {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
display: flex
将body设置为弹性容器align-items: center
垂直居中(交叉轴方向)justify-content: center
水平居中(主轴方向)height: 100vh
使body高度等于视窗高度overflow: hidden
隐藏溢出内容,防止滚动条出现
注意:
- vh单位:1vh等于视窗高度的1%,100vh就是整个视窗高度。这是响应式设计中常用的相对单位。
- justify-content :现在是水平居中,但其实是主轴的方向居中,
align-items
就是另一个方向的居中(这两个方向相互垂直),通过flex-direction
属性可以改变主轴的方向。(可以参考博客:告别浮动!Flexbox弹性布局终极指南引言)
容器样式
.container {
display: flex; /* 弹性布局 */
width: 90vw; /* 宽度 90% 视窗宽度 */
}
- 再次使用flex布局,使子元素排列在一行
width: 90vw
容器宽度为视窗宽度的90%,留出边距
卡片基础样式
.qq-panel {
height: 80vh; /* 高度 80% 视窗高度 */
border-radius: 50px; /* 圆角 50px */
color: #fff; /* 字体颜色 */
cursor: pointer; /* 鼠标指针 */
margin: 10px; /* 外边距 */
position: relative; /* 相对定位 */
flex: 1; /* 弹性布局 1 */
transition: all 0.7s ease-in; /* 过渡效果 */
}
height: 80vh
卡片高度为视窗高度的80%border-radius: 50px
大圆角效果,现代感更强flex: 1
所有卡片平均分配剩余空间,这个是相对的,如果有一个盒子是flex:2
,那么这个盒子就是其他盒子的两倍,后面会看到,点击的盒子(div
)是其他的5倍
transition: all 0.7s ease-in
是CSS过渡效果的简写属性,分解来看:
- 作用范围:
all
表示监听元素所有可过渡属性的变化
- 也可指定特定属性如
opacity, transform
- 时间控制:
0.7s
表示过渡持续700毫秒
- 时间长短影响动画节奏感(0.3s-1s最常用)
- 缓动函数:
ease-in
表示动画"慢入快出"
- 其他常见值:
ease-out
(快入慢出)
ease-in-out
(慢入慢出)
linear
(匀速)
- 延迟时间:
- 其实后面还有一个值,如:
transition: opacity 0.3s ease-in 0.4s;
所示,这里的0.4s
表示动画不会立即执行,而是等待 0.4 秒后才开始。
提示:过渡属性应写在元素的默认状态,而非:hover等伪类中
卡片标题样式
.qq-panel__title {
font-size: 24px; /* 字体大小 */
position: absolute; /* 绝对定位 */
bottom: 20px; /* 底部 20px */
left: 20px; /* 左边 20px */
opacity: 0; /* 不透明度 */
}
- 使用绝对定位将标题固定在卡片左下角
- 初始
opacity: 0
使标题不可见
激活状态卡片样式
.qq-panel_active {
flex: 5; /* 弹性布局 5 */
}
.qq-panel_active .qq-panel__title {
opacity: 1; /* 不透明度 */
transition: opacity 0.3s ease-in 0.4s; /* 过渡效果 */
}
flex: 5
激活的卡片占据更多空间(是普通卡片的5倍)- 标题显示(
opacity: 1
)并有单独的过渡效果 transition: opacity 0.3s ease-in 0.4s
表示:
- 属性:opacity(只有这一个属性发生变化时,才会触发这个过渡函数,前面的
all
是不管什么属性发生变化都会触发这个过渡函数) - 时长:0.3秒
- 缓动函数:ease-in
- 延迟:0.4秒(让卡片展开动画先进行)
- 属性:opacity(只有这一个属性发生变化时,才会触发这个过渡函数,前面的
JavaScript交互逻辑
//获取所有卡片元素
const panels = document.querySelectorAll('.qq-panel');
panels.forEach(panel => {
// JS 是事件机制的语言
panel.addEventListener('click', () => {
// 移除所有的 active 类
removeActiveClasses(); // 模块化
panel.classList.toggle('qq-panel_active');
});
});
function removeActiveClasses() {
panels.forEach(panel => {
panel.classList.remove('qq-panel_active');
})
}
- 获取所有卡片元素
- 为每个卡片添加点击事件监听器
- 点击时:
- 先移除所有卡片的激活状态
- 然后切换当前卡片的激活状态
removeActiveClasses
函数封装了移除激活状态的逻辑
设计要点总结
- 响应式布局:使用vh/vw单位确保不同设备上比例一致
- 弹性布局:flexbox轻松实现水平和垂直居中
- 视觉层次:通过缩放和标题显示/隐藏创造焦点
- 动画细节:
- 主动画0.7秒确保效果明显但不拖沓
- 标题延迟0.4秒显示,避免与卡片展开动画冲突
- 缓动函数(ease-in)使动画更自然
- 用户体验:
- 光标变为指针形状(cursor: pointer)提示可点击
- 圆角设计更友好
- 平滑过渡减少视觉跳跃
这个项目展示了如何用简洁的代码实现优雅的交互效果,核心在于对CSS弹性布局和过渡动画的熟练运用。通过分析这个案例,我们可以学习到现代前端开发中许多实用的技巧和设计理念。
来源:juejin.cn/post/7510836365711130634
【实例】H5呼起摄像头进行拍照、扫福等操作
主要是借助navigator.mediaDevices.getUserMedia
方法来呼气摄像头获取视频流
// 初始化摄像头
async function initCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' } // 后置摄像头
});
video.srcObject = stream; // 将数据流传入到视频组件当中
return new Promise((resolve) => {
video.onloadedmetadata = () => {
video.play(); // 播放数据, 微信当中需要手动播放,无法自动播放
resolve();
};
});
} catch (err) {
alert(JSON.stringify(err))
}
}
获取到视频流之后,点击按钮去对视频截图,上传视频到后端,对视频截图可以使用canvas
来实现。
// 捕获图像
function captureImage() {
video.pause()
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
capturedImageBlob = blob;
const imageUrl = URL.createObjectURL(blob);
let result = document.getElementById('result')
result.src = imageUrl
$('#page-3').show().siblings().hide()
}, 'image/jpeg', 0.8);
}
来源:juejin.cn/post/7516910928669130787
彻底解决PC滚动穿透问题
背景:
之前在做需求的时候,产品有提到一个bug,说是在某些情况下不应该触发外部滚动条滚动,例如鼠标在气泡框的内部就不能产生外部滚动条滚动,这样会影响用户的体验
原效果:

禁止滚动穿透之后效果:

可以看到浏览器的默认行为就是会滚动穿透的,因此我也查找了一些解决方案,但是似乎效果都不太理想,例如最简单有效的方式就是通过设置一个css属性来解决这个问题
overscroll-behavior: contain;
但是呢这个属性实现的效果并不完美,以上方示例的黄色滚动区域为例,如果黄色区域是可以滚动的,那么该属性有效,如果黄色区域不可以滚动,该属性就会失效了,所以没办法只能另寻他法,用JS去解决这个问题
原理:通过监听目标元素内部的滚轮事件,如果内部还有元素可滚动则不做处理,如果内部元素已无法滚动,则禁止滚轮事件冒泡至外部,从而导致无法触发外部滚动条滚动的行为
以下是整体代码,我封装了一个VUE版本的通用HOOKS函数,具体实现大家可参考代码,希望给大家带来帮助!
import { isRef, onMounted, onUnmounted, nextTick } from "vue"
import type { Ref } from "vue"
/**
* 可解析为 DOM 元素的数据源类型
* - 选择器字符串 | Ref | 返回dom函数
*/
type TElement<T> = string | Ref<T> | (() => T)
/**
* HOOKS: 使用滚动隔离
*
* @author dyb-dev
* @date 21/06/2025/ 14:20:34
* @param {(TElement<HTMLElement | HTMLElement[]>)} target 目标元素 `[选择器字符串 | ref对象 | 返回dom函数]`
* @param {TElement<HTMLElement>} [scope=() => document.documentElement] 作用域元素(注意:目标元素为 `选择器字符串` 才奏效) `[选择器字符串 | ref对象 | 返回dom函数]`
*/
export const useScrollIsolate = (
target: TElement<HTMLElement | HTMLElement[]>,
scope: TElement<HTMLElement> = () => document.documentElement
) => {
/** LET: 当前绑定监听器的目标元素列表 */
let _targetElementList: HTMLElement[] = []
/** HOOKS: 挂载钩子 */
onMounted(async() => {
await nextTick()
// 获取目标元素列表
_targetElementList = _getTargetElementList()
// 遍历绑定 `滚轮` 事件
_targetElementList.forEach(_element => {
_element.addEventListener("wheel", _onWheel, { passive: false })
})
})
/** HOOKS: 卸载钩子 */
onUnmounted(() => {
_targetElementList.forEach(_element => {
_element.removeEventListener("wheel", _onWheel)
})
})
/**
* FUN: 获取目标元素列表
* - 字符串时基于作用域选择器查找
*
* @returns {HTMLElement[]} 目标元素列表
*/
const _getTargetElementList = (): HTMLElement[] => {
let _getter: () => unknown
if (typeof target === "string") {
_getter = () => {
const _scopeElement = _getScopeElement()
return _scopeElement ? [..._scopeElement.querySelectorAll(target)] : []
}
}
else {
_getter = _createGetter(target)
}
const _result = _getter()
const _normalized = Array.isArray(_result) ? _result : [_result]
return _normalized.filter(_node => _node instanceof HTMLElement)
}
/**
* FUN: 获取作用域元素(scope)
* - 字符串时使用 querySelector
*
* @returns {HTMLElement | null} 作用域元素
*/
const _getScopeElement = (): HTMLElement | null => {
let _getter: () => unknown
if (typeof scope === "string") {
_getter = () => document.querySelector(scope)
}
else {
_getter = _createGetter(scope)
}
const _result = _getter()
return _result instanceof HTMLElement ? _result : null
}
/**
* FUN: 创建公共 getter 函数
* - 支持 Ref、函数、直接值
*
* @param {unknown} target 目标元素
* @returns {(() => unknown)} 公共 getter 函数
*/
const _createGetter = (target: unknown): (() => unknown) => {
if (isRef(target)) {
return () => (target as Ref<unknown>).value
}
if (typeof target === "function") {
return target as () => unknown
}
return () => target
}
/**
* FUN: 监听滚轮事件
*
* @param {WheelEvent} event 滚轮事件
*/
const _onWheel = (event: WheelEvent) => {
const { target, currentTarget, deltaY } = event
let _element = target as HTMLElement
while (_element) {
// 启用滚动时
if (_isScrollEnabled(_element)) {
// 无法在当前滚动方向上继续滚动时
if (!_isScrollFurther(_element, deltaY)) {
event.preventDefault()
}
return
}
// 向上查找不到滚动元素且到达当前目标元素边界时
if (_element === currentTarget) {
event.preventDefault()
return
}
_element = _element.parentElement as HTMLElement
}
}
/**
* FUN: 是否启用滚动
*
* @param {HTMLElement} element 目标元素
* @returns {boolean} 是否启用滚动
*/
const _isScrollEnabled = (element: HTMLElement): boolean =>
/(auto|scroll)/.test(getComputedStyle(element).overflowY) && element.scrollHeight > element.clientHeight
/**
* FUN: 是否能够在当前滚动方向上继续滚动
*
* @param {HTMLElement} element 目标元素
* @param {number} deltaY 滚动方向
* @returns {boolean} 是否能够在当前滚动方向上继续滚动
*/
const _isScrollFurther = (element: HTMLElement, deltaY: number): boolean => {
/** 是否向下滚动 */
const _isScrollingDown = deltaY > 0
/** 是否向上滚动 */
const _isScrollingUp = deltaY < 0
const { scrollTop, scrollHeight, clientHeight } = element
/** 是否已到顶部 */
const _isAtTop = scrollTop === 0
/** 是否已到底部 */
const _isAtBottom = scrollTop + clientHeight >= scrollHeight - 1
/** 是否还能向下滚动 */
const _willScrollDown = _isScrollingDown && !_isAtBottom
/** 是否还能向上滚动 */
const _willScrollUp = _isScrollingUp && !_isAtTop
return _willScrollDown || _willScrollUp
}
}
来源:juejin.cn/post/7519695901289267254
字节跨平台框架 Lynx 开源:一个 Web 开发者的原生体验
最近各大厂都在开源自己的跨平台框架,前脚腾讯刚宣布计划四月开源基于 Kotlin 的跨平台框架 「Kuikly」 ,后脚字节跳动旧开源了他们的跨平台框架「 Lynx」,如果说 Kuikly 是一个面向客户端的全平台框架,那么 Lynx 就是一个完全面向 Web 前端的跨平台全家桶。
为什么这么说?我们简单看官方提供的一个 Demo ,相信你可以看到许多熟悉的身影:
- scss
- React
- useEffect
- react native 的
view
import "../index.scss";
import { useEffect, useMainThreadRef, useRef } from "@lynx-js/react";
import { MainThread, type ScrollEvent } from "@lynx-js/types";
import type { NodesRef } from "@lynx-js/types";
import LikeImageCard from "../Components/LikeImageCard.jsx";
import type { Picture } from "../Pictures/furnitures/furnituresPictures.jsx";
import { calculateEstimatedSize } from "../utils.jsx";
import { NiceScrollbar, type NiceScrollbarRef } from "./NiceScrollbar.jsx";
import { adjustScrollbarMTS, NiceScrollbarMTS } from "./NiceScrollbarMTS.jsx";
export const Gallery = (props: { pictureData: Picture[] }) => {
const { pictureData } = props;
const scrollbarRef = useRef<NiceScrollbarRef>(null);
const scrollbarMTSRef = useMainThreadRef<MainThread.Element>(null);
const galleryRef = useRef<NodesRef>(null);
const onScrollMTS = (event: ScrollEvent) => {
"main thread";
adjustScrollbarMTS(
event.detail.scrollTop,
event.detail.scrollHeight,
scrollbarMTSRef,
);
};
const onScroll = (event: ScrollEvent) => {
scrollbarRef.current?.adjustScrollbar(
event.detail.scrollTop,
event.detail.scrollHeight,
);
};
useEffect(() => {
galleryRef.current
?.invoke({
method: "autoScroll",
params: {
rate: "60",
start: true,
},
})
.exec();
}, []);
return (
<view className="gallery-wrapper">
<NiceScrollbar ref={scrollbarRef} />
<NiceScrollbarMTS main-thread:ref={scrollbarMTSRef} />
<list
ref={galleryRef}
className="list"
list-type="waterfall"
column-count={2}
scroll-orientation="vertical"
custom-list-name="list-container"
bindscroll={onScroll}
main-thread:bindscroll={onScrollMTS}
>
{pictureData.map((picture: Picture, index: number) => (
<list-item
estimated-main-axis-size-px={calculateEstimatedSize(picture.width, picture.height)}
item-key={"" + index}
key={"" + index}
>
<LikeImageCard picture={picture} />
</list-item>
))}
</list>
</view>
);
};
export default Gallery;
没错,目前 Lynx 开源的首个支持框架就是基于 React 的 ReactLynx,当然官方也表示Lynx 并不局限于 React,所以不排除后续还有 VueLynx 等其他框架支持,而 Lynx 作为核心引擎支持,其实并不绑定任何特定前端框架,只是当前你能用的暂时只有 ReactLynx :
对于支持平台,目前开源版本支持 Android、iOS 和 Web,而 Lynx 官方也表示其实内部已经支持了鸿蒙平台,不过由于时间的关系,暂没有开放。
至于是否支持小程序,这个从设计上看其实应该并不会太困难。
另外 Lynx 的另一个特点就是 CSS 友好,Lynx 原生支持了 CSS 动画和过渡、CSS 选择器,以及渐变、裁剪和遮罩等现代 CSS 视效能力,使开发者能够像在 Web 上一样继续使用标记语言和 CSS。
同时 Lynx 表示,在从 Web 迁移到 Lynx 的界面,普遍能缩短 2–4 倍的启动时间,并且相比同类技术,Lynx 在 iOS 上不相上下,在安卓上则持续领先。
性能主要体现在自己特有的排版引擎、线程模型和更新机制。
而在实现上,源代码中的标签,会在运行时被 Lynx 引擎解析,翻译成用于渲染的 Element,嵌套的 Element 会组成的一棵树,从而构建出复杂的界面:
而 Lynx Element 是和平台无关的统一抽象支持,它们会被 Lynx 引擎渲染为原生平台的 UI 控件,比如 iOS 与 Android 中的 Native View,或 Web 中的 HTML 元素(包括 custom_elements),从目前的 Demo 直出 App 我们也可以看到这一点:
那看到这里,你是不是想说,这不就是 react-native 吗?这里有几个不同点:
- Lynx 默认在引擎层就支持 Web
- Lynx 有自己特有的线程模型和布局模型
- Lynx 在官方宣传中可以切换到自渲染,虽然暂时没找到
事实上,Lynx 官方并没有避讳从其他框架里学习相应优势的情况,官方就很大方的表示,Lynx 项目就是使用了 react-native 和 Flutter 的部分优势能力,从这一点看Lynx 还是相当真诚的:
react-native 不用说,比如 JSI 等概念都可以在项目内找到,而类似 Flutter 里的 buildroot 和 Runner 也可以在项目内看到,包含 Flutter 里的 message loop 等事件驱动的线程编程模型:
例如 Lynx 的 Virtual Thread 概念,对应 Lynx 托管的“执行线程” ,用于提供 Task 的顺序执行,并且它与物理线程可能存在不是一一对应的关系,这和 Flutter 的 Task Runners 概念基本一样,支持将 Task 发布上去执行,但不关心其线程模型情况。
另外 Lynx 最大的特点之一是「双线程架构」,JavaScript 代码会在「主线程」和「后台线程」两个线程上同时运行,并且两个线程使用了不同的 JavaScript 引擎作为其运行时:
- Lynx 主线程负责处理直接处理屏幕像素渲染的任务,包括:执行主线程脚本、处理布局和渲染图形等等,比如负责渲染初始界面和应用后续的 UI 更新,让用户能尽快看到第一屏内容。
- Lynx 的后台线程会运行完整的 React 运行时,处理的任务不直接影响屏幕像素的显示,包括在后台运行的脚本和任务(生命周期和其他副作用),它们与主线程分开运行,这样可以让主线程专注于处理用户交互和渲染,从而提升整体性能。
比如下面这个代码,当组件 <HelloComponent/>
被渲染时,你可能会在控制台看到 "Hello" 被打印两次,因为代码运行在两个线程上:
const HelloComponent = () => {
console.log('Hello'); // 这行会被打印两次
return <text>Hello</text>;
};
在 Lynx 规则里,事件处理器、Effect、标注
background only
、backgroundOnlyFunction 等只能运行在后台线程,因为后台线程才有完整的 React 运行时。
而在 JS 运行时,主线程使用由 Lynx 团队官方维护的 PrimJS 作为运行时,它是基于 QuickJS 的轻量、高性能 JavaScript 引擎,可以为主线程提供良好的运行性能。
而 Lynx 的后台线程:
- Android:出于包体积和性能的综合考量,默认使用 PrimJS 作为运行时
- iOS:默认情况下使用 JavaScriptCore 作为运行时,但由于调试协议支持度的原因,当需要调试的时候,需要切换到 PrimJS
同时 PrimJS 提供了一种高性能的 FFI 能力,可以较低成本的将 Lynx 对象封装为 JS 对象返回给 FFI 调用者,相比传统的 FFI 性能优势明显,但是这种类型的 JS 对象并不是 Object Model,Lynx 引擎无法给该对象绑定 setter getter 方法,只能提供 FFI 将其作为参数传入,实现类似的功能。
另外,Lynx 的布局引擎命名为 starlight,它是一个独立的布局引擎,支持各种布局算法,包括 flex、linear、grid 等,它还公开了自定义度量和自定义布局的功能,为用户提供了扩展其功能的灵活性。
在 Lynx 内部,LynxView
的作用类似于原生的 WebView
,用于加载渲染对应 Bundle 文件,其中 LynxView
对应的就是 Page,Page 就是 Lynx App 的 Root Element。
客户端可以给 LynxView 设置不同的大小约束,也就是给 Page 设置大小约束,Lynx 排版引擎会使用这些约束来计算 Page 节点以及所有子节点的大小位置信息:
<page>
是页面的根节点,一个页面上只能有一个<page>
。你也可以不在页面最外层显式写<page>
,前端框架会默认生成根节点。
最后,从 Lynx 的实现上看,后续如果想支持更多平台其实并不复杂,而官方目前也提示了:,Lynx 并不适合从零开始构建一个新的应用,你需要将 Lynx(引擎)集成自原生移动应用或 Web 应用中,通过 Lynx 视图加载 Lynx 应用 ,所以 Lynx 应该是一个混合开发友好的框架。
那么,对于你来说,Lynx 会是你跨平台开发的选择之一吗?
来源:juejin.cn/post/7478167090530320424