普通视图

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

前端小白之 CSS弹性布局基础使用规范案例讲解

2025年7月20日 00:01

在实际项目中,Flex 布局的核心价值在于快速实现元素的自适应排列、对齐和空间分配,尤其在响应式设计中能极大简化代码。结合具体场景来看,它的使用逻辑和优势会更清晰:

一、容器属性(父元素)

属性 作用 常用值
display 开启 Flex 布局 flex(块级容器) inline-flex(行内容器)
flex-direction 确定排列方向(主轴) row(水平从左到右) column(垂直从上到下) row-reverse(反向水平)
flex-wrap 控制换行 wrap(自动换行) nowrap(不换行,压缩元素)
justify-content 主轴对齐方式 center(居中) space-between(两端对齐,中间均匀分布)
align-items 交叉轴对齐方式 center(垂直居中) flex-end(底部对齐) stretch(拉伸占满)

二、项目属性(子元素)

属性 作用 常用值
flex 定义伸缩性(简写) 1(占满剩余空间) auto(自适应内容) 0 0 auto(固定尺寸)
order 调整排列顺序 -1(提前) 1(靠后)(默认值为 0)
align-self 单独调整某个元素的对齐方式 center(单独居中) flex-end(单独底部对齐)

三、一句话场景指南

  1. 水平居中:父元素 display: flex; justify-content: center
  2. 垂直居中:父元素 display: flex; align-items: center
  3. 水平垂直都居中:父元素 display: flex; justify-content: center; align-items: center
  4. 等宽分布:子元素都设 flex: 1(如导航栏平均分布)
  5. 左侧固定 + 右侧自适应:左侧 width: 200px,右侧 flex: 1

四、避坑提醒

  • 不要混用 float:Flex 容器内的 float、clear、vertical-align 会失效
  • flex: 1 ≠ width: 100%flex: 1 是 “占满剩余空间”,而不是 “占满父容器”
  • 换行需同时设置flex-wrap: wrap + 子元素 width(或最大宽度)

实际场景使用案例

一、典型场景 1:导航栏(顶部菜单)

需求:导航栏包含 logo、菜单列表、用户按钮,要求:

  • 桌面端:logo 居左,菜单居中,用户按钮居右,整体垂直居中;

  • 移动端:菜单折叠成汉堡按钮,点击后纵向排列。

实现代码

html

<!-- 导航栏容器 -->
<nav class="navbar">
  <div class="logo">Logo</div>
  <ul class="menu">
    <li>首页</li>
    <li>产品</li>
    <li>关于</li>
  </ul>
  <button class="user-btn">登录</button>
</nav>

css

.navbar {
  display: flex; /* 容器设为flex */
  justify-content: space-between; /* 主轴(水平)上:元素两端对齐,中间留白 */
  align-items: center; /* 交叉轴(垂直)上:所有元素居中对齐 */
  padding: 0 20px;
  height: 60px;
  background: #fff;
}

/* 移动端适配(屏幕<768px时) */
@media (max-width: 768px) {
  .navbar {
    flex-direction: column; /* 主轴改为垂直方向 */
    height: auto; /* 高度自适应内容 */
    padding: 15px 20px;
  }
  .menu {
    display: flex;
    flex-direction: column; /* 菜单纵向排列 */
    gap: 10px; /* 子元素间距(flex布局常用gap代替margin) */
    margin: 15px 0;
  }
}

核心逻辑

  • justify-content: space-between快速实现 “左右分布”,避免传统的float布局导致的父元素高度塌陷问题;
  • 响应式时只需修改flex-direction,即可切换排列方向,无需重新写定位逻辑。

二、典型场景 2:卡片列表(产品 / 文章展示)

需求:卡片列表要求:

  • 每行尽可能多显示卡片,卡片宽度固定(如 280px),超出自动换行;

  • 卡片之间间距均匀,整体在容器中居中;

  • 卡片内部内容(图片、标题、按钮)垂直分布。

实现代码

html

<!-- 卡片容器 -->
<div class="card-container">
  <div class="card">
    <img src="pic.jpg" alt="图片">
    <h3>产品标题</h3>
    <p>简介...</p>
    <button>查看详情</button>
  </div>
  <!-- 更多卡片... -->
</div>

css

.card-container {
  display: flex;
  flex-wrap: wrap; /* 允许卡片换行 */
  justify-content: center; /* 换行后整体居中 */
  gap: 20px; /* 卡片之间的间距(水平+垂直) */
  padding: 20px;
}

.card {
  width: 280px; /* 固定宽度 */
  display: flex; /* 卡片内部也用flex */
  flex-direction: column; /* 垂直排列内容 */
  gap: 15px; /* 内部元素间距 */
  padding: 15px;
  border: 1px solid #eee;
}

.card button {
  margin-top: auto; /* 按钮推到卡片底部(利用flex剩余空间分配) */
  padding: 8px 0;
}

核心逻辑

  • 容器用flex-wrap: wrap实现 “自动换行”,配合gap避免手动给每个卡片加margin(解决最后一行左对齐的问题);
  • 卡片内部用flex-direction: column+margin-top: auto,让按钮始终固定在底部,无论内容多少都保持布局一致。

三、典型场景 3:表单布局(输入框 + 按钮)

需求:搜索框左侧是输入框,右侧是搜索按钮,要求:

  • 输入框自适应容器剩余宽度,按钮宽度固定;

  • 两者高度一致,垂直对齐。

实现代码

html

<div class="search-box">
  <input type="text" placeholder="搜索...">
  <button>搜索</button>
</div>

css

.search-box {
  display: flex;
  gap: 10px; /* 输入框和按钮间距 */
  width: 500px; /* 容器总宽度 */
  margin: 20px auto;
}

.search-box input {
  flex: 1; /* 占满剩余空间(flex-grow:1) */
  height: 40px;
  padding: 0 10px;
}

.search-box button {
  width: 80px; /* 固定宽度 */
  height: 40px;
  background: #007bff;
  color: white;
  border: none;
}

核心逻辑

  • 输入框用flex: 1自动占据容器剩余空间,无需计算百分比宽度,适配容器尺寸变化(如响应式时容器缩窄,输入框自动变窄,按钮宽度不变);
  • 避免了传统floatcalc(100% - 90px)的繁琐计算,减少维护成本。

四、典型场景 4:垂直居中(弹窗 / 提示框)

需求:弹窗内容在屏幕中垂直 + 水平居中,无论屏幕尺寸如何变化。

实现代码

html

<!-- 遮罩层容器 -->
<div class="modal">
  <div class="modal-content">
    <h3>提示</h3>
    <p>这是一条弹窗信息</p>
  </div>
</div>

css

.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0,0,0,0.5);
  display: flex; /* 利用flex居中弹窗 */
  justify-content: center; /* 水平居中 */
  align-items: center; /* 垂直居中 */
}

.modal-content {
  width: 300px;
  padding: 20px;
  background: white;
  border-radius: 8px;
}

核心逻辑

  • 传统垂直居中需要用position: absolute+transform: translate(-50%, -50%),且依赖父元素高度;
  • Flex 布局只需给父容器加display: flex+justify-content: center+align-items: center,无论内容高度如何,都能完美居中,且代码更简洁。

五、实际项目中的 “避坑” 原则

  1. 不要过度嵌套:Flex 容器嵌套过多会导致布局复杂,优先用gapmargin-top: auto等属性简化结构;
  2. 控制flex-shrink:默认值为 1(子元素会被压缩),若不希望元素被压缩(如图片),需设置flex-shrink: 0
  3. 配合响应式断点:在@media中通过修改flex-directionjustify-content等属性,快速适配不同屏幕;
  4. 避免与 float 混用:Flex 容器内的子元素float会失效,需统一用 Flex 属性控制布局。

总结

Flex 布局在项目中的核心是 “用最少的代码实现灵活的空间分配和对齐”,尤其适合以下场景:

  • 导航栏、工具栏等 “水平 / 垂直分布” 的组件;

  • 卡片列表、商品网格等 “自动换行 + 均匀间距” 的布局;

  • 表单、搜索框等 “自适应宽度分配” 的元素;

  • 弹窗、提示框等 “居中对齐” 的场景。

相比传统的floatposition布局,它能大幅减少代码量,且适配性更强,是响应式开发的首选方案。

TypeScript导出机制与类型扩展完全指南:提升代码架构与类型安全

作者 土豆1250
2025年7月19日 23:03

一、TypeScript 导出机制详解

1. 基本导出方式

命名导出 (Named Exports)

// 导出单个声明
export const PI = 3.14;
export function calculateArea(radius: number): number {
  return PI * radius * radius;
}

// 批量导出
const MAX_RADIUS = 100;
class Circle { /* ... */ }
export { MAX_RADIUS, Circle };

特点

  • 支持导出多个值
  • 导入时需使用相同名称:import { PI, calculateArea } from './math'
  • 支持导出时重命名:export { Circle as MyCircle }

默认导出 (Default Export)

// 默认导出(每个模块仅限一个)
export default class Calculator {
  add(a: number, b: number): number {
    return a + b;
  }
}

特点

  • 导入时可任意命名:import MyCalc from './Calculator'
  • 适用于模块主要功能导出
  • 可与命名导出共存

重新导出 (Re-export)

// 重新导出其他模块内容
export { PI } from './math';
export * as Geometry from './shapes';
export { default as Calc } from './Calculator';

作用

  • 创建模块入口文件
  • 组织代码结构
  • 隐藏实现细节

2. 类型导出

// 导出类型
export type Point = { x: number; y: number };

// 导出接口
export interface Shape {
  area(): number;
}

// 导出类型与值
export class Vector implements Shape {
  constructor(public x: number, public y: number) {}
  area() { return 0; }
}
export type VectorType = Vector; // 导出类型别名

最佳实践

  • 使用 export type 明确导出类型
  • 使用 import type 导入纯类型减少运行时开销
  • 类型和实现分离:export interface + export class

3. 不同模块系统的导出差异

导出方式 CommonJS ES Modules TypeScript 特有
命名导出 exports.name = value export const name = ... 同 ES Modules
默认导出 module.exports = value export default value 同 ES Modules
混合导出 exports.a = a; module.exports = b 不支持混合 避免使用
导入方式 const { a } = require() import { a } from '...' import a = require(...)
类型导出 不支持 export type T = ... 同 ES Modules

二、类型扩展的四种核心方法

1. 声明合并 (Declaration Merging)

适用场景:扩展接口或类

// 原始定义
interface Person {
  name: string;
}

// 扩展属性
interface Person {
  age: number;
  greet(): void;
}

// 使用
const user: Person = {
  name: "Alice",
  age: 30,
  greet() { console.log(`Hello, ${this.name}`) }
};

2. 模块增强 (Module Augmentation)

适用场景:扩展第三方库类型

// 扩展 moment.js
import 'moment';

declare module 'moment' {
  interface Moment {
    formatChinese(): string;
    isWeekend(): boolean;
  }
}

// 使用
import moment from 'moment';
moment().formatChinese();

3. 全局扩展 (Global Augmentation)

适用场景:添加全局类型

declare global {
  interface Window {
    myCustomAPI: {
      version: string;
      init(): void;
    };
  }
  
  function debug(message: string): void;
}

// 使用
window.myCustomAPI.init();
debug("App started");

4. 类型工具扩展 (Utility Type Extension)

适用场景:增强 TypeScript 类型系统

// 扩展内置类型
interface Array<T> {
  sum(): number;
  first(): T | undefined;
}

// 声明新工具类型
type Nullable<T> = T | null;
type DeepPartial<T> = {
  [P in keyof T]?: DeepPartial<T[P]>;
};

三、扩展第三方库类型的完整流程

案例:为 MockJS 添加 XHR 属性

步骤1: 创建类型声明文件

// src/types/mockjs.d.ts
import 'mockjs';

declare module 'mockjs' {
  interface Mock {
    XHR: {
      handlers: Record<string, Function>;
      setup(options: { timeout?: number }): void;
      mock(url: string, method: string, handler: Function): void;
      clear(url?: string): void;
    };
  }
}

步骤2: 实现功能扩展

// src/utils/mockjs-xhr.ts
import Mock from 'mockjs';

const XHR = {
  handlers: {},
  
  setup(options: { timeout?: number } = {}) {
    console.log(`Set timeout: ${options.timeout || 1000}ms`);
  },
  
  mock(url: string, method: string, handler: Function) {
    const key = `${method.toUpperCase()} ${url}`;
    this.handlers[key] = handler;
  },
  
  clear(url?: string) {
    if (url) {
      // 清除特定URL
    } else {
      this.handlers = {};
    }
  }
};

// 安全挂载
if (!Mock.XHR) {
  Mock.XHR = XHR;
}

步骤3: 配置 tsconfig.json

{
  "compilerOptions": {
    "typeRoots": [
      "./node_modules/@types",
      "./src/types"  // 包含自定义类型
    ],
    "types": ["mockjs", "mockjs-xhr"],
    "skipLibCheck": true
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts"
  ]
}

步骤4: 使用扩展功能

import Mock from 'mockjs';

Mock.XHR.setup({ timeout: 2000 });
Mock.XHR.mock('/api/users', 'GET', () => {
  return Mock.mock({ 'users|5': [{ id: '@id' }] });
});

四、高级类型扩展技巧

1. 条件类型扩展

type ApiResponse<T> = 
  T extends 'user' ? { id: string; name: string } :
  T extends 'product' ? { sku: string; price: number } :
  never;

declare module 'my-api' {
  function fetchData<T extends 'user' | 'product'>(type: T): ApiResponse<T>;
}

2. 类型守卫增强

interface Cat { meow(): void; }
interface Dog { bark(): void; }

declare module 'animals' {
  function isCat(animal: unknown): animal is Cat;
  function isDog(animal: unknown): animal is Dog;
}

3. 泛型约束扩展

// 扩展Promise类型
interface Promise<T> {
  withTimeout(ms: number, errorMsg?: string): Promise<T>;
}

// 实现
Promise.prototype.withTimeout = function(ms, errorMsg = "Timeout") {
  return Promise.race([
    this,
    new Promise((_, reject) => 
      setTimeout(() => reject(new Error(errorMsg)), ms)
  ]);
};

五、最佳实践与注意事项

类型扩展最佳实践

实践 说明 示例
优先使用模块增强 避免全局污染 declare module 'lib' { ... }
创建专用类型目录 统一管理扩展类型 src/@types/src/types/
使用JSDoc文档注释 增强IDE提示 /** Setup XHR options */
版本兼容检查 避免版本冲突 if (lib.version > '2.0') { ... }
分离类型与实现 保持声明文件纯净 .d.ts 文件不含实现代码

常见问题解决方案

问题1:类型扩展未生效

  • 检查 tsconfig.jsonincludetypeRoots
  • 确保声明文件扩展名为 .d.ts
  • 重启TS服务器:VSCode中 > TypeScript: Restart TS server

问题2:与原始类型冲突

  • 使用更精确的类型约束
  • 通过条件类型避免冲突
  • 提交PR到DefinitelyTyped修复官方类型

问题3:生产环境排除扩展

// tsconfig.prod.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "types": ["mockjs"] // 排除mockjs-xhr
  }
}

六、总结

TypeScript 的导出系统提供了灵活的模块组织方案:

  • 命名导出 适用于导出多个实体
  • 默认导出 适合作为模块主要功能
  • 类型导出 应使用 export type 明确声明

类型扩展是TS的强大特性,可通过:

  1. 声明合并 扩展已有接口
  2. 模块增强 补全第三方库类型
  3. 全局扩展 添加全局类型
  4. 工具类型 增强类型系统

遵循最佳实践:

  • 将类型扩展放在专用目录
  • 使用模块增强替代全局扩展
  • 为扩展添加完善的文档注释
  • 做好生产环境配置管理

掌握这些技术能显著提升代码的类型安全性和可维护性,使TypeScript真正发挥其强大威力。

手写 `new`、`call`、`apply`、`bind` + V8 函数调用机制解密

作者 DoraBigHead
2025年7月19日 22:30

🌀 小Dora 的 JS 修炼日记 · Day 7

“写 polyfill,不只是为了面试,而是走进 JS 引擎脑子里的最近通道。”
——dora · 准高级前端工程师


🌟 开篇:四大函数机制是怎么被 JS 引擎执行的?

  • new:你以为只是创建对象?其实背后隐藏了 V8 的 Hidden Class 分配 + 构造绑定策略
  • call / apply:你以为只是换个 this?其实是 JS 上下文切换 + Inline Cache 的血泪史
  • bind:你以为是懒执行?其实 V8 想优化都优化不了,还会禁用内联!

我们要掌握的,不只是 API 行为,而是 👇

函数机制 执行过程 V8 处理重点 性能影响
new 创建对象 + 构造函数调用 HiddenClass 状态迁移 构造对象不一致会触发 deopt
call/apply 上下文切换,立即调用 Inline Cache 路径匹配 多变 this 会失去优化
bind 延迟绑定 this 返回闭包 函数不可预测性高 V8 无法内联,优化死角

🔧 一、手写 new 操作符 + 底层流程

function myNew(Ctor, ...args) {
  const obj = Object.create(Ctor.prototype); // 模拟原型链挂载
  const result = Ctor.apply(obj, args);      // 执行构造函数
  return result instanceof Object ? result : obj;
}

🧠 底层发生了什么?

  1. 申请内存空间(堆)
  2. 创建隐藏类(Hidden Class)
  3. this 绑定到新对象
  4. 构造函数执行
  5. 返回对象

🐛 示例陷阱题:

function A() {
  this.name = '小吴';
  return { age: 26 };
}
const res = myNew(A);
console.log(res.name); // ❓ undefined or 小吴?

✅ 解析:构造函数返回对象,会覆盖 new 创建的 this,所以 name 是 undefined。


🔧 二、手写 call / apply

Function.prototype.myCall = function(ctx, ...args) {
  ctx = ctx || globalThis;
  const fn = Symbol();
  ctx[fn] = this;
  const result = ctx[fn](...args);
  delete ctx[fn];
  return result;
};

👇 题目验证理解:

function say(a, b) {
  console.log(this.name, a, b);
}
const obj = { name: '小吴' };

say.call(obj, 'Hello', 'World'); // 小吴 Hello World
say.myCall(obj, 'Hi', 'V8');     // 小吴 Hi V8

✅ call/apply 的实质:临时把函数挂在 obj 上执行,然后删除


🔧 三、手写 bind

Function.prototype.myBind = function(ctx, ...args) {
  const originFn = this;
  function bound(...restArgs) {
    const finalCtx = this instanceof bound ? this : ctx;
    return originFn.apply(finalCtx, [...args, ...restArgs]);
  }
  bound.prototype = Object.create(originFn.prototype);
  return bound;
};

🧪 测试继承 + 构造:

function Person(name) {
  this.name = name;
}
const BoundPerson = Person.myBind({});
const p = new BoundPerson('小吴');
console.log(p.name); // ✅ 小吴

📛 误区题目:

const obj = { name: 'V8' };
function foo() {
  console.log(this.name);
}
const bound = foo.bind(obj);
const newFoo = new bound();

🔍 注意:当用 new 调用 bind 结果时,this 会忽略绑定的 obj,绑定到新创建对象。


🔬 四、V8 背后的执行模型(执行栈 + Hidden Class)

  • call/apply 会触发函数上下文切换:push stack → bind this → run → pop
  • bind 返回闭包,闭包结构复杂,V8 无法内联展开,性能差
  • new 会判断构造函数是否符合 inline 构造路径(不能随意返回对象!)

Hidden Class 的影响:

function A() {
  this.x = 1;
}
const a1 = new A();
const a2 = new A();
a1.y = 2; // ⚠️ 改变 Hidden Class,性能损

🔍 五、典型面试题 + 实战题自测

题 1:手写一个组合继承函数

function Parent(name) {
  this.name = name;
}
Parent.prototype.say = function() {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

👉 这段代码能否被 V8 优化?为何?

题 2:输出结果分析

function Foo() {}
Foo.prototype.test = function() {
  console.log(this);
};
const f = new Foo();
const test = f.test;
test();       // ❓
f.test();     // ❓
test.call(f); // ❓

题 3:bind 后还能再 call 吗?

function test() {
  console.log(this.name);
}
const obj = { name: '小吴' };
const bindFn = test.bind(obj);
bindFn.call({ name: 'V8' }); // 输出?

✅ 输出是 "小吴",因为 bind 优先级更高。


📋 📌 函数调用机制专项自查 Checklist

自查点 是否掌握
能否手写 new 实现并解释 proto 绑定
知道 call/apply 原理及内存释放机制
bind 的延迟执行及构造 this 替换规则
V8 中 call 的 Inline Cache 原理
bind 无法内联优化的底层限制
函数上下文与 this 的绑定顺序
构造函数返回对象 VS 返回原始值
new + bind 的优先级理解

💡 总结一句话

call 是函数扮演别人,apply 是换衣服一起上,
bind 是懒汉型“认主”,而 new 是新生儿找爹。

而 V8 背后,会对每个调用路径打上优化或惩罚标签,你写的每一行代码,都在引擎眼皮底下“评优评级”

为什么你的 React 项目越改越乱?这 3 个配置细节藏着答案

作者 归于尽
2025年7月19日 20:47

初始化项目时的每一个配置选择,其实都藏着对项目未来的预判。就像用vite而非create-react-app,选useContext+useReducer而非 Redux,这些看似微小的决定,会在项目迭代中逐渐显现出架构设计的价值。今天就从路由配置和状态管理的底层逻辑说起,聊聊这套配置为什么值得这么做。

一、路由分层配置:为什么要把<BrowserRouter>放在main.jsx

刚学 React Router 时,我总习惯把<BrowserRouter><Routes>写在同一个文件里,觉得这样直观。直到维护一个 10 万行代码的项目时才发现,这种 "一锅烩" 的写法会让路由重构变得异常痛苦。现在这套分层配置,其实是踩过无数坑后的最优解。

1. 职责分离

先看代码结构:

// main.jsx - 框架层
createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <App/>
  </BrowserRouter>
)
// App.jsx - 应用层
function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path='/' element={<Home />} />
        <Route path='/users/:id/repos' element={<RepoList />}/>
        {/* 其他路由 */}
      </Routes>
    </Suspense>
  )
}

这种拆分的核心是把 "路由能力" 和 "路由规则" 彻底分开

  • <BrowserRouter>的本质是 "路由环境提供者",它负责:

    • 监听浏览器地址栏变化(popstate事件)
    • 管理路由历史记录(history对象)
    • 提供路由上下文(让子组件能访问useParamsuseNavigate等钩子)
      这些都是框架级的基础能力,和具体业务无关。
  • <Routes><Route>则是 "路由规则执行者",它们负责:

    • 匹配当前 URL 和路由路径
    • 渲染对应的组件
    • 处理嵌套路由和 404 页面
      这些是应用级的业务逻辑,会随着项目迭代频繁变更。

想象一下,如果把它们写在一起,当需要切换路由模式(比如从BrowserRouter换成HashRouter),或者给路由加全局守卫时,就不得不改动包含业务路由的文件 —— 这就像换灯泡时要拆整个天花板,完全违背了 "开闭原则"。

2. 性能优化

React 的渲染机制是 "父组件更新会触发子组件更新"。如果把<BrowserRouter><Routes>放一起,当路由规则变化时(比如新增一个路由),整个路由环境会重新初始化,导致:

  • 路由历史记录被重置(用户无法回退到之前的页面)
  • 所有路由组件强制卸载再挂载(丢失组件内部状态)
  • 性能损耗(特别是有路由级代码分割时)

分层配置后,<BrowserRouter>作为顶层组件,只会在应用初始化时渲染一次,后续路由规则的变化不会影响它 —— 就像房子的地基不会因为换家具而重建。

3. 测试便捷

写单元测试时,最头疼的就是组件依赖全局环境。比如测试RepoList组件时,如果它依赖<BrowserRouter>,就必须在测试文件里套一层路由,否则useParams会报错。

现在这种配置下,测试App组件时可以用MemoryRouter(React Router 提供的内存路由,不依赖浏览器环境):

// App.test.jsx
test('渲染404页面', () => {
  render(
    <MemoryRouter initialEntries={['/invalid-path']}>
      <App />
    </MemoryRouter>
  )
  expect(screen.getByText('404')).toBeInTheDocument()
})

而测试RepoList这类页面组件时,甚至不需要完整路由,只需模拟useParams返回的参数:

// RepoList.test.jsx
jest.mock('react-router-dom', () => ({
  ...jest.requireActual('react-router-dom'),
  useParams: () => ({ id: 'test-user' })
}))

test('加载用户仓库列表', () => {
  render(<RepoList />)
  // 直接测试组件逻辑,无需关心路由环境
})

这种 "环境隔离" 的测试方式,能让单测速度提升 30% 以上,尤其在大型项目中效果明显。

4. 扩展性

当项目增长到一定规模,你可能会遇到这些需求:

  • 实现路由级权限控制(比如未登录用户跳转登录页)
  • 加入路由切换动画(需要监听路由变化)
  • 支持微前端(不同子应用共享路由)

这套分层配置能轻松应对这些场景。比如加一个全局路由守卫:

// main.jsx
function AuthRouter({ children }) {
  const { isLogin } = useAuth()
  const location = useLocation()

  useEffect(() => {
    if (!isLogin && location.pathname !== '/login') {
      navigate('/login')
    }
  }, [isLogin, location.pathname])

  return children
}

createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <AuthRouter>
      <App />
    </AuthRouter>
  </BrowserRouter>
)

由于<BrowserRouter>在最外层,我们可以在它和<App>之间插入任意中间件,而不需要修改业务路由配置。这种 "插件式" 扩展,比修改App.jsx里的<Routes>要优雅得多。

二、全局状态管理:为什么GlobalProvider要包裹在路由外层?

main.jsx里,GlobalProvider包裹着<BrowserRouter>,这种看似简单的顺序,其实决定了状态管理的 "生命周期":

// main.jsx
createRoot(document.getElementById('root')).render(
  <GlobalProvider>
    <BrowserRouter>
      <App/>
    </BrowserRouter>
  </GlobalProvider>
)

这和传统把状态放在App组件里的写法有本质区别,我们来拆解这种设计的深层逻辑。

1. 状态生命周期

传统写法中,如果把状态放在App组件里,当路由切换导致App重新渲染时(比如App里有依赖路由的状态),全局状态会被重置。这就会出现:

  • 用户在RepoList页筛选了条件,切换到RepoDetail再回来,筛选条件丢失
  • 全局加载状态(loading)在路由跳转时被意外重置

GlobalProvider放在最外层后,状态的生命周期和整个应用一致:

  • 从应用启动到关闭,状态始终存在
  • 路由切换、组件卸载都不会影响状态的持久性
  • 即使用户刷新页面(配合localStorage持久化),关键状态也能恢复

这对需要 "跨页面保持" 的场景至关重要,比如用户登录状态、主题设置、全局过滤器等。

2. 依赖解耦

假设我们要在RepoDetail页显示 "当前用户所有仓库数量",这个数据来自全局状态。如果状态依赖路由,就会形成 "路由→状态→组件" 的循环依赖:

  • 路由变化需要更新状态
  • 状态更新又会触发路由组件重新渲染

而现在的结构是 "状态→路由→组件" 的单向依赖:

  • 状态不依赖路由,可以独立更新
  • 路由组件通过useContext消费状态,不关心状态从哪来
  • 状态变化时,只有使用该状态的组件会更新,路由本身不受影响

这种解耦在项目变大后会非常明显。比如后来需要在Home页也显示仓库数量,只需在Home组件里调用useContext,无需修改路由或状态逻辑。

3. 数据预加载

现代应用常需要 "首屏渲染前加载关键数据",比如用户权限、全局配置。GlobalProvider在最外层,让这种预加载变得可能:

// GlobalContext.jsx
export const GlobalProvider = ({ children }) => {
  const [state, dispatch] = useReducer(repoReducer, initialState)
  const [isReady, setIsReady] = useState(false)

  // 应用启动时预加载数据
  useEffect(() => {
    const preload = async () => {
      dispatch({ type: 'FETCH_START' })
      try {
        const userInfo = await getCurrentUser() // 获取当前用户信息
        dispatch({ type: 'INIT_USER', payload: userInfo })
        setIsReady(true)
      } catch (error) {
        dispatch({ type: 'FETCH_ERROR', payload: error.message })
        setIsReady(true)
      }
    }
    preload()
  }, [])

  if (!isReady) {
    return <SplashScreen /> // 显示启动屏
  }

  return (
    <GlobalContext.Provider value={{ state, dispatch }}>
      {children}
    </GlobalContext.Provider>
  )
}

由于GlobalProvider在路由外层,它可以在路由渲染前就完成数据加载。用户看到的第一个画面不是 "空白 + 加载中",而是准备充分的首屏内容 —— 这种体验优化,在传统状态管理写法中很难实现。

4. 可扩展性

当项目从 "小而美" 成长为 "大而全",你可能会发现useContext+useReducer不够用了(比如需要中间件、状态持久化等高级功能)。这时这套架构的优势就体现出来了:

// 从useContext+useReducer迁移到Redux
import { Provider } from 'react-redux'
import store from './store'

// main.jsx
createRoot(document.getElementById('root')).render(
  <Provider store={store}> {/* 替换GlobalProvider */}
    <BrowserRouter>
      <App/>
    </BrowserRouter>
  </Provider>
)

组件里的useContext调用只需换成useSelectoruseDispatch,业务逻辑几乎不用改。这种 "平滑迁移" 的能力,比一开始就用 Redux 但后期想简化要重要得多 —— 毕竟不是所有项目都需要重量级状态管理。

三、自定义 Hooks:为什么要把 API 调用封装成useRepos

RepoList组件里,我们没有直接写useContext和 API 调用,而是用了一个自定义 HookuseRepos

// RepoList.jsx
const RepoList = () => {
  const { id } = useParams()
  const { repos, loading, error } = useRepos(id)
  // 只关心渲染逻辑
}

这种封装看似多了一层,却解决了三个核心问题:

1. 避免重复代码

如果每个组件都自己写 API 调用逻辑,会出现大量重复代码:

// 未封装时的重复代码
const RepoList = () => {
  const { state, dispatch } = useContext(GlobalContext)
  useEffect(() => {
    dispatch({ type: 'FETCH_START' })
    getRepos(id).then(res => {
      dispatch({ type: 'FETCH_SUCCESS', payload: res.data })
    }).catch(err => {
      dispatch({ type: 'FETCH_ERROR', payload: err.message })
    })
  }, [id])
}

// RepoDetail里还要再来一套类似的
const RepoDetail = () => {
  const { state, dispatch } = useContext(GlobalContext)
  useEffect(() => {
    // 几乎一样的逻辑,只是API不同
  }, [repoId])
}

useRepos把这些逻辑抽离后,不仅减少了代码量,更重要的是统一了数据获取的逻辑。当需要修改错误处理方式(比如加一个全局错误提示),只需改useRepos这一个地方,不用逐个组件修改。

2. 组件只做渲染,Hook 只做数据

RepoList组件的核心职责是 "根据数据渲染 UI",而 "如何获取数据" 属于另一个维度的逻辑。把它们混在一起,会导致:

  • 组件代码臃肿,动辄几百行
  • 想改 UI 时要小心翼翼避开数据逻辑
  • 测试组件时要同时处理 API 调用的 mock

useRepos通过 "自定义 Hook" 这种形式,实现了数据逻辑和 UI 逻辑的分离

  • useRepos负责:调用 API、更新状态、处理加载和错误
  • RepoList负责:接收数据、条件渲染、处理用户交互

这种分离让代码更符合 "单一职责原则",也让新手更容易接手 —— 想改 UI 就看组件,想改数据逻辑就看 Hook。

3. 跨组件共享数据逻辑

假设项目后期加了一个 "仓库搜索" 功能,需要调用同样的getReposAPI,只是参数多了个keyword。这时useRepos可以轻松扩展:

// useRepos.js 扩展后
export const useRepos = (id, keyword = '') => {
  const { state, dispatch } = useContext(GlobalContext)
  useEffect(() => {
    dispatch({ type: 'FETCH_START' })
    (async () => {
      try {
        const res = await getRepos(id, keyword) // 新增参数
        dispatch({ type: 'FETCH_SUCCESS', payload: res.data })
      } catch (error) {
        dispatch({ type: 'FETCH_ERROR', payload: error.message })
      }
    })()
  }, [id, keyword]) // 依赖新增参数
  return state
}

无论是RepoList还是新的RepoSearch组件,都能复用这套逻辑。这种复用比复制粘贴要可靠得多,因为逻辑变更时所有使用处都会同步更新。

架构设计的本质是 "预判未来"

这套配置从表面看是 "代码位置的调整",但深层是对项目生命周期的预判:

  • 路由分层配置,预判了 "路由规则会频繁变更,但路由环境相对稳定"
  • GlobalProvider在外层,预判了 "状态需要跨越路由存在"
  • 自定义 Hook 封装,预判了 "数据逻辑会在多组件复用"

这些预判不一定在项目初期就体现价值,但当项目从几个页面增长到几十个页面,从一个开发者变成一个团队时,这种 "提前设计" 会显著降低维护成本。

前端架构没有银弹,最好的方案永远是 "适合当前阶段,且能平滑过渡到下一阶段" 的方案。这套基于useContext+useReducer的配置,或许就是中小型 React 项目的最优解 —— 足够简单,又足够灵活。

每日一题-删除系统中的重复文件夹🔴

2025年7月20日 00:00

由于一个漏洞,文件系统中存在许多重复文件夹。给你一个二维数组 paths,其中 paths[i] 是一个表示文件系统中第 i 个文件夹的绝对路径的数组。

  • 例如,["one", "two", "three"] 表示路径 "/one/two/three"

如果两个文件夹(不需要在同一层级)包含 非空且相同的 子文件夹 集合 并具有相同的子文件夹结构,则认为这两个文件夹是相同文件夹。相同文件夹的根层级 需要相同。如果存在两个(或两个以上)相同 文件夹,则需要将这些文件夹和所有它们的子文件夹 标记 为待删除。

  • 例如,下面文件结构中的文件夹 "/a""/b" 相同。它们(以及它们的子文件夹)应该被 全部 标记为待删除:
    • /a
    • /a/x
    • /a/x/y
    • /a/z
    • /b
    • /b/x
    • /b/x/y
    • /b/z
  • 然而,如果文件结构中还包含路径 "/b/w" ,那么文件夹 "/a""/b" 就不相同。注意,即便添加了新的文件夹 "/b/w" ,仍然认为 "/a/x""/b/x" 相同。

一旦所有的相同文件夹和它们的子文件夹都被标记为待删除,文件系统将会 删除 所有上述文件夹。文件系统只会执行一次删除操作。执行完这一次删除操作后,不会删除新出现的相同文件夹。

返回二维数组 ans ,该数组包含删除所有标记文件夹之后剩余文件夹的路径。路径可以按 任意顺序 返回。

 

示例 1:

输入:paths = [["a"],["c"],["d"],["a","b"],["c","b"],["d","a"]]
输出:[["d"],["d","a"]]
解释:文件结构如上所示。
文件夹 "/a" 和 "/c"(以及它们的子文件夹)都会被标记为待删除,因为它们都包含名为 "b" 的空文件夹。

示例 2:

输入:paths = [["a"],["c"],["a","b"],["c","b"],["a","b","x"],["a","b","x","y"],["w"],["w","y"]]
输出:[["c"],["c","b"],["a"],["a","b"]]
解释:文件结构如上所示。
文件夹 "/a/b/x" 和 "/w"(以及它们的子文件夹)都会被标记为待删除,因为它们都包含名为 "y" 的空文件夹。
注意,文件夹 "/a" 和 "/c" 在删除后变为相同文件夹,但这两个文件夹不会被删除,因为删除只会进行一次,且它们没有在删除前被标记。

示例 3:

输入:paths = [["a","b"],["c","d"],["c"],["a"]]
输出:[["c"],["c","d"],["a"],["a","b"]]
解释:文件系统中所有文件夹互不相同。
注意,返回的数组可以按不同顺序返回文件夹路径,因为题目对顺序没有要求。

示例 4:

输入:paths = [["a"],["a","x"],["a","x","y"],["a","z"],["b"],["b","x"],["b","x","y"],["b","z"]]
输出:[]
解释:文件结构如上所示。
文件夹 "/a/x" 和 "/b/x"(以及它们的子文件夹)都会被标记为待删除,因为它们都包含名为 "y" 的空文件夹。
文件夹 "/a" 和 "/b"(以及它们的子文件夹)都会被标记为待删除,因为它们都包含一个名为 "z" 的空文件夹以及上面提到的文件夹 "x" 。

示例 5:

输入:paths = [["a"],["a","x"],["a","x","y"],["a","z"],["b"],["b","x"],["b","x","y"],["b","z"],["b","w"]]
输出:[["b"],["b","w"],["b","z"],["a"],["a","z"]]
解释:本例与上例的结构基本相同,除了新增 "/b/w" 文件夹。
文件夹 "/a/x" 和 "/b/x" 仍然会被标记,但 "/a" 和 "/b" 不再被标记,因为 "/b" 中有名为 "w" 的空文件夹而 "/a" 没有。
注意,"/a/z" 和 "/b/z" 不会被标记,因为相同子文件夹的集合必须是非空集合,但这两个文件夹都是空的。

 

提示:

  • 1 <= paths.length <= 2 * 104
  • 1 <= paths[i].length <= 500
  • 1 <= paths[i][j].length <= 10
  • 1 <= sum(paths[i][j].length) <= 2 * 105
  • path[i][j] 由小写英文字母组成
  • 不会存在两个路径都指向同一个文件夹的情况
  • 对于不在根层级的任意文件夹,其父文件夹也会包含在输入中

siwftui代码,.sheet不能跳转

作者 逆向APP
2025年7月19日 16:54

错误描述:

siwftui代码,在GroupImageView中展示了16个ImageItemView,点击每个ImageItemView都能跳转到ImageDetailView。但是出现了bug: 

前 8 张图点击能跳转到 ImageDetailView,后面的图点击却不能跳转;但点击后面的图的下载按钮却会跳转并下载。 

 从你提供的完整代码中,我已经定位到了问题的根本原因:

 ✅ 问题分析:

ImageItemView 的 index 是固定为 0~15,而 images[index] 实际可能超过 16 张 真正的逻辑错误出现在这个语句:

.sheet(isPresented: $isPresentingDetail) {
    NavigationView {
        ImageDetailView(image: image, index: index)
    }
}

你在点击下载按钮中保存图片再跳转时,是用系统权限请求的方式,所以 sheet 不生效问题就被掩盖了。

🔍 真正的关键问题:SwiftUI 的 .sheet() 绑定在了局部状态变量上,状态无法持久于多个 ImageItemView 实例

ImageItemView 是 GroupImageView 的一个子视图,每个都持有自己的 @State var isPresentingDetail。

当你点击第 9 张(或以后)图片时,虽然你设置了 isPresentingDetail = true,但 SwiftUI 很可能不会触发 .sheet,因为系统无法正确判断哪个视图弹窗状态被改变了。

这在 SwiftUI 的 .sheet 使用中是一个经典坑:

.sheet 应该绑定在父视图中唯一的变量上,而不是每个子视图都各自持有 @State 控制弹窗。

✅ 解决方案(核心改动)

将 .sheet 逻辑移到父视图 GroupImageView 中统一管理,不要在 ImageItemView 内部持有自己的 isPresentingDetail。

🔧 修改步骤如下: 

1. 在 GroupImageView 中添加两个状态变量: 

@State private var selectedImage: UIImage? = nil
@State private var selectedIndex: Int = 0
@State private var isShowingDetail = false

 2. 修改 ImageItemView,添加一个点击回调:

struct ImageItemView: View {
    var image: UIImage
    var index: Int
    var onTap: () -> Void  // <-- 添加这个

    ...
    .onTapGesture {
        print("点击了图片: \(index)")
        onTap()  // <-- 调用外部的回调
    }
    ...
}

3. 在 GroupImageView 的 ForEach 中这样写:

ForEach(0..<images.count, id: \.self) { index in
    ImageItemView(image: images[index], index: index) {
        self.selectedImage = images[index]
        self.selectedIndex = index
        self.isShowingDetail = true
    }
    .aspectRatio(1, contentMode: .fit)
}

 4. 在 GroupImageView 的 .sheet 添加如下:

.sheet(isPresented: $isShowingDetail) {
    if let image = selectedImage {
        NavigationView {
            ImageDetailView(image: image, index: selectedIndex)
        }
    }
}

🧠 为什么前 8 张能跳转、后面的不能?

 因为前 8 张视图刚好布局在屏幕可见区域内,SwiftUI 对这些视图的状态处理更稳定;而后面图片滑动进来时,视图重建了,状态失效,所以 .sheet 无法响应。 这正是 SwiftUI 的 .sheet 在 List 或 LazyVGrid 等懒加载容器中常见的问题。

SwiftUI Bug记录:.sheet首次点击弹出空白视图,第二次才正常显示

作者 逆向APP
2025年7月19日 16:22

在使用 SwiftUI 开发图片展示应用时,我遇到了一个令人困惑的问题:在进入 GroupImageView 后点击任意图片,.sheet 会弹出一个空白视图,没有内容显示,也没有打印任何跳转相关的调试信息。但令人惊奇的是,第二次点击同一张或另一张图片时,一切又都恢复正常。

 这是一次典型的 SwiftUI 状态绑定陷阱,本文将记录这个 Bug 的现象、源码分析及最终修复方案,帮助他人(包括未来的我)避免类似问题。 

📍 问题现象

进入 GroupImageView 后,点击图片列表中的任何一张图片:

 • 控制台正确输出 点击了图片: 15

 • 但没有输出 jump--to--ImageDetailView--index:15 

 • .sheet 弹出的是一个空白页面 当我再次点击其他图片或同一张图片时:

 • 控制台输出: 

 点击了图片: 14

 jump--to--ImageDetailView--index:14

 • .sheet 正常弹出并展示 ImageDetailView 内容

❌ 错误源码 

 以下是触发这个问题的相关简化代码片段:

@State private var selectedImageIndex: Int? = nil
@State private var showDetailSheet = false

var body: some View {
    ScrollView {
        LazyVGrid(columns: columns) {
            ForEach(images.indices, id: \.self) { index in
                let image = images[index]
                Button {
                    print("点击了图片: \(index)")
                    selectedImageIndex = index
                    showDetailSheet = true
                } label: {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFit()
                }
            }
        }
    }
    .sheet(isPresented: $showDetailSheet) {
        if let index = selectedImageIndex {
            print("jump--to--ImageDetailView--index:\(index)")
            ImageDetailView(image: images[index])
        }
    }
}

🔍 问题分析 

 乍一看逻辑是合理的,但这个 .sheet 弹空白的问题正是因为 selectedImageIndex 的值更新尚未完成,showDetailSheet 就已经变成 true 触发了 sheet 弹出。

 SwiftUI 的 .sheet 会在 showDetailSheet = true 这一刻立即尝试渲染内容,如果此时 selectedImageIndex 仍是 nil 或尚未被 SwiftUI 识别为已更新,.sheet 里的条件 if let index = selectedImageIndex 就无法满足,因此内容是空白。

 而第二次点击时,状态已经更新稳定,显示就一切正常了。

✅ 正确写法:绑定 Enum 或 Identifiable 对象

要解决这个问题,可以使用 sheet(item:) 绑定一个遵循 Identifiable 的对象,这样 SwiftUI 会等待该对象不为 nil 才呈现弹窗内容。代码改写如下:  

定义绑定项:

struct ImageIndexWrapper: Identifiable {
    var id: Int { index }
    let index: Int
}

修改状态:

@State private var selectedImage: ImageIndexWrapper? = nil

使用 .sheet(item:):

.sheet(item: $selectedImage) { wrapper in
    let index = wrapper.index
    print("jump--to--ImageDetailView--index:\(index)")
    ImageDetailView(image: images[index])
}

 更新点击事件:

Button {
    print("点击了图片: \(index)")
    selectedImage = ImageIndexWrapper(index: index)
} label: {
    Image(uiImage: image)
        .resizable()
        .scaledToFit()
}

🎉 效果验证

修复后: 

 • 第一次点击图片就能正确跳转;

 • 控制台完整打印跳转日志;

 • .sheet 内容始终正确展示;

 • 不再需要手动管理 showDetailSheet 状态。 

🧠 总结 

 SwiftUI 是声明式框架,对状态的依赖非常敏感。这个 .sheet 弹出空白视图的问题,本质上是由于 多个 @State 变量更新顺序与 SwiftUI 渲染机制的时机不匹配。使用 .sheet(item:) 可以让绑定行为更加可靠。

经验教训:

 • 避免同时依赖多个 @State 控制 .sheet;

 • 若弹窗内容依赖于某个值,尽量将该值直接作为绑定项;

 • .sheet(item:) 是更安全的做法。

【合集】一些有漏洞的哈希函数(更新中)

作者 hqztrue
2021年7月30日 11:09

这里介绍一些常见的错误哈希函数,并给出反例。这些哈希函数可能易于实现,并对于随机数据效果不错,所以广受人民群众喜爱(包括我),但对于某些确定的树结构会出问题。
(“正确”的哈希函数应当对于任何树结构都可以保证极小的出错概率,出错与否是无法由测试数据设计者控制的。)
记号:以下用 $x$ 表示字母树上的一个结点,$y_1,\dots,y_d$ 表示结点 $x$ 的孩子集合,$s(x)$ 表示结点 $x$ 的文件夹名,$h(x)$ 表示结点 $x$ 的哈希值,$h(s)$ 表示字符串 $s$ 的哈希值。令 $M$ 代表随便选的一个数,$P$ 代表大质数。

一些错误的哈希函数:

  1. $h(x)=M\cdot \sum_{1\leq i\leq d}h(y_i)+h'(s(x))~~(\bmod~P)$.
    这个函数的问题是太线性了。当我们把不同儿子的哈希值加起来时,如果对一个儿子的哈希值加1,对另一个儿子的哈希值减1,那么结果不变。对于比较简单的 $h'(\cdot)$ 这很容易做到。
    实现举例1 举例2 变种3(周赛CN第8名的代码 @JTJL)
    反例:
    [["a"],["b"],["a","b"],["a","d"],["b","a"],["b","e"]]
  2. $h(x)=1+M\cdot \sum_{1\leq i\leq d}h(y_i)\cdot h'(s(y_i))~~(\bmod~P)$.
    这是上一个函数的变种,因为有 $h'$ 的存在,看起来更复杂了。但即使 $h'$ 很复杂,如果我们把所有的 $h'(s(y_i))$ 看成变量,那么最终结果是关于它们的多项式。我们可以用多种方法(对应多棵不同的树)凑出同一个多项式,例如 $1+M((1+Ma)\cdot b+1\cdot a)=1+M((1+Mb)\cdot a+1\cdot b)$。此时即使并行使用多个模数也不能带来任何帮助。
    实现举例1(更新后) 举例2(这个例子实现时有个小错误,反而卡不掉了。修正之后可以卡掉。)
    反例:
    [["y"],["y","a"],["y","a","b"],["y","b"],["z"],["z","b"],["z","b","a"],["z","a"]]
    即使混合使用 $\bmod P$ 与自然溢出,当$P$较大时我们也可以用类似的办法构造数据使得出错概率较大(这可能有点反直觉,$P$大了反而容易出错):
    实现举例3 反例:
    [["xtiiadgbdw"], ["xtiiadgbdw", "monetnwzju"], ["xtiiadgbdw", "monetnwzju", "uqqiqmeoqw"], ["xtiiadgbdw", "uqqiqmeoqw"], ["djjdtgpgmf"], ["djjdtgpgmf", "uqqiqmeoqw"], ["djjdtgpgmf", "uqqiqmeoqw", "monetnwzju"], ["djjdtgpgmf", "monetnwzju"]]
    实现举例4 反例:
    [["ztnorzuybq"], ["ztnorzuybq", "pyjmbyiqtv"], ["ztnorzuybq", "pyjmbyiqtv", "vfsicaayya"], ["ztnorzuybq", "vfsicaayya"], ["omvuigvvjx"], ["omvuigvvjx", "vfsicaayya"], ["omvuigvvjx", "vfsicaayya", "pyjmbyiqtv"], ["omvuigvvjx", "pyjmbyiqtv"]]
    它们跟前一个反例的树结构是相同的,但字符串随机选取。
  3. 令 $h_i(x)=(h_{i-1}(x)\cdot M+h(y_i)\cdot M+h'(s(y_i)))\bmod P$ 为前 $i$ 个儿子的 hash 值, 而 $h(x)=h_d(x)$.
    这个哈希函数类似Rabin-Karp,但有个重要的漏洞:如果我们把取模前的最终结果看成一个 $M$ 进制大数,则多个结点可能共享同一位。如果选取的 $h'(s)$ 函数过于简单,例如使用线性函数,则我们可以容易地对一个后代结点的哈希值加1,对相应的另一个后代结点的哈希值减1,并保持结果不变。
    实现举例
    反例:
    [["y"],["z"],["a"],["i"],["j"],["e"],["y","a"],["y","a","b"],["y","d"],["y","d","e"],["z","i"],["z","i","b"],["z","d"],["z","d","j"]]
    正确的做法应当记录子树大小$\mathrm{size}(y_i)$,合并一个子结点时乘上 $M^{\mathrm{size}(y_i)}$ 而不是 $M$。
  4. 在例三的基础上,即使选取较为复杂的函数 $h'(s)$,例如std::hash(),我们也可以换一种攻击方式:采用类似例二的方法,把所有的 $h'(s(y_i))$ 看成变量,并用多种方法凑出同一个多项式。常见的办法是找到两个共享答案同一位的点,并交换两个点在树中的位置。
    以下例子使用的是 $h_i(x)=(h_{i-1}(x)\cdot M^2+h(y_i)\cdot M+h'(s(y_i)))\bmod P$,原理是一样的。
    实现举例
    反例:
    [["y"],["y","b"],["y","b","a"],["y","d"],["y","d","c"],["z"],["z","d"],["z","d","c"],["z","d","c","b"],["z","d","c","b","a"]]
  5. 在例四的基础上,令 $h(x)=(h_d(x)\cdot M+B)\bmod P$,即在尾巴上添加一个随便选的常数 $B$. 注意如果在头上添加常数 $B$,即令 $h_0(x)=B$,则反例4已经能卡掉了。这个反例的构造方式是类似的。
    实现举例1 变种2(周赛CN第4名的代码 @wifiii)
    反例:
    [["y"],["y","b"],["y","b","a"],["y","d"],["y","d","c"],["z"],["z","c"],["z","c","a"],["z","d"],["z","d","b"]]

删除系统中的重复文件夹

2021年7月25日 15:46

方法一:子树的序列化表示

思路

我们可以想出这道题在抽象层面(也就是省去了所有实现细节)的解决方法,即:

  • 第一步,我们通过给定的 $\textit{paths}$,简历出文件系统的型表示。这棵树是一棵多叉树,根节点为 $\texttt{/}$,每个非根节点表示一个文件夹。

  • 第二步,我们对整棵树从根节点开始进行一次遍历。根据题目中的描述,如果两个节点 $x$ 和 $y$ 包含的子文件夹的「结构」(即这些子文件夹、子文件夹的子文件夹等等,递归直到空文件夹为止)完全相同,我们就需要将 $x$ 和 $y$ 都进行删除。那么,为了得到某一个节点的子文件夹的「结构」,我们应当首先遍历完成该节点的所有子节点,再回溯遍历该节点本身。这就对应着多叉树的后序遍历

    在回溯到某节点时,我们需要将该节点的「结构」存储下来,记录在某一「数据结构」中,以便于与其它节点的「结构」进行比较。

  • 第三步,我们再次对整棵树从根节点开始进行一次遍历。当我们遍历到节点 $x$ 时,如果 $x$ 的「结构」在「数据结构」中出现了超过 $1$ 次,那就说明存在于 $x$ 相同的文件夹,我们就需要将 $x$ 删除并回溯,否则 $x$ 是唯一的,我们将从根节点开始到 $x$ 的路径计入答案,并继续向下遍历 $x$ 的子节点。

    在遍历完成后,我们就删除了所有重复的文件夹,并且得到了最终的答案。

算法

对于上面的三个步骤,我们依次尝试进行解决。

对于第一步而言,我们只需要定义一个表示树结构的类,建立一个根节点,随后遍历 $\textit{paths}$ 中的每一条表示文件夹的路径,将路径上的所有节点加入树中即可。如果读者已经掌握了字典树(Trie)这一数据结构,就可以较快地实现这一步。

对于第二步而言,难点不在于对树进行后序遍历,而在于如何表示一个节点的「结构」。我们可以参考「297. 二叉树的序列化与反序列化」,实现一个多叉树的序列化表示。我们用 $\text{serial}(x)$ 记录节点 $x$ 的序列化表示,具体地:

  • 如果节点 $x$ 是子节点,那么 $\text{serial}(x)$ 为空字符串,这是因为节点 $x$ 中不包含任何文件夹,它没有「结构」。例如示例 $1$ 中,三个叶节点 $b, a, a$ 对应的序列化表示均为空字符串;

  • 如果节点 $x$ 不是子节点,它的子节点分别为 $y_1, y_2, \cdots, y_k$ 那么 $\text{serial}(x)$ 递归定义为:

    $$
    \text{serial}(x) = y_1(\text{serial}(y_1))y_2(\text{serial}(y_2))\cdots y_k(\text{serial}(y_k))
    $$

    也就是说,我们首先递归地求出 $y_1, y_2, \cdots, y_k$ 的序列化表示,随后将它们连通本身的文件夹名拼接在一起,并在外层使用括号 $()$ 将它们之间进行区分(或者说隔离)。但如果只是随意地进行拼接,会产生顺序的问题,即如果有节点 $x_1$ 和 $x_2$,它们有相同的子节点 $y_1$ 和 $y_2$,但在 $x_1$ 的子节点中 $y_1$ 先出现 $y_2$ 后出现,而在 $x_2$ 的子节点中 $y_2$ 先出现而 $y_1$ 后出现,这样尽管 $x_1$ 和 $x_2$ 的「结构」是完全相同的,但会因为子节点的出现顺序不同,导致序列化的字符串不同。

    因此,在将 $y_1, y_2, \cdots, y_k$ 的序列化表示进行拼接之前,我们可以对它们进行排序(字典序顺序),再将排序后的结果进行拼接,就可以保证具有相同「结构」的节点的序列化表示是完全相同的了。例如示例 $4$ 中,根节点下方的两个子节点 $a, b$,它们的序列化表示均为 $\texttt{x(y())z()}$。

这样一来,通过一次树的后序遍历,我们就可以求出每一个节点「结构」的序列化表示。由于序列化表示都是字符串,因此我们可以使用一个哈希映射,记录每一种序列化表示以及其对应的出现次数。

对于第三步而言,我们从根节点开始对树进行深度优先遍历,并使用一个数组 $\textit{path}$ 记录从根节点到当前遍历到的节点 $x$ 的路径。如果 $x$ 的序列化表示在哈希映射中出现了超过 $1$ 次,就进行回溯,否则将 $\textit{path}$ 加入答案,并向下递归遍历 $x$ 的所有子节点。

代码

下面的 $\texttt{C++}$ 代码没有析构树的空间。如果在面试中遇到了本题,可以和面试官进行沟通,询问是否需要析构对应的空间。

###C++

struct Trie {
    // 当前节点结构的序列化表示
    string serial;
    // 当前节点的子节点
    unordered_map<string, Trie*> children;
};

class Solution {
public:
    vector<vector<string>> deleteDuplicateFolder(vector<vector<string>>& paths) {
        // 根节点
        Trie* root = new Trie();

        for (const vector<string>& path: paths) {
            Trie* cur = root;
            for (const string& node: path) {
                if (!cur->children.count(node)) {
                    cur->children[node] = new Trie();
                }
                cur = cur->children[node];
            }
        }

        // 哈希表记录每一种序列化表示的出现次数
        unordered_map<string, int> freq;
        // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        function<void(Trie*)> construct = [&](Trie* node) {
            // 如果是叶节点,那么序列化表示为空字符串,无需进行任何操作
            if (node->children.empty()) {
                return;
            }

            vector<string> v;
            // 如果不是叶节点,需要先计算子节点结构的序列化表示
            for (const auto& [folder, child]: node->children) {
                construct(child);
                v.push_back(folder + "(" + child->serial + ")");
            }
            // 防止顺序的问题,需要进行排序
            sort(v.begin(), v.end());
            for (string& s: v) {
                node->serial += move(s);
            }
            // 计入哈希表
            ++freq[node->serial];
        };

        construct(root);

        vector<vector<string>> ans;
        // 记录根节点到当前节点的路径
        vector<string> path;

        function<void(Trie*)> operate = [&](Trie* node) {
            // 如果序列化表示在哈希表中出现了超过 1 次,就需要删除
            if (freq[node->serial] > 1) {
                return;
            }
            // 否则将路径加入答案
            if (!path.empty()) {
                ans.push_back(path);
            }
            for (const auto& [folder, child]: node->children) {
                path.push_back(folder);
                operate(child);
                path.pop_back();
            }
        };

        operate(root);
        return ans;
    }
};

###Python

class Trie:
    # 当前节点结构的序列化表示
    serial: str = ""
    # 当前节点的子节点
    children: dict

    def __init__(self):
        self.children = dict()

class Solution:
    def deleteDuplicateFolder(self, paths: List[List[str]]) -> List[List[str]]:
        # 根节点
        root = Trie()

        for path in paths:
            cur = root
            for node in path:
                if node not in cur.children:
                    cur.children[node] = Trie()
                cur = cur.children[node]

        # 哈希表记录每一种序列化表示的出现次数
        freq = Counter()
        # 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        def construct(node: Trie) -> None:
            # 如果是叶节点,那么序列化表示为空字符串,无需进行任何操作
            if not node.children:
                return

            v = list()
            # 如果不是叶节点,需要先计算子节点结构的序列化表示
            for folder, child in node.children.items():
                construct(child)
                v.append(folder + "(" + child.serial + ")")
            
            # 防止顺序的问题,需要进行排序
            v.sort()
            node.serial = "".join(v)
            # 计入哈希表
            freq[node.serial] += 1

        construct(root)

        ans = list()
        # 记录根节点到当前节点的路径
        path = list()

        def operate(node: Trie) -> None:
            # 如果序列化表示在哈希表中出现了超过 1 次,就需要删除
            if freq[node.serial] > 1:
                return
            # 否则将路径加入答案
            if path:
                ans.append(path[:])

            for folder, child in node.children.items():
                path.append(folder)
                operate(child)
                path.pop()

        operate(root)
        return ans

###Java

class Solution {
    class Trie {
        String serial; // 当前节点结构的序列化表示
        Map<String, Trie> children = new HashMap<>(); // 当前节点的子节点
    }

    public List<List<String>> deleteDuplicateFolder(List<List<String>> paths) {
        Trie root = new Trie(); // 根节点

        // 构建字典树
        for (List<String> path : paths) {
            Trie cur = root;
            for (String node : path) {
                cur.children.putIfAbsent(node, new Trie());
                cur = cur.children.get(node);
            }
        }

        Map<String, Integer> freq = new HashMap<>(); // 哈希表记录每一种序列化表示的出现次数
        // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        construct(root, freq);
        List<List<String>> ans = new ArrayList<>();
        List<String> path = new ArrayList<>();
        // 操作字典树,删除重复文件夹
        operate(root, freq, path, ans);
        return ans;
    }

    private void construct(Trie node, Map<String, Integer> freq) {
        if (node.children.isEmpty()) return; // 如果是叶节点,无需操作

        List<String> v = new ArrayList<>();
        for (Map.Entry<String, Trie> entry : node.children.entrySet()) {
            construct(entry.getValue(), freq);
            v.add(entry.getKey() + "(" + entry.getValue().serial + ")");
        }

        Collections.sort(v);
        StringBuilder sb = new StringBuilder();
        for (String s : v) {
            sb.append(s);
        }
        node.serial = sb.toString();
        freq.put(node.serial, freq.getOrDefault(node.serial, 0) + 1);
    }

    private void operate(Trie node, Map<String, Integer> freq, List<String> path, List<List<String>> ans) {
        if (freq.getOrDefault(node.serial, 0) > 1) return; // 如果序列化表示出现超过1次,需要删除

        if (!path.isEmpty()) {
            ans.add(new ArrayList<>(path));
        }

        for (Map.Entry<String, Trie> entry : node.children.entrySet()) {
            path.add(entry.getKey());
            operate(entry.getValue(), freq, path, ans);
            path.remove(path.size() - 1);
        }
    }
}

###C#

public class Solution {
    class Trie {
        public string Serial { get; set; } = ""; // 当前节点结构的序列化表示
        public Dictionary<string, Trie> Children { get; } = new Dictionary<string, Trie>(); // 当前节点的子节点
    }

    public IList<IList<string>> DeleteDuplicateFolder(IList<IList<string>> paths) {
        // 根节点
        Trie root = new Trie();
        // 构建字典树
        foreach (var p in paths) {
            Trie current = root;
            foreach (var node in p) {
                if (!current.Children.ContainsKey(node)) {
                    current.Children[node] = new Trie();
                }
                current = current.Children[node];
            }
        }

        // 哈希表记录每一种序列化表示的出现次数
        var freq = new Dictionary<string, int>();
        
        // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        void Construct(Trie node) {
            // 如果是叶节点,那么序列化表示为空字符串,无需进行任何操作
            if (node.Children.Count == 0) {
                return;
            }
            var v = new List<string>();
            // 如果不是叶节点,需要先计算子节点结构的序列化表示
            foreach (var entry in node.Children) {
                Construct(entry.Value);
                v.Add($"{entry.Key}({entry.Value.Serial})");
            }
            // 防止顺序的问题,需要进行排序
            v.Sort();
            node.Serial = string.Join("", v);
            // 计入哈希表
            if (!freq.ContainsKey(node.Serial)) {
                freq[node.Serial] = 0;
            }
            freq[node.Serial]++;
        }

        Construct(root);
        var ans = new List<IList<string>>();
        // 记录根节点到当前节点的路径
        var path = new List<string>();

        void Operate(Trie node) {
            // 如果序列化表示在哈希表中出现了超过 1 次,就需要删除
            if (freq.TryGetValue(node.Serial, out int count) && count > 1) {
                return;
            }
            // 否则将路径加入答案
            if (path.Count > 0) {
                ans.Add(new List<string>(path));
            }

            foreach (var entry in node.Children) {
                path.Add(entry.Key);
                Operate(entry.Value);
                path.RemoveAt(path.Count - 1);
            }
        }

        Operate(root);
        return ans;
    }
}

###Go

type Trie struct {
serial   string             // 当前节点结构的序列化表示
children map[string]*Trie   // 当前节点的子节点
}

func deleteDuplicateFolder(paths [][]string) [][]string {
root := &Trie{children: make(map[string]*Trie)} // 根节点
// 构建字典树
for _, path := range paths {
cur := root
for _, node := range path {
if _, ok := cur.children[node]; !ok {
cur.children[node] = &Trie{children: make(map[string]*Trie)}
}
cur = cur.children[node]
}
}

freq := make(map[string]int) // 哈希表记录每一种序列化表示的出现次数
// 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
var construct func(*Trie)
construct = func(node *Trie) {
if len(node.children) == 0 {
return // 如果是叶节点,无需操作
}
v := make([]string, 0, len(node.children))
for folder, child := range node.children {
construct(child)
v = append(v, folder + "(" + child.serial + ")")
}
sort.Strings(v)
node.serial = strings.Join(v, "")
freq[node.serial]++
}
construct(root)
ans := make([][]string, 0)
path := make([]string, 0)
// 操作字典树,删除重复文件夹
var operate func(*Trie)
operate = func(node *Trie) {
if freq[node.serial] > 1 {
return // 如果序列化表示出现超过1次,需要删除
}

if len(path) > 0 {
tmp := make([]string, len(path))
copy(tmp, path)
ans = append(ans, tmp)
}

for folder, child := range node.children {
path = append(path, folder)
operate(child)
path = path[:len(path) - 1]
}
}
operate(root)

return ans
}

###JavaScript

var deleteDuplicateFolder = function(paths) {
    class Trie {
        constructor() {
            this.serial = ""; // 当前节点结构的序列化表示
            this.children = new Map(); // 当前节点的子节点
        }
    }

    const root = new Trie(); // 根节点
    // 构建字典树
    for (const path of paths) {
        let cur = root;
        for (const node of path) {
            if (!cur.children.has(node)) {
                cur.children.set(node, new Trie());
            }
            cur = cur.children.get(node);
        }
    }

    const freq = new Map(); // 哈希表记录每一种序列化表示的出现次数
    // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
    function construct(node) {
        if (node.children.size === 0) return; // 如果是叶节点,无需操作
        const v = [];
        for (const [folder, child] of node.children) {
            construct(child);
            v.push(`${folder}(${child.serial})`);
        }
        v.sort();
        node.serial = v.join("");
        freq.set(node.serial, (freq.get(node.serial) || 0) + 1);
    }
    construct(root);

    const ans = [];
    const path = [];
    // 操作字典树,删除重复文件夹
    function operate(node) {
        if ((freq.get(node.serial) || 0) > 1) return; // 如果序列化表示出现超过1次,需要删除
        if (path.length > 0) {
            ans.push([...path]);
        }
        for (const [folder, child] of node.children) {
            path.push(folder);
            operate(child);
            path.pop();
        }
    }
    operate(root);

    return ans;
}

###TypeScript

function deleteDuplicateFolder(paths: string[][]): string[][] {
    class Trie {
        serial: string = ""; // 当前节点结构的序列化表示
        children: Map<string, Trie> = new Map(); // 当前节点的子节点
    }

    const root = new Trie(); // 根节点

    // 构建字典树
    for (const path of paths) {
        let cur = root;
        for (const node of path) {
            if (!cur.children.has(node)) {
                cur.children.set(node, new Trie());
            }
            cur = cur.children.get(node)!;
        }
    }

    const freq = new Map<string, number>(); // 哈希表记录每一种序列化表示的出现次数
    // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
    function construct(node: Trie) {
        if (node.children.size === 0) return; // 如果是叶节点,无需操作

        const v: string[] = [];
        for (const [folder, child] of node.children) {
            construct(child);
            v.push(`${folder}(${child.serial})`);
        }

        v.sort();
        node.serial = v.join("");
        freq.set(node.serial, (freq.get(node.serial) || 0) + 1);
    }
    construct(root);
    const ans: string[][] = [];
    const path: string[] = [];

    // 操作字典树,删除重复文件夹
    function operate(node: Trie) {
        if ((freq.get(node.serial) || 0) > 1) return; // 如果序列化表示出现超过1次,需要删除

        if (path.length > 0) {
            ans.push([...path]);
        }

        for (const [folder, child] of node.children) {
            path.push(folder);
            operate(child);
            path.pop();
        }
    }
    operate(root);
    return ans;
}

###Rust

use std::collections::HashMap;

#[derive(Default)]
struct Trie {
    serial: String, // 当前节点结构的序列化表示
    children: HashMap<String, Trie>, // 当前节点的子节点
}

impl Solution {
    pub fn delete_duplicate_folder(paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
        let mut root = Trie::default(); // 根节点
        // 构建字典树
        for path in paths {
            let mut cur = &mut root;
            for node in path {
                cur = cur.children.entry(node.clone()).or_default();
            }
        }

        let mut freq = HashMap::new(); // 哈希表记录每一种序列化表示的出现次数

        // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        fn construct(node: &mut Trie, freq: &mut HashMap<String, usize>) {
            if node.children.is_empty() {
                return; // 如果是叶节点,无需操作
            }

            let mut v = Vec::new();
            for (folder, child) in node.children.iter_mut() {
                construct(child, freq);
                v.push(format!("{}({})", folder, child.serial));
            }

            v.sort();
            node.serial = v.join("");
            *freq.entry(node.serial.clone()).or_default() += 1;
        }
        construct(&mut root, &mut freq);
        let mut ans = Vec::new();
        let mut path = Vec::new();

        // 操作字典树,删除重复文件夹
        fn operate(node: &Trie, freq: &HashMap<String, usize>, path: &mut Vec<String>, ans: &mut Vec<Vec<String>>) {
            if freq.get(&node.serial).unwrap_or(&0) > &1 {
                return; // 如果序列化表示出现超过1次,需要删除
            }

            if !path.is_empty() {
                ans.push(path.clone());
            }

            for (folder, child) in &node.children {
                path.push(folder.clone());
                operate(child, freq, path, ans);
                path.pop();
            }
        }
        operate(&root, &freq, &mut path, &mut ans);

        ans
    }
}

复杂度分析

这里我们只考虑计算所有节点结构的序列化表示需要的时间,以及哈希映射需要使用的空间。对于其它的项(无论是时间项还是空间项),它们在渐近意义下一定都小于计算以及存储序列化表示的部分,因此可以忽略。

在最坏情况下,节点结构的序列化表示的字符串两两不同,那么需要的时间和空间级别均为「所有节点结构的序列化表示的字符串的长度之和」。如何求出这个长度之和的上界呢?

这里我们需要用到一个很重要的结论:

设 $T$ 为无权多叉树。对于 $T$ 中的节点 $x$,记 $\textit{dist}[x]$ 为从根节点到 $x$ 经过的节点个数,$\textit{size}[x]$ 为以 $x$ 为根的子树的大小,那么有:

$$
\sum_{x \in T} \textit{dist}[x] = \sum_{x \in T} \textit{size}[x]
$$

证明也较为直观。对于任意的节点 $x'$,在右侧的 $\sum_{x \in T} \textit{size}[x]$ 中,$x'$ 被包含在 $\textit{size}[x]$ 中的次数就等于 $x'$ 的祖先节点的数目(也包括 $x'$ 本身),其等于根节点到 $x'$ 的经过的节点个数,因此得证。

回到本题,$\textit{path}$ 中给出了根节点到所有节点的路径,其中最多包含 $2\times 10^5$ 个字符,那么 $\sum_{x \in T} \textit{dist}[x]$ 不超过 $2\times 10^5$,$\sum_{x \in T} \textit{size}[x]$ 同样也不超过 $2\times 10^5$。

对于任意的节点 $x$,$x$ 结构的序列化表示的字符串长度包含两部分,第一部分为其中所有子文件夹名的长度之和,其不超过 $10 \cdot \textit{size}[x]$,第二部分为额外添加的用来区分的括号,由于一个子文件夹会恰好被添加一对括号,因此其不超过 $2 \cdot \textit{size}[x]$。这样一来,「所有节点结构的序列化表示的字符串的长度之和」的上界为:

$$
12 \cdot \sum_{x \in T} \textit{size}[x] = 2.4 \times 10^6
$$

即空间的数量级为 $10^6$。而对于时间,即使算上排序的额外 $\log$ 的时间复杂度,也在 $10^7$ 的数量级,可以在规定的时间内通过本题。并且需要指出的是,在上述估算上界的过程中,我们作出的许多假设是非常极端的,因此实际上该方法的运行时间很快。

括号表示法 + 字典树(Python/Java/C++/Go)

作者 endlesscheng
2021年7月25日 12:06

核心思路:把相同的子树映射为相同的字符串,就能用哈希表去重了。

如何把子树转化成字符串?为了准确判断两棵子树的结构是否相同,需要做到两点:

  1. 字符串需要包含子树所有节点的文件夹名
  2. 字符串要能够表达节点的父子关系

考察树的递归过程,把向下「递」的动作用一个非字母字符表示,向上「归」的动作用另一个非字母字符表示,就可以描述一棵树的形状了(用非字母字符是为了与文件夹名区分开)。比如说,用左括号表示递,用右括号表示归。从节点 $\texttt{x}$ 向下递到节点 $\texttt{y}$,再归回 $\texttt{x}$,就可以表示为 $\texttt{x(y)}$。如果 $\texttt{x}$ 有两个儿子 $\texttt{y}$ 和 $\texttt{z}$(并且这两个儿子都是叶子),那么子树 $\texttt{x}$ 就可以表示为 $\texttt{x(y)(z)}$。

一般地,定义如下括号表达式:

  • 对于叶子节点,设其文件夹名为 $S$,则其括号表达式就是 $S$。
  • 对于任意子树 $x$,设 $x$ 的儿子为 $y_1,y_2,\ldots,y_k$,则子树 $x$ 的括号表达式为
    $$
    x 的文件夹名 + (子树 y_1 的表达式) + (子树 y_2 的表达式) + \cdots + (子树 y_k 的表达式)
    $$

image.png{:width=300}

看示例 4,子树 $\texttt{x}$ 的括号表达式为 $\texttt{x(y)}$,子树 $\texttt{a}$ 的括号表达式为 $\texttt{a(x(y))(z)}$,子树 $\texttt{b}$ 的括号表达式为 $\texttt{b(x(y))(z)}$。

根据题意,我们不关心子树根节点的文件夹名,在去掉子树根节点后,子树 $\texttt{a}$ 和子树 $\texttt{b}$ 的括号表达式都是 $\texttt{(x(y))(z)}$,所以这两个文件夹「包含非空且相同的子文件夹集合,并具有相同的子文件夹结构」。

括号表达式既包含了文件夹名,又通过括号的嵌套关系表达了父子关系,因此可用于判断两个文件夹是否为相同文件夹。

你可能会问:如果子树 $\texttt{b}$ 的两棵子树是 $\texttt{z}$ 在左,$\texttt{x(y)}$ 在右呢?得到的表达式为 $\texttt{(z)(x(y))}$,这样没法判断两棵子树相同呀?

解决办法:在构造括号表达式时,先把子树 $y_1,y_2,\ldots,y_k$ 的表达式按照字典序排序,再把表达式依次拼接,就可以避免出现上述情况了。

代码实现时,用 字典树 表示这个文件系统,节点保存文件夹名称。注意这与一般的字典树不同,不是二十六叉树那种用单个字母对应节点,而是用一整个字符串(文件夹名)对应节点。这棵字典树一个节点(文件夹)最多能有 $20000$ 个儿子(子文件夹)。

用 $\textit{paths}$ 构建完字典树后,DFS 这棵树,按照前文的规则生成括号表达式 $\textit{subTreeExpr}$:

  • 如果首次遇到 $\textit{subTreeExpr}$,那么把 $\textit{subTreeExpr}$ 及其对应的子树根节点保存到哈希表中。
  • 否则我们找到了重复的文件夹,把哈希表中 $\textit{subTreeExpr}$ 对应的节点,以及当前节点,都标记为待删除。

最后,再次 DFS(回溯)这棵字典树,仅访问未被删除的节点,同时用一个列表 $\textit{path}$ 记录路径上的文件夹名。每次递归到一个节点,就把 $\textit{path}$ 的一个拷贝加到答案中。做法类似 257. 二叉树的所有路径

class TrieNode:
    __slots__ = 'son', 'name', 'deleted'

    def __init__(self):
        self.son = {}
        self.name = ''  # 文件夹名称
        self.deleted = False  # 删除标记


class Solution:
    def deleteDuplicateFolder(self, paths: List[List[str]]) -> List[List[str]]:
        root = TrieNode()
        for path in paths:
            # 把 path 插到字典树中,见 208. 实现 Trie
            cur = root
            for s in path:
                if s not in cur.son:
                    cur.son[s] = TrieNode()
                cur = cur.son[s]
                cur.name = s

        expr_to_node = {}  # 子树括号表达式 -> 子树根节点

        def gen_expr(node: TrieNode) -> str:
            if not node.son:  # 叶子
                return node.name  # 表达式就是文件夹名

            # 每个子树的表达式外面套一层括号
            expr = sorted('(' + gen_expr(son) + ')' for son in node.son.values())
            sub_tree_expr = ''.join(expr)  # 按字典序拼接所有子树的表达式
            if sub_tree_expr in expr_to_node:  # 哈希表中有 sub_tree_expr,说明有重复的文件夹
                expr_to_node[sub_tree_expr].deleted = True  # 哈希表中记录的节点标记为删除
                node.deleted = True  # 当前节点标记为删除
            else:
                expr_to_node[sub_tree_expr] = node

            return node.name + sub_tree_expr

        for son in root.son.values():
            gen_expr(son)

        ans = []
        path = []

        # 在字典树上回溯,仅访问未被删除的节点,并将路径记录到答案中
        # 类似 257. 二叉树的所有路径
        def dfs(node: TrieNode) -> None:
            if node.deleted:
                return
            path.append(node.name)
            ans.append(path.copy())  # path[:]
            for child in node.son.values():
                dfs(child)
            path.pop()  # 恢复现场

        for son in root.son.values():
            dfs(son)

        return ans
class Solution {
    private static class TrieNode {
        Map<String, TrieNode> son = new HashMap<>();
        String name; // 文件夹名称
        boolean deleted = false; // 删除标记
    }

    public List<List<String>> deleteDuplicateFolder(List<List<String>> paths) {
        TrieNode root = new TrieNode();
        for (List<String> path : paths) {
            // 把 path 插到字典树中,见 208. 实现 Trie
            TrieNode cur = root;
            for (String s : path) {
                if (!cur.son.containsKey(s)) {
                    cur.son.put(s, new TrieNode());
                }
                cur = cur.son.get(s);
                cur.name = s;
            }
        }

        Map<String, TrieNode> exprToNode = new HashMap<>(); // 子树括号表达式 -> 子树根节点
        for (TrieNode son : root.son.values()) {
            genExpr(son, exprToNode);
        }

        List<List<String>> ans = new ArrayList<>();
        List<String> path = new ArrayList<>();
        for (TrieNode son : root.son.values()) {
            dfs(son, path, ans);
        }
        return ans;
    }

    private String genExpr(TrieNode node, Map<String, TrieNode> exprToNode) {
        if (node.son.isEmpty()) { // 叶子
            return node.name; // 表达式就是文件夹名
        }

        List<String> expr = new ArrayList<>();
        for (TrieNode son : node.son.values()) {
            // 每个子树的表达式外面套一层括号
            expr.add("(" + genExpr(son, exprToNode) + ")");
        }
        Collections.sort(expr);

        String subTreeExpr = String.join("", expr); // 按字典序拼接所有子树的表达式
        TrieNode n = exprToNode.get(subTreeExpr);
        if (n != null) { // 哈希表中有 subTreeExpr,说明有重复的文件夹
            n.deleted = true; // 哈希表中记录的节点标记为删除
            node.deleted = true; // 当前节点标记为删除
        } else {
            exprToNode.put(subTreeExpr, node);
        }

        return node.name + subTreeExpr;
    }

    // 在字典树上回溯,仅访问未被删除的节点,并将路径记录到答案中
    // 类似 257. 二叉树的所有路径
    private void dfs(TrieNode node, List<String> path, List<List<String>> ans) {
        if (node.deleted) {
            return;
        }
        path.add(node.name);
        ans.add(new ArrayList<>(path)); // 记录路径
        for (TrieNode son : node.son.values()) {
            dfs(son, path, ans);
        }
        path.removeLast(); // 恢复现场
    }
}
struct TrieNode {
    unordered_map<string, TrieNode*> son;
    string name; // 文件夹名称
    bool deleted = false; // 删除标记
};

class Solution {
public:
    vector<vector<string>> deleteDuplicateFolder(vector<vector<string>>& paths) {
        TrieNode* root = new TrieNode();
        for (auto& path : paths) {
            // 把 path 插到字典树中,见 208. 实现 Trie
            TrieNode* cur = root;
            for (auto& s : path) {
                if (!cur->son.contains(s)) {
                    cur->son[s] = new TrieNode();
                }
                cur = cur->son[s];
                cur->name = s;
            }
        }

        unordered_map<string, TrieNode*> expr_to_node; // 子树括号表达式 -> 子树根节点

        auto gen_expr = [&](this auto&& gen_expr, TrieNode* node) -> string {
            if (node->son.empty()) { // 叶子
                return node->name; // 表达式就是文件夹名
            }

            vector<string> expr;
            for (auto& [_, son] : node->son) {
                // 每个子树的表达式外面套一层括号
                expr.emplace_back("(" + gen_expr(son) + ")");
            }
            ranges::sort(expr);

            string sub_tree_expr;
            for (auto& e : expr) {
                sub_tree_expr += e; // 按字典序拼接所有子树的表达式
            }

            if (expr_to_node.contains(sub_tree_expr)) { // 哈希表中有 sub_tree_expr,说明有重复的文件夹
                expr_to_node[sub_tree_expr]->deleted = true; // 哈希表中记录的节点标记为删除
                node->deleted = true; // 当前节点标记为删除
            } else {
                expr_to_node[sub_tree_expr] = node;
            }

            return node->name + sub_tree_expr;
        };

        for (auto& [_, son] : root->son) {
            gen_expr(son);
        }

        vector<vector<string>> ans;
        vector<string> path;

        // 在字典树上回溯,仅访问未被删除的节点,并将路径记录到答案中
        // 类似 257. 二叉树的所有路径
        auto dfs = [&](this auto&& dfs, TrieNode* node) -> void {
            if (node->deleted) {
                return;
            }
            path.push_back(node->name);
            ans.push_back(path);
            for (auto& [_, son] : node->son) {
                dfs(son);
            }
            path.pop_back(); // 恢复现场
        };

        for (auto& [_, son] : root->son) {
            dfs(son);
        }

        return ans;
    }
};
type trieNode struct {
son     map[string]*trieNode
name    string // 文件夹名称
deleted bool   // 删除标记
}

func deleteDuplicateFolder(paths [][]string) (ans [][]string) {
root := &trieNode{}
for _, path := range paths {
// 把 path 插到字典树中,见 208. 实现 Trie
cur := root
for _, s := range path {
if cur.son == nil {
cur.son = map[string]*trieNode{}
}
if cur.son[s] == nil {
cur.son[s] = &trieNode{}
}
cur = cur.son[s]
cur.name = s
}
}

exprToNode := map[string]*trieNode{} // 子树括号表达式 -> 子树根节点
var genExpr func(*trieNode) string
genExpr = func(node *trieNode) string {
if node.son == nil { // 叶子
return node.name // 表达式就是文件夹名
}

expr := make([]string, 0, len(node.son)) // 预分配空间
for _, son := range node.son {
// 每个子树的表达式外面套一层括号
expr = append(expr, "("+genExpr(son)+")")
}
slices.Sort(expr)

subTreeExpr := strings.Join(expr, "") // 按字典序拼接所有子树的表达式
n := exprToNode[subTreeExpr]
if n != nil { // 哈希表中有 subTreeExpr,说明有重复的文件夹
n.deleted = true    // 哈希表中记录的节点标记为删除
node.deleted = true // 当前节点标记为删除
} else {
exprToNode[subTreeExpr] = node
}

return node.name + subTreeExpr
}
for _, son := range root.son {
genExpr(son)
}

// 在字典树上回溯,仅访问未被删除的节点,并将路径记录到答案中
// 类似 257. 二叉树的所有路径
path := []string{}
var dfs func(*trieNode)
dfs = func(node *trieNode) {
if node.deleted {
return
}
path = append(path, node.name)
ans = append(ans, slices.Clone(path))
for _, son := range node.son {
dfs(son)
}
path = path[:len(path)-1] // 恢复现场
}
for _, son := range root.son {
dfs(son)
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(\ell\cdot m\log m)$,其中 $m$ 是字符串的总个数,$\ell\le 10$ 是单个字符串的长度,题目保证 $\ell\cdot m\le 2\cdot 10^5$。瓶颈在排序上。字符串拼接的复杂度是 $\mathcal{O}(\ell\cdot m)$。
    • 排序:最坏情况下一个节点有 $\mathcal{O}(m)$ 个儿子,我们会对 $\mathcal{O}(m)$ 个字符串排序。会发生 $\mathcal{O}(m\log m)$ 次字符串比较,每次 $\mathcal{O}(\ell)$ 时间,所以排序的时间复杂度为 $\mathcal{O}(\ell\cdot m\log m)$。
    • 字符串拼接:可能读者会认为当树退化成链时,代码会跑到 $\mathcal{O}((\ell\cdot m)^2)$ 时间,但这是不可能的。注意题目的这句话:「对于不在根层级的任意文件夹,其父文件夹也会包含在输入中。」这意味着如果一棵树的高度是 $d$,那么至少要 $1+2+3+\cdots+d = \mathcal{O}(d^2)$ 个字符串才能生成这棵树(可以参考示例 2),所以树的高度只有 $\mathcal{O}(\sqrt m)$。相应地,代码中的 node.name + subTreeExpr 会对长为 $\mathcal{O}(\ell\sqrt m)$ 的字符串复制拼接 $\mathcal{O}(\sqrt m)$ 次,所以时间复杂度为 $\mathcal{O}(\ell(\sqrt m)^2) = \mathcal{O}(\ell\cdot m)$。
  • 空间复杂度:$\mathcal{O}(\ell\cdot m)$。

专题训练

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

昨天 — 2025年7月19日首页

万科组织架构大调整:撤销所有区域公司

2025年7月19日 19:04

据传,万科将会对公司组织架构进行新一轮大变革,撤销所有区域公司,变成总部--片区总公司两级管控。

36氪获悉,万科集团内部尚未下发调整组织架构的正式文件,但思路已基本敲定,变化只是时间问题。

目前万科地产开发业务线下设5大区域公司、2个总部直管的公司、2个总公司:

  • 5大区域公司:北京、华东、华中、南方、西南
  • 2个总部直管的城市公司:广佛、上海
  • 2个总公司:东北、西北

未来,北京、华东、华中、南方、西南这5个区域公司将会被撤销,降级为与东北、西北相同的片区总公司,原先在区域平台上的部分职能会被收回到集团本部,它们下辖的城市公司也可能会继续合并精简。

万科“5+2+2”的组织架构是2024年10月落地的,目的是为了缩减区域公司数量,减少管理层级,集团集约管理,下沉到业务前线。

2024年,万科经历的两轮组织架构调整都是按上述方向进行的。3月,对南方区域城市公司进行合并,把广东、福建、海南、广西的十多个城市公司合并为8个;10月,将东北区域、西北区域降级为片区总公司,原来的7个区域公司缩减为5个,同时集团层面成立开发经营本部,直接接管上海、广佛公司,进一步精简区域平台的数量和职能。

据悉,2025年1月底,万科董事会与集团核心管理层大换血,郁亮、祝九胜、朱旭集体辞任,来自大股东深铁的辛杰接任董事会主席之后不久,万科已经在酝酿新一轮的组织架构调整,原计划是在上半年调整完毕,但因为一些原因出现了推迟。

万科本次大调整其实并不意外。一方面,包括华润、招商等在内的主流房企目前都在缩减管理层级。

在原先的地产扩张期,设立区域平台是为了在投资、营销等方面放权,激励各区域“跑得快”,进入市场缩量期之后,过去激进的做法已经不再适用,区域平台的各种权限被收回,职级上又要履行管理职责,就出现了各种“乱管”和权责不对等的现象,调整是早晚都要走的一步。

另一方面,自2021年开始,各大房企一直在“瘦身”、换节奏,从原先的扩张、向低线城市下沉,转换到了收缩、深耕重点城市,伴随着老项目开发完成,房企实际布局的城市、项目数量都在缩减,公司管理架构也要随之精简。

伴随着万科组织架构大调整箭在弦上,万科区域平台上的高管变数也在增加,最近再有两位老员工离职。

6月,万科华中区域BG合伙人王博群为在自己万科的16年职业生涯画上句号。近日,市场传闻,原上海万科事业部总经理于佳兴离职,加盟华润置地。

地产大变局下,公司和人都要跟着变。

地产行业下行带来的压力,地产人体会最深。未来会如何?身在这艘大船上的人谁都无法给出定论,唯有奋力掌舵划桨,迎击海浪,同时也借助海与浪的力量继续下一航。

5家消费公司拿到新钱;抖音否认做外卖;KKR拟收购大窑汽水85%股权|创投大视野

2025年7月19日 17:57

整理|兰杰

Busy Money

“橘帝堂”完成千万级天使轮融资

36氪获悉,近日,广东省橘帝堂健康管理股份有限公司宣布完成1000万元天使轮融资。本轮融资将主要用于深化互联网医院平台建设、拓展健康产品供应链及推动“橘帝堂”品牌连锁门店的规模化发展。

橘帝堂成立于2024年11月,是一家专注于健康食品供应链的企业,主要从事药品、保健品及健康食品的研发、管理、生产、销售等业务。

纯粒NFC鲜榨玉米汁所属企业完成A轮融资

据IT桔子消息,近日,以功能性玉米饮品研发为导向的玉米科技有限公司宣布完成A轮融资,将进一步推动鲜榨玉米汁产品的技术创新与市场拓展。作为主打健康概念的饮品,纯粒NFC玉米汁以“非浓缩还原”工艺为亮点,保留玉米天然风味与营养成分,契合当下消费者对健康饮食的需求趋势。

“枫蓝咖啡”获得5000万融资

近日,成都枫蓝时代餐饮管理有限公司旗下品牌“枫蓝咖啡”完成A轮5000万融资。此次融资投资方未透露,获投资金将重点用于三个方向:一是深化供应链建设,与全球优质产区建立更紧密的直采合作;二是加速门店拓展;三是投入产品研发,探索咖啡与多元风味的融合,推出更具创意的饮品与周边。

灵境AI完成数千万元天使轮融资

36氪获悉,“AI动漫工业化生产平台”——灵境万维(杭州)智能科技有限公司今日宣布完成数千万元天使轮融资,由柏睿资本、零以创投投资,高鹄资本担任独家财务顾问。本轮资金将用于加速多模态生成架构研发,夯实AI动漫领域的技术壁垒。

“AR四小龙”影目科技完成1.5亿元B+融资

7月11日,影目科技宣布完成超1.5亿元B+轮融资,由普华资本、梁溪产发集团、神骐资本联合投资。本轮资金将主要用于下一代产品研发、AI核心能力建设、供应链纵深及线下场景拓展。

影目科技成立于2020年,核心业务是研发消费级AR眼镜,是国内较早实现“无线消费级AR眼镜”量产的厂商。此外,公司预计将在2025年下半年发布一款全新战略级智能眼镜产品,延续“轻量化+AI融合”方向。

公司情报

饮料品牌大窑股权案再获新进展

据红星资本局报道,大窑汽水被美国私募股权公司KKR收购85%股权案已过公示期。

了解该交易的人士表示,目前交易正在进行中,交割将分多次进行,预计今年内启动。收购完成后,大窑汽水的下一步除了全国化可能还包括国际化。

大窑方面则表示,目前经营团队稳定,公司业务有序开展,全国化及年轻化战略不会有任何变化。

此前36氪曾独家报道过,美国私募股权机构KKR拟收购大窑汽水85%股权,交易谈了一年,此外2024年,大窑营收在数十亿元左右。

大窑凭借在餐饮赛道的切入,成功横扫了全国各地餐饮门店的饮料柜——官网资料显示,大窑旗下的全国经销商已超千家,百万家零售终端,遍布31个省、自治区、直辖市。

而此次KKR出手抄底,或将进一步助推大窑的全国化进程。

抖音否认做外卖

外卖大战愈演愈烈之际,抖音也进入了大众视野。

36氪获悉,针对抖音上线团购版外卖的消息,抖音方面回应称,抖音生活服务聚焦在到店业务上,没有自建外卖的打算。

只是相较于已经有强大配送网络的美团和饿了么,抖音的外卖业务不具备规模优势,进展也不顺利。

早在2021年,抖音本地生活业务独立成为一级部门时,同年7月便内测了“心动外卖”,但随后不久便暂停下线。

2023年,抖音外卖业务全年GMV目标从1000亿元大幅下调到50亿元。2024年,抖音外卖业务多次调整,进一步拖慢了抖音外卖业务的发展进程。

今年以来,抖音外卖更多聚焦在去年10月由原团购配送业务升级的“随心团”业务。从目前情况来看,这一业务更多的是为了满足较为成熟的本地生活商家所需的团购到家配送需求。

回顾抖音做外卖的经历,其始终未自建配送系统,而是选择与第三方进行合作,这符合字节一以贯之的“短平快”打法,但却难以和美团、饿了么这种拥有百万骑手的庞然大物正面竞争。

字节跳动否认甲骨文等将收购TikTok美国业务

北京时间7月8日,有消息称字节跳动已同意将TikTok美国业务出售给由甲骨文牵头的美国财团,自身保留少数股权。对此,字节跳动方面表示,该信息不实。

关于TikTok出售的相关问题,仍需中美两国政府批准,目前美国政府设定的最终截止日期为9月17日。

特朗普政府一直在推动TikTok的出售,且多次延期TikTok封禁的最后期限。大国博弈之下,美区TikTok的命运摇摆不定,在最终结果之前,TikTok将面临的或是无限期的延期。

另据The Information报道,知情人士称,TikTok正在为美国用户开发一个新版应用,内部代号为“M2”(当前的 TikTok 被称为“M”),将于9月5日在美国应用商店上线。

TikTok为了分摊美国市场的风险,正在加大力度拓展拉美、东南亚在内的其他市场,同时,TikTok也放不下潜力最大的美国市场。

小红书App启用新品牌口号“你的生活兴趣社区”

相较于小红书原来的slogan“你的生活指南”,此次变动体现在兴趣和社区之上。

小红书官方给出的解释是——兴趣,从不是件「小事」。它能带我们跨越时间、地域与语言的边界,找到共鸣,建立连接。

这对于已经习惯在小红书上做“情感判官”和“搞抽象”的用户们来讲,是情理之中的变动。小红书不想也不是用户们在需要攻略时才会打开的平台,越来越多的人们习惯在平台上分享自己的日常。

而让更多的用户凭借自己的兴趣凝聚在平台之中,或许也是小红书谋求进一步增长的重要锚点。

未来Idea

小红书官宣打造“RED LAND”

小红书再次加码二次元赛道。

7月16日,小红书正式官宣,将打造全球首个开放世界冒险岛活动“RED LAND”,并于8月8日-8月10日落地上海杨浦复兴岛。这也是小红书首个为游戏、二次元爱好者们打造的超大型线下IP。

此次活动包含了《王者荣耀》《和平精英》《蛋仔派对》《永劫无间》《非人哉》《全职高手》《少女乐队的呐喊》《航海王》等知名IP,覆盖了游戏、动漫等细分赛道,阵容强悍。

平台用户们可以通过答题积攒经验值的方式换取登岛资格。另据《游戏茶馆》报道,此次活动还会针对传统零售和快消品牌招商。

小红书加码二次元赛道已经不是新鲜事,此次大型“漫展”无疑会进一步强化平台的二次元心智——二次元和游戏的相关内容在小红书内呈快速发展趋势,过去一年笔记发布量在所有品类中排在第三和第四,同比增长分别达到175%、168%。

另一方面,推动兴趣连接的圈层文化进一步变现,也是当下小红书的商业化战略。

甜啦啦联名巨人网络旗下游戏IP《球球大作战》,推出活力果蔬茶系列

此次联名产品以健康和低价为主要特征。一方面,“活力果蔬茶系列”采用了羽衣甘蓝、胡萝卜、黄番茄三大食材,均是健康饮品赛道中的常用原料。另一方面,此次系列产品的定价在8元每杯,仍旧延续了品牌的低价路线。

与游戏、影视、动漫以及潮玩IP联名,已经是茶饮界屡见不鲜的营销套路。果蔬茶更是各大茶饮品牌今年以来的竞争点。甜啦啦的加入,并不让人意外。

数读消费

2025中国网络零售Top100中,有25家消费品企业实现双位数增长

36氪获悉,7月16日,中国连锁经营协会(CCFA)联合德勤中国发布“2025年中国网络零售TOP100”及细分Top。数据显示,入选企业网络销售总额达2.17万亿元,同比增长13.6%。其中,60家企业实现网络销售正增长,其中40家同比增速达两位数。分类型看,电商企业中5家、实体零售企业中10家、消费品企业中25家实现两位数增长。

暑期档期已过半,电影总票房达35亿元

截至7月16日,暑期档已过半程。灯塔专业版数据显示,截至7月17日16时55分,2025年暑期档(6月-8月)总票房(含预售)突破35亿。相较于热闹的短剧和小游戏市场,电影行业整体表现不温不火。

6月33个中国游戏厂商合计吸金17.6亿美元

36氪获悉,Sensor Tower商店情报平台显示,2025年6月共33个中国厂商入围全球手游发行商收入榜TOP100,合计吸金17.6亿美元,占同期全球TOP100手游发行商收入33%。其中,点点互动6月收入上涨10%,在一众强势厂商中继续稳居中国手游发行商全球收入榜第2名,超过了网易和米哈游。

外卖大战带动餐饮品牌和即时零售品牌销量

近日,在大消费场景带动下,淘宝平台上总计4124个餐饮品牌生意突票房(含预售)破历史峰值,同时有2318个非餐饮细分品类订单量翻倍,整体非餐饮订单相较上线初期增长143%。此次增长受到了淘宝闪购启动的500亿元补贴计划的带动。

上半年四川进出口创历史同期新高,规模首次突破5000亿元

2025年7月19日 17:54
36氪获悉,据成都海关统计,今年上半年,四川外贸进出口5190.9亿元,规模位列全国第8,同比(下同)增长6.3%。其中,出口3156.2亿元,增长8.2%;进口2034.7亿元,增长3.4%。进出口规模创历史同期新高。今年第一、第二季度,四川进出口规模逐季抬升,分别达2570.5亿元、2620.4亿元,分别增长7.2%、5.3%,上半年首次在历史同期突破5000亿元,出口、进口规模均创历史同期新高。

辽宁:2025年上半年全省地区生产总值15707.9亿元,同比增长4.7%

2025年7月19日 17:38
36氪获悉,据辽宁统计,根据地区生产总值统一核算结果,2025年上半年,全省地区生产总值15707.9亿元,按不变价格计算,同比增长4.7%。其中,第一产业增加值964.3亿元,同比增长4.3%;第二产业增加值5438.4亿元,同比增长3.5%;第三产业增加值9305.2亿元,同比增长5.3%。

中伟股份:固态电池前驱体绝大部分使用的是高镍及超高镍材料

2025年7月19日 17:27
36氪获悉,中伟股份在互动平台表示,在固态电池材料领域,公司是电池厂商的上游材料供应商。固态电池用前驱体是公司的主要产品之一。固态电池前驱体绝大部分使用的是高镍及超高镍材料,公司的高镍及超高镍材料对于硫化物、氧化物、聚合物这三种形式的固态电池均适用。结合行业趋势和客户需求,公司积极开展固态电池用前驱体的研究、开发及工艺优化,已陆续推出“超小粒径富锂锰基材料前驱体”等多款产品,并已通过相关认证并实现几十吨级以上供货。

半导体公司半年报业绩预喜率超八成,5家绩优半导体公司或遭“错杀”

2025年7月19日 17:15
在全球景气度持续提升的背景下,A股市场半导体产业的业绩也极为亮眼。据统计,以可比口径计算,截至7月18日,已有30余家半导体公司披露2025年上半年业绩预告。其中,业绩预喜的公司数量占比超过八成。从市场表现来看,截至7月18日,30余家披露2025年半年报预告的半导体公司,年内平均涨幅接近15%,大幅超越未披露半年报预告的半导体公司的平均涨幅(5.97%)。其中,5家公司或遭“错杀”,今年以来股价处于下跌状态。(证券时报)

webpack+vite前端构建工具全掌握(中篇)

作者 Yodame
2025年7月19日 16:58

本片文章是构建工具学习的第二部分,主要讲解了一些实战配置

Webpack技巧性配置

hash值的意义

浏览器加载了一个资源后会缓存,但是如果名字改了呢?

如果项目种不去配置hash值,原来的打包后的css文件叫'a.css',但是打包之后仍然叫做'a.css',浏览器会默认加载之前的缓存

加上了hash值,才会由新旧文件的不同之处,浏览器就会重新去加载新的资源

image-20250716112436499

但是如果项目中只是变化了一个文件,例如app.js,重新打包之后,所有配置的项目都变化了

解决方案

使用 chunkhash

image-20250716114000925

resolve配置(重要)

image-20250716114505464

  • resolve的使用
import "@css/test"
import "@css/test.less"
import b from './a'
import c from './obj'

image-20250716115235572

require.context(十分有用)

r(item) 等价于动态 require()

当您调用 r(item) 时,它实际执行的是 动态加载 对应路径的模块(类似 require("./mode/a.js")),返回的是该模块的导出对象。

疑点: 为啥不是r[item]: item 是字符串(如 "./a.js"),r["./a.js"] 会尝试访问函数的 "./a.js" 属性,该属性不存在(值为 undefined

// 第二个布尔参数,false --> 不检查该目录下的子目录 true --> 则是检查子目录
const r = require.context("./mode",false,/.js/)
console.log(r.keys()); // ['./num1.js', './num2.js', './num3.js']
r.keys().forEach((item) => {
  // 这里使用的是r() 
  // 动态导入路径
  console.log(r(item).default);
})

// 第二种写法
const r = require.context("./mode",false,/\.js$/)
const requireAll = requireContext => requireContext.keys().map(requireContext)
requireAll(req)

路径的处理

需求: 把打包的css文件放到dist/css文件夹中, img文件放到 dist/img文件夹当中

  • 在mincss当中去配置即可

image-20250716123525516

  • 同理img图片

image-20250716123653316

  • 后续的打包文件放到cdn地址,在output中配置

打包后的文件后续会自动拼接上去 www.xxx.com/./css/test.bundle.css 去获取资源

如果放到自己的服务器上./css/test.bundle.css这样去获取

  output: {
    path: __dirname + "/dist",
    // hash 作为文件是否改变的标志
    filename: "[name].[chunkhash:4].bundle.js",
    publicPath: "www.xxx.com"
  },

开发模式

image-20250716130217811

作用:本地开启一个服务去运行--> 参考vue本地运行的时候npm run dev

image-20250717213343227

原理简讲

image-20250717220959596

##运行devServer

  • 安装webpack-dev-server包
npm install -g webpack-dev-server
  • 运行
webpack-dev-server

修改js文件--> 默认强制更新

在js文件中去写这一段,变成热更新,不推荐

image-20250717232320129

image-20250717232050733

  • proxy代理配置
image-20250717232702057
  • source-map

image-20250717233532115

  • 配置 --> 详细配置去看文档

image-20250717233902837

实战的配置技巧

  • 为啥需要区分环境

  • 区分要点

image-20250718003618215

  • scripts脚本命令

如果你没有自己去输入命令而是使用使用scripts脚本命令,你需要在项目本地去安装webpack

image-20250719114309148

  • process.env脚本命令

    cross-env(单独安装) 这个库帮助我们的指令脚本跨平台运行,详细讲解看这位作者大大的前端必懂 -- cross-env作用

  • 配置文件编程式技巧

image-20250719122856224

image-20250719122918756

image-20250719122935335

webpack自带的配置

通过 webpack --help 查看

  • env (不推荐采用)

    • 在script去配置

      image-20250719150230761

    • 在webpack.devconfig.js文件中配置

    image-20250719150603669

    • webpack.baseconfig.js文件结构

    image-20250719151313737

在业务代码中去使用配置文件中的变量

通过插件去实现--> webpack里面内置了

  • 在配置文件中配置

image-20250719151926437

  • 直接在业务代码中使用即可

    // app.js
    console.log(baseURL)
    

优化相关

官方方案不推荐--> 要科学上网,推荐使用后者,后者直接在本地去启动

image-20250719152242845

  • 安装
pnpm install webpack-bundle-analyzer --save-dev
  • 使用-> 引入完插件就可以运行打包了
const bundleanlyzer = require("webpack-bundle-analyzer").BundleAnalyzerPlugin

// ....
new bundleanlyzer()
  • 打包速度的优化(dll优化

image-20250719154414703

  • 新建一个webpack.dll.config.js文件

    输出的文件不建议放到dist文件夹下

    image-20250719160329696

  • 填写运行脚本

image-20250719160411158

  • 在正式配置文件中配置关联

image-20250719161108470

打包速度变快了,但是打包后的html文件没有引入,因此去模板html文件里引入

优化相关2

  • 压缩和tree-shaking

压缩不仅仅压缩成一行

举例:

let _a = 123;
function f1() {
   console.log(_a)
}

// 压缩后
consol.log(123)

压缩还具有混淆的功能

举例: 开发的时候语义化

let imageObj = {}

//压缩后
let _i = {}

tree-shaking: 通俗来讲,项目中引入了某种库,这个库里有100个方法,但是我只用到了一个方法,项目只会去打包用到的那一种方法--> 分析

  • 局限性: 代码中某个部分写成一种类的形式,是无效的。 --> 写成函数式编程的方案解决
❌
❌