普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月8日掘金 前端

CSS3渐变:用代码描绘色彩的流动之美

2025年11月8日 15:18

在网页设计的调色盘中,CSS3渐变就像一位神奇的魔术师,它能让颜色在元素间自然流动,创造出令人惊艳的视觉效果。告别单调的纯色背景,迎接丰富多彩的渐变时代!

CSS3渐变

CSS3渐变是一种让颜色在元素内部平滑过渡的技术。想象一下日落的天空——橙色、红色、紫色自然地融合在一起,这就是渐变的魅力。在网页设计中,我们可以用代码实现同样美妙的效果,让界面更加生动和富有层次感。

渐变的主要类型:

🌈 线性渐变 - 沿着直线方向颜色变化

🔵 径向渐变 - 从中心向外辐射的颜色变化

🎯 锥形渐变 - 围绕中心点旋转的颜色变化

线性渐变基础语法

background: linear-gradient(direction, color-stop1, color-stop2, ...);

径向渐变基础语法

background: radial-gradient(shape size at position, color-stop1, color-stop2, ...);

全部类型代码示例:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS3渐变效果大全</title>
    <style>
        /* 基础样式重置 */
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 40px 20px;
            color: #333;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
        }

        h1 {
            text-align: center;
            color: white;
            margin-bottom: 40px;
            font-size: 2.5rem;
            text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
        }

        .gradient-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
            gap: 30px;
            margin-bottom: 40px;
        }

        .gradient-card {
            background: white;
            border-radius: 15px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
            transition: transform 0.3s ease;
        }

        .gradient-card:hover {
            transform: translateY(-5px);
        }

        .gradient-title {
            font-size: 1.2rem;
            font-weight: 600;
            margin-bottom: 15px;
            color: #2d3748;
        }

        .gradient-preview {
            height: 150px;
            border-radius: 10px;
            margin-bottom: 15px;
            border: 1px solid #e2e8f0;
        }

        .code-snippet {
            background: #f7fafc;
            padding: 15px;
            border-radius: 8px;
            font-family: 'Courier New', monospace;
            font-size: 0.9rem;
            border-left: 4px solid #667eea;
            overflow-x: auto;
        }

        /* 1. 基础线性渐变 */
        .linear-basic {
            background: linear-gradient(#667eea, #764ba2);
        }

        /* 2. 角度线性渐变 */
        .linear-angle {
            background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
        }

        /* 3. 多色线性渐变 */
        .linear-multi {
            background: linear-gradient(to right, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
        }

        /* 4. 径向渐变 */
        .radial-basic {
            background: radial-gradient(circle, #667eea, #764ba2);
        }

        /* 5. 椭圆形径向渐变 */
        .radial-ellipse {
            background: radial-gradient(ellipse at center, #ff6b6b, #4ecdc4);
        }

        /* 6. 位置径向渐变 */
        .radial-position {
            background: radial-gradient(circle at top right, #667eea, transparent 50%),
                        radial-gradient(circle at bottom left, #764ba2, transparent 50%);
        }

        /* 7. 重复线性渐变 */
        .repeating-linear {
            background: repeating-linear-gradient(45deg, #667eea, #667eea 10px, #764ba2 10px, #764ba2 20px);
        }

        /* 8. 重复径向渐变 */
        .repeating-radial {
            background: repeating-radial-gradient(circle, #ff6b6b, #ff6b6b 10px, #4ecdc4 10px, #4ecdc4 20px);
        }

        /* 9. 锥形渐变 */
        .conic-gradient {
            background: conic-gradient(from 0deg, #ff6b6b, #4ecdc4, #45b7d1, #ff6b6b);
        }

        /* 10. 复杂渐变组合 */
        .complex-gradient {
            background: 
                linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%),
                radial-gradient(circle at top left, rgba(255, 107, 107, 0.6) 0%, transparent 50%),
                radial-gradient(circle at bottom right, rgba(78, 205, 196, 0.6) 0%, transparent 50%);
        }

        /* 11. 文字渐变效果 */
        .text-gradient {
            background: linear-gradient(135deg, #667eea, #764ba2);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
            font-size: 2rem;
            font-weight: bold;
            text-align: center;
            margin: 20px 0;
        }

        /* 12. 边框渐变 */
        .border-gradient {
            border: 4px solid transparent;
            background: 
                linear-gradient(white, white) padding-box,
                linear-gradient(135deg, #667eea, #764ba2) border-box;
        }

        /* 响应式设计 */
        @media (max-width: 768px) {
            .gradient-grid {
                grid-template-columns: 1fr;
            }
            
            .container {
                padding: 0 10px;
            }
            
            h1 {
                font-size: 2rem;
            }
        }

        /* 说明区域 */
        .explanation {
            background: white;
            border-radius: 15px;
            padding: 30px;
            margin-top: 40px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
        }

        .explanation h2 {
            color: #2d3748;
            margin-bottom: 20px;
        }

        .explanation p {
            line-height: 1.6;
            color: #4a5568;
            margin-bottom: 15px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>CSS3渐变效果展示</h1>
        
        <div class="gradient-grid">
            <!-- 基础线性渐变 -->
            <div class="gradient-card">
                <div class="gradient-title">1. 基础线性渐变</div>
                <div class="gradient-preview linear-basic"></div>
                <div class="code-snippet">background: linear-gradient(#667eea, #764ba2);</div>
            </div>

            <!-- 角度线性渐变 -->
            <div class="gradient-card">
                <div class="gradient-title">2. 45度角线性渐变</div>
                <div class="gradient-preview linear-angle"></div>
                <div class="code-snippet">background: linear-gradient(45deg, #ff6b6b, #4ecdc4);</div>
            </div>

            <!-- 多色线性渐变 -->
            <div class="gradient-card">
                <div class="gradient-title">3. 多色线性渐变</div>
                <div class="gradient-preview linear-multi"></div>
                <div class="code-snippet">background: linear-gradient(to right, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);</div>
            </div>

            <!-- 径向渐变 -->
            <div class="gradient-card">
                <div class="gradient-title">4. 基础径向渐变</div>
                <div class="gradient-preview radial-basic"></div>
                <div class="code-snippet">background: radial-gradient(circle, #667eea, #764ba2);</div>
            </div>

            <!-- 椭圆形径向渐变 -->
            <div class="gradient-card">
                <div class="gradient-title">5. 椭圆形径向渐变</div>
                <div class="gradient-preview radial-ellipse"></div>
                <div class="code-snippet">background: radial-gradient(ellipse at center, #ff6b6b, #4ecdc4);</div>
            </div>

            <!-- 位置径向渐变 -->
            <div class="gradient-card">
                <div class="gradient-title">6. 位置径向渐变</div>
                <div class="gradient-preview radial-position"></div>
                <div class="code-snippet">
background: radial-gradient(circle at top right, #667eea, transparent 50%),
            radial-gradient(circle at bottom left, #764ba2, transparent 50%);
                </div>
            </div>

            <!-- 重复线性渐变 -->
            <div class="gradient-card">
                <div class="gradient-title">7. 重复线性渐变</div>
                <div class="gradient-preview repeating-linear"></div>
                <div class="code-snippet">background: repeating-linear-gradient(45deg, #667eea, #667eea 10px, #764ba2 10px, #764ba2 20px);</div>
            </div>

            <!-- 重复径向渐变 -->
            <div class="gradient-card">
                <div class="gradient-title">8. 重复径向渐变</div>
                <div class="gradient-preview repeating-radial"></div>
                <div class="code-snippet">background: repeating-radial-gradient(circle, #ff6b6b, #ff6b6b 10px, #4ecdc4 10px, #4ecdc4 20px);</div>
            </div>

            <!-- 锥形渐变 -->
            <div class="gradient-card">
                <div class="gradient-title">9. 锥形渐变</div>
                <div class="gradient-preview conic-gradient"></div>
                <div class="code-snippet">background: conic-gradient(from 0deg, #ff6b6b, #4ecdc4, #45b7d1, #ff6b6b);</div>
            </div>

            <!-- 复杂渐变组合 -->
            <div class="gradient-card">
                <div class="gradient-title">10. 复杂渐变组合</div>
                <div class="gradient-preview complex-gradient"></div>
                <div class="code-snippet">
background: 
    linear-gradient(135deg, rgba(102,126,234,0.8), rgba(118,75,162,0.8)),
    radial-gradient(circle at top left, rgba(255,107,107,0.6), transparent 50%),
    radial-gradient(circle at bottom right, rgba(78,205,196,0.6), transparent 50%);
                </div>
            </div>
        </div>

        <!-- 文字渐变效果 -->
        <div class="gradient-card">
            <div class="gradient-title">11. 文字渐变效果</div>
            <div class="text-gradient">渐变文字效果</div>
            <div class="code-snippet">
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
            </div>
        </div>

        <!-- 边框渐变 -->
        <div class="gradient-card">
            <div class="gradient-title">12. 边框渐变效果</div>
            <div class="gradient-preview border-gradient" style="height: 100px; display: flex; align-items: center; justify-content: center;">
                渐变边框
            </div>
            <div class="code-snippet">
border: 4px solid transparent;
background: 
    linear-gradient(white, white) padding-box,
    linear-gradient(135deg, #667eea, #764ba2) border-box;
            </div>
        </div>

        <div class="explanation">
            <h2>CSS3渐变核心语法</h2>
            <p>CSS3渐变提供了丰富的颜色过渡效果,主要包括三种类型:</p>
            
            <p><strong>线性渐变 (linear-gradient)</strong>:颜色沿着一条直线方向变化。可以指定方向(角度或关键词)和多个颜色停止点。</p>
            
            <p><strong>径向渐变 (radial-gradient)</strong>:颜色从中心点向外辐射变化。可以定义形状(圆形或椭圆形)、大小和位置。</p>
            
            <p><strong>锥形渐变 (conic-gradient)</strong>:颜色围绕中心点旋转变化。适合创建饼图、色轮等效果。</p>
            
            <p>渐变可以叠加使用,创建复杂的视觉效果,并且支持透明度,可以实现更加丰富的设计。</p>
        </div>
    </div>
</body>
</html>

运行结果:

结果1.png

结果2.png

结果3.png

结果4.png

结果5.png

结果6.png

核心属性

属性 作用 常用值
linear-gradient() 创建线性渐变 方向, 颜色停止点
radial-gradient() 创建径向渐变 形状 大小 at 位置, 颜色停止点
conic-gradient() 创建锥形渐变 from 角度, 颜色停止点
repeating-linear-gradient() 创建重复线性渐变 方向, 颜色停止点
repeating-radial-gradient() 创建重复径向渐变 形状 大小 at 位置, 颜色停止点

总结

渐变设计的三个关键点:

  1. 选择合适的渐变类型 - 根据设计目标选择线性、径向或锥形渐变
  2. 精心搭配颜色 - 选择和谐的颜色组合,确保可读性
  3. 考虑性能和使用场景 - 在美观和性能之间找到平衡

CSS Sprite技术:用“雪碧图”提升网站性能的魔法

2025年11月8日 15:17

在网站性能优化的工具箱中,有一个看似简单却极其有效的技术——CSS Sprite。它就像把多个小图标打包成一个“全家福”,让网页加载速度瞬间起飞!

CSS Sprite技术

CSS Sprite就是网页设计的“工具箱”。它将多个小图片合并成一张大图片,通过CSS背景定位来显示需要的部分。这种技术在中国前端圈有个可爱的昵称——“雪碧图”。

工作原理

核心原理:一张图 + 精准定位

  1. 合并:把多个小图标合并到一张大图中
  2. 定位:通过CSS的background-position属性精准显示需要的图标

代码原理示例:

/* 原理示例 */
.icon {
    background-image: url('sprite.png'); /* 同一张图片 */
    background-repeat: no-repeat;
}

.home-icon {
    background-position: 0 0;  /* 显示左上角的图标 */
}

.user-icon {
    background-position: -32px 0; /* 向右移动32px,显示第二个图标 */
}

完整代码示例:制作一个图标Sprite

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS Sprite技术完整示例</title>
    <style>
        /* 基础样式 */
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Microsoft YaHei', sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 40px 20px;
            color: #333;
        }

        .container {
            max-width: 600px;
            margin: 0 auto;
            background: white;
            border-radius: 15px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
        }

        h1 {
            text-align: center;
            margin-bottom: 30px;
            color: #2d3748;
        }

        /* Sprite图标基础样式 */
        .sprite-icon {
            display: inline-block;
            background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><rect fill="%23667eea" width="64" height="64"/><g fill="white"><path d="M12 22h40v4H12z"/><path d="M12 30h40v4H12z"/><path d="M12 38h40v4H12z"/></g><circle cx="32" cy="16" r="4" fill="%2338a169"/><circle cx="48" cy="48" r="4" fill="%23e53e3e"/><path d="M20 52a4 4 0 1 1 0 8 4 4 0 0 1 0-8z" fill="%23ed8936"/><path d="M36 52a4 4 0 1 1 0 8 4 4 0 0 1 0-8z" fill="%239f7aea"/></svg>');
            background-repeat: no-repeat;
            width: 32px;
            height: 32px;
            margin-right: 10px;
            vertical-align: middle;
        }

        /* 各个图标的位置定位 */
        .home-icon {
            background-position: 0 0;
        }

        .user-icon {
            background-position: -32px 0;
        }

        .settings-icon {
            background-position: 0 -32px;
        }

        .search-icon {
            background-position: -32px -32px;
        }

        /* 图标展示区域 */
        .icon-demo {
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 15px;
            margin-bottom: 30px;
        }

        .icon-item {
            display: flex;
            align-items: center;
            padding: 15px;
            background: #f7fafc;
            border-radius: 8px;
            transition: transform 0.2s ease;
        }

        .icon-item:hover {
            transform: translateY(-2px);
            background: #e2e8f0;
        }

        /* 响应式设计 */
        @media (max-width: 480px) {
            .container {
                padding: 20px;
            }
            
            .icon-demo {
                grid-template-columns: 1fr;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>CSS Sprite技术演示</h1>
        
        <div class="icon-demo">
            <div class="icon-item">
                <span class="sprite-icon home-icon"></span>
                <span>首页图标</span>
            </div>
            
            <div class="icon-item">
                <span class="sprite-icon user-icon"></span>
                <span>用户图标</span>
            </div>
            
            <div class="icon-item">
                <span class="sprite-icon settings-icon"></span>
                <span>设置图标</span>
            </div>
            
            <div class="icon-item">
                <span class="sprite-icon search-icon"></span>
                <span>搜索图标</span>
            </div>
        </div>
        
        <div style="background: #f1f5f9; padding: 20px; border-radius: 8px;">
            <h3 style="margin-bottom: 10px;">技术要点:</h3>
            <ul style="color: #4a5568;">
                <li>所有图标使用同一张背景图片</li>
                <li>通过background-position定位显示不同图标</li>
                <li>减少HTTP请求,提升加载性能</li>
            </ul>
        </div>
    </div>
</body>
</html>

运行结果如下:

屏幕截图 2025-11-05 213816.png

核心CSS属性

属性 作用 常用值
background-image 设置Sprite图片 url('sprite.png')
background-position 定位显示区域 -32px 0
background-repeat 控制重复 no-repeat
width/height 控制显示尺寸 32px

CSS Sprite技术的优点:

  • 性能优势明显
  • 维护更加便捷

实际应用适用Sprite的情况:

  • 网站的导航图标
  • 社交媒体的分享按钮
  • 工具类网站的工具栏
  • 游戏中的角色状态图标

深拷贝:JavaScript 中对象复制的终极解法

作者 Zyx2007
2025年11月8日 15:00

引言:一个看似简单却暗藏玄机的问题

在 JavaScript 开发中,我们经常需要复制一个对象或数组。然而,一句简单的 const newData = oldData 往往会带来意想不到的副作用——修改新数据竟然影响了原始数据!这背后的原因,正是 JavaScript 内存模型与引用机制的本质体现。

当我们处理像用户列表、配置项、表单数据等复杂结构时,浅拷贝(shallow copy)常常无法满足需求。真正的解决方案是深拷贝(deep clone) ——创建一个与原对象完全独立、互不影响的新对象。本文将从内存原理出发,深入剖析深拷贝的必要性、实现方式及其局限性,帮助开发者彻底掌握这一核心技能。


一、内存模型:理解“引用”为何危险

要理解深拷贝,必须先理解 JavaScript 的内存分配机制。

1.1 栈内存 vs 堆内存

  • 栈内存(Stack) :存储基本数据类型(如 numberstringboolean)。变量直接保存值,赋值即值拷贝

    let a = 1;
    let b = a; // b 获得 a 的副本
    b = 2;
    console.log(a); // 1(不受影响)
    
  • 堆内存(Heap) :存储引用类型(如 objectarrayfunction)。变量保存的是指向堆内存的地址,赋值即引用拷贝

    const users = [{ name: '张三' }];
    const data = users; // data 与 users 指向同一块堆内存
    data[0].name = '李四';
    console.log(users[0].name); // '李四'(原始数据被意外修改!)
    

这种设计使得复杂数据结构可以动态扩展(如 users.push(...)),但也带来了“共享引用”的风险。

1.2 浅拷贝的局限性

常见的“复制”方法如展开运算符(...)、Object.assign() 都只是浅拷贝

const users = [{ id: 1, name: '张三', hobbies: ['篮球'] }];
const shallowCopy = [...users];
shallowCopy[0].hobbies.push('足球');
console.log(users[0].hobbies); // ['篮球', '足球'] —— 原始数据被污染!

原因在于:浅拷贝只复制了对象的第一层属性,而嵌套的对象/数组仍然共享引用。


二、深拷贝:彻底隔离数据的唯一途径

深拷贝的目标是:递归复制所有层级的属性,确保新旧对象在内存中完全独立

2.1 JSON 方法:最简单的深拷贝

利用 JavaScript 内置的序列化能力,是最常用的深拷贝方案:

const users = [
  { id: 1, name: '张三', hometown: '北京' },
  { id: 2, name: '李四', hometown: '上海' }
];

// 序列化 → 字符串
const jsonString = JSON.stringify(users);
// 反序列化 → 全新对象
const deepCopy = JSON.parse(jsonString);

deepCopy[0].hobbies = ['篮球', '足球'];
console.log(users[0].hobbies);   // undefined(未受影响)
console.log(deepCopy[0].hobbies); // ['篮球', '足球']

优点

  • 代码简洁,一行搞定
  • 自动处理任意深度的嵌套结构
  • 性能较好(底层由 V8 优化)

缺点

  • 无法处理函数、undefinedSymbolDateRegExp 等特殊类型
  • 会忽略对象的原型链(constructor 丢失)
  • 无法处理循环引用(会报错)

因此,JSON 方法适用于纯数据对象(如 API 返回的 JSON 数据),但不适用于包含方法或复杂类型的对象。

2.2 手写递归深拷贝:全面但复杂

为了克服 JSON 方法的局限,我们可以手动实现递归深拷贝:

function deepClone(obj, hash = new WeakMap()) {
  // 处理 null、undefined、基本类型
  if (obj === null || typeof obj !== 'object') return obj;
  
  // 处理 Date
  if (obj instanceof Date) return new Date(obj);
  
  // 处理 RegExp
  if (obj instanceof RegExp) return new RegExp(obj);
  
  // 防止循环引用
  if (hash.has(obj)) return hash.get(obj);
  
  // 创建新实例
  const cloned = new obj.constructor();
  hash.set(obj, cloned);
  
  // 递归拷贝所有属性
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloned[key] = deepClone(obj[key], hash);
    }
  }
  
  return cloned;
}

这个版本支持:

  • 函数、日期、正则表达式
  • 循环引用检测(通过 WeakMap
  • 保留原型链

但实现复杂,且性能不如 JSON 方法。


三、实战场景:何时必须使用深拷贝?

3.1 状态管理(React/Vue)

在前端框架中,状态变更必须返回新对象,否则视图不会更新:

//  错误:直接修改原状态
state.users[0].name = '新名字';

//  正确:使用深拷贝创建新状态
const newState = JSON.parse(JSON.stringify(state));
newState.users[0].name = '新名字';
setState(newState);

3.2 表单回滚与撤销功能

当用户编辑表单时,需要保留原始数据用于“取消”操作:

const originalData = JSON.parse(JSON.stringify(formData));
// 用户修改 formData...
// 点击“取消”时
formData = JSON.parse(JSON.stringify(originalData));

四、深拷贝的边界与替代方案

4.1 何时不需要深拷贝?

  • 数据是扁平结构(无嵌套对象)
  • 只读数据(不会被修改)
  • 性能敏感场景(深拷贝开销大)

此时,浅拷贝或直接引用更高效。

4.2 结构化克隆(Structured Clone)

现代浏览器支持 structuredClone() API(ES2022):

const deepCopy = structuredClone(users);

它支持更多类型(包括 DateRegExpMapSet),但仍不支持函数和循环引用。


五、最佳实践建议

  1. 优先使用 JSON 方法:适用于 90% 的纯数据场景
  2. 明确数据边界:只对可能被修改的复杂对象进行深拷贝
  3. 避免过度拷贝:深拷贝性能开销大,不要滥用
  4. 测试边界情况:确保深拷贝方案能处理你的实际数据结构

结语:深拷贝不仅是技术,更是思维

深拷贝问题的本质,是对数据所有权副作用控制的理解。在函数式编程日益流行的今天,“不可变性”已成为构建可靠系统的基石。

掌握深拷贝,不仅是为了写出正确的代码,更是为了培养一种防御性编程思维:永远假设数据会被修改,永远确保自己的操作不会影响他人。

“在 JavaScript 的世界里,共享引用是默认,独立拷贝是选择。”
—— 而深拷贝,正是我们做出正确选择的有力工具。

axios

作者 jump680
2025年11月8日 13:46

一、Axios 的核心原理

从本质上讲,Axios 是一个基于 Promise 的 HTTP 客户端,用于浏览器和 Node.js 环境。这句话包含了它的几个核心要点,我们来逐一拆解。

1. 基于 Promise 的 API

这是 Axios 最基础也是最重要的特性。在早期,网络请求(例如使用 XMLHttpRequest)是基于回调函数的。这很容易导致“回调地狱”(Callback Hell),代码难以阅读和维护。

codeJavaScript

// 回调地狱的伪代码
ajax('api/user', function(user) {
    ajax('api/posts?userId=' + user.id, function(posts) {
        ajax('api/comments?postId=' + posts[0].id, function(comments) {
            console.log(comments);
        }, handleError);
    }, handleError);
}, handleError);

Axios 将这些异步操作封装成了 Promise。Promise 是一种更优雅地处理异步操作的模式,它允许我们使用 .then(), .catch(), .finally() 以及 async/await 语法来编写更线性和可读的代码。

codeJavaScript

// 使用 Axios (async/await)
async function fetchComments() {
    try {
        const userResponse = await axios.get('api/user');
        const postsResponse = await axios.get('api/posts?userId=' + userResponse.data.id);
        const commentsResponse = await axios.get('api/comments?postId=' + postsResponse.data[0].id);
        console.log(commentsResponse.data);
    } catch (error) {
        handleError(error);
    }
}

原理: Axios 内部创建并返回一个 Promise 对象。当请求成功时,它会调用 Promise 的 resolve 函数,并将响应数据传递出去;当请求失败时,它会调用 reject 函数,并传递一个错误对象

2. 同构性(Isomorphic):浏览器与 Node.js 通用

这是一个非常强大的特性。 “同构”意味着同一套代码可以在不同的环境(客户端和服务器端)中运行。  Axios 是如何做到的呢?

  • 在浏览器端:它底层封装的是 XMLHttpRequest (XHR) 对象。这是浏览器提供的原生 API,用于发送 HTTP 请求。
  • 在 Node.js 端:它底层封装的是 Node.js 内置的 http 或 https 模块。因为 Node.js 环境中没有 XHR 这个浏览器 API。

Axios 通过一个**适配器(Adapter)**层来判断当前运行环境,并选择合适的底层 API 来发送请求。这使得开发者无需关心底层实现细节,用一套统一的 axios() API 即可完成在任何环境下的网络请求。

3. 拦截器(Interceptors)

拦截器是 Axios 的一个核心且非常实用的功能。它允许你在请求发送之前响应返回之后对它们进行拦截和处理。

  • 请求拦截器 (Request Interceptor) :在请求被发送到服务器之前,可以用来做一些统一处理,例如:

    • 为每个请求添加认证 token 到请求头(headers)中。
    • 开启请求加载动画(loading aniamtion)。
    • 对请求数据进行统一的格式化。
  • 响应拦截器 (Response Interceptor) :在 .then() 或 .catch() 被触发之前,可以对响应数据进行预处理,例如:

    • 统一处理 HTTP 错误码(如 401 未授权,直接跳转到登录页)。
    • 关闭加载动画。
    • 对返回的数据进行解构,只返回核心的 data 部分。

原理: Axios 内部维护了两个拦截器数组(一个请求,一个响应)。当一个请求发出时,它会像一个链条一样执行:请求拦截器 -> 发送请求 -> 响应拦截器 -> 返回给用户的 Promise。请求拦截器是“后进先出”(LIFO)的顺序执行,而响应拦截器是“先进先出”(FIFO)的顺序执行。

codeJavaScript

// 添加请求拦截器
axios.interceptors.request.use(config => {
  // 在发送请求之前做些什么
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
}, error => {
  return Promise.reject(error);
});

// 添加响应拦截器
axios.interceptors.response.use(response => {
  // 对响应数据做点什么,例如只返回 data
  return response.data;
}, error => {
  // 对响应错误做点什么
  if (error.response.status === 401) {
    // 跳转到登录页
  }
  return Promise.reject(error);
});

4. 请求和响应数据转换

Axios 会自动处理请求和响应数据的转换。

  • 请求时: 如果你传递一个 JavaScript 对象作为 data,Axios 会自动将其 JSON.stringify() 并设置请求头 Content-Type 为 application/json。
  • 响应时: 如果收到的响应头 Content-Type 是 application/json,Axios 会自动为你 JSON.parse() 响应体,所以你直接就能拿到 JavaScript 对象。

原理: 这是通过 transformRequest 和 transformResponse 这两个配置项实现的。它们是一组函数,允许你在请求发送前和响应返回后修改数据。

5. 其他核心功能

  • 请求取消: 允许在请求未完成时取消它,以避免不必要的网络流量和资源占用。
  • 超时设置: 可以设置请求超时时间,防止请求长时间无响应。
  • CSRF 防护: 内置了对客户端跨站请求伪造(CSRF)的防护机制。
  • 更丰富的配置: 提供了全局配置、实例配置和单次请求配置,非常灵活。

Vue响应式声明的API差异、底层原理与常见陷阱你都搞懂了吗

作者 kknone
2025年11月8日 12:36

一、Options API中的响应式声明与操作

Options API是Vue 2的经典写法,Vue 3保留了它的兼容性。在Options API中,响应式状态的核心是data选项。

1.1 用data选项声明响应式状态

data选项必须是一个返回对象的函数(避免组件复用时光享状态),Vue会将返回对象的所有顶级属性包裹进响应式系统。这些属性会被代理到组件实例(this)上,可通过this访问或修改:

export default {
  data() {
    return {
      count: 1, // 声明响应式属性count
      user: { name: 'Alice', age: 20 } // 嵌套对象也会被深层响应式处理
    }
  },
  mounted() {
    console.log(this.count) // 1(通过this访问响应式数据)
    this.count = 2 // 修改响应式数据,触发DOM更新
    this.user.age = 21 // 深层修改嵌套对象,同样触发更新
  }
}

关键注意事项

  • 必须预声明所有属性:若后期通过this.newProp = 'new'添加属性,newProp不会是响应式的(因为Vue无法追踪未预声明的属性)。如需动态添加,可先在data中用null/undefined占位(如newProp: null)。
  • 避免覆盖this的内置属性:Vue用$(如this.$emit)和_(如this._uid)作为内置API的前缀,不要用这些字符开头命名data属性。

1.2 响应式代理与原始对象的区别

Vue 3用JavaScript Proxy实现响应式(取代Vue 2的Object.defineProperty)。代理对象与原始对象是不同的引用

往期文章归档
免费好用的热门在线工具
修改原始对象不会触发响应式更新:
export default {
  data() {
    return {
      someObject: {}
    }
  },
  mounted() {
    const newObject = {}
    this.someObject = newObject // 将代理指向newObject
    console.log(newObject === this.someObject) // false(this.someObject是代理)
    newObject.foo = 'bar' // 修改原始对象,不会触发DOM更新
    this.someObject.foo = 'bar' // 修改代理对象,触发更新
  }
}

结论:始终通过this访问响应式数据(即操作代理对象),而非原始对象。

二、Composition API中的响应式声明与操作

Composition API是Vue 3的推荐写法,更灵活、更适合复杂逻辑复用。核心API是refreactive

2.1 ref():包裹任意值的响应式容器

ref用于包裹基本类型(如numberstring)或需要替换的对象,返回一个带value属性的响应式对象。

基本用法
<script setup>
import { ref } from 'vue'

// 声明ref:初始值0,count是一个ref对象
const count = ref(0)

// 修改ref的值:必须通过.value(JavaScript中)
function increment() {
  count.value++
}
</script>

<template>
  <!-- 模板中自动解包,不用写.value -->
  <button @click="increment">{{ count }}</button>
</template>
关键细节
  • .value的作用:Vue通过ref.valuegetter/setter追踪响应式(getter时记录依赖,setter时触发更新)。
  • 自动解包场景
    • 模板中的顶级ref(如上面的count)会自动解包;
    • 作为响应式对象的属性时(如const state = reactive({ count })state.count会自动解包为count.value)。
  • 非自动解包场景
    • 数组/集合中的ref(如const books = reactive([ref('Vue Guide')]),需用books[0].value访问);
    • 嵌套对象中的ref(如const obj = { id: ref(1) },模板中{{ obj.id + 1 }}不会解包,需解构const { id } = obj后使用{{ id + 1 }})。

2.2 reactive():让对象本身变响应式

reactive用于将对象类型(对象、数组、Map/Set)转换为响应式代理,无需value属性即可直接修改:

<script setup>
import { reactive } from 'vue'

// 声明reactive对象:state是响应式代理
const state = reactive({
  count: 0,
  user: { name: 'Bob' }
})

// 修改响应式数据:直接操作属性
function increment() {
  state.count++
  state.user.name = 'Charlie' // 深层修改嵌套对象
}
</script>

<template>
  <button @click="increment">{{ state.count }} - {{ state.user.name }}</button>
</template>
局限性与规避方法

reactive有3个关键局限:

  1. 不能包裹基本类型reactive(1)无效,需用ref(1)
  2. 不能替换整个对象:若state = reactive({ count: 1 }),替换state = { count: 2 }会丢失响应式(代理引用被切断),需用ref包裹对象(const state = ref({ count: 0 }),修改state.value = { count: 2 });
  3. 解构丢失响应式const { count } = state会将count变成普通变量,修改count不会触发更新。需用toRefsreactive对象转为ref集合:
    import { reactive, toRefs } from 'vue'
    const state = reactive({ count: 0 })
    const { count } = toRefs(state) // count是ref,保留响应式
    count.value++ // 触发更新
    

2.3 深层响应式与浅响应式

refreactive默认会深层递归处理所有嵌套对象(即修改嵌套属性也会触发响应式):

const obj = ref({ nested: { count: 0 }, arr: ['foo'] })
obj.value.nested.count++ // 触发更新
obj.value.arr.push('bar') // 触发更新

若需优化性能(如大对象无需深层响应式),可使用:

  • shallowRef:仅追踪.value的变化(不处理嵌套对象);
  • shallowReactive:仅追踪对象的顶级属性变化(不处理嵌套对象)。

三、DOM更新的时机与nextTick

Vue修改响应式数据后,DOM更新是异步的(缓冲到“下一个tick”,避免多次修改导致重复更新)。若需等待DOM更新完成后操作DOM,需用nextTick

<script setup>
import { ref, nextTick } from 'vue'

const count = ref(0)

async function increment() {
  count.value++
  // 等待DOM更新完成
  await nextTick()
  // 此时可安全访问更新后的DOM
  console.log(document.querySelector('.count').textContent) // 输出1
}
</script>

<template>
  <span class="count">{{ count }}</span>
  <button @click="increment">Increment</button>
</template>

课后Quiz

1. 为什么在Composition API中修改ref的值需要用.value

答案解析
ref是一个包裹值的对象,Vue通过ref.valuegetter/setter实现响应式:

  • getter:当访问ref.value时,Vue记录当前组件作为依赖;
  • setter:当修改ref.value时,Vue通知所有依赖组件重新渲染。
    模板中ref会自动解包(即隐式访问.value),但JavaScript中必须显式写.value

2. 用reactive声明的对象,为什么不能直接替换整个引用?

答案解析
reactive返回的是原始对象的代理,Vue的响应式追踪基于这个代理的属性访问。若替换整个对象(如state = { count: 1 }),新对象不是代理,Vue无法追踪其变化,导致DOM不更新。
解决方法:用ref包裹对象(const state = ref({ count: 0 })),修改state.value = { count: 1 }ref.value的变化会被追踪)。

3. 修改响应式数据后,立即访问DOM得到旧值,如何解决?

答案解析
Vue的DOM更新是异步缓冲的(批量处理所有状态变化,避免重复渲染)。需用nextTick等待DOM更新完成:

async function update() {
  count.value++
  await nextTick() // 等待下一个DOM更新周期
  // 此时DOM已更新
}

常见报错解决方案

报错1:修改数据后DOM不更新

  • 可能原因
    1. 数据未在响应式系统中声明(如let count = 0,未用ref包裹);
    2. 替换了reactive对象的整个引用(如state = { count: 1 });
    3. 修改了未预声明的属性(如Options API中this.newProp = 'new')。
  • 解决方法
    1. ref/reactive声明所有响应式数据;
    2. ref包裹需要替换的对象(修改ref.value);
    3. data中预声明属性(如newProp: null)。

报错2:ref在模板中显示[object Object]

  • 可能原因:ref嵌套在对象中,且不是文本插值的最终值(如{{ object.id + 1 }}object.id是ref)。
  • 解决方法
    1. 解构ref为顶级属性(const { id } = object,然后{{ id + 1 }});
    2. 显式访问.value(不推荐,如{{ object.id.value + 1 }})。

报错3:解构reactive对象后,修改数据不触发更新

  • 可能原因:解构会将reactive属性转为普通变量(如const { count } = statecount是普通number)。
  • 解决方法:用toRefsreactive对象转为ref集合:
    import { reactive, toRefs } from 'vue'
    const state = reactive({ count: 0 })
    const { count } = toRefs(state) // count是ref,保留响应式
    count.value++ // 触发更新
    

参考链接:vuejs.org/guide/essen…

前端解构赋值避坑指南基础到高阶深度解析技巧

作者 搜罗万相
2025年11月8日 11:48

前端解构赋值避坑指南:从基础到高阶的深度解析

一、解构赋值的基本语法与常见场景

解构赋值是ES6中引入的语法糖,它允许我们从数组或对象中提取值并赋值给变量。这种语法在处理复杂数据结构时特别有用,能显著提高代码的可读性和简洁性。

1. 数组解构的基本用法

// 基本数组解构
const [a, b, c] = [1, 2, 3];
console.log(a); // 1
console.log(b); // 2
console.log(c); // 3

// 跳过元素
const [x, , z] = [1, 2, 3];
console.log(x); // 1
console.log(z); // 3

// 剩余元素收集
const [first, ...rest] = [1, 2, 3, 4];
console.log(first); // 1
console.log(rest); // [2, 3, 4]

2. 对象解构的基本用法

// 基本对象解构
const person = { name: 'Alice', age: 30 };
const { name, age } = person;
console.log(name); // Alice
console.log(age); // 30

// 重命名变量
const { name: userName, age: userAge } = person;
console.log(userName); // Alice
console.log(userAge); // 30

// 默认值
const { city = 'Beijing' } = person;
console.log(city); // Beijing

二、解构赋值中的常见陷阱

1. 变量声明与赋值的混淆

// 错误示例:直接解构赋值给已声明变量
let x, y;
{x, y} = {x: 1, y: 2}; // 报错!SyntaxError: Unexpected token '='

// 正确方式:用括号包裹
let x, y;
({x, y} = {x: 1, y: 2});
console.log(x, y); // 1 2

2. 默认值的陷阱

// 错误示例:对已定义但值为undefined的变量使用默认值
const options = { timeout: undefined };
const { timeout = 5000 } = options;
console.log(timeout); // 5000,而非undefined

// 区分undefined和其他假值
const { maxRetries = 3 } = { maxRetries: 0 };
console.log(maxRetries); // 3,而非0

3. 嵌套解构的复杂性

// 复杂嵌套结构的解构
const data = {
  user: {
    name: 'Bob',
    address: {
      city: 'Shanghai',
      street: {
        main: 'Nanjing Rd',
        detail: undefined
      }
    }
  }
};

// 安全的嵌套解构
const {
  user: {
    address: {
      street: { main = 'Unknown' } = {}
    } = {}
  } = {}
} = data;

console.log(main); // Nanjing Rd

三、高级应用场景与最佳实践

1. 函数参数解构

// 函数参数解构与默认值
function fetchData({ url, method = 'GET', timeout = 3000 } = {}) {
  console.log(url, method, timeout);
}

fetchData({ url: 'https://api.example.com' }); // https://api.example.com GET 3000
fetchData(); // undefined GET 3000(需注意这种情况)

2. 与扩展运算符结合使用

// 提取特定属性,剩余属性保留
const user = { id: 1, name: 'Charlie', age: 25, email: 'charlie@example.com' };
const { id, ...userInfo } = user;

console.log(id); // 1
console.log(userInfo); // { name: 'Charlie', age: 25, email: 'charlie@example.com' }

3. 解构动态属性名

// 使用变量作为解构的属性名
const key = 'color';
const config = { color: 'red', size: 'large' };
const { [key]: value } = config;

console.log(value); // red

四、性能考量与优化建议

虽然解构赋值语法简洁,但在性能敏感的场景下,过度使用可能带来一定开销。特别是在循环中频繁解构大型对象或数组时,建议缓存解构结果以提高性能。

// 性能优化示例:避免循环中重复解构
const items = [
  { id: 1, name: 'Item 1' },
  { id: 2, name: 'Item 2' },
  // ...更多项
];

// 较差的写法
for (const { id, name } of items) {
  // 处理逻辑
}

// 更好的写法(性能优化)
for (let i = 0; i < items.length; i++) {
  const item = items[i];
  const id = item.id;
  const name = item.name;
  // 处理逻辑
}

五、总结

解构赋值是前端开发中非常实用的语法特性,但也存在一些容易被忽视的陷阱。通过理解其工作原理、掌握正确的使用方式以及注意性能优化,开发者可以更加安全、高效地运用这一特性,提升代码质量和开发效率。

在实际项目中,建议结合ESLint等工具配置相关规则(如prefer-destructuring)来规范解构赋值的使用,避免常见错误。同时,对于复杂的解构场景,适当添加注释可以提高代码的可维护性。


前端开发,解构赋值,JavaScript,ES6, 避坑指南,基础教程,高阶技巧,深度解析,前端框架,Web 开发,变量声明,数组解构,对象解构,前端优化,前端面试



资源地址: pan.quark.cn/s/50438c9ee…


CSS选择器与层叠机制

2025年11月8日 11:34

CSS(层叠样式表)作为网页设计的核心技术之一,不仅决定了网页的外观和布局,还通过其独特的选择器系统和层叠机制实现了样式的精确控制。本文将通过分析多个HTML和CSS示例,深入探讨CSS选择器的类型、优先级计算以及层叠原理。

一、CSS基础结构

CSS的基本组成单位是"属性-值"对的声明,多个声明构成声明块,声明块通过选择器与HTML元素关联,最终形成完整的样式规则。

css

复制下载

p {
  color: blue;
  font-size: 16px;
}

上述代码中,color: blue;font-size: 16px;是两个声明,它们共同组成了一个声明块,p是选择器,用于指定这些样式将应用于哪些HTML元素。

二、CSS选择器类型与优先级

1. 基础选择器

基础选择器包括元素选择器、类选择器和ID选择器:

css

复制下载

/* 元素选择器 */
p {
  color: blue;
}

/* 类选择器 */
.container {
  width: 100%;
}

/* ID选择器 */
#main {
  margin: 0 auto;
}

2. 优先级计算模型

CSS选择器的优先级通常被描述为一个四位数的权重系统,按"个十百千"从低到高排列:

  • 千位:行内样式(style属性)
  • 百位:ID选择器
  • 十位:类选择器、属性选择器和伪类
  • 个位:元素选择器和伪元素

在1.html示例中,我们可以清楚地看到不同选择器的优先级表现:

html

复制下载运行

<style>
p {
  color: blue; /* 优先级:1 (个位) */
}
.container p {
  color: red; /* 优先级:11 (十位+个位) */
}
#main p {
  color: green; /* 优先级:101 (百位+个位) */
}
</style>

<div id="main" class="container">
  <p>这是一个段落</p>
</div>

最终段落文字显示为绿色,因为ID选择器(#main p)的优先级最高。这个例子直观地展示了CSS优先级计算规则。

3. 关系选择器

关系选择器根据元素在文档树中的位置关系进行选择:

css

复制下载

/* 后代选择器 */
.container p {
  text-decoration: underline;
}

/* 子选择器 */
.container > p {
  color: pink;
}

/* 相邻兄弟选择器 */
h1 + p {
  color: red;
}

/* 通用兄弟选择器 */
h1 ~ p {
  color: blue;
}

在3.html中,这些关系选择器的效果得到了充分展示:

html

复制下载运行

<style>
h1 + p { color: red; } /* 紧接在h1后的p元素 */
p + p { color: green; } /* 紧接在p后的p元素 */
h1 ~ p { color: blue; } /* h1后面的所有p元素 */
.container > p { color: pink; } /* .container的直接子p元素 */
.container p { text-decoration: underline; } /* .container的所有后代p元素 */
</style>

<div class="container">
  <p>这是第二段文字</p> <!-- 粉色、下划线 -->
  <h1>标题</h1>
  <p>这是第一段文字。</p> <!-- 蓝色、红色(被蓝色覆盖)、下划线 -->
  <p>这是第二段文字。</p> <!-- 蓝色、绿色(被蓝色覆盖)、下划线 -->
  <a href="#">链接</a>
  <span>这是一个span元素。</span>
  <div class="inner">
    <p>这是内部段落。</p> <!-- 仅下划线 -->
  </div>
</div>

这个例子展示了不同关系选择器的应用范围和优先级关系。

4. 属性选择器

属性选择器根据元素的属性及属性值进行选择:

css

复制下载

/* 匹配具有特定属性值的元素 */
[data-category="科幻"] {
  background-color: #1e0216;
  color: rgb(169, 137, 158);
}

/* 匹配属性值以特定字符串开头的元素 */
[title^="入门"] h2::before {
  content: "🌟";
}

在2.html中,属性选择器被用于为不同类别的书籍设置不同的样式:

html

复制下载运行

<div class="book" data-category="科幻">
  <h2>三体</h2>
  <p>作者:刘慈欣</p>
</div>
<div class="book" data-category="历史">
  <h2>明朝那些事儿</h2>
  <p>作者:当年明月</p>
</div>

5. 伪类与伪元素

伪类用于选择处于特定状态的元素,而伪元素则用于创建不在文档树中的抽象元素:

css

复制下载

/* 伪类 */
button:active {
  color: red;
}

p:hover {
  background-color: yellow;
}

input:checked + label {
  font-weight: bold;
}

/* 反选伪类 */
li:not(:last-child) {
  margin-bottom: 10px;
}

/* 结构化伪类 */
li:nth-child(odd) {
  background-color: lightgray;
}

/* 伪元素 */
.more::before {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 2px;
  background-color: white;
}

.more::after {
  content: "\2192";
  margin-left: 5px;
}

4.html和6.html展示了伪类和伪元素的应用:

html

复制下载运行

<!-- 4.html中的伪类示例 -->
<button>点击我</button> <!-- 点击时变红 -->
<p>鼠标悬浮在这里</p> <!-- 悬浮时背景变黄 -->
<ul>
  <li>列表项1</li> <!-- 灰色背景,底部间距 -->
  <li>列表项2</li> <!-- 无背景,底部间距 -->
  <li>列表项3</li> <!-- 灰色背景,底部间距 -->
  <li>列表项4</li> <!-- 无背景,无底部间距 -->
</ul>

<!-- 6.html中的伪元素示例 -->
<a href="#" class="more">查看更多</a> <!-- 有箭头图标,悬停时有下划线动画 -->

6. :nth-child与:nth-of-type的区别

这两个伪类经常被混淆,但它们的选择逻辑有本质区别:

css

复制下载

/* 选择.container的第4个子元素,且该元素必须是p标签 */
.container p:nth-child(4) {
  background-color: yellow;
}

/* 选择.container的第3个p类型子元素 */
.container p:nth-of-type(3) {
  background-color: orange;
}

在5.html中,这两种选择器的差异得到了清晰展示:

html

复制下载运行

<div class="container">
  <h1>nth-child vs nth-of-type 实例</h1> <!-- 第1个子元素 -->
  <p>这是一个段落。</p> <!-- 第2个子元素,第1个p元素 -->
  <div>这是一个div。</div> <!-- 第3个子元素 -->
  <p>这是第二个段落。</p> <!-- 第4个子元素,第2个p元素 - 黄色背景 -->
  <p>这是第三个段落。</p> <!-- 第5个子元素,第3个p元素 - 橙色背景 -->
  <div>这是第二个div。</div> <!-- 第6个子元素 -->
</div>

:nth-child(n)选择的是父元素的第n个子元素,且必须同时满足其他选择条件;而:nth-of-type(n)选择的是父元素下同类型元素的第n个。

三、CSS层叠机制

1. 样式来源与优先级

CSS样式有三个主要来源,按优先级从高到低排列:

  1. 作者样式表:网页开发者编写的样式
  2. 用户样式表:浏览器用户自定义的样式
  3. 浏览器默认样式表:浏览器的默认样式

在作者样式表中,又有不同的引入方式和优先级:

html

复制下载运行

<!-- 外联样式 -->
<link rel="stylesheet" href="theme.css">

<!-- 内嵌样式 -->
<style>
.text p {
  color: red;
}
</style>

<!-- 行内样式 -->
<button style="background: pink;">Click</button>

2. 层叠规则

当多个规则应用于同一元素时,CSS通过以下顺序决定最终样式:

  1. 重要性:带有!important的声明
  2. 来源:作者样式表 > 用户样式表 > 浏览器默认样式
  3. 选择器特异性:按千位、百位、十位、个位比较
  4. 代码顺序:后出现的规则覆盖先出现的规则

在7.html中,我们可以观察到这些规则的相互作用:

html

复制下载运行

<style>
.text p { color: red; } /* 优先级:11 */
div p { color: blue; } /* 优先级:2 */
#main p { color: green; } /* 优先级:101 */
.container #main p { color: orange; } /* 优先级:201 */
</style>

<div class="text">
  <p>Hello</p> <!-- 红色:.text p (11) > div p (2) -->
</div>

<div class="container">
  <div id="main">
    <p>hello</p> <!-- 橙色:.container #main p (201) > #main p (101) -->
  </div>
</div>

<button class="btn" style="background: pink;">Click</button>
<!-- 粉色:行内样式 (1000) > .btn (10) -->

3. 继承与初始值

某些CSS属性会自动从父元素继承到子元素,如colorfont-family等。对于那些不能继承的属性,每个元素都有初始值。

css

复制下载

body {
  color: blue; /* 所有body内的文本元素都会继承这个颜色 */
}

div {
  border: 1px solid black; /* border不会继承给子元素 */
}

四、CSS实践中的注意事项

1. 盒模型与边距重叠

在CSS盒模型中,相邻元素的上下边距会发生重叠,取两者中的较大值作为实际间距:

css

复制下载

.box1 {
  margin-bottom: 20px;
}

.box2 {
  margin-top: 30px;
}
/* 实际间距为30px,而不是50px */

2. 小数像素处理

当使用小数像素值时,不同浏览器的处理方式可能不同。一般来说,浏览器会进行亚像素渲染,但实际显示效果可能因浏览器和操作系统而异。

3. 行内元素的限制

行内元素(inline)在某些情况下不支持某些CSS属性,如transform。如果需要使用这些属性,可以将元素设置为inline-blockblock

css

复制下载

.inline-element {
  display: inline-block; /* 使行内元素支持transform */
  transform: rotate(10deg);
}

五、CSS选择器最佳实践

  1. 避免过度使用ID选择器:由于ID选择器的高特异性,后续难以覆盖,不利于维护。
  2. 优先使用类选择器:类选择器具有适中的特异性,易于复用和覆盖。
  3. 避免使用!important:除非必要,否则应避免使用!important,因为它会破坏正常的层叠顺序。
  4. 保持选择器简洁:过于复杂的选择器不仅难以理解,还可能影响性能。
  5. 利用CSS自定义属性:使用CSS变量提高样式的可维护性:

css

复制下载

:root {
  --primary-color: #007bff;
  --spacing: 10px;
}

.button {
  background-color: var(--primary-color);
  padding: var(--spacing);
}

六、结语

CSS选择器和层叠机制是CSS强大功能的核心。通过深入理解不同类型选择器的特性和优先级计算规则,开发者可以编写出更加精确、高效和可维护的样式代码。同时,掌握层叠原理有助于解决样式冲突,实现预期的视觉效果。随着CSS标准的不断发展,选择器的功能和性能也在持续优化,为网页设计带来更多可能性。

在实际开发中,建议结合开发者工具进行样式调试,直观地观察选择器的匹配情况和样式覆盖关系,这将大大提高CSS代码的编写效率和准确性

浅入理解跨端渲染:从零实现 React DSL 跨端渲染机制

2025年11月8日 13:02

前言

在移动应用开发领域,跨端技术已经成为主流选择。React Native、Flutter、Weex 等框架让我们能够用一套代码运行在多个平台上。不同的框架实现的原理不同,更多的总结对比可以看这篇博客大厂自研跨端框架技术揭秘

笔者工作中使用的跨端框架叫做 Kun,是闲鱼基于 W3C 标准 & Flutter 打造的混合高性能终端容器,其原理与 React Native 相似:

  1. 使用 React 语法编写业务代码
  2. 编译打包成 JavaScript Bundle
  3. 在运行时通过桥接层渲染到各个平台

本文将通过一个极简的 Web 模拟案例,带你深入理解这种 React DSL 跨端渲染的核心机制

跨端渲染的本质

跨端渲染的核心思想可以用一句话概括:用统一的 API 描述 UI,由框架负责在不同平台上完成渲染

传统的移动端原生开发中,iOS 使用 UIKit,Android 使用 Android SDK,两者的 API 完全不同。而跨端框架通过引入一个中间层,让开发者用统一的方式描述 UI,然后由框架负责处理平台差异——可能是映射到原生组件(如 React Native、Kun),也可能是自己绘制 UI(如 Flutter),或是通过 WebView 渲染(如 H5、各家小程序方案等)。

架构分层

一个典型的基于 React DSL 的跨端框架包含三个核心层次:

┌─────────────────────────────────┐
│      业务逻辑层 (JavaScript)      │  ← 开发者编写的代码
├─────────────────────────────────┤
│      桥接层 (Bridge)             │  ← 通信中枢
├─────────────────────────────────┤
│      渲染层 (Native)             │  ← 平台原生渲染
└─────────────────────────────────┘

完整的原理链路如下:

在编译时,可以通过 React DSL 脚手架工具,将 JSX 转化成 createElement 形式。最终的产物可以理解成一个 JS 文件,可以称之为 JSBundle。

重点来了,在运行时,我们分别从 web 应用 和 Native 应用 两个角度来解析流程:

  • 如果是 React DSL web 应用,那么可以通过浏览器加载 JSBundle ,然后通过运行时的 api 将页面结构,转化成虚拟 DOM , 虚拟 DOM 再转化成真实 DOM, 然后浏览器可以渲染真实 DOM 。

  • 如果是 React DSL Native 应用,那么 Native 会通过一个 JS 引擎来运行 JSBundle ,然后同样通过运行时的 API 转化成虚拟 DOM, 接下来因为 Native 应用,所以不能直接转化的 DOM, 这个时候可以生成一些绘制指令,可以通过桥的方式,把指令传递给 Native 端,Native 端接收到指令之后,就可以绘制页面了。这样的好处就可以动态上传 bundle ,来实现动态化更新(个人认为没有动态化更新的跨端框架是没有灵魂的)。

核心概念解析

1. 虚拟 DOM (Virtual DOM)

虚拟 DOM 是对真实 UI 的轻量级描述,它是一个纯 JavaScript 对象树。

// 虚拟 DOM 节点结构
{
  tag: 'View',           // 组件类型
  props: { id: 'root' }, // 属性
  children: [            // 子节点
    {
      tag: 'Text',
      props: { text: 'Hello World' },
      children: []
    }
  ]
}

为什么需要虚拟 DOM?

  • 性能优化:直接操作原生 UI 成本高,虚拟 DOM 可以批量计算变更
  • 跨平台抽象:提供统一的 UI 描述方式
  • Diff 算法:通过对比新旧虚拟 DOM,最小化实际渲染操作

2. 桥接通信 (Bridge)

桥接层是 JavaScript 层和 Native 层之间的通信管道。

// Native → JS: 事件传递
bridge.sendToJS({
  type: 'EVENT',
  payload: {
    eventName: 'handleClick',
    params: { x: 100, y: 200 }
  }
});

// JS → Native: 渲染指令
bridge.sendToNative({
  type: 'RENDER',
  payload: [
    { type: 'CREATE', payload: {...} },
    { type: 'UPDATE', payload: {...} }
  ]
});

桥接通信的特点

  • 异步通信:避免阻塞主线程
  • 序列化传输:数据需要序列化为 JSON
  • 双向通道:支持 JS ↔ Native 双向消息传递

3. 渲染指令 (Render Instructions)

渲染指令是 JavaScript 层告诉 Native 层"如何渲染"的命令集。

// 三种基本指令类型
const instructions = [
{
type: 'CREATE', // 创建新元素
payload: {
id: 'vdom_1',
tag: 'View',
props: {},
parentId: null
}
},
{
type: 'UPDATE', // 更新已有元素
payload: {
id: 'vdom_2',
props: { text: 'New Text' }
}
},
{
type: 'DELETE', // 删除元素
payload: {
id: 'vdom_3'
}
}
];

完整渲染流程

让我们通过一个完整的交互流程,理解整个渲染机制:

阶段一:初始化渲染

1. Native 层启动
   ↓
2. 初始化 JS 引擎(JSCore/V8/Hermes)
   ↓
3. 加载并执行 JavaScript 代码
   ↓
4. JS 层创建虚拟 DOM 树
   ↓
5. 生成渲染指令
   ↓
6. 通过 Bridge 发送到 Native
   ↓
7. Native 层执行渲染指令
   ↓
8. 显示 UI

代码示例

// JS 层:初始化渲染
class ReactDSL {
mount() {
// 1. 执行 render 函数生成虚拟 DOM
const vdom = this.render();

// 2. 转换为渲染指令
const instructions = this.vdomToInstructions(vdom);

// 3. 发送到 Native
this.sendToNative({
type: 'RENDER',
payload: instructions
});
}

render() {
return this.createElement(
'View',
{},
this.createElement('Text', {
text: '欢迎使用 React Native'
})
);
}
}

阶段二:交互更新

1. 用户点击按钮
   ↓
2. Native 层捕获事件
   ↓
3. 通过 Bridge 发送事件到 JS 层
   ↓
4. JS 层执行事件处理函数
   ↓
5. 更新状态 (setState)
   ↓
6. 重新执行 render 生成新虚拟 DOM
   ↓
7. Diff 算法对比新旧虚拟 DOM
   ↓
8. 生成最小化的更新指令
   ↓
9. 通过 Bridge 发送到 Native
   ↓
10. Native 层执行更新指令
   ↓
11. UI 更新完成

代码示例

// JS 层:状态更新流程
class ReactDSL {
handleIncrement() {
// 1. 更新状态
this.setState({ count: this.state.count + 1 });
}

setState(newState) {
this.state = { ...this.state, ...newState };

// 2. 触发更新
this.update();
}

update() {
// 3. 生成新虚拟 DOM
const newVDOM = this.render();

// 4. Diff 算法
const instructions = this.diff(this.currentVDOM, newVDOM);

// 5. 更新缓存
this.currentVDOM = newVDOM;

// 6. 发送更新指令
if (instructions.length > 0) {
this.sendToNative({
type: 'RENDER',
payload: instructions
});
}
}
}

Diff 算法简析

Diff 算法是跨端渲染的性能关键。它的目标是:找出新旧虚拟 DOM 的最小差异

简化版 Diff 实现

diff(oldVDOM, newVDOM) {
  const instructions = [];

  // 策略1:如果节点类型不同,直接替换
  if (oldVDOM.tag !== newVDOM.tag) {
    instructions.push(
      { type: 'DELETE', payload: { id: oldVDOM.id } },
      { type: 'CREATE', payload: newVDOM }
    );
    return instructions;
  }

  // 策略2:对比属性变化
  const propsChanged = this.diffProps(oldVDOM.props, newVDOM.props);
  if (propsChanged) {
    instructions.push({
      type: 'UPDATE',
      payload: {
        id: oldVDOM.id,
        props: newVDOM.props
      }
    });
  }

  // 策略3:递归对比子节点
  const childInstructions = this.diffChildren(
    oldVDOM.children,
    newVDOM.children
  );
  instructions.push(...childInstructions);

  return instructions;
}

React 的 Diff 优化策略

  1. 同层比较:只比较同一层级的节点,不跨层级
  2. Key 优化:通过 key 快速识别节点移动
  3. 类型判断:不同类型的组件直接替换,不深入比较

原理最简化实现及实战案例

下面用一个非常简单案例,来用前端的方式模拟 React DSL Native 渲染流程。

  • index.html 为视图层, 这里用视图层模拟代替了 Native 应用。
  • bridge 为 JS 层和 Native 层的代码。
  • service.js 为我们写在 js 业务层的代码。

核心流程如下:

  • 本质上 service.js 运行在 Native 的 JS 引擎中,形成虚拟 DOM ,和绘制指令。
  • 绘制指令可以通过 bridge 传递给 Native 端 (案例中的 html 和 js ),然后渲染视图。
  • 当触发更新时候,Native 端响应事件,然后把事件通过桥方式传递给 service.js, 接下来 service.js 处理逻辑,发生 diff 更新,产生新的绘制指令,通知给 Native 渲染视图。

因为这个案例是用 web 应用模拟的 Native ,所以实现细节和真实场景有所不同,尽请谅解,本案例主要让读者更清晰了解渲染流程。

完整代码在仓库react-dsl-renderer-demo中,直接运行仓库下面的index.html即可看到相关效果:

让我们通过一个完整的计数器应用,串联所有知识点:

// service.js - 业务逻辑层
class CounterApp {
constructor() {
this.state = { count: 0 };
}

// 渲染函数
render() {
return this.createElement(
'View',
{},
this.createElement('Text', {
text: `计数: ${this.state.count}`
}),
this.createElement('Button', {
title: '增加',
onPress: 'handleIncrement'
})
);
}

// 事件处理
handleIncrement() {
this.setState({ count: this.state.count + 1 });
}
}

执行流程分析

  1. 初始渲染

    • 生成虚拟 DOM:View -> [Text, Button]
    • 转换为 3 条 CREATE 指令
    • Native 创建对应的原生组件
  2. 点击按钮

    • Native 捕获点击事件
    • 发送 EVENT 消息到 JS
    • JS 执行 handleIncrement
    • 状态从 {count: 0} 变为 {count: 1}
    • 重新 render 生成新虚拟 DOM
    • Diff 发现 Text 的 text 属性变化
    • 生成 1 条 UPDATE 指令
    • Native 更新 Text 组件显示

总结

通过本文的剖析,我们了解了 React DSL 跨端渲染的核心机制:

  1. 虚拟 DOM 提供了统一的 UI 描述方式
  2. Bridge 实现了 JS 与 Native 的通信桥梁
  3. Diff 算法 最小化了实际的渲染操作
  4. 渲染指令 将 UI 变更转换为平台操作

当然,用于生产环境的跨端框架在实现上会有更多细节和优化,使用的具体技术也可能不同,但核心原理是一致的。

跨端技术的本质是在性能和开发效率之间找到平衡。理解其底层原理,能帮助我们:

  • 写出更高性能的跨端应用
  • 更好地调试和优化问题
  • 为技术选型提供依据

《uni-app跨平台开发完全指南》- 05 - 基础组件使用

2025年11月8日 10:55

基础组件

欢迎回到《uni-app跨平台开发完全指南》系列!在之前的文章中,我们搭好了开发环境,了解了项目目录结构、Vue基础以及基本的样式,这一章节带大家了解基础组件如何使用。掌握了基础组件的使用技巧,就能独立拼装出应用的各个页面了!

一、 初识uni-app组件

在开始之前,先自问下什么是组件?

你可以把它理解为一个封装了结构(WXML)、样式(WXSS)和行为(JS)的、可复用的自定义标签。比如一个按钮、一个导航栏、一个商品卡片,都可以是组件。

uni-app的组件分为两类:

  1. 基础组件:框架内置的,如<view>, <text>, <image>等。这些是官方为我们准备好的标准组件。
  2. 自定义组件:开发者自己封装的,用于实现特定功能或UI的组件,可反复使用。

就是这些基础组件,它们遵循小程序规范,同时被映射到各端,是实现“一套代码,多端运行”的基础。

为了让大家对基础组件有个全面的认识,参考下面的知识脉络图:

graph TD
    A[uni-app 基础组件] --> B[视图容器类];
    A --> C[基础内容类];
    A --> D[表单类];
    A --> E[导航类];
    A --> F[自定义组件];

    B --> B1[View];
    B --> B2[Scroll-View];

    C --> C1[Text];
    C --> C2[Image];

    D --> D1[Button];
    D --> D2[Input];
    D --> D3[Checkbox/Radio];

    E --> E1[Navigator];

    F --> F1[创建];
    F --> F2[通信];
    F --> F3[生命周期];

接下来,我们详细介绍下这些内容。


二、 视图与内容:View、Text、Image

这三个组件是构建页面最基础、最核心的部分,几乎无处不在。

2.1 一切的容器:View

<view> 组件是一个视图容器。它相当于传统HTML中的 <div> 标签,是一个块级元素,主要用于布局和包裹其他内容。

核心特性:

  • 块级显示:默认独占一行。
  • 样式容器:通过为其添加classstyle,可以轻松实现Flex布局、Grid布局等。
  • 事件容器:可以绑定各种触摸事件,如@tap(点击)、@touchstart(触摸开始)等。

以一个简单的Flex布局为例:

<!-- 模板部分 -->
<template>
  <view class="container">
    <view class="header">我是头部</view>
    <view class="content">
      <view class="left-sidebar">左边栏</view>
      <view class="main-content">主内容区</view>
    </view>
    <view class="footer">我是底部</view>
  </view>
</template>

<style scoped>
/* 样式部分 */
.container {
  display: flex;
  flex-direction: column; /* 垂直排列 */
  height: 100vh; /* 满屏高度 */
}
.header, .footer {
  height: 50px;
  background-color: #007AFF;
  color: white;
  text-align: center;
  line-height: 50px; /* 垂直居中 */
}
.content {
  flex: 1; /* 占据剩余所有空间 */
  display: flex; /* 内部再启用Flex布局 */
}
.left-sidebar {
  width: 100px;
  background-color: #f0f0f0;
}
.main-content {
  flex: 1; /* 占据content区域的剩余空间 */
  background-color: #ffffff;
}
</style>

以上代码:

  • 我们通过多个<view>的嵌套,构建了一个经典的“上-中-下”布局。
  • 外层的.container使用flex-direction: column实现垂直排列。
  • 中间的.content自己也是一个Flex容器,实现了内部的水平排列。
  • flex: 1 是Flex布局的关键,表示弹性扩展,填满剩余空间。

小结一下View:

  • 它是布局的骨架,万物皆可<view>
  • 熟练掌握Flex布局,再复杂的UI也能用<view>拼出来。

2.2 Text

<text> 组件是一个文本容器。它相当于HTML中的 <span> 标签,是行内元素。最重要的特点是:只有 <text> 组件内部的文字才是可选中的、长按可以复制!

核心特性:

  • 行内显示:默认不会换行。
  • 文本专属:用于包裹文本,并对文本设置样式和事件。
  • 选择与复制:支持user-select属性控制文本是否可选。
  • 嵌套与富文本:内部可以嵌套,自身也支持部分HTML实体和富文本。

以一个文本样式与事件为例:

<template>
  <view>
    <!-- 普通的view里的文字无法长按复制 -->
    <view>这段文字在view里,无法长按复制。</view>
    
    <!-- text里的文字可以 -->
    <text user-select @tap="handleTextTap" class="my-text">
      这段文字在text里,可以长按复制!点击我也有反应。
      <text style="color: red; font-weight: bold;">我是嵌套的红色粗体文字</text>
    </text>
  </view>
</template>

<script>
export default {
  methods: {
    handleTextTap() {
      uni.showToast({
        title: '你点击了文字!',
        icon: 'none'
      });
    }
  }
}
</script>

<style>
.my-text {
  color: #333;
  font-size: 16px;
  /* 注意:text组件不支持设置宽高和margin-top/bottom,因为是行内元素 */
  /* 如果需要,可以设置 display: block 或 inline-block */
}
</style>

以上代码含义:

  • user-select属性开启了文本的可选状态。
  • <text>组件可以绑定@tap事件,而<view>里的纯文字不能。
  • 内部的<text>嵌套展示了如何对部分文字进行特殊样式处理。

Text使用小技巧:

  1. 何时用? 只要是涉及交互(点击、长按)或需要复制功能的文字,必须用<text>包裹。
  2. 样式注意:它是行内元素,设置宽高和垂直方向的margin/padding可能不生效,可通过display: block改变。
  3. 性能:避免深度嵌套,尤其是与富文本一起使用时。

2.3 Image

<image> 组件用于展示图片。它相当于一个增强版的HTML <img>标签,提供了更丰富的功能和更好的性能优化。

核心特性与原理:

  • 多种模式:通过mode属性控制图片的裁剪、缩放模式,这是它的灵魂所在
  • 懒加载lazy-load属性可以在页面滚动时延迟加载图片,提升性能。
  • 缓存与 headers:支持配置网络图片的缓存策略和请求头。

mode属性详解(非常重要!) mode属性决定了图片如何适应容器的宽高。我们来画个图理解一下:

stateDiagram-v2
    [*] --> ImageMode选择
    
    state ImageMode选择 {
        [*] --> 首要目标判断
        
        首要目标判断 --> 保持完整不裁剪: 选择
        首要目标判断 --> 保持比例不变形: 选择  
        首要目标判断 --> 固定尺寸裁剪: 选择
        
        保持完整不裁剪 --> scaleToFill: 直接进入
        scaleToFill : scaleToFill\n拉伸至填满,可能变形
        
        保持比例不变形 --> 适应方式判断
        适应方式判断 --> aspectFit: 完全显示
        适应方式判断 --> aspectFill: 填满容器
        
        aspectFit : aspectFit\n适应模式\n容器可能留空
        aspectFill : aspectFill\n填充模式\n图片可能被裁剪
        
        固定尺寸裁剪 --> 多种裁剪模式
        多种裁剪模式 : widthFix / top / bottom\n等裁剪模式
    }
    
    scaleToFill --> [*]
    aspectFit --> [*]
    aspectFill --> [*]
    多种裁剪模式 --> [*]

下面用一段代码来展示不同Mode的效果

<template>
  <view>
    <view class="image-demo">
      <text>scaleToFill (默认,拉伸):</text>
      <!-- 容器 200x100,图片会被拉伸 -->
      <image src="/static/logo.png" mode="scaleToFill" class="img-container"></image>
    </view>

    <view class="image-demo">
      <text>aspectFit (适应):</text>
      <!-- 图片完整显示,上下或左右留白 -->
      <image src="/static/logo.png" mode="aspectFit" class="img-container"></image>
    </view>

    <view class="image-demo">
      <text>aspectFill (填充):</text>
      <!-- 图片填满容器,但可能被裁剪 -->
      <image src="/static/logo.png" mode="aspectFill" class="img-container"></image>
    </view>

    <view class="image-demo">
      <text>widthFix (宽度固定,高度自适应):</text>
      <!-- 非常常用!高度会按比例自动计算 -->
      <image src="/static/logo.png" mode="widthFix" class="img-auto-height"></image>
    </view>
  </view>
</template>

<style>
.img-container {
  width: 200px;
  height: 100px; /* 固定高度的容器 */
  background-color: #eee; /* 用背景色看出aspectFit的留白 */
  border: 1px solid #ccc;
}
.img-auto-height {
  width: 200px;
  /* 不设置height,由图片根据widthFix模式自动计算 */
}
.image-demo {
  margin-bottom: 20rpx;
}
</style>

Image使用注意:

  1. 首选 widthFix:在需要图片自适应宽度(如商品详情图、文章配图)时,mode="widthFix" 是神器,无需计算高度。
  2. ** 必设宽高**:无论是直接设置还是通过父容器继承,必须让<image>有确定的宽高,否则可能显示异常。
  3. 加载失败处理:使用@error事件监听加载失败,并设置默认图。
    <image :src="avatarUrl" @error="onImageError" class="avatar"></image>
    
    onImageError() {
      this.avatarUrl = '/static/default-avatar.png'; // 替换为默认头像
    }
    
  4. 性能优化:对于列表图片,务必加上 lazy-load

三、 按钮与表单组件

应用不能只是展示,更需要与用户交互。

3.1 Button

<button> 组件用于捕获用户的点击操作。它功能强大,样式多样,甚至能直接调起系统的某些功能。

核心特性

  • 多种类型:通过type属性控制基础样式,如default(默认)、primary(主要)、warn(警告)。
  • 开放能力:通过open-type属性可以直接调起微信的获取用户信息、分享、客服等功能。
  • 样式自定义:虽然提供了默认样式,但可以通过hover-class等属性实现点击反馈,也可以通过CSS完全自定义。

用一段代码来展示各种按钮:

<template>
  <view class="button-group">
    <!-- 基础样式按钮 -->
    <button type="default">默认按钮</button>
    <button type="primary">主要按钮</button>
    <button type="warn">警告按钮</button>

    <!-- 禁用状态 -->
    <button :disabled="true" type="primary">被禁用的按钮</button>

    <!-- 加载状态 -->
    <button loading type="primary">加载中...</button>

    <!-- 获取用户信息 -->
    <button open-type="getUserInfo" @getuserinfo="onGetUserInfo">获取用户信息</button>

    <!-- 分享 -->
    <button open-type="share">分享</button>

    <!-- 自定义样式 - 使用 hover-class -->
    <button class="custom-btn" hover-class="custom-btn-hover">自定义按钮</button>
  </view>
</template>

<script>
export default {
  methods: {
    onGetUserInfo(e) {
      console.log('用户信息:', e.detail);
      // 在这里处理获取到的用户信息
    }
  }
}
</script>

<style>
.button-group button {
  margin-bottom: 10px; /* 给按钮之间加点间距 */
}
.custom-btn {
  background-color: #4CD964; /* 绿色背景 */
  color: white;
  border: none; /* 去除默认边框 */
  border-radius: 10px; /* 圆角 */
}
.custom-btn-hover {
  background-color: #2AC845; /*  hover时更深的绿色 */
}
</style>

Button要点:

  • open-type:这是uni-app和小程序生态打通的关键,让你能用一行代码实现复杂的原生功能。
  • 自定义样式:默认按钮样式可能不符合设计,记住一个原则:先重置,再定义。使用border: none; background: your-color;来覆盖默认样式。
  • 表单提交:在<form>标签内,<button>form-type属性可以指定为submitreset

3.2 表单组件 - Input, Checkbox, Radio, Picker...

表单用于收集用户输入。uni-app提供了一系列丰富的表单组件。

Input - 文本输入框

核心属性:

  • v-model:双向绑定输入值,最常用
  • type:输入框类型,如text, number, idcard, password等。
  • placeholder:占位符。
  • focus:自动获取焦点。
  • @confirm:点击完成按钮时触发。

下面写一个登录输入框:

<template>
  <view class="login-form">
    <input v-model="username" type="text" placeholder="请输入用户名" class="input-field" />
    <input v-model="password" type="password" placeholder="请输入密码" class="input-field" @confirm="onLogin" />
    <button type="primary" @tap="onLogin">登录</button>
  </view>
</template>

<script>
export default {
  data() {
    return {
      username: '',
      password: ''
    };
  },
  methods: {
    onLogin() {
      // 验证用户名和密码
      if (!this.username || !this.password) {
        uni.showToast({ title: '请填写完整', icon: 'none' });
        return;
      }
      console.log('登录信息:', this.username, this.password);
      // 发起登录请求...
    }
  }
}
</script>

<style>
.input-field {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 10px;
  margin-bottom: 15px;
  height: 40px;
}
</style>

Checkbox 与 Radio - 选择与单选

这两个组件需要和<checkbox-group>, <radio-group>一起使用,来管理一组选项。

代码实战:选择兴趣爱好

<template>
  <view>
    <text>请选择你的兴趣爱好:</text>
    <checkbox-group @change="onHobbyChange">
      <label class="checkbox-label">
        <checkbox value="reading" :checked="true" /> 阅读
      </label>
      <label class="checkbox-label">
        <checkbox value="music" /> 音乐
      </label>
      <label class="checkbox-label">
        <checkbox value="sports" /> 运动
      </label>
    </checkbox-group>
    <view>已选:{{ selectedHobbies.join(', ') }}</view>

    <text>请选择性别:</text>
    <radio-group @change="onGenderChange">
      <label class="radio-label">
        <radio value="male" /></label>
      <label class="radio-label">
        <radio value="female" /></label>
    </radio-group>
    <view>已选:{{ selectedGender }}</view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      selectedHobbies: ['reading'], // 默认选中阅读
      selectedGender: ''
    };
  },
  methods: {
    onHobbyChange(e) {
      // e.detail.value 是一个数组,包含所有被选中的checkbox的value
      this.selectedHobbies = e.detail.value;
      console.log('兴趣爱好变化:', e.detail.value);
    },
    onGenderChange(e) {
      // e.detail.value 是单个被选中的radio的value
      this.selectedGender = e.detail.value;
      console.log('性别变化:', e.detail.value);
    }
  }
}
</script>

<style>
.checkbox-label, .radio-label {
  display: block;
  margin: 5px 0;
}
</style>

表单组件使用技巧:

  1. 善用v-model:能够极大简化双向数据绑定的代码。
  2. 理解事件checkboxradio的change事件发生在组(group) 上,通过e.detail.value获取所有值。
  3. UI统一:原生组件样式在各端可能略有差异,对于要求高的场景,可以考虑使用UI库(如uView)的自定义表单组件。

四、 导航与容器组件

当应用内容变多,我们需要更好的方式来组织页面结构和实现页面跳转。

4.1 Navigator

<navigator> 组件是一个页面链接,用于在应用内跳转到指定页面。它相当于HTML中的 <a> 标签,但功能更丰富。

核心属性与跳转模式:

  • url必填,指定要跳转的页面路径。
  • open-type跳转类型,决定了跳转行为。
    • navigate:默认值,保留当前页面,跳转到新页面(可返回)。
    • redirect:关闭当前页面,跳转到新页面(不可返回)。
    • switchTab:跳转到tabBar页面,并关闭所有非tabBar页面。
    • reLaunch:关闭所有页面,打开到应用内的某个页面。
    • navigateBack:关闭当前页面,返回上一页面或多级页面。
  • delta:当open-typenavigateBack时有效,表示返回的层数。

为了更清晰地理解这几种跳转模式对页面栈的影响,我画了下面这张图:

mermaid-diagram-2025-11-08-094700.png

下面用代码实现一个简单的导航

<template>
  <view class="nav-demo">
    <!-- 普通跳转,可以返回 -->
    <navigator url="/pages/about/about" hover-class="navigator-hover">
      <button>关于我们(普通跳转)</button>
    </navigator>

    <!-- 重定向,无法返回 -->
    <navigator url="/pages/index/index" open-type="redirect">
      <button type="warn">回首页(重定向)</button>
    </navigator>

    <!-- 跳转到TabBar页面 -->
    <navigator url="/pages/tabbar/my/my" open-type="switchTab">
      <button type="primary">个人中心(Tab跳转)</button>
    </navigator>

    <!-- 返回上一页 -->
    <navigator open-type="navigateBack">
      <button>返回上一页</button>
    </navigator>
    <!-- 返回上两页 -->
    <navigator open-type="navigateBack" :delta="2">
      <button>返回上两页</button>
    </navigator>
  </view>
</template>

<style>
.nav-demo button {
  margin: 10rpx;
}
.navigator-hover {
  background-color: #f0f0f0; /* 点击时的反馈色 */
}
</style>

Navigator避坑:

  1. url路径:必须以/开头,在pages.json中定义。
  2. 跳转TabBar:必须使用open-type="switchTab",否则无效。
  3. 传参:可以在url后面拼接参数,如/pages/detail/detail?id=1&name=test,在目标页面的onLoad生命周期中通过options参数获取。
  4. 跳转限制:小程序中页面栈最多十层,注意使用redirect避免层级过深。

4.2 Scroll-View

<scroll-view> 是一个可滚动的视图容器。当内容超过容器高度(或宽度)时,提供滚动查看的能力。

核心特性:

  • 滚动方向:通过scroll-x(横向)和scroll-y(纵向)控制。
  • 滚动事件:可以监听@scroll事件,获取滚动位置。
  • 上拉加载/下拉刷新:通过@scrolltolower@scrolltoupper等事件模拟,但更推荐使用页面的onReachBottomonPullDownRefresh

代码实现一个横向滚动导航和纵向商品列表

<template>
  <view>
    <!-- 横向滚动导航 -->
    <scroll-view scroll-x class="horizontal-scroll">
      <view v-for="(item, index) in navList" :key="index" class="nav-item">
        {{ item.name }}
      </view>
    </scroll-view>

    <!-- 纵向滚动商品列表 -->
    <scroll-view scroll-y :style="{ height: scrollHeight + 'px' }" @scrolltolower="onLoadMore">
      <view v-for="(product, idx) in productList" :key="idx" class="product-item">
        <image :src="product.image" mode="aspectFill" class="product-img"></image>
        <text class="product-name">{{ product.name }}</text>
      </view>
      <view v-if="loading" class="loading-text">加载中...</view>
    </scroll-view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      navList: [ /* ... 导航数据 ... */ ],
      productList: [ /* ... 商品数据 ... */ ],
      scrollHeight: 0,
      loading: false
    };
  },
  onLoad() {
    // 动态计算scroll-view的高度,使其充满屏幕剩余部分
    const sysInfo = uni.getSystemInfoSync();
    // 假设横向导航高度为50px,需要根据实际情况计算
    this.scrollHeight = sysInfo.windowHeight - 50;
  },
  methods: {
    onLoadMore() {
      // 加载更多
      if (this.loading) return;
      this.loading = true;
      console.log('开始加载更多数据...');
      // 请求数据
      setTimeout(() => {
        // ... 获取新数据并拼接到productList ...
        this.loading = false;
      }, 1000);
    }
  }
}
</script>

<style>
.horizontal-scroll {
  white-space: nowrap; /* 让子元素不换行 */
  width: 100%;
  background-color: #f7f7f7;
}
.nav-item {
  display: inline-block; /* 让子元素行内排列 */
  padding: 10px 20px;
  margin: 5px;
  background-color: #fff;
  border-radius: 15px;
}
.product-item {
  display: flex;
  padding: 10px;
  border-bottom: 1px solid #eee;
}
.product-img {
  width: 80px;
  height: 80px;
  border-radius: 5px;
}
.product-name {
  margin-left: 10px;
  align-self: center;
}
.loading-text {
  text-align: center;
  padding: 10px;
  color: #999;
}
</style>

Scroll-View使用心得:

  • 横向滚动:牢记两个CSS:容器white-space: nowrap;,子项display: inline-block;
  • 性能<scroll-view>内不适合放过多或过于复杂的子节点,尤其是图片,可能导致滚动卡顿。对于长列表,应使用官方的<list>组件或社区的长列表组件。
  • 高度问题:纵向滚动的<scroll-view>必须有一个固定的高度,否则会无法滚动。通常通过JS动态计算。

五、 自定义组件基础

当项目变得复杂,我们会发现很多UI模块或功能块在重复编写。这时,就该自定义组件了!它能将UI和功能封装起来,实现复用和解耦。

5.1 为什么要用自定义组件?

  1. 复用性:一次封装,到处使用。
  2. 可维护性:功能集中在一处,修改方便。
  3. 清晰性:将复杂页面拆分成多个组件,结构清晰,便于协作。

5.2 创建与使用一个自定义组件

让我们来封装一个简单的UserCard组件。

第一步:创建组件文件 在项目根目录创建components文件夹,然后在里面创建user-card/user-card.vue文件。uni-app会自动识别components目录下的组件。

第二步:编写组件模板、逻辑与样式

<!-- components/user-card/user-card.vue -->
<template>
  <view class="user-card" @tap="onCardClick">
    <image :src="avatarUrl" class="avatar" mode="aspectFill"></image>
    <view class="info">
      <text class="name">{{ name }}</text>
      <text class="bio">{{ bio }}</text>
    </view>
    <view class="badge" v-if="isVip">VIP</view>
  </view>
</template>

<script>
export default {
  // 声明组件的属性,外部传入的数据
  props: {
    avatarUrl: {
      type: String,
      default: '/static/default-avatar.png' 
    },
    name: {
      type: String,
      required: true 
    },
    bio: String, // 简写方式,只定义类型
    isVip: Boolean
  },
  // 组件内部数据
  data() {
    return {
      // 这里放组件自己的状态
    };
  },
  methods: {
    onCardClick() {
      // 触发一个自定义事件,通知父组件
      this.$emit('cardClick', { name: this.name });
      // 也可以在这里处理组件内部的逻辑
      uni.showToast({
        title: `点击了${this.name}的名片`,
        icon: 'none'
      });
    }
  }
}
</script>

<style scoped>
.user-card {
  display: flex;
  padding: 15px;
  background-color: #fff;
  border-radius: 8px;
  margin: 10px;
  position: relative;
  box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
.avatar {
  width: 50px;
  height: 50px;
  border-radius: 25px;
}
.info {
  display: flex;
  flex-direction: column;
  margin-left: 12px;
  justify-content: space-around;
}
.name {
  font-size: 16px;
  font-weight: bold;
}
.bio {
  font-size: 12px;
  color: #999;
}
.badge {
  position: absolute;
  top: 10px;
  right: 10px;
  background-color: #ffd700;
  color: #333;
  font-size: 10px;
  padding: 2px 6px;
  border-radius: 4px;
}
</style>

第三步:在页面中使用组件

<!-- pages/index/index.vue -->
<template>
  <view>
    <text>用户列表</text>
    <!-- 使用自定义组件 -->
    <!-- 1. 通过属性传递数据 -->
    <user-card 
      name="码小明" 
      bio="热爱编程" 
      :is-vip="true"
      avatar-url="/static/avatar1.jpg"
      @cardClick="onUserCardClick" <!-- 2. 监听子组件发出的自定义事件 -->
    />
    <user-card 
      name="产品经理小鱼儿" 
      bio="让世界更美好" 
      :is-vip="false"
      @cardClick="onUserCardClick"
    />
  </view>
</template>

<script>
// 2. 导入组件
// import UserCard from '@/components/user-card/user-card.vue';
export default {
  // 3. 注册组件
  // components: { UserCard },
  methods: {
    onUserCardClick(detail) {
      console.log('父组件收到了卡片的点击事件:', detail);
      // 这里可以处理跳转逻辑
      // uni.navigateTo({ url: '/pages/user/detail?name=' + detail.name });
    }
  }
}
</script>

5.3 核心概念:Props, Events, Slots

一个完整的自定义组件通信机制,主要围绕这三者展开。它们的关系可以用下图清晰地表示:

mermaid-diagram-2025-11-08-095516.png

  1. Props(属性)由外到内的数据流。父组件通过属性的方式将数据传递给子组件。子组件用props选项声明接收。
  2. Events(事件)由内到外的通信。子组件通过this.$emit('事件名', 数据)触发一个自定义事件,父组件通过v-on@来监听这个事件。
  3. Slots(插槽)内容分发。父组件可以将一段模板内容“插入”到子组件指定的位置。这极大地增强了组件的灵活性。

插槽(Slot)简单示例: 假设我们的UserCard组件,想在bio下面留一个区域给父组件自定义内容。

在子组件中:

<!-- user-card.vue -->
<view class="info">
  <text class="name">{{ name }}</text>
  <text class="bio">{{ bio }}</text>
  <!-- 默认插槽,父组件传入的内容会渲染在这里 -->
  <slot></slot>
  <!-- 具名插槽 -->
  <!-- <slot name="footer"></slot> -->
</view>

在父组件中:

<user-card name="小明" bio="...">
  <!-- 传入到默认插槽的内容 -->
  <view style="margin-top: 5px;">
    <button size="mini">关注</button>
  </view>
  <!-- 传入到具名插槽footer的内容 -->
  <!-- <template v-slot:footer> ... </template> -->
</user-card>

5.4 EasyCom

你可能会注意到,在上面的页面中,我们并没有importcomponents注册,但组件却正常使用了。这是因为uni-app的 easycom 规则。

规则:只要组件安装在项目的components目录下,并符合components/组件名称/组件名称.vue的目录结构,就可以不用手动引入和注册,直接在页面中使用。极大地提升了开发效率!


六、 内容总结

至此基本组件内容就介绍完了,又到了总结的时候了,本节主要内容:

  1. View、Text、Image:构建页面的三大核心组件。注意图片Image的mode属性。
  2. Button与表单组件:与用户交互的核心。Button的open-type能调起强大原生功能。表单组件用v-model实现数据双向绑定。
  3. Navigator与Scroll-View:组织页面和内容。Navigator负责路由跳转,要理解五种open-type的区别。Scroll-View提供滚动区域,要注意它的高度和性能问题。
  4. 自定义组件:必会内容。理解了Props下行、Events上行、Slots分发的数据流,你就掌握了组件通信的精髓。easycom规则让组件使用更便捷。

如果你觉得这篇文章对你有所帮助,能够对uni-app的基础组件有更清晰的认识,不要吝啬你的“一键三连”(点赞、关注、收藏)哦(手动狗头)!你的支持是我持续创作的最大动力。 在学习过程中遇到任何问题,或者有哪里没看明白,都欢迎在评论区留言,我会尽力解答。


版权声明:本文为【《uni-app跨平台开发完全指南》】系列第五篇,原创文章,转载请注明出处。

规避ProseMirror React渲染差异带来的BUG

作者 悟忧
2025年11月8日 10:05

在 React 项目中使用 ProseMirror(如通过 Tiptap、Remirror 或自定义封装)时,由于 ProseMirror 的命令式 DOM 更新机制React 的声明式虚拟 DOM 渲染机制存在根本差异,若处理不当,极易引发以下问题:

  • 编辑器内容闪烁或重置
  • 光标跳转/丢失
  • 内存泄漏(未正确销毁 EditorView)
  • 状态不同步(React state 与 ProseMirror state 不一致)
  • 自定义节点(NodeView)渲染异常

✅ 核心原则:让 ProseMirror 完全控制编辑区域的 DOM

这是避免渲染冲突的根本前提。React 不应尝试渲染或更新 ProseMirror 所管理的 DOM 子树。


🛠️ 具体实践策略

1. 正确挂载和卸载 EditorView

确保在 useEffect 中创建,在清理函数中销毁,防止多次初始化或内存泄漏。

import { useEffect, useRef } from 'react';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { schema } from './schema';

export default function ProseMirrorEditor() {
  const editorRef = useRef<HTMLDivElement>(null);
  const viewRef = useRef<EditorView | null>(null);

  useEffect(() => {
    if (!editorRef.current) return;

    const state = EditorState.create({ schema });
    const view = new EditorView(editorRef.current, { state });
    viewRef.current = view;

    return () => {
      view.destroy(); // 👈 关键:释放事件监听、定时器等资源
      viewRef.current = null;
    };
  }, []);

  return <div ref={editorRef} />;
}

❗ 避免在每次 render 时重建 EditorView(如放在组件顶层或依赖频繁变化的 deps)。


2. 不要用 React 控制编辑器内容

❌ 错误做法:

// 危险!React 会尝试 reconciliation,与 ProseMirror 冲突
<div contentEditable={true} dangerouslySetInnerHTML={{ __html: htmlFromState }} />

✅ 正确做法:

// 仅提供一个“容器”,由 ProseMirror 接管其子 DOM
<div ref={editorRef} />

3. 避免将 ProseMirror State 同步到 React State(除非必要)

频繁将 state.doc 转为 JSON/HTML 并存入 useState,会导致不必要的 React 重渲染,甚至触发循环更新。

✅ 建议:

  • 仅在需要时(如保存、预览)读取内容。
  • 工具栏状态可通过 ProseMirror 插件 + useCallback 监听状态变化,而非全量同步文档。

示例:监听选区变化更新工具栏

const updateToolbar = useCallback(() => {
  const { state } = viewRef.current!;
  setIsBold(state.schema.marks.strong.isInSet(state.selection.$from.marks()));
}, []);

useEffect(() => {
  const view = viewRef.current!;
  view.dispatch = (tr) => {
    view.updateState(view.state.apply(tr));
    updateToolbar(); // 在 dispatch 后同步 UI
  };
}, [updateToolbar]);

4. 自定义 NodeView:谨慎集成 React 组件

若需在 ProseMirror 节点中渲染 React 组件(如嵌入图表、评论),必须:

  • 使用 createRoot().render()(React 18+)或 ReactDOM.render 挂载
  • destroy() 中正确卸载
  • 避免在 React 组件内部修改 ProseMirror 状态(除非通过 dispatch)
class ReactComponentNodeView {
  dom: HTMLElement;
  contentDOM?: HTMLElement;

  constructor(node: Node, view: EditorView, getPos: () => number | false) {
    this.dom = document.createElement('div');
    
    const reactElement = <MyCustomWidget node={node} onUpdate={(attrs) => {
      const pos = getPos();
      if (pos !== false) {
        const tr = view.state.tr.setNodeMarkup(pos, undefined, attrs);
        view.dispatch(tr);
      }
    }} />;
    
    createRoot(this.dom).render(reactElement);
  }

  destroy() {
    unmountComponentAtNode(this.dom); // 或 root.unmount()
  }
}

⚠️ 注意:getPos() 可能返回 false(节点已删除),务必判空。


5. 避免 SSR / Hydration 冲突

ProseMirror 依赖浏览器 DOM API,不能在服务端渲染

✅ 解决方案:

  • 使用动态导入(next/dynamicReact.lazy)禁用 SSR
  • 或在客户端 useEffect 中延迟初始化

Next.js 示例:

import dynamic from 'next/dynamic';

const ProseMirrorEditor = dynamic(
  () => import('../components/ProseMirrorEditor'),
  { ssr: false }
);

6. 不要手动操作编辑器 DOM

例如:

// ❌ 危险:直接修改 ProseMirror 管理的 DOM
document.querySelector('.ProseMirror').innerHTML = '...';

这会破坏 ProseMirror 的内部状态与 DOM 的一致性,导致崩溃或不可预测行为。

所有内容变更应通过 dispatch Transaction 完成。


7. 使用成熟封装库(推荐)

如非必要,建议使用经过验证的封装:

  • Tiptap(最流行,React 友好)
  • Remirror
  • @nytimes/react-prosemirror

它们已处理大部分桥接细节,提供 useEditorEditorContent 等 React-friendly API。

示例(Tiptap):

const editor = useEditor({ ... });

return (
  <>
    <MenuBar editor={editor} />
    <EditorContent editor={editor} /> {/* 内部正确挂载 ProseMirror */}
  </>
);

但仍需理解其底层机制,以便调试。


🔍 常见 BUG 排查清单

现象 可能原因 解决方案
内容闪烁/重置 多次创建 EditorView 确保 useEffect 依赖项稳定,只初始化一次
光标跳到开头 React 强制更新容器 DOM 确保容器 div 无 children,不被 React 控制
自定义组件不更新 React 组件未响应 ProseMirror 状态 通过 props 传递最新 node 数据,或使用 context
内存泄漏 未调用 view.destroy() useEffect 清理函数中销毁
Hydration failed SSR 渲染了编辑器 禁用 SSR

总结:关键守则

  1. 编辑区域 DOM 归 ProseMirror,其他归 React
  2. 状态以 ProseMirror 为主,React 为辅
  3. 所有内容变更走 Transaction,绝不直接改 DOM
  4. 生命周期严格管理:init in effect, destroy on unmount
  5. 复杂节点用 NodeView + React Portal,注意卸载

遵循这些原则,可极大降低因渲染机制差异导致的 bug。

如你有具体场景(如“如何实现带 React 表单的嵌入节点”或“协作编辑中的状态同步”),我可以提供针对性代码示例。

小程序云开发有类似 uniCloud 云对象的方案吗?有的兄弟,有的!

作者 小皮虾
2025年11月8日 09:28

如果你是一位 uni-app 开发者,你一定对 uniCloud 的“云对象”赞不绝口。那种在前端直接 import 一个云端对象,然后像调用本地方法一样 await cloudObj.add(1, 2) 的丝滑体验,简直是开发者的福音。

这让许多小程序原生开发的同学羡慕不已,心中不禁会问:

“难道我们原生云开发,就只能在 switch...case 的泥潭里挣扎,或者忍受 wx.cloud.callFunction 的冗长写法吗?我们配拥有‘云对象’这种优雅的开发模式吗?”

答案是:配的兄弟,当然配!而且,通过两个轻量级的 NPM 包,你不仅能拥有,甚至能获得一个更纯粹、更无厂商锁定的“云对象”体验。

在揭晓这套方案之前,我们先快速了解一个后端常见术语:RPC (Remote Procedure Call,远程过程调用)。

别让这个名字吓到你。RPC 的核心思想极其简单:“我想调用一个函数,但那个函数远在服务器上,不在我的前端代码里。” RPC 框架的使命,就是施展魔法,让这个“远程调用”的过程,感觉就跟调用一个本地函数一模一样。

好了,魔法要开始了。今天,就向你隆重介绍这套开源组合拳:rpc-server-tcb + rpc-client-tcb

第一步:在云函数中“创建”你的云对象 (rpc-server-tcb)

rpc-server-tcb 奉行“约定优于配置”。它约定,你 api/ 目录下的每一个 .js 文件,本身就是一个云对象

假设我们要创建一个名为 rpc 的云函数,并在其中创建一个处理用户逻辑的“云对象” user

cloudfunctions/rpc/api/user.js

// 这个文件导出的对象,就是你的 'user' 云对象
module.exports = {
  /**
   * 根据 ID 获取用户信息
   * 这就是云对象的一个方法
   * @param {string} userId 
   */
  async getInfo(userId) {
    if (!userId) {
      throw new Error('User ID is required.');
    }
    // ... 你的数据库查询逻辑
    return { id: userId, name: '张三', vip: true };
  },

  /**
   * 获取当前调用者的 OpenID
   * 另一个方法,还能通过 this 访问上下文
   */
  getMyOpenId() {
    const { OPENID } = this.context.userInfo;
    return OPENID;
  }
}

看到了吗?你不需要写 class,也不需要继承任何东西。一个纯粹的、导出的 JavaScript 对象,就是你的云对象。

然后,你的云函数入口 index.js 只需要一行代码来启动这个“云对象”服务:

cloudfunctions/rpc/index.js

const { createRpcServer } = require('rpc-server-tcb');

// 启动服务,自动加载 api/ 目录下所有“云对象”
exports.main = createRpcServer();

至此,你的云端已经准备就绪。user.js 就是 user 云对象,order.js 就是 order 云对象,干净、纯粹。

第二步:在小程序中“调用”你的云对象 (rpc-client-tcb)

这是见证奇迹的时刻。在小程序端,我们同样只需要进行一次初始化。

utils/rpc.js

import { createRpcClient } from 'rpc-client-tcb';

// 初始化 RPC 客户端,指向你的云函数入口
const rpc = createRpcClient({
  functionName: 'rpc', 
});

export default rpc;

现在,假设我们要在页面中调用 user 云对象的 getInfo 方法:

import rpc from '../../utils/rpc';

Page({
  async onGetUserInfo() {
    try {
      // 直接调用!就像它是一个本地对象一样!
      const userInfo = await rpc.user.getInfo('user-123');
      
      console.log(userInfo); // { id: 'user-123', name: '张三', vip: true }
    } catch (error) {
      // 优雅地捕获所有错误
      wx.showToast({ title: error.message, icon: 'none' });
    }
  },
});

rpc.user.getInfo('user-123') —— 这行代码所带来的开发体验,与 uniCloud 云对象相比,是不是如出一辙,甚至因为无需 importObject 而显得更加直接?

我们的方案 vs uniCloud 云对象,优势何在?

虽然体验相似,但这套 rpc-tcb 方案为你带来了 uniCloud 所不具备的独特优势:

  1. 轻量与非侵入: 它不是一个庞大的平台或运行时,仅仅是两个专注于解决 RPC 问题的、总代码量不足百行的轻量级库。你可以随时加入到现有项目中,也可以随时移除,对你的项目没有“污染”。

  2. 原生云开发,无厂商锁定: 你编写的每一行代码,都是在微信/腾讯云官方的原生云开发环境中运行。你享受的是腾讯云的生态、稳定性和未来的所有更新,不存在被特定前端框架(uni-app)绑定的风险。

  3. 极致的灵活性: 就像我们在另一篇文章中讨论的,如果你的应用是一个包含上百个工具的“工具箱”,你可以轻松地将“云对象”拆分到多个云函数中(rpc-pdf, rpc-image),客户端也只需多创建几个实例即可。这种架构的灵活性是 uniCloud 单一服务空间模型难以比拟的。

  4. 纯粹的 JavaScript/Node.js: 你不需要学习任何平台特有的 class 继承或特定语法。你写的,就是最纯粹、最通用的 JavaScript 模块。

结论

所以,回到最初的问题:小程序云开发有类似 uniCloud 云对象的方案吗?

不仅有,而且这个方案更轻量、更原生、更灵活。

rpc-server-tcbrpc-client-tcb 为原生小程序开发者架起了一座通往现代化、优雅后端开发的桥梁。它证明了,好的开发体验并非某个平台的专利,通过优秀的设计模式和社区工具,我们同样可以拥有。

别再羡慕隔壁的 uni-app 了,兄弟!现在就动手,给你自己的小程序项目也装上这对翅膀吧。

鸿蒙Notification Kit通知服务开发快速指南

2025年11月8日 08:26

鸿蒙Notification Kit通知服务开发快速指南

一、Notification Kit概述

Notification Kit为开发者提供本地通知发布能力,可以在应用运行时向用户推送通知,包括文本、进度条、角标等多种样式。

1.1 核心能力

  • ✅ 发布文本类型通知(普通文本、多行文本)
  • ✅ 发布进度条通知
  • ✅ 管理应用角标
  • ✅ 取消和查询通知
  • ✅ 请求用户授权

1.2 业务流程

sequenceDiagram
    participant App as 应用
    participant NM as NotificationManager
    participant User as 用户
    participant NC as 通知中心

    App->>NM: 请求授权
    NM->>User: 弹窗询问
    User->>NM: 允许/拒绝
    NM-->>App: 授权结果

    App->>NM: publish(通知)
    NM->>NC: 展示通知
    NC->>User: 显示通知

1.3 约束限制

限制项 说明
留存数量 单个应用最多24条
通知大小 不超过200KB
发布频次 单应用≤10条/秒
更新频次 单应用≤20条/秒

二、请求通知授权

应用首次发布通知前必须获取用户授权。

2.1 授权流程

import { notificationManager } from '@kit.NotificationKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';

class NotificationAuth {
  async requestPermission(context: common.UIAbilityContext) {
    try {
      // 1. 检查是否已授权
      let isEnabled = await notificationManager.isNotificationEnabled();

      if (!isEnabled) {
        // 2. 请求授权(首次会弹窗)
        await notificationManager.requestEnableNotification(context);
        console.info('通知授权成功');
      } else {
        console.info('已获得通知授权');
      }
    } catch (err) {
      let error = err as BusinessError;
      if (error.code === 1600004) {
        console.error('用户拒绝授权');
        // 3. 可选:拉起设置页面再次请求
        this.openSettings(context);
      } else {
        console.error(`授权失败: ${error.message}`);
      }
    }
  }

  async openSettings(context: common.UIAbilityContext) {
    try {
      await notificationManager.openNotificationSettings(context);
    } catch (err) {
      let error = err as BusinessError;
      console.error(`打开设置失败: ${error.message}`);
    }
  }
}

三、发布文本通知

3.1 普通文本通知

import { notificationManager } from '@kit.NotificationKit';
import { BusinessError } from '@kit.BasicServicesKit';

async function publishTextNotification() {
  let notificationRequest: notificationManager.NotificationRequest = {
    id: 1,
    content: {
      notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
      normal: {
        title: '新消息',
        text: '您有一条新消息',
        additionalText: '刚刚'
      }
    }
  };

  try {
    await notificationManager.publish(notificationRequest);
    console.info('通知发布成功');
  } catch (err) {
    let error = err as BusinessError;
    console.error(`通知发布失败: ${error.message}`);
  }
}

3.2 多行文本通知

async function publishMultiLineNotification() {
  let notificationRequest: notificationManager.NotificationRequest = {
    id: 2,
    content: {
      notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_MULTILINE,
      multiLine: {
        title: '会议提醒',
        text: '团队会议',
        briefText: '3条新通知',
        longTitle: '今日会议安排',
        lines: [
          '上午10:00 - 产品评审会',
          '下午14:00 - 技术分享会',
          '下午16:00 - 周报会议'
        ]
      }
    }
  };

  try {
    await notificationManager.publish(notificationRequest);
  } catch (err) {
    let error = err as BusinessError;
    console.error(`发布失败: ${error.message}`);
  }
}

四、进度条通知

4.1 下载进度示例

class DownloadNotification {
  private notificationId = 100;

  async showDownloadProgress(fileName: string, progress: number) {
    let notificationRequest: notificationManager.NotificationRequest = {
      id: this.notificationId,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: `下载中: ${fileName}`,
          text: `进度: ${progress}%`,
          additionalText: ''
        }
      },
      // 进度条配置
      template: {
        name: 'downloadTemplate',
        data: {
          progressValue: progress,
          progressMaxValue: 100
        }
      }
    };

    try {
      await notificationManager.publish(notificationRequest);
    } catch (err) {
      let error = err as BusinessError;
      console.error(`更新进度失败: ${error.message}`);
    }
  }

  async completeDownload(fileName: string) {
    let notificationRequest: notificationManager.NotificationRequest = {
      id: this.notificationId,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: '下载完成',
          text: fileName,
          additionalText: '点击查看'
        }
      }
    };

    await notificationManager.publish(notificationRequest);
  }

  async cancelDownload() {
    await notificationManager.cancel(this.notificationId);
  }
}

// 使用示例
let downloader = new DownloadNotification();
// 模拟下载进度
for (let i = 0; i <= 100; i += 10) {
  await downloader.showDownloadProgress('document.pdf', i);
  await new Promise(resolve => setTimeout(resolve, 500));
}
await downloader.completeDownload('document.pdf');

五、通知角标管理

5.1 设置和更新角标

class BadgeManager {
  async setBadgeNumber(count: number) {
    let notificationRequest: notificationManager.NotificationRequest = {
      id: 1,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: '新消息',
          text: `您有${count}条未读消息`
        }
      },
      // 设置角标数字
      badgeNumber: count
    };

    try {
      await notificationManager.publish(notificationRequest);
    } catch (err) {
      let error = err as BusinessError;
      console.error(`设置角标失败: ${error.message}`);
    }
  }

  async clearBadge() {
    // 取消所有通知即可清除角标
    await notificationManager.cancelAll();
  }
}

六、通知管理操作

6.1 取消通知

class NotificationControl {
  // 取消指定通知
  async cancelNotification(id: number) {
    try {
      await notificationManager.cancel(id);
      console.info(`通知${id}已取消`);
    } catch (err) {
      let error = err as BusinessError;
      console.error(`取消失败: ${error.message}`);
    }
  }

  // 取消所有通知
  async cancelAllNotifications() {
    try {
      await notificationManager.cancelAll();
      console.info('所有通知已清除');
    } catch (err) {
      let error = err as BusinessError;
      console.error(`清除失败: ${error.message}`);
    }
  }

  // 查询活跃通知
  async getActiveNotifications() {
    try {
      let notifications = await notificationManager.getActiveNotifications();
      console.info(`当前有${notifications.length}条活跃通知`);
      return notifications;
    } catch (err) {
      let error = err as BusinessError;
      console.error(`查询失败: ${error.message}`);
      return [];
    }
  }
}

七、实战示例:消息通知管理器

7.1 完整通知管理器

import { notificationManager } from '@kit.NotificationKit';
import { wantAgent, WantAgent } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';

export enum NotificationType {
  MESSAGE = 'message',
  SYSTEM = 'system',
  PROGRESS = 'progress'
}

export interface NotificationConfig {
  id: number;
  type: NotificationType;
  title: string;
  content: string;
  badgeNumber?: number;
}

export class NotificationManager {
  private static instance: NotificationManager;
  private isAuthorized: boolean = false;

  private constructor() {}

  static getInstance(): NotificationManager {
    if (!NotificationManager.instance) {
      NotificationManager.instance = new NotificationManager();
    }
    return NotificationManager.instance;
  }

  // 初始化并请求授权
  async init(context: common.UIAbilityContext): Promise<boolean> {
    try {
      this.isAuthorized = await notificationManager.isNotificationEnabled();

      if (!this.isAuthorized) {
        await notificationManager.requestEnableNotification(context);
        this.isAuthorized = true;
      }
      return this.isAuthorized;
    } catch (err) {
      let error = err as BusinessError;
      console.error(`初始化失败: ${error.message}`);
      return false;
    }
  }

  // 发布普通通知
  async publishNotification(config: NotificationConfig) {
    if (!this.isAuthorized) {
      console.error('未获得通知授权');
      return;
    }

    let notificationRequest: notificationManager.NotificationRequest = {
      id: config.id,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: config.title,
          text: config.content
        }
      }
    };

    if (config.badgeNumber !== undefined) {
      notificationRequest.badgeNumber = config.badgeNumber;
    }

    try {
      await notificationManager.publish(notificationRequest);
      console.info(`通知${config.id}发布成功`);
    } catch (err) {
      let error = err as BusinessError;
      console.error(`发布失败: ${error.message}`);
    }
  }

  // 发布可点击通知
  async publishClickableNotification(
    config: NotificationConfig,
    context: common.UIAbilityContext,
    targetAbility: string
  ) {
    try {
      // 创建WantAgent
      let wantAgentInfo: wantAgent.WantAgentInfo = {
        wants: [
          {
            bundleName: context.abilityInfo.bundleName,
            abilityName: targetAbility
          }
        ],
        requestCode: 0,
        operationType: wantAgent.OperationType.START_ABILITY,
        wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
      };

      let wantAgentObj = await wantAgent.getWantAgent(wantAgentInfo);

      let notificationRequest: notificationManager.NotificationRequest = {
        id: config.id,
        content: {
          notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
          normal: {
            title: config.title,
            text: config.content
          }
        },
        wantAgent: wantAgentObj
      };

      await notificationManager.publish(notificationRequest);
      console.info('可点击通知发布成功');
    } catch (err) {
      let error = err as BusinessError;
      console.error(`发布失败: ${error.message}`);
    }
  }

  // 更新通知
  async updateNotification(config: NotificationConfig) {
    // 更新通知只需使用相同ID重新发布
    await this.publishNotification(config);
  }

  // 取消通知
  async cancelNotification(id: number) {
    try {
      await notificationManager.cancel(id);
    } catch (err) {
      let error = err as BusinessError;
      console.error(`取消失败: ${error.message}`);
    }
  }

  // 清除所有通知
  async cancelAll() {
    try {
      await notificationManager.cancelAll();
    } catch (err) {
      let error = err as BusinessError;
      console.error(`清除失败: ${error.message}`);
    }
  }

  // 获取活跃通知数量
  async getActiveCount(): Promise<number> {
    try {
      let notifications = await notificationManager.getActiveNotifications();
      return notifications.length;
    } catch (err) {
      return 0;
    }
  }
}

7.2 使用示例页面

import { NotificationManager, NotificationType } from '../model/NotificationManager';
import { common } from '@kit.AbilityKit';

@Entry
@Component
struct NotificationDemo {
  private notificationMgr: NotificationManager = NotificationManager.getInstance();
  private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  @State messageCount: number = 0;
  @State activeNotifications: number = 0;

  async aboutToAppear() {
    await this.notificationMgr.init(this.context);
    this.updateActiveCount();
  }

  async updateActiveCount() {
    this.activeNotifications = await this.notificationMgr.getActiveCount();
  }

  async sendTextNotification() {
    this.messageCount++;
    await this.notificationMgr.publishNotification({
      id: 1,
      type: NotificationType.MESSAGE,
      title: '新消息',
      content: `您有${this.messageCount}条新消息`,
      badgeNumber: this.messageCount
    });
    this.updateActiveCount();
  }

  async sendClickableNotification() {
    await this.notificationMgr.publishClickableNotification(
      {
        id: 2,
        type: NotificationType.SYSTEM,
        title: '系统通知',
        content: '点击查看详情'
      },
      this.context,
      'EntryAbility'
    );
    this.updateActiveCount();
  }

  async clearNotifications() {
    await this.notificationMgr.cancelAll();
    this.messageCount = 0;
    this.activeNotifications = 0;
  }

  build() {
    Column({ space: 20 }) {
      // 标题
      Text('通知服务演示')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      // 状态显示
      Row({ space: 20 }) {
        Text(`未读消息: ${this.messageCount}`)
          .fontSize(16)
        Text(`活跃通知: ${this.activeNotifications}`)
          .fontSize(16)
      }
      .padding(15)
      .backgroundColor('#f0f0f0')
      .borderRadius(8)
      .width('90%')

      // 操作按钮
      Column({ space: 15 }) {
        Button('发送文本通知')
          .width('100%')
          .onClick(() => this.sendTextNotification())

        Button('发送可点击通知')
          .width('100%')
          .onClick(() => this.sendClickableNotification())

        Button('发送多行通知')
          .width('100%')
          .onClick(async () => {
            let notificationRequest: notificationManager.NotificationRequest = {
              id: 3,
              content: {
                notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_MULTILINE,
                multiLine: {
                  title: '待办事项',
                  text: '今日待办',
                  lines: [
                    '完成项目文档',
                    '参加团队会议',
                    '代码review'
                  ]
                }
              }
            };
            await notificationManager.publish(notificationRequest);
            this.updateActiveCount();
          })

        Button('清除所有通知')
          .width('100%')
          .backgroundColor('#ff6b6b')
          .onClick(() => this.clearNotifications())
      }
      .width('90%')
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }
}

八、最佳实践

8.1 通知频率控制

class NotificationRateLimiter {
  private lastPublishTime: number = 0;
  private minInterval: number = 1000; // 最小间隔1秒

  async publishWithLimit(request: notificationManager.NotificationRequest) {
    let now = Date.now();
    if (now - this.lastPublishTime < this.minInterval) {
      console.warn('发布频率过高,请稍后再试');
      return false;
    }

    try {
      await notificationManager.publish(request);
      this.lastPublishTime = now;
      return true;
    } catch (err) {
      return false;
    }
  }
}

8.2 通知分组管理

class NotificationGroup {
  private groupedNotifications: Map<string, number[]> = new Map();

  async publishToGroup(group: string, request: notificationManager.NotificationRequest) {
    await notificationManager.publish(request);

    if (!this.groupedNotifications.has(group)) {
      this.groupedNotifications.set(group, []);
    }
    this.groupedNotifications.get(group)?.push(request.id);
  }

  async cancelGroup(group: string) {
    let ids = this.groupedNotifications.get(group);
    if (ids) {
      for (let id of ids) {
        await notificationManager.cancel(id);
      }
      this.groupedNotifications.delete(group);
    }
  }
}

8.3 错误处理

class RobustNotification {
  async publishWithRetry(
    request: notificationManager.NotificationRequest,
    maxRetries: number = 3
  ): Promise<boolean> {
    for (let i = 0; i < maxRetries; i++) {
      try {
        await notificationManager.publish(request);
        return true;
      } catch (err) {
        let error = err as BusinessError;
        console.error(`第${i + 1}次发布失败: ${error.message}`);

        if (i < maxRetries - 1) {
          await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
        }
      }
    }
    return false;
  }
}

九、总结

本文介绍了HarmonyOS通知服务开发的核心内容:

功能 API 使用场景
请求授权 requestEnableNotification 首次使用通知
文本通知 NOTIFICATION_CONTENT_BASIC_TEXT 普通消息提醒
多行通知 NOTIFICATION_CONTENT_MULTILINE 列表消息
进度通知 template配置 下载/上传进度
通知角标 badgeNumber 未读消息数
可点击通知 wantAgent 跳转到详情页

开发要点:

  1. ✅ 首次使用必须请求授权
  2. ✅ 控制通知发布频率
  3. ✅ 及时取消无用通知
  4. ✅ 合理使用通知ID管理
  5. ✅ 做好错误处理和重试

通过本文学习,您应该能够:

  • 熟练请求和管理通知授权
  • 发布各种类型的通知
  • 实现进度类通知
  • 管理通知角标
  • 开发完整的通知管理系统

参考资料

全方位解释 JavaScript 执行机制(从底层到实战)

作者 T___T
2025年11月8日 02:55

在 JavaScript 学习中,变量提升、作用域屏蔽等问题常常让初学者困惑。比如一段看似简单的代码,却能引出关于执行机制的深层思考。本文将以如下代码为例,从执行上下文调用栈的底层视角等等,完整拆解代码的执行流程,带你看透 JS 代码运行的核心逻辑。

一、JS 是如何执行的?

在 Chrome 浏览器中,JavaScript 的执行由 V8 引擎负责。
V8 在运行 JS 代码时分为两个阶段:

1️⃣ 编译阶段

在代码执行前的一刹那,V8 会:

工作内容:

  1. 语法分析
    检查语法错误(比如括号、花括号是否配对)。

  2. 变量提升(Hoisting)

    • var 声明的变量 → 提前创建并赋值为 undefined
    • 函数声明(function xxx(){}) → 整体提升(优先级最高)
  3. 创建执行上下文对象 (Execution Context Object)

    • 包含三部分:

      • 变量环境
      • 词法环境
      • 可执行代码
  4. 把执行上下文压入调用栈 (Call Stack)

    • 全局上下文 → 首先压栈
    • 函数被调用 → 创建新的函数上下文 → 压栈

2️⃣ 执行阶段

编译完后开始执行:

  1. 变量和函数声明已准备好
  2. 按代码顺序逐行执行
  3. 函数调用 → 创建新上下文 → 压栈
  4. 函数执行完毕 → 上下文出栈(销毁,等待垃圾回收)

二、执行上下文与调用栈

V8 通过一个叫做 调用栈(Call Stack) 的结构来管理代码执行过程。

我们可以把它想象成一个「任务清单」:

  1. 全局执行上下文(Global Execution Context) 首先创建并压入栈底;
  2. 当执行函数时,会创建一个新的函数执行上下文,并压入栈顶;
  3. 函数执行完毕后,从栈顶弹出(出栈);
  4. 栈顶总是代表当前正在执行的上下文。

JS 引擎启动后,会自动创建一个 全局执行上下文

此时,执行栈中只有它一个上下文

┌────────────────────┐ ← 栈顶
│ 全局执行上下文      │
└────────────────────┘ ← 栈底

✅ 所以,在创建全局执行上下文时,它既是第一个入栈的
也是当前栈顶的上下文

var a = 1;
function fn(a) {
  var a = 2;
  function a() {}
  var b = a;
  console.log(a);
}
fn(3);

调用栈变化示意:

阶段 栈顶内容 说明
初始 全局上下文 代码准备执行
调用 fn(3) fn 执行上下文 函数被调用,压入栈顶
执行完 fn 全局上下文 函数上下文出栈
程序结束 全局上下文销毁 页面关闭或脚本结束

① 程序开始 → 创建全局执行上下文

[ 全局执行上下文 ]
变量环境: { a: undefined, fn: <function> }
词法环境: {}
代码: 全局代码

执行到 a = 1; fn(3); 时:

名称
a 1
fn function

② 调用 fn(3) → 创建新的函数执行上下文

┌────────────────────┐ ← 栈顶(当前执行环境)
│ fn 执行上下文       │
├────────────────────┤
│ 全局执行上下文      │ ← 栈底
└────────────────────┘

JS 引擎调用函数 fn,于是创建 fn 的执行上下文,并压入栈顶

此时:

  • 全局还在栈中(没被销毁);

  • 但栈顶变成了 fn

  • JS 正在执行 fn 函数体的代码。

编译阶段:

逐步提升分析:

  1. 形参 a → 先在环境中占位

    a = 3 (调用时传入的参数)
    
  2. 发现函数声明 function a() {}
    提升并覆盖前面的 a

    a = function a() {}
    
  3. 发现 var a = 2;
    var a 部分已存在(被提升过),此时不会再声明,只会在执行阶段再赋值。

  4. 发现 var b;
    b = undefined

编译阶段结束后:

名称
a function a() {}
b undefined
fn 执行上下文
变量环境:
  a: function a(){}   // 函数声明覆盖形参
  b: undefined
词法环境:
  (空)
代码:
  var a = 2;
  function a() {}
  var b = a;
  console.log(a);

执行阶段:

  1. var a = 2; → a = 2(覆盖变量环境中的 a: function a(){})
  2. var b = a; → b = 2
  3. console.log(a); → 输出 2

然后函数执行完毕 → 出栈。


③ 回到全局上下文

调用栈恢复为:全局执行上下文

执行栈状态:
┌────────────────────────┐
│ fn 函数执行上下文       │ ← 出栈(弹出)
├────────────────────────┤
│ 全局执行上下文          │ ← 回到全局
└────────────────────────┘

最终执行栈:
┌────────────────────────┐
│ 全局执行上下文          │
└────────────────────────┘

程序执行结束。

三、函数表达式不会被提升

我们来看一个非常经典的坑:

func(); // ❌ ReferenceError
let func = () => {
  console.log('函数表达式不会提升');
}

1️⃣ 编译阶段:

  • 变量 func 被登记进 词法环境
  • 但由于是 let 声明,它尚未初始化
  • 此时 func 处于 暂时性死区(TDZ)

2️⃣ 执行阶段:

  • 执行到 func(); 时,JS 发现 func 尚未初始化;

  • 于是抛出:

    ReferenceError: Cannot access 'func' before initialization
    

对比 var

func(); // ❌ TypeError: func is not a function
var func = function() {}
  • var 提升会使 func 被初始化为 undefined
  • 调用时相当于 undefined()
  • 所以报的是 TypeError

✅ 结论:let / const 存在暂时性死区;var 会变量提升。

四、严格模式下的执行机制

'use strict';
var a = 1;
var a = 2;

许多人以为“严格模式会禁止重复声明”,但其实不然。

严格模式下:

  • var 依然允许重复声明
  • 只是禁止未声明变量直接使用;
  • 禁止 this 自动绑定到全局对象;
  • 禁止删除变量;
  • 禁止函数参数重名等。

所以上面的代码仍然能正常执行,最终 a = 2

只有 letconst 声明时,重复定义才会抛出错误。


五、拓展:严格模式的其他影响

特性 普通模式 严格模式
未声明直接赋值 自动创建全局变量 ❌ 报错
重复声明 var ✅ 允许 ✅ 允许
重复声明 let/const ❌ 报错 ❌ 报错
this 指向 全局对象(window) undefined
删除变量 静默失败 ❌ 报错
函数参数重名 ✅ 允许 ❌ 报错

六、JS 底层机制(内存):值类型与引用类型详解

// 基本数据类型(Number):存储在栈内存中
let num = 1;

// 引用数据类型(Object):栈内存存储引用地址,堆内存存储实际对象
let obj = { age: 18 };

image.png

1.简单数据类型

let num1 = 10;
let num2 = 20;
num1 = num2;
console.log(num1);

1️⃣ 编译阶段

  • JS 引擎在栈内存中为 num1num2 各分配一块空间;
  • 它们都属于简单数据类型(number)
  • 值直接存在栈中。

2️⃣ 执行阶段

num1 = num2;

这一步只是把 num2值 20 拷贝一份赋给 num1
它们之间完全没有引用关系

2.复杂数据类型

let obj1 = {age:18};

let obj2 = obj1;
console.log(obj2);

image.png

1️⃣ 编译阶段

JavaScript 引擎在栈内存中登记两个变量名:

obj1 → undefined
obj2 → undefined

(此时只是变量声明,还未赋值)


2️⃣ 执行阶段

开始一行行执行代码👇

let obj1 = { age: 18 };
  • 堆内存中创建一个对象 { age: 18 }
  • 假设它在堆内存中的地址是 0x12312
  • 然后在栈中保存 obj1 → 0x12312(也就是对象的引用地址)。

当前内存图:

栈内存:
obj1 → 0x12312

堆内存:
0x12312 → { age: 18 }
let obj2 = obj1;

并不会在堆中创建新对象;

只是把 obj1 的地址拷贝一份给 obj2;

所以现在两个变量都指向同一个堆内存对象。

内存示意图:

栈内存:
obj1 → 0x12312
obj2 → 0x12312

堆内存:
0x12312 → { age: 18 }
console.log(obj2);
  • 输出 obj2 当前指向的对象,即堆内存中地址 0x001 里的数据;
  • 结果:{ age: 18 }

🚨七、 JS 执行机制与内存总结

1️⃣ 执行机制

  • JS 由 V8 引擎执行,分为 编译阶段执行阶段
  • 编译阶段:创建执行上下文、变量提升、语法检查。
  • 执行阶段:按顺序执行代码,遇到函数会创建新的执行上下文压入调用栈
  • 函数执行完毕后,执行上下文从栈中弹出(退栈,释放内存)。

2️⃣ 数据类型与内存

类型 存储位置 保存内容 拷贝方式 是否共享
简单类型(Number、String、Boolean、null、undefined、Symbol、BigInt) 值拷贝 ❌ 否
复杂类型(Object、Array、Function) 栈 + 堆 地址 引用拷贝 ✅ 是

🔍参考文档:mdn

🌊 深入理解 CSS:从选择器到层叠的艺术

作者 Qilana
2025年11月8日 00:18

CSS(Cascading Style Sheets,层叠样式表)是网页“颜值”的缔造者。它通过将 属性(property)值(value) 配对成声明,再由多个声明组成 声明块,最终通过 选择器 将这些样式精准地应用到 HTML 元素上。

p {
  color: blue;
  font-size: 16px;
}

上面这段代码就是一个典型的 CSS 规则:p 是选择器,花括号内是声明块,包含两个声明。


🔍 选择器优先级:谁说了算?

当多个规则作用于同一个元素时,CSS 需要决定“听谁的”——这就是 层叠(Cascading) 的核心机制。优先级按“个十百千”来记忆:

  • 千位:元素选择器(如 p)、伪元素(如 ::before
  • 百位:类选择器(.class)、属性选择器([type="text"])、伪类(:hover
  • 十位:ID 选择器(#id
  • 个位:行内样式(style="..."
  • 特殊存在!important —— 它拥有最高权限,但请慎用⚠️,容易破坏样式的可维护性。

举个例子:

<p id="intro" class="highlight">这是一段文字。</p>

如果同时有:

p { color: black; }           /* 千位 */
.highlight { color: green; }  /* 百位 */
#intro { color: red; }        /* 十位 */

最终文字会是 红色,因为 ID 选择器优先级更高。


🧪 伪类 vs 伪元素:别再混淆!

伪类(Pseudo-classes)

描述元素的状态,比如:

  • :hover(鼠标悬停)
  • :focus(获得焦点)
  • :nth-child(n) / :nth-of-type(n)(选第几个子元素)

💡 小知识:

  • :nth-child(2) 选的是父元素下的第二个子元素,不管类型;
  • :nth-of-type(2) 选的是同类型中的第二个
    例如在 <div><p>1</p><div>A</div><p>2</p></div> 中,p:nth-child(3) 能选中第二个 <p>,但 p:nth-child(2) 选不到任何东西!

伪元素(Pseudo-elements)

用于创建不存在于 HTML 中的内容,常用:

  • ::before / ::after:在元素前后插入内容
  • ::first-line / ::first-letter:美化首行或首字
a::after {
  content: " ➡️";
  color: gray;
}

这样每个链接后面都会自动加上一个箭头图标,非常适合做“查看更多”这类提示 ✨


🧱 布局与定位:inline 的小秘密

你可能注意到,有些 inline 元素(如 <span>不支持 transformwidth/height。这是因为 inline 元素只占据内容所需宽度,不能设置盒模型属性。

但有趣的是:当你给一个 inline 元素设置 position: absolute,它会自动变成 inline-block 行为!这意味着你可以自由设置宽高、使用 transform 等——这是浏览器的隐式转换机制。

<span style="position: absolute; transform: rotate(10deg);">旋转我!</span>

✅ 这样是有效的!


⚖️ margin 重叠:布局中的“幽灵现象”

在垂直方向上,相邻块级元素的上下 margin 会发生合并(collapse) ,最终间距取两者中的最大值,而不是相加。

<div style="margin-bottom: 20px;">A</div>
<div style="margin-top: 30px;">B</div>

A 和 B 之间的实际间距是 30px,不是 50px!这是很多初学者踩过的坑 🕳️

解决方法包括:

  • 使用 padding 代替部分 margin
  • 给父容器设置 overflow: hidden
  • 使用 Flex 或 Grid 布局(它们不会发生 margin 重叠)

📏 单位小谈:px 是怎么处理的?

px(像素)是最常用的绝对单位。虽然叫“绝对”,但在现代浏览器中,它其实是相对于设备像素比(DPR)进行缩放的逻辑像素。比如在 Retina 屏上,1px 可能对应 2 个物理像素,但开发者无需关心,浏览器会自动处理。

对于响应式设计,更推荐使用相对单位:

  • em / rem:基于字体大小
  • %:基于父元素
  • vw / vh:基于视口

💡 总结:CSS 是一门“层叠的艺术”

从选择器的精准匹配,到优先级的微妙博弈;从伪类的状态响应,到伪元素的内容增强;再到布局中的细节陷阱……CSS 不只是“调颜色”,而是一套精密的视觉控制语言

掌握它,你就能让静态的 HTML “活”起来,像海浪一样层层推进,又井然有序 🌊✨

记住:好的 CSS = 清晰的选择器 + 合理的层叠 + 对盒模型的深刻理解。

继续加油,前端之路,风景这边独好!🚀

【深度揭秘】JS 那些看似简单方法的底层黑魔法

2025年11月7日 23:43

前言:你看到的不一定是真相

嘿,各位前端工友们!👋

每天写着 arr.map()parseInt()str.length,你真的以为你了解它们吗?

很多时候,我们就像只会按按钮的操作工,知道按一下会出结果,但机器内部是怎么哐当哐当运作的,那就是一片迷雾了。今天,我就来当一回你们的 “金牌导游”,带你深入 JS 引擎的锅炉房,扒一扒这些常用方法背后那些不为人知的 “黑魔法”。

坐稳了,我们要发车了!


一、 map() 的底层:不仅仅是遍历

javascript

运行

const arr = [1, 2, 3, 4, 5, 6];
console.log(arr.map(item => item * item)); 
// 输出: [1, 4, 9, 16, 25, 36]

map 是个好东西,ES6 一出来就成了香饽饽。我们都知道它能遍历数组,返回一个新数组。但它的底层是怎样的呢?

底层揭秘:

  1. 创建新数组map 方法一调用,首先会在内存里开辟一块新空间,创建一个空数组,用来存放后续的结果。这也是为什么 map 会返回一个新数组,而不会修改原数组的原因。
  2. 遍历老数组:接着,它会像一个勤劳的小蜜蜂,挨个儿访问原数组中的每一个元素。
  3. 执行回调函数:对于每一个元素,它都会把这个元素(item)、它的索引(index)以及整个原数组(array)作为参数,传给你写的那个回调函数。
  4. 收集返回值:你的回调函数执行完后,会返回一个值。map 会把这个返回值,像捡到宝贝一样,小心翼翼地放进最开始创建的那个新数组里。
  5. 返回新数组:等所有元素都遍历完,回调函数也都执行完毕,新数组也收集满了宝贝,map 就会把这个新数组作为最终结果返回给你。

一句话概括map 的核心是 “映射”,它负责把老数组里的每一个元素,经过你给的 “加工机器”(回调函数),变成一个新元素,然后组装成一个全新的数组。


二、 parseInt() 的血泪史:你以为你懂了,其实你错了

javascript

运行

console.log([1, 2, 3].map(parseInt)); // 输出: [1, NaN, NaN]

这道题堪称面试界的 “送命题”。为什么不是 [1, 2, 3]parseInt 你到底在搞什么鬼?

要搞懂这个,我们必须先看 parseInt 的完整签名:parseInt(string, radix)

  • string:要被解析的值。
  • radix基数,一个 2 到 36 之间的整数。表示string参数的基数(进制)。

问题就出在 map 的回调函数会接收三个参数:(item, index, array)。当你把 parseInt 直接作为回调函数传给 map 时,map 会非常 “热心” 地把这三个参数都传给 parseInt

所以,上面那段代码的实际执行过程是这样的:

  1. parseInt('1', 0, [1,2,3])

    • radix 为 0 时,parseInt 会根据字符串的开头来判断基数。以 '1' 开头,默认为十进制。所以结果是 1
  2. parseInt('2', 1, [1,2,3])

    • radix 为 1。但是,parseInt 的 radix 范围是 2-36。1 是一个无效的基数。所以结果是 NaN
  3. parseInt('3', 2, [1,2,3])

    • radix 为 2(二进制)。但二进制里只有 0 和 1。字符串 '3' 在二进制里是无效的。所以结果也是 NaN

底层揭秘 & 血泪教训:

parseInt 的底层会根据你提供的 radix 去尝试将字符串解析为对应进制的整数。如果 radix 无效,或者字符串内容超出了 radix 进制的表示范围,它就会返回 NaN(Not a Number)。

所以,正确的用法是,给 map 传一个匿名函数,明确地只把 item 传给 parseInt

javascript

运行

// 正确用法
console.log([1, 2, 3].map(item => parseInt(item))); // 输出: [1, 2, 3]

记住,不要轻易把一个需要特定参数的函数直接作为回调函数传递,除非你非常清楚调用方会传递什么参数。


三、 JS 的 “包装类” 黑魔法:str.length 是怎么来的?

javascript

运行

let str = "hello"; // typeof str 是 "string",一个原始值
console.log(str.length); // 输出: 5

这看起来天经地义,但如果你细想一下,就会发现其中的奥秘。str 是一个字符串原始值,不是一个对象。那它为什么能像对象一样,拥有 .length 属性并调用方法呢?

这就是 JS 引擎的 “包装类”(Wrapper Classes)黑魔法。

底层揭秘:

当你试图访问一个原始值(如 string, number, boolean)的属性或方法时,JS 引擎会偷偷地、瞬间地做以下几件事:

  1. 创建包装对象:JS 引擎会根据原始值的类型,创建一个对应的临时对象。比如 'hello' 会创建一个 new String('hello') 对象。
  2. 访问属性 / 方法:然后,在这个临时对象上访问你想要的属性(length)或方法(如 toUpperCase())。
  3. 销毁包装对象:访问完成后,这个临时的包装对象就会被立即销毁,释放内存。

整个过程快如闪电,你完全感知不到。JS 引擎这么做,是为了让代码写起来更简洁、更直观,让你可以像操作对象一样操作简单的原始值。

你可以把它想象成:你(开发者)想跟一个明星(原始值 'hello')说话。你不能直接上去说,于是经纪人(JS 引擎)临时给明星套上一个 “人形外壳”(包装对象 String {'hello'}),你跟这个外壳交流(访问 .length),交流完,外壳就被收走了。

javascript

运行

let str = "hello";
str.length; // 这里发生了包装类的魔法

// 等价于:
let tempObj = new String(str); // 创建临时对象
let len = tempObj.length;      // 访问属性
tempObj = null;                // 销毁临时对象(示意)
console.log(len);

四、 NaN 的迷之特性:连自己都不认识的 “数字”

javascript

运行

console.log(NaN, typeof NaN); // 输出: NaN 'number'
console.log(0/0);             // 输出: NaN
console.log(parseInt("hello"));// 输出: NaN

// 最诡异的特性
console.log(NaN === NaN); // 输出: false

NaN(Not a Number)是一个非常特殊的值。它表示一个 “不是数字” 的数字。

底层揭秘:

NaN 是 Number 类型,但它代表一个无效的或未定义的数学运算结果。比如 0 除以 0,或者试图把一个非数字字符串 'hello' 转换成数字。

它最让人头疼的特性是:它不等于任何值,包括它自己

所以,你永远不能用 === 来判断一个值是不是 NaN

javascript

运行

// 错误的判断方式
if (someValue === NaN) { 
  // 这里的代码永远不会执行
}

那该怎么判断呢?正确的姿势是使用全局函数 isNaN() 或者 ES6 新增的 Number.isNaN()

javascript

运行

const b = parseInt("hello"); // b 的值是 NaN

// 正确的判断方式
if (Number.isNaN(b)) {
  console.log("哎呀,出错了,这不是一个有效的数字!");
}

Number.isNaN() 比全局的 isNaN() 更严谨,因为 isNaN() 会先尝试将参数转换为数字,导致一些误判。


五、字符串的索引与 length 的小秘密

javascript

运行

const str = " Hello, 世界! 👋  ";
console.log(str.length); // 输出: 15
console.log(str[1]);     // 输出: 'H'

str.length 返回字符串的长度,str[index] 可以通过索引访问字符。这很基础,但底层也有讲究。

底层揭秘:

  1. length 的计算:JS 字符串在底层是基于 UTF-16 编码存储的。length 属性返回的是字符串中 UTF-16 编码单元(code unit)的数量,而不是字符(code point)的数量。

    • 对于大多数常见字符(如英文字母、数字、常用中文),一个字符对应一个 UTF-16 编码单元,所以 length 看起来是正确的。
    • 但对于一些扩展字符集的字符,比如某些 emoji 😊、👋 或者一些生僻字,它们可能需要两个或更多的 UTF-16 编码单元来表示。

    javascript

    运行

    console.log('𝄞'.length); // 输出: 2 (这是一个音乐符号,需要两个UTF-16编码单元)
    console.log('😊'.length); // 输出: 2 (这个emoji也是)
    

    这是一个常见的 “陷阱”,在处理包含 emoji 或特殊字符的字符串时需要特别注意。

  2. str[index] 的访问:这种方式访问的是第 index 个 UTF-16 编码单元,而不是第 index 个视觉上的字符。对于上面的例子,'𝄞'[0] 会得到一个无效的代理对(surrogate pair)字符。

    如果你需要正确地遍历每一个视觉上的字符(code point),应该使用 for...of 循环或者 Array.from()

    javascript

    运行

    const emoji = '😊';
    console.log(emoji.length); // 2
    
    for (const char of emoji) {
      console.log(char); // 正确输出: 😊
    }
    
    console.log(Array.from(emoji)); // 正确输出: ['😊']
    

总结:知其然,更要知其所以然

今天我们深入探讨了 mapparseIntlength包装类 和 NaN 这些 JS 中看似简单的特性背后的底层逻辑。

了解这些底层原理,不仅仅是为了在面试中炫技,更重要的是:

  • 避免踩坑:比如 [1,2,3].map(parseInt) 的陷阱。
  • 写出更健壮的代码:比如知道了 NaN 的特性,就会用 Number.isNaN() 来判断。
  • 理解代码行为:当代码出现意外结果时,能够从底层逻辑出发去分析和调试问题。

JS 是一门充满 “惊喜” 的语言,表面简单,实则水深。希望这篇文章能帮助你拨开迷雾,看到 JS 更真实、更有趣的一面。

你还想知道哪些 JS 方法的底层实现?评论区告诉我!

从变量提升到调用栈:V8 引擎如何 “读懂” JS 代码

作者 闲云ing
2025年11月7日 23:37

在 Chrome 浏览器中,JavaScript 代码的编译和执行全靠 V8 引擎 。不同于 C++、Java 等编译型语言,JS 作为脚本语言,编译过程发生在执行前的一霎那 —— 这也造就了它独特的执行逻辑:代码编写顺序和实际执行顺序往往并不一致

今天我们就从代码实例出发,一步步拆解 V8 引擎如何处理 JS 代码,把变量提升、执行上下文、调用栈这些核心概念讲明白~

一、先编译,后执行:V8 引擎的 “预加工” 操作

JS 代码运行时,V8 引擎不会直接逐行执行,而是先进入 编译阶段 做准备工作,再进入 执行阶段 运行代码。编译阶段的核心任务有两个:

  1. 检测语法错误(比如漏写括号、变量未声明等);
  1. 变量提升(提前识别变量和函数,为执行阶段铺路)。

看个直观例子

// 先调用函数、访问变量,再定义它们
showName();
console.log(myName);
console.log(hero);
var myName = 'Asdj';
let hero = '钢铁侠';
function showName() {
    console.log('函数showName被执行'); 
}

按 “从上到下” 的编写逻辑,showName() 和 console.log(myName) 早该报错,但实际运行结果是:

  • showName() 正常执行(输出 “函数 showName 被执行”);
  • console.log(myName) 输出 undefined;
  • console.log(hero) 报错(Cannot access 'hero' before initialization)。

这就是 变量提升 在起作用 ——V8 引擎在编译时,会把 var 声明的变量和函数声明 “提前” 到当前作用域顶部。

变量提升的规则

V8 引擎编译时,对不同声明的 “提升逻辑” 不同:

声明类型 提升行为
函数声明 优先级最高,完整提升整个函数体(可以在定义前直接调用)
var 变量声明 只提升 “声明”,不提升 “赋值”,初始值设为 undefined
let/const 声明 不提升,会进入 “暂时性死区”(TDZ),执行阶段前访问会直接报错

编译后的执行逻辑

上面的代码经过编译后,实际执行顺序是这样的:

// 编译阶段:提升的内容(开发者看不到,是引擎内部操作)
function showName(){ // 函数声明完整提升
    console.log('函数showName被执行');
}
var myName; // var变量只提升声明,初始值undefined
// 执行阶段:按编译后的顺序执行
showName(); // 正常调用(函数已提升)
console.log(myName); // 输出undefined(只声明未赋值)
console.log(hero); // 报错(let未提升,处于暂时性死区)
myName = 'Asdj'; // 执行赋值操作(编译阶段不处理赋值)
let hero = '钢铁侠'; // let声明和赋值在执行阶段进行

为什么需要变量提升?

本质是为了适配 JS “边编译、边执行” 的脚本语言特性:

编译阶段提前识别变量和函数,能避免执行时因 “变量未定义” 导致的逻辑断裂,同时允许函数声明在定义前调用,提升代码编写的灵活性。

二、执行上下文:代码运行的 “环境容器”

编译阶段的核心产物是 执行上下文(Execution Context) —— 它就像一个 “环境容器”,包含了代码执行所需的所有信息(变量、函数、作用域、this 等)。

V8 引擎会为不同类型的代码创建对应的执行上下文:

  • 全局执行上下文:全局代码(不在任何函数内的代码)对应的上下文,页面加载时创建,直到页面关闭才销毁;
  • 函数执行上下文:函数代码对应的上下文,每次调用函数时都会创建一个全新的实例,函数执行完毕后就会被销毁(垃圾回收)。

执行上下文的组成

每个执行上下文都包含 3 个核心部分:

  1. 变量环境(Variable Environment) :存储 var 声明的变量和函数声明,允许重复声明;
  1. 词法环境(Lexical Environment) :存储 let/const 声明的变量,不允许重复声明,存在暂时性死区;
  1. 可执行代码(Executable Code) :编译后等待执行的代码(去除了声明提升部分)。

用代码拆解执行上下文流程

我们用一段带函数的代码,一步步看执行上下文的创建、执行、销毁过程:

var a = 1;
function fn(a) {
  console.log(a);
  var a = 2;
  var b = a;
  console.log(a);
}
fn(3);
console.log(a);

步骤 1:全局执行上下文(编译阶段)

V8 引擎先编译全局代码,创建全局执行上下文:

  • 变量环境:a: undefined(var a 提升声明)、fn: 函数体(函数声明完整提升);
  • 词法环境:空(无 let/const 声明);
  • 可执行代码:全局代码(赋值、函数调用等逻辑)。

步骤 2:全局执行上下文(执行阶段)

全局执行上下文被压入调用栈后,开始执行代码:

  1. 执行 var a = 1:变量环境中 a 的值从 undefined 更新为 1;
  1. 执行 fn(3):调用函数,触发 函数执行上下文 的创建。

步骤 3:函数执行上下文(编译阶段)

编译 fn 函数内部代码,创建函数执行上下文:

  • 变量环境:形参 a: 3(实参赋值给形参)、b: undefined(var b 提升声明);
  • 词法环境:空(无 let/const 声明);
  • 可执行代码:函数内部代码(console.log(a)、var a = 2 等)。

步骤 4:函数执行上下文(执行阶段)

函数执行上下文被压入调用栈顶(优先执行),开始执行内部代码:

  1. 执行 console.log(a):读取变量环境中的形参 a,输出 3;
  1. 执行 var a = 2:变量环境中 a 的值从 3 更新为 2;
  1. 执行 var b = a:变量环境中 b 的值设为 2;
  1. 执行 console.log(a):读取变量环境中的 a,输出 2;
  1. 函数执行完毕:函数执行上下文从调用栈中弹出,被垃圾回收销毁。

步骤 5:回到全局执行上下文

继续执行全局代码:

  • 执行 console.log(a):读取全局变量环境中的 a,输出 1。

最终运行结果:3 → 2 → 1—— 这正是执行上下文 “创建 → 执行 → 销毁” 的完整流程体现。

三、调用栈:管理执行上下文的 “调度中枢”

V8 引擎用 调用栈(Call Stack) 来管理所有执行上下文 —— 调用栈是一种 “先进后出(LIFO)” 的数据结构,能确保代码按正确顺序执行。

调用栈的工作流程

  1. 页面加载时,全局执行上下文 先被压入栈底;
  1. 调用函数时,创建对应的 函数执行上下文 并压入栈顶;
  1. 栈顶的执行上下文(当前正在执行的代码)优先执行;
  1. 函数执行完毕,其执行上下文从栈顶弹出并销毁;
  1. 所有代码执行完毕,调用栈中只剩全局执行上下文,页面关闭时弹出销毁。

用例子理解调用栈变化

function a() {
  console.log('a执行');
  b(); // 调用函数b
}
function b() {
  console.log('b执行');
}
a(); // 调用函数a

调用栈的变化过程(可视化):

执行步骤 调用栈状态 说明
1 [全局执行上下文] 页面加载,全局上下文入栈
2 [全局,a] 调用 a (),a 的上下文入栈
3 [全局,a, b] a 中调用 b (),b 的上下文入栈
4 [全局,a] b 执行完毕,出栈
5 [全局] a 执行完毕,出栈
6 [] 页面关闭,全局上下文出栈

运行结果:a执行 → b执行—— 完全符合调用栈 “栈顶优先执行” 的规则。

四、var、let/const 的核心区别:从编译角度看

var 和 let/const 的差异,本质是 编译阶段存储位置不同(变量环境 vs 词法环境),我们用代码直观感受:

// 1. 访问var和let变量
console.log(a); // undefined(变量环境,提升后初始值undefined)
console.log(b); // 报错(词法环境,暂时性死区)
// 2. 重复声明
var a = 1;
var a = 2; // var允许重复声明,覆盖值
console.log(a); // 输出2
let b = 3;
// let b = 4; // let不允许重复声明,报错
console.log(b); // 输出3
// 3. 严格模式下的var
'use strict';
var c = 1;
var c = 2; // 严格模式下,var仍允许重复声明
console.log(c); // 输出2
// 4. 函数表达式
func(); // 报错:func is not a function(函数表达式不提升)
let func = () => {
    console.log('函数表达式不会提升');
}

核心区别总结

特性 var let/const
提升行为 提升声明,初始值 undefined 不提升,进入暂时性死区
重复声明 允许(无报错,覆盖值) 不允许(直接报错)
存储位置 变量环境 词法环境
函数表达式 仅提升变量声明(值为 undefined) 不提升(调用时报错)

五、数据类型存储:栈内存与堆内存的差异

JS 中简单数据类型和复杂数据类型的存储方式不同,导致赋值时出现 “值拷贝” 和 “引用拷贝” 的差异 —— 本质是 栈内存堆内存 的使用逻辑不同。

先看代码例子

// 1. 简单数据类型(字符串、数字、布尔等)
let str = 'hello';
let str2 = str; // 值拷贝:复制栈内存中的值
str2 = '你好'; // 修改str2的栈内存值,不影响str
console.log(str, str2); // 输出:hello 你好
// 2. 复杂数据类型(对象、数组、函数等)
let obj = { 
    name: '老板',
    age: 18
};
let obj2 = obj; // 引用拷贝:复制栈内存中的地址(指向堆内存数据)
obj2.age++; // 通过地址修改堆内存数据,obj也受影响
console.log(obj2, obj); // 两者age都是19

栈内存与堆内存的区别

内存类型 特点 存储内容
栈内存 空间小、读取快 简单数据类型的 “值”、复杂数据类型的 “地址”
堆内存 空间大、存储复杂 复杂数据类型的 “实际数据”(如对象的键值对)

简单理解:

  • 简单数据类型:变量直接持有 “值”(存在栈里),赋值时复制 “值”;
  • 复杂数据类型:变量持有 “地址”(存在栈里),“地址” 指向堆内存中的实际数据,赋值时复制 “地址”(两个变量指向同一堆数据)。

总结:V8 引擎执行 JS 的完整流程

V8 引擎执行 JS 代码的全过程可以概括为 4 步:

  1. 编译阶段:接管代码,检测语法错误,创建执行上下文(变量环境存 var 和函数声明,词法环境存 let/const),完成变量提升;
  1. 调用栈调度:全局执行上下文压入栈底,函数调用时创建函数执行上下文并压入栈顶;
  1. 执行阶段:栈顶执行上下文对应的代码逐行执行(变量赋值、函数调用等);
  1. 销毁阶段:函数执行完毕,其执行上下文出栈并销毁,全局执行上下文在页面关闭时销毁。

埋点监控平台全景调研

作者 Forever_xl
2025年11月7日 23:32

基础知识

1. 埋点监控平台

什么是埋点监控平台?

埋点监控平台是一套采集、存储、分析用户行为与系统数据的工具,核心是通过代码 “埋点” 获取数据,再通过平台实现可视化监控与分析。

就好比给一个产品安置了“眼睛”“大脑”“眼睛”(埋点)负责看用户和系统的一举一动,“大脑”(平台)负责把这些 “看到的” 整理成有用的信息告诉你。

为什么要自己做一个埋点监控平台?

埋点监控平台是一个 “全链路技术实践” 项目,能串联起数据采集、后端服务、前端可视化等多个技术领域,可以快速提升个人综合开发能力以及团队协作能力。

其实我觉得市面上的大部分工具都需要钱💰,自己做一个埋点监控平台,可以想要啥功能就开发啥,完全跟着自己的业务走,不花冤枉钱。😋

通过这个项目准备学习哪些内容?

  • ✨学会怎么写 “埋点 SDK”: 比如给电商网页按钮加段 JS 代码,用户一点击就自动收集 “谁点的、什么时候点的、点的次数多吗”,再把这些数据打包发给后端。搞懂 “数据怎么格式化成统一样子”“怎么避免重复发数据” 这些实际问题。
  • ✨学会怎么处理 “垃圾数据”: 平台实际采集的数据可能会有垃圾(比如用户乱点产生的无效数据、格式错误的信息),学会如何写代码过滤这些数据,比如“用户1秒点击了按钮100次,导致页面崩溃”,相信很多人过年抢车票的时候深有体会。
  • ✨学会怎么做 “监控仪表盘”: 用 ECharts 或 Grafana 画图表,比如把 “近 7 天点击量” 做成折线图、“不同设备的访问占比” 做成饼图,更直观的体现这些数据所体现出的问题。
  • ✨团队协作: 这一点是对于第一次接触多人协作项目的初学者来说挺重要的一点,培养团队协作能力,学会如何与同事同步项目信息。

一个基础的埋点监控平台需要具备哪些功能?

功能模块 核心能力
埋点管理 支持创建 / 编辑埋点(定义埋点名称、类型、关联业务场景)
埋点数据格式校验(避免无效数据入库)
数据采集 提供 SDK 供业务端集成埋点
接收埋点数据(支持高并发,避免数据丢失)
数据存储 存储原始埋点数据(用于追溯)
存储聚合后的数据(用于快速查询,如 “今日按钮点击总量”)
可视化监控 支行为监控:用户点击、页面访问量等图表展示
系统监控:接口成功率、耗时、错误码分布展示
告警通知 支持配置告警规则(如接口成功率低于 99% 触发告警)
多渠道通知(短信、邮件、企业微信)

怎么去实现这个项目基础功能?

架构设计

用户在应用层(APP / 网站)产生行为→行为被应用层的 SDK采集并发送→接入层对数据加工、清洗、聚合后存起来→平台服务提供查询、告警等功能→最终在监控平台以图表、报表的形式呈现,帮你发现问题、优化业务。

削峰限流: 用于应对数据量激增(如大促活动时用户行为爆发)或恶意高并发访问,防止因流量过载导致服务不可用。

数据加工: 对采集的原始数据进行增强,例如补充 IP 归属地、运营商类型等维度信息,丰富数据的分析价值。

数据清洗: 通过白名单过滤合法数据、黑名单拦截无效 / 恶意数据,同时清理已下线应用的历史残留数据,保障入库数据的有效性。

数据聚合: 将具有相同特征的分散数据进行归类汇总(如将同一类型的埋点异常抽象为一个可追踪的 issue),便于后续的查询分析和问题追溯。

SDK架构设计

参考文档👉:腾讯三面:说说前端监控平台/监控SDK的架构设计和难点亮点?    

SDK 架构通过 “内核 + 插件” 设计,以多包管理实现多端兼容,通过 Core 层、SDK 层、Plugins 层构建功能体系,再依托内核与 Instance形成 “插件采集原始数据→Instance 传递给内核→内核格式化并通过 Fetch/Beacon 等方案上报” 的闭环,最终实现既能灵活适配 Web、小程序等多端场景,又能稳定提供一致的埋点采集能力,还可按需扩展功能的核心目标。

采集模块
1.性能监控:

核心目标:捕获页面加载、资源加载、交互响应等性能指标,定位性能瓶颈(如加载慢、卡顿),支撑体验优化。

window.addEventListener('load', () => {
       const timing = performance.timing;
       const performanceData = {
            trackId: 'page_performance',
            whiteScreenTime: timing.domLoading - timing.navigationStart, // 白屏时间
            domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart, // DOM就绪
            loadTime: timing.loadEventEnd - timing.navigationStart // 全量加载完成
       };
       dataProcessor.handle(performanceData);
});

监控指标: 白屏时间、DOM 加载完成时间、页面完全加载时间、首屏渲染时间。

实现逻辑: 通过performance.timing获取页面导航到各阶段的时间戳,计算各指标耗时。

window.addEventListener('load', () => {
       performance.getEntriesByType('resource').forEach(resource => {
          dataProcessor.handle({
              trackId: 'resource_performance',
              url: resource.name,
              type: resource.initiatorType, // 资源类型:img/script等
              duration: resource.duration, // 加载耗时(ms)
              startTime: resource.startTime // 开始加载时间
          });
       });
});

监控指标: 各资源(JS/CSS/ 图片)的加载耗时、开始 / 结束时间、资源类型。

实现逻辑: 通过performance.getEntriesByType('resource')获取所有资源的加载详情。

2. 错误监控:
window.onerror = (message, source, lineno, colno, error) => {
  dataProcessor.handle({
    trackId: 'js_error',
    message: message,
    source: source, // 错误文件路径
    line: lineno,
    column: colno,
    stack: error?.stack || '无堆栈'
  });
  return true; // 阻止控制台重复输出
};

监控范围: 同步代码错误(如undefined调用函数)、语法错误、DOM 操作错误。

实现逻辑: 通过window.onerror捕获错误信息、发生位置和堆栈。

class ErrorBoundary extends React.Component {
 componentDidCatch(error, info) {
   dataProcessor.handle({
     trackId: 'react_component_error',
     message: error.message,
     stack: error.stack,
     componentStack: info.componentStack // 组件调用栈
   });
 }
 render() { return this.props.children; }
}

监控范围: React 组件渲染错误(如render函数报错、子组件异常)。

实现逻辑: 通过ErrorBoundary捕获组件树错误,上报组件调用栈。

3. 行为监控:
// 点击行为捕获
document.addEventListener('click', (e) => {
  const target = e.target;
  if (target.classList.contains('ignore-track')) return; // 过滤无需采集的元素
  dataProcessor.handle({
    trackId: 'user_click',
    elementId: target.id || target.dataset.track, // 元素标识
    x: e.clientX, // 点击坐标
    y: e.clientY,
    pageUrl: location.href
  });
});

// 表单输入行为捕获
document.addEventListener('input', (e) => {
  const target = e.target;
  if (target.type === 'text' || target.type === 'search') {
    dataProcessor.handle({
      trackId: 'user_input',
      elementId: target.id,
      value: target.value.slice(0, 5) // 脱敏,只保留前5位
    });
  }
}, { passive: true }); // 非阻塞模式,提升性能

监控范围: 按钮点击、链接跳转、表单输入、页面滚动等。

实现逻辑:document上统一监听事件(如click/input),通过事件冒泡获取触发元素,过滤无效行为。

数据收集

标准化格式: 将不同场景的原始数据(如点击事件的 “坐标”、错误事件的 “堆栈”)统一为固定结构,必含eventId(唯一标识)、trackId(事件类型)、timestamp(时间戳)、基础环境信息(设备、用户、页面)等核心字段,避免数据格式混乱。

{
  "eventId": "uuid-xxx-xxx", // 全局唯一ID(去重、追溯用)
  "trackId": "user_click",   // 事件类型标识(与埋点配置对应)
  "timestamp": 1730764800000, // 事件发生时间戳(毫秒)
  "basicInfo": {             // 基础环境信息(自动补充)
    "userId": "12345",       // 登录用户ID(未登录则为空)
    "deviceId": "xxx-xxx",   // 设备唯一标识
    "pageUrl": "https://xxx.com/home",
    "browser": "Chrome 120", // 浏览器信息
    "os": "Windows 10"       // 操作系统
  },
  "eventData": {}            // 场景化数据(如点击事件的elementId、错误事件的stack)
}

清洗过滤剔除无效数据: 过滤格式错误(如非法trackId、异常时间戳)、重复数据(如 1 秒内同一设备的重复点击)、敏感信息(如手机号脱敏),只保留符合规则的有效数据。

function cleanData(rawData) {
// 1. 校验trackId是否合法(从配置中心获取有效trackId列表)
if (!validTrackIds.includes(rawData.trackId)) {
console.warn(`无效trackId: ${rawData.trackId}`);
return null; // 过滤该数据
}

// 2. 校验时间戳
const now = Date.now();
if (rawData.timestamp < 1577808000000 || rawData.timestamp > now + 300000) { // 早于2020年或晚于5分钟后
console.warn(`无效时间戳: ${rawData.timestamp}`);
return null;
}

// 3. 重复数据过滤(用localStorage暂存最近1秒的事件ID)
const cacheKey = `dup_cache_${rawData.deviceId}_${rawData.trackId}`;
const recentEvents = JSON.parse(localStorage.getItem(cacheKey) || '[]');
if (recentEvents.includes(rawData.eventId)) {
return null; // 重复数据,过滤
}
// 保留最近10条,避免缓存过大
recentEvents.push(rawData.eventId);
if (recentEvents.length > 10) recentEvents.shift();
localStorage.setItem(cacheKey, JSON.stringify(recentEvents));

// 4. 敏感信息脱敏(如eventData中的手机号)
if (rawData.eventData.phone) {
rawData.eventData.phone = rawData.eventData.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}

return rawData; // 清洗后的数据
}

补充与聚合

自动补充上下文(如网络类型、页面标题、IP 属地),丰富数据维度;

对高频事件聚合处理,减少上报次数,降低性能损耗(对短时间内的重复点击,聚合为 “点击次数”,如 10 次点击合并为 1 条数据,count: 10)。

数据上报

1.基础上报:Fetch/XMLHttpRequest

适用场景:用户主动操作(如点击按钮、提交表单),需要实时上报且允许等待响应。

原理:通过 HTTP 请求将数据发送到接入层接口,类似普通的前后端交互。

// SDK中的上报函数(Fetch版)
function trackWithFetch(data) {
  fetch("https://平台域名/api/track", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
    keepalive: true  // 页面关闭时也能尝试发送(增强可靠性)
  }).catch(err => {
    // 失败时存本地(如localStorage),后续补发
    saveToLocalStorage("pendingData", data);
  });
}
// SDK中的上报函数(XHR版)
function trackWithXHR(data) {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", "https://平台域名/api/track", true);
  xhr.setRequestHeader("Content-Type", "application/json");
  xhr.onreadystatechange = () => {
    if (xhr.readyState === 4 && xhr.status !== 200) {
      saveToLocalStorage("pendingData", data); // 失败存本地
    }
  };
  xhr.send(JSON.stringify(data));
}

2.页面退出上报:Beacon

适用场景:用户离开页面时的行为(如 “页面停留时长”“退出原因”),需要确保数据能发出去,不阻塞页面关闭。

痛点:用 Fetch/XHR 可能因页面关闭被中断,导致数据丢失。

原理:浏览器提供的navigator.sendBeaconAPI,专门用于异步发送 “离开页面时的关键数据”,优先级高且不阻塞页面卸载。

// SDK中监听页面关闭事件(用Beacon上报)
window.addEventListener("unload", () => {
  const data = {
    trackId: "page_leave",
    stayTime: Date.now() - pageLoadTime, // 页面停留时长
    ...baseData // 包含userId、deviceId等基础信息
  };
  // 转换为FormData(Beacon默认用POST,数据格式需兼容)
  const formData = new FormData();
  formData.append("data", JSON.stringify(data));
  // 发送Beacon请求,返回true表示浏览器已接收任务
  const success = navigator.sendBeacon("https://平台域名/api/track/beacon", formData);
  if (!success) {
    // 浏览器不支持或队列满了
    trackWithFetch(data);
  }
});

技术栈调研选取及理由

技术环节 技术选型 选取理由
数据采集层(SDK 开发) TypeScript + Webpack 强类型减少数据格式错误,支持模块化与多端复用;
打包工具保障 SDK 轻量易集成。
数据传输与接入层 Spring Boot(Java)+ Redis Spring Boot 生态成熟,快速搭建高可用接口;
Redis 做临时队列削峰、限流,保障高并发稳定性。
数据处理与存储层 MySQL + Redis + Quartz MySQL 存储原始数据,支持复杂查询与事务;
Redis 存储聚合数据,提升高频读取效率;
Quartz 实现定时聚合任务,稳定可靠。
可视化与应用层 React + Vite + ECharts React组件化思想成熟,适合拆分仪表盘的复杂模块,生态丰富;
Vite支持 React 的快速热更新,开发效率不低于 Vue3 场景;
ECharts与 React 兼容良好,可通过
告警通知层 企业微信 /飞书 配置简单、免费无限制,支持 @指定人员,确保告警及时触达,适合快速落地。

2.埋点

埋点这一块我将会从什么是埋点?有什么作用?前端如何埋点?三个角度出发👇

什么是埋点?

埋点是一种在应用程序的特定功能或用户交互节点(例如用户在应用中的点击、浏览、购买、注册等操作行为)中预先嵌入代码片段,以采集用户行为数据(如操作路径、停留时长、功能使用频次)、系统运行数据(如接口响应时间、报错信息)及业务数据(如订单提交、商品收藏),并将这些数据传输至后端数据平台进行存储、分析,最终用于优化产品功能、提升用户体验、辅助业务决策的技术实现方式。


埋点有什么作用?

埋点主要用于收集和分析用户行为数据。通过对收集到的数据进行分析,开发人员和产品团队可以了解用户行为模式、优化产品功能、改善用户体验、评估转化率、针对不同用户群体制定营销策略等。具体分析如下👇

  • 🍉收集用户行为数据: 通过在关键位置插入特殊代码,可以收集用户的行为数据,例如用户访问哪些页面,点击哪些按钮,使用哪些功能等。
  • 🍊分析用户习惯: 通过分析收集的用户行为数据,可以了解用户的行为习惯,例如用户喜欢使用哪些功能,访问哪些页面,以及在什么时间段使用应用等。
  • 🍎提供数据支持: 通过收集用户行为数据,企业可以有更有价值的数据支持,从而制定更科学的产品策略、营销策略和开发策略。
  • 🍒优化产品体验: 通过收集用户行为数据,企业可以了解用户使用产品的痛点和需求,从而针对性地优化产品体验,提高用户满意度。
  • 🍓提高转化率: 通过分析用户的行为数据,可以找到影响用户转化的关键因素,从而对产品、页面、营销策略等进行优化,提高转化率

    什么是转化率?

    比如在电商场景中,通过埋点追踪用户 “浏览商品 - 加入购物车 - 下单支付” 的全流程,若发现很多用户在购物车环节流失,就可以分析是价格、配送还是其他原因,然后优化购物车页面的设计、推出满减活动等,从而让更多用户完成支付,提高购买转化率。

    举个简单粗暴的例子:100 个用户进入商品详情页,最终有 20 人下单,下单转化率就是 20%。


前端如何埋点?

前端埋点主要有代码埋点、可视化埋点、无埋点(全埋点) 三种核心方案,它们在实现方式、灵活性和成本上各有差异,需根据业务需求选择。

代码埋点

开发人员在代码中手动插入埋点代码,触发特定行为(如点击、提交)时上报数据。

优点:数据精准,仅采集需要的关键行为。可自定义上报字段,满足复杂业务需求。

缺点:开发成本高,需逐个场景写代码。易漏埋或错埋,后期维护麻烦。

使用场景:核心业务场景(如支付、注册)、需自定义数据的场景。

// 给按钮绑定点击事件,触发埋点
document.getElementById('buy-btn').addEventListener('click', () => {
  // 业务逻辑:比如跳转到支付页
  console.log('用户点击了购买按钮');
  // 埋点:上报“购买按钮点击”事件及商品信息
  reportEvent('buy_button_click', {
    productId: 'p1001',
    price: 99
  });
});
可视化埋点

无需写代码,通过可视化工具选择页面元素(如按钮、链接),设置需要追踪的行为,工具自动生成埋点规则并生效。

优点:非技术人员也能操作,降低开发成本。埋点效率高,可快速配置和修改。

缺点:功能有局限,复杂交互可能无法精准圈选。

使用场景:简单交互,不需要自定义事件的场景。

无埋点(全埋点)

自动采集页面所有用户行为(如所有点击、页面浏览、输入操作),无需人工配置,数据全量上报后再在后台筛选需要的信息。

优点:一次性接入,后续无需维护埋点。可回溯分析,遗漏数据时无需重新埋点。

缺点:数据量极大,增加存储和传输成本。无用数据多,筛选和分析效率低。

使用场景:早期产品探索期、无法预判埋点需求的场景。

国内流行的埋点工具

TalkingData:移动数据分析平台,提供了用户画像、行为分析、漏斗分析等功能

阿里云ARMS:阿里云提供的应用性能监控服务,提供了性能埋点、错误监控、资源优化等功能。

诸葛IO:专业的用户行为分析平台,具备用户行为路径分析、留存分析、漏斗分析等功能,助力企业深入洞察用户行为,优化产品体验与运营策略。

友盟+:国内知名的全域数据智能服务商,覆盖 APP、小程序、H5 等多场景,提供用户增长、数据统计、精准营销等一站式解决方案,助力企业实现数据驱动的业务增长。

神策数据:以用户行为分析为核心的大数据分析平台,提供用户画像构建、行为路径挖掘、智能推荐等功能,帮助企业从数据中挖掘价值,驱动精细化运营与产品迭代。

流行的监控工具

监控可以实时收集关于实时了解应用的性能表现,如页面加载速度、响应时间等。这些数据可以为性能优化提供依据,帮助开发者找到性能瓶颈并进行优化,还可以帮助开发者及时发现应用中的错误和异常,通过对监控数据的分析,开发者可以定位问题原因,快速解决问题,降低故障对用户体验的影响

Sentry:一个开源的前端错误监控工具,可以捕获和报告JavaScript和前端框架的错误和异常。它提供详细的错误信息和堆栈跟踪,帮助开发人员快速定位和解决问题。

fundebug:专业的全栈错误监控平台,支持 JavaScript、微信小程序、Java、Node.js 等多技术栈,能实时捕获并分析代码错误、性能异常,提供错误详情、用户行为回溯等功能,助力开发者快速定位和解决线上问题,保障应用稳定运行。

webfunny:轻量级前端监控系统,专注于前端异常和性能监控,支持捕获 JavaScript 错误、资源加载异常、接口请求失败等场景,提供可视化的错误统计和用户行为轨迹,帮助开发者高效排查前端线上问题,提升应用质量。

下面这两个网址需要使用加速器

Google Analytics(谷歌分析):非常流行的网站统计和分析工具,提供了丰富的功能,如用户行为分析、性能监控、事件追踪等。

Lighthouse:由Google提供的开源网站性能分析工具,可以评估页面的性能、可访问性、SEO等方面

相关项目总结

websee(前端监控与埋点)

Github地址:github.com/xy-sea/web-…

demo地址:github.com/xy-sea/web-…

亮点:

  • 支持多种错误还原方式: 定位源码、播放录屏、记录用户行为
  • 支持项目的白屏检测: 兼容有骨架屏、无骨架屏这两种情况
  • 支持错误上报去重: 错误生成唯一的id,重复的代码错误只上报一次
  • 支持多种上报方式: 默认使用web beacon,也支持图片打点、http 上报

功能点:

  • 错误捕获: 代码报错、资源加载报错、接口请求报错
  • 性能数据: FP、FCP、LCP、CLS、TTFB、FID
  • 用户行为: 页面点击、路由跳转、接口调用、资源加载
  • 个性化指标: Long Task、Memory 页面内存、首屏加载时间
  • 白屏检测: 检测页面打开后是否一直白屏
  • 错误去重: 开启缓存队列,存储报错信息,重复的错误只上报一次
  • 手动上报错误
  • 支持多种配置: 自定义 hook 与选项
  • 支持的 Web 框架: vue2、vue3、React

monitorjs_horse(前端异常监控工具库)

Github地址:github.com/Jameszws/mo…

npm包地址:www.npmjs.com/package/mon…

亮点:

  • 轻量:作为轻量级工具,接入成本低,只需简单配置即可快速在项目中启用监控功能。
  • 多框架兼容:对 Vue 等主流前端框架有良好的兼容性,适配多种技术栈的项目。
  • 数据可定制化: 支持对采集的数据进行自定义过滤、加工,灵活适配不同业务场景的分析需求。

功能点:

  • 前端异常监控:可捕获 JS 语法错误、运行时错误、Vue 框架错误等各类前端异常,还能监控资源加载异常(如图片、脚本加载失败)、接口请求失败等情况。
  • 页面性能监控:能够采集页面加载时间、首屏渲染时间、资源加载耗时等性能指标,助力优化页面加载体验。
  • 设备信息采集:可获取用户设备的浏览器类型、版本、操作系统、屏幕分辨率等信息,便于分析不同设备环境下的问题。
  • 自定义埋点:支持自定义事件埋点,如按钮点击、页面跳转等用户行为,满足个性化的数据采集需求。

webfunny-monitor(前端全链路监控平台)

Github地址:github.com/a597873885/…

npm包地址:www.npmjs.com/package/@we…

官方地址:www.webfunny.cn/

亮点:

  • 私有化部署:支持私有化部署,满足企业对数据隐私和安全性的高要求。
  • 轻量级:工具使用简单,接入流程便捷,开发者可快速在项目中部署启用监控功能。
  • 数据可视化强:提供丰富的可视化图表,如错误统计趋势图、性能指标对比图、用户行为轨迹图等,直观呈现监控数据。
  • 功能全面且可扩展:覆盖前端异常、性能、用户行为等多方面监控需求,且支持二次开发和功能扩展,能适配不同业务场景的个性化需求。

功能点:

  • 前端异常监控:可捕获 JS 语法错误、运行时错误、资源加载异常(如图片、脚本加载失败)、接口请求失败等各类前端异常,还能记录错误发生时的调用堆栈、用户操作路径等详细信息。
  • 性能监控:采集页面加载时间、首屏渲染时间、资源加载耗时、接口响应时间等性能指标,助力优化页面加载体验和接口性能。
  • 用户行为埋点与分析:支持自定义事件埋点(如按钮点击、页面跳转、表单提交等用户行为),并能对用户行为轨迹进行可视化分析,了解用户在产品中的操作路径。
  • 设备信息采集:获取用户设备的浏览器类型、版本、操作系统、屏幕分辨率、网络环境等信息,便于分析不同设备环境下的问题。
  • 告警功能:支持自定义告警规则,当出现异常或性能指标超出阈值时,可通过邮件、钉钉等方式及时通知相关人员。
  • 多端支持:适配 Web、微信小程序、React Native 等多端应用的监控需求。

JavaScript 执行机制深度解析(上):编译、提升与执行上下文

作者 Zyx2007
2025年11月7日 23:17

引言:你以为的顺序,不是引擎看到的顺序

当你写下一行 JavaScript 代码时,是否曾想过:浏览器真的按照你写的顺序执行吗?为什么 showName() 在函数定义之前调用却不会报错?为什么 console.log(myName) 输出的是 undefined 而不是报错?这些看似“反直觉”的现象背后,隐藏着 V8 引擎精妙的执行机制。

JavaScript 并非像我们想象中那样“逐行解释执行”。相反,它在真正运行前会经历一个短暂但至关重要的编译阶段。正是这个阶段,决定了变量和函数的命运,也塑造了 JavaScript 独特的运行时行为。本文将带你深入 V8 引擎内部,揭开 JavaScript 执行机制的第一层面纱。


一、V8 引擎:JavaScript 的“翻译官”与“调度员”

Chrome 浏览器中的 V8 引擎不仅是 JavaScript 的执行环境,更是一个高性能的编译器。它负责将人类可读的 JS 代码转化为机器能高效执行的指令。

与其他语言(如 C++、Java)不同,JavaScript 是一种即时编译(JIT)语言:它没有独立的编译步骤,而是在执行前的一瞬间完成编译。这种“边编译、边执行”的特性,使得 JS 既灵活又高效,但也带来了独特的语义规则——比如变量提升

正是这种“编译发生在执行前的一霎那”的机制,让 JS 表现出与传统编译型语言截然不同的行为。


二、两个阶段:编译与执行的交响曲

2.1 编译阶段:准备舞台

在代码真正运行前,V8 会进行一次“预演”:

  • 检查语法错误
  • 识别所有变量和函数声明
  • 创建执行上下文(Execution Context)
  • 进行变量提升(Hoisting)

例如,对于以下代码:

showName();
console.log(myName);
var myName = '张三';
function showName() {
    console.log('函数被执行');
}

V8 在编译阶段会将其“重排”为:

// 编译阶段处理后(逻辑上)
var myName; // 提升为 undefined
function showName() { ... } // 函数声明整体提升

// 执行阶段
showName();        //  正常执行
console.log(myName); // undefined
myName = '张三';   // 赋值

这就是为什么 showName() 能在定义前调用,而 myName 输出 undefined 而非报错。

2.2 执行阶段:正式演出

当编译准备就绪,V8 开始逐行执行代码。此时:

  • 变量被赋予实际值
  • 函数被调用
  • 表达式被求值

关键点在于:编译只发生一次,执行可能多次(如函数被反复调用)。


三、执行上下文与调用栈:JS 的“内存剧场”

3.1 什么是执行上下文?

每次 JS 代码运行,都会创建一个执行上下文对象,它包含三个核心部分:

  1. 变量环境(Variable Environment) :存放 var 声明的变量和函数声明
  2. 词法环境(Lexical Environment) :存放 let/const 声明的变量(处于“暂时性死区”)
  3. this 绑定与作用域链信息

3.2 调用栈:函数执行的“舞台调度系统”

JS 使用**调用栈(Call Stack)**来管理函数的执行顺序:

  • 全局代码首先创建全局执行上下文,压入栈底
  • 每调用一个函数,就创建新的函数执行上下文,压入栈顶
  • 函数执行完毕,其上下文出栈,相关变量被垃圾回收
function A() { B(); }
function B() { C(); }
function C() { console.log('执行'); }
A(); // 调用栈:global → A → B → C → B → A → global

这种“后进先出”的结构,确保了函数执行的有序性和内存的高效回收。


四、var 与 let/const:提升规则的分水岭

4.1 var:宽松的“老派”声明

  • 变量提升:声明被提升到作用域顶部,初始化为 undefined
  • 允许重复声明var a = 1; var a = 2; 不报错
  • 函数优先级更高:函数声明比同名变量提升更彻底
console.log(a); // function a() {}
var a = 1;
function a() {}

4.2 let/const:严格的“现代”声明

  • 不提升值,但有“暂时性死区”(TDZ) :在声明前访问会报错
  • 禁止重复声明:同一作用域内不能重复声明
  • 存放在词法环境中,而非变量环境
console.log(b); //  ReferenceError: Cannot access 'b' before initialization
let b = 1;

这种设计避免了 var 时代因提升导致的意外行为,使代码更安全、可预测。


五、函数是一等公民:声明的特殊待遇

在 JavaScript 中,函数声明具有最高优先级。在编译阶段:

  1. 所有函数声明被完整提升(包括函数体)
  2. 变量声明次之(仅提升名称,值为 undefined
  3. 函数表达式不会提升
func(); //  TypeError: func is not a function
let func = () => { console.log('函数表达式不会提升'); };

这是因为 funclet 声明的变量,其值(函数表达式)在执行阶段才赋值。


结语:理解机制,写出更可靠的代码

JavaScript 的执行机制看似复杂,实则逻辑严密。通过理解编译阶段的提升行为执行上下文的创建过程以及调用栈的工作原理,我们不仅能解释那些“奇怪”的输出结果,更能写出更健壮、更高效的代码。

在下篇中,我们将深入探讨参数传递、值拷贝与引用拷贝的本质,以及严格模式如何改变变量行为,进一步揭开 JavaScript 内存模型的神秘面纱。

从 0 到上架:用 Flutter 一天做一款功德木鱼

作者 _大学牲
2025年11月7日 23:12

起因

那天我在 AppStore 晃荡寻求灵感,翻来翻去索然无味,看着工具榜单有个木鱼。
那我寻思就搜搜看...
一看,嚯!这小玩意也还是挺招人喜欢的。

于是打定主意,就 完整的做一整套上架到市场 玩玩,打通一下整个 App 上架流程是什么样的。

实践

一. 准备阶段

1. 软件设计

1.1 页面构思

1.2 开发语言

作为一个简单的 App,同时又是 跨平台 的。
因此我们需要:

  • 缩短开发时间
  • 提升开发效率
  • 一次开发,多平台使用

毫无疑问,我们选择了 Flutter, 尽管在 性能上必然不如原生,到那时对于自由开发者,优势在我

☘️ 什么是 Flutter ?

Flutter 是由 Google 推出的跨平台 UI 框架,具备同时开发 Android 与 iOS 应用的显著优势。首先,它>用 Dart 语言与自绘引擎(Skia),能够在两端实现一致的界面与高性能渲染,避免原生界面差异带来的适配问题。其次,Flutter 提供丰富的组件库和热重载(Hot Reload)功能,大幅提升开发效率和调试体验。相较于传统的双端分别开发,Flutter 可通过一套代码实现多平台部署,降低开发与维护成本。同时,其良好的社区生态和插件支持,方便集成相机、定位、蓝牙等原生功能,满足复杂业务需求。综合来看,Flutter 在性能、效率和统一体验方面兼具优势,是移动应用跨平台开发的理想方案。

2.素材收集


2.1 兄弟网站 借

浏览器上搜一搜,木鱼的网站还是很多的。
借一下它们的 图片音效 🙏。

截屏2025-11-07 15.56.13.png

截屏2025-11-07 16.02.48.png


2.1 专业网站 取

除了借,在这个 iconfont 网站上,还是有非常多的图标,我们也用上一用。

💡 在此也把这个网站分享给大家:www.iconfont.cn/

截屏2025-11-07 16.12.34.png


二. 开发阶段

截屏2025-11-07 17.00.49.png

话不多说,直接 Trae 启动!
虽说用trae很多基础代码文件,不需要手写了,甚至第一版也都是直接可用的。

但是 万丈高楼平地起,房子精装靠自己

接下来就介绍一下 核心模块

1. 敲木鱼

1.1 轻微缩放

🤔 你问我为什么保持缩放,因为只有这样别人才知道,你敲的是木鱼。

output1.gif

return GestureDetector(
  onTap: onTap,
  child: AnimatedScale(
    scale: tapped ? 0.9 : 1.0,
    duration: const Duration(milliseconds: 100),
    child: flipHorizontal
        ? Transform(
            alignment: Alignment.center,
            transform: Matrix4.identity()..scale(-1.0, 1.0, 1.0),
            child: base,
          )
        : base,
  ),
);
1.2 播放音效

👻 每一次伴随着点击,直触你灵魂的,是这音效。

Future<void> playKnock() async {
    try {
      // 获取选择音效
      final asset = StorageService().readAudioAsset();
      // 每次播放前设置当前音量
      final volume = StorageService().readMusicVolume();
      await _player.setVolume(volume);
      await _player.play(AssetSource(asset));
    } catch (_) {
      // 音频资源不存在或加载失败时忽略,不影响交互
    }
  }
1.3 漂浮文字

👅 漂浮的不是文字,而是文化的韵味。


固定位置,单浮现

固定位置,多浮现

起点变动,多浮现

始末双变,多浮现

多重文字 和 随机偏移值 对漂浮效果的影响


1.3.1 漂浮文字组件
/// 漂浮文字组件 —— 用于显示短暂浮动的文字效果(如“功德+1”、“点赞”等)
class FloatingText extends StatefulWidget {
  /// 是否显示该文字(true 时触发动画)
  final bool visible;

  /// 需要显示的文字内容
  final String text;

  const FloatingText({
    super.key,
    required this.visible,
    required this.text,
  });

  @override
  State<FloatingText> createState() => _FloatingTextState();
}

class _FloatingTextState extends State<FloatingText>
    with SingleTickerProviderStateMixin {
  /// 动画控制器 —— 控制动画时间、播放进度
  late final AnimationController _controller =
      AnimationController(
        vsync: this, // 使用当前 State 作为动画的 Ticker
        duration: const Duration(milliseconds: 1300), // 动画时长:1.3 秒
      );

  /// 位移动画 —— 让文字从下往上随机方向漂浮
  late final Animation<Offset> _offset = Tween(
    // 起点:x 在 [-0.5, 0.5] 之间随机,y 从 0.5 开始(相对位置)
    begin: Offset(Random().nextDouble() - 0.5, 0.5),

    // 终点:x 同样随机,y 向上漂浮(-5.0 到 -4.0 之间)
    end: Offset(Random().nextDouble() - 0.5, Random().nextDouble() - 5.0),
  ).animate(
    // 曲线动画,使用 easeOut(先快后慢)
    CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOut,
    ),
  );

  @override
  void initState() {
    super.initState();

    // 当 visible 为 true 时,组件创建后立即播放动画
    if (widget.visible) {
      _controller.forward(from: 0);
    }
  }

  @override
  void dispose() {
    // 释放动画资源,防止内存泄漏
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return IgnorePointer(
      // 忽略点击事件,不阻挡底层操作(比如用户点击其他区域)
      child: FadeTransition(
        // 使用控制器的值作为透明度动画
        opacity: _controller,
        child: SlideTransition(
          // 使用上面定义的偏移动画实现漂浮
          position: _offset,
          child: Text(
            widget.text,
            style: TextStyle(
              fontSize: 20,
              color: Theme.of(context)
                  .colorScheme
                  .primary
                  .withOpacity(0.5), // 半透明的主色文字
            ),
          ),
        ),
      ),
    );
  }
}
  • 随机漂浮方向
    使用 Random().nextDouble() - 0.5 生成随机的水平偏移,使文字每次漂浮方向略有不同,视觉上更自然。

  • 组合动画

    • FadeTransition 控制透明度(随时间逐渐消失)
    • SlideTransition 控制位移(从下往上飘)
      两者叠加形成“漂浮消散”的动画效果。
  • 生命周期控制

    • initState():检测 visible 是否为 true,如果是,则立即执行动画。
    • dispose():动画结束后释放控制器,避免内存泄漏。
  • 无交互干扰
    外层用 IgnorePointer,确保漂浮文字不拦截触摸事件(例如点击按钮或屏幕其他部分仍可响应)。


1.3.2 多重文字浮现
/// 漂浮文字的数据模型:用于在 UI 上显示诸如“功德+1”等文案
/// - `id`:唯一标识,用于定时移除和列表匹配
/// - `text`:显示的文案内容
class BlessItem {
  final int id;
  final String text;
  BlessItem(this.id, this.text);
}



/// 当前屏幕上正在显示的漂浮文字列表(响应式集合)
/// UI 通过 `Obx` 监听此列表并将每个项渲染成 `FloatingText`
final RxList<BlessItem> blessList = <BlessItem>[].obs;

/// 递增计数器,用于为每个 `BlessItem` 分配唯一的 `id`
/// 非持久化,仅用于本次会话的移除匹配(应用重启会归零)
int wc = 0;

/// 新增一个漂浮文字
void showBlessText(String text) {
final id = wc++;
blessList.add(BlessItem(id, text));

// 动画播放完自动移除(可根据 FloatingText 动画时长调整)
Future.delayed(const Duration(milliseconds: 2000), () {
  blessList.removeWhere((e) => e.id == id);
});
}

  • 使用 blessList 承载每次敲击生成的漂浮文字,每次调用 showBlessText(popupText.value) 都会向列表追加一个新项。
  • 通过 blessList.map(...) 将列表里的所有项都渲染出来,不会等旧的消失才渲染新的,因此同一时刻会出现多个 功德+1 同时漂浮。
  • 每个漂浮文字在 showBlessText 中被设定延迟 2000ms 后移除,在这 2 秒有效期内,所有新增的漂浮文字会并存。

2. 抽奖

抽奖 这也算是木鱼界一点点小小创新。
大家在敲击木鱼时有几率中奖,可以去抽奖页面抽皮肤。

2.1 多彩木鱼生成
/// 点击按钮后执行随机上色
void applyRandomColorize() {
  // 若原始图片不存在则直接返回
  if (_original == null) return;

  // 🎲 随机决定颜色数量(即渐变色数量)
  // 概率分布:
  //   1色:70%
  //   2色:15%
  //   3色:10%
  //   4色:5%
  final int roll = _random.nextInt(100);
  int count;
  if (roll < 70) {
    count = 1;
  } else if (roll < 85) {
    count = 2;
  } else if (roll < 95) {
    count = 3;
  } else {
    count = 4;
  }

  // 🎲 随机决定渐变方向:垂直 or 水平
  final bool isVertical = _random.nextBool();

  // 🎨 随机生成指定数量的亮色
  final List<Color> colors = List<Color>.generate(count, (_) => _genBright());

  // ⚙️ 调用图像处理函数,对白色区域进行上色
  final img.Image out = _colorizeWhite(
    _original!,
    stops: colors,
    vertical: isVertical,
  );

  // 将结果图片转为 PNG 格式的字节流,用于显示或保存
  final Uint8List bytes = Uint8List.fromList(img.encodePng(out));

  // 更新 UI 响应变量
  outputPng.value = bytes;      // 结果图像
  vertical.value = isVertical;  // 当前方向
  stops.assignAll(colors);      // 当前使用的渐变色
}

/// 随机生成亮色
Color _genBright() {
  // ch(min):生成 [min, 255] 范围内的随机数,用于控制亮度下限
  int ch(int min) => min + _random.nextInt(256 - min);

  int r = ch(80), g = ch(80), b = ch(80);

  // 随机挑选一个主色通道(R/G/B)强化,增加饱和度
  switch (_random.nextInt(3)) {
    case 0:
      r = ch(160);
      break;
    case 1:
      g = ch(160);
      break;
    default:
      b = ch(160);
  }

  // 返回随机亮色(ARGB 模式)
  return Color.fromARGB(255, r, g, b);
}

/// 白色区域上色
img.Image _colorizeWhite(
  img.Image src, {
  required List<Color> stops,
  required bool vertical,
}) {
  final int w = src.width;
  final int h = src.height;

  // 创建副本,防止修改原图
  final img.Image out = img.Image.from(src);

  final int denomX = (w - 1) <= 0 ? 1 : (w - 1);
  final int denomY = (h - 1) <= 0 ? 1 : (h - 1);

  /// 内部函数:根据 t 值采样渐变颜色
  Color sample(double t) {
    // 若只有一个颜色,直接返回
    if (stops.length == 1) return stops[0];

    // 渐变分段数量 = 颜色数 - 1
    final int segments = stops.length - 1;
    final double segLen = 1.0 / segments;

    // 当前 t 所在分段索引
    int idx = (t ~/ segLen);
    if (idx >= segments) idx = segments - 1;

    // 计算在当前分段内的相对位置 [0~1]
    final double localT = ((t - idx * segLen) / segLen).clamp(0.0, 1.0);

    // 线性插值计算颜色分量
    final Color a = stops[idx];
    final Color b = stops[idx + 1];
    final int rr = (a.red + (b.red - a.red) * localT).round();
    final int gg = (a.green + (b.green - a.green) * localT).round();
    final int bb = (a.blue + (b.blue - a.blue) * localT).round();
    return Color.fromARGB(255, rr, gg, bb);
  }

  // 遍历像素,检测是否为白色区域
  for (int y = 0; y < h; y++) {
    for (int x = 0; x < w; x++) {
      // t 用于控制渐变方向与进度
      final double t = vertical
          ? (y / denomY).clamp(0.0, 1.0)
          : (x / denomX).clamp(0.0, 1.0);

      final Color c = sample(t); // 取对应渐变颜色

      // 获取当前像素
      final img.Pixel p = out.getPixel(x, y);
      final int r = p.r.toInt();
      final int g = p.g.toInt();
      final int b = p.b.toInt();
      final int a = p.a.toInt();

      // 判断是否接近纯白
      final bool isWhite = a > 8 && r > 240 && g > 240 && b > 240;

      // 若是白色像素,则替换成渐变颜色
      if (isWhite) {
        out.setPixelRgba(x, y, c.red, c.green, c.blue, a);
      }
    }
  }

  return out;
}
步骤 说明
随机生成 1~4 种亮色
随机选择渐变方向(垂直或水平)
扫描图片每个像素,判断是否为接近白色的区域
对这些白色像素按渐变比例替换为对应的彩色像素
最后输出处理后的新图像(PNG 格式)
2.2 抽奖逻辑
  // 每 10000 次必出,其余每次 1% 概率触发抽取
 void _maybeTriggerDraw() {
   final total = _storage.readCounter();
   final guaranteed = total > 0 && total % 10000 == 0;
   final chance = Random().nextInt(1000) == 0; // 0.1%
   if (guaranteed || chance) {
     _storage.addSkin();
     Get.snackbar('恭喜!', '获取抽取皮肤次数+1 !');
   }
 }

三. 上架阶段

3.1 阿里云备案

3.2 苹果商店上架


阿里云 与 苹果 的备案上架,由于是第一次不太懂,也是麻烦多多。
好在最后也是克服问题, 服 🍎($99开发者账户)。


总结

木鱼 App 最终效果

这就是最终真机演示效果,app store 备案通过后也即将上线。

先给大家一个网页版,尝尝咸淡。

感谢大家支持🙏🙏🙏

在线爽敲🐠:muyu.shanchen.space/
个人门户🧑‍🎓:www.shanchen.com

❌
❌