普通视图

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

若依框架实现表格列按需显示功能

作者 独守一隅
2026年1月26日 11:01

一、项目背景

本文基于若依框架(RuoYi v3.9.1)进行二次开发,实现表格列的按需显示功能。若依框架是一个基于SpringBoot+Vue3前后端分离的Java快速开发框架,前端技术栈采用Vue3 + Element Plus + Vite。

技术栈版本信息

  • 框架版本:RuoYi v3.9.1
  • Vue版本:3.5.16
  • Element Plus版本:2.10.7
  • Vite版本:6.3.5

二、问题描述

在若依框架的优惠券管理模块中,默认显示所有表格列,包括序号、优惠券名称、面值、有效期开始、有效期结束、状态、使用说明等。但在实际业务场景中,用户可能只需要关注部分字段,过多的列会影响表格的可读性和用户体验。

参考若依框架内置的用户管理页面,发现该页面已经实现了表格列的按需显示功能,用户可以通过右上角的列配置按钮自由选择要显示的列。因此,我们需要在优惠券管理页面中实现相同的功能。

三、解决方案

通过分析用户管理页面的实现方式,我们发现若依框架已经内置了列显示/隐藏的组件RightToolbar,只需要进行以下三步改造:

  1. RightToolbar组件中传入columns属性
  2. 定义列配置对象columns
  3. 为每个表格列添加v-if条件控制显示/隐藏

四、实现步骤

步骤1:修改RightToolbar组件,传入columns属性

在优惠券管理页面的工具栏区域,找到right-toolbar组件,添加:columns="columns"属性。

修改位置src/views/feature/coupon/index.vue 第92行

修改前

<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>

修改后

<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>

步骤2:定义columns响应式对象

在script setup部分,定义columns响应式对象,配置每个列的标签和显示状态。

修改位置src/views/feature/coupon/index.vue 第187-193行

添加代码

const columns = ref({
  couponName: { label: '优惠券名称', visible: true },
  couponValue: { label: '面值', visible: true },
  startTime: { label: '有效期开始', visible: true },
  endTime: { label: '有效期结束', visible: true },
  status: { label: '状态', visible: true },
  remark: { label: '使用说明', visible: true }
})

说明

  • 每个属性对应一个表格列的key
  • label属性用于在列配置面板中显示列名
  • visible属性控制列的默认显示状态(true为显示,false为隐藏)

步骤3:为表格列添加v-if条件

为每个需要控制显示/隐藏的表格列添加v-if条件,绑定到columns对象中对应列的visible属性。

修改位置src/views/feature/coupon/index.vue 第97-115行

修改前

<el-table-column label="优惠券名称" align="center" prop="couponName" />
<el-table-column label="面值" align="center" prop="couponValue" />
<el-table-column label="有效期开始" align="center" prop="startTime" width="180">
  <template #default="scope">
    <span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d}') }}</span>
  </template>
</el-table-column>
<el-table-column label="有效期结束" align="center" prop="endTime" width="180">
  <template #default="scope">
    <span>{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}</span>
  </template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status">
  <template #default="scope">
    <dict-tag :options="common_status" :value="scope.row.status"/>
  </template>
</el-table-column>
<el-table-column label="使用说明" align="center" prop="remark" />

修改后

<el-table-column label="优惠券名称" align="center" prop="couponName" v-if="columns.couponName.visible" />
<el-table-column label="面值" align="center" prop="couponValue" v-if="columns.couponValue.visible" />
<el-table-column label="有效期开始" align="center" prop="startTime" width="180" v-if="columns.startTime.visible">
  <template #default="scope">
    <span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d}') }}</span>
  </template>
</el-table-column>
<el-table-column label="有效期结束" align="center" prop="endTime" width="180" v-if="columns.endTime.visible">
  <template #default="scope">
    <span>{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}</span>
  </template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" v-if="columns.status.visible">
  <template #default="scope">
    <dict-tag :options="common_status" :value="scope.row.status"/>
  </template>
</el-table-column>
<el-table-column label="使用说明" align="center" prop="remark" v-if="columns.remark.visible" />

注意

  • 序号列和操作列不需要添加v-if条件,因为它们应该始终显示
  • 复选框列(type="selection")也不需要添加v-if条件

五、完整代码示例

以下是优惠券管理页面的完整代码,重点标注了修改部分:

<template>
  <div class="app-container">
    <!-- 搜索表单区域 -->
    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="88px">
      <!-- ... 搜索表单内容 ... -->
    </el-form>

    <!-- 操作按钮栏 -->
    <el-row :gutter="10" class="mb8">
      <!-- ... 操作按钮 ... -->
      <!-- 修改点1:添加columns属性 -->
      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>
    </el-row>

    <!-- 数据表格 -->
    <el-table v-loading="loading" :data="couponList" @selection-change="handleSelectionChange">
      <el-table-column type="selection" width="55" align="center" />
      <el-table-column label="序号" type="index" align="center" width="80" :index="indexMethod" />
      
      <!-- 修改点2:为每个列添加v-if条件 -->
      <el-table-column label="优惠券名称" align="center" prop="couponName" v-if="columns.couponName.visible" />
      <el-table-column label="面值" align="center" prop="couponValue" v-if="columns.couponValue.visible" />
      <el-table-column label="有效期开始" align="center" prop="startTime" width="180" v-if="columns.startTime.visible">
        <template #default="scope">
          <span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d}') }}</span>
        </template>
      </el-table-column>
      <el-table-column label="有效期结束" align="center" prop="endTime" width="180" v-if="columns.endTime.visible">
        <template #default="scope">
          <span>{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}</span>
        </template>
      </el-table-column>
      <el-table-column label="状态" align="center" prop="status" v-if="columns.status.visible">
        <template #default="scope">
          <dict-tag :options="common_status" :value="scope.row.status"/>
        </template>
      </el-table-column>
      <el-table-column label="使用说明" align="center" prop="remark" v-if="columns.remark.visible" />
      
      <!-- 操作列始终显示 -->
      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
        <template #default="scope">
          <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['feature:coupon:edit']">修改</el-button>
          <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['feature:coupon:remove']">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页组件 -->
    <pagination
      v-show="total>0"
      :total="total"
      v-model:page="queryParams.pageNum"
      v-model:limit="queryParams.pageSize"
      @pagination="getList"
    />

    <!-- 对话框 -->
    <el-dialog :title="title" v-model="open" width="500px" append-to-body>
      <!-- ... 对话框内容 ... -->
    </el-dialog>
  </div>
</template>

<script setup name="Coupon">
import { listCoupon, getCoupon, delCoupon, addCoupon, updateCoupon } from "@/api/feature/coupon"

const { proxy } = getCurrentInstance()
const { common_status } = proxy.useDict('common_status')

// 修改点3:定义columns响应式对象
const columns = ref({
  couponName: { label: '优惠券名称', visible: true },
  couponValue: { label: '面值', visible: true },
  startTime: { label: '有效期开始', visible: true },
  endTime: { label: '有效期结束', visible: true },
  status: { label: '状态', visible: true },
  remark: { label: '使用说明', visible: true }
})

const couponList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")

const data = reactive({
  form: {},
  queryParams: {
    pageNum: 1,
    pageSize: 10,
    couponName: null,
    couponValue: null,
    startTime: null,
    endTime: null,
    status: null,
  },
  rules: {
  }
})

const { queryParams, form, rules } = toRefs(data)

/** 序号计算方法 */
function indexMethod(index) {
  return (queryParams.value.pageNum - 1) * queryParams.value.pageSize + index + 1
}

/** 查询优惠券管理列表 */
function getList() {
  loading.value = true
  listCoupon(queryParams.value).then(response => {
    couponList.value = response.rows
    total.value = response.total
    loading.value = false
  })
}

// ... 其他方法 ...
</script>

六、功能说明

实现完成后,优惠券管理页面将具备以下功能:

  1. 列配置按钮:在表格右上角工具栏中会显示一个列配置图标
  2. 列显示/隐藏:点击列配置按钮,会弹出列配置面板,用户可以勾选或取消勾选要显示的列
  3. 实时生效:勾选状态变化后,表格列会立即显示或隐藏,无需刷新页面
  4. 默认全部显示:所有列默认visible: true,用户可以根据需要隐藏不需要的列
  5. 固定列:序号列、复选框列和操作列始终显示,不受列配置控制

七、扩展应用

这个功能可以轻松应用到若依框架的其他列表页面中,只需按照上述三个步骤进行修改:

  1. right-toolbar组件中添加:columns="columns"属性
  2. 定义columns响应式对象,配置需要控制的列
  3. 为对应的表格列添加v-if="columns.列名.visible"条件

八、注意事项

  1. 列名一致性columns对象中的属性名必须与表格列的prop属性保持一致
  2. 默认显示状态:根据业务需求设置visible的默认值,通常建议默认显示所有列
  3. 固定列:序号列、复选框列和操作列等关键列不建议添加显示/隐藏控制
  4. 响应式数据columns必须使用ref定义,确保响应式更新

九、总结

通过参考若依框架内置的用户管理页面,我们成功为优惠券管理页面添加了表格列按需显示功能。该实现方式简单高效,充分利用了若依框架已有的组件和功能,避免了重复开发。用户可以根据自己的需求自由选择要显示的列,提升了表格的可读性和用户体验。

这种实现方式具有良好的可复用性和扩展性,可以快速应用到其他类似的列表页面中,是若依框架二次开发中的一个实用技巧。

n8n文件写入权限问题的深度诊断与解决方案:一次完整的技术排查实录

作者 code小白
2026年1月26日 11:00

n8n文件写入权限问题的深度诊断与解决方案:一次完整的技术排查实录

引言:当自动化工作流遭遇文件系统壁垒

在现代企业自动化架构中,n8n作为一款强大的工作流自动化工具,承担着连接各种服务和系统的重任。然而,当我们在本地环境中部署n8n并尝试进行文件操作时,常常会遇到看似简单实则复杂的权限问题。本文通过一个真实案例的完整排查过程,深入剖析n8n文件写入失败的技术根源,并提供系统化的解决方案。

案例背景:自动化RSS采集工作流的意外中断

1.1 问题场景描述

用户搭建了一个自动化技术资讯采集工作流,核心流程包括:

  • 从Wired RSS源获取最新技术文章
  • 通过n8n的"Read/Write Files from Disk"节点将数据写入本地Markdown文件
  • 后续进行数据分析和内容处理

1.2 故障表现

工作流在执行到文件写入节点时持续失败,错误信息明确显示:

复制
NodeApiError: The file "C:\Users\86147\Desktop\workspace2\lesson_zp\ai\n8n\tech\tech.md" is not writable.

技术诊断:多层次权限问题的系统排查

2.1 初级排查:基础权限验证

第一层诊断:文件存在性与基本权限

powershell
powershell
复制
# 文件存在性检查
Test-Path "C:\Users\86147\Desktop\workspace2\lesson_zp\ai\n8n\tech\tech.md"

# 基本权限检查
icacls "C:\Users\86147\Desktop\workspace2\lesson_zp\ai\n8n\tech\tech.md"

# 结果:文件存在,权限配置正常

发现:文件确实存在,且用户的Windows账户具有完全控制权限。这排除了最基础的权限问题。

2.2 中级排查:运行上下文与文件锁定

第二层诊断:n8n运行环境分析

通过创建诊断Function节点,我们获得了以下关键信息:

javascript
javascript
下载
复制
// 环境诊断结果
{
  "environment": {
    "currentDirectory": "C:\Program Files\nodejs",  // 问题线索!
    "userInfo": "86147",
    "nodeProcess": {
      "pid": 1234,
      "platform": "win32",
      "arch": "x64"
    }
  },
  "fileSystem": {
    "techMdExists": true,
    "directoryExists": true,
    "directoryWritable": true
  }
}

关键发现:n8n的工作目录与其试图写入的文件路径不匹配。这是Windows环境下常见的路径解析问题。

第三层诊断:文件锁定状态检查

powershell
powershell
复制
# 使用PowerShell检查文件锁定状态
$filePath = "C:\Users\86147\Desktop\workspace2\lesson_zp\ai\n8n\tech\tech.md"

try {
    $fileStream = [System.IO.File]::Open($filePath, 'Open', 'Write', 'None')
    $fileStream.Close()
    Write-Host "文件未被锁定"
} catch {
    Write-Host "文件被以下进程锁定:" 
    # 通过Process Explorer发现VSCode持有文件句柄
}

问题根源的多维度分析

3.1 技术架构层面的根本原因

原因一:Node.js文件系统API的异步特性

n8n基于Node.js开发,其文件系统操作具有异步特性。当多个节点尝试同时访问同一文件时,可能出现竞态条件。

原因二:Windows文件锁定机制

Windows操作系统对打开的文件实施严格的锁定策略。当文件被文本编辑器(如VSCode)打开时,即使只是读取模式,也可能阻止其他进程的写入操作。

原因三:n8n的工作目录配置

n8n默认的工作目录可能与实际文件路径存在不一致,导致相对路径解析错误。

原因四:二进制数据模式配置

从错误信息中的"binaryDataMode": "filesystem"可以看出,n8n使用了文件系统模式处理二进制数据,这需要特定的存储路径配置。

3.2 用户操作层面的诱发因素

  1. 开发与运行环境混合:在同一个目录中同时进行开发和运行自动化工作流
  2. 文件长期打开:使用VSCode等编辑器保持工作流配置文件长时间打开
  3. 路径配置不一致:在工作流中使用了混合路径格式(正斜杠/反斜杠)

系统性解决方案矩阵

4.1 立即修复方案

方案A:强制释放文件锁并重试

javascript
javascript
下载
复制
// 在n8n工作流中添加的重试逻辑
async function safeWriteWithRetry(filePath, content, maxRetries = 3) {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
            const fs = require('fs').promises;
            await fs.writeFile(filePath, content, 'utf8');
            return { success: true, attempt };
        } catch (error) {
            if (attempt === maxRetries) throw error;
            
            // 指数退避策略
            await new Promise(resolve => 
                setTimeout(resolve, Math.pow(2, attempt) * 100)
            );
            
            // 尝试强制释放锁(Windows特定)
            if (process.platform === 'win32') {
                try {
                    require('child_process').execSync(
                        `powershell -Command "Get-Process | Where-Object {$_.Path -like '*${filePath}*'} | Stop-Process -Force"`,
                        { stdio: 'ignore' }
                    );
                } catch (e) {
                    // 忽略释放失败
                }
            }
        }
    }
}

方案B:使用临时文件中转

javascript
javascript
下载
复制
// 避免直接写入目标文件
const tempFilePath = filePath + '.tmp';
fs.writeFileSync(tempFilePath, content, 'utf8');
fs.renameSync(tempFilePath, filePath);

4.2 中期优化方案

重新设计工作流架构

复制
原始设计:
RSS源 → 数据处理 → 写入tech.md

优化设计:
RSS源 → 数据处理 → 写入临时文件 → 验证 → 重命名为目标文件
         ↓
     错误处理 → 日志记录 → 告警通知

配置标准化

javascript
javascript
下载
复制
// 创建统一的路径管理模块
class FilePathManager {
    constructor(basePath) {
        this.basePath = basePath || 
            process.env.N8N_DATA_PATH || 
            "C:\n8n_workspace";
    }
    
    getPath(type, filename) {
        const paths = {
            data: `${this.basePath}\data\${filename}`,
            temp: `${this.basePath}\temp\${filename}`,
            logs: `${this.basePath}\logs\${filename}`
        };
        return paths[type] || paths.data;
    }
    
    ensureDirectory(filePath) {
        const dir = require('path').dirname(filePath);
        if (!require('fs').existsSync(dir)) {
            require('fs').mkdirSync(dir, { recursive: true });
        }
    }
}

4.3 长期预防方案

Docker容器化部署

dockerfile
dockerfile
复制
# Docker Compose配置示例
version: '3.8'
services:
  n8n:
    image: n8nio/n8n
    container_name: n8n_automation
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - N8N_PROTOCOL=https
      - NODE_ENV=production
    volumes:
      - n8n_data:/home/node/.n8n
      - ./local_data:/data
    user: "1000:1000"  # 明确的用户ID,避免权限问题

配置管理自动化

powershell
powershell
复制
# 自动化权限设置脚本
$n8nUser = "n8n_service"
$dataPath = "C:\n8n_data"

# 创建专用目录
New-Item -ItemType Directory -Path $dataPath -Force

# 设置权限
$acl = Get-Acl $dataPath
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
    $n8nUser, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow"
)
$acl.SetAccessRule($rule)
Set-Acl $dataPath $acl

行业最佳实践总结

5.1 文件操作安全准则

  1. 最小权限原则:为n8n创建专用服务账户,仅授予必要目录的访问权限
  2. 隔离性原则:将运行文件、配置文件和数据文件分离存储
  3. 原子性原则:通过"写入临时文件+重命名"确保操作的原子性
  4. 可追溯原则:所有文件操作都应记录详细日志

5.2 n8n工作流设计规范

javascript
javascript
下载
复制
// 标准化的文件操作模板
const fs = require('fs').promises;
const path = require('path');

class SafeFileWriter {
    constructor(options = {}) {
        this.maxRetries = options.maxRetries || 3;
        this.retryDelay = options.retryDelay || 100;
    }
    
    async write(filePath, content) {
        const tempPath = filePath + '.writing';
        const backupPath = filePath + '.backup';
        
        try {
            // 1. 写入临时文件
            await fs.writeFile(tempPath, content, 'utf8');
            
            // 2. 备份原文件(如果存在)
            try {
                await fs.copyFile(filePath, backupPath);
            } catch (e) {
                // 文件不存在,无需备份
            }
            
            // 3. 原子性替换
            await fs.rename(tempPath, filePath);
            
            // 4. 清理备份
            try {
                await fs.unlink(backupPath);
            } catch (e) {
                // 忽略清理失败
            }
            
            return { success: true };
        } catch (error) {
            // 5. 错误处理和恢复
            try {
                await fs.rename(backupPath, filePath);
            } catch (e) {
                // 恢复失败
            }
            
            throw error;
        }
    }
}

5.3 监控与告警体系

建立多层级的监控体系:

  1. 文件系统监控:监控目标目录的可用空间和权限状态
  2. 进程监控:监控n8n进程的运行状态和资源使用
  3. 业务监控:监控工作流的执行成功率和处理时延
  4. 自动告警:通过Webhook集成到Teams/Slack/钉钉

技术深度思考

6.1 跨平台兼容性挑战

n8n作为跨平台工具,在不同操作系统上的文件系统行为存在显著差异:

操作系统 文件锁定策略 路径分隔符 权限模型
Windows 严格锁定,共享控制 `` ACL访问控制列表
Linux 宽松锁定,基于inode / POSIX权限位
macOS 介于两者之间 / POSIX + ACL混合

6.2 云原生环境下的演进

随着工作流向云端迁移,新的解决方案正在涌现:

yaml
yaml
复制
# Kubernetes环境下的n8n部署
apiVersion: apps/v1
kind: Deployment
metadata:
  name: n8n
spec:
  template:
    spec:
      containers:
      - name: n8n
        image: n8nio/n8n:latest
        volumeMounts:
        - name: data
          mountPath: /home/node/.n8n
        - name: shared
          mountPath: /shared
        securityContext:
          runAsUser: 1000
          runAsGroup: 1000
      volumes:
      - name: data
        persistentVolumeClaim:
          claimName: n8n-data
      - name: shared
        nfs:
          server: nfs-server
          path: /shared

结论:从技术问题到系统思维

通过这个n8n文件写入权限问题的完整排查过程,我们看到了一个简单错误背后复杂的系统性问题。问题的解决不仅需要技术手段,更需要系统思维:

  1. 层级化诊断:从表象到根源,层层深入
  2. 多维度解决:技术方案、流程优化、架构设计相结合
  3. 预防性思维:建立规范,防患于未然
  4. 可观测性:完善的监控和日志体系是快速定位问题的关键

在自动化时代,工具的使用效率直接决定了业务的价值实现速度。通过对这类"小问题"的深入研究和系统解决,我们不仅修复了当前的工作流,更为构建稳定、可靠的自动化体系奠定了坚实基础。

最终启示:在数字化和自动化进程中,最大的挑战往往不是技术本身的复杂性,而是对系统运行环境和上下文理解的深度。每一次故障排除,都是对系统认知的一次深化,也是对技术架构的一次优化机会。

BFC (Block Formatting Context) 详解

作者 娜妹子辣
2026年1月26日 10:54

BFC(Block Formatting Context,块级格式化上下文)  是 CSS 渲染中的一个核心概念,它决定了块级元素如何布局、如何与兄弟/子元素交互理解 BFC 能解决很多经典布局问题(如浮动塌陷、外边距重叠等)。

🧠 一、什么是 BFC?

BFC 是 Web 页面中一个独立的渲染区域,内部的块级元素按照特定规则布局,且不会与外部元素相互影响

你可以把它想象成一个“隔离的盒子”:

  • 盒子内部的元素按规则排列
  • 盒子外部的元素不会干扰内部
  • 盒子也不会被外部元素干扰

二、BFC 的触发条件(如何创建 BFC?)

触发方式 是否常用 副作用 兼容性 推荐度 适用场景
display: flow-root ✅ 现代首选 ❌ 无 Chrome 58+ / FF 53+ / Safari 10.1+ ⭐⭐⭐⭐⭐ 通用场景(现代浏览器)
overflow: hidden/auto/scroll ✅ 广泛使用 ⚠️ 可能裁剪内容 ✅ 全浏览器 ⭐⭐⭐⭐ 需要兼容旧浏览器时
float: left/right ⚠️ 逐渐淘汰 ⚠️ 脱离文档流 ✅ 全浏览器 ⭐⭐ 传统浮动布局(不推荐新项目)
position: absolute/fixed ⚠️ 特定场景 ⚠️ 脱离文档流 ✅ 全浏览器 ⭐⭐ 定位元素(非布局目的)
display: inline-block ⚠️ 有局限 ⚠️ 行内块特性(间隙、基线对齐) ✅ 全浏览器 ⭐⭐ 行内布局(非块级容器)
display: table-cell ⚠️ 少用 ⚠️ 表格布局行为 ✅ 全浏览器 模拟表格(不推荐)
display: flex/grid ✅ 现代布局 ⚠️ 改变子元素布局模型 ✅ 现代浏览器 ⭐⭐⭐ 本身是布局容器时
contain: layout/style/paint ⚠️ 新特性 ⚠️ 性能隔离(可能影响渲染) Chrome 52+ / FF 69+ ⭐⭐ 性能优化场景

三、BFC 的核心作用(解决什么问题?)

场景一:父容器包含浮动子元素(解决父容器高度塌陷

html
运行html
<div class="parent">
  <div class="child" style="float: left">Child 1</div>
  <div class="child" style="float: left">Child 2</div>
</div>
方案 代码 优点 缺点
flow-root .parent { display: flow-root; } 无副作用、语义清晰 不支持 IE
overflow: hidden .parent { overflow: hidden; } 全兼容 可能裁剪阴影
::after clearfix .parent::after { content:""; display:table; clear:both } 全兼容、无裁剪 代码冗余
flex .parent { display: flex; } 现代、自动 BFC 改变子元素布局

场景二:解决Margin折叠

<div class="box1">Box 1</div> 
<div class="box2">Box 2</div>
方案 CSS 代码 是否生效 副作用 推荐度
无处理 ❌ 折叠(间距=30px)
flow-root .box2 { display: flow-root; } ✅ 间距=50px ❌ 无 ⭐⭐⭐⭐⭐
overflow: hidden .box2 { overflow: hidden; } ✅ 间距=50px ⚠️ 可能裁剪内容 ⭐⭐⭐
float: left .box2 { float: left; width: 100%; } ✅ 间距=50px ⚠️ 脱离文档流
position: absolute .box2 { position: absolute; } ✅ 但布局错乱 ⚠️ 完全脱离流
inline-block .box2 { display: inline-block; width: 100%; } ✅ 间距=50px ⚠️ 行内块间隙/对齐问题 ⭐⭐
flex 容器 给父容器加 display: flex; flex-direction: column; ✅ 间距=50px ⚠️ 改变子元素布局模型 ⭐⭐⭐

场景3:自适应两栏布局

<!-- 两栏布局:左侧固定,右侧自适应 -->
<div class="layout">
  <div class="sidebar">侧边栏</div>
  <div class="content">主内容区域</div>
</div>
/* 传统方法的问题 */
.sidebar {
  float: left;
  width: 200px;
  background: #ff6b6b;
  height: 300px;
}

.content {
  /* 问题:内容会被浮动元素覆盖 */
  background: #4ecdc4;
  height: 300px;
}

/* BFC解决方案 */
.content-bfc {
  overflow: hidden; /* 创建BFC,避开浮动元素 */
  background: #4ecdc4;
  height: 300px;
  /* BFC区域不会与float box重叠 */
}

场景4:防止元素被浮动元素覆盖

<!-- 文字环绕问题 -->
<div class="container">
  <div class="float-box">浮动盒子</div>
  <div class="text-content">
    这是一段很长的文字内容,在没有BFC的情况下,
    文字会环绕浮动元素。但有时我们希望文字内容
    独立成块,不被浮动元素影响。
  </div>
</div>
.float-box {
  float: left;
  width: 100px;
  height: 100px;
  background: #ff6b6b;
  margin-right: 10px;
}

/* 问题:文字环绕浮动元素 */
.text-content {
  background: #f0f0f0;
  padding: 10px;
}

/* 解决方案:创建BFC */
.text-content-bfc {
  background: #f0f0f0;
  padding: 10px;
  overflow: hidden; /* 创建BFC,不与浮动元素重叠 */
}

四、BFC vs IFC vs FFC

全屏复制

上下文 全称 触发条件 用途
BFC Block Formatting Context overflow: hiddendisplay: flow-root 等 块级布局、解决浮动塌陷
IFC Inline Formatting Context 行内元素、display: inline 文本、行内元素布局
FFC Flex Formatting Context display: flex 弹性布局
GFC Grid Formatting Context display: grid 网格布局

代码 Review 的艺术:如何委婉地告诉同事你写的代码是一坨屎?

作者 ErpanOmer
2026年1月26日 10:51

1_HEyuDA6Y8gOkNaHMPoU_ew.png

有一天下午四点,我点开了同事刚提的一个 PR, 先上代码👇:

// PR

async function handle(list) {
  const list1 = []
  let temp = null
  const a = 1

  for (let i = 0; i < list.length; i++) {
    if (list[i]) {
      if (list[i].status) {
        if (list[i].status === 'active') {
          if (list[i].type) {
            if (list[i].type === 'user') {
              // 在 for 里 await 查库
              temp = await db.query(
                'SELECT * FROM users WHERE id = ?',
                [list[i].id]
              )

              if (temp && temp.length > 0) {
                list1.push(temp[0])
              }
            } else {
              if (a === 1) {
                // 什么都不干
              }
            }
          }
        }
      }
    }
  }

  return list1
}

第一眼,还行。
第二眼,嗯?
第三眼,我开始怀疑人生。

变量叫 atemplist1if-else 套了六层,在 forawait 查库,像一锅没加盖的乱炖。

我当时脑子里只有一个念头:
这玩意要是上线了,今晚谁都别睡😡😡😡。

但问题是——
你不能在评论区直接敲一句🤔:

这段代码太烂了,建议重写。

你可以这么想,但不能这么说。
因为你不是在写技术博客,你是在上班😖。

Code Review 本来是为了让项目更稳,结果很多时候,反而成了团队关系的坟头。

后来我慢慢意识到一件事:
你要做的,不是指出问题,而是—— 让对方愿意帮你把问题改掉。


先说个核心原则:别对人下手,先对代码下手

CR 最容易翻车的点,就一个字:

你这里写错了。你为什么不判空。你这逻辑不太行。

只要你用了-,对方心里的防御盾基本就举起来了😒。

更聪明的做法,是把对象换掉。

  • 换成我们
  • 换成这段代码
  • 换成如果这个场景
  • 换成我们下次如何防范

比如:

我们这里可能要考虑一下空变量的情况。这段逻辑在并发高的时候,可能会有点风险,这个写法在数据量大的时候,性能可能撑不住的

意思一点没变,但攻击性直接砍半。 敌人是 Bug,不是你的同事😃。


一些能救命的 CR 话术(真的可以试试)

变量名随便起,看不懂

你心里想的是:

list1 到底是个啥?

但你可以这么说:

这个 list1 里装的主要是什么数据来着?
要不要换个更具体点的名字,比如 activeUserList
这样后面维护的人(包括未来的我们)会省不少事。

重点不在你命名不行,而是以后接受的人会感谢你的🤔。


if-else 套到你头皮发麻

别说逻辑太乱了。

你可以说:

这里我看了一下,逻辑稍微有点绕,我读了两遍才理顺。
要不要考虑用提前 return 的方式拆一下?
缩进少一点,后面看起来也轻松些。

先承认是我读得有点费劲
再给建议,
最后用商量的语气收尾。

对方台阶都有了,基本不会炸。


明显的性能隐患

比如循环里查库、循环里调接口。

可以这样:

这个写法在当前数据量下应该没啥问题。
我主要有点担心,如果后面数据上来,接口时间会不会被拉长。
要不这里改成批量查或者并发一下?稳一点。

这句话的潜台词只有一个: 我是在给项目兜底🥱。


完全看不懂他在干嘛?

这时候千万别装懂。

你可以说:

这块我好像没太跟上你的思路,
能不能简单说下这里为什么要这么处理?
或者加点注释也行,我怕下次有人接手会有点懵。

很多时候,对方在解释的过程中,
自己就会发现问题。

你甚至都不用再补刀的。


顺便说一句:别做格式检查!

CR 不是让你盯着:

  • 少没少空格
  • 单引号还是双引号
  • 换行对不对
  • 有没有写注释
  • 有没有加 commit 标签

你在 PR 里留 20 条这种评论,
对方只会想一件事:
这人是不是有点烦😥。

格式这种事,交给工具就完了😖。

ESLint、Prettier、Husky,甚至是AI整理代码
能解决 80% 的低级争吵。

如果真的乱到影响阅读,可以一句话带过:

这一块缩进好像有点乱,
跑一下 lint 自动修一下就行,
我们主要还是看下逻辑。

点到为止😒。


真到必须重写的时候,别在评论区硬刚

有些代码,确实已经到了不改不行的程度。
但就算这样,也别在 PR 评论区写小作文。

人多的地方,没人愿意认输。

更好的方式是:

私聊?语音?当面?

开场也别太重:

刚看了下那个 PR,有一块我有点担心,咱们对一下怎么样?

把风险讲清楚,
把后果讲明白,
共识达成了,再让他自己改。

给成年人留面子,成本真的很低的😁。


说个实在的

Code Review 从来都不是,谁技术更牛谁说了算的。你今天指出的问题, 很可能是为了防止哪天半夜两点, 把他从被窝里拽起来修 Bug。

这不是找茬,这是在剩你的时间。

所以以后 CR 的时候:

  • 少一点命令
  • 多一点商量
  • 少一点你怎么这样?
  • 多一点我们是不是可以?

代码是冷的,但一起写代码的人,最好别凉😃。

1_bh0C57IWRr6orsCDJXCFDA_abgbhi.webp

灵感来自于上面👆这本书,希望对大家有帮助😁。 也整理出来了一些想法,望大家喜欢 👉 为什么我开始减少逛技术社区,而是去读非技术的书?

谢谢大家.gif


详解Metadata 和 Reflector

作者 前端付豪
2026年1月26日 10:51

装饰器声明后,启动 Nest 应用,对象会创建好,依赖也给注入了,为啥 ?

先看 Reflect 的 metadata

Reflect.get 是获取对象属性值

image.png

Reflect.set 是设置对象属性值 image.png

Reflect.has 是判断对象属性是否存在

image.png

Reflect.apply 是调用某个方法,传入对象和参数

image.png

Reflect.construct 是用构造器创建对象实例,传入构造器参数

image.png

Nest 用到的 api

Reflect.defineMetadata(metadataKey, metadataValue, target);

Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);


let result = Reflect.getMetadata(metadataKey, target);

let result = Reflect.getMetadata(metadataKey, target, propertyKey);

Reflect.defineMetadata 和 Reflect.getMetadata 分别用于设置和获取某个类的元数据,如果最后传入了属性名,还可以单独为某个属性设置元数据

那元数据存在哪?

存在类或者对象上,如果给类或者类的静态属性添加元数据,那就保存在类上

如果给实例属性添加元数据,那就保存在对象上,用类似 [[metadata]] 的 key 来存

支持装饰器的方式使用:

@Reflect.metadata(metadataKey, metadataValue)
class C {

  @Reflect.metadata(metadataKey, metadataValue)
  method() {
  }
}

基础封装

function Meta(metadataKey: string, metadataValue: any) {
  return Reflect.metadata(metadataKey, metadataValue)
}

实际例子

function Validate() {
  return function (target, propertyKey, descriptor) {
    const types = Reflect.getMetadata(
      'design:paramtypes',
      target,
      propertyKey
    )

    const original = descriptor.value

    descriptor.value = function (...args) {
      args.forEach((arg, index) => {
        const Type = types[index]
        if (!(arg instanceof Type) && typeof arg !== Type.name.toLowerCase()) {
          throw new Error(
            `参数 ${index} 类型错误,期望 ${Type.name}`
          )
        }
      })
      return original.apply(this, args)
    }
  }
}

使用

class MathService {

  @Validate()
  add(a: number, b: number) {
    return a + b
  }
}

new MathService().add(1, 2)     // ✅
new MathService().add('1', 2)   // ❌

Nest 的实现原理就是通过装饰器给 class 或者对象添加元数据,然后初始化的时候取出这些元数据,进行依赖的分析,然后创建对应的实例对象就可以了。

所以说,nest 实现的核心就是 Reflect metadata 的 api

安装依赖 & 打开配置

npm i reflect-metadata

tsconfig.json

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

入口文件最顶部:

import 'reflect-metadata'

自己写一个装饰器

function MyController(path: string): ClassDecorator {
  return (target) => {
    Reflect.defineMetadata('path', path, target)
  }
}

这一行是核心:

Reflect.defineMetadata(key, value, target)

👆 把 path 这个信息,挂到 class 上

使用这个装饰器

@MyController('/user')
class UserController {}

运行时把元数据读出来

const path = Reflect.getMetadata('path', UserController)
console.log(path) // /user

🔑 到这里你已经实现了 Nest 的第一步

Nest 的 Controller / Injectable 本质

Controller 装饰器 ≈

@Controller('/user')
class UserController {}

等价于:

Reflect.defineMetadata('path', '/user', UserController)

Injectable 装饰器 ≈

@Injectable()
class UserService {}

等价于:

Reflect.defineMetadata('injectable', true, UserService)

依赖注入是怎么“自动知道”的?

关键:构造函数参数的类型元数据

@Injectable()
class UserService {}
@Injectable()
class UserController {
  constructor(private userService: UserService) {}
}

TypeScript 在背后偷偷干了什么?

开启 emitDecoratorMetadata 后:

Reflect.getMetadata('design:paramtypes', UserController)

得到:

[ UserService ]

💥 这一步是 Nest DI 的灵魂


import 'reflect-metadata'

@Injectable()
class UserService {}

@Injectable()
class UserController {
  constructor(private userService: UserService) {}
}

const deps = Reflect.getMetadata(
  'design:paramtypes',
  UserController
)

console.log(deps)
// [ [class UserService] ]

Nest 初始化时到底在干嘛?

可以理解成这几步👇

function bootstrap() {
  // 1. 扫描所有 class
  // 2. 找 @Controller / @Injectable
  // 3. 读取元数据
  // 4. 分析依赖
  // 5. new 实例
}

极简 DI 容器

const container = new Map()

function createInstance(target) {
  const deps =
    Reflect.getMetadata('design:paramtypes', target) || []

  const params = deps.map(dep => {
    if (!container.has(dep)) {
      container.set(dep, createInstance(dep))
    }
    return container.get(dep)
  })

  return new target(...params)
}

使用:

const controller = createInstance(UserController)

👉 这就是 Nest DI 的雏形

让写作更有“手感”:开源 Tiptap Writing Kit(支持 Vue / React)

2026年1月26日 10:37

让写作更有“手感”:开源 Tiptap Writing Kit(支持 Vue / React)

如果你在做内容创作平台、AI 写作工具或知识库编辑器,一定遇到过这个问题:功能很全,但“写起来不舒服”。
我最近把自己在项目里沉淀的写作体验能力抽出来,开源了 Tiptap Writing Kit,目标很明确:让写作更顺手、更像在真实写作

这是一套基于 Tiptap 的增强组件,提供 排版控制、标尺辅助、打字音效/特效、查找替换、选中气泡菜单 等功能,并同时支持 Vue 3 / React


效果预览

CPT2601261032-1521x782.gif


一句话亮点

  • 面向写作场景:不是通用编辑器,而是“写作体验”增强
  • Vue / React 双端支持,核心能力共享
  • 轻量:按需启用,体验增强不臃肿
  • 可配置:字体、行高、内容宽度、段间距、标尺风格、快捷键、气泡菜单都能改

在线 Demo

建议在电脑端打开,有明显的“写作手感”变化。


功能一览

  • 排版控制:字体/字号/行高/内容宽度/段间距
  • 标尺线:写作时的视觉节奏辅助
  • 打字音效 & 特效:可选的轻量反馈(crisp / drop / typewriter、splash / ripple / mist / fire / cheer)
  • 查找/替换:内置面板 + 快捷键支持
  • 选中气泡菜单:支持自定义按钮、图标与样式
  • 快捷键配置:find / save 等可完全自定义

快速上手

React

npm i tiptap-writing-kit-react
import { InkEditor } from 'tiptap-writing-kit-react'
import 'tiptap-writing-kit-react/style.css'

export default function App() {
  return <InkEditor />
}

Vue 3

npm i tiptap-writing-kit-vue
<script setup lang="ts">
import { InkEditor } from 'tiptap-writing-kit-vue'
import 'tiptap-writing-kit-vue/style.css'
</script>

<template>
  <InkEditor />
</template>

自定义气泡菜单(示例)

React

<InkEditor
  bubbleMenuRender={({ actions, labels, onAction, isActive }) => (
    <>
      {actions.map((action) => (
        <button
          key={action}
          className={`twk-bubble-btn ${isActive(action) ? 'is-active' : ''}`}
          onClick={() => onAction(action)}
        >
          {labels[action]}
        </button>
      ))}
    </>
  )}
/>

Vue

<InkEditor>
  <template #bubble-menu="{ actions, labels, onAction, isActive }">
    <button
      v-for="action in actions"
      :key="action"
      class="twk-bubble-btn"
      :class="{ 'is-active': isActive(action) }"
      @click="onAction(action)"
    >
      {{ labels[action] }}
    </button>
  </template>
</InkEditor>

为什么开源这个组件?

我之前在实际项目中做过 AI 写作平台,发现在“写作体验”上差 1 步,用户就会跳出。
于是把这些体验增强能力抽象成组件,希望帮助更多内容类产品“更像在写作”。


适用场景

  • AI 写作 / 小说创作 / 长文编辑
  • 内容管理后台的编辑器体验升级
  • 知识库、文档编辑工具

仓库地址


路线图(Roadmap)

  • 更丰富的气泡菜单组件与主题
  • 编辑器内的轻量模板系统
  • 可插拔的“写作状态”辅助(字数、段落节奏、节拍)

最后

如果你正在做内容产品,希望写作体验更“像笔尖在纸上”,欢迎试试。
如果你愿意给我一个 Star 或者反馈建议,对项目帮助非常大。

谢谢阅读,欢迎交流!

移动端 Web 开发学习笔记:从视口到流式布局,一篇带你真正入门

作者 YukiMori23
2026年1月26日 10:16

在开始移动端 Web 开发之前,我一直以为「移动端就是把 PC 页面缩小」。
真正系统学完一轮之后才发现,视口、像素比、二倍图、布局方案,每一个点都决定了页面体验的好坏。

这篇文章是我在学习 移动端 Web 开发(流式布局方向) 时的完整总结,适合刚从 PC 端转向移动端,或者对移动端概念比较模糊的同学。


一、移动端开发的现状与特点

1. 移动端浏览器现状

移动端常见浏览器包括:

  • UC 浏览器
  • QQ 浏览器
  • 百度手机浏览器
  • Safari(iOS)
  • Chrome(Android)

一个非常重要的事实是:

国内主流移动端浏览器,基本都基于 WebKit 内核。

这意味着:

  • H5 + CSS3 可以放心使用
  • 私有前缀重点关注 -webkit-
  • 不需要像 PC 时代那样被 IE 折磨

2. 屏幕碎片化问题

移动设备的分辨率极其碎片化,例如:

  • Android:720×1280、1080×1920、2K、4K
  • iPhone:750×1334、1242×2208 等

但作为前端开发者:

不需要纠结 dp、dpi、ppi 等概念
我们关心的是:
CSS 像素如何映射到设备屏幕。


二、视口(Viewport)是移动端的第一道门槛

很多移动端布局问题,根源都在 视口没理解清楚

1. 三种视口的区别

1)布局视口(Layout Viewport)

  • 浏览器默认的布局宽度
  • 大多数移动端默认是 980px
  • 用来兼容早期 PC 页面

这也是为什么 不加 viewport 标签时,页面会整体缩小


2)视觉视口(Visual Viewport)

  • 用户当前看到的区域
  • 可以通过手指缩放改变
  • 不影响布局视口

3)理想视口(Ideal Viewport)

  • 设备最理想的展示宽度
  • 设备有多宽,布局就有多宽
  • 移动端开发真正想要的视口

2. 标准 viewport 写法(必须掌握)

<meta 
  name="viewport" 
  content="
    width=device-width,
    initial-scale=1.0,
    maximum-scale=1.0,
    minimum-scale=1.0,
    user-scalable=no
  "
>

这段代码的作用是:

  • 布局视口等于设备宽度
  • 默认不缩放
  • 禁止用户手动缩放

这是移动端页面的标配


三、二倍图与像素比:为什么图片会模糊

1. 物理像素 vs CSS 像素

在 Retina 屏幕下:

  • 1 个 CSS 像素 ≠ 1 个物理像素
  • iPhone 常见的是 2 倍、3 倍像素比

例如:

  • 页面上显示 50×50
  • 实际需要 100×100 或 150×150 的图片资源

2. 二倍图的解决方案

图片本身使用更大的尺寸,但在 CSS 中压缩显示。

img {
  width: 50px;
  height: 50px;
}

如果原图是 100×100,就能在 Retina 屏上保持清晰。


3. 背景图的处理方式

.box {
  background-image: url(bg@2x.png);
  background-size: 50px 50px;
}

关键点在于:

  • 图片是二倍图
  • background-size 写成设计稿尺寸

四、移动端开发方案选择

1. 两种主流方案

方案一:单独制作移动端页面(主流)

  • m.jd.com
  • m.taobao.com

特点:

  • 专门为手机设计
  • 性能和体验最好
  • 维护成本较高

方案二:响应式页面

  • 同一套 HTML
  • 通过媒体查询适配不同设备

缺点很明显:

  • 样式复杂
  • 维护成本高
  • 移动端体验一般

实际项目中,大厂基本都选择第一种方案。


五、移动端技术解决方案汇总

1. CSS 初始化推荐 normalize.css

相比 reset.css,normalize.css 的优点是:

  • 保留有价值的默认样式
  • 修复浏览器差异
  • 模块化、文档完善

2. CSS3 盒子模型(移动端强烈推荐)

* {
  box-sizing: border-box;
}

优点:

  • padding、border 不会撑大盒子
  • 布局更直观
  • 非常适合移动端

3. 移动端常见特殊样式

* {
  -webkit-tap-highlight-color: transparent;
}

input,
button {
  -webkit-appearance: none;
}

img,
a {
  -webkit-touch-callout: none;
}

这些样式可以:

  • 去掉点击高亮
  • 统一表单样式
  • 禁止长按弹出菜单

六、移动端常见布局方式

1. 流式布局(百分比布局)

流式布局的核心思想:

  • 宽度使用百分比
  • 随屏幕变化自适应
.container {
  width: 100%;
}

.item {
  width: 50%;
}

2. 限制最大最小宽度(常用技巧)

body {
  min-width: 320px;
  max-width: 640px;
  margin: 0 auto;
}

好处是:

  • 防止页面过窄或过宽
  • 常用于电商移动端页面

七、总结

通过这一阶段的学习,我对移动端 Web 开发有了几个明确的认知:

  • 视口是移动端布局的基础
  • 二倍图是清晰显示的关键
  • 流式布局是最常用的移动端布局方式
  • CSS3 盒模型极大降低了布局复杂度
  • 移动端开发更注重体验和性能

如果你正准备从 PC 端转向移动端,这些内容几乎是必经之路

一次吃透「移除元素」:双指针的最朴素、也最重要的形态

作者 YukiMori23
2026年1月26日 10:15

LeetCode 27|简单
核心思想:快慢指针(双指针)
关键词:原地修改、覆盖、不关心顺序


一、题目回顾

给你一个数组 nums 和一个值 val,你需要 原地移除所有等于 val 的元素,并返回移除后数组的新长度。

要求:

  • 不使用额外数组
  • 不关心新数组后面的内容
  • 返回的是“有效元素的长度”

示例:

nums = [3,2,2,3], val = 3
返回 2
nums 前 2 个元素为 [2,2]

二、这道题最容易踩的坑

很多人一开始会纠结:

  • “删元素后数组怎么变?”
  • “是不是要真的把数组缩短?”
  • “后面的值要不要清空?”

但题目其实已经偷偷告诉你答案了:

你只需要保证前 k 个元素是正确的,后面是什么不重要。

这一点非常关键。


三、核心思路:双指针在“做什么”?

我们用两个指针:

  • right:扫描整个数组(读)
  • left:指向下一个“应该被保留下来的位置”(写)

它们的分工非常明确:

right 负责看每一个元素,left 只在“遇到合法元素”时前进。


四、代码实现(Java)

class Solution {
    public int removeElement(int[] nums, int val) {
        int n = nums.length;
        int left = 0;

        for (int right = 0; right < n; right++) {
            if (nums[right] != val) {
                nums[left] = nums[right];
                left++;
            }
        }

        return left;
    }
}

五、逐行拆解这段代码的“真实含义”

1. left 指针的意义

int left = 0;

left 永远指向:

下一个可以放“有效元素”的位置

它不是在找 val,也不是在删除元素,而是在“维护结果数组的长度”。


2. right 指针在干嘛?

for (int right = 0; right < n; right++) {

right 是一个纯扫描指针

  • 从头到尾看一遍数组
  • 不回头、不跳跃
  • 保证每个元素只看一次

3. 为什么只在 != val 时才赋值?

if (nums[right] != val) {
    nums[left] = nums[right];
    left++;
}

这一步非常精髓:

  • 如果当前元素是 val

    • 直接跳过,相当于“删掉”
  • 如果不是 val

    • 把它覆盖写到 left 的位置
    • left 前进,表示有效长度 +1

注意:这是“覆盖”,不是交换。


六、用一个具体例子走一遍

输入:

nums = [0,1,2,2,3,0,4,2]
val = 2

执行过程:

right nums[right] 是否保留 left nums 前部
0 0 1 [0]
1 1 2 [0,1]
2 2 2 [0,1]
3 2 2 [0,1]
4 3 3 [0,1,3]
5 0 4 [0,1,3,0]
6 4 5 [0,1,3,0,4]
7 2 5 [0,1,3,0,4]

最终返回 left = 5


七、为什么这道题不需要交换?

有些双指针题会用“左右交换”,但这里不需要,原因是:

  • 题目不要求保留原有顺序之外的任何信息
  • 我们只关心“哪些值留下”
  • 覆盖写入比交换更简单、更稳定

这类写法也被称为:

慢指针构造结果,快指针遍历输入


八、时间与空间复杂度

  • 时间复杂度:O(n)

    • 每个元素只看一次
  • 空间复杂度:O(1)

    • 原地修改,没有额外数组

九、这道题在整个双指针体系里的位置

这是最基础、最干净的双指针模型:

  • 没有排序
  • 没有边界博弈
  • 没有复杂条件

但它是下面这些题的“地基”:

  • 移除重复元素
  • 移动零
  • 有效数组长度类问题
  • 原地过滤问题

一句话总结它的思想:

用一个指针遍历世界,用另一个指针构造答案。


十、最后的小总结

这道题的难点不在代码,而在观念转变:

  • 不要执着于“删除”
  • 把问题转化为“保留什么”
  • 用指针去描述“状态变化”

当你真正理解了这道题,
后面很多数组题都会突然变得顺眼。

【节点】[Position节点]原理解析与实际应用

作者 SmalBox
2026年1月26日 10:14

【Unity Shader Graph 使用与特效实现】专栏-直达

Unity URP Shader Graph Position节点:空间坐标与视觉效果的桥梁

Position节点是Shader Graph中用于获取三维空间坐标的核心工具,其输出结果受空间坐标系选择直接影响。该节点通过连接不同空间转换模块,可精准获取顶点或片元在特定坐标系下的位置信息,为开发者提供了强大的空间数据访问能力。在URP(Universal Render Pipeline)渲染管线中,其核心功能包括:

  • 对象空间Object Space‌:以物体自身中心为原点,坐标值不随物体移动或旋转改变,适合制作与物体形态强关联的效果(如基于距离的着色)
  • 世界空间World Space‌:以场景原点为基准,坐标值随物体位置变化,常用于制作与场景空间相关的动态效果
  • 视图空间View Space‌:以摄像机为原点,适合制作与摄像机视角相关的特效(如扫描光效果)

注意:URP管线中需通过Graph Settings确认渲染管线类型,传统内置管线不支持Shader Graph功能

基础应用示例:基于距离的着色效果

对象空间下的距离着色

通过连接Position节点到BaseColor输出,可创建以物体中心为基准的渐变效果。这种效果在物体移动或旋转时保持不变,因为坐标系始终以物体中心为原点,适用于制作固定形态的材质变化,如UV动画或程序化纹理。

实现步骤‌:

  1. 创建Unlit Graph模板,添加Position节点并设置空间为Object
  2. 使用Vector3 Length节点计算顶点到中心的距离
  3. 通过Saturate节点将距离值归一化到[0,1]范围
  4. 连接至Color节点生成渐变效果

应用场景‌:

  • 制作基于物体几何形状的纹理变化
  • 创建物体表面的渐变效果,如从中心到边缘的颜色过渡
  • 实现物体表面的动态纹理,如随时间变化的图案

世界空间下的动态效果

将空间切换为World后,Position节点输出值会随物体在场景中的位置变化。这种效果常用于制作环境交互效果,如根据物体与场景中心的距离改变透明度。

实现步骤‌:

  1. 创建PBR Graph模板,添加World Position节点
  2. 使用SceneDepth节点获取相机到物体的距离
  3. 通过Vector3 Subtract计算物体与场景中心的相对位置
  4. 连接至Emissive Color实现动态发光效果

应用场景‌:

  • 制作物体在场景中移动时颜色变化的特效
  • 创建基于物体位置的动态光照效果
  • 实现物体与场景互动的视觉效果,如接近特定区域时改变材质属性

进阶应用:渐隐粒子效果实现

原理与节点配置

通过Position节点实现渐隐效果的核心是利用深度差值控制透明度。这种方法可精确控制粒子消失的过渡区域,通过调整Range参数可改变渐变范围,适用于制作溶解效果或环境粒子消散。

实现步骤‌:

  1. 创建Transparent Surface模板,启用深度写入关闭
  2. 添加View Position节点获取相机坐标系下的Z值
  3. 使用SceneDepth节点获取物体到相机的距离
  4. 通过Vector3 Subtract计算深度差值并连接至Alpha通道
  5. 添加OneMinus节点实现反向渐变效果

应用场景‌:

  • 制作粒子系统在特定距离开始消失的效果
  • 创建物体逐渐透明的溶解效果
  • 实现环境粒子与场景深度相关的消散效果

性能优化技巧

  • 对透明物体使用Alpha Clipping替代混合,可显著减少像素处理量
  • 在Graph Settings中设置合适的渲染队列(如Transparent)
  • 使用Dithering技术伪造低透明度区域的视觉效果,提升视觉质量
  • 对粒子系统启用深度预计算,避免实时计算场景深度,提升性能

常见问题与解决方案

坐标空间选择错误

  • 现象‌:物体移动时颜色不变(预期应变化)
  • 原因‌:误用Object空间代替World空间
  • 解决‌:检查Position节点的空间设置,确保与预期效果匹配

透明效果异常

  • 现象‌:透明物体出现闪烁或重叠错误
  • 原因‌:深度写入未正确关闭或渲染队列设置不当
  • 解决‌:在Graph Settings中启用深度写入关闭,并设置正确的渲染队列

性能瓶颈

  • 现象‌:复杂Shader导致帧率下降
  • 原因‌:过度使用实时计算节点(如SceneDepth)
  • 解决‌:预计算深度值或使用简化算法,对粒子系统启用批处理,提升性能

最佳实践与扩展应用

空间转换技巧

  • 使用World Position减Object Position获取物体局部坐标向量,实现更精细的空间控制
  • 结合RotateAboutAxis节点实现动态坐标变换,创造独特的视觉效果
  • 通过Screen Position节点制作屏幕空间特效,增强视觉冲击力

扩展应用场景

  • 环境交互‌:根据物体与场景物体的距离改变材质属性,实现更自然的互动效果
  • 动态UV‌:结合时间节点创建随时间变化的UV动画,增添动态元素
  • 特殊效果‌:制作扫描光、X光透视等视觉效果,提升游戏或应用的沉浸感

提示:URP管线中建议使用HDR颜色模式处理高动态范围效果,可通过Color节点的HDR选项启用,确保视觉效果更加真实


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

B 端工业软件图表优化:让十万级设备数据不再拖慢操作

2026年1月26日 10:12

一、前言

Hello~大家好。我是秋天的一阵风

在 B 端工业软件开发中,设备参数趋势、能耗分析、质检对比等核心模块均依赖图表可视化,单台设备按 5 秒采集频率,一天数据量即可突破 10 万级。当数据量达到此规模时,传统渲染方式会面临明显性能问题:

1. 遇到的问题:

  • 图表初始化加载耗时超 1.5 秒,多模块并发打开时更久
  • 用户交互(拖动时间轴、缩放数据)存在 0.5-0.6 秒延迟
  • 折线图拖动时出现 “卡顿断连”,影响数据趋势判断

2. 技术挑战:

如何在保证工业数据趋势完整性(如设备参数波动、异常点捕捉)的前提下,实现 10 万级数据图表的流畅渲染与交互?

二、解决方案概述

本文聚焦以下几种 ECharts 适配工业场景的性能优化方案,无需复杂定制开发即可落地:

  1. LTTB 数据降采样(Sampling) :智能筛选设备数据中的关键波动点(峰值、谷值、突变点),剔除平稳冗余数据,在不影响趋势判断的前提下,将渲染数据量压缩至原有的 1/25-1/30。

  2. DataZoom 区域缩放:默认只渲染 “最近 24 小时” 等核心时段数据(约 12% 数据量),用户按需缩放时才加载对应时段数据,避免全量渲染浪费资源。

  3. large 模式:ECharts 针对超大数据量(50万级以上)设计的底层渲染优化模式,通过简化绘制逻辑、减少绘制细节来提升渲染效率,适配设备集群海量数据概览场景。

三、方案详细落地与实测

1. 第一招:LTTB 降采样 —— 精准保留工业数据核心趋势

B 端工业软件对图表的核心需求是 “捕捉设备参数异常波动”,而非展示每一个原始数据点。LTTB( Largest-Triangle-Three-Bucket )算法的优势在于,能从 10 万条数据中,优先保留体现趋势变化的 3000-4000 个关键节点(如温度突升点、转速骤降点),剔除数值平稳的冗余数据,既保证工业数据的分析价值,又大幅降低渲染压力。

核心配置与避坑点:

series: [{
  type: 'line', // 折线图
  data: deviceParamData, // 10万条设备原始数据
  sampling: 'lttb', // 启用LTTB降采样,优先保留波动点
  symbol: 'none', // 必加:工业图表无需单个数据点标记,减少DOM渲染
  animation: false, // 必关:工业场景需实时响应,动画会增加0.3秒延迟
  emphasis: { lineStyle: { width: 2 } } // 适配多设备对比,hover时曲线加粗易区分
}]

其他采样方式及适用场景

  • average(平均值采样) :功能是将采样区间内的所有数据点取平均值作为采样结果。适用场景:工业能耗统计、设备运行平均参数监控等需要体现整体均值水平的场景,例如按小时统计车间设备平均功率。
  • max(最大值采样) :仅保留采样区间内的最大值作为采样点。适用场景:设备峰值监控,如电机最大负载、锅炉最高温度等关键参数的极值追踪,可快速定位设备运行的峰值时刻。
  • min(最小值采样) :与max相反,保留采样区间内的最小值。适用场景:设备运行下限监控,如冷却系统最低水温、润滑油最低压力等,确保关键参数不低于安全阈值。
  • sum(求和采样) :对采样区间内的数据点进行求和计算作为采样结果。适用场景:工业产量统计、物料消耗累计等需要累加数据的场景,比如按班次统计生产线产品总产量。

实测效果:

原 10 万条 5 秒级设备参数数据,初始化需 1.9 秒,拖动卡顿;启用 LTTB 后,数据点降至 3500 个,初始化仅需 350 毫秒,交互无延迟,且设备异常波动点 100% 保留,未影响数据分析。

2. 第二招:DataZoom 区域缩放 —— 适配工业时段化查看习惯

工业软件用户常按 “生产班次”“单日 / 单周” 查看数据,全量加载全年数据完全无必要。

DataZoom 的核心是 “按需渲染”:默认只加载最近 24 小时数据(约 1.2 万条,占 12%),用户通过滑块或滚轮缩放时,再动态加载对应时段数据,从源头减少渲染数据量。

核心配置与工业适配:

dataZoom: [
  {
    type: 'inside', // 支持鼠标滚轮精准缩放,适配工业精细查看需求
    start: 0, end: 12, // 初始显示12%数据(对应1个生产班次)
    minValueSpan: 300, // 最小缩放300个数据点(对应5分钟工业数据粒度)
    filterMode: 'empty' // 过滤不可见数据,减少计算负担
  },
  {
    type: 'slider', // 显示滑块,方便定位历史生产时段
    start: 0, end: 12,
    height: 28,
    labelFormatter: value => new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) // 适配工业时间显示习惯
  }
]

适配技巧:

requestIdleCallback替代setTimeout初始化图表,避开工业软件 “设备告警”“生产计数” 等核心模块的加载高峰,避免图表与核心功能抢资源导致的延迟。实测显示,此调整可减少 30% 的初始化卡顿概率。

3. 第三招:large模式——超大数据量渲染的底层优化

当工业场景需要展示车间所有设备(如50台设备,单台10万条数据,总计500万条)的运行状态概览时,仅靠降采样可能仍存在渲染压力。

large模式是ECharts的底层优化机制,开启后会自动采用 “批量绘制” *** “简化路径”* 等策略,减少Canvas绘制的调用次数,同时关闭部分精细渲染效果,在保证整体趋势可见的前提下最大化提升性能。

核心配置与工业适配案例

以“车间50台设备温度趋势概览”场景为例,核心配置如下:

series: [{
  type: 'line', // 折线图,适配多设备趋势对比
  data: multiDeviceTempData, // 50台设备总计500万条温度数据
  sampling: 'lttb', // 结合降采样进一步压缩数据量
  symbol: 'none', // 关闭数据点标记,减少绘制负担
  animation: false, // 关闭动画,保障实时交互
  large: true, // 启用large模式,开启底层渲染优化
  largeThreshold: 10000, // 阈值设置:数据量超过1万条时触发large模式
  lineStyle: {
    width: 1 // 线条宽度设为1px,平衡显示效果与渲染性能
  }
}]

关键说明与适用场景

  • 核心功能:通过合并绘制指令、简化图形路径等底层优化,降低Canvas绘制开销,针对100万级以上数据量效果显著;largeThreshold用于设置触发阈值,可根据场景灵活调整(工业场景建议设5000-20000)。
  • 适用场景:设备集群运行状态概览(如车间所有设备温度/压力趋势)、全厂能耗总览等“重全局轻局部”的场景,不适用于需要精准查看单个数据点或细微波动的质检分析场景。
  • 搭配技巧:large模式建议与LTTB降采样组合使用,降采样负责“数据量压缩”,large模式负责“渲染效率优化”,两者结合可处理千万级数据的初步概览展示。

实测效果

500万条多设备温度数据,仅用LTTB降采样时初始化需1.2秒,拖动有轻微延迟;开启“LTTB+large模式”后,初始化耗时降至520毫秒,拖动、缩放等交互延迟均控制在0.1秒内,且50台设备的温度高低趋势清晰可辨,满足概览监控需求。

四、统计对比

优化方案 单模块渲染点 单模块初始化耗时 交互体验
无优化 100,000 1800ms 缩放延迟 0.6 秒,切换模块卡顿
只开 LTTB 3,500 350ms 流畅,多曲线对比无延迟
只开 DataZoom 12,000 280ms 流畅,放大历史数据稍慢 0.1 秒
两者一起开 1,800 210ms 缩放、切换模块均无感知延迟
LTTB+DataZoom+large模式(500万数据) 10,000 520ms 概览流畅,支持快速切换设备集群视图

五、B 端工业图表避坑指南与优化思路

  1. 基础配置必做:无论哪种方案,animation: false和symbol: 'none'是底线 —— 工业场景无需动画效果,单个数据点标记只会增加 DOM 负担,这两项配置可减少 40% 渲染耗时。
  2. 数据粒度协同:当数据超 50 万条时,需联合后端按 “工业时间粒度预采样”(如按分钟合并数据),前端再二次降采样,若数据超100万条,建议叠加large模式,避免前端处理超大数据量导致的内存占用问题。
  3. 交互适配场景:工业用户多通过鼠标操作,DataZoom 滑块高度设 25-30px,minValueSpan对应实际生产粒度(如 5 分钟 / 1 小时);开启large模式后,避免开启emphasis等精细交互效果,防止性能回退。

raychart 基于 Vue 的 3D 图表可视化实践:移动端优先从交互到性能的一次系统升级 🚀

2026年1月26日 09:50

raychart 基于 Vue 的 3D 图表可视化实践:移动端优先从交互到性能的一次系统升级 🚀

先说结论:做 3D 图表可视化编辑器时,移动端优先并不是“迁就小屏”,而是帮你把复杂度收敛到正确的地方。3D 图表的信息密度高、交互多、渲染重,如果按桌面端思维直接堆功能,结果通常是功能齐全但体验割裂。我们在 raychart 的 Vue 技术栈里把编辑器先打磨成“手机也能顺畅使用”的体验,再向桌面扩展,最终移动端更好用、桌面端也更清晰。

为什么 3D 图表可视化更需要移动端优先 📱

移动端限制不是坏事,而是设计的天然约束。小屏幕会迫使你重新定义“最重要的交互路径”,也会倒逼你把功能收敛成清晰的层级结构。对基于 Vue 的 3D 图表可视化来说,这正是体验提升的关键。

  • 交互路径更短:移动端需要“少步骤完成关键任务”
  • 信息层级更清晰:控制项必须分组、折叠、聚焦
  • 性能更稳:移动端的硬件限制驱动我们优化默认值

RayChart 预览地址: https://chart3js.netlify.app/#/ ScreenShot_2026-01-26_092915_641.png

项目中如何落地:从控制模块的边界开始 🧩

我们把图表设置拆分为多个独立控制模块,每个模块只负责一个清晰的任务边界,例如:

  • 数据控制:输入与编辑数据点 🧮
  • 坐标控制:管理坐标系与维度映射 🧭
  • 光照控制:灯光类型、强度与阴影 💡
  • 主题控制:配色方案和默认颜色 🎨

这种结构直接服务移动端优先的目标:把复杂系统拆成可理解的小块,让用户在小屏幕上也能顺畅完成配置。桌面端只是展示空间更大,但交互路径依然保持简洁。

ScreenShot_2026-01-26_092942_569.png

ScreenShot_2026-01-26_092954_321.png

ScreenShot_2026-01-26_092925_249.png

移动端优先带来的三类收益 ✅

1. 交互体验更“可预期” 🤝

移动端用户通常只关心最核心的操作:切换图表、调参数、预览效果。我们把这些动作放在最短路径上,次要配置收进折叠区域。结果是桌面端也同样清爽,用户更快找到关键设置。

2. 设计一致性更强 🧱

为了适配小屏幕,我们统一了组件大小、间距、配色和状态样式,形成稳定的一致性。这种一致性一旦建立,桌面端也同步获得更可靠的视觉体验。

3. 性能优化更有效 ⚡

3D 图表可视化的性能瓶颈多在光照、阴影、材质等环节。移动端优先的限制迫使我们设置更合理的默认值,例如:

  • 默认灯光数量更克制
  • 阴影为可选项而非强制项
  • 材质细节更收敛

这些优化也让桌面端获得更稳定的帧率和更低的功耗。

ScreenShot_2026-01-26_094809_782.png

总结:移动端优先不是妥协,而是效率策略 🎯

移动端优先的价值不在于“做个能在手机上跑的版本”,而在于用它作为一种强约束,让系统更简单、更清晰、更高效。对 3D 图表这种复杂交互场景来说,它可以帮助我们:

  • 快速收敛需求边界
  • 建立明确的控制层级
  • 统一 UI 的风格与节奏
  • 同时提升移动端与桌面端体验

如果你也在做 3D 图表、Vue 可视化编辑器或复杂配置系统,建议试着从移动端出发设计一次,你会更快发现真正有价值的交互路径。

关键词 🔎

3D 图表、3D 可视化、Vue、Vue3、前端可视化、图表编辑器、移动端优先、交互设计、性能优化

低代码与脚手架:打造自研的 CLI 工具

2026年1月26日 09:49

对于 8 年经验的资深开发者来说, “懒”是最高级的赞美。如果你发现自己在一个月内重复配置了三次同样的 Lint 规则、复制了五次同样的目录结构,那么你就该意识到:你的生产力正在被这种低效的“搬运”吞噬。

在前端工程化体系中,CLI(命令行界面)工具是封装团队最佳实践、实现“一键基建”的核心武器。


一、 为什么我们要“自研” CLI?

市面上已经有 create-react-appvue-cli,为什么还要自研?

  1. 业务强绑定: 官方工具是普适的,但自研工具可以内置公司私有的组件库、埋点 SDK、SSO 登录逻辑和 CI/CD 模版。
  2. 技术栈收敛: 强制团队统一使用 pnpm、特定版本的 TypeScript 或 Tailwind CSS,减少维护成本。
  3. 流程自动化: 除了拉取模板,CLI 还可以集成自动创建 Git 仓库、自动分配权限、自动生成 API 代码等功能。

二、 CLI 工具的“权力清单”:核心技术选型

要写一个专业的 CLI,你需要这套成熟的 Node.js 工具链:

  • commander / yargs 命令参数解析(如 my-cli create <name>)。
  • inquirer / prompts 命令行交互,让用户做选择题或填空题。
  • chalk / picocolors 让你的终端输出变得五颜六色(成功是绿的,报错是红的)。
  • ora 终端加载动画(Spinner),让等待过程不焦虑。
  • execa 增强版的 child_process,用于执行 git clone 或 pnpm install。
  • download-git-repo 远程下载 GitHub/GitLab 上的模版仓库。

三、 实战:三步打造一个项目脚手架

第一步:命令定义

bin/index.js 中定义入口,利用 commander 监听 create 命令。

JavaScript

#!/usr/bin/env node
const { program } = require('commander');

program
  .command('create <project-name>')
  .description('创建一个新的项目模板')
  .action((name) => {
    // 逻辑代码
  });

program.parse(process.argv);

第二步:交互问询

询问用户需要什么功能,比如是否需要 Pinia、是否需要 E2E 测试。

JavaScript

const questions = [
  {
    type: 'list',
    name: 'template',
    message: '请选择项目模板',
    choices: ['Vue3-Vite-Starter', 'React-Next-Admin'],
  },
  {
    type: 'confirm',
    name: 'install',
    message: '是否自动安装依赖?',
    default: true,
  }
];

第三步:模版拉取与动态渲染

  • 静态模式: 直接从 GitLab 仓库 git clone 对应的分支。
  • 动态模式(更高级): 使用 handlebars 模板引擎,根据用户刚才的选择,动态修改 package.json 中的字段(如项目名称、作者、依赖项)。

四、 进阶:从脚手架到低代码(Low-Code)

如果 CLI 解决的是“从 0 到 1”的基建问题,那么低代码/弱代码解决的就是“从 1 到 N”的效率问题。

  1. Schema 驱动代码生成: 编写一个脚本,读取 Swagger/OpenAPI 的 JSON 结构,自动生成 TypeScript 类型定义和 API 请求函数。
  2. 区块(Block)复用: 类似 Ant Design Pro,通过 CLI 一键插入一个“登录页面区块”或“表格筛选区块”,而不是手动复制粘贴代码。
  3. 内联脚本(In-place Scripts): 比如开发一个 my-cli add-page <name> 命令,它能自动在 src/views 创建文件,并顺便在 router.js 里插好路由配置。

💡 给前端开发者的硬核贴士

  • 版本管理: CLI 自身也要记得做版本检查(比如检测到旧版本时提示 npm update -g my-cli),否则会导致团队成员用的模版版本不统一。
  • 本地开发调试: 在 CLI 项目目录下执行 npm link(或 pnpm link --global),这样你就可以在全局直接使用你正在开发的命令了。
  • 别忘了 chmod +x 确保你的入口脚本有执行权限,并且顶部带有 #!/usr/bin/env node

结语

自研 CLI 是前端工程师从“使用者”向“建设者”转变的标志。它沉淀了你的技术深度,也将你从繁琐的配置中彻底解脱。

构建产物优化:如何让包体积减少 60%?

2026年1月26日 09:48

在前端工程化的链路中,如果说 CI/CD 是传送带,那么**构建产物(Bundle)**就是最终交付给用户的“货物”。对于一个拥有 8 年经验的开发者来说,优化体积不只是为了“好看”,更是为了减少 HTTP 传输损耗、降低浏览器解析耗时(Parse Cost),从而直接提升业务转化率。

本篇我们将深入构建引擎底层,实战拆解如何通过“外科手术级”的手段,将包体积硬生生砍掉 60%。


一、 瘦身第一步:依赖的“大扫除”

很多时候,包体积臃肿是因为我们引入了“核武器”却只用来“切菜”。

1. 彻底清算“巨无霸”库

  • Moment.js -> dayjs: Moment 包含大量语言包且体积巨大。换成 API 几乎一致的 dayjs,体积直接从 200KB+ 降至 2KB。

  • Lodash 的正确姿势: 拒绝 import _ from 'lodash'

    • 方案 A: 使用 import get from 'lodash/get'
    • 方案 B: 配置 babel-plugin-lodash 自动按需引入。

2. 揭秘 Tree Shaking 的“失效”真相

为什么明明用了 ESM,有些代码还是删不掉?

  • Side Effects(副作用): 只要模块顶层有全局赋值或修改原型链的行为,编译器就不敢删它。
  • 实战:package.json 中明确声明 "sideEffects": false(或指定特定文件),这是开启极致压缩的通行证。

二、 进阶:分而治之(Code Splitting)

把一个 2MB 的主包拆成十个 200KB 的小包,利用浏览器的并发下载持久化缓存,用户体感速度会快得多。

1. 路由级懒加载(Dynamic Import)

在现代框架中,这是标配。

JavaScript

const AdminDash = () => import('./views/AdminDash.vue');

2. 策略化拆包:SplitChunks

不要指望构建工具的默认配置。在 Webpack 或 Vite (Rollup) 中手动划分缓存组:

  • Runtime Chunk: 提取引导代码(Manifest),防止业务代码变动导致 Hash 变化。
  • Vendor Chunk:vue/react 等极少变动的框架代码独立打包。
  • Commons Chunk: 提取被多个页面同时引用的公共组件。

三、 底层黑科技:现代语法与压缩

1. 目标浏览器:Browserslist

如果你还在为 IE11 打包,那么你的产物里会充斥着大量的 Polyfill(补丁代码)。

  • 配置: 修改 .browserslistrc,放弃过时的浏览器。
  • 结果: 移除大量的 core-js 注入,产物直接缩水 20%-30%。

2. 压缩引擎的更迭:从 Terser 到 ESBuild/SWC

  • 在生产环境使用 ESBuildTerser (Parallel mode) 进行极致压缩。
  • 开启 drop_console: true,自动剔除所有 console.log 指令。

四、 终极杀招:从资源本身下手

1. 图片的“降维打击”

图片通常占包体积的大头,但它们不该在 JS 包里。

  • WebP 转换: 利用 image-minimizer-webpack-plugin 自动将图片转为 WebP 格式。
  • 小图内联: 设置 data-uri 阈值(如 4KB),减少小图片的 HTTP 请求数。

2. 开启 Gzip / Brotli(全栈必会)

这是工程化与运维的配合:

  • 在构建阶段生成 .gz.br 文件。
  • 配置 Nginx 开启 gzip_static on;。Brotli 的压缩率通常比 Gzip 还要高出 15%-20%。

💡 给前端开发者的硬核贴士

  • 可视化是优化的前提: 永远先跑一遍 webpack-bundle-analyzerrollup-plugin-visualizer你看不见的 Bug 没法修,你看不见的体积没法减。
  • 警惕第三方 SDK: 很多统计、地图、在线客服的 SDK 及其庞大,尽量通过 CDN 异步加载,不要打包进主包。

结语

产物优化不是一次性的工作,而是一个持续的观测过程。通过依赖精简、智能拆包、现代标准和高效压缩,包体积减少 60% 并不是神话,而是工程化能力的自然体现。

🔥🔥🔥 React18 源码学习 - DIFF算法

作者 yyyao
2026年1月26日 09:43

前言

本文的React代码版本为18.2.0

可调试的代码仓库为:GitHub - yyyao-hh/react-debug at master-pure

React的世界里,每次状态更新都会触发组件的重新渲染。如果直接销毁旧DOM节点并创建新节点,性能开销将无法承受。React通过调和(Reconciliation) 过程,配合DIFF算法,找出需要更新的最小节点集合,实现了高效的UI更新。

本文将带你深React18源码,揭示DIFF算法的核心原理、实现细节和优化策略。

DIFF 算法的策略

策略一:同级比较

React只会对同一层级的节点进行比较,不会跨层级追踪节点变化。

策略二:类型不同则销毁重建

如果节点类型改变,React会直接销毁整个子树,重新构建新的节点。

策略三:Key 值优化列表更新

React使用key来匹配原有树上的子元素,匹配成功就会进行复用。通过key标识节点的稳定性,React可以更准确地识别节点的移动、添加和删除,使得树的转换效率得以提高。

比如:两个同级兄弟节点位置进行了调换,存在key的情况下两个节点都会被复用,而不是卸载重新构建。

DIFF 算法的入口

在上一节我们讲构建Fiber树的时候,讲到了beginWork方法内部主要是根据tag分发逻辑,处理不同类型的Fiber节点。将处理方法的返回的子节点作为下一个遍历节点

/* packages/react-reconciler/src/ReactFiberBeginWork.old.js */

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  if(...) {...}; // 满足一定条件下执行熔断策略

  switch (workInProgress.tag) {
    case IndeterminateComponent: return mountIndeterminateComponent(...);
    case LazyComponent: return mountLazyComponent(...);
    case FunctionComponent: return updateFunctionComponent(...);
    case ClassComponent: return updateClassComponent(...);
    case HostRoot: return updateHostRoot(...);
    case HostComponent: return updateHostComponent(...);
    case HostText: return updateHostText(...);
    case SuspenseComponent: return updateSuspenseComponent(...);
    case HostPortal: return updatePortalComponent(...);
    case ForwardRef: return updateForwardRef(...);
    case Fragment: return updateFragment(...);
    case Mode: return updateMode(...);
    case Profiler: return updateProfiler(...);
    case ContextProvider: return updateContextProvider(...);
    case ContextConsumer: return updateContextConsumer(...);
    case MemoComponent: return updateMemoComponent(...);
    case SimpleMemoComponent: return updateSimpleMemoComponent(...);
    case IncompleteClassComponent: return mountIncompleteClassComponent(...);
    case SuspenseListComponent: return updateSuspenseListComponent(...);
    case ScopeComponent: return updateScopeComponent(...);
    case OffscreenComponent: return updateOffscreenComponent(...);
    case LegacyHiddenComponent: return updateLegacyHiddenComponent(...);
    case CacheComponent: return updateCacheComponent(...);
    case TracingMarkerComponent: return updateTracingMarkerComponent(...);
  }
}

这些不同的处理逻辑最终都会走到reconcileChildren函数中去处理,这里简单以类组件(ClassComponent)和函数式组件(FunctionComponent)的举例:

// 类组件 (ClassComponent)
function updateClassComponent(...) {
  const nextUnitOfWork = finishClassComponent(...);
  
  return nextUnitOfWork;
}

function finishClassComponent(...) {
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);

  return workInProgress.child;
}

// 函数式组件 (FunctionComponent)
function updateFunctionComponent(...) {
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  
  return workInProgress.child;
}
/* src/react/packages/react-reconciler/src/ReactFiberBeginWork.old.js */

export function reconcileChildren(...) {
  if (current === null) {
    workInProgress.child = mountChildFibers(...);
  } else {
    workInProgress.child = reconcileChildFibers(...);
  }
}
/* src/react/packages/react-reconciler/src/ReactChildFiber.old.js */

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);
/* src/react/packages/react-reconciler/src/ReactChildFiber.old.js */

function ChildReconciler(shouldTrackSideEffects) {

  ...

  function reconcileChildFibers() {}

  return reconcileChildFibers;
}

所以reconcileChildFibers函数是实现Diff算法的核心方法

DIFF 算法的实现

我们紧接着入口之后,来分析reconcileChildFibers函数的内部逻辑。

  1. 首先处理顶级且无keyFragment元素,满足条件则直接使用它的孩子。这里值得注意的两点:

    a. 存在keyFragment不满足条件,于是乎在下方“处理单元素”逻辑中又会处理一下。

    b. 非顶级的Fragment依旧不满足条件,在下方“处理数组元素”的逻辑中会进行处理。

  2. 如果是一个对象,那么它是一个ReactElement。分为普通元素、Lazy组件、Portal组件、节点数组、可迭代对象分别进行处理;

  3. 如果是stringnumber类型,调用处理文本节点的方法。

  4. 剩余情况:可能为nullundefinedfalse'',不能转化为Fiber节点,因此直接删除所有旧子Fiber节点。

function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  lanes: Lanes,
): Fiber | null {
  
  /* 1. 顶级无key的Fragment元素, 直接使用它的children */
  const isUnkeyedTopLevelFragment =
    typeof newChild === 'object' &&
    newChild !== null &&
    newChild.type === REACT_FRAGMENT_TYPE &&
    newChild.key === null;
  if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children;
  }

  /* 2. 处理对象类型 */
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      // 2.1 React 元素
      case REACT_ELEMENT_TYPE:
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes,
          ),
        );
      // 2.2 Portal
      case REACT_PORTAL_TYPE:
        return placeSingleChild(
          reconcileSinglePortal(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes,
          ),
        );
      // 2.3 懒加载组件
      case REACT_LAZY_TYPE:
        const payload = newChild._payload;
        const init = newChild._init;
        return reconcileChildFibers(
          returnFiber,
          currentFirstChild,
          init(payload),
          lanes,
        );
    }

    // 2.4. 数组类型处理
    if (isArray(newChild)) {
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }

    // 2.5 可迭代对象处理
    if (getIteratorFn(newChild)) {
      return reconcileChildrenIterator(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }

    // 2.6 无效对象类型错误
    throwOnInvalidObjectType(returnFiber, newChild);
  }

  /* 3. 处理文本节点类型 */
  if (
    (typeof newChild === 'string' && newChild !== '') ||
    typeof newChild === 'number'
  ) {
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        '' + newChild,
        lanes,
      ),
    );
  }

  /* 4. 剩余情况 */
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

处理单节点

reconcileSingleElement方法用于处理当新的子节点是一个单独的元素(不是数组或文本节点)时的情况。

它的处理分为两个阶段,判断条件便是child !== null

  1. 更新阶段(while循环)
  2. 初始化阶段或在循环中不满足复用条件(while循环之后)

首先while循环的逻辑:遍历旧子Fiber节点,尝试找到可以复用的Fiber节点:

  • 如果key相同且type相同,则复用并删除其他兄弟节点;
  • 如果key相同但type不同,则删除包括当前节点在内的所有子节点,然后跳出循环;
  • 如果key不同,则标记当前child为删除,继续遍历下一个兄弟节点。

然后是循环之后的逻辑:遍历完或break后未找到可复用节点,直接创建新的Fiber节点。

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  /**
   * 1. 更新阶段
   * - 如果key相同且type相同, 则复用并删除其他兄弟节点
   * - 如果key相同但type不同, 则删除包括当前节点在内的所有子节点, 然后跳出循环
   * - 如果key不同, 则标记当前child为删除, 继续遍历下一个兄弟节点
   */
  while (child !== null) {
    if (child.key === key) {
      const elementType = element.type;
      if (elementType === REACT_FRAGMENT_TYPE) {
        if (child.tag === Fragment) {
          deleteRemainingChildren(returnFiber, child.sibling);
          const existing = useFiber(child, element.props.children);
          existing.return = returnFiber;
          return existing;
        }
      } else {
        if (
          child.elementType === elementType ||
          (__DEV__
            ? isCompatibleFamilyForHotReloading(child, element)
            : false) ||
          (typeof elementType === 'object' &&
            elementType !== null &&
            elementType.$$typeof === REACT_LAZY_TYPE &&
            resolveLazy(elementType) === child.type)
        ) {
          deleteRemainingChildren(returnFiber, child.sibling);
          const existing = useFiber(child, element.props);
          existing.ref = coerceRef(returnFiber, child, element);
          existing.return = returnFiber;
          return existing;
        }
      }
      // key相同, 类型不同, 删除该节点和其兄弟节点
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 如果key不同, 则标记当前child为删除, 继续遍历下一个兄弟节点
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  /**
   * 2. 初始化阶段 | 或循环中不满足复用条件, 直接创建新的Fiber节点
   */
  if (element.type === REACT_FRAGMENT_TYPE) { // Fragment 组件
    const created = createFiberFromFragment(
      element.props.children,
      returnFiber.mode,
      lanes,
      element.key,
    );
    created.return = returnFiber;
    return created;
  } else { // 原生DOM元素、函数组件、类组件等
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
  }
}

总结下reconcileSingleElement方法的作用:负责处理单个React元素的更新,它的目标:

  1. 在现有子节点链表中找到可以复用的节点
  2. 删除不需要的旧节点(打标记,commit阶段处理)
  3. 创建新的Fiber节点(如果没有可复用的)

处理多节点

reconcileChildrenArray函数负责协调一个数组类型的子元素列表。

  1. 第一次循环:同时遍历旧的子Fiber节点链表和新的子元素数组,直到其中一个遍历完。
    在遍历中,通过updateSlot函数尝试复用旧节点(key相同则复用,否则返回null)。
    1. 如果复用失败(key不同),则跳出循环。
    2. 如果复用成功,但是位置可能发生变化,则通过placeChild标记位置(是否需要移动)。
  1. 如果新子元素数组遍历完(newIdx === newChildren.length),说明剩下的旧节点都是多余,需要删除。
  2. 如果旧节点链表遍历完(oldFiber === null),说明剩下的新子元素都是需要新增的,直接循环创建新的Fiber节点。
  3. 如果以上都不是,说明有节点位置发生变化(比如中间插入了新节点,或者节点被删除导致旧节点有剩余),此时将剩余的旧节点放入一个Map中(key作为索引,如果没有key则用index),然后继续遍历新子元素数组,从Map中查找可复用的节点,并标记移动。
  4. 最后,如果还有旧节点未被复用,则标记删除。
function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes,
): Fiber | null {
  let resultingFirstChild: Fiber | null = null; // 结果链表的头节点
  let previousNewFiber: Fiber | null = null;    // 上一个创建的Fiber

  let oldFiber = currentFirstChild; // 旧Fiber链表的节点, 开始指向同层的第一个节点
  let lastPlacedIndex = 0;          // 已经处理完的节点中,最后一个"不需要移动"的节点在旧列表中的位置
  let newIdx = 0;                   // 新children数组的当前索引
  let nextOldFiber = null;          // 下一个要处理的旧Fiber(临时存储)
  /**
   * 1. 同时遍历旧的子Fiber节点链表和新的子元素数组, 直到其中一个遍历完
   */
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    // updateSlot: 尝试复用旧节点(key相同则复用,否则返回null)
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes,
    );
    // 匹配失败, 不能复用
    if (newFiber === null) {
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }
    if (shouldTrackSideEffects) {
      if (oldFiber && newFiber.alternate === null) {
        deleteChild(returnFiber, oldFiber);
      }
    }
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    // 
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }

  /**
   * 2. 如果新子元素数组遍历完, 说明剩下的旧节点都是多余, 需要删除
   */
  if (newIdx === newChildren.length) {
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  /**
   * 3. 如果旧节点链表遍历完, 说明剩下的新子元素都是需要新增的, 直接循环创建
   */
  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      if (newFiber === null) {
        continue;
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    return resultingFirstChild;
  }

  /**
   * 4. 如果以上都不是, 说明有节点位置发生变化, 此时将剩余的旧节点放入一个Map中,
   * 然后继续遍历新子元素数组, 从Map中查找可复用的节点, 并标记移动
   */
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes,
    );
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key,
          );
        }
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  /**
   * 5. 最后, 如果还有旧节点未被复用, 则标记删除
   */
  if (shouldTrackSideEffects) {
    existingChildren.forEach(child => deleteChild(returnFiber, child));
  }

  return resultingFirstChild;
}

总结下reconcileChildrenArray方法的作用:负责协调一个数组类型的子元素列表,它的设计:

第一阶段:顺序比较(处理相同位置节点)

第二阶段:处理新增/删除边界

第三阶段:Map查找(处理节点移动)

实际案例:列表重排的DIFF过程

假设有如下列表更新:

// 更新前
<ul>
  <li key="A">A</li>
  <li key="B">B</li>
  <li key="C">C</li>
  <li key="D">D</li>
</ul>

// 更新后(D移动到B之前)
<ul>
  <li key="A">A</li>
  <li key="D">D</li>
  <li key="B">B</li>
  <li key="C">C</li>
</ul>

DIFF过程:

  1. 第一轮遍历:比较A-A(复用),比较B-Dkey不同,跳出第一轮)
  2. 建立Map:{B: B_Fiber, C: C_Fiber, D: D_Fiber}
  3. 第二轮遍历:
    • 处理D:从Map中找到D_FiberlastPlacedIndex=0oldIndex=3>0,不需要移动
    • 处理B:从Map中找到B_FiberlastPlacedIndex=3oldIndex=1<3,需要移动
    • 处理C:从Map中找到C_FiberlastPlacedIndex=3oldIndex=2<3,需要移动
  1. 结果:D保持原位,BC向后移动

总结

ReactDIFF算法是一个经过精心设计和不断优化的复杂系统。从React16Fiber架构到React18的并发特性,DIFF算法始终围绕着以下核心目标:

  1. 高效性:通过O(n)复杂度的算法快速找出差异
  2. 稳定性:确保在可中断渲染中UI的一致性
  3. 可预测性:开发者可以通过key等手段控制更新行为
  4. 渐进增强:支持并发渲染,提高应用响应速度

下一章我们将了解真实DOM的变更:commit阶段

解决 Vite 开发服务器启动报错:spawn EPERM

作者 独守一隅
2026年1月26日 09:05

问题现象

在 Windows 系统下运行 Vite 开发服务器时,控制台报错:

Error: spawn EPERM 
     at ChildProcess.spawn (node:internal/child_process:420:11) 
     at Object.spawn (node:child_process:787:9) 
     at baseOpen (file:///E:/标签溯源/code/TraceGuard-UI/node_modules/vite/dist/node/chunks/dep-DBxKXgDP.js:26487:36)

开发服务器无法正常启动,或者启动后立即崩溃。

发生原因

这个错误通常由以下几个因素导致:

1. Vite 自动打开浏览器功能

vite.config.js 配置文件中,server.open 选项被设置为 true

server: {
  port: 80,
  host: true,
  open: true,  // 这里是问题所在
  // ...
}

open: true 时,Vite 会在服务器启动后自动调用系统默认浏览器打开开发地址。

2. Windows 权限限制

在 Windows 系统上,Node.js 的 child_process.spawn() 方法尝试启动浏览器进程时,可能会遇到以下权限问题:

  • 权限不足:当前进程没有足够的权限启动外部应用程序
  • 安全策略限制:Windows Defender 或企业安全策略阻止了进程创建
  • 路径问题:浏览器可执行文件路径无法正确解析

3. 端口占用(次要原因)

如果配置的端口(如 80)已被其他进程占用,也可能导致类似的错误。

解决方案

方案一:禁用自动打开浏览器(推荐)

修改 vite.config.js 文件,将 open 选项设置为 false

// vite.config.js
export default defineConfig(({ mode, command }) => {
  return {
    server: {
      port: 80,
      host: true,
      open: false,  // 改为 false,禁用自动打开浏览器
      proxy: {
        // ... 其他配置
      }
    },
    // ... 其他配置
  }
})

优点

  • 彻底解决权限问题
  • 不影响开发服务器功能
  • 可以手动选择浏览器访问

使用方式: 启动开发服务器后,在浏览器中手动访问 http://localhost:80

方案二:指定浏览器路径

如果确实需要自动打开浏览器,可以指定浏览器可执行文件的完整路径:

server: {
  port: 80,
  host: true,
  open: 'chrome',  // 或者指定完整路径
  // 或者
  open: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
}

注意:这种方式仍然可能遇到权限问题,不如方案一稳定。

方案三:以管理员身份运行

以管理员身份运行终端或 IDE,然后执行 npm run dev 命令。

缺点

  • 不是长期解决方案
  • 可能带来安全风险
  • 不推荐作为常规做法

验证修复

修改配置后,重新启动开发服务器:

npm run dev

应该看到类似以下的成功输出:

  VITE v6.3.5  ready in xxx ms

  ➜  Local:   http://localhost:80/
  ➜  Network: http://192.168.x.x:80/
  ➜  press h + enter to show help

然后在浏览器中手动访问 http://localhost:80 即可。

总结

spawn EPERM 错误是 Windows 系统下 Vite 开发服务器的常见问题,主要原因是自动打开浏览器功能触发了权限限制。最简单有效的解决方案是禁用自动打开浏览器功能,改为手动访问开发地址。

这种方式不仅解决了权限问题,还提供了更好的开发体验——你可以自由选择浏览器和打开时机。

相关资源

HagiCode 实践:如何利用 GitHub Actions 实现 Docusaurus 自动部署

作者 newbe36524
2026年1月26日 08:56

为 HagiCode 添加 GitHub Pages 自动部署支持

本项目早期代号为 PCode,现已正式更名为 HagiCode。本文记录了如何为项目引入自动化静态站点部署能力,让内容发布像喝水一样简单。

背景/引言

在 HagiCode 的开发过程中,我们遇到了一个很现实的问题:随着文档和提案越来越多,如何高效地管理和展示这些内容成了当务之急。我们决定引入 GitHub Pages 来托管我们的静态站点,但是手动构建和部署实在是太麻烦了——每次改动都要本地构建、打包,然后手动推送到 gh-pages 分支。这不仅效率低下,还容易出错。

为了解决这个问题(主要是为了偷懒),我们需要一套自动化的部署流程。本文将详细记录如何为 HagiCode 项目添加 GitHub Actions 自动部署支持,让我们只需专注于内容创作,剩下的交给自动化流程。

关于 HagiCode

嘿,介绍一下我们正在做的东西

我们正在开发 HagiCode——一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能——AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷——多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣——游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看~

目标分析

在动手之前,我们得先明确这次任务到底要干啥。毕竟磨刀不误砍柴工嘛。

核心需求

  1. 自动化构建:当代码推送到 main 分支时,自动触发构建流程。
  2. 自动部署:构建成功后,自动将生成的静态文件部署到 GitHub Pages。
  3. 环境一致性:确保 CI 环境和本地构建环境一致,避免"本地能跑,线上报错"的尴尬。

技术选型

考虑到 HagiCode 是基于 Docusaurus 构建的(一种非常流行的 React 静态站点生成器),我们可以利用 GitHub Actions 来实现这一目标。

配置 GitHub Actions 工作流

GitHub Actions 是 GitHub 提供的 CI/CD 服务。通过在代码仓库中定义 YAML 格式的工作流文件,我们可以定制各种自动化任务。

创建工作流文件

我们需要在项目根目录下的 .github/workflows 文件夹中创建一个新的配置文件,比如叫 deploy.yml。如果文件夹不存在,记得先手动创建一下。

这个配置文件的核心逻辑如下:

  1. 触发条件:监听 main 分支的 push 事件。
  2. 运行环境:最新版的 Ubuntu。
  3. 构建步骤
    • 检出代码
    • 安装 Node.js
    • 安装依赖 (npm install)
    • 构建静态文件 (npm run build)
  4. 部署步骤:使用官方提供的 action-gh-pages 将构建产物推送到 gh-pages 分支。

关键配置代码

以下是我们最终采用的配置模板:

name: Deploy to GitHub Pages

# 触发条件:当推送到 main 分支时
on:
  push:
    branches:
      - main
    # 可以根据需要添加路径过滤,比如只有文档变动才构建
    # paths:
    #   - 'docs/**'
    #   - 'package.json'

# 设置权限,这对于部署到 GitHub Pages 很重要
permissions:
  contents: read
  pages: write
  id-token: write

# 并发控制:取消同一分支的旧构建
concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        # 注意:必须设置 fetch-depth: 0,否则可能导致构建版本号不准确
        with:
          fetch-depth: 0

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 20 # 建议与本地开发环境保持一致
          cache: 'npm'     # 启用缓存可以加速构建过程

      - name: Install dependencies
        run: npm ci
        # 使用 npm ci 而不是 npm install,因为它更快、更严格,适合 CI 环境

      - name: Build website
        run: npm run build
        env:
          # 如果你的站点构建需要环境变量,在这里配置
          # NODE_ENV: production
          # PUBLIC_URL: /your-repo-name
          
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./build # Docusaurus 默认输出目录

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

实施过程中的坑点

在实际操作中,我们遇到了一些问题,这里分享出来希望大家能避开(或者提前准备好解决方案)。

1. GitHub Token 权限问题

最开始配置的时候,部署总是报错 403 (Forbidden)。查了好久才发现,是因为 GitHub 默认的 GITHUB_TOKEN 并没有写入 Pages 的权限。

解决方案:在仓库的 Settings -> Actions -> General -> Workflow permissions 中,务必选择 "Read and write permissions"

2. 构建目录路径错误

Docusaurus 默认把构建好的静态文件放在 build 目录。但是有些项目(比如 Create React App 默认是 build,Vite 默认是 dist)可能配置不一样。如果在 Actions 中报错找不到文件,记得去 docusaurus.config.js 里检查一下输出路径配置。

3. 子路径问题

如果你的仓库不是用户主页(即不是 username.github.io),而是项目主页(比如 username.github.io/project-name),你需要配置 baseUrl

docusaurus.config.js 中:

module.exports = {
  // ...
  url: 'https://HagiCode-org.github.io', // 你的 GitHub URL
  baseUrl: '/site/', // 如果你的仓库叫 site,这里就填 /site/
  // ...
};

这一点很容易被忽略,配置不对会导致页面打开全是白屏,因为资源路径加载不到。

验证成果

配置完所有东西并推送代码后,我们就可以去 GitHub 仓库的 Actions 标签页看戏了。

你会看到黄色的圆圈(工作流正在运行),变绿就代表成功啦!如果变红了,点击进去查看日志,通常都能排查出问题(大部分时候是拼写错误或者路径配置不对)。

构建成功后,访问 https://<你的用户名>.github.io/<仓库名>/ 就能看到崭新的站点了。

总结

通过引入 GitHub Actions,我们成功实现了 HagiCode 文档站的自动化部署。这不仅节省了手动操作的时间,更重要的是保证了发布流程的标准化。现在不管是哪位小伙伴更新了文档,只要合并到 main 分支,几分钟后就能在线上看到最新的内容。

核心收益

  • 效率提升:从"手动打包、手动上传"变成"代码即发布"。
  • 降低错误:消除了人为操作失误的可能性。
  • 体验优化:让开发者更专注于内容质量,而不是被繁琐的部署流程困扰。

虽然配置 CI/CD 刚开始有点麻烦(尤其是各种权限和路径问题),但这是一次性投入,长期回报巨大的工作。强烈建议所有静态站点项目都接入类似的自动化流程。

参考资料


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

元信息

一次 scrollIntoView 在 Android 企微中失效的踩坑实录

作者 zhEng
2026年1月26日 08:20

这是一个看起来“理所当然”,却足以让你怀疑人生的Bug。

它不会在你本地出现,不会在 iOS 上出现,甚至在大多数 Android 浏览器上也“表现正常”。

但它会在 Android 企业微信 里,悄无声息地让你的页面—— 滚动不到指定位置

1、事情的起点:一个再正常不过的需求

故事要从一个移动端项目说起。

页面很常见:

  • 使用 Vant 组件库
  • 一个 Form 表单
  • 若干个输入项

需求也很常见:

提交表单时触发校验,校验失败就自动滚动到对应的表单项位置。

做过 PC 或移动端表单的人都知道,这几乎是“标配能力”。

在 Vant 中,对应的实现路径也非常清晰,校验失败后,调用滚动方法

const formRef = ref(null);
formRef.value.validate().then(()=> {
    // TODO
}).cathc(err=> {
    const name = err?.[0]?.name ?? '';
    name && formRef.value.scrollToField(name)
})

PC 端,这种体验甚至已经“理所当然”。

2、测试的一句话,让事情开始变味

提测之后,测试小姐姐提了一个非常合理、也非常人性化的建议

「现在滚动是瞬间跳过去的,能不能加个过渡?看起来有点生硬。」

听起来是不是很简单?👉 “加个平滑滚动而已。”

我第一时间翻了 Vant 官方文档

文档里对 scrollToField 的描述是这样的:

image.png

类似:

scrollToField(name: string, alignToTop?: boolean)

但问题在于:

  • 文档没有提平滑滚动
  • 没有提是否支持更复杂的滚动配置

不过,作为一个习惯 “不完全相信文档”的前端,我做了一件很自然的事——👉 去看源码。

3、源码一看:这不就有戏了吗?

在 Vant 的源码里,我很快找到了实现:

// packages/vant/src/form/Form.tsx
const scrollToField = (
  name: string,
  options?: boolean | ScrollIntoViewOptions,
) => {
  children.some((item) => {
    if (item.name === name) {
      item.$el.scrollIntoView(options);
      return true;
    }
    return false;
  });
};

看到这里,好家伙,这不是直接透传 scrollIntoView 吗?

也就是说:

  • 不仅能传 boolean
  • 还能直接传 ScrollIntoViewOptions

那事情就简单了。

const formRef = ref(null);
formRef.value.validate().then(()=> {
    // TODO 校验通过
}).cathc(err=> {
    const name = err?.[0]?.name ?? '';
    name && formRef.value.scrollToField(name, {
      behavior: 'smooth',
      block: 'center'
    })
});

本地一测:

  • ✅ 滚动顺滑
  • ✅ 居中展示
  • ✅ 体验明显提升

4、Bug 来了,而且来得很“安静”

没过多久,测试小姐姐提了一个 Bug。

描述非常简短:

「 现在触发校验之后,页面好像滚动不过去了 」

我第一反应是:

不可能吧?我刚刚还测过。

于是我拿起,🍎 iPhone 16 Pro 手机,点击表单提交按钮,触发校验

  • 一切正常
  • 平滑滚动
  • 定位精准

🤔 我心想:

那这是啥问题?「 于是我换了测试同款手机 」

真凶现身:Android + 企业微信 测试环境复现条件逐渐清晰:

  • Android 手机
  • 企业微信内置浏览器
  • 特定系统版本

关键信息最终锁定为:

  • MagicOS 8.0(荣耀 / 华为系,基于 Android 14)
  • 企业微信 5.0.3 (内置 X5 / 系统 WebView)

现象也非常“诡异”:

  • scrollToField 被调用了
  • 页面没有任何报错
  • 但页面就是没有滚动

5、真相:Android WebView 并不“讲武德”

深入排查后,问题逐渐明朗:

(1)Android WebView 对 scrollIntoView 支持并不完整

在 Android WebView / X5 内核 中:

  • scrollIntoView() 基本可用
  • block: 'center' 经常被忽略
  • behavior: 'smooth' 在复杂布局中,会被打断或失效

(2) 企业微信 Android 端不是“纯浏览器”

企业微信 Android 端:

  • 使用的是系统 WebView 或 X5 内核
  • 滚动是原生 + JS 混合实现
  • smooth 滚动有「动画被中断」的情况

而 iOS WKWebView:

  • scrollIntoView({ block: 'center' }) 支持是规范级别的
  • 滚动计算非常稳定

👉 所以看到的是:「 苹果:完美 ; 安卓:玄学 」

(3)Android 对「center」的计算有 Bug(尤其 Android 13+)

在 Android 12+,特别是 14:

  • block: 'center' 的中心点
  • 忽略滚动容器 padding
  • 或错误使用 offsetParent

这在 企微 + MagicOS 组合下非常容易触发。

6、最终方案:别再指望 scrollIntoView 了

问题明确后,解决思路也就清晰了。

方案一:Android 端不使用 smooth

const isAndroid = /Android/i.test(window.navigator.userAgent);

element.scrollIntoView({
  behavior: isAndroid ? 'auto' : 'smooth',
  block: 'center'
});

方案二(最稳):自己计算滚动距离

核心思想只有一句话: 自己算 scrollTop,别把命运交给 WebView。

示例:

/**
 * 将目标元素滚动到容器中间
 * @param container 滚动元素
 * @param target 目标元素
 */
const scrollToCenter(container, target) => {
  const containerRect = container.getBoundingClientRect();
  const targetRect = target.getBoundingClientRect();

  const offset =
    targetRect.top -
    containerRect.top -
    container.clientHeight / 2 +
    target.clientHeight / 2;

  container.scrollTo({
    top: container.scrollTop + offset,
    behavior: 'smooth'
  });
}

usage

const formRef = ref(null);
formRef.value.validate().then(()=> {
    // TODO 校验通过
}).cathc(err=> {
    const name = err?.[0]?.name ?? '';
    const container = document.getElementById('app');
    const target = document.getElementsByClassName('van-field__error-message')?.[0]
    scrollToCenter(container, target);
});

上线测试:

  • ✅ Android 企业微信
  • ✅ iOS
  • ✅ 本地浏览器

全部通过。

测试小姐姐给了一个评价:「这次的体验很好 👍」 那一刻,真的值了。

7、踩坑总结

如果你也在做类似的事情,建议直接收藏:

  • 不要在 Android 企业微信中过度依赖 scrollIntoView 的高级配置项

  • 尤其是:

    • behavior: 'smooth'
    • block: 'center'
  • iOS 正常 ≠ 代码在所有环境都正确

这类问题的本质往往不是:

你写错了代码*,

而是:

你刚好踩到了 WebView 的能力边界了。

从零开始:手把手教你创建 Vue 3 + TypeScript 项目

作者 鱼予余
2026年1月26日 01:35

本文将带你一步步创建完整的 Vue 3 项目,包含现代化的开发工具链、代码规范和最佳实践。适合前端开发者学习和参考。

前言

Vue 3 带来了 Composition API、更好的 TypeScript 支持和性能优化,是现代前端开发的较广泛的选择。本教程将从环境搭建开始,逐步创建完整的 Vue 3 项目。

环境准备

1. Node.js 环境

Vue 3 项目需要 Node.js 环境,推荐使用 LTS 版本。

Windows 系统

# 访问 Node.js 官网下载安装包
# https://nodejs.org/

# 或使用 nvm-windows 管理多个版本
# 1. 下载 nvm-windows: https://github.com/coreybutler/nvm-windows/releases
# 2. 安装并重启命令行
nvm install 20.19.0
nvm use 20.19.0

macOS 系统

# 使用 Homebrew 安装
brew install node

# 或使用 nvm 管理版本
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
source ~/.bashrc
nvm install 20.19.0
nvm use 20.19.0

验证安装

# 检查版本
node --version  # 应显示 v20.19.0 或更高
npm --version   # 应显示 10.x.x 或更高

2. 包管理工具

推荐使用 npm 或 pnpm,yarn 也可以。

# 全局安装 pnpm (可选)
npm install -g pnpm

# 检查版本
pnpm --version

3. IDE 配置

推荐使用 VS Code,并安装 Vue 相关插件。

必需插件

  • Vue (Official) - Vue 官方插件,提供语法高亮和智能提示
  • TypeScript Importer - 自动导入 TypeScript 类型
  • Prettier - 代码格式化
  • ESLint - 代码检查

VS Code 设置

// .vscode/settings.json
{
  "typescript.preferences.importModuleSpecifier": "relative",
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.vscode-prettier",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

项目创建

方法一:使用 Vite 官方模板

Vite 是 Vue 官方推荐的构建工具,速度快,开发体验好。

# 创建项目
npm create vue@latest vue-course

# 进入项目目录
cd vue-course

配置选项

在创建过程中选择以下配置:

✅ TypeScript
✅ JSX
✅ Vue Router
✅ Pinia
✅ ESLint
✅ Prettier

方法二:手动创建项目

如果你喜欢自定义配置,可以手动创建。

# 创建项目目录
mkdir vue-course
cd vue-course

# 初始化 package.json
npm init -y

# 安装核心依赖
npm install vue@latest @vue/compiler-sfc@latest
npm install -D @vitejs/plugin-vue@latest vite@latest
npm install -D typescript@latest vue-tsc@latest

项目配置

1. Vite 配置

创建 vite.config.ts 文件:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  server: {
    port: 5173,
    host: true,
    open: true
  },
  build: {
    target: 'esnext',
    minify: 'esbuild',
    outDir: 'dist'
  }
})

2. TypeScript 配置

创建 tsconfig.json

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
  "exclude": ["src/**/__tests__/*"],
  "compilerOptions": {
    "composite": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": ["vite/client"]
  }
}

创建 tsconfig.node.json

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": ["vite.config.*"],
  "compilerOptions": {
    "composite": true,
    "types": ["node"]
  }
}

创建 env.d.ts

/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

3. ESLint 配置

创建 eslint.config.js

import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import typescript from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'

export default [
  {
    name: 'app/files-to-lint',
    files: ['**/*.{ts,mts,tsx,vue}'],
  },

  {
    name: 'app/files-to-ignore',
    ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
  },

  js.configs.recommended,
  ...vue.configs['flat/essential'],

  {
    name: 'app/vue-rules',
    files: ['**/*.vue'],
    languageOptions: {
      parser: vue.parser,
      parserOptions: {
        parser: typescriptParser,
        extraFileExtensions: ['.vue'],
        sourceType: 'module',
      },
    },
  },

  {
    name: 'app/typescript-rules',
    files: ['**/*.{ts,mts,tsx,vue}'],
    languageOptions: {
      parser: typescriptParser,
      parserOptions: {
        ecmaVersion: 2020,
        sourceType: 'module',
      },
    },
    rules: {
      ...typescript.configs.recommended.rules,
      '@typescript-eslint/no-unused-vars': 'error',
      '@typescript-eslint/explicit-function-return-type': 'off',
    },
  },
]

4. Prettier 配置

创建 .prettierrc.json

{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100,
  "endOfLine": "lf"
}

项目结构搭建

1. 基础文件结构

vue-course/
├── public/
│   ├── favicon.ico
├── src/
│   ├── assets/
│   │   ├── base.css
│   │   ├── main.css
│   │   └── logo.svg
│   ├── components/
│   │   ├── HelloWorld.vue
│   │   ├── TheWelcome.vue
│   │   ├── WelcomeItem.vue
│   │   └── icons/
│   ├── views/
│   │   ├── HomeView.vue
│   │   └── AboutView.vue
│   ├── router/
│   │   └── index.ts
│   ├── stores/
│   │   └── counter.ts
│   ├── App.vue
│   ├── main.ts
│   └── mcp-server.ts
├── .vscode/
│   └── settings.json
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── env.d.ts
├── eslint.config.ts
├── .prettierrc.json
└── README.md

2. 入口文件

src/main.ts

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'

import './assets/main.css'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

index.html

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vue Course</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

3. 根组件

src/App.vue

<template>
  <div id="app">
    <header class="app-header">
      <h1>Vue 3 项目</h1>
      <nav>
        <router-link to="/">首页</router-link>
        <router-link to="/about">关于</router-link>
      </nav>
    </header>

    <main class="app-main">
      <router-view />
    </main>
  </div>
</template>

<script setup lang="ts">
// 组件逻辑
</script>

<style scoped>
.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 2rem;
  background: #f8f9fa;
  border-bottom: 1px solid #e9ecef;
}

.app-main {
  padding: 2rem;
  min-height: calc(100vh - 80px);
}
</style>

路由配置

1. 安装 Vue Router

npm install vue-router@4

2. 路由配置

src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('../views/HomeView.vue'),
    meta: { title: '首页' }
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('../views/AboutView.vue'),
    meta: { title: '关于' }
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

// 路由守卫
router.beforeEach((to, from, next) => {
  document.title = `${to.meta.title} - Vue Course`
  next()
})

export default router

3. 创建页面组件

src/views/HomeView.vue

<template>
  <div class="home">
    <h2>欢迎使用 Vue 3</h2>
    <p>当前计数: {{ count }}</p>
    <button @click="increment" class="btn">增加</button>
    <button @click="decrement" class="btn">减少</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

const increment = () => count.value++
const decrement = () => count.value--
</script>

<style scoped>
.home {
  text-align: center;
  padding: 2rem;
}

.btn {
  margin: 0 0.5rem;
  padding: 0.5rem 1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: white;
  cursor: pointer;
}

.btn:hover {
  background: #f0f0f0;
}
</style>

状态管理

1. 安装 Pinia

npm install pinia

2. 配置 Pinia

已经在 main.ts 中配置了。

3. 创建 Store

src/stores/counter.ts

import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  // 状态
  const count = ref(0)

  // 计算属性
  const doubleCount = computed(() => count.value * 2)

  // 动作
  const increment = () => {
    count.value++
  }

  const decrement = () => {
    count.value--
  }

  const reset = () => {
    count.value = 0
  }

  return {
    count,
    doubleCount,
    increment,
    decrement,
    reset
  }
})

4. 在组件中使用

<template>
  <div>
    <p>计数: {{ counter.count }}</p>
    <p>双倍: {{ counter.doubleCount }}</p>
    <button @click="counter.increment">+</button>
    <button @click="counter.decrement">-</button>
  </div>
</template>

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

样式配置

1. 全局样式

src/assets/main.css(项目中使用的是main.css):

#app {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
  font-weight: normal;
}

a,
.green {
  text-decoration: none;
  color: hsla(160, 100%, 37%, 1);
  transition: 0.4s;
}

@media (hover: hover) {
  a:hover {
    background-color: hsla(160, 100%, 37%, 0.2);
  }
}

@media (min-width: 1024px) {
  body {
    display: flex;
    place-items: center;
  }

  #app {
    display: grid;
    grid-template-columns: 1fr 1fr;
    padding: 0 2rem;
  }
}

src/assets/base.css

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: 16px;
  line-height: 1.5;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  padding: 0;
  color: #333;
  background-color: #fff;
}

h1, h2, h3, h4, h5, h6 {
  margin: 0 0 1rem 0;
  font-weight: 600;
  line-height: 1.2;
}

p {
  margin: 0 0 1rem 0;
}

a {
  color: #007acc;
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

button {
  font-family: inherit;
  border: none;
  cursor: pointer;
}

img {
  max-width: 100%;
  height: auto;
}

开发和构建

1. 开发服务器

# 启动开发服务器
npm run dev

# 或使用自定义端口
npm run dev -- --port 3000

2. 构建生产版本

# 构建生产版本
npm run build

# 预览构建结果
npm run preview

3. 代码检查

# 代码格式化
npm run format

# 代码检查
npm run lint

# 类型检查
npm run type-check

部署选项

1. 静态站点部署

Vercel

npm i -g vercel
vercel

Netlify

npm i -g netlify-cli
netlify deploy

常见问题

1. 依赖安装失败

# 清理缓存
npm cache clean --force
rm -rf node_modules package-lock.json

# 使用国内镜像
npm config set registry https://registry.npmmirror.com

# 重新安装
npm install

2. TypeScript 错误

确保 tsconfig.json 配置正确:

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"]
}

3. 热重载不工作

检查 Vite 配置:

export default defineConfig({
  server: {
    hmr: true
  }
})

总结

通过本教程,你已经创建了一个完整的 Vue 3 项目,包含:

  • ✅ 现代化的开发工具链
  • ✅ TypeScript 支持
  • ✅ 组件化架构
  • ✅ 状态管理
  • ✅ 路由系统
  • ✅ 代码规范
  • ✅ 静态站点部署

这个项目结构可以作为你开发 Vue 3 应用的起点。根据实际需求,你可以继续添加更多功能,如国际化、权限管理、数据可视化等。

资源链接


发布时间: 2026年1月 技术标签: Vue.js, TypeScript, Vite, 前端开发

希望这篇教程对你有帮助!如果有问题欢迎在评论区交流。

Vue 3 事件透传机制详解

2026年1月26日 00:21

Vue 3 事件透传机制详解

1. 基础概念

  • 事件处理器的本质:在 Vue 中,@close="handler"实际上会被编译为一个名为 onClose的 prop,其值为对应的函数。
  • 透传规则:组件上绑定的、但未在 defineProps中声明的属性(包括普通属性和事件监听器),会自动传递给组件的根元素
  • 引用保持不变:在透传过程中,函数的引用始终是同一个,不会重新创建。

2. 透传工作原理

可以将其理解为“属性/事件的自动继承”。

2.1 传递过程

当一个属性或事件监听器从父组件传递给子组件时:

  1. 子组件检查自身 props是否声明了该属性。
  2. 如果未声明,则该属性会被放入子组件的 $attrs对象中。
  3. 如果子组件是单根元素,Vue 会自动将这些 $attrs绑定到该根元素上。如果是多根节点组件,则不会自动绑定,需要手动处理。
  4. 该过程可以逐层向下进行,直到被某个组件显式接收或绑定到最终的元素上。

2.2 事件执行机制

  • 直接调用:当底层元素触发事件(如 click)时,它实际上调用的是透传下来的、来自最外层组件的那个原始函数。
  • 上下文正确:函数执行时,this指向定义它的原始组件实例。

3. 透传内容的类型

  • 普通 HTML 属性:如 idclassdata-*style等。
  • 事件监听器:以 on开头的属性,如 onClickonClose

4. 如何控制透传?(防护与定制)

有时我们不希望所有属性都自动透传。

4.1 显式声明为 Prop

在子组件中使用 defineProps声明属性,即可阻止其继续向下透传。

const props = defineProps({
  onClose: Function, // 声明后,onClose 将不再进入 $attrs
})

4.2 完全禁用自动透传

设置 inheritAttrs: false,Vue 将不再自动将 $attrs绑定到根元素。

defineOptions({
  inheritAttrs: false
})

4.3 手动控制绑定位置

禁用自动透传后,可以使用 v-bind="$attrs"将属性精确绑定到任意元素上,实现更灵活的传递。

<template>
  <div class="wrapper">
    <!-- 将透传属性只绑定到内部某个元素 -->
    <button v-bind="$attrs">点击我</button>
  </div>
</template>
❌
❌