编程导航Javascript话题讨论

Javascript

486 参与
分享

快来分享你的内容吧~

点击登录,快来和大家讨论吧~
表情
图片
话题
打卡
综合
交流
文章
问答

Notion 的公式栏里,藏着一台虚拟机——逆向 + 用 600 行 JS 复刻它的编译器与栈式 VM

> 本文基于对 Notion 公开前端产物的静态分析,所有指令名、变量名均为还原命名、行为等价,仅供学习与研究。文中区分了「逆向实抓」与「合理推断」,请放心食用。 在 Notion 里建一张表,加一个公式列,敲下: ```text prop("时薪") * prop("工时") ``` 回车,数字立刻出现。平平无奇——直到你打开 DevTools,在压缩后的前端代码里翻到一个 31KB 的模块,发现里面赫然躺着:一个**词法分析器**、一个**递归下降解析器**、一个把语法树编译成**字节码**的编译器,以及一台逐条执行字节码的**栈式虚拟机**。 一个笔记软件,为了算一列公式,在你的浏览器里塞了一台虚拟机。 为什么?这篇文章就顺着这个问题,把这台 VM 逆向出来,再用不到 600 行 JavaScript 把它复刻一遍——重点是它最精彩的两个设计:**用生成器实现「算到一半能挂起、取完数据再从原地继续」的求值**,以及**把 lambda 当作「字节码数据」在运行时重新喂回 VM**。 ![复刻demo演示](https://pic.code-nav.cn/post_picture/2059567394535264258/JroaBkIF7FtypDx7.webp) *** ## TL;DR * 逆向对象是 Notion 前端两个 rspack 模块:`448187`(VM + 编译器)与 `947152 / 942007`(函数目录,命名空间 `formula2`)。 * 它是一台**纯 JavaScript 解释器**,全程没有 `WebAssembly`。`formula2` 暴露了 **31 个算子 + 65 个函数**(`map`/`filter`/`sort` 等列表高阶函数、`let`/`lets` 绑定、正则字符串函数都在)。 * 栈上的每个值是带类型标签的「盒子」`{type, value}`。 * 编译器把操作数**逆序压栈**;VM 主循环「`ip` 先自增、后分派」;`if` 被编译成跳转字节码。 * 王牌是**生成器**:求值到 `prop("X")` 这种需要远端数据的地方就 `yield` 挂起,调度器取回数据后 `.next(data)` 让它从同一条指令继续。 * lambda 不是闭包,而是**编译好的子字节码当成常量压栈**;库函数执行时 `yield*` 把它重新喂回 VM——所以 VM 必须是**可重入**的。 * 我把整套东西做成了一个**可单步、全状态可视化**的教学网页(单文件 HTML,零依赖),源码在 [GitHub](https://github.com/fluffyox/notion-vm)。 *** ## 一、为什么不直接 `eval`? 最朴素的实现是:把用户公式拼成 JS,丢给 `eval` 或 `new Function`。Notion 没这么做,原因有三个,每一个都直接逼出了「编译器 + 虚拟机」这套架构。 **第一,同一个公式要在一整列上反复跑。** 一个公式列有几千行,每行都要算一遍;筛选、排序、滚动都会触发重算。把公式**编译一次**得到字节码,然后这列的每一行复用同一份字节码——这就是 compile-once-run-many。每次都重新解析语法树是巨大的浪费。 **第二,求值过程必须能「暂停」。** 公式里可以写 `prop("关联表").map(...)`,沿着 relation/rollup 去引用别的行、别的表。这些数据常常**不在本地**,要异步去取。如果用同步的 `eval`,碰到缺数据就只能阻塞或报错。Notion 要的是「同步的写法、异步的执行」:算到需要远端数据的那一刻,**把整个求值过程冻结起来**,去把数据取回来,再从冻结点继续。 **第三,公式语言有 lambda。** `map`、`filter`、`sort` 的参数是一段「对每个元素都要重新跑一遍」的表达式。它需要被表示成一个**可反复调用的独立执行单元**。 这三条约束——高频重算、异步可挂起、列表 lambda——单靠递归解释一棵语法树是很难优雅满足的。于是就有了一台字节码虚拟机。这跟 SQLite 把 SQL 编译成字节码喂给它的虚拟机 VDBE 是同一个思路(顺带一提,Notion 原生端的本地存储正是 SQLite)。 *** ## 二、流水线总览 一行公式从字符串到结果,要走五道工序: ```mermaid graph LR SRC[&#34;公式源码&#34;] --> TOK[&#34;词法<br/>Token 流&#34;] TOK --> AST[&#34;语法分析<br/>AST 语法树&#34;] AST --> BC[&#34;编译器<br/>栈式字节码&#34;] BC --> VM[&#34;栈式虚拟机<br/>生成器解释循环&#34;] VM --> RES[&#34;结果盒子<br/>type + value&#34;] VM -. 挂起取数 .-> NET[&#34;记录缓存/网络&#34;] NET -. next 恢复 .-> VM ``` 词法和语法分析是教科书内容,本文不展开(我的复刻里是一个递归下降 + 优先级爬升的解析器)。真正有意思的是后三段:编译器、虚拟机、以及它们之间那条「挂起取数」的虚线。我们一段段拆。 *** ## 三、值是带类型标签的「盒子」 第一个设计决定:栈上跑的不是裸 JS 值,而是统一的「盒子」——`{type, value}`。逆向出的类型有: `number`、`text`(`value` 是富文本数组)、`checkbox`、`date`、`person`、`block`(行指针)、`array`、`undefined`,以及一个特别的 `compiledCode`(一段子字节码,后面讲 lambda 时会用到)。 为什么不用裸值?因为 `1 + "x"` 在公式里要做文本拼接、`date < date` 要走时区感知比较、`undefined` 在数值上下文里要当 0——**运算的语义由类型决定**。把类型随值一起带在盒子里,分派起来才干净。 配套的是一个看似普通、实则关键的栈类: ```js class Stack { constructor() { this.u = []; } push(v) { this.u.push(v); } popValueOrCode() { return this.u.pop(); } // 允许弹出 compiledCode popValue() { // 禁止弹出 compiledCode const v = this.u.pop(); if (v && v.type === "compiledCode") throw new Error("unexpected compiled code"); return v; } } ``` 注意它有**两种弹出**。普通运算用 `popValue`:如果你试图把一段「代码」当成「值」去做加法,它直接抛错。而库函数取它的惰性参数(lambda)时用 `popValueOrCode`,允许拿到那段代码。这个区分,是整个 lambda 机制的支柱——记住它,第六节会回来。 *** ## 四、第一个反直觉点:操作数逆序压栈 栈式 VM 的常识是:算 `a - b`,先把 `a`、`b` 压栈,再执行减法。但逆向出来的编译器,**操作数是反着压的**。 ```js function compileBin(node) { const { op } = node; // ……除法、取模、and/or 走库函数,此处省略…… const t = (op === "+" || op === "-") ? "add" : op === "*" ? "multiply" : op === "^" ? "exponentiation" : (op === "==" || op === "!=") ? "equality" : "relational"; // 逆序压栈:先发射 rhs,再发射 lhs return I(node.rhs).concat(I(node.lhs)).concat([{ type: t, op, node }]); } ``` `I(rhs)` 在前、`I(lhs)` 在后。以 `1 - 2` 为例,编译产物是: ```text 0 loadConstant number 2 ← 先压右操作数 1 loadConstant number 1 ← 再压左操作数(它在栈顶) 2 add (op: "-") ``` 为什么要这样?因为栈是**后进先出**。我们希望执行减法时,**先弹出的是左操作数**。逆序压栈之后,左操作数 `1` 正好在栈顶,于是: ```js case "add": { const a = frame.stack.popValue(); // 弹出 = 1(左操作数) const b = frame.stack.popValue(); // 再弹 = 2(右操作数) frame.stack.push(addOp(node, a, b)); // 算 a - b = -1,顺序正确 break; } ``` 这个规则在二元运算、函数参数、数组字面量里是统一应用的(参数也逆序发射,执行时 `pop` 重建书写顺序)。它不影响结果,但你不知道这条约定的话,照着写出来的减法、除法会全部算反——这是逆向时一个很容易栽的坑。 *** ## 五、VM 主循环:先自增,后分派 虚拟机的心脏是一个 `while` 循环。逆向出的版本有个细节:**取出当前指令后,先把指令指针 `ip` 自增,再去分派执行**。 ```js function* F(instrs, ctx) { const frame = { instrs, ip: 0, stack: new Stack(), ctx }; // …把 frame 压入运行时 frames 栈,用于可视化… while (frame.ip < instrs.length) { const T = instrs[frame.ip]; frame.ip++; // ★ 先自增,后分派 switch (T.type) { case "loadConstant": frame.stack.push(T.value); break; case "loadName": frame.stack.push(lookupBinding(ctx, T.name)); break; case "loadToken": frame.stack.push(yield* resolveToken(T, ctx)); break; // 可挂起 case "add": { const a = frame.stack.popValue(), b = frame.stack.popValue(); frame.stack.push(addOp(T.node, a, b)); break; } case "multiply": { const a = frame.stack.popValue(), b = frame.stack.popValue(); frame.stack.push(mulOp(a, b)); break; } case "relational": { const a = frame.stack.popValue(), b = frame.stack.popValue(); frame.stack.push(yield* relOp(T.node, a, b)); break; } // 可挂起 case "array": { const vs = []; for (let i = 0; i < T.count; i++) vs.push(frame.stack.popValue()); frame.stack.push({ type: "array", values: vs }); break; } case "relativeJump": frame.ip += T.offset; break; case "jumpIfTruthy": { const c = frame.stack.popValue(); if (truthy(c)) frame.ip += T.offset; break; } case "callLibraryFunction": { const args = []; for (let i = 0; i < T.argCount; i++) args.push(frame.stack.popValueOrCode()); frame.stack.push(yield* T.fn.eval(args, ctx)); break; } // 可挂起 + 可重入 case "runLets": frame.stack.push(yield* runLets(T, ctx)); break; } } return frame.stack.popValue(); } ``` (上面为聚焦主线略去了少量错误守卫,完整版见仓库。) ```mermaid graph TD A["ip = 0"] --> B{"ip < 指令数?"} B -- 否 --> Z["弹出栈顶作为返回值"] B -- 是 --> C["取指 T = instrs[ip]"] C --> D["ip++(先自增,后分派)"] D --> E{"按 T.type 分派"} E --> F1["loadConstant:压入常量"] E --> F2["add / multiply:弹2个算1个压回"] E --> F3["loadToken:yield 取数(可挂起)"] E --> F4["callLibraryFunction:yield* 调用函数"] E --> F5["jumpIfTruthy:改写 ip"] F1 --> B F2 --> B F3 --> B F4 --> B F5 --> B ``` 「先自增后分派」的意义,在跳转指令上才显出来:`jumpIfTruthy` 的偏移量 `offset` 是相对**已经自增过的** `ip` 计算的。复刻时如果偏移基准算错一格,整段跳转会错位。这就引出下一节。 注意 `switch` 里有好几个 `yield*`——它们是这台 VM 能「挂起」和「重入」的入口,是后两节的主角。 *** ## 六、`if` 被编译成跳转——顺便揭穿一个误解 公式里的 `if(条件, 真值, 假值)`,**不是一个普通函数**。如果它是函数,那么调用前两个分支都得先求值(参数总是先于调用被算出来),`if` 就失去短路能力了。逆向出的做法是:编译器把 `if` 直接**展开成跳转字节码**。 ```js function compileIf(node) { const cond = emit(node.args[0]); const thenBC = emit(node.args[1]); const elseBC = emit(node.args[2]); return [ ...cond, { type: "jumpIfTruthy", offset: elseBC.length + 1 }, // 条件为真 → 跳过 else 段 ...elseBC, { type: "relativeJump", offset: thenBC.length }, // else 执行完 → 跳过 then 段 ...thenBC, ]; } ``` 布局是「条件 → 跳转 → else 段 → 无条件跳转 → then 段」。条件为真时跳过整个 else 段、落到 then 段;为假时顺序落入 else 段、执行完再无条件跳过 then 段。两个分支永远只走一个——这才是真正的短路。`ifs`(多路条件)则被递归地拆成嵌套的 `if`。 **反过来,`and` / `or` 是急性求值的普通函数。** 它们的参数在调用前就已经被全部算到栈上了,所以 `and`/`or` **不短路**。在公式引擎里,唯一的短路控制流来自 `if`/`ifs` 的跳转。这个区别,不看字节码是不会注意到的。 *** ## 七、王牌:用生成器做一台「可挂起」的虚拟机 现在来到全篇最漂亮的设计。 回想第一节的动机二:求值碰到远端数据要能暂停。Notion 的实现是——**VM 主体 `F` 是一个生成器函数**(`function*`)。当执行到 `loadToken`(也就是读 `prop("X")`)而本地没有这条记录时,它不阻塞、不报错,而是 `yield` 出一个「我需要这个记录」的请求: ```js function* resolveToken(T, ctx) { if (T.token.kind === "property") { const data = yield { t: "fetch", pointer: ctx.rowPointer, property: T.token.name }; if (data == null) throw new Error("MissingThisRow"); // 取不到 → 结构化错误 return data; // 取到 → 作为值盒子返回,压栈 } } ``` `yield` 之后,**这个生成器就地冻结**——它的指令指针 `ip`、操作数栈、整条调用栈,全被 JavaScript 运行时原样保存在生成器对象里。外层的调度器(驱动循环)接管: ```js async function drive(bytecode, ctx) { const gen = F(bytecode, ctx); let injected; for (;;) { const { value: ev, done } = injected === undefined ? gen.next() : gen.next(injected); injected = undefined; if (done) return ev; // 求值完成,ev 是结果盒子 if (ev.t === "fetch") { const box = await getRecord(ev.pointer, ev.property); // 本地命中就立即返回,缺数据就走网络 injected = box; // 把数据通过 .next(box) 喂回挂起点 } } } ``` 关键在 `gen.next(injected)`:传给 `next` 的值,会成为生成器内部那个 `yield` 表达式的返回值。也就是说,数据取回来后,VM 从**那条 `loadToken` 的同一位置**继续往下跑,仿佛中间什么都没发生。 ```mermaid sequenceDiagram participant VM as 虚拟机·生成器 participant SCH as 调度器 participant DATA as 记录缓存/网络 VM->>VM: 执行到 loadToken(读 prop) VM-->>SCH: yield 需要的记录指针 Note over VM: 在此冻结:ip、操作数栈、<br/>整条调用栈被原样保留 SCH->>DATA: 本地有这条记录吗? alt 命中本地缓存 DATA-->>SCH: 立即返回值盒子 else 缺数据 DATA-->>SCH: 发起网络请求,返回值盒子 end SCH-->>VM: gen.next 把盒子喂回 Note over VM: 从同一条指令解冻、继续执行 ``` 一句话总结这个机制:**生成器捕获的那个挂起态,本身就是一个可以冻结、可以解冻的调用栈。** 当数据本来就在本地时,整个过程一次 `yield` 都不发生,纯同步,零开销;只有真要去远端取数时才挂起。这就是「同步的写法、异步的执行」。 > 我在复刻的教学工具里,把这个「冻结」做成了一个会盖在虚拟机面板上的覆盖层:求值撞到一个标记为「冷」的属性时,整台 VM 的 `ip`、栈、调用栈定格不动,取数返回后再「解冻」从原地继续。看一眼那个动画,比读十遍文字都直观。 *** ## 八、lambda 的真相:不是闭包,是「字节码即数据」 `map([1,2,3], current * current)` 里的 `current * current`,是一段要对每个元素都重跑的代码。Notion 怎么表示它? 不是闭包。逆向出的答案更硬核:**编译时把这段表达式单独编译成一串子字节码,包成一个 `compiledCode` 盒子,当成常量压栈。** ```js function compileCall(node) { const fn = LIB[node.name]; const args = node.args.slice(); let out = []; for (let k = args.length - 1; k >= 0; k--) { // 参数同样逆序压栈 const an = args[k]; if (fn.lazy && fn.lazy.has(k)) { // 该形参是「惰性/代码」参数? // 不直接发射这段表达式,而是把它编译成子字节码,作为常量压栈 out.push({ type: "loadConstant", value: { type: "compiledCode", instructions: I(an) } }); } else { out = out.concat(I(an)); // 普通参数照常发射 } } out.push({ type: "callLibraryFunction", name: node.name, argCount: args.length, fn }); return out; } ``` 每个库函数自带一张「哪些参数是惰性的」表(比如 `map` 的第 2 个参数)。惰性参数不会被立即求值,而是变成一颗 `compiledCode`「代码弹珠」躺在栈上。 到了运行时,`map` 的实现用 `popValueOrCode`(还记得第三节那两种弹出吗?)把这颗代码弹珠取出来,然后**对每个元素,`yield*` 把这段子字节码重新喂回 VM 主体 `F` 跑一遍**,跑之前往上下文里注入两个绑定——当前元素 `current` 和下标 `index`: ```js function* runLambda(codeBox, el, idx, ctx) { const childCtx = { ...ctx, values: [{ kind: "Binding", id: "current", value: el }, { kind: "Binding", id: "index", value: num(idx) }, ...ctx.values], }; return yield* F(codeBox.instructions, childCtx); // ★ VM 重新调用自己 } ``` `yield* F(...)` 这一句,就是 **VM 在执行库函数的过程中,又递归地驱动了一个新的 VM 帧**。这正是为什么主循环里那么多 `yield*`、为什么 VM 必须是**可重入**的生成器:lambda 的执行 = 子字节码 + 再次进入 VM。 ```mermaid graph TD M["main 帧:执行 sum(map(...))"] --> C1["遇到 callLibraryFunction map"] C1 --> L["map.eval 对每个元素调用 runLambda"] L --> R["yield* F(λ 子字节码, ctx'):注入 current / index"] R --> N["新建一个 VM 帧(VM 重入自己)"] N --> RET["lambda 求完值,帧弹出,结果回到 map"] RET --> C1 ``` 光说不够,看一段我的复刻在跑 `sum(map([1, 2], current * 10))` 时,逐指令打印的真实执行轨迹(精简了列): ```text 0 ip=0 loadConstant compiledCode(λ) stack:[] frames:[main] 1 ip=1 loadConstant number 2 stack:[λ] frames:[main] 2 ip=2 loadConstant number 1 stack:[λ 2] frames:[main] 3 ip=3 array count=2 stack:[λ 2 1] frames:[main] 4 ip=4 callLibraryFunction map stack:[λ [1 2]] frames:[main] 5 ip=0 loadConstant number 10 stack:[] frames:[main › λ current=1 [#0]] 6 ip=1 loadName current stack:[10] frames:[main › λ current=1 [#0]] 7 ip=2 multiply stack:[10 1] frames:[main › λ current=1 [#0]] 8 ip=0 loadConstant number 10 stack:[] frames:[main › λ current=2 [#1]] 9 ip=1 loadName current stack:[10] frames:[main › λ current=2 [#1]] 10 ip=2 multiply stack:[10 2] frames:[main › λ current=2 [#1]] 11 ip=5 callLibraryFunction sum stack:[[10 20]] frames:[main] RESULT 30 ``` 看第 5 行那一刻:`frames` 从 `[main]` 长出了 `[main > lambda current=1 [#0]]`——VM 重入了自己,调用栈多了一层 lambda 帧,`current` 被绑成了第一个元素。第 7 行栈是 `[10 1]`,正应了第四节的逆序压栈:先弹 `1`(即 `current`)、后弹 `10`,算 `current * 10`。两个元素各跑完一遍 lambda 帧后,回到 `main`,`sum` 把 `[10, 20]` 折叠成 `30`。 `map`/`filter`/`find`/`some`/`every`/`sort` 全都共用这一套底座。`let`/`lets` 也是类似套路:编译成一条 `runLets` 指令,逐个求值绑定、压进 `ctx.values` 头部,`loadName` 再反查。 *** ## 九、算术里的小心思:整数走快路、小数才付精度税 被坑过 `0.1 + 0.2 !== 0.3` 的人都知道浮点的麻烦。表格软件对数值精度是较真的。逆向出的加法语义是这样权衡的: ```js function addOp(node, a, b) { const op = node.op === "-" ? "-" : "+"; const aNum = a.type === "number" || a.type === "undefined"; const bNum = b.type === "number" || b.type === "undefined"; if (op === "+" && aNum && bNum) { // 两边都是数(undefined 当 0) const x = a.type === "undefined" ? 0 : a.value; const y = b.type === "undefined" ? 0 : b.value; return num(isInt(x) && isInt(y) ? x + y // 整数 → 原生加法(快路径) : decAdd(x, y)); // 含小数 → 任意精度加法(慢路径) } // 减法同构;任一边不是纯数字 → 转富文本后拼接成 text return txt(boxToText(a) + boxToText(b)); } ``` 精髓在那个三元表达式:**两个操作数都是整数,就走原生 `+`**(比任意精度库快一个数量级,而整数运算的 JS 原生结果是精确的);**只要含小数,才切到任意精度路径**,保证 `0.1 + 0.2` 得到 `0.3` 而不是 `0.30000000000000004`。常见的整数运算不为不存在的精度问题买单,小数才付这份「精度税」。`-`、`*`、`^` 都是同样的整数快路径 + 慢路径分流。 其余几个语义也值得记一笔:除法零除返回 `undefined`(不是 `Infinity`);`round` 的精度参数必须是整数且绝对值 ≤ 12(每个函数都自带参数类型校验和结构化错误);`min`/`max` 返回**原始盒子**以保留类型。 *** ## 十、海量派生为什么不退化成 N\*M 次往返? > 诚实声明:这一节描述的是 **VM 之上的调度层**,属于合理推断 + 业界标准做法,**不是从那两个模块逐字逆出**的。前面九节都有实抓代码支撑,这节没有,请区别对待。 第七节解决了「一个单元格挂起取数」。但一张大表有成百上千个派生单元格,每个都可能挂起、都要请求关系记录。如果每个请求各自单独往返,那就是 N 行 x M 条关系 = N\*M 次网络调用,必然卡死。 标准解法是在 VM 之上放一个**调度层**,把同一轮里所有挂起的请求**收集、去重、合并成一次批量取数**(就是 DataLoader 那套)。多个生成器各自挂在自己的 `yield` 上,调度器把它们的数据需求并起来、去重、一次性取回,再唤醒全部挂起的生成器。 ```text 4 个派生格,各自挂起,需要的关系记录有重叠 R1:[a b c] R2:[b c d] R3:[c d e] R4:[a e] │ │ │ │ └──── 收集 + 去重 → {a b c d e} ───────┘ │ 一次批量取数(1 次往返) │ └──── 唤醒全部挂起的生成器,各自恢复 ────┘ 朴素:3+3+3+2 = 11 次往返 合并后:1 次往返 ``` 注意:**底层用的还是第七节那套完全相同的挂起/恢复协议**,区别只在 VM 之上的调度层是否合并请求。再叠加依赖图标脏 + 拓扑重算(只重算受影响的单元格)、视口懒求值(只算屏幕上可见的行)、服务端聚合下推等手段,「断网只挂掉一个格子、而不是整张表」才成为可能。 *** ## 十一、我把它做成了一个能单步的「虚拟机示波器」 读到这你大概已经有画面了。但「字节码逆序压栈」「生成器挂起恢复」「VM 重入自己跑 lambda」这些,光看文字总隔一层。所以我把整套引擎复刻了一遍(不到 600 行、零运行时依赖),又给它套了一个可视化外壳——一台「虚拟机示波器」: * **三段流水线一屏可见**:左边语法树、中间字节码(lambda 的子字节码可以展开)、右边虚拟机执行。 * **逐指令单步**,或按速度自动播放。当前 `ip` 高亮,对应的语法树节点同步点亮。 * **操作数栈画成物理盘片**,按类型着色,`push`/`pop` 带动画;调用栈帧实时显示 `main > lambda current=... [#i]` 的重入层级。 * **两种模式**:教学模式每条指令都暂停;真实模式只在取数处挂起——直接把「真实 VM 只在哪儿停」摆给你看。 * **挂起/恢复的「冻结」动画**:把某个属性标成「冷·需取数」,求值撞上它时整台 VM 定格、弹出覆盖层,取数返回后再解冻继续。 * 还有个进阶面板演示第十节的请求合并:N 行如何不退化成 N\*M 次往返。 为了靠谱,引擎在 Node 里跑了 37 个公式/错误/挂起用例全过,整页又用 jsdom 做了端到端冒烟(单步执行、lambda 重入、冻结覆盖层、结果正确)全过。 我把它整理成了一个开箱即跑的小工程: ```text notion-vm/ ├── build.sh # 组装 src/* → dist/notion-vm.html ├── src/{engine,ui}.js, style.css, body.html ├── test/{test.js, step_smoke.js, dom_smoke.mjs} └── dist/notion-vm.html # 自包含单文件,浏览器直接打开 ``` ```bash npm run build # 从源码重建单文件 HTML npm test # 37 个公式/错误/挂起用例(纯 node,无需装依赖) ``` > 在线 Demo 与完整源码:[github.com/fluffyox/notion-vm](https://github.com/fluffyox/notion-vm) *** ## 收尾:它到底是什么? 逆完这一圈,很容易冒出一个念头:Notion 是不是一个跑在浏览器里的迷你操作系统?哲学层面确实有几分像——一切皆 block(一切皆文件)、字节码 VM(用户态运行时)、权限分级、事务队列(调度器)、GC…… 但要较真的话,它更像一个**带内嵌运行时的、local-first 的数据库**:没有硬件、没有内存保护、权威数据在服务端,公式语言也不是图灵完备的——它是一门受限的领域 DSL,不是通用编程语言。这台 VM 的存在,不是为了「能算任何东西」,而是为了让「一列公式在几千行上反复、可暂停、可沿关系链取数地求值」这件事,变得高效而可控。 一个笔记软件的公式栏,背后是一套相当完整的编译器 + 虚拟机工程。下次你在 Notion 里敲下一个公式,不妨想想:那一刻,你的浏览器里有一台小小的虚拟机,正把你的字符串编译成字节码、逐条执行,碰到要去远端取的数据就优雅地冻结自己,等数据回来再从原地醒来。 *** *如果这篇对你有用,欢迎点赞 / 收藏 / 关注。逆向与复刻的全部代码都在 [GitHub 仓库](https://github.com/fluffyox/notion-vm) 里,欢迎对着字节码自己玩。*

阿里云多模态图片生成!抛弃SDK手写Fetch请求,我终于搞懂了大模型调用底层

> 上周做 AI 人像换装需求直接卡崩,原本依赖 OpenAI 封装 SDK 快速开发,本地调试连续收到鉴权 401 报错,对着文档翻了一上午没找到问题根源。干脆删掉所有 SDK 依赖,用 Node 原生`fetch`手动拼接请求对接阿里云万相 wan2.7-image 模型,反倒借着排错把多模态图文生成、Prompt 多图入参的知识点彻底捋顺了。 > 帅哥美女们帮我的掘金点点赞呗😘👍 完整文章地址👉:https://juejin.cn/post/7647707869932765203 先聊聊这次的落地需求,也是我手上这三张素材的由来。 ![input1.png](https://pic.code-nav.cn/post_picture/1806249471285702658/VTzcLJKLozmaUFdX.webp) ![input2.png](https://pic.code-nav.cn/post_picture/1806249471285702658/gojHKXT466afI3j5.webp) ![input3.png](https://pic.code-nav.cn/post_picture/1806249471285702658/53AJB66zCEfaT256.webp) 需求很直白:保留第一张女生的五官样貌,给她换上第二张的黑色连衣裙,并且严格按照第三张骨架标记的坐姿生成成片。放在 AIGC 里,这就是典型从纯文本生成过渡到**多模态图文混输**的场景,也是我笔记里`text generation → image generation`的实际落地案例。 说实话之前我对多模态一直一知半解,总觉得图生图就是丢一张参考图再加一句提示词就行,直到要一次性传入三张不同用途的参考图,才琢磨明白大模型接收多素材的运行逻辑。我拿生活化的例子捋了一遍:大模型好比影楼全能造型师,第一张人像图是固定出镜的模特(锁定长相、面部特征),第二张裙子图是选定的定制服装,第三张关键点图是摄影师规定好的摆拍姿势,最后的文字 Prompt 就是我跟造型师口述的成片要求,三份参考素材 + 一句指令,共同构成完整输入。 ### 能直接跑通的最小 Demo 代码 折腾半天整理出可本地运行的代码,注释里顺带标了我踩坑的关键点,直接替换.env 里的密钥就能测试: ````javascript import dotenv from 'dotenv'; dotenv.config(); async function generateImage() { // 坑1:密钥千万不要随便塞到Content-Type请求头,我在这栽了半小时 const OPENAI_API_KEY = process.env.OPENAI_API_KEY; const response = await fetch( // 坑2:阿里云通义万相专属接口地址,别错填成OpenAI官方域名 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', { method: 'POST', // AIGC接口基本全用POST,后面细说原因 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}`, // 鉴权密钥固定放在这个字段 }, body: JSON.stringify({ "model": "wan2.7-image", "input": { "messages": [{ "role": "user", "content": [ // 三张参考图+文字指令,嵌套在同一个content数组是规范写法 { "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/thtclx/input1.png" }, { "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/iclsnx/input2.png" }, { "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/gborgw/input3.png" }, { "text": "图1中的女生穿着图2中的黑色裙子按图3的姿势坐下" } ] }] } }) } ) const data = await response.json(); // 获取任务ID,方便后续异步轮询生成结果 const requestId = data.request_id || '未知'; console.log(`request_id: ${requestId}`); // 从返回体里提取生成图片链接 let imageUrl = null; if (data.output && data.output.choices && data.output.choices.length > 0) { const choice = data.output.choices[0]; if (choice.message && choice.message.content) { const content = choice.message.content; if (Array.isArray(content)) { const imageContent = content.find(item => item.image); if (imageContent && imageContent.image) { const imageData = imageContent.image; // 接口返回要么在线URL,要么base64编码图片,两种格式都做兼容 if (imageData.startsWith('http')) { imageUrl = imageData; } else if (imageData.startsWith('data:image')) { imageUrl = imageData; } } } } } console.log(`图片URL: ${imageUrl || '未找到'}`); return { requestId, imageUrl }; } generateImage(); 本地新建`.env`文件填入密钥,格式如下: ```env OPENAI_API_KEY=sk-xxx ```` 终端运行脚本后,控制台成功打印出生成图片的在线链接那一刻,悬着的心才算落地。 ![image.png](https://pic.code-nav.cn/post_picture/1806249471285702658/2neR0MXzz2R1GfVT.webp) ### 深挖一层:不管什么 SDK,本质全是封装 HTTP 请求 跑通 demo 后我突然好奇,平时我们用的 OpenAI 官方 SDK 到底干了什么?翻了一圈 SDK 源码后恍然大悟:市面上所有大模型封装 SDK,底层没有黑魔法,全是对`fetch/axios`这类网络请求的二次封装。 说白了对接大模型接口永远绕不开三件事,正好对应我手写 fetch 的三个配置项: 1. **请求 URL**:不同厂商大模型有专属接口域名,阿里云万相、OpenAI、文心一言全不通用,填错直接接口报错; 2. **请求头 headers**:`Content-Type`固定`application/json`,鉴权密钥统一挂载在`Authorization: Bearer xxx`字段,用来校验调用权限; 3. **请求体 body**:业务参数全塞在这里,我们的参考图、Prompt 文本、模型名称都属于 body 内容。 顺带解惑了笔记里的疑问:为什么 AIGC 接口几乎清一色用 POST 而不是 GET?GET 的参数会拼接在 URL 链接里,一方面多张图片的资源链接过长极易超出 URL 长度限制,另一方面**密钥暴露**在链接上很容易被抓包窃取,POST 把数据藏在请求体里,安全和长度问题一次性解决。 ### 踩过的两个致命大坑,错误写法贴出来避坑 `这两个错误我实打实浪费近一小时排查,新手调用多模态接口大概率也会踩中` 1. **鉴权密钥存放位置错误**最开始随手把 API\_KEY 写在了`Content-Type`里,接口反复返回 401 无权限。 ```javascript // 错误写法!千万别这么写 headers: { 'Content-Type': `application/json;${OPENAI_API_KEY}` } // 正确写法:Authorization单独承载鉴权信息 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}` } ``` 2. **多图 content 嵌套层级写错**初期我把三张 image 对象和 text 平级放在 message 外层,大模型完全识别不到参考图片,只会根据文字随机生成人物。规范要求:**所有图片资源 + 文本提示词,必须全部嵌套在同一个 user 角色的 content 数组中**。 除此之外还有个小乌龙:我曾用 OpenAI 的接口地址去调用万相的`wan2.7-image`模型,接口直接返回「模型不存在」,不同服务商的接口域名和模型命名体系完全割裂,不能混用。 ### 收尾:这次折腾沉淀下来的三点收获 折腾完整套流程,抛开代码本身,有三个实打实的感悟: 1. 看不懂第三方 SDK 的时候,抛弃封装、裸写原生网络请求是吃透底层最快的办法,拆解完请求结构,再回头看 SDK 源码一目了然; 2. 多模态图生图的多参考图逻辑:每张参考图各司其职(控人脸 / 控服饰 / 控姿态),Prompt 做最终约束,入参格式严格遵循 content 数组嵌套规范; 3. 所有大模型调用本质是远程 HTTP 通讯,鉴权、入参、域名是对接接口的三要素,掌握这三点,换任何厂商的 AIGC 接口都能快速上手。 另外客观说下这个方案的短板:单靠多张参考图 + Prompt 做换装,复杂褶皱服饰、高难度人体姿态很容易生成崩坏图,大批量商用换装场景不能只依赖 Prompt 参考图,需要针对性微调大模型权重。 如果你平时也在折腾 AI 图生图、多模态调用,踩过鉴权、传参相关的奇葩 bug,搞懂之后不妨在评论区聊聊,我也想瞅瞅大家遇到过哪些离谱报错。

GitVision · GitHub 仓库历史全景总结工具

github官方在提交页面没有设置分页跳转的功能,有时想看看某个仓库第一次提交的内容要费一些精力查找,遂诞生了这个项目🤓,希望鱼友们点个🌟支持一下! 一个纯原生 Node.js + 原生前端实现的 Web 工具,粘贴任意 GitHub 仓库地址即可: - 一键直达该仓库的 **第一次提交**(解决 GitHub 原生无法快速跳到最早 commit 的痛点) - 自动抓取完整提交历史概览、tag 列表、里程碑节点 - 按时间线 / 类别(feat / fix / refactor / perf / docs / test / chore)摘要整个项目的发展脉络 - 所有跳转链接严格遵循 GitHub 官方 URL 规则,可直接在浏览器打开 GitHub:https://github.com/hateStudyy/GitVision 直接访问:https://gitvision-wine.vercel.app/

Web 应用部署后发送消息失败排查记录

## 一、问题描述 部署到生产环境后,聊天功能发送消息失败,显示"发送失败"错误提示。但本地开发环境一切正常。 **环境信息**: - 前端:Vue 3 + Vite,Nginx 部署 - 后端:Spring Boot 2.7.2 - 部署:Docker Compose - 访问地址:`http://139.199.158.118:9001` ## 二、排查过程 ### 2.1 初步检查 1. **确认前后端是否有报错** — 没有错误日志 2. **检查 Nginx 配置** — WebSocket 代理配置问题 3. **检查网络请求** — 惊讶发现:**没有 API 请求发出** ### 2.2 WebSocket 连接问题 首先发现 WebSocket 连接失败: ``` WebSocket connection to 'ws://139.199.158.118:9001/api/ws/chat' failed ``` 排查 Nginx 配置,发现: - 后端 `context-path: /api` - WebSocket 端点完整路径是 `/api/ws/chat` - Nginx 需要正确代理 WebSocket 连接 修复 Nginx 配置后,WebSocket 连接成功,但消息仍然发送失败。 ### 2.3 深入排查 由于没有 API 请求发出,从浏览器 Console 逐步排查: 1. **确认用户登录状态** — 正常 2. **确认 conversationId** — 正常 3. **手动测试 API 调用** — 成功! 手动 fetch 调用成功说明: - 网络没问题 - 后端 API 没问题 - Cookie 认证也没问题 问题一定在前端代码中。 ### 2.4 定位根因 通过 Console 直接调用 `chatStore.sendMessage()` 方法: ```javascript chatStore.sendMessage('2038981077471002625', { messageType: 1, content: '测试', senderId: '2038274525120315394' }) ``` **终于看到错误**: ``` TypeError: crypto.randomUUID is not a function ``` ## 三、根因分析 ### 3.1 技术原因 `crypto.randomUUID()` 只在**安全上下文**(Secure Context)中可用: | 上下文 | 是否安全 | | --------------- | -------- | | `https://` | ✓ | | `localhost` | ✓ | | `127.0.0.1` | ✓ | | `http://域名` | ✗ | | `http://IP地址` | ✗ | ### 3.2 为什么本地正常? 本地开发环境使用 `http://localhost:5173`,属于安全上下文,`crypto.randomUUID()` 可用。 生产环境使用 `http://139.199.158.118:9001`(HTTP + IP地址),不属于安全上下文,API 不可用。 ### 3.3 浏览器检测结果 ```javascript // 生产环境 Console 输出 User-Agent: Mozilla/5.0 ... Chrome/144.0.0.0 crypto.randomUUID: undefined // ← 不可用 location.protocol: http: location.hostname: 139.199.158.118 ``` ## 四、解决方案 ### 4.1 代码修复 添加兼容性处理: ```javascript // 修改前 const clientMsgId = crypto.randomUUID() // 修改后 const clientMsgId = typeof crypto?.randomUUID === 'function' ? crypto.randomUUID() : `msg-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` ``` ### 4.2 长期方案 配置 HTTPS 证书,使生产环境也使用安全上下文。 ## 五、经验总结 1. **"本地正常生产异常"** — 考虑环境差异,特别是安全上下文、协议差异 2. **没有网络请求** — 代码在调用前就出错,用 Console 直接调试比看日志更高效 3. **安全上下文限制** — `crypto.randomUUID()`、`Service Worker` 等 API 需要安全上下文 4. **调试技巧** — 通过 `document.querySelector('#app').__vue_app__` 访问 Vue 应用实例进行调试 5. **Nginx WebSocket 代理** — 需要设置 `Upgrade` 和 `Connection` 头,以及更长的超时时间

全栈开发者的谎言:什么都会 = 什么都不精?

上周面了一个自称5年全栈的兄弟🤔。 简历漂亮得像报菜名:精通 Vue/React,熟悉 Node.js/Go,玩过 K8s,能画原型图,甚至还写过两个 Flutter App。 我只问了一个问题:如果不使用任何框架,Node.js 的 HTTP 模块是如何处理高并发下的内存积压的? 他愣了三秒,支支吾吾说:厄...一般我们都用 NestJS,框架处理好了吧?😖 那一刻,我看到了无数前端人的缩影:我们拼命想成为无所不能的全栈大神,最后却活成了什么都懂一点、什么都搞不定的API 缝合怪。 全栈不等于样样稀松,真正的价值在于深耕核心难题。与其在重复造轮子中消耗精力,不如用RollCode 低代码平台 提效。它支持私有化部署 和自定义组件 ,搞定 静态页面发布(SSG+SEO),让开发回归技术本质。 ## 全栈的本质 你要知道,全栈工程师(Full Stack Engineer)这个词,最开始是谁捧红的? 是硅谷的创业公司。 为什么?因为没钱。 他们招不起一个前端专家 + 一个后端专家 + 一个运维专家。他们需要一个性价比极高的耗材,一个人把这三个坑都填了。 于是,招聘 JD 画风突变: 25K,招全栈。要求精通 React、Node.js、MySQL、Docker、AWS... 你看似拿了比纯前端高 20% 的工资,干的却是 3 个人的活。你的大脑需要在 CSS 的 z-index 和 MySQL 的 Transaction Isolation Level 之间疯狂切换。 结果是什么? 你的认知被彻底击穿。 你以为你的认知,什么场景都能用。 但在真正的技术攻坚战里,什么都不是。🥱 **机-会** 技术大厂,前端-后端-测试,全国均有[机-会](https://jsj.top/f/o38ijj),感兴趣可以试试。待遇和稳定性都还不错~ ## 所谓的全栈,大多是全沾 我见过太多这种虚假全栈的代码了,简直是灾难现场。 他们写后端,思维还是前端那一套: 数据库设计:没有范式概念,一张表 50 个字段,全是 JSON 字符串。 错误处理:try-catch 包住整个 API,报错全返 200 OK,msg 里写 bug。 并发安全:在 for 循环里 await 查库,完全不懂什么是连接池耗尽。 让我们看一段典型的前端思维写后端的死代码: // 典型的假全栈代码 // 以为用了 async/await 就是后端大神了 router.post('/buy', async (req, res) => { // 1. 先查库存(没有锁,并发一来直接超卖) const stock = await db.query(`SELECT count FROM products WHERE id=${req.body.id}`); if (stock > 0) { // 2. 扣库存(中间如果服务挂了,数据不一致) await db.query(`UPDATE products SET count = count - 1 WHERE id=${req.body.id}`); // 3. 创建订单 await db.query(`INSERT INTO orders ...`); return res.json({ success: true }); } }); AI写代码 这种代码,稍微有点后端经验的人看了都会心肌梗塞。但在全栈眼里:跑通了啊,没报错啊! 什么都会 = 什么都不精。 你以为你拓宽了广度,其实你牺牲了深度。在裁员潮来临时,公司是会留一个能解决复杂内存泄漏的 Node 专家,还是留一个既能写页面又能写增删改查,但稍微上点量就崩服务的万金油? 在我们国内,答案是极其残酷的。 T 型人才的骗局 很多人反驳:我要做 T 型人才,一专多能! 理想很丰满,现实是绝大多数人做成了 一型人才 —— 横向铺得无限开,纵向深度为零。 学了 Docker,只会 docker run,不懂 Cgroup 原理。 学了 React,只会 useEffect,不懂 Fiber 调度。 学了 Rust,只会写 Hello World,借用检查器都过不去。 学了 SQLite, 只会增删改查,不懂什么叫锁,什么叫性能优化 这种简历驱动型学习产生的知识,极其脆弱。 一旦遇到深水区的 Bug,你的全栈光环瞬间破碎,只能去 AI Chat 复制粘贴,然后祈祷奇迹发生。 真正的全栈 是你能从前端的一个点击事件(Click),一路追踪到内核的系统调用(Syscall),这中间的每一层你都可控。 如果你做不到,那你充其量只是一个全栈水货。😥 请你先成为单栈战神 人的精力是有限的。在 35 岁危机到来之前,请功利一点,聚焦一点。 如果你是前端: 别急着去学 Go,别急着去搞 K8s。 先把浏览器渲染原理吃透,把 V8 垃圾回收搞懂,把图形学(WebGL/Canvas)啃下来。 当你在一个领域钻得足够深,深到能解决 99% 人解决不了的问题时,你才有资格去谈横向扩展。 这时候的扩展,不是为了凑简历,而是为了解决问题。 学 Node.js,是因为前端构建工具跑得太慢,你需要深入 OS 层优化 I/O。 学 Rust,是因为 JS 在计算密集型任务上拉胯,你需要 WASM 来救场。 这才是全栈的正确打开方式:降维打击。 别再用全栈来标榜自己了。 在这个分工日益精细化的时代,专家永远比杂家值钱。 专注你的赛道,把它做到极致。 那才是你不可被替代的根本。 大家怎么看🤔 ——转载自:ErpanOmer

Incremark Solid 版本上线:Vue/React/Svelte/Solid 四大框架,统一体验

Incremark 现已支持 Solid,至此完成了对 Vue、React、Svelte、Solid 四大主流前端框架的全面覆盖。 ## 为什么要做框架无关 市面上大多数 Markdown 渲染库都是针对特定框架开发的。这带来几个问题: 1. **重复造轮子**:每个框架社区都在独立实现相似的功能 2. **能力不一致**:不同框架的实现质量参差不齐 3. **团队切换成本**:换框架意味着重新学习新的 API Incremark 采用不同的思路:**核心逻辑与 UI 框架完全解耦**。 `@incremark/core` 负责所有解析、转换、增量更新的工作,输出的是框架无关的数据结构。各框架包(`@incremark/vue`、`@incremark/react`、`@incremark/svelte`、`@incremark/solid`)只需要把这些数据渲染成对应框架的组件即可。 这意味着: - 核心能力一次实现,四个框架同时受益 - Bug 修复和性能优化自动同步到所有框架 - API 设计保持高度一致,切换框架几乎零学习成本 ## 包结构 ``` ┌───────────────────────────────┐ │ @incremark/core │ │ │ │ 增量解析 · 双引擎 · 插件系统 │ └───────────────┬───────────────┘ │ ▼ ┌───────────────────────────────┐ │ @incremark/vue │ │ @incremark/react │ │ @incremark/svelte │ │ @incremark/solid ← NEW │ └───────────────┬───────────────┘ │ ▼ ┌───────────────────────────────┐ │ @incremark/theme │ │ │ │ 样式 · 主题 · 代码高亮 │ └───────────────────────────────┘ ``` ## 增量解析 传统 Markdown 渲染器在流式场景下存在性能问题:每次新内容到达都要重新解析整个文档,复杂度是 O(n²)。 Incremark 只处理新增内容,已解析的块不再重复处理,复杂度降至 O(n)。 ## 四个框架的用法对比 四个框架的组件 API 完全一致,只是语法风格不同: **Vue** ```vue <script setup> import { IncremarkContent } from '@incremark/vue' // ... </script> <template> <IncremarkContent :content="content" :is-finished="isFinished" /> </template> ``` **React** ```tsx import { IncremarkContent } from '@incremark/react' // ... <IncremarkContent content={content} isFinished={isFinished} /> ``` **Svelte** ```svelte <script> import { IncremarkContent } from '@incremark/svelte' // ... </script> <IncremarkContent content={content} isFinished={isFinished} /> ``` **Solid** ```tsx import { IncremarkContent } from '@incremark/solid' // ... <IncremarkContent content={content()} isFinished={isFinished()} /> ``` 可以看到,除了各框架本身的响应式语法差异(Vue 的 `ref`、React 的 `useState`、Svelte 的 `$state`、Solid 的 `createSignal`),组件的使用方式完全统一。 ## 在线演示 - [Solid Demo](https://solid.incremark.com/) - [Vue Demo](https://vue.incremark.com/) - [React Demo](https://react.incremark.com/) - [Svelte Demo](https://svelte.incremark.com/) ## 链接 - npm: [@incremark/core](https://www.npmjs.com/package/@incremark/core) - 文档: [incremark.com](https://www.incremark.com) - GitHub: [github.com/anthropics/incremark](https://github.com/anthropics/incremark) MIT 许可证。

Incremark 0.3.0 发布:双引擎架构 + 完整插件生态,AI 流式渲染的终极方案

# 从 O(n²) 到 O(n):为 AI 时代打造的流式 Markdown 渲染器 如果你开发过 AI 聊天应用,你可能注意到一个令人沮丧的问题:**对话越长,渲染越卡**。 原因很简单——每次 AI 输出新的 token,传统 markdown 解析器都会*从头开始*重新解析整个文档。这是一个根本性的架构问题,而且随着 AI 输出越来越长,问题只会越来越严重。 我们开发了 **Incremark** 来解决这个问题。 ## 2025 年 AI 的残酷现实 如果你一直关注 AI 的发展,你会发现数据变得越来越夸张: - **2022**:GPT-3.5 的回复?几百个字,问题不大 - **2023**:GPT-4 把输出提升到 2,000-4,000 字 - **2024-2025**:推理模型(o1、DeepSeek R1)输出 **10,000+ 字的"思考过程"** 我们正在从 4K token 的对话走向 32K,甚至 128K。没人谈论的一个事实是:**渲染 500 字和渲染 50,000 字的 Markdown 是完全不同的工程问题。** 大多数 markdown 库?它们是为博客文章设计的,不是为会"大声思考"的 AI 设计的。 ## 为什么你的 Markdown 解析器在骗你 当你通过传统解析器流式传输 AI 输出时,底层发生了什么: ``` Chunk 1: 解析 100 字符 ✓ Chunk 2: 解析 200 字符 (100 旧 + 100 新) Chunk 3: 解析 300 字符 (200 旧 + 100 新) ... Chunk 100: 解析 10,000 字符 😰 ``` 总工作量:`100 + 200 + 300 + ... + 10,000 = 5,050,000` 字符操作。 这是 **O(n²)**。成本不是线性增长——而是*爆炸式增长*。 对于 20KB 的 AI 回复,这意味着: - **ant-design-x**:1,657 ms 解析时间 - **markstream-vue**:5,755 ms(将近 **6 秒**的解析!) 而这些都是流行的、维护良好的库。问题不在于代码写得不好——而在于架构选择错误。 ## 核心洞察 关键在这里: **一旦一个 markdown 块"完成",它就永远不会改变。** 想想看。当 AI 输出: ```markdown # 标题 这是一个段落。 ``` 在第二个空行之后,这个段落就*完成*了。锁定了。无论后面来什么——代码块、列表、更多段落——这个段落永远不会再被动了。 那我们为什么要重复解析它 500 次? ## Incremark 的工作原理 我们围绕这个洞察构建了 **Incremark**。核心算法: 1. **检测稳定边界** — 空行、新标题、代码块结束符 2. **缓存已完成的块** — 永不再动 3. **只重新解析待处理的块** — 当前正在接收输入的那个 ``` Chunk 1: 解析 100 字符 → 缓存稳定块 Chunk 2: 只解析 ~100 新字符 Chunk 3: 只解析 ~100 新字符 ... Chunk 100: 只解析 ~100 新字符 ``` 总工作量:`100 × 100 = 10,000` 字符操作。 这是 **500 倍的减少**。每个字符最多只被解析一次。这就是 O(n)。 ## 完整基准测试数据 ### 测试环境 - **测试文件**:38 个文件,共 6,484 行,128.55 KB - **测试方式**:模拟流式输入,逐字符 append - **测试数据**:真实 AI 对话、文档、代码分析报告(非合成数据) - **对比方案**:Streamdown、markstream-vue、ant-design-x ### 完整测试结果 | 文件名 | 行数 | 大小(KB) | Incremark | Streamdown | markstream | ant-design-x | vs Streamdown | vs markstream | vs ant-design-x | |--------|------|----------|-----------|------------|------------|--------------|---------------|---------------|-----------------| | test-footnotes-simple.md | 15 | 0.09 | 0.3 ms | 0.0 ms | 1.4 ms | 0.2 ms | 0.1x | 4.7x | 0.6x | | simple-paragraphs.md | 16 | 0.41 | 0.9 ms | 0.9 ms | 5.9 ms | 1.0 ms | 1.1x | 6.7x | 1.2x | | test-footnotes-multiline.md | 21 | 0.18 | 0.6 ms | 0.0 ms | 2.2 ms | 0.4 ms | 0.1x | 3.5x | 0.6x | | test-footnotes-edge-cases.md | 27 | 0.25 | 0.8 ms | 0.0 ms | 4.2 ms | 1.2 ms | 0.0x | 5.3x | 1.5x | | test-footnotes-complex.md | 28 | 0.24 | 2.1 ms | 0.0 ms | 4.8 ms | 1.0 ms | 0.0x | 2.3x | 0.5x | | introduction.md | 34 | 1.57 | 5.6 ms | 12.6 ms | 75.6 ms | 12.8 ms | 2.2x | 13.4x | 2.3x | | devtools.md | 51 | 0.92 | 1.2 ms | 0.9 ms | 6.1 ms | 1.1 ms | 0.8x | 5.0x | 0.9x | | footnotes.md | 52 | 0.94 | 1.7 ms | 0.2 ms | 10.6 ms | 1.9 ms | 0.1x | 6.3x | 1.2x | | html-elements.md | 55 | 1.02 | 1.6 ms | 2.2 ms | 12.6 ms | 2.8 ms | 1.4x | 7.8x | 1.7x | | themes.md | 58 | 0.96 | 1.9 ms | 1.3 ms | 8.6 ms | 1.8 ms | 0.7x | 4.4x | 0.9x | | test-footnotes-comprehensive.md | 63 | 0.66 | 5.6 ms | 0.1 ms | 25.8 ms | 7.7 ms | 0.0x | 4.6x | 1.4x | | auto-scroll.md | 72 | 1.68 | 3.9 ms | 3.5 ms | 39.9 ms | 4.9 ms | 0.9x | 10.1x | 1.2x | | custom-codeblocks.md | 72 | 1.44 | 3.4 ms | 2.0 ms | 14.9 ms | 2.5 ms | 0.6x | 4.4x | 0.7x | | custom-components.md | 73 | 1.40 | 4.0 ms | 2.0 ms | 32.7 ms | 2.9 ms | 0.5x | 8.1x | 0.7x | | custom-containers.md | 88 | 1.67 | 4.2 ms | 2.4 ms | 18.1 ms | 3.1 ms | 0.6x | 4.3x | 0.7x | | typewriter.md | 88 | 1.89 | 5.6 ms | 4.1 ms | 35.0 ms | 4.9 ms | 0.7x | 6.2x | 0.9x | | concepts.md | 91 | 4.29 | 12.0 ms | 50.5 ms | 381.9 ms | 53.6 ms | 4.2x | 31.9x | 4.5x | | INLINE_CODE_UPDATE.md | 94 | 1.66 | 4.7 ms | 17.2 ms | 60.9 ms | 15.6 ms | 3.7x | 12.9x | 3.3x | | comparison.md | 109 | 5.39 | 20.5 ms | 74.0 ms | 552.2 ms | 85.2 ms | 3.6x | 26.9x | 4.1x | | basic-usage.md | 130 | 3.04 | 8.5 ms | 12.3 ms | 74.1 ms | 14.1 ms | 1.4x | 8.7x | 1.7x | | CODE_BACKGROUND_SEPARATION.md | 131 | 2.83 | 8.7 ms | 28.8 ms | 153.6 ms | 31.3 ms | 3.3x | 17.6x | 3.6x | | P2_SUMMARY.md | 138 | 2.61 | 8.3 ms | 38.4 ms | 157.2 ms | 41.9 ms | 4.6x | 18.9x | 5.0x | | quick-start.md | 146 | 3.04 | 7.3 ms | 7.3 ms | 64.2 ms | 9.6 ms | 1.0x | 8.8x | 1.3x | | complex-html-examples.md | 147 | 3.99 | 9.0 ms | 58.8 ms | 279.3 ms | 57.2 ms | 6.6x | 31.1x | 6.4x | | CODE_COLOR_SEPARATION.md | 162 | 3.51 | 10.0 ms | 32.8 ms | 191.1 ms | 36.9 ms | 3.3x | 19.1x | 3.7x | | P0_OPTIMIZATION_REPORT.md | 168 | 3.53 | 10.1 ms | 56.2 ms | 228.0 ms | 58.1 ms | 5.6x | 22.6x | 5.8x | | COLOR_SYSTEM_REFACTOR.md | 169 | 3.78 | 18.5 ms | 64.0 ms | 355.5 ms | 69.1 ms | 3.5x | 19.2x | 3.7x | | FOOTNOTE_TEST_GUIDE.md | 219 | 2.87 | 12.3 ms | 0.2 ms | 167.6 ms | 45.0 ms | 0.0x | 13.7x | 3.7x | | P2_COLORS_PACKAGE_REPORT.md | 226 | 4.10 | 11.4 ms | 77.9 ms | 311.6 ms | 80.5 ms | 6.8x | 27.2x | 7.0x | | FOOTNOTE_FIX_SUMMARY.md | 236 | 3.93 | 22.7 ms | 0.5 ms | 535.0 ms | 120.8 ms | 0.0x | 23.6x | 5.3x | | BASE_COLORS_SYSTEM.md | 259 | 4.47 | 35.8 ms | 43.0 ms | 191.8 ms | 43.4 ms | 1.2x | 5.4x | 1.2x | | OPTIMIZATION_COMPARISON.md | 270 | 5.42 | 17.8 ms | 52.3 ms | 366.1 ms | 61.9 ms | 2.9x | 20.6x | 3.5x | | P1_OPTIMIZATION_REPORT.md | 327 | 5.63 | 20.7 ms | 106.8 ms | 433.8 ms | 114.8 ms | 5.2x | 21.0x | 5.5x | | OPTIMIZATION_PLAN.md | 371 | 6.89 | 33.1 ms | 67.6 ms | 372.1 ms | 76.7 ms | 2.0x | 11.2x | 2.3x | | OPTIMIZATION_SUMMARY.md | 391 | 6.24 | 19.1 ms | 208.4 ms | 980.6 ms | 217.8 ms | 10.9x | 51.3x | 11.4x | | P1.5_COLOR_SYSTEM_REPORT.md | 482 | 9.12 | 22.0 ms | 145.5 ms | 789.8 ms | 168.2 ms | 6.6x | 35.9x | 7.7x | | BLOCK_TRANSFORMER_ANALYSIS.md | 489 | 9.24 | 75.7 ms | 574.3 ms | 1984.1 ms | 619.9 ms | 7.6x | 26.2x | 8.2x | | test-md-01.md | 916 | 17.67 | 87.7 ms | 1441.1 ms | 5754.7 ms | 1656.9 ms | 16.4x | 65.6x | 18.9x | | **【合计】** | **6484** | **128.55** | **519.4 ms** | **3190.3 ms** | **14683.9 ms** | **3728.6 ms** | **6.1x** | **28.3x** | **7.2x** | ### 诚实面对:我们慢的地方 你会注意到数据中有些奇怪的地方。对于 `footnotes.md` 和 `FOOTNOTE_FIX_SUMMARY.md`,Streamdown 看起来快得多: | 文件 | Incremark | Streamdown | 原因 | |------|-----------|------------|------| | footnotes.md | 1.7 ms | 0.2 ms | Streamdown 不支持脚注 | | FOOTNOTE_FIX_SUMMARY.md | 22.7 ms | 0.5 ms | 同上——它直接跳过了 | **这不是性能问题——这是功能差异。** 当 Streamdown 遇到 `[^1]` 脚注语法时,它直接忽略。Incremark 完整实现了脚注——而且我们必须解决一个流式场景特有的棘手问题: 在流式场景中,**引用通常比定义先到达**: ``` Chunk 1: "详见脚注[^1]..." // 引用先到达 Chunk 2: "更多内容..." Chunk 3: "[^1]: 这是脚注定义" // 定义后到达 ``` 传统解析器假设你有完整的文档。我们构建了"乐观引用"机制,在流式传输过程中优雅地处理不完整的链接/图片,然后在定义到达时解析它们。 我们选择完整实现脚注、数学公式块(`$...$`)和自定义容器(`:::tip`),因为这些是真实 AI 内容所需要的。 ### 我们真正的优势 排除脚注文件,看看标准 markdown 的性能: | 文件 | 行数 | Incremark | Streamdown | 优势 | |------|------|-----------|------------|------| | concepts.md | 91 | 12.0 ms | 50.5 ms | **4.2x** | | comparison.md | 109 | 20.5 ms | 74.0 ms | **3.6x** | | complex-html-examples.md | 147 | 9.0 ms | 58.8 ms | **6.6x** | | OPTIMIZATION_SUMMARY.md | 391 | 19.1 ms | 208.4 ms | **10.9x** | | test-md-01.md | 916 | 87.7 ms | 1441.1 ms | **16.4x** | **规律很明显:文档越大,我们的优势越大。** 对于最大的文件(17.67 KB),Incremark 的优势最为明显: - vs Streamdown:快 **16.4 倍** - vs ant-design-x:快 **18.9 倍** - vs markstream-vue:快 **65.6 倍** ### 为什么差距这么大? 这就是 O(n) vs O(n²) 的实际表现。 传统解析器每次收到新 chunk 都重新解析整个文档: ``` Chunk 1: 解析 100 字符 Chunk 2: 解析 200 字符 (100 旧 + 100 新) Chunk 3: 解析 300 字符 (200 旧 + 100 新) ... Chunk 100: 解析 10,000 字符 ``` 总工作量:`100 + 200 + ... + 10,000 = 5,050,000` 字符操作。 Incremark 只处理新内容: ``` Chunk 1: 解析 100 字符 → 缓存稳定块 Chunk 2: 只解析 ~100 新字符 Chunk 3: 只解析 ~100 新字符 ... Chunk 100: 只解析 ~100 新字符 ``` 总工作量:`100 × 100 = 10,000` 字符操作。 这是 **500 倍的差距**。而且随着文档增长,差距只会更大。 ### 什么时候用 Incremark ✅ **适合使用 Incremark 的场景:** - AI 聊天流式输出(Claude、ChatGPT 等) - 长篇 AI 内容(推理模型、代码生成) - 实时 markdown 编辑器 - 需要脚注、数学公式或自定义容器的内容 - 100K+ token 的对话 ⚠️ **考虑使用其他方案的场景:** - 一次性静态 markdown 渲染(直接用 marked 就行) - 非常小的文件(<500 字符)——开销不值得 ## 双引擎,一个目标 **Marked 还是 Micromark?** 两者各有取舍。 Marked 极快但缺少高级功能。Micromark 规范完美但更重。 我们的答案:**两个都支持。** | 引擎 | 速度 | 最佳场景 | |------|------|----------| | **Marked**(默认) | ⚡⚡⚡⚡⚡ | 实时流式、AI 对话 | | **Micromark** | ⚡⚡⚡ | 复杂文档、严格 CommonMark | 我们用自定义 tokenizer 扩展了 Marked,支持脚注、数学公式和容器。如果遇到 Marked 无法处理的边界情况,只需一个配置就能切换到 Micromark。 两个引擎产生完全相同的 **mdast** 输出。你的渲染代码不关心底层用的是哪个引擎。 ## 没人谈论的打字机问题 你知道 ChatGPT 那种丝滑的"打字"效果吗?大多数实现是这样做的: ```ts displayText = fullText.slice(0, currentIndex) ``` 这会不断破坏 markdown。你会看到渲染到一半的 `**粗体**` 标签、闪烁的代码块、看起来像喝醉了的语法。 我们把动画移到了 **AST 层**。我们的 `BlockTransformer` 理解结构——它在节点*内部*做动画,永远不会跨节点。结果:丝滑流畅的打字效果,同时尊重 markdown 语义。 ## 跨框架支持 我们深知前端生态的多样性。Incremark 提供开箱即用的框架适配: | 框架 | 包名 | 版本要求 | |------|------|----------| | Vue | `@incremark/vue` | Vue 3.5+ | | React | `@incremark/react` | React 18+ | | Svelte | `@incremark/svelte` | Svelte 5+ | **一个核心,三个框架,零行为差异。** 所有框架共享: - 完全一致的 API 设计 - 相同的组件结构和 DOM 输出 - 统一的主题系统(`@incremark/theme`) - 相同的性能特性 ```bash # 选择你的框架 npm install @incremark/vue npm install @incremark/react npm install @incremark/svelte ``` ### Vue 示例 ```vue <script setup> import { ref } from 'vue' import { IncremarkContent } from '@incremark/vue' const content = ref('') const isFinished = ref(false) async function handleStream(stream) { for await (const chunk of stream) { content.value += chunk } isFinished.value = true } </script> <template> <IncremarkContent :content="content" :is-finished="isFinished" :incremark-options="{ gfm: true, math: true }" /> </template> ``` ### React 示例 ```tsx import { useState } from 'react' import { IncremarkContent } from '@incremark/react' function Chat() { const [content, setContent] = useState('') const [isFinished, setIsFinished] = useState(false) async function handleStream(stream: AsyncIterable<string>) { for await (const chunk of stream) { setContent(prev => prev + chunk) } setIsFinished(true) } return ( <IncremarkContent content={content} isFinished={isFinished} incremarkOptions={{ gfm: true, math: true }} /> ) } ``` ### Svelte 示例 ```svelte <script> import { IncremarkContent } from '@incremark/svelte' let content = $state('') let isFinished = $state(false) async function handleStream(stream) { for await (const chunk of stream) { content += chunk } isFinished = true } </script> <IncremarkContent {content} {isFinished} incremarkOptions={{ gfm: true, math: true }} /> ``` ## 下一步 这是 0.3.0 版本。我们才刚刚开始。 AI 世界正在走向更长的输出、更复杂的推理轨迹、更丰富的格式。传统解析器跟不上——它们的 O(n²) 架构注定如此。 我们开发 Incremark 是因为我们自己需要它。希望你也觉得它有用。 --- 📚 **文档**:[incremark.com](https://www.incremark.com/) 💻 **GitHub**:[kingshuaishuai/incremark](https://github.com/kingshuaishuai/incremark) 🎮 **在线演示**:[Vue](https://incremark-vue.vercel.app/) | [React](https://incremark-react.vercel.app/) | [Svelte](https://incremark-svelte.vercel.app/) 如果这篇文章帮你节省了调试时间,去 GitHub 点个 ⭐️ 吧。有问题?开个 issue 或者在下面留言。

搞懂前端代理:Axios baseURL 与 Vite Proxy 的协作机制

#前端工程化 #网络请求与跨域 #环境配置与部署 #Vite #Axios ### 在搭建前端项目时,我们常会发现 myAxios.ts(请求封装)和 vite.config.ts(构建配置)中似乎都在配置后端地址。为什么myAxios和vite.config都要配置请求地址?能不能只写一个? ### 这两者虽然看似重复,实则各司其职,**缺一不可**。 ### **1.职责分离** * **myAxios.ts (baseURL):是"暗号"** 它给所有请求加上统一前缀(如 /api),告诉代码:“凡是带这个前缀的,都是发给后端的”。 生效范围:开发环境 + 生产环境 * **vite.config.ts (Proxy):是"翻译官"** 它拦截带有“暗号”的请求,将其转发到真实的后端地址(如 localhost:8080),解决浏览器的同源策略 (跨域)限制 生效范围:仅限开发环境 * * * **2.流量流向对比** * **开发环境 (Dev):** Axios 发送 '/api/user' ➔ Vite 服务器拦截 ➔ 代理转发到 'http://localhost:8080/user' * **生产环境 (Prod):** Axios 发送 '/api/user' ➔ Nginx/后端服务器直接接收 (此时没有 Vite 了) * * * **3.为什么不能只写一个:** * 如果只在 Axios 写死 'http://localhost:8080' , 生产环境会报错(地址变了),且开发环境会有跨域问题。 * 如果只在 Vite 配代理:Axios 不加前缀,Vite 就不知道哪些请求需要被代理转发。 以此确保: * 开发环境可以正常工作(通过代理解决跨域) * 生产环境可以正确部署(直接请求后端) * 代码的可维护性(清晰的职责分离) * * * **4.代码实例:** ```typescript // 开发和生产都用这个相对路径 const myAxios = axios.create({ baseURL: '/api' // 统一前缀,不写死 IP }); ``` ```typescript // 只在开发环境使用 server: { proxy: { '/api': {// 捕获前缀 target: 'http://localhost:8080',// 真实后端地址 changeOrigin: true } } } ```

为了解决 AI 流式输出的重复解析问题,我发布了 incremark:普通情况下 AI 流式渲染也能提速 2-10 倍以上

昨天,我发布了周末开发的 [incremark](https://incremark-docs.vercel.app/)。实际性能远超预期——**在 AI 流式场景中通常实现了 2-10 倍的速度提升,对于更长的文档提升更大**。虽然最初打算作为自己产品的内部工具,但我意识到开源可能是一个更好的方向。 ## 解决的痛点问题 每次 AI 流式输出新的文本块时,传统的 markdown 解析器都会**从头开始重新解析整个文档**——在已经渲染的内容上浪费 CPU 资源。Incremark 通过只解析新增内容来解决这个问题。 ## 基准测试结果:眼见为实 **较短的 Markdown 文档:** ![image.png](https://pic.code-nav.cn/post_picture/1697067765011161089/D0q8wc5ABVqMpDzN.webp) **较长的 Markdown 文档:** ![image.png](https://pic.code-nav.cn/post_picture/1697067765011161089/WKTTzmW7koVAkEXu.webp) ![image.png](https://pic.code-nav.cn/post_picture/1697067765011161089/dU7qCv1XdojN4D5c.webp) > **说明:**由于分块策略的影响,每次基准测试的性能提升倍数可能有所不同。演示页面使用随机块长度:`const chunks = content.match(/[\s\S]{1,20}/g) || []`。这种分块方式会影响稳定块的生成,更好地模拟真实场景(一个块可能包含前一个或后一个块的内容)。无论如何分块,性能提升都是有保证的。演示网站没有使用任何人为的分块策略来夸大结果。 **在线演示:** - Vue 演示:<https://incremark-vue.vercel.app/> - React 演示:<https://incremark-react.vercel.app/> - 文档:<https://incremark-docs.vercel.app/> 对于超长的 markdown 文档,性能提升更加惊人。**20KB 的 markdown 基准测试实现了令人难以置信的 46 倍速度提升**。内容越长,提速越显著——理论上没有上限。 ## 核心优势 ⚡ **通常 2-10 倍提速** - 针对 AI 流式场景 🚀 **更大的提速** - 对于更长的文档(测试最高达 46 倍) 🎯 **零冗余解析** - 每个字符最多只解析一次 ✨ **完美适配 AI 流式** - 专为增量更新优化 💪 **也适用于普通 markdown** - 不仅限于 AI 场景 🔧 **框架支持** - 包含 React 和 Vue 组件 ## 为什么这么快? ### 传统解析器的问题 任何构建过 AI 聊天应用的人都知道,AI 流式输出会将内容分成小块传输到前端。每次接收到新块后,整个 markdown 字符串都必须喂给 markdown 解析器(无论是 remark、marked.js 还是 markdown-it)。这些解析器每次都会重新解析整个 markdown 文档,即使是那些已经渲染且稳定的部分。这造成了巨大的性能浪费。 像 vue-stream-markdown 这样的工具在渲染层做了努力,将稳定的 token 渲染为稳定的组件,只更新不稳定的组件,从而在 UI 层实现流畅的流式输出。 然而,这仍然无法解决根本的性能问题:**markdown 文本的重复解析**。这才是真正吞噬 CPU 性能的怪兽。输出文档越长,性能浪费越严重。 ### Incremark 的核心性能优化 除了在 UI 渲染层实现组件复用和流畅更新外,incremark 的关键创新在于 **markdown 解析**:**只解析不稳定的 markdown 块,永不重新解析稳定的块**。这将解析复杂度从 **O(n²) 降低到 O(n)**。理论上,输出越长,性能提升越大。 #### 1. 增量解析:从 O(n²) 到 O(n) 传统解析器每次都重新解析整个文档,导致解析工作量呈二次方增长。Incremark 的 `IncremarkParser` 类采用增量解析策略(参见 `IncremarkParser.ts`): ```typescript // 设计思路: // 1. 维护一个文本缓冲区来接收流式输入 // 2. 识别"稳定边界"并将已完成的块标记为 'completed' // 3. 对于正在接收的块,只重新解析该块的内容 // 4. 复杂的嵌套节点作为一个整体处理,直到确认完成 ``` #### 2. 智能边界检测 `append` 函数中的 `findStableBoundary()` 方法是关键优化点: ```typescript append(chunk: string): IncrementalUpdate { this.buffer += chunk this.updateLines() const { line: stableBoundary, contextAtLine } = this.findStableBoundary() if (stableBoundary >= this.pendingStartLine && stableBoundary >= 0) { // 只解析新完成的块,永不重新解析已完成的内容 const stableText = this.lines.slice(this.pendingStartLine, stableBoundary + 1).join('\n') const ast = this.parse(stableText) // ... } } ``` #### 3. 状态管理避免冗余计算 解析器维护几个关键状态来消除重复工作: - `buffer`:累积的未解析内容 - `completedBlocks`:已完成且永不重新解析的块数组 - `lineOffsets`:行偏移量前缀和,支持 O(1) 行位置计算 - `context`:跟踪代码块、列表等的嵌套状态 #### 4. 增量行更新优化 `updateLines()` 方法只处理新内容,避免全量 split 操作: ```typescript private updateLines(): void { // 找到最后一个不完整的行(可能被新块续上) const lastLineStart = this.lineOffsets[prevLineCount - 1] const textFromLastLine = this.buffer.slice(lastLineStart) // 只重新 split 最后一行及其后续内容 const newLines = textFromLastLine.split('\n') // 只更新变化的部分 } ``` ## 性能对比 这种设计在实际测试中表现卓越: | 文档大小 | 传统解析器(字符数) | Incremark(字符数) | 减少比例 | |---------|-------------------|-------------------|---------| | 1KB | 1,010,000 | 20,000 | 98% | | 5KB | 25,050,000 | 100,000 | 99.6% | | 20KB | 400,200,000 | 400,000 | 99.9% | ## 关键不变量 Incremark 的性能优势源于一个关键不变量:**一旦块被标记为 completed,就永远不会被重新解析**。这确保了每个字符最多只被解析一次,实现了 O(n) 的时间复杂度。 ## 适用场景 完美适用于: - 🤖 带流式响应的 AI 聊天应用 - ✍️ 实时 markdown 编辑器 - 📝 实时协作文档 - 📊 带 markdown 内容的流式数据看板 - 🎓 交互式学习平台 **无论你是在构建 AI 界面还是只是想要更快的 markdown 渲染,incremark 都能提供你需要的性能。** ## 欢迎体验与支持 非常欢迎尝试与体验,在线演示是感受速度提升最直观的方式: Vue 演示:https://incremark-vue.vercel.app/ React 演示:https://incremark-react.vercel.app/ 文档:https://incremark-docs.vercel.app/ 如果你觉得 incremark 有用并想要参与改进,也欢迎提交 issue 与独特想法![GitHub Issues](https://github.com/kingshuaishuai/incremark/issues) 由于之前 v 站大哥们的反馈跟 star,打算将这个工具开源的事当个事儿来做,这里也欢迎 Y 站的朋友一起参与 ![image.png](https://pic.code-nav.cn/post_picture/1697067765011161089/kW0wBFnb0zBdZ6LT.webp)

余辉程的求职思考指南(下)

## 八、小余的思考 以下内容算是我临时的想法以及对简历的一些思考,就相当于和大家聊聊天,算是我2025年度总结的部分内容,算是针对本次文章主题的呼应。 ### 8.1 小余的职业规划 职业规划是怎么出来的呢?在眼光不足的情况下,职业规划要怎么做?每个人在职业规划时都会产生很多问题,我也不例外,我第一次做职业规划时,对整个行业一无所知,在那个时候,网上流传的资料稀少,每一步都跨得额外艰难,难在未知。 未知是非常可怕的,会令人产生恐惧,令我们给自己假想出一个无法应对的对手,从而被自己吓到。就像中式恐怖的核心在日常中塑造未知,因此职业规划对我来说,不能做得太长,我无法对自己不了解的地方做出规划。 第一次做出职业规划是在2022年初(2月13号),职业规划只有一层(一年计划),学好HTML、CSS、JavaScript三件套之后,学习Vue.js 2。我的初次职业规划如图8-1所示。 ![image-20250930232553287](https://pic.code-nav.cn/post_picture/1608488597789343746/vixubFXYKpFsffor.webp) <p align="center"> <b> 图8-1 小余的初次职业规划</b> </p> 在两周后(2022年2月27号),我给出了第一份答卷,第一份JavaScript思维导图。从这一次开始,持续1年的每日计划开始,在睡觉前,我会简单复盘今天所学,并将第二天需要做什么写下来,在这个阶段,我的职业规划还是处于一个混沌状态。 ![image-20250930233259792](https://pic.code-nav.cn/post_picture/1608488597789343746/a1gO3ZUI3w1gqmSx.webp) <p align="center"> <b> 图8-2 小余交出的第一份JavaScript思维导图</b> </p> 虽然一切都很粗糙,但已经实现从0到1的突破。随着不断学习,我下意识地将一年职业规划分为四份(四个季度),并且对每个季度进行复盘总结。小余第一季度的总结如图8-3所示。 ![image-20250930234327114](https://pic.code-nav.cn/post_picture/1608488597789343746/mcZbcmXjprcD26zg.webp) <p align="center"> <b> 图8-3 小余第一季度的总结</b> </p> 一切似乎都走上正轨,但我明确的和大家说,随着第二季度的学习计划结束(全勤 2022年6月30号),如图8-4所示。这持续了几个月的努力,我还没理解职业规划意味着什么,对前路很迷茫,我只不过沿着一条学习路线在摸索前进,没有人教我要怎么做。我从互联网中,看见大家推荐浙大翁恺老师的C语言课程以及陈越老师的数据结构课程,从翁恺老师那里,我学习到了计算机的核心精神,从陈越老师那里,我收获了挫折。陈越老师的数据结构课程对当初的我来说还是难度太高,我是硬磨过去的,10分钟的内容我能来回拉十几遍进度条,看两小时,这种方式并不推荐,我当时还未掌握学习方法,硬磨了10w字的学习记录出来并分享。PS:暑假以同样的方式去攻克哈工大计算机网络课程失败。 ![image-20250930235108077](https://pic.code-nav.cn/post_picture/1608488597789343746/1qw4EV1YGasdWKWk.webp) <p align="center"> <b> 图8-4 小余第二季度的总结</b> </p> 学习了一天又一天,从无间断,知识的量在不断积累,挫折不断的遇到,到底还要坚持多久才能迎来质变,那时候的我不知道,我看到的是一条未知的道路。我咬咬牙继续坚持(纯靠力大砖飞,性价比很低,曾经错误的教育理念让我走得磕磕碰碰)。接下来要快进了,下一季度总结直接跳过,在这个过程中学了很多内容,都很有意思。在2022年10月27号,我的学习思路开始转变,思考的火花开始产生了。 ![image-20251001001212120](https://pic.code-nav.cn/post_picture/1608488597789343746/B6EsPsj60JWSsFRd.webp) <p align="center"> <b> 图8-5 小余学习思路转变</b> </p> 在2023年2月26号,我写出了我的年度总结,完成了第一年的职业规划总结,如图8-6所示。思考深度此时稍微好了一些。 ![image-20251001001414089](https://pic.code-nav.cn/post_picture/1608488597789343746/tOEawv3mTSTmdvBK.webp) <p align="center"> <b> 图8-6 小余第一年的职业规划总结</b> </p> 第一年的职业规划总结关键点内容如图8-7所示。这是非常关键的节点,此时我已经意识到我百家饭式的学习方法隐患很大,理解了体系化的重要性。当面对自己努力一整年,学了一身稀碎、乱七八糟的技术时,大家会怎么做? ![image-20251001002228375](https://pic.code-nav.cn/post_picture/1608488597789343746/2pQGC0DePhKr8ayw.webp) <p align="center"> <b> 图8-7 小余第一年的职业规划总结关键点</b> </p> 此时我刚接触到coderwhy老师的前端系统课,这也是导致我必须面对我过去一年学得就是乱七八糟的结果,学得浅显、理解错误地方多、学习方式不对。我做出了一个决定,我没有沿着去年基础去修修补补,而是放弃了2022年一整年所学的所有内容,就当从来没学习过。做出这个决策,对我而言是困难的,我必须亲自否认自己过去一年努力的成果,然后开启二周目。改变可怕,意味着舍弃过去;不改变更可怕,意味着活在过去。 虽然抛弃了2022一整年的学习成果,但学习的思路优化已经逐渐在我身上体现出来,我的逻辑思维以及各种底层能力相对一年前有极大幅度的提升。在2023年,我开始正式学习coderwhy老师的前端系统课,从这份课程中,我摄取到最为宝贵的编程学习概念,即面对计算机领域的知识如何去正确学习。在这之前,我甚至不知道有框架官方文档,这很离谱,我2022年在B站学习时,所有的前端学习课程都完全不提框架官方文档。PS:就在刚才(2025年10月1号),我使用百度检索Vue关键词,前10页里找不到Vue的官方文档...。 因此我不推荐大家使用百度搜索,不能翻墙选Bing搜索,能科学上网选谷歌搜索,谷歌对我检索资源的帮助极其重要。 2023上半年,我自学完了coderwhy老师的一整套课程,成功完成对2023年职业规划的上半年内容。在这个过程中,我拿出自己曾经攒下的钱,在力所能及的范围内,支持了coderwhy老师的部分正版课程,如图8-8所示。对于好的、认可的内容,我都愿意付费去支持,这也是我消费的绝对大头来源,不管是Typora正版,还是购买过的各种课程、掘金小册等等。在有能力获取盗版资源,我依旧愿意花钱去支持作者,从生活费中一点点存下来。 ![image-20251001010315267](https://pic.code-nav.cn/post_picture/1608488597789343746/UB81RUdfRH6ElMQc.webp) <p align="center"> <b> 图8-8 购入正版课程</b> </p> 由于我只参与了部分正版课程(只拥有部分课程群聊),我将我上半年写出的所有笔记,都发给了小黎老师,希望这些笔记对于正版付费的同学,也能够有所帮助,至于他们认不认识我,对我不重要(我当初并没有考虑影响力等问题,我只是乐于分享,并做出行动)。前端系统课所有的笔记如图8-9所示。 ![image-20251001010142611](https://pic.code-nav.cn/post_picture/1608488597789343746/iSZuTXAHq2AjzN0C.webp) <p align="center"> <b> 图8-9 前端系统课所有的笔记</b> </p> 到目前为止,我已经学习一年半的时间,其中2023年的上半年掌握体系化的精粹,而2023年的下半年,我终于正式入门职业规划了。我看过官方文档,看了掘金,看了书,看了框架不断迭代,工具不断更替,AI的出现崛起。自己开始对整个行业有了更深度的思考,行业的信息慢慢的对我来说不再是未知,我也开始进一步修正我的职业规划,一年的职业规划上限,也提升到三年,职业规划的上限提升主要原因来自我思考问题的视角拔升,能看到更多之前看不到的内容。 因此在2023年度总结(在2024年初写下),开始做出更清晰的职业规划,如图8-10所示。2023年度总结具体内容就不展示了,因为2024年度总结更清晰。 ![image-20251001011638367](https://pic.code-nav.cn/post_picture/1608488597789343746/eijzNPwMhfOhPkp5.webp) <p align="center"> <b> 图8-10 小余的2023年度总结</b> </p> 在2024年度总结中,我精细规划职业道路,细化毕业一年目标,锚定三年成长路线,眺望五年志向。想看我2025年想法的,可以在掘金直接检索标题就能找到文章了。今年2025年度总结会在2026年初写下,大家如果感兴趣的话,到时候可以分享给大家。 ![image-20251001011343440](https://pic.code-nav.cn/post_picture/1608488597789343746/9G2aLdY0vNmtpKbR.webp) <p align="center"> <b> 图8-23 小余的2024年度总结</b> </p> 好的朋友们,以上就是我这一路来,职业规划的发展路程,每一步都有留档,都有进行思考,这一职业规划蜕变过程,能对大家有所启发自然是最好的。在最开始的时候,笨方法往往能打开一点希望(力大砖飞),有的人可以坚持3天,有人能坚持21天,我坚持了两年,然后迎来了转机,这两年有一大半的时间都是对抗性学习(不推荐)。 需要说明,职业规划不是一蹴而就的,在1.6小节的做好职业规划中,虽然有推荐的模板嵌套,但我认为拿来面试一下就差不多了。 真正的职业规划,需要看自身的能力,我第一次规划只能规划到第1年,现在也只能规划到第3年,想一口气规划到第10年,你需要对行业有极度深入的认知,因此根据自己的实际情况,去选择年限问题,有所成长了再把职业规划迭代一下就好了。所有的建议对没有自己主见的人都是灾难,多考虑一下自己的实际情况,把模板职业规划修整到合适自己的长度,职业规划刚开始简单一点也无所谓。 我之所以将我的经历展现出来,是为了让看到这篇文章的大家对比成长路径,获得更多的信息有利于你去设计自己的路线。把我的经历当参考就好了,最终做出决定的是你自己。在我成长的过程中,有很多人想上手“指导”我的人生,例如:“前端已死,你为什么不出国留学,前端上限太低没什么钱”,这些人其实并不包含恶意,他们只是觉得有点可惜我有这种能力为什么做前端,我并没有觉得有什么不好的,我收获的是更重要的东西-成长。我推荐大家需要有自己的理解,只有自己才能为自己负责,迷茫的话就先行动吧。 我不会停下学习的脚步,无论哪一门技术都无法伴随我们一生,我会不断的学习,不断的分享传播,我从其中找到乐趣与价值。学了前端就不能学其他内容?没有人这么规定,coderwhy老师也说过,将自己定位为coder(编码者),不局限于某一门语言中。在8.2小节中,我会展现不同的一面。 ### 8.2 小余的人生规划 在2024年度总结中,我做出了初步的人生规划。职业规划对绝大多数程序员来说,区分为三个阶段: (1)前三年:精进技术。 (2)中三年:培养一定的影响力。 (3)尾三年:技术or管理or其他。 前三年用于精进技术,实现学校阶段所得理论转为真正的实践,这也是精力最旺盛,技术进步最快的时候。当通过技术站稳脚步后,就可以将侧重点转为利用技术扩大自身影响力,影响力可通过六方面逐步扩大:互惠、喜好、社会认同、权威、稀缺、承诺。 (1)互惠:利他最终导致利己。 (2)喜好:对更大需求的群体进行针对性分享,实现分享效果最大化。 (3)社会认同:引起社会他人的认同认可,共鸣产生信任。 (4)权威:技术带来的背书,在分享过程所形成的IP化,是他人信任我们的捷径。 (5)稀缺:我们所分享的,需要是真正的干货所需,稀缺会带来溢价。 (6)承诺:一诺千金,慎重承诺,说出口的话会成为口碑,而口碑会传递。 尾三年,有技术有影响力,已经能做出取舍,抉择出未来真正的方向,此时三十而立,是时候在社会有自己的立足点了。无论什么时间段,都是技术精进、培养影响力和前路思考同时进行,区别只在于精力分配与侧重点不同。 但人生不只是有职场,我们还有生活。在这几年里的新年里,我看到在热闹喜庆的氛围下,一些家庭依旧掩藏不住的苦涩,沉重的氛围让我忍不住带入思考,我是否能够面对这样的场景?我认为不能,有些事情并不是努力的问题,从一开始就走在错误的道路上,随着时间的流逝,可供腾转的余地越来越少,只能眼睁睁看着自己走向不愿意看到的未来。因此我将这部分思考,融入了我的方法论中,在图8-23所示的"选择"分支上。人生很多事情没有对错,只有选择,能依靠努力就有所收获的事情相对选择而言是简单的事情。 我认为婚姻是前期最核心的转折点,这直接将一个人的未来收束到可预测的范围,当过了30之后,前路基本上都非常清晰,是前期重大选择下的反馈结果。这其实很像投资,人生的大改变取决于5%不到的关键选择,这种选择时机往往转瞬即逝,抓取时机需要极其敏锐的直觉,我总结的结论是:关键选择往往能长远改变我们所处环境,当面对这类选择时千万要谨慎(选择往往没直接的体现,需要有一定的发散思维)。通用的关键选择是非常稀少的,当面对能提前思考准备的关键时机没去抓住,就像简历随便写,简历上的内容不深挖,被考倒也是自己的问题。 但我的人生规划与婚姻无关,婚姻是不可控因素,未知的部分不能纳入规划中,否则未知的前景会导致未知的投入,从而导致规划崩盘。我将人生规划区分为三个阶段: (1)学生时代。 (2)职场时代(社会)。 (3)自我价值实现。 我的人生阶段正处于学生时代的末尾,对简历的准备是踏入职场时代的入场券,在职场相关的技巧也能从相关的书籍去摄取然后实践。职场时代与职业规划有强关联,而我最终目的是财富自由,做到“提前退休”,财富自由与收入、欲望、自我认知等要素有关,克制欲望是追求财富自由最核心的要素。在达到第二阶段的目标后,我梦寐以求的未来是:可以在自己喜欢的时间里和喜欢的人做喜欢的工作,想做多久做多久。这是非常奢侈的事情,所以需要足够的规划去铺垫开始。 第二阶段的最终目标,是前段时间看完《金钱心理学》后所受到的启发,人生规划最终追求的幸福,幸福是内求的。我需要去避免一些可见的陷阱,并为未来留下足够的兜底本钱(面对未知陷阱所必要的准备)。在我的人生规划中,最主要的价值体现应该是复利,稳定踏步向前是最有可能走向希望未来的方式。 好的朋友们,这就是我对自己人生规划的部分思考,如果大家想更进一步看我的想法的话,可以等我2026年度总结,我写下该篇文章的时间段是2025年的国庆假期,距离2025年度总结大致还有3个月时间,大概率会在掘金平台更新。 ### 8.3 小余对对HR问题的回答 你对未来3-5年的职业规划? ​ 回答:第一年我主要做的事情会对所学技术与实际场景相结合,尽可能完成学校到职场的转化,把技术真正的落地。第二年我会精进我的技术,去深入学习重要框架的源码并形成自己的思考。第三年我打算学习更多方向的技术内容,而不止局限于前端这一领域,并通过第四第五年来兑现更多自己的价值。我乐于分享,我希望在未来3-5年中,能为开源做出贡献,为团队做出努力,形成自己的影响力。 如果看待公司的加班问题(996)? ​ 回答:加班问题是互联网行业不可避免的事情,我对此已经有所心理准备。我不抗拒加班,但我会尽可能以更高效的方式完成手中的任务,并从整个团队的角度去思考,通过自己的能力与想法,去想办法实现与团队的高效协作,提高整个团队的产出效率,从而减少加班频率与加班时长,更有利于自己与团队未来的成长。 你工作中遇到的最大挑战是什么?是如何解决的? ​ 回答:目前尚未工作,如果要我回答,我会回答在写书过程中遇到的最大挑战。PS:挑战的具体内容并不重要,面试官大概率不了解具体内容,这是一道心理学题目,我认为重要方面来自4个方面: ​ (1)挑战波及了工作的多大范围?这直接取决于我们职权的实际表现力,多大的职位背多大的锅。 ​ (2)面对挑战时,我们的反应是什么? ​ (3)挑战的解决过程,是展现我们成体系方法论能力的绝佳机会。 ​ (4)挑战结束后,是如何处理余波的,是否有进行复盘,是否有做出预防措施,以防下次再次遇上,是否有成长,完成挑战实际给公司带来的收益是什么。 面临超负荷和时间紧迫的工作,你会如何处理? ​ 回答:展现我的方法论,对工作任务进行紧急与重要性的排列。我通常情况下会先解决重要且紧急的事件,其次是重要但不紧急的事件,最后解决不重要但紧急的任务。其中有些情况下,我会先解决重要但不紧急的任务,因为有时候解决这部分内容会同时解决很多问题,优先重要但不紧急的任务的理论以及好处来自雷军的演讲,我认为如此优秀的人物所遇到面临超负荷和时间紧迫的工作会是整个行业的顶级水准,从他们身上借鉴方法是值得一试的。 ​ 以上是我对时间紧迫工作的处理方式,而面对超负荷情况下,我认为平时需要多锻炼身体,身体是革命的本钱,良好的身体机能会带来更高质量与更丰厚的精力。这种方式对程序员来说,由于底子薄弱,提升会更明显,是面对超负荷最厚实的屏障。 曾在上海工作,为何选择来北京发展? ​ 回答:还没工作。我曾在福建读书,但北京相对福建整体互联网环境氛围会好上许多,选择一个对互联网行业更友好,就业需求更强的环境去做前端工程师对我而言是一件更好的事情,我打算在这里长期发展自己。接着阐述理由... 你认为自己的缺点是什么? ​ 回答:我认为我的缺点是英语水平不太够,这对我之前经历的影响还是很明显的。目前通过炭炭背单词App去积累词汇量有一段时间,希望在未来能够弥补自己在英语方面的弱势项。如果身体素质不太好,就可以联动面临超负荷问题的回答进行回复。这两点都是可以弥补,且前端开发工程师中比较普遍的问题。 你认为自己的优点是什么? ​ 回答:拥有自主学习的自驱力,清晰明确的职业规划以及良好的沟通能力。PS:然后举例增强说服力,我这些能力在前文和后文都有提及过,不再重复。 HR的问题范围不会这么少,我会使用AI将HR有可能问的问题归纳出来,再去一个个回答。在我看来,这些问题并不难回答,因为我有足够的积累,而感觉简单是积累对我的回报。因此大家也可以多积累,从而获取等同的回报。 ### 8.4 小余对发散性问题的回答 你的职业规划是什么? ​ 回答:详见8.1小节内容。 你比较关注哪些前端技术? ​ 回答:我关注前端前沿的热门技术,目前对尤雨溪成立的VoidZero公司有所关注,他所研究的JavaScript工具链会直接影响我们...对目前JS的原生响应式提案也有关注,这也许会改变几年之后的技术方向。PS:大家多看一些文章就行了,掘金或者Stack Overflow都有很多讨论。 你最近都在看什么书籍? ​ 回答:最近看的书籍是心理学经典书籍《影响力》。我从小就自主培养出阅读书籍的兴趣,小学至高中阶段,更多看的是世界文学作品。在大学期间,阅读过与前端相关的书籍主要有:红宝书、小黄书以及犀牛书。我对霍春阳的《Vue.js设计与实现》也有涉猎,对计算机领域的多类书籍都有所阅读。非计算机领域阅读方向有:经济学、心理学、历史、基础医学科普、英语、摄影、科学哲学与逻辑学。在校期间,平均每年花费在阅读上的时间在1400小时左右,最近一两年使用的阅读软件是微信读书,阅读时长如图8-11所示。PS:但我不认为阅读时长能证明什么,阅读中带给我的思考会更重要,阅读时长只是收获中所体现过程。 ![8d5c0384ec323b9a5b327902bc43f1cd](https://pic.code-nav.cn/post_picture/1608488597789343746/wWyCIqap2LaKkIe7.webp) <p align="center"> <b> 图8-11 微信读书阅读时长</b> </p> 在团队管理中通常都做什么? ​ 回答:大学生,没有实际面对过该问题。但我曾经有接过一单小程序商单,组合过一个三人小团队开发,完整经历了与客户的沟通、需求制定、协作团队沟通、合同制定、前后端开发、首付款与尾款的催收等环节,基本上什么都干,对整体流程有一个粗浅的认知。PS:这是真实的,最重要的一定是拿到首款和尾款,尤其是尾款,很容易出现跑路情况。 ### 8.5 小余和coderwhy老师一起出书的故事 在掘金社区有分享过与coderwhy老师出书前的故事,链接为:[和coderwhy老师的结识经历 - 掘金](https://juejin.cn/post/7436409761691516947),这里讲的是出书过程的故事。 出书是一段较为坎坷漫长的经历,起始于2024年5月21号,结束于2025年9月28号(等待书号),一共经历了约71周(496天)。出书的起点如图8-12所示,一切的开始来自coderwhy老师主动发起了一句问候,然后有了之后的一拍即合。 ![image-20251004004528213](https://pic.code-nav.cn/post_picture/1608488597789343746/3e60Kuu4qjMz1cny.webp) <p align="center"> <b> 图8-12 出书的起点</b> </p> 我与coderwhy老师一开始规划的想法是出关于React的书籍,那时候是React19版本即将推出的时间段(2024 年 12 月 5 日正式发布了 React v19 稳定版),但又很想出JS高级内容的书籍,在两者中纠结了一段时间,最终选择了JS高级,可以顺承React,会更加流畅。PS:实际上两者都写了(狗头),React的稿件还躺在我的文件夹里,如图8-13所示,最终还是需要取决于大家的实际需求。 ![image-20251004010646369](https://pic.code-nav.cn/post_picture/1608488597789343746/sCkk1d6tkQQPiSop.webp) <p align="center"> <b> 图8-13 React系列稿件</b> </p> JS高级书籍的正式书写分为4个阶段: (1)囤稿件,提前书写了15章内容。 (2)coderwhy老师已就出书事宜与张爽编辑接洽,并确认将为我提供背书,共同推进本书出版。 (3)在掘金平台(XiaoYu2002)与公众号(coderwhy)于2024年8月19号开启了同步连载,每周一三五更新,2024年11月7号完结。 (4)稿件正式提交给张爽编辑,进入漫长的修改阶段,于2025年6月前修改完成,当年10月前走完流程。 张爽编辑(后续统一称呼为张编辑)是非常优秀的编辑,她以凌厉的审核眼光挑出了文章中的所有问题,这是一点都不夸张的。我一共经历的5个版本的书籍迭代,基本上将整本书重写了一遍,相对于目前连载在掘金与公众号版本的内容,我更推荐大家买一本实体书来学习,书名是《JavaScript高级编程权威指南》,出版印刷时间取决于书号获取时间,大家可以关注公众号coderwhy获取最新信息。 我觉得那些认为世界只是一个巨大的草台班子的说法是有点离谱的,真正专业的人始终专业严谨。张编辑的严谨不拖沓,始终锁定关键信息的做法,引起了我的兴趣,我利用了自己的检索能力,查询到张爽编辑从2016年毕业进入人民邮电出版社,2020年后进入电子工业出版社博文视点,至今已经九年多的时间,我面对的原来是经验如此丰富的一位编辑。 从张编辑身上,我大幅度地强化了我的书面化表达能力,她就像导师一样指导我如何去修改文章,如何去做到更好。PS:但修改批注的过程也让张编辑变得烦躁,其实这是完全可以理解的,大家可以欣赏下张编辑的批注(一共二十多张),就知道为什么张编辑烦躁了,张编辑的批注如图8-14所示。 ![a488f2b4d80c71dbdf7588535e5f8bce.jpg](https://pic.code-nav.cn/post_picture/1608488597789343746/ZpBfQUYlj5ZELwEj.webp) <p align="center"> <b> 图8-14 张编辑的批注</b> </p> 在我的总结规律中,一共有数百个细节要点,这些细节之处是我自己摸索很难发觉的。如图8-14所示的批注版本是第三个版本的内容了,但标红的内容依旧如此之多,这也是我那时候遇到最重大的几个困难之一,这修改工作量对张编辑来说太大了,那时一度让张编辑停止修改,我来接手。我逐字逐句的看张编辑的批注,并且与coderwhy老师沟通了两天,用于探讨和修改第27章的内容,coderwhy老师给出了很多切实的建议,并且负责多次审核样章。 在修改书籍内容的过程中,我做出了一个非常重大的决定。我利用不同的AI不断的挑刺,对其中的三四章反复的修改,并强制自己深入学习其中的细节部分。然后就当自己没写过这些内容,我从第一篇开始,一个字一个字的重新编写。我知道可以总结AI Prompt并结合智能体来加快优化,但我就没办法进步,我想要做到,哪怕不利用AI,我也能够完成书籍的编写,提高文字的质量。AI最核心的作用是提升我们自身能力和提高效率,在需要我们亲自去学习时,AI不应该是偷懒的工具。PS:上一次推倒从来还是学习coderwhy老师的系统课,这种感觉还是一如既往的酸爽。 最终我在6月份完成第4版的书籍编写工作,把张编辑认为修改难度为"地狱级别"的内容完成修改(图8-15所示),后续错误微乎其微,这是对读者的负责,我因此也有极大的成就感。吸收这次修改过程的经验,我编写了一份数万字的AI使用小册来印证我的提升效果。 ![image-20251004021715956](https://pic.code-nav.cn/post_picture/1608488597789343746/FJOcjtAGXi7HE8wR.webp) <p align="center"> <b> 图8-15 书籍修改的难度</b> </p> 在2025年7月24号,花费1小时不到,完成第5版的修改,如图8-16所示。当然这不意味着就只有2处需要修改,张编辑还是额外修改了不少地方,我在看最终版本时有发现,其中包括了目录的优化等,我吸取了其中的经验,用在这篇简历文章的编写当中(也是数万字)。 ![image-20251004020552954](https://pic.code-nav.cn/post_picture/1608488597789343746/wQdfFyyoFCNiergC.webp) <p align="center"> <b> 图8-16 JS高级书籍第5版的修改</b> </p> 这就是写书的大致故事,和大家进行一个分享,也是对自己这段经历的一次记录。就像张编辑自己曾经的想法一样:“回望这些年,我发觉曾经策划和出版的一些技术书好像在随着时间的推移与我渐行渐远,回忆也开始不那么清晰。可每本书都是我当年耗费几个月,甚至一两年的时间和精力打造的。我想让它们在记忆中再停留得久一些,更希望它们能在读者的心中停留得久一些。” 《JavaScript高级编程权威指南》是张编辑花费极大精力,极多时间去保质保量的书籍,如果大家对这本书有兴趣,可以支持一本,非常感谢。coderwhy老师的视频课程内容是书籍的骨架,是不可或缺的,从而形成视频+书籍+社群三位一体的服务体系。 如果想一起合作出书的话,可以联系coderwhy老师,但记得要展现出乐意于分享的成果与想法^_^。PS:大家不要低估写书的难度,审核要求非常高的,如果没有达到对应要求是会被退稿的。 在编写技术书籍中,有6个非常核心的要点一定需要注意(一旦没做到,会消耗大量的时间精力去弥补): (1)图片,一定要保存好源文件并做好分类,不要保存在设计网站上。 (2)图片,背景一定需要是白色的,黑色的背景在印刷后会导致内容看不清。我第3版修改了四百多张图片,根据内容复刻了之前所有的环境去重新截图。整个过年期间都在干这事了。 (3)图片,文字大小占据方框比例问题。 (4)图片,markdown文档转为Word文档的过程中,如果图片链接放在HTML格式中会导致无法正常转化。 (5)文字,内容一定要留底,即之前修改过的版本(标注修改日期)不要丢,所有内容都要分类清晰保存好。 (6)所有代码示例都要分门别类保存好,后续会用到。 最后说明:时间真就是海绵中的水,挤一挤总还是有的,在写书的过程中,我还完成了1200小时+的书籍阅读、3份大四计算机本科毕设(2025年暑假全国旅游的资金来源),1份微信小程序商单(团队合作,2024年11月份旅游资金),以及一整个暑假的劳动,上百份的复盘记录,回答上千个JS高级社群问题。在编写书籍的过程中,我查阅了前端相关的绝大多数成体系文档,引用了其中的部分文档内容,并对MDN文档提交数次修改意见,这些文档也值得大家平时去进行学习,如图8-17所示。正如coderwhy老师所说,最好的学习资料是官方文档。 ![image-20251004025309629](https://pic.code-nav.cn/post_picture/1608488597789343746/8qR9UXs2rXIUe4bw.webp) <p align="center"> <b> 图8-17 书籍图片的部分来源</b> </p> 以下是花絮彩蛋时间: 如图8-12所示的最后一句话,我有提到打算找一份实习,刚好与本章内容写好简历很契合,就当作一份彩蛋和大家分享。 在2024年5月21号的大早上(早上6点),我打算找一份前端助手实习生的线上实习,如图8-18所示。大家可以注意到这个时间是非常非常的巧合的,与coderwhy老师找上我是同一天。这次经历让我复盘起我曾经的关键转折点,让我察觉到了一些非常有意思的现象,下次有机会和大家聊。 ![image-20251004025618539](https://pic.code-nav.cn/post_picture/1608488597789343746/B7Q3j0OWfQyTmrR9.webp) <p align="center"> <b> 图8-18 2024年5月份寻求实习</b> </p> 如图8-19所示,这是一份非常标准的信息收集,其中主要信息包括考核任务(筛选)、个人介绍(定制简历)、以及微信号(联系方式)。考核任务还是很简单的,我花费20分钟完成代码编写并上传GitHub和部署到Vercel中,顺利通过考核任务。 ![image-20251004030544818](https://pic.code-nav.cn/post_picture/1608488597789343746/Hl8k6WHOCpxOUgYL.webp) <p align="center"> <b> 图8-19 前端开发小助手简历信息收集</b> </p> 事后就面试官来加我微信详聊,这里是关键了,我当时并没有准备简历,当看完这篇文章后,可以发现踩了很多坑,主要3个如下: (1)没有简历,面试官无从下嘴,不知道如何提问。面试官的委婉提醒如图8-20所示。 (2)我将编程导航中的年度总结部分导出给面试官看,但内容太多,面试官明确回复太多了没多少时间能看全,所以希望能提炼一下重点内容。第二次要求简历。 (3)了解项目。要求提交项目源代码,我当时正与coderwhy老师磋商是否出React系列的书籍,和面试官说明这是正在合作出版的内容,没办法直接给出项目源代码,我也还没来得及抽离出项目的亮点,因此项目经历卒。面试官明确表明重点关注项目经历。第三次要求简历。 ![image-20251004032422533](https://pic.code-nav.cn/post_picture/1608488597789343746/7l2G6cOK3kfzxSNb.webp) <p align="center"> <b> 图8-20 面试官的委婉提醒</b> </p> ![image-20251004033001466](https://pic.code-nav.cn/post_picture/1608488597789343746/1jmw9MMzCi4AF9TZ.png) <p align="center"> <b> 图8-21 面试的重点</b> </p> 如果学习完本篇文章再来应付这次面试,可以说是躺着过了,面试官很努力的在强调重点,但我没有把握住,最终结果是婉拒,如图8-22所示。这是我人生第一次寻找实习,总结下来是过于草率,没有提前写好简历,否则实际上能做到内推的效果。 ![image-20251004033237698](https://pic.code-nav.cn/post_picture/1608488597789343746/fPYxbo9RX2PLiy6z.webp) <p align="center"> <b> 图8-22 婉拒</b> </p> 客观限制有如下3点: (1)看到实习招聘才想找实习,顺序是有错误的。正确的方式应该先准备好通用简历/论文、定制简历/论文,了解招聘公司的产品以及文化,做到前面这些再去投递。 (2)和写书时间冲突,时间已经拿去写书,无法抽出足够的时间去应对。 (3)经验不足/重视程度不够,第一次编写简历不够好,亮点不足,并且没有投入足够的时间。 拿不到面试机会是很正常的事情,因为面试官找不到问题可以询问,无法正常展开面试。 在这次面试中,我能够利用的优势是足够了解该公司的产品和文化,见证该公司所有产品的从零开始以及内测版本,提出过数次建议,面试内容也不会是主要限制。因简历问题卡住,导致后续所有的优势都无法应用上。因此简历是第一关,通过我第一次真实的内推投递经历来印证事实。 当清楚简历是寻找工作中非常重要的一部分时,我就需要投入足够的精力去准备,我给自己留下接近6个月的时间去准备(我觉得实际2个月已经非常多了,但目前来看时间确实还有这么多,也可以可以再写一些有意思的内容)。这篇文章只是其中的部分思路梳理。 没有真正应聘成功线上前端小助手的岗位,对我而言是一件非常庆幸的事情(塞翁失马,焉知非福)。在暑假前,我妈瞒着自己生病的事实继续干活,导致病情加重。暑假期间,我接过我妈的工作,继续保证店面的正常运转(我家是餐饮夫妻中餐店)。白天晚上我负责非炒菜的所有工作,在没人的时候抽空回复JS高级社群中的同学的提问,在晚上工作结束后熬夜通宵写文章,保证内容的持续不断更。 都说夫妻餐饮行业是重劳动力很辛苦,我觉得相对我曾经的经历来说还是比较轻松的,算是对比效应吧,来自图8-23的相对理论。那段时间对我而言,最痛苦的事情在于端菜刷盘子的时候,总会不自觉的进入思考心流模式,对JS又有了新的理解后却不得不掐断这种深度思考的状态。如果我成功通过面试,那在不到一个月的时间内就得辞职,会给公司造成更加不良的印象,这就非常不好了。 以上为寻找实习彩蛋内容分享。 ### 8.6 小余的方法论 我拥有不少方法论,都是自己总结出来的。我并不赞成大家从0自己苦苦思索搭建,大家需要多去借鉴,多去实践。方法论这种东西,是需要在实践中才能体现价值的,否则束之高楼只会将它变成一份精致的艺术品(美妙但无法使用)。 在2024年,我有了一些积累,曾经本能的行为,在学习模仿的过程中,不断的根据自身情况调整优化,使其借鉴而来的方法契合自己,在这一过程,我筛选掉不合适我的方法,优化对我有帮助的方法,将真正在实践中发挥价值的方法进行了归纳总结。我的方法论之一如图8-23所示。 ![image.png](https://pic.code-nav.cn/post_picture/1608488597789343746/ZmbW6zdQzGG4NaxW.webp) <p align="center"> <b> 图8-23 小余的系统方法论</b> </p> 和大家分享这份方法论其中一部分的故事。在2022年-2023年中旬,我的学习历程是非常规律的,每天风雨无阻的去学习,时间一长,精神就会开始疲惫,因此我引入弹性学习机制和预留备用时间。一份精致的学习时间作息表是用来看的,不是用来实践的,越精致的规划越容易出现问题,因为现实不是计算机程序,既定的规划中一定会有突发事件阻碍我们的步伐,这些突发事件是未知的。而一旦错过一天的学习,当天的学习规划内容会堆积到第二天,而第二天又很难完成当天规划内容的同时把昨天的学习全部清理干净,一天的错过至少需要3-4天的额外"学习加班",这进一步加剧我的学习压力。 在这种压力下度过一段时间,我认为不能这样下去了,不然很难坚持下去,无论是身体还是精神都很难承受。因此预留备用时间的方案因此产生,我每周只有5天用于学习,其中2天用于预留突发情况,假设在周三完全无法学习,那学习时间顺位延续,周三的内容放到周四学,周四顺位周五,周五顺位周六学习,然后周末休息。没有突发情况的话,那周六周末两天都是休息时间。 但绝大多数的突发事件是不会将一整天都占用的,可能只占用一两个小时的时间。如果我规划每个时间段固定学什么,就很容易产生固定时间学习与突发事件冲突。此时弹性学习机制就十分重要,我只规划第二天要学哪些内容,大致学多久。我模糊化了学习目标,例如:“要学多少集JavaScript高级课程视频”的做法已经淘汰了,接下来要学习的内容我了解吗,我不了解,那我怎么能对不了解的内容做出时间规划呢,万一遇到一集非常难的知识点讲解,我2小时都看不完,而规划时间只有40分钟,我是要掩耳盗铃还是承认我今天计划已经无法完成。在我自身没有出现懈怠的情况下,无法完成计划,只能说明计划本身有问题。很多人遇到这种情况会选择掩耳盗铃,因此坚持好几天的计划不想就这样断掉,那这种不希望断掉的本能会蒙蔽我们原有的学习初衷,也会带来更高的精神负担。在中国大学慕课网学习哈工大的计算机网络时,我就因为规划当天学完几集,结果完全无法做到(基础过于薄弱),在不掩耳盗铃的情况下,给我带来了极大的精神负担,也宣告学习计划的失败。 在2024年,我的学习方式发生新的变化,我的学习规划越来越弹性,或者说是模糊。2024到目前为止(2025.10.2),我是凭借状态学习的,即当天很有学习兴致,我就多学一点,反之状态不好想睡觉,我就不学了。在这种学习方式的调整下,我进入心流状态的频率越来越高,从而做到同等学习时间下更高的效率,这份效率的提升非常恐怖,而且一点精神负担都没有,我通过数次迭代(完整的迭代过程在该故事中省略),终于感受到学习的乐趣。 那么,这份故事与方法论的关联在哪里?我曾看过杨振宁先生分享过他的学习方法,他是通过以下2个正反馈循环公式学习的: (1)直觉-实践-公式。 (2)直觉-纠正-正确的直觉。 通过直觉进行学习,如果实践结果与直觉有所不同,就深入思考去寻找原因,进而修改直觉,循环反复。这是非常精妙的学习方法,人的大脑可以区分为本能脑、情绪脑、理智脑三种,理智脑是最弱小的,运转时最消耗能量,但我们的主动改变都来自理智脑。如果我将理智脑所思考的内容转为本能,那么就可以交给性能强大的本能脑来运行(直觉归属于本能脑),用时刻运转、性能高效、不怎么消耗能量的方式做出关键决策。 这份收获,促使我产生自己的想法,我构建出偏差值方法,我会对接下来遇到的事情做出预测,预测接下来会出现什么结果,预测与实际会有所偏差,而偏差的大小会决定我接下来的修正方向以及投入思考的时间等因素,当偏差值越来越小,我的预测也会越来越准(预测需要足够的信息,而本能脑会在我不知情的情况下搜集足够丰富的信息,这会促使我本能做出判断)。大家可以看出来这与直觉修正是类似的,而我对学习状态的判断也是通过预测来实现的。一开始我也无法判断清楚我的学习状态,但随着偏差值不断修正,我就真正做到了,掌握了这类难度极高的学习方法。 通过不断预测,我的直觉(本能脑)得到进一步强化,我产出清晰梦的概率提升,在睡醒后的第一时间记录下梦境内容后,通过AI分析结合自身理解能够得到一些平时难以关注到的细节以及梦境的隐喻信息,这对我理解自己心理状态有极其大的帮助。 学习方法是递进的,没有最好只有最合适的。如果没有足够的积累,直接通过判断状态来学习,要么判断不准,要么状态好就拿去打游戏了。对于一无所有的人来说,也许固定死的学习方法才是最有效的,当自己真正渴望向前且有足够的思考积累后,就可以换学习方法了。 以上就是关于图8-23所示中的偏差值部分的效果与想法诞生的主要核心过程。人生是多维的,不止学习、不止工作、不止享受、不止家庭、不止社交,很难有一份方法论能适用于所有场景,因此可以多准备几份方法论来应对不同场景,当拥有足够阅历后,就可以抽取多份方法论的共性,得出一份更通用的方法论。但我认为越抽取到最后,方法论会越抽象,越质朴,没有前面积累过程,其他人直接看到最后的方法论也很难感受到重点。 提示:方法论需要做到简洁,这不是学习思维导图什么都往上写。上面的内容一定得是自己深深受益的核心部分(至少受益半年),实践过,有自己理解的往上写。书上看到的,觉得很有道理很有共鸣的不写,有时候共鸣只是错觉,真实践是有差距的,没有根据自己情况去调整,会使方法论价值降低。 ### 8.7 小余想分享的一些话 关于简历: 影视飓风有非常明确的口号:无限进步。而Tim在每一集视频中,在视频内容之外,还会讲解一些自己的感悟和想法,他们公司内部把这段感悟想法时间定义为"Tim的涩话时间"。我觉得很有意思,什么是涩话?我认为夹杂着人生经历的感悟,通常往往是苦涩、青涩、羞涩的,讲出来会令自己舌头"发麻"的话。coderwhy老师在课程中,也是会经常的和大家聊一些涩话。学习了他们的精神,我在文章的末尾,也想和大家聊聊一些我的感悟与想法。 做好了写好简历并且投递,是本次文章的核心,而拿到一份理想公司的Offer,领着丰厚的薪水是大家会收获的结果。但写完简历后呢?要开始面对工作了,大家对工作又是怎么看的呢?我曾看过一部电影《毕业生》,逃婚后男女主在公交车上迷茫,对前路未来是怎么样的无措让他们相望无言。达到自己想达成的目标后,却反而要迷茫了吗?作为一名学生,我认为Offer只是一张入场券,好的坏的入场券都不会让我们避开职场,因此对职场提前做好一些相关准备是有所必要的。职场与职业规划是强关联的。本篇文章也有未尽之意,面对职场要如何去应对取决于个人的价值观念,我是通过人生规划来锚定职场方向,你可以借鉴我的想法,也可以思考对你更有意义的做法。纳瓦尔在2025年也有一段关于生活、工作和智慧的访谈,很具备价值,感兴趣的也可以去听。 关于AI:AI最近发展越来越快了,我看见了AI与硬件的结合也已经出现,例如无比逼真的仿真机器人,纯软件领域的也有Sora的出现面世。未来的需求会是什么?以前科技还未发展起来时,我们主要依靠体力劳动获取收获;现在科技发展起来时,主要靠脑力劳动获取收获;那等未来当AI发展起来后,我认为靠情感获取收获,情感越丰富,越能够合理表达传递情感的人会成为大家所向往追求的。我认为理想的状态是,利用AI结合硬件释放已有劳动力,额外创造更多的价值,从而落实之前一些无法落实的政策(例如劳动法,社保的延续)。以后也许能落实双休或者一周休息三天也不一定,但取消工作是不可能的(哪怕能做到),很多人的价值是从工作中得到展现的,当不让工作后,很多人会是茫然的,因为他们的内心是空洞的。而空洞的内心,是需要情感去支撑、需要目标去锚定的。 山姆·奥特曼(OpenAI首席执行官)对AI的看法与我不谋而合,他的想法是:从某种客观意义上说,我从阅读维基百科中学到的东西,比我从任何老师那里学到的都要多。但是当我想起在我学习过程中的那些关键时刻,都和特定的人有关,他们与我建立了联系,对我产生了兴趣,了解我、关心我,而且我能感觉得到。是的,其中一些会被与AI的关系所复制,但我认为整件事会比表面上听起来更奇怪、更复杂,也更不均衡。AI将能够做很多不同的工作,也许是几乎所有工作中的所有部分。然而,我们会发现,我们与生俱来的生物程序是很难克服的。 我认为每个大学生都值得拿出一段假期出来不去学习实习,就纯享受,当剔除游戏之后,面对自由的时间,是否能够坐得住?很多人休息两三天会很幸福,但当休息时间一拉长就会受不了,在这个过程中尝试去寻求内心渴求的价值是比较有意思的一次实验。 关于努力: 努力是一种基本能力,即为心之向往而实践奋斗,这并不稀缺。达到一定阶段后,其实可以发现身边所有人都能做到努力,努力在我看来并不是拉开差距的关键,努力是止住堕落的托手。攀爬需要多方面因素综合展现,单靠努力还是太过单薄了,很容易产生无力感。 如果你现在还在迷茫,还在不知所措,还在低谷,那努力是首当要做的,如果你已经逐渐变得更好,那需要适应努力后,让自己每一分力气产生最大化的效益,用最合适的力道去做事情。在简历的编写中,也通过定制简历说明适合企业的简历,对企业才是最主要的。一股脑的盲目努力将所有东西写进简历,将所有时间拿来从头准备八股文,最终极大的努力换来收效甚微就不太值当了。 我每时每刻都会产生很多问题,这些问题都来自我的所思所见,并根据我自身的理解和阅读做出分析,经常都有收获,也是我一部分的思考素材。这种能力在我们小时候都有体现,我小时候有《十万个为什么》专门解答稀奇古怪的问题。古人将这称为童蒙,道家典籍常以"童蒙"喻指为本真状态,对世界的好奇会驱使我们去探索去前进,进阶版本的说法是:“路漫漫其修远兮,吾将上下而求索”,但中式教育较为僵化,我这种能力在初中阶段就被抹杀干净了,上了大学后,随着自我认知的深入,我又将这种能力找回来了。 避免对努力有过高的期望,能在面对挫折时保持一颗平常心,清楚知道努力不是万能的,这点非常重要。人的能力是多维度,努力也只是其中的一面。仔细想想,在面对困境,面对顺境,那些起决定性作用的璀璨品质都是哪些?由自己思考得出的结论总是令自己更加信服,我觉得大家都是可以做到的。 关于利他: 个体足够的利他最终带来的是个体交易环境的改良,这是社会化所带来的互惠模式,根生于生存在社会中的所有人脑海中。由于交易环境的改良,所有基于一对一资源交换的额外成本都会降低,交易效率也会极大幅度提升,这种全面的变化,经过时间的积累后,会带来足够丰厚的汇报,因此我认为利他导向的是利己。 这令我想起一些关于命运的思考,我认为三分靠自己、三分靠地、三分靠天,一共九分。一个人想改变自己的命运,自己的努力是必不可少的,那地与天又意味着什么呢?我认为地与天都是一个概念,并不指某一项具体的内容,地是涵盖环境、平台、风水等等含义,即人所处周围因素对人本身的影响。利他所改变的自身所处的一类环境,会使地的因素倾向于我们,从而更好的协调命运走向。而类似运气等因素来自天,而命这个字,上人下叩,中间一横拦截开。这一横可以是门也可以是某种屏障,但肯定不是依靠蛮力可以打开的,因为叩是轻轻敲击的含义。叩开了这扇门得见了人,因此命中最重要的成果是人,而人最上面,压住了所有。这个人是谁?我更倾向于是我们自己,因为人永远没办法举起自己,无法依靠蛮力实现。因此命是什么?是一个人最深的执着,轻得看不见摸不着,但压在人心上重如泰山,从而决定了一个人的态度,态度决定了行为,行为决定一生,因此命是一个人一生的结果,越是执着想去改变行为,越是命中注定。 关于学习: 我认为学习能力是后天造就的,这是一项不断优化迭代的能力。从实际表现来看,可能体现在老一辈认为的聪明上,我家里的长辈认为这是无法改变的,那是因为他们从未见过能够做到的人,受到眼光的局限。这让我联想到现在的教育,侧重方向已经逐渐转移到筛选机制上。更注重学生自己的能力,而非老师的能力。很多的好学生是伪的,因为所有知识的困难都被老师所拆解,一旦脱离老师就非常容易跌回原有水平。那么受到老师加持的学生享受到自身原有能力做不到的事情就会深刻依赖上这种感觉,而依赖上搭便车的舒适感,很容易导致自身学习能力的停滞或者退化。好老师的作用更多是引导作用,这一点在我今天(2025年10月7号)重温2005年《高三》纪录片时,理解得更加深刻。 这也许是国家不断的调整教育资源分配问题以及打击补习班的原因,因为老师是不可能跟随学生一生的,一旦学生失去老师讲解加持回归原有水准,在心智尚未成熟的阶段,会很难接受真相以及周围的压力。最好的老师还是自己,总究还是需要靠自己。但如果能遇到好老师也是非常好的事情,只需要在吸收消化知识的基础上,去跨出舒适区锻炼自己的能力,百尺竿头更进一步。探索未知的自驱力不失去,那好老师所带来的好处就都可以利用上,产生的隐性风险也可以规避。 现在环境下,没能掌握自己学习方式的好学生,很容易被真正热爱探索的学生击溃。我觉得目前这类学习现象是挺值得注意的事实,还是需要分出精力去考虑锻炼学习理解能力以及自我驱动力等问题。PS:只要还保持学习,就都可以算是学生,人可以一辈子都是学生。一个人如果仅仅因为完成学校教育就停止再学习,他将注定成为一个毫无希望的平庸之人,不管他从事任何职业。 关于阅读: 阅读是好事,也是搭建成体系思维结构最佳的几种方式之一。但阅读并不简单,特别是那些厚重的经典大部头,阅读的过程往往是有压力的,越是面对这类书籍,越是需要放缓节奏,哪怕花一年去看都是值得的。阅读是需要挑选根基的,挑选一个领域中最权威最核心的书籍来扎根,后续所有的同领域的书籍就都是挑着阅读,因为同领域的书籍有部分内容是重复的,我们没必要一直看重复的内容(如果你已经理解了的话),后续书籍是用来深入某些细节或者对照印证的,集百家之所长,形成自己的风格。阅读还需要面对一个重要问题,很多书籍针对一些敏感的内容都是绕弯子去描述的,需要我们去理解作者的核心思想,从而反着看知识、侧着看想法。带着自己的思考去辩证的阅读,面对书籍中的知识先相信才能学进去,学进去就要针对知识去验证是否正确。 好的,以上是我在写该篇文章时,对简历、AI、努力、利他、学习、阅读的一些自己简单想法^_^,也不一定对,也许过段时间,我就迭代出新的想法。 最后欢迎大家按需选购我的书籍,详情信息如下所示。>_< ![2d2a605d60553884ce9329f037e8514b.jpg](https://pic.code-nav.cn/post_picture/1608488597789343746/6V6hv64s85xbuaRQ.webp)

下载 APP