普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月19日掘金 前端

2026前端面试题及答案

2026年1月18日 23:12

2026前端面试题及答案

HTML/CSS 部分

1. 什么是盒模型?标准盒模型和IE盒模型的区别是什么?

答案: 盒模型是CSS中用于布局的基本概念,每个元素都被表示为一个矩形盒子,由内容(content)、内边距(padding)、边框(border)和外边距(margin)组成。

区别:

  • 标准盒模型(W3C盒子模型)widthheight只包含内容(content)
  • IE盒模型(怪异模式盒子模型)widthheight包含内容(content)、内边距(padding)和边框(border)

可以通过box-sizing属性切换:

/* 标准盒模型 */
box-sizing: content-box;

/* IE盒模型 */
box-sizing: border-box;

2. CSS选择器优先级如何计算?

答案: CSS选择器优先级从高到低:

  1. !important
  2. 内联样式(style="")
  3. ID选择器(#id)
  4. 类选择器(.class)、属性选择器([type="text"])、伪类(:hover)
  5. 元素选择器(div)、伪元素(::before)
  6. 通配符(*)、关系选择器(>, +, ~)

计算规则:

  • ID选择器:100
  • 类/属性/伪类:10
  • 元素/伪元素:1
  • 相加比较,值大的优先级高

3. BFC是什么?如何创建BFC?

答案: BFC(Block Formatting Context)块级格式化上下文,是Web页面的可视化CSS渲染的一部分,是一个独立的渲染区域。

创建BFC的方法:

  • float不为none
  • position为absolute或fixed
  • display为inline-block、table-cell、table-caption、flex、inline-flex
  • overflow不为visible

BFC特性:

  1. 内部盒子垂直排列
  2. margin会重叠在同一个BFC中
  3. BFC区域不会与float box重叠
  4. BFC是独立容器,外部不影响内部

JavaScript部分

4. JavaScript中的事件循环机制是怎样的?

答案: JavaScript是单线程语言,通过事件循环机制实现异步。事件循环由以下部分组成:

  1. 调用栈(Call Stack):执行同步代码的地方
  2. 任务队列(Task Queue)
    • 宏任务(macrotask):script整体代码、setTimeout、setInterval、I/O、UI渲染等
    • 微任务(microtask):Promise.then/catch/finally、MutationObserver等

执行顺序:

  1. 执行同步代码(宏任务)
  2. 执行过程中遇到异步任务:
    • 微任务放入微任务队列
    • 宏任务放入宏任务队列
  3. 同步代码执行完毕,检查微任务队列并全部执行
  4. UI渲染(如果需要)
  5. 取出一个宏任务执行,重复上述过程

5. ES6中let/const与var的区别?

答案:

var let const
作用域 函数作用域 块级作用域 块级作用域
变量提升 暂时性死区 暂时性死区
重复声明 允许 不允许 不允许
全局属性 会成为 不会成为 不会成为
初始值 可不设 可不设 必须设置
修改值 可以 可以 不可以

6. Promise的原理是什么?手写一个简单的Promise实现。

答案: Promise是一种异步编程解决方案,主要解决回调地狱问题。它有三种状态:pending、fulfilled、rejected。

简单实现:

class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];
    
    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };
    
    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };
    
    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }
  
  then(onFulfilled, onRejected) {
    if (this.state === 'fulfilled') {
      onFulfilled(this.value);
    }
    
    if (this.state === 'rejected') {
      onRejected(this.reason);
    }
    
    if (this.state === 'pending') {
      this.onFulfilledCallbacks.push(() => onFulfilled(this.value));
      this.onRejectedCallbacks.push(() => onRejected(this.reason));
    }
  }
}

React/Vue框架部分

7.React中setState是同步还是异步的?

答案: 在React中,setState的行为表现有时"异步",有时"同步":

1.大部分情况下表现为异步(批量更新优化):

  • React合成事件处理函数中(setTimeout/setInterval/Promise回调等原生事件外)
  • React生命周期函数中

在这些情况下React会将多个setState合并为一个更新以提高性能。

2.某些情况下表现为同步:

  • setTimeout/setInterval回调中
  • DOM原生事件处理函数中
  • Promise.then等异步代码中

React18后所有情况都默认批量处理(auto batching),如需强制同步可使用flushSync。

原理原因: React通过isBatchingUpdates标志控制是否批量更新,合成事件和生命周期会开启此标志。

###8.Vue的响应式原理是怎样的?

答案: Vue2.x使用Object.defineProperty,Vue3使用Proxy实现响应式:

Vue2实现原理: 1.数据劫持:通过Object.defineProperty对data对象每个属性添加getter/setter追踪变化。

Object.defineProperty(obj, key, { 
 get() { //依赖收集 },
 set(newVal) { //触发更新 } 
})

2.依赖收集:在getter中将观察者Watcher实例添加到Dep订阅器中。 3.派发更新:setter被触发时通知Dep中的所有Watcher重新计算并更新视图。 缺点:无法检测对象属性的添加删除,数组变动需特殊处理。

Vue3使用Proxy改进:

new Proxy(data, { 
 get(target, key){},
 set(target, key, value){},
 deleteProperty(target, key){}
})

优势:可直接监听对象/数组的各种变化;无需递归遍历整个对象初始化。

##性能优化相关

###9.Webpack有哪些常见的性能优化手段?

构建速度优化:

1.缩小文件搜索范围

resolve:{ modules:[path.resolve(__dirname,'node_modules')] },
module:{ noParse:/jquery|lodash/ } //忽略未模块化库的解析  

2.缓存loader结果(cache-loader/babel-loader?cacheDirectory=true)

3.多进程构建(thread-loader/happyPack)

4.DllPlugin预编译不变模块

5.合理使用sourceMap(开发环境cheap-module-eval-source-map)

打包体积优化:

1.Tree Shaking(ES6模块+production模式+sideEffects配置)

2.Code Splitting:

optimization:{ splitChunks:{ chunks:'all' } },
entry:{ main:'./src/main.js', vendor:['lodash'] }  

3.Scope Hoisting(ModuleConcatenationPlugin) 4.UglifyJsPlugin压缩混淆代码

5.Gzip压缩(compression-webpack-plugin) 6.CDN引入外部资源(externals) 7.PurgeCSS移除无用CSS

8.OptimizeCSSAssetsPlugin压缩CSS

9.ImageMinimizerPlugin压缩图片

10.babel按需加载polyfill

##算法与编程题

###10.[编程题]手写防抖和节流函数

防抖(debounce):高频触发时只在停止触发后执行一次

function debounce(fn, delay){
 let timer=null;
 return function(...args){
   clearTimeout(timer);  
   timer=setTimeout(()=>fn.apply(this,args),delay);
 }
}

节流(throttle):高频触发时每隔一段时间执行一次

function throttle(fn, interval){
 let lastTime=0;  
 return function(...args){
   const now=Date.now();
   if(now-lastTime>=interval){
     fn.apply(this,args);  
     lastTime=now;  
   }  
 } 
}

//定时器版本节流:
function throttle(fn,delay){  
 let timer=null;   
 return function(...args){   
   if(!timer){     
     timer=setTimeout(()=>{      
       fn.apply(this,args);       
       timer=null;      
     },delay);     
   }   
 };   
}   

##HTTP与浏览器相关

###11.HTTPS的工作原理是什么?

HTTPS=HTTP+TLS/SSL加密层工作流程:

1.Client发送支持的加密算法列表+随机数A给Server

2.Server选择加密算法+发送数字证书+随机数B给Client

3.Client验证证书有效性(颁发机构/过期时间/域名匹配),生成随机数C并用证书公钥加密发送给Server

4.Server用私钥解密获取随机数C

5.Client和Server都用ABC三个随机数生成对称加密密钥(session key)

6.HTTP通信开始使用该密钥加密数据

关键点: -CA机构验证服务器身份防止中间人攻击
-非对称加密交换对称密钥提高安全性又保证性能
-TLS握手阶段采用非对称加密通信阶段采用对称加密

安全特性: 机密性(对称加密)+完整性(MAC校验)+身份认证(X509证书链)

昨天 — 2026年1月18日掘金 前端

《实时渲染》第1章-绪论-1.1内容概览

作者 charlee44
2026年1月18日 21:09

实时渲染

1. 概述

实时渲染是指在计算机上快速渲染图像。它是计算机图形中交互性最高的领域。图像出现在屏幕上,观看者做出动作或反应,这种反馈会影响接下来生成的内容。这种反应和渲染的循环以足够快的速度发生,以至于观看者不会看到单个图像,而是沉浸在一个动态的过程中。

图像的显示速率以每秒帧数 (FPS) 或赫兹 (Hz) 为单位。FPS为1时,几乎没有交互感;用户痛苦地意识到每个新图像的到来。在大约6FPS 时,交互感开始增强。视频游戏的目标是30、60、72或更高的FPS;只有到达如上的速度,用户才好专注于行动和反应。

电影放映机以24FPS的速度显示帧,但使用快门系统将每帧显示两到四次以避免闪烁。此刷新率与显示率分开,以赫兹 (Hz)表示。照亮框架三次的快门具有72Hz的刷新率。LCD显示器也会将刷新率与显示率分开。

以24FPS的速度观看屏幕上显示的图像可能是可以接受的,但更高的速率对于最大限度地缩短响应时间很重要。仅15毫秒的时间延迟就会减慢并干扰交互[1849]。例如,用于虚拟现实的头戴式显示器通常需要90FPS以最大限度地减少延迟。

实时渲染不仅仅是交互性。如果速度是唯一的标准,那么任何快速响应用户命令并在屏幕上绘制任何内容的应用程序都符合条件。实时渲染通常意味着生成3D图像。

交互性和对三维空间的某种连接感是实时渲染的充分条件,但第三个元素已成为其定义的一部分:图形加速硬件。许多人认为1996年推出的3Dfx Voodoo 1卡是消费级3D图形的真正开始 [408]。随着这个市场的飞速发展,现在每台计算机、平板电脑和手机都内置了图形处理器。图 1.1 和 1.2 显示了一些通过硬件加速实现实时渲染结果的优秀示例。

图1.1 Forza Motorsport 7 的镜头(图片由 Turn 10 Studios 提供,Microsoft)

图1.2 巫师3中呈现的Beauclair城市

图形硬件的进步推动了交互式计算机图形领域研究的爆炸式增长。我们将专注于提供提高速度和改善图像质量的方法,同时描述加速算法和图形API的特性和局限性。我们无法深入涵盖每个主题,因此我们的目标是展示关键概念和术语,解释该领域最强大和实用的算法,并提供指向获取更多信息的最佳位置的地址。我们希望我们为您提供理解该领域的工具的尝试被证明是值得您在本书上花费的时间和精力。

1.1 内容概览

以下是对本书章节的简要概述。

第2章,图形渲染管线。实时渲染的核心是获取场景的描述,并将其转换为我们可以看到的内容的一系列步骤。

第3章,图形处理单元。现代GPU使用固定功能和可编程单元的组合来实现渲染管线的各个阶段。

第4章,图形变换。图形变换是操纵对象的位置、方向、大小和形状以及相机的位置和视图的基本工具。

第5章,着色基础。首先讨论了材质和灯光的定义,及其在表面外观(无论是逼真的还是风格化的)的实现中的使用。介绍了其他与外观相关的主题,例如通过使用抗锯齿、透明度和伽马校正提供更高的图像质量。

第6章,纹理。实时渲染最强大的工具之一是能够在表面上快速访问和显示图像。此过程称为纹理化,并且有多种应用方法。

第7章,阴影。为场景添加阴影可以增加真实感和理解力。介绍了用于快速计算阴影的更流行的算法。

第8章,光照与颜色。在我们执行基于物理的渲染之前,我们首先需要了解如何量化光线和颜色。在我们的物理渲染过程完成后,我们需要将结果数量转换为显示值,考虑屏幕和观看环境的属性。本章涵盖了这两个主题。

第9章,基于物理的着色。我们从头开始了解基于物理的着色模型。本章从潜在的物理现象开始,涵盖各种渲染材质的模型,并以将材质混合在一起并对其进行过滤以避免混叠和保持表面外观的方法结束。

第10章,局部照明。探索了描绘更精细光源的算法。表面着色考虑到光是由具有特征形状的物理对象发出的。

第11章,全局光照。模拟光与场景之间多次交互的算法进一步增加了图像的真实感。我们讨论了环境和方向遮挡以及在漫反射和镜面反射表面上渲染全局照明效果的方法,以及一些有希望的统一方法。

第12章,图像空间效果。图形硬件擅长以快速的速度执行图像处理。首先讨论图像滤波和重投影技术,然后我们调查了几种流行的后期处理效果:镜头光晕、运动模糊和景深。

第13章,超越多边形。三角形并不总是描述物体的最快或最现实的方式。基于使用图像、点云、体素和其他样本集的替代表示各有其优点。

第14章,体积和半透明渲染。这里的重点是体积材料表示及其与光源的相互作用的理论和实践。大到大气效应,小到头发纤维内的光散射,都是该仿真的现象范围。

第15章,非真实感渲染。尝试使场景看起来逼真只是渲染场景的一种方式。其他风格,例如卡通阴影和水彩效果,也是值得被讨论的。还讨论了行和文本生成技术。

第16章,多边形技术。几何数据来自广泛的来源,有时需要修改才能快速良好地呈现。介绍了多边形数据表示和压缩的许多方面。

第17章,曲线和曲面。更复杂的表面表示提供了一些优势,例如能够在质量和渲染速度之间进行权衡、更紧凑的表示和平滑的表面生成。

第18章,管线优化。一旦应用程序运行并使用高效算法,就可以使用各种优化技术使其更快。找到瓶颈并决定如何解决它是这里的主题。还讨论了多进程处理。

第19章,加速算法。更快的让渲染完成。涵盖了各种形式的剔除和细节渲染级别。

第20章,高效着色。场景中的大量灯光会显着降低性能。在知道表面片元可见之前,进行完全着色,是浪费性能的另一个来源。我们探索了多种方法来解决这些和其他形式的着色效率低下问题。

第21章,虚拟现实和增强现实。这些领域具有以快速和一致的速度有效地生成逼真图像的特殊挑战和技术。

第22章,交叉测试方法。相交测试对于渲染、用户交互和碰撞检测很重要。此处提供了针对常见几何相交测试的各种最有效算法的深入介绍。

第23章,图形硬件。这里的重点是颜色深度、帧缓冲区和基本架构类型等组件。提供了代表性GPU的案例研究。

第24章,展望未来的一些图形技术。

由于篇幅限制,我们在realtimerendering.com上制作了一个关于碰撞检测的免费章节,以及关于线性代数和三角学的附录。

React 事件绑定全攻略:5种方式优劣大比拼

作者 北辰alk
2026年1月18日 19:16

React 事件绑定全攻略:5种方式优劣大比拼

为什么事件绑定这么重要?

在React中,事件绑定不仅仅是把函数和元素连接起来那么简单。它关系到:

  • • 组件的性能表现
  • • 代码的可维护性
  • • this指向的正确性
  • • 内存泄漏的防范

下面我们一起来看看React事件绑定的5种主要方式,以及它们各自的“性格特点”。

方式一:箭头函数内联绑定

class Button extends React.Component {
  render() {
    return (
      <button onClick={() => this.handleClick()}>
        点击我
      </button>
    );
  }
  
  handleClick() {
    console.log('按钮被点击了');
  }
}

优点:

  • • 语法简洁直观
  • • 无需担心this指向问题

缺点:

  • • 性能陷阱:每次渲染都会创建新的函数实例
  • • 不利于子组件的shouldComponentUpdate优化

方式二:构造函数内绑定

class Button extends React.Component {
  constructor(props) {
    super(props);
    this.handleClick = this.handleClick.bind(this);
  }
  
  render() {
    return <button onClick={this.handleClick}>点击我</button>;
  }
  
  handleClick() {
    console.log('按钮被点击了');
  }
}

优点:

  • • 性能最佳,函数只在构造函数中绑定一次
  • • 支持shouldComponentUpdate优化

缺点:

  • • 代码稍显冗长
  • • 需要维护构造函数中的绑定

方式三:类属性箭头函数(推荐)

class Button extends React.Component {
  handleClick = () => {
    console.log('按钮被点击了');
  };
  
  render() {
    return <button onClick={this.handleClick}>点击我</button>;
  }
}

优点:

  • • 语法简洁美观
  • • this永远指向组件实例
  • • 性能优秀(函数只创建一次)

缺点:

  • • 需要Babel插件支持(class properties)
  • • 不属于ES标准语法(但已成为事实标准)

方式四:render中bind绑定

class Button extends React.Component {
  render() {
    return (
      <button onClick={this.handleClick.bind(this)}>
        点击我
      </button>
    );
  }
  
  handleClick() {
    console.log('按钮被点击了');
  }
}

优点:

  • • 简单直接

缺点:

  • • 性能最差:每次渲染都重新绑定
  • • 代码可读性降低
  • • 不推荐在生产环境使用

方式五:函数组件中的事件绑定

function Button() {
  const handleClick = () => {
    console.log('按钮被点击了');
  };
  
  // 或者使用useCallback优化
  const memoizedHandleClick = React.useCallback(() => {
    console.log('按钮被点击了');
  }, []);
  
  return <button onClick={handleClick}>点击我</button>;
}

优点:

  • • 最适合函数组件
  • • useCallback可以优化性能

缺点:

  • • 对于简单事件可能显得“杀鸡用牛刀”

性能对比实测

让我们用数据说话:

绑定方式 每次渲染新建函数 内存占用 适合场景
箭头函数内联 简单组件、原型验证
构造函数绑定 性能敏感组件
类属性箭头函数 主流Class组件
render中bind 不推荐使用
函数组件+useCallback 可选 中等 函数组件

实战建议

1. Class组件优先选择

// 推荐:类属性箭头函数
class Profile extends React.Component {
  handleFollow = async () => {
    await this.props.followUser(this.state.userId);
  };
  
  // 对于需要参数的事件
  handleSelectItem = (itemId) => () => {
    this.setState({ selectedItem: itemId });
  };
  
  render() {
    return (
      <div>
        <button onClick={this.handleFollow}>关注</button>
        {items.map(item => (
          <div 
            key={item.id} 
            onClick={this.handleSelectItem(item.id)}
          >
            {item.name}
          </div>
        ))}
      </div>
    );
  }
}

2. 函数组件注意事项

function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('');
  
  // 好的做法:useCallback避免子组件不必要的重渲染
  const handleSearch = useCallback(() => {
    onSearch(query);
  }, [query, onSearch]);
  
  // 坏的做法:每次渲染都新建函数
  const handleChange = (e) => {
    setQuery(e.target.value);
  };
  
  // 好的做法:简单的setState可以直接内联
  const handleChange = (e) => setQuery(e.target.value);
  
  return <input value={query} onChange={handleChange} />;
}

3. 事件绑定优化技巧

技巧一:事件委托

class List extends React.Component {
  handleClick = (e) => {
    if (e.target.tagName === 'LI') {
      const id = e.target.dataset.id;
      this.handleItemClick(id);
    }
  };
  
  render() {
    return (
      <ul onClick={this.handleClick}>
        {this.props.items.map(item => (
          <li key={item.id} data-id={item.id}>
            {item.text}
          </li>
        ))}
      </ul>
    );
  }
}

技巧二:合成事件与原生事件

class Modal extends React.Component {
  componentDidMount() {
    // 在document上绑定原生事件
    document.addEventListener('keydown'this.handleKeyDown);
  }
  
  componentWillUnmount() {
    // 一定要记得移除!
    document.removeEventListener('keydown'this.handleKeyDown);
  }
  
  handleKeyDown = (e) => {
    if (e.key === 'Escape') {
      this.props.onClose();
    }
  };
  
  // React合成事件
  handleOverlayClick = (e) => {
    e.stopPropagation();
    this.props.onClose();
  };
}

常见坑点与避雷指南

🚫 坑点1:忘记绑定this

class BadExample extends React.Component {
  handleClick() {
    // 这里this是undefined!
    console.log(this.props.message);
  }
  
  render() {
    return <button onClick={this.handleClick}>点我</button>;
  }
}

🚫 坑点2:内联箭头函数导致性能问题

// 在长列表中这样做会非常卡顿
render() {
  return (
    <div>
      {items.map(item => (
        <Item 
          key={item.id}
          onClick={() => this.handleSelect(item.id)}  // 每次渲染都新建函数
        />
      ))}
    </div>
  );
}

// 改进方案
render() {
  return (
    <div>
      {items.map(item => (
        <Item 
          key={item.id}
          onClick={this.handleSelect}
          data-id={item.id}
        />
      ))}
    </div>
  );
}

总结

  1. 1. Class组件:优先使用类属性箭头函数(handleClick = () => {}
  2. 2. 函数组件:简单事件可直接定义,复杂事件考虑useCallback
  3. 3. 性能关键:避免在render中创建新函数,特别在列表渲染中
  4. 4. 内存管理:绑定在全局或document上的事件,一定要在组件卸载时移除

选择合适的事件绑定方式,能让你的React应用运行得更流畅,代码也更易于维护。

2025总结:我在深圳做前端的第8年

作者 wing98
2026年1月18日 19:07

转眼入行前端已经8个年头,我也算一名老前端了。可能自己对这一行谈不上特别喜欢,也不讨厌,工作上一直没有什么起色。

工作

去年年底我入职了一家外包公司,然后派去给一家上市公司干活。自己当时待的前端团队加上两个外包员工共有7人,涉及的项目有管理平台(微前端)以及对应的管理后台、Uniapp小程序、App(React Native)、可视化大屏系统。我主要参与的是pc端系统,都是基于Vue框架。其中管理平台主要是一些常见的业务需求的开发,但也有基于svg封装的实时监控主图组件还是比较复杂的;另外可视化大屏项目也参与的比较多,学习到了大屏适配的相关方案。

另外,今年工作过程中,自己也尝试用起了AI编程工具。我用的比较多的是阿里的通义灵码,不得不说对工作效率的提升还是很大。最近我开始转向字节的AI编辑器trae,体验上来说确实比插件要好很多。

在这家公司上班,还是比较清闲的,周末双休,平时也不会强制加班。领导和同事之间相处也比较愉快,在离场的时候,还一起吃了好几顿饭。

业余时间

其实今年自己的业余时间是比较多的,但还是没有很好的利用。可能我这个人比较懒吧,不肯放弃休闲娱乐的时间,到现在年初的目标也没实现几个。说好的多写点技术文章,结果就年终一篇总结,笑死!另外我也不是一个有耐心的人,今年本来想搭建一个自己的博客系统,但做了一半又去搞面试小程序去了,到现在两个都还没弄完。最让我气馁的还是软考,考了三次都还没过。今年考的两次在考前都刷题了很长一段时间,但最后都是其中一科差两分,太伤心了。

希望26年自己对自己要求高一点,养成自律的好习惯。

副业探索

今年我尝试的副业是虚拟店铺和网盘拉新。在网上搜罗了几十G的网盘资源,有小部分自己觉得比较好的放到了淘宝店铺上,最初还是出了几单的,但后面也慢慢没有流量了,就没有太上心。网盘拉新也差不多,特别是遭到各平台封号禁言之后,也没有去花时间了。两个副业一起大概收益不到200元,也算是副业探索上跨出的一步。其实我个人觉得这两个副业都挺好的,都不需要什么启动资金,就是要多花点时间去研究。

希望26年自己多花点时间在上面,争取副业收入月入过千。

二次被裁

年底的时候我又经历了一次裁员,与其说是被裁,其实是入职之初就能预料到的结果。因为继上一次裁员之后,我入职了一家外包公司,而且是不缴纳公积金和社保那种,最可恨的是在入职之前就让你签署各种主动放弃公积金和社保的协议。由于当时找工作几个月无果,最后无奈还是同意了。年底的时候由于驻场的甲方公司业务调整,所有外包员工都需要离场。其实在9月份的时候,外包公司迫于国家的压力,还是与我们签订了正式劳动合同,但同时也让我们签署放弃追缴赔偿的协议。虽然我也了解到这种违法劳动法的协议都是不合法的,但也不太想闹得去仲裁,就让他们配合我能领取失业金就行。

面试找工作

其实再次失业后,我心里也没有太过焦虑,也正好可以便找边休息一下。有了上一次的失业经历,我知道这次找工作也还是会很难,毕竟我的学历不行,还是非科班,技术能力也一般。其实没离场之前,我心里打定不再进外包了,但实际投简历的时候发现不考虑外包的话,面试机会就更少了。目前面了大概有5家公司,其中两家外包,有一家外包都发offer了,最后说甲方考虑到我是非统招学历,取消了offer。

这几年互联网行业下行,裁员失业的比较多,导致了市场供需不平衡。但毕竟是我工作了近8年的行业,而且目前我的副业也还没有发展起来。所以我未来几年也还是会继续深耕这一行,直到那天彻底找不到工作,或能有其它收入吧。

最后还是总结一下吧。

25年对我来说还是平淡的一年,工作和生活都没有什么大的变化。不过心态上来说,自己还是比较平和知足的,不用特别为生计发愁;而且国家也在日益强盛(虽然有产业转型的阵痛,如失业)。所以对未来,我还是有很多期待...

JS-彻底告别跨域烦恼:从同源策略到 CORS 深度实战

2026年1月18日 17:12

前言

在 Web 开发中,“跨域”是每个前端开发者绕不开的坎。当你看到控制台报出 Access-Control-Allow-Origin 错误时,其实是浏览器的同源策略在起作用。本文将带你深度解析跨域的本质,并掌握主流的解决方案。

一、 什么是跨域?

1. 同源策略 (Same-origin policy)

跨域问题的根源是浏览器为了安全而实施的同源策略。所谓“同源”,是指两个 URL 的以下三部分完全相同:

  • 协议:http 、 https
  • 域名 :域名就是我们每次访问网站输入的网址,每个域名都对应了一个IP地址,浏览器会通过域名解析来获取这个IP地址,例如www.test.com
  • 端口号 :80 、 8080

2. 域名解析小科普

域名是 IP 地址的“外壳”。

  • 顶级域名:.com, .cn
  • 一级域名:test.com
  • 二级域名www.test.com
  • 注意:一级域名和二级域名之间、二级域名和三级域名之间,统统属于跨域!比如在www.test.com网页使用 XMLHttpRequest 请求time.test.con的页面内容,由于它们不是同一个源,所以就涉及到了跨域(在 A 站点中去访问不同源的 B 站点的内容)。默认情况下,跨域请求是不被允许的,你可以看下面的示例代码:

二、 解决方案一:JSONP

1. 实现原理

利用 <script> 标签的 src 属性不受同源策略限制的特性。通过动态创建 script 标签,发送一个带有 callback 参数的 GET 请求。

2. 代码实现

前端逻辑:

btn.click(() => {
  var script = document.createElement("script");// 创建 scrip 标签
  script.src = `http://localhost:3000?callback=show`;// 添加 src 请求路径
  document.body.appendChild(script);
  script.onload = function(){
    document.body.removeChild(script)
  }
});

//这个函数就是回调函数,它会拼接到src属性中,并对数据进行操作
function show(result) {
  // ...
console.log("获取到的数据:", result);
}

服务端逻辑:

const http = require("http")
const url = require("url")
http.createServer(
  (req,res)=>{
    var callback = url.parse(req.url,true).query.callback;
    var severData = "xxxxxxxx";
    severData = JSON.stringify(severData)
    res.writeHead(200,{
      "Content-Type": "text/plain;charset=utf-8"
    });
    res.write(`${callback}(${severData})`);
    res.end();
  }
).listen(80)

局限性仅支持 GET 请求,不安全,且无法处理复杂的报错信息。


三、 解决方案二:CORS (现代的标准方案)

CORS(跨域资源共享)是目前的标准解法。它将请求分为简单请求非简单请求

1. 简单请求

条件:方法为 GET/POST

  • 流程:浏览器直接发起请求,并在 Header 中带上 Origin
  • 服务端:通过返回 Access-Control-Allow-Origin 来告知浏览器是否放行。

2. 非简单请求(预检请求)

条件:包含 PUT/DELETE 方法。

  • 流程:浏览器会先发送一个 OPTIONS 方法的“预检请求”。

  • 关键字段

    • Access-Control-Max-Age: 设置预检请求的缓存时间(秒),避免每次请求都多发一次 OPTIONS,优化性能。

3. Nginx 服务端配置示例

server {
    listen 80;
    location / {
        # 允许跨域的域名,建议生产环境指定具体域名而非 *
        add_header 'Access-Control-Allow-Origin' '$http_origin';
        add_header 'Access-Control-Allow-Credentials' 'true';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE';
        add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
        
        # 处理预检请求
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Max-Age' 1728000;
            return 204;
        }
    }
}

四、 面试模拟题

Q1:为什么要有同源策略?如果没有会怎样?

参考回答:

同源策略主要是为了防止 CSRF(跨站请求伪造) 攻击。如果没有同源策略,黑客的网页可以随意读取你银行网页的 Cookie 或 DOM 内容,从而冒充你发送请求或窃取敏感信息。

Q2:CORS 预检请求(OPTIONS)在什么情况下会触发?

参考回答:

当请求满足以下任意条件时会触发预检:

  1. 使用了 PUTDELETECONNECTOPTIONSTRACEPATCH 方法。
  2. 设置了非简单的 Header 字段(如 Authorization、自定义 Token)。
  3. Content-Type 的值不属于 application/x-www-form-urlencodedmultipart/form-datatext/plain

Q3:如何解决跨域时 Cookie 无法携带的问题?

参考回答:

  1. 前端XMLHttpRequestfetch 需设置 withCredentials: true
  2. 服务端:设置响应头 Access-Control-Allow-Credentials: true
  3. 注意:当开启凭证携带时,Access-Control-Allow-Origin 不能设置为 * ,必须指定具体的域名。

五、 总结

方案 原理 优点 缺点
JSONP <script> 标签不受限 兼容性极好(老浏览器) 只支持 GET,安全性差
CORS 服务端 Header 授权 正式标准,支持所有方法 需服务端配合,有预检开销

浏览器中如何摆脱浏览器下12px的限制

作者 hello_Code
2026年1月18日 17:10

目前Chrome浏览器依然没有放开12px的限制,但Chrome仍然是使用人数最多的浏览器。

在笔者开发某个项目时突发奇想:如果实际需要11px的字体大小怎么办?这在Chrome中是实现不了的。关于字体,一开始想到的就是rem等非px单位。但是rem只是为了响应式适配,并不能突破这一限制。

em、rem等单位只是为了不同分辨率下展示效果提出的换算单位,常见的库px2rem也只是利用了js将px转为rem。包括微信小程序提出的rpx单位也是一样!

这条路走不通,就只剩下一个方法:改变视觉大小而非实际大小

理论基础

css中有一个属性:transform: scale();

  • 值的绝对值>1,就是放大,比如2,就是放大2倍
  • 值的绝对值 0<值<1,就是缩小,比如0.5,就是原来的0.5倍;
  • 值的正负,负值表示图形翻转。

默认情况下,scale(x, y):以x/y轴进行缩放;如果y没有值,默认y==x; 也可以分开写:scaleX() scaleY() scaleZ(),分开写的时候,可以对Z轴进行缩放

第二种写法:transform: scale3d(x, y, z)该写法是上面的方法的复合写法,结果和上面的一样。

但使用这个属性要注意一点:scale 缩放的时候是以“缩放元素所在空间的中心点”为基准的。 所以如果用在改变元素视觉大小的场景下,一般还需要利用另一个元素来“恢复位置”:

transform-origin: top left;

语法上说,transform-origin 拥有三个属性值:

transform-origin: x-axis y-axis z-axis;

默认为:

transform-origin:50% 50% 0;

属性值可以是百分比、em、px等具体的值,也可以是top、right、bottom、left和center这样的关键词。作用就是更改一个元素变形的原点。

实际应用

<div class="mmcce__info-r">
  <!-- 一些html结构 -->
  <div v-show="xxx" class="mmcce-valid-mj-period" :class="{'mmcce-mh': showStr}">
    <div class="mmcce-valid-period-child">xxx</div><!-- 父级结构,点击显示下面内容 -->
    <div class="mmcce-valid-pro" ref="mmcceW">
      <!-- 下面内容在后面有讲解 -->
      <div class="mmcce-text" 
        v-for="(item, index) in couponInfo.thresholdStr" 
        :key="index" 
        :index="index"
        :style="{height: mTextH[index] + 'px'}"
      >{{item}}</div>
    </div>
  </div>
</div>
.mmcce-valid-mj-period {
  max-height: 15px;
  transition: all .2s ease;

  &.mmcce-mh {
    max-height: 200px;
  }

  .mmcce-valid-pro {
    display: flex;
    flex-direction: column;
    padding-bottom: 12px;

    .mmcce-text {
      width: 200%; // !
      font-size: 22px;
      height: 15px;
      line-height: 30px;
      color: #737373;
      letter-spacing: 0;
      transform       : scale(.5);
      transform-origin: top left;
    }
  }
}

.mmcce-valid-period-child {
  position: relative;
  width      : 200%;
  white-space: nowrap;
  font-size  : 22px;
  color      : #979797;
  line-height: 30px;

  transform       : scale(.5);
  transform-origin: top left;

  //xxx
}

屏幕截图 2025-12-17 194818.png

可以明确说明的是,这样的 hack 需要明确规定缩放元素的height!!!

上面代码中为什么.mmcce-valid-mj-period类中要用max-height ?为什么对展开元素中的文字类.mmcce-text中使用height? 我将类.mmcce-text中的height去掉后,看下效果:

屏幕截图 2025-12-17 194840.png

(使用min-height是一样的效果)

OK,可以看到,占高没有按我们想的“被缩放”。影响到了下面的元素位置。

本质上是“视觉大小改变了但实际(占位)大小无变化”。 这时候,宽高实际也被缩放了的。这一点通过代码中width:200%也可以看出来。或者你设置了overflow:hidden;也可以有相应的效果!

这一点需要注意,一般来说,给被缩放元素显式设置一个大于等于其font-sizeheight值即可。

缩放带来的其它问题

可能在很多人使用的场景中是不会考虑到这个问题的:被缩放元素限制高度以后如果元素换行那么会出现文字重叠的现象。

屏幕截图 2025-12-17 194858.png

为此,我采用了在mounted生命周期中获取父元素宽度,然后动态计算是否需要换行以及换行的行数,最后用动态style重新渲染每一条数据的height值。 这里有三点需要注意:

  1. 这里用的是一种取巧的方法:用每个文字的视觉font-size值*字符串长度。因为笔者遇到的场景不会出现问题所以可以这么用。在不确定场景中更推荐用canvas或dom实际计算每个字符的宽度再做判断(需要知道文字、字母和数字的宽度是不一样的);
  2. 需要注意一些特殊机型的展示,比如三星的galaxy fold,这玩意是个折叠屏,它的计算会和一般的屏幕计算的不一致;
  3. 在vue生命周期中,mounted可以操作dom,你可以通过this.$el获取元素。但要注意:在这个时期被获取的元素不能用v-if(即:必须存在于虚拟tree中)。这也是上面代码中笔者使用v-showopacity的原因。

关于第三点,还涉及到加载顺序的问题。比如刚进入页面时要展示弹窗,弹窗是一个组件。那你在index.vue中是获取不到这个组件的。但是你可以将比如header也拆分出来,然后在header组件的mounted中去调用弹窗组件暴露出的方法。

mounted(){
  let thresholdStr = this.info.dropDownTextList;
  let minW = false;
  if(this.$el.querySelector('.mmcce-valid-pro').clientWidth < 140) { // 以iPhone5位准,再小于其中元素宽度的的机型就要做特殊处理了
    minW = true
  }
  let mmcw = this.$el.querySelector('.mmcce-valid-pro').getBoundingClientRect().width;

  let mmch = [];

  for(let i=0;i<thresholdStr.length;i++) {
    // 11是指缩放后文字的font-size值,这是一种取巧的方式
    if(11*(thresholdStr[i].length) > mmcw) {
      if(minW) {
        mmch[i] = Math.floor((11*thresholdStr[i].length) / mmcw) * 15;
      }else {
        mmch[i] = Math.floor((11*(thresholdStr[i].length) + 40) / mmcw) * 15;
      }
    }else {
      mmch[i] = 15;
    }
  }

  this.mTextH = mmch;
},

笔者前段时间弄了一个微信公众号:前端Code新谈。里面暂时有webrtc、前端面试和用户体验系列文章,最近暂时搁置了webrtc,新开了一个系列“three.js”,欢迎关注!希望能够帮到大家,也希望能互相交流!一起学习共同进步

面试官 : “请你讲一下 JS 的 《垃圾回收机制》 ? ”

作者 千寻girling
2026年1月18日 16:37

1. 垃圾回收到底是什么?

JavaScript 是自动内存管理的语言,你不用手动申请 / 释放内存(比如 C/C++ 需要 malloc/free),垃圾回收就是 JS 引擎(如 V8)自动做的两件事:

  • 找 “垃圾” :识别出程序中不再使用的变量 / 对象(占用的内存就是 “垃圾内存”);
  • 清垃圾:释放这些 “垃圾” 占用的内存,避免内存泄漏、提升性能。

举个简单例子:

function fn() {
  let num = 10; // 函数执行时,num 占用内存
}
fn(); // 函数执行完后,num 再也访问不到了 → 变成“垃圾”,GC 会回收它的内存

2. JS 怎么判断 “哪些是垃圾”?

GC 不是瞎回收的,核心判断标准是:一个对象 / 变量是否还能被 “访问到”(是否有引用指向它)

  • 能访问到 → 存活(不回收);
  • 访问不到 → 垃圾(会被回收)。

3. JS 垃圾回收的核心算法(V8 引擎为主)

不同 JS 引擎的 GC 算法略有差异,但核心是两种:标记 - 清除(主流)和引用计数(辅助 / 历史)。

算法 1:标记 - 清除(Mark-and-Sweep,现代引擎主流)

这是 V8 最核心的 GC 算法,分为 “标记” 和 “清除” 两步,逻辑很直观:

GC 启动

标记阶段:从根对象(如 window/global)出发,遍历所有可访问的对象,打上“存活”标记

清除阶段:遍历堆内存,清除所有没有“存活”标记的对象,释放内存

内存整理可选):将空闲内存碎片合并,方便后续分配

举个例子理解

// 根对象:window(浏览器环境)
let obj1 = { name: "John" }; // obj1 被 window 引用 → 标记为存活
let obj2 = obj1; // obj2 也引用 obj1 → 还是存活
obj1 = null; // 解除 obj1 的引用,但 obj2 还指向 → 仍存活
obj2 = null; // 所有引用都解除 → obj1 无法访问 → 标记为垃圾,下次 GC 清除

优点

  • 解决了引用计数的 “循环引用” 问题(下面会说);
  • 逻辑简单,效率高。

缺点

  • 清除后会产生内存碎片(比如内存里零散的空闲空间),但 V8 会通过 “内存整理” 优化。

算法 2:引用计数(Reference Counting,历史算法,已淘汰核心场景)

早期(如 IE8 之前)的算法,逻辑是:给每个对象记录 “被引用的次数”,次数为 0 就回收

  • 当对象被引用 → 计数 + 1;
  • 当引用解除 → 计数 - 1;
  • 计数 = 0 → 立即回收。

例子

let obj = { a: 1 }; // 引用计数 = 1
let obj2 = obj;     // 引用计数 = 2
obj = null;         // 引用计数 = 1(还不能回收)
obj2 = null;        // 引用计数 = 0 → 变成垃圾,被回收

致命缺点:无法处理循环引用(这也是它被标记 - 清除取代的核心原因):

// 循环引用:obj1 和 obj2 互相引用,引用计数都为 1,永远不会为 0
let obj1 = {};
let obj2 = {};
obj1.fn = obj2;
obj2.fn = obj1;

// 即使解除外部引用,计数仍为 1 → 引用计数算法不会回收,造成内存泄漏
obj1 = null;
obj2 = null;

👉 而标记 - 清除算法能解决这个问题:因为 obj1 / obj2 都无法从根对象访问到,会被标记为垃圾,最终回收。

4. V8 引擎的 GC 优化(进阶,面试高频)

V8 为了提升 GC 效率,还做了针对性优化,核心是 “分代回收”:

  • 将内存分为 新生代(Young Generation)老生代(Old Generation)

    • 新生代:存储短期存活的对象(如函数内部的临时变量),GC 频率高、速度快(用 “Scavenge 算法”,复制 - 清除);
    • 老生代:存储长期存活的对象(如全局变量),GC 频率低,用 “标记 - 清除 + 标记 - 整理” 算法。
  • 优点:避免对整个内存做全量 GC,减少卡顿(JS 是单线程,GC 时会暂停代码执行,分代回收能缩短暂停时间)。

5. 常见的内存泄漏场景(GC 没回收的 “伪垃圾”)

垃圾回收不是万能的,如果代码写得不好,会导致 “本该回收的对象没被回收”,也就是内存泄漏,常见场景:

  1. 意外的全局变量(最常见):

    function fn() {
      num = 10; // 没写 let/var/const → 自动挂载到 window → 全局变量,永远不回收
    }
    
  2. 未清除的定时器 / 事件监听

    // 定时器引用了 obj,即使页面关闭前不清除定时器,obj 永远存活
    let obj = { data: "xxx" };
    setInterval(() => { console.log(obj); }, 1000);
    // 解决:不用时 clearInterval(timer)
    
  3. 闭包滥用

    function outer() {
      let bigData = new Array(1000000); // 大数组
      return function() { // 闭包引用 bigData,outer 执行完后 bigData 也不回收
        console.log(bigData);
      };
    }
    let fn = outer();
    // 解决:不用时 fn = null,解除引用
    

最后总结 🤔

  1. 核心本质:JS 垃圾回收是引擎自动回收 “不可访问” 对象的内存,避免手动管理内存的繁琐和错误。
  2. 核心算法:现代引擎以标记 - 清除为主(解决循环引用),引用计数已淘汰核心场景。
  3. V8 优化:分代回收(新生代 + 老生代)减少 GC 卡顿,提升性能。
  4. 避坑重点:避免意外全局变量、未清除的定时器 / 监听、滥用闭包,防止内存泄漏。

React 文本截断组件 rc-text-ellipsis

作者 wulala0102
2026年1月18日 16:32

前言

在前端开发中,文本截断(Text Ellipsis)是一个极其常见的需求。无论是新闻列表、商品描述、用户评论,还是各类卡片组件,我们经常需要在有限的空间内展示较长的文本内容。虽然 CSS 的 text-overflow: ellipsis 可以实现单行文本截断,但在面对多行文本、自定义省略位置、展开/收起功能等复杂场景时,纯 CSS 方案就显得力不从心了。

本文将深入剖析 rc-text-ellipsis 组件的设计与实现,这是一个功能强大、高度灵活的 React 文本截断组件,它不仅支持多行文本截断,还提供了三种省略位置(开始、中间、结尾)、展开/收起功能、自定义操作按钮、响应式自动重算等特性。

核心特性一览

在深入代码实现之前,让我们先了解一下这个组件提供的核心能力:

  • 🎯 精确的多行文本截断 - 支持任意行数的文本截断控制
  • 📍 三种省略位置 - 支持在文本开始、中间、结尾位置进行省略
  • 🔄 展开/收起功能 - 提供完整的状态管理和交互能力
  • 🎨 高度可定制 - 支持自定义操作按钮、省略符号等
  • 📱 响应式设计 - 窗口大小变化时自动重新计算
  • 🎛️ 命令式 API - 通过 ref 提供外部控制能力
  • 💪 TypeScript 支持 - 完整的类型定义
  • 高效算法 - 采用二分查找算法优化性能

技术架构设计

1. 组件结构

rc-text-ellipsis 采用了清晰的分层架构:

src/
├── TextEllipsis.tsx          # 主组件,负责渲染和事件处理
├── hooks/
│   └── useTextEllipsis.ts    # 核心 Hook,封装文本截断逻辑
└── utils.ts                   # 工具函数,实现算法和 DOM 操作

这种架构设计遵循了单一职责原则,将渲染逻辑、状态管理和计算逻辑清晰分离。

2. 主组件设计

主组件 TextEllipsis.tsx 采用了 React.forwardRef 来暴露命令式 API:

export interface TextEllipsisRef {
  toggle: (expanded?: boolean) => void;
}

const TextEllipsis = React.forwardRef<TextEllipsisRef, TextEllipsisProps>(
  (props, ref) => {
    // 组件实现
  }
);

这种设计让组件既可以作为受控组件使用,也可以通过 ref 进行外部控制,提供了极大的灵活性。

核心算法深度解析

1. 文本截断的本质挑战

实现文本截断看似简单,但实际上面临诸多挑战:

  1. 如何精确控制行数? - 不同字体、字号、行高下的行数计算
  2. 如何找到截断点? - 在保证不超过指定行数的前提下,尽可能多地显示文本
  3. 如何处理操作按钮? - 操作按钮本身也占用空间,需要纳入计算
  4. 如何优化性能? - 避免大量 DOM 操作和重排

2. 克隆容器技术

rc-text-ellipsis 的第一个巧妙设计是使用"克隆容器"技术:

export const cloneContainer = (
  rootElement: HTMLElement | null,
  content: string,
): HTMLDivElement | null => {
  const originStyle = window.getComputedStyle(rootElement);
  const container = document.createElement('div');

  // 复制所有样式
  const styleNames: string[] = Array.prototype.slice.call(originStyle);
  styleNames.forEach((name) => {
    container.style.setProperty(name, originStyle.getPropertyValue(name));
  });

  // 设置为离屏元素
  container.style.position = 'fixed';
  container.style.zIndex = '-9999';
  container.style.top = '-9999px';
  container.style.height = 'auto';

  container.innerText = content;
  document.body.appendChild(container);

  return container;
};

设计亮点:

  • 完整样式继承 - 通过 getComputedStyle 获取所有计算后的样式,确保克隆容器与原容器在渲染上完全一致
  • 离屏渲染 - 将克隆容器移出视口,避免影响页面布局和用户体验
  • 高度自适应 - 重置 heightminHeightmaxHeightauto,让内容自然撑开,便于测量真实高度

这种技术让我们可以在不影响实际 DOM 的情况下,进行各种测试和计算。

3. 二分查找算法

找到最佳截断点是文本截断的核心难题。rc-text-ellipsis 采用了二分查找算法,大大提升了查找效率:

const tail = (left: number, right: number): string => {
  // 递归终止条件
  if (right - left <= 1) {
    if (position === 'end') {
      return content.slice(0, left) + dots;
    }
    return dots + content.slice(right, end);
  }

  // 取中点
  const midPoint = Math.round((left + right) / 2);

  // 构造测试文本
  container.innerText = position === 'end'
    ? content.slice(0, midPoint) + dots
    : dots + content.slice(midPoint, end);
  container.innerHTML += actionHTML;

  // 判断是否超高
  if (container.offsetHeight > maxHeight) {
    // 超高了,需要缩短文本
    if (position === 'end') {
      return tail(left, midPoint);
    }
    return tail(midPoint, right);
  }

  // 还有空间,可以尝试显示更多文本
  if (position === 'end') {
    return tail(midPoint, right);
  }
  return tail(left, midPoint);
};

算法分析:

  • 时间复杂度:O(log n),其中 n 是文本长度
  • 空间复杂度:O(log n),递归调用栈
  • 对比暴力搜索:如果从头逐字符测试,时间复杂度是 O(n),在长文本场景下性能差距显著

直观理解:

假设文本有 1000 个字符:

  • 暴力搜索:最坏情况需要测试 1000 次
  • 二分查找:最多只需要测试 10 次(log₂1000 ≈ 10)

4. 中间位置省略的双指针算法

对于中间位置省略(middle position),问题变得更加复杂,因为需要同时确定左右两个截断点。rc-text-ellipsis 采用了双指针同步二分的策略:

const middleTail = (
  leftPart: [number, number],
  rightPart: [number, number],
): string => {
  // 递归终止条件
  if (leftPart[1] - leftPart[0] <= 1 && rightPart[1] - rightPart[0] <= 1) {
    return (
      content.slice(0, leftPart[0]) +
      dots +
      content.slice(rightPart[1], end)
    );
  }

  // 同时缩小左右两个搜索区间
  const leftMiddle = Math.floor((leftPart[0] + leftPart[1]) / 2);
  const rightMiddle = Math.ceil((rightPart[0] + rightPart[1]) / 2);

  container.innerText =
    content.slice(0, leftMiddle) +
    dots +
    content.slice(rightMiddle, end);
  container.innerHTML += actionHTML;

  if (container.offsetHeight >= maxHeight) {
    // 超高了,两边同时向中心收缩
    return middleTail(
      [leftPart[0], leftMiddle],
      [rightMiddle, rightPart[1]],
    );
  }

  // 还有空间,两边同时向外扩展
  return middleTail(
    [leftMiddle, leftPart[1]],
    [rightPart[0], rightMiddle],
  );
};

算法亮点:

  • 对称性设计 - 左右两边同步进行二分,保证文本均匀分布
  • 统一的时间复杂度 - 仍然是 O(log n)
  • 适用场景 - 特别适合文件路径、URL 等中间部分不重要的场景

例如:/very/long/path/to/some/deep/directory/important-file.txt 可以显示为:/very/long/.../important-file.txt

5. 精确的高度计算

准确计算最大允许高度是算法的基础:

const { paddingBottom, paddingTop, lineHeight } = container.style;
const maxHeight = Math.ceil(
  (Number(rows) + 0.5) * pxToNum(lineHeight) +
    pxToNum(paddingTop) +
    pxToNum(paddingBottom),
);

设计细节:

  • +0.5 行的容差 - 考虑到字体渲染的亚像素偏差,增加半行容差,避免边界情况下的截断错误
  • Math.ceil 向上取整 - 保守策略,确保不会超出限制
  • 包含 padding - 完整考虑盒模型,避免遗漏边距影响

状态管理与生命周期

1. 核心 Hook 设计

useTextEllipsis 是整个组件的核心大脑:

export const useTextEllipsis = (options: UseTextEllipsisOptions) => {
  const [text, setText] = React.useState(content);
  const [expanded, setExpanded] = React.useState(false);
  const [hasAction, setHasAction] = React.useState(false);
  const needRecalculateRef = React.useRef(false);

  // 核心计算逻辑
  const calcEllipsised = React.useCallback(() => {
    // ... 计算截断文本
  }, [content, rows, position, dots, expandText, action, rootRef, actionRef]);

  return { text, expanded, hasAction, toggle };
};

状态设计:

  • text - 当前显示的文本(可能是截断后的)
  • expanded - 展开/收起状态
  • hasAction - 是否需要显示操作按钮(文本够短时不需要)
  • needRecalculateRef - 标记是否需要重新计算(延迟计算优化)

2. 响应式更新

组件通过监听 window resize 事件实现响应式:

React.useEffect(() => {
  const handleResize = () => {
    calcEllipsised();
  };

  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, [calcEllipsised]);

这确保了在窗口大小变化时,文本截断能够实时调整,提供良好的用户体验。

3. 自定义操作按钮的延迟计算

针对自定义操作按钮,组件采用了巧妙的延迟策略:

React.useEffect(() => {
  calcEllipsised();

  if (action) {
    const timer = setTimeout(calcEllipsised, 0);
    return () => clearTimeout(timer);
  }
}, [calcEllipsised, action]);

为什么需要延迟?

当使用自定义 action 渲染函数时,第一次渲染时 actionRef.current 可能还未挂载,无法获取其 outerHTML。通过 setTimeout(..., 0) 将计算推迟到下一个事件循环,确保 DOM 已经完全渲染。

使用场景与最佳实践

1. 基础用法

最简单的使用方式:

import TextEllipsis from 'rc-text-ellipsis';
import 'rc-text-ellipsis/assets/index.css';

<TextEllipsis
  rows={3}
  content="这是一段很长的文本..."
  expandText="展开"
  collapseText="收起"
/>

2. 文件路径显示

利用 middle position 优雅地显示长路径:

<TextEllipsis
  position="middle"
  rows={1}
  content="/Users/username/Documents/Projects/MyApp/src/components/TextEllipsis.tsx"
/>

显示效果:/Users/username/.../TextEllipsis.tsx

3. 自定义操作按钮

实现更丰富的交互:

<TextEllipsis
  rows={2}
  content={longText}
  action={(expanded) => (
    <span style={{ color: '#1890ff', cursor: 'pointer' }}>
      {expanded ? '▲ 收起' : '▼ 查看更多'}
    </span>
  )}
/>

4. 外部控制

通过 ref 实现编程式控制:

const ref = useRef<TextEllipsisRef>(null);

// 在需要的时候展开
ref.current?.toggle(true);

// 切换状态
ref.current?.toggle();

性能优化策略

1. 使用 React.memo

对于列表渲染场景,建议配合 React.memo 使用:

const TextItem = React.memo(({ content }: { content: string }) => (
  <TextEllipsis rows={2} content={content} />
));

2. 避免频繁的 prop 变化

由于每次 content 变化都会触发重新计算,应该避免不必要的更新:

// ❌ 不好的做法
<TextEllipsis content={data.map(item => item.text).join(' ')} />

// ✅ 好的做法
const memoizedContent = useMemo(
  () => data.map(item => item.text).join(' '),
  [data]
);
<TextEllipsis content={memoizedContent} />

3. 合理设置 rows

rows 值越大,二分查找的迭代次数可能会增加。如果不需要很多行,尽量使用较小的值。

浏览器兼容性

组件使用的核心 API:

  • window.getComputedStyle - IE 9+
  • element.offsetHeight - 所有现代浏览器
  • Array.prototype.slice.call - ES5

兼容性总结:现代浏览器及 IE11+

与其他方案的对比

1. 纯 CSS 方案

.text-ellipsis {
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

优势:性能最好,无需 JavaScript 局限

  • 仅支持 end position
  • 无法添加展开/收起功能
  • 无法自定义省略符号位置
  • 兼容性较差(需要 -webkit- 前缀)

2. React-Text-Truncate

优势:较为成熟的社区方案 对比 rc-text-ellipsis

  • ✅ rc-text-ellipsis 支持三种省略位置
  • ✅ rc-text-ellipsis 提供了 ref API
  • ✅ rc-text-ellipsis 使用二分查找,性能更优

总结与展望

rc-text-ellipsis 是一个设计精良、实现优雅的文本截断组件。它的核心价值在于:

  1. 算法优化 - 采用二分查找而非线性搜索,性能优异
  2. 功能完整 - 支持多种省略位置、展开/收起、自定义按钮等
  3. 工程化优秀 - TypeScript 支持、清晰的代码结构、完善的测试
  4. 用户体验好 - 响应式、流畅的交互

未来可能的改进方向:

  • 支持虚拟滚动场景的优化
  • 提供服务端渲染(SSR)支持
  • 支持动画过渡效果
  • 支持更多的自定义钩子(如 onExpand、onCollapse)

参考资源

希望这篇文章能够帮助你深入理解文本截断组件的实现原理,并在自己的项目中灵活运用这些技术。如果你有任何问题或建议,欢迎在 GitHub 上提 issue 讨论。

加上这个React最佳实践Skill,再也不怕AI写代码不考虑性能优化了

作者 墨舟
2026年1月18日 16:12

Vercel 团队维护的 React 和 Next.js 性能优化指南的Skill: React Best Practices,包含 45 条规则,8大类的优化的方向:

类别 说明
消除瀑布流请求 瀑布流是性能杀手,每个顺序 await 都会增加完整的网络延迟
Bundle 体积优化 减少初始 bundle 大小,改善首次可交互时间(TTI)和最大内容绘制(LCP)
服务端性能 优化服务端渲染和数据获取,消除服务端瀑布流,减少响应时间
客户端数据获取 自动去重和高效的数据获取模式,减少冗余网络请求
重渲染优化 减少不必要的重渲染,最小化浪费的计算,提升 UI 响应性
渲染性能 优化渲染过程,减少浏览器的工作量
JavaScript 性能 热路径的微优化,积少成多也能带来明显改善
高级模式 针对特定场景的高级模式,需要谨慎实现

接着我们来具体看看第一条:消除瀑布流请求这个类别,它一共分为了5条rules:

1、async-defer-await:在真正需要的时候再使用await

核心思想:在真正需要使用到 await 时使用,避免阻塞前置不需要 await 的地方。

错误示例(两个分支都被阻塞):

async function handleRequest(userId: string, skipProcessing: boolean) {
  const userData = await fetchUserData(userId)  // 总是等待

  if (skipProcessing) {
    // 虽然立即返回,但已经等待了 userData
    return { skipped: true }
  }

  // 只有这个分支使用 userData
  return processUserData(userData)
}

正确示例(只在需要时阻塞):

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    // 立即返回,无需等待
    return { skipped: true }
  }

  // 只在需要时获取
  const userData = await fetchUserData(userId)
  return processUserData(userData)
}

另一个实际场景:

// 错误:每次都获取权限
async function updateResource(resourceId: string, userId: string) {
  const permissions = await fetchPermissions(userId)
  const resource = await getResource(resourceId)

  if (!resource) {
    return { error: 'Not found' }
  }

  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }

  return await updateResourceData(resource, permissions)
}

// 正确:只在需要时获取
async function updateResource(resourceId: string, userId: string) {
  const resource = await getResource(resourceId)

  if (!resource) {
    return { error: 'Not found' }  // 提前返回,省掉权限请求
  }

  const permissions = await fetchPermissions(userId)

  if (!permissions.canEdit) {
    return { error: 'Forbidden' }
  }

  return await updateResourceData(resource, permissions)
}

2、async-parallel:使用 Promise.all() 并发执行

核心思想:当每个异步操作之间没有依赖关系时,可以使用 Promise.all() 来进行并发执行

错误示例(串行执行,3 次网络往返):

const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()

正确示例(并行执行,1 次网络往返):

const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
])

这可能是最简单但也是最有效的优化之一。

3、async-dependencies:使用 better-all 处理部分依赖

核心思想:当异步操作之间存在依赖时,可以使用 better-all 库来自动最大化并行度。

问题场景:profile 依赖 user,但 config 不依赖其他操作。

错误示例(profile 不必要地等待 config):

const [user, config] = await Promise.all([
  fetchUser(),
  fetchConfig()
])
const profile = await fetchProfile(user.id)  // config 已完成,但 profile 还要等

正确示例(config 和 profile 并行运行):

import { all } from 'better-all'

const { user, config, profile } = await all({
  async user() { return fetchUser() },
  async config() { return fetchConfig() },
  async profile() {
    // 只等待 user,不等待 config
    return fetchProfile((await this.$.user).id)
  }
})

better-all 会自动分析依赖关系,在最早可能的时刻启动每个任务。

参考:github.com/shuding/bet…

4、async-api-routes:API 路由中尽早开始 Promise

核心思想:在 API 路由和 Server Actions 中,立即启动独立操作,即使你还不需要 await 它们。

错误示例(config 等待 auth,data 等待两者):

export async function GET(request: Request) {
  const session = await auth()
  const config = await fetchConfig()  // 等 auth 完成才开始
  const data = await fetchData(session.user.id)
  return Response.json({ data, config })
}

正确示例(auth 和 config 立即并行启动):

export async function GET(request: Request) {
  // 立即启动,不等待
  const sessionPromise = auth()
  const configPromise = fetchConfig()

  // 需要 session 时才 await
  const session = await sessionPromise

  // config 和 data 并行获取
  const [config, data] = await Promise.all([
    configPromise,
    fetchData(session.user.id)
  ])

  return Response.json({ data, config })
}

关键技巧:先启动 Promise,后 await。

5、async-suspense-boundaries:使用 Suspense

核心思想:不要在 async 组件中 await 数据后再返回 JSX,而是使用 Suspense 边界让外层 UI 先显示。

错误示例(整个页面被数据获取阻塞):

async function Page() {
  const data = await fetchData()  // 阻塞整个页面

  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <DataDisplay data={data} />
      </div>
      <div>Footer</div>
    </div>
  )
}

整个布局都要等数据,尽管只有中间部分需要它。

正确示例(外层立即显示,数据流式加载):

function Page() {
  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <div>
        <Suspense fallback={<Skeleton />}>
          <DataDisplay />
        </Suspense>
      </div>
      <div>Footer</div>
    </div>
  )
}

async function DataDisplay() {
  const data = await fetchData()  // 只阻塞这个组件
  return <div>{data.content}</div>
}

Sidebar、Header、Footer 立即渲染,只有 DataDisplay 等待数据。

进阶:多个组件共享 Promise

function Page() {
  // 立即开始获取,但不 await
  const dataPromise = fetchData()

  return (
    <div>
      <div>Sidebar</div>
      <div>Header</div>
      <Suspense fallback={<Skeleton />}>
        <DataDisplay dataPromise={dataPromise} />
        <DataSummary dataPromise={dataPromise} />
      </Suspense>
      <div>Footer</div>
    </div>
  )
}

function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise)  // 使用 React 19 的 use() hook
  return <div>{data.content}</div>
}

function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
  const data = use(dataPromise)  // 复用同一个 Promise
  return <div>{data.summary}</div>
}

两个组件共享同一个 Promise,只发起一次请求。

注意:这种方式需要权衡。更快的首次绘制 vs 重绘重排

如何安装这个 Skill

直接将github的这整个文件夹:github.com/vercel-labs… 放到 ~/.claude/skills(mac电脑)下即可 。安装完成后,Claude Code会自动判断是否在需要的时候使用这个skill,当然也可以通过以下方式直接调用这个 Skill:

/vercel-react-best-practices

React Router进阶:懒加载、权限控制与性能优化

作者 ohyeah
2026年1月18日 16:09

引言:现代前端路由的重要性

在单页面应用(SPA)的架构中,前端路由扮演着至关重要的角色。与传统的多页面应用不同,SPA 通过前端路由实现页面间的无缝切换,无需每次跳转都向服务器请求完整的 HTML 文档。React Router 作为 React 生态中最流行的路由解决方案,提供了强大而灵活的路由管理能力。

本文将通过一个完整的 React Router 实践项目,深入剖析路由配置、组件懒加载、动态路由、嵌套路由、路由守卫等核心概念,帮助你掌握现代前端路由的最佳实践。

项目架构概览

首先让我们了解项目的整体结构:

src/
├── App.jsx                 # 应用根组件,配置路由容器
├── router/
│   └── index.jsx          # 路由配置文件
├── components/             # 通用组件目录
│   ├── Navigation.jsx     # 导航组件
│   ├── ProtectRoute.jsx   # 路由守卫组件
│   └── LoadingFallback/   # 加载状态组件
│       ├── index.jsx
│       └── index.module.css
├── pages/                  # 页面组件目录
│   ├── Home.jsx           # 首页
│   ├── About.jsx          # 关于页
│   ├── Login.jsx          # 登录页
│   ├── UserProfile.jsx    # 用户详情页
│   ├── NotFound.jsx       # 404页面
│   ├── Pay.jsx            # 支付页面
│   ├── NewPath.jsx        # 新路径页面
│   └── product/           # 产品相关页面
│       ├── index.jsx      # 产品列表页
│       ├── ProductDetail.jsx
│       └── NewProduct.jsx
└── index.css              # 全局样式

这种模块化的目录结构清晰地将路由配置、页面组件和通用组件分离,便于维护和扩展。

核心配置:路由容器的建立

App.jsx:路由容器的封装

App.jsx 中,我们建立了整个应用的路由基础框架:

import {
  BrowserRouter as Router,
  // HashRouter
} from 'react-router-dom'

import Navigation from './components/Navigation'
import RouterConfig from './router'

export default function App(){
  return (
    <Router>
      <Navigation />
      <RouterConfig />
    </Router>
  )
}

这里有几个关键点需要注意:

  1. 路由模式选择:我们使用 BrowserRouter 作为路由容器。与 HashRouter 相比,BrowserRouter 使用 HTML5 History API 实现路由,URL 更加清晰(没有 # 符号)。这在现代浏览器中得到良好支持,且有利于 SEO 优化,并且所有的<Link> useNavigate useParams等Hook必须在BrowserRouter的子组件树中才能正常工作。

  2. 组件分离策略:将导航组件和路由配置组件分离,这种设计模式使得代码结构更加清晰。导航组件负责所有导航链接的展示,而路由配置组件专注于路由规则的声明。

  3. 路由层级关系:注意 Navigation 组件在 RouterConfig 之前,这意味着无论路由如何切换,导航栏都会保持显示,这是典型的 SPA 导航模式。

高级路由配置详解

路由懒加载:性能优化的利器

router/index.jsx 中,我们实现了全面的路由懒加载策略:

import { Route,Routes, Navigate } from 'react-router-dom'
import { lazy, Suspense } from 'react'
import LoadingFallback from '../components/LoadingFallback'

// 动态引入页面组件
const Home = lazy(() => import('../pages/Home'))
const About = lazy(() => import('../pages/About'))
const UserProfile = lazy(() => import('../pages/UserProfile'))
const Product = lazy(() => import('../pages/product'))
const ProductDetail = lazy(() => import('../pages/product/ProductDetail'))
const NewProduct = lazy(() => import('../pages/product/NewProduct'))
const Login = lazy(() => import('../pages/Login'))
const ProtectRoute = lazy(() => import('../components/ProtectRoute'))
const Pay = lazy(() => import('../pages/Pay'))
const NotFound = lazy(() => import('../pages/NotFound'))
const NewPath = lazy(() => import('../pages/NewPath'))

懒加载的核心机制

动态 import 语法import('../pages/Home') 返回一个 Promise,Webpack 等打包工具会将其识别为代码分割点,单独打包成一个 chunk。

React.lazy 的工作原理

  • React.lazy() 接收一个返回 Promise 的函数
  • 当组件首次渲染时,React 调用该函数,触发动态导入
  • 导入过程中,React 会抛出(throw)这个 Promise
  • <Suspense> 组件捕获这个 Promise,并显示 fallback 内容
  • Promise 解析完成后,React 重新渲染,显示真实组件

这种机制的优势在于:

  • 减小初始包体积:应用启动时只加载必要的代码
  • 按需加载:用户在访问特定路由时才加载对应代码
  • 优化用户体验:减少首屏加载时间,特别对于大型应用

为什么首页也要懒加载?

很多人误以为首页必须同步加载。但实际上,用户可能通过分享链接直接访问/about 或 /user/123,这种情况下加载首页就会产生浪费

Suspense 的优雅降级

<Suspense fallback={<LoadingFallback/>}>
    <Routes>
        {/* 路由配置 */}
    </Routes>
</Suspense>

Suspense 组件为所有懒加载组件提供了统一的加载状态管理。fallback 属性接受一个 React 元素,在子组件加载期间显示。这里我们使用了自定义的 LoadingFallback 组件,提供了美观的加载动画。

路由守卫的实现

保护路由是应用中常见的需求,特别是在需要用户认证的场景:

export default function ProtectRoute({ children }){
  const isLoggedIn = localStorage.getItem('isLogin') === 'true'
  if(!isLoggedIn){
    return <Navigate to="/login" />
  }
  
  return children
}

在路由配置中使用:

<Route path="/pay" element={
  <ProtectRoute>
    <Pay />
  </ProtectRoute>
}>
</Route>

这种实现方式具有以下特点:

  1. 高阶组件模式ProtectRoute 作为高阶组件,接收子组件作为参数
  2. 条件重定向:通过检查认证状态决定是否重定向到登录页
  3. 无侵入性:被保护的组件无需关心认证逻辑,保持了组件的纯粹性
  4. 灵活扩展:可以轻松添加其他权限检查逻辑

路由类型全解析

基础路由

<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />

这是最基本的路由配置,直接映射路径到对应组件。

动态路由:参数化路径

<Route path="/user/:id" element={<UserProfile />} />

UserProfile 组件中获取参数:

import { useParams } from "react-router-dom"

export default function UserProfile(){
  const { id } = useParams()
  return <div>UserProfile {id}</div>
}

动态路由的特点:

  • :id 是路径参数占位符
  • 可以匹配 /user/123/user/abc 等路径
  • useParams() 返回包含所有参数的对象
  • 支持多个参数:path="/product/:category/:id"

嵌套路由:父子路由关系

<Route path="/products" element={<Product/>}>
  <Route path=":productId" element={<ProductDetail/>} />
  <Route path="new" element={<NewProduct />} />
</Route>

在父组件中使用 <Outlet /> 渲染子路由:

import { Outlet } from "react-router-dom"

export default function Product(){
  return (
    <>
      Product
      <Outlet />
    </>
  )
}

嵌套路由的优势:

  • 共享布局:父组件可以包含导航、页眉、页脚等共享元素
  • 层次化URL/products/123/products/new 有清晰的层级关系
  • 独立渲染:子路由变化时,父组件可以保持不变

重定向路由

<Route path='/old-path' element={<Navigate replace to='/new-path'/>} />

<Navigate /> 组件在渲染时会执行重定向:

  • replace 属性:为 true 时替换当前历史记录,而不是添加新记录
  • to 属性:目标路径,可以是绝对路径或相对路径
  • 避免用户点击浏览器后退按钮时再次进入旧路径

通配符路由:404处理

<Route path='*' element={<NotFound />} />

通配符路由 * 匹配所有未匹配的路径,通常用于404页面。必须放在 <Routes> 的最后,否则会拦截所有路由。

NotFound 组件中,我们实现了自动重定向:

import { useNavigate } from "react-router-dom"
import { useEffect } from "react"

const NotFound = () => {
  let navigate = useNavigate()
  
  useEffect(() => {
    setTimeout(() => {
      navigate('/')
    }, 6000)
  }, [])
  
  return <div>NotFound</div>
}

这里使用了 useNavigate hook 进行编程式导航,结合 setTimeout 实现延迟跳转,6秒后自动跳回首页。

导航组件的实现细节

智能导航链接

Navigation.jsx 中,我们实现了带有活动状态指示的导航链接:

import { Link, useResolvedPath, useMatch } from "react-router-dom"

export default function Navigation(){
  const isActive = (to) => {
    const resolvedPath = useResolvedPath(to)
    
    const match = useMatch({
      path: resolvedPath.pathname,
      end: true
    })
    
    return match ? 'active' : ''
  }

  return (
    <nav>
      <ul>
        <li>
          <Link to="/" className={isActive('/')}>Home</Link>
        </li>
        <li>
          <Link to="/about" className={isActive('/about')}>About</Link>
        </li>
        {/* 其他链接 */}
      </ul>
    </nav>
  )
}

关键Hook解析

useResolvedPath:将传入的 to 值解析为标准的路径对象。这确保了无论传入的是相对路径还是绝对路径,都能正确解析。

useMatch:将解析后的路径与当前URL进行匹配:

  • path:要匹配的路径模式
  • end: true:要求精确匹配。如果设为 false,路径 / 会匹配所有以 / 开头的路由(如 /about),导致多个链接同时显示激活状态

导航链接的类型

  1. 基础导航<Link to="/about">About</Link>
  2. 嵌套路由导航<Link to="/products/new">Product New</Link>
  3. 带参数路由导航<Link to="/products/123">Product Detail</Link>

样式与用户体验优化

加载状态组件

LoadingFallback 组件通过CSS动画提供了优雅的加载状态指示:

/* 旋转动画 */
@keyframes spin {
  from{ transform: rotate(0deg); }
  to{ transform: rotate(360deg); }
}

/* 呼吸效果动画 */
@keyframes pulse {
  0% { opacity: 0.6; }
  50% { opacity: 1; }
  100% { opacity: 0.6; }
}

这种设计提升了用户体验,避免了页面切换时的突兀感。

活动状态样式

index.css 中定义了活动状态的样式:

.active{
  color: red;
}

通过动态添加 active 类名,用户可以清晰地了解当前所在页面。

最佳实践总结

1. 代码分割策略

  • 将路由组件按需加载,减小初始包体积
  • 即使是首页也可以考虑懒加载,适用于直接访问深层链接的场景
  • 使用统一的 Suspense 边界管理加载状态

2. 路由组织原则

  • 使用扁平化的路由配置结构
  • 嵌套路由用于有明确父子关系的页面
  • 路由配置与组件定义分离,便于维护

3. 导航设计要点

  • 保持导航组件的独立性
  • 提供清晰的活动状态指示
  • 支持编程式导航和声明式导航

4. 错误处理与边界

  • 使用通配符路由处理404情况
  • 考虑用户友好型的错误提示
  • 实现自动重定向机制

5. 权限控制实现

  • 使用高阶组件模式实现路由守卫
  • 分离认证逻辑和业务逻辑
  • 提供友好的未授权处理(重定向到登录页)

状态管理集成

在实际项目中,路由状态经常需要与全局状态管理(如Redux、Context)集成:

  1. 路由参数同步:将路由参数同步到全局状态
  2. 导航状态管理:在状态管理中记录导航历史
  3. 权限状态集成:将路由守卫与全局权限状态结合

测试策略

  1. 路由配置测试:确保所有路由正确配置
  2. 导航组件测试:验证链接和活动状态
  3. 路由守卫测试:测试不同权限状态下的路由行为

结语

通过这个完整的 React Router 6 实践项目,我们深入探讨了现代前端路由的各个方面。从基础的路由配置到高级的懒加载、嵌套路由和路由守卫,React Router 提供了强大而灵活的工具集来构建复杂的单页面应用。

记住,良好的路由设计不仅仅是技术实现,更是用户体验的重要组成部分。合理的路由结构、清晰的URL设计和流畅的页面过渡都能显著提升应用质量。

随着 React 生态的不断发展,路由相关的模式和最佳实践也在不断演进。保持学习,持续优化,才能在快速变化的前端领域中保持竞争力。

前端样式工程化三剑客:CSS Modules、Scoped CSS 与 CSS-in-JS 深度实战

2026年1月18日 15:37

前言:为什么我们需要“工程化”样式?

在早期的前端开发中,CSS 是全局的。我们写一个 .button { color: red },它会立刻影响页面上所有的按钮。这在小型项目中或许可行,但在大型应用、多人协作或开源组件库开发中,这无异于“灾难”——样式冲突、优先级战争(Specificity Wars)层出不穷。

为了解决这个问题,现代前端框架提出了三种主流的解决方案:

  1. CSS Modules (React 生态主流方案)
  2. Scoped CSS (Vue 生态经典方案)
  3. CSS-in-JS (Stylus Components 为代表的动态方案)

本文将通过三个实战 Demo (css-demo, vue-css-demo, styled-component-demo),带你深入理解这三种方案的原理、差异及面试考点。


第一部分:CSS Modules - 基于文件的模块化

场景描述:
css-demo 项目中,我们不再直接使用全局的 CSS,而是利用 Webpack 等构建工具,将 CSS 文件编译成 JavaScript 对象。

1.1 核心原理

CSS Modules 并不是一门新的语言,而是一种编译时的解决方案。

  • 编译机制:构建工具(如 Webpack)会将 .module.css 文件编译成一个 JS 对象。
  • 局部作用域:默认情况下,CSS Modules 中的类名是局部的。构建工具会将类名(如 .button)编译成唯一的哈希值(如 _src-components-Button-module__button__23_a0)。
  • 导入方式:在组件中,我们通过 import styles from './Button.module.css' 导入这个对象,然后通过 styles.button 动态绑定类名。

1.2 代码实战解析

在我们的 Demo 中,定义了一个按钮组件:

// Button.jsx
import styles from './Button.module.css';

export default function Button() {
  return (
    <button className={styles.button}>My Button</button>
  );
}

对应的样式文件:

/* Button.module.css */
.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}

发生了什么?

  1. 构建工具读取 Button.module.css
  2. .button 转换为类似 _button_hash123 的唯一类名。
  3. 生成一个对象:{ button: '_button_hash123' }
  4. JSX 渲染时,className 变成了唯一的哈希值,实现了样式隔离。

1.3 答疑解惑与面试宝典

Q1:CSS Modules 是如何解决样式冲突的?

  • 答: 核心在于哈希化(Hashing) 。它利用构建工具,在编译阶段将局部类名映射为全局唯一的哈希类名。由于哈希值的唯一性,不同组件即使定义了同名的 .button,最终生成的 CSS 类名也是不同的,从而从根本上杜绝了冲突。

Q2:CSS Modules 和普通的 CSS import 有什么区别?

  • 答:

    • 普通 CSSimport './style.css' 只是引入了样式,类名依然是全局的。
    • CSS Modulesimport styles from './style.module.css' 将样式变成了 JS 对象,你必须通过对象的属性来引用类名,从而强制实现了作用域隔离。

Q3:如何在 CSS Modules 中使用全局样式?

  • 答: 虽然不推荐,但有时确实需要。可以通过 :global 伪类来声明:

    :global(.global-class) {
      color: red;
    }
    

    这样 global-class 就不会被哈希化,保持全局生效。


第二部分:Vue Scoped CSS - 属性选择器的魔法

场景描述:
vue-css-demo 项目中,我们使用 Vue 单文件组件(SFC)的经典写法,通过 <style scoped> 实现样式隔离。

2.1 核心原理

Vue 的 scoped 属性实现原理与 CSS Modules 截然不同,它采用的是属性选择器方案。

  • 编译机制:Vue Loader 会为组件中的每个 DOM 元素生成一个唯一的属性(例如 data-v-f3f3eg9)。
  • 样式重写:同时,它会将 <style scoped> 中的选择器(如 .txt)重写为属性选择器(如 .txt[data-v-f3f3eg9])。
  • 作用域限制:由于只有当前组件的 DOM 元素拥有该属性,样式自然只能作用于当前组件。

2.2 代码实战解析

在 Vue 的 Demo 中,我们有两个层级:App.vueHelloWorld.vue

<!-- App.vue -->
<template>
  <div>
    <h1 class="txt">Hello world in App</h1>
    <HelloWorld />
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
</style>
<!-- HelloWorld.vue -->
<template>
  <div>
    <h1 class="txt">你好,世界!!!</h1>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
</style>

发生了什么?

  1. 编译后,App.vue 中的 <h1> 标签被加上了 data-v-abc123 属性。
  2. App.vue 的 CSS 变成了 .txt[data-v-abc123] { color: red }
  3. 编译后,HelloWorld.vue 中的 <h1> 标签被加上了 data-v-xyz456 属性。
  4. HelloWorld.vue 的 CSS 变成了 .txt[data-v-xyz456] { color: blue }
  5. 结果:父子组件的 .txt 类名互不干扰,各自生效。

2.3 答疑解惑与面试宝典

Q1:Vue Scoped 的性能怎么样?

  • 答: 性能通常很好,但也有局限。它只生成一次属性,且利用了浏览器原生的属性选择器能力。但是,如果组件层级很深,属性选择器的权重会增加。此外,它无法穿透子组件(即父组件的 scoped 样式无法直接修改子组件的样式),这是它的设计初衷,也是需要注意的点。

Q2:如何修改子组件的样式?(深度选择器)

  • 答: 当需要修改第三方组件库(如 Element Plus)的样式时,scoped 会失效。Vue 提供了深度选择器:

    • Vue 2:使用 >>>/deep/
    • Vue 3:使用 :deep()
    /* Vue 3 写法 */
    .parent-class :deep(.child-class) {
      color: red;
    }
    

Q3:scoped 会导致样式权重增加吗?

  • 答: 会。 因为它变成了属性选择器,例如 .txt 变成了 .txt[data-v-123],其权重高于普通的类选择器。如果在全局样式中写了 .txt { color: blue },而在 scoped 中写了 .txt { color: red },scoped 的样式会因为权重更高而覆盖全局样式。

第三部分:Stylus Components - CSS-in-JS 的动态艺术

场景描述:
styled-component-demo 项目中,我们将 CSS 直接写在 JavaScript 文件中,通过模板字符串创建组件。

3.1 核心原理

CSS-in-JS 是一种运行时的解决方案(虽然也支持 SSR 和编译时优化)。

  • 组件即样式:它将样式直接绑定到组件上。你不是在组件中引用类名,而是直接创建一个“带样式的组件”。
  • 动态性:样式可以像 JS 变量一样使用,支持传参(Props)。这使得主题切换、动态样式变得非常简单。
  • 唯一性:生成的类名也是唯一的(通常基于组件名和哈希),确保不污染全局。

3.2 代码实战解析

// App.jsx
import styled from 'styled-components';

// 创建一个带样式的 Button 组件
const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  padding: 8px 16px;
`;

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  );
}

发生了什么?

  1. 组件定义styled.button 是一个函数,它接收模板字符串作为参数,返回一个 React 组件。
  2. 动态插值:在模板字符串中,我们可以使用 JavaScript 逻辑(如三元表达式)来根据 props 动态生成 CSS。
  3. 渲染:当 <Button primary> 渲染时,库会根据逻辑生成对应的 CSS 规则(如 background: blue),注入到 <head> 中,并将生成的唯一类名应用到 DOM 上。

3.3 答疑解惑与面试宝典

Q1:CSS-in-JS 的优缺点是什么?

  • 答:

    • 优点:极致的动态能力(基于 Props 的样式)、天然的组件隔离、支持主题(Theme)、解决了全局污染问题。
    • 缺点:运行时性能开销(需要 JS 计算生成 CSS)、CSS 文件体积无法单独缓存(随 JS 打包)、调试时类名可读性差(全是哈希)、学习成本较高。

Q2:CSS-in-JS 和 CSS Modules 哪个更好?

  • 答: 没有绝对的好坏,取决于场景。

    • CSS Modules:适合对性能要求极高、样式逻辑简单的项目,或者团队习惯传统的 CSS 写法。
    • CSS-in-JS:适合组件库开发、需要高度动态样式(如主题切换)、或者团队追求极致的组件封装性。

Q3:面试官问“你怎么看待把 CSS 写在 JS 里?”

  • 答: 这是一个经典的“分离关注点”争论。

    • 传统观点认为 HTML/CSS/JS 应该分离。
    • 现代组件化观点认为,组件才是关注点。一个 Button 组件的逻辑、结构和样式是紧密耦合的,放在一起更利于维护和复用。CSS-in-JS 正是这种理念的体现。

第四部分:三剑客终极对比与选型建议

为了让你更直观地理解,我整理了以下对比表:

特性 CSS Modules Vue Scoped CSS-in-JS (Stylus Components)
作用域机制 哈希类名 (编译时) 属性选择器 (编译时) 哈希类名 (运行时/编译时)
动态性 弱 (需配合 classnames 库) 中 (需配合动态 class 绑定) (直接使用 JS 逻辑)
学习成本 低 (仍是 CSS) 低 (Vue 特性) 中 (需学习新 API)
调试难度 低 (类名清晰) 中 (类名哈希化)
适用场景 大型 React 应用 Vue 2/3 项目 组件库、高动态 UI

选型建议:

  1. 如果你在用 Vue:首选 scoped,简单高效。如果项目非常复杂,可以考虑 CSS Modules 或 CSS-in-JS。

  2. 如果你在用 React

    • 如果追求性能和工程化规范,选 CSS Modules
    • 如果追求极致的组件封装和动态主题,选 CSS-in-JS (如 Stylus Components 或 Emotion)。
    • 如果是新项目,也可以考虑 Tailwind CSS 等 Utility-First 方案。

结语:样式工程化的未来

从全局 CSS 到现在的模块化、组件化,前端样式的发展始终围绕着**“隔离”“复用”**这两个核心矛盾。

CSS Modules 和 Vue Scoped 通过编译时手段解决了隔离问题,而 CSS-in-JS 则通过运行时手段赋予了样式以逻辑能力。

无论你选择哪一种方案,理解其背后的原理(哈希化、属性选择器、动态注入)都是至关重要的。希望这篇博客能帮助你在 css-demovue-css-demostyled-component-demo 三个项目中游刃有余,并在面试中脱颖而出。

最后的思考题:

  • 如果让你设计一个组件库(如 Ant Design),你会选择哪种方案?为什么?(提示:考虑主题定制和样式隔离的平衡)

附录:常见面试题汇总

  1. Vue scoped 的原理是什么?

    • 答:通过属性选择器。给组件元素加唯一属性,给样式加属性选择器。
  2. React 中如何实现 CSS Modules?

    • 答:文件名加 .module.css,导入为对象,通过对象属性绑定 className。
  3. CSS-in-JS 的性能瓶颈在哪里?

    • 答:运行时计算样式、注入 CSSOM 的操作(虽然现代库做了很多优化,如缓存)。
  4. 如何解决 CSS Modules 中的长类名问题?

    • 答:通常不需要解决,构建工具会压缩。如果在 DevTools 中调试,可以配置 Webpack 的 localIdentName 来生成可读的开发类名。
  5. Shadow DOM 和上述方案有什么区别?

    • 答:Shadow DOM 是浏览器原生的样式隔离方案,隔离性最强(完全独立的 DOM 树),但兼容性和集成成本较高。上述方案都是基于现有 DOM 的模拟隔离。

React 组件封装最佳实践:6 条让你少掉头发的秘诀

2026年1月18日 15:20

大家好!今天我们来聊聊前端开发中一个既让人头疼又让人欲罢不能的话题——React 组件的封装。毕竟,谁不想写出优雅又聪明的代码,让同事们看了都忍不住给你点个赞呢?

在前端开发的江湖中,React 组件封装可是修炼内功的必经之路。一个好的组件能让你的代码像丝绸一样顺滑,而一个差劲的组件...嗯,可能会让你怀疑人生。今天,我们就聊聊 React 组件封装的 6 条最佳实践,助你在前端世界中少踩坑、少掉发!


1. 单一职责:一个组件只干一件事

首先,咱们得明确一点:组件不是超人,它没必要啥都干!一个组件的职责越多,它的复杂度就越高,最后就会变成“代码乱炖”。试想一下,你让一个按钮组件既负责显示文字,又负责管理 API 调用,再顺便管管用户登录状态,这不就是逼着它“过劳死”吗?

最佳实践:

  • 每个组件只做一件事,且做好它。
  • 如果发现某个组件的代码越来越长,那就停下来想想,是不是可以拆分成更小的子组件。

搞笑案例:

// 一个灾难性的“万能组件”
const DisasterComponent = () => {
  // 管理状态
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [data, setData] = useState([]);
  
  // 做 API 调用
  useEffect(() => {
    fetch('/api/data').then(res => res.json()).then(setData);
  }, []);
  
  return (
    <div>
      {isLoggedIn ? '欢迎回来!' : '请登录'}
      <button onClick={() => setIsLoggedIn(!isLoggedIn)}>切换登录状态</button>
      <ul>
        {data.map(item => <li key={item.id}>{item.name}</li>)}
      </ul>
    </div>
  );
};

这代码看得头皮发麻吧?拆分成子组件后,世界瞬间清净了!


2. 可复用性:别把组件写得像一次性筷子

写 React 组件时,千万别想着“反正这个功能只用一次”。你以为是一次性筷子,结果下次又有类似需求时,只能再造一个差不多的轮子。这样一来,你的代码仓库很快会变成“轮子博物馆”。

最佳实践:

  • 把通用逻辑抽出来,做成可复用的组件。
  • 如果有特殊需求,可以通过 props 或者 children 来定制化。

搞笑案例:

// 不可复用的“土味按钮”
const RedButton = () => <button style={{ color: 'red' }}>红色按钮</button>;
const BlueButton = () => <button style={{ color: 'blue' }}>蓝色按钮</button>;

改进后:

const Button = ({ color, children }) => (
  <button style={{ color }}>{children}</button>
);

// 用法
<Button color="red">红色按钮</Button>
<Button color="blue">蓝色按钮</Button>

看吧,一个通用的 Button 分分钟解决问题!


3. 状态管理:别把状态塞进每个角落

React 的状态管理就像养宠物,你得好好规划它们住在哪儿、吃什么。如果你随便把状态塞进每个组件,那到最后,整个项目可能就会变成“状态迷宫”,连你自己都找不到北。

最佳实践:

  • 状态应该尽量提升到最近的公共父组件,或用 Context/Redux/Zustand 等进行集中管理。
  • 别让不需要状态的组件也参与管理,保持它们“无状态”的清爽模样。

搞笑案例:

// 状态管理得像一锅粥
const Parent = () => {
  const [value, setValue] = useState('');
  
  return (
    <Child value={value} setValue={setValue} />
  );
};

const Child = ({ value, setValue }) => (
  <input value={value} onChange={e => setValue(e.target.value)} />
);

如果状态特别复杂,用 Context 或 Redux 把它们“拎出去单独养”,这样父子关系会更和谐。


4. Prop 验证:别让你的组件乱吃东西

React 的 props 就像是给组件喂的饭菜。如果你不给它规定饮食,它可能会吃坏肚子(运行错误)。所以啊,Prop 验证非常重要!

最佳实践:

  • 使用 PropTypes 或 TypeScript 来验证 props 的类型和必要性。
  • 如果某个 prop 是必需的,一定要标记出来。

搞笑案例:

// 没有 Prop 验证的灾难现场
const Greeting = ({ name }) => (
  <h1>你好,{name.toUpperCase()}!</h1> // name 万一是 undefined 呢?
);

改进后:

import PropTypes from 'prop-types';

const Greeting = ({ name }) => (
  <h1>你好,{name.toUpperCase()}!</h1>
);

Greeting.propTypes = {
  name: PropTypes.string.isRequired,
};

这样一来,喂错饭菜的时候 React 会直接报警!


5. 避免过度优化:别给自己挖坑

React 的性能优化是一门艺术,但千万别走火入魔。比如,有些人为了省几个毫秒,就疯狂用 React.memouseCallback,结果代码复杂度飙升,debug 的时候哭得像个孩子。

最佳实践:

  • 优化只做必要的,不要为了优化而优化。
  • React.memouseCallback 是好工具,但用之前先问自己:“真的需要吗?”

搞笑案例:

const ExpensiveComponent = React.memo(({ data }) => {
  console.log('渲染了!');
  return <div>{data}</div>;
});

如果 data 每次都是新对象,那这个 memo 就是摆设,还不如直接删掉。


6. 写文档:别让你的组件变成迷宫

最后一条,也是最重要的一条:写!文!档!一个没有文档的组件,就像一个没有说明书的 IKEA 家具,看着简单,用起来却能让人崩溃。

最佳实践:

  • 为每个组件写清楚功能、props 和用法。
  • 如果条件允许,可以用 Storybook 或类似工具为组件生成可视化文档。

搞笑案例:

// 没文档的组件
const MysteriousComponent = ({ a, b, c }) => (
  <div>{a + b + c}</div>
);

这三个 prop 是啥?谁知道呢!写上文档后:

// MysteriousComponent 文档
props:
- a: 数字,第一个加数
- b: 数字,第二个加数
- c: 数字,第三个加数

是不是瞬间清晰了?


好了,这就是今天分享的 React 组件封装最佳实践!希望大家看完后不仅学到了东西,还能多笑几声。如果你还在为写组件掉头发,那就赶紧把这些实践用起来吧——毕竟,头发比代码更重要啊!

Vue3 响应式系统——ref 和 reactive

2026年1月18日 14:24

一、Vue3 响应式系统概述

Vue3 响应式包 @vue/reactivity,核心由三部分构成:

数据 (Proxy Object)  —— 依赖收集 Track  —— 触发更新 Trigger  ——  Effect 执行更新

核心目标:

  • 拦截读取和设置操作
  • 收集依赖
  • 在数据变化时重新触发相关副作用

主要实现 API:

二、reactive() 执行机制

2.1 核心逻辑(核心源码)

function reactive(target) {
  return createReactiveObject(target, false, mutableHandlers)
}

function createReactiveObject(target, isReadonly, baseHandlers) {
  if (!isObject(target)) {
    return target
  }
  if (target already has proxy) return existing proxy
  const proxy = new Proxy(target, baseHandlers)
  cache proxy
  return proxy
}

Vue3 用 Proxy 拦截对象操作,比 Vue2 的 Object.defineProperty 更强(能监听属性增删)。

2.2 reactive 的 handler(简化)

const mutableHandlers = {
  get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    track(target, key)
    return isObject(res) ? reactive(res) : res
  },
  set(target, key, value, receiver) {
    const oldValue = target[key]
    const result = Reflect.set(target, key, value, receiver)
    if (oldValue !== value) {
      trigger(target, key)
    }
    return result
  }
}

三、依赖收集和触发更新:track()trigger()

Vue 内部维护一个 全局的 activeEffect

let activeEffect = null

function effect(fn) {
  activeEffect = wrappedEffect(fn)
  fn() // 执行一次用于收集依赖
  activeEffect = null
}

每次读取(get)响应式数据时:

function track(target, key) {
  if (!activeEffect) return
  const depsMap = targetMap.get(target) || new Map()
  const dep = depsMap.get(key) || new Set()
  dep.add(activeEffect)
}

当数据被设置(set)时:

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  const dep = depsMap?.get(key)
  dep?.forEach(effect => effect())
}
  • track 只在 读取时收集依赖
  • trigger 只在 数据修改时触发 effect 重新执行

四、ref() 的设计与区别

4.1 ref 是什么?

ref() 主要用于包装 基本类型 (对于对象引用类型内部直接调用上面的 reactive()):

const count = ref(0)

其结构本质上是:

interface RefImpl {
  value: T
}

源码核心:

function ref(rawValue) {
  return createRef(rawValue)
}

function createRef(rawValue) {
  const refImpl = { 
    _value: convert(rawValue), 
    dep: new Set(), // 区别于reactive引用类型复杂的多层嵌套数据结构封装dep,ref这里直接在实例中存放一个dep来实现
    get value() {
      trackRefValue(refImpl)
      return refImpl._value
    },
    set value(newVal) {
      if (hasChanged(newVal, refImpl._value)) {
        refImpl._value = convert(newVal)
        triggerRefValue(refImpl)
      }
    }
  }
  return refImpl
}

4.2 ref vs reactive 的本质区别

五、template / setup 中的自动 unwrap

在 Vue 模板中:

<p>{{ count }}</p>

如果 count 是一个 ref,它会 自动解包,模板中不需要写 .value。这是由编译阶段的 transform 实现的。

六、响应式系统执行流程图(简化)

reactive/ref 数据 -> Proxy getter -> track
                           │
                        effect 注册
                           │
                    数据 setter -> trigger
                           ↓
                    重新执行 effect

用纯 CSS3 打造《星球大战》片头字幕动画|前端特效实战

2026年1月18日 14:08

🌌 用纯 CSS3 打造《星球大战》片头字幕动画|前端特效实战

无需 JavaScript,仅用 HTML + CSS3 关键帧动画,复刻电影级 3D 字幕效果

大家好!今天带大家用 纯 CSS3 实现一个经典又酷炫的前端动画—— 《星球大战》开场字幕。这个效果利用了 perspectivetransform: translateZ()@keyframes 等 CSS3 特性,完美模拟了文字从屏幕前方飞向远方的 3D 视觉效果。

整个项目零 JS、零依赖,是学习 CSS 动画和 3D 变换的绝佳案例!


🎬 效果预览(文字描述)

  • 黑色宇宙背景(bg.jpg
  • “STAR WARS” 标题从远处飞入,逐渐放大后消失
  • 副标题 “The Force Awakens” 同样飞入飞出
  • 主文案(如 “A long time ago...”)以倾斜角度从底部向上滚动,最终消失在远方
  • 所有文字带有金属光泽渐变,增强科幻感

🧱 一、HTML 结构:语义化 + 精简

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Star Wars Intro - Pure CSS3</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div class="starwars">
    <img src="./star.svg" alt="STAR" class="star" />
    <img src="./wars.svg" alt="WARS" class="wars" />
    <div class="byline">
      <span>A</span><span> </span>
      <span>L</span><span>O</span><span>N</span><span>G</span>
      <!-- ... 每个字符用 span 包裹 ... -->
    </div>
  </div>
</body>
</html>

✅ 设计思路:

  • 使用 <img> 加载 “STAR” 和 “WARS” 的 SVG 图标(更易控制缩放与动画)
  • 主文案每个字符用 <span> 包裹 → 便于逐字控制动画
  • 容器 .starwars 作为 3D 舞台

💡 为什么用 13 个 <span>
因为要对每一个文字单独做 3D 旋转和透明度动画,行内元素必须转为 inline-block 才支持 transform


🎨 二、CSS 核心:3D 舞台 + 关键帧动画

1. 初始化:重置样式 + 全屏背景

/* 引入 Meyer Reset */
html, body { margin: 0; padding: 0; }

body {
  height: 10 h;
  background: #000 url(./bg.jpg);
}

2. 创建 3D 舞台(关键!)

.starwars {
  perspective: 800px;           /* 模拟人眼到屏幕的距离 */
  transform-style: preserve-3d; /* 保持子元素 3D 变换 */
  width: 34em;
  height: 17em;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

🔍 perspective: 800px 是实现“纵深感”的核心!值越小,3D 效果越强。

3. 文字定位

.star, .wars, .byline {
  position: absolute;
}

.star { top: -0.75em; }
.wars { bottom: -0.5em; }
.byline {
  top: 45%;
  text-align: center;
  letter-spacing: 0.4em;
  font-size: 1.6em;
  /* 金属渐变文字 */
  background: linear-gradient(90deg, #fff 0%, #000 50%, #fff 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent; /* 让渐变生效 */
}

4. 行内元素支持 3D 旋转

.byline span {
  display: inline-block; /* 必须!否则 rotateY 无效 */
}

🌀 三、动画实现:@keyframes 关键帧

1. STAR/WARS 飞入飞出

.star {
  animation: star 10s ease-out infinite;
}

@keyframes star {
  0% {
    opacity: 0;
    transform: scale(1.5) translateY(-0.75em);
  }
  20% { opacity: 1; }
  90% { opacity: 1; transform: scale(1); }
  100% {
    opacity: 0;
    transform: translateZ(-1000em); /* 飞向远方 */
  }
}

translateZ(-1000em) 是让元素“远离屏幕”的关键!

2. 主文案滚动 + 逐字旋转

.byline {
  animation: move-byline 10s linear infinite;
}

.byline span {
  animation: spin-letters 10s linear infinite;
}

@keyframes move-byline {
  0% { transform: translateZ(5em); }   /* 从近处开始 */
  100% { transform: translateZ(0); }   /* 向远处移动 */
}

@keyframes spin-letters {
  0%, 10% {
    opacity: 0;
    transform: rotateY(90deg); /* 初始“侧躺” */
  }
  30% { opacity: 1; }
  70%, 86% {
    opacity: 1;
    transform: rotateY(0deg); /* 正面朝向 */
  }
  95%, 100% { opacity: 0; }
}

💡 rotateY(90deg) 让文字像“钢管舞”一样从侧面转正,增强动感!


🛠️ 四、技术亮点总结

技术点 作用
perspective 创建 3D 视觉空间
transform-style: preserve-3d 保留子元素 3D 变换
translateZ() 控制元素在 Z 轴(纵深)位置
@keyframes 定义复杂动画流程
display: inline-block 使行内元素支持 3D 变换
background-clip: text 实现文字渐变填充

📦 五、项目结构

star-wars-css/
├── index.html
├── style.css
├── readme.md
├── bg.jpg          # 宇宙背景图
├── star.svg        # "STAR" Logo
└── wars.svg        # "WARS" Logo

✅ 所有资源本地化,开箱即用!


🚀 六、如何运行?

  1. 下载全部文件
  2. 在浏览器中打开 index.html
  3. 享受你的星球大战时刻!

💬 结语

通过这个项目,我们不仅复刻了一个经典电影特效,更深入理解了:

  • CSS 3D 变换的核心原理
  • 如何用 translateZ 模拟纵深运动
  • 关键帧动画的精细控制
  • 行内元素的动画限制与突破

CSS 不只是样式,更是动画引擎!


🔗 所用图片

star.svg

bg.jpg

wars.svg

微信小程序Canvas海报生成组件的完整实现方案

作者 Joie
2026年1月18日 14:04

微信小程序Canvas海报生成组件的完整实现方案

前言

在微信小程序开发中,海报生成是一个常见的需求场景,比如邀请海报、分享海报、活动推广等。虽然需求看似简单,但实际开发中会遇到很多技术难点:Canvas 绘制、图片处理、权限管理、2倍图适配等。

本文将详细介绍如何从零开始开发一个高度可配置的微信小程序海报生成组件 wxapp-poster,深入解析核心实现细节和技术难点。

一、需求分析与技术选型

1.1 需求分析

在开始开发之前,我们需要明确组件的核心需求:

  • 功能需求:支持背景图、二维码、文字的自定义配置
  • 样式需求:支持颜色、字体、间距等所有样式参数的自定义
  • 交互需求:支持预览、保存到相册
  • 性能需求:使用 2 倍图提升清晰度,避免模糊
  • 兼容性需求:适配不同屏幕尺寸,处理权限问题

1.2 技术选型

为什么选择 Canvas?

微信小程序中实现图片合成主要有两种方案:

  1. 服务端生成:需要后端支持,增加服务器压力,实时性差
  2. 客户端 Canvas 绘制:实时生成,用户体验好,无需服务器支持

我们选择 Canvas 方案,因为:

  • 实时生成,用户体验好
  • 不依赖后端服务
  • 可以充分利用小程序原生能力

为什么使用 Component 而不是 Page?

组件化设计可以让代码更易复用和维护,符合小程序的最佳实践。

二、架构设计

2.1 组件结构

components/poster/
├── poster.js      # 组件逻辑
├── poster.wxml    # 组件模板
├── poster.wxss    # 组件样式
├── poster.json    # 组件配置
└── package.json   # npm 包配置

2.2 设计思路

组件采用双视图设计

  1. 预览视图:使用 WXML + WXSS 实现,用于用户预览
  2. Canvas 视图:隐藏的 Canvas,用于实际绘制和生成图片

这种设计的优势:

  • 预览视图可以实时响应样式变化
  • Canvas 在后台绘制,不影响用户体验
  • 最终保存的是 Canvas 生成的图片,质量更高

三、核心实现详解

3.1 Canvas 初始化与 2 倍图处理

为什么需要 2 倍图?

在移动端,为了在高分辨率屏幕上显示清晰,需要使用 2 倍图。Canvas 的默认分辨率较低,直接绘制会导致图片模糊。

实现代码:

initCanvas() {
  let that = this;
  wx.getSystemInfo({
    success: (res) => {
      const { imageRatio, whiteAreaHeight } = that.properties;
      // 使用2倍canvas提升清晰度
      const canvasWidth = res.screenWidth * 2; // 2倍图宽度
      // 根据配置的图片比例计算高度
      const imageAreaHeight = canvasWidth * imageRatio.height / imageRatio.width;
      // 白色区域高度转换为px(2倍图)
      const whiteAreaHeightPx = whiteAreaHeight * canvasWidth / res.screenHeight * 2;
      const canvasHeight = imageAreaHeight + whiteAreaHeightPx;
      
      that.setData({
        screenHeight: res.screenHeight,
        photoWidth: canvasWidth, // canvas实际宽度(2倍图)
        photoHeight: canvasHeight, // canvas实际高度(2倍图)
      });
      // 初始化完成后绘制
      that.draw();
    }
  });
}

关键技术点:

  1. 2 倍图计算canvasWidth = screenWidth * 2
  2. 比例计算:根据配置的 imageRatio 计算实际高度
  3. rpx 转 px:小程序使用 rpx 单位,需要转换为 px
    • 转换公式:px = rpx * screenWidth / 750
    • 2倍图转换:px = rpx * screenWidth * 2 / 750

3.2 Canvas 绘制流程

绘制流程分为以下几个步骤:

draw() {
  // 1. 获取图片信息
  wx.getImageInfo({
    src: backgroundImage,
    success: (imageRes) => {
      let ctx = wx.createCanvasContext('canvasPoster', that);
      
      // 2. 绘制背景
      ctx.setFillStyle(canvasBackgroundColor);
      ctx.fillRect(0, 0, canvasWidth, canvasHeight);
      
      // 3. 绘制背景图片
      ctx.drawImage(backgroundImage, 0, 0, canvasWidth, imageAreaHeight);
      
      // 4. 绘制白色区域
      ctx.setFillStyle(whiteAreaBackgroundColor);
      ctx.fillRect(0, imageAreaHeight, canvasWidth, whiteAreaHeightPx);
      
      // 5. 绘制文字
      // ... 文字绘制逻辑
      
      // 6. 绘制二维码(异步)
      if (qrImage) {
        wx.getImageInfo({
          src: qrImage,
          success: (qrRes) => {
            ctx.drawImage(qrImage, qrX, qrY, qrSize, qrSize);
            that.drawCanvas(ctx, canvasWidth, canvasHeight);
          }
        });
      }
    }
  });
}

3.3 文字绘制与垂直居中

文字绘制是组件中最复杂的部分,需要处理:

  • rpx 到 px 的转换
  • 字体大小的 2 倍图适配
  • 垂直居中对齐
  • 行间距控制

实现代码:

// rpx 转 px 的转换系数
// 原逻辑:28rpx -> 36 * canvasWidth / 750
// 转换系数:主文本 36/28 ≈ 1.286, 次文本 32/24 ≈ 1.333
const primaryTextRatio = 36 / 28;
const secondaryTextRatio = 32 / 24;
const fontSize1 = primaryTextSize * primaryTextRatio * canvasWidth / 750;
const fontSize2 = secondaryTextSize * secondaryTextRatio * canvasWidth / 750;

// 计算白色区域中心点
const whiteAreaStartY = imageAreaHeight;
const whiteAreaCenterY = whiteAreaStartY + whiteAreaHeightPx / 2;

// 计算行间距
const lineSpacing = fontSize2 * lineSpacingRatio;
// 计算两行文字的总高度
const totalTextHeight = fontSize1 + lineSpacing + fontSize2;

// 计算第一行文字的y坐标(垂直居中)
// fillText的y坐标是基线位置,所以需要加上字体大小
const firstLineY = whiteAreaCenterY - totalTextHeight / 2 + fontSize1;
// 计算第二行文字的y坐标
const secondLineY = firstLineY + fontSize1 + lineSpacing;

// 绘制文字
ctx.setFontSize(fontSize1);
ctx.setFillStyle(primaryTextColor);
ctx.setTextAlign('left');
ctx.fillText(primaryText, leftPaddingPx, firstLineY);

关键技术点:

  1. 基线对齐fillText 的 y 坐标是文字基线位置,不是顶部,需要加上字体大小的一半才能实现视觉居中
  2. 行间距计算:使用相对于字体大小的比例,保证不同字体大小下间距协调
  3. 垂直居中算法
    中心点Y = 区域起始Y + 区域高度 / 2
    第一行Y = 中心点Y - 总高度 / 2 + 字体大小
    第二行Y = 第一行Y + 字体大小 + 行间距
    

3.4 二维码绘制与定位

二维码需要:

  • 根据配置的比例计算大小
  • 右对齐
  • 垂直居中
if (qrImage) {
  wx.getImageInfo({
    src: qrImage,
    success: (qrRes) => {
      // 计算二维码尺寸(正方形,根据配置的比例)
      const qrSize = whiteAreaHeightPx * qrSizeRatio;
      // 计算x坐标:距离右边
      const rightPaddingPx = rightPadding * canvasWidth / 750;
      const qrX = canvasWidth - rightPaddingPx - qrSize;
      // 计算y坐标:垂直居中
      const qrY = whiteAreaCenterY - qrSize / 2;
      
      // 绘制二维码(正方形)
      ctx.drawImage(qrImage, qrX, qrY, qrSize, qrSize);
      
      // 绘制所有内容
      that.drawCanvas(ctx, canvasWidth, canvasHeight);
    }
  });
}

3.5 Canvas 转图片

绘制完成后,需要将 Canvas 转换为图片文件:

drawCanvas(ctx, canvasWidth, canvasHeight) {
  let that = this;
  ctx.draw(true, () => {
    // 因为安卓机兼容问题, 所以方法要延迟
    setTimeout(() => {
      wx.canvasToTempFilePath({
        canvasId: 'canvasPoster',
        x: 0,
        y: 0,
        width: canvasWidth,
        height: canvasHeight,
        destWidth: canvasWidth,  // 保持2倍图分辨率
        destHeight: canvasHeight,
        success: res => {
          let path = res.tempFilePath;
          that.setData({
            tempImagePath: path
          });
          // 触发绘制完成事件
          that.triggerEvent('drawcomplete', { tempImagePath: path });
        },
        fail: (err) => {
          console.error('生成临时图片失败:', err);
          that.triggerEvent('error', { err });
        }
      }, that); // 新版小程序必须传this
    }, 200);
  });
}

关键技术点:

  1. 延迟处理:Android 设备上需要延迟执行,确保 Canvas 绘制完成
  2. 分辨率保持destWidthdestHeight 设置为 2 倍图尺寸,保持高清
  3. this 传递:新版小程序 API 必须传递组件实例

四、权限处理机制

保存图片到相册需要处理相册权限,这是小程序开发中的常见难点。

4.1 权限状态

微信小程序的权限有三种状态:

  1. 未授权:用户未操作过
  2. 已授权:用户已同意
  3. 已拒绝:用户已拒绝,需要引导到设置页面

4.2 实现代码

saveImage() {
  if (!this.data.tempImagePath) {
    wx.showToast({
      title: loadingText,
      icon: 'none'
    });
    return;
  }

  // 检查授权
  wx.getSetting({
    success: (res) => {
      if (res.authSetting['scope.writePhotosAlbum']) {
        // 已授权,直接保存
        this.doSaveImage();
      } else if (res.authSetting['scope.writePhotosAlbum'] === false) {
        // 已拒绝授权,引导用户开启
        wx.showModal({
          title: permissionModalTitle,
          content: permissionModalContent,
          showCancel: true,
          confirmText: permissionModalConfirmText,
          success: (modalRes) => {
            if (modalRes.confirm) {
              wx.openSetting(); // 打开设置页面
            }
          }
        });
      } else {
        // 未授权,请求授权
        wx.authorize({
          scope: 'scope.writePhotosAlbum',
          success: () => {
            this.doSaveImage();
          },
          fail: () => {
            wx.showToast({
              title: needPermissionText,
              icon: 'none'
            });
          }
        });
      }
    }
  });
}

处理流程:

用户点击保存
    ↓
检查权限状态
    ↓
┌─────────────────┬──────────────┬──────────────┐
│   已授权        │   已拒绝      │   未授权      │
│   直接保存      │   引导设置    │   请求授权    │
└─────────────────┴──────────────┴──────────────┘

五、组件化设计

5.1 属性设计

组件采用高度可配置的设计,所有样式参数都可通过属性配置:

properties: {
  // 内容相关
  backgroundImage: { type: String, value: '' },
  qrImage: { type: String, value: '' },
  primaryText: { type: String, value: '邀请您一起加入POPO' },
  secondaryText: { type: String, value: '长按二维码识别' },
  
  // Canvas相关
  canvasBackgroundColor: { type: String, value: '#7e57c2' },
  canvasZoom: { type: Number, value: 40 },
  imageRatio: { type: Object, value: { width: 750, height: 1050 } },
  
  // 颜色配置
  whiteAreaBackgroundColor: { type: String, value: '#ffffff' },
  primaryTextColor: { type: String, value: '#000000' },
  secondaryTextColor: { type: String, value: '#9C9C9C' },
  
  // 字体配置
  primaryTextSize: { type: Number, value: 28 },
  secondaryTextSize: { type: Number, value: 24 },
  lineSpacingRatio: { type: Number, value: 0.3 },
  
  // ... 更多配置
}

5.2 事件设计

组件通过事件与父组件通信:

// 绘制完成事件
that.triggerEvent('drawcomplete', { tempImagePath: path });

// 保存成功事件
this.triggerEvent('savesuccess', { tempImagePath: this.data.tempImagePath });

// 保存失败事件
this.triggerEvent('saveerror', { err, message });

// 错误事件
that.triggerEvent('error', { err });

5.3 方法暴露

组件暴露 saveImage 方法供外部调用:

// 在页面中调用
const poster = this.selectComponent('#poster');
poster.saveImage();

六、样式与布局

6.1 Canvas 隐藏

Canvas 需要隐藏,但保持绘制能力。使用 zoomposition 实现:

<canvas 
  class="poster__canvas" 
  canvas-id="canvasPoster" 
  style="width:{{photoWidth}}px;height:{{photoHeight}}px;zoom:{{canvasZoom}}%">
</canvas>
.poster__canvas {
  position: absolute;
  left: 99999rpx;  /* 移出屏幕 */
  top: 0rpx;
}

6.2 预览视图

预览视图使用常规的 WXML + WXSS 实现,实时响应样式变化:

<view class="poster__image-container">
  <image class="poster__image-container-image" src="{{backgroundImage}}"></image>
  <view class="poster__white-area">
    <view class="poster__white-area-left">
      <view class="poster__text poster__text--primary">{{primaryText}}</view>
      <view class="poster__text poster__text--secondary">{{secondaryText}}</view>
    </view>
    <view class="poster__white-area-right">
      <image class="poster__qr-image" src="{{qrImage}}" />
    </view>
  </view>
</view>

七、性能优化

7.1 图片加载优化

  • 使用 wx.getImageInfo 预加载图片,确保绘制时图片已加载完成
  • 异步加载二维码,避免阻塞主流程

7.2 Canvas 绘制优化

  • 使用 2 倍图提升清晰度,避免模糊
  • 延迟执行 canvasToTempFilePath,确保 Android 设备兼容性

7.3 内存管理

  • 及时清理临时文件路径
  • 避免重复绘制,只在必要时触发

八、常见问题与解决方案

8.1 Canvas 模糊问题

问题:生成的图片模糊

解决方案:使用 2 倍图

const canvasWidth = res.screenWidth * 2; // 2倍图

8.2 Android 设备兼容性

问题:Android 设备上 canvasToTempFilePath 执行失败

解决方案:延迟执行

setTimeout(() => {
  wx.canvasToTempFilePath({...}, that);
}, 200);

8.3 文字垂直居中

问题:文字无法垂直居中

解决方案:考虑基线对齐

// fillText的y坐标是基线位置
const firstLineY = whiteAreaCenterY - totalTextHeight / 2 + fontSize1;

8.4 权限处理

问题:用户拒绝权限后无法再次请求

解决方案:引导用户到设置页面

if (res.authSetting['scope.writePhotosAlbum'] === false) {
  wx.showModal({
    confirmText: '去设置',
    success: (modalRes) => {
      if (modalRes.confirm) {
        wx.openSetting();
      }
    }
  });
}

九、发布 npm 包

9.1 package.json 配置

{
  "name": "wxapp-poster",
  "version": "1.0.2",
  "description": "微信小程序海报生成组件",
  "main": "poster.js",
  "miniprogram": ".",
  "files": [
    "poster.js",
    "poster.wxml",
    "poster.wxss",
    "poster.json",
    "README.md"
  ]
}

9.2 发布流程

# 1. 登录 npm
npm login

# 2. 发布
npm publish

十、总结

本文详细介绍了微信小程序海报生成组件的开发实践,包括:

  1. 技术选型:选择 Canvas + Component 方案
  2. 2倍图处理:提升图片清晰度
  3. 文字绘制:处理 rpx 转换、垂直居中、行间距
  4. 权限管理:完善的权限处理机制
  5. 组件化设计:高度可配置、低耦合
  6. 性能优化:图片预加载、延迟执行等

通过这个组件的开发,我们不仅解决了海报生成的需求,还积累了很多小程序开发的经验。希望这篇文章能帮助到正在开发类似功能的开发者。

相关链接


如果这篇文章对你有帮助,欢迎 Star ⭐ 和 Fork,也欢迎提出 Issue 和 PR!

Vue3中v-model在表单元素双向绑定中的场景差异与绑定策略是什么?

作者 kknone
2026年1月18日 13:01

一、文本输入框与v-model的基础绑定:双向交互的起点

在Vue3中,v-model是处理表单输入的“瑞士军刀”——它通过语法糖简化了“值绑定+事件监听”的重复工作。对于文本输入框(input[type="text"]),v-model的本质是:

  • 将表单元素的value属性绑定到组件的状态变量;
  • 监听表单元素的input事件,当用户输入时更新状态变量。

1. 基础示例:用户名输入框

我们用一个简单的用户名输入案例,直观感受双向绑定的魔力:

<script setup>
import { ref } from 'vue'  
// 用ref创建响应式变量,初始值为空字符串
const username = ref('')  
</script>

<template>
  <div class="form-item">
    <label>用户名:</label>
    <!-- v-model绑定username,实现双向同步 -->
    <input type="text" v-model="username" placeholder="请输入用户名" />
    <!-- 实时展示输入结果 -->
    <p class="tip">当前输入:{{ username }}</p>
  </div>
</template>

效果说明

  • 用户在输入框中打字时,username会自动同步更新;
  • 若通过代码修改username(比如username.value = 'Vue3'),输入框的内容也会立即更新。

2. 底层原理流程图

v-model的双向绑定逻辑可以用以下流程概括:

graph TD
A[组件状态变量(如username)] --> B[渲染到表单元素的value属性]
B --> C[用户输入触发input事件]
C --> D[Vue更新状态变量]
D --> A[重新渲染表单元素]

二、多行文本与复选框:不同场景的绑定策略

1. 多行文本(textarea):和文本框“无缝衔接”

多行文本框(textarea)的绑定逻辑与文本输入框完全一致——v-model会自动处理value属性和input事件,无需额外配置:

<script setup>
import { ref } from 'vue'  
const introduction = ref('') // 个人简介,初始为空
</script>

<template>
  <div class="form-item">
    <label>个人简介:</label>
    <textarea v-model="introduction" rows="3" placeholder="说说你的故事"></textarea>
    <p class="tip">简介预览:{{ introduction }}</p>
  </div>
</template>

2. 复选框(checkbox):两种绑定场景

复选框的绑定分为单个复选框多个复选框两种情况,核心区别在于变量类型:

  • 单个复选框:绑定布尔值(表示“是否选中”),常用于“同意条款”场景;
  • 多个复选框:绑定数组(存储选中的value值),常用于“选择爱好”场景。
示例:单个复选框(同意条款)
<script setup>
import { ref } from 'vue'  
const agree = ref(false) // 默认未同意
</script>

<template>
  <div class="form-item">
    <input type="checkbox" id="agree" v-model="agree" />
    <label for="agree">我已阅读并同意《用户协议》</label>
    <p class="tip">状态:{{ agree ? '已同意' : '未同意' }}</p>
  </div>
</template>
示例:多个复选框(选择爱好)
<script setup>
import { ref } from 'vue'  
const hobbies = ref([]) // 存储选中的爱好,初始为空数组
const hobbyList = ['阅读', ' coding', '旅行', '摄影'] // 预设爱好列表
</script>

<template>
  <div class="form-item">
    <label>爱好:</label>
    <!-- 用v-for循环生成复选框,绑定hobbies数组 -->
    <div v-for="hobby in hobbyList" :key="hobby">
      <input 
        type="checkbox" 
        :id="hobby" 
        :value="hobby" 
        v-model="hobbies" 
      />
      <label :for="hobby">{{ hobby }}</label>
    </div>
    <p class="tip">已选爱好:{{ hobbies.join('、') }}</p>
  </div>
</template>

三、单选框与下拉选择:分组与关联的艺术

往期文章归档
免费好用的热门在线工具

1. 单选框(radio):用name属性分组

单选框需要通过name属性分组,确保同一组内只能选一个。v-model绑定的变量会存储选中项的value值:

<script setup>
import { ref } from 'vue'  
const gender = ref('male') // 默认选中“男”
</script>

<template>
  <div class="form-item">
    <label>性别:</label>
    <!-- name="gender" 分组,确保互斥 -->
    <input type="radio" name="gender" value="male" v-model="gender" id="male" />
    <label for="male">男</label>
    <input type="radio" name="gender" value="female" v-model="gender" id="female" />
    <label for="female">女</label>
    <p class="tip">选择性别:{{ gender }}</p>
  </div>
</template>

2. 下拉选择(select):单选与多选的区别

下拉选择框(select)的绑定逻辑与复选框类似,需根据单选/多选场景选择变量类型:

  • 单选:绑定单个值(如字符串、数字);
  • 多选:绑定数组,并添加multiple属性(按住Ctrl可多选)。
示例:下拉单选(选择城市)
<script setup>
import { ref } from 'vue'  
const city = ref('beijing') // 默认选中北京
const cityList = [ // 城市列表,含value和标签
  { value: 'beijing', label: '北京' },
  { value: 'shanghai', label: '上海' },
  { value: 'guangzhou', label: '广州' }
]
</script>

<template>
  <div class="form-item">
    <label>城市:</label>
    <select v-model="city">
      <!-- 用v-for循环生成选项 -->
      <option v-for="item in cityList" :key="item.value" :value="item.value">
        {{ item.label }}
      </option>
    </select>
    <p class="tip">当前城市:{{ city }}</p>
  </div>
</template>
示例:下拉多选(选择水果)
<script setup>
import { ref } from 'vue'  
const fruits = ref([]) // 存储选中的水果,初始为空数组
const fruitList = ['苹果', '香蕉', '橙子', '草莓']
</script>

<template>
  <div class="form-item">
    <label>喜欢的水果:</label>
    <!-- multiple属性开启多选,v-model绑定数组 -->
    <select v-model="fruits" multiple>
      <option v-for="fruit in fruitList" :key="fruit" :value="fruit">
        {{ fruit }}
      </option>
    </select>
    <p class="tip">已选水果:{{ fruits.join('、') }}</p>
  </div>
</template>

四、v-model修饰符:精准控制输入行为

Vue3为v-model提供了3个实用修饰符,用于解决常见的输入处理问题:

1. .lazy:延迟更新,减少性能消耗

默认情况下,v-model会在每一次输入(如按键、粘贴)时更新状态。对于大型表单(如长文本输入),频繁更新可能影响性能——.lazy修饰符会将更新时机延迟到失去焦点按下回车键时:

<input type="text" v-model.lazy="bio" placeholder="请输入个人简介" />

2. .number:自动转换为数字类型

用户输入的内容默认是字符串类型,若需要处理数字(如年龄、价格),.number修饰符会自动将输入值转换为Number类型:

<input type="text" v-model.number="age" placeholder="请输入年龄" />
<p>年龄类型:{{ typeof age }}</p> <!-- 输出:number -->

3. .trim:自动去除首尾空格

用于处理用户名、昵称等场景,避免用户误输入的空格影响逻辑(如“ Vue3 ”会被转换为“Vue3”):

<input type="text" v-model.trim="nickname" placeholder="请输入昵称" />
<p>昵称长度:{{ nickname.length }}</p> <!-- 去除空格后的长度 -->

课后Quiz:巩固你的理解

问题:请说明v-model在多个复选框下拉多选中的绑定规则,并解释两者的变量类型差异。

答案解析

  • 多个复选框:需将v-model绑定到数组,数组元素为选中项的value值(如hobbies: ['阅读', 'coding']);
  • 下拉多选:同样需要绑定数组,但需为select标签添加multiple属性(如<select v-model="fruits" multiple>);
  • 变量类型差异:两者均绑定数组,但复选框的valueinput标签的value属性指定,下拉多选的valueoption标签的value属性指定。

常见报错与解决方案

1. 报错:v-model cannot be used on input type="checkbox" with multiple values

  • 原因:多个复选框的v-model未绑定数组(比如绑定了布尔值);
  • 解决:将v-model绑定到一个空数组(如const hobbies = ref([]))。

2. 报错:.number modifier requires the input type to be number or text

  • 原因.number修饰符被用在非文本/数字输入类型(如checkboxradio);
  • 解决:仅在input[type="text"]input[type="number"]上使用.number

3. 报错:v-model value must be a ref when using script setup

  • 原因:v-model绑定的变量不是响应式的(未用refreactive包裹);
  • 解决:用ref创建响应式变量(如const username = ref(''))。

参考链接

Node.js 进程是单线程,就可以放心追加日志吗?

作者 donecoding
2026年1月18日 12:03

在开发 Node.js 服务或 CLI 工具时,日志系统是我们的“眼睛”。很多同学认为: “既然 Node.js 是单线程的,那我用 fs.appendFile 往文件里写日志,肯定不会乱序或者冲突,对吧?”

答案是:对了一半,但忽略了操作系统层面的真相。

今天我们就从单线程逻辑、多进程竞争、原子性写入三个维度,深度拆解 Node.js 日志追加的“正确姿势”。


一、 逻辑上的“绝对安全”:单线程与事件循环

从 Node.js 进程内部看,你的直觉是对的。

由于 Node.js 的 Event Loop(事件循环)  机制,虽然底层的 I/O 是异步非阻塞的,但你在 JavaScript 层发起的日志写入请求会按顺序排队。

javascript

// 即使你连续调用两次,JS 引擎也会保证它们的入队顺序
fs.appendFile(logPath, 'Log Line 1\n', () => {});
fs.appendFile(logPath, 'Log Line 2\n', () => {});

请谨慎使用此类代码。

单进程环境下,你永远不需要担心“第一行日志写到一半,第二行就插进来”这种交错现象。因为在同一时刻,只有一个 V8 实例在处理你的写入逻辑。


二、 物理上的“暗箭难防”:多进程并发竞争

然而,现代应用往往是多进程的(如使用 PM2 开启 Cluster 模式,或手动 fork 子进程)。

当多个进程同时向同一个文件执行 append 时,灾难就可能发生了:

  1. 覆盖风险:如果两个进程同时读取文件末尾指针并写入,后写的可能会覆盖先写的。
  2. 内容交错:在高并发下,进程 A 的数据和进程 B 的数据可能在物理层面上被操作系统“混”在一起,导致日志文件无法解析。

🚀 资深架构的解决方案:一进程一文件(隔离思想)

在我们的讨论中,一种非常高明的方案是:避开竞争,直接物理隔离。

  • 唯一命名:每个进程启动时,利用 crypto.randomUUID() 和时间戳生成唯一的日志文件名(如 20260118-uuid.log)。
  • 父子关联:子进程在日志头部记录父进程的日志 ID。

这种设计通过  “无锁化”  规避了复杂的底层锁竞争,性能最高,且天然支持跨机器的日志归档。


三、 深度细节:日志写入的“原子性”

即使是单进程,还有一个隐蔽的坑:原子性(Atomicity)

POSIX 标准规定,如果写入的数据块超过了系统的 PIPE_BUF(通常是 4KB 或 8KB),系统可能会将其拆分为多次 I/O 操作。

  • 场景:如果你的一条日志由于记录了巨大的 JSON 堆栈而达到了 1MB。
  • 风险:虽然 JS 是单线程,但在操作系统底层,如果此时发生系统中断或内存压力,这 1MB 的数据可能是不连续写入的。

2026 年的最佳实践

为了保证绝对的健壮性,建议在 Node.js 中遵循以下原则:

  1. 放弃外部包,拥抱原生原生

不要再引用 uuid 或 dayjs 来处理基础的日志元数据了。2026 年,Node.js 原生 API 已经足够强大且性能更高:

javascript

import { randomUUID } from 'node:crypto'; // 高性能 UUID
const logTime = new Intl.DateTimeFormat('zh-CN', { ... }).format(new Date()); // 标准时间

请谨慎使用此类代码。

  1. 使用 fs.createWriteStream

对于高频日志,频繁打开/关闭文件句柄(appendFileSync)是有开销的。建议维护一个持久的可写流:

javascript

const logStream = fs.createWriteStream(logPath, { flags: 'a' }); // 'a' 代表追加

export const writeLog = (content) => {
  const line = `[${getLogTime()}] [PID:${process.pid}] ${content}\n`;
  logStream.write(line); // 这里的写入在单进程内是绝对顺序的
};

请谨慎使用此类代码。

  1. 跨进程配置同步

如果你在 Monorepo 或多进程环境中,利用 globalThis(内存单例)配合 process.env(进程快照)来同步日志路径等配置,是目前最稳健的架构设计。


四、 总结

Node.js 是单线程的,这确实为我们提供了逻辑上的追加安全;但真正健壮的日志系统,必须考虑到操作系统层面的进程隔离

核心结论:

  • 单进程场景:放心追加,只需关注单条日志不要过大(建议控制在 8KB 以内)。
  • 多进程场景:不要头铁去争夺同一个文件, “一进程一文件 + 唯一标识 + 逻辑关联”  才是通往资深工程师的进阶之路。

告别 WebView 卡顿!我开发了一款纯原生的 React Native (Fabric) 富文本编辑器

作者 小冰球
2026年1月18日 10:45

告别 WebView 卡顿!我开发了一款纯原生的 React Native (Fabric) 富文本编辑器

2025 年了,React Native 开发者还在为富文本编辑器头疼吗?这里有一个全新的、纯原生的、基于 Fabric 架构的解决方案。

🔴 痛点:为什么我们需要一个新的富文本编辑器?

作为一名长期深耕 React Native 的开发者,在处理富文本需求时,我发现市面上的解决方案总是存在各种妥协:

  1. WebView 方案的性能瓶颈:大多数编辑器(如基于 Quill 或 Prosemirror 的封装)本质上是在跑一个 Web 页面。加载慢、输入延迟、键盘高度计算不准、光标跳动...这些问题在复杂的交互场景下尤其实明显。
  2. 原生方案的维护困境:曾经有一些原生实现的库,但大多已停止维护,不支持最新的 RN 版本,更别提适配 RN 0.83+ 的新架构(Fabric)了。
  3. 架构过时:随着 React Native 全面拥抱 New Architecture (Fabric),老旧的 Native Module 在未来将寸步难行。

"为了极致的体验,必须上纯原生,必须支持 Fabric。"

基于这个执念,我开发了 react-native-rich-text-fabric

✨ 核心优势:回归原生体验

react-native-rich-text-fabric 是一个专为高性能场景设计的富文本显示与输入组件库。

1. 🚀 纯原生实现 (No WebView)

完全抛弃 WebView,利用原生 Text 和 Image 组件进行渲染。这意味着:

  • 0 秒开屏:没有 WebView 的加载时间。
  • 极致流畅:输入体验和原生输入框完全一致,跟手度 100%。
  • 内存更优:原生组件的内存占用远低于完整的浏览器内核。

2. 🏗 拥抱新架构 (Fabric Ready)

专为 React Native 0.83+ 设计,强制要求开启 Fabric。这确保了组件在未来的 RN 版本中依然稳健,同时享受 Fabric 带来的同步渲染和并发特性。

3. 📝 强大的图文混排与编辑

  • RichText (显示):不仅支持文本,还支持图片插入。
  • RichTextInput (编辑):支持边写边改,插入图片,修改样式。
  • @提及 (Mention) 支持:这是很多社交 App 的刚需。我们实现了原子化删除——删除 @Asterisk 时,会将其作为一个整体删除,而不是一个字符一个字符删,体验对齐原生 App。

4. 🎨 高度可定制

  • 支持加粗、斜体、下划线、背景色等常用样式。
  • 光标颜色、图片占位图均可自定义。

📸 效果演示

Demo转存失败,建议直接上传图片文件

🛠 快速上手

安装

由于依赖 Fabric,请确保你的项目版本 >= 0.83。

npm install react-native-rich-text-fabric
# 或者
yarn add react-native-rich-text-fabric

使用 RichTextInput

import { useRef } from 'react';
import {
  RichTextInput,
  type RichTextInputRef,
} from 'react-native-rich-text-fabric';

const App = () => {
  const ref = useRef<RichTextInputRef>(null);

  return (
    <RichTextInput
      ref={ref}
      placeholder="请输入内容..."
      onContentChange={(content) => console.log('当前内容:', content)}
      style={{ flex: 1 }}
    />
  );
};

插入样式文本和图片

// 插入带样式的文本
ref.current?.insertText({
  text: 'Hello Fabric!',
  textStyle: { fontWeight: 'bold', color: 'blue' },
});

// 插入图片
ref.current?.insertImage({
  image: 'https://example.com/image.png',
  imageStyle: { width: 100, height: 100 },
});

实现 @Mention 功能

// 插入一个不可分割的 @Mention
ref.current?.insertText({
  text: `@Asterisk `,
  textStyle: { color: '#1890ff' },
  atomicId: 'user_123', // 关键:设置 atomicId 后,该文本段落将被视为一个整体
});

⚙️ 技术细节:我们是如何做到的?

文本与图片的组合

在底层,我们并没有使用复杂的自绘引擎,而是回归本源,利用 React Native 已有的 Text 组件嵌套机制,结合 Image 组件的图文混排能力。这保证了最大的兼容性和稳定性。

关于图片组件

目前组件内部使用了原生的 Image。为了追求更好的加载体验(如缓存、WebP 支持),建议结合 react-native-fast-image 使用。未来计划将高性能图片加载能力内置或提供更灵活的适配接口。

🤝 开源与共建

这个项目依然在快速迭代中。目前 Roadmap 包含:

  • 更多样式的支持
  • 撤销/重做 (Undo/Redo)
  • 更加完善的 Web 端支持 (如果有需求)

如果你也在寻找 RN 平台的高性能富文本方案,欢迎试用并给个 Star ⭐️!

🔗 GitHub 仓库: github.com/AsteriskZuo…


Made with ❤️ by AsteriskZuo

搞混了 setState 同步还是异步问题

作者 T___T
2026年1月18日 10:38

刚学 React 接触setState的时候,经常会想一个问题:setState 到底是同步的还是异步的?

“好像是异步的”,结果写代码时又发现有时候它“立刻生效”了。越想越糊涂,直到后来踩了坑、看了源码、再结合 React 18 的变化,才真正理清楚。

就最近遇到的切换页面主题的react项目,里面的有一下一段代码

const toggleTheme = () => {
  setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};

这又让我想起setState这个许久的问题,它和“同步/异步”有关系吗?决定写一篇文章来捋一捋。

一开始,我以为 setState 是“异步”的

脑子里立刻浮现出那个经典例子:

const [count, setCount] = useState(0);

const handleClick = () => {
  setCount(count + 1);
  console.log(count); 、
};

这里打印出来的还是老值,导致我一直以为是因为“setState 是异步的,还没执行完”。 但后来我才意识到——这个理解其实有点跑偏了

一、 所谓的“异步”,其实是 React 在“攒大招”

为什么 console.log(count) 打印的是 0?

并不是因为 setCountsetTimeout 或者接口请求那样真的是个异步任务,被扔到了微任务队列里。根本原因是 React 为了性能,开启了一个叫 “批处理” 的机制。

想象一下你去超市结账。如果你拿一瓶水,收银员算一次钱;再拿包薯片,收银员再算一次钱……收银员(渲染引擎)肯定会被你累死。 React 很聪明,它会把你的多次 setState 操作先“记在小本本上”,等你这一轮事件处理函数执行完了,它再一次性把所有账单结了,这个操作在react里面叫更新dom

所以,当你执行 console.log 的时候,React 甚至还没开始动手更新呢,你读到的自然是旧值。

为了验证这一点,咱们直接上代码测试,用 React 17 和 React 18 对比,真相立马浮出水面。

二、在 React 17 里的不同

后来我看了一些老教程,说“在 setTimeoutsetState 是同步的”。于是我兴奋地去试了一下:

// 环境:React 17 
const handleClick = () => {
  setTimeout(() => {
    setCount(c => c + 1);
    
    // 很多人(包括以前的我)以为这里能打印出 1
    // 结果控制台啪的一下打脸:依然是 0 !!!
    console.log(count); 
  }, 0);
};

image.png

当时我就懵了,直到我打开 Chrome 开发者工具的 Elements 面板,盯着那个 DOM 节点看,才发现了一个惊人的事实:

  1. DOM 确实变了!console.log 执行的那一瞬间,页面上的数字已经变成 1 了。说明 React 确实同步完成了渲染。
  2. count 变量没变! 因为我是用函数式组件写的。

这就触及到了知识盲区: 在 React 17 的 setTimeout 里,React 确实失去了“批处理”的能力,导致它被迫同步更新了视图。但是!由于函数式组件的闭包特性,我当前这个 handleClick 函数是在 count=0 的时候创建的,它手里拿的 count 永远是 0。

所以,视图是新的,变量是旧的。这才是最坑的地方。

三、React 18 的大一统

回到 React 18,官方推出了 自动批处理

现在,不管你是在 setTimeoutPromise 还是原生事件里,React 都会把门焊死,统统进行批处理。

setTimeout(() => {
  setCount(c => c + 1);
  setName('Alice');
  setIsLoading(false);
}, 0);

👉 结果:只 re-render 1 次!

React 18 无论你在哪调用状态更新(事件、定时器、Promise、fetch 回调等) ,都会自动把它们“攒起来”,在当前 tick 结束时一次性合并更新并渲染

这意味着,在 React 18 里,除非你用 flushSync 这种逃生舱,否则你几乎看不到 DOM 同步更新的情况了。这其实是好事,心智负担少了很多,不用再去记那些特例。

首先,我们来看最常见的场景。如果它是同步的,那我改三次,它就应该变三次

来看这段代码:

// React 18 环境
export default function App() {
  console.log("组件渲染了!"); // 埋点:监控渲染次数
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 连发三枪
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    
    // 马上查看
    console.log("点击时的 count:", count); 
  };

  return <button onClick={handleClick}>{count}</button>;
}

image.png

运行结果直接打脸:

  1. 控制台打印 点击时的 count: 0。(说明:代码执行到这行时,状态根本没变)
  2. "组件渲染了!" 只打印了 1 次。(说明:三次操作被合并了)
  3. 页面上的数字变成了 1,而不是 3

四、setState 同步的情况

我们可以逼 React 同步执行。在 React 18 里,我们需要用 flushSync 这个 API 来关掉自动批处理。

上代码:

import { useState } from 'react';
import { flushSync } from 'react-dom';

export default function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 第一次更新:强制同步
    flushSync(() => {
      setCount(c => c + 1);
    });
    console.log("第一次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);

    // 第二次更新:强制同步
    flushSync(() => {
      setCount(c => c + 1);
    });
    console.log("第二次 flushSync 结束,DOM 上的数字是:", document.getElementById('count-span').innerText);
  };

  return (
    <div>
      <span id="count-span">{count}</span>
      <button onClick={handleClick}>点击增加</button>
    </div>
  );
}

image.png

结论: 看,React 其实完全有能力同步更新。只要你用 flushSync 勒令它“立刻、马上干活”,它就会停下手头的工作,立刻执行更新流程。

所以,准确地说:setState 本质上是同步执行代码的,只是 React 默认挂了个“防抖”的机制,让它看起来像是异步的。

五、最坑的“假异步”(闭包陷阱)

既然上面的代码里,DOM 都已经同步变了,那我在 JS 里直接打印 count 变量

看这段代码:

const handleClick = () => {
  flushSync(() => {
    setCount(c => c + 1); 
  });
  
  // 刚才代码证明了,DOM 这里已经变成 1 
  // 那这里打印 count 应该是几?
  console.log("也就是现在的 count 是:", count); 
};

image.png

这不是 React 的锅,这是 JavaScript 闭包的锅。

我们这个 handleClick 函数,是在 count 为 0 的那次渲染中生成的。它就像一张照片,永远定格在了那一刻。

无论你用办法(比如 flushSync)让 React 在外部把 DOM 更新了,或者把 React 内部的 State 更新了,但你当前正在运行的这个 handleClick 函数作用域里,count 这个局部变量,它就是个常量 0,再怎么搞它也是 0

回到最初的问题

理清了这些,再回过头看开头那段代码:

const toggleTheme = () => {
  setTheme(previousState => previousState === 'light' ? 'dark' : 'light');
};

为什么要写成 previousState => ... 这种函数形式?

这和“同步/异步”有关系吗?有关系。

正因为 React 的 setState 是“异步”(批处理)的,而且函数式组件有闭包陷阱,如果直接写 setTheme(theme === 'light' ? ...),你拿到的 theme 很可能是旧值(也就是上面例子里那个永远是 0 的 count)。

当你传入一个函数时,你是在告诉 React:

“麻烦把当时最新的那个状态值传给我的函数。我不信我自己闭包里的旧变量,我只信你传给我的新值。”

总结一下

1、定性: “严格来说,setState 是由 React 调度的更新,表现得像异步(批处理的原因)。”

2、亮点:

  • “在 React 18 中,得益于自动批处理,无论在 React 事件还是 setTimeout 中,它都会合并更新,表现为异步。”

  • “但在 React 17 及以前,如果在 setTimeout 或原生事件中,它会脱离 React 的管控,表现为同步行为。”

3、补充特例: “如果需要在 React 18 中强制同步更新 DOM,我们可以使用 flushSync。”

4、最后补刀(闭包): “但无论 DOM 是否同步更新,在函数式组件中,由于 JS 闭包 的存在,我们在当前函数执行上下文中拿到的 state 永远是本次渲染的快照(旧值),要获取最新值应该依赖 useEffectuseRef。”

❌
❌