阅读视图

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

NASA 编程准则:为什么他们的代码逻辑异于常人,且值得你我借鉴?

过去十年,我一直待在“快速迭代、不惧犯错”这种文化的第一线。

我见过初创公司在周五下午硬要上线,见过在 30 秒内临时拼出来的“紧急修复”,也见过那句“完成胜过完美”被当成万能挡箭牌,用来掩盖那些足以让银行家抓狂的内存泄漏、竞争条件和技术债。

直到后来,我遇到了一位来自 JPL(喷气推进实验室)的软件工程师。

我问他平时用什么框架,他笑了;我又问他多久部署一次代码,他的笑声慢慢收住。

“当你的代码运行在 1.4 亿英里之外时,”他平静地说,“你根本没有重启服务器的机会。”

正当现代 Web 开发者还在为“React 服务端组件还是客户端组件”争论不休时,航空航天工程师们却在践行一门几乎失传的艺术:“生存导向开发”

他们的代码不只是“能跑”,而是要“活下去”。它要扛住辐射,熬过宇宙射线,在长达二十年的任务里,一次也不重启地坚持下去。

image.png

秘诀在于:他们对待软件开发的态度,更像是在维护一座核反应堆,而不是在写一个简单的待办事项 App。

一套会改变一切的哲学

防御性设计:不信任任何人(甚至包括你自己)

在硅谷,我们写代码时总是假设一切顺利:API 肯定会响应,数据库肯定在线,用户也绝不会在“年龄”那一栏里输一个表情包。

而在航空航天领域,他们写代码时总是假设最坏情况——这就是防御性设计(Defensive Design)

翻看火星探测器的代码时,我发现了很多“强迫症式”的细节:每个函数都要检查输入参数,每次给变量赋值都要再校验一遍。看起来好像有点“被迫害妄想症”。

我忍不住问:“你为什么要检查 speed 是不是数字?上一行不是刚把它设成数字吗?”那位工程师回答: “因为在强辐射环境下,内存里的‘比特翻转’(Bit Flip)是真实存在且被记录过的风险。一个 0 会莫名其妙变成 1,转眼间,探测器的时速就可能从 4 英里飙到 400 英里。”

// 硅谷风格:“大概率没事。”
function setRoverSpeed(targetSpeed) {
  this.currentSpeed = targetSpeed;
  // 如果 targetSpeed 是 "fast"(字符串)或 NaN,物理引擎会直接炸掉。
}

// NASA 风格:“相信物理定律,不要迷信变量。”
function setRoverSpeed(targetSpeed) {
  // 1. 检查数据类型
  if (typeof targetSpeed !== 'number') {
    return ERROR_INVALID_INPUT;
  }

  // 2. 校验物理约束(探测车最高时速 0.1 m/s)
  if (targetSpeed < 0 || targetSpeed > MAX_DESIGN_LIMIT) {
    logAnomaly("Speed request out of physical bounds");
    return ERROR_UNSAFE_OPERATION;
  }

  // 3. 冗余状态校验
  this.currentSpeed = targetSpeed;
  return SUCCESS;
}

这种心态上的转变,是彻底颠倒过来的:

  • Web 开发者想的是: “要是崩了,就弹个报错窗口。”
  • NASA 工程师想的是: “要是崩了,降落伞就打不开,20 亿美金会以终端速度‘啪’地砸在地上。”

硬核到有点“反人类”的“编程十诫”

NASA 的喷气推进实验室(JPL)遵循一套近乎苛刻的编码规范,被称为**“编程十诫”(The Power of Ten)**。

要是你在黑客马拉松上拿出这套规则,多半会被笑话“太老派”。但正是这套思路,让“旅行者号”在飞行了 47 年之后,还能从深空顽强地往地球发回数据。

规则一:严禁动态内存分配(初始化完成后) 在 JS 或 Python 里,我们随手就能创建对象:const user = new User(),剩下的烂摊子全交给垃圾回收机制(GC)去打扫。

但在 NASA 的飞行控制软件中,一旦完成初始化,就绝对禁止再申请动态内存。 火箭升空后,你没机会管系统再要更多内存。 所有的内存必须预先分配好。 为什么要这么死板? 因为只要你不再开口要内存,“内存溢出”(OOM)这种破事儿就永远不会发生。而且,垃圾回收导致的系统停顿是不可控的风险。 心得:可预测性高于一切。

规则二:禁用递归 递归很优雅,甚至带有一种数学美。

但在 NASA,它被封杀了。 为什么? 因为递归极易引发死循环或栈溢出。而像 for i in range(10) 这种固定的循环结构是“确定性”的——你一眼就能算出它跑完需要多长时间。

# “优雅”的 Web 写法(风险:死循环 / 栈溢出)
def find_root_node(node):
    if node.parent is None:
        return node
    return find_root_node(node.parent) # 如果链表有环怎么办?程序直接崩。

# “无聊”的航天写法(风险:接近 0)
def find_root_node(node):
    # 硬性上限:永远别假设数据结构一定正确
    MAX_DEPTH = 1000
    
    for _ in range(MAX_DEPTH):
        if node.parent is None:
            return node
        node = node.parent
        
    # 一旦达到上限就安全返回,而不是崩溃。
    return ERROR_TREE_CYCLE_DETECTED

其中的教训是:如果你无法证明一段程序何时停止,那么打一开始就不该运行它。

规则三:复杂度的天花板

函数必须短到能打印在一张 A4 纸上(大约 60 行以内)。

这不仅仅是为了代码整洁,更是为了**“可验证性”**。 如果一个函数超过 60 行,它的逻辑分支数量就会呈爆炸式增长。到那时候,想要穷尽所有的测试场景,几乎是不可能完成的任务。

“零缺陷”主义:玛格丽特·汉密尔顿的遗产

有一张非常著名的照片:玛格丽特·汉密尔顿(Margaret Hamilton)站在一叠和她人一样高的打印纸旁。那一叠纸,就是阿波罗计划导航计算机的全部源代码。

image.png

就是这叠代码,把人类送上了月球。

“在阿波罗 11 号降落期间,没有出现过任何会导致任务失败的代码 Bug。”

当时确实出现了硬件超载报警(著名的 1201 和 1202 错误),但软件的表现完全符合设计预期:它果断切掉了低优先级的任务(如雷达更新),把所有资源都倾斜给了核心任务(降落)。

这可不是撞大运,这叫**“异步执行调度”**。

现代 Web 应用可能因为你点按钮快了几次就直接崩溃,而阿波罗号的计算机在距离月球表面只有 15 分钟路程、负载爆表的情况下,只是冷静地说了句:“我很忙,雷达数据我先放一边了。”

“去自我化”的代码审查

在很多科技公司,代码审查(Code Review)简直是场“自尊心大乱斗”:“这儿干嘛不用 map 函数?”或者“这写法不符合 React 的规范啊”。

但在关键系统工程领域,他们践行的是**“无我编程”(Egoless Programming)**。

代码不是“你的”,它属于整个任务。 工程师们会坐满一间屋子,把代码投在墙上,逐行进行“解剖”。 如果有人在你的代码里发现了 Bug,你不会感到难堪或想要辩解,你只会感到如释重负

你会说:“谢谢,你刚刚拯救了整个任务。

他们审查的不是“代码风格”,而是:

  • 如果这个输入值是 Null 会怎样?
  • 如果这个循环跑了 100 万次会怎样?
  • 如果传感器在这行代码执行时突然断开了,会怎样?

事实胜于雄辩

旅行者 1 号:终极“祖传代码” 旅行者 1 号发射于 1977 年。它的算力甚至还不如你手里那把汽车电子钥匙。

image.png

它目前远在 150 亿英里之外的星际空间。 就在前几年,旅行者 1 号传回的遥测数据开始出现乱码。 工程师们翻阅着那叠足有 50 年历史的技术文档,从地球上远程诊断出了一个损坏的内存芯片。然后,他们给这台“迪斯科流行时代”制造的计算机上传了一个补丁。 它竟然起死回生了。

对比一下:你家那台现代化的智能冰箱,可能仅仅因为厂家关掉了某台服务器,就变成了一堆废铁。

为什么这事儿跟你有关?(给 Web 开发者的“降温”指南)

你可能会想:“我只是个做电商网站的,又不是造火箭,没必要搞这套。”

不,你有必要。 因为“快速迭代,不惧犯错”这种文化,已经快把一切都搞砸了。

  • 我们有在发工资当天直接瘫痪的银行 App。
  • 我们有泄露患者隐私数据的医疗门户网站。
  • 我们甚至有会一本正经胡说八道、甚至蹦出种族歧视词汇的 AI 聊天机器人。

我们居然接受了将“不稳定”作为追求速度的代价。NASA 证明了,这纯属扯淡。

如何在你的代码里应用“火箭科学”?

你不需要非得写汇编语言才能从中获益:

1. 静态分析就是你的安全网 NASA 使用各种工具从数学逻辑上证明代码的正确性。 对应到你的工作中:你可以开启 TypeScript 的“严格模式(Strict Mode)” ,用好各种 Linter。 把警告(Warnings)当成错误(Errors)来对待。 如果代码检查过不去,火箭就绝不升空。

2. 失败也要“优雅”,而不是“暴毙” 当一个 React 组件报错时,整张页面往往直接变白(即所谓的“白屏死机”)。 这叫 “硬性崩溃(Fail Hard)” 。 “故障自保(Fail Safe)”的逻辑应该是:如果“推荐商品”的小组件挂了,那“加入购物车”的按钮也必须能正常工作。 一定要隔离你的核心逻辑路径。

// “硬崩溃”写法(白屏死机)
try {
  loadRecommendations();
} catch (error) {
  // React 错误边界会拦截,但整页组件会被卸载
  throw new Error("Component Failed"); 
}

// “故障自保”写法(任务继续执行)
try {
  loadRecommendations();
} catch (error) {
  // 1. 崩溃前先把当前状态打进日志
  telemetry.log("Recs failed", systemState);
  
  // 2. 隐藏出问题的功能,不要让应用挂掉
  this.showRecommendations = false;
  
  // 3. 确保关键路径保持可用
  // 把“结算”按钮隔离出来,保证它还能正常工作
}

3. 日志要记录“为什么”,而不只是“发生了什么” 别只记一个枯燥的 Error: 500

你要记录的是系统崩溃前的快照状态。 阿波罗号的工程师之所以能瞬间秒懂 1201 报警的含义,是因为系统设计初衷就是为了告诉他们为什么超载,而不仅仅是抛出一个“我超载了”的冷冰冰的消息。

4. 别让文档的“公交车指数”为 1 如果你明天不幸被公交车撞了,你的团队还能接手并部署你的代码吗?

NASA 的文档是业界传奇——他们写的操作手册是给那些还没出生的人看的。 写 README 的时候,请假设你的读者是在 20 年后的凌晨三点被迫起来修 Bug,而你本人正躺在冷冻舱里睡大觉。

这种文化层面的转变

从“码农(Coder)”到“工程师(Engineer)”的转变,本质上是思维的进化。

  • 码农会问: “这玩意儿能跑通吗?”
  • 工程师会问: “要是它跑不动了,会发生什么?”

我们生活在一个由软件驱动的世界。我们的汽车、心脏起搏器、电网系统,乃至我们的全部身家性命。 也许我们该停止把代码当成“一次性玩具”,转而把它当作“任务级精密仪器”来对待了。

你不需要非得去造火箭才能拥有“火箭科学家”的思维。 你只需要下定决心:在这里,失败不是选项。

你是否参与过那种“绝对不允许出错”的系统开发?这种经历是如何重塑你的编程风格的?欢迎在评论区聊聊。

image.png

Three.js实现更真实的3D地球🌍动态昼夜交替

  这一切始于一个偶然的发现。前几天笔者在应用商店闲逛时,被一款3D动态壁纸深深吸引——那颗在手机屏幕上缓缓旋转的地球,光影随着时间自然流转,从阳光灿烂的白昼到星光点点的黑夜,过渡得如此丝滑而真实。那一刻,我被这种将宇宙微观化的美感震撼了。

  作为一名前端开发,笔者的第一反应不是“这个壁纸真好看”,而是“这个效果我能实现吗?”。这种奇特的好奇心驱使我开始了用代码复现这一视觉奇观的探索之旅。

  有趣的是,最打动我的不是最终地球模型的逼真程度,而是那个微妙的光影过渡——那条被称为“晨昏线”的光暗分界线,它既清晰又模糊,既分割又连接着地球的白天与黑夜。如何在代码中捕捉这种自然界的诗意过渡?这个问题成为了整个项目最迷人的挑战。

  现在我们一起踏上这段从视觉灵感转化为技术实现的旅程,我们将用Three.js绘制星辰与大海,用着色器计算光影效果,用数学公式模拟昼夜的交替;当你看到那颗由你亲手编码的地球在浏览器中开始第一次转动时,你会发现,前端不仅仅是职业,更是一种生活方式。

本文的最终效果可以访问这个链接查看查看,随手截图就是一张精美的壁纸。

最终效果

环境准备

  要实现这样的效果,我们先准备需要的一些素材贴图:

// 背景星空球体半径
const BACKGROUND_STARS_RADIUS = 200;

// 地球球体的半径
const EARTH_RADIUS = 5;

// 太阳半径
const SUN_RADIUS = 1;

// 月球半径
const MOON_RADIUS = 0.5

// 月球轨道半径
const MOON_TRACK_RADIUS = EARTH_RADIUS * 2

class Earth {
  constructor() {
    this.assetsLoader = new AssetsLoader();
    this.assetsLoader.load([
      {
        type: AssetsType.Texture,
        name: "sun", // 太阳贴图
        path: "/images/earth/8k_sun.jpg",
      },
      {
        type: AssetsType.Texture,
        name: "moon", // 月球贴图
        path: "/images/earth/8k_moon.jpg",
      },
      {
        type: AssetsType.Texture,
        name: "stars", // 星空背景贴图
        path: "/images/earth/8k_stars_milky_way.jpg",
      },
      {
        type: AssetsType.Texture,
        name: "dayTexture", // 白天贴图
        path: "/images/earth/8k_earth_daymap.jpg",
      },
      {
        type: AssetsType.Texture,
        name: "nightTexture", // 夜晚贴图
        path: "/images/earth/8k_earth_nightmap.jpg",
      },
      {
        type: AssetsType.Texture,
        name: "normalMap", // 法线贴图
        path: "/images/earth/8k_earth_normal_map.jpg",
      },
      {
        type: AssetsType.Texture,
        name: "clouds", // 云层贴图
        path: "/images/earth/earth_clouds_2048.png",
      },
    ]);
    this.assetsLoader.on("onLoad", () => {
      this.initMesh();
    });
  }
}

本文所有素材均下载于Solar Textures

  素材准备好后,我们就可以来初始化场景下的物体;我们先创造我们美丽的蓝色星球,让它位于中心原点的位置:

class Earth {
  initEarth() {
    if (dayTexture && nightTexture) {
      const earthMaterial = new ShaderMaterial({
        uniforms: {
          dayTexture: { value: dayTexture },
          nightTexture: { value: nightTexture },
          sunPosition: { value: this.sunPosition },
        },
        vertexShader: earthVertexShader,
        fragmentShader: earthFragmentShader,
      });

      const earthGeometry = new SphereGeometry(EARTH_RADIUS, 128, 128);

      const earthMesh = new Mesh(earthGeometry, earthMaterial);
      earthMesh.position.set(0, 0, 0);

      this.basic.addScene(earthMesh);
    }
  }
}

  然后给空旷的宇宙安装一颗恒星,放在右边偏上的位置:

class Earth {
  sunPosition: Vector3 = new Vector3(20, 10, 0);
  initSun() {
    const sunTexture = this.assetsLoader.getAssets("sun") as Texture | null;

    if (sunTexture) {
      const sunGeometry = new SphereGeometry(SUN_RADIUS, 32, 32);
      const sunMaterial = new MeshBasicMaterial({
        map: sunTexture,
      });
      const sun = new Mesh(sunGeometry, sunMaterial);
      sun.position.copy(this.sunPosition);
      this.scene.add(sun);
    }
  }
}

  有意思的是,这里的太阳虽然看起来像个发光的球,但实际上它只是个“装饰品”;我们真正用到的其实是它的位置信息sunPosition,用于在后面模拟昼夜交替时,将太阳的位置信息传入到着色器代码中。

  在真实宇宙中,是地球绕着太阳转。但在我们的虚拟场景中,为了保持地球始终在画面中央(坐标原点),笔者耍了个小聪明——让太阳“绕着”地球转,同时给太阳一个自转。

class Earth {
  rotateVector3ByRadian(vec3: Vector3, axis: Vector3, radian: number) {
    // 创建旋转矩阵
    const matrix = new Matrix4()
    // 设置绕轴旋转的矩阵
    matrix.makeRotationAxis(axis.normalize(), radian)
    // 应用旋转矩阵到向量
    vec3.applyMatrix4(matrix)
  }
  render(clock: Clock) {
    rotateVector3ByRadian(
      this.sunPosition,
      new Vector3(0, 1, 0),
      0.0004,
    )
    if (this.sunMesh) {
      this.sunMesh.position.copy(sunPos)
      this.sunMesh.rotation.y += 0.002
    }
  }
}

  接着,给我们的宇宙增加一份浩瀚感,这里的星空背景通过球体加上贴图来进行渲染:

class Earth {
  initStarBackground() {
    const starsTexture = this.assetsLoader.getAssets("stars") as Texture | null;
    if (starsTexture) {
      const sphereGeometry = new SphereGeometry(
        BACKGROUND_STARS_RADIUS,
        64,
        64
      );
      sphereGeometry.scale(-1, 1, 1);
      const sphereMaterial = new MeshBasicMaterial({
        map: starsTexture,
        side: DoubleSide,
      });
      const sphere = new Mesh(sphereGeometry, sphereMaterial);
      this.scene.add(sphere);
    }
  }
}

  地球怎么能独自在宇宙中流浪呢?怎么能少得了它忠实的小跟班——月球呢?但只是放个月球太普通了,我决定给它加个专属的“跑道”track:

class Earth {
  moonPosition: Vector3 = new Vector3(0, MOON_TRACK_RADIUS, 0)
  initMoon() {
    const group = new Group()

    const trackGeo = new TorusGeometry(MOON_TRACK_RADIUS, 0.01, 64, 64)
    const trackMt = new MeshBasicMaterial({
      color: guiOption.moon.trackColor,
      transparent: true,
      opacity: 0.5,
    })
    const track = new Mesh(trackGeo, trackMt)
    group.add(track)

    const moonGeo = new SphereGeometry(MOON_RADIUS, 64, 64)
    const moonMt = new MeshBasicMaterial({
      map: moonTexture,
    })
    const moon = new Mesh(moonGeo, moonMt)
    moon.position.copy(this.moonPosition)
    group.add(moon)
    
    group.rotateX(MathUtils.degToRad(100))
    this.scene.add(group)    
  }
}

  那个半透明的轨道环其实是个视觉引导——它告诉用户“嘿,月球是沿着这条路径运动的”。虽然真实月球没有可见轨道,但这个设计增加了场景的科技感和可读性。

  当我看到月球带着它的光环开始绕着地球旋转时,那感觉就像完成了一个精密的宇宙钟表——每个部件都有它的位置,每个运动都有它的规律。这个小跟班让我们的地球不再孤单,整个太阳系开始有了“系统”的感觉。

实现昼夜分明

  前面我们搭建好了整个太阳系舞台,但此刻的地球还只是一个静止的球体,没有光影变化,没有昼夜交替。现在,是时候为这颗蓝色星球注入灵魂了。还记得我们初始化地球时预留的vertexShader和fragmentShader吗?那两个看似简单的GLSL代码文件,才是实现昼夜交替魔法的核心所在。

  如果说地球模型是个巨大的工厂,那么顶点着色器就是为每个工人(像素点)准备工牌的生产线。下面着色器代码其实在做两件重要的事情:

// 纹理坐标
varying vec2 vUv;
// 变换后的法线向量
varying vec3 vNormal;

void main(){
    vUv=uv;
    vNormal=normalize(normalMatrix*normal);
    gl_Position= projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

  vUv用来记录每个顶点的纹理坐标,vNormal计算并传递法线向量,每个顶点的法线就像一根小指针,直直地指向该点的“正上方”,normalize()函数让法线向量保持长度为1(归一化)。varying表示把顶点着色器的计算结果传递给下面的片元着色器。

  所以别看上面这段代码短,它可是整个昼夜效果的地基。它为地球表面每个点都准备好了:“我是谁(uv坐标)”、“我面朝哪里(法线)”,就等着片元着色器来判断:“你现在应该是白天还是黑夜”;下面是我们最重要的片元着色器代码了:

#ifdef GL_ES
precision mediump float;
#endif

uniform sampler2D dayTexture;
uniform sampler2D nightTexture;
uniform vec3 sunPosition;

varying vec2 vUv;
varying vec3 vNormal;

void main(){
    vec3 lightDir=normalize(sunPosition);
    
    float dotProduct=dot(normalize(vNormal),lightDir);
    
    if(dotProduct>0.){
        gl_FragColor=texture2D(dayTexture,vUv);
    }else{
        gl_FragColor=texture2D(nightTexture,vUv);
    }
}

  这段代码虽然只有短短的十几行,却决定了地球表面每一处是光明还是黑暗。GLSL语法比较难懂,我们下面就来详细介绍一下。

  首先我们将前面顶点着色器中处理好的单位法线向量vNormal接收,然后将传入的太阳的位置接收,通过normalize函数进行归一化操作,得到了太阳的方向;最轴将上面计算的法线向量和太阳的方向进行点积运算。

将太阳位置归一化后的方向向量,表示从原点指向太阳位置的单位向量,而不是从太阳位置出发指向原点,这一点需要注意。

  这里详细说一下点积运算的几何意义,在三维空间中,两个向量的点积公式是:

dot(A, B) = |A| × |B| × cos(θ)

  其中θ是A和B之间的夹角;而我们上面已经对两个向量都进行了归一化操作,因此公式简化为:

dot(A, B) = cos(θ)

  因此这里的角度θ其实代表了法线与太阳光线方向的夹角,我们通过一张图来理解,球体表面的蓝色箭头,表示法线;而原点黄色的箭头,表示太阳的方向:

太阳方向夹角

  因此在上面片元着色器代码中,计算得到的dotProduct变量,其实也是cos(θ)的值;我们通过对它的值进行判断,如果dotProduct大于0,则表示法线与太阳方向夹角小于90度,则表示当前点在白天,则使用白天贴图进行渲染;否则使用夜晚贴图进行渲染;运行后,我们就能看到地球的白天黑夜有明显的界线分隔了。

地球白天黑夜效果

  最后在太阳转动的同时,不要忘记更新地球材质的uniforms中的sunPosition属性:

class Earth {
  render(clock: Clock) {
    if (this.earthMaterial) {
      this.earthMaterial.uniforms.sunPosition.value.copy(this.sunPosition)
    }
  }
}

星空顶

  如果你最近去过高端楼盘展厅或者坐过某些豪华车型,大概率见过那个让人惊艳的设计——星空顶。无数光点在头顶缓缓闪烁,像是把整个银河系微缩在了方寸之间。这种将宇宙浪漫融入空间的设计,早已成为“高端感”的代名词。

  我们项目怎么能少了这样迷人的星空呢?不行,我们的项目也要向高端、豪华看齐。虽然之前我们用一张星空贴图作为背景,但是总感觉不够真实,缺少了星空那种忽明忽暗的光亮效果;我们在太阳到星空背景球体之间,通过Points来添加众多的星星,我们首先初始化星星的一些参数:

// 星星数量
const STARS_AMOUNT = 1000;
// 星星最小距离
const STARS_MIN_DISTANCE = 100;
// 星星最大距离
const STARS_MAX_DISTANCE = 200;
class Earth {
  initStars() {
    const starGeometry = new BufferGeometry();
    // 每个点的xyz坐标
    const positions = new Float32Array(STARS_AMOUNT * 3);
    // 每个点rgb颜色
    const colors = new Float32Array(STARS_AMOUNT * 3);
    // 每个点的初始大小
    const sizes = new Float32Array(STARS_AMOUNT);
    // 每个点的闪烁相位
    const phases = new Float32Array(STARS_AMOUNT);
    // 每个点的闪烁频率
    const frequencies = new Float32Array(STARS_AMOUNT);
  }
}

  由于我们想要生成从太阳到星空背景球体之间圆环内的随机点,因此我们可以通过极坐标的方式来计算,通过极坐标转换到三维空间内的坐标:

class Earth {
  initStars() {
    for (let i = 0; i < STARS_AMOUNT; i++) {
      const i3 = i * 3

      // 在球体空间内随机生成位置
      const distance = getRandomInt(STARS_MIN_DISTANCE, STARS_MAX_DISTANCE)
      const theta = Math.random() * Math.PI * 2 // 方位角
      const phi = Math.acos(2 * Math.random() - 1) // 极角

      // 球坐标转直角坐标
      positions[i3] = distance * Math.sin(phi) * Math.cos(theta)
      positions[i3 + 1] = distance * Math.sin(phi) * Math.sin(theta)
      positions[i3 + 2] = distance * Math.cos(phi)
    }
  }
}

  坐标位置搞定了,我们继续给每个点生成随即的颜色、大小、闪烁相位、闪烁频率属性:

// 随机颜色(偏向白色和蓝色)
const colorChoice = Math.random()
if (colorChoice < 0.7) {
  // 白色/淡黄色星星
  colors[i3] = 1.0 // R
  colors[i3 + 1] = 0.9 + Math.random() * 0.1 // G
  colors[i3 + 2] = 0.8 + Math.random() * 0.2 // B
} else if (colorChoice < 0.9) {
  // 蓝色星星
  colors[i3] = 0.4 + Math.random() * 0.3 // R
  colors[i3 + 1] = 0.6 + Math.random() * 0.3 // G
  colors[i3 + 2] = 1.0 // B
} else {
  // 红色/橙色星星
  colors[i3] = 1.0 // R
  colors[i3 + 1] = 0.5 + Math.random() * 0.3 // G
  colors[i3 + 2] = 0.3 + Math.random() * 0.2 // B
}

// 大小
sizes[i] = Math.random() * 2 + 0.5
// 闪烁频率
frequencies[i] = Math.random() * 0.5 + 0.5
// 闪烁相位
phases[i] = Math.random() * Math.PI * 2

  最后,我们通过BufferGeometry将这些数据传入Points对象中:

starGeometry.setAttribute("position", new BufferAttribute(positions, 3))
starGeometry.setAttribute("color", new BufferAttribute(colors, 3))
starGeometry.setAttribute("size", new BufferAttribute(sizes, 1))
starGeometry.setAttribute("phase", new BufferAttribute(phases, 1))
starGeometry.setAttribute("frequency", new BufferAttribute(frequencies, 1))

  然后创建Points对象,同时在uniforms中添加一个time属性,用于控制星星闪烁:

const starMaterial = new ShaderMaterial({
  uniforms: {
    time: { value: 0.0 },
  },
  vertexShader: starsVertexShader,
  fragmentShader: starsFragmentShader,
  transparent: true,
  blending: AdditiveBlending,
})
const stars = new Points(starGeometry, starMaterial)

  在我们的顶点着色器代码中,接收上面的顶点数据:

attribute float size;
attribute vec3 color;
attribute float phase;
attribute float frequency;

varying vec3 vColor;

uniform float time;

void main() {
    vColor = color;
    
    // 闪烁效果计算
    float blink = sin(time * frequency + phase) * 0.5 + 0.8;
    
    // 添加一些随机噪声使闪烁更自然
    float noise = sin(dot(position, vec3(12.9898, 78.233, 45.5432)) * 43758.5453) * 0.1;
    
    // 最终大小
    float finalSize = size * (blink + noise);
    
    vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
    gl_PointSize = finalSize * (300.0 / -mvPosition.z);
    gl_Position = projectionMatrix * mvPosition;
}

  vColor用来将前面生成的点的颜色传递给片元着色器,让星星有独立的颜色;blink是实现星星闪烁的关键公式,time × frequency表示随时间变化的相位,phase控制每个粒子的初始相位偏移,使得闪烁不会同步进行;sin函数产生平滑的正弦波振荡,取值范围是[-1, 1],最终blink的范围是[0.3, 1.3],再乘以size初始化大小,得到了星星在不同时刻的最终大小。

  最后片元着色器代码如下:

#ifdef GL_ES
precision mediump float;
#endif

varying vec3 vColor;

void main() {
    // 圆形点
    float distanceToCenter = length(gl_PointCoord - vec2(0.5));
    if (distanceToCenter > 0.5) {
        discard;
    }
    
    // 添加一些发光效果
    float alpha = 1.0 - smoothstep(0.0, 0.5, distanceToCenter);
    
    gl_FragColor = vec4(vColor, alpha * 0.9);
}

美丽的晨昏线

  如果仔细观察真实的地球照片,你会发现一个迷人的细节:白天和黑夜之间,并没有一条生硬的分界线。取而代之的,是一片温柔过渡的“灰色地带”——这就是我们常说的晨昏线,也是日出日落时分最富诗意的区域。

  回头看我们之前实现的昼夜分割的效果,虽然功能完整,但是少了些自然界的柔美;现实世界的光影变化,从来不是非黑即白的开关,而是渐变的艺术;下面我们就来创造出一个平滑过渡的晨昏区域,让白昼缓缓融入黑夜。

  上面我们详细介绍了白天黑夜如何通过太阳光和法线进行判断,而其核心原理就是下面的计算公式:

float dotProduct=dot(normalize(vNormal),lightDir);

  dotProduct的取值范围是[-1, 1],当在0~1之间时,代表表面正对太阳,是白天;-1~0之间,表示背对太阳,是黑夜;我们想要让太阳在中间地带有一个过渡的范围,我们先给ShaderMaterial传入一个参数transitionWidth

const earthMaterial = new ShaderMaterial({
  uniforms: {
    dayTexture: { value: dayTexture },
    nightTexture: { value: nightTexture },
    // 新增过渡范围参数
    transitionWidth: { value: 0.2 },
  },
  vertexShader: earthVertexShader,
  fragmentShader: earthFragmentShader,
})

  然后,在片元着色器中添加过渡参数:

uniform float transitionWidth; 
void main(){
  float transitionCenter = 0.0; // 晨昏线
  float transitionStart = transitionCenter - transitionWidth * 0.5;
  float transitionEnd = transitionCenter + transitionWidth * 0.5;
}

  当传入transitionWidth是0.2时,计算得到下面的范围:

  • transitionStart = -0.1
  • transitionEnd = 0.1

  这就意味着在dotProduct点积值[-0.1, 0.1]范围内是过渡区域;然后使用smoothstep创建一个平滑插值:

void main(){
  // 使用smoothstep创建平滑过渡
  float mixFactor = smoothstep(transitionStart, transitionEnd, dotProduct);
}

  smoothstep函数的行为如下:

  • 当 dotProduct <= transitionStart 时:mixFactor = 0.0
  • 当 dotProduct >= transitionEnd 时:mixFactor = 1.0
  • 当 transitionStart < dotProduct < transitionEnd 时:mixFactor 平滑过渡

  因此,smoothstep函数实际上将dotProduct区间值[-1, 1]映射到mixFactor的[0, 1]范围内,并创建一个平滑过渡;其中[-1 , -0.1],映射为0,表示黑夜,[0.1 , 1],映射为1,表示白天,中间的(-0.1 , 0.1)映射到(0, 1)表示过渡的区域;我们通过一个表格来详细表示:

dotProduct值 mixFactor 纹理
-1 0 黑夜
-0.5 0 黑夜
-0.1 0 逐渐从黑夜过渡到白天
0 0.5 中间过渡区域
0.1 1 逐渐从白天过渡到黑夜
0.5 1 白天
1 1 白天

  最后使用mix函数将白天和黑夜的纹理进行混合:

void main(){
  // 采样纹理
  vec4 dayColor = texture2D(dayTexture, vUv);
  vec4 nightColor = texture2D(nightTexture, vUv);
  
  // 混合白天和黑夜纹理
  gl_FragColor = mix(nightColor, dayColor, mixFactor);
}

  这样,我们就实现了白天黑夜的过渡效果。

白天黑夜过渡效果

尾声:在代码中雕刻时光的意义

  当我们看着这个由自己亲手绘制的“微缩版太阳系”在浏览器中静静旋转时,一种奇特的感受油然而生——在这个微观的数字宇宙里,我既是造物主,也是最渺小的观察者。

  我们用了不到1000行的代码,就模拟了一个直径12742公里的星球上每时每刻的光影变迁。真实的地球需要24小时完成一次昼夜交替,我们的数字地球只需几分钟。这种尺度上的巨大反差让我突然明白:人类的所有创造,本质上都是对无限宇宙的有限翻译。

  那颗在轨道上“绕着”地球转的太阳,其实只是在绕着原点旋转的几行向量计算。但就是这简单的数学,却再现了我们祖先仰望天空时看到的景象——日出东方,日落西沉。从地心说到日心说,再到今天的代码模拟,人类对宇宙的理解在变,但那份想要理解世界的好奇心从未改变。

“给岁月以文明,而不是给文明以岁月”。——《三体》

  我们不是在追求让这个数字地球永恒运行,那颗正在你屏幕上旋转的蓝色星球,可能在下一秒就会浏览器缓存清理;那些闪烁的星星,可能随着标签页关闭而永远熄灭。

  而是在有限的运行时间里,赋予它最丰富的意义:让晨昏线温柔过渡,让星空会呼吸,让光影如诗歌般流动。我们关心的不是代码能运行多久,而是它在运行的每一帧里,是否足够美好,是否传递了我们对真实世界的观察与敬意。

  毕竟,在浩瀚的宇宙面前,所有文明都只是瞬间的火花。而最美的火花,不是燃烧得最久的那个,而是燃烧时最亮、最温暖的那个。

本文的最终效果可以访问这个链接查看查看。

如果觉得写得还不错,请关注我的掘金主页。更多文章请访问谢小飞的博客

# 秒懂SKILLS: 模块化的RULES + 轻量化脚本

为什么我们需要skills?

众所周知,在AI编程的语境下,RULES 几乎是必不可少的,人们需要在 RULES 中提前给 AI 制定规则:

  1. 它是一个什么样的角色
  2. 本工程采用了什么技术栈,它应该按什么编码规范来编码,如何组织工程代码。
  3. 当遇上一些非常见情况时,它应该如何处理,遵循什么原则
  4. 如何处理某些异常

但是,问题来了:

  • 如果 RULES 太短,那它能覆盖的范围就非常有限。
  • 如果 RULES 太长,每次会话AI都需要完全加载一遍它,浪费token倒是其次,重要的是token会降低AI的准确性提升幻觉。

于是,模块化【懒加载】的诉求,便血淋淋摆在人们面前了。

SKILLS 要解决的也正是这个痛点。

  • 它允许不同的规则被注册在不同的 SKILL.md 中,只在需要的时候进行加载。

SKILLS 的结构

要实现懒加载,有一个重要的问题需要解决:

大模型需要知道合适加载哪个 Skill。

因此,SKILLS的结构笼统性来说,分为两个部分:

  • 元数据: 告诉大模型我是谁,我有什么能力,什么时候应该调用我。
  • 内容:指导大模型如何进行编程。

这是一个典型 SKILL.md 文件的结构:

---
name: API公约
description: 适用于当前代码库的 API 设计模式
---

在编写 API 接口(端点)时:
- 遵循 RESTful 命名规范
- 返回统一的错误格式
- 包含请求参数校验

在头部被 --- 包裹起来的部分就是markdown元数据,在这里它们被用来描述技能本身的特性。

  • name:技能的名称
  • description:技能的描述,有什么用,什么时候应该被加载

而下面具体指导编程规范的部分,则是该技能的【内容】。

一开始的时候,【内容】并不会被加载到上下文中,只加载精简过的【元数据】,这会极大地节约token消耗,也能降低模型幻觉。

SKILLS 放在哪?

结合 Claude Code、Trae、OpenCode 以及 Cursor 的最新文档,

Claude Code

位置:项目根目录 /.claude/skills/

  • 结构:
ProjectRoot/
└── .claude/
    └── skills/
        ├── skill-a/  <-- 技能名称文件夹
        │   ├── SKILL.md  <-- 核心定义 (SOP & Prompts)
        │   └── scripts/  <-- (可选) 对应的 Python/Node 脚本
        └── skill-b/
            └── SKILL.md
  • 生效方式:Claude Code 启动时自动扫描该目录,根据 User Prompt 和 SKILL.md 中的 description 自动挂载。

OpenCode

位置:通常为 /.opencode/skills/

Project config: .opencode/skills/<name>/SKILL.md
Global config: ~/.config/opencode/skills/<name>/SKILL.md
Project Claude-compatible: .claude/skills/<name>/SKILL.md
Global Claude-compatible: ~/.claude/skills/<name>/SKILL.md

Cursor

位置:通常为 /.cursor/skills/

.cursor/
└── skills/
    └── deploy-app/
        ├── SKILL.md
        ├── scripts/
        │   ├── deploy.sh
        │   └── validate.py
        ├── references/
        │   └── REFERENCE.md
        └── assets/
            └── config-template.json

Trae

位置:通常为 /.trae/skills/

.trae/skills/
├──skill-name/
    ├── SKILL.md  
    ├── scripts
    └── references

总的来说,各家有各家的习惯和地盘,希望后续能统一成标准吧。

有了SKILLS,可以不要MCP了吗?

绝对不可以!

它们并不是互斥的两套技术。

恰恰相反,它们是 “黄金搭档”,是底层能力 (Capabilities) 与 上层应用 (Applications) 的关系。

如果把构建 Agent 比作雇佣一个员工,那么:

  • MCP (Model Context Protocol) 是这个员工的 “手”和“感官”。

    • 它定义了员工能做什么(比如:能拿杯子、能查数据库、能运行 Python 代码)。

    • 它解决了“怎么连接”的问题(标准化的接口协议)。

  • Skills (技能/规则) 是这个员工的 “职业培训手册” (SOP)。

    • 它定义了员工该怎么做(比如:看到客人来了要倒水、查库前要先鉴权、代码报错了要重试)。

    • 它解决了“怎么思考”和“怎么决策”的问题(业务逻辑与流程控制)。

总的来说,只要把 SKILLS 当作模块化的 RULES来理解会比较容易。

但,SKILLS 除了是模块化的 RULES 外,它还有一个重要的能力:

  • 它具备脚本执行能力。

软硬一体的 SKILLS

之前的回答为了强调“规则”的重要性,确实简化了 Skills 的定义。实际上,完整的 Skills 是“软硬一体”的。

在 Claude Code 的架构中,一个 Skill 确实可以包含它私有的、本地的脚本。

1. 重新定义:Skills 的完整公式

纠正之前的定义,现在的公式应该是:

Skill = 🧠 业务规则 (SOP) + 🛠️ 专用脚本 (Local Scripts)

SOP (SKILL.md):这是大脑。它告诉 AI 什么时候用、怎么用。

Scripts (/scripts/*.py):这是随身工具包。它是为了配合这个 SOP 而存在的轻量级代码。

2. 为什么要允许 Skill 包含脚本?

既然有了 MCP,为什么还需要 Skill 自带脚本?

这就像:虽然工厂里有重型机床(MCP),但工人腰带上还是得挂一把螺丝刀(Skill Script)。

主要有以下三个原因:

  • A. 降低依赖 (Self-Contained) 如果你的 Skill 只是为了做一些简单的文本处理(比如“把 xxx 格式化一下”),为此启动一个 HTTP MCP Server 太重了。 把这个逻辑写成一个 20 行的 Python 脚本放在 Skill 文件夹里,随拿随用,这才是“技能包”的便携性。

  • B. 胶水逻辑 (Glue Logic) 有时候,MCP 提供的原子能力太碎了。

    • MCP 工具 A:get_file_list

    • MCP 工具 B:analyze_file

    • Skill 脚本:你可以写一个脚本,循环调用 A,过滤结果,然后传给 B,最后输出统计报表。

    • 优势:你把“循环与判断”的计算压力从 LLM(昂贵、慢)转移到了 CPU(便宜、快)。

  • C. 本地文件操作 Claude Code 是在本地运行的。Skill 脚本可以直接通过 bash 访问你项目里的文件系统,这比远程 MCP Server 通过网络传输文件内容要高效得多。

Vue 中的 deep、v-deep 和 >>> 有什么区别?什么时候该用?

“你用 Element Plus 写了个按钮,想改下 hover 颜色,结果死活不生效!最后查了半天,发现得加个 :deep() 才行”

其实,这是 Vue 中一个非常常见的坑:样式作用域冲突。那为什么用 UI 库时,加上 :deep()::v-deep>>>后,样式就能生效呢?

它们是什么?有什么区别?什么时候该用哪个?

一、先说背景

我们在 Vue 单文件组件(.vue 文件)里写样式时,通常会加上 scoped 属性:

<template>
  <el-button>点我</el-button>
</template>

<style scoped>
.el-button {
  background: red;
}
</style>

加了 scoped 后,Vue 会自动给这个组件里的所有元素加上一个唯一的属性(比如 data-v-123456),然后把 CSS 选择器也加上这个属性,变成:

.el-button[data-v-123456] {
  background: red;
}

这样做的好处是:样式只作用于当前组件,不会污染全局。、

但问题来了:Element Plus 的 <el-button> 组件内部结构,是在它自己的组件里定义的。也就是说,你写的 .el-button 元素,其实是 Element Plus 渲染出来的子组件,它身上没有你当前组件的 data-v-xxx 属性!

所以你的样式根本匹配不到它,自然就失效了。


二、那怎么办?

为了解决这个问题,Vue 提供了样式穿透(style penetration)的语法,让你能穿透当前组件的作用域,去影响子组件内部的元素。

Vue 社区出现过三种写法:

写法 适用版本 状态
>>> Vue 2(某些预处理器支持) 已废弃/不推荐
::v-deep Vue 2 + Vue 3(兼容写法) 过渡方案
:deep() Vue 3.0+(推荐) 官方推荐

下面我们一个个拆解。


1. >>>:曾经的快捷方式,但问题很多

早期 Vue2 时代,很多人用:

<style scoped>
.parent >>> .child {
  color: blue;
}
</style>

它的意思是:从 .parent 开始,穿透到所有后代中的 .child

但问题在于:

  • Sass/Less 等预处理器不认 >>>,会报错。
  • 不是标准 CSS 语法。
  • Vue3 已经明确不再支持。

所以现在基本可以忘掉它了。


2. ::v-deep:Vue2 到 Vue3 的桥梁

为了兼容预处理器,Vue 引入了 ::v-deep

<style scoped lang="scss">
.parent ::v-deep(.child) {
  color: blue;
}
</style>

或者更常见的写法:

.parent {
  ::v-deep(.child) {
    color: blue;
  }
}

它在 Vue2 和 Vue3 中都能用,算是一个安全的过渡方案。

但注意:在 Vue3 中,官方文档已经明确建议使用 :deep() 替代它


3. :deep():Vue3 的标准答案

Vue3 引入了更简洁、更符合 CSS 规范的伪类函数写法:

<style scoped>
:deep(.el-button) {
  background: red !important;
}
</style>

或者配合父级选择器:

<style scoped>
.my-wrapper :deep(.el-input__inner) {
  border-radius: 10px;
}
</style>

优点

  • 语法清晰,像原生 CSS。
  • 支持所有预处理器(Sass/Less/Stylus)。

:deep() 本质上是一个编译时转换,Vue 在构建时会把它展开成带 data-v-xxx 的复杂选择器,从而实现穿透。


三、怎么正确修改 Element Plus 的样式?

举个真实例子:你想把 Element Plus 的输入框圆角改成 8px。

错误写法(不生效):

<style scoped>
.el-input__inner {
  border-radius: 8px;
}
</style>

正确写法:

<template>
  <div class="my-form">
    <el-input v-model="value" />
  </div>
</template>

<style scoped>
.my-form :deep(.el-input__inner) {
  border-radius: 8px;
}
</style>

为什么要加 .my-form 这个父级?
避免全局污染!如果直接写 :deep(.el-input__inner),那么这个页面里所有 Element 输入框都会被改掉。加上父级限定,就能精准控制范围。

本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

每日一题-移除最小数对使数组有序 I🟢

给你一个数组 nums,你可以执行以下操作任意次数:

  • 选择 相邻 元素对中 和最小 的一对。如果存在多个这样的对,选择最左边的一个。
  • 用它们的和替换这对元素。

返回将数组变为 非递减 所需的 最小操作次数 

如果一个数组中每个元素都大于或等于它前一个元素(如果存在的话),则称该数组为非递减

 

示例 1:

输入: nums = [5,2,3,1]

输出: 2

解释:

  • 元素对 (3,1) 的和最小,为 4。替换后 nums = [5,2,4]
  • 元素对 (2,4) 的和为 6。替换后 nums = [5,6]

数组 nums 在两次操作后变为非递减。

示例 2:

输入: nums = [1,2,2]

输出: 0

解释:

数组 nums 已经是非递减的。

 

提示:

  • 1 <= nums.length <= 50
  • -1000 <= nums[i] <= 1000

3507. 移除最小数对使数组有序 I

解法一

思路和算法

如果初始时数组 $\textit{nums}$ 已经非递减,则最小操作次数是 $0$。以下只考虑初始时数组 $\textit{nums}$ 不满足非递减的情况。

最直观的思路是模拟数组的操作。每次操作时,遍历数组 $\textit{nums}$ 寻找相邻元素对中的元素和最小且最左边的一个元素对,用元素和替换这对元素,直到数组变成非递减,此时的操作次数即为将数组变为非递减所需的最小操作次数。

用 $n$ 表示数组 $\textit{nums}$ 的长度。由于每次操作之后都会将数组中的元素个数减少 $1$,因此每次操作应将执行合并操作的元素对右侧的元素向左移动。对于 $0 \le k < n$ 的整数 $k$,在执行 $k$ 次操作之后,剩余元素个数是 $n - k$,因此下一次操作时应只考虑数组的前 $n - k$ 个元素。

代码

###Java

class Solution {
    public int minimumPairRemoval(int[] nums) {
        int removals = 0;
        int n = nums.length;
        while (!isNonDecreasing(nums, n)) {
            update(nums, n);
            removals++;
            n--;
        }
        return removals;
    }

    public void update(int[] nums, int n) {
        int minSum = Integer.MAX_VALUE;
        int index = -1;
        for (int i = 0; i < n - 1; i++) {
            if (nums[i] + nums[i + 1] < minSum) {
                minSum = nums[i] + nums[i + 1];
                index = i;
            }
        }
        if (index >= 0) {
            nums[index] = minSum;
            for (int i = index + 1; i < n - 1; i++) {
                nums[i] = nums[i + 1];
            }
        }
    }

    public boolean isNonDecreasing(int[] nums, int n) {
        for (int i = 1; i < n; i++) {
            if (nums[i - 1] > nums[i]) {
                return false;
            }
        }
        return true;
    }
}

###C#

public class Solution {
    public int MinimumPairRemoval(int[] nums) {
        int removals = 0;
        int n = nums.Length;
        while (!IsNonDecreasing(nums, n)) {
            Update(nums, n);
            removals++;
            n--;
        }
        return removals;
    }

    public void Update(int[] nums, int n) {
        int minSum = int.MaxValue;
        int index = -1;
        for (int i = 0; i < n - 1; i++) {
            if (nums[i] + nums[i + 1] < minSum) {
                minSum = nums[i] + nums[i + 1];
                index = i;
            }
        }
        if (index >= 0) {
            nums[index] = minSum;
            for (int i = index + 1; i < n - 1; i++) {
                nums[i] = nums[i + 1];
            }
        }
    }

    public bool IsNonDecreasing(int[] nums, int n) {
        for (int i = 1; i < n; i++) {
            if (nums[i - 1] > nums[i]) {
                return false;
            }
        }
        return true;
    }
}

复杂度分析

  • 时间复杂度:$O(n^2)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。最差情况需要执行 $n - 1$ 次操作,每次操作的时间是 $O(n)$,因此时间复杂度是 $O(n^2)$。

  • 空间复杂度:$O(1)$。所有的操作均为原地修改数组。

解法二

思路和算法

使用双向链表、哈希表和优先队列,可以将时间复杂度降低到 $O(n \log n)$。具体见题解「3510. 移除最小数对使数组有序 II」。

代码

###Java

class Solution {
    private class Node {
        private int index;
        private long value;
        private Node prev;
        private Node next;

        public Node() {
            this(-1, Integer.MAX_VALUE);
        }

        public Node(int index, long value) {
            this.index = index;
            this.value = value;
            prev = null;
            next = null;
        }

        public int getIndex() {
            return index;
        }

        public long getValue() {
            return value;
        }

        public void setValue(long value) {
            this.value = value;
        }

        public Node getPrev() {
            return prev;
        }

        public void setPrev(Node prev) {
            this.prev = prev;
        }

        public Node getNext() {
            return next;
        }

        public void setNext(Node next) {
            this.next = next;
        }
    }

    public int minimumPairRemoval(int[] nums) {
        int removals = 0;
        Map<Integer, Node> indexToNode = new HashMap<Integer, Node>();
        Node pseudoHead = new Node();
        Node pseudoTail = new Node();
        Node prevNode = pseudoHead;
        int n = nums.length;
        for (int i = 0; i < n; i++) {
            Node node = new Node(i, nums[i]);
            indexToNode.put(i, node);
            prevNode.setNext(node);
            node.setPrev(prevNode);
            prevNode = node;
        }
        prevNode.setNext(pseudoTail);
        pseudoTail.setPrev(prevNode);
        PriorityQueue<long[]> pq = new PriorityQueue<long[]>((a, b) -> a[0] != b[0] ? Long.compare(a[0], b[0]) : Long.compare(a[1], b[1]));
        int reversePairs = 0;
        for (int i = 0; i < n - 1; i++) {
            pq.offer(new long[]{nums[i] + nums[i + 1], i, i + 1});
            if (nums[i] > nums[i + 1]) {
                reversePairs++;
            }
        }
        while (reversePairs > 0) {
            long[] arr = pq.poll();
            long newValue = arr[0];
            int index1 = (int) arr[1], index2 = (int) arr[2];
            if (!indexToNode.containsKey(index1) || !indexToNode.containsKey(index2) || newValue != indexToNode.get(index1).getValue() + indexToNode.get(index2).getValue()) {
                continue;
            }
            removals++;
            Node node1 = indexToNode.get(index1), node2 = indexToNode.get(index2);
            if (node1.getValue() > node2.getValue()) {
                reversePairs--;
            }
            if (node1.getPrev().getIndex() >= 0 && node1.getPrev().getValue() > node1.getValue()) {
                reversePairs--;
            }
            if (node2.getNext().getIndex() >= 0 && node2.getNext().getValue() < node2.getValue()) {
                reversePairs--;
            }
            node1.setValue(newValue);
            remove(node2);
            indexToNode.remove(index2);
            if (node1.getPrev().getIndex() >= 0) {
                pq.offer(new long[]{node1.getPrev().getValue() + node1.getValue(), node1.getPrev().getIndex(), node1.getIndex()});
                if (node1.getPrev().getValue() > node1.getValue()) {
                    reversePairs++;
                }
            }
            if (node1.getNext().getIndex() >= 0) {
                pq.offer(new long[]{node1.getValue() + node1.getNext().getValue(), node1.getIndex(), node1.getNext().getIndex()});
                if (node1.getNext().getValue() < node1.getValue()) {
                    reversePairs++;
                }
            }
        }
        return removals;
    }

    public void remove(Node node) {
        Node prev = node.getPrev(), next = node.getNext();
        prev.setNext(next);
        next.setPrev(prev);
    }
}

###C#

public class Solution {
    public class Node {
        public int Index { get; set; }
        public long Value { get; set; }
        public Node Prev { get; set; }
        public Node Next { get; set; }

        public Node() : this(-1, int.MaxValue) {

        }

        public Node(int index, long value) {
            Index = index;
            Value = value;
            Prev = null;
            Next = null;
        }
    }

    public int MinimumPairRemoval(int[] nums) {
        int removals = 0;
        IDictionary<int, Node> indexToNode = new Dictionary<int, Node>();
        Node pseudoHead = new Node();
        Node pseudoTail = new Node();
        Node prevNode = pseudoHead;
        int n = nums.Length;
        for (int i = 0; i < n; i++) {
            Node node = new Node(i, nums[i]);
            indexToNode.Add(i, node);
            prevNode.Next = node;
            node.Prev = prevNode;
            prevNode = node;
        }
        prevNode.Next = pseudoTail;
        pseudoTail.Prev = prevNode;
        Comparer<Tuple<long, int, int>> comparer = Comparer<Tuple<long, int, int>>.Create((a, b) => a.Item1 != b.Item1 ? a.Item1.CompareTo(b.Item1) : a.Item2.CompareTo(b.Item2));
        PriorityQueue<Tuple<long, int, int>, Tuple<long, int, int>> pq = new PriorityQueue<Tuple<long, int, int>, Tuple<long, int, int>>(comparer);
        int reversePairs = 0;
        for (int i = 0; i < n - 1; i++) {
            pq.Enqueue(new Tuple<long, int, int>(nums[i] + nums[i + 1], i, i + 1), new Tuple<long, int, int>(nums[i] + nums[i + 1], i, i + 1));
            if (nums[i] > nums[i + 1]) {
                reversePairs++;
            }
        }
        while (reversePairs > 0) {
            Tuple<long, int, int> tuple = pq.Dequeue();
            long newValue = tuple.Item1;
            int index1 = tuple.Item2, index2 = tuple.Item3;
            if (!indexToNode.ContainsKey(index1) || !indexToNode.ContainsKey(index2) || newValue != indexToNode[index1].Value + indexToNode[index2].Value) {
                continue;
            }
            removals++;
            Node node1 = indexToNode[index1], node2 = indexToNode[index2];
            if (node1.Value > node2.Value) {
                reversePairs--;
            }
            if (node1.Prev.Index >= 0 && node1.Prev.Value > node1.Value) {
                reversePairs--;
            }
            if (node2.Next.Index >= 0 && node2.Next.Value < node2.Value) {
                reversePairs--;
            }
            node1.Value = newValue;
            Remove(node2);
            indexToNode.Remove(index2);
            if (node1.Prev.Index >= 0) {
                pq.Enqueue(new Tuple<long, int, int>(node1.Prev.Value + node1.Value, node1.Prev.Index, node1.Index), new Tuple<long, int, int>(node1.Prev.Value + node1.Value, node1.Prev.Index, node1.Index));
                if (node1.Prev.Value > node1.Value) {
                    reversePairs++;
                }
            }
            if (node1.Next.Index >= 0) {
                pq.Enqueue(new Tuple<long, int, int>(node1.Value + node1.Next.Value, node1.Index, node1.Next.Index), new Tuple<long, int, int>(node1.Value + node1.Next.Value, node1.Index, node1.Next.Index));
                if (node1.Next.Value < node1.Value) {
                    reversePairs++;
                }
            }
        }
        return removals;
    }

    public void Remove(Node node) {
        Node prev = node.Prev, next = node.Next;
        prev.Next = next;
        next.Prev = prev;
    }
}

复杂度分析

  • 时间复杂度:$O(n \log n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。遍历数组初始化数据结构的时间是 $O(n \log n)$,最差情况需要执行 $n - 1$ 次操作,每次操作中的双向链表和哈希表的更新时间是 $O(1)$,优先队列的更新时间是 $O(\log n)$,因此时间复杂度是 $O(n \log n)$。

  • 空间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。双向链表、哈希表和优先队列的空间是 $O(n)$。

你不知道的 TypeScript:模板字符串类型

大部分前端应该都或多或少地使用过 TypeScript 开发,但是此文将带你探索少有人了解的 TS 领域:模板字符串类型。

这样的类型操作你见过吗?

type World = 'World'
type Greeting = `Hello ${World}` // "Hello World"

type UserName = 'cookie'
type UserNameCapitalize = Capitalize<UserName> // "Cookie"

type ButtonVariant = `btn-${'primary' | 'secondary'}-${'sm' | 'lg'}`
// "btn-primary-sm" | "btn-primary-lg" | "btn-secondary-sm" | "btn-secondary-lg"

看起来不可思议,但是这些都是 TypeScript 模板字符串类型的能力。

模板字符串类型(Template Literal Types) 是 TypeScript 4.1 引入的高级特性。它建立在字符串字面量类型的基础上,允许你通过类似 JavaScript 模板字符串的语法,动态地组合、操作和生成新的字符串类型。

接下来,我将从字符串字面量类型开始,逐步讲解到模板字符串类型的初级到高级的用法。

一、基础:什么是字符串字面量类型?

1. 定义

字符串字面量类型是指将一个具体的字符串值作为一种类型

// 普通的 string 类型
let s1: string = 'hello'
s1 = 'world' // 正确

// 字符串字面量类型
let s2: 'hello' = 'hello'
s2 = 'world' // 报错:不能将类型"world"分配给类型"hello"

2. 联合类型 (Union Types)

字符串字面量类型最常见的用法是配合联合类型使用,限制变量只能是某几个特定字符串之一。

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
function request(method: HttpMethod, url: string) {
  // ...
}
request('GET', url) // 正确
request('LET', url) // 报错:类型"LET"的参数不能赋给类型"HttpMethod"的参数。

二、进阶:模板字符串类型

1. 基础拼接

就像 JavaScript 中的模板字符串 ${var} 一样,TypeScript 类型也可以通过反引号 ``${} 配合实现字符串类型的插值。

// 字符串拼接
type World = 'World'
type Greeting = `Hello ${World}` // 类型 "Hello World"

// 多个插值
type Protocol = 'https'
type Domain = 'example.com'
type URL = `${Protocol}://${Domain}` // 类型 "https://example.com"

// 与其他类型结合
type Version = 1 | 2 | 3
type APIVersion = `v${Version}` // "v1" | "v2" | "v3"

在这里提醒一下大家,一定不要把类型和值搞混,这里操作的是类型,不要把它当做值去操作。一些错误示例:

console.log(World) // 报错:"World"仅表示类型,但在此处却作为值使用。
type Greeting = "Hello" + World // 报错:"World"仅表示类型,但在此处却作为值使用。

2. 联合类型的自动分发(笛卡尔积)

在模板字符串中,把多个联合类型组合在一起时,TypeScript 会自动生成所有可能的组合,也就是按笛卡尔积(Cartesian Product)组合。

type Space = 'sm' | 'md' | 'lg'
type Side = 't' | 'b' | 'l' | 'r'
type PaddingClass = `p-${Side}-${Space}`
// "p-t-sm" | "p-t-md" | "p-t-lg" | "p-b-sm" | "p-b-md" | "p-b-lg" | "p-l-sm" | "p-l-md" | "p-l-lg" | "p-r-sm" | "p-r-md" | "p-r-lg"

// 实际使用
function addPadding(el: HTMLElement, className: PaddingClass) {
  el.classList.add(className)
}
const div = document.createElement('div')
addPadding(div, 'p-t-sm') // 正确
addPadding(div, 'p-x-xx') // 类型错误

性能提示:当联合类型的成员数量较多时,组合后的类型数量会呈指数级增长(例如 3 个联合类型各有 10 个成员,组合后会有 1000 种可能),这可能会导致 TypeScript 编译器性能下降或类型检查变慢。

3. 内置工具类型

为了性能和方便,TypeScript 编译器内置了四个特殊的工具类型。它们不是通过 TS 代码实现的,而是直接由编译器处理。

  • Uppercase<S> 将字符串中的每个字符转换为大写
  • Lowercase<S> 将字符串中的每个字符转换为小写
  • Capitalize<S> 将字符串的第一个字符转换为大写
  • Uncapitalize<S> 将字符串的第一个字符转换为小写

下面是使用示例:

// Uppercase:全部转大写
type Color = 'red'
type UpperColor = Uppercase<Color> // "RED"

// Lowercase:全部转小写
type MixedCase = 'TypeScript'
type LowerCase = Lowercase<MixedCase> // "typescript"

// Capitalize:首字母大写
type Name = 'cookie'
type CapName = Capitalize<Name> // "Cookie"

// Uncapitalize:首字母小写
type Components = 'Button' | 'Input' | 'Modal'
type UncapComponents = Uncapitalize<Components> // "button" | "input" | "modal"

结合模板字符串使用:

// 生成事件处理器名称
type Events = 'click' | 'change' | 'input'
type EventHandlers = `on${Capitalize<Events>}`
// "onClick" | "onChange" | "onInput"

// 生成 CSS 类名
type Size = 'sm' | 'MD' | 'Lg'
type SizeClass = `size-${Lowercase<Size>}`
// "size-sm" | "size-md" | "size-lg"

三、 高阶:模式匹配与 infer

掌握了基础的模板字符串拼接后,接下来我们进入更强大的领域——模式匹配。要在类型中解析字符串(例如提取参数、去掉空格),我们需要结合 extendsinfer

1. 什么是 infer

infer 属于 TS 的高阶用法,它需要配合条件语句一起使用:

A extends B ? C : D

含义是:如果类型 A 可以赋值给类型 B,则结果为 C,否则为 D。

infer 的作用是在条件类型的 extends 子句(也就是 B 语句)中 声明一个待推断的类型变量。可以把它理解为"占位符",让 TypeScript 帮你从某个复杂类型中"提取"出一部分。当类型匹配成功时,infer 声明的类型变量会被推断为匹配到的具体类型。

举一些实用的例子:

// 获取 Promise 返回值的类型
type UnpackPromise<T> = T extends Promise<infer R> ? R : T
type P1 = UnpackPromise<Promise<string>> // string

// 获取数组中元素类型
type GetArrayType<T> = T extends (infer U)[] ? U : never
type A1 = GetArrayType<number[]> // number

// 获取函数的返回值类型
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never
type R1 = GetReturnType<() => boolean> // boolean

2. 在模板字符串类型中使用 infer

在模板字符串中 infer 的使用方法同上,也需要配合条件语句,区别是我们需要通过 ${infer T} 把它插入到模板字符串类型中,通过模式匹配来提取字符串的特定部分。

比如提取出字符串类型空格后面的部分。

type GetSurname<S> = S extends `${infer First} ${infer Last}` ? Last : never

type T1 = GetSurname<'Hello Cookie'> // "Cookie"
type T2 = GetSurname<'Cookie'> // never (因为没有空格,匹配失败)

3. 贪婪与非贪婪匹配

多个 infer 连用,TypeScript 使用 “非贪婪” 匹配策略,也就是会遵循 “从左到右,尽可能匹配最小单位” 的原则。

比如,当使用 ${infer A}${infer B} 时:

  • A(第一个 infer):匹配第一个字符(最短匹配)
  • B(第二个 infer):匹配剩余所有字符(由于 A 已经匹配了第一个字符,B 必须匹配剩余的全部内容)

举例说明:

// 三个连续的 infer 会依次匹配:A 匹配第一个字符,B 匹配第二个字符,C 匹配剩余所有字符
type Split<S> = S extends `${infer A}${infer B}${infer C}` ? [A, B, C] : []
type Res = Split<'Hello'> // ["H", "e", "llo"]

// 有占位符的情况,也只会匹配到第一个
type GetExt<S> = S extends `${infer Name}.${infer Ext}` ? Ext : never
type E2 = GetExt<'ts.config.json'> 
// "config.json"(Name 匹配到第一个点之前,Ext 获取之后的所有内容)

4. infer 与联合类型联动

当模式中包含联合类型时,TypeScript 会尝试匹配联合类型中的任意一个成员:

// 提取指定几种扩展类型的文件名
type ImageExt = 'jpg' | 'png' | 'gif' | 'webp'
type ExtractRawName<FileName> = FileName extends `${infer Name}.${ImageExt}`
  ? Name
  : never

type E1 = ExtractRawName<'photo.jpg'> // "photo"
type E2 = ExtractRawName<'logo.png'> // "logo"
type E3 = ExtractRawName<'document.pdf'> // never (pdf 不在 ImageExt 联合类型中)

TypeScript 会对联合类型中的每一个成员分别执行 infer 逻辑,最后再把结果重新组合成一个新的联合类型,在模板字符串中也一样。

type IconNames = 'icon-home' | 'icon-user' | 'icon-settings' | 'logo-main'

// 提取所有以 "icon-" 开头的图标名称
type ExtractIcon<T> = T extends `icon-${infer Name}` ? Name : never

type PureNames = ExtractIcon<IconNames>
// 结果: "home" | "user" | "settings"
// 注意: "logo-main" 匹配失败,返回 never,在联合类型中 never 会被自动过滤掉

5. 递归类型 (Recursive Types)

在上一节的基础上,我们再结合递归,可以更加灵活的处理字符串,接下来以 TrimLeft 举例:

目标:去除字符串左边的空格。

type Space = ' ' | '\n' | '\t' // 联合类型 包含三种空白字符

// 如果第一个字符是空白字符,就取除了第一个空白字符的剩余字符串,然后递归处理
// 否则直接取整个字符
type TrimLeft<S extends string> = S extends `${Space}${infer R}`
  ? TrimLeft<R> // 递归调用
  : S // 终止

type T = TrimLeft<'  cookie'> // "cookie"

如果你在纠结为什么不是 \s 而是 ' '?这是因为 TypeScript 的模板字符串类型不支持正则表达式语法。这里的 ' ''\n''\t' 都是具体的字符串字面量类型,而 \s 是正则表达式的特殊语法,在类型系统中没有意义。

四、 映射类型与模板字符串的结合

TypeScript 4.1 不仅引入了模板字符串类型,还支持在映射类型中使用 as 重命名键名。

1. as 语法

type MappedType<T> = {
  [K in keyof T as NewKeyType<K>]: T[K]
}

在之前《面试官:请实现 TS 中的 Pick 和 Omit》一文中,在 Omit 的实现中就用到了 as 来剔除一些类型:

type MyOmit<T, K> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

type Todo = {
  title: string
  description: string
  completed: boolean
}
type TodoWithoutDescription = MyOmit<Todo, 'description'>
/*
type TodoWithoutDescription = {
  title: string
  completed: boolean
}
*/

2. 在模板字符串中使用

示例 1:添加前缀/后缀

type AddPrefix<T, Prefix extends string> = {
  [K in keyof T as `${Prefix}${string & K}`]: T[K]
}

interface User {
  name: string
  age: number
}

type PrefixedUser = AddPrefix<User, 'user_'>
// { user_name: string; user_age: number }

为什么要 string & K

因为 K 的类型是 keyof T,可能是 string | number | symbol。用交叉类型 & 将其约束为 string

示例 2:生成 Getter/Setter

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (val: T[K]) => void
}

interface State {
  count: number
  name: string
}

type StateGetters = Getters<State>
// { getCount: () => number; getName: () => string }

type StateSetters = Setters<State>
// { setCount: (val: number) => void; setName: (val: string) => void }

示例 3:移除特定前缀

type RemovePrefix<T, Prefix extends string> = {
  [K in keyof T as K extends `${Prefix}${infer Rest}` ? Rest : K]: T[K]
}

interface ApiResponse {
  api_name: string
  api_token: string
  userId: number
}

type CleanResponse = RemovePrefix<ApiResponse, 'api_'>
// { name: string; token: string; userId: number }

// 注意:传入空字符串作为前缀时,由于空字符串会匹配所有键名,但实际上不会移除任何内容
type CleanResponse1 = RemovePrefix<ApiResponse, ''>
// { api_name: string; api_token: string; userId: number }

五、 总结

写这篇文章因为我在刷 TypeScript 类型体操 时,遇到了第一个模板字符串类型的题目 TrimLeft 搜了半天没有发现现成的文章,干脆自己写一个。

如果你也对 TypeScript 类型体操感兴趣,欢迎一起来刷!💪🏻💪🏻💪🏻

GDAL 实现影像裁剪

^ 关注我,带你一起学GIS ^

前言

GDAL作为地理空间数据处理的核心工具,提供了多种影像裁剪方式,可以方便的提取目标区域遥感影像数据,为数据处理和分析提供高效服务。

在地理空间数据处理中,影像裁剪是基础且高频的操作,其核心目标是从整幅遥感影像或栅格数据中提取指定地理范围的子集,以降低数据体量、聚焦研究区域,满足专题分析、地图制作、数据共享等多样化业务需求。

由于本文由一些前置知识,在正式开始之前,需要你掌握一定的Python开发基础和GDAL的基本概念。在之前的文章中讲解了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,可以作为基础入门学习。

本篇教程在之前一系列文章的基础上讲解如何使用GDAL 实现影像裁剪

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2026年

系统:Windows 11

Python:3.11.11

GDAL:3.11.1

2. 数据准备

俗话说巧妇难为无米之炊,数据就是软件的基石,没有数据,再美好的设想都是空中楼阁。因此,第一步需要下载遥感影像数据。

但是,影像数据在哪里下载呢?别着急,本文都给你整理好了。

数据下载可参考文章:GIS 影像数据源介绍

如下,这是我在【地理空间数据云】平台下载的landsat8遥感影像。

3. 导入依赖

GeoTIFF作为一种栅格数据格式,可以使用GDAL直接进行处理,以实现影像数据的裁剪操作。

在影像裁剪开始之前,需要检查数据路径是否正确,所以导入os模块。

from osgeo import gdal
import os

4. 影像裁剪

定义一个方法image_clip(output_file,input_file,clip_file)用于实现影像数据裁剪。

本研究采用矢量范围提取影像区域,实现栅格数据按掩膜裁剪功能。

"""
说明:GDAL 影像裁剪
参数:
    -output_file:输出裁剪后的影像
    -input_files:输入需要裁剪的的影像
    -clip_file:用于影像裁剪的矢量文件
"""
def image_clip(output_file,input_file,clip_file):

在数据裁剪之前,使用方法checkFilePath检查数据路径。

# 检查文件是否存在
checkFilePath(input_file)
"""
说明:检查文件路径是否正常
参数:
-filePath:文件数据路径
"""
def checkFilePath(filePath):
    if os.path.exists(filePath):
        print(f"{filePath} 文件数据路径存在")
    else:
        print(f"{filePath} 文件数据路径不存在,请检查!")

使用gdal.WarpOptions选项定义裁剪参数,其中参数cutlineDSName为用于提取范围的矢量数据源,cropToCutline为布尔类型,表示是否使用裁剪线作为输出边界,dstNodata为设置NoData值。

# 定义裁剪参数
options = gdal.WarpOptions(
    cutlineDSName=clip_file,
    cropToCutline=True,
    dstNodata=0
)

这个方法参数非常多,感兴趣的同学可以到官网查看。

https://gdal.org/en/stable/api/python/utilities.html#osgeo.gdal.WarpOptions

调用gdal对象方法Warp进行影像裁剪,该函数第一个参数destNameOrDestDS 为输出数据集名称或者数据源,第二个参数srcDSOrSrcDSTab为源数据,第三个参数options为可选项描述,用于定义影像裁剪信息。

gdal.Warp(output_file,input_file,options=options)

main函数中调用合并方法。

if __name__ == "__main__":

    input_file"E:\ArcGIS\band_432.tif"

    output_file"E:\ArcGIS\clip_result.tif"

    clip_file"E:\data\target.shp"

    image_clip(output_file,input_file,clip_file)

注:GDAL某些参数是真难记,难写啊,比如WarpOptions对象。

GIS之路

图片效果

OpenLayers示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现影像合并

小小声说一下GDAL的官方API接口

《云南省加快构建现代化产业体系推进产业强省建设行动计划》发布

ArcGIS Pro 添加底图的方式

为什么每次打开 ArcGIS Pro 页面加载都如此缓慢?

ArcGIS 波段合成操作

自然资源部党组关于苗泽等4名同志职务任免的通知

GDAL 创建矢量图层的两种方式

GDAL 实现矢量数据转换处理(全)

GDAL 实现投影转换

GDAL 实现矢量合并

国产版的Google Earth,吉林一号卫星App“共生地球”来了

2026年全国自然资源工作会议召开

GDAL 实现矢量裁剪

GDAL 实现空间分析

vite.config.js 8 大核心模块,一文吃透

一、Vite 是什么?—— 面向未来的前端构建工具

Vite(法语意为“快”)是由 Vue 作者尤雨溪创建的新型前端构建工具。它利用浏览器原生支持 ES 模块(ESM)的能力,在开发环境下实现了极快的冷启动和热更新;而在生产环境中,则通过预构建依赖和 Rollup 打包输出高性能代码。

vite.config.js 作为 Vite 工程的核心配置文件,定义了整个项目的运行规则、编译逻辑和部署方案,是连接 Vite 核心能力与项目实际需求的桥梁,一份完善的 vite.config.js 能够让前端工程化流程更高效、更规范。

二、核心概念对比:Vite vs Webpack

虽然 Vite 和 Webpack 都用于构建前端应用,但它们的设计哲学完全不同,核心概念的差异直接决定了两者的使用体验和性能表现:

概念 Webpack Vite
Entry(入口) 显式配置 entry 字段,从 JS 入口开始递归解析依赖 默认以 index.html 为入口,开发时按需加载 ESM 模块,生产环境可显式配置 HTML 入口
Chunk(代码块) 构建阶段静态分析生成 chunks 开发时无需生成 chunk,生产构建依托 Rollup 实现动态代码拆分
Loader(转换器) 使用 loader 处理非 JS 资源(如 babel-loader, sass-loader) 无明确 Loader 概念,通过插件机制 + 内置转换器处理特殊资源,更灵活高效
Plugin(插件) 插件监听生命周期钩子扩展功能 插件系统强大,支持开发、构建双模式介入,兼容部分 Rollup 插件
Output(输出) 输出 bundle 到指定目录,需额外配置优化 生产环境输出优化后的静态资源,内置多种打包优化策略,配置更简洁

关键区别:开发与生产环境的差异化处理

Webpack:开发和生产环境均走完整的打包流程,所有模块需提前编译合并为 bundle 文件,项目体积越大,启动和更新速度越慢。

Vite:

  1. 开发环境:基于浏览器 ESM 直接运行,不进行全量打包,仅对浏览器请求的模块进行即时编译,响应速度极快。
  2. 生产环境:使用 Rollup 进行完整打包,产出经过代码压缩、树摇优化、资源分类的静态资源,兼顾性能与兼容性。

这正是 Vite 能够实现“秒级启动”的根本原因,既保证了开发体验,又满足了生产环境的部署要求。

三、vite.config.js 核心模块配置实战

vite.config.js 采用模块化导出方式,支持根据环境动态返回配置,下面将按照功能模块拆解配置逻辑,详细说明各部分的配置目的和实现方式。

模块一:环境初始化与多环境配置

这是配置文件的前置步骤,核心是获取当前环境变量,实现不同环境下的差异化配置,依赖 Vite 内置的 loadEnv 方法。

配置逻辑

  1. 接收 Vite 传入的 mode 参数,该参数对应启动/构建命令中的环境(如 development、production)。
  2. 通过 loadEnv 加载对应环境的配置文件(如 .env.development、.env.production)。
  3. 定义不同环境下的公共路径、输出目录等核心配置,实现环境隔离。

代码实现

import { defineConfig, loadEnv } from 'vite';
import path from 'path';
import rimraf from 'rimraf';

// 生成时间戳,用于生产环境版本隔离
function createFileDate () {
  const today = new Date();
  const y = today.getFullYear();
  const m = today.getMonth() + 1 > 9 ? today.getMonth() + 1 : '0' + (today.getMonth() + 1);
  const d = today.getDate() > 9 ? today.getDate() : '0' + today.getDate();
  const h = today.getHours() > 9 ? today.getHours() : '0' + today.getHours();
  const M = today.getMinutes() > 9 ? today.getMinutes() : '0' + today.getMinutes();
  return y + '' + m + '' + d + '' + h + '' + M;
}

export default ({ mode }) => {
  // 第一步:加载环境变量,指定环境配置文件所在目录
  const env = loadEnv(mode, path.join(process.cwd(), './env'));
  const fileDateDir = createFileDate();
  
  // 第二步:定义多环境核心配置项
  // 生产环境 CDN 公共路径
  const prodPublicPath = `https://yyt.com/resources/ph7/${fileDateDir}/`;
  // 测试环境本地公共路径
  const testPublicPath = '/ph7/';
  
  // 第三步:生产环境前置清理旧构建产物
  if (mode === 'production') {
    rimraf(path.join(process.cwd(), './dist'), (err) => {
      if (err) console.error('清理 dist 目录失败:', err);
    });
  }

  // 返回最终配置
  return defineConfig({
    // 配置公共基础路径,根据环境切换
    base: mode === 'production' ? prodPublicPath : testPublicPath,
    // 其他核心配置...
  });
};

配置说明

  1. loadEnv 第一个参数为环境模式,第二个参数为环境配置文件目录,会自动加载该目录下 .env.${mode} 格式的文件。
  2. 生产环境构建前通过 rimraf 清理旧的 dist 目录,避免旧资源残留导致部署问题。
  3. 公共路径 base 用于配置打包后资源的根路径,生产环境配置 CDN 地址,测试环境配置本地子路径,解决资源 404 问题。

模块二:生产构建配置(build)

该模块是 vite.config.js 的核心之一,用于定义生产环境打包的输出规则、优化策略,所有配置均放在 build 字段下,依托 Rollup 实现打包能力。

配置逻辑

  1. 配置差异化输出目录,实现生产环境版本隔离。
  2. 开启输出目录自动清空,避免手动清理遗漏。
  3. 配置 Rollup 打包参数,包括入口、代码块输出规则、静态资源分类输出规则。
  4. 配置插件实现打包后资源自动拷贝,满足本地部署需求。
  5. 配置 SourceMap 生成规则,兼顾调试与安全。

代码实现

return defineConfig({
  // 其他配置...
  build: {
    // 1. 差异化输出目录:生产环境带时间戳,测试环境简易目录
    outDir: mode === 'production' ? `dist/cdn/${fileDateDir}` : 'dist',
    // 2. 打包前自动清空 outDir 对应的目录
    emptyOutDir: true,
    // 3. 是否生成 SourceMap:开发环境生成,生产环境关闭(安全+减小体积)
    sourcemap: mode === 'development',
    // 4. Rollup 打包详细配置
    rollupOptions: {
      // 配置打包入口:指定 index.html 作为入口文件
      input: {
        main: path.resolve(__dirname, 'index.html')
      },
      // 配置输出规则
      output: {
        // 入口代码块输出规则:输出到 assets/js 目录,添加 hash 后缀
        entryFileNames: 'assets/js/[name]-[hash].js',
        // 公共/异步代码块输出规则:与入口代码块统一目录
        chunkFileNames: 'assets/js/[name]-[hash].js',
        // 静态资源分类输出规则:按文件类型拆分目录
        assetFileNames: ({ name }) => {
          if (name.endsWith('.css')) {
            return 'assets/css/[name]-[hash][extname]';
          }
          if (name.endsWith('.html')) {
            return 'assets/html/[name]-[hash][extname]';
          }
          if (
            name.endsWith('.png') ||
            name.endsWith('.jpg') ||
            name.endsWith('.jpeg') ||
            name.endsWith('.svg')
          ) {
            return 'assets/img/[name]-[hash][extname]';
          }
          if (
            name.endsWith('.xls') ||
            name.endsWith('.xlsx') ||
            name.endsWith('.csv') ||
            name.endsWith('.pdf')
          ) {
            return 'assets/files/[name]-[hash][extname]';
          }
          if (
            name.endsWith('.ttf') ||
            name.endsWith('.eot') ||
            name.endsWith('.woff') ||
            name.endsWith('.otf')
          ) {
            return 'assets/fonts/[name]-[hash][extname]';
          }
          // 默认输出目录
          return 'assets/[name]-[hash][extname]';
        }
      },
      // 5. Rollup 插件配置:打包后资源拷贝
      plugins: [
        copy({
          targets: [
            {
              src: [
                `dist/cdn/${fileDateDir}/json`,
                `dist/cdn/${fileDateDir}/locales`,
                `dist/cdn/${fileDateDir}/index.html`
              ],
              dest: 'dist/local'
            }
          ],
          // 打包完成后执行拷贝
          hook: 'writeBundle',
          // 扁平化目录结构,避免多级嵌套
          flatten: true
        })
      ]
    }
  },
});

配置说明

  1. outDir 定义打包输出目录,生产环境使用带时间戳的目录名,实现版本隔离,防止旧资源缓存导致线上问题。
  2. Vite 生产打包(vite build)时,默认会给静态资源文件(CSS/图片/字体等)添加内容哈希,规则是:文件名格式:[name].[hash].[ext](比如 app.8a3b2.js)。对于普通 JS 文件,需通过配置 entryFileNames/chunkFileNames 手动添加 hash。
  3. assetFileNames 实现静态资源分类,将 CSS、图片、办公文件、字体分别放入对应目录,便于部署和运维排查。
  4. rollupOptions.plugins 中配置 rollup-plugin-copy,在打包完成后将核心资源拷贝到 dist/local,满足本地测试部署需求。

模块三:静态资源扩展配置(assetsInclude)

Vite 有默认支持的静态资源类型,对于一些特殊格式的文件(如 xlsx、pdf),需要通过 assetsInclude 扩展识别,确保打包时能正确处理这些资源。

配置逻辑

  1. 以数组形式列出需要扩展的静态资源后缀。
  2. 配置在顶层字段中,全局生效。

代码实现

return defineConfig({
  // 其他配置...
  // 扩展静态资源类型识别
  assetsInclude: [
    '**/*.xlsx',
    '**/*.xls',
    '**/*.csv',
    '**/*.pdf',
    '**/*.png',
    '**/*.jpg',
    '**/*.svg'
  ],
});

配置说明

  1. 通配符 **/ 表示匹配所有目录下的对应文件。
  2. 配置后,这些特殊格式文件可以通过 import 引入,打包时会按照 build.rollupOptions.output 中的规则输出到对应目录。

模块四:插件配置(plugins)

插件是 Vite 扩展功能的核心载体,通过配置不同插件,可以实现 Vue 解析、JSX 支持、HTML 优化等功能,所有插件配置在 plugins 数组中,按需求引入并初始化。

配置逻辑

  1. 安装所需插件(如 @vitejs/plugin-vue)。
  2. 在配置文件中导入插件。
  3. plugins 数组中初始化插件,传入必要的配置参数。

代码实现

import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import { createHtmlPlugin } from 'vite-plugin-html';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';

return defineConfig({
  // 其他配置...
  plugins: [
    // 1. 解析 Vue 单文件组件(.vue),Vue 项目必备
    vue({
      template: {
        transformAssetUrls: {
          video: ['src', 'poster'],
          source: ['src'],
          img: ['src'],
          image: ['xlink:href', 'href'],
          use: ['xlink:href', 'href'],
          a: ['downloadHref']
        }
      }
    }),
    // 2. 支持 Vue JSX/TSX 语法解析
    vueJsx({}),
    // 3. HTML 优化插件:压缩 HTML、动态注入数据
    createHtmlPlugin({
      minify: true,
      entry: 'src/main.js',
      inject: {
        data: {}
      }
    }),
    // 4. SVG 图标管理插件:生成 SVG Sprite,实现图标复用
    createSvgIconsPlugin({
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      symbolId: 'g-icon-[name]'
    })
  ],
});

配置说明

  1. @vitejs/plugin-vue:Vue 项目核心插件,用于解析 .vue 单文件组件,transformAssetUrls 配置用于修正模板中资源的路径解析。
  2. @vitejs/plugin-vue-jsx:支持 JSX/TSX 语法,满足个性化编码需求,无需额外配置即可使用。
  3. vite-plugin-html:生产环境自动压缩 HTML,支持动态注入数据到 HTML 中,提升页面加载性能。
  4. vite-plugin-svg-icons:将指定目录下的 SVG 图标生成 Sprite,在项目中可通过 <svg><use xlink:href="#g-icon-xxx"></use></svg> 复用,避免图标重复引入。

模块五:路径别名配置(resolve.alias)

在大型项目中,相对路径(如 ../../../src/components/Modal)会降低开发效率和代码可维护性,通过 resolve.alias 配置路径别名,可简化模块引入路径。

配置逻辑

  1. 借助 path.resolve 解析绝对路径。
  2. resolve.alias 中定义别名与对应目录的映射关系。

代码实现

return defineConfig({
  // 其他配置...
  resolve: {
    // 优先解析 browser 字段和 module 字段
    mainFields: ['browser', 'module'],
    // 路径别名配置
    alias: {
      '@': path.resolve('./src'), // 映射 src 目录
      '@LC': path.resolve('../lib-components/src'), // 映射外部组件库目录
      '#': path.resolve('./types'), // 映射类型定义目录
      'td-print': path.resolve('./node_modules/td-print/index.js') // 映射特定模块
    }
  },
});

配置说明

  1. 配置后,可使用 @/components/Modal 替代 ../../../src/components/Modal,简化路径书写。
  2. 别名不仅支持目录映射,还支持单个文件映射(如 td-print):
    • td-print 是自定义模块别名,对应项目中 ./node_modules/td-print/index.js(前端打印相关第三方库);
    • 不配置别名时需写完整路径 import print from './node_modules/td-print/index.js',配置后可直接 import print from 'td-print',简化导入、提升可读性。
  3. path.resolve 用于生成绝对路径,避免不同操作系统下的路径分隔符问题。
  4. 补充:路径别名需配合编辑器配置(如 tsconfig.json/jsconfig.jsoncompilerOptions.paths),实现代码提示和跳转。

模块六:CSS 预处理器配置(css)

Vite 内置支持 SCSS、Less 等 CSS 预处理器,只需安装对应的依赖,再通过 css.preprocessorOptions 配置预处理器参数,即可正常使用。

配置逻辑

  1. 安装 SCSS 依赖(sass,注意不是 node-sass)。
  2. css.preprocessorOptions.scss 中配置编译参数、抑制弃用警告等。

代码实现

return defineConfig({
  // 其他配置...
  css: {
    preprocessorOptions: {
      scss: {
        // 使用现代编译器 API,提升兼容性和编译性能
        api: 'modern-compiler',
        // 抑制 import 相关的弃用警告,保持构建日志整洁
        silenceDeprecations: ['import']
      }
    }
  },
});

配置说明

  1. 使用 SCSS 前需安装依赖:npm install sass --save-dev
  2. api: 'modern-compiler' 指定使用现代编译器 API,替代旧的 node-sass 编译器,提升编译速度和兼容性。
  3. silenceDeprecations 用于抑制不必要的弃用警告,避免构建日志被冗余信息覆盖。

模块七:本地开发服务器配置(server)

该模块用于配置本地开发服务器的相关参数,包括端口、跨域代理、主机访问权限等,核心是通过 proxy 配置解决本地开发的接口跨域问题。

配置逻辑

  1. 配置服务器端口和主机访问权限。
  2. 通过 proxy 配置接口转发规则,将前端请求转发到后端服务。
  3. 配置 changeOrigin 实现跨域模拟,配置 rewrite 修正请求路径。

代码实现

return defineConfig({
  // 其他配置...
  server: {
    // 配置本地开发服务器端口
    port: 8387,
    // 允许外部设备访问(如手机、同一局域网的其他电脑)
    host: true,
    // 跨域代理配置
    proxy: {
      // 匹配以 /charm 开头的请求
      '/charm': {
        // 后端服务目标地址
        target: 'http://10.1.11.11:58***/',
        // 开启跨域模拟:修改请求头中的 Origin 为目标地址
        changeOrigin: true,
        // 路径重写:此处保持原路径不变,可根据需求修改
        rewrite: (path) => path.replace(/^\/charm/, '/charm')
      },
      // 可配置多个代理规则
      '/g-filestore': {
        target: 'http://10.5.11.11:8***/',
        changeOrigin: true
      }
    }
  },
});

配置说明

  1. port 配置本地开发端口,避免与其他服务端口冲突。
  2. host: true 允许外部设备访问,方便在手机上调试移动端页面。
  3. proxy 中的 target 为后端服务地址,changeOrigin: true 是解决跨域的核心,通过修改请求头的 Origin 模拟同源请求。
  4. rewrite 用于修正请求路径,若前端请求路径与后端接口路径不一致,可通过该配置进行调整。

模块八:全局常量注入配置(define)

通过 define 可以在项目中注入全局常量,这些常量会在打包时被静态替换,无需手动引入即可在代码中直接使用,适用于埋点、版本号、CDN 路径拼接等场景。

配置逻辑

  1. define 中定义全局常量,注意字符串类型需要使用 JSON.stringify 包裹。
  2. 在项目代码中直接访问该常量。

代码实现

return defineConfig({
  // 其他配置...
  define: {
    // 注入时间戳全局常量,用于版本标识
    'import.meta.env.VITE_APP_LOCAL_HASH': JSON.stringify(fileDateDir)
  },
});

配置说明

  1. define 中的键名建议遵循 import.meta.env.XXX 格式,与 Vite 内置环境变量格式保持一致。
  2. 字符串类型必须使用 JSON.stringify 包裹,否则打包时会被当作变量解析,导致报错。
  3. 在项目代码中可直接使用:console.log(import.meta.env.VITE_APP_LOCAL_HASH),打包后会被静态替换为对应的时间戳字符串。

四、Vite 的构建流程

尽管 Vite 采用了与 Webpack 不同的底层机制,但它依然遵循清晰的构建流程,分为开发环境和生产环境两个阶段:

1. 开发环境构建流程

  1. 初始化参数:解析 vite.config.js,合并命令行参数,加载环境变量和插件。
  2. 启动开发服务器:创建 HTTP 服务,监听指定端口,开启 WebSocket 通信(用于热更新)。
  3. 确定入口:加载根目录下的 index.html,自动修正其中的资源路径和公共基础路径。
  4. 按需编译模块:浏览器请求某个模块时,Vite 实时对该模块进行编译(如 Vue SFC 解析、TS 转 JS),修正依赖路径后返回给浏览器。
  5. 热更新(HMR):监听项目文件变化,仅重新编译修改的单个模块,通过 WebSocket 向浏览器推送更新通知,浏览器直接替换对应模块,无需全页刷新。
  6. 接口代理:根据 server.proxy 配置,将前端请求转发到后端服务,解决跨域问题。

2. 生产环境构建流程

  1. 环境准备:解析 mode 参数,加载对应环境变量,清理旧的打包产物。
  2. 初始化编译器:合并 vite.config.js 中的 build 配置,初始化 Rollup 编译器,注册所有插件。
  3. 解析入口与依赖:以 index.html 为入口,递归分析所有模块的依赖关系,构建完整的依赖图谱。
  4. 模块编译与优化:对所有模块进行编译转换,执行 Tree Shaking 剔除死代码,进行代码压缩和混淆。
  5. 组装与输出资源:将模块组装为入口代码块、公共代码块,按照配置的输出规则将静态资源写入指定目录。
  6. 后续自动化操作:执行插件的 writeBundle 钩子(如资源拷贝),生成最终的打包产物,完成构建。

五、Vite 的核心优势与适用场景

核心优势

  1. 极速启动:利用浏览器原生 ESM,无需全量打包,开发环境启动速度远超传统打包工具。
  2. 快速热更新:仅更新修改的单个模块,热更新响应无延迟,大幅提升开发效率。
  3. 丰富的插件生态:支持 Vue、React、TypeScript 等主流技术栈,兼容部分 Rollup 插件,扩展能力强。
  4. 开箱即用:内置 TypeScript、JSX、CSS 预处理器等支持,无需额外复杂配置。
  5. 高度可配置:vite.config.js 提供完善的配置项,可满足各类项目的工程化需求。
  6. 优化的生产打包:基于 Rollup 实现,产出的静态资源体积小、性能优,满足生产环境部署要求。

适用场景

  1. 新一代 SPA/MPA 项目开发。
  2. 前端组件库开发。
  3. 内部中后台系统、管理平台开发。
  4. 需要快速迭代的原型项目。
  5. 注重开发者体验的团队和项目。

六、总结

vite.config.js 作为 Vite 项目的核心配置文件,涵盖了环境配置、打包输出、插件扩展、本地开发等多个模块,一份完善的配置能够让前端工程化流程更规范、更高效。

Vite 凭借“开发环境按需编译、生产环境 Rollup 打包”的差异化策略,既解决了传统打包工具的性能瓶颈,又满足了生产环境的部署要求,是现代前端开发的优质选择。掌握 vite.config.js 的配置逻辑,能够充分发挥 Vite 的核心优势,助力项目高效开发与部署。

《实时渲染》第2章-图形渲染管线-2.3几何处理

实时渲染

2. 图形渲染管线

2.3 几何处理

GPU上的几何处理阶段负责大多数每个三角形和每个顶点的操作。该阶段进一步分为以下功能阶段:顶点着色、投影、裁剪和屏幕映射(如图2.3)。

图2.3. 几何处理阶段分为一系列功能阶段

2.3.1 顶点着色

顶点着色有两个主要任务,即计算顶点的位置和评估程序员可能喜欢的顶点输出数据,例如法线坐标和纹理坐标。传统上,对象的大部分阴影是通过将灯光应用到每个顶点的位置和法线并仅将结果颜色存储在顶点来计算的。然后将这些颜色插入整个三角形。出于这个原因,这个可编程的顶点处理单元被命名为顶点着色器[1049]。随着现代GPU的出现,以及每个像素发生的部分或全部着色,这个顶点着色阶段更加通用,并且可能根本不会求取任何着色方程的值,其工作主要取决于程序员的意图。顶点着色器现在是一个更通用的单元,专门用于设置与每个顶点关联的数据。例如,顶点着色器可以使用第4.4节第4.5节中的方法为对象设置动画。

我们首先描述如何计算顶点位置,一组始终需要的坐标。在被屏幕显示的过程中,模型被转换成几个不同的空间或坐标系。最初,模型驻留在自己的模型空间中,这仅意味着它根本没有被转换。每个模型都可以与模型变换相关联,以便可以对其进行定位和定向。可以将多个模型转换与单个模型相关联。这允许同一模型的多个副本(称为实例)在同一场景中具有不同的位置、方向和大小,而无需复制基本几何体。

模型变换所变换的是模型的顶点和法线。对象的坐标称为模型坐标,在对这些坐标应用模型变换后,模型被称为位于世界坐标或世界空间中。世界空间是唯一的,模型经过各自的模型变换后,所有的模型都存在于同一个空间中。

如前所述,模型只有被相机(或观察者)看到才能渲染。相机在世界空间中有一个位置和一个方向,用于放置和瞄准相机。为了便于投影和剪辑,相机和所有模型都使用视图变换进行了变换。视图变换的目的是将相机放置在原点并瞄准它,使其看向负z轴的方向,y轴指向上方,x轴指向右侧。我们使用-z轴约定;一些文章也会使用向下看+z轴的约定。区别主要是语义上的,因为一个和另一个之间的转换很简单。应用视图变换后的实际位置和方向取决于底层应用程序编程接口 (API)。如此划定的空间称为相机空间,或更常见的是,视图空间或眼睛空间。视图变换影响相机和模型的方式示例如图2.4所示。模型变换和视图变换都可以用4×4矩阵来实现,这是第4章的主题。但是,重要的是要意识到可以以程序员喜欢的任何方式计算顶点的位置和法线。

图2.4 在左图中,自上而下的视图显示了在+z轴向上的坐标系中,按照用户希望的方式定位和定向的相机。视图变换重新定向了坐标系,使相机位于原点,沿其负z轴看,相机的+y轴向上,如右图所示。这样做是为了使裁剪和投影操作更简单、更快捷。浅蓝色区域是视锥体。在这里,假设透视图,因为视锥体是一个平截头体。类似的技术适用于任何类型的投影。

接下来,我们将描述顶点着色的第二种类型的输出。要生成逼真的场景,仅渲染对象的形状和位置是不够的,还必须对它们的外观进行建模。该描述包括每个物体的材质,以及任何光源照射在物体上的效果。材料和灯光可以通过多种方式建模,从简单的颜色到物理描述的精细表示。

这种确定光对材料效果的操作称为着色。它涉及计算对象上不同点的着色方程。通常,其中一些计算在模型顶点的几何处理期间执行,而其他计算可能在逐像素处理期间执行。各种材质数据可以存储在每个顶点,例如点的位置、法线、颜色或求取着色方程值所需的任何其他数字信息。然后,顶点着色结果(可以是颜色、矢量、纹理坐标以及任何其他类型的着色数据)被发送到光栅化和像素处理阶段进行插值并用于计算表面的着色。

GPU顶点着色器形式的顶点着色在本书中进行了更深入的讨论,尤其是在第3章第5章中。

作为顶点着色的一部分,渲染系统先进行投影,然后进行裁剪,将视图体换为单位立方体,其极值点位于(1,1,1)(-1,-1,-1)(1,1,1)(1,1,1)之间。可以使用不同的范围来定义相同的体积,例如,(0z1)(0 ≤ z ≤ 1)。单位立方体称为正规化视图体。投影是在GPU上由顶点着色器首先完成的。常用的投影方法有两种,即正射投影(也称平行投影)和透视投影,如图2.5。事实上,正射投影只是一种平行投影。也可以使用其他几种投影方式(特别是在建筑领域),例如斜投影和轴测投影。老式街机游戏Zaxxon就是以后者命名的。

图2.5. 左侧是正射投影或平行投影;右边是透视投影。

请注意,投影表示为矩阵(第4.7节),因此它有时可能与几何变换的其余部分连接。

正交观察的视图体通常是一个矩形框,正交投影将这个视图体变换为单位立方体。正交投影的主要特点是平行线在变换后保持平行。这种转换是平移和缩放的组合。

透视投影有点复杂。在这种类型的投影中,物体离相机越远,投影后看起来越小。另外,平行线可能会聚在地平线上。因此,透视变换模仿了我们感知物体大小的方式。在几何上,称为截头锥体的视图体是一个具有矩形底面的截棱锥。截头锥体也转化为单位立方体。正交变换和透视变换都可以用4×4矩阵构造(第4章),并且在任一变换之后,模型都被称为在裁剪坐标中。这些实际上是齐次坐标,在第4章中讨论过,因此这发生在除以w之前。GPU的顶点着色器必须始终输出这种类型的坐标,以便下一个功能阶段(裁剪)正常工作。

尽管这些矩阵将一个几何体转换为另一个几何体,但它们被称为投影,因为在显示之后,z坐标不存储在生成的图像中,而是存储在z缓冲区中,如第2.5节所述。通过这种方式,模型从三维投影到两维。

2.3.2 可选的顶点处理

每个管线都有刚刚描述的顶点处理。完成此处理后,可以在GPU上进行几个可选阶段,按顺序是:曲面细分、几何着色和流输出。它们的使用取决于硬件的能力——并非所有 GPU 都有它们——以及程序员的愿望。它们相互独立,一般不常用。 将在第3章中详细介绍每一个。

第一个可选阶段是曲面细分。假设你有一个弹跳球对象。如果用一组三角形表示它,则可能会遇到质量或性能问题。您的球在5米外可能看起来不错,但近距离观察单个三角形,三角形的轮廓,就会变得清晰可见。如果你用更多的三角形制作球来提高质量,当球很远并且只覆盖屏幕上的几个像素时,你可能会浪费大量的处理时间和内存。通过曲面细分,可以使用适当数量的三角形生成曲面。

我们已经讨论了一些三角形,但在管线中的这一点上,我们只处理了顶点。这些可用于表示点、线、三角形或其他对象。顶点可用于描述曲面,例如球。这样的表面可以由一组面片指定,每个面片由一组顶点组成。曲面细分阶段由一系列阶段本身组成——外包着色器(hull shader)、曲面细分器(tessellator)和域着色器(domain shader)——将这些面片顶点集转换为(通常)更大的顶点集,然后用于制作新的三角形集。场景的相机可用于确定生成了多少个三角形:面片很近时很多,远处时很少。

下一个可选阶段是几何着色器。该着色器早于曲面细分着色器,因此在GPU上更常见。它类似于曲面细分着色器,因为它接受各种类型的图元并可以生成新的顶点。这是一个更简单的阶段,因为此创建的范围有限,输出图元的类型也更有限。几何着色器有多种用途,其中最流行的一种是粒子生成。想象一下模拟烟花爆炸。每个火球都可以用一个点来表示,一个顶点。几何着色器可以将每个点变成面向观察者并覆盖多个像素的正方形(由两个三角形组成),从而为我们提供更令人信服的图元进行着色。

最后一个可选阶段称为流输出。这个阶段让我们使用GPU作为几何引擎。与将我们处理过的顶点沿着管道的其余部分发送到屏幕上不同,此时我们可以选择将这些顶点输出到数组中以供进一步处理。这些数据可以由CPU或GPU本身在以后的过程中使用。此阶段通常用于粒子模拟,例如我们的烟花示例。

这三个阶段按此顺序执行——曲面细分、几何着色和流输出——每个阶段都是可选的。无论使用哪个(如果有)选项,如果我们继续沿着管道向下走,我们就会得到一组具有齐次坐标的顶点,这些顶点将被检查相机是否能看到它们。

2.3.3 裁剪

只有全部或部分在视图体内部的图元需要传递到光栅化阶段(以及随后的像素处理阶段),然后在屏幕上绘制它们。完全位于视图体内部的图元将按原样传递到下一个阶段。完全在视图体积之外的基元不会被进一步传递,因为它们没有被渲染。部分位于视图体内部的图元需要裁剪。例如,一条直线,在视图体外部有一个顶点,在视图体积内部有一个顶点,此时应该根据视图体对其进行裁剪;以便外部的顶点被位于该线和视图体之间的交点处的新顶点替换。投影矩阵的使用意味着变换后的图元被裁剪到单位立方体上。在裁剪之前进行视图变换和投影的好处是可以使裁剪问题保持一致;图元总是针对单位立方体进行裁剪。

裁剪过程如图2.6所示。除了视图体积的六个剪裁平面之外,用户还可以定义额外的剪裁平面来明显地剪裁对象。第818页的图19.1中显示了显示这种可视化类型的图像,称为剖视(sectioning)。

图2.6. 只需要单位立方体内部的图元(对应视锥体内部的图元)继续处理。因此,单位立方体外面的图元被丢弃,而完全在里面的图元被保留。与单位立方体相交的图元被裁剪在单位立方体上,从而产生新的顶点并丢弃旧的顶点。

裁剪步骤使用投影产生的4值齐次坐标进行裁剪。值通常不会跨透视空间中的三角形进行线性插值。需要第四个坐标,以便在使用透视投影时正确插入和裁剪数据。最后,执行透视除法,将生成的三角形的位置放入三维标准化设备坐标中。如前所述,此视图体积范围从(1,1,1)(-1,-1,-1)(1,1,1)(1,1,1)。几何阶段的最后一步是从这个空间转换到窗口坐标。

2.3.4 屏幕映射

只有视图体内部的(裁剪的)图元被传递到屏幕映射阶段,进入这个阶段时坐标仍然是三维的。每个图元的x和y坐标被转换为屏幕坐标。屏幕坐标与z坐标一起也称为窗口坐标。假设场景应该被渲染到一个最小位置在(x1,y1)(x_1,y_1),最大位置在(x2,y2)(x_2 ,y_2)处的窗口(其中x1<x2x_1 < x_2y1<y2y_1 < y_2)。屏幕映射先是平移,然后是缩放操作。新的x和y坐标称为屏幕坐标。z坐标(OpenGL的[1,+1][−1,+1]和DirectX的[0,1][0,1])也被映射到[z1,z2][z_1,z_2],其中z1=0z_1=0z2=1z_2=1作为默认值。但是,这些可以通过API进行更改。窗口坐标连同这个重新映射的z值被传递到光栅化阶段。屏幕映射过程如图2.7所示。

图2.7. 投影变换后的图元位于单位立方体中,屏幕映射程序负责在屏幕上找到坐标。

接下来,我们描述整数和浮点值如何与像素(和纹理坐标)相关。给定像素的水平数组并使用笛卡尔坐标,最左边像素的左边缘在浮点坐标中为0.0。OpenGL一直使用这种方案,DirectX10及其后续版本也使用它。该像素的中心为0.5。因此,一系列像素 [0,9] 覆盖了 [0.0,10.0) 的跨度。转换很简单:

d=floor(c)(2.1)d = floor(c) \tag{2.1}
c=d+0.5(2.2)c = d + 0.5 \tag{2.2}

其中dd是像素的离散(整数)索引,cc是像素内的连续(浮点)值。

虽然所有API的像素位置值都从左到右增加,但在OpenGL和DirectX1之间的某些情况下,顶部和底部边缘的零位置不一致。OpenGL始终偏爱笛卡尔系统,将左下角视为最低值元素,而DirectX有时根据上下文将左上角定义为该元素。每个人都有一个逻辑,在他们不同的地方不存在正确的答案。例如,(0,0)(0,0)在 OpenGL中位于图像的左下角,而在DirectX中位于左上角。在从一个API迁移到另一个API时,必须考虑到这种差异。

Footnotes

  1. “Direct3D”是DirectX的三维图形API组件。DirectX包括其他API元素,例如输入和音频控件。我们不区分在指定特定版本时编写“DirectX”和在讨论此特定API时编写“Direct3D”,而是通过始终编写“DirectX”来遵循常见用法。

都2026年,React源码还值不值得读 ❓❓❓

随着前端技术生态的不断演进,React 作为目前最流行的前端框架之一,已经走过了十多个年头。在 2026 年这个时间节点,很多开发者都在思考一个问题:React 源码还值不值得深入阅读?

这个问题的答案并不是简单的"是"或"否",而需要从多个维度进行分析。本文将从实际价值、学习成本、技术趋势等角度,为你提供一个全面的分析。

为什么曾经值得读 React 源码?

在讨论"现在是否值得"之前,我们先回顾一下为什么 React 源码曾经被认为是值得学习的经典。

React 引入了很多开创性的概念:虚拟 DOM(Virtual DOM)虽然现在已不是新概念,但在当时是一个突破;组件化思想确立了声明式 UI 开发范式;单向数据流成为状态管理的最佳实践;Fiber 架构实现了时间切片和可中断渲染的创新。

React 的代码库以其高质量著称:清晰的代码组织和架构设计、完善的注释和文档、严格的类型检查(使用 Flow,后来迁移到 TypeScript)、丰富的测试覆盖。

阅读 React 源码可以学到大型开源项目的组织方式、性能优化的思路和技巧、复杂状态管理的实现方式,以及设计模式和架构模式的实际应用。

2026 年的技术环境变化

到了 2026 年,React 已经非常成熟:API 已经相对稳定,重大变更减少;核心概念已经被广泛理解和应用;生态系统完善,最佳实践明确。

前端技术栈变得更加多样化:Vue 3、Svelte、Solid.js 等框架各有优势;服务端渲染框架(Next.js、Remix、Astro)的兴起;Web Components 的标准化;编译时优化成为趋势(如 React Compiler)。

学习资源也变得更加丰富:大量的教程、视频、文章;官方文档的完善;社区经验的沉淀;AI 辅助学习工具的普及。

2026 年读 React 源码的利弊分析

仍然值得读的理由

虽然你可以通过文档和教程学会如何使用 React,但阅读源码能让你真正理解 React 的工作原理,而不是表面的 API 使用;理解为什么某些 API 是这样设计的;理解性能优化的底层原理(如 Diff 算法、Fiber 调度)。

当你遇到框架层面的问题时,源码知识能帮助你快速定位问题根源、找到绕过框架限制的方法、为框架贡献代码或参与讨论。

从职业发展角度来看,深入理解 React 源码可以展现技术深度、学习大型项目的架构设计思路、培养阅读复杂代码的能力。

面试仍然是读源码的重要驱动力。打开牛客网等面试平台,你会发现 React 源码相关的面经依然大量存在。大厂面试中,React 源码问题几乎是必问项:Fiber 架构的实现原理、Hooks 的工作机制、Diff 算法的优化策略、事件系统的合成事件机制、调度器的优先级调度原理等等。如果你只能回答 API 的使用,而无法解释底层的实现原理,在面试中很难获得竞争优势。不仅仅是中高级前端开发者的面试,就连秋招和实习面试中也经常出现 React 源码相关的问题,深入理解 React 源码几乎成了标配要求。

在 AI 工具日益普及的 2026 年,一个现实是:即使你不懂 React 原理,也能通过 AI 辅助工具(如 GitHub Copilot、claude code、Cursor 等)完成日常开发工作。AI 可以帮助你生成代码、解决 bug、优化性能。但如果你想要找到一份好的工作,特别是如果你的技术栈是 React,那么最好还是深入理解 React 原理。原因很简单:AI 可以帮你写代码,但不能帮你通过技术面试;AI 可以解决具体问题,但不能替代你对框架的深度理解。在竞争激烈的就业市场中,能够解释 React 底层原理的开发者,明显比只会使用 API 的开发者更有优势。

以下是从牛客网面经中截取的实际面试题目,可以看到 React 原理类问题确实频繁出现:

牛客网 React 面经截图 1

牛客网 React 面经截图 2

React 19 引入了很多新特性,值得深入研究:并发渲染(Concurrent Rendering)、自动批处理(Automatic Batching)、Suspense 的完整实现、Transition API、React Compiler、Actions 和 Form 的改进等。

React 源码是学习设计模式的绝佳教材:观察者模式(事件系统)、策略模式(调度器)、适配器模式(各种 renderer)、工厂模式(组件创建)。

可能不值得读的理由

React 源码库庞大(超过 10 万行代码),需要大量的时间和精力投入,对于大多数应用开发场景,可能"用不到"这么深的知识。

如果你已经是经验丰富的 React 开发者,读源码的边际收益可能不大,很多概念已经通过其他方式学习到了,实际工作中很少需要深入到框架实现层面。

如果项目使用了其他框架(Vue、Svelte 等),React 源码的学习价值相对降低;如果转向了服务端渲染或边缘计算,客户端框架源码的价值降低。

现在有更多高质量的学习资源(视频教程、互动式课程),可以通过构建简化版 React 来学习核心概念,通过 TypeScript 类型定义也能理解很多设计。

如何判断你是否应该读 React 源码?

适合读源码的情况包括:你已经熟练使用 React 进行开发(至少 1-2 年经验);你遇到了框架层面的问题,需要深入理解才能解决;你想提升技术深度,为职业发展做准备;你即将参加面试,无论是秋招、实习还是社招,特别是大厂面试,React 源码问题几乎是必考点;你对框架设计感兴趣,想要学习架构设计;你想要为 React 贡献代码,或参与相关技术讨论;你是前端技术专家或架构师,需要全面的技术理解。

不太适合读源码的情况包括:React 新手应该先掌握基础使用,再考虑读源码;时间有限的情况下,如果项目压力大,先保证业务能力;如果职业方向不在前端框架,如果转向全栈、后端或移动端,优先级应该调整;如果只做业务开发,工作中不需要深入到框架层面,可能收益不大。

如果决定读,应该如何读?

如果你决定要读 React 源码,不要试图一次性读完所有代码,应该按模块学习。

下面这张图是我整理的 React 源码整体阅读流程,用一条清晰的路径把从 JSX 到 Fiber、再到 Scheduler 和 commit 阶段串联在一起。建议你先整体看一遍这张图,对 React 内部的执行链路有个全局认知,再按图中的顺序去对应阅读源码里的关键部分。

20260121200944

结合这张流程图,可以按下面的顺序来读 React 源码:

  1. 从使用入口出发:先看 packages/react 中与 JSX 相关的实现(createElement、jsx 等),再看 packages/react-dom 中的 createRoot、render,弄清楚一次渲染是如何被发起的。

  2. 理解 Fiber 数据结构:在 packages/react-reconciler 中阅读 FiberNode 的定义、createFiber 等代码,搞清楚 Fiber 上都挂了哪些信息,以及它和更新队列的关系。

  3. 看渲染阶段的工作循环:继续在 react-reconciler 里看 workLoopConcurrent、performUnitOfWork、beginWork、completeWork,对应流程图中「渲染阶段」这部分。

  4. 单独啃 Scheduler:切到 packages/scheduler,理解任务队列、优先级和时间切片机制,这一块对应流程图中间的调度器节点。

  5. 回到 commit 阶段:再回到 react-reconciler,看 commitRoot、commitMutationEffects、commitLayoutEffects,弄清楚 DOM 实际在什么时候、以什么顺序被更新。

  6. 深入 Hooks 实现:重点阅读 useState、useReducer、useEffect 等 Hook 对应的实现文件,结合 Hooks 链表的那部分流程图,理解 hooks 链表如何在 current、workInProgress 之间切换。

  7. 最后再看 Context、Suspense、并发特性等模块,把前面打下的基础扩展成完整的 React 内部心智模型。

如果你时间不多,建议优先把 1-6 跑通:入口能串起来、Fiber 能看懂、workLoop 能跟住、Scheduler 有概念、commit 知道在干嘛、Hooks 能解释清楚,基本就已经具备“读懂 React 源码”的主干能力。

工具和方法上,建议先把 React 源码仓库拉到本地,在源码里定位关键函数和关键数据结构。

同时搭配 React DevTools 观察组件树和状态变化。真正调试运行时路径时,浏览器里执行的是构建后的 bundle,所以需要开启 Source Map,把断点、调用栈从 bundle 映射回你本地的源码文件,这样你在 DevTools 里跟代码时看到的就是 React 源码而不是编译产物。

遇到数据结构或边界条件不确定,再对照官方文档和 TypeScript 类型定义去校验。

最后,边读边做一个“最小可运行”的简化版 React,再用少量测试用例验证自己的推导,会比纯读更容易形成稳定的心智模型。

2026 年的新视角:React Compiler 与未来

到了 2026 年,React 可能已经集成了一些新特性,值得关注。

如果 React Compiler 已经集成到核心,这将是值得深入学习的新内容:编译时优化的思路、静态分析和代码转换、性能优化的新范式。

React 19 的并发特性已经稳定,这些实现值得深入研究:时间切片(Time Slicing)、优先级调度、可中断渲染。

了解 React 如何与新技术整合也很重要:React Server Components、与 WebAssembly 的交互、与 Web Workers 的集成。

替代学习路径

如果你觉得读完整源码成本太高,可以考虑这些替代方案。

通过实现一个简化版的 React(如 1000 行左右的代码),你可以学习到核心概念:Virtual DOM、组件系统、Diff 算法、Hooks 基础实现。

只读最核心的部分:Reconciler 的核心逻辑、Hooks 的实现、Scheduler 的调度算法。

React 的 TypeScript 类型定义本身就是很好的文档,可以帮你理解 API 设计思路、数据流、组件生命周期。

关注技术博客和视频:React 团队的官方博客、技术社区的文章、深度解析视频。

结论与建议

在 2026 年,React 源码仍然值得读,但不再是"必读项"。

对于大多数开发者,建议优先掌握 React 的使用和最佳实践,通过文档、教程和项目经验提升能力,只在遇到特定问题或想提升深度时,有针对性地阅读相关源码。

对于有追求的开发者,如果你有时间和兴趣,阅读源码绝对是有价值的投资。建议采用"选择性深度阅读"的方式,重点学习核心模块。结合实践项目,通过构建简化版来加深理解。

对于技术专家和架构师,深入理解 React 源码是必要的。这不仅能帮你做出更好的技术决策,还能提升架构设计能力。

技术学习是一个持续的过程,不应该有"一劳永逸"的想法。是否读 React 源码,应该基于你的当前水平、职业目标、项目需求、时间资源。

在 2026 年,我们有更多的学习选择。React 源码仍然是宝贵的学习资源,但它不再是唯一的选择。选择最适合你当前情况的路径,比盲目追求"读完源码"更重要。

无论你是否选择深入阅读 React 源码,保持学习的心态和对技术的热情,比掌握任何特定的技术栈都更重要。技术会变化,但学习能力是永恒的。

如果你读到这里,说明你对 React 源码已经有不小的兴趣,或者正在认真考虑要不要系统地学一遍。最近我刚刚完成了 React 源码的系统学习,并整理成了一个专栏,用大量示意图和流程图来讲清楚 Fiber 架构、Hooks 的内部实现、调度器以及事件系统等关键部分。

2148a5531fb6563f813d90e0dd467838

3c465d2e2e4b34ff7d851ecb64f30482

60956e3d526aca95969b8a72bad33364

如果你想用更直观的方式把这些知识真正吃透,欢迎加我微信 yunmz777 私聊,一起交流源码学习的思路和实践经验。

前端ESLint 和 Babel对比

ESLint 和 Babel 虽然都基于 AST(抽象语法树)工作,但它们的设计目的、工作流和 API 设计有着本质的区别。

  1. ESLint 插件实战:开发一个 eslint-plugin-clean-arch,用于强制执行“整洁架构”的依赖原则(例如:禁止 UI 层直接导入 DAO 层,必须经过 Service 层)。
  2. Babel 插件实战:开发一个 babel-plugin-auto-try-catch,用于在编译时自动给 async/await 函数包裹 try-catch 块,并上报错误信息,避免手动写大量重复代码。

第一部分:核心差异概览

在进入代码之前,先通过表格建立核心认知:

特性 ESLint 插件 Babel 插件
核心目标 代码质量检查与风格统一(Linting) 代码转换与编译(Transpiling)
输出结果 报告错误/警告,或进行源码级的字符串替换(Fix) 生成全新的、兼容性更好的 JavaScript 代码
AST 标准 ESTree (使用 espree 解析) Babel AST (基于 ESTree 但有细微差异,如 Literal 分类)
遍历方式 扁平化的选择器遍历 (Selectors) 访问者模式 (Visitor Pattern)
修改能力 弱。主要通过 fixer 提供文本范围替换,必须保持 AST 有效性比较难 强。可以随意增删改查节点,生成完全不同的代码结构
运行时机 开发时(IDE提示)、提交时(Husky)、CI/CD 阶段 构建打包阶段(Webpack/Vite/Rollup 加载器中)

第二部分:ESLint 自定义插件实战 (深度代码)

场景描述

在大型项目中,我们需要控制模块间的依赖关系。假设项目结构如下:

  • src/views (UI层)
  • src/services (业务逻辑层)
  • src/api (数据访问层)

规则src/views 下的文件,禁止直接 import 来自 src/api 的文件,必须通过 src/services 调用。

1. 插件入口结构

通常定义在 index.js 中。

/**
 * @fileoverview eslint-plugin-clean-arch
 * 强制执行项目架构分层依赖规则的 ESLint 插件
 */
'use strict';

// 导入我们即将编写的规则定义
const restrictLayerImports = require('./rules/restrict-layer-imports');

// 插件主入口
module.exports = {
  // 插件元数据
  meta: {
    name: 'eslint-plugin-clean-arch',
    version: '1.0.0'
  },
  // 暴露配置预设(用户可以直接 extends: ['plugin:clean-arch/recommended'])
  configs: {
    recommended: {
      plugins: ['clean-arch'],
      rules: {
        'clean-arch/restrict-layer-imports': 'error'
      }
    }
  },
  // 规则定义集合
  rules: {
    'restrict-layer-imports': restrictLayerImports
  },
  // 处理器(可选,用于处理非 JS 文件,如 .vue 中的 script)
  processors: {
    // 这里简单示意,通常 vue-eslint-parser 已经处理了
  }
};

2. 规则实现核心 (rules/restrict-layer-imports.js)

这是最核心的部分,包含了 AST 分析逻辑。

/**
 * @fileoverview 禁止跨层级直接调用
 */
'use strict';

const path = require('path');

// 辅助函数:标准化路径分隔符,兼容 Windows
function normalizePath(filePath) {
  return filePath.split(path.sep).join('/');
}

// 辅助函数:判断文件属于哪个层级
function getLayer(filePath) {
  const normalized = normalizePath(filePath);
  if (normalized.includes('/src/views/')) return 'views';
  if (normalized.includes('/src/services/')) return 'services';
  if (normalized.includes('/src/api/')) return 'api';
  return 'other';
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    type: 'problem', // problem | suggestion | layout
    docs: {
      description: 'Enforce strict layer dependency rules: Views -> Services -> API',
      category: 'Architecture',
      recommended: true,
      url: 'https://my-company.wiki/arch-rules'
    },
    fixable: null, // 本规则不提供自动修复,因为架构调整需要人工介入
    // 定义错误消息模板
    messages: {
      restrictedImport: '架构违规: "{{currentLayer}}" 层禁止直接引入 "{{targetLayer}}" 层模块。请通过 Service 层中转。',
      invalidPath: '无法解析的导入路径: {{importPath}}'
    },
    // 规则配置 Schema
    schema: [
      {
        type: 'object',
        properties: {
          // 允许用户自定义层级映射
          layers: {
            type: 'object'
          }
        },
        additionalProperties: false
      }
    ]
  },

  /**
   * create 方法返回一个对象,该对象的方法名为 AST 选择器
   * ESLint 遍历 AST 时会回调这些方法
   * @param {import('eslint').Rule.RuleContext} context
   */
  create(context) {
    // 获取当前正在被 Lint 的文件名
    const currentFilename = context.getFilename();
    const currentLayer = getLayer(currentFilename);

    // 如果当前文件不在受控层级中,直接忽略
    if (currentLayer === 'other') {
      return {};
    }

    // 定义层级依赖约束表
    // Key: 当前层级, Value: 禁止引入的层级集合
    const RESTRICTED_MAP = {
      'views': ['api'], // View 层禁止引入 API 层
      'services': [],   // Service 层可以引入 API
      'api': ['views', 'services'] // API 层通常是底层的,不应反向依赖
    };

    /**
     * 核心校验逻辑
     * @param {ASTNode} node - ImportDeclaration 节点
     */
    function verifyImport(node) {
      // 获取 import 的路径值,例如: import x from '@/api/user' 中的 '@/api/user'
      const importPath = node.source.value;

      // 忽略第三方库 (通常不以 . / @ 开头,或者是 node_modules)
      // 这里简单判断:如果不是相对路径也不是别名路径,认为是 npm 包
      if (!importPath.startsWith('.') && !importPath.startsWith('/') && !importPath.startsWith('@')) {
        return;
      }

      // 尝试解析导入路径对应的实际层级
      // 注意:在 ESLint 规则中做完整的文件系统解析比较重,
      // 通常我们会根据字符串特征判断,或者依赖 resolver
      let targetLayer = 'other';
      
      if (importPath.includes('/api/') || importPath.includes('@/api/')) {
        targetLayer = 'api';
      } else if (importPath.includes('/services/') || importPath.includes('@/services/')) {
        targetLayer = 'services';
      } else if (importPath.includes('/views/') || importPath.includes('@/views/')) {
        targetLayer = 'views';
      }

      // 检查是否违规
      const forbiddenLayers = RESTRICTED_MAP[currentLayer] || [];
      
      if (forbiddenLayers.includes(targetLayer)) {
        context.report({
          node: node.source, // 错误红线标在路径字符串上
          messageId: 'restrictedImport', // 使用 meta.messages 中定义的 ID
          data: {
            currentLayer: currentLayer,
            targetLayer: targetLayer
          }
        });
      }
    }

    return {
      // 监听 ES6 Import 语句
      // 例如: import { getUser } from '@/api/user';
      ImportDeclaration(node) {
        verifyImport(node);
      },

      // 监听动态 Import
      // 例如: const user = await import('@/api/user');
      ImportExpression(node) {
        // 动态 import 的 source 就是调用的参数
        verifyImport(node);
      },

      // 监听 CommonJS require (如果项目混用)
      // 例如: const api = require('@/api/user');
      CallExpression(node) {
        if (
          node.callee.name === 'require' &&
          node.arguments.length > 0 &&
          node.arguments[0].type === 'Literal'
        ) {
          // 构造成类似的结构以便复用 verifyImport
          const mockNode = {
            source: node.arguments[0]
          };
          verifyImport(mockNode);
        }
      }
    };
  }
};

3. 单元测试 (tests/rules/restrict-layer-imports.test.js)

ESLint 提供了 RuleTester 工具,非常方便进行 TDD 开发。

'use strict';

const rule = require('../../rules/restrict-layer-imports');
const { RuleTester } = require('eslint');

const ruleTester = new RuleTester({
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module'
  }
});

// 定义测试用例
ruleTester.run('restrict-layer-imports', rule, {
  // 1. 合法代码测试 (Valid)
  valid: [
    {
      // Service 层调用 API 层 -> 合法
      code: "import { getUser } from '@/api/user';",
      filename: '/Users/project/src/services/userService.js'
    },
    {
      // View 层调用 Service 层 -> 合法
      code: "import { getUserService } from '@/services/userService';",
      filename: '/Users/project/src/views/UserDetail.vue'
    },
    {
      // 引入第三方库 -> 合法
      code: "import axios from 'axios';",
      filename: '/Users/project/src/views/UserDetail.vue'
    },
    {
      // 相对路径引用同层级文件 -> 合法
      code: "import Header from './Header';",
      filename: '/Users/project/src/views/Footer.vue'
    }
  ],

  // 2. 违规代码测试 (Invalid)
  invalid: [
    {
      // View 层直接调用 API 层 -> 报错
      code: "import { getUser } from '@/api/user';",
      filename: '/Users/project/src/views/UserDetail.vue',
      errors: [
        {
          message: '架构违规: "views" 层禁止直接引入 "api" 层模块。请通过 Service 层中转。',
          type: 'Literal' // 报错节点类型
        }
      ]
    },
    {
      // API 层反向依赖 Views 层 -> 报错
      code: "import router from '@/views/router';",
      filename: '/Users/project/src/api/http.js',
      errors: [
        {
          message: '架构违规: "api" 层禁止直接引入 "views" 层模块。请通过 Service 层中转。'
        }
      ]
    },
    {
      // 动态 Import 也要拦截
      code: "const api = import('@/api/user');",
      filename: '/Users/project/src/views/Home.vue',
      parserOptions: { ecmaVersion: 2020 },
      errors: [{ messageId: 'restrictedImport' }]
    }
  ]
});

第三部分:Babel 自定义插件实战 (深度代码)

场景描述

前端开发中,异步操作如果不加 try-catch,一旦报错可能导致页面白屏。手动给每个 awaittry-catch 很繁琐且代码臃肿。 目标:编写一个 Babel 插件,自动识别 async 函数中的 await 语句,如果它没有被 try-catch 包裹,则自动包裹,并注入错误上报逻辑。

转换前

async function fetchData() {
  const res = await api.getData();
  console.log(res);
}

转换后

async function fetchData() {
  try {
    const res = await api.getData();
    console.log(res);
  } catch (e) {
    console.error('Auto Captured Error:', e);
    // window.reportError(e); // 可以在插件配置中传入上报函数名
  }
}

1. Babel 插件基础结构

Babel 插件导出一个函数,返回一个包含 visitor 属性的对象。

// babel-plugin-auto-try-catch.js

/**
 * Babel Types 库提供了用于构建、验证和转换 AST 节点的工具方法
 * @param {import('@babel/core')} babel
 */
module.exports = function(babel) {
  const { types: t, template } = babel;

  return {
    name: 'babel-plugin-auto-try-catch',
    // visitor 是访问者模式的核心
    visitor: {
      // 我们关注 FunctionDeclaration, FunctionExpression, ArrowFunctionExpression
      // 可以合并为一个选择器 'Function'
      Function(path, state) {
        // 1. 如果函数不是 async 的,跳过
        if (!path.node.async) {
          return;
        }

        // 2. 如果函数体已经是空的,跳过
        if (path.node.body.body.length === 0) {
          return;
        }

        // 3. 检查函数体是否已经被 try-catch 包裹
        // 获取函数体的第一条语句
        const firstStatement = path.node.body.body[0];
        // 如果只有一条语句且是 TryStatement,说明已经处理过或用户手动写了,跳过
        if (path.node.body.body.length === 1 && t.isTryStatement(firstStatement)) {
          return;
        }

        // 4. 获取用户配置的排除项 (例如排除某些文件或函数名)
        const exclude = state.opts.exclude || [];
        // 获取当前处理的文件路径
        const filename = state.file.opts.filename || 'unknown';
        // 简单的排除逻辑示例
        if (exclude.some(pattern => filename.includes(pattern))) {
          return;
        }
        
        // 5. 开始执行转换
        // 核心逻辑:将函数体原来的内容,塞入 try 块中
        
        // 步骤 A: 生成 catch 子句的 error 参数节点 (identifier)
        // 使用 path.scope.generateUidIdentifier 防止变量名冲突 (例如防止用户原代码里已经有个变量叫 err)
        const errorParam = path.scope.generateUidIdentifier('err');

        // 步骤 B: 构建 catch 块的内容
        // 这里我们可以根据配置,生成 console.error 或 reportError 调用
        const reporterName = state.opts.reporter || 'console.error';
        
        // 使用 babel template 快速构建 AST 节点,比手动 t.callExpression 更直观
        // %%err%% 是占位符,会被替换为上面生成的 errorParam
        const catchBodyTemplate = template.statement(`
          ${reporterName}('Async Error:', %%err%%);
        `);
        
        const catchBlockStatement = t.blockStatement([
          catchBodyTemplate({ err: errorParam })
        ]);

        // 步骤 C: 构建 catch 子句节点
        const catchClause = t.catchClause(
          errorParam,
          catchBlockStatement
        );

        // 步骤 D: 构建 try 语句节点
        // path.node.body 是 BlockStatement,包含 body 属性(语句数组)
        const originalBodyStatements = path.node.body.body;
        
        const tryStatement = t.tryStatement(
          t.blockStatement(originalBodyStatements), // try 块内容
          catchClause, // catch 块
          null // finally 块 (可选)
        );

        // 步骤 E: 替换原函数体
        // 注意:直接替换 body 可能会导致死循环(因为新生成的节点也包含函数体),
        // 但这里我们要替换的是 Function 的 body (BlockStatement) 的内容,
        // 或者直接替换 body 为包含 tryStatement 的新 BlockStatement。
        
        path.get('body').replaceWith(
          t.blockStatement([tryStatement])
        );

        // 标记该节点已被访问,避免递归处理死循环 (Babel 默认会重新访问新插入的节点)
        path.skip(); 
      }
    }
  };
};

2. 增强版 Babel 插件逻辑 (处理细节)

上面的版本比较粗暴(把整个函数体包起来)。但在实际中,我们可能只想包裹包含 await 的代码段,或者如果用户已经写了部分 try-catch 该怎么办?

下面是更精细的 AST 操作逻辑:

// 进阶工具函数:检查 BlockStatement 中是否包含 await 表达式
function hasAwaitExpression(path) {
  let hasAwait = false;
  // 使用 path.traverse 可以在当前路径下进行子遍历
  path.traverse({
    AwaitExpression(childPath) {
      // 必须确保 await 是属于当前函数的,而不是嵌套在内部其他 async 函数里的
      const parentFunction = childPath.getFunctionParent();
      if (parentFunction === path) {
        hasAwait = true;
        childPath.stop(); // 找到一个就停止
      }
    },
    // 防止遍历进入内部函数的陷阱
    Function(childPath) {
      childPath.skip();
    }
  });
  return hasAwait;
}

// 修改 visitor 部分
visitor: {
  Function(path, state) {
    if (!path.node.async) return;
    
    // 进阶优化:如果没有 await,其实不需要包裹 try-catch (虽然 async 函数报错会返回 reject promise,但这里假设只捕获 await 异常)
    if (!hasAwaitExpression(path)) {
      return;
    }

    // 处理 React/Vue 组件方法名排除
    const functionName = path.node.id ? path.node.id.name : '';
    if (['render', 'setup', 'componentDidCatch'].includes(functionName)) {
      return;
    }
    
    // ... 后续转换逻辑同上 ...
  }
}

3. Babel 插件单元测试

Babel 插件测试通常使用 babel-plugin-tester 或直接调用 @babel/coretransformSync

const babel = require('@babel/core');
const autoTryCatchPlugin = require('./babel-plugin-auto-try-catch');
const assert = require('assert');

// 辅助测试函数
function transform(code, options = {}) {
  const result = babel.transformSync(code, {
    plugins: [
      [autoTryCatchPlugin, options] // 加载插件并传入配置
    ],
    // 禁用 Babel 默认生成严格模式,减少干扰
    sourceType: 'script', 
    compact: false // 格式化输出代码
  });
  return result.code;
}

console.log('--- 开始测试 Babel 插件 ---');

// 测试用例 1: 普通 Async 函数转换
const code1 = `
async function getData() {
  const res = await api.get('/user');
  return res;
}
`;
const output1 = transform(code1);
console.log('[Case 1 Output]:\n', output1);
/*
预期输出:
async function getData() {
  try {
    const res = await api.get('/user');
    return res;
  } catch (_err) {
    console.error('Async Error:', _err);
  }
}
*/
assert.match(output1, /try \{/, 'Case 1 Failed: try block missing');
assert.match(output1, /catch \(_err\)/, 'Case 1 Failed: catch block missing');


// 测试用例 2: 箭头函数转换
const code2 = `
const doWork = async () => {
  await sleep(1000);
  console.log('done');
};
`;
const output2 = transform(code2);
console.log('[Case 2 Output]:\n', output2);
assert.match(output2, /try \{/, 'Case 2 Failed');


// 测试用例 3: 已经有 Try-Catch 的函数 (应跳过)
const code3 = `
async function safe() {
  try {
    await risky();
  } catch (e) {
    handle(e);
  }
}
`;
const output3 = transform(code3);
// 输出应该和输入几乎一样(除了格式化差异)
// 我们通过判断 catch 块的数量来验证没有重复插入
const catchCount = (output3.match(/catch/g) || []).length;
assert.strictEqual(catchCount, 1, 'Case 3 Failed: Should not add extra try-catch');


// 测试用例 4: 自定义 Reporter 配置
const code4 = `async function test() { await fn(); }`;
const output4 = transform(code4, { reporter: 'window.reportToSentry' });
console.log('[Case 4 Output]:\n', output4);
assert.match(output4, /window\.reportToSentry/, 'Case 4 Failed: Custom reporter not working');

console.log('--- 所有测试通过 ---');

第四部分:底层机制深度对比

这部分解释为什么代码要这么写,这对于理解 1000 行级别的复杂插件开发至关重要。

1. 遍历机制:Scope (作用域) 管理

这是 Babel 和 ESLint 插件开发中最难的部分。

  • ESLint:

    • context.getScope() 获取当前节点的作用域。
    • 主要用于查找变量定义(References)。例如:no-undef 规则就是通过遍历 Scope 中的 references 列表,看是否有未定义的变量。
    • ESLint 的 Scope 分析是静态只读的。你不能在 lint 过程中修改 Scope。
  • Babel:

    • path.scope 对象非常强大。
    • path.scope.generateUidIdentifier('name'):自动生成唯一变量名(如 _name, _name2),这在转换代码注入变量时必不可少(如上面 try-catch 中的 err)。
    • path.scope.push({ id: ... }):可以将变量声明提升到作用域顶部。
    • Binding:Babel 维护了极其详细的变量绑定信息。你可以通过 path.scope.bindings['x'] 找到变量 x 的所有引用位置(referencePaths)和赋值位置(constantViolations)。这使得做“死代码消除”或“常量折叠”成为可能。

2. 状态管理 (State)

  • ESLint:

    • 状态通常保存在闭包变量中,或者 create 函数的局部变量中。
    • 因为 ESLint 是按文件处理的,create 每次处理新文件都会重新执行,所以闭包变量是文件隔离的。
    • 如果需要跨文件信息(极其少见且不推荐,因为破坏缓存),需要用到全局单例。
  • Babel:

    • 状态通过 state 参数在 Visitor 方法间传递。
    • state.file 包含当前文件的元数据。
    • state.opts 包含用户在 .babelrc 传入的插件配置。
    • 可以在 pre()post() 钩子中初始化和清理状态。
// Babel 状态管理示例
module.exports = {
  pre(state) {
    this.cache = new Map(); // 初始化文件级缓存
  },
  visitor: {
    Identifier(path, state) {
      // this.cache 在这里可用
    }
  },
  post(state) {
    // 清理
  }
};

3. 节点构造与替换

  • ESLint Fixer:

    • 基于文本索引(Index)。
    • API: replaceText(node, 'newText'), insertTextAfter(node, ';').
    • 非常脆弱。如果你删除了一个逗号,可能导致后续的代码语法错误。ESLint 会尝试多次运行 fix 直到不再有变动,但它不保证生成的代码 AST 结构正确,只保证文本替换。
  • Babel Types:

    • 基于对象构建
    • API: t.binaryExpression('+', t.numericLiteral(1), t.numericLiteral(2))
    • 非常健壮。只要符合 AST 规范,Babel Generator 就能生成合法的 JavaScript 代码(自动处理括号优先级、分号等)。

总结

  • 如果你的需求是 “阻止开发者提交烂代码” 或者 “统一团队的代码风格”,请选择 ESLint 插件。它的核心是 Context 和 Report。
  • 如果你的需求是 “减少重复的样板代码”“兼容低版本浏览器” 或者 “实现一种新的语法糖”,请选择 Babel 插件。它的核心是 Path、Visitor 和 Types Builder。

以上代码展示了从零构建一个架构级 ESLint 规则和一个编译级 Babel 转换插件的完整过程,涵盖了 AST 分析、上下文判断、节点构建和单元测试等核心环节。在实际工程中,这两者往往结合使用:Babel 负责把代码变样,ESLint 负责保证变样前的源码符合规范。

前端向架构突围系列模块化 [4 - 1]:思想-超越文件拆分的边界思维

写在前面

很多前端开发者对“模块化”的理解,长期停留在“文件拆分”的物理层面。

比如:一个 Vue/React 组件写了 1000 行,觉得太乱了,于是把里面的三个函数提取出来,扔到 utils.js 里;把 HTML 里的弹窗拆出来,扔到 components/Modal.vue 里。做完这些,看着只有 200 行的主文件,心里一阵舒爽:“啊,我做好了模块化。”

这是错觉。

如果你只是把一团乱麻的代码切成了五段乱麻,那这不叫模块化,这叫 “分布式屎山”

在架构师的眼里,模块化不是为了把文件变小,而是为了治理复杂性。它是关于边界(Boundaries)内聚(Cohesion)耦合(Coupling) 的艺术。本篇我们将抛开具体的语法,探讨如何建立架构级别的模块化思维。

image.png


一、 什么是模块?从“物理文件”到“逻辑单元”

初级工程师看模块,看到的是文件后缀(.js, .vue, .tsx);架构师看模块,看到的是职责的边界

1.1 模块化的三个层级

我们的认知通常经历了三个阶段的进化:

  1. 语法级模块化 (Syntax Level): 这是最基础的。AMD、CommonJS、ES Modules。解决的是命名空间污染脚本加载顺序的问题。这是 2015 年前我们要解决的主要矛盾,现在已经变成了像空气一样的基础设施。
  2. 文件级模块化 (File Level): 为了代码复用,我们将通用逻辑提取为 hooks,将 UI 提取为 components。这是目前绝大多数中级工程师所处的阶段。但如果不小心,很容易陷入 “为了拆分而拆分” 的陷阱。
  3. 领域级模块化 (Domain Level): 这是架构师关注的层面。一个模块不再是一个文件,而是一个业务能力的集合。 比如“用户模块”,它可能包含 UserCard.tsx(UI)、useUser.ts(逻辑)、user-service.ts(API)、UserType.ts(类型)。它们在物理上可能分散,但在逻辑上是一个整体。只有当这个整体对外暴露极其有限的接口,而隐藏内部所有复杂度时,它才是一个真正的模块。

1.2 架构师的视角:隐藏而非暴露

软件工程大师 David Parnas 早在 1972 年就提出了一个振聋发聩的观点:

“模块化的核心在于你隐藏了什么,而不是你暴露了什么。”

在前端开发中,我们经常犯的错误是暴露过多

  • 错误示范: 一个 DatePicker 组件,通过 props 把内部的 calendarInstance 暴露给父组件,允许父组件直接操作日历内部状态。
  • 架构灾难: 这意味着父组件和子组件形成了隐性耦合。一旦哪天你要把底层的日历库从 Moment.js 换成 Day.js,整个应用可能都会崩溃。

真正的模块化思维是“黑盒思维”: 外部只管输入(Props/Params)和输出(Events/Return Values),绝不关心内部是如何实现的。


二、 核心心法:高内聚与低耦合的辩证关系

这八个字被说烂了,但真正能做到的寥寥无几。在前端语境下,它们有具体的落地含义。

2.1 什么是“真内聚”?(True Cohesion)

很多项目习惯按“技术类型”分目录:

src/
  ├── components/  (放所有组件)
  ├── hooks/       (放所有钩子)
  ├── utils/       (放所有工具)
  ├── types/       (放所有类型)

这看起来很整洁,其实是 “假内聚” (或者叫偶然内聚)。 当你需要修改“登录”功能时,你需要去 components 改表单,去 hooks 改逻辑,去 types 改接口定义。你的修改行为是跨越空间的。

现代前端架构推崇的“真内聚”是按“功能特性(Feature)”组织:

src/
  ├── features/
  │   ├── auth/          (登录模块:包含自己的 components, hooks, types)
  │   ├── dashboard/     (大盘模块)

判定标准: 那些只有在一起工作才有意义的代码,必须物理上就在一起。共同封闭原则(CCP) 告诉我们:将那些会因为相同理由而修改的类/文件,聚合在一起。

2.2 什么是“低耦合”?(Loose Coupling)

耦合不可避免,没有耦合的代码就是一堆死代码。架构师要做的是治理耦合的类型

  • 内容耦合(最差): 直接修改另一个模块的内部数据。比如组件 A 通过 ref 强行修改组件 B 的 state。
  • 控制耦合(较差): 传递 flag 告诉另一个模块该怎么做。比如 Button 组件接收一个 isLoginButton 的 prop,导致 Button 内部包含了业务逻辑。
  • 数据耦合(推荐): 仅仅传递数据。组件只接收它需要渲染的数据,不关心数据来源。
  • 事件/消息耦合(最优): 通过发布订阅或回调函数通信。我不直接调用你,我只广播“我做完了”,谁关心谁就来处理。

架构师的刀法: 当你发现两个模块必须同时修改才能跑通时,它们就是强耦合的。要么把它们合并成一个模块,要么引入一个中间层(适配器)来解耦。


三、 边界思维:如何切分模块?

在拿到一个复杂的业务需求(比如一个在线协作文档编辑器)时,普通开发者的第一反应是画页面,而架构师的第一反应是划边界

3.1 不稳定的依赖要隔离

稳定依赖原则(SDP): 依赖关系应该指向更稳定的方向。

  • UI 是不稳定的:产品经理今天要把按钮放左边,明天要放右边,后天要换个颜色。
  • 业务逻辑是相对稳定的:文档的保存、协同算法、权限校验,这些核心逻辑不会轻易变。
  • 基础库是最稳定的:React 框架、Lodash 工具函数。

模块化切分策略: 绝不要把核心业务逻辑写在 UI 组件里(Vue 的 script 或 React 的 useEffect)。 Headless(无头化) 是前端架构的必然趋势。你应该把逻辑抽离成纯 JS/TS 模块(Hook 或 Class),UI 只是一个只有 render 函数的笨蛋壳子。这样,当 UI 翻天覆地变化时,你的核心逻辑模块可以纹丝不动。

3.2 循环依赖是架构的癌细胞

如果 A 模块引用了 B,B 又引用了 A,这在文件层面可能通过 Webpack 解决了,但在逻辑层面,这意味着 A 和 B 锁死在了一起,无法单独测试,无法单独复用。

如何打破循环?

  1. 下沉法: 找到 A 和 B 共同依赖的部分,抽取成 C 模块,A 和 B 都依赖 C。
  2. 反转法(依赖倒置 DIP): A 不直接依赖 B,A 定义一个接口(Interface),B 去实现这个接口。A 只依赖接口。

四、 模块化的代价:过度设计的陷阱

最后,必须给架构师们泼一盆冷水。模块化是有成本的。

模块化 = 增加间接层。

如果你把一个简单的“Hello World”拆成了 Provider、Service、Component、Type 四个文件,那你不是在做架构,你是在制造噪音

架构师的判断力体现在:

  • 识别变化点: 只有那些未来极有可能发生变化,或者复杂度极高的地方,才值得被封装成独立模块。
  • 适度冗余(DRY vs WET): 有时候,复制粘贴代码比错误的抽象更好。如果你强行把两个看似相似但业务背景完全不同的逻辑合并成一个模块,未来当它们向不同方向演进时,你将陷入无尽的 if (isModeA) else (isModeB) 的地狱。

结语:从“写代码”到“设计系统”

模块化不是一种技术,而是一种世界观

当你开始思考**“如果我删掉这行代码,影响的范围是多大” ,或者“如果我把这个文件夹移走,其他部分还能不能跑”**的时候,你就已经跨越了“文件拆分”的边界,开始用架构师的眼光审视你的系统了。

这只是思想的开篇。有了这个思维基石,接下来我们将深入骨架,探讨如何在具体的 UI 层面实现极致的逻辑与视图分离

Next Step: 思想已经建立,下一节我们将进入实战深水区。如何设计一个既能复用,又能灵活定制 UI 的组件? 请看**《第二篇:骨架(上)——组件化深度设计:逻辑与视图的极致分离(Headless UI)》**。

数据语义层 vs 宽表模式:哪种架构更适合 AI 时代的数据分析?

在 AI 驱动的数据分析时代,传统宽表模式因敏捷性不足、数据冗余和难以支持即席查询而力不从心。相比之下,NoETL 数据语义层(Semantic Layer)作为位于数据存储与应用间的抽象层,通过将物理数据映射为统一业务语义,实现了逻辑与物理解耦。对于需要快速响应变化、支持 AI 交互的场景,语义层架构是更具适应性的选择,能提供零等待的指标交付和 100% 一致的业务口径。

AI 时代下,传统宽表模式为何力不从心?

数据分析正从“预制品加工”转向“自助式厨房”。过去支撑报表的宽表模式,在 AI 驱动、即席查询的需求下暴露三大瓶颈:

  1. 敏捷性坍塌:业务变更需回溯修改 ETL、重跑宽表,响应周期长达数周。
  2. 数据一致性失控:多张口径各异的宽表导致“指标打架”,AI 模型基于此将产生不可靠洞察。
  3. 无法支持即席查询:宽表只能回答预设问题,无法响应跨域、临时的分析需求。

例如,周五下午,市场部需要新指标评估促销活动。数据团队告知需新建宽表,排期至下周三。决策时机已然错过。这种“响应迟滞”在 AI 时代是致命的。

什么是 NoETL 数据语义层(Semantic Layer)?

NoETL 数据语义层(Semantic Layer)是数据存储与数据应用间的关键抽象层,其核心功能是将复杂的技术数据结构映射为统一的业务术语和指标,充当数据的“业务翻译官”。其颠覆性源于三大技术理念:

  1. 解耦逻辑与物理:业务逻辑(如“销售额=价格×数量-折扣”)不再硬编码于 ETL,而是作为可复用定义存储于语义层。
  2. 统一业务语义:动态编织明细数据为统一的业务语义,确保全公司对“销售额”只有一个定义,实现“单一事实来源”。
  3. 实时查询下推:将“查看华东区销售额”的查询实时翻译、优化并下推至数据源执行,无需移动和预计算数据。

为什么它是 AI 时代的关键?

AI Agent 需要无歧义的上下文来准确生成 SQL。语义层提供了这份“业务词典”,为 AI 提供了稳定、可靠的数据接口,从根本上避免了因口径混乱导致的“AI 幻觉”。

Aloudata 如何基于语义层赋能 AI 驱动的分析?

作为国内数据语义编织(Semantic Fabric)领导者,Aloudata 方案的核心是:用 Aloudata CAN 自动化指标平台构建语义层,用 Aloudata Agent 分析决策智能体作为交互入口。

企业可以通过 Aloudata CAN 中连接数仓明细层,在可视化界面通过配置化的方式定义业务实体、维度和指标,构建语义模型,形成 NoETL 数据语义层,实现业务语义的标准化开发和管理,保障 100% 指标口径的一致性,避免 AI 问数的“幻觉”出现。

以 NoETL 数据语义层为底座,用户可以部署 Aloudata Agent,通过自然语言交互的方式直接提问:“上周新用户首单平均客单价?”Agent 基于语义层理解意图,通过 NL2MQL2SQL 的技术路径,先输出 MQL,再通过指标语义引擎生成 100% 准确的 SQL 语句并返回结果。

在这个过程中,用户零等待指标交付,逻辑变更分钟级生效,无需 ETL;100%一致口径,所有人与 AI 通过同一语义层访问数据;无缝对接 AI,语义层为 AI 提供标准化查询 API。

常见疑问回答(FAQ)

Q: 语义层架构的性能是否比宽表差?

不会。语义层采用智能查询下推与缓存,其优势在于在保证核心性能的同时,极大扩展了可即时响应的问题范围。

Q: 已建的宽表和数据仓库,是否要推倒重来?

不需要。语义层是增强层。Aloudata CAN 可直接连接现有数据资产,在其之上构建统一语义,保护投资的同时解锁新能力。

Q: 语义层如何保证数据安全与权限控制?

企业级产品(如 Aloudata CAN)提供行列级权限管控,并将规则与语义模型绑定。任何查询都会自动注入权限过滤,确保安全合规。

vue2+vue3 Table表格合并

之前在写表格合并的时候非常痛苦,弄不明白合并的具体逻辑,我这里直接贴上通用方法,只需要配置合并规则就可以了,在这里不扯那么多过程,你完全可以拷贝回去立马能用。

vue2 表格合并

<el-table
      :data="tableData"
     :span-method="(param)=>objectSpanMethod(param,tableData)"
      border
      style="width: 100%">
      <el-table-column
        prop="id"
        label="ID"
        width="180">
      </el-table-column>
      <el-table-column
        prop="name"
        label="姓名">
      </el-table-column>
      <el-table-column
        prop="amount1"
        sortable
        label="数值 1">
      </el-table-column>
      <el-table-column
        prop="amount2"
        sortable
        label="数值 2">
      </el-table-column>
      <el-table-column
        prop="amount3"
        sortable
        label="数值 3">
      </el-table-column>
    </el-table>
<script>
function filterArray(item) {
  const valueArray = this.rule.filter(prop => {
    return item[prop] === this.data[prop]
  })
  if (valueArray.length === this.rule.length) {
    return true
  } else {
    return false
  }
}
  export default {
    data() {
      return {
        tableData: [{
          id: '12987122',
          name: '王小虎',
          amount1: '234',
          amount2: '3.2',
          amount3: 10
        }, {
          id: '12987123',
          name: '王小虎',
          amount1: '165',
          amount2: '4.43',
          amount3: 12
        }, {
          id: '12987124',
          name: '王小虎',
          amount1: '324',
          amount2: '1.9',
          amount3: 9
        }, {
          id: '12987125',
          name: '王小虎',
          amount1: '621',
          amount2: '2.2',
          amount3: 17
        }, {
          id: '12987126',
          name: '王小虎',
          amount1: '539',
          amount2: '4.1',
          amount3: 15
        }],
        spanRule: {
            rule: {
              0: ['department_name']   //表示第一列的合并规则
            }
      }
      };
    },
    methods: {
      // 表格合并
          objectSpanMethod({ row, column, rowIndex, columnIndex }, item) {
            if (Object.keys(this.spanRule.rule).includes(columnIndex.toString())) {
              // filter验证数组
              const currentTable = {
                rule: this.spanRule.rule[columnIndex],
                data: item[rowIndex]
              }
              // 该单元格是否被合并 true 合并, false : 不合并
              let chooseSpan = false
              if (rowIndex !== 0) {
                chooseSpan = filterArray.call(currentTable, item[rowIndex - 1])
              }
              if (chooseSpan) {
                return {
                  rowspan: 0,
                  colspan: 0
                }
              } else {
                return {
                  rowspan: item.filter(filterArray, currentTable).length,
                  colspan: 1
                }
              }
            }
          },
    }
  };
</script>


vue3 表格合并

vue3 hooks文件内容


// 定义通用类型(支持任意表格数据类型)
export interface TableSpanRule {
    rule: Record<string, string[]>; // 列索引 → 合并字段列表
}

// 表格合并Hook
export function useTableSpan<T = Record<string, any>>(spanRule: TableSpanRule) {

    const filterArray = (
        currentTable: { rule: string[]; data: T },
        item: T
    ): boolean => {
        const valueArray = currentTable.rule.filter((prop) => {
            return item[prop] === currentTable.data[prop];
        });
        return valueArray.length === currentTable.rule.length;
    };

    const objectSpanMethod = (
        param: {
            row: T;
            column: T;
            rowIndex: number;
            columnIndex: number;
        },
        tableData: T[]
    ) => {
        const { columnIndex, rowIndex } = param;
        // 判断当前列是否在合并规则中
        if (Object.keys(spanRule.rule).includes(columnIndex.toString())) {
            const currentTable = {
                rule: spanRule.rule[columnIndex],
                data: tableData[rowIndex]
            };
            let chooseSpan = false;
            // 非第一行时验证是否需要合并
            if (rowIndex !== 0) {
                chooseSpan = filterArray(currentTable, tableData[rowIndex - 1]);
            }
            // 需要合并则隐藏当前单元格,否则设置合并行数
            if (chooseSpan) {
                return {
                    rowspan: 0,
                    colspan: 0
                };
            } else {
                return {
                    rowspan: tableData.filter((i) => filterArray(currentTable, i)).length,
                    colspan: 1
                };
            }
        }
        // 非合并列返回默认值
        return {
            rowspan: 1,
            colspan: 1
        };
    };

    return {
        objectSpanMethod
    };
}

vue3 表格合并

<el-table
      :data="tableData"
     :span-method="(param)=>objectSpanMethod(param,tableData)" //这里非常重要,tableData字段是表格的数据
      border
      style="width: 100%">
      <el-table-column 
        prop="day"
        label="day"
        width="180">
      </el-table-column>
      <el-table-column
        prop="domainName"
        label="domainName">
      </el-table-column>
      <el-table-column
        prop="allPurchaseCount"
        sortable
        label="allPurchaseCount">
      </el-table-column>
      <el-table-column
        prop="allPurchaseValue"
        sortable
        label="allPurchaseValue">
      </el-table-column>
      <el-table-column
        prop="gaAmountUsd"
        sortable
        label="交易额">
      </el-table-column>
    </el-table>
const tableCol = [  //表格列
  {
    label: t('localeAudience.datetime'),
    prop: 'day',
    width: 120,
    sortable: "custom",
    formatter: (row: any, column: any, text: any) => {
      return text || "-";
    },
  },
  {
    label: t('localeAudience.domain'),
    width: 120,
    prop: 'domainName',
    'show-overflow-tooltip': true,
  },
  {
    label: t('localeAudience.allorders'),
    sortable: "custom",
    width: 120,
    prop: 'allPurchaseCount',
  },
  {
    label: t('localeAudience.allamount'),
    sortable: "custom",
    width: 140,
    prop: 'allPurchaseValue',
  },
  {
    label: '交易额',
    sortable: "custom",
    width: 180,
    prop: 'gaAmountUsd',
  },
];
const tableData = [ // 1.表格数据
  {
    day: '2023-08-01',
    domainName: 'example.com',
    allPurchaseCount: 10,
    allPurchaseValue: 1000,
    gaAmountUsd: 500,
  },
  {
    day: '2023-08-01',
    domainName: 'example.com',
    allPurchaseCount: 5,
    allPurchaseValue: 500,
    gaAmountUsd: 250,
  },
  {
    day: '2023-08-02',
    domainName: 'example.com',
    allPurchaseCount: 8,
    allPurchaseValue: 800,
    gaAmountUsd: 400,
  },
];
// 2. 定义列合并规则
const spanRule = {
  rule: {
    0: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第1列的合并规则
    1: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第2列的合并规则
    2: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第3列的合并规则
    3: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第4列的合并规则
  }
};

// 3. 使用表格合并Hook
const { objectSpanMethod } = useTableSpan(spanRule);

手把手实现链表:单链表与双链表的完整实现

手把手实现链表:单链表与双链表的完整实现

链表是数据结构的基础,也是面试高频考点。很多初学者会卡在“指针操作混乱”“边界条件处理不当”等问题上。本文将从设计思路出发,拆解单链表实现的核心逻辑,同时补充双向链表(双链表)的实现方法,帮你彻底掌握链表的实现技巧。

一、为什么需要手动实现链表?

编程语言(如JavaScript)没有内置链表结构,但链表的“动态扩容”“非连续存储”特性使其在插入/删除操作中比数组更高效(尤其是头部/中部操作)。手动实现链表的核心目标是:

  • 掌握指针(引用)操作的核心逻辑;

  • 理解虚拟头/尾节点等技巧解决边界问题;

  • 规避“空指针操作”“状态不同步”等高频报错;

  • 区分单链表与双链表的设计差异,适配不同场景需求。

二、单链表实现

1. 单链表核心设计思路

链表的最小单元是“节点(Node)”,每个节点包含两部分:

  • val:节点存储的值;

  • next:指向下一个节点的指针(引用),默认null

链表类(MyLinkedList)需维护核心属性,且遵守状态同步约束

属性名 作用 核心约束
dummyHead(虚拟头节点) 统一头节点操作逻辑,避免单独处理头节点 始终存在,next指向真实头节点
tail(尾节点) 优化尾插效率(从O(n)→O(1)) size=0tail=nullsize>0tail指向最后一个节点
size(链表长度) 简化边界判断,避免冗余遍历 增/删操作必须同步更新,与tail状态严格一致

实现步骤(从0开始设计)

第一步:定义节点类

class LinkedNode {
  constructor(val) {
    this.val = val;   // 节点值
    this.next = null; // 指向下一个节点的指针
  }
}

第二步:设计链表类结构

  1. 初始化虚拟头节点(dummyHead):统一头节点操作,避免边界判断
  2. 初始化尾节点(tail):初始为null,空链表时无尾节点
  3. 初始化长度(size):初始为0,记录链表节点数量

第三步:实现基础查询方法

  • isEmpty():判断链表是否为空(size === 0
  • get(index):获取指定索引的节点值
    • 边界校验:index < 0 || index >= size 返回 -1
    • dummyHead.next开始遍历到目标位置

第四步:实现插入方法(核心:先连后断)

  • addAtHead(val):头部插入

    1. 创建新节点
    2. 新节点next指向原头节点(dummyHead.next
    3. dummyHead.next指向新节点
    4. 更新size,若size === 1则更新tail
  • addAtTail(val):尾部插入

    1. 边界处理:空链表时调用addAtHead
    2. 创建新节点
    3. tail.next指向新节点
    4. tail更新为新节点
    5. 更新size
  • addAtIndex(index, val):指定位置插入

    1. 边界处理:index <= 0调用addAtHeadindex > size直接返回
    2. 遍历到插入位置的前驱节点
    3. 新节点next指向原节点,前驱节点next指向新节点
    4. 若插入到尾部,更新tail
    5. 更新size

第五步:实现删除方法(核心:先连后断)

  • deleteAtIndex(index):删除指定位置节点
    1. 边界校验:index < 0 || index >= size || isEmpty() 直接返回
    2. 遍历到删除位置的前驱节点
    3. 前驱节点next指向待删除节点的next(跳过待删除节点)
    4. 待删除节点next置为null(释放引用)
    5. 更新size,若删除的是尾节点,更新tail

关键设计要点:

  • ✅ 使用虚拟头节点统一边界处理
  • ✅ 维护tail指针优化尾插操作
  • sizetail状态必须严格同步
  • ✅ 所有指针操作前先校验边界条件
  • ✅ 遵循"先连后断"原则:先建立新连接,再断开旧连接
  • ✅ 使用虚拟头节点统一边界处理
  • ✅ 维护tail指针优化尾插操作
  • sizetail状态必须严格同步
  • ✅ 所有指针操作前先校验边界条件

2. 单链表完整实现


/**
 * 单链表节点类
 * @param {number} val - 节点存储的值
 */
class LinkedNode {
  constructor(val) {
    this.val = val;       // 节点值
    this.next = null;     // 指向下一个节点的指针
  }
}

/**
 * 单链表实现
 */
class MySinglyLinkedList {
  constructor() {
    this.dummyHead = new LinkedNode('_dummy'); // 虚拟头节点
    this.tail = null;  // 尾节点
    this.size = 0;     // 链表长度
    // 约束:size=0 时 tail=null;size>0 时 tail 指向最后一个节点
  }

  /**
   * 判断链表是否为空
   * @returns {boolean}
   */
  isEmpty() {
    return this.size === 0;
  }

  /**
   * 获取指定索引的节点值
   * @param {number} index - 目标索引(从0开始)
   * @returns {number} 节点值,索引无效返回-1
   */
  get(index) {
    if (index < 0 || index >= this.size) return -1;

    let pointer = this.dummyHead.next;
    for (let i = 0; i < index; i++) {
      pointer = pointer.next;
    }
    return pointer.val;
  }

  /**
   * 头部插入节点
   * @param {number} val - 要插入的值
   */
  addAtHead(val) {
    const newNode = new LinkedNode(val);
    newNode.next = this.dummyHead.next;
    this.dummyHead.next = newNode;

    this.size++;
    // 空链表插入,尾节点同步更新
    if (this.size === 1) {
      this.tail = newNode;
    }
  }

  /**
   * 尾部插入节点
   * @param {number} val - 要插入的值
   */
  addAtTail(val) {
    // 双重兜底校验:避免tail为null但size>0的异常
    if (this.isEmpty() || this.tail === null) {
      this.addAtHead(val);
      return;
    }

    const newNode = new LinkedNode(val);
    this.tail.next = newNode;
    this.tail = newNode;
    this.size++;
  }

  /**
   * 指定索引插入节点
   * @param {number} index - 插入位置
   * @param {number} val - 要插入的值
   */
  addAtIndex(index, val) {
    if (index <= 0) {
      this.addAtHead(val);
      return;
    }
    if (index > this.size) return;

    let pointer = this.dummyHead;
    for (let i = 0; i < index; i++) {
      pointer = pointer.next;
    }

    const newNode = new LinkedNode(val);
    newNode.next = pointer.next;
    pointer.next = newNode;

    // 插入到尾部时更新tail
    if (index === this.size) {
      this.tail = newNode;
    }
    this.size++;
  }

  /**
   * 删除头部节点
   */
  deleteAtHead() {
    if (this.isEmpty()) return;

    const oldHead = this.dummyHead.next;
    this.dummyHead.next = oldHead.next;
    oldHead.next = null;

    this.size--;
    // 同步更新tail
    if (this.size === 0) {
      this.tail = null;
    } else if (oldHead === this.tail) {
      this.tail = this.dummyHead.next;
    }
  }

  /**
   * 删除尾部节点
   */
  deleteAtTail() {
    if (this.isEmpty()) return;

    if (this.size === 1) {
      this.deleteAtHead();
      return;
    }

    let pointer = this.dummyHead;
    while (pointer.next.next) {
      pointer = pointer.next;
    }

    pointer.next.next = null;
    this.tail = pointer.next;
    this.size--;
  }

  /**
   * 删除指定索引节点
   * @param {number} index - 要删除的索引
   */
  deleteAtIndex(index) {
    if (index < 0 || index >= this.size || this.isEmpty()) {
      return;
    }

    let pointer = this.dummyHead;
    for (let i = 0; i < index; i++) {
      pointer = pointer.next;
    }

    const nodeToDel = pointer.next;
    pointer.next = nodeToDel.next;
    nodeToDel.next = null;

    this.size--;
    // 同步更新tail
    if (this.size === 0) {
      this.tail = null;
    } else if (nodeToDel === this.tail) {
      this.tail = pointer.next || pointer;
    }
  }
}

// 单链表测试用例
const singlyList = new MySinglyLinkedList();
singlyList.addAtHead(1);
singlyList.addAtTail(3);
singlyList.addAtIndex(1, 2);
console.log("单链表get(1):", singlyList.get(1)); // 输出2
singlyList.deleteAtIndex(1);
console.log("单链表get(1):", singlyList.get(1)); // 输出3

3. 单链表核心易错点

易错点 错误表现 修复方案
空指针操作 Cannot set properties of null (setting 'next') 所有指针操作前先校验null,使用isEmpty()size判断
tail状态不同步 删除节点后tail仍指向已删除节点 删除操作后同步更新tailsize=0tail=null
边界条件遗漏 index=0index=size时操作失败 使用虚拟头节点统一处理,特殊位置单独判断
指针操作顺序错误 先断开原链表导致节点丢失 遵循"先连后断"原则:先建立新连接,再断开旧连接
size未同步更新 size与实际节点数不一致 每次增/删操作必须同步更新size

调试技巧:

// 添加调试方法:打印链表结构
toString() {
  const values = [];
  let current = this.dummyHead.next;
  while (current) {
    values.push(current.val);
    current = current.next;
  }
  return `[${values.join(' -> ')}] (size: ${this.size}, tail: ${this.tail?.val ?? 'null'})`;
}

三、双向链表(双链表)实现

1. 双链表核心实现逻辑

(1)双链表与单链表的核心差异

单链表的节点只有next指针(指向后继节点),只能“单向遍历”;双链表的节点新增prev指针(指向前驱节点),支持“双向遍历”,核心优势:

  • 删除节点时,无需遍历找前驱节点(时间复杂度从O(n)→O(1));

  • 支持从尾部反向遍历,适配“逆序操作”场景;

  • 插入/删除操作更灵活,边界处理可通过“虚拟头+虚拟尾”进一步简化。

(2)双链表核心设计要点
  • 节点结构:每个节点包含val(值)、prev(前驱指针)、next(后继指针);

  • 虚拟节点:同时维护dummyHead(虚拟头)和dummyTail(虚拟尾),彻底统一头尾节点的操作逻辑;

  • 状态同步:维护size(长度),且每个节点的prev/next指针必须成对更新(避免指针悬空);

  • 操作原则:插入/删除时,先更新新节点的prev/next,再更新原链表的指针(先连后断)。

实现步骤(基于单链表扩展)

前提:已掌握单链表实现,在此基础上扩展为双链表。

第一步:扩展节点类(新增prev指针)

class DoublyLinkedNode {
  constructor(val) {
    this.val = val;   // 节点值
    this.prev = null; // 指向前驱节点的指针(新增)
    this.next = null; // 指向后继节点的指针
  }
}

第二步:扩展链表类结构(新增虚拟尾节点)

  1. 保留虚拟头节点(dummyHead):与单链表相同
  2. 新增虚拟尾节点(dummyTail:统一尾节点操作,避免边界判断
  3. 初始化连接:dummyHead.next = dummyTaildummyTail.prev = dummyHead
  4. 初始化长度(size):初始为0

第三步:实现辅助方法(优化查找)

  • getNode(index):根据索引获取节点(优化版)
    • 边界校验:index < 0 || index >= size 返回 null
    • 优化策略:索引在前半段从头遍历,在后半段从尾遍历(最坏O(n/2))

第四步:实现插入方法(核心:prevnext成对更新)

  • addAtHead(val):头部插入

    1. 创建新节点
    2. 获取原头节点(dummyHead.next
    3. 成对更新指针
      • 新节点:prev指向dummyHeadnext指向原头节点
      • 原头节点:prev指向新节点
      • dummyHeadnext指向新节点
    4. 更新size
  • addAtTail(val):尾部插入

    1. 创建新节点
    2. 获取原尾节点(dummyTail.prev
    3. 成对更新指针
      • 新节点:prev指向原尾节点,next指向dummyTail
      • 原尾节点:next指向新节点
      • dummyTailprev指向新节点
    4. 更新size
  • addAtIndex(index, val):指定位置插入

    1. 边界处理:index <= 0调用addAtHeadindex > size直接返回,index === size调用addAtTail
    2. 使用getNode(index)找到插入位置的后继节点(nextNode
    3. 获取前驱节点(nextNode.prev
    4. 成对更新指针
      • 新节点:prev指向prevNodenext指向nextNode
      • prevNodenext指向新节点
      • nextNodeprev指向新节点
    5. 更新size

第五步:实现删除方法(核心优势:O(1)删除)

  • deleteAtIndex(index):删除指定位置节点
    1. 边界校验:使用getNode(index)获取待删除节点,无效则返回
    2. 核心优势:直接获取前驱(nodeToDel.prev)和后继(nodeToDel.next),无需遍历
    3. 成对更新指针
      • 前驱节点:next指向后继节点
      • 后继节点:prev指向前驱节点
      • 待删除节点:prevnext置为null(释放引用)
    4. 更新size

第六步:实现扩展功能(双链表特有)

  • reverseTraverse():逆序遍历
    1. dummyTail.prev开始
    2. 通过prev指针向前遍历
    3. 直到dummyHead结束

关键设计要点(相比单链表的升级):

  • 双指针维护:每个节点的prevnext必须成对更新
  • 虚拟头+虚拟尾:彻底统一边界处理,无需维护tail指针
  • O(1)删除优势:删除任意节点无需遍历找前驱
  • 双向遍历优化:根据索引位置选择遍历方向(优化查找效率)
  • 指针释放:删除节点后必须将prevnext置为null

2. 双链表完整实现


/**
 * 双链表节点类
 * @param {number} val - 节点存储的值
 */
class DoublyLinkedNode {
  constructor(val) {
    this.val = val;       // 节点值
    this.prev = null;     // 指向前驱节点的指针
    this.next = null;     // 指向后继节点的指针
  }
}

/**
 * 双向链表实现(优化版:虚拟头+虚拟尾)
 */
class MyDoublyLinkedList {
  constructor() {
    this.dummyHead = new DoublyLinkedNode('_dummyHead'); // 虚拟头节点
    this.dummyTail = new DoublyLinkedNode('_dummyTail'); // 虚拟尾节点
    this.size = 0;                                       // 链表长度

    // 初始化:虚拟头的next指向虚拟尾,虚拟尾的prev指向虚拟头
    this.dummyHead.next = this.dummyTail;
    this.dummyTail.prev = this.dummyHead;
    // 约束:真实节点始终在dummyHead和dummyTail之间
  }

  /**
   * 判断链表是否为空
   * @returns {boolean}
   */
  isEmpty() {
    return this.size === 0;
  }

  /**
   * 辅助方法:根据索引找到对应节点(优化:判断索引位置,选择从头/尾遍历)
   * @param {number} index - 目标索引
   * @returns {DoublyLinkedNode|null} 找到的节点/索引无效返回null
   */
  getNode(index) {
    if (index < 0 || index >= this.size) return null;

    let current;
    // 优化:索引在前半段,从头遍历;索引在后半段,从尾遍历
    if (index < this.size / 2) {
      current = this.dummyHead.next;
      for (let i = 0; i < index; i++) {
        current = current.next;
      }
    } else {
      current = this.dummyTail.prev;
      for (let i = this.size - 1; i > index; i--) {
        current = current.prev;
      }
    }
    return current;
  }

  /**
   * 获取指定索引的节点值
   * @param {number} index - 目标索引
   * @returns {number} 节点值,索引无效返回-1
   */
  get(index) {
    const node = this.getNode(index);
    return node ? node.val : -1;
  }

  /**
   * 头部插入节点
   * @param {number} val - 要插入的值
   */
  addAtHead(val) {
    const newNode = new DoublyLinkedNode(val);
    const nextNode = this.dummyHead.next; // 虚拟头的后继节点(原真实头)

    // 步骤1:新节点的prev指向虚拟头,next指向原真实头
    newNode.prev = this.dummyHead;
    newNode.next = nextNode;

    // 步骤2:原真实头的prev指向新节点
    nextNode.prev = newNode;

    // 步骤3:虚拟头的next指向新节点
    this.dummyHead.next = newNode;

    this.size++; // 长度+1
  }

  /**
   * 尾部插入节点
   * @param {number} val - 要插入的值
   */
  addAtTail(val) {
    const newNode = new DoublyLinkedNode(val);
    const prevNode = this.dummyTail.prev; // 虚拟尾的前驱节点(原真实尾)

    // 步骤1:新节点的prev指向原真实尾,next指向虚拟尾
    newNode.prev = prevNode;
    newNode.next = this.dummyTail;

    // 步骤2:原真实尾的next指向新节点
    prevNode.next = newNode;

    // 步骤3:虚拟尾的prev指向新节点
    this.dummyTail.prev = newNode;

    this.size++; // 长度+1
  }

  /**
   * 指定索引插入节点
   * @param {number} index - 插入位置
   * @param {number} val - 要插入的值
   */
  addAtIndex(index, val) {
    // 边界处理:index<=0插头部,index>size不插入
    if (index <= 0) {
      this.addAtHead(val);
      return;
    }
    if (index > this.size) return;
    // index===size 插尾部
    if (index === this.size) {
      this.addAtTail(val);
      return;
    }

    // 找到插入位置的目标节点(新节点的后继节点)
    const nextNode = this.getNode(index);
    const prevNode = nextNode.prev; // 目标节点的前驱(新节点的前驱)
    const newNode = new DoublyLinkedNode(val);

    // 步骤1:新节点的prev指向prevNode,next指向nextNode
    newNode.prev = prevNode;
    newNode.next = nextNode;

    // 步骤2:prevNode的next指向新节点
    prevNode.next = newNode;

    // 步骤3:nextNode的prev指向新节点
    nextNode.prev = newNode;

    this.size++; // 长度+1
  }

  /**
   * 删除指定索引节点
   * @param {number} index - 要删除的索引
   */
  deleteAtIndex(index) {
    const nodeToDel = this.getNode(index);
    if (!nodeToDel) return; // 索引无效直接返回

    // 步骤1:获取待删除节点的前驱和后继
    const prevNode = nodeToDel.prev;
    const nextNode = nodeToDel.next;

    // 步骤2:跳过待删除节点,连接前驱和后继
    prevNode.next = nextNode;
    nextNode.prev = prevNode;

    // 步骤3:释放待删除节点的指针(避免内存泄漏)
    nodeToDel.prev = null;
    nodeToDel.next = null;

    this.size--; // 长度-1
  }

  /**
   * 扩展方法:逆序遍历链表(双链表核心优势)
   * @returns {number[]} 逆序的节点值数组
   */
  reverseTraverse() {
    const result = [];
    let current = this.dummyTail.prev; // 从虚拟尾的前驱开始遍历
    while (current !== this.dummyHead) {
      result.push(current.val);
      current = current.prev;
    }
    return result;
  }
}

// 双链表测试用例
const doublyList = new MyDoublyLinkedList();
doublyList.addAtHead(1);
doublyList.addAtTail(3);
doublyList.addAtIndex(1, 2);
console.log("双链表get(1):", doublyList.get(1)); // 输出2
console.log("双链表逆序遍历:", doublyList.reverseTraverse()); // 输出[3,2,1]
doublyList.deleteAtIndex(1);
console.log("双链表get(1):", doublyList.get(1)); // 输出3
console.log("双链表逆序遍历:", doublyList.reverseTraverse()); // 输出[3,1]

3. 双链表核心易错点

易错点 错误表现 修复方案
指针更新顺序错误 先修改原链表指针,导致新节点指针丢失 先更新新节点的prev/next,再修改原链表的指针(先连后断)
虚拟头尾未初始化 dummyHead.next/dummyTail.prev为null,操作时报错 初始化时必须让dummyHead.next = dummyTaildummyTail.prev = dummyHead
遍历方向选择不当 无论索引位置都从头遍历,效率低 判断索引是否小于size/2,选择从头/尾遍历(优化时间复杂度)
仅更新单向指针 只更新next不更新prev,导致链表断裂 插入/删除时,prevnext必须成对更新
未释放删除节点的指针 节点删除后仍有prev/next引用,导致内存泄漏(JS中影响小,但不规范) 删除后将节点的prev/next置为null

四、实战应用场景

1. LeetCode 经典题目

2. 实际应用场景

  • LRU缓存:使用双链表维护访问顺序,O(1)时间删除任意节点
  • 浏览器历史记录:双链表支持前进/后退操作
  • 撤销/重做功能:双链表维护操作历史
  • 音乐播放列表:单链表实现顺序播放
  • 任务队列:单链表实现FIFO队列

3. 面试高频考点

  1. 指针操作:如何正确更新next/prev指针
  2. 边界处理:空链表、单节点、头尾节点的特殊处理
  3. 状态同步sizetail等状态的维护
  4. 时间复杂度优化:双链表的删除优势、虚拟节点的作用
  5. 内存管理:指针释放、避免内存泄漏

五、总结

1. 单链表核心

  • 核心属性:dummyHead(虚拟头)+ tail(尾节点)+ size(长度);

  • 修复关键:sizetail同步更新,对null敏感操作增加兜底校验;

  • 避坑原则:先校验边界,再执行核心逻辑,指针操作“先连后断”。

2. 双链表核心

  • 核心升级:节点新增prev指针,新增dummyTail(虚拟尾);

  • 效率优势:删除节点无需找前驱,支持双向遍历;

  • 操作原则:prev/next成对更新,遍历方向按需选择。

掌握单链表和双链表的实现逻辑后,不仅能应对链表等基础题,还能扩展到环形链表、LRU缓存(双链表+哈希表)等进阶场景。建议结合测试用例反复调试,重点关注指针操作和状态同步,形成肌肉记忆。

❌