阅读视图

发现新文章,点击刷新页面。

使劲折腾Element Plus的Table组件

背景

笔者公司的一个项目大量使用el-table组件,并做出一些魔改的效果

多列显示

废话不多讲,直接上效果

image.png

使用el-table组件的多级表头,不存在滴

核心代码如下

<script setup lang="ts">
import { ref, computed } from 'vue'
import { Search, Refresh, Edit, Delete, View } from '@element-plus/icons-vue'

interface User {
  id: number
  avatar: string
  username: string
  realName: string
  email: string
  phone: string
  gender: 'male' | 'female' | 'unknown'
  age: number
  department: string
  position: string
  status: 'active' | 'inactive' | 'banned'
  registerTime: string
  lastLoginTime: string
  province: string
  city: string
  address: string
  salary: number
  education: string
  workYears: number
}

const loading = ref(false)
const searchText = ref('')
const statusFilter = ref('')
const departmentFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)

const departments = ['技术部', '产品部', '设计部', '市场部', '运营部', '人事部', '财务部']
const positions = ['工程师', '高级工程师', '技术经理', '产品经理', '设计师', '运营专员', 'HR专员', '财务专员']
const educations = ['高中', '大专', '本科', '硕士', '博士']
const provinces = ['北京', '上海', '广东', '浙江', '江苏', '四川', '湖北']

const generateMockData = (): User[] => {
  const data: User[] = []
  for (let i = 1; i <= 100; i++) {
    data.push({
      id: i,
      avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
      username: `user${i}`,
      realName: `用户${i}`,
      email: `user${i}@example.com`,
      phone: `138${String(i).padStart(8, '0')}`,
      gender: ['male', 'female', 'unknown'][i % 3] as User['gender'],
      age: 20 + (i % 30),
      department: departments[i % departments.length],
      position: positions[i % positions.length],
      status: ['active', 'inactive', 'banned'][i % 3] as User['status'],
      registerTime: `2023-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 10:30:00`,
      lastLoginTime: `2024-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 14:20:00`,
      province: provinces[i % provinces.length],
      city: '市区',
      address: `街道${i}号`,
      salary: 8000 + (i % 20) * 1000,
      education: educations[i % educations.length],
      workYears: i % 15,
    })
  }
  return data
}

const allUsers = ref<User[]>(generateMockData())

const filteredUsers = computed(() => {
  let result = allUsers.value

  if (searchText.value) {
    const search = searchText.value.toLowerCase()
    result = result.filter(
      (user) =>
        user.username.toLowerCase().includes(search) ||
        user.realName.toLowerCase().includes(search) ||
        user.email.toLowerCase().includes(search) ||
        user.phone.includes(search)
    )
  }

  if (statusFilter.value) {
    result = result.filter((user) => user.status === statusFilter.value)
  }

  if (departmentFilter.value) {
    result = result.filter((user) => user.department === departmentFilter.value)
  }

  return result
})

const paginatedUsers = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return filteredUsers.value.slice(start, end)
})

const total = computed(() => filteredUsers.value.length)

const getGenderText = (gender: string) => {
  const map: Record<string, string> = {
    male: '男',
    female: '女',
    unknown: '未知',
  }
  return map[gender] || '未知'
}

const getStatusType = (status: string) => {
  const map: Record<string, string> = {
    active: 'success',
    inactive: 'warning',
    banned: 'danger',
  }
  return map[status] || 'info'
}

const getStatusText = (status: string) => {
  const map: Record<string, string> = {
    active: '正常',
    inactive: '未激活',
    banned: '已禁用',
  }
  return map[status] || '未知'
}

const handleSearch = () => {
  currentPage.value = 1
}

const handleReset = () => {
  searchText.value = ''
  statusFilter.value = ''
  departmentFilter.value = ''
  currentPage.value = 1
}

const handleView = (row: User) => {
  console.log('查看用户:', row)
}

const handleEdit = (row: User) => {
  console.log('编辑用户:', row)
}

const handleDelete = (row: User) => {
  console.log('删除用户:', row)
}

const handleSizeChange = (val: number) => {
  pageSize.value = val
  currentPage.value = 1
}

const handleCurrentChange = (val: number) => {
  currentPage.value = val
}

const formatSalary = (salary: number) => {
  return `¥${salary.toLocaleString()}`
}
</script>

<template>
  <div class="user-list-container">
    <el-card class="search-card">
      <el-form :inline="true" class="search-form">
        <el-form-item label="关键词">
          <el-input
            v-model="searchText"
            placeholder="用户名/姓名/邮箱/手机"
            clearable
            :prefix-icon="Search"
            @keyup.enter="handleSearch"
          />
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="statusFilter" placeholder="全部" clearable style="width: 120px">
            <el-option label="正常" value="active" />
            <el-option label="未激活" value="inactive" />
            <el-option label="已禁用" value="banned" />
          </el-select>
        </el-form-item>
        <el-form-item label="部门">
          <el-select v-model="departmentFilter" placeholder="全部" clearable style="width: 120px">
            <el-option v-for="dept in departments" :key="dept" :label="dept" :value="dept" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
          <el-button :icon="Refresh" @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <el-card class="table-card">
      <el-table
        :data="paginatedUsers"
        v-loading="loading"
        border
        stripe
        highlight-current-row
        style="width: 100%"
        :header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
      >
        <el-table-column type="selection" width="50" fixed="left" />
        <el-table-column prop="id" label="ID" width="70" fixed="left" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.id }}
          </template>
        </el-table-column>
        <el-table-column label="头像" width="80">
          <template #default="{ row, $index }">
            <el-avatar v-if="$index !== 0" :size="40" :src="row.avatar" />
          </template>
        </el-table-column>
        <el-table-column prop="username" label="用户名" width="120" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.username }}
          </template>
        </el-table-column>
        <el-table-column prop="realName" label="姓名" width="100" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.realName }}
          </template>
        </el-table-column>
        <el-table-column prop="gender" label="性别" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : getGenderText(row.gender) }}
          </template>
        </el-table-column>
        <el-table-column prop="age" label="年龄" width="70" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.age }}
          </template>
        </el-table-column>
        <el-table-column prop="phone" label="手机号" width="130">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.phone }}
          </template>
        </el-table-column>
        <el-table-column prop="email" label="邮箱" width="180" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.email }}
          </template>
        </el-table-column>
        <el-table-column prop="department" label="部门" width="100">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.department }}
          </template>
        </el-table-column>
        <el-table-column prop="position" label="职位" width="120">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.position }}
          </template>
        </el-table-column>
        <el-table-column prop="education" label="学历" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.education }}
          </template>
        </el-table-column>
        <el-table-column prop="workYears" label="工龄" width="70" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : `${row.workYears}年` }}
          </template>
        </el-table-column>
        <el-table-column prop="salary" label="薪资" width="100" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : formatSalary(row.salary) }}
          </template>
        </el-table-column>
        <el-table-column prop="status" label="状态" width="100">
          <template #default="{ row, $index }">
            <span v-if="$index === 0">
              {{ '' }}
            </span>
            <el-tag v-else :type="getStatusType(row.status) as any">
              {{ getStatusText(row.status) }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="province" label="" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '省份' : row.province }}
          </template>
        </el-table-column>
        <el-table-column prop="city" label="地址" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '市' : row.city }}
          </template>
        </el-table-column>
        <el-table-column prop="address" label="" width="120" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '街道' : row.address }}
          </template>
        </el-table-column>
        <el-table-column prop="registerTime" label="注册时间" width="170" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.registerTime }}
          </template>
        </el-table-column>
        <el-table-column prop="lastLoginTime" label="最后登录" width="170" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.lastLoginTime }}
          </template>
        </el-table-column>
        <el-table-column label="操作" width="180" fixed="right">
          <template #default="{ row, $index }">
            <template v-if="$index !== 0">
              <el-button type="primary" link :icon="View" @click="handleView(row)">查看</el-button>
              <el-button type="warning" link :icon="Edit" @click="handleEdit(row)">编辑</el-button>
              <el-popconfirm title="确定删除该用户吗?" @confirm="handleDelete(row)">
                <template #reference>
                  <el-button type="danger" link :icon="Delete">删除</el-button>
                </template>
              </el-popconfirm>
            </template>
          </template>
        </el-table-column>
      </el-table>

      <div class="pagination-container">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="[10, 20, 50, 100]"
          :total="total"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>
  </div>
</template>

<style scoped>
.user-list-container {
  padding: 20px;
}

.search-card {
  margin-bottom: 20px;
}

.search-form {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.table-card {
  width: 100%;
}

.pagination-container {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
}

:deep(.el-table__header-wrapper thead tr th:nth-of-type(16)) {
  border-right: 0;
}

:deep(.el-table__header-wrapper thead tr th:nth-of-type(17)) {
  border-right: 0;
}
</style>

陆续更新

Angular的Service创建多个实例的总结

在 Angular 中,Service 默认是单例的,但可以通过不同的方式创建多个实例。以下是几种实现多实例 Service 的方法:

1. 在组件级别提供 Service

// my-service.service.ts
@Injectable()
export class MyService {
  private data = Math.random(); // 用于区分不同实例

  getData() {
    return this.data;
  }
}

// component-a.component.ts
@Component({
  selector: 'app-component-a',
  templateUrl: './component-a.component.html',
  providers: [MyService] // 在这里提供,每个组件实例都有自己的 service
})
export class ComponentA {
  constructor(public myService: MyService) {}
}

// component-b.component.ts
@Component({
  selector: 'app-component-b',
  templateUrl: './component-b.component.html',
  providers: [MyService] // 这里会创建另一个实例
})
export class ComponentB {
  constructor(public myService: MyService) {}
}

2. 使用工厂函数创建不同实例

// configurable.service.ts
@Injectable()
export class ConfigurableService {
  constructor(private config: { prefix: string }) {}

  process(value: string): string {
    return `${this.config.prefix}: ${value}`;
  }
}

// 在模块中注册多个实例
@NgModule({
  providers: [
    // 实例1
    {
      provide: 'ServiceInstanceA',
      useFactory: () => {
        return new ConfigurableService({ prefix: 'InstanceA' });
      }
    },
    // 实例2
    {
      provide: 'ServiceInstanceB',
      useFactory: () => {
        return new ConfigurableService({ prefix: 'InstanceB' });
      }
    }
  ]
})
export class AppModule {}

// 在组件中注入特定实例
@Component({
  selector: 'app-example',
  template: `
    <div>Service A: {{ resultA }}</div>
    <div>Service B: {{ resultB }}</div>
  `
})
export class ExampleComponent {
  resultA: string;
  resultB: string;

  constructor(
    @Inject('ServiceInstanceA') private serviceA: ConfigurableService,
    @Inject('ServiceInstanceB') private serviceB: ConfigurableService
  ) {
    this.resultA = this.serviceA.process('Hello');
    this.resultB = this.serviceB.process('World');
  }
}

3. 使用 @Injectable({ providedIn: 'any' })

在 Angular 6+ 中,可以使用 providedIn: 'any'

// any-scope.service.ts
@Injectable({
  providedIn: 'any' // 每个懒加载模块都会得到新实例,但模块内是单例
})
export class AnyScopeService {
  instanceId = Math.random();
}

// 在不同懒加载模块中使用
// LazyModule1 和 LazyModule2 会得到不同的实例

4. 使用继承创建子类实例

// base.service.ts
@Injectable()
export class BaseService {
  protected baseValue = 'Base';

  getValue(): string {
    return this.baseValue;
  }
}

// 创建多个继承的子类
@Injectable()
export class ExtendedServiceA extends BaseService {
  constructor() {
    super();
    this.baseValue = 'ServiceA';
  }
}

@Injectable()
export class ExtendedServiceB extends BaseService {
  constructor() {
    super();
    this.baseValue = 'ServiceB';
  }
}

// 在模块中注册
@NgModule({
  providers: [
    ExtendedServiceA,
    ExtendedServiceB
  ]
})
export class AppModule {}

5. 动态创建实例

// dynamic-instance.service.ts
@Injectable()
export class DynamicInstanceService {
  private static instanceCounter = 0;
  private instanceId: number;

  constructor() {
    this.instanceId = ++DynamicInstanceService.instanceCounter;
  }

  getId(): number {
    return this.instanceId;
  }
}

// instance-factory.service.ts
@Injectable({
  providedIn: 'root'
})
export class InstanceFactoryService {
  createServiceInstance(): DynamicInstanceService {
    return new DynamicInstanceService();
  }
}

// 使用
@Component({
  selector: 'app-dynamic',
  template: `
    <button (click)="createInstance()">创建新实例</button>
    <div *ngFor="let service of services">
      实例 ID: {{ service.getId() }}
    </div>
  `
})
export class DynamicComponent {
  services: DynamicInstanceService[] = [];

  constructor(private factory: InstanceFactoryService) {}

  createInstance() {
    const instance = this.factory.createServiceInstance();
    this.services.push(instance);
  }
}

6. 使用 InjectionToken 和工厂模式

// 创建 InjectionToken
export const MULTI_SERVICE = new InjectionToken<MultiService[]>('MultiService');

// 服务接口
export interface MultiService {
  process(data: string): string;
}

// 实现类
@Injectable()
export class MultiServiceImpl implements MultiService {
  private id = Math.random();

  process(data: string): string {
    return `${this.id}: ${data}`;
  }
}

// 注册多个实例
@NgModule({
  providers: [
    {
      provide: MULTI_SERVICE,
      useFactory: () => new MultiServiceImpl(),
      multi: true // 关键:允许多个 provider
    },
    {
      provide: MULTI_SERVICE,
      useFactory: () => new MultiServiceImpl(),
      multi: true
    },
    {
      provide: MULTI_SERVICE,
      useFactory: () => new MultiServiceImpl(),
      multi: true
    }
  ]
})
export class AppModule {}

// 注入所有实例
@Component({
  selector: 'app-multi',
  template: `
    <div *ngFor="let service of services">
      {{ service.process('test') }}
    </div>
  `
})
export class MultiComponent {
  services: MultiService[];

  constructor(@Inject(MULTI_SERVICE) services: MultiService[]) {
    this.services = services; // 获取所有实例的数组
  }
}

7. 实际应用场景示例

场景:每个标签页需要独立的数据服务

// tab-data.service.ts
@Injectable()
export class TabDataService {
  private data: any[] = [];

  addItem(item: any) {
    this.data.push(item);
  }

  getItems() {
    return [...this.data];
  }

  clear() {
    this.data = [];
  }
}

// tab.component.ts
@Component({
  selector: 'app-tab',
  template: `
    <div class="tab">
      <input [(ngModel)]="newItem" placeholder="输入内容">
      <button (click)="addItem()">添加</button>
      <ul>
        <li *ngFor="let item of items">{{ item }}</li>
      </ul>
    </div>
  `,
  providers: [TabDataService] // 每个标签页有自己的实例
})
export class TabComponent {
  newItem = '';
  items: any[] = [];

  constructor(private tabDataService: TabDataService) {
    this.items = this.tabDataService.getItems();
  }

  addItem() {
    this.tabDataService.addItem(this.newItem);
    this.items = this.tabDataService.getItems();
    this.newItem = '';
  }
}

// tabs-container.component.ts
@Component({
  selector: 'app-tabs-container',
  template: `
    <button (click)="addTab()">添加标签页</button>
    <div *ngFor="let tab of tabs; let i = index">
      <h3>标签页 {{ i + 1 }}</h3>
      <app-tab></app-tab>
    </div>
  `
})
export class TabsContainerComponent {
  tabs: number[] = [1, 2, 3];

  addTab() {
    this.tabs.push(this.tabs.length + 1);
  }
}

8. 注意事项

内存管理

  • 组件级别的服务实例会随着组件销毁而销毁
  • 手动创建的实例需要手动管理生命周期

性能考虑

  • 多个实例会增加内存使用
  • 根据实际需求选择合适的方式

依赖注入层级

// 不同层级的注入器会创建不同的实例
@NgModule()              // 模块级单例
@Component()            // 组件级实例
@Directive()            // 指令级实例

总结

选择哪种方式取决于具体需求:

  1. 组件级隔离 → 使用 providers: [Service]
  2. 配置不同的实例 → 使用工厂函数
  3. 模块级多实例 → 使用 providedIn: 'any'
  4. 动态创建 → 使用工厂服务手动创建
  5. 同一接口多个实现 → 使用 multi: true

最常用的是在组件级别提供 Service,这样每个组件实例都会得到自己的 Service 实例,实现了完全的隔离。

注意事项

如果是急加载模块(非懒加载模块),无论在多少个模块的 providers 中声明,Service 实例都是同一个(单例)。 Angular 的依赖注入系统在应用启动时创建了一个 根注入器(Root Injector) 。所有急加载模块的 providers 都会被合并到根注入器中。

模块类型 在多个模块的 providers 中声明 实例情况
急加载模块 同一个实例(单例)
懒加载模块 不同实例(每个模块新实例)
混合情况 急加载+懒加载都有声明 急加载用根实例,懒加载用新实例

关键点:

  • 所有急加载模块共享根注入器
  • providers 声明会被合并
  • Service 在应用启动时实例化一次
  • 要实现多实例,需要使用不同的 Token 或在更细粒度级别提供

React vs Vue 调度机制深度剖析:从源码到事件循环的完整解读

本文基于 React 18.2.0Vue 3.2.45 源码分析,带你真正理解两大框架的调度设计差异。


📑 目录

  1. 事件循环:理解调度的基础
  2. Vue 3:Promise.then 微任务调度(源码解析)
  3. React 18:MessageChannel 宏任务调度(源码解析)
  4. 为什么不用 setTimeout?为什么不用 rAF?
  5. 时间切片的真正含义
  6. 可中断渲染的实现原理
  7. 两种设计的权衡与适用场景
  8. 总结

一、事件循环:理解调度的基础

在深入源码之前,必须先理解浏览器事件循环的运行机制:

┌─────────────────────────────────────────────────────────────┐
│                      一轮事件循环                            │
├─────────────────────────────────────────────────────────────┤
│  ① 执行一个宏任务(Script / setTimeout / MessageChannel)   │
│     ↓                                                       │
│  ② 清空所有微任务(Promise.then / queueMicrotask)          │
│     ↓                                                       │
│  ③ 浏览器判断是否需要渲染                                   │
│     ├─ 是 → 执行 rAF → LayoutPaintComposite           │
│     └─ 否 → 跳过渲染                                        │
│     ↓                                                       │
│  ④ 进入下一轮事件循环                                       │
└─────────────────────────────────────────────────────────────┘

🔑 关键结论

任务类型 执行时机 是否阻塞渲染
微任务 当前宏任务结束后立即执行 ✅ 会阻塞(必须清空)
宏任务 下一轮事件循环 ❌ 不阻塞(之间有渲染机会)

这个差异是 Vue 和 React 选择不同调度策略的根本原因


二、Vue 3:Promise.then 微任务调度(源码解析)

2.1 nextTick 源码

📁 源码位置:packages/runtime-core/src/scheduler.ts

// Vue 3.2.45 - packages/runtime-core/src/scheduler.ts

const resolvedPromise = Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null

export function nextTick<T = void>(
  this: T,
  fn?: (this: T) => void
): Promise<void> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

极简实现nextTick 本质就是 Promise.resolve().then(fn)

2.2 任务队列调度源码

// Vue 3.2.45 - packages/runtime-core/src/scheduler.ts

const queue: SchedulerJob[] = []
let isFlushing = false
let isFlushPending = false

export function queueJob(job: SchedulerJob) {
  // 去重:同一个 job 不会重复入队
  if (
    !queue.length ||
    !queue.includes(
      job,
      isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
    )
  ) {
    if (job.id == null) {
      queue.push(job)
    } else {
      // 按 id 排序插入(保证父组件先于子组件更新)
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()
  }
}

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    // 🔥 核心:使用 Promise.then 调度
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

2.3 任务执行源码

function flushJobs(seen?: CountMap) {
  isFlushPending = false
  isFlushing = true

  // 按 id 排序,确保:
  // 1. 父组件先于子组件更新
  // 2. 父组件的 watch 先于子组件执行
  queue.sort(comparator)

  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job && job.active !== false) {
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    flushIndex = 0
    queue.length = 0
    isFlushing = false
    currentFlushPromise = null
  }
}

2.4 Vue 更新流程图

┌──────────────────────────────────────────────────────────────┐
│ 同步代码                                                      │
│                                                              │
│   data.value = 'new'  ──→  触发 setter                       │
│                              ↓                               │
│                         queueJob(componentUpdateFn)          │
│                              ↓                               │
│                         queueFlush()                         │
│                              ↓                               │
│                   Promise.resolve().then(flushJobs)          │
│                              │                               │
└──────────────────────────────│───────────────────────────────┘
                               ↓
┌──────────────────────────────────────────────────────────────┐
│ 微任务阶段                                                    │
│                                                              │
│   flushJobs() 执行                                           │
│       ↓                                                      │
│   queue.sort()  ──→  按 id 排序                              │
│       ↓                                                      │
│   遍历执行所有 job  ──→  patch DOM                           │
│                                                              │
└──────────────────────────────────────────────────────────────┘
                               ↓
┌──────────────────────────────────────────────────────────────┐
│ 浏览器渲染                                                    │
│   Layout → Paint → Composite                                 │
└──────────────────────────────────────────────────────────────┘

2.5 Vue 选择微任务的原因

优势 说明
更新极快 数据变更后,在同一轮事件循环内完成 DOM 更新
批量优化 多次数据变更只触发一次 DOM 更新(去重 + 排序)
实现简单 不需要复杂的调度器,代码量极少
符合直觉 同步代码执行完毕后,DOM 立即反映最新状态

Vue 的设计哲学:响应式系统 + 编译优化已经让 patch 开销足够小,不需要可中断渲染。


三、React 18:MessageChannel 宏任务调度(源码解析)

3.1 调度入口源码

📁 源码位置:packages/scheduler/src/forks/Scheduler.js

// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js

let schedulePerformWorkUntilDeadline;

if (typeof MessageChannel !== 'undefined') {
  // 🔥 核心:使用 MessageChannel 调度宏任务
  const channel = new MessageChannel();
  const port = channel.port2;
  
  channel.port1.onmessage = performWorkUntilDeadline;
  
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // 降级方案:setTimeout
  schedulePerformWorkUntilDeadline = () => {
    setTimeout(performWorkUntilDeadline, 0);
  };
}

注意:React 只用 MessageChannel,不依赖 requestAnimationFrame

3.2 时间切片核心源码

// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js

// 每个时间切片的默认时长:5ms
let frameInterval = 5;

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    
    // 🔥 关键:计算本次切片的截止时间
    startTime = currentTime;
    const hasTimeRemaining = true;
    
    let hasMoreWork = true;
    try {
      // 执行任务,返回是否还有剩余任务
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      if (hasMoreWork) {
        // 🔥 还有任务,调度下一个宏任务继续执行
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
};

3.3 任务循环与时间检查

// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js

function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (
      currentTask.expirationTime > currentTime &&
      // 🔥 关键:检查是否应该让出控制权
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // 时间片用完,跳出循环,让出主线程
      break;
    }
    
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      
      const didUserCallbackTimeout = 
        currentTask.expirationTime <= currentTime;
      
      // 执行任务
      const continuationCallback = callback(didUserCallbackTimeout);
      
      currentTime = getCurrentTime();
      
      if (typeof continuationCallback === 'function') {
        // 任务未完成,保留继续执行
        currentTask.callback = continuationCallback;
      } else {
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
      
      advanceTimers(currentTime);
    } else {
      pop(taskQueue);
    }
    
    currentTask = peek(taskQueue);
  }
  
  // 返回是否还有剩余任务
  return currentTask !== null;
}

3.4 让出控制权的判断逻辑

// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js

function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  
  if (timeElapsed < frameInterval) {
    // 时间片未用完,继续执行
    return false;
  }
  
  // 🔥 时间片用完,应该让出控制权
  // 让浏览器有机会渲染和响应用户输入
  return true;
}

3.5 React 更新流程图

┌──────────────────────────────────────────────────────────────┐
│ 同步代码                                                      │
│                                                              │
│   setState({ count: 1 })  ──→  创建 Update 对象              │
│                                  ↓                           │
│                           scheduleUpdateOnFiber()            │
│                                  ↓                           │
│                           ensureRootIsScheduled()            │
│                                  ↓                           │
│                    scheduleCallback(bindPerformWork)         │
│                                  ↓                           │
│                    MessageChannel.postMessage()              │
│                                  │                           │
└──────────────────────────────────│───────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────┐
│ 微任务阶段(如有)                                            │
│   清空微任务队列                                              │
└──────────────────────────────────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────┐
│ 🎨 浏览器渲染机会                                             │
│   可能发生:Layout / Paint / 用户输入响应                     │
└──────────────────────────────────────────────────────────────┘
                                   ↓
┌──────────────────────────────────────────────────────────────┐
│ 下一个宏任务                                                  │
│                                                              │
│   performWorkUntilDeadline()                                 │
│       ↓                                                      │
│   workLoop()  ──→  执行 Fiber 任务                           │
│       ↓                                                      │
│   shouldYieldToHost()  ──→  检查是否需要让出                 │
│       ├─ 是 → postMessage() 调度下一个切片                   │
│       └─ 否 → 继续执行当前任务                               │
│                                                              │
└──────────────────────────────────────────────────────────────┘
                                   ↓
                            (循环直到完成)
                                   ↓
┌──────────────────────────────────────────────────────────────┐
│ Commit 阶段                                                   │
│   同步执行,不可中断                                          │
│   真正修改 DOM                                                │
└──────────────────────────────────────────────────────────────┘

四、为什么不用 setTimeout?为什么不用 rAF?

4.1 为什么不用 setTimeout(fn, 0)

// React 源码注释 - packages/scheduler/src/forks/Scheduler.js

// setTimeout 的问题:
// 1. 浏览器规范规定嵌套调用超过 5 次后,最小延迟为 4ms
// 2. 这会导致调度延迟累积,影响性能

// 实验证明:
setTimeout(() => {
  setTimeout(() => {
    setTimeout(() => {
      setTimeout(() => {
        setTimeout(() => {
          // 这里开始,每次调用至少延迟 4ms
          console.log('delay >= 4ms');
        }, 0);
      }, 0);
    }, 0);
  }, 0);
}, 0);

MessageChannel 的优势:没有 4ms 的最小延迟限制,可以更精确地控制时间切片。

4.2 为什么不用 requestAnimationFrame

// 很多文章误以为 React 使用 rAF,实际上:

// ❌ 不使用 rAF 的原因:
// 1. rAF 受浏览器刷新率限制(通常 16.67ms 一次)
// 2. 后台标签页中 rAF 会暂停
// 3. rAF 的触发时机不稳定

// ✅ MessageChannel 的优势:
// 1. 不受帧率限制
// 2. 后台标签页仍然工作
// 3. 调度时机更可控

4.3 源码中的降级策略

// React 18.2.0 - packages/scheduler/src/forks/Scheduler.js

let schedulePerformWorkUntilDeadline;

if (typeof MessageChannel !== 'undefined') {
  // 首选:MessageChannel
  const channel = new MessageChannel();
  // ...
} else {
  // 降级:setTimeout(仅在不支持 MessageChannel 的环境)
  schedulePerformWorkUntilDeadline = () => {
    setTimeout(performWorkUntilDeadline, 0);
  };
}

五、时间切片的真正含义

5.1 误解澄清

很多文章展示这样的示意图:

[chunk1] — 渲染 — [chunk2] — 渲染 — [chunk3] — 渲染 — ...

这是不准确的!正确的理解是:

[chunk1] — 渲染机会 — [chunk2] — 渲染机会 — [chunk3] — 渲染机会 — ...
          ↑                    ↑                    ↑
        可能渲染             可能渲染             可能渲染
        可能响应输入         可能响应输入         可能响应输入
        可能什么都不做       可能什么都不做       可能什么都不做

5.2 浏览器决定是否渲染

浏览器在每个宏任务之间会判断是否需要渲染:

  • 有 DOM 变更需要反映?→ 渲染
  • 有动画帧需要更新?→ 渲染
  • 没有视觉变化?→ 跳过渲染,直接执行下一个宏任务

5.3 时间切片的核心价值

价值 说明
响应用户输入 长任务期间,用户点击/输入可以被处理
保持动画流畅 动画帧可以在切片之间执行
避免页面卡死 复杂渲染不会阻塞整个页面

5.4 实验验证

// 验证微任务会阻塞渲染
function blockWithMicrotasks() {
  let count = 0;
  function task() {
    const start = performance.now();
    while (performance.now() - start < 10) {} // 阻塞 10ms
    count++;
    if (count < 100) {
      Promise.resolve().then(task); // 微任务
    }
  }
  Promise.resolve().then(task);
}
// 结果:页面完全卡住 ~1 秒

// 验证宏任务之间有渲染机会
function blockWithMacrotasks() {
  const channel = new MessageChannel();
  let count = 0;
  
  channel.port1.onmessage = () => {
    const start = performance.now();
    while (performance.now() - start < 10) {} // 阻塞 10ms
    count++;
    if (count < 100) {
      channel.port2.postMessage(null); // 宏任务
    }
  };
  channel.port2.postMessage(null);
}
// 结果:页面仍然可以响应,动画流畅

5.5 🚨 常见误解澄清(很多前端不知道的真相)

这是事件循环中一个常见盲区——很多人背诵"宏任务→微任务→渲染",但错误地理解成"每次都渲染"。

误解 vs 真相

误解 真相
"每个宏任务后都会渲染" ❌ 浏览器按需渲染,可能连续执行多个宏任务后才渲染一次
"60fps = 每 16.67ms 必渲染" ❌ 没有视觉变化时浏览器会跳过,省电省资源
"rAF 每 16.67ms 触发一次" ❌ 后台标签页可能降到 1fps 甚至完全暂停
"JS 可以强制浏览器渲染" ❌ JS 只能"请求",浏览器自己决定何时渲染

浏览器的渲染决策流程

宏任务 A 完成
    ↓
微任务清空
    ↓
┌─────────────────────────────────────┐
│  浏览器判断:需要渲染吗?            │
│                                     │
│  ✅ 需要渲染的情况:                 │
│    • 有 DOM 变更待反映              │
│    • 有 CSS 动画/过渡在进行          │
│    • 有 rAF 回调等待执行             │
│    • 距离上次渲染已过 ~16.67ms       │
│                                     │
│  ❌ 跳过渲染的情况:                 │
│    • 没有任何视觉变化               │
│    • 距离上次渲染太短(<16.67ms)    │
│    • 页面在后台标签页               │
└─────────────────────────────────────┘
    ↓
宏任务 B 开始

关键点:这个决策完全由浏览器控制,JavaScript 无法干预。

实验证明

// 如果每个宏任务后都渲染,1000 个宏任务需要 16.67s
const channel = new MessageChannel();
let count = 0;
const start = performance.now();

channel.port1.onmessage = () => {
  count++;
  if (count < 1000) {
    channel.port2.postMessage(null);
  } else {
    console.log(`完成:${performance.now() - start}ms`);
    // 实际结果:~10-20ms
    // 证明:绝大多数宏任务之间没有发生渲染
  }
};
channel.port2.postMessage(null);

为什么这个知识点重要?

理解这点后,你会真正明白:

  1. React 时间切片不是"让浏览器每次都渲染" → 而是"给浏览器机会做它想做的事"(渲染、响应输入、或什么都不做)

  2. rAF 不是定时器 → 而是浏览器说"我决定要渲染了,你有啥要准备的吗?"

  3. 性能优化的本质 → 不是"让渲染更快",而是"别挡着浏览器的路"


六、可中断渲染的实现原理

6.1 Fiber 架构是基础

// React Fiber 节点结构(简化)
interface Fiber {
  type: any;
  child: Fiber | null;      // 第一个子节点
  sibling: Fiber | null;    // 下一个兄弟节点
  return: Fiber | null;     // 父节点
  // ...
}

// Fiber 树可以通过链表遍历,随时暂停和恢复

6.2 workLoopConcurrent 源码

// React 18.2.0 - packages/react-reconciler/src/ReactFiberWorkLoop.js

function workLoopConcurrent() {
  // 🔥 关键:每处理一个 Fiber 节点,都检查是否需要让出
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;
  
  // beginWork: 处理当前节点
  let next = beginWork(current, unitOfWork, renderLanes);
  
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  
  if (next === null) {
    // 没有子节点,完成当前节点
    completeUnitOfWork(unitOfWork);
  } else {
    // 有子节点,下次处理子节点
    workInProgress = next;
  }
}

6.3 中断与恢复的流程

                    开始渲染
                       ↓
┌──────────────────────────────────────┐
│  workLoopConcurrent()                │
│                                      │
│  while (workInProgress && !yield) {  │
│    performUnitOfWork(fiber)          │
│  }                                   │
│                                      │
│  ← 时间片用完,shouldYield() = true  │
└──────────────────────────────────────┘
                       ↓
               保存 workInProgress
                       ↓
             postMessage 调度下一个切片
                       ↓
              浏览器渲染/响应输入
                       ↓
┌──────────────────────────────────────┐
│  下一个切片继续从 workInProgress     │
│  继续遍历 Fiber 树                   │
└──────────────────────────────────────┘

七、两种设计的权衡与适用场景

7.1 核心对比

维度 Vue 3 (Promise.then) React 18 (MessageChannel)
调度类型 微任务 宏任务
更新延迟 ~0ms(同一轮事件循环) ~0-5ms(下一轮事件循环)
可中断 ❌ 不可中断 ✅ 可中断
优先级调度 ❌ 无 ✅ 有(Lane 模型)
时间切片 ❌ 无 ✅ 有(5ms 切片)
实现复杂度 简单(~200 行) 复杂(~1000+ 行)
调试难度

7.2 适用场景

Vue 更适合

  • 中小型应用
  • 表单/列表/CRUD 类应用
  • 需要快速响应的交互
  • 团队规模较小

React 更适合

  • 大型复杂应用
  • 高频动画 + 复杂 UI 同时存在
  • 需要 Suspense 数据加载
  • 有并发渲染需求

7.3 Vue 为什么不需要时间切片?

Vue 的优化策略:
                                           
┌─────────────────────────────────────────┐
│  编译时优化                              │
│  • 静态节点提升                          │
│  • patchFlag 标记动态节点                │
│  • 事件处理函数缓存                      │
└─────────────────────────────────────────┘
              ↓ 结合
┌─────────────────────────────────────────┐
│  响应式精确追踪                          │
│  • 只更新依赖变化的组件                  │
│  • 不需要 shouldComponentUpdate          │
└─────────────────────────────────────────┘
              ↓ 结果
┌─────────────────────────────────────────┐
│  patch 开销极小                          │
│  • 大多数情况下 < 1ms                    │
│  • 不需要可中断渲染                      │
└─────────────────────────────────────────┘

八、总结

📊 一图总结

┌─────────────────────────────────────────────────────────────────┐
│                        事件循环与框架调度                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Vue 3                          React 18                        │
│  ┌─────────────┐                ┌─────────────┐                 │
│  │ setState    │                │ setState    │                 │
│  └──────┬──────┘                └──────┬──────┘                 │
│         ↓                              ↓                        │
│  ┌─────────────┐                ┌─────────────┐                 │
│  │ queueJob    │                │ schedule    │                 │
│  │ (去重/排序) │                │ Callback    │                 │
│  └──────┬──────┘                └──────┬──────┘                 │
│         ↓                              ↓                        │
│  ┌─────────────┐                ┌─────────────┐                 │
│  │ Promise     │                │ Message     │                 │
│  │ .then()     │                │ Channel     │                 │
│  │ (微任务)    │                │ (宏任务)    │                 │
│  └──────┬──────┘                └──────┬──────┘                 │
│         ↓                              ↓                        │
│  ┌─────────────┐                ┌─────────────┐                 │
│  │ flushJobs   │                │ 浏览器渲染  │ ← 渲染机会!    │
│  │ patch DOM   │                │ 机会        │                 │
│  └──────┬──────┘                └──────┬──────┘                 │
│         ↓                              ↓                        │
│  ┌─────────────┐                ┌─────────────┐                 │
│  │ 浏览器渲染  │                │ workLoop    │                 │
│  └─────────────┘                │ Fiber 处理  │                 │
│                                 │ (可中断)    │                 │
│                                 └──────┬──────┘                 │
│                                        ↓                        │
│                                 ┌─────────────┐                 │
│                                 │ 时间片用完? │                 │
│                                 │ postMessage │                 │
│                                 │ 继续调度    │                 │
│                                 └─────────────┘                 │
│                                                                 │
│  特点:快速、简单、不可中断     特点:可中断、可调度、复杂       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

🎯 核心结论

  1. Vue 使用 Promise.then(微任务)

    • 更新极快,同一轮事件循环内完成
    • 简单高效,编译优化 + 响应式让 patch 开销很小
    • 不需要可中断,因为不需要
  2. React 使用 MessageChannel(宏任务)

    • 宏任务之间有渲染机会,不阻塞页面
    • 支持时间切片(5ms 一个切片)
    • 支持可中断渲染(Fiber 架构)
    • 不使用 rAF(常见误解!)
  3. 选择差异源于设计目标不同

    • Vue:追求简单高效,响应式 + 编译优化解决性能问题
    • React:追求可控调度,Fiber + Scheduler 解决复杂场景
  4. 没有绝对的优劣

    • 小型应用:Vue 的同步更新更直观
    • 大型应用:React 的调度能力更强

📚 参考资料

  1. Vue 3 Scheduler 源码
  2. React Scheduler 源码
  3. HTML Living Standard - Event Loop
  4. MessageChannel MDN 文档

💡 作者注:本文所有源码引用基于 React 18.2.0 和 Vue 3.2.45,如有更新请以官方仓库为准。

如何用 vxe-table 实现粘贴数据自动进入新增行与新增列

如何用 vxe-table 实现粘贴数据自动进入新增行与新增列,数据无限扩充,对于大部分业务操作场景,有时需要从 excel 复制数据并粘贴到表格中,由于粘贴的数据会列多于表格定义的行与列,多出的数据需要能支持自动新增与自行新增列,vxe-table提供非常简单的配置方式可以直接支持。

自动新增行

当粘贴数据时,如果粘贴的行数超过表格的行数,可以通过 clip-config.isRowIncrement 自动新增临时行

table_clip_increment_rows

<template>
  <div>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

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

const gridOptions = reactive({
  border: true,
  height: 400,
  showOverflow: true,
  keepSource: true,
  columnConfig: {
    resizable: true
  },
  mouseConfig: {
    area: true // 是否开启区域选取
  },
  areaConfig: {
    multiple: true // 是否启用多区域选取功能
  },
  editConfig: {
    mode: 'cell', // 单元格编辑模式
    trigger: 'dblclick', // 双击单元格激活编辑状态
    showStatus: true // 显示数据编辑状态
  },
  keyboardConfig: {
    isClip: true, // 是否开启复制粘贴
    isEdit: true, // 是否开启任意键进入编辑(功能键除外)
    isDel: true, // 是否开启删除键功能
    isEsc: true // 是否开启Esc键关闭编辑功能
  },
  clipConfig: {
    isRowIncrement: true // 如果粘贴的行数超过表格的行数,自动新增临时行
    // createRowsMethod ({ insertRows, pasteCells }) {
    //   console.log(pasteCells)
    //   // 自定义返回新的行数据
    //   return insertRows
    // }
  },
  columns: [
    { type: 'seq', fixed: 'left', width: 60 },
    { field: 'name', fixed: 'left', title: 'name', editRender: { name: 'input' } },
    { field: 'role', title: 'Role', editRender: { name: 'input' } },
    { field: 'sex', title: 'sex', editRender: { name: 'input' } },
    { field: 'num', title: 'Num', editRender: { name: 'input' } },
    { field: 'age', title: 'age', editRender: { name: 'input' } },
    { field: 'address', title: 'Address', width: 200, editRender: { name: 'input' } }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', num: 23, age: 28, address: 'Shengzhen' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', num: 23, age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', num: 23, age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', num: 456, age: 24, address: 'Shanghai' },
    { id: 10005, name: 'Test5', role: 'Designer', sex: 'Women', num: 23, age: 42, address: 'Guangzhou' },
    { id: 10006, name: 'Test6', role: 'Designer', sex: 'Man', num: 23, age: 38, address: 'Shengzhen' },
    { id: 10007, name: 'Test7', role: 'Test', sex: 'Women', num: 100, age: 24, address: 'Shengzhen' },
    { id: 10008, name: 'Test8', role: 'PM', sex: 'Man', num: 345, age: 34, address: 'Shanghai' }
  ]
})
</script>

自动新增列

当粘贴数据时,如果粘贴的列数超过表格的列数时,可以通过 clip-config.isColumnIncrement 自动新增临时列

table_clip_increment_cols

<template>
  <div>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

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

const gridOptions = reactive({
  border: true,
  height: 400,
  showOverflow: true,
  keepSource: true,
  columnConfig: {
    resizable: true
  },
  mouseConfig: {
    area: true // 是否开启区域选取
  },
  areaConfig: {
    multiple: true // 是否启用多区域选取功能
  },
  editConfig: {
    mode: 'cell', // 单元格编辑模式
    trigger: 'dblclick', // 双击单元格激活编辑状态
    showStatus: true // 显示数据编辑状态
  },
  keyboardConfig: {
    isClip: true, // 是否开启复制粘贴
    isEdit: true, // 是否开启任意键进入编辑(功能键除外)
    isDel: true, // 是否开启删除键功能
    isEsc: true // 是否开启Esc键关闭编辑功能
  },
  clipConfig: {
    isColumnIncrement: true // 如果粘贴的列数超过表格的列数时,自动新增临时列
    // createColumnsMethod ({ insertColumns, pasteCells }) {
    //   console.log(pasteCells)
    //   // 自定义返回新的列配置
    //   return insertColumns
    // }
  },
  columns: [
    { type: 'seq', fixed: 'left', width: 60 },
    { field: 'name', fixed: 'left', title: 'name', editRender: { name: 'input' } },
    { field: 'role', title: 'Role', editRender: { name: 'input' } },
    { field: 'sex', title: 'sex', editRender: { name: 'input' } },
    { field: 'num', title: 'Num', editRender: { name: 'input' } },
    { field: 'age', title: 'age', editRender: { name: 'input' } },
    { field: 'address', title: 'Address', width: 200, editRender: { name: 'input' } }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', num: 23, age: 28, address: 'Shengzhen' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', num: 23, age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', num: 23, age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', num: 456, age: 24, address: 'Shanghai' },
    { id: 10005, name: 'Test5', role: 'Designer', sex: 'Women', num: 23, age: 42, address: 'Guangzhou' },
    { id: 10006, name: 'Test6', role: 'Designer', sex: 'Man', num: 23, age: 38, address: 'Shengzhen' },
    { id: 10007, name: 'Test7', role: 'Test', sex: 'Women', num: 100, age: 24, address: 'Shengzhen' },
    { id: 10008, name: 'Test8', role: 'PM', sex: 'Man', num: 345, age: 34, address: 'Shanghai' }
  ]
})
</script>

vxetable.cn

Angular中懒加载模块的加载顺序总结

在 Angular 应用中,懒加载模块的加载顺序是基于路由导航的按需加载机制。以下是详细的加载顺序和过程:

1. 初始加载阶段

当用户首次访问 Angular 应用时:

  • 主模块(AppModule)和所有急加载模块被打包到 main.js 中
  • 懒加载模块被分离成独立的 chunk 文件

2. 路由导航触发加载

当用户导航到懒加载路由时:

触发时机:

const routes: Routes = [
  {
    path: 'lazy',
    loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule)
  }
];

加载流程:

用户点击链接/导航 → Angular 路由检测到懒加载配置 → 
开始下载对应的 chunk 文件 → 文件下载完成 → 
模块被实例化 → 组件渲染

3. 实际加载顺序示例

项目结构:

src/
├── app/
│   ├── app.module.ts
│   ├── app-routing.module.ts
├── feature1/
│   ├── feature1.module.ts
│   └── feature1-routing.module.ts
├── feature2/
│   ├── feature2.module.ts
│   └── feature2-routing.module.ts

编译后的文件结构:

dist/
├── index.html
├── main.js                    # 主应用包
├── polyfills.js
├── runtime.js
├── styles.css
├── chunk-feature1.js         # 懒加载模块1
├── chunk-feature2.js         # 懒加载模块2
├── chunk-common.js           # 共享依赖
└── assets/

4. 具体加载顺序

第一次访问应用:

  1. 加载主包文件

    runtime.js → polyfills.js → main.js → styles.css
    
  2. 应用初始化

    • AppComponent 被初始化
    • 急加载的组件和模块被注册

导航到 /feature1:

// 当路由匹配到 feature1 时
http://localhost:4200/feature1

加载顺序:

  1. 检查路由配置 - 发现是懒加载路由
  2. 下载 chunk 文件 - 开始下载 chunk-feature1.js
  3. 模块实例化 - Feature1Module 被 Angular 编译器实例化
  4. 组件渲染 - Feature1Component 被渲染

预加载策略的影响

Angular 提供了不同的预加载策略:

默认策略(NoPreloading)

  • 只在需要时才加载懒加载模块
  • 顺序:用户导航 → 下载 → 实例化

预加载所有模块(PreloadAllModules)

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      preloadingStrategy: PreloadAllModules
    })
  ]
})

加载顺序:

  1. 主应用加载完成
  2. 空闲时后台下载所有懒加载模块
  3. 用户导航时立即实例化,无需等待下载

自定义预加载策略

@Injectable()
export class CustomPreloading implements PreloadingStrategy {
  preload(route: Route, load: Function): Observable<any> {
    return route.data && route.data.preload ? load() : of(null);
  }
}

5. Chunk 文件的命名和依赖

默认命名:

  • chunk-[contenthash].js(基于内容哈希)

自定义命名:

// angular.json
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "outputHashing": "all",
            "namedChunks": true  // 启用命名chunks
          }
        }
      }
    }
  }
}

共享依赖:

如果多个懒加载模块使用相同的第三方库,Webpack 会:

  1. 提取公共依赖到单独的 chunk(如 chunk-vendors.js
  2. 确保依赖只加载一次

6. 网络瀑布流示例

浏览器开发者工具 Network 标签显示:

Initial Load:
├── runtime.js (立即)
├── polyfills.js (并行)
├── main.js (并行)
└── styles.css (并行)

Navigation to /feature1:
└── chunk-feature1.js (按需)

Navigation to /feature2:
└── chunk-feature2.js (按需)

7. 优化建议

代码分割:

// 将大型组件单独懒加载
const routes: Routes = [
  {
    path: 'reports',
    loadChildren: () => import('./reports/reports.module')
      .then(m => m.ReportsModule)
  }
];

预加载关键模块:

const routes: Routes = [
  {
    path: 'dashboard',
    loadChildren: () => import('./dashboard/dashboard.module')
      .then(m => m.DashboardModule),
    data: { preload: true }  // 自定义预加载
  }
];

使用路由守卫控制加载:

const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.module')
      .then(m => m.AdminModule),
    canLoad: [AuthGuard]  // 条件加载
  }
];

总结

懒加载模块的加载顺序原则:

  1. 按需加载 - 只在路由导航时触发
  2. 异步下载 - 通过网络获取 chunk 文件
  3. 按序实例化 - 下载完成后 Angular 实例化模块
  4. 缓存机制 - 已加载的模块不会重复下载

这种机制显著改善了:

  • 初始加载性能(减小主包体积)
  • 用户体验(快速首屏显示)
  • 资源利用率(只加载需要的代码)

写Tailwind CSS像在写屎山?这锅该不该它背

我上次在群里吐槽Tailwind,被几个大佬围攻了:“现在还在写传统CSS的怕不是还在用jQuery?”、“都2025年了还用BEM?”,整得我都不敢说话了。

作为一个前端搬砖工,我从Nodejs到React再到Vue都踩过一遍坑,今天就跟大伙儿聊聊这个让我又爱又恨的Tailwind。

一、为什么我觉得Tailwind有时候真的很操蛋

1. 这HTML还能看吗?

这是我第一次看到Tailwind代码的反应:

<div class="flex flex-col md:flex-row items-center justify-between p-4 md:p-6 lg:p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-300">
  <!-- 还有一堆嵌套div,每个都带着几十个类名 -->
</div>

同事问我:“这坨代码什么意思?”我看了半天说:“一个卡片,会动,能响应式,深色模式适配了……”但我心里想的是:这TM跟当年在HTML里写style="color: red; font-size: 14px;"有啥本质区别?

2. 接手别人的Tailwind项目有多痛苦

上个月接了个离职同事的项目,打开一看差点没背过气去:

<div className={`px-${size === 'large' ? 6 : size === 'small' ? 2 : 4} py-${hasIcon ? 3 : 2} ${variant === 'primary' ? 'bg-blue-500' : 'bg-gray-200'} ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-90'}`}>
  {/* 还有50行类似的代码 */}
</div>

这种动态拼接类名的操作,让我调试的时候想砸键盘。查了半天发现有个按钮在某种状态下padding不对,原来是px-${size}这种骚操作导致的。

3. 这玩意真的能提高开发效率吗?

老板跟我说:“用Tailwind开发速度快啊!”但真实情况是:

  • 边写边查文档:m-4p-4到底哪个是margin哪个是padding?mt-4mr-4又是啥?
  • 遇到复杂布局:用flex还是grid?Tailwind的grid类名又长又难记
  • 调个细节样式:想微调一个阴影,得查半天文档才知道shadow-lgshadow-xl的区别

有这查文档的时间,我CSS早写完了。

二、但为什么大佬们都在吹爆Tailwind?

1. 等我真的用起来之后……

两个月后,当我对常用类名烂熟于心后,发现有些场景真香:

快速原型开发:产品经理站我身后:“这里改个间距,那里调个颜色,这个按钮hover效果换一下……”

以前:切到CSS文件 -> 找到对应的类 -> 修改 -> 切回来预览 -> 重复 现在:直接在HTML里改几个类名 -> 实时预览

设计一致性:以前团队里每个开发者对“大间距”的理解都不一样,有人写margin: 20px,有人写margin: 24px,还有人写margin: 1.5rem。现在统一用m-5m-6,UI终于统一了。

2. 性能确实牛逼

我原来不信,直到对比了项目打包后的CSS文件大小:

  • 之前的项目(手写CSS):main.css 87KB
  • 现在的项目(Tailwind + JIT):main.css 12KB

因为Tailwind只生成你用到的样式,不会有未使用的CSS代码。

3. 再也不用想类名了

还记得那些年被BEM命名支配的恐惧吗?

.card {}
.card__header {}
.card__header--active {}
.card__body {}
.card__footer {}
.card__footer__button {}
.card__footer__button--disabled {}

现在?直接写样式就行了,不用再想header-wrapper-inner-content这种傻逼名字了。

三、我从抗拒到真香的转变

转折点是我开始用正确的方式写Tailwind

错误示范 ❌

// 直接把所有类名堆在组件里
function BadButton() {
  return (
    <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
      提交
    </button>
  );
}

正确姿势 ✅

// 1. 先封装基础组件
function Button({ 
  children, 
  variant = 'primary',
  size = 'medium',
  fullWidth = false 
}) {
  const baseClasses = "font-bold rounded transition-colors";
  
  const variants = {
    primary: "bg-blue-500 hover:bg-blue-700 text-white",
    secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800",
    danger: "bg-red-500 hover:bg-red-700 text-white"
  };
  
  const sizes = {
    small: "py-1 px-3 text-sm",
    medium: "py-2 px-4",
    large: "py-3 px-6 text-lg"
  };
  
  const widthClass = fullWidth ? "w-full" : "";
  
  return (
    <button className={`${baseClasses} ${variants[variant]} ${sizes[size]} ${widthClass}`}>
      {children}
    </button>
  );
}

// 2. 使用 cva 库管理变体(更优雅)
import { cva } from 'class-variance-authority';

const buttonVariants = cva(
  "font-bold rounded transition-colors", // 基础样式
  {
    variants: {
      variant: {
        primary: "bg-blue-500 hover:bg-blue-700 text-white",
        secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800",
      },
      size: {
        small: "py-1 px-3 text-sm",
        medium: "py-2 px-4",
      }
    },
    defaultVariants: {
      variant: "primary",
      size: "medium"
    }
  }
);

// 3. 实际使用
function GoodButton() {
  return (
    <Button variant="primary" size="large">
      提交
    </Button>
  );
}

四、什么时候该用,什么时候不该用

赶紧用起来吧 👍

  1. 新项目,尤其是React/Vue/Svelte项目:组件化能很好解决Tailwind的可维护性问题
  2. 需要统一设计规范:设计系统配好了,大家就按这个来,别TM再自己发挥了
  3. 内部管理系统、后台项目:快速迭代,老板天天改需求,这种场景Tailwind无敌
  4. 团队协作项目:不用再解释为什么这里用margin-top: 8px而不是10px

算了,别用了 ❌

  1. 静态小网站:就几个页面,写点CSS完事了,别折腾
  2. 老项目迁移:除非你想加班加到死
  3. 完全不懂CSS的新手:Tailwind不是CSS的替代品,它是工具。连CSS盒模型都不懂就用Tailwind,等于不会开车就用自动驾驶
  4. 设计师天马行空:如果你们设计师每个页面风格都不一样,用Tailwind配置会把你逼疯

五、我总结的血泪经验

  1. 不要直接在JSX里堆类名:这是所有屎山的源头!一定一定要封装成组件
  2. 配置好自己的设计系统:别用默认配置,根据项目需求配一套自己的tailwind.config.js
  3. 善用 @apply:重复出现的样式组合,用@apply提取
/* 在CSS文件中 */
.btn-primary {
  @apply bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded;
}
  1. 结合现代工具链clsx处理条件类名,tailwind-merge解决类名冲突
  2. 定期重构:发现重复的样式组合就抽象,别懒!

最后说句实话

用不用Tailwind,其实跟你用什么技术关系不大,关键看你怎么用。

那些说Tailwind垃圾的,多半是看到了滥用它的项目;那些吹爆Tailwind的,多半是用对了方法。

就像当年大家吵jQuery和原生JS,吵React和Vue一样,最后你会发现:工具没有对错,只有适不适合。 牛逼的程序员用记事本都能写出好代码,菜鸡用再牛逼的框架也能写出屎山。

所以,别吵了,赶紧去写代码吧。老板又改需求了,今天还得加班呢。


关注公众号" 大前端历险记",掌握更多前端开发干货姿势!

微信小程序页面栈:从一个 Bug 讲到彻底搞懂

微信小程序页面栈:从一个 Bug 讲到彻底搞懂

上周上线前,测试同事丢过来一个非常诡异的 bug:

从首页一路点下去,点到差不多第十层,再点“下一步”按钮就没反应了
按钮动画有,点击态有,就是页面完全不跳。

当时这条链路大概是:

tab 首页 → 列表 → 详情 → 子详情 1 → 子详情 2 → … → 一路 wx.navigateTo 叠上去

1. 第一步:怀疑是按钮/事件问题

我们第一反应肯定是前端老三样:
事件没绑上 / 被遮挡 / 防抖拦截 / 状态锁死

于是:

// 按钮绑定的点击事件里
handleNext() {
  console.log('[next] clicked')
  wx.showToast({ title: '点击到了', icon: 'none' })

  wx.navigateTo({
    url: '/pages/step-next/index',
  })
}

回到现场重现:

  • Toast 正常弹出 ✅
  • 控制台 clicked 正常打印 ✅
  • wx.navigateTo 确实被执行 ✅
  • 就是页面不跳

事件层的问题基本排除。

2. 第二步:加 fail 回调,看 errMsg 到底说啥

直觉告诉我:要么是路由层面限制,要么是宿主直接拒绝了这次跳转。

于是把 navigateTo 改成这样:

wx.navigateTo({
  url: '/pages/step-next/index',
  success() {
    console.log('[next] navigateTo success')
  },
  fail(err) {
    console.error('[next] navigateTo fail', err)
    wx.showToast({
      title: '跳转失败',
      icon: 'none'
    })
  }
})

再重现一次,这次控制台终于给了关键线索:

[next] navigateTo fail 
{errMsg: "navigateTo:fail webview count limit exceed"}

大概意思就是:webview 数量超限了

同时我顺手在点击前后打了一行:

console.log('[next] pages length', getCurrentPages().length)

打印出来是:

[next] pages length 10

到这就非常清晰了:

  • navigateTo 一次就多叠一层页面
  • 叠到第 10 层之后,再 navigateTo,直接被框架拒绝
  • fail 里给了 webview count limit exceed 这样的错误
  • 在页面上表现出来,就是**“按钮点击没报错,但页面就是不跳”**

这个坑,很多小程序其实都踩过。
真正的核心,就是 “页面栈” 有上限

接下来,我们就围绕这个真实问题,把“页面栈”这个概念彻底讲清楚。


一、页面栈到底是什么?

在小程序里,可以把所有正在存在的页面实例,想象成一个有顺序的数组:

  • 栈底:用户最早进入的页面(通常是首页)
  • 栈顶:当前正在展示的页面
  • 这个数组就是:页面栈(page stack)

用伪代码抽象一下(方便你在脑子里跑):

// 抽象理解,并非真实源码
let pageStack = []

// 1)小程序启动 → 进入首页
pageStack.push(new Page('/pages/index/index'))  // [index]

// 2)首页 → 详情
pageStack.push(new Page('/pages/detail/detail')) // [index, detail]

// 3)详情 → 返回首页
pageStack.pop()  // [index]

有两个硬规则

  1. 页面栈中的每一项都是一个“活着的页面实例”;
    只要还在栈里,它的 data、方法、状态都在内存里。

  2. 页面栈最多只能有 10 个页面
    getCurrentPages().length === 10 时,再 wx.navigateTo

    • 不会再创建新页面
    • 本次跳转失败
    • 控制台/fail 里会看到类似
      navigateTo:fail webview count limit exceed 这样的错误

开头那个真实 bug,本质就是:一路 navigateTo 堆满了 10 层页面栈


二、别把“页面栈”和 WebView 历史搞混了

在讲 API 之前,有一个经常让人困惑的问题:

“既然页面栈是栈结构,只能返回,那我在某些页面里还能右滑‘前进’,这又怎么解释?”

这里其实有两套完全不同的“历史系统”:

1)小程序页面栈(本文主角)

  • 管的是:小程序每一个 Page 页面实例
  • 操作它的是:navigateTo / navigateBack / redirectTo / reLaunch / switchTab
  • 特点:只存在“后退”(navigateBack),没有“前进”这个概念

2)WebView / H5 浏览器历史

  • 当你在小程序里用 <web-view> 嵌入 H5 时,里面其实是浏览器
  • 浏览器有自己的 history.back() / history.forward()
  • iOS/Android 的 WebView 还会给你左滑后退/右滑前进的手势

也就是说:

  • 小程序页面栈管的是:
    A(Page) → B(Page) → navigateBack → A(Page)
  • H5 历史管的是:
    WebView 里那几层 URL 的前进后退

你在某些场景下面到的“右滑前进”,很有可能只是 WebView 那一层在前进,小程序的页面栈根本没变

后面我们说的所有“压栈/出栈”,指的都是小程序这层的页面栈。


三、五个路由 API 和页面栈的关系:先看一张总表

把小程序路由 API 都翻译成“对页面栈的操作”:

API 对页面栈的抽象操作 常见用途
navigateTo 在栈顶 push 一个新页面 A 打开 B,可以返回到 A
navigateBack 从栈顶 pop 掉 N 个页面 返回上一页 / 多级返回
redirectTo 弹出当前页,再 push 一个新页面 当前页不该再返回时,用来“换页”
reLaunch 清空整个栈,再 push 一个新页面 登录完成 / 退出登录 / 重置应用
switchTab 关闭所有非 tabBar 页面,切到某个 tab 回到 tabBar 主入口

下面逐个拆开,但统一围绕一件事:页面栈怎么变 + 生命周期怎么走


四、wx.navigateTo:最常用的“进下一页”(push)

4.1 内部流程

wx.navigateTo({
  url: '/pages/detail/detail?id=1&from=list'
})

发生的事:

  1. 当前页(例如 list)触发:onHide

  2. 创建新页面 detail

    • detail.onLoad({ id: '1', from: 'list' })
    • detail.onShow()
  3. 页面栈:

[list][list, detail]

4.2 两个关键限制

  1. 不能跳转到 tabBar 页面
    想去 tab 页,必须用 switchTab

  2. 当页面栈长度已经是 10:

    const pages = getCurrentPages()
    console.log(pages.length) // 10
    

    wx.navigateTo

    • 不会再压入新页面
    • 本次跳转 fail
    • errMsg 通常类似:navigateTo:fail webview count limit exceed

    页面表现:
    事件触发了,代码也走了,但页面完全没跳。


五、wx.navigateBack:往回退(pop)

5.1 单级返回

wx.navigateBack()
// 等价于 wx.navigateBack({ delta: 1 })

假设当前页面栈:

[index, list]

执行后:

  1. list.onUnload()(销毁,从栈中移除)
  2. index.onShow()(重新展示)
  3. 页面栈变成:
[index]

5.2 多级返回

wx.navigateBack({ delta: 2 })

假设当前:

[home, list, detail, edit]   // 当前在 edit

执行后:

  1. edit.onUnload()
  2. detail.onUnload()
  3. list.onShow()
  4. 页面栈变成:
[home, list]

注意:

  • delta 超过栈长只会退到栈底,不会炸
  • 被退到的页面 只触发 onShow,不会再触发 onLoad

所以:
所有“返回后要刷新”的逻辑,都应该放在 onShow 里,而不是 onLoad


六、wx.redirectTo:替换当前页(当前页没用了)

6.1 行为

wx.redirectTo({
  url: '/pages/result/result'
})

假设当前页面栈:

[A, B]   // 当前在 B

执行后:

  1. B.onUnload() → 从页面栈移除

  2. 新建 C

    • C.onLoad()
    • C.onShow()
  3. 页面栈:

[A, C]

6.2 使用场景

可以直接记一句:

当前页面“走过去就不应该再回来了”,用 redirectTo

典型例子:

  • 登录成功后跳首页:不希望用户再退回登录页
  • 填写完表单跳“成功页”:不一定要再回到表单页
  • 一些“中间引导页”“结果页”,只负责中转一次

七、wx.reLaunch:清空整个栈,重新开始

7.1 行为

wx.reLaunch({
  url: '/pages/home/home'
})

假设原来页面栈:

[guide, login, home, list]

执行后:

  1. guide、login、home、list 依次 onUnload,全部被销毁

  2. 创建新的 home 页面:

    • home.onLoad()
    • home.onShow()
  3. 页面栈变成:

[home]

7.2 常见用途

  • 登录成功 / 退出登录后,重置整个应用路由
  • 发生严重异常,回到首页或错误页,把历史一刀切掉

例如登录流程常见写法:

// 未登录 → 拉到登录页
wx.reLaunch({
  url: '/pages/login/login'
})

// 登录成功 → 拉回首页
wx.reLaunch({
  url: '/pages/home/home'  // 或主 tab
})

八、wx.switchTab:切到 tabBar 页,顺手清掉非 tab 页

8.1 行为

wx.switchTab({
  url: '/pages/tab-home/index'
})

假设当前页面栈:

[tab-home, list, detail]  // 当前 detail,tab-home 是 tabBar 页面

执行后:

  1. 关闭所有非 tabBar 页面:list、detailonUnload

  2. 激活 tab-home

    • 第一次:onLoad()onShow()
    • 之后:只 onShow()
  3. 页面栈可以理解为:

[tab-home]

可以直接记:

switchTab = 回到某个 tabBar 根页面,并清掉这次业务流中的非 tab 页面。


九、动作 ⇄ 生命周期 ⇄ 页面栈,一张表记住

把上面的内容汇总成一张“速查表”:

场景 页面栈变化(抽象) 被关闭页 最终停留页生命周期
首次进入某页面 [] → [A] A:onLoadonShow
navigateTo [A] → [A, B] A:onHide B:onLoadonShow
navigateBack [A, B] → [A] B:onUnload A:onShow
redirectTo [A, B] → [A, C] B:onUnload C:onLoadonShow
reLaunch [很多] → [X] 所有旧页:onUnload X:onLoadonShow
switchTab(首次) [...] → [..., T] 非 tab 页:onUnload T:onLoadonShow
switchTab(再次) [...] → [...] 上一个 tab:onHide 当前 tab:onShow

工程落地结论只有一句:

  • 只做一次的初始化逻辑 → 写在 onLoad
  • 只要页面“重新可见”就要跑的逻辑 → 写在 onShow

十、10 层限制,对项目设计的实际影响

再用页面栈视角推一次“跳不动”的过程:

  1. 一路 navigateTo 叠上去:

    [P1]
    [P1, P2]
    [P1, P2, P3]
    ...
    [P1, P2, ..., P10]  // 此时 length = 10
    
  2. 再执行:

    wx.navigateTo({ url: '/pages/P11/P11' })
    
  3. 结果:

    • 当前栈长度 = 10,无法再创建新页面
    • 本次跳转 fail,errMsg 类似:navigateTo:fail webview count limit exceed
    • 页面栈仍然是 [P1, ..., P10],页面当然不会跳走

怎么避免被这个坑反复阴?

  1. 一条业务链路里,不要一路 navigateTo 到天荒地老

    • 中间不需要回去的页面,用 redirectTo 替换掉
  2. 对关键业务链路封装一个 safeNavigateTo,统一打印 getCurrentPages().length 和 fail 日志

  3. 对于“走完流程就回首页/主 tab”的场景,优先考虑 reLaunchswitchTab顺便清掉一整段页面栈


十一、结合业务场景看几条真实“页面栈时间线”

11.1 tab + 列表 + 详情 + 编辑

假设有以下页面:

  • tab 首页:/pages/tab-home/index
  • 列表页:/pages/list/list
  • 详情页:/pages/detail/detail
  • 编辑页:/pages/edit/edit
1)tab 首页 → 列表
// tab-home.js
wx.navigateTo({
  url: '/pages/list/list'
})

页面栈:[tab-home, list]

2)列表 → 详情
wx.navigateTo({
  url: '/pages/detail/detail?id=1'
})

页面栈:[tab-home, list, detail]

3)详情 → 编辑
wx.navigateTo({
  url: '/pages/edit/edit?id=1'
})

页面栈:[tab-home, list, detail, edit]

4)编辑保存成功 → 退回详情并刷新
// edit.js
save() {
  // 假设接口已成功
  const pages = getCurrentPages()
  const prevPage = pages[pages.length - 2] // 上一个页面 = detail

  prevPage.setData({ needRefresh: true })
  wx.navigateBack() // 页面栈:[tab-home, list, detail]
}
// detail.js
Page({
  data: {
    needRefresh: false
  },
  onShow() {
    if (this.data.needRefresh) {
      this.fetchDetail()
      this.setData({ needRefresh: false })
    }
  }
})
5)详情 → 返回列表
wx.navigateBack() // 页面栈:[tab-home, list]
6)列表 → 切到“我的” tab
wx.switchTab({
  url: '/pages/tab-mine/index'
})

非 tab 页面被清掉,页面栈变为:[tab-mine]

整条链路,你其实可以用“页面栈时间线”一眼推出来每一步应该用哪个 API。


11.2 登录流程:从页面栈层面彻底断掉“返回登录页”

需求非常常见:

  • 未登录强制进入登录页
  • 登录成功后进入首页
  • 再怎么点“返回”,都不应该回到登录页

从页面栈角度来设计:

// 未登录时
wx.reLaunch({
  url: '/pages/login/login'
})
// 页面栈:[login]

// 登录成功后
wx.reLaunch({
  url: '/pages/home/home'  // 或主 tab 页
})
// 页面栈:[home] 或 [tab-home]

因为登录页这一层直接被清掉了,所以“返回到登录页”这条路在栈里根本不存在。


十二、开发时如何“看到”页面栈?

12.1 在关键点打印 getCurrentPages()

遇到复杂路由问题时,直接在页面/按钮里打:

const pages = getCurrentPages()
console.log(
  '当前页面栈:',
  pages.map(p => p.route),
  '长度:',
  pages.length
)

你会在控制台看到类似:

当前页面栈: ["pages/tab-home/index", "pages/list/list", "pages/detail/detail"] 长度: 3

配合前面的“时间线思维”,你可以清楚知道当前到底叠了多少层页面、每层是谁。

12.2 用标题栏显示当前深度(调试专用小技巧)

开发环境可以直接在所有页面里加:

Page({
  onShow() {
    const pages = getCurrentPages()
    wx.setNavigationBarTitle({
      title: `${pages.length} 层 · ${this.route}`
    })
  }
})

一边点页面、一边留意标题栏,页面栈在怎么涨怎么减,肉眼可见


最后,小结几条真正需要记住的规则

  1. 小程序内部维护着一个最多 10 层的页面栈,每一层是一个 Page 实例。
  2. navigateTo = 压栈、navigateBack = 出栈、redirectTo = 替换栈顶、reLaunch = 清空重建、switchTab = 回到 tabBar 并清掉非 tab 页。
  3. 页面栈长度到 10 时,再 wx.navigateTo 会失败,errMsg 通常类似 navigateTo:fail webview count limit exceed,页面表现就是“跳不动”。
  4. 返回只触发 onShow 不触发 onLoad,“返回后刷新”逻辑必须写在 onShow
  5. 页面栈只负责小程序页面间的跳转,H5 的前进/后退是 WebView 自己那套历史系统,不要混在一起。

习惯用“页面栈时间线”的视角去设计路由之后,小程序里大部分“返回乱跳、页面跳不动、刷新异常”的问题,你基本都能自己推出来。

【包管理器】pnpm、npm、cnpm、yarn 深度对比

JavaScript 生态以 npm Registry 为核心,围绕其衍生出多种包管理器。随着工程规模膨胀、CI/CD 普及以及国内网络环境的限制,团队对依赖解析、磁盘占用、锁文件一致性与镜像策略的要求日益严苛。本文聚焦 pnpm、npm、cnpm 与 yarn,结合工程实践对比其关键差异。

TL;DR 速览

  • 追求高速安装、极致磁盘效率与严格依赖隔离:首选 pnpm。
  • 强调生态兼容与“开箱即用”体验:延续 npm,类库作者与对外分发场景尤佳。
  • 希望尝试 Zero-Install、Constraints、Plug’n’Play 等高级特性:使用 yarn Berry,最好配合 Corepack 固定版本。
  • 主要痛点是国内下载慢或需自建镜像:选择 cnpm,或任何工具结合 npmmirror;cnpm 还能手动 sync 包。
  • 团队存在多种包管理器共存:引入 Corepack/Volta/NVM 锁定 CLI 版本,避免环境漂移。

工具概览

  • npm:Node.js 自带,生态覆盖面最广。v7+ 支持 Workspaces、自动安装 peerDependencies,适合追求稳定、兼容性的团队。
  • pnpm:依靠内容寻址 Store 与硬链接去重实现高速安装与极低磁盘占用,原生支持 monorepo 与过滤器,强调严格依赖隔离。
  • cnpm:阿里维护的 npm CLI 分支,默认指向 registry.npmmirror.com,用于解决国内网络瓶颈;命令几乎与 npm 完全一致。
  • yarn:Classic 版本主打安装速度与锁文件稳态;Berry(2+) 引入 Plug’n’Play、Constraints、Zero-Install 等高级特性,但配置与心智成本更高。

关键对比维度

维度 npm pnpm yarn cnpm
安装/性能 v9+ 并行,整体偏稳健 内容寻址 store,高速且二次安装更快 Classic 并行 + Berry PnP,冷启动快 与 npm 类似,网络镜像更优
磁盘占用 每项目完整 node_modules 共享 store,硬链接去重 Berry 无 node_modules,Classic 与 npm 类似 与 npm 相同
依赖隔离 hoist 规则易出幽灵依赖 默认隔离,未声明依赖即报错 PnP 阻断幽灵依赖,Classic 需注意 与 npm 一致
锁文件 package-lock.json,兼容佳 pnpm-lock.yaml,支持离线 fetch yarn.lock + .pnp.cjs(Berry) package-lock.json
中国区可用性 需手动配置镜像 可自定义 registry,常配 npmmirror .yarnrc.yml 指定 registry 默认 npmmirror,cnpm sync
Monorepo 能力 基础 Workspaces Workspaces + filter/并行发布 Berry Workspaces + Constraints 无扩展,依赖额外工具
CLI/生态 npm, npx, npm audit 等工具完备 支持过滤、pnpm env、Corepack 管理 CLI 插件化,可扩展 doctor 等 命令与 npm 兼容,新增 sync
安全/审计 npm audit fix 官方数据库 复用 npm 数据,可在 pnpmfile 打补丁 yarn audit/yarn npm audit + Constraints 与 npm 数据同步,镜像或有延迟

下文将围绕表格中的核心维度展开,并补充关键实践注意事项。

安装与性能

  • npm:v9+ 已具备并行安装能力,但缺乏共享缓存,冷启动速度与磁盘占用表现一般。
  • pnpm:依托内容寻址 store 和硬/软链接,同机项目共享依赖,首装快、二次装更快。
  • yarn:Classic 通过并行安装与离线缓存兼顾速度与稳态;Berry 借助 PnP 跳过 node_modules,冷启动极快。
  • cnpm:在安装链路上等同 npm,但得益于默认镜像在国内网络表现更友好。

依赖解析与一致性

  • npm:传统的层级 node_modules,hoist 规则偶尔导致幽灵依赖;lockfile v3 提升了一致性但仍需人工自查。
  • pnpm:默认严格隔离,未声明的依赖无法被"间接"解析,问题能在安装或构建期暴露。
  • yarn:Classic 与 npm 类似;Berry 的 Plug'n'Play 通过虚拟文件系统彻底阻断幽灵依赖。
  • cnpm:继承 npm 的解析策略,无额外一致性增强。

幽灵依赖详解

什么是幽灵依赖?

幽灵依赖(Phantom Dependency)是指项目代码中使用了某个包,但该包并未在 package.jsondependenciesdevDependencies 中显式声明,而是作为其他依赖的间接依赖(传递依赖)被安装到 node_modules 中。

产生原因

在 npm/yarn Classic 的 hoist 机制下,依赖会被提升到 node_modules 的顶层。当项目 A 依赖包 B,而包 B 又依赖包 C 时,包 C 可能会被提升到项目根目录的 node_modules 中,导致项目 A 可以直接 require('C')import C,即使 package.json 中并未声明包 C。

示例场景

// package.json
{
  "dependencies": {
    "express": "^4.18.0"  // express 内部依赖了 cookie-parser
  }
}

// app.js
const cookieParser = require('cookie-parser');  // ❌ 幽灵依赖!
// 虽然能运行,但 cookie-parser 并未在 package.json 中声明

潜在问题

  1. 版本不确定性:当 express 升级并移除对 cookie-parser 的依赖时,代码会突然报错。
  2. 依赖关系不透明:团队成员无法从 package.json 看出项目实际使用了哪些包。
  3. CI/CD 风险:不同环境下的 hoist 结果可能不同,导致"本地能跑,CI 失败"。
  4. 安全审计盲区npm audit 可能无法检测到未声明的依赖中的漏洞。

不同工具的处理方式

  • npm/yarn Classic/cnpm:允许幽灵依赖存在,依赖提升机制使得未声明的包也能被解析。这是历史遗留问题,需要开发者自觉避免。

  • pnpm:默认严格隔离模式,每个包只能访问其 package.json 中声明的依赖。如果代码尝试使用未声明的依赖,会在运行时抛出 MODULE_NOT_FOUND 错误,强制开发者显式声明所有依赖。

  • yarn Berry (PnP):通过 Plug'n'Play 机制,完全阻断对未声明依赖的访问。.pnp.cjs 中只包含已声明的依赖映射,任何未声明的依赖都会在解析阶段被拦截。

最佳实践

  1. 显式声明所有依赖:即使某个包是间接依赖,如果项目代码直接使用它,也应该在 package.json 中声明。
  2. 使用 lint 工具:配置 eslint-plugin-importdepcheck 检测未声明的依赖。
  3. 迁移到严格模式:考虑使用 pnpm 或 yarn Berry,让工具强制暴露依赖问题。
  4. 定期审查:使用 npm ls <package>pnpm why <package> 检查依赖来源,确保所有直接使用的包都已声明。

底层原理与依赖目录结构

不同工具在落盘结构与模块解析链路上差异明显,理解目录组织能帮助排错与迁移。

npm(也适用于 cnpm)

逻辑:自根目录向上查找 node_modules,通过 hoist 将依赖尽量提升至顶层,未能提升的依赖保留在子目录中。Node.js runtime 的 MODULE_NOT_FOUND 解析就是按 ./node_modules → ../node_modules → ... 回溯。

目录示例

node_modules/
├─ react/
├─ react-dom/
├─ webpack/
└─ project-a/
   └─ node_modules/
      └─ lodash/   ← 未被 hoist 的重复依赖

代价:空间占用大、幽灵依赖易出现,缓存只存 tarball(~/.npm/_cacache),不会减少 node_modules 占用。

pnpm

逻辑:先把所有包解压到全局内容寻址仓库 ~/.pnpm-store/v3/files/<hash>,相同文件引用一次;项目 node_modules 里只放指向 .pnpm/<pkg>@<version>/node_modules 的符号/硬链接,并通过 node_modules/.modules.yaml 记录映射。运行时依赖解析顺序为:模块目录 → 该模块自己的 node_modules → 上层 .pnpm 入口,未声明的包无法被解析。

目录示例

node_modules/
├─ .pnpm/
│  ├─ react@18.2.0/
│  │  └─ node_modules/react/  ← 实际包内容
│  └─ project-a@1.0.0/
│     └─ node_modules/project-a/
├─ react → .pnpm/react@18.2.0/node_modules/react
└─ project-a → .pnpm/project-a@1.0.0/node_modules/project-a

优势:多项目共享 store、严格依赖隔离;若启用 pnpm fetch,甚至可在无网络时把 store 拉取到 CI 机器。

yarn Classic

逻辑:解析锁文件后,按 npm 同样的 hoist 策略在本地生成 node_modules。差别在于 yarn.lock 确保依赖树稳定,同时可启用 yarn install --check-files 验证一致性。

目录:与 npm 几乎无异,若开启 Plug'n'Play 插件(Classic 也支持),则会生成 .pnp.js 并跳过 node_modules

yarn Berry (2+)

逻辑:默认 nodeLinker: pnp。安装时将包归档到 .yarn/cache/<pkg>-<hash>.zip,然后在 .pnp.cjs 中记录"依赖 → 包路径"映射。运行过程中,Yarn 的 PnP runtime hook 截获 require/import 调用,定位到缓存 zip,并以 zip 内虚拟文件形式提供模块。只有在设置 nodeLinker: node-modules 时才会生成真实 node_modules

目录示例(PnP 默认模式)

.yarn/
├─ cache/
│  ├─ react-npm-18.2.0-8ad33.zip
│  └─ webpack-npm-5.95.0-bbd1e.zip
├─ releases/
└─ sdk/
.pnp.cjs            ← 依赖解析表

特点:Zero-Install 可直接把 .yarn/cache 提交到仓库,无需安装步骤;但某些脚本若硬编码 node_modules 路径,则需切换 nodeLinker 或使用 yarn unplug

模块解析流程

解析流程示意
[npm/cnpm/yarn Classic]
require('lodash')
    ↓ Node.js resolver
./node_modules/lodash →
../node_modules/lodash →
系统 PATH

[pnpm]
require('lodash')
    ↓ Node.js resolver
./node_modules/.pnpm/lodash@x/node_modules/lodash  (符号链接目标)
    ↓ 若未找到则报错(不会回溯到未声明依赖)

[yarn Berry PnP]
require('lodash')
    ↓ PnP hook (Module._resolveFilename 被 patch)
查 .pnp.cjs → `.yarn/cache/lodash-npm-4.17.21.zip`
    ↓ 从 zip 提供虚拟 fs 路径
运行时 Hook 细节
  • npm / cnpm / yarn Classic / pnpm:完全依赖 Node.js 内置 Module._resolveFilenamefs 模块逻辑,pnpm 通过符号链接保证目录结构仍符合 Node 的查找路径,因此无需额外 runtime patch。

  • yarn Berry PnP

    1. node 启动时加载 .pnp.cjs,该文件会使用 require('module').Module._resolveFilename_findPath 的 monkey patch 拦截模块解析。
    2. 当应用执行 require('pkg') 时,PnP hook 根据调用方位置解析出该包对应的 locator(类似 pkg@npm:1.0.0),再在 .pnp.data.json(隐藏在 .pnp.cjs 内部)中寻找缓存 zip 的绝对路径。
    3. hook 会注入自定义的 fs.readFileSync/fs.readdirSync 逻辑,使得 zip 内文件可透明访问;若调用方请求真实路径,PnP 会返回 zip:/path/to/cache#package/index.js
    4. 对不兼容 PnP 的工具,可通过 yarn unplug pkg 将特定包解压到 .yarn/unplugged,或把 nodeLinker 设为 node-modules 退回传统模式。
  • Corepack 对运行时的影响:Corepack 本身不改变解析逻辑,但会在执行 pnpm/yarn/npm 时注入合适的 CLI 版本,确保 .pnp.loader.mjs.pnpm-store 等 runtime 结构与 lockfile 匹配。

安装流程

安装流程图示
npm / yarn Classic / cnpm
┌──────────┐    ┌────────────┐    ┌─────────────┐
│ package  │ -> │ Arborist   │ -> │ node_modules│
│ json+lock│    │ 构建依赖树 │    │ hoist 写入  │
└──────────┘    └────────────┘    └─────────────┘

pnpm
┌──────────┐    ┌─────────────┐    ┌──────────────────┐    ┌────────────────┐
│ package  │ -> │ Resolver    │ -> │ ~/.pnpm-store     │ -> │ 项目 node_modules│
│ json+lock│    │ & linker    │    │ (内容寻址)        │    │ 写符号/硬链接   │
└──────────┘    └─────────────┘    └──────────────────┘    └────────────────┘

yarn Berry (PnP)
┌──────────┐    ┌────────────┐    ┌────────────────────┐
│ package  │ -> │ Plug'n'Play│ -> │ .yarn/cache + .pnp │
│ json+lock│    │ 解析器      │    │ 生成映射/zip        │
└──────────┘    └────────────┘    └────────────────────┘
实现细节

npm / yarn Classic 安装链路

  1. CLI 调用 Arborist(npm 内置解析器)读取 package.json、lockfile,产出依赖树(ideal tree)。
  2. Arborist 根据 semver 冲突策略决定 hoist 位置,并生成实际安装计划(actual tree)。
  3. 安装阶段将包解压到 node_modules/<name>,必要时写入嵌套 node_modules。当运行 npm dedupe 时,会再次构建树并尝试把可兼容版本提升到上层。
  4. npm ci 会跳过依赖解析,直接按照 lockfile 指定的版本和树结构落盘,以减少差异。

pnpm store 与 .modules.yaml

  • store 路径:~/.pnpm-store/v3/files/ab/cdef...,文件名为内容散列(SHA512 → base32),任一项目安装同一 tarball 时直接引用。
  • 项目级 node_modules/.modules.yaml 示例:
hoistPattern:
  - '*'
included:
  dependencies: true
  hoistedDependencies: true
packages:
  /react@18.2.0:
    resolution: {integrity: sha512-...}
    path: .pnpm/react@18.2.0/node_modules/react
  • 安装流程:
    1. 下载 tarball → 解压到 store。
    2. .pnpm/<pkg>@<version>/node_modules 生成真实目录。
    3. 在根 node_modules 写入指向 .pnpm/... 的符号链接;.modules.yaml 记录每个链接的来源与 hoist 情况,供后续 install/dedupe 对比。
  • pnpm install --virtual-store-dir 可自定义 .pnpm 的存放位置,以满足只读文件系统或沙箱要求。

yarn Berry PnP loader

  • .pnp.cjs 同时导出 CommonJS 钩子及 .pnp.loader.mjs(供 node --loader 使用)来支持 ESM。
  • 当运行 node -r ./.pnp.cjs app.js(Yarn 自动插入该参数)时,Module 原型被 patch:
    • _resolveReference 通过调用 PnpRuntime.resolveRequest,后者查询 .pnp.data.json 中的 dependency map。
    • _load 在定位到 zip 文件后,调用 ZipFS(来自 @yarnpkg/fslib)将压缩包映射为 in-memory FS;若 PRESERVE_SYMLINKS 设置为 true,PnP 也会模拟符号链接。
  • 对 ESM,yarn 会令 Node 通过 --experimental-loader ./.pnp.loader.mjs 引入同样的解析逻辑,从而支持 import 语法。
  • yarn unplug pkg 实际是将 .yarn/cache/pkg-*.zip 解压到 .yarn/unplugged/pkg-npm-<version>/node_modules/pkg,再在 .pnp.cjs 中把该 locator 指向解压目录,以便原生工具访问真实路径。

进阶配置示例

pnpm 配置
# .npmrc
shared-workspace-lockfile = true
virtual-store-dir = .pnpm-store
public-hoist-pattern[] = "*eslint*"
node-linker = "isolated"
// pnpmfile.cjs
module.exports = {
  hooks: {
    readPackage(pkg) {
      if (pkg.name === 'legacy-tool' && !pkg.dependencies.react) {
        pkg.dependencies.react = '^18.2.0';
      }
      return pkg;
    }
  }
};

说明

  • virtual-store-dir 可让 .pnpm 与源码分离(例如放到 /tmp/pnpm 以便容器层缓存)。
  • public-hoist-pattern 控制哪些包需要 hoist 到传统 node_modules,以兼容硬编码路径的工具。
  • pnpmfile.cjs 中的 hook 允许在安装阶段动态改写依赖、打补丁或增加 peerDependencies。
yarn Berry 配置
# .yarnrc.yml
nodeLinker: pnp
enableImmutableInstalls: true
yarnPath: .yarn/releases/yarn-4.2.2.cjs
# constraints.pro
gen_enforced_dependency("workspace:^", "react", "^18.2.0").
gen_enforced_field("workspace:^", "license", "MIT").
$ yarn constraints --fix

说明

  • Constraints 基于 Prolog(@yarnpkg/constraint 提供求解器),可对所有 workspace 强制字段/依赖版本,避免团队成员擅自修改。
  • Zero-Install 流程:提交 .yarn/cache/*.zip.pnp.cjs.yarnrc.yml 到仓库,CI 直接 yarn install --immutable 即可跳过网络下载;若需排除大文件,可结合 Git LFS 或 .yarn/cache/.gitignore 精细控制。

调试与排障技巧

安装器差异导致排障命令也不同,合理利用 CLI 可以迅速定位依赖链、缓存或镜像问题。

工具 诊断命令 作用
npm/cnpm npm ls <pkg> 查看指定依赖的解析路径与版本冲突。
npm/cnpm npm doctor 检查 npm 配置、权限与缓存状态。
pnpm pnpm why <pkg> 显示 .modules.yaml 与 store 中该依赖的引用链。
pnpm pnpm store path / pnpm store prune 获取共享 store 位置或清理孤儿包,排查磁盘占用。
yarn Classic yarn why <pkg> 输出依赖链与版本来源,辅助定位 hoist 问题。
yarn Berry yarn explain peer-requirements <hash> 根据 peer hash 找到触发告警的 workspace 与依赖。
yarn Berry YARN_ENABLE_LOG_FILTERS=0 yarn install -v 关闭日志过滤并输出 PnP 解析细节,便于定位 .pnp.cjs 问题。

典型调试输出示例

pnpm

$ pnpm why react
Legend: production dependency, optional only, dev only
project-a
└───┬ react-dom 18.2.0
    └───┬ react 18.2.0

yarn Berry

$ yarn explain peer-requirements 7b42d
pnp:7b42d - ✓ @storybook/react@8.1.0 provides react@>=18
pnp:7b42d - ✗ @storybook/react@8.1.0 requires react-dom@>=18 (missing)

npm

$ npm doctor
Check npm version: ok
Check permissions: ok
Check package integrity: ok

PnP loader 与 ESM 交互

  • .pnp.loader.mjs 基于 Node Loader API,导出 resolveload 钩子。resolve(specifier, context, next) 会调用与 CommonJS 同源的 pnpapi.resolveRequest,返回形如 zip:/… 的 URL。load(url, context, defaultLoad) 则把 zip 条目读入内存并返回 {format: 'module', source}
  • import.meta.resolve,yarn Berry 会在 pnpapi 中实现 resolveToUnqualified,使得 bundler 或 runtime 可以显式解析依赖;若工具只接受真实文件路径,可结合 yarn unplug 将特定包落地到 .yarn/unplugged
  • 若项目需要以 node --loader ts-node/esm 方式运行 TypeScript,需在 .pnp.loader.mjs 中加入自定义链路:yarn dlx @yarnpkg/sdks vim 会为 VS Code/TS Server 生成适配插件,确保 Loader 顺序正确。

锁文件与可移植性

  • npmpackage-lock.json;跨平台兼容好,工具链广泛支持。
  • pnpmpnpm-lock.yaml;记录 store 引用,结合 pnpm fetch 可实现离线安装。
  • yarn:Classic 使用 yarn.lock;Berry 引入 yarn.lock + .pnp.cjs,需要配套插件与编辑器支持。
  • cnpm:依旧生成 package-lock.json

二进制与平台支持

  • npm:通过 npm config set python 等方式自定义构建环境,Node-gyp 生态成熟。
  • pnpm:安装二进制依赖时可沿用 npm 脚本;pnpmfile.cjs 可按平台调整依赖。
  • yarn:Berry PnP 模式下运行 node-gyp 需启用 nodeLinker: node-modules;Classic 表现与 npm 相同。
  • cnpm:依赖 npm 行为,但镜像同步二进制包时可能延迟,需关注 native 模块版本。

磁盘占用

  • npm/cnpm/yarn Classic:每个项目完整复制依赖,monorepo 下空间浪费明显。
  • pnpm:单机共享 store,节省 60%~90% 空间;同时减少重复下载。
  • yarn Berry:启用 PnP 后不再创建 node_modules,磁盘占用极低,但部分工具需额外适配。

中国区可用性与镜像

  • npm:需手动配置镜像或使用 nrm 切换。
  • pnpm:可与任意 registry 配合,常与 corepack + 环境变量设定镜像。
  • cnpm:默认指向阿里镜像,提供 cnpm sync <pkg> 主动同步能力。
  • yarn:通过 .yarnrc.yml 指定 npmRegistryServer;Berry 还能对特定 scope 配置自定义 registry。

Monorepo 与高级特性

  • npm:Workspaces 支持基础 monorepo,但缺乏任务编排等能力。
  • pnpm:内建过滤器(pnpm -r --filter ...)、pnpm publish -r 等命令,结合 pnpmfile.cjs 可重写依赖。
  • yarn:Berry 提供 Constraints、Plugins、Zero-Install、yarn workspaces focus 等高级机制。
  • cnpm:未扩展 monorepo 能力,通常与 Lerna/Nx 组合使用。

CLI 体验与生态工具

  • npm:命令语义直观,npx 集成度高;配套 npm auditnpm dedupe 等工具完善。
  • pnpm:CLI 支持过滤、pnpm env use 等进阶命令;可通过 corepack 在多环境统一版本。
  • yarn:Classic 命令接近 npm;Berry CLI 插件化,可按需引入 @yarnpkg/doctor@yarnpkg/plugin-npm-cli 等。
  • cnpm:保持 npm 命令兼容,额外提供 cnpm sync 便于镜像同步。

学习曲线与团队协作

  • npm:默认工具,文档与社区问答最丰富,新成员上手成本最低。
  • pnpm:需理解 store 结构与 pnpm-lock.yaml,但 CLI 输出清晰、迁移指南完备。
  • yarn:Classic 易用;Berry 引入 Plug'n'Play、Constraints 等概念,需额外培训与 IDE 配置。
  • cnpm:操作与 npm 一致,重点在维护镜像同步策略。

安全与审计

  • npm:原生 npm audit fix;Registry 团队维护 advisories。
  • pnpm:继承 npm audit 数据,同时可在 pnpmfile.cjs 中集中打补丁或替换依赖版本。
  • yarnyarn npm audit(Berry)或 yarn audit(Classic);Berry 的 Constraints 可强制版本范围。
  • cnpm:沿用 npm 安全数据库,但国内镜像同步可能存在延迟,关键包需关注更新时差。

CI/CD 与缓存策略

  • npm:依赖 npm ci 获得确定性安装,但大型 monorepo 下缓存命中率一般。
  • pnpmpnpm fetch + pnpm install --offline 结合远程 store 缓存,可显著降低 CI 时间。
  • yarn:Classic 可缓存 .yarn/cache;Berry 默认生成压缩包,可和 Zero-Install 一起提交仓库。
  • cnpm:在 CI 中价值有限,更多用于开发机加速;若 CI 运行在国内亦可直接使用 cnpm registry。

日常开发、打包与运行体验

  • npmnpm run dev/build/test 是绝大多数脚手架(Next.js、Vite、create-react-app 等)的默认命令,生态插件与 IDE 集成都围绕 npm script 实现;npm run 支持 --workspaces 批量触发,但功能较基础。
  • pnpmpnpm dev/pnpm build 与 npm 保持一致,额外提供 pnpm run --parallelpnpm -r --filter 等能力,可在 monorepo 中只针对受影响包运行 Vite、Webpack、Rollup;pnpm dlx 可直接执行 bundler CLI 而无需全局安装。
  • yarn:Classic 的 yarn runyarn workspace <name> dev 体验成熟;Berry 则可用 yarn workspaces foreachyarn constraints 抽象复杂脚本,并通过 Plug'n'Play 让 bundler 冷启动更快,必要时配置 pnpMode: loose 兼容不支持 PnP 的工具。
  • cnpm:与 npm run 语义完全一致,常在国内开发机上用于安装依赖,脚本执行仍由 npm/yarn/pnpm 完成;若希望统一,则在安装阶段用 cnpm,运行阶段回到 npm scripts。

迁移成本与社区活跃度

  • npm:无需迁移;版本更新平滑。
  • pnpm:迁移需关注脚本里显式依赖 node_modules 路径的场景;社区对 monorepo 与现代框架支持积极。
  • yarn:Classic→Berry 需重写配置;部分第三方脚本不兼容 PnP,需要 nodeLinker: node-modules 兜底。
  • cnpm:只要 npm 命令兼容即可切换;但社区讨论集中在网络加速层面,功能性演进较少。

典型场景与推荐组合

场景 主要诉求 推荐工具 说明
大型 monorepo + 频繁 CI 安装速度、磁盘占用、严格依赖 pnpm pnpm fetch + 远程 store,可与 Nx/Turbo/Turborepo 等配合。
面向外部的库或脚手架 生态兼容、默认体验 npm 避免用户额外安装工具,npm publish 流程也最成熟。
全栈团队追求高级特性 Zero-Install、Constraints、自定义 CLI yarn Berry 结合 Corepack 固定版本,必要时退回 node_modules 模式。
国内内网/隔离网络 稳定镜像、可自建代理 cnpm 或 npm/pnpm + npmmirror cnpm 自带同步命令,亦可配制 Verdaccio 等私服。
多技术栈混合仓库 同时管理 Node、Python、Rust 工具 npm/pnpm + Volta 或 asdf 通过版本管理器固定 CLI,保持 lockfile 一致。

迁移与实施建议

  1. 从 npm 迁移至 pnpm:提前排查脚本中硬编码的 node_modules/.bin,改用 pnpm dlxpackageManager 跨平台调用;在 CI 中新增 pnpm fetch 步骤,并确认缓存权限。
  2. 引入 yarn Berry:先用 yarn set version berry + yarn init 生成配置,再根据实际情况决定使用 PnP 还是 nodeLinker: node-modules,并为 IDE 安装 Yarn SDK 与插件。
  3. 混合工具链的版本治理:使用 Corepack、Volta、asdf 或 mise 固定 npm/pnpm/yarn 版本,必要时在 package.json 中声明 packageManager 字段,避免锁文件因环境差异而漂移。
  4. 镜像与私服策略:对需要离线或加速的环境,将 registry、disturl、binary mirror 等配置写入 .npmrc/.yarnrc.yml,确保 CI 与本地一致;若使用 cnpm,可周期性执行 cnpm sync 防止版本滞后,也可结合 Verdaccio/Artifactory 做企业级缓存。

综合考虑

  1. 追求磁盘效率与严格依赖管理:首选 pnpm;在大型 monorepo、CI/CD 频繁场景中尤为高效。
  2. 需要最广泛的兼容性:npm 仍是默认选项,尤其适用于对外分发的库或对工具链兼容性要求极高的项目。
  3. 面向国内网络环境:若团队主要痛点是下载速度,可选 cnpm 或在 npm/pnpm/yarn 上配置 npmmirror。
  4. 希望尝试先进特性:yarn Berry 适合需要 Zero-Install、Constraints、可插拔 CLI 的团队,但需投入额外学习成本。

总结

pnpm 代表“高效与严谨”,适合一切追求极致依赖管理的现代工程;npm 以稳健与兼容性取胜,是生态默认语言;yarn 借助 Berry 获得高度可配置的高级特性;cnpm 则在中国区网络环境下提供最佳可达性。根据团队的体量、CI 频率、网络环境与学习成本进行权衡,往往能自然地落在表格中的某一列:新项目优先考虑 pnpm 或 yarn Berry,存量项目可在保持 npm 的同时逐步评估迁移收益,而只需解决下载加速时选择 cnpm 或镜像即可。

vue2中transition使用方法解析,包含底部弹窗示例、样式未生效踩坑记录

Vue2中Transition组件的使用方法与实战解析

在Vue2的前端开发中,过渡动画是提升用户体验的核心手段之一。Vue内置的transition组件为元素的插入、更新、移除等DOM操作提供了简洁且可扩展的过渡封装能力,无需手动操作CSS类名或监听DOM事件,即可快速实现流畅的动画效果。本文将从核心原理、使用规则、实战案例三个维度系统讲解transition组件,并结合实际开发中遇到的样式覆盖问题,给出完整的解决方案。

一、Transition组件核心原理与使用规则

1.1 核心工作机制

Vue的transition组件本质是一个“动画控制器”,其核心逻辑是:在包裹的元素触发显隐(或状态变化)时,自动在不同生命周期阶段为元素添加/移除预设的CSS类名,开发者只需通过这些类名定义不同阶段的样式,即可实现过渡动画。

当元素被transition包裹且触发显隐(如v-if/v-show、组件切换)时,Vue会按以下时序执行动画流程:

  1. 进入阶段(Enter):元素插入DOM → 触发进入动画 → 动画完成后移除进入相关类名;
  2. 离开阶段(Leave):元素触发隐藏 → 触发离开动画 → 动画完成后移除DOM(若为v-if)并移除离开相关类名。

1.2 核心CSS类名体系

transition组件的动画类名分为“默认前缀”和“自定义前缀”两类,核心类名及作用如下:

类名类型 进入阶段 离开阶段 核心作用
初始状态 v-enter(Vue2.1.8+为v-enter-from v-leave(Vue2.1.8+为v-leave-from 动画开始前的初始样式,元素插入/移除前瞬间添加,下一帧移除
动画过程 v-enter-active v-leave-active 动画执行过程中的样式,覆盖整个进入/离开阶段,可定义transition/animation属性
结束状态 v-enter-to(Vue2.1.8+新增) v-leave-to(Vue2.1.8+新增) 动画结束时的目标样式,动画开始后立即添加,动画完成后移除

关键说明:

  1. Vue2.1.8版本对类名做了优化,新增-from后缀替代原v-enter/v-leave(原类名仍兼容),使语义更清晰;
  2. 若为transition设置name属性(如name="slide-popup"),类名前缀会从默认的v-替换为自定义前缀(如slide-popup-),可有效避免全局样式冲突;
  3. 所有动画类名仅在动画周期内生效,动画结束后会被自动移除,不会污染元素默认样式。

1.3 基础使用条件

要让transition组件生效,需满足以下基础条件:

  1. 组件仅包裹单个元素/组件(若需包裹多个元素,需使用<transition-group>);
  2. 触发动画的方式需为Vue可检测的DOM变化:
    • 条件渲染:v-if/v-show
    • 组件动态切换:component :is="xxx"
    • 根元素的显隐切换(如路由组件);
  3. 必须通过CSS类名定义动画样式(或结合JavaScript钩子实现JS动画);
  4. 若使用v-show,需确保元素初始display属性不影响动画(如避免display: none直接覆盖过渡效果)。

1.4 过渡类型与配置

transition组件支持两种动画实现方式:

  • CSS过渡(Transition):通过transition CSS属性实现(如transition: all 0.3s ease),也是最常用的方式;
  • CSS动画(Animation):通过animation CSS属性实现(如animation: fade 0.5s linear);

可通过transition组件的属性对动画进行精细化配置:

属性名 作用
name 自定义动画类名前缀,避免样式冲突
duration 统一设置进入/离开动画时长(如:duration="300"),也可分开展开:duration="{ enter: 300, leave: 500 }"
type 指定动画类型(transition/animation),Vue会自动检测动画结束时机
appear 开启初始渲染动画(页面加载时即触发进入动画)
mode 控制进入/离开动画的执行顺序(in-out:先入后出;out-in:先出后入)

二、实战示例:底部弹出弹窗动画

以下实现一个从页面底部平滑弹出/消失的弹窗,完整覆盖transition组件的核心使用场景,并标注关键注意事项。

2.1 完整代码实现

<template>
  <div class="demo-container">
    <!-- 触发按钮 -->
    <button @click="showPopup = !showPopup" class="open-btn">
      打开底部弹窗
    </button>

    <!-- 遮罩层 -->
    <div v-if="showPopup" class="popup-mask" @click="showPopup = false"></div>

    <!-- 过渡包裹弹窗:仅保留自定义name,移除appear属性 -->
    <transition name="slide-popup">
      <div v-if="showPopup" class="popup-container">
        <div class="popup-content">
          <h3>底部弹窗示例</h3>
          <p>基于Vue2 Transition实现的底部弹出动画</p>
          <button @click="showPopup = false" class="close-btn">关闭</button>
        </div>
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  name: 'SlidePopupDemo',
  data() {
    return {
      showPopup: false // 控制弹窗显示/隐藏
    };
  }
};
</script>

<style scoped>
/* 页面容器 */
.demo-container {
  position: relative;
  min-height: 100vh;
}

/* 触发按钮样式 */
.open-btn {
  padding: 8px 16px;
  font-size: 14px;
  cursor: pointer;
  margin: 20px;
  border: 1px solid #409eff;
  border-radius: 4px;
  background: #409eff;
  color: #fff;
  transition: background 0.2s ease;
}

.open-btn:hover {
  background: #66b1ff;
}

/* 遮罩层:半透明背景,点击关闭弹窗 */
.popup-mask {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  z-index: 999;
  transition: opacity 0.3s ease;
}

/* 弹窗容器 - 关键:避免与动画类冲突的样式书写顺序 */
.popup-container {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  background: #fff;
  border-radius: 12px 12px 0 0;
  box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
  z-index: 1000;
  /* 注意:此处若设置transform,需确保动画类在其后定义 */
  /* 错误示例:transform: translateY(0); 会覆盖动画类的transform */
}

.popup-content {
  padding: 30px 20px;
  text-align: center;
}

.popup-content h3 {
  margin: 0 0 10px 0;
  color: #333;
  font-size: 18px;
}

.popup-content p {
  margin: 0 0 20px 0;
  color: #666;
  font-size: 14px;
}

.close-btn {
  padding: 8px 20px;
  font-size: 14px;
  cursor: pointer;
  background: #f5f7fa;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  color: #666;
  transition: all 0.2s ease;
}

.close-btn:hover {
  background: #e4e7ed;
  color: #333;
}

/* 过渡动画类 - 需写在容器样式之后(核心!) */
/* 进入初始状态:弹窗完全在视口外(底部),透明度0 */
.slide-popup-enter {
  transform: translateY(100%);
  opacity: 0;
}

/* 进入动画过程:定义过渡属性和时长 */
.slide-popup-enter-active {
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
}

/* 进入结束状态:弹窗归位,透明度1 */
.slide-popup-enter-to {
  transform: translateY(0);
  opacity: 1;
}

/* 离开初始状态:弹窗在正常位置,透明度1 */
.slide-popup-leave {
  transform: translateY(0);
  opacity: 1;
}

/* 离开动画过程:与进入动画保持一致的过渡曲线 */
.slide-popup-leave-active {
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
}

/* 离开结束状态:弹窗回到视口外,透明度0 */
.slide-popup-leave-to {
  transform: translateY(100%);
  opacity: 0;
}
</style>

2.2 代码解析

(1)结构层设计
  • transition组件通过name="slide-popup"自定义动画类名前缀,替代默认的v-前缀,避免全局样式冲突(核心实践);
  • 弹窗容器通过v-if="showPopup"控制显隐,触发transition的进入/离开动画(v-if会触发DOM的插入/移除,是transition生效的核心条件);
  • 遮罩层与弹窗联动显隐,点击遮罩层可关闭弹窗,补充交互完整性;
  • 未额外配置appear(贴合实际开发习惯,仅聚焦核心的显隐动画场景)。
(2)样式层设计
  • 弹窗容器popup-container采用fixed定位固定在页面底部,作为动画载体,通过border-radiusbox-shadow优化视觉表现;
  • 动画核心基于slide-popup-enter/slide-popup-leave-to等类名实现:
    • 进入阶段:从transform: translateY(100%)(底部完全出视口)过渡到transform: translateY(0)(归位),配合opacity实现淡入;
    • 离开阶段:从transform: translateY(0)过渡到transform: translateY(100%),配合opacity实现淡出;
  • 过渡曲线使用cubic-bezier自定义缓动函数,相比默认ease更贴合移动端弹窗的弹性交互体验;
  • 所有动画类名必须写在容器样式之后,利用CSS“后定义优先”原则保证动画样式优先级。
(3)逻辑层设计
  • 仅通过showPopup一个布尔值控制弹窗和遮罩层的显隐,逻辑极简且易维护;
  • 触发按钮、关闭按钮、遮罩层绑定同一状态切换逻辑,保证交互行为一致性。

三、踩坑记录:动画类样式不生效问题

3.1 问题现象

按常规思路定义slide-popup-enter/slide-popup-leave-to等动画类后,弹窗显隐无位移动画:

  • 弹窗直接显示/隐藏,无平滑过渡效果;
  • 浏览器开发者工具中,动画类的transform属性被划掉(样式被覆盖);
  • opacity属性生效(无样式冲突),位移动画完全失效。

3.2 根因定位

(1)CSS 优先级核心规则

类选择器权重均为0,1,0时,后定义的样式会覆盖先定义的样式,这是CSS的基础优先级规则。

(2)具体冲突场景

实际开发中错误的样式书写顺序:

/* 错误:先写动画类,后写容器类 */
.slide-popup-enter {
  transform: translateY(100%); /* 先定义,权重相同会被覆盖 */
  opacity: 0;
}
.slide-popup-leave-to {
  transform: translateY(100%);
  opacity: 0;
}

.popup-container {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  background: #fff;
  z-index: 1000;
  transform: translateY(0); /* 后定义,直接覆盖动画类的transform */
}

容器类popup-container中transform: translateY(0)后定义,完全覆盖了动画类的transform属性,导致位移动画失效;而opacity无冲突,因此仍能生效。

3.3 解决方案

方案 1:调整样式书写顺序(推荐,符合开发习惯)

将动画类样式书写在容器基础样式之后,利用CSS“后定义优先”的优先级规则,让动画类的样式覆盖容器类中冲突的属性,确保动画相关的样式能够生效,这也是实际开发中最常用、最符合编码习惯的解决方案。

方案 2:移除容器类中的冲突属性(极简方案)

直接删除容器类里和动画类重复定义的属性(如transform),不再让容器样式中存在与动画效果相关的同类型属性,由动画类完全掌控元素的动画属性,从根源上避免样式覆盖的问题,这种方式也能让样式结构更简洁。

方案 3:提高动画类权重(应急方案,不推荐)

通过组合选择器的方式提升动画类的样式权重,以此强制覆盖容器类的冲突属性。但该方式会增加样式的复杂度,不利于后续的维护和调试,仅建议在紧急场景下临时使用,不推荐作为常规解决方案。

3.4 避坑核心总结

  1. 实际开发中使用transition组件时,核心类名就是name-enter/name-enter-active/name-enter-to/name-leave/name-leave-active/name-leave-to,这是最通用、最贴合实际开发的写法;
  2. 动画类样式必须写在元素基础样式之后,这是解决样式覆盖问题的核心原则,也是保证动画生效的关键;
  3. 尽量避免在元素基础样式中定义与动画类重复的属性(如transform、opacity等),从根源上减少样式冲突的可能性;
  4. 调试动画不生效问题时,优先通过浏览器“元素→样式”面板检查动画属性是否被划掉,以此快速定位样式优先级冲突问题。

四、总结

Vue2 transition组件的核心价值是通过name自定义前缀 + 固定的enter/leave类名体系,实现低成本的过渡动画效果,实际开发中需重点关注以下几点:

  1. 掌握核心类名体系:name-enter(进入初始状态)→ name-enter-active(进入动画过程)→ name-enter-to(进入结束状态);name-leave(离开初始状态)→ name-leave-active(离开动画过程)→ name-leave-to(离开结束状态),这是最贴合实际开发的写法;
  2. 重视样式优先级:动画类务必书写在元素基础样式之后,利用CSS“后定义优先”的原则保证动画样式生效;
  3. 规避样式冲突:不重复定义动画相关属性,从根源上减少样式覆盖的风险;
  4. 优化交互体验:结合cubic-bezier自定义缓动函数,让动画效果更符合实际产品的交互质感。

transition是Vue2中实现单元素过渡动画的最优方案,掌握上述规则可解决绝大多数动画不生效的问题,同时能保证代码的可维护性和交互体验。

纯 Viem 脚手架:最干净的链上交互方式

前言

在 Web3 开发领域,与以太坊等区块链网络进行交互是构建去中心化应用(DApp)的核心环节。传统的 Web3 开发框架如 wagmi 为开发者提供了便利的 React Hooks,但有时我们也需要更底层、更灵活的控制。

本文将介绍一个纯 Viem 脚手架项目,详细分析如何使用 Viem 库直接与 MetaMask 钱包和智能合约进行交互,不依赖 wagmi 等高级抽象库,让开发者更好地理解底层交互逻辑。Viem 作为下一代以太坊开发工具,相比传统的 ethers.js 和 web3.js,提供了更现代化的 TypeScript 接口和更轻量级的实现。

项目概述

这是一个使用 Next.js 和 Viem 构建的简单 DApp 脚手架项目,主要功能包括连接钱包、获取账户信息、读写智能合约以及监听钱包状态变化。该项目采用纯 Viem 实现,没有使用 wagmi 等第三方状态管理库。

为什么选择纯 Viem?

纯 Viem 方案在 Web3 开发中因其轻量级设计和底层控制能力,逐渐成为开发者调查中的新趋势,尤其适合追求灵活性和性能的场景。相较于 wagmi 等高级抽象库,纯 Viem 提供了以下显著优势:

  • 更细粒度的控制

    开发者可以直接操作每个链上请求,深入理解底层通信逻辑,便于调试和优化,无需受抽象层限制。

  • 轻量级实现

    不依赖额外的状态管理库,项目体积大幅减少(仅 viem 比 wagmi 全家桶少 70%+),加载速度显著提升。

  • 灵活性更高

    根据项目需求自由定制交互逻辑,不被高级库的预设框架束缚,适合复杂或定制化场景。

  • 更好的 TypeScript 支持

    Viem 的原生类型推断确保合约交互类型安全,降低运行时错误风险,成为开发者信赖的核心。

  • 更直观的 API 设计

    API 贴近以太坊原生操作,易于掌握区块链交互本质,减少学习曲线。

  • 调试友好与未来趋势

    出错时直接面对 Viem 的原始错误信息,无需解构 hook 问题,调试效率翻倍。同时,wagmi v2 已全面转向 Viem,纯 Viem 方案是未来 Web3 开发的标杆。

这种架构不仅适合初学者快速上手,也为高级开发者提供无限扩展空间,是链上开发的理想选择。

项目创建步骤

项目初始化

# 1. 创建 Next.js 项目(App Router)
npx create-next-app@latest simple-viem --typescript --tailwind --eslint --app --import-alias "@/*"

cd simple-viem

# 2. 安装核心依赖
pnpm install \
  viem@latest \
  antd \
  @ant-design/icons \
  @ant-design/nextjs-registry \
  dotenv

# 3. 安装开发依赖
pnpm install -D \
  prettier

智能合约部分初始化

# 在项目根目录初始化 Foundry(智能合约)
forge init contracts
cd contracts

# 删除 foundry 的 git 仓库,统一使用上层的 git 仓库
rm -rf .git

# 安装 OpenZeppelin,不能使用 git 安装,否则会使仓库管理混乱
forge install --no-git OpenZeppelin/openzeppelin-contracts

# 生成 ABI 文件
mkdir ../app/abis
forge inspect Counter abi --json > ../app/abis/Counter.json

核心依赖分析

该项目的核心依赖包括:

  • Next.js:React 框架,提供 SSR 和现代化的开发体验
  • Viem:用于与以太坊区块链交互的 TypeScript 库,是项目的核心
  • Ant Design:UI 组件库
  • Foundry:以太坊开发工具链(智能合约部分)

配置文件

项目的 tsconfig.json、package.json 等配置文件均遵循 next.js 的最佳实践配置,同时 TypeScript 确保了代码的类型安全。

项目文件结构

simple-viem/
├── app/
│   ├── abis/                    # 智能合约ABI文件
│   │   └── Counter.json        # Counter合约的ABI
│   ├── favicon.ico             # 网站图标
│   ├── globals.css             # 全局样式
│   ├── layout.tsx              # Next.js布局组件
│   ├── page.tsx                # 主页面,包含所有交互逻辑
│   ├── providers.tsx           # 提供者组件(空实现)
│   └── types/
│       └── ethereum.d.ts       # TypeScript类型定义
├── contracts/                  # 智能合约目录
│   ├── .env                    # 环境变量配置
│   ├── foundry.toml            # Foundry配置文件
│   ├── lib/                    # 依赖库(OpenZeppelin)
│   ├── out/                    # 编译输出目录
│   ├── script/                 # 部署脚本
│   ├── src/                    # 合约源码
│   │   └── Counter.sol         # Counter智能合约
│   └── test/                   # 测试文件
├── .gitignore                  # Git忽略文件
├── .prettierrc.cjs             # Prettier配置
├── eslint.config.mjs           # ESLint配置
├── next-env.d.ts               # Next.js类型声明
├── next.config.ts              # Next.js配置
├── package.json                # 项目依赖配置
├── postcss.config.mjs          # PostCSS配置
├── public/                     # 静态资源目录
│   ├── favicon.ico             # 网站图标
│   └── vercel.svg              # Vercel图标
└── tsconfig.json               # TypeScript配置

核心代码实现

Page.tsx 页面的 Viem 操作分析

项目的核心交互逻辑集中在 app/page.tsx 文件中,下面详细分析其中的关键操作:

支持多链配置

// 支持的链
const SUPPORTED_CHAINS = [foundry, sepolia] as const;

const COUNTER_ADDRESS_FOUNDRY = '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9';
const COUNTER_ADDRESS_SEPOLIA = '0x7B6781B15b4f3eF8476af20Ed45Cf6d09e0Ef55F';

function getCounterAddress(chainId: number) {
  return chainId === foundry.id ? COUNTER_ADDRESS_FOUNDRY : COUNTER_ADDRESS_SEPOLIA;
}

该项目支持多条链(Foundry 和 Sepolia 测试网),通过地址映射实现在不同网络上访问对应的合约实例。这种设计使得 DApp 能适应不同的开发和测试环境。

连接钱包

const connectWallet = async () => {
  if (typeof window.ethereum === 'undefined') {
    alert('请安装 MetaMask');
    return;
  }
  try {
    setIsLoading(true);
    const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' });
    const chainId = await window.ethereum.request({ method: 'eth_chainId' });
    setAddress(address as `0x${string}`);
    setChainId(Number(chainId));
    setIsConnected(true);
  } catch (error) {
    console.error('连接钱包失败:', error);
  } finally {
    setIsLoading(false);
  }
};

连接钱包功能通过直接调用 window.ethereum.request 方法实现,请求 eth_requestAccounts 方法获取用户授权的账户地址,同时获取当前链 ID。这种方式绕过了 wagmi 等高级抽象,直接使用 EIP-1193 标准与钱包通信。

获取钱包信息

// 读取余额
const fetchBalance = useCallback(async () => {
  if (!address || !chainId) return;
  try {
    const client = getPublicClient(chainId);
    const bal = await client.getBalance({ address });
    setBalance(formatEther(bal));
  } catch (err) {
    console.error('读取余额失败', err);
  }
}, [address, chainId]);

function getPublicClient(chainId: number) {
  const chain = SUPPORTED_CHAINS.find(c => c.id === chainId) ?? foundry;
  return createPublicClient({
    chain,
    transport: http(),
  }).extend(publicActions);
}

获取钱包信息分为几个步骤:

  1. 创建 publicClient,用于与区块链进行只读交互
  2. 使用 client.getBalance 方法获取指定地址的余额
  3. 使用 formatEther 将 bigint 格式的余额转换为易读的 ETH 格式

读写智能合约

读取合约数据
const fetchCounterNumber = useCallback(async () => {
  if (!chainId) return;
  try {
    const client = getPublicClient(chainId);
    const contract = getContract({
      address: getCounterAddress(chainId),
      abi: Counter_ABI,
      client,
    });
    const num = (await contract.read.number()) as bigint;
    setCounterNumber(num.toString());
  } catch (err) {
    console.error('读取 Counter 失败', err);
  }
}, [chainId]);

读取合约数据的步骤:

  1. 创建 publicClient
  2. 使用 getContract 创建合约实例
  3. 调用 contract.read[functionName] 方法读取合约状态
写入合约数据
const handleIncrement = async () => {
  if (!address || !window.ethereum || !chainId) return;
  const walletClient = getWalletClient();
  if (!walletClient) return alert('钱包未连接');
  try {
    setIsLoading(true);
    const hash = await walletClient.writeContract({
      address: getCounterAddress(chainId),
      abi: Counter_ABI,
      functionName: 'increment',
      account: address,
    });
    console.log('Transaction hash:', hash);

    const receipt = await walletClient.waitForTransactionReceipt({ hash: hash });
    console.log(`交易状态: ${receipt.status === 'success' ? '成功' : '失败'}`);

    // 更新数值显示
    await fetchCounterNumber();
  } catch (error) {
    console.error('调用 increment 失败:', error);
  } finally {
    setIsLoading(false);
  }
};

// 创建 walletClient(只在需要签名时创建)
const getWalletClient = useCallback(() => {
  if (!window.ethereum || !chainId || !address) return null;
  const chain = SUPPORTED_CHAINS.find(c => c.id === chainId) ?? foundry;
  return createWalletClient({
    account: address,
    chain,
    transport: custom(window.ethereum),
  }).extend(publicActions);
}, [address, chainId]);

写入合约数据的步骤:

  1. 创建 walletClient,用于需要签名的交易
  2. 调用 writeContract 方法发送交易到合约
  3. 使用 waitForTransactionReceipt 等待交易确认
  4. 更新相关状态

时序图:

sequenceDiagram
    participant User as 用户(浏览器)
    participant Page as React 页面 (page.tsx)
    participant WalletClient as Viem WalletClient
    participant MetaMask as MetaMask 钱包
    participant Node as 节点 (Infura/Alchemy/Anvil)
    participant Chain as 区块链

    User->>Page: 点击 “+1” 按钮
    Page->>Page: 调用 handleIncrement()
    Page->>Page: getWalletClient() 创建 walletClient
    Page->>WalletClient: writeContract({ ..., functionName: 'increment' })
    WalletClient->>MetaMask: eth_sendTransaction (签名请求弹窗)
    MetaMask-->>User: 请确认交易…
    User->>MetaMask: 点击【确认】
    MetaMask->>WalletClient: 返回已签名的交易
    WalletClient->>Node: 广播交易 (hash)
    Node-->>Chain: 提交交易
    WalletClient-->>Page: 返回 transaction hash
    Page->>Page: console.log('Transaction hash:', hash)

    Note over Page,Chain: 等待上链确认
    Page->>WalletClient: waitForTransactionReceipt({ hash })
    WalletClient->>Node: 轮询 receipt
    Node-->>Chain: 区块已打包
    Node-->>WalletClient: 返回 receipt (status: success)
    WalletClient-->>Page: receipt
    Page->>Page: console.log('交易成功')

    Page->>Page: 调用 fetchCounterNumber()
    Page->>publicClient: readContract({ functionName: 'number' })
    publicClient->>Node: call (只读)
    Node-->>publicClient: 返回最新 number
    publicClient-->>Page: 返回新值
    Page->>Page: setCount(新值) → 页面更新

    Note over User,Chain: 整个过程用户只点了一次确认<br/>所有状态自动同步

断开连接

const disconnectWallet = useCallback(async () => {
  if (!address || !window.ethereum || !chainId) return;
  setIsConnected(false);
  setAddress(undefined);
  setChainId(undefined);
  setBalance('0');
  setCounterNumber('0');
  try {
    // 对于 MetaMask 10.28+
    await window.ethereum.request({
      method: 'wallet_revokePermissions',
      params: [{ eth_accounts: {} }],
    });
    // 老版本 MM 会抛 4200 错误,捕获即可
  } catch (e: unknown) {
    if (typeof e === 'object' && e !== null && 'code' in e && (e as { code: unknown }).code === 4200) {
      alert('请手动在钱包里断开本次连接');
    }
  }
}, [address, chainId]);

断开连接功能不仅清空了本地状态,还通过 wallet_revokePermissions 方法撤销了对钱包的访问权限。

监听钱包操作

useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    console.log('账户变化', accounts);
    if (accounts.length === 0) {
      disconnectWallet().catch(console.error);
    } else {
      setAddress(accounts[0] as `0x${string}`);
    }
  };

  const handleChainChanged = (chainIdHex: string) => {
    console.log('网络变化', chainIdHex);
    setChainId(Number(chainIdHex));
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  return () => {
    window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum?.removeListener('chainChanged', handleChainChanged);
  };
}, [address, disconnectWallet, fetchBalance, fetchCounterNumber]);

通过监听 accountsChangedchainChanged 事件,DApp 能够实时响应用户的账户切换和网络切换操作,保持应用状态与钱包状态的一致性。

Counter 智能合约分析

Counter 合约是一个简单的计数器合约,包含以下功能:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Counter {
    uint256 public number;

    function setNumber(uint256 newNumber) public {
        number = newNumber;
    }

    function increment() public {
        number++;
    }
}

该合约提供了两个主要功能:

  1. number() - 读取当前计数值(getter function)
  2. increment() - 将计数值加 1
  3. setNumber() - 设置新的计数值

纯 Viem 脚手架项目的架构优势

更细粒度的控制

使用纯 Viem 相比于 wagmi 等抽象库,开发者能够更精确地控制每个操作,了解底层通信逻辑,便于调试和优化。

轻量级实现

不引入额外的状态管理库,减少了项目体积,提高了加载速度。

灵活性更高

可以根据项目需求定制特定的交互逻辑,而不受高级抽象库的限制。

更好的 TypeScript 支持

Viem 提供了优秀的 TypeScript 类型推断,确保合约交互的类型安全。

更直观的 API 设计

Viem 的 API 设计更接近以太坊的原生操作,便于理解区块链交互的本质。

对比 wagmi 的核心差异

功能 wagmi 写法(抽象) 纯 Viem 写法
连接钱包 useConnect() window.ethereum.request('eth_requestAccounts')
切换链/账号自动更新 wagmi 自动 手动监听 accountsChanged / chainChanged
读余额 useBalance() publicClient.getBalance()
读合约 useReadContract() publicClient.readContract()
发交易 useWriteContract() walletClient.writeContract()
等待确认 自动 walletClient.waitForTransactionReceipt()

完整代码示例

下面是一个完整的示例,展示了如何使用纯 Viem 构建一个功能完整的 DApp:

'use client';

import { useState, useEffect, useCallback } from 'react';
import { createPublicClient, createWalletClient, http, formatEther, getContract, custom, publicActions } from 'viem';
import { foundry, sepolia } from 'viem/chains';
import Counter_ABI from './abis/Counter.json';

// 支持的链
const SUPPORTED_CHAINS = [foundry, sepolia] as const;

// Counter 合约地址
const COUNTER_ADDRESS_FOUNDRY = '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9';
const COUNTER_ADDRESS_SEPOLIA = '0x7B6781B15b4f3eF8476af20Ed45Cf6d09e0Ef55F';

function getCounterAddress(chainId: number) {
  return chainId === foundry.id ? COUNTER_ADDRESS_FOUNDRY : COUNTER_ADDRESS_SEPOLIA;
}

function getPublicClient(chainId: number) {
  const chain = SUPPORTED_CHAINS.find(c => c.id === chainId) ?? foundry;
  return createPublicClient({
    chain,
    transport: http(),
  }).extend(publicActions);
}

export default function Home() {
  const [balance, setBalance] = useState<string>('0');
  const [counterNumber, setCounterNumber] = useState<string>('0');
  const [address, setAddress] = useState<`0x${string}` | undefined>();
  const [isConnected, setIsConnected] = useState(false);
  const [chainId, setChainId] = useState<number | undefined>();
  const [isLoading, setIsLoading] = useState(false);

  const currentChain = SUPPORTED_CHAINS.find(c => c.id === chainId);

  // 创建 walletClient(只在需要签名时创建)
  const getWalletClient = useCallback(() => {
    if (!window.ethereum || !chainId || !address) return null;
    const chain = SUPPORTED_CHAINS.find(c => c.id === chainId) ?? foundry;
    return createWalletClient({
      account: address,
      chain,
      transport: custom(window.ethereum),
    }).extend(publicActions);
  }, [address, chainId]);

  // 获取 Counter 合约的数值
  const fetchCounterNumber = useCallback(async () => {
    if (!chainId) return;
    try {
      const client = getPublicClient(chainId);
      const contract = getContract({
        address: getCounterAddress(chainId),
        abi: Counter_ABI,
        client,
      });
      const num = (await contract.read.number()) as bigint;
      setCounterNumber(num.toString());
    } catch (err) {
      console.error('读取 Counter 失败', err);
    }
  }, [chainId]);

  // 读取余额
  const fetchBalance = useCallback(async () => {
    if (!address || !chainId) return;
    try {
      const client = getPublicClient(chainId);
      const bal = await client.getBalance({ address });
      setBalance(formatEther(bal));
    } catch (err) {
      console.error('读取余额失败', err);
    }
  }, [address, chainId]);

  // 连接钱包
  const connectWallet = async () => {
    if (typeof window.ethereum === 'undefined') {
      alert('请安装 MetaMask');
      return;
    }
    try {
      setIsLoading(true);
      const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' });
      const chainId = await window.ethereum.request({ method: 'eth_chainId' });
      setAddress(address as `0x${string}`);
      setChainId(Number(chainId));
      setIsConnected(true);
    } catch (error) {
      console.error('连接钱包失败:', error);
    } finally {
      setIsLoading(false);
    }
  };

  // 断开连接
  const disconnectWallet = useCallback(async () => {
    if (!address || !window.ethereum || !chainId) return;
    setIsConnected(false);
    setAddress(undefined);
    setChainId(undefined);
    setBalance('0');
    setCounterNumber('0');
    try {
      // 对于 MetaMask 10.28+
      await window.ethereum.request({
        method: 'wallet_revokePermissions',
        params: [{ eth_accounts: {} }],
      });
      // 老版本 MM 会抛 4200 错误,捕获即可
    } catch (e: unknown) {
      if (typeof e === 'object' && e !== null && 'code' in e && (e as { code: unknown }).code === 4200) {
        alert('请手动在钱包里断开本次连接');
      }
    }
  }, [address, chainId]);

  // 调用 increment 函数
  const handleIncrement = async () => {
    if (!address || !window.ethereum || !chainId) return;
    const walletClient = getWalletClient();
    if (!walletClient) return alert('钱包未连接');
    try {
      setIsLoading(true);
      const hash = await walletClient.writeContract({
        address: getCounterAddress(chainId),
        abi: Counter_ABI,
        functionName: 'increment',
        account: address,
      });
      console.log('Transaction hash:', hash);

      const receipt = await walletClient.waitForTransactionReceipt({ hash: hash });
      console.log(`交易状态: ${receipt.status === 'success' ? '成功' : '失败'}`);

      // 更新数值显示
      await fetchCounterNumber();
    } catch (error) {
      console.error('调用 increment 失败:', error);
    } finally {
      setIsLoading(false);
    }
  };

  // 全局监听(只添加一次)
  useEffect(() => {
    if (!window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log('账户变化', accounts);
      if (accounts.length === 0) {
        disconnectWallet().catch(console.error);
      } else {
        setAddress(accounts[0] as `0x${string}`);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      console.log('网络变化', chainIdHex);
      setChainId(Number(chainIdHex));
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum?.removeListener('chainChanged', handleChainChanged);
    };
  }, [address, disconnectWallet, fetchBalance, fetchCounterNumber]);

  // 连接后自动读取数据
  useEffect(() => {
    if (address && chainId) {
      console.log('连接后自动读取数据:', address);
      fetchBalance().catch(console.error);
      fetchCounterNumber().catch(console.error);
    }
  }, [address, chainId, fetchBalance, fetchCounterNumber]);

  return (
    <div className='min-h-screen flex flex-col items-center justify-center p-8'>
      <h1 className='text-3xl font-bold mb-8'>Simple Viem Demo</h1>

      <div className='bg-white p-6 rounded-lg shadow-lg w-full max-w-2xl'>
        {!isConnected ? (
          <button
            onClick={connectWallet}
            disabled={isLoading}
            className='w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition-colors'
          >
            {isLoading ? '连接中...' : '连接 MetaMask'}
          </button>
        ) : (
          <div className='space-y-4'>
            <div className='text-center'>
              <p className='text-gray-600'>钱包地址:</p>
              <p className='font-mono break-all'>{address}</p>
            </div>
            <div className='text-center'>
              <p className='text-gray-600'>当前网络:</p>
              <p className='font-mono'>
                {currentChain?.name || '未知网络'} (Chain ID: {chainId})
              </p>
            </div>
            <div className='text-center'>
              <p className='text-gray-600'>余额:</p>
              <p className='font-mono'>{balance} ETH</p>
            </div>
            <div className='text-center'>
              <p className='text-gray-600'>Counter 数值:</p>
              <p className='font-mono'>{counterNumber}</p>
              <button
                onClick={handleIncrement}
                disabled={isLoading}
                className='mt-2 w-full bg-green-500 text-white py-2 px-4 rounded hover:bg-green-600 transition-colors'
              >
                {isLoading ? '交易进行中...' : '增加计数'}
              </button>
            </div>
            <button
              onClick={disconnectWallet}
              className='w-full bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600 transition-colors'
            >
              断开连接
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

总结

本文详细分析了一个纯 Viem 脚手架项目,展示了如何使用 Viem 库直接与 MetaMask 钱包和智能合约进行交互,包括:

  1. 项目创建步骤和核心依赖
  2. 如何使用 Viem 实现多链支持
  3. 如何连接和断开 MetaMask 钱包
  4. 如何获取钱包信息和余额
  5. 如何读写智能合约
  6. 如何监听钱包状态变化
  7. 纯 Viem 实现相对于 wagmi 等库的优势

纯 Viem 方案为开发者提供了更底层的控制和更灵活的实现方式,适合需要深入了解区块链交互逻辑的开发者使用。这种架构不仅保持了代码的简洁性,还提供了更大的扩展空间。

参考资料

揭秘 React Native 布局:Yoga 引擎与那些“反直觉”的 Flexbox

如果你是从 Web 前端转战 React Native (RN) 的开发者,第一次写布局时通常会产生三个灵魂拷问:

  1. “为什么我没写 display: flex,布局就已经生效了?”
  2. “为什么元素默认是垂直排列的?我的 row 去哪了?”
  3. “官网文档里提到的 Yoga 到底是个什么东西?”

这篇文章将带你深入 React Native 的布局底层,揭开 Yoga 引擎 的面纱,并解析 RN 与 Web CSS 在布局上的核心差异。


一、幕后大脑:什么是 Yoga?

在 React Native 的官方文档中,你很难找到关于 Yoga 的详细篇幅,但它却是整个 RN 能够跨平台渲染 UI 的基石。

Yoga 是一个由 Facebook (Meta) 开发的跨平台布局引擎,使用 C++ 编写。

1. 为什么要造一个 Yoga?

React Native 的愿景是“Learn Once, Write Anywhere”,但现实很骨感:

  • iOS 原生使用的是 AutoLayout 或 Frame 计算。
  • Android 原生使用的是 Measure/Layout 过程 (LinearLayout, RelativeLayout 等)。
  • Web 使用的是 CSS Box Model。

如果 RN 想要跨平台,开发者不可能去写三套布局逻辑。于是 Facebook 决定统一使用 Flexbox 规范。但原生系统看不懂 Flexbox,这就需要一个“翻译官”。

2. Yoga 的工作流

Yoga 就是这个翻译官,也是 RN 的排版大脑

  1. 输入: 你在 JS 代码里写的 flex: 1, width: 100 等样式。
  2. 计算: RN 将这些指令传给 C++ 层的 Yoga,Yoga 根据 Flexbox 算法算出每个元素精确的 (x, y, width, height)
  3. 输出: Yoga 将算好的坐标告诉 iOS 的 UIView 或 Android 的 ViewGroup,原生系统负责把它们画在屏幕上。

二、Web 开发者必读:RN 布局的“反直觉”差异

虽然 Yoga 实现了 Flexbox 规范,但为了适应移动端的特性和性能要求,它对标准 CSS 做了一些**“魔改”**。这也是新手最容易踩坑的地方。

1. 默认就是 display: flex

在 Web 中,div 默认是 display: block。但在 RN 中, “万物皆 Flex”

  • 所有的组件(View, Text, Image)默认都已经开启了 Flex 布局。
  • 你不需要,也不能写 display: 'block'display: 'grid'(RN 不支持 Grid,只能用 Flex 模拟)。

2. 主轴默认是纵向 (column)

这是最大的误解来源。

  • Web CSS: 默认 flex-direction: row(从左到右)。
  • React Native: 默认 flex-direction: column(从上到下)。

设计哲学: 手机屏幕是狭长的,用户习惯上下滑动浏览内容,因此将默认流设为从上到下更符合移动端直觉。如果你想横向排列按钮,必须显式声明:

<View style={{ flexDirection: 'row' }}>...</View>

3. 没有“样式继承” (Cascading)

CSS 全称是 Cascading Style Sheets(层叠样式表),但在 RN 中,样式不会继承

  • Web: 给父级设 color: red,子元素文字都会变红。
  • RN: 给父 View 设颜色,子 Text 毫无反应。你必须给每个 Text 单独设置样式。

4. 强制的 border-box

Web 开发中常需重置 box-sizing: border-box。在 RN 中,只有 border-box 这一种模式。Padding 和 Border 永远包含在宽高内,这大大简化了尺寸计算。


三、实战:彻底搞懂 flex: number

在 RN 中,我们经常看到 flex: 1flex: 2,这比 Web CSS 中的 flex-grow/shrink/basis 组合要直观得多。

在 Yoga 引擎中,flex 接收一个数字,代表瓜分剩余空间的权重(比例)

示例代码:

<View style={{ flex: 1, height: 300 }}>
  {/* 红色:占 1 份 */}
  <View style={{ flex: 1, backgroundColor: 'red' }} />
  {/* 绿色:占 2 份 */}
  <View style={{ flex: 2, backgroundColor: 'green' }} />
  {/* 蓝色:占 3 份 */}
  <View style={{ flex: 3, backgroundColor: 'blue' }} />
</View>

计算逻辑:

  1. 总份数 = 1 + 2 + 3 = 6 份
  2. 红色高度 = 父容器高度 × (1/6)。
  3. 绿色高度 = 父容器高度 × (2/6)。
  4. 蓝色高度 = 父容器高度 × (3/6)。

注意: 只有当父容器有明确尺寸(固定高度 或 也是 flex: 1 撑开)时,子元素的 flex 比例瓜分才会生效。


四、总结与工具推荐

React Native 的布局系统可以总结为:一个用 C++ 编写的、默认纵向排列的、强制 border-box 的严格 Flexbox 子集。

调试利器:Yoga Playground

如果你在写复杂布局时被“挤”得乱七八糟,或者想单纯测试 Flexbox 逻辑,推荐使用官方的在线游乐场:

🔗 Yoga Layout Playground

在这里,你可以脱离 RN 环境,直接调整 Flex 属性,实时观察布局引擎的计算结果。

核心对照表

特性 Web CSS React Native (Yoga)
默认 Display block flex
默认主轴方向 row (横向) column (纵向)
盒模型 content-box (默认) border-box (强制)
样式继承 支持 不支持
单位 px, rem, %, vw dp (逻辑点), %

掌握了这些,你就掌握了 RN 布局的 90%。剩下的,就是把 Flexbox 的属性排列组合而已。

SpreadJS 电子表格权限管控设置指南

SpreadJS 电子表格权限管控设置指南

电子表格作为企业数据存储、分析与协作的核心工具,常包含财务数据、客户信息、业务机密等敏感内容。一旦权限管控不当,可能引发数据泄露、误修改、版本混乱等风险。本节课我们将以 SpreadJS 为核心,从访问权限控制、数据安全控制、协作权限控制等维度,提供一套完整的权限管控实操指南。

一、访问权限控制

访问权限控制主要限制的是用户的操作权限,这里又细分为以下三种:

1、文件级权限 - 控制 "谁能打开文件"

SpreadJS支持在导入和导出xlsx文件时使用密码进行加密和解密。

API 设置

导出示例:

let password = "spreadjs2023";
spread.export(blob => saveAs(blob, "encrypted-export.xlsx"), console.log, {
   fileType: GC.Spread.Sheets.FileType.excel,
   password: password
});

导入示例:

spread.import(file, successCallback, error => {
   console.log(error.errorMessage, `Error Code: ${error.errorCode}`);
}, {
   fileType: GC.Spread.Sheets.FileType.excel,
   password: password
})

Designer UI操作

img

2、内容级权限 - 控制 "能修改哪些内容"

2.1 限制对工作表的编辑操作

表单保护

针对单个工作表,可禁止修改单元格内容、格式、插入删除行 / 列等操作,仅保留必要权限(如仅允许填写指定单元格)。

API设置
sheet.options.isProtected = true;

密码保护:

var password = '<user_input>';
sheet.protect
(password);
// ...
if (sheet.hasPassword()) {
      var passwordUnlock = '<user_input>';
      var success = sheet.unprotect(passwordUnlock);
      // ...
} else {
      sheet.unprotect(); // or sheet.options.isProtect = false;
}
Designer UI操作

img

单元格锁定

通过将 locked 方法设置为False,允许用户编辑受保护工作表中的特定单元格。

sheet.getCell(1,1, GC.Spread.Sheets.SheetArea.viewport).locked(false);
sheet.setValue(1,1,"unLocked");
sheet.getRange(-1,3, -1, 1).locked(false);
sheet.getRange(5, -1, 1, -1).locked(false);
sheet.options.isProtected = true;
单元格禁止编辑:

使用 Style 类的 allowEditInCell 属性或 CellRange 类的allowEditInCell 属性禁用单元格编辑。默认情况下,该属性是启用的。

var sheet = spread.getActiveSheet();
// 使用 Style 类的 allowEditInCell 属性
var style = new GC.Spread.Sheets.Style();
style.allowEditInCell = false;
sheet.setStyle(1, 1, style);
公式隐藏:

使用 Style 类的 hidden 属性或 CellRange 类的 hidden 方法来控制受保护工作表中公式单元格的可见性。默认情况下,hidden 属性设置为 false,表示公式不会被隐藏。

hidden 属性在某些场景下非常有用,比如根据员工自评和主管评估生成 KPI 数据或年终奖金时,老板希望隐藏相关公式并保护评估系统。

此代码示例使用 GC.Spread.Sheets.CellRange 类型中的隐藏方法:

activeSheet.options.isProtected = true;
activeSheet.setFormula(1, 3, "=SUM(10,20)");
activeSheet.getRange(1, 3, GC.Spread.Sheets.SheetArea.viewport).hidden(true);

2.2 隐藏敏感内容:仅显示必要信息

工作表选项卡:
// 通过设置 tabStripVisible 控制是否显示工作表标签。
spread.options.tabStripVisible = false;
// 将 tabNavigationVisible 设置为 false 可以隐藏导航箭头按钮(默认显示)
spread.options.tabNavigationVisible = false;
//设置 newTabVisible 选项,可以允许或禁止用户通过点击"+"按钮添加工作表(默认显示)。
spread.options.newTabVisible = false;
// 通过 allSheetsListVisible 选项控制"≡"按钮是否可见
spread.options.allSheetsListVisible = GC.Spread.Sheets.AllSheetsListVisibility.auto;
sheet可见性:
  • hidden:工作表标签被隐藏,等于false。
  • visible:工作表标签可见,等于true,为sheetTabVisible的默认值。
  • veryHidden:工作表选项卡深度隐藏。
// 隐藏sheet
sheet.visible(false);
// 深度隐藏一个工作表(这意味着它不能通过 UI 取消隐藏)
sheet.visible(SheetTabVisible.veryHidden);
隐藏行列头:
// 是否显示行列头
sheet.options.rowHeaderVisible = false;
sheet.options.colHeaderVisible = false;
隐藏右键菜单:
// 设置是否展示上下文菜单。
spread.options.allowContextMenu = false;

3、功能级权限 - 控制 "能操作哪些功能"

通过对在线表格编辑器(Designer)二次开发定制,可实现工具栏、上下文菜单、文件菜单等功能项的增删改操作。

例如:

  • 开启表单保护后,工具栏部分button可用 (禁用状态)

img

  • 删除/禁用菜单项

img

  • 删除文件菜单-导入文件的入口

img

二、数据安全控制

数据验证条件格式的深度应用,能从 "数据输入合法性""风险可视化预警" 两个维度,为电子表格权限管理筑起更精细的安全防线。

数据验证

数据验证是电子表格 "权限管理前置化" 的关键手段 ------ 它能限制用户 "能输入什么数据",从源头避免非法操作和数据污染,本质是对 "数据编辑权限" 的精细化管控。

1、下拉列表:锁定合法数据范围

通过设置下拉列表,可强制用户从预设选项中选择输入值,避免随意录入导致的数据混乱,同时也限定了 "谁能输入哪些内容" 的权限边界。

  • 场景

财务报销单的 "费用类型" 列,仅允许选择 "差旅费、办公费、招待费";

HR 报表的 "员工状态" 列,仅开放 "在职、离职、试用期" 选项。

2、 输入值范围限制:杜绝越权或错误输入

通过限定数值、日期的输入范围,可避免用户录入超出权限或逻辑范围的数据。

  • 场景

如 "采购单价" 列,限定输入范围为0-1000(防止录入负数或天价采购单);

"员工年龄" 列限定18-65(符合劳动法逻辑)。

3、 自定义公式验证:复杂场景的权限逻辑

对于更复杂的权限或数据逻辑,可通过自定义公式实现验证。

  • 场景

若 "发票金额" 列需小于 "合同金额" 列,可在发票金额单元格设置公式:=B2>A2(B2 为发票金额,A2 为合同金额),仅当公式返回TRUE时允许输入。

4、输入提示与错误警告:权限的可视化指引

为数据验证添加 "输入信息" 和 "错误警告",可明确告知用户权限边界和操作规范。

条件格式

条件格式是 "权限风险的可视化雷达"------ 它能自动标记出越权、违规或高风险数据,让权限问题从 "隐藏" 变为 "直观可见",便于管理员快速审计和干预。 通过颜色、图标标记出超出权限或逻辑的数据,实现 "风险可视化"。

  • 场景

若某列 "客户等级" 仅允许 "普通、VIP",而出现 "SVIP" 时,用红色填充单元格并标记警告图标。

结合管理示例

将数据验证和条件格式结合,可形成 "预防 - 监控 - 审计" 的权限管理闭环。数据验证(预防非法输入)→ 条件格式(监控违规数据)

  • 场景:采购审批表

① 数据验证:"采购金额" 列仅允许输入0-5000(普通员工权限),超出范围需经理审批;

② 条件格式:对 "采购金额> 5000" 的单元格标记为红色,并添加 "需经理审批" 的批注;

③ 结果:普通员工无法录入超 5000 的金额(预防),若因特殊情况录入(或经理录入),则自动标红提醒(监控),便于后续审计。

三、协作权限控制

随着数字化转型加速,远程办公渗透率已超40%,跨地域、跨部门协作成为企业常态。传统电子表格管理面临版本混乱失控、协作效率低下、权限管控薄弱等问题。针对于此,SpreadJS 协同编辑插件提供全方位解决方案,让表格协作从混乱低效走向有序高效。

多人实时协作

打破单人编辑限制,实现多人实时协作,大幅提升团队效率:

  • 支持团队多人同时编辑,操作实时同步无延迟
  • 显示协作者光标与编辑状态,避免操作冲突
  • 智能冲突解决,自动处理交叉编辑场景
  • 编辑即时生效,无需手动刷新或文件上传
  • 较传统模式提升协作效率60%以上,团队首选

全链路版本管理

告别"文件_v2_final"式的混乱命名,提供自动化版本管理:

  • 生成版本快照,记录编辑人、时间与修改内容
  • 可视化版本对比,清晰展示差异变化
  • 一键回溯历史版本,杜绝数据丢失风险
  • 支持版本命名与备注,重要节点一目了然

精细化权限体系

从整体到单元格的多维权限控制,兼顾数据安全与协作自由

  • 支持单元格/行/列/工作表多级权限设置
  • 灵活角色定义:查看者 vs 编辑者
  • 动态权限调整,随需变更访问权限
  • 敏感数据智能隐藏,未授权用户不可见
  • 完整操作日志记录,变更轨迹一目了然

在线体验地址:demo.grapecity.com.cn/spreadjs/de…

img

结语

在数据价值日益凸显的今天,电子表格作为企业数据流转的核心载体,其权限管控已成为数据安全体系的重要一环。希望本文的指南能为读者带来帮助,让电子表格在安全可控的前提下,充分发挥数据存储、分析与协作的核心价值,成为支撑业务高效运转的可靠工具。

扩展链接

学习指南:demo.grapecity.com.cn/spreadjs/Sp…

产品文档:demo.grapecity.com.cn/spreadjs/he…

产品首页:www.grapecity.com.cn/developer/s…

在线Excel:demo.grapecity.com.cn/SpreadJS/We…

每日一题-同时运行 N 台电脑的最长时间🔴

你有 n 台电脑。给你整数 n 和一个下标从 0 开始的整数数组 batteries ,其中第 i 个电池可以让一台电脑 运行 batteries[i] 分钟。你想使用这些电池让 全部 n 台电脑 同时 运行。

一开始,你可以给每台电脑连接 至多一个电池 。然后在任意整数时刻,你都可以将一台电脑与它的电池断开连接,并连接另一个电池,你可以进行这个操作 任意次 。新连接的电池可以是一个全新的电池,也可以是别的电脑用过的电池。断开连接和连接新的电池不会花费任何时间。

注意,你不能给电池充电。

请你返回你可以让 n 台电脑同时运行的 最长 分钟数。

 

示例 1:

输入:n = 2, batteries = [3,3,3]
输出:4
解释:
一开始,将第一台电脑与电池 0 连接,第二台电脑与电池 1 连接。
2 分钟后,将第二台电脑与电池 1 断开连接,并连接电池 2 。注意,电池 0 还可以供电 1 分钟。
在第 3 分钟结尾,你需要将第一台电脑与电池 0 断开连接,然后连接电池 1 。
在第 4 分钟结尾,电池 1 也被耗尽,第一台电脑无法继续运行。
我们最多能同时让两台电脑同时运行 4 分钟,所以我们返回 4 。

示例 2:

输入:n = 2, batteries = [1,1,1,1]
输出:2
解释:
一开始,将第一台电脑与电池 0 连接,第二台电脑与电池 2 连接。
一分钟后,电池 0 和电池 2 同时耗尽,所以你需要将它们断开连接,并将电池 1 和第一台电脑连接,电池 3 和第二台电脑连接。
1 分钟后,电池 1 和电池 3 也耗尽了,所以两台电脑都无法继续运行。
我们最多能让两台电脑同时运行 2 分钟,所以我们返回 2 。

 

提示:

  • 1 <= n <= batteries.length <= 105
  • 1 <= batteries[i] <= 109

两种方法:二分答案 / 排序+贪心(Python/Java/C++/Go)

方法一:二分答案

如果可以让 $n$ 台电脑同时运行 $x$ 分钟,那么必然可以同时运行 $x-1,x-2,\ldots$ 分钟(要求更宽松);如果无法让 $n$ 台电脑同时运行 $x$ 分钟,那么必然无法同时运行 $x+1,x+2,\ldots$ 分钟(要求更苛刻)。

据此,可以二分猜答案。关于二分算法的原理,请看 二分查找 红蓝染色法【基础算法精讲 04】

假设可以让 $n$ 台电脑同时运行 $x$ 分钟,那么对于电量大于 $x$ 的电池,其只能被使用 $x$ 分钟,因此每个电池的使用时间至多为 $\min(\textit{batteries}[i], x)$。累加所有电池的使用时间,记作 $\textit{sum}$。那么要让 $n$ 台电脑同时运行 $x$ 分钟,必要条件是 $n\cdot x\le \textit{sum}$。

下面证明该条件也是充分的,即如果 $n\cdot x\le \textit{sum}$ 成立,那么一定存在一种安排电池的方式,可以让 $n$ 台电脑同时运行 $x$ 分钟。

构造方法如下:

对于电量 $\ge x$ 的电池,我们可以让其给一台电脑供电 $x$ 分钟。由于一个电池不能同时给多台电脑供电,因此该电池若给一台电脑供电 $x$ 分钟,那它就不能用于其他电脑了(因为电脑运行时间就是 $x$ 分钟)。我们可以将所有电量 $\ge x$ 的电池各给一台电脑供电。

对于其余电池,设其电量和为 $\textit{sum}'$,剩余 $n'$ 台电脑未被供电。我们可以随意选择剩下的电池,供给剩余的第一台电脑(用完一个电池就换下一个电池),多余的电池电量与剩下的电池一起供给剩余的第二台电脑,依此类推。注意由于这些电池的电量均小于 $x$,按照这种做法是不会出现同一个电池在同一时间供给多台电脑的(如果某个电池供给了两台电脑,可以将这个电池的供电时间划分到第一台电脑的末尾和第二台电脑的开头)。

由于 $\textit{sum}'=\textit{sum}-(n-n')\cdot x$,结合 $n\cdot x\le \textit{sum}$ 可以得到 $n'\cdot x\le \textit{sum}'$,按照上述供电方案(用完一个电池就换下一个电池),这 $n'$ 台电脑可以运行至少 $x$ 分钟。充分性得证。

细节

下面代码采用开区间二分,这仅仅是二分的一种写法,使用闭区间或者半闭半开区间都是可以的。

  • 开区间左端点初始值:$0$。不运行任何电脑,一定满足要求。
  • 开区间右端点初始值:平均值加一,即 $\left\lfloor\dfrac{\sum \textit{batteries}[i]}{n}\right\rfloor + 1$。一定无法满足要求。
class Solution:
    def maxRunTime(self, n: int, batteries: List[int]) -> int:
        l, r = 0, sum(batteries) // n + 1
        while l + 1 < r:
            x = (l + r) // 2
            if n * x <= sum(min(b, x) for b in batteries):
                l = x
            else:
                r = x
        return l
class Solution:
    def maxRunTime(self, n: int, batteries: List[int]) -> int:
        r = sum(batteries) // n
        # 二分找最小的不满足要求的 x+1,那么最大的满足要求的就是 x
        check = lambda x: n * (x + 1) > sum(min(b, x + 1) for b in batteries)
        return bisect_left(range(r), True, key=check)
class Solution {
    public long maxRunTime(int n, int[] batteries) {
        long tot = 0;
        for (int b : batteries) {
            tot += b;
        }

        long l = 0;
        long r = tot / n + 1;
        while (l + 1 < r) {
            long x = l + (r - l) / 2;
            long sum = 0;
            for (int b : batteries) {
                sum += Math.min(b, x);
            }
            if (n * x <= sum) {
                l = x;
            } else {
                r = x;
            }
        }
        return l;
    }
}
class Solution {
public:
    long long maxRunTime(int n, vector<int>& batteries) {
        long long tot = reduce(batteries.begin(), batteries.end(), 0LL);
        long long l = 0, r = tot / n + 1;
        while (l + 1 < r) {
            long long x = l + (r - l) / 2;
            long long sum = 0;
            for (long long b : batteries) {
                sum += min(b, x);
            }
            (n * x <= sum ? l : r) = x;
        }
        return l;
    }
};
func maxRunTime(n int, batteries []int) int64 {
tot := 0
for _, b := range batteries {
tot += b
}

return int64(sort.Search(tot/n, func(x int) bool {
x++
sum := 0
for _, b := range batteries {
sum += min(b, x)
}
return n*x > sum
}))
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(m\log (S/n))$,其中 $m$ 是 $\textit{batteries}$ 的长度,$S$ 是 $\textit{batteries}$ 的元素和。
  • 空间复杂度:$\mathcal{O}(1)$。

方法二:排序 + 贪心

受解法一的启发,我们可以得出如下贪心策略:

记电池电量和为 $\textit{sum}$,则理论上至多可以供电 $x=\Big\lfloor\dfrac{\textit{sum}}{n}\Big\rfloor$ 分钟。我们对电池电量从大到小排序,然后从电量最大的电池开始遍历:

  • 若该电池电量超过 $x$,则将其供给一台电脑,问题缩减为 $n-1$ 台电脑的子问题。

  • 若该电池电量不超过 $x$,则其余电池的电量均不超过 $x$,此时有

    $$
    n\cdot x=n\cdot\Big\lfloor\dfrac{\textit{sum}}{n}\Big\rfloor \le \textit{sum}
    $$

    根据解法一的结论,这些电池可以给 $n$ 台电脑供电 $x$ 分钟。

由于随着问题规模减小,$x$ 不会增加,因此若遍历到一个电量不超过 $x$ 的电池时,可以直接返回 $x$ 作为答案。

class Solution:
    def maxRunTime(self, n: int, batteries: List[int]) -> int:
        batteries.sort(reverse=True)
        s = sum(batteries)
        for b in batteries:
            if b <= s // n:
                return s // n
            s -= b
            n -= 1
class Solution {
    public long maxRunTime(int n, int[] batteries) {
        Arrays.sort(batteries);

        long sum = 0;
        for (int b : batteries) {
            sum += b;
        }

        for (int i = batteries.length - 1; ; i--) {
            if (batteries[i] <= sum / n) {
                return sum / n;
            }
            sum -= batteries[i];
            n--;
        }
    }
}
class Solution {
public:
    long long maxRunTime(int n, vector<int>& batteries) {
        ranges::sort(batteries, greater());
        long long sum = reduce(batteries.begin(), batteries.end(), 0LL);
        for (int i = 0; ; i++) {
            if (batteries[i] <= sum / n) {
                return sum / n;
            }
            sum -= batteries[i];
            n--;
        }
    }
};
func maxRunTime(n int, batteries []int) int64 {
slices.Sort(batteries)
sum := 0
for _, b := range batteries {
sum += b
}
for i := len(batteries) - 1; ; i-- {
if batteries[i] <= sum/n {
return int64(sum / n)
}
sum -= batteries[i]
n--
}
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(m\log m)$,其中 $m$ 是 $\textit{batteries}$ 的长度。瓶颈在排序上。
  • 空间复杂度:$\mathcal{O}(1)$。忽略排序的栈开销。

专题训练

  1. 二分题单的「§2.2 求最大」。
  2. 贪心题单的「§1.1 从最小/最大开始贪心」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/最短路/最小生成树/二分图/基环树/欧拉路径)
  7. 动态规划(入门/背包/状态机/划分/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

二分答案的check函数的思考方式

本题来自ABC 227D。这题很容易想到二分答案,但是check函数稍微有点难想。
借用了张图来表达一下。原日文博客
设本题电脑同时运行时间为P,这也是我们不断二分得到的结果。
设一共有K台电脑,我们的目的是在P的时间内不断运转他们。
因此,我们的目的其实是看电池的状态能不能填满P*K的矩形。

image.png
上部分代表了一种电池的合法分布情况。
很明显,当Batteries0(黄色)的数量超过了P,我们其实只需要P即可,剩下的都只能抛弃。
当Batteries1(橘色)的数量小于P,我们需要把当前电池全部用完。同时提前借用别的电池来填充该列。

然而,下面也有NG的情况。我们把橘色电池容量-1,红色的+1,再来看看我们构造的矩形。因为一行不能存在2个同样的颜色(即不能存在一个电池给2个电脑续航的情况),所以红色的电池会浪费掉一个(对应了代码里的min(p, 红色电池容量)),最终导致矩形的构造失败。
总结一下可以用这个心态来构造矩形:小于P的时候,贪心地利用多个电池,但是同时不能在一行里有相同的颜色。

###C++

auto check = [&](i64 mid) {
            i64 sum = 0;
            for(int x : batteries) sum += min(mid, (i64)x);
            return sum >= n * mid;
        };

全部代码:

###C++

typedef long long i64;
class Solution {
public:
    long long maxRunTime(int n, vector<int>& batteries) {
        auto check = [&](i64 mid) {
            i64 sum = 0;
            for(int x : batteries) sum += min(mid, (i64)x);
            return sum >= n * mid;
        };
        i64 l = 0, r = 1e16/n;
        while (l < r) {
            i64 mid = l + r + 1>> 1;
            if (check(mid)) l = mid;
            else r = mid - 1;
        }
        return l;
    }
};

二分答案(证明+图解)

解法:二分法

假设所有电脑同时运行 $t$ 分钟。因为一个电池同时只能给一台电脑供电,所以一个电池最多有 $t$ 分钟的供电时间。我们只需要统计所有电池的可供电时间总和$\displaystyle{S = \sum_i{\min(t, batteries_i)}}$ ,然后检查它们是否可以给 $n$ 台电脑供电即可(即 $\displaystyle{\frac{S}{t} \ge n}$)。

为什么这个解法是正确的?实际上,如果 $\displaystyle{\frac{S}{t} \ge n}$ ,那么我们总可以找出一种符合要求的方案来支持 $n$ 台电脑的运行。

如下图所示,我们依次分配电池 $0 \sim m$ 给电脑 $0 \sim n$。图中,横轴代表时间,各个栏目代表各个电脑的电池分配情况。首先我们把电池 $0$ (蓝色)分配给电脑 $0$。电池 $0$ 给电脑 $0$ 供电时段为 $0 \sim t_2$。然后,电脑 $0$ 由电池 $1$ 继续供电,而电池 $1$ 的余下电量用于供给电脑 $1$。然后,我们继续安排电池 $2,3,4... m-1$ 即可。

image.png

我们可以得出以下结论:只要每个电池的供电时间不超过 $t$,那么每个电池的供电时间就不会发生重叠,也就不会发生同一个电池给多台电脑的情况。

因此,每个电池的 最大可供电时间 = $\min(电池电量, t)$。只要最大可供电时间的 总和 可以 覆盖 所有的电脑的时间总和($n \times t$),那么这个供电方案就是可行的。

class Solution:
    def maxRunTime(self, n: int, batteries: List[int]) -> int:
        l, r = 1, 10 ** 15
        while l < r:
            m = (l + r + 1) >> 1
            if sum(min(x, m) for x in batteries) >= n*m:
                l = m
            else:
                r = m-1
        return l
class Solution {
public:
    long long maxRunTime(int n, vector<int>& batteries) {
        auto check = [&](long long t) {
            long long sum = 0;
            for(int i : batteries) sum += min(t, (long long)i);
            return sum / t >= n;
        };
        
        long long l = 1, r = 1e15;
        while(l < r) {
            long long m = (l + r + 1) / 2;
            if(check(m)) {
                l = m;
            }
            else {
                r = m - 1;
            }
        }
        return l;
    }
};
❌