0-introduction
用户界面难点 (User Interface Hard Parts) - 导论
- 本课程将探索计算机和技术体验中最重要的一部分:用户界面 (UI)。
- 用户界面是我们通过屏幕与之交互的方式:打字、点击、滑动、轻触,从而改变我们所看到的内容。
- 目标:从零开始构建对用户界面的理解。
UI 工程的两个核心目标
- 展示内容:在屏幕上显示内容,以便用户可以看到。
- 这个目标相对简单,大约 20 分钟内就能完成。
- 允许用户改变所见内容:让用户能够实际改变他们看到的东西。
- 这个目标极其困难 (profoundly difficult)。
UI 的重要性
- 用户界面是我们现代技术体验中至关重要的一部分。
- 当前我们正经历技术的“寒武纪大爆发 (Cambrian explosion)”:
- 技术的常态化:软件工程成为一切体验的中心。
- 例子:像摩根大通 (JP Morgan) 这样的公司也在大规模招聘工程师(例如,通过 Midwest 的 tech elevator 项目招聘了 600 名工程师)。技术正成为一切事物的核心。
- ChatGPT 的崛起:进一步推动了技术和软件数量的爆炸式增长。
- 技术的常态化:软件工程成为一切体验的中心。
- 结论:这将导致需要构建的用户界面数量大幅增加。我们需要更多屏幕来与技术互动、改变所见内容并改变技术状态。
课程内容概览:UI 工程的四个难点
- 第一部分:构建最小化用户界面 (Minimal UI)
- 理解如何在 Web 浏览器这个特别的、临时发展起来的应用创建环境中构建最基本的 UI。
- 需要理解 JavaScript 如何与 C++ 运行时(DOM 和事件 API 定义的地方)交互。
- 涉及 WebCoRE、WebIDL 等技术。
- 这是唯一一个接近“真理 (truth)”的部分。
- 第二部分:单向数据绑定 (One-Way Data Binding)
- 当 UI 规模显著增大时(例如,屏幕上成千上万个可交互内容),需要对代码编写方式施加限制,使开发更可预测、更简单。
- 引入单向数据绑定:限制用户改变所见内容的方式,只能通过改变底层数据,然后由一个单一函数将数据拉取并显示。
- 第三部分:虚拟 DOM (Virtual DOM)
- 在 JavaScript 中呈现页面内容的可视化表示 (visual representation)。
- HTML 本身非常直观,但一旦进入 JavaScript,代码与页面显示内容之间将不再直观对应。
- 构建一个能够反映页面实际内容的虚拟 DOM。
- 第四部分:性能优化 (Performance Optimization)
- 认识到前面所有改进都伴随着成本,主要是性能成本。
- 要构建可大规模持续的 UI,需要引入效率措施。
- 例如:状态钩子 (state hooks)、差异化算法 (diffing algorithm)(即使是基础版本),以确保只更新实际发生变化的部分。
核心理念:“Moves not truths”
- 除了第一部分(构建最小化 UI 是基础),课程后续内容(如单向数据绑定、虚拟 DOM 等)更多的是“权宜之计 (Moves)”而非“绝对真理 (Truths)”。
- 这些“Moves”是为了让开发者在构建复杂 UI 时工作更轻松、更可预测。
工程原则 (Principles of Engineering)
- 这些是 CodeSmith 招聘候选人的标准,也是优秀工程师的长期特质。
- 解决问题 (Problem Solving):
- 从零开始构建对 UI 工程底层心智模型的理解。
- 从而能够为日常工作中遇到的新挑战推导出解决方案。
- 技术沟通 (Technical Communication):
- 能够通过口头交流,让其他人理解并实现你的心智模型。
- 非技术沟通 (Non-technical Communication):
- 在讨论代码时表现出同理心和支持性。
- 认可他人的表达。
- 解决问题 (Problem Solving):
课程目标总结
- 在“UI 难点”课程中,我们将深入理解在 Web 浏览器中构建用户界面的底层机制。
- 这关乎我们的 JavaScript 和编程经验。
1-user-interface-dev-overview
第一部分:UI 难点 - 状态、视图、JavaScript DOM 和事件
UI 工程的两个核心目标
- 编写代码以显示内容 (Displaying Content)
- 例如:TikTok 的视频流、点赞数、用户头像、评论窗口、观看次数。
- 在内容创作部分,能够看到自己并进行编辑。
- 核心:所有显示的内容背后都有状态 (state) 或数据 (data) 支持。这些数据存在于计算机或手机中(可能来自互联网,但最终在设备上)。
- 目标:让用户能看到这些内容。
- 允许用户交互并改变所见内容 (Enabling User Interaction and Content Change)
- 例如:点击、滑动、在手机上输入文字。
- 这是最困难 (most difficult) 的部分,尽管听起来很直观。
- 人们常常低估这个领域的复杂性,因为在浏览器中显示内容本身可以非常直观。
Web 浏览器的发展历史和复杂性
- 问题所在:Web 浏览器临时拼凑 (ad hoc) 的发展历史导致在构建大规模应用(成千上万个显示内容)时面临巨大挑战。
- 历史背景:
- Web 浏览器已有约 30 年历史,始于 1991 年 CERN 的一个项目。
- 最初设计:作为一个带链接的文本文档查看器,比 PDF 查看器功能还弱。
- 如今期望:在这样的平台上构建视频编辑器等复杂应用。
- 复杂性的根源:
- 由大约 20 个不同开发团队在 30 多年间开发。
- 涉及 10 个不同的监管标准机构。
- 使用不同的运行时 (runtimes) 和 API。
- 挑战:需要将所有这些分散的部分整合起来,才能理解如何构建一个最基本的用户界面。
需要整合的技术
- JavaScript 运行时
- DOM (文档对象模型),通常用 C++ 实现
- Web CoRE
- WebIDL
- DOM API
- Event API
- 目标:如果能成功整合这些技术,就能构建出动态、交互式的完整 Web 应用,这些应用是所有现代体验的核心。
再次强调 UI 的两个核心目标
- 显示计算机内部的内容 (计算机的输出)。
- 让用户通过操作来改变这些内容。
UI 示例与交互
- Zoom 视频流:我们日常生活的一部分。
- 推文点赞:点击点赞数,数字从 7 变为 8 (讲者自嘲自己的推文通常是从 0 到 1)。
- 聊天评论:输入评论,评论内容显示出来,并且被记录下来。
- 关键点:行为 (action) -> 显示内容的变化 (change in what we see) -> 底层状态的变化 (change under the hood)。
- 马里奥游戏:点击马里奥,马里奥跳起来。
讨论:更多 UI 及其交互性示例
- Spotify 播放列表:
- 显示所有歌曲。
- 点击播放按钮,按钮变为暂停按钮,音乐开始播放。底层状态变为“正在播放”。
- 智能锁应用 (Smart Lock App):
- 在应用中锁定或解锁门。
- Gmail 撰写窗口:
- 点击撰写,弹出窗口,开始撰写邮件。
- Excel / Google Sheets:
- 包含成千上万个可显示和交互的内容片段。
- 可以点击单元格输入、高亮单元格、点击“文件”菜单、创建数据透视表等。
- 核心概念:用户看到内容,并能够改变它。
复杂性的来源
- 当屏幕上有成千上万个内容片段时。
- 每个片段都可以通过多种方式交互:双击、单击、滑动、拖动、鼠标移入/移出等。
- 这使得系统极其复杂 (profoundly complex)。
再次总结:UI 工程的两个目标
- 显示来自计算机内部的内容。
- 让用户能够改变这些内容。
2-display-content
目标 1:显示内容 (Display Content)
编程语言输出的本质
- 传统编程语言 (如 JavaScript) 的输出:
- 通常是在计算机内部保存数据 (saving data)。例如
let likes = 7,这在计算机底层是改变晶体管的开关状态 (0 和 1),是不可见的。 - 尽管我们可能在白板上将其可视化,但其本质并非视觉输出。
- 通常是在计算机内部保存数据 (saving data)。例如
- 用户界面工程 (UI Engineering) 的输出:
- 是视觉的 (visual),即屏幕上的像素 (pixels)。
- 屏幕上有大量的像素(例如,一个窗口可能有 200 万到 800 万像素),理论上每个像素点都需要我们编码定义。
挑战:逐像素编码的不可行性
-
设想:如果我们必须为屏幕上的每个像素编写代码来定义其位置和颜色。
-
例如,显示文本 "What's on your mind?" 的第一个字母 "W" 的第一个像素点:
pixel(x: 100, y: 900) // 假设语法 -
然后是第二个像素点:
pixel(x: 101, y: 899) // 假设语法 -
以此类推...
-
-
结论:这种逐像素编码的方式是完全不可行 (untenable) 的,尤其是在内容复杂、屏幕尺寸多样的情况下。
寻求更优方案:类比传统编程
- 传统编程中的抽象:
- 我们不需要直接操作内存地址(硬件层面的 0 和 1)。
- 有关键词(如
let)帮助我们声明变量,编译器/解释器负责分配内存。 - 有高级函数(如
sum,尽管 JavaScript 本身没有内置sum,但有reduce等)来简化操作。
- UI 开发中的类比:我们也需要类似的抽象机制,避免直接操作像素。
探索直观的 UI 描述方式
- 如果输出是文本 "What's on your mind?",最直观的代码是什么?
- 可能是直接写下
"What's on your mind?"
- 可能是直接写下
- 如果想显示一个输入框 (input field)?
- 可能是写下
input - 为了区分普通文本和特殊元素,我们可以使用标记,例如
<input>(使用尖括号)。
- 可能是写下
- 如果想显示一个按钮 (button) 并带有文本 "Publish"?
- 可能是
<button>Publish</button>
- 可能是
HTML:一种直观的 UI 描述语言
- 上述直观的描述方式正是 HTML (HyperText Markup Language) 的核心思想。
- HTML 允许我们以一种列表式 (list-like) 的方式,按顺序描述页面上应该出现的元素。
- 优点:
- 极其直观 (phenomenally intuitive):我们不需要关心具体的像素排列、布局计算。
- 声明式:我们声明“想要什么”,而不是“如何一步步实现”。
浏览器的工作机制:从 HTML 到像素
-
HTML 文件:我们创建一个
.html文件,用文本列出页面元素。What's on your mind? <input /> <button>Publish</button> -
解析 (Parsing):浏览器读取 HTML 文件,将其解析 (parse)。
-
构建文档对象模型 (DOM - Document Object Model):
- 浏览器根据解析的 HTML,在内存中(通常是用 C++ 实现)创建一个 文档对象模型 (DOM)。
- DOM 是一个简化的、对象化 (object-based) 的页面表示。它是一个树状结构(尽管这里为了简化,讲师将其比喻为列表或模型)。
- 这个模型包含了页面上的所有元素及其关系。
- 关键:我们通过编写 HTML 间接操作这个 C++ 实现的 DOM 列表/对象。
- 命名由来:
- Document:因为早期网页主要是文本文档和链接。
- Object:因为它是一个对象化的表示。
- Model:因为它是真实页面的一个模型/简化表示(类似乐高模型之于真实物体)。
-
布局和渲染引擎 (Layout and Render Engine):
- 浏览器内部的引擎(通常称为渲染引擎或布局引擎)获取 DOM。
- 该引擎负责计算每个元素在屏幕上的确切位置、大小、样式等(即布局 (layout))。
- 然后将这些计算结果转换为屏幕上的实际像素 (pixels) 并显示出来(即渲染 (render))。
- 这个引擎处理了所有复杂的像素级工作,适应不同的设备类型(平板、手机、笔记本)和窗口大小。
HTML 的价值
- 高度抽象:让我们从繁琐的像素操作中解脱出来。
- 关注内容结构:我们只需要关注页面应该有哪些元素以及它们的顺序。
- 跨平台/设备:浏览器引擎负责处理不同环境下的显示细节。
- 不要低估 HTML:尽管它看起来简单直观,但它是构建现代 Web 用户界面的基石。我们甚至在 Codesmith 都不专门教它,因为它太直观了。
总结:目标 1 的实现
- 通过编写 HTML,我们告诉浏览器我们想要在页面上显示什么内容。
- 浏览器通过其内部机制(解析器、DOM、渲染引擎)将这些声明转换为用户在屏幕上看到的视觉元素。
- 我们不需要直接与 C++ 或像素打交道,HTML 提供了必要的抽象。
3-rendering-html-under-the-hood
HTML 渲染的底层机制
回顾:DOM - 页面内容的模型
- DOM (Document Object Model):一个在 C++ 中实现的元素列表 (或更准确地说是对象),作为网页内容的模型。
- 我们通过编写 HTML 代码,指示浏览器向这个 C++ 列表(DOM)中添加元素。
- 这些元素随后会自动通过布局 (Layout) 和渲染 (Render) 引擎显示在网页上。
WebCore:核心渲染技术
- 布局引擎 (Layout Engine):
- 分析如何在特定的浏览器视图、屏幕和设备上放置我们列出的元素。
- 例如,当窗口缩小,文本需要换行时,布局引擎会计算新的布局。
- 渲染引擎 (Render Engine):
- 确定最终的像素。
- 其输出是一个位图图像 (bitmap image),该图像会以高速率(例如,每秒 60 次,约每 16 毫秒一次)发送到显卡。
- WebCore:这一整套技术(存储 C++ 元素列表、加载到网页、将列表转换为像素)的总称。
- 我们不直接编写 C++,而是使用直观的 HTML 来按顺序列出页面内容。
HTML 的直观性与设计
- “所思即所写”:想在页面上添加文本,就直接写下文本。想在下方添加一个框,就在下一行写下该框的名称。
- 这种“无需思考的设计 (thought-free design)”是非常强大的。
逐步构建 DOM
- 第一行 HTML:
What's on your mind?- 向 DOM 列表添加第一个元素:文本节点 "What's on your mind?"。
- 页面上相应显示该文本。
- 澄清:严格来说,纯文本也会被包裹在一个文本节点(text node)内。
- 第二行 HTML:
<input>- 向 DOM 添加一个
input元素 (或称为节点 node)。 - 页面上显示一个输入框。
- 术语:HTML 中的
<input>等称为标签 (tags),在 DOM 中对应的称为节点 (nodes) 或元素 (elements)。
- 向 DOM 添加一个
- 第三组 HTML:
<button>Publish</button>- 优雅设计:如果想在按钮内部显示文本,HTML 的设计非常直观——将文本直接写在
<button>和</button>标签之间。 - DOM 结构:
- 添加一个
button节点。 - 按钮节点内部会有一个嵌套的文本节点 "Publish"(可以理解为子元素、子数组成员或子对象引用)。
- 添加一个
- 页面显示:一个包含 "Publish" 文本的按钮。
- 优雅设计:如果想在按钮内部显示文本,HTML 的设计非常直观——将文本直接写在
- 结构化页面:
<div>- 需求:将页面划分为不同区域,方便组织内容结构,而不是一个扁平的元素列表。
- 实现:使用
<div>(division) 标签。 - 将其他元素(如视频、段落)放置在
<div>和</div>之间,这些元素在 DOM 中会成为div节点的子元素。 - 页面上,这些元素会显示在由
div定义的区域内(尽管div本身默认不可见,除非添加样式或内容)。
- 嵌套示例:一个包含视频和文本的
div- HTML 结构:
<div> <video src="carpool-karaoke.mp4"></video> <p>Love Les Mis</p> <p>Heart seven</p> </div> - DOM 结构:
- 一个
div节点。 div节点下有三个子节点:- 一个
video节点 (包含src信息,指向视频文件)。 - 一个
p(paragraph) 节点,包含文本 "Love Les Mis"。 - 另一个
p节点,包含文本 "Heart seven"。
- 一个
- 一个
- 页面显示:一个区域,里面有视频播放器和两段文字。
- HTML 结构:
HTML 渲染流程总结
- 编写 HTML:使用文本格式,按顺序和结构(通过嵌套)列出想要添加到 C++ DOM 列表中的元素。
- DOM 构建:浏览器解析 HTML,构建 DOM。DOM 的顺序和结构由 HTML 代码的顺序和嵌套决定。
- 布局引擎:根据设备类型和窗口大小,计算出元素的确切结构和位置。
- 渲染引擎:生成一个合成图像 (composite image),将所有元素组合起来,将其栅格化 (rasterize)(转换为像素列表),然后高频率地发送到显卡进行显示。
HTML 的特性:“半可视化代码 (Semi-Visual Code)”
- HTML 代码本身在某种程度上直观地反映了页面的结构和内容。
- 它创建了一个页面的模型 (DOM),这个模型随后被渲染成实际的像素。
- 目标 1(显示内容)完成:我们能够创建元素,添加内容,并通过 HTML 文件中的位置决定其在页面上的位置。
讨论与问答
- 布局引擎 vs. 渲染引擎:
- 布局引擎:负责定位和尺寸计算(例如,窗口缩小时文本如何环绕)。
- 渲染引擎:负责实际生成位图。
- 复杂性:渲染引擎是浏览器中非常大且复杂的部分,这也是为什么主流浏览器引擎数量很少的原因之一。它们需要处理各种设备类型、历史遗留问题和旧版浏览器兼容性。构建一个全新的渲染引擎极其困难。
- 文本节点:
- 即使是纯文本,在 DOM 中通常也会被一个实际的文本节点所包裹。
- HTML 的“宽容性 (Forgiving Nature)”:
- 现象:浏览器通常不会因为 HTML 中的小错误而停止渲染或抛出明显错误。
- 原因探讨:
- HTML 的设计初衷是成为一种非常直观的、用于显示内容的最小化语言。
- 它本身更像是一个一次性的元素列表声明,而不是一个持续运行的程序。因此,复杂的错误处理机制可能不是其核心设计。
- 错误在哪里显示也是个问题,因为 HTML 本身没有像 JavaScript 那样的执行时环境和控制台日志系统(控制台错误通常由 JavaScript 调用)。
- 可以想象在 HTML 加载到浏览器之前使用解析器检查语法错误,但 HTML 本身没有“运行时错误”的概念。
- 这种宽容性也暗示了为什么我们不直接用 HTML 构建复杂的交互式应用,而需要转向其他技术。
4-cssom-for-styling
CSS (Cascading Style Sheets) - 为内容添加样式
CSS 的作用
- 定义元素外观:允许我们指定页面上 HTML 元素的具体视觉样式(例如颜色、字体、大小、布局细节)。
- 功能强大且复杂:CSS 是一种非常强大且复杂的语言,可以实现精细的视觉控制。
- 不仅仅是样式:
- CSS 甚至可以用来存储状态 (state),例如通过
display: none来控制元素的显示或隐藏。 - 潜在问题:这会引入另一个可以描述用户所见内容的地方,与“单一数据源”原则相悖,可能导致维护困难。当 UI 应用中最大的挑战是保持底层数据与用户所见内容的一致性时,这一点尤为重要。
- 类比:像一个不断添加新标准的委员会,CSS 试图解决很多问题,有时会与其他技术功能重叠。
- CSS 甚至可以用来存储状态 (state),例如通过
如何将 CSS 应用到 HTML
- 不是直接加载:不像某些语言或环境那样直接集成。
- 通过 HTML 链接:在 HTML 文件中,使用
<link>标签来引用一个外部的 CSS 文件(样式表 stylesheet)。<link rel="stylesheet" href="styles.css" /> - 这个
<link>标签本身也会成为 DOM(C++ 元素列表)中的一个元素,但它在页面上是不可见的。它的作用是触发 CSS 引擎。
CSSOM (CSS Object Model) - CSS 的对象模型
- CSS 解析器 (CSS Parser):
- 当浏览器遇到
<link>标签并加载 CSS 文件后,CSS 解析器会读取 CSS 代码。
- 当浏览器遇到
- 构建 CSSOM:
- 与 DOM 类似,浏览器会在内存中(同样通常是 C++ 实现)创建一个CSS 对象模型 (CSSOM)。
- CSSOM 也是一个对象结构(可以将其简化理解为一个列表或树),它包含:
- 规则列表 (List of Rules):所有在 CSS 文件中定义的样式规则。例如,“所有按钮的背景色为 slateblue”。
- 页面模型的镜像 (Mirror of the Page Model):一个与 DOM 结构类似(或完全对应)的结构,包含了页面上的所有元素,并关联了计算后的样式。
- 例如,如果 CSS 中有
button { background-color: slateblue; },那么 CSSOM 中与按钮对应的部分会记录这个样式信息。
渲染流程结合 CSSOM
- 布局和渲染引擎:现在会同时分析 DOM (结构和内容) 和 CSSOM (样式)。
- 最终显示:引擎结合这两者的信息,共同决定最终在屏幕像素上显示什么内容和外观。
完成目标 1:显示内容
- 通过 HTML(结构和内容)和 CSS(样式),我们已经完成了 UI 工程的第一个核心目标——显示内容。
- 尽管这种方式(HTML 链接 CSS,两种不同的语言和模型)在某些人看来可能有些“古怪”或“临时拼凑 (ad hoc)”,尤其与其他从零开始设计的 UI 开发环境(如 Apple 的 Swift)相比。
- Web 浏览器的特殊性:
- Web 浏览器技术栈并非由单一实体掌控,而是由多个监管机构、不同的浏览器团队在过去 30 年中逐步发展和演变而来。
- 这导致了我们现在需要将这些不同时期、不同设计哲学的部分组合在一起使用。
总结
- CSSOM 是浏览器内部对 CSS 样式的一种对象化表示。
- 浏览器结合 DOM 和 CSSOM 来渲染最终的网页视觉效果。
- 至此,我们已经理解了如何实现“显示内容”这一目标。
5-enabling-change-of-content
目标 2:允许用户改变内容 (Enabling Change of Content)
交互的核心:数据改变
- 用户交互:用户点击内容(如“发布”按钮、点赞数从 7 变到 8)并期望看到变化。
- 变化的本质:这种视觉上的变化必须对应底层数据的改变。否则,如果只是表面上的像素变化,而实际数据未变,就会产生不一致。
- 常见的误解:
- 用户在屏幕上看到数字 7 变成 8,会直观地认为“我看到了数据在变化”。
- 这是一种由 UI 工程师精心营造的“假象”。
视觉变化 vs. 数据变化
- 屏幕上的像素:
- 例如,数字“7”在屏幕上可能由大约 30 个亮着的像素点组成。
- 这些像素点本身没有“我是数字 7”的概念。它们只是一堆特定排列的光点。
- 当数字“7”变成“8”,实际上是一组 30 个像素点的排列变成了另一组 30 个像素点的排列。
- 无法直接操作像素进行算术运算:你不能给一堆像素“加 1”来让它从“7”的形状变成“8”的形状。
- 现实世界的直觉:
- 从孩提时代起,我们学习到“所见即所得 (what I see is what I get)”。我看到一支笔,我触摸它,它移动了,我看到的和真实存在的物体是一致的。
- 认识论 (epistemic - 我所知道的) 与 形而上学 (metaphysical - 真实存在的) 在物理世界中是统一的。
- 计算机世界的差异:
- 屏幕上看到的像素不是底层数据。
- 底层数据存储在计算机内存中(例如,变量
likes = 7)。 - UI 工程师的工作就是模拟现实世界的“所见即所得”,尽管在计算机中这两者是分离的。
交互的真正流程
- 用户操作:用户点击屏幕上的某个区域(例如,代表“马リオ”的像素区域)。
- 消息传递:点击事件被捕获,并发送一个消息到某个地方。
- 数据改变:这个消息触发了对底层数据的修改(例如,马里奥的高度从 10 单位增加到 11 单位)。
likes = 7执行likes = likes + 1变成likes = 8。
- 重新渲染:修改后的数据被用来重新计算并显示新的像素排列(例如,马里奥的像素现在显示在更高一点的位置,数字“7”的像素排列变成数字“8”的像素排列)。
结论:用户看到的任何内容变化,都必须有相应的底层数据变化。像素本身无法“改变”自己。
HTML 和 DOM 的局限性
- HTML 的一次性显示:
- HTML 文件在加载时被浏览器一次性解析,用于构建初始的 DOM。
- 浏览器不会返回到 HTML 文件去重新读取或修改它。
- 因此,HTML 本身不能用于动态改变已显示的内容。
- DOM (C++ 实现) 的数据存储:
- DOM 是一个在 C++ 中实现的真实数据结构(元素列表/对象),它确实存储了页面的内容。
- 当用户在输入框中输入文字(例如,从空字符串变为 "Hi"),DOM 中对应元素的数据(例如,其
value属性)确实会改变。
- DOM (C++ 实现) 的不可编程性 (从外部):
- 关键问题:我们不能直接在浏览器中编写 C++ 代码来操作或响应 DOM 中数据的变化。
- 如果我们可以直接用 C++ 操作 DOM,很多事情会更简单(但也更危险)。
- 原因:
- 浏览器通常使用 C++ 这类底层语言来实现其核心功能(如解析、渲染)。
- 但它们通常提供更高层次的语言(如 JavaScript)供开发者使用。
- 最初设计 Web 浏览器时,并未预料到它会发展成构建高度复杂、交互式应用的平台。它们被设想为主要显示文本和链接的工具。因此,C++ DOM 最初设计为主要通过 HTML 一次性填充。
为什么需要 JavaScript
- HTML:一次性添加元素到 DOM。
- DOM (C++):存储数据,但我们不能直接用 C++ 编写逻辑来改变它或基于其变化执行操作。
- 解决方案:我们需要一种能够在浏览器环境中运行的编程语言来:
- 创建和保存内容/数据/状态。
- 运行代码来改变这些数据。
- 响应用户交互,并根据交互更新数据和视图。
- JavaScript:正是为此而生。
- 我们可以使用 JavaScript 来创建、存储和修改数据(通常称为状态 (state))。
- 状态 (State):泛指所有描述当前显示内容的信息(例如,输入框的文本、点赞数、单元格是否被选中等)。
结论:要实现目标 2(让用户能够交互并改变他们看到的内容),并且能够对这些改变执行逻辑,我们必须使用 JavaScript。我们不能依赖 HTML 的一次性加载,也不能直接编程操作 C++ 实现的 DOM。JavaScript 为我们提供了在浏览器中动态管理数据和响应交互的必要能力。
6-storing-data-in-javascript
JavaScript:在浏览器中存储和操作数据
JavaScript 在浏览器中的角色
- 浏览器环境:Web 浏览器几乎像一个操作系统,为 JavaScript 提供了丰富的功能。
- 文件加载流程:
- 浏览器直接打开的唯一文件类型是 HTML 文件 (例如
app.html)。 - HTML 解析器 (HTML Parser):逐行解释和读取 HTML 文件。
- HTML 解析器将元素添加到 C++ 实现的 DOM 列表中。
- 浏览器直接打开的唯一文件类型是 HTML 文件 (例如
- 引入 JavaScript:
- HTML 文件可以通过
<script>标签链接到一个 JavaScript 文件 (例如<script src="app.js"></script>)。 - 这个
<script>标签本身也会成为 DOM 中的一个节点(例如script节点,链接到app.js),但它在页面上通常不可见。 - 当浏览器遇到
<script>标签时,会启动 JavaScript 引擎 (JavaScript Engine) 来执行该文件中的代码。
- HTML 文件可以通过
JavaScript 引擎的核心能力
- 数据存储 (Memory):JavaScript 引擎有自己的内存空间来存储数据(变量、对象等),这些数据是可以被改变的。
- 执行线程 (Thread of Execution):JavaScript 引擎有一个执行线程,可以逐行执行代码。
- 调用栈 (Call Stack):用于跟踪当前正在执行的函数。当一个函数开始执行时,它会被添加到调用栈的顶部;执行完毕后,会从栈顶移除。
- JavaScript 代码运行时,最初会处于全局执行上下文 (Global Execution Context),可以将其视为一个名为
global的顶层函数。
- JavaScript 代码运行时,最初会处于全局执行上下文 (Global Execution Context),可以将其视为一个名为
JavaScript 语言特性
- 基础但强大:JavaScript 本身是一门相对基础的语言,但因其在浏览器环境中的能力而变得非常强大。
- 核心操作:查看数据、运行代码以改变数据,这些操作在执行上下文 (Execution Contexts) 中进行,执行上下文会管理其作用域内的数据。
- 关键特性:
- 异步性 (Asynchronicity):函数可以被保存并在稍后(例如,响应用户操作时)以非定义顺序执行。
- 闭包 (Closure):当这些异步函数执行时,它们仍然能够访问其定义时所在作用域中的数据(“背包”特性)。
JavaScript 与浏览器功能的交互 (Web APIs)
console对象:console不是 JavaScript 语言本身的一部分,而是浏览器提供的一个功能 (属于 DevTools 的一部分)。- 当 JavaScript 在浏览器中加载时,其全局内存中会自动填充一个名为
console的对象。 console对象包含许多方法,如log(),error(),table(),time(),dir()。- 调用这些方法(例如
console.log("Hi!"))时,主要的工作不是在 JavaScript 引擎内部完成,而是通过一个隐藏的链接 (hidden link) 与浏览器的控制台功能交互,从而在控制台中显示输出。 - 这些被称为 Web Browser APIs (Web API):浏览器提供的、可以通过 JavaScript 接口进行交互的功能。
document对象:- 与
console类似,当 JavaScript 在浏览器中加载时,会自动获得一个名为document的全局对象。 document对象也包含一个隐藏的链接,这个链接指向 C++ 实现的 DOM (页面模型)。- JavaScript 数据类型限制:JavaScript 本身没有“指向外部浏览器功能的链接”这种直接的数据类型。它能存储的是数字、字符串、布尔值、对象、数组等。因此,这种与外部功能的链接通常通过在 JavaScript 对象上设置隐藏属性来实现。
document对象充满了方法(例如querySelector()),这些方法在执行时,会通过其隐藏链接与 C++ 中的 DOM 进行交互,从而允许 JavaScript 读取或修改 DOM。- 意义:这使得 JavaScript 能够访问和操作 C++ 实现的页面模型,从而动态地改变用户看到的内容。
- 与
总结 JavaScript 的作用
- 数据和代码的唯一场所:在浏览器中,要实现动态的数据存储和代码执行来改变这些数据,JavaScript 是唯一的选择。
- 完整的运行时环境:通过
<script>标签引入的 JavaScript 文件在一个完整的运行时环境中执行,该环境包括:- 执行代码的线程。
- 存储数据的内存。
- 支持异步函数执行的能力。
- 目标:利用 JavaScript 中可变的数据和可执行的代码,与 DOM(C++ 列表)交互,因为任何添加到 DOM 的内容都可能被渲染引擎显示为页面上的像素。
7-webidl-webcore
WebIDL 和 WebCore:连接 JavaScript 与浏览器内部功能
背景:如何在 JavaScript 中显示数据?
我们已经在 JavaScript 中存储了数据(例如 let post = "Hi!";),但如何将其显示在页面上呢?这需要 JavaScript 与浏览器的核心部分(特别是 DOM)进行交互。
核心概念
- WebCore (或类似的浏览器核心):
- 这是浏览器内部实现其核心功能的地方,通常使用 C++ 编写。
- DOM(页面模型,一个 C++ 的元素列表)就存在于 WebCore 中。
- WebIDL (Web Interface Description Language):
- 作用:一种标准化的接口描述语言。它定义了浏览器不同功能模块之间(特别是 JavaScript 与其他 Web API 之间)如何交互。
- 标准化格式:为不同的团队(构建 DOM、JavaScript 引擎、控制台、Web Audio API 等的团队)提供了一个统一的方式来描述他们的功能如何被其他部分访问。
- 例如:
document.querySelector()方法的行为就是通过 WebIDL 来描述的。DOM API(描述如何访问 DOM 元素)本身也是用 WebIDL 来定义的。 - 结果:使得 JavaScript 能够“跨越”其运行时环境,与浏览器的其他特性进行通信。
如何区分 JavaScript 内建功能与 Web API?
- MDN (Mozilla Developer Network):通常是查找此类信息的首选资源。
- Web Browser APIs 列表:MDN 上会列出所有 Web API。如果一个功能(如
document、console、定时器setTimeout/setInterval)被列为 Web API,那么它就不是纯粹的 JavaScript 引擎内部功能,而是浏览器提供的额外能力。 - JavaScript 核心功能:主要围绕数据存储、代码执行(函数、操作符)以及异步特性(闭包等)。
- 浏览器的强大:JavaScript 之所以如此流行和强大,很大程度上是因为它能作为“控制面板”来操作浏览器提供的几乎构成一个操作系统的庞大功能集(例如 Chromium 引擎被用于构建像 Spotify、Slack、Atom 等桌面应用)。
示例:使用 JavaScript 操作 DOM
场景: HTML 结构:
<input />
<div></div>
<script src="app.js"></script>
JavaScript (app.js):
let post = "Hi!";
const jsDiv = document.querySelector("div");
// ... 更多操作
逐步解析:
- HTML 解析与 DOM 构建:
- 浏览器解析 HTML。
input元素被添加到 C++ 实现的 DOM 列表中,并在页面上渲染出一个输入框。div元素被添加到 DOM 列表中,并在页面上渲染(初始可能不可见或无内容)。script元素被添加到 DOM 列表中,触发 JavaScript 引擎执行app.js。- 注意:
<script>标签在 HTML 中的位置决定了 JavaScript 代码何时开始执行,这是一个历史遗留的特性。
- 注意:
- JavaScript 执行:
- 全局内存和调用栈:JS 引擎开始执行代码。
let post = "Hi!";:在 JS 内存中声明post变量并赋值 "Hi!"。
document.querySelector('div')详解:document对象:JavaScript 全局作用域中存在一个document对象,它有一个指向 C++ DOM 的隐藏链接(可以理解为一个 C++ 指针,指向内存中 DOM 列表的确切位置)。- 调用
querySelector方法:- 查找隐藏链接:
querySelector方法内部首先访问document对象上的隐藏链接,定位到 C++ DOM。 - 在 DOM 中查询:它根据传入的选择器(这里是
'div')在 DOM 树中搜索匹配的第一个元素。 - 返回结果:
- 不能直接返回 C++ 对象:JavaScript 不能直接存储或操作原生的 C++ 对象。
- 创建 JavaScript 包装对象:
querySelector会在 JavaScript 中创建一个新的包装对象 (wrapper object)。 - 这个新的 JavaScript 对象 (
jsDiv将引用的对象):- 内部也包含一个隐藏链接,指向在 C++ DOM 中找到的那个
div元素。 - 根据 WebIDL 的定义,这个对象会被填充一系列的方法(如
textContent,innerHTML,setAttribute等),这些方法允许 JavaScript 间接地读取或修改那个被链接的 C++ DOM 元素。
- 内部也包含一个隐藏链接,指向在 C++ DOM 中找到的那个
- 查找隐藏链接:
const jsDiv = ...:jsDiv现在引用了这个新创建的 JavaScript 包装对象。
jsDiv的本质:jsDiv是一个 JavaScript 对象。- 它拥有一个指向真实 DOM 节点 (在 C++ 中) 的“遥控器”。
- 它上面的方法(如
jsDiv.textContent = ...)会通过那个隐藏链接去操作远端的 C++ DOM 元素。
console.log(jsDiv) 的“误导性”行为
- 期望:我们可能会期望
console.log(jsDiv)显示一个包含隐藏链接和一堆方法的 JavaScript 对象。 - 实际显示:浏览器控制台为了“帮助”开发者,通常会显示类似创建该 DOM 元素的 HTML 标签,例如
<div></div>。 - 为什么“误导”:
<div></div>(HTML 标签) 是一个命令,告诉浏览器去创建一个 DOM 元素。jsDiv实际上是一个 JavaScript 对象,它引用的是执行该命令后在 C++ 中创建的结果 (DOM 元素对象),并通过一个包装对象提供接口。- 控制台显示的是“输入的命令”而不是“命令执行后的、被引用的实际对象(的 JS 包装)”。
- 对比:
console.log(add(1, 2, 3))会打印6(评估结果),而不是打印add(1, 2, 3)(命令本身)。 - 原因:这是控制台设计者为了方便开发者快速识别 DOM 元素而做出的选择,尽管这在技术上是对实际情况的一种简化和变形表示。
总结
- WebCore 包含了 C++ 实现的 DOM。
- WebIDL 定义了 JavaScript 如何与 WebCore (及其他浏览器功能) 交互的接口。
- JavaScript 通过
document对象和其方法(如querySelector)来获取对 DOM 元素的引用。 - 这些引用实际上是 JavaScript 包装对象,它们内部有指向真实 C++ DOM 元素的链接,并提供了方法来操作这些元素。
- 浏览器控制台对这些包装对象的显示方式是为了开发者友好,但可能与对象的实际内部结构不完全一致。
8-updating-dom-elements-with-javascript
使用 JavaScript 更新 DOM 元素
回顾:jsDiv 的真实面目
jsDiv(通过document.querySelector('div')获取) 不是 HTML 字符串<div></div>。- 它是一个 JavaScript 对象,内部有一个指向 C++ DOM 中真实
div元素的隐藏链接,并且拥有一些方法(如textContent)来操作这个 DOM 元素。
通过 jsDiv.textContent = post 更新 DOM
场景: JavaScript 中:
let post = "Hi!"; // post 变量的值是字符串 "Hi!"
const jsDiv = document.querySelector("div");
jsDiv.textContent = post; // 将 post 的值赋给 jsDiv 的 textContent 属性
解析 jsDiv.textContent = post;:
post的值:字符串"Hi!"。jsDiv.textContent的特殊性:- 如果
textContent是jsDiv对象上的一个普通 JavaScript 属性,那么jsDiv.textContent = "Hi!"只会在jsDiv这个 JavaScript 对象内部创建一个名为textContent的键,并将其值设为"Hi!"。这对更新页面上的 DOM 元素毫无用处。 - 实际上,
textContent是一个“访问器属性 (accessor property)”或更通俗地称为“getter/setter 属性”。这意味着当你给它赋值时,它并不仅仅是简单地存储值,而是会执行一段预定义的代码。
- 如果
textContentsetter 的工作流程:- 访问隐藏链接:setter 代码首先会访问
jsDiv对象内部的那个隐藏链接,找到它所指向的 C++ DOMdiv元素。 - 设置 DOM 元素的文本内容:然后,它会调用相应的 C++ 内部函数,将这个 DOM
div元素的文本内容设置为"Hi!"。 - 页面更新:由于 DOM 发生了变化,浏览器的渲染引擎会检测到这个变化,并重新绘制页面的相关部分,最终用户会在页面上看到
div元素中显示出 "Hi!"。
- 访问隐藏链接:setter 代码首先会访问
结果:我们成功地将 JavaScript 中的数据 ("Hi!") 显示在了页面上。
JavaScript vs. HTML:复杂性与控制力
- HTML 的简洁直观:要在 HTML 中直接显示 "Hi!" 在一个
div里,只需要写<div>Hi!</div>,非常简单。 - JavaScript 的“迂回”:通过 JavaScript 实现同样的效果,需要:
- 在 JS 中存储数据。
- 获取 DOM 元素的 JS 包装对象。
- 通过包装对象的特定属性(背后是 setter 方法)来修改实际的 DOM 元素。 这个过程涉及了多个步骤和概念(JS 运行时、C++ DOM 运行时、隐藏链接、访问器属性)。
- JavaScript 的优势:尽管更复杂,但 JavaScript 提供了对 DOM 元素进行细粒度编辑控制 (fine-grained editing control) 的能力。我们可以动态地改变数据,并根据这些改变来更新页面,这是静态 HTML 无法做到的。
理解底层模型的重要性
- “是否需要知道这么多?”:虽然将内容显示到页面上的过程在 JavaScript 中显得复杂,但理解这个底层模型(JS 运行时、DOM 运行时、它们之间的交互)是至关重要的。
- 模型的一致性:一旦掌握了这个模型,它将贯穿 UI 开发的始终。后续的框架和库(如 React、Vue、Angular、Svelte)都是在这个基础上构建更易用的抽象。
- 数据分离的挑战:
- 我们的数据存储在 JavaScript 运行时中。
- 而持久化显示这些数据并直接映射到页面像素的是 C++ DOM 运行时。
- 这两个运行时是分离的,我们需要手动地通过 JS 代码将 JS 中的数据变化同步到 DOM 上。
- 当用户与页面交互导致 DOM 数据变化时(例如在输入框输入),这个变化直接发生在 DOM 中,如果 JS 中也维护了这份数据的副本,那么还需要将 DOM 的变化同步回 JS,或者确保只有一个“真实数据源 (single source of truth)”。
- 这种分离是许多 UI 开发复杂性的根源,也是为什么我们需要建立严格的规则和模式(如状态管理)来应对。
- 浏览器的“误导”:浏览器的一些行为(如
console.log一个 DOM 包装对象时显示其 HTML 标签)可能会掩盖这种分离,使得理解真实情况更加困难。
关于调用栈 (document.querySelector 是否在调用栈上?)
- 技术上是的:
document.querySelector是一个 JavaScript 函数,所以在它执行时,它会被推入调用栈。 - 概念上的区分:
- 讲师倾向于将调用栈主要视为我们自己编写的、可以修改其内部逻辑的函数。
- 像
document.querySelector这样的 Web API 函数,其大部分工作(如访问 C++ DOM)发生在 JavaScript 引擎之外。 - 区分 JavaScript 代码的执行与它调用浏览器底层功能(C++ 实现)是很重要的。
多个 <script> 标签与 JavaScript 运行时
- 问题:多个
<script>标签是否共享同一个 JavaScript 运行时、内存空间等? - 回答:这个问题将在后续讨论构建自定义虚拟 DOM 时从“为什么”的角度来解释。重点是按需引入知识点,而不是为了纯粹的学术探究。
学习这些底层知识的目的
- 不仅仅是智力满足:虽然理解这些很酷。
- 理解高级 UI 框架:深入理解 React、Vue、Angular、Svelte 等框架的内部工作原理。
- 调试和大规模应用:能够更有效地调试问题,并在大型应用中更好地使用这些框架。
- 构建自定义方案:有能力(理论上)构建自己的类似实现(例如自定义虚拟 DOM)。
- 面试优势:能够清晰地阐述这些底层模型,展示出对 Web 工作方式的深刻理解,这对于高级职位面试非常有价值。
9-displaying-data-summary
显示数据:总结与反思
核心回顾:JavaScript 如何访问和更新 DOM
- WebCore 与 WebIDL 的角色:
- WebCore (浏览器核心,通常 C++) 包含 DOM (文档对象模型),DOM 最终决定了页面上显示的像素。
- WebIDL (Web 接口描述语言) 定义了 JavaScript 如何与 DOM API (以及其他 Web API) 交互的规范。
- 结果:JavaScript 获得了访问和操作 DOM 的能力。
- 目标一:向用户显示内容/数据
- 使用 HTML:
- 非常直接和快速。例如,
<div>Hi!</div>就能在页面上显示 "Hi!"。 - 局限性:HTML 本身是静态的,它描述的是一次性的页面结构和内容,不提供可变数据 (changeable data) 的直接支持。
- 非常直接和快速。例如,
- 使用 JavaScript:
- 过程更复杂,但提供了动态性:
- 创建和存储可变数据:在 JavaScript 中,我们可以创建变量(如
let post = "Hi!";)来存储数据,这些数据是可以被修改的 (mutable)。 - 获取 DOM 元素的引用:
- 使用全局
document对象(它有一个指向整个 C++ DOM 元素列表的隐藏链接)。 - 调用
document.querySelector('div')方法在 DOM 中搜索指定的元素(例如div)。
- 使用全局
- 返回的不是 C++ 对象本身:
querySelector不会将 C++ DOM 对象直接复制到 JavaScript 中(这是不可能的)。 - 创建 JavaScript 包装对象:它会在 JavaScript 中创建一个新的对象(例如,赋值给
jsDiv)。这个对象内部:- 包含一个指向单个特定 C++ DOM 元素(我们查询到的那个
div)的隐藏链接。 - 填充了一系列的方法或属性(如
textContent)。
- 包含一个指向单个特定 C++ DOM 元素(我们查询到的那个
- 特殊属性/方法 (
textContent):- 这些属性(如
textContent)不是普通的 JavaScript 对象属性。 - 如果它们是普通属性,赋值操作(如
jsDiv.textContent = post)只会修改jsDiv这个 JavaScript 对象本身,而不会影响到 C++ DOM。 - 实际上,它们是访问器属性 (getter/setter)。当你给
jsDiv.textContent赋值时,会触发其 setter 函数,该函数会:- 通过隐藏链接访问到对应的 C++ DOM 元素。
- 调用 C++ 内部的相应方法来修改该 DOM 元素的文本内容,将 JavaScript 中的数据(
post的值)设置到 DOM 上。
- 这些属性(如
- 创建和存储可变数据:在 JavaScript 中,我们可以创建变量(如
- 跨越运行时边界:这个过程涉及从 JavaScript 运行时 将数据传递到 C++ 运行时 (DOM 所在的环境)。这是一个关键的交互点。
- 过程更复杂,但提供了动态性:
- 使用 HTML:
- DOM 自动更新视图:
- 一旦 C++ DOM 元素的内容被修改,浏览器会自动将这些更改反映到用户看到的页面(视图)上。
对比:声明式 vs. 命令式 (Descriptive vs. Imperative)
- HTML (声明式/描述性):
- 你描述你想要页面看起来是什么样子。
- 非常直观、简单(例如
<div>Hi!</div>)。
- JavaScript (命令式/多步骤):
- 你需要给出一系列具体的步骤和指令来达到同样的效果。
- 相对不那么直观,涉及更多底层细节。
- 挑战:UI 工程很多时候都在努力找回 HTML 那种声明式的简洁性,同时又希望能拥有 JavaScript 的动态数据处理能力。
追求的目标
- 理想状态:像 HTML 那样描述页面结构和内容,但同时拥有可变的数据。
- Web Components 等尝试:一些技术(如 Web Components)试图在不从头构建所有东西的情况下,更接近这种理想状态。
关键点
- JavaScript 中获取的 DOM 元素引用(如
jsDiv)是一个包含隐藏链接到真实 C++ DOM 元素的 JavaScript 对象。 - 对这些 JS 对象属性(如
textContent)的修改,会通过这些隐藏链接跨越 JavaScript 运行时和 C++ 运行时之间的界限,去操作实际的 DOM。 - HTML 的主要优势在于其声明式的简洁性,但它缺乏可变数据的直接支持。
- JavaScript 提供了操作可变数据和细粒度控制 DOM 的能力,但过程更为命令式和复杂。
- UI 开发的核心挑战之一,就是如何在保持声明式简洁性的同时,有效地管理和同步动态数据到视图。
10-handling-user-interaction-overview
笔记标题:用户交互处理概述
UI 工程的两个核心目标
- 显示内容 (Displaying Content):
- HTML 非常直观,可以直接列出我们想要在页面上显示的元素。
- 这些元素通过布局和渲染引擎(WebCore 的一部分)自动显示在页面上。
- HTML 本身只负责展示,它所呈现的“数据”(如数字 7)仅仅是像素的集合,用户无法直接改变它,因为它背后没有关联的可变数据。
- 处理用户交互 (Handling User Interaction):
- 要让用户能够改变他们看到的内容,意味着必须改变底层的数据。
- 这种改变必须发生在计算机内存中的数据存储区。
- JavaScript 是我们用来处理可变(mutable)数据并编写代码来改变这些数据的语言。
从 HTML 显示到 JavaScript 数据交互
- 问题: 我们如何让用户在页面上的操作(例如,输入文本)能够影响 JavaScript 中的数据,并最终更新页面显示?
- 我们已知:可以通过 JavaScript 将数据渲染到 C++ 实现的 DOM(文档对象模型)中,从而显示在页面上。
- 关键: 是否可以反向操作?即,用户的操作如何通知 JavaScript?
核心流程回顾(Schematic / 示意图)
- HTML 加载与解析 (
app.html):- HTML 文件被加载。
- HTML 解析器 (HTML Parser):逐行读取 HTML,将其中的元素(如
input,div,script)添加到 C++ 实现的 文档对象模型 (DOM) 中。- DOM 是页面的模型,可以看作一个元素列表或一个特殊的对象。
- 例如,示例中的
input,div,script标签会被依次加入 DOM。
- DOM 到像素 (Pixels):
- DOM 中的元素通过布局与渲染引擎 (Layout & Render Engine)计算它们在页面上的位置和样式,并将它们绘制成实际的像素显示在网页上。
- DOM 中有什么,页面上就显示什么。
- JavaScript 引擎启动:
- 当 HTML 解析器遇到
<script>标签时,会启动 JavaScript 引擎 (JavaScript Engine)。 - HTML 文件本身的解析是一次性的,完成后,主要运行时环境就交给了 JavaScript。
- 当 HTML 解析器遇到
- JavaScript 环境:
- 数据存储 (Data Store):JS 引擎有自己的内存区域,可以保存可变数据。
- 执行线程 (Thread of Execution) / 调用栈 (Call Stack):用于运行 JavaScript 代码。初始状态是全局执行上下文。
document对象:- 浏览器会自动在 JS 环境中预置一个名为
document的全局对象。 - 这个
document对象是 JS 与 C++ DOM 交互的桥梁/引用/链接。 - 它包含大量方法(如
querySelector)和属性,允许 JS 代码查询、添加、修改 DOM 元素。
- 浏览器会自动在 JS 环境中预置一个名为
HTML vs. JavaScript 操作 DOM 的对比
- HTML: 添加元素非常简单直观,只需在文件中新写一行标签,它就会被自动添加到 DOM 列表中。
- JavaScript: 通过
document对象操作 DOM(如document.createElement(),element.appendChild())虽然强大,但相比 HTML 的声明方式更为繁琐。
处理用户输入:连接用户操作与 JavaScript
-
目标: 当用户在页面上进行操作时(例如,在输入框中打字),能够触发 JavaScript 代码的执行。
-
示例代码结构:
// 在 JavaScript 中: let post = ""; // 1. JS内部的数据存储 // 2. 获取对DOM元素的JS引用(accessor objects) const jsInput = document.querySelector("input"); // 指向DOM中的input元素 const jsDiv = document.querySelector("div"); // 指向DOM中的div元素 // 3. 定义一个处理函数 function handleInput() { // ... (处理输入的逻辑,例如更新 post 和 jsDiv.innerText) } // 4. 将JS函数附加到DOM元素的事件上 (核心!) jsInput.oninput = handleInput; -
事件处理器 (Event Handler):
jsInput.oninput:jsInput是我们通过document.querySelector获取到的、指向 DOM 中input元素的 JavaScript 对象引用。.oninput是这个inputDOM 元素上预先存在的一个属性。- 这是一个“设置器属性 (setter property)”。我们将一个 JavaScript 函数 (
handleInput) 赋值给它。
- 工作机制:
- 当用户在
input元素中输入内容时(用户操作导致 DOM 发生某种内部变化/事件)。 - 浏览器会自动调用(触发执行)之前赋给
oninput属性的handleInput函数。 - 这个
handleInput函数会在 JavaScript 环境中运行。
- 当用户在
- “Handler”(处理器)这个名字很贴切:它处理用户的行为/输入。
总结
通过将 JavaScript 函数(事件处理器)赋值给 DOM 元素的特定事件属性(如 oninput, onclick 等),我们建立了一条从用户在浏览器中的交互行为到 JavaScript 代码执行的通道。这样,用户的操作就能驱动 JavaScript 代码的运行,进而可以更新数据、修改 DOM,最终改变用户在页面上看到的内容。
11-understanding-the-handleinput-function
笔记标题:理解 handleInput 函数及其设置过程
目标:构建完整的用户界面
- 显示内容: 让用户看到信息。
- 用户改变内容: 允许用户与界面交互并改变显示的内容。
- 在 Web 浏览器中,这涉及到多个 API 和运行时环境的协作。
JavaScript 代码逐行解析与执行流程
let post = "";- 在 JavaScript 的内存中定义一个名为
post的变量(标识符/标签),并将其初始化为空字符串""。 - 这个变量预期将用来存储用户输入的数据,并最终显示在页面上。
- 在 JavaScript 的内存中定义一个名为
const jsInput = document.querySelector('input');- 左侧
const jsInput: 定义一个常量jsInput。这意味着jsInput将永久指向右侧表达式返回的对象(但对象内部的属性可以改变)。 - 右侧
document.querySelector('input'):document: JavaScript 环境中预置的全局对象,它有一个隐藏的链接指向 C++ 实现的 DOM(文档对象模型,即页面元素的列表/树)。.querySelector('input'): 调用document对象上的方法,在 C++ DOM 中搜索第一个 HTMLinput类型的元素。- 查找结果:
- 假设在 DOM 中找到了
input元素。 - 由于不能直接将 C++ 对象完整地“拉”回 JavaScript 环境。
querySelector会在 JavaScript 内存中创建一个新的 JS 对象(这个新对象被赋给jsInput)。- 这个新的 JS 对象(我们称之为“对应的 JS 对象”或“访问器对象”)内部包含一个隐藏的链接 (hidden link),该链接指向 C++ DOM 中实际的
input元素。 - 同时,这个
jsInput对象会自动获得一些预定义的属性和方法(由 DOM API 规范定义,通过 WebIDL 等技术实现),用于与它所链接的 C++ DOMinput元素进行交互。例如,value(用于获取/设置输入框的值) 和oninput(用于设置事件处理器)。
- 假设在 DOM 中找到了
- 左侧
const jsDiv = document.querySelector('div');- 与
jsInput的创建过程类似。 - 在 JavaScript 内存中定义一个常量
jsDiv。 - 通过
document.querySelector('div')在 C++ DOM 中查找第一个div元素。 - 在 JavaScript 中创建一个新的 JS 对象(赋给
jsDiv),该对象包含一个指向 C++ DOM 中实际div元素的隐藏链接。 jsDiv对象也会获得相应的属性和方法,例如textContent(用于获取/设置元素的文本内容)。- “宿主定义 (host defined)”: 这种 JS 对象到 C++ DOM 元素的链接,其具体内存地址和实现方式是由宿主环境(在这里是 Web 浏览器)决定的,JavaScript 语言规范本身不强制规定。
- 与
function handleInput() { /* ... */ }- 使用
function关键字定义一个名为handleInput的函数。 - 浏览器/JavaScript 运行时会在内存中为这个函数定义分配空间,存储函数体的代码文本以及其参数列表(这里没有参数)。
- 重要: 此时,函数仅仅是被定义和存储,其内部的代码并不会立即执行。函数代码只有在被显式调用(invoked/executed)时才会运行。
- 函数目标 (预期行为):
- 从
jsInput(即 DOM 中的input元素) 获取用户输入的当前值。 - 用获取到的值更新 JavaScript 中的
post变量。 - 使用更新后的
post数据去修改jsDiv(即 DOM 中的div元素) 的显示内容。
- 从
- 使用
jsInput.oninput = handleInput;// 关键步骤:设置事件处理器jsInput: 我们之前通过document.querySelector('input')获取到的、在 JavaScript 中代表 DOMinput元素的那个对象。.oninput: 这是jsInput对象(实际上是它所链接的inputDOM 元素原型链上)的一个预定义属性。- 它是一个设置器属性 (setter property)。当给它赋值一个函数时,这个函数就会被注册为该
input元素“输入(input)”事件的处理器 (handler)。 - 命名非常贴切:“处理输入的函数”。
- 它是一个设置器属性 (setter property)。当给它赋值一个函数时,这个函数就会被注册为该
= handleInput:- 我们将
handleInput函数本身(一个引用/指针) 赋值给jsInput.oninput。 - 注意: 这里不是调用
handleInput()并将其返回值赋给oninput,而是将函数定义本身赋过去。 - 效果: 现在,当用户在浏览器中与该
input元素交互并输入内容时(即input事件被触发),浏览器会自动调用(执行)我们在这里设置的handleInput函数。 - 技术细节: 由于不能直接将一个 JavaScript 函数“附加”到 C++对象上,实际上是在 C++ DOM 元素侧保存了一个指向 JavaScript 中
handleInput函数的链接/引用。当事件发生时,通过这个链接“回调”到 JavaScript 环境中执行该函数。
- 我们将
- 真正的“回调 (Callback)”:
- 与某些在定义时就可能被立即执行的“回调”(例如数组的
.map()方法中的回调函数)不同,这里的handleInput函数被“保存”起来。 - 它会在未来的某个不确定时间点(当用户输入时),由系统从外部(DOM 事件系统)“回调”到 JavaScript 环境中执行。
- 与某些在定义时就可能被立即执行的“回调”(例如数组的
系统状态:设置完成,等待用户交互
- 至此,所有初始化的 JavaScript 同步代码(我们编写的这几行)已经执行完毕。
- JavaScript 主线程现在可能处于空闲状态,但它并没有完全停止,因为它知道有些函数(如
handleInput)被设置为事件监听器,需要等待用户操作来触发。 - 我们已经成功建立了:
- 内容显示的结构: 页面上有一个输入框 (
input) 和一个用于显示文本的div。 - 数据存储: JavaScript 中有
post变量。 - 交互连接: 用户在
input框中输入 => 触发input事件 => 调用handleInput函数。 - 数据流向 (预期在
handleInput内部实现):- 用户输入数据进入
inputDOM 元素。 handleInput从inputDOM 元素读取数据 (通过jsInput.value)。handleInput更新 JavaScript 中的post变量。handleInput使用post变量的数据更新divDOM 元素的显示内容 (通过jsDiv.textContent)。
- 用户输入数据进入
- 内容显示的结构: 页面上有一个输入框 (
- 现在,整个系统等待来自“外部”(即用户在浏览器中的输入行为)的触发。
12-user-interaction-dom-updates
笔记标题:用户交互与 DOM 更新的完整流程
场景:用户输入 "Hi"
- 用户操作 (User Action - 绿色标记):
- 用户在页面的
input输入框中键入 "Hi"。 - 即时更新 DOM: 当用户键入时,浏览器会立即更新 C++ DOM 中
input元素的value属性。此时,C++ DOM 中input元素的value属性变为 "Hi"。 - 注意: 这个更新发生在 C++ 层面,JavaScript 此时尚未直接参与。
- 用户在页面的
- 事件触发 (Event Triggering):
- 用户在
input元素中的输入行为会触发一个input事件。 - 这个事件可以理解为一个信号,通知浏览器该
input元素上发生了用户输入。
- 用户在
- 处理函数的回调 (Callback):
- 由于我们之前设置了
jsInput.oninput = handleInput;,浏览器知道当input事件发生时,需要调用handleInput函数。 - 浏览器会将
handleInput函数(的引用)放入一个叫做 回调队列 (Callback Queue) 的地方。- 回调队列是一个先进先出 (FIFO) 的队列,用于存放等待被执行的异步回调函数。
- 由于我们之前设置了
- 事件循环 (Event Loop):
- 浏览器中有一个持续运行的机制叫做 事件循环 (Event Loop)。
- 事件循环会不断检查 调用栈 (Call Stack) 是否为空。
- 当前,我们初始的同步 JavaScript 代码(定义变量、函数、设置事件处理器)已经执行完毕,所以调用栈是空的。
- 函数进入调用栈并执行:
- 当调用栈为空时,事件循环会从回调队列中取出第一个等待的函数(在这里是
handleInput),并将其放入调用栈中。 - 自动执行: 当函数进入调用栈时,JavaScript 引擎会自动在其末尾加上括号
()来执行 (execute/invoke/call) 这个函数。我们并没有显式地写handleInput()来调用它。
- 当调用栈为空时,事件循环会从回调队列中取出第一个等待的函数(在这里是
handleInput函数执行:- 创建新的执行上下文 (Execution Context):
- 当
handleInput函数开始执行时,JavaScript 会为它创建一个新的执行上下文。 - 这个执行上下文有自己的局部内存空间,用于存放函数内部的变量和参数。
- 当
- 第一行代码:
post = jsInput.value;:jsInput: 指向 JavaScript 中代表 DOMinput元素的那个对象。.value: 这是一个获取器 (getter) 属性。当访问jsInput.value时:- 它会通过
jsInput内部的隐藏链接,去访问 C++ DOM 中实际的input元素。 - 从该 C++ DOM 元素中读取其当前的
value属性值(此时是 "Hi")。 - 这个值(一个 DOMString "Hi")被返回到 JavaScript 环境,并可能被转换为 JavaScript 字符串。
- 它会通过
post = ...: 将获取到的字符串 "Hi" 赋值给 JavaScript 中的全局变量post。现在,post的值是 "Hi"。- 关于
oninput触发时机 (Ian 的提问):oninput事件通常在用户每次输入字符时都会触发。所以,如果用户输入 "H",handleInput会执行一次,post变为 "H";然后输入 "i",handleInput再执行一次,post变为 "Hi"。- 为了简化,当前示例假设一次性处理 "Hi"。在实际应用中,如果需要等待用户完成输入(例如按回车键),可以使用其他事件如
onkeydown并检查按键码。
- 第二行代码:
jsDiv.textContent = post;:jsDiv: 指向 JavaScript 中代表 DOMdiv元素的那个对象。.textContent: 这是一个设置器 (setter) 属性。当给jsDiv.textContent赋值时:- 它会通过
jsDiv内部的隐藏链接,去访问 C++ DOM 中实际的div元素。 - 将
post变量的值 ("Hi") 设置为该 C++ DOMdiv元素的文本内容。 - C++ DOM 中
div元素的内部文本现在是 "Hi"。
- 它会通过
- 函数执行完毕:
handleInput函数执行完成,其执行上下文从调用栈中移除。
- 创建新的执行上下文 (Execution Context):
- DOM 更新与页面渲染:
- 当 C++ DOM 中
div元素的文本内容被 JavaScript 修改为 "Hi" 后,浏览器会检测到这个变化。 - 布局与渲染引擎 (Layout & Render Engine) 会重新计算并重新绘制受影响的页面部分。
- 用户在屏幕上看到
div区域的内容更新为 "Hi"。
- 当 C++ DOM 中
总结与反思
- 完整流程: 用户输入 -> DOM
value更新 (C++) ->input事件触发 ->handleInput进入回调队列 -> 事件循环将其移至调用栈 ->handleInput执行 -> 读取input.value(JS -> C++) -> 更新 JS 变量post-> 更新div.textContent(JS -> C++) -> 浏览器渲染更新。 - 复杂性: 即使是这样一个简单的交互(输入文本并显示),也涉及了 C++ DOM、JavaScript 运行时、事件、回调队列、事件循环等多个组件之间的复杂协作。
- 数据流: 数据从用户输入到 C++ DOM,然后通过事件机制传递到 JavaScript,在 JavaScript 中处理后,再写回到 C++ DOM,最终更新用户界面。
- 关注点分离的代价: 浏览器将渲染(HTML/CSS/DOM)和逻辑(JavaScript)分离,带来了灵活性,但也引入了这种跨环境通信的开销。
- 没有永久绑定:
- JavaScript 中的
post变量和 DOM 中div的文本内容之间没有自动的、永久的绑定。 - 如果
post变量在其他地方被修改,div的内容不会自动更新,除非再次显式执行类似jsDiv.textContent = post;的代码。 - 同样,
jsInput.value和post也不是永久绑定的。 - 每次数据同步都需要通过显式的 getter/setter 操作和事件处理函数来完成。
- JavaScript 中的
- “从困难到乏味 (difficult to tedious)”: 一旦理解了这个核心模型(JS 与 DOM 通过
document对象和事件处理器交互),重复应用这个模式来构建更复杂的交互虽然步骤繁多,但逻辑上会变得熟悉。 - 根本原因:
- 用户看到的是像素,像素的改变依赖于 DOM 数据的改变。
- DOM 数据本身(在 C++ 中)不能直接被 JavaScript 代码随意修改或在其上运行任意逻辑。
- 可变数据和可执行逻辑主要存在于 JavaScript 环境中。
- 因此,任何由用户发起的、需要改变显示内容的交互,都必须通过 JavaScript 来处理数据,然后再将结果反映回 DOM。
- JS 对象(如
jsInput,jsDiv)仅仅是访问器对象 (accessor objects),它们本身不直接存储 DOM 数据,而是提供了访问和修改远程 C++ DOM 数据的通道。
这个过程揭示了 Web 前端开发中处理用户交互和状态管理的基本但至关重要的机制。
13-handling-user-interaction-q-a
笔记标题:用户交互处理总结与问答
核心回顾:构建完整的用户界面
- 目标 1:显示内容 (Display Content)
- 最初,页面可能显示一个“空”的输入框,这也是一种内容。
- 最终目标是显示来自计算机内部的数据。
- 在我们的模型中,所有可变数据 (mutable data) 都存储在 JavaScript 中(例如
post变量)。 - 因为只有 JavaScript 中的数据是我们可以通过代码直接改变的,所以视图(用户看到的内容)最终必须源自 JavaScript。
- 目标 2:用户改变内容 (Users can change what they see)
- 用户通过操作(如打字、点击)与他们看到的内容进行交互。
- 这种交互会改变他们看到的内容(例如,从空输入框变为显示 "Hi")。
- 机制:
- 用户行为注册 (Registered): 用户的行为(如在
input元素上打字)会被 DOM 元素“感知”到。 - 事件 (Events): 用户的行为被封装成“事件”(例如
input事件)。 - 事件处理器 (Handler Functions):
- 我们预先通过
jsInput.oninput = handleInput;将handleInput函数存储/关联到input元素的input事件上。 - 当事件发生时,DOM 会将对这个
handleInput函数的引用添加到回调队列 (Callback Queue)。 - JavaScript 的事件循环机制随后会执行这个回调队列中的函数。
- 我们预先通过
- 数据改变与重新显示:
- 在
handleInput函数内部(在 JavaScript 环境中执行):- 我们读取用户输入的数据(例如从
jsInput.value)。 - 我们更新 JavaScript 中的底层数据(例如
post从""变为"Hi")。 - 我们手动地 (manually) 将更新后的数据重新应用到 DOM 上,以改变用户的视图(例如
jsDiv.textContent = post;)。
- 我们读取用户输入的数据(例如从
- 在
- 用户行为注册 (Registered): 用户的行为(如在
- 没有自动数据传播 (No Propagation):
- JavaScript 中的数据(如
post)与 DOM 元素的显示内容之间没有自动的、双向的绑定。 - 每次 JavaScript 数据发生变化,如果希望在页面上看到这个变化,都必须手动编写代码去更新 DOM 的相应部分。
- JavaScript 中的数据(如
问答 (Q&A)
- 为什么改变 DOM 中的一个小东西有时会花费几百毫秒?
- 回答者理解: 之前的经验是直接命令式操作 DOM,例如在一个列表中更改一项,有时会很慢。通过本次学习理解到,这背后涉及到大量的内存分配和一系列复杂的交互步骤(如前面详述的 C++ DOM 与 JS 之间的通信、事件处理、渲染等)。
- 讲师补充: 示例中提到的“一分钟”是用户思考输入内容的时间,并非代码执行时间。但实际的 DOM 操作确实会涉及这些底层机制。
- 回调队列 (Callback Queue) 与任务队列 (Task Queue) / 微任务队列 (Microtask Queue) 的关系?
- 回调队列 (Callback Queue) ≈ 任务队列 (Task Queue): 在这个上下文中,可以将我们讨论的回调队列视为标准的任务队列。DOM 事件(如点击、输入)触发的回调函数通常进入这个队列。
- 微任务队列 (Microtask Queue):
- 这是一个具有更高优先级的队列,有时被称为“VIP 通道”。
- 来源: 主要由
Promise的.then(),.catch(),.finally()回调,以及通过async/await(其底层是 Promise)产生的异步操作,还有queueMicrotask()API 等放入。 - 优先级: 如果调用栈为空,事件循环会优先清空微任务队列中的所有任务,然后再去处理任务队列(回调队列)中的任务。
- 示例: 如果
handleInput(来自 DOM 事件,在任务队列) 和一个由Promise解析产生的函数(在微任务队列)同时等待执行,微任务队列中的函数会先被执行。
- 移除机制: 当一个函数从回调队列(或微任务队列)被移到调用栈并执行完毕后,它会从原队列中移除。
关键点总结
- 用户交互的本质是用户行为触发事件,事件调用预设的 JavaScript 处理函数。
- 处理函数在 JavaScript 环境中运行,可以访问和修改 JavaScript 内部的数据。
- 为了让用户看到数据的变化,JavaScript 代码必须显式地操作 DOM 来更新视图。
- JavaScript 与 DOM 之间的这种数据同步是手动的,不存在自动的数据绑定(在这个基础模型中)。
- 理解事件循环、回调队列和微任务队列有助于更深入地掌握异步操作和事件处理的执行顺序。
14-one-way-data-binding
核心思想:引入单向数据绑定
- 从混沌到有序:我们正从 Web UI 开发的混乱、临时的历史中,转向一种更有序、更易于管理的范式——单向数据绑定。
- UI 工程的核心挑战:
- 挑战在于同步两个完全不同的运行时环境:
- JavaScript 中的数据:我们存储和改变应用状态的地方。
- 浏览器中的视图(View):用户所看到的一切,由 C++实现的 DOM(文档对象模型)管理。
- 挑战在于同步两个完全不同的运行时环境:
- 问题的根源:
- JS 运行时和 C++ DOM 运行时之间的通信是复杂且绕口的。
- JS 通过调用
getter/setter属性来访问 DOM。 - DOM 通过调用 JS 中注册的
handler(事件处理函数) 来响应用户操作。 - 这种来回的、非直接的交互方式,在 UI 变得复杂时,会极难管理。
单向数据绑定 (One-Way Data Binding) 范式
- 定义:这是一种流行的编程范式,旨在解决上述核心挑战。它被主流框架如 Angular、Vue 和 React 广泛采用。
- 本质:它并非技术的“真理”,而是我们作为工程师为了简化开发而给自己施加的一种 “约束” (restriction)。
- 目标与优势:
- 构建可扩展的应用:即使有成千上万的交互点和数据片段,也能保持代码结构清晰。
- 简化调试:更容易地追踪和修复复杂的 UI 问题。
- 应对面试:是 UI 工程领域的核心知识点。
实例:扩展一个更复杂的 UI
- 目标:在现有 UI 上增加一个新的交互——点击 (click) 事件。
- 演示目的:展示即使只是增加一个简单的交互,当多个用户行为可能发生时,UI 的逻辑复杂性也会随之增加。
底层模型分解(白板讲解)
- 初始环境:
- Web 页面:用户看到的最终视图。
- C++ DOM:一个 C++对象列表,作为页面内容的模型(一种简化的表示)。
- JavaScript 运行时:
- 拥有独立的内存,用于存储数据和对象。
- 通过全局的
document对象(一个隐藏的链接)访问 C++ DOM。 - 提供了如
querySelector这样的方法来查询和操作 DOM。
- HTML 解析与 DOM 构建:
- 浏览器加载
app.html,HTML 解析器会读取标签,并在 C++环境中构建 DOM 树。 - 初始 DOM 节点包括:
<input>,<div>,<script>。 - 这些节点随后被渲染引擎绘制到页面上。
- 浏览器加载
- JavaScript 执行流程:
- 解析到
<script>标签时,JS 引擎启动。 let post = '':在 JS 内存中创建一个变量post,用于存储我们的核心数据状态。const jsInput = document.querySelector('input'):- JS 通过
document对象访问 C++ DOM。 querySelector查找<input>节点。- 关键点:它不会返回 C++对象本身,而是在 JS 中创建一个 “访问器对象” (accessor object)。
- 这个
jsInput对象包含一个指向真实 DOM 节点的隐藏链接,以及一系列方法和属性(如.value,.oninput,.onclick)来间接操作它。
- JS 通过
const jsDiv = document.querySelector('div'):- 同理,为
<div>元素创建访问器对象jsDiv,它包含.textContent等属性。 - (讲师强调了这个过程的重复性和乏味性,以此引出后续优化的必要性)。
- 同理,为
jsInput.value = "what's up":- 通过
jsInput访问器对象的.value设置器 (setter)。 - 更新 C++ DOM 中
<input>节点的value属性。 - 浏览器渲染引擎自动将这个变化反映到页面上。
- 通过
handleInput和handleClick函数定义:在 JS 内存中创建这两个函数。jsInput.oninput = handleInput和jsInput.onclick = handleClick:- 通过访问器对象的
oninput和onclick设置器。 - 将这两个 JS 函数作为事件处理程序,附加到 C++ DOM 的
<input>节点上。
- 通过访问器对象的
- 解析到
小结
这个过程详细展示了我们如何手动设置 UI、获取 DOM 引用并附加数据和行为。这为下一步处理用户交互,并展示其复杂性做好了铺垫。这种手动、重复的设置过程也预示着需要一种更高级、更简洁的模式来管理这一切。
15-changing-view-based-on-user-interaction
进入用户交互阶段
- 初始的同步 JavaScript 代码已执行完毕。
- 现在,系统处于“锁定并加载”状态,等待用户的操作。
事件一:用户点击输入框
- 用户行为:用户点击了页面上的
<input>元素。 - DOM 事件触发:一个
click事件在 DOM 中被触发。 - 进入回调队列 (Callback Queue):
- 浏览器发现该元素上注册了
handleClick函数。 - 于是,
handleClick函数被添加到 JavaScript 的回调队列中,等待执行。
- 浏览器发现该元素上注册了
- 事件循环 (Event Loop) 的工作:
- 事件循环机制会检查调用栈 (Call Stack) 是否为空。
- 因为初始脚本已经运行完毕,调用栈是空的。
- 事件循环将
handleClick从回调队列中取出,并放入调用栈,准备执行。
handleClick 函数的执行
- 创建执行上下文:当
handleClick进入调用栈时,JS 引擎会为其创建一个新的执行上下文 (Execution Context)。 - 处理逻辑:这个处理函数的目标非常明确——当用户点击输入框时,清空其中默认的提示文字("what's up"),为用户的输入做准备。
- 关于状态与视图的思考 (Reasoning about State and View):
- 讲师指出,这个操作就是一种“关于状态和视图的思考”——根据用户的交互来决定用户应该看到什么。
- 他特别提到,在这个简单的例子中,我们并没有真正在 JS 中追踪“数据状态”,而只是用一个写死的空字符串 (
'') 直接去操纵视图。
- 代码执行:
jsInput.value = '':- 调用
jsInput访问器对象的.value设置器 (setter)。 - 将一个空字符串字面量赋值给它。
- 这个操作会更新 C++ DOM 中
<input>元素的value属性。 - 渲染引擎自动将这个变化反映到页面上,输入框中的 "what's up" 文本消失。
- 调用
结论
handleClick函数执行完毕,从调用栈中弹出。- UI 已根据用户的点击操作完成更新,现在等待下一个用户行为。
- 这个例子展示了一种直接的、基于用户行为的视图操作模式,它有效但缺乏系统性的状态管理。
16-handling-multiple-user-interactions
事件二:用户在输入框中输入内容
- 用户行为:用户在(现在为空的)输入框中输入了 "Ian"。
- 视图的即时更新:用户输入的同时,"Ian" 这个文本就直接出现在了 DOM 的输入框中。
- DOM 事件触发:用户的输入行为触发了
input事件。 - 回调与事件循环:
handleInput函数被放入回调队列,随后被事件循环移入调用栈执行。
handleInput 函数的执行
- 创建执行上下文:为
handleInput创建一个新的执行上下文。 - 步骤一:数据从视图流向 JS (
post = jsInput.value)- 通过
jsInput访问器对象的.value获取器 (getter)。 - 从 C++ DOM 的
<input>元素中读取当前的值 ("Ian")。 - 更新 JavaScript 内存中的
post变量,使其值为 "Ian"。数据从 View 同步到了 JS。
- 通过
- 步骤二:数据从 JS 流向视图 (
jsDiv.textContent = post)- 通过
jsDiv访问器对象的.textContent设置器 (setter)。 - 获取
post变量的当前值 ("Ian")。 - 更新 C++ DOM 中
<div>元素的文本内容。 - 渲染引擎将此变化反映到页面上,"Ian" 现在也出现在了输入框下方的预览
div中。数据从 JS 同步到了 View 的另一部分。
- 通过
问题所在:日益增长的复杂性
- 难以推理的数据流:即使只有两个元素和两个事件处理器,数据的来回流动已经开始变得难以追踪和理解。
- 视图操作的分散:视图在代码的多个地方被修改(初始设置时、
handleInput中、handleClick中)。 - 依赖用户行为顺序的条件逻辑:
- UI 的最终状态取决于用户操作的顺序(是先点击再输入,还是直接输入?)。
- 这导致了大量的隐式条件和难以预测的 UI 状态。
- 可扩展性问题:
- 这种“哪里需要改就去哪里改”的临时方法,对于两个小盒子来说尚可应付。
- 但对于一个拥有成百上千个元素和处理器的真实应用来说,将是 “极其困难的”。
- 可能的交互路径呈指数级增长,维护和调试将成为噩梦。
解决方案的提出(单向数据绑定的核心)
- 约束流程,而非放任自由:为了解决这种复杂性,我们需要引入一套严格的规则。
- 规则 1:用户操作只允许改变状态
- 用户的任何行为(点击、输入等)唯一能做的事情就是更新 JavaScript 中的数据(State)。
- 规则 2:视图完全由状态驱动
- 用户看到的一切(View)必须是当前数据状态的直接反映。
- 这个“反映”过程由一个单一的、“数据到视图的转换器”函数来完成。
- 最终效果:在数据和视图之间建立一种可预测的、一对一的映射关系,彻底简化 UI 逻辑。
17-separating-data-view-updates
新的架构:关注点分离
- 数据 (Data):应用的 “单一数据源” (Single Source of Truth)。在我们的例子中,就是
post变量。它是所有状态的中心。 - 视图逻辑 (View Logic):一个单一的函数
dataToView,它承担了所有更新视图的责任。所有关于“如何根据数据来展示界面”的条件逻辑都集中在这里。 - 处理器 (Handlers):
handleClick和handleInput这些事件处理函数,现在只有一个职责:更新数据,然后触发dataToView函数。它们不再直接操作 DOM。
dataToView 函数的角色
- 它就像一个“转换器” (converter) 或对视图的“描述” (description)。
- 它接收数据作为输入,输出用户看到的视图。
- 集中化管理:所有的视图逻辑都集中于此。例如,使用三元运算符
post === undefined ? "what's on your mind" : post来决定输入框显示什么。 - 好处:当你想知道 UI 为什么是现在这个样子时,你只需要查看
dataToView这一个函数。
思维模式的转变 (Mindset Shift)
- 这是从根本上改变我们思考 UI 的方式。
- 旧方式:用户操作 → 直接修改与该操作相关的那一小块视图。
- 新方式:用户操作 → 修改底层的数据 → 基于新数据重新渲染整个(相关的)视图。
新方法的优势
- 可预测性 (Predictability):
- 视图永远是数据的直接映射。你不再需要去追溯一长串复杂的用户操作历史来理解 UI 的当前状态。
- 可调试性 (Debuggability):
- 如果出现 UI bug,你可以直接检查数据状态的历史记录。因为视图是数据的纯函数,只要数据是正确的,视图就应该是正确的。
- 简化的处理器 (Simplified Handlers):
- 事件处理器的逻辑变得极其简单,它们只负责将用户行为翻译成一次数据变更。
“低效”的权衡 (The "Inefficiency" Trade-off)
- 核心代价:采用这种模式,我们可能会更新视图中一些并未实际发生变化的部分。
- 例如,当用户输入时,
dataToView可能会重新设置输入框的值,尽管这个值已经是用户刚刚输入的样子了。
- 例如,当用户输入时,
- 为什么可以接受(目前):
- 现代计算机性能强大:对于中小型应用,这点“不必要”的重复工作所带来的性能开销几乎可以忽略不计。
- 开发效率的巨大提升:代码的可预测性和维护性的提升,远比那一点微小的性能损耗重要得多。
- 简化心智模型:开发者不再需要去思考“在当前这种情况下,我是否需要更新输入框?”这种复杂问题。统一处理,简单粗暴但有效。
展望未来
- 这种模式是所有现代前端框架(React, Vue, Angular 等)的基石。
- 在大型应用中,框架会在此基础上进行优化(例如 React 的虚拟 DOM 比对算法),以避免完全重新渲染所有内容所带来的性能问题。
- 这个概念也引出了“半可视化编程”的思想,即你可以在 JavaScript 代码中拥有一份对 UI 的完整描述。
18-understanding-the-datatoview-function
回顾:问题与范式转变
- 问题:JavaScript 数据与 DOM 视图之间的来回数据流,在 UI 变复杂时,难以管理和推理。
- 范式转变:状态驱动视图 (State-Driven Views)。视图是数据的一个函数,这创建了一个可预测且简化的模型。
- 核心流程:
用户操作->改变数据(State)->触发单一的dataToView转换函数->基于新数据更新整个视图这个流程确保了“没有谜团”,UI 的任何状态都有迹可循。
详细演练:再次构建心智模型
- 环境设置:
- 在白板上画出 JS 运行时、C++ DOM 和最终渲染的像素区域。
- HTML 文件被解析,在 C++ DOM 中创建了
<input>,<div>,<script>节点。 - 页面上渲染出初始视图(一个空的输入框和一个不可见的 div)。
- JS 中的
document对象提供了与 DOM 交互的桥梁。
- JavaScript 执行(初始设置阶段):
let post = undefined;: 初始化核心数据状态post为undefined。这是它的第一个可能状态。jsInput = document.querySelector('input');: 为输入框创建访问器对象,它包含了.value,.onclick,.oninput等属性。jsDiv = document.querySelector('div');: 为 div 创建访问器对象,它包含了.textContent属性。- 函数定义:
dataToView,handleClick,handleInput这三个函数被定义并存储在 JS 内存中。 - 附加处理器: 通过访问器对象,将
handleClick和handleInput函数附加到 DOM 的<input>元素上。
初始渲染:第一次运行 dataToView
- 在全局作用域中,
dataToView函数被首次调用。 - JS 引擎为它创建了一个新的执行上下文。
dataToView函数内部逻辑:- 处理输入框:
- 代码:
jsInput.value = (post === undefined) ? "what's up" : post; - 检查条件
post === undefined,结果为true。 - 三元运算符返回字符串
"what's up"。 - 通过
jsInput的.value设置器,将 DOM 中<input>元素的值更新为"what's up"。 - 视图随之更新,页面输入框中显示了这段默认文字。
- 代码:
- 处理 div:
- 代码:
jsDiv.textContent = post; post的当前值是undefined。- 浏览器对
.textContent的 API 实现非常友好,它会自动将undefined转换为空字符串''。 - DOM 中
<div>元素的文本内容被设置为空字符串。 - 视图中,这个 div 保持为空,不可见。
- 代码:
- 处理输入框:
结论
- 应用的初始 UI 状态已完全渲染。
- 关键点:页面上所有动态的内容,都是由 JS 中的
post数据变量,经过单一的dataToView函数处理后得出的。 - 这为后续在这个新的、受约束的模式下处理用户交互奠定了坚实的基础。讲师也补充说明了“单向数据绑定”的含义:数据单向流向视图,而用户输入则是一种可控的、“精确”的数据提交,回流到数据层。
19-one-way-data-binding-ui-elements
核心思想:重申单向数据绑定模型
- 本节再次强调了单向数据绑定的核心理念:所有用户可见的内容,都必须由一个明确的数据状态派生而来。
数据状态的定义
- 为了实现这一模式,我们需要清晰地定义我们的数据
post可能存在的所有状态:undefined: 初始状态,用户还未进行任何交互。''(空字符串): 用户点击了输入框,但还未输入任何内容。string(字符串值): 用户已经输入了具体内容。
详细演练(与上一节内容一致)
- 初始化数据:
let post = undefined; - 获取 DOM 访问器:创建
jsInput和jsDiv访问器对象。 - 定义函数:在内存中创建
dataToView,handleClick,handleInput函数。 - 附加处理器:将
handleClick和handleInput附加到输入框的onclick和oninput事件上。 - 执行初始渲染:调用
dataToView()。 dataToView()内部逻辑:- 因为
post是undefined,jsInput.value被设置为"what's up"。 - 因为
post是undefined,jsDiv.textContent被设置为空字符串''。
- 因为
结论
- 应用的初始 UI 完全基于其初始数据状态生成。
- 这一过程再次强化了核心原则:数据是唯一的真理,视图仅仅是数据的可视化表达。这为接下来分析用户交互如何在这个模型下工作做好了铺垫。
20-one-way-data-binding-user-interactions
系统就绪
- UI 已根据初始数据渲染完毕,现在等待用户的操作。
交互一:用户点击输入框
- 事件流:用户点击 -> DOM 触发
click事件 ->handleClick进入回调队列 -> 事件循环将其移至调用栈。 - 执行
handleClick:- 为其创建一个新的执行上下文。
- 步骤 1: 更新数据 (
post = ''): 这是处理器现在做的第一件事,也是核心的事——只改变数据。post变量从undefined变为''。 - 步骤 2: 触发视图更新 (
dataToView()): 处理器立即调用dataToView函数来同步视图。
- 执行
dataToView(第二次运行):- 为其创建一个新的执行上下文。
- 处理输入框:
- 检查
post === undefined,结果为false。 - 三元运算符返回
post的值,即''。 - 输入框的值被更新为空字符串,"what's up" 文本消失。
- 检查
- 处理 div:
div的文本内容被设置为post的值,即''。div 保持为空。
交互二:用户输入 "Y"
- 事件流:用户输入 'Y' -> 'Y' 出现在输入框中 -> DOM 触发
input事件 ->handleInput进入回调队列 -> 调用栈。 - 执行
handleInput:- 为其创建一个新的执行上下文。
- 步骤 1: 更新数据 (
post = jsInput.value):- 从输入框读取当前值 (
"Y")。 - 更新 JS 中的
post变量为"Y"。
- 从输入框读取当前值 (
- 步骤 2: 触发视图更新 (
dataToView()): 调用dataToView。
- 执行
dataToView(第三次运行):- 为其创建一个新的执行上下文。
- 处理输入框:
- 检查条件为
false,将输入框的值设置为post的值,即"Y"。输入框被来自“官方数据源”的值“刷新”了。
- 检查条件为
- 处理 div:
- 将
div的文本内容设置为post的值,即"Y"。文本 "Y" 出现在预览 div 中。
- 将
结论与模式的优势
- 整个 UI 交互流程在一个高度受限但极其可预测的模式下完成了。
- 清晰性:视图的每一次变化都是数据变化的结果,并且都通过同一个函数处理。不再需要在不同的处理器中到处寻找修改视图的代码。
- 单一数据源:JavaScript 中的数据是视图的唯一真理。
- 虚拟 DOM 的雏形:这个模式启发我们可以在 JS 中维护一个对 UI 的完整描述。有了这张“地图”,我们就可以通过比较新旧“地图”的差异,只更新变化的部分,从而实现性能优化——这正是虚拟 DOM 比对算法的核心思想。
21-predictable-data-view-flow
目标:更紧密的数据-视图绑定
- UI 的核心是展示“内容”(数据+视图)并允许用户修改它。
- 单向数据绑定模式已经让数据和视图感觉像一个统一的“内容”单元。
- 但目前我们仍需在每次数据变化后手动调用
dataToView。我们能让这个过程自动化吗?
“黑科技”:用 setInterval 实现自动化
- 问题:开发者必须时刻记得在改变数据后调用
dataToView,这容易出错和遗漏。 - 解决方案(一种简单粗暴的“黑科技”):使用
setInterval(dataToView, 15)。 - 工作原理:
- 这行代码会使
dataToView函数大约每 15 毫秒(约每秒 60 次)被自动调用一次,持续不断。
- 这行代码会使
- 带来的变化:
- 我们可以从事件处理器 (
handleClick,handleInput) 中移除对dataToView()的手动调用。 - 现在,处理器的逻辑变得无比简单:改变数据,然后什么都不用管了。
setInterval循环会在下一次运行时自动捕捉到数据的变化,并更新视图。
- 我们可以从事件处理器 (
自动化方法的优势
- “魔法”般的绑定:数据和视图现在看起来是内在地、自动地绑定在一起了。你一改变数据,视图就“神奇地”自动更新了。
- 极简的处理器:处理器可以完全专注于业务逻辑(即如何根据用户行为来修改数据),而无需关心视图更新的细节。
缺点与为何称之为“黑科技”
- 性能问题:这是“极其野蛮”的方式。即使数据没有任何变化,它也在每秒 60 次地重新渲染整个 UI,这非常低效。
- 阻塞问题:高频度的执行可能会阻塞浏览器的一些其他任务,如平滑滚动和 CSS 动画。
- 不适用于真实应用:这种方法对于任何实际规模的应用都是不可行的。它在这里仅用于教学目的,以最直观的方式展示数据-视图自动同步的概念。
从“黑科技”到现代框架
- 现代框架(如 React 的 State Hooks)为你提供了这种自动更新的感觉,但没有其性能缺陷。
- 它们提供 API 让你改变数据,然后框架内部足够智能,能够只在数据真正发生变化时才触发高效的重新渲染。
- 你得到了
setInterval方式的简洁开发体验,同时避免了其巨大的性能浪费。
单向数据绑定的最终总结
- 它是一个可预测、受约束的结构,极大地简化了 UI 开发。
- 核心流程:
用户交互->改变数据->单一的“转换/渲染”函数根据数据更新视图。 - 这种模式将 UI 开发的“指数级复杂性”降低为线性、可管理的逻辑,使代码更健壮、更易于维护。
22-virtual-dom-introduction
核心概念:虚拟 DOM (Virtual DOM)
- 动机:
- 实现声明式编程 (Declarative Programming):
- 理想的 UI 编程方式应该是“所见即所得”,即代码的结构应该直观地反映最终页面的样子。HTML 就是这种声明式语言的典范。
- 然而,JavaScript 本质上是 命令式 (Imperative) 的,我们通过一步步的指令来操作 DOM,这不够直观。
- 我们希望在 JavaScript 中也能用一种更“可视化”或“声明式”的方式来构建 UI。
- 性能优化:
- 频繁地直接操作真实 DOM 是非常昂贵的。
- 通过引入一个中间层,我们可以比较 UI 变化前后的状态,计算出最小的差异,然后只对真实 DOM 进行必要的操作。
- 实现声明式编程 (Declarative Programming):
- 什么是虚拟 DOM?
- 它是在 JavaScript 内存中对真实 DOM 的一种轻量级表示。通常是一个普通的 JavaScript 对象或数组。
- 它就像是真实 DOM 的一份“蓝图”或“快照”。
- 虚拟 DOM 如何工作?
- 描述 UI:我们首先在 JavaScript 中用对象或数组的形式,声明式地描述出我们想要的 UI 结构和内容。
- 生成虚拟 DOM:当数据(State)发生变化时,我们基于新的数据重新生成一份全新的虚拟 DOM。
- 比较差异 (Diffing):
- 我们比较新的虚拟 DOM 和上一次旧的虚拟 DOM。
- 这个过程被称为 “Diffing” (差异比对)。
- 精确更新 (Reconciliation):
- 找出两份虚拟 DOM 之间的具体差异(例如,哪个节点的文本变了,哪个节点被添加或删除了)。
- 然后,只把这些差异精确地应用到真实 DOM 上。这个过程被称为 “Reconciliation” (协调)。
- 虚拟 DOM 带来的双重好处:
- 提升开发体验:
- 允许我们用更直观、更接近 HTML 的方式在 JavaScript 中组织和“组合”UI 组件。
- 开发者可以专注于“UI 应该是什么样子”,而不是“如何一步步操作 DOM 去变成那个样子”。
- 提升应用性能:
- 通过 Diffing 和 Reconciliation,避免了不必要的、昂贵的 DOM 操作,从而显著提升了应用的性能和响应速度。
- 提升开发体验:
23-auto-updating-views-ui
引入新问题:动态的 UI 结构
- 之前的例子中,UI 的结构(
<input>和<div>)是固定的,只有它们的内容在变。 - 现在,我们要引入更复杂的场景:根据用户的输入,动态地添加或移除 DOM 元素本身。
- 新需求:如果用户输入了"will",就把预览的
<div>从页面上移除。
问题分析:当视图结构也成为一种“状态”
- 问题的根源:
- 最初,我们只把文本内容看作是需要用数据(
post变量)来管理的状态。 - 现在,一个元素(
<div>)的存在与否也变成了动态的。这本质上也是一种状态(显示/不显示,或 true/false)。
- 最初,我们只把文本内容看作是需要用数据(
- 当前实现的缺陷:
- 我们直接在
handleInput事件处理器中调用了jsDiv.remove()来操作 DOM。 - 这意味着,我们又回到了在多个地方(
dataToView和handleInput)直接修改视图的老路。 - 更糟糕的是,这个视图的结构变化没有被一个明确的 JavaScript 数据状态所追踪。
dataToView函数并不知道<div>已经被移除了,它依然会尝试去更新一个不存在(或已分离)的元素的.textContent。
- 我们直接在
迈向最终解决方案的铺垫
- 核心洞察:如果一个 UI 元素(无论是它的内容还是它本身)是可能改变的,那么它的状态就必须被一个明确的 JavaScript 数据所控制。
- 引出的新原则:
- 所有可变的视图都应由数据驱动。
- 不仅是文本内容,元素的创建、销毁、显示、隐藏都应该在我们的单一转换函数(
dataToView)中,根据数据状态来决定。
- 这意味着:我们不能再依赖 HTML 来预先创建 DOM 结构。我们需要在 JavaScript 中,在每次视图更新时,根据当前数据从零开始创建所需的 DOM 元素。
演练过程简述
- 本节详细演练了当
handleInput直接移除div后,系统会发生什么。 - 关键发现:当
dataToView再次运行时(通过setInterval),它会尝试在那个已经被remove()的div上设置.textContent。虽然这在某些浏览器实现中可能不会直接报错,但它暴露了我们逻辑上的一个严重漏洞:数据和视图结构已经不同步了。 - 这为下一节引入一个更彻底的、完全由 JavaScript 驱动的 UI 组件创建模式埋下了伏笔。
24-auto-updating-views-with-setinterval
场景回顾
- 这是一个简化版的 UI,用户在输入框中输入内容,下方
<div>显示预览。 - 引入了
setInterval机制,使得dataToView函数每 15 毫秒自动运行一次,以持续同步数据和视图。 - 增加了一个新逻辑:当用户输入"will"时,
<div>会被移除。
setInterval 的工作机制
- 注册回调:
setInterval(dataToView, 15)这行代码执行时,它并不会阻塞主线程。它只是在浏览器的 定时器 (Timer) 模块中注册了一个任务。 - 任务内容: 这个任务是:“每隔 15 毫秒,请将
dataToView函数放入 JavaScript 的回调队列 (Callback Queue)”。 - 事件循环: 随后,事件循环会检查调用栈,如果为空,则将
dataToView从队列中取出并执行。
演练流程
1. 初始渲染 (第一次 dataToView 执行)
setInterval注册后约 15 毫秒,dataToView第一次被放入回调队列并执行。- 此时
post为空字符串''。 jsInput.value被设为''。jsDiv.textContent被设为''。- 页面上显示一个空的输入框和一个空的 div。
2. 用户输入 "will"
- 用户操作: 用户输入 "will"。
- 事件触发:
input事件触发,handleInput函数进入回调队列并执行。 handleInput执行:post变量被更新为"will"。- 关键:执行条件判断
if (post === 'will'),结果为true。 jsDiv.remove()被调用。这直接操作了 C++ DOM,将<div>元素从主 DOM 树中分离 (detach) 出来,使其不再被渲染。它并没有被销毁,只是进入了“游离”状态。
3. 下一次 setInterval 触发的视图更新
- 又过了 15 毫秒,
dataToView再次被调用。 - 此时
post的值是 "will"。 jsInput.value = post;: 输入框的值被更新为 "will"。jsDiv.textContent = post;:- 问题暴露:代码尝试在
jsDiv指向的那个已经被分离的<div>元素上设置文本内容。 - 虽然这可能不会立即导致程序崩溃,但它完全没有达到预期的效果(因为
div已经不在页面上了),并且暴露了我们视图逻辑的混乱。 - 我们现在不得不在
dataToView中增加额外的检查(“这个 div 还在页面上吗?”),这违背了我们追求简单、可预测模型的初衷。
- 问题暴露:代码尝试在
结论
- 问题的核心:我们允许了视图的修改发生在多个地方(
handleInput和dataToView),并且视图结构的变化没有被一个 JS 数据状态所追踪。 - 引向的解决方案:为了保持单一数据源和单一视图更新逻辑的原则,我们必须让所有的 UI 创建和修改都发生在
dataToView函数内部,完全由 JS 数据驱动。这意味着,连 DOM 元素的创建都不能依赖 HTML 了。
25-understanding-ui-components
核心思想:UI 组件 (UI Component)
- 我们正在构建一个完整的 UI 组件。
- 什么是 UI 组件?
- 它是一个函数(或一组相关的代码),封装了 UI 某一部分的所有逻辑。
- 它完整地描述了:
- 数据 (Data): 它依赖哪些底层数据。
- 视图 (View): 如何根据这些数据来创建和更新 DOM 元素。
- 行为 (Behavior): 如何响应用户交互(即事件处理器),并反过来更新数据。
范式转变:从“修改”到“重新创建”
- 旧模式:HTML 先创建好 DOM 骨架,JS 再去修改它的内容,或者在特殊情况下移除它。
- 新模式 (组件化模式):
- HTML 几乎是空的,只提供一个挂载点(如
<body>)。 - JS 中的
dataToView函数(现在可以看作是组件的“渲染”函数)承担了全部责任。 - 在每次数据更新时,这个函数会根据当前数据,从零开始在 JS 中创建所有需要的 DOM 元素(
<input>和<div>)。 - 然后,用新创建的元素完全替换掉页面上旧的元素。
- HTML 几乎是空的,只提供一个挂载点(如
新代码结构分析 (dataToView 函数内部)
- 元素的创建:
jsInput = document.createElement('input');jsDiv = (post !== 'will') ? document.createElement('div') : '';- 这里的逻辑变得非常清晰:
input总是创建;div只有在post不等于"will"时才创建。
- 内容的填充和行为的附加:
- 和之前一样,为创建出的元素设置
.value,.textContent,.oninput等。
- 和之前一样,为创建出的元素设置
- 渲染到页面:
document.body.replaceChildren(jsInput, jsDiv);- 这一行代码是关键。它会清空
<body>里所有旧的子元素,然后把我们新创建的jsInput和jsDiv(如果jsDiv不是空字符串的话)添加进去。
这个模式的意义
- 真正的单一数据源: 现在,页面上的一切(元素的有无、内容、行为)都由 JS 中的数据和
dataToView这一个函数决定。HTML 不再是初始状态的来源。 - 声明式:我们不再命令式地“移除一个 div”,而是声明式地“当数据显示为'will'时,视图中不应包含 div”。
- 可组合性 (Composability):
- 这种封装好的组件可以被复用。我们可以创建多个“输入框-预览”组件,每个都有自己的数据状态。
- 这是现代前端框架构建复杂 UI 的基础。
讨论和澄清
- 关于性能: 每次都重新创建和替换所有元素,听起来性能很差。这正是之后引入 虚拟 DOM 比对算法 (Diffing) 来解决的问题。
- 受控 vs. 非受控输入 (Controlled vs. Uncontrolled Inputs):
- 我们现在做的是受控组件:JS 完全控制了输入框的值。用户输入只是一个“信号”,JS 接收信号、更新数据、然后把新数据“刷”回输入框。
- 非受控组件则让浏览器自己管理输入框的值,JS 只在需要时(如提交时)去读取它。这是一个重要的设计决策,现代框架通常都支持这两种模式。
- DOM 节点炼狱 (DOM Node Purgatory): 被
remove()或replaceChildren()替换掉的 DOM 节点,如果没有 JS 变量继续引用它,它就会被浏览器的 垃圾回收机制 (Garbage Collection) 清理掉,不会造成内存泄漏。
26-ui-component-setup
核心目标:构建完整的 UI 组件
- 本节旨在完整地演练一个新的 UI 组件的设置和初始渲染过程。
- 这个组件的特点是:所有 UI 的创建和更新都完全在 JavaScript 中进行,由数据驱动。
架构变化
- HTML 的角色最小化:
- HTML 文件现在几乎是空的,只包含一个
<script>标签和一个默认的<body>元素。 <body>作为我们动态创建的 UI 的“挂载点”。
- HTML 文件现在几乎是空的,只包含一个
- JavaScript 承担全部责任:
dataToView函数(我们的 UI 组件核心)现在不仅负责填充内容,还负责创建DOM 元素本身(<input>和<div>)。- 这意味着,元素的有无、内容、属性、事件处理器等所有方面,都由 JS 中的数据(
post)和dataToView函数中的逻辑决定。
演练步骤:初始设置阶段
- 环境准备:
- 白板上画出 Web 页面、C++ DOM、JS 运行时,以及它们之间的关系。
- HTML 文件加载,在 DOM 中创建了
<body>和<script>节点。 - JS 引擎启动。
- JavaScript 全局作用域设置:
- 变量声明:
let post = '';:初始化核心数据。let jsInput, jsDiv;:声明用于存储 DOM 访问器对象的变量,初始为undefined。这些变量将在dataToView中被反复赋值。let VDOM;(虽然这段代码没有,但为后续虚拟 DOM 做铺垫)。
- 函数定义:
dataToView:核心的渲染函数。handleInput:事件处理器。convert(虽然这段代码没有,但为后续虚拟 DOM 做铺垫)。
- 定时器设置:
setInterval(dataToView, 15);:设置一个每 15 毫秒调用一次dataToView的定时器,实现视图的自动更新。- 讲师强调,这行代码是非阻塞的,它只是向浏览器的定时器模块注册了一个任务。
- 变量声明:
小结
- 至此,应用的整个“骨架”已经搭建完毕。
- 我们创建了数据、定义了行为(处理器)和视图逻辑(
dataToView),并通过setInterval建立了一个自动化的数据-视图同步循环。 - 这个设置过程展示了一个完全由 JavaScript 控制的 UI 组件的典型启动流程,为接下来的初始渲染和用户交互分析做好了准备。
27-ui-component-datatoview-function
核心内容:dataToView函数的首次执行
- 这是在 UI 组件模式下,
dataToView函数的首次运行,负责生成应用的初始视图。
演练流程
- 触发执行:
setInterval在注册后约 15 毫秒,将dataToView函数放入回调队列。- 事件循环将其移入调用栈,JS 引擎为其创建新的执行上下文。
dataToView内部执行步骤:- 创建输入框 (
jsInput = document.createElement('input');)- 调用
document.createElement('input')。 - 这会在 C++ DOM 中创建一个 未附加 (unattached) 的
<input>节点。它存在,但还不在页面上。 - 同时,在 JS 中返回一个指向这个新节点的访问器对象。
- 这个访问器对象被赋值给全局变量
jsInput。
- 调用
- 创建 Div (
jsDiv = (post !== 'will') ? ... : '';)- 执行三元运算符判断
post !== 'will'。 - 当前
post是'',所以条件为true。 - 因此,执行
document.createElement('div'),创建一个未附加的<div>节点。 - 返回的访问器对象被赋值给全局变量
jsDiv。
- 执行三元运算符判断
- 设置内容 (
jsInput.value = post;和jsDiv.textContent = post;)- 通过访问器对象,将这两个新创建的 DOM 元素的内容都设置为空字符串
''。
- 通过访问器对象,将这两个新创建的 DOM 元素的内容都设置为空字符串
- 附加处理器 (
jsInput.oninput = handleInput;)- 通过访问器对象,将
handleInput函数附加到新创建的<input>节点的oninput事件上。
- 通过访问器对象,将
- 渲染到页面 (
document.body.replaceChildren(jsInput, jsDiv);)- 这是最关键的一步。
replaceChildren方法会清空<body>元素的所有现有子节点。- 然后,它会将
jsInput和jsDiv指向的 DOM 节点作为新的子节点附加 (append) 到<body>中。 - 一旦被附加到主 DOM 树,渲染引擎就会将它们绘制到页面上。
- 创建输入框 (
讨论与澄清
- 关于焦点 (Focus):
- 一个重要的问题被提出:每次这样重新创建
<input>元素,用户输入时光标会丢失焦点。 - 解决方案:在
replaceChildren之后,需要手动调用jsInput.focus()来将光标重新设置到输入框中。 - 讲师解释说,为了简化图表,他省略了这行代码,但在实际应用中这是必需的。
- 一个重要的问题被提出:每次这样重新创建
- 代码的简化:
- 讲师还提到,为了教学目的,他会忽略一些对模型理解不关键但实践中必要的代码,比如对不同元素类型做不同处理的条件判断。
结论
- 应用的初始视图成功渲染。
- 这个过程完整地展示了 UI 组件模式下的渲染流程:根据数据 -> 在 JS 中创建完整的 UI 片段 -> 一次性替换到真实 DOM 中。
- 这个模式虽然在性能上有待优化,但其逻辑的清晰性和可预测性是巨大的优势。
28-ui-component-interaction
场景:用户与 UI 组件的交互
- 初始 UI 已渲染,现在用户开始输入。
演练流程:用户输入 "will"
- 用户操作: 用户在输入框中输入 "will"。
- 事件触发与处理器执行:
input事件触发,handle函数进入回调队列并执行。handle函数内部,post变量被更新为"will"。handle函数执行完毕,从调用栈弹出。它的唯一职责就是更新数据。
setInterval触发的视图更新:- 约 15 毫秒后,
dataToView(或updateDOM)再次被调用。 - JS 引擎为其创建新的执行上下文。
- 此时
post的值是 "will"。
- 约 15 毫秒后,
dataToView内部执行步骤 (第二次):- 重新创建
jsInput:document.createElement('input')创建了一个全新的<input>节点。- 旧的
<input>节点因为不再被jsInput引用,会被垃圾回收。 - 新的访问器对象被赋值给
jsInput。
- 处理
jsDiv:- 执行三元运算符
(post !== 'will') ? ... : ''。 - 因为
post等于"will",条件为false。 - 关键:
jsDiv被直接赋值为空字符串''。没有创建任何<div>元素。
- 执行三元运算符
- 设置内容与处理器:
- 新创建的
input元素的值被设为 "will"。 handle函数被附加到新input的oninput事件上。- 当代码尝试在
jsDiv(现在是空字符串)上设置.textContent时,虽然逻辑上不严谨,但因为 JavaScript 的灵活性,这并不会导致程序崩溃。
- 新创建的
- 渲染到页面:
document.body.replaceChildren(jsInput, jsDiv);被调用。- 它接收了
jsInput(一个访问器对象)和jsDiv(一个空字符串)。 replaceChildren方法会忽略空字符串参数。- 结果是:旧的
<input>和<div>被从页面移除,只有新创建的<input>被添加了进去。
- 重新创建
结论
- 我们成功地通过只改变数据,并让一个统一的渲染函数来响应,实现了 UI 结构的动态变化。
- 模式的威力:
- 声明式:我们没有写“移除 div”,而是写“当数据是'will'时,视图中不应该有 div”。
- 可预测性: UI 的任何状态都严格地由数据决定,逻辑集中在一个地方。
- 性能问题初现:
- 每次更新都完全重新创建所有元素,即使它们没有变化,这是非常低效的。
- 这为下一阶段引入虚拟 DOM和Diffing 算法来优化性能埋下了伏笔。
29-emulate-html-with-string-interpolation
核心思想:追求代码的可视化与声明性
- 目标:让我们的 JavaScript 代码在结构上尽可能地接近它所要生成的视觉产出(UI)。
- 类比:
- 字符串拼接:
- 命令式方法:
text = 'Hello'; text = text.concat(' Jo'); text = text.concat('!');-> 代码过程与最终结果 "Hello Jo!" 看起来完全不同。 - 声明式方法(模板字符串):
-> 代码本身就直观地描绘了最终的输出。text = `Hello ${name}!`;
- 命令式方法:
- 字符串拼接:
- 应用到 UI:我们能否用类似的方式,在 JS 中“画出”我们想要的 DOM 结构?
引入“描述性”的 UI 单元
- 新方法:不再直接在
dataToView中调用document.createElement,而是先创建一个描述 UI 的数据结构。 - 数据结构:我们选择用一个数组来代表一个 DOM 元素。
const divInfo = ["div", `Hi, ${name}!`];- 这个数组非常直观地告诉我们:我们想要一个
'div'类型的元素,它的内容是'Hi, Jo!'。
- 这个数组非常直观地告诉我们:我们想要一个
- 转换函数 (
convert):- 我们创建一个名为
convert的通用函数。 - 它的职责是:接收一个像
divInfo这样的描述性数组,然后负责执行所有命令式的 DOM 操作(createElement,textContent = ...),最终返回一个真正的 DOM 访问器对象。
- 我们创建一个名为
演练过程
- 定义描述:
-> 在 JS 内存中创建了一个数组const name = "Jo"; const divInfo = ["div", `Hi, ${name}!`];['div', 'Hi, Jo!']。 - 定义转换器:
function convert(node) { ... }被定义。
- 执行转换:
const jsDiv = convert(divInfo);convert函数被调用,参数是divInfo数组。- 在
convert内部:const elem = document.createElement(node[0]);->createElement('div')elem.textContent = node[1];-> 设置文本内容为 'Hi, Jo!'return elem;-> 返回创建好的 DOM 访问器对象。
- 结果:
jsDiv现在持有一个指向新创建的、带有正确内容的div元素的访问器对象。
结论
- 我们成功地将 “描述 UI”(声明式)和 “操作 DOM”(命令式)这两个关注点分离开来。
- 我们现在有了一种在 JavaScript 中以半可视化的方式表示 DOM 元素的方法。
- 展望:
- 如果我们可以用一个数组来表示一个元素,那么我们就可以用一个嵌套的数组来表示整个 DOM 树。
- 这个在 JS 中描述 DOM 结构的数据,就是虚拟 DOM (Virtual DOM) 的核心思想。
30-creating-a-javascript-virtual-dom
核心概念:构建我们自己的虚拟 DOM
- 目标:将上一节的“描述性数组”思想扩展,用一个嵌套数组来完整地描述我们整个 UI 的结构和内容。这个嵌套数组就是我们的虚拟 DOM。
新的代码结构
createVDOM()函数:- 这是一个专门用来生成虚拟 DOM的函数。
- 它不操作真实 DOM,只返回一个描述 UI 的 JavaScript 数组。
- 例如:
return [ ["input", name, handle], ["div", `Hello, ${name}!`], ]; - 这个返回的二维数组,就是当前数据状态下,UI 的"蓝图"。
updateDOM()函数 (原dataToView):- 这是我们的主渲染循环函数。
- 步骤 1:生成虚拟 DOM ->
vDOM = createVDOM(); - 步骤 2:遍历虚拟 DOM -> 循环遍历
vDOM数组中的每一个子数组(代表一个元素)。 - 步骤 3:转换并渲染 -> 对每一个子数组,调用
convert()函数将其转换为真实的 DOM 节点,然后用replaceChildren渲染到页面上。
convert()函数:- 变得更通用,可以处理更复杂的元素描述,如包含属性和事件处理器。
- 它接收一个描述元素的数组(如
['input', '', handle]),然后负责所有底层的 DOM API 调用。
演练流程:初始渲染
- 初始设置:
- 声明全局变量
name,jsInput,jsDiv,vDOM。 - 定义
createVDOM,handle,updateDOM,convert四个函数。 setInterval(updateDOM, 15)启动渲染循环。
- 声明全局变量
updateDOM()首次执行:- 调用
createVDOM(),此时name为''。 - 返回的虚拟 DOM 数组被赋值给
vDOM:vDOM = [['input', '', handle], ['div', 'Hello, !']]。 - 遍历
vDOM:- 处理第一个元素:
- 调用
convert(['input', '', handle])。 convert内部创建了一个<input>元素,设置其.value为空,并附加了handle事件处理器。- 返回的访问器对象被赋值给
jsInput。
- 调用
- 处理第二个元素:
- 调用
convert(['div', 'Hello, !'])。 convert内部创建了一个<div>元素,设置其.textContent。- 返回的访问器对象被赋值给
jsDiv。
- 调用
- 处理第一个元素:
- 渲染:
document.body.replaceChildren(jsInput, jsDiv)将新创建的两个元素渲染到页面上。
- 调用
结论
- 我们成功地构建了一个完整的、虽然简单但功能齐全的虚拟 DOM 实现。
- 清晰的流程:
数据变化->重新生成虚拟DOM->遍历虚拟DOM,将其转换为真实DOM->渲染到页面 - 代码分离: 我们清晰地分离了“描述 UI”(
createVDOM)和“渲染 UI”(convert,updateDOM)的逻辑。 - 这个模型为我们提供了在纯 JavaScript 中进行声明式 UI 编程的能力,并为下一阶段的性能优化(Diffing)打下了坚实的基础。
31-js-virtual-dom-user-interaction
场景:用户与我们的虚拟 DOM 实现的交互
- 初始 UI 已通过虚拟 DOM 渲染,现在用户开始输入。
演练流程:用户输入 "y"
- 用户操作: 用户在输入框中输入 "y"。
- 事件触发与处理器执行:
input事件触发,handle函数进入回调队列并执行。handle函数内部,name变量被更新为"y"。handle函数执行完毕。
setInterval触发的视图更新 (updateDOM第二次执行):- 步骤 1:重新生成虚拟 DOM:
- 调用
createVDOM()。因为name变量现在是"y",它返回了一个新的虚拟 DOM 数组: vDOM = [['input', 'y', handle], ['div', 'Hello, y!']]。- 讲师强调,我们可以把旧的
vDOM存档,这样就能比较新旧差异了(为 Diffing 埋下伏笔)。
- 调用
- 步骤 2:遍历新的虚拟 DOM 并转换:
- 处理第一个元素:
- 调用
convert(['input', 'y', handle])。 convert创建了一个全新的<input>元素,其.value被设为"y",并附加了handle处理器。- 这个新
<input>的访问器对象被赋给了jsInput。
- 调用
- 处理第二个元素:
- 调用
convert(['div', 'Hello, y!'])。 convert创建了一个全新的<div>元素,其.textContent被设为"Hello, y!"。- 这个新
<div>的访问器对象被赋给了jsDiv。
- 调用
- 处理第一个元素:
- 步骤 3:渲染到页面:
document.body.replaceChildren(jsInput, jsDiv)。- 旧的
<input>和<div>被从页面上移除。 - 新创建的、内容已更新的
<input>和<div>被添加到页面上。
- 步骤 1:重新生成虚拟 DOM:
结论
- 我们通过虚拟 DOM 模型,成功地响应了用户交互并更新了 UI。
- 模型的完整性:
- 整个流程是闭环的:
用户操作->更新数据->生成新vDOM->渲染新vDOM。 - JavaScript 中的
vDOM数组现在是 UI 的唯一、完整的描述。我们可以在渲染到真实 DOM 之前,就拥有一个关于 UI 应该是什么样子的精确蓝图。
- 整个流程是闭环的:
- 声明式编程的胜利:
- 我们现在可以在
createVDOM函数中,用一种非常直观、类似 HTML 的结构来描述 UI。 - 我们甚至可以在这个描述中嵌入条件逻辑(例如,使用三元运算符决定是否包含某个元素),从而实现强大的动态 UI。
- 我们现在可以在
- 最后的拼图:
- 尽管这个模型在逻辑上非常清晰,但其性能仍然是最大的问题(每次都销毁并重建所有 DOM)。
- 下一阶段,我们将通过比较新旧
vDOM的差异,只更新变化的部分,来解决这个性能瓶颈。
32-declarative-ui-as-a-paradigm
核心思想:声明式 UI 作为一种编程范式
- 目标:在 JavaScript 中实现一种类似 HTML 的、嵌套的、可视化的代码结构来描述 UI,从而简化开发。
- 实现方式:
- 通过嵌套的数组(或对象)来表示 DOM 树的层级关系。
- 通过创建可复用的 函数式组件 (Functional Components) 来生成这些描述性的数据结构。
声明式 UI Paradigm 的基石:单向数据绑定
- 要让我们的 JavaScript 虚拟 DOM(UI 的描述)保证与真实 DOM 同步,单向数据绑定是不可或缺的前提。
- 为什么?
- 如果允许用户操作直接修改真实 DOM(而不是只更新 JS 数据),那么我们的虚拟 DOM 就会立刻过时和不准确。
- 例如,如果用户输入了"y",但我们没有将这个信息流回 JS 数据层,那么下一次生成的虚拟 DOM 仍然会认为输入框是空的,这就会导致 UI 状态的混乱。
- 正确的流程:
- 用户操作被视为一次对数据的 “提交” (Submission)。
- Handler接收这个提交,并更新 JavaScript 中的 “单一数据源” (Single Source of Truth)。
- 基于更新后的数据,重新生成一份全新的、准确的虚拟 DOM。
- 最后,将这份新的虚拟 DOM 渲染到页面上,覆盖掉任何可能存在的旧状态。
总结:数据流的全景图
- 数据 (Data): 在 JavaScript 中的核心状态。
- 虚拟 DOM (Virtual DOM):
- 由一个函数(如
createVDOM)根据当前数据生成。 - 它是对 UI 的一个声明式描述,是 JS 内存中的“蓝图”。
- 由一个函数(如
- 转换 (Conversion):
- 一个通用函数(如
convert)负责将虚拟 DOM 的描述翻译成真实的 DOM 节点。
- 一个通用函数(如
- 真实 DOM (Actual DOM):
- 最终在 C++环境中被创建和更新,并由浏览器渲染。
- 用户输入 (User Input):
- 通过事件和Handler,形成一个精确的、受控的数据回流通道,只用于更新第一步的数据。
这个闭环流程,确保了我们的 UI 系统是可预测、可维护且最终(在优化后)是高性能的。它构成了所有现代前端框架的底层哲学。
33-using-lists-for-ui-development
核心思想:处理 UI 中的列表
- 问题引入:
- 真实的 UI 应用中,我们经常需要处理大量的、重复的元素,例如社交媒体上的帖子列表、视频会议的参与者列表等。
- 在之前的虚拟 DOM 实现中,我们是手动地、一个一个地从
vDOM数组中取出元素描述来进行转换和渲染的(convert(vDOM[0]),convert(vDOM[1]))。 - 这种手动方式缺乏灵活性和可扩展性。如果列表有 50 个元素,或者我们想改变元素的顺序,手动修改代码将是一场噩梦。
解决方案:自动化列表处理
- 我们需要一种更通用的方法来处理这个“元素描述列表”(即我们的虚拟 DOM 数组)。
- 使用
.map()遍历和转换:- 数组的
.map()方法是处理这个问题的完美工具。 - 我们可以调用
vDOM.map(convert)。 - 这行代码会自动遍历
vDOM数组中的每一个元素(每一个子数组),并将它们逐一作为参数传递给convert函数。 .map()会收集每次convert函数调用的返回值(即 DOM 访问器对象),并最终返回一个包含所有这些访问器对象的新数组。- 优点:代码变得简洁且富有弹性。无论
vDOM中有 2 个元素还是 200 个元素,这行代码都不需要改变。
- 数组的
- 使用扩展运算符 (
...) 处理函数参数:.map()返回的是一个数组(例如[inputAccessor, divAccessor])。- 然而,
document.body.replaceChildren()这个方法期望接收的是一系列独立的参数,而不是一个数组(即replaceChildren(inputAccessor, divAccessor))。 - 扩展运算符 (
...) 可以完美地解决这个问题。 replaceChildren(...elems)会将elems数组中的所有元素“展开”,作为独立的参数传递给replaceChildren方法。replaceChildren(...[inputAccessor, divAccessor])等价于replaceChildren(inputAccessor, divAccessor)。
新的代码流程 (updateDOM 函数内部)
- 生成虚拟 DOM:
const vDOM = createVDOM(); - 批量转换:
const elems = vDOM.map(convert); - 批量渲染:
document.body.replaceChildren(...elems);
结论
- 通过结合使用
.map()和扩展运算符,我们极大地提升了虚拟 DOM 实现的代码质量。 - 灵活性与可组合性:我们现在可以轻松地在
createVDOM函数中添加、删除或重排元素描述,而渲染逻辑(updateDOM)完全不需要修改。这使得构建和维护动态列表变得非常简单。 - 这是声明式 UI 编程威力的又一次体现:我们只关心“应该有哪些元素”,而把“如何逐个处理它们”的复杂性交给了像
.map()这样的高级函数。
34-composable-code-with-map-and-spread
核心目标:演练元素灵活的虚拟 DOM 实现
- 本节旨在完整地演练使用了
.map()和扩展运算符 (...) 的、更灵活的虚拟 DOM 渲染流程。 - 这个新实现使得我们可以轻松地组合和管理任意数量的 UI 元素。
演练流程:初始渲染
- 环境设置与初始数据:
- 搭建好 Web 页面、DOM、JS 运行时的基本模型。
- 声明全局变量
name = '',vDOM,elems。 - 定义
createVDOM,handle,updateDOM,convert四个核心函数。
- 启动渲染循环:
setInterval(updateDOM, 15)被调用,注册了一个每 15 毫秒运行一次的updateDOM任务。
updateDOM首次执行:- 步骤 1: 生成虚拟 DOM:
- 调用
createVDOM(),返回一个包含三个元素描述的二维数组,并赋值给vDOM。 vDOM的内容是:[['input', '', handle], ['div', 'Hello, !'], ['div', 'great job']]。
- 调用
- 步骤 2: 批量转换 (
elems = vDOM.map(convert)):.map()方法开始工作。- 第一次迭代:
convert(['input', '', handle])被调用。它创建了一个<input>DOM 节点和对应的访问器对象,并返回该对象。 - 第二次迭代:
convert(['div', 'Hello, !'])被调用,创建并返回一个<div>的访问器对象。 - 第三次迭代:
convert(['div', 'great job'])被调用,创建并返回另一个<div>的访问器对象。 .map()将这三个返回的访问器对象收集到一个新数组中,并赋值给全局变量elems。现在elems是一个包含三个访问器对象的数组。
- 步骤 3: 批量渲染 (
document.body.replaceChildren(...elems)):- 扩展运算符
...将elems数组“展开”。 replaceChildren接收到三个独立的访问器对象作为参数。- 它清空
<body>,然后将这三个对象所指向的 DOM 节点(一个<input>和两个<div>)依次附加到<body>中。
- 扩展运算符
- 步骤 1: 生成虚拟 DOM:
引入新概念:事件对象 (Event Object)
- 问题: 在这种批量处理模式下,我们不再为每个 DOM 元素(如输入框)保留一个单独的、命名的 JS 变量(如
jsInput)。那么,在handle事件处理器中,我们如何知道是哪个元素触发了事件,并获取其值呢? - 解决方案: 事件对象。
- 当一个事件处理器(如
handle)被浏览器调用时,浏览器会自动向其传递一个事件对象 (event object) 作为参数。 - 这个对象包含了关于该事件的所有详细信息。
- 其中,
event.target属性是一个指向触发该事件的 DOM 元素的引用。
- 当一个事件处理器(如
- 应用:
- 在
handle(e)函数中,我们可以通过e.target.value来获取用户在输入框中输入的值,无论这个输入框是列表中的哪一个。 - 这使得我们的事件处理器变得更加通用和强大。
- 在
结论
- 我们演练了一个功能更强大、代码更具组合性的虚拟 DOM 实现。
.map()和...的使用大大简化了对动态列表的处理。- 引入的事件对象概念解决了在批量渲染模式下如何识别事件源的问题,是编写通用事件处理器的关键。
35-event-api
核心概念:事件 API 与事件对象 (Event API & Event Object)
- 问题: 在我们新的、灵活的列表渲染模式下,我们不再为每个输入框保留一个单独的 JS 变量(如
jsInput)。那么,当用户输入时,我们的handle函数如何知道该从哪个输入框获取值呢? - 解决方案: 利用浏览器强大的事件 API。
- 当浏览器调用一个事件处理器(如
handle)时,它不仅仅是执行函数代码,还会自动向这个函数传递一个参数。 - 这个自动插入的参数就是事件对象 (event object)。
- 当浏览器调用一个事件处理器(如
事件对象详解
- 定义: 一个包含了关于触发事件的所有详细信息的 JavaScript 对象。
- 获取方式:
- 在定义处理器时,需要为其设置一个参数(通常命名为
e或event),例如function handle(e) { ... }。 - 当事件发生时,浏览器会自动创建事件对象并将其作为
e传入。
- 在定义处理器时,需要为其设置一个参数(通常命名为
- 关键属性:
e.target: 这是最重要的属性之一。它是一个指向触发该事件的 DOM 元素的访问器对象。- 其他信息: 事件对象还包含大量其他有用信息,如:
- 鼠标事件的坐标 (
e.clientX,e.clientY) - 键盘事件中是否有辅助键被按下 (
e.ctrlKey,e.shiftKey) - ...等等
- 鼠标事件的坐标 (
演练流程:用户交互与事件对象的使用
- 用户操作: 用户在页面上的输入框中输入了 "Li"。
- 事件触发:
input事件触发,handle函数被放入回调队列。 - 处理器执行:
handle函数被移入调用栈。- 关键: 浏览器自动创建了一个事件对象,并将其作为第一个参数传递给
handle函数。我们的参数e现在就引用了这个对象。
- 在
handle(e)函数内部:e.target属性现在指向了那个用户正在输入内容的<input>DOM 节点。- 通过
e.target.value,我们可以准确地获取到输入框的当前值 "Li"。 name = e.target.value;:我们将 JS 中的核心数据name更新为 "Li"。
- 后续的视图更新:
handle执行完毕。setInterval触发updateDOM。updateDOM调用createVDOM,此时因为name是"Li",生成了新的虚拟 DOM。- 后续流程(
.map(convert)->replaceChildren(...))与之前相同,最终将包含"Hello, Li!"的新 UI 渲染到页面上。
结论
- 事件对象是连接用户交互和数据更新的关键桥梁,特别是在动态和批量渲染的场景中。
- 它让我们的事件处理器可以变得非常通用,无需硬编码对特定 DOM 元素的引用。
- 至此,我们已经拥有了一个逻辑闭环且功能强大的声明式 UI 系统:
- 用 JS 数组(虚拟 DOM)声明式地描述 UI。
- 用
.map()和...批量处理列表。 - 用事件对象通用地处理用户输入。
- 尽管实现上仍有性能问题,但其设计思想已经非常接近现代前端框架。
36-event-api-review
核心概念回顾
- 事件对象 (Event Object):
- 当事件处理器被调用时,浏览器会自动传入一个
event对象。 event.target属性是关键,它指向触发事件的 DOM 元素,让我们可以在不知道具体是哪个元素的情况下,获取其信息(如.value)。- 这使得我们的事件处理器变得通用,能够处理一个由虚拟 DOM 生成的动态元素列表。
- 当事件处理器被调用时,浏览器会自动传入一个
- 虚拟 DOM (Virtual DOM):
- 是 UI 在 JavaScript 中的一个声明式描述,通常是一个嵌套的数组或对象。
- 由一个专门的函数(如
createVDOM)根据当前的数据(State)生成。 - 它是 UI 的单一数据源和“蓝图”。
- 单向数据流 (One-Way Data Binding):
- 整个系统的运作遵循一个严格的单向流程:
用户操作→Handler更新数据→(定时器触发)→重新生成vDOM→转换vDOM为真实DOM→渲染 - 这个流程确保了 UI 状态的可预测性和一致性。用户输入被视为一次“提交”,JS 数据是唯一权威,视图只是这个权威的忠实反映。
- 整个系统的运作遵循一个严格的单向流程:
- 元素灵活的代码 (Element Flexible Code):
- 通过结合使用
.map()和扩展运算符 (...),我们的渲染逻辑(updateDOM)不再关心虚拟 DOM 中有多少个元素或它们的顺序。 - 这使得添加、删除、重排 UI 元素变得极其简单,只需要修改
createVDOM函数返回的数组即可。这种能力被称为可组合性 (Composability)。
- 通过结合使用
总结
- 我们已经构建了一个相当完善的声明式 UI 编程模型。
- 它具备了现代前端框架的许多核心特征:
- 组件化的思想(虽然还没明确命名)。
- 声明式的 UI 描述(虚拟 DOM)。
- 单向数据流。
- 高效处理列表的能力。
- 这个模型虽然在性能上是“野蛮”的(每次都完全替换 DOM),但它在代码结构、可维护性和开发体验上展示了巨大的优势。
- 接下来的挑战将是如何在保持这些优势的同时,解决性能问题。
37-generating-vdom-elements-from-array
核心思想:函数式组件与进一步的组合
- 问题: 如果我们要渲染一个包含 50 个帖子的列表,在
createVDOM函数中手动写一个包含 50 个子数组的巨大数组,依然非常笨拙和重复。 - 解决方案: 将创建单个列表项的逻辑封装到一个可复用的函数中。这个函数就是一个函数式组件 (Functional Component)。
函数式组件 (Functional Component)
- 定义: 一个接收数据(props)作为参数,并返回一段 UI 描述(虚拟 DOM 片段)的函数。
- 示例:
function Post(message) { return ["div", message]; }Post就是一个函数式组件。它接收一个message字符串,返回一个描述div元素的数组。
- 命名约定: 组件的函数名通常以大写字母开头(如
Post),这是一种广泛遵循的社区约定,用以区分普通函数和 UI 组件。
如何使用函数式组件
-
在我们的主渲染函数
createVDOM中,我们可以通过遍历数据数组并调用组件函数来动态生成虚拟 DOM。 -
示例:
const postsData = ['Ginger', 'Gez', 'Ursy', 'Fen']; function createVDOM() { return [ ['input', ...], // 输入框描述 ...postsData.map(Post) // 关键! ]; }postsData.map(Post)会对postsData中的每个名字调用Post函数,生成一个包含四个['div', message]数组的新数组。...扩展运算符将这个新数组展开,使其成员成为createVDOM返回的主数组的一部分。
动态更新列表
- 现在,要更新 UI 列表,我们只需要更新数据数组即可。
- 示例:
- 在
handle函数中,当用户输入时,我们不再是修改一个name变量,而是向postsData数组中push新的内容。 postsData.push(e.target.value);
- 在
- 效果:
- 数据数组
postsData被改变。 - 下一次
updateDOM运行时,createVDOM会基于这个更长的数据数组,通过.map(Post)生成一个包含更多元素的虚拟 DOM。 - 最终,新的列表项就会被渲染到页面上。
- 数据数组
结论
- 函数式组件是实现 UI 可复用性和可组合性的核心工具。
- 它让我们能够将复杂的 UI 拆分成更小、更易于管理的独立单元。
- 通过数据驱动(修改数据数组)和函数式组件的结合,我们可以用非常声明式和高效的方式来构建和管理动态列表。
- 这个模型已经非常接近 React 等现代框架的工作方式了。在 React 中,JSX 最终会被编译成类似
React.createElement()的函数调用,其作用与我们这里的组件函数和描述性数组非常相似。
38-update-the-dom-on-data-change
核心问题:性能瓶颈
- 当前模式的优势:我们通过虚拟 DOM 在 JavaScript 中实现了对 UI 的直观、声明式的描述。这极大地简化了我们的心智模型:
- 单一数据源:所有 UI 都源于 JS 中的数据。
- 单一渲染逻辑:
createVDOM函数根据数据生成 UI 的完整“蓝图”。 - 开发者只需关心数据和这个“蓝图”,而无需手动操作 DOM。
- 当前模式的代价:性能灾难。
- 在我们的实现中,每次数据发生微小变化,我们都会:
- 生成全新的虚拟 DOM。
- 遍历虚拟 DOM,从零开始重新创建所有真实的 DOM 元素。
- 用新创建的元素完全替换掉页面上所有的旧元素。
- 这种“推倒重来”的方式,对于任何规模的应用都是不可接受的。
- 在我们的实现中,每次数据发生微小变化,我们都会:
解决方案的提出:引入差异比对 (Diffing)
- 核心洞察:我们没有必要每次都“推倒重来”。既然我们手头有变化前和变化后的两份虚拟 DOM(UI 蓝图),我们完全可以比较这两份蓝图,找出其中的差异。
- 新流程的设想:
- 当数据变化时,保留旧的虚拟 DOM(
previousVDOM)。 - 根据新数据生成新的虚拟 DOM(
vDOM)。 - 编写一个差异比对算法 (Diffing Algorithm),即一个函数(如
findDiff),来比较previousVDOM和vDOM。 - 这个算法会逐一对比两个虚拟 DOM 中的元素描述,找出哪些元素是新增的、删除的,或是哪些元素的内容/属性发生了变化。
- 最后,我们只对真实 DOM 执行这些必要的、精确的修改,而不是全部替换。
- 当数据变化时,保留旧的虚拟 DOM(
对setInterval循环的再反思
- 问题 1:性能浪费
- 我们的
updateDOM函数每 15 毫秒就运行一次,即使数据根本没有变化。这造成了大量的 CPU 资源浪费。
- 我们的
- 问题 2:潜在的阻塞
- 高频度的执行可能会影响浏览器的其他任务,如 CSS 动画和平滑滚动,导致用户体验下降。
解决方案:按需更新(State Hook 的思想)
- 目标:我们希望
updateDOM只在数据真正发生变化时才运行。 - 问题:如何保证开发者在修改数据后,总能记得去调用
updateDOM? - 解决方案:将“更新数据”和“触发视图更新”这两个操作封装到一个函数中。
- 创建一个函数,比如
updateData(key, value)。 - 这个函数内部会做两件事:
- 更新 JS 中的数据对象(
data[key] = value)。 - 调用
updateDOM()。
- 更新 JS 中的数据对象(
- 我们规定,团队中的所有开发者必须通过调用这个封装好的函数来修改数据,而不能直接操作数据对象。
- 创建一个函数,比如
- “Hook” 的概念:
- 这个封装好的
updateData函数,就是一种 “钩子” (Hook)。 - 我们通过“钩入”这个函数来管理我们的状态(数据)。它为我们提供了一种机制,确保了在状态变更的同时,能够自动触发一系列关联行为(如视图更新)。
- 这正是 React 等现代框架中 State Hook(如
useState)的核心思想。它提供了看似自动的视图更新,而实际上是建立在这种受控的、封装好的状态更新机制之上的。
- 这个封装好的
39-automatic-updates-with-hooks
核心概念:用“钩子” (Hook) 实现按需自动更新
- 目标:摆脱低效的
setInterval循环,实现只在数据变化时才更新 UI,同时保持开发的便捷性。 - 解决方案:创建一个封装了“更新数据”和“触发视图更新”逻辑的函数,我们称之为状态钩子 (State Hook)。
新的代码结构
- 集中化的数据存储:
- 我们将所有状态(数据)存储在一个单一的 JavaScript 对象中,例如
const data = { name: '' };。
- 我们将所有状态(数据)存储在一个单一的 JavaScript 对象中,例如
- 创建更新函数 (
updateData):- 这是一个通用的函数,用于更新
data对象中的任何属性。 function updateData(label, value) { ... }- 内部逻辑:
data[label] = value;-> 使用传入的label(如'name')和value(如'Li')来更新数据对象。updateDOM();-> 在更新数据后,立即调用主渲染函数。
- 这是一个通用的函数,用于更新
- 在事件处理器中使用更新函数:
- 在
handle函数中,我们不再直接写name = ...。 - 而是调用
updateData('name', e.target.value);。
- 在
- 移除
setInterval:- 我们不再需要
setInterval来不断地轮询和刷新。
- 我们不再需要
这种模式的优势
- 高效 (Efficient):
updateDOM函数现在只在必要的时候(即数据发生改变时)被调用,极大地节省了计算资源。 - 可靠 (Reliable):通过将数据更新和视图更新绑定在一起,我们确保了视图永远不会与数据状态脱节。开发者不会因为忘记调用
updateDOM而产生 bug。 - 声明式体验 (Declarative Experience):对于使用者来说,他们感觉就像在调用一个简单的
updateData函数,而视图的更新是“自动”发生的。这种隐藏了底层命令式调用的做法,提供了非常好的开发体验。 - “钩子”的本质:
- “Hook”这个词听起来很深奥,但它的本质就是:提供一个函数,让你“挂钩”到某个核心功能上。
- 在这里,
updateData就是一个钩子,它让我们能够安全地“挂钩”到应用的状态管理和渲染流程中。
演练流程
- 当用户输入"Li"时:
handle函数被调用。handle调用updateData('name', 'Li')。updateData内部:- 将
data.name更新为'Li'。 - 调用
updateDOM()。
- 将
updateDOM()执行,根据新的data.name值生成新的虚拟 DOM,并最终更新页面。
讨论:requestAnimationFrame
- 讲师提到,即使是在按需更新的模式下,如果更新操作非常频繁,我们也可以使用
requestAnimationFrame来包裹对updateDOM的调用。 requestAnimationFrame能确保我们的 UI 更新操作与浏览器的刷新率同步,并且不会与滚动、动画等高优先级的渲染任务冲突,从而提供更平滑的用户体验。但它仍然比 State Hook 的模式低效,因为它还是在循环运行。
结论
- 通过引入“状态钩子”的思想,我们解决了
setInterval带来的性能问题,实现了一个高效且可靠的按需更新系统。 - 这是从“持续轮询”到“事件驱动”的一次重要升级,也是现代前端框架状态管理的核心模式。
40-performance-gains-using-diffing
核心问题:DOM 操作的性能浪费
- 虽然我们通过“状态钩子”解决了何时更新 UI 的问题(只在数据变化时更新),但如何更新 UI 的问题依然存在。
- 当前实现中,即使只是一个字符的变化,我们仍然会销毁所有旧的 DOM 元素,并从零开始创建所有新的 DOM 元素。这是一个巨大的性能瓶 GAINS。
解决方案:差异比对 (Diffing) 与精确更新
- 核心洞察:既然我们拥有变化前(
previousVDOM)和变化后(vDOM)的两份 UI“蓝图”,我们就可以通过比较它们来找出最小的变更集。 - Diffing Algorithm (差异比对算法):
- 我们需要编写一个函数(如
findDiff),它接收新旧两个虚拟 DOM 作为参数。 - 这个算法会逐一比较两个虚拟 DOM 中的元素描述。
- 我们需要编写一个函数(如
- 基本实现思路:
- 循环遍历: 遍历其中一个虚拟 DOM(假设长度相同)。
- 逐个比较: 在循环的每一步,比较
previousVDOM[i]和vDOM[i]。 - 如何比较: 因为数组是引用类型,直接用
===比较是无效的。一个简单的方法是使用JSON.stringify()将它们都转换为字符串,然后比较字符串是否相等。 - 发现差异: 如果字符串不相等,就意味着这个元素发生了变化。
- 精确更新: 一旦发现差异,我们就只针对这个发生变化的元素,去更新真实 DOM 的相应部分(例如,只更新某个
div的.textContent或某个input的.value)。 - 无差异则跳过: 如果字符串相等,说明这个元素没有变化,我们就不对它对应的真实 DOM 做任何操作。
演练场景
- 初始状态:
name是''。 - 变化后: 用户输入,
name变成'will'。 findDiff函数执行:- 比较元素 0 (input):
- 旧 vDOM 描述:
['input', '', handle] - 新 vDOM 描述:
['input', 'will', handle] JSON.stringify后的字符串不同。- 操作: 更新真实
<input>元素的.value属性。
- 旧 vDOM 描述:
- 比较元素 1 (div "Hello"):
- 旧 vDOM 描述:
['div', 'Hello, !'] - 新 vDOM 描述:
['div', 'Hello, will!'] - 字符串不同。
- 操作: 更新真实
<div>元素的.textContent。
- 旧 vDOM 描述:
- 比较元素 2 (div "great job"):
- 旧 vDOM 描述:
['div', 'great job'] - 新 vDOM 描述:
['div', 'great job'] - 字符串相同。
- 操作: 不执行任何 DOM 操作。
- 旧 vDOM 描述:
- 比较元素 0 (input):
结论
- 通过引入Diffing 算法,我们终于解决了性能问题。
- 我们现在拥有了一个既具备声明式开发体验(得益于虚拟 DOM),又具备高性能(得益于 Diffing 和精确更新)的 UI 编程模型。
- 开发者体验: 开发者依然只需要关心数据和 UI 的最终形态描述,无需关心底层的 DOM 操作细节。
- 运行时效率: 底层系统足够智能,能够将声明式的描述转换成最高效的命令式 DOM 更新。
- 这套组合拳——单向数据流 + 虚拟 DOM + Diffing 算法——正是 React 等现代前端框架能够风靡全球的核心原因。
41-dom-diffing-setup
核心目标:最终实现的设置阶段
- 本节旨在搭建我们最终、最完整版本的 UI 框架模型的初始环境,为接下来的 Diffing 算法演练做准备。
- 这个版本将结合之前所有的概念:单向数据流、虚拟 DOM、按需更新,并最终引入差异比对。
演练流程:初始设置
- 环境搭建:
- 在白板上画出 Web 页面、C++ DOM、JS 运行时的标准模型。
- HTML 文件现在是最小化的,只负责加载 JS 脚本。所有 UI 元素都将由 JS 动态创建。
- JavaScript 全局作用域初始化:
- 数据声明:
let name = '';:初始化我们的核心数据。let vDOM;:将用于存储当前虚拟 DOM 的变量。let previousVDOM;:新增变量,用于在更新时存档旧的虚拟 DOM,以便进行比较。let elems;:将用于存储一个持久化的真实 DOM 元素访问器数组。这是与之前最大的不同,因为我们不再每次都销毁和重新创建真实 DOM。
- 函数定义:
createVDOM,handle,updateDOM,convert,findDiff等所有核心函数都被定义。- 讲师特别提到了函数提升 (Hoisting),解释了为什么我们可以在函数定义之前就调用它(因为使用了
function关键字)。
- 数据声明:
- 启动渲染循环:
setInterval(updateDOM, 15)被调用。- 注意: 讲师选择回到
setInterval的方式,主要是为了简化教学图表。在概念上,我们已经知道如何用 State Hook 的方式实现按需更新,但为了专注于 Diffing 过程,这里暂时回归循环模式。
与之前版本的关键不同点
elems数组的持久化:- 在之前的版本中,
elems(或jsInput,jsDiv)在每次updateDOM运行时都会被重新赋值,指向新创建的 DOM 元素。 - 在最终版本中,
elems数组只在首次渲染时被创建和填充。 - 在后续的更新中,我们不再重新创建真实 DOM,而是通过
elems数组中存储的这些持久化的访问器对象,去修改已存在的 DOM 元素。
- 在之前的版本中,
updateDOM的条件逻辑:updateDOM函数现在有了一个if (elems === undefined)的判断。- 首次渲染 (Mounting):
elems是undefined,执行首次渲染逻辑——创建所有真实 DOM 元素,填充elems数组,并将元素append到页面上。 - 后续更新 (Updating):
elems已经有值,执行更新逻辑——生成新旧 vDOM,调用findDiff进行比较,并进行精确的 DOM 修改。
结论
- 我们已经完成了最终实现的“挂载”(Mounting)阶段前的所有准备工作。
- 这个设置引入了持久化的 DOM 引用 (
elems数组)和区分首次渲染与更新的逻辑,这是实现高效 Diffing 更新的前提。 - 模型已经非常成熟,准备好进入最后一步——演练完整的更新与差异比对流程。
42-conditionally-updating-the-dom
核心内容:首次渲染(挂载)流程
- 本节详细演练了在最终的 Diffing 模型中,UI 的首次渲染 (Mounting) 过程。
- 这个过程只会在应用启动时执行一次。
演练流程:updateDOM 的首次执行
- 触发与执行:
setInterval在约 15 毫秒后,将updateDOM函数放入回调队列并执行。
- 条件判断:
- 进入
updateDOM函数后,首先检查if (elems === undefined)。 - 因为这是第一次运行,
elems尚未被赋值,所以条件为true。 - 程序进入首次渲染逻辑块。
- 进入
- 批量转换 (
elems = vDOM.map(convert)):- 这一步与之前的版本完全相同。
.map()遍历vDOM(此时由初始的空name生成),对每个元素描述调用convert函数。convert函数负责在 C++ DOM 中创建真实 DOM 节点(一个<input>和两个<div>),并返回对应的 JS访问器对象。.map()收集所有返回的访问器对象,形成一个数组,并最终赋值给全局变量elems。
- 持久化 DOM 引用:
- 关键点:
elems现在被填充了。它包含三个访问器对象,分别持久地指向了我们在 DOM 中创建的三个真实元素。这个elems数组在后续的更新中将不再被重新赋值。
- 关键点:
- 渲染到页面 (
document.body.append(...elems)):- 使用
append方法(与replaceChildren效果类似,但更适合首次添加),并将elems数组通过扩展运算符展开。 - 三个新创建的 DOM 元素被一次性添加到
<body>中,并显示在页面上。
- 使用
首次渲染完成
- 此时,页面上已经显示了初始 UI(一个空的输入框,和两个
div)。 updateDOM函数首次执行完毕,从调用栈弹出。- 最重要的是,我们现在有了一个持久化的
elems数组,它像一座桥梁,连接着我们的 JS 代码和页面上真实存在的 DOM 元素。 - 这个持久化的引用是后续进行精确 DOM 修改的基础。我们不再需要重新查询 DOM,而是可以直接通过
elems[0],elems[1]等来访问和修改对应的 DOM 节点。
总结
- 本节清晰地展示了“挂载”阶段的完整流程。
- 它通过一个简单的条件判断,将应用的生命周期分为了“首次渲染”和“后续更新”两个阶段。
- “首次渲染”负责从无到有地创建所有 DOM 结构并建立持久引用。
- “后续更新”将利用这些引用,进行高效的、增量的修改。
43-dom-diffing-user-interaction
核心内容:用户交互与更新流程
- 本节演练了在用户交互后,我们的最终 Diffing 模型是如何进行 UI 更新的。
演练流程
- 用户操作与数据更新:
- 用户在输入框中输入 "FM"。
handle(e)函数被触发。- 通过事件对象
e.target.value,获取到输入值 "FM"。 name变量被更新为"FM"。
updateDOM再次执行 (更新阶段):setInterval再次触发updateDOM。- 条件判断:
if (elems === undefined)为false,因为elems在首次渲染时已经被赋值。 - 程序进入
else更新逻辑块。
- 更新逻辑块内部:
- 步骤 1: 存档旧的虚拟 DOM (
previousVDOM = [...vDOM]):- 使用扩展运算符
...创建一个vDOM的浅拷贝,并赋值给previousVDOM。 - 现在,
previousVDOM保存了上一个渲染周期的 UI“蓝图”(name为空字符串时的版本)。
- 使用扩展运算符
- 步骤 2: 生成新的虚拟 DOM (
vDOM = createVDOM()):- 再次调用
createVDOM()。因为此时name的值是"FM",它会返回一个全新的虚拟 DOM 数组,其中包含了更新后的内容。
- 再次调用
- 步骤 3: 调用差异比对算法 (
findDiff(previousVDOM, vDOM)):- 这是整个更新流程的核心。
- 我们将新旧两个虚拟 DOM 作为参数,传递给
findDiff函数,让它去找出具体的差异。
- 步骤 1: 存档旧的虚拟 DOM (
小结
- 本节清晰地展示了更新流程的前半部分:捕获用户输入 -> 更新 JS 数据 -> 准备好新旧两份虚拟 DOM -> 启动差异比对。
- 关键概念:
- 区分渲染阶段: 通过
if/else区分了“挂载”(Mounting)和“更新”(Updating)。 - 状态快照:
previousVDOM和vDOM分别代表了 UI 在变化前后的两个“快照”。
- 区分渲染阶段: 通过
- 所有的准备工作已经完成,接下来的悬念就是
findDiff函数内部究竟是如何工作的。
44-diffing-algorithm
核心内容:Diffing 算法的实现与执行
- 本节是整个 UI Hard Parts 的高潮,详细演练了
findDiff函数如何工作,以实现高效的 DOM 更新。
findDiff 函数的执行
- 函数调用:
findDiff被调用,接收previousVDOM(旧蓝图)和vDOM(新蓝图)作为参数。 - 循环遍历: 函数内部有一个
for循环,从i = 0开始,遍历虚拟 DOM 中的每一个元素。 - 差异比较 (Diffing):
- 在循环的每一步,使用
JSON.stringify()来比较新旧两个 vDOM 中对应位置的元素描述。 if (JSON.stringify(previous[i]) !== JSON.stringify(current[i])) { ... }
- 在循环的每一步,使用
演练流程:逐个元素比对
- 当
i = 0(输入框):previous[0](旧):['input', '', handle]-> 字符串化current[0](新):['input', 'FM', handle]-> 字符串化- 结果: 两个字符串不相等,发现差异!
- 执行更新:
elems[0].value = current[0][1];- 通过我们持久化的访问器数组
elems,直接定位到真实的<input>DOM 节点。 - 只更新其
.value属性为新的内容 "FM"。
- 当
i = 1(第一个 div):previous[1](旧):['div', 'Hello, !']current[1](新):['div', 'Hello, FM!']- 结果: 两个字符串不相等,发现差异!
- 执行更新:
elems[1].textContent = current[1][1];- 定位到第一个真实的
<div>DOM 节点。 - 只更新其
.textContent属性。
- 当
i = 2(第二个 div):previous[2](旧):['div', 'great job']current[2](新):['div', 'great job']- 结果: 两个字符串完全相等,没有差异。
- 执行更新:
if条件不满足,不执行任何 DOM 操作。
结论
- 高效的胜利: 我们成功地避免了销毁和重建所有 DOM 元素。取而代之的是,我们进行了两次精确的、小范围的属性修改,并完全跳过了对未变化元素的任何操作。
- CPU 周期的节省: 这种方式极大地提升了性能,节省了大量的计算资源。
- 完整的范式: 我们最终构建了一个完整的、自洽的声明式 UI 编程范式:
- 单向数据流: 保证了数据的可预测性。
- 虚拟 DOM: 提供了声明式的开发体验和可组合性。
- Diffing 算法: 解决了性能问题,将声明式的描述高效地转换为命令式的 DOM 操作。
UI Hard Parts 核心思想总结
- UI 开发的本质是展示内容并允许用户改变内容。
- 最大的挑战在于,在浏览器中,数据(在 JS 中)和视图(在 C++ DOM 中)是分离的。
- 我们通过一系列“约束”和“模式”来解决这个问题:
- 单向数据流: 建立清晰的数据流动方向。
- JS 作为唯一数据源: 避免多源导致的状态混乱。
- 虚拟 DOM: 在 JS 中创建 UI 的“蓝图”,以实现声明式编程。
- Diffing: 智能地比较“蓝图”差异,实现高效的、最小化的真实 DOM 更新。
- 这整个过程,就是从零开始,一步步推导出 React 等现代前端框架背后的核心原理。
45-wrapping-up
核心思想总结
- 单一数据源 (Single Source of Truth)
- 在复杂的浏览器环境中(HTML, DOM, JS, CSSOM 等),我们强制规定JavaScript 中的数据是驱动 UI 的唯一来源。
- 这种约束带来了极大的可预测性。UI 的任何状态都可以追溯到唯一的 JS 数据状态。
- 数据驱动视图 (Data Propagation to the View)
- 我们建立了一种模式,其中视图是数据的直接函数。
- 开发者只需一次性地描述“视图应该如何根据数据来呈现”,之后数据的任何变化都会自动(通过我们的更新机制)地“传播”到视图上。
- UI 的可组合性 (UI Composition with JavaScript)
- 通过虚拟 DOM(JS 中的 UI 描述)和函数式组件,我们能够在 JavaScript 中以模块化的方式构建 UI。
- 我们可以像拼接乐高积木一样,组合、重排这些 UI 单元,代码的结构直观地反映了页面的结构。
- 性能优化 (Efficiency Improvements)
- 问题: 声明式编程的直接实现(每次都从零重建 UI)是极其低效的。
- 解决方案:
- 状态钩子 (Hooks):用按需更新代替了持续的循环,解决了“何时”更新的问题。
- 差异比对与协调 (Diffing & Reconciliation):通过比较新旧虚拟 DOM,只对真实 DOM 进行最小化的、必要的修改,解决了“如何”更新的问题。
最终结论
- 我们从 Web UI 开发最根本的挑战出发,通过一系列逐步演进的编程模式,最终推导出了一套功能强大、体验良好且性能高效的解决方案。
- 这个方案的核心就是 单向数据流 + 虚拟 DOM + Diffing 算法。
- 这不仅是对某个具体框架的理解,更是对现代前端 UI 工程领域底层设计哲学的深刻洞察。通过这个“硬核”的旅程,我们真正理解了为什么现代前端会是今天这个样子。