普通视图
新赛股份:副总经理张成年因工作调整辞职
软银集团CEO:实现通用人工智能(AGI)不存在障碍
使劲折腾Element Plus的Table组件
背景
笔者公司的一个项目大量使用el-table组件,并做出一些魔改的效果
多列显示
废话不多讲,直接上效果
![]()
使用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() // 指令级实例
总结
选择哪种方式取决于具体需求:
-
组件级隔离 → 使用
providers: [Service] - 配置不同的实例 → 使用工厂函数
-
模块级多实例 → 使用
providedIn: 'any' - 动态创建 → 使用工厂服务手动创建
-
同一接口多个实现 → 使用
multi: true
最常用的是在组件级别提供 Service,这样每个组件实例都会得到自己的 Service 实例,实现了完全的隔离。
注意事项
如果是急加载模块(非懒加载模块),无论在多少个模块的 providers 中声明,Service 实例都是同一个(单例)。 Angular 的依赖注入系统在应用启动时创建了一个 根注入器(Root Injector) 。所有急加载模块的 providers 都会被合并到根注入器中。
| 模块类型 | 在多个模块的 providers 中声明 | 实例情况 |
|---|---|---|
| 急加载模块 | 是 | 同一个实例(单例) |
| 懒加载模块 | 是 | 不同实例(每个模块新实例) |
| 混合情况 | 急加载+懒加载都有声明 | 急加载用根实例,懒加载用新实例 |
关键点:
- 所有急加载模块共享根注入器
- providers 声明会被合并
- Service 在应用启动时实例化一次
- 要实现多实例,需要使用不同的 Token 或在更细粒度级别提供
恒指收涨0.67%,恒生科技指数涨0.82%
React vs Vue 调度机制深度剖析:从源码到事件循环的完整解读
本文基于 React 18.2.0 和 Vue 3.2.45 源码分析,带你真正理解两大框架的调度设计差异。
📑 目录
- 事件循环:理解调度的基础
- Vue 3:Promise.then 微任务调度(源码解析)
- React 18:MessageChannel 宏任务调度(源码解析)
- 为什么不用 setTimeout?为什么不用 rAF?
- 时间切片的真正含义
- 可中断渲染的实现原理
- 两种设计的权衡与适用场景
- 总结
一、事件循环:理解调度的基础
在深入源码之前,必须先理解浏览器事件循环的运行机制:
┌─────────────────────────────────────────────────────────────┐
│ 一轮事件循环 │
├─────────────────────────────────────────────────────────────┤
│ ① 执行一个宏任务(Script / setTimeout / MessageChannel) │
│ ↓ │
│ ② 清空所有微任务(Promise.then / queueMicrotask) │
│ ↓ │
│ ③ 浏览器判断是否需要渲染 │
│ ├─ 是 → 执行 rAF → Layout → Paint → Composite │
│ └─ 否 → 跳过渲染 │
│ ↓ │
│ ④ 进入下一轮事件循环 │
└─────────────────────────────────────────────────────────────┘
🔑 关键结论
| 任务类型 | 执行时机 | 是否阻塞渲染 |
|---|---|---|
| 微任务 | 当前宏任务结束后立即执行 | ✅ 会阻塞(必须清空) |
| 宏任务 | 下一轮事件循环 | ❌ 不阻塞(之间有渲染机会) |
这个差异是 Vue 和 React 选择不同调度策略的根本原因。
二、Vue 3:Promise.then 微任务调度(源码解析)
2.1 nextTick 源码
// 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 调度入口源码
// 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);
为什么这个知识点重要?
理解这点后,你会真正明白:
-
React 时间切片不是"让浏览器每次都渲染" → 而是"给浏览器机会做它想做的事"(渲染、响应输入、或什么都不做)
-
rAF 不是定时器 → 而是浏览器说"我决定要渲染了,你有啥要准备的吗?"
-
性能优化的本质 → 不是"让渲染更快",而是"别挡着浏览器的路"
六、可中断渲染的实现原理
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 │ │
│ │ 继续调度 │ │
│ └─────────────┘ │
│ │
│ 特点:快速、简单、不可中断 特点:可中断、可调度、复杂 │
│ │
└─────────────────────────────────────────────────────────────────┘
🎯 核心结论
-
Vue 使用
Promise.then(微任务)- 更新极快,同一轮事件循环内完成
- 简单高效,编译优化 + 响应式让 patch 开销很小
- 不需要可中断,因为不需要
-
React 使用
MessageChannel(宏任务)- 宏任务之间有渲染机会,不阻塞页面
- 支持时间切片(5ms 一个切片)
- 支持可中断渲染(Fiber 架构)
- 不使用 rAF(常见误解!)
-
选择差异源于设计目标不同
- Vue:追求简单高效,响应式 + 编译优化解决性能问题
- React:追求可控调度,Fiber + Scheduler 解决复杂场景
-
没有绝对的优劣
- 小型应用:Vue 的同步更新更直观
- 大型应用:React 的调度能力更强
📚 参考资料
💡 作者注:本文所有源码引用基于 React 18.2.0 和 Vue 3.2.45,如有更新请以官方仓库为准。
汇丰与Mistral AI达成合作,加快应用生成式AI
如何用 vxe-table 实现粘贴数据自动进入新增行与新增列
如何用 vxe-table 实现粘贴数据自动进入新增行与新增列,数据无限扩充,对于大部分业务操作场景,有时需要从 excel 复制数据并粘贴到表格中,由于粘贴的数据会列多于表格定义的行与列,多出的数据需要能支持自动新增与自行新增列,vxe-table提供非常简单的配置方式可以直接支持。
自动新增行
当粘贴数据时,如果粘贴的行数超过表格的行数,可以通过 clip-config.isRowIncrement 自动新增临时行
![]()
<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 自动新增临时列
![]()
<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>
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. 具体加载顺序
第一次访问应用:
-
加载主包文件
runtime.js → polyfills.js → main.js → styles.css -
应用初始化
- AppComponent 被初始化
- 急加载的组件和模块被注册
导航到 /feature1:
// 当路由匹配到 feature1 时
http://localhost:4200/feature1
加载顺序:
- 检查路由配置 - 发现是懒加载路由
-
下载 chunk 文件 - 开始下载
chunk-feature1.js - 模块实例化 - Feature1Module 被 Angular 编译器实例化
- 组件渲染 - Feature1Component 被渲染
预加载策略的影响
Angular 提供了不同的预加载策略:
默认策略(NoPreloading)
- 只在需要时才加载懒加载模块
- 顺序:用户导航 → 下载 → 实例化
预加载所有模块(PreloadAllModules)
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules
})
]
})
加载顺序:
- 主应用加载完成
- 空闲时后台下载所有懒加载模块
- 用户导航时立即实例化,无需等待下载
自定义预加载策略
@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 会:
- 提取公共依赖到单独的 chunk(如
chunk-vendors.js) - 确保依赖只加载一次
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] // 条件加载
}
];
总结
懒加载模块的加载顺序原则:
- 按需加载 - 只在路由导航时触发
- 异步下载 - 通过网络获取 chunk 文件
- 按序实例化 - 下载完成后 Angular 实例化模块
- 缓存机制 - 已加载的模块不会重复下载
这种机制显著改善了:
- 初始加载性能(减小主包体积)
- 用户体验(快速首屏显示)
- 资源利用率(只加载需要的代码)
贝莱德比特币ETF成公司最赚钱业务线
写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-4和p-4到底哪个是margin哪个是padding?mt-4和mr-4又是啥? - 遇到复杂布局:用flex还是grid?Tailwind的grid类名又长又难记
- 调个细节样式:想微调一个阴影,得查半天文档才知道
shadow-lg和shadow-xl的区别
有这查文档的时间,我CSS早写完了。
二、但为什么大佬们都在吹爆Tailwind?
1. 等我真的用起来之后……
两个月后,当我对常用类名烂熟于心后,发现有些场景真香:
快速原型开发:产品经理站我身后:“这里改个间距,那里调个颜色,这个按钮hover效果换一下……”
以前:切到CSS文件 -> 找到对应的类 -> 修改 -> 切回来预览 -> 重复 现在:直接在HTML里改几个类名 -> 实时预览
设计一致性:以前团队里每个开发者对“大间距”的理解都不一样,有人写margin: 20px,有人写margin: 24px,还有人写margin: 1.5rem。现在统一用m-5或m-6,UI终于统一了。
2. 性能确实牛逼
我原来不信,直到对比了项目打包后的CSS文件大小:
- 之前的项目(手写CSS):
main.css87KB - 现在的项目(Tailwind + JIT):
main.css12KB
因为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>
);
}
四、什么时候该用,什么时候不该用
赶紧用起来吧 👍
- 新项目,尤其是React/Vue/Svelte项目:组件化能很好解决Tailwind的可维护性问题
- 需要统一设计规范:设计系统配好了,大家就按这个来,别TM再自己发挥了
- 内部管理系统、后台项目:快速迭代,老板天天改需求,这种场景Tailwind无敌
-
团队协作项目:不用再解释为什么这里用
margin-top: 8px而不是10px
算了,别用了 ❌
- 静态小网站:就几个页面,写点CSS完事了,别折腾
- 老项目迁移:除非你想加班加到死
- 完全不懂CSS的新手:Tailwind不是CSS的替代品,它是工具。连CSS盒模型都不懂就用Tailwind,等于不会开车就用自动驾驶
- 设计师天马行空:如果你们设计师每个页面风格都不一样,用Tailwind配置会把你逼疯
五、我总结的血泪经验
- 不要直接在JSX里堆类名:这是所有屎山的源头!一定一定要封装成组件
-
配置好自己的设计系统:别用默认配置,根据项目需求配一套自己的
tailwind.config.js -
善用 @apply:重复出现的样式组合,用
@apply提取
/* 在CSS文件中 */
.btn-primary {
@apply bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded;
}
-
结合现代工具链:
clsx处理条件类名,tailwind-merge解决类名冲突 - 定期重构:发现重复的样式组合就抽象,别懒!
最后说句实话
用不用Tailwind,其实跟你用什么技术关系不大,关键看你怎么用。
那些说Tailwind垃圾的,多半是看到了滥用它的项目;那些吹爆Tailwind的,多半是用对了方法。
就像当年大家吵jQuery和原生JS,吵React和Vue一样,最后你会发现:工具没有对错,只有适不适合。 牛逼的程序员用记事本都能写出好代码,菜鸡用再牛逼的框架也能写出屎山。
所以,别吵了,赶紧去写代码吧。老板又改需求了,今天还得加班呢。
关注公众号" 大前端历险记",掌握更多前端开发干货姿势!
马斯克预言:AI将在三年内终结美国“债务危机”
工业富联:已耗资2.47亿元回购公司0.05%股份
微信小程序页面栈:从一个 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]
有两个硬规则:
-
页面栈中的每一项都是一个“活着的页面实例”;
只要还在栈里,它的data、方法、状态都在内存里。 -
页面栈最多只能有 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'
})
发生的事:
-
当前页(例如
list)触发:onHide -
创建新页面
detail:detail.onLoad({ id: '1', from: 'list' })detail.onShow()
-
页面栈:
[list] → [list, detail]
4.2 两个关键限制
-
不能跳转到 tabBar 页面
想去 tab 页,必须用switchTab。 -
当页面栈长度已经是 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]
执行后:
-
list.onUnload()(销毁,从栈中移除) -
index.onShow()(重新展示) - 页面栈变成:
[index]
5.2 多级返回
wx.navigateBack({ delta: 2 })
假设当前:
[home, list, detail, edit] // 当前在 edit
执行后:
edit.onUnload()detail.onUnload()list.onShow()- 页面栈变成:
[home, list]
注意:
-
delta超过栈长只会退到栈底,不会炸 - 被退到的页面 只触发
onShow,不会再触发onLoad
所以:
所有“返回后要刷新”的逻辑,都应该放在 onShow 里,而不是 onLoad。
六、wx.redirectTo:替换当前页(当前页没用了)
6.1 行为
wx.redirectTo({
url: '/pages/result/result'
})
假设当前页面栈:
[A, B] // 当前在 B
执行后:
-
B.onUnload()→ 从页面栈移除 -
新建
C:C.onLoad()C.onShow()
-
页面栈:
[A, C]
6.2 使用场景
可以直接记一句:
当前页面“走过去就不应该再回来了”,用
redirectTo。
典型例子:
- 登录成功后跳首页:不希望用户再退回登录页
- 填写完表单跳“成功页”:不一定要再回到表单页
- 一些“中间引导页”“结果页”,只负责中转一次
七、wx.reLaunch:清空整个栈,重新开始
7.1 行为
wx.reLaunch({
url: '/pages/home/home'
})
假设原来页面栈:
[guide, login, home, list]
执行后:
-
guide、login、home、list依次onUnload,全部被销毁 -
创建新的
home页面:home.onLoad()home.onShow()
-
页面栈变成:
[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 页面
执行后:
-
关闭所有非 tabBar 页面:
list、detail→onUnload -
激活
tab-home:- 第一次:
onLoad()→onShow() - 之后:只
onShow()
- 第一次:
-
页面栈可以理解为:
[tab-home]
可以直接记:
switchTab = 回到某个 tabBar 根页面,并清掉这次业务流中的非 tab 页面。
九、动作 ⇄ 生命周期 ⇄ 页面栈,一张表记住
把上面的内容汇总成一张“速查表”:
| 场景 | 页面栈变化(抽象) | 被关闭页 | 最终停留页生命周期 |
|---|---|---|---|
| 首次进入某页面 | [] → [A] |
— | A:onLoad → onShow
|
navigateTo |
[A] → [A, B] |
A:onHide
|
B:onLoad → onShow
|
navigateBack |
[A, B] → [A] |
B:onUnload
|
A:onShow
|
redirectTo |
[A, B] → [A, C] |
B:onUnload
|
C:onLoad → onShow
|
reLaunch |
[很多] → [X] |
所有旧页:onUnload
|
X:onLoad → onShow
|
switchTab(首次) |
[...] → [..., T] |
非 tab 页:onUnload
|
T:onLoad → onShow
|
switchTab(再次) |
[...] → [...] |
上一个 tab:onHide
|
当前 tab:onShow
|
工程落地结论只有一句:
- 只做一次的初始化逻辑 → 写在
onLoad - 只要页面“重新可见”就要跑的逻辑 → 写在
onShow
十、10 层限制,对项目设计的实际影响
再用页面栈视角推一次“跳不动”的过程:
-
一路
navigateTo叠上去:[P1] [P1, P2] [P1, P2, P3] ... [P1, P2, ..., P10] // 此时 length = 10 -
再执行:
wx.navigateTo({ url: '/pages/P11/P11' }) -
结果:
- 当前栈长度 = 10,无法再创建新页面
- 本次跳转
fail,errMsg 类似:navigateTo:fail webview count limit exceed - 页面栈仍然是
[P1, ..., P10],页面当然不会跳走
怎么避免被这个坑反复阴?
-
一条业务链路里,不要一路
navigateTo到天荒地老- 中间不需要回去的页面,用
redirectTo替换掉
- 中间不需要回去的页面,用
-
对关键业务链路封装一个
safeNavigateTo,统一打印getCurrentPages().length和 fail 日志 -
对于“走完流程就回首页/主 tab”的场景,优先考虑
reLaunch或switchTab,顺便清掉一整段页面栈
十一、结合业务场景看几条真实“页面栈时间线”
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}`
})
}
})
一边点页面、一边留意标题栏,页面栈在怎么涨怎么减,肉眼可见。
最后,小结几条真正需要记住的规则
- 小程序内部维护着一个最多 10 层的页面栈,每一层是一个
Page实例。 -
navigateTo= 压栈、navigateBack= 出栈、redirectTo= 替换栈顶、reLaunch= 清空重建、switchTab= 回到 tabBar 并清掉非 tab 页。 - 页面栈长度到 10 时,再
wx.navigateTo会失败,errMsg 通常类似navigateTo:fail webview count limit exceed,页面表现就是“跳不动”。 - 返回只触发
onShow不触发onLoad,“返回后刷新”逻辑必须写在onShow。 - 页面栈只负责小程序页面间的跳转,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.json 的 dependencies 或 devDependencies 中显式声明,而是作为其他依赖的间接依赖(传递依赖)被安装到 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 中声明
潜在问题
-
版本不确定性:当 express 升级并移除对
cookie-parser的依赖时,代码会突然报错。 -
依赖关系不透明:团队成员无法从
package.json看出项目实际使用了哪些包。 - CI/CD 风险:不同环境下的 hoist 结果可能不同,导致"本地能跑,CI 失败"。
-
安全审计盲区:
npm audit可能无法检测到未声明的依赖中的漏洞。
不同工具的处理方式
-
npm/yarn Classic/cnpm:允许幽灵依赖存在,依赖提升机制使得未声明的包也能被解析。这是历史遗留问题,需要开发者自觉避免。
-
pnpm:默认严格隔离模式,每个包只能访问其
package.json中声明的依赖。如果代码尝试使用未声明的依赖,会在运行时抛出MODULE_NOT_FOUND错误,强制开发者显式声明所有依赖。 -
yarn Berry (PnP):通过 Plug'n'Play 机制,完全阻断对未声明依赖的访问。
.pnp.cjs中只包含已声明的依赖映射,任何未声明的依赖都会在解析阶段被拦截。
最佳实践
-
显式声明所有依赖:即使某个包是间接依赖,如果项目代码直接使用它,也应该在
package.json中声明。 -
使用 lint 工具:配置
eslint-plugin-import或depcheck检测未声明的依赖。 - 迁移到严格模式:考虑使用 pnpm 或 yarn Berry,让工具强制暴露依赖问题。
-
定期审查:使用
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._resolveFilename与fs模块逻辑,pnpm 通过符号链接保证目录结构仍符合 Node 的查找路径,因此无需额外 runtime patch。 -
yarn Berry PnP:
-
node启动时加载.pnp.cjs,该文件会使用require('module').Module._resolveFilename和_findPath的 monkey patch 拦截模块解析。 - 当应用执行
require('pkg')时,PnP hook 根据调用方位置解析出该包对应的 locator(类似pkg@npm:1.0.0),再在.pnp.data.json(隐藏在.pnp.cjs内部)中寻找缓存 zip 的绝对路径。 - hook 会注入自定义的
fs.readFileSync/fs.readdirSync逻辑,使得 zip 内文件可透明访问;若调用方请求真实路径,PnP 会返回zip:/path/to/cache#package/index.js。 - 对不兼容 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 安装链路:
- CLI 调用 Arborist(npm 内置解析器)读取
package.json、lockfile,产出依赖树(ideal tree)。 - Arborist 根据 semver 冲突策略决定 hoist 位置,并生成实际安装计划(actual tree)。
- 安装阶段将包解压到
node_modules/<name>,必要时写入嵌套node_modules。当运行npm dedupe时,会再次构建树并尝试把可兼容版本提升到上层。 -
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
- 安装流程:
- 下载 tarball → 解压到 store。
- 在
.pnpm/<pkg>@<version>/node_modules生成真实目录。 - 在根
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,导出resolve与load钩子。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 顺序正确。
锁文件与可移植性
-
npm:
package-lock.json;跨平台兼容好,工具链广泛支持。 -
pnpm:
pnpm-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 audit、npm 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中集中打补丁或替换依赖版本。 -
yarn:
yarn npm audit(Berry)或yarn audit(Classic);Berry 的 Constraints 可强制版本范围。 - cnpm:沿用 npm 安全数据库,但国内镜像同步可能存在延迟,关键包需关注更新时差。
CI/CD 与缓存策略
-
npm:依赖
npm ci获得确定性安装,但大型 monorepo 下缓存命中率一般。 -
pnpm:
pnpm fetch+pnpm install --offline结合远程 store 缓存,可显著降低 CI 时间。 -
yarn:Classic 可缓存
.yarn/cache;Berry 默认生成压缩包,可和 Zero-Install 一起提交仓库。 - cnpm:在 CI 中价值有限,更多用于开发机加速;若 CI 运行在国内亦可直接使用 cnpm registry。
日常开发、打包与运行体验
-
npm:
npm run dev/build/test是绝大多数脚手架(Next.js、Vite、create-react-app 等)的默认命令,生态插件与 IDE 集成都围绕 npm script 实现;npm run支持--workspaces批量触发,但功能较基础。 -
pnpm:
pnpm dev/pnpm build与 npm 保持一致,额外提供pnpm run --parallel、pnpm -r --filter等能力,可在 monorepo 中只针对受影响包运行 Vite、Webpack、Rollup;pnpm dlx可直接执行 bundler CLI 而无需全局安装。 -
yarn:Classic 的
yarn run、yarn workspace <name> dev体验成熟;Berry 则可用yarn workspaces foreach、yarn 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 一致。 |
迁移与实施建议
-
从 npm 迁移至 pnpm:提前排查脚本中硬编码的
node_modules/.bin,改用pnpm dlx或packageManager跨平台调用;在 CI 中新增pnpm fetch步骤,并确认缓存权限。 -
引入 yarn Berry:先用
yarn set version berry+yarn init生成配置,再根据实际情况决定使用 PnP 还是nodeLinker: node-modules,并为 IDE 安装 Yarn SDK 与插件。 -
混合工具链的版本治理:使用 Corepack、Volta、asdf 或 mise 固定 npm/pnpm/yarn 版本,必要时在
package.json中声明packageManager字段,避免锁文件因环境差异而漂移。 -
镜像与私服策略:对需要离线或加速的环境,将 registry、disturl、binary mirror 等配置写入
.npmrc/.yarnrc.yml,确保 CI 与本地一致;若使用 cnpm,可周期性执行cnpm sync防止版本滞后,也可结合 Verdaccio/Artifactory 做企业级缓存。
综合考虑
- 追求磁盘效率与严格依赖管理:首选 pnpm;在大型 monorepo、CI/CD 频繁场景中尤为高效。
- 需要最广泛的兼容性:npm 仍是默认选项,尤其适用于对外分发的库或对工具链兼容性要求极高的项目。
- 面向国内网络环境:若团队主要痛点是下载速度,可选 cnpm 或在 npm/pnpm/yarn 上配置 npmmirror。
- 希望尝试先进特性:yarn Berry 适合需要 Zero-Install、Constraints、可插拔 CLI 的团队,但需投入额外学习成本。
总结
pnpm 代表“高效与严谨”,适合一切追求极致依赖管理的现代工程;npm 以稳健与兼容性取胜,是生态默认语言;yarn 借助 Berry 获得高度可配置的高级特性;cnpm 则在中国区网络环境下提供最佳可达性。根据团队的体量、CI 频率、网络环境与学习成本进行权衡,往往能自然地落在表格中的某一列:新项目优先考虑 pnpm 或 yarn Berry,存量项目可在保持 npm 的同时逐步评估迁移收益,而只需解决下载加速时选择 cnpm 或镜像即可。
中国通号等在苏州新设交通科技公司,含无人飞行器业务
由屎山代码组成的游戏,讲遍了中国小镇里的青少年 | 玩点好的
文丨贝果树
编辑丨果脯
单作为游戏来讲,《纸房子》其实算不上一款「合格」的产品。
游戏各处几乎都充斥着简陋的味道,比如黑白画风,直接现实取景的背景图,以及完全没有的优化。时至今日,你恐怕很难想象一款流程只有4小时的游戏,不仅有着「屎山代码」,占用4G内存,短暂的体验里还会多次出现文字或图像无法加载的恶性Bug。
哪怕它只售价26元,玩家的骂声想必也应该是不绝于耳——理论上来说本该如此。
反直觉的是,就是这样一款产品在国内短暂地火了一把,不仅获得了94%的Steam好评率,更是直接带动了自贡的文旅产业发展。
在诸多与《纸房子》相关的讨论中,不少玩家都积极分享游戏里触动自己的片段,表示「玩哭了」,严重些的更是出现了胸口发闷、干呕等躯体化反应。另外还有人明确给出建议:如果你在高中,或是在家庭有过心理创伤,最好不要购买并游玩《纸房子》。
那么,《纸房子》究竟讲了什么?
01
在《纸房子》的周边铺满整座城市前,很多人其实并不了解自贡。
在外人看来,自贡只是四川省中部的一座五线城市,只因盛产恐龙化石和井盐出名。然而这两种特产,在国内诸多城市里都并不唯一,这也就导致大家更难记住这个名字。而对本地人来说,自贡则是名副其实的小地方,安静舒适,低调发展,和国内数不清的小县城没什么区别。
但小县城容不下活在信息时代的青少年。
在他们眼中,这座城市太小,街道太窄,出门只能去相同的电玩城、电影院。在此长大,他们已经能背下每条街道连接的地标,只觉城市里皆是苟延残喘、却从未离开过的「尸体」。
压抑的环境,无形造就了一批伤痛文学作者的兴起。尽管郭敬明参加第三届新概念作文大赛决赛并展露峥嵘后,就一直留在了上海,直到功成名就,把上海当做自己真正的家。但事实上,他出身于自贡市的富顺县。同一时期,同乡的饶雪漫也开始凭借《小妖的金色城堡》闻名。
他们故事里的少年少女通常有着极高的相似性,抽烟、打架、早恋都是常事,有的还会出现怀孕桥段。最终,少年少女以叛逆武装自己,渴望逃离那座生活了十数年的囚笼,并找到一个更加繁华、值得重新开始的地方。
![]()
《悲伤逆流成河》
《纸房子》同样如此。它取材于自贡,讲述了一个有关「逃离」的青春故事。
17岁的赵颖出生在名叫陈水的小城,身边有父母陪伴,还有一个缠着她的妹妹。晚修回家,她能吃上一桌为自己而做的饭菜。逢年过节,她可以跟家人们聚在一起看电影,除夕夜还会收到一个厚厚的红包。因为是姐姐,她也有着崭新、合身的衣服和鞋子。
对于一些作为留守儿童的同学,这样的她是被人羡慕的对象。
但赵颖总是想要离开这个家庭。唯有她知道,自己只是这个温馨小家里的局外人,偶尔扮演父亲发泄情绪、诉诸暴力的沙包。
比起赵颖,家里人更关心她亲生母亲留下的100万抚养费。多亏这笔钱,父亲能够购置新房,同父异母的妹妹也能打通关系上更好的学校。所谓除夕夜的红包,只不过是父亲为了补偿赵颖,从中漏出的一小部分心安理得。晚修后的晚饭也一样,是她将房间让给妹妹的「补偿」。
偏偏赵颖走不掉。于她而言,这个家就像一座纸房子,虽不如水泥瓦房那般结实,但终究多少能为其遮蔽一些风雨,提供她成长所需的养分,一如冬雪下小女孩手中的火柴,偶尔的温暖短暂、虚幻,一点就燃。自5岁起,赵颖就和亲生母亲分离,她对亲情有着更加强烈的渴求——哪怕这份亲情具有代价。
这造就了赵颖异常复杂矛盾的心理。
她时常自我反问:“如果不喜欢我,为什么还要对我好?”
![]()
《纸房子》游戏截图
“为什么好像在爱我,又同时打我骂我、无视我?”
![]()
《纸房子》游戏截图
如果对她好些,赵颖就不会痛恨这个家庭,痛恨暴力的父亲、冷漠的继母,以及……那不谙世事却本能关心姐姐的妹妹。而如果对她差些,赵颖就能提起足够的勇气,毫无负担地离开这座纸房子,远去搭建真正的家。
最终,她与其他家庭成员相互形成一种微妙而平衡的亲情关系,爱得不纯粹,恨得不彻底。
02
在求生本能的驱使下,赵颖试图寻找对抗现状的武器。
她先学会了抽烟和打架。在一次和小太妹的争吵中,赵颖被一群人堵在巷子里,于是她学着父亲的样子挥动拳头,毕竟,这是父亲唯一教会她的东西。
![]()
《纸房子》游戏截图
只是暴力这把武器也像一柄双刃剑,能刺伤他人,也会划到自己。
作为赵颖的爱慕者,陆婷无疑是她的另一种「可能性」。贫穷、缺爱的陆婷掌控情绪暴力的手段后,以自我毁灭般的生存模式活跃着,就像是一只下水道里的老鼠。因为是无价值的老鼠,它可以在手上划出一道道伤口,用以发泄心中的压抑。因为是无自尊的老鼠,它可以肆无忌惮放射心中的感情,严重到完全如溺水者般捆绑他人,以眼泪、跟踪、自残等方式吸引赵颖的注意力。
陆婷的潜意识里其实知道这些行为有多极端,也明白这么做得不到真正想要的东西。
但是没关系。因为她是老鼠,老鼠不配被爱。
![]()
《纸房子》游戏截图
赵颖的另一个武器是移情,她尝试着用其他情感弥补自己内心的缺口,比如把师生情。在她眼中,班主任才更像自己的妈妈,不论成绩好坏,对方都会鼓励她,陪在她身边。她甚至有些抗拒升学,因为这意味着将要与老师分别,重新认识新的陌生人。
友情也一样。赵颖有两个好朋友,不论任何时候,她都能获得两人的鼎力支持。但天下无不散之筵席,朋友和真正的家人最大的区别,在于友谊铸就的陪伴往往难以永恒持续。
作为朋友,王亦菡初中时放弃离开陈水,只为了青春时光里与赵颖多待几年。直到高中临近毕业,身为富二代的她必须开始筹备与计划自己的未来,发挥家庭背景带来的优势——比如出国留学。
![]()
《纸房子》游戏截图
赵颖自然没有留学的条件。于是,离别的倒计时如一柄利剑,悬于三人的友谊上。
那一刻,赵颖再次感受到了一如5岁时的无助——不论如何移情,最终亲密的人终将离开自己,内心的缺口也永远得不到弥补与满足。它只会随着时间推移,不断崩塌、放大。当然,仍在就读高中的赵颖自然无法体悟得如此深刻,这一切有关亲密关系的情绪对她而言都尤其陌生。
赵颖甚至因此自我怀疑,朋友的离去明明是走向光明的未来,自己为何反而只会因分别感到悲伤?这到底是友情……还是爱情?
![]()
《纸房子》游戏截图
不可否认,在这短暂的几小时流程里,《纸房子》能对青少年内心那堆复杂情绪作如此呈现,实属难得。它精准刻画出了少年少女们「幼稚」的烦恼,商讨的却是那些那怕步入中年,乃至老年,都未必能够想通并释怀的问题。
这也是《纸房子》为什么讲的本质仍旧是「伤痛文学」,却仍旧能引起大量玩家的共鸣,甚至引发躯体化反应。
游戏玩到最后,几乎每位玩家心中都会冒出相同一个问题:赵颖真正希望逃离的,是陈水这座熟悉、压抑、死气沉沉、一眼望到头的城市,还是自己过去十多年那支离破碎的人生?
玩家不知道,赵颖自然更不知道。在所有结局里,她几乎都会涌现犯罪念头,甚至动手实施。
比如其中一个结局里,赵颖买了两瓶白酒,淋着雨,抽着烟,将父亲、继母、妹妹,以及用自己抚养费购置的新房,一并付之一炬。她终是自己动手,彻彻底底烧毁了那座伴她成长的「纸房子」。彼时赵颖虽未身处火中,但她的自我也一并死在了那天——或是更早。
03
没办法,赵颖面临的困境不是人或社会造成的,它是时代发展背景下的一种必然趋势。现实中,走过青春期的玩家们,其实很早就默契地找到了一个共通又无奈的解法——再忍忍。
游戏里,22岁的大学生徐敏敏告诉赵颖,对现在的你来说,2年是生命中的八分之一,而十年后,你27岁了,2年就只是生命中的十四分之一了。在人生的广阔尺度下,尽管痛苦不会消失,但也会被慢慢冲淡。
而更重要的是,长大后的自己可以主动离开那摇摇欲坠的纸房子,为自己重新搭建一座新家,把自己重新养一次。少年们现在所要做的,是睁眼看世界,并学会跟自己和解。
电影《过春天》也曾传达过类似的理念。
故事中,主角佩佩与朋友相约,一定要在十六岁这年的圣诞去日本看雪。为此,她当走私的「水客」,在香港与深圳之间来回穿梭,很快就挣够了机票钱。不出意外,佩佩很快也为自己的行为付出代价,随满足感而来的,是与朋友因误会产生的决裂,以及法律的制裁。最终,她也没能完成去日本看雪的梦想。
在影片的最后,想要得到一切但失去一切的佩佩,再次登上了曾经与友人登上的太平山顶,整个香港的景色在眼前徐徐展开,一个曾经她当作玩笑讲出来的愿望实现了,没能在日本见到的那场雪,此刻在香港的山顶上下了起来。佩佩接受了这个失败的青春结局,走出来,开始成长为一个大人,像囚禁在鱼缸里的鲨鱼游向大海。
![]()
《过春天》
这里面自然存在艺术加工的成分——很多时候,心态转变带来的反馈绝不会如影片中那般迅捷。16岁想看的雪,可能要到26岁,36岁甚至46岁才有可能见到。佩佩在现实中所需要付出的代价,也往往比影片中要更多。
但它弥足珍贵的是,那份面向青少年的鼓励态度。人生并不是要过得完美才能继续向前,虽然在珍贵的青春中什么都没有做到,但反正还有明年后年,即使一无所有,但人总在成长。
《纸房子》也是一样。尽管大部分时候,这款游戏讲述的是一个彻底的悲剧,但却展现了不只是青少年所需的共情与认同感,填补了玩家们曾经,或是现在的内心空缺。「青春伤痛文学」真正重要的,不是那些满带无病呻吟的伤痛,而是关于成长与人生的思考。
![]()
《纸房子》游戏截图
本文首发自“36氪游戏”。