普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月1日首页

使劲折腾Element Plus的Table组件

作者 至简简
2025年12月1日 16:24

背景

笔者公司的一个项目大量使用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>

陆续更新

雀巢中国回应婴儿营养品业务与惠氏合并:不影响现有业务开展

2025年12月1日 16:21
雀巢中国确认,惠氏营养品业务和雀巢婴儿营养品业务在中国市场将于2026年1月1日起正式成立雀巢营养品业务,谢国耀(Joel Seah)将成为该业务的负责人。“婴儿营养始终是我们的核心业务,我们将继续加大对品牌和渠道的投资。在业务调整过程中,公司严格依法合规执行。调整不影响现有业务开展,惠氏营养品(中国)有限公司和惠氏(上海)贸易有限公司将继续运行,惠氏启赋、S-26等品牌将继续服务中国消费者。”雀巢方面称。(21财经)

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

作者 ze_juejin
2025年12月1日 16:17

在 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 或在更细粒度级别提供

恒指收涨0.67%,恒生科技指数涨0.82%

2025年12月1日 16:11
36氪获悉,恒指收涨0.67%,恒生科技指数涨0.82%;有色金属、零售、交通运输板块领涨,五矿资源涨超12%,高鑫零售涨超8%,中远海能涨超4%;消费、医疗、造纸板块跌幅居前,华检医疗跌超15%,泡泡玛特跌超4%,晨鸣纸业跌超2%;南向资金净买入21.48亿港元。

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

2025年12月1日 16:04

本文基于 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,如有更新请以官方仓库为准。

汇丰与Mistral AI达成合作,加快应用生成式AI

2025年12月1日 16:01
12月1日,汇丰与法国人工智能初创公司Mistral AI宣布建立战略合作伙伴关系,以增强和加速生成式AI在全行的应用,供全行员工用于从财务分析到翻译等各类任务。声明称,未来的重点领域将包括面向客户的创新,例如改进信贷和贷款流程以及强化欺诈与反洗钱检查。(界面)

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

2025年12月1日 16:00

如何用 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中懒加载模块的加载顺序总结

作者 ze_juejin
2025年12月1日 15:58

在 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像在写屎山?这锅该不该它背

2025年12月1日 15:52

我上次在群里吐槽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一样,最后你会发现:工具没有对错,只有适不适合。 牛逼的程序员用记事本都能写出好代码,菜鸡用再牛逼的框架也能写出屎山。

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


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

马斯克预言:AI将在三年内终结美国“债务危机”

2025年12月1日 15:50
在上周日播出的一档播客节目中,马斯克表示,美国债务问题只有一个出口:人工智能。马斯克进一步指出,人工智能尚未将生产力提高到足以推动经济产出增速超过通货膨胀的程度,但这种情况即将改变。“我估计,三年或更短的时间内,商品和服务产出将超过通货膨胀率,”他补充说。(财联社)

工业富联:已耗资2.47亿元回购公司0.05%股份

2025年12月1日 15:47
36氪获悉,工业富联发布公告,截至2025年11月30日,公司通过上交所交易系统以集中竞价交易方式累计回购公司股份931.99万股,占公司目前总股本的0.05%,回购最高价格63.4元/股,回购最低价格18.4元/股,使用资金总额2.47亿元(不含交易佣金、过户费等交易费用)。

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

作者 吹水一流
2025年12月1日 15:47

微信小程序页面栈:从一个 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 深度对比

作者 珑墨
2025年12月1日 15:43

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 或镜像即可。

中国通号等在苏州新设交通科技公司,含无人飞行器业务

2025年12月1日 15:40
36氪获悉,爱企查App显示,近日,通号(苏州)交通科技有限公司成立,注册资本4000万元人民币,经营范围包括轨道交通运营管理系统开发、铁路运输辅助活动、电气信号设备装置销售、智能无人飞行器销售等。股东信息显示,该公司由中国铁路通信信号股份有限公司等共同持股。

由屎山代码组成的游戏,讲遍了中国小镇里的青少年 | 玩点好的

2025年12月1日 15:37

文丨贝果树

编辑丨果脯

单作为游戏来讲,《纸房子》其实算不上一款「合格」的产品。

游戏各处几乎都充斥着简陋的味道,比如黑白画风,直接现实取景的背景图,以及完全没有的优化。时至今日,你恐怕很难想象一款流程只有4小时的游戏,不仅有着「屎山代码」,占用4G内存,短暂的体验里还会多次出现文字或图像无法加载的恶性Bug。

哪怕它只售价26元,玩家的骂声想必也应该是不绝于耳——理论上来说本该如此。

反直觉的是,就是这样一款产品在国内短暂地火了一把,不仅获得了94%的Steam好评率,更是直接带动了自贡的文旅产业发展。

在诸多与《纸房子》相关的讨论中,不少玩家都积极分享游戏里触动自己的片段,表示「玩哭了」,严重些的更是出现了胸口发闷、干呕等躯体化反应。另外还有人明确给出建议:如果你在高中,或是在家庭有过心理创伤,最好不要购买并游玩《纸房子》。

那么,《纸房子》究竟讲了什么?

 01

在《纸房子》的周边铺满整座城市前,很多人其实并不了解自贡。

在外人看来,自贡只是四川省中部的一座五线城市,只因盛产恐龙化石和井盐出名。然而这两种特产,在国内诸多城市里都并不唯一,这也就导致大家更难记住这个名字。而对本地人来说,自贡则是名副其实的小地方,安静舒适,低调发展,和国内数不清的小县城没什么区别。

但小县城容不下活在信息时代的青少年。

在他们眼中,这座城市太小,街道太窄,出门只能去相同的电玩城、电影院。在此长大,他们已经能背下每条街道连接的地标,只觉城市里皆是苟延残喘、却从未离开过的「尸体」。

压抑的环境,无形造就了一批伤痛文学作者的兴起。尽管郭敬明参加第三届新概念作文大赛决赛并展露峥嵘后,就一直留在了上海,直到功成名就,把上海当做自己真正的家。但事实上,他出身于自贡市的富顺县。同一时期,同乡的饶雪漫也开始凭借《小妖的金色城堡》闻名。

他们故事里的少年少女通常有着极高的相似性,抽烟、打架、早恋都是常事,有的还会出现怀孕桥段。最终,少年少女以叛逆武装自己,渴望逃离那座生活了十数年的囚笼,并找到一个更加繁华、值得重新开始的地方。

《悲伤逆流成河》

《纸房子》同样如此。它取材于自贡,讲述了一个有关「逃离」的青春故事。

17岁的赵颖出生在名叫陈水的小城,身边有父母陪伴,还有一个缠着她的妹妹。晚修回家,她能吃上一桌为自己而做的饭菜。逢年过节,她可以跟家人们聚在一起看电影,除夕夜还会收到一个厚厚的红包。因为是姐姐,她也有着崭新、合身的衣服和鞋子。

对于一些作为留守儿童的同学,这样的她是被人羡慕的对象。

但赵颖总是想要离开这个家庭。唯有她知道,自己只是这个温馨小家里的局外人,偶尔扮演父亲发泄情绪、诉诸暴力的沙包。

比起赵颖,家里人更关心她亲生母亲留下的100万抚养费。多亏这笔钱,父亲能够购置新房,同父异母的妹妹也能打通关系上更好的学校。所谓除夕夜的红包,只不过是父亲为了补偿赵颖,从中漏出的一小部分心安理得。晚修后的晚饭也一样,是她将房间让给妹妹的「补偿」。

偏偏赵颖走不掉。于她而言,这个家就像一座纸房子,虽不如水泥瓦房那般结实,但终究多少能为其遮蔽一些风雨,提供她成长所需的养分,一如冬雪下小女孩手中的火柴,偶尔的温暖短暂、虚幻,一点就燃。自5岁起,赵颖就和亲生母亲分离,她对亲情有着更加强烈的渴求——哪怕这份亲情具有代价。

这造就了赵颖异常复杂矛盾的心理。

她时常自我反问:“如果不喜欢我,为什么还要对我好?”

《纸房子》游戏截图

“为什么好像在爱我,又同时打我骂我、无视我?”

《纸房子》游戏截图

如果对她好些,赵颖就不会痛恨这个家庭,痛恨暴力的父亲、冷漠的继母,以及……那不谙世事却本能关心姐姐的妹妹。而如果对她差些,赵颖就能提起足够的勇气,毫无负担地离开这座纸房子,远去搭建真正的家。

最终,她与其他家庭成员相互形成一种微妙而平衡的亲情关系,爱得不纯粹,恨得不彻底。 

02

在求生本能的驱使下,赵颖试图寻找对抗现状的武器。

她先学会了抽烟和打架。在一次和小太妹的争吵中,赵颖被一群人堵在巷子里,于是她学着父亲的样子挥动拳头,毕竟,这是父亲唯一教会她的东西。

《纸房子》游戏截图

只是暴力这把武器也像一柄双刃剑,能刺伤他人,也会划到自己。

作为赵颖的爱慕者,陆婷无疑是她的另一种「可能性」。贫穷、缺爱的陆婷掌控情绪暴力的手段后,以自我毁灭般的生存模式活跃着,就像是一只下水道里的老鼠。因为是无价值的老鼠,它可以在手上划出一道道伤口,用以发泄心中的压抑。因为是无自尊的老鼠,它可以肆无忌惮放射心中的感情,严重到完全如溺水者般捆绑他人,以眼泪、跟踪、自残等方式吸引赵颖的注意力。

陆婷的潜意识里其实知道这些行为有多极端,也明白这么做得不到真正想要的东西。

但是没关系。因为她是老鼠,老鼠不配被爱。

《纸房子》游戏截图

赵颖的另一个武器是移情,她尝试着用其他情感弥补自己内心的缺口,比如把师生情。在她眼中,班主任才更像自己的妈妈,不论成绩好坏,对方都会鼓励她,陪在她身边。她甚至有些抗拒升学,因为这意味着将要与老师分别,重新认识新的陌生人。

友情也一样。赵颖有两个好朋友,不论任何时候,她都能获得两人的鼎力支持。但天下无不散之筵席,朋友和真正的家人最大的区别,在于友谊铸就的陪伴往往难以永恒持续。

作为朋友,王亦菡初中时放弃离开陈水,只为了青春时光里与赵颖多待几年。直到高中临近毕业,身为富二代的她必须开始筹备与计划自己的未来,发挥家庭背景带来的优势——比如出国留学。

《纸房子》游戏截图

赵颖自然没有留学的条件。于是,离别的倒计时如一柄利剑,悬于三人的友谊上。

那一刻,赵颖再次感受到了一如5岁时的无助——不论如何移情,最终亲密的人终将离开自己,内心的缺口也永远得不到弥补与满足。它只会随着时间推移,不断崩塌、放大。当然,仍在就读高中的赵颖自然无法体悟得如此深刻,这一切有关亲密关系的情绪对她而言都尤其陌生。

赵颖甚至因此自我怀疑,朋友的离去明明是走向光明的未来,自己为何反而只会因分别感到悲伤?这到底是友情……还是爱情?

《纸房子》游戏截图

不可否认,在这短暂的几小时流程里,《纸房子》能对青少年内心那堆复杂情绪作如此呈现,实属难得。它精准刻画出了少年少女们「幼稚」的烦恼,商讨的却是那些那怕步入中年,乃至老年,都未必能够想通并释怀的问题。

这也是《纸房子》为什么讲的本质仍旧是「伤痛文学」,却仍旧能引起大量玩家的共鸣,甚至引发躯体化反应。

游戏玩到最后,几乎每位玩家心中都会冒出相同一个问题:赵颖真正希望逃离的,是陈水这座熟悉、压抑、死气沉沉、一眼望到头的城市,还是自己过去十多年那支离破碎的人生?

玩家不知道,赵颖自然更不知道。在所有结局里,她几乎都会涌现犯罪念头,甚至动手实施。

比如其中一个结局里,赵颖买了两瓶白酒,淋着雨,抽着烟,将父亲、继母、妹妹,以及用自己抚养费购置的新房,一并付之一炬。她终是自己动手,彻彻底底烧毁了那座伴她成长的「纸房子」。彼时赵颖虽未身处火中,但她的自我也一并死在了那天——或是更早。

03

没办法,赵颖面临的困境不是人或社会造成的,它是时代发展背景下的一种必然趋势。现实中,走过青春期的玩家们,其实很早就默契地找到了一个共通又无奈的解法——再忍忍。

游戏里,22岁的大学生徐敏敏告诉赵颖,对现在的你来说,2年是生命中的八分之一,而十年后,你27岁了,2年就只是生命中的十四分之一了。在人生的广阔尺度下,尽管痛苦不会消失,但也会被慢慢冲淡。

而更重要的是,长大后的自己可以主动离开那摇摇欲坠的纸房子,为自己重新搭建一座新家,把自己重新养一次。少年们现在所要做的,是睁眼看世界,并学会跟自己和解。

电影《过春天》也曾传达过类似的理念。

故事中,主角佩佩与朋友相约,一定要在十六岁这年的圣诞去日本看雪。为此,她当走私的「水客」,在香港与深圳之间来回穿梭,很快就挣够了机票钱。不出意外,佩佩很快也为自己的行为付出代价,随满足感而来的,是与朋友因误会产生的决裂,以及法律的制裁。最终,她也没能完成去日本看雪的梦想。

在影片的最后,想要得到一切但失去一切的佩佩,再次登上了曾经与友人登上的太平山顶,整个香港的景色在眼前徐徐展开,一个曾经她当作玩笑讲出来的愿望实现了,没能在日本见到的那场雪,此刻在香港的山顶上下了起来。佩佩接受了这个失败的青春结局,走出来,开始成长为一个大人,像囚禁在鱼缸里的鲨鱼游向大海。

《过春天》

这里面自然存在艺术加工的成分——很多时候,心态转变带来的反馈绝不会如影片中那般迅捷。16岁想看的雪,可能要到26岁,36岁甚至46岁才有可能见到。佩佩在现实中所需要付出的代价,也往往比影片中要更多。

但它弥足珍贵的是,那份面向青少年的鼓励态度。人生并不是要过得完美才能继续向前,虽然在珍贵的青春中什么都没有做到,但反正还有明年后年,即使一无所有,但人总在成长。

《纸房子》也是一样。尽管大部分时候,这款游戏讲述的是一个彻底的悲剧,但却展现了不只是青少年所需的共情与认同感,填补了玩家们曾经,或是现在的内心空缺。「青春伤痛文学」真正重要的,不是那些满带无病呻吟的伤痛,而是关于成长与人生的思考。

《纸房子》游戏截图

本文首发自“36氪游戏”。

❌
❌