跳到主要内容

画布、HTTP 与像素编辑器 — 画图、通信与界面架构

这一章讲三件事: 浏览器里画图的三条路线与各自的账; 页面怎么跟服务器说话(HTTP/fetch/表单/本地存储); 以及一个完整小应用怎么组织自己(像素编辑器)。 读完你能做选型、能发请求,还能说清「界面为什么该只有一个状态」。

1. 三条画图路线:改谁,决定选谁

上一章的 DOM 方块能走很远,但「在任意两点间画线」这类事用它做极其别扭1。替代品两条:

  • SVG:仍是 DOM——它保留形状的原始描述,随时可以移动、缩放;任意缩放都清晰,还能给每个形状挂事件;
  • canvas:一个元素、一支画笔——落笔即变像素,引擎不记得像素是什么;想挪,只能擦掉重画2

书末的选型总结值得抄在墙上:HTML 与文字配合最好;SVG 缩放不糊、逐元素可交互;canvas 每个形状的成本低,海量小元素、逐像素效果(光线追踪、后处理)只有它行;而且三者可以混搭(图用 canvas、文字用 HTML 叠上去);不苛刻的场合,选谁都行3

2. canvas 速成:属性驱动的接口

getContext("2d") 拿到绘图上下文;坐标系原点在左上,y 向下。最反直觉的一点:颜色线宽不是方法参数,而是上下文的属性——先设 fillStyle,再调 fillRect(10, 10, 100, 50)4

自定义形状走路径:beginPath → moveTo/lineTo/arc → stroke 或 fill。路径完全靠副作用累积,不是可以存起来的值;fill 时没闭合的形状自动补一条回起点的线(三角形只显式画两条边)5。曲线用控制点(quadratic 一个、bezier 两个),书里直言控制点常要试错6。文字有 fillText,字体、基线(字母站着的那条线)皆可调7

贴图与动画:九参数的 drawImage 能把「源图的一个矩形」贴到「画布的一个矩形」——书里的走路小人把 8 个姿势打包进一张图,每帧 cycle = (cycle + 1) % 8 取下一格,再配 clearRect 擦除,就是动画8

变换:translate/scale/rotate 改的是坐标系;变换会叠加,顺序有影响(先转再移≠先移再转);scale(-1, 1) 镜像。书里的 flipHorizontally 三连(平移到镜面→镜像→平移回来)是理解叠加的最佳练习;save/restore 则是变换的栈,递归画分形树全靠它——没有它们,第二条树枝会接在第一条最深处9

平台游戏的 canvas 显示器(第 15 章完整拆的那一层)每帧重画一切(像素没有「移动」可言),自己管视口(Math.max/min 钳制边界),而且不等贴图加载——对未加载的图 drawImage 直接什么都不做,前几帧缺图无所谓10

3. HTTP:页面与世界的合同

请求长这样:GET /18_http.html HTTP/1.1;方法有 GET(取)/ DELETE(删)/ PUT(造或换)/ POST(交信息)——服务器没义务照办,让它删首页多半会被拒;资源未必是文件,常是现场生成(github 的用户页就是查库现拼的)11。响应首行是状态码:2 开头成功,4 开头怪请求(404=找不到),5 开头服务器有错;随后是头(name: value),正文隔一个空行跟上——没有 Content-Type,浏览器不知道拿正文怎么办12

表单是把用户输入打包的原始机制:method="GET" 时每格字段(页面上的一格输入)的值拼成查询串挂在 URL 后(?name=Jean&message=Yes%3F),POST 则把它装进请求体。规矩:无副作用的用 GET,改变服务器状态的用 POST——浏览器敢擅自发 GET 预取,不敢擅自 POST13

浏览器里发请求的接口叫 fetch:返回 promise,落定成 Response。三个必须知道的脾气14:

  1. 服务器返回错误码时照样 resolve;只有网络层面失败才 reject——所以状态码要自己查;
  2. resp.text()/resp.json() 返回一个 promise:响应头一到就落定,正文还得等;
  3. 选项在第二个参数:{method, headers, body};DELETE 一个不许删的资源得 405,Range 头可以只取文档的一段。

同源策略:themafia.org 的脚本不许拿你浏览器的身份去请求 mybank.com——浏览器默认禁止跨域请求;服务器可以声明 Access-Control-Allow-Origin: * 主动放行15。设计接口时书里给了两个门派:RPC 派(远程调用函数,HTTP 只是载体)与资源派(PUT /users/larry + JSON 文档);后者能白吃 HTTP 的缓存等红利,书里明确偏爱它——「HTTP 的概念设计得很好,值得当服务器接口的设计原则」16。要保密防篡改,用 HTTPS:先验服务器身份(证书)、再加密整条连接;出过证书事故,但仍比明文安全得多17

表单与本地存储:字段的 DOM 接口各有一套——value(文本)、checked(勾选)、form.elements 既按序号又按 name 取;change 在「改完失焦」时发,input 每敲一次发一次;submit 事件上 preventDefault 即可接管提交(先校验,或用 fetch 静默发送)18。文件字段是个守门人:脚本无权读你机器上的文件,你在字段里选中文件才算授权;内容经 FileReader 异步读出(为 promise 诞生前设计的接口,可包成 promise)19。要让数据活过刷新,localStorage:按域名分仓、键值全为字符串、浏览器限额;sessionStorage 关浏览器即焚——书末的笔记应用示范了「setState(整体状态)→ 存 JSON」的最小架构20

4. 主走查:像素编辑器 —— 一个状态,一条通道

三个项目章里,这一章的架构最值得带走。需求:网格画板,选工具、换颜色、画、存、载、撤销。

4.1 铁律:状态只有一份

反例先摆出来:如果颜色散落在颜色字段里,取色工具就必须知道「颜色字段存在」并反过来改它;每加一处显示颜色的地方,就要多养一条同步线路——每个部件都得认识所有其他部件,小应用尚可,大项目是噩梦21。于是立规矩:

状态只存在一份;界面永远是状态的投影;事件不许直接改界面,只能发 action,由唯一的函数算出新状态,再让各组件 syncState。 书里的总结:状态变更必须走一条明确界定过的通道,不许四处发生22

4.2 不可变的数据底座

Picture 类存宽、高、像素一维数组(pixel(x, y)pixels[x + y * width]);draw() 不改自己——slice 拷贝、覆写、返回新 Picture。颜色是 #RRGGBB 十六进制串23

4.3 一次完整的画笔回合

状态:{tool: "draw", color: "#000000",
picture: 60×30 的 "#f0f0f0", done: [], doneAt: 0}

① 指针按下画布 → PictureCanvas 换算出像素坐标 (12, 5)
② 调当前工具 draw(pos, state, dispatch):
造 drawn = [{x:12, y:5, color:"#000000"}]
dispatch({picture: state.picture.draw([drawn])})
——并返回自己,拖动时对新像素重复
③ dispatch → historyUpdateState(state, action) 算新状态
④ 各组件 syncState:画布重绘、颜色框/工具框对表、撤销按钮换可用态

两个工具藏着妙处:rectangle 拖动时每次都从原始 state 重画——所以矩形可以拉大又缩回,中间痕迹不留(这正是不可变 Picture 的存在理由之一);fill 的「待探索列表」就是 drawn 数组本身,循环下标追着数组长度跑,追平即结束——和第 07 章寻路的 work list 是一家人24

4.4 撤销史:1 秒阈值

done 数组存旧版本,doneAt 记上次存档时间。规则:距上次存档不足 1 秒(1000 毫秒)的改动不入史(否则拖一笔产生上百条历史);undo 之后把 doneAt 清零,保证下一次改动一定被存回——于是 undo 一次后再画一笔,再 undo 还能回到刚才那张25

4.5 存与载的「怪路」

保存:把图画到 1:1 的离屏 canvas → toDataURL()(data: URL,整个资源塞进 URL)→ 造一个带 download 属性的链接、程序化点它、再删掉——「有时干活的路子就是这么怪」26。加载:程序化点一个隐藏的 file input → FileReader 读成 data URL → 贴到 <img>——但 img 的像素拿不到,只能再画到 canvas 上用 getImageData 读 RGBA(各 0-255),转成 #RRGGBB27

4.6 为什么这么难?

章末的慨然长叹值得原文级保留:浏览器技术既惊人又荒谬;默认编程模型糟到多数程序员宁愿盖好几层抽象也不直接面对;改进还多以「再加元素」的形式到来——越加越复杂;被百万网站用着的特性没法替换。作者的结论成熟而不愤世:技术不在真空中存在;理解它为何如此,比对着现实发怒更有生产力。他点名 React 与 Svelte 是写作时流行的界面库——本章手搭的「状态+action」正是它们的 crude 版28

5. 作者的判断与证据

  • 「SVG 留描述、canvas 只剩像素」:机制差异,由两章的实例互证;
  • 「fetch 对错误码照样 resolve」:API 事实,书内代码注释明示;
  • 「HTTP 概念设计得好」:作者的评价,给出理由(缓存等特性可白吃)16;
  • 「React/Svelte 流行」:写作时点的行业观察,书里自称是「小作坊产业」级别的多28

判断(我们的,不是书里的): 「状态只有一份 + action 单通道」在 2026 年看已是行业标准 (Redux/Zustand 一系),但这章真正超前的是用不可变数据让时间旅行(撤销)变成免费: done 数组就是历史快照栈。学任何状态库之前,先把这 60 行手写版吃透。 如果错,会错在: 高频更新场景(拖拽、动画)每笔都拷贝像素数组代价大,书里自己的练习 「只重绘变化的像素」就是在补这个洞——不可变不是免费午餐。

6. 边界与局限

  • WebGL/WebGPU(三维画图接口)只点名,书只讲 2d;
  • HTTP 的缓存机制、Cookie、认证流程未讲(只点了 ETag 一笔在下一章);
  • 表单校验(CSS/JS 原生校验)、FormData 未覆盖;
  • 像素编辑器无键盘快捷键、无触屏笔压——都在练习里。

7. 可带走的

  1. 选画图路线先问「改的是形状还是像素」:改形状用 SVG,改像素用 canvas;
  2. canvas 的颜色线宽在上下文属性上,不在方法参数里;
  3. 路径靠副作用累积;九参数 drawImage=源矩形→目标矩形,精灵图全靠它;
  4. 变换叠加且顺序敏感;save/restore 是变换的栈;
  5. HTTP:方法表 + 状态码三段制 + Content-Type 必填;
  6. fetch 对 4xx/5xx 也 resolve——状态码自己查;text()/json() 还要再等一拍;
  7. 同源策略防的是「别人的脚本用你的身份」;放行靠 CORS 头;
  8. 接口设计:资源+方法派能白吃 HTTP 的红利;
  9. change vs input;submit 上 preventDefault 接管;文件字段=用户授权的守门人;
  10. localStorage 按域分仓、全是字符串;状态只有一份、action 走单通道;
  11. 撤销史的 1 秒阈值:把「存历史」的粒度配到人的操作节奏。

8. 原文地图

主题原书章原文位置
DOM 做图不行、两条替代Drawing on Canvastext/20-fm-drawing-on-canvas.txt:11(搜「awkward」) · :13(搜「Scalable Vector」)
SVG 留描述 vs canvas 像素同上text/20-fm-drawing-on-canvas.txt:15(搜「converts the shapes to pixels」)
上下文与坐标同上text/20-fm-drawing-on-canvas.txt:45(搜「context」) · :56(搜「getContext」) · :63(搜「upper-left corner」)
属性驱动的样式同上text/20-fm-drawing-on-canvas.txt:71(搜「properties of the context」)
路径靠副作用同上text/20-fm-drawing-on-canvas.txt:92(搜「side effects」) · :109(搜「closed」)
曲线与试错同上text/20-fm-drawing-on-canvas.txt:129(搜「control point」) · :164(搜「trial and error」)
饼图与 fillText同上text/20-fm-drawing-on-canvas.txt:198(搜「pie chart」) · :227(搜「fillText」)
九参数 drawImage 与精灵同上text/20-fm-drawing-on-canvas.txt:263(搜「nine arguments」) · :285(搜「source rectangle」)
变换叠加与顺序同上text/20-fm-drawing-on-canvas.txt:317(搜「stack」) · :321(搜「order」)
flipHorizontally同上text/20-fm-drawing-on-canvas.txt:325(搜「flipHorizontally」)
save/restore 与分形同上text/20-fm-drawing-on-canvas.txt:356(搜「stack of transformation」) · :382(搜「not a tree」)
canvas 版游戏显示器同上text/20-fm-drawing-on-canvas.txt:424(搜「redraw the background」) · :493(搜「do nothing」)
选型总结同上text/20-fm-drawing-on-canvas.txt:555(搜「Plain HTML」) · :563(搜「lower cost per shape」)
HTTP 方法与义务HTTP and Formstext/21-fm-http-and-forms.txt:35(搜「not obliged」) · :37(搜「on the fly」)
状态码三段同上text/21-fm-http-and-forms.txt:47(搜「404」)
Content-Type 必要同上text/21-fm-http-and-forms.txt:57(搜「Content-Type」)
表单 GET/POST 与 URL 编码同上text/21-fm-http-and-forms.txt:77(搜「query string」) · :83(搜「URL encoding」) · :98(搜「side effects」)
fetch 的 resolve 脾气同上text/21-fm-http-and-forms.txt:115(搜「error code」) · :119(搜「text method」)
405 与 Range同上text/21-fm-http-and-forms.txt:135(搜「405」) · :139(搜「Range」)
同源策略与 CORS同上text/21-fm-http-and-forms.txt:148(搜「mybank」) · :154(搜「Access-Control」)
RPC vs 资源派同上text/21-fm-http-and-forms.txt:160(搜「remote procedure calls」) · :166(搜「well designed」)
HTTPS同上text/21-fm-http-and-forms.txt:174(搜「certificate」)
焦点与 tabindex同上text/21-fm-http-and-forms.txt:244(搜「activeElement」) · :263(搜「tabindex」)
提交拦截同上text/21-fm-http-and-forms.txt:313(搜「fetch」)
change vs input同上text/21-fm-http-and-forms.txt:344(搜「input event」)
文件守门人与 FileReader同上text/21-fm-http-and-forms.txt:431(搜「gatekeeper」) · :468(搜「readAsText」)
localStorage 与笔记应用同上text/21-fm-http-and-forms.txt:489(搜「localStorage」) · :525(搜「Notes」)
状态只有一份的论证Project: A Pixel Art Editortext/22-fm-project-a-pixel-art-editor.txt:25(搜「color field」) · :29(搜「strict about data flow」)
单通道总结同上text/22-fm-project-a-pixel-art-editor.txt:37(搜「single well-defined channel」)
组件接口同上text/22-fm-project-a-pixel-art-editor.txt:39(搜「dom property」)
Picture 不可变同上text/22-fm-project-a-pixel-art-editor.txt:69(搜「immutable」)
updateState 展开合并同上text/22-fm-project-a-pixel-art-editor.txt:79(搜「updateState」)
rectangle 从原状重画同上text/22-fm-project-a-pixel-art-editor.txt:310(搜「original state」)
fill 的工作列表同上text/22-fm-project-a-pixel-art-editor.txt:322(搜「visited」) · :338(搜「lags」)
撤销 1 秒阈值同上text/22-fm-project-a-pixel-art-editor.txt:455(搜「historyUpdateState」) · :465(搜「1000」)
保存的怪路同上text/22-fm-project-a-pixel-art-editor.txt:375(搜「rather odd」)
getImageData 读像素同上text/22-fm-project-a-pixel-art-editor.txt:416(搜「getImageData」)
为什么这么难同上text/22-fm-project-a-pixel-art-editor.txt:541(搜「ridiculous」) · :545(搜「vacuum」) · :547(搜「React」)

Footnotes

  1. 出处:「Drawing on Canvas」第 11 段(text/20-fm-drawing-on-canvas.txt:11,搜「awkward」)。

  2. 出处:「Drawing on Canvas」第 15 段(text/20-fm-drawing-on-canvas.txt:15,搜「converts the shapes to pixels」)。

  3. 出处:「Drawing on Canvas」第 557 段(text/20-fm-drawing-on-canvas.txt:557,搜「Plain HTML」)、第 561 段(text/20-fm-drawing-on-canvas.txt:561,搜「register mouse event」)、第 563 段(text/20-fm-drawing-on-canvas.txt:563,搜「lower cost per shape」)与第 567 段(text/20-fm-drawing-on-canvas.txt:567,搜「nondemanding」)。

  4. 出处:「Drawing on Canvas」第 71 段(text/20-fm-drawing-on-canvas.txt:71,搜「properties of the context」)与第 63 段(text/20-fm-drawing-on-canvas.txt:63,搜「upper-left corner」)。

  5. 出处:「Drawing on Canvas」第 92 段(text/20-fm-drawing-on-canvas.txt:92,搜「side effects」)与第 109 段(text/20-fm-drawing-on-canvas.txt:109,搜「closed」)。

  6. 出处:「Drawing on Canvas」第 129 段(text/20-fm-drawing-on-canvas.txt:129,搜「control point」)与第 164 段(text/20-fm-drawing-on-canvas.txt:164,搜「trial and error」)。

  7. 出处:「Drawing on Canvas」第 227 段(text/20-fm-drawing-on-canvas.txt:227,搜「fillText」)与第 239 段(text/20-fm-drawing-on-canvas.txt:239,搜「baseline」)。

  8. 出处:「Drawing on Canvas」第 263 段(text/20-fm-drawing-on-canvas.txt:263,搜「nine arguments」)与第 285 段(text/20-fm-drawing-on-canvas.txt:285,搜「sprite」)、第 293 段(text/20-fm-drawing-on-canvas.txt:293,搜「cycle」)。

  9. 出处:「Drawing on Canvas」第 317 段(text/20-fm-drawing-on-canvas.txt:317,搜「stack」)、第 321 段(text/20-fm-drawing-on-canvas.txt:321,搜「order」)、第 331 段(text/20-fm-drawing-on-canvas.txt:331,搜「mirrored universe」)与第 382 段(text/20-fm-drawing-on-canvas.txt:382,搜「not a tree」)。

  10. 出处:「Drawing on Canvas」第 424 段(text/20-fm-drawing-on-canvas.txt:424,搜「redraw the background」)、第 447 段(text/20-fm-drawing-on-canvas.txt:447,搜「Math.max」)与第 493 段(text/20-fm-drawing-on-canvas.txt:493,搜「do nothing」)。

  11. 出处:「HTTP and Forms」第 35 段(text/21-fm-http-and-forms.txt:35,搜「not obliged」)与第 37 段(text/21-fm-http-and-forms.txt:37,搜「on the fly」)。

  12. 出处:「HTTP and Forms」第 47 段(text/21-fm-http-and-forms.txt:47,搜「404」)与第 57 段(text/21-fm-http-and-forms.txt:57,搜「Content-Type」)、第 59 段(text/21-fm-http-and-forms.txt:59,搜「body」)。

  13. 出处:「HTTP and Forms」第 83 段(text/21-fm-http-and-forms.txt:83,搜「URL encoding」)与第 98 段(text/21-fm-http-and-forms.txt:98,搜「side effects」)。

  14. 出处:「HTTP and Forms」第 115 段(text/21-fm-http-and-forms.txt:115,搜「error code」)、第 119 段(text/21-fm-http-and-forms.txt:119,搜「text method」)与第 135 段(text/21-fm-http-and-forms.txt:135,搜「405」)、第 139 段(text/21-fm-http-and-forms.txt:139,搜「Range」)。

  15. 出处:「HTTP and Forms」第 150 段(text/21-fm-http-and-forms.txt:150,搜「other domains」)与第 154 段(text/21-fm-http-and-forms.txt:154,搜「Access-Control」)。

  16. 出处:「HTTP and Forms」第 164 段(text/21-fm-http-and-forms.txt:164,搜「resources and HTTP methods」)与第 166 段(text/21-fm-http-and-forms.txt:166,搜「well designed」)。 2

  17. 出处:「HTTP and Forms」第 174 段(text/21-fm-http-and-forms.txt:174,搜「certificate」)与第 176 段(text/21-fm-http-and-forms.txt:176,搜「forged」)。

  18. 出处:「HTTP and Forms」第 299 段(text/21-fm-http-and-forms.txt:299,搜「submit event」)、第 313 段(text/21-fm-http-and-forms.txt:313,搜「fetch」)与第 344 段(text/21-fm-http-and-forms.txt:344,搜「input event」)。

  19. 出处:「HTTP and Forms」第 431 段(text/21-fm-http-and-forms.txt:431,搜「gatekeeper」)与第 470 段(text/21-fm-http-and-forms.txt:470,搜「before promises」)。

  20. 出处:「HTTP and Forms」第 489 段(text/21-fm-http-and-forms.txt:489,搜「localStorage」)、第 498 段(text/21-fm-http-and-forms.txt:498,搜「compartments」)与第 556 段(text/21-fm-http-and-forms.txt:556,搜「sessionStorage」)。

  21. 出处:「Project: A Pixel Art Editor」第 25 段(text/22-fm-project-a-pixel-art-editor.txt:25,搜「color field」)与第 27 段(text/22-fm-project-a-pixel-art-editor.txt:27,搜「nightmare」)。

  22. 出处:「Project: A Pixel Art Editor」第 29 段(text/22-fm-project-a-pixel-art-editor.txt:29,搜「strict about data flow」)、第 33 段(text/22-fm-project-a-pixel-art-editor.txt:33,搜「actions」)与第 37 段(text/22-fm-project-a-pixel-art-editor.txt:37,搜「single well-defined channel」)。

  23. 出处:「Project: A Pixel Art Editor」第 57 段(text/22-fm-project-a-pixel-art-editor.txt:57,搜「pixel(」)与第 69 段(text/22-fm-project-a-pixel-art-editor.txt:69,搜「immutable」)。

  24. 出处:「Project: A Pixel Art Editor」第 310 段(text/22-fm-project-a-pixel-art-editor.txt:310,搜「original state」)与第 338 段(text/22-fm-project-a-pixel-art-editor.txt:338,搜「lags」)。

  25. 出处:「Project: A Pixel Art Editor」第 455 段(text/22-fm-project-a-pixel-art-editor.txt:455,搜「historyUpdateState」)与第 465 段(text/22-fm-project-a-pixel-art-editor.txt:465,搜「1000」)、第 477 段(text/22-fm-project-a-pixel-art-editor.txt:477,搜「guaranteed」)。

  26. 出处:「Project: A Pixel Art Editor」第 373 段(text/22-fm-project-a-pixel-art-editor.txt:373,搜「data:」)与第 375 段(text/22-fm-project-a-pixel-art-editor.txt:375,搜「rather odd」)。

  27. 出处:「Project: A Pixel Art Editor」第 398 段(text/22-fm-project-a-pixel-art-editor.txt:398,搜「pretend」)与第 416 段(text/22-fm-project-a-pixel-art-editor.txt:416,搜「getImageData」)、第 439 段(text/22-fm-project-a-pixel-art-editor.txt:439,搜「alpha」)。

  28. 出处:「Project: A Pixel Art Editor」第 541 段(text/22-fm-project-a-pixel-art-editor.txt:541,搜「ridiculous」)、第 545 段(text/22-fm-project-a-pixel-art-editor.txt:545,搜「vacuum」)与第 547 段(text/22-fm-project-a-pixel-art-editor.txt:547,搜「React」)。 2