前端进阶第三天进阶 HTTP协议、页面渲染、数据驱动、模板引擎、路由库

编程知识 更新时间:2023-05-03 01:27:23

07 HTTP 协议和前端开发有什么关系?

HTTP 请求的场景相对复杂,对应的 HTTP 协议也是各式各样的,因此很多时候大家都认为内容太多太杂,认为学习 HTTP 协议性价比太低了。

其实我们日常开发中,经常会使用到 cookie、浏览器的缓存机制、各种形式的网络连接(比如 Websocket),这些网络请求相关的场景都跟 HTTP 协议有密切的关系。

对于前端开发来说,HTTP 协议基本上不会离开嘴边。HTTP 协议的内容很多很杂,这里我先给大家梳理一下它的设计和演变。

认识 HTTP 协议

上一讲,我们知道了网络请求的过程,当服务端建立起与客户端的 TCP 连接之后,服务端会持续监听客户端发起的请求。接下来,客户端将发起 HTTP 请求,请求内容通常包括请求方法、请求的资源等,服务端收到请求后会进行回复,回复内容通常包括 HTTP 状态、响应消息等。

可以看到,网络请求的过程包括两个步骤:客户端发送请求,服务器返回响应。这就是HTTP 协议的主要特点:遵循经典的“客户端-服务端”模型

除此之外,HTTP 协议还被设计得简单易读。在 HTTP/2 之前,HTTP 协议是语义可读的,我们可以直观地获取其中的内容。比如:

  • HTTP 请求方法:代表着客户端的动作行为(GET-获取资源/POST-提交资源/PUT-修改资源/DELETE-删除资源)。

  • HTTP 状态码:代表着当前请求的状态(1XX-提示信息/2XX-成功/3XX-重定向/4XX-客户端错误/5XX-服务端错误)。

  • HTTP 消息头:客户端和服务端通过requestresponse传递附加信息。

通过 HTTP 协议,我们可以看到该请求是否成功、错误原因是哪些、请求是否使用了缓存、请求和响应数据是否符合预期等。想必这些内容你多少都已经有所了解,因此这里我不再赘述。

前面我们说到,HTTP 协议在 HTTP/2 之前是语义可读的,那么 HTTP/2 之后发生了什么呢?我们可以看一下 HTTP 协议的演变过程。

HTTP 协议的演变

HTTP 协议从被创造以来,一直在不断演变着:从 HTTP/1.0、HTTP/1.1,到 HTTP/2、HTTP/3,HTTP 协议在保持协议简单性的同时,拓展了灵活性,提供越来越快、更加可靠的传输服务。

HTTP/1.0 到 HTTP/1.1,主要实现了对 TCP 连接的复用。 最初在 HTTP/1.0 中,每一对 HTTP 请求和响应都需要打开一个单独的 TCP 连接。这样的方式对资源消耗很大,因此 HTTP/1.1 中引入了持久连接的概念,通过设置Connection头部为keep-alive的方式,可以让 TCP 连接不会关闭。该功能避免了 TCP 连接的重新建立,客户端可在已建立的 TCP 连接上,长时间地对同一个服务端的发起请求。

HTTP/1.1 到 HTTP/2,主要实现了多个请求的复用。 HTTP/2 通过将 HTTP 消息拆分为独立的帧,进行交错发送,实现在同一个连接上并行多个请求,来减少网络请求的延迟。为了实现多路复用,HTTP/2 协议对 HTTP 头部进行了二进制编码,因此不再语义可读。除此之外,HTTP2 还实现了 Header 压缩、服务端主动推动、流优先级等能力。

HTTP/2 到 HTTP/3,主要实现了基于 UDP 协议、更快的传输。 HTTP/3 使用了基于 UDP 的 QUIC 协议,实现了又快又可靠的传输。由于 UDP 协议中没有错误检查内容,因此可以更快地实现通信。同时,QUIC 协议负责合并纠错、重建丢失的数据,解决了 UDP 协议传输丢包的问题。

总的来说,HTTP 协议的演变过程主要围绕着传输效率和速度上的优化,我们可以通过升级 HTTP 协议来优化前端应用。除此之外,我们在日常的工作中,同样可以借鉴 HTTP 协议的优化手段。比如,可以使用资源压缩、资源复用等技术手段,来优化前端性能。技术常常是通用的,我们在学习一些看起来不相关的内容时,会发现其实很多技术转变都是值得思考和参考的。

下面我们来看一下常见的一些 HTTP 协议场景。

HTTP Cookie

HTTP 协议是无状态的,这意味着在同一个 TCP 连接中,先后发起的请求之间没有任何关系。这给服务端带来了挑战:用户在同一个网站中进行连续的操作,服务端无法知道这些操作来自哪里。

使用 HTTP Cookie 可以解决这个问题。当服务端将 HTTP 响应返回给客户端时,通过在响应头里面添加一个Set-Cookie信息,浏览器收到带Set-Cookie信息的响应后会将 Cookie 保存,在后面发送给该服务端的每个请求中,都会自动带上 Cookie 信息。服务端根据 Cookie 信息,就能取得客户端的数据信息。

由于 Cookie 信息是被浏览器识别并自动保存和发送的,因此在默认情况下,浏览器关闭之后它就会被自动删除。但我们也可以通过指定过期时间(Expires)或者有效期(Max-Age),来让 Cookie 获得更久的有效期。

需要注意的是,某个网站在设置了 Cookie 之后,所有符合条件(有效期、域名、路径、适用站点等)的请求都会被自动带上 Cookie。这带来了一个 Web 安全隐患:服务端只知道请求来自某个用户的浏览器,却不知道请求本身是否用户自愿发出的。

利用这一漏洞,攻击者可通过一些技术手段(图片地址、超链接等)欺骗用户的浏览器访问曾经认证过的网站,并利用用户的登录态进行一些操作,可能导致用户信息泄露、资产被转移、在不知情的情况下发送信息等,带来了恶劣的后果。这便是我们常说的 Web 安全问题之一:跨站请求伪造(CSRF)。

为了应对这种情况,我们可以校验 HTTP 请求头中的Referer字段,这个字段用以标明请求来源于哪个地址。但由于该字段可能会被篡改,因此只能作为辅助校验手段。

防范跨站请求伪造攻击的有效方法,就是避免依赖浏览器自动带上的 Cookie 信息。我们可以使用其他方式校验用户登录态,比如将用户登录态保存在浏览器缓存中,在发送请求的时候添加用于标识用户的参数值,现在大多数应用也是使用 Token 来进行用户标识。

除了 HTTP Cookie 之外,浏览器中 HTTP 缓存机制也同样依赖 HTTP 协议。

HTTP 缓存

缓存常常被用作性能优化的技术方案之一,通过缓存我们可以有效地减少资源获取的耗时,减少用户的等待时长,从而提升用户的体验。

其中,我们可以通过 HTTP 协议,设置浏览器对 HTTP 响应资源进行缓存。使用浏览器缓存后,当我们再发起 HTTP 请求时,如果浏览器缓存发现请求的资源已经被存储,它会拦截请求并返回该资源的副本,不需要再去请求服务端获取资源,因此减少了 HTTP 请求的耗时,同时也能有效地缓解服务端压力。

一般来说,HTTP 缓存只能存储 GET 请求的响应内容,对于这些响应内容可能会存在两种情况:

  1. 不缓存内容,每次请求的时候都会从服务端获取最新的内容;

  2. 设置了缓存内容,则在有效期内会从缓存获取,如果用户刷新或内容过期则去服务端获取最新的内容。

那么,要如何给 GET 请求设置缓存呢?在浏览器中,便是依靠请求和响应中的头信息来控制缓存的。根据缓存的行为,我们可以将它们分为强制缓存和协商缓存两种。

  1. 强制缓存, 在规定有效期内,直接使用缓存。可以通过以下的方式使用强制缓存:

    1. 服务端通过设置ExpiresCache-Control,和客户端约定缓存内容的有效时间;

    2. 若符合缓存条件,浏览器响应HTTP 200(from cache)

  2. 协商缓存, 与服务端协商是否使用缓存。可以通过以下的方式使用协商缓存:

    1. 服务端通过设置If-Modified-SinceIf-None-Match,和客户端约定标识协商缓存的值;

    2. 当有效期过后,浏览器将缓存信息中的 Etag 和 Last-Modified 信息,分别使用 If-None-Match 和 If-Modified-Since 请求头设置,提交给服务端。

    3. 若符合缓存条件,服务端则响应HTTP 304,浏览器将从缓存读数据。

若以上缓存条件均不符合,服务端响应HTTP 200,返回更新后的数据,同时通过响应头更新 HTTP 缓存设置。整个过程可以用下面的流程图来表示:

浏览器会在第一次请求完服务端后得到响应,通过适当地设置响应头信息,我们可以使用更多的缓存资源,从而提升网站的响应速度和性能,给到用户更好的体验。

除了常见的 Cookie 和 GET 请求的缓存,客户端和服务端在实现双向通信的时候,同样会依赖 HTTP 协议来完成。

客户端服务端双向通信

客户端和服务端的通信方式有很多种,大多数场景下都是由客户端主动发送数据给服务端,但在特定的场景下(如多人协作、在线游戏)客户端还需要和服务端保持实时通信,此时需要使用双向通信。

常见的双向通信方式包括 HTTP 短轮询(polling)、HTTP 长轮询(long-polling)、XHR Streaming、Server-Sent Events、Websocket 等。

其中,最简单粗暴的莫过于 HTTP 短轮询,客户端每隔特定的时间(比如 1s)便向服务端发起请求,获取最新的资源信息。该方式会造成较多的资源浪费,尤其当服务端内容更新频率低于轮询间隔时,就会造成服务端资源、客户端资源的浪费。除此之外,过于频繁的请求也会给服务端造成额外的压力,当服务端负载较高的时候,甚至可能导致雪崩等情况发生。

HTTP 长轮询解决了短轮询的一些问题,长轮询实现特点主要为当客户端向服务端发起请求后,服务端保持住连接,当数据更新响应之后才断开连接。然后客户端会重新建立连接,并继续等待新数据。此技术的主要问题在于,在重新连接过程中,页面上的数据可能会过时且不准确。

相比 HTTP 长轮询,XHR Streaming 可以维护客户端和服务端之间的连接。但使用 XHR Streaming 过程中,XMLHttpRequest对象的数量将不断增长,因此在使用过程中需要定期关闭连接,来清除缓冲区。

SSE(Server-Sent Events)方案思想便是 XHR Streaming,主要基于浏览器中EventSourceAPI 的封装和协议。它会对 HTTP 服务开启一个持久化的连接,以text/event-stream格式发送事件, 会一直保持开启直到被要求关闭。

最后我们来介绍 WebSocket,它实现了浏览器与服务端全双工通信。前面我们提到,HTTP 短轮询、长轮询都会带来额外的资源浪费,因此 Websocket 在实现实时通信的同时,能更好地节省服务端资源和带宽。

Websoctet 是如何实现全双工通信的呢?Websocket 建立在 TCP 协议之上,握手阶段采用 HTTP 协议,但这个 HTTP 协议的请求头中,有以下的标识性内容。

  • Connection: UpgradeUpgrade: websocket:表示这个连接将要被转换为 WebSocket 连接。

  • Sec-WebSocket-Key:向服务端提供所需的信息,以确认客户端有权请求升级到 WebSocket。

  • Sec-WebSocket-Protocol:指定一个或多个的 WebSocket 协议。

  • Sec-WebSocket-Version:指定 WebSocket 的协议版本。

如果服务端同意启动 WebSocket 连接,会在握手过程中的 HTTP 协议中返回包含Sec-WebSocket-Accept的响应消息,接下来客户端和服务端便建立 WebSocket 连接,并通过 WebSocket 协议传输数据。

由于不再需要通过 HTTP 协议通信,省去请求头等内容设置,Websocket 数据格式会更加轻量,通信更加高效,性能开销也相应地降低。除此之外,不同于 HTTP 协议,Websocket 协议没有同源限制,因此客户端可以与任意服务端通信。

以上这些,都是客户端和服务端双向通信的一些解决方案,我帮你简单整理成思维导图:

在依赖双向通信的场景中,这些方案并没有绝对的最优解,更多时候都是不同场景和架构设计下的选择。

如果你去仔细研究在线协作的办公工具,比如谷歌文档、石墨文档、金山文档、腾讯文档,你会发现它们的双向通信都分别使用了不同的解决方案。

小结

今天,我主要介绍了 HTTP 协议相关的内容,同时介绍了较常见的一些 HTTP 协议的应用场景,包括 HTTP Cookie、HTTP 缓存、客户端和服务端双向通信。

关于 HTTP 缓存过程想必你也应该掌握了,那你知道当我们在浏览器中分别使用F5ctrl + F5快捷键刷新页面的时候,HTTP 的缓存过程又是怎样呢?把你的想法写在留言区~

我们在日常开发中就会经常遇到网络请求失败、调试异常等情况,如果不了解 HTTP 协议会极大地影响调试效率。因此对 HTTP 协议的掌握,对于前端的联调和开发过程中是必不可少的。


精选评论

**北:

如果想防止更新自动删除数据,就需要用到本地存储来将刷新前数据存起来然后再设置

    讲师回复:

    这是一个思路,但是可能使用场景会少一些~

**峰:

需要注意的是,某个网站在设置了 Cookie 之后,所有符合条件(有效期、域名、路径、适用站点等)的请求都会被自动带上 Cookie。———这里的设置cookie什么意思?浏览器设置的,还是服务器设置的?

    讲师回复:

    当服务器收到 HTTP 请求时,服务器可以在响应头里面添加一个 Set-Cookie 选项。浏览器收到响应后通常会保存下 Cookie,之后对该服务器每一次请求中都通过 Cookie 请求头部将 Cookie 信息发送给服务器。
也就是说,Cookie 是服务器种下的,后续符合条件的请求会被自动带上则是浏览器默认的行为。

**飞:

ctrl + F5 应该等同于 dev tools里面设置 no cache,F5 还是会按照缓存逻辑去命中缓存的。

    讲师回复:

    基本上差不多,但是如果服务器忽略无缓存头,则即使Ctrl + F5也会返回该页面的旧版本

08 深入剖析浏览器中页面的渲染过程

作为前端开发,我们的日常工作中除了编码以外,几乎大多数时间都在跟浏览器打交道。所以我们更加要吃透浏览器,掌握它到底是怎样将我们编写的代码渲染到页面中的。

所以,今天我主要结合浏览器的内部工作原理,深入剖析下浏览器中页面的渲染过程。

第 6 讲我们介绍了一个 HTTP 请求在浏览器中的请求过程,该过程将浏览器作为单独的对象,描述客户端和服务端之间的通信过程。那么,当我们在浏览器的地址栏中输入 URL,按下回车键,到页面在浏览器中渲染完成,这个过程中浏览器的内部发生了什么了呢?

为了了解这个过程,首先我们要了解浏览器的内部结构。

浏览器的内部结构

从结构上来说,浏览器主要包括了八个子系统:用户界面、浏览器引擎、渲染引擎、网络子系统、JavaScript 解释器、XML 解析器、显示后端、数据持久性子系统。

这些子系统组合构成了我们的浏览器。页面的加载和渲染过程,离不开网络子系统、渲染引擎、JavaScript 解释器和浏览器引擎。

以前端开发最常使用的 Chrome 浏览器为例, Chrome 浏览器是使用多进程架构的方式来管理这些子系统。

Chrome 多进程架构

Chrome 浏览器采用的多进程架构,主要包括四个进程:

  1. 浏览器进程:选项卡之外的所有内容都由浏览器进程处理,浏览器进程则主要用于控制和处理用户可见的 UI 部分(包括地址栏,书签,后退和前进按钮)和用户不可见的隐藏部分(例如网络请求和文件访问)。

  2. GPU 进程:该进程用于完成图像处理任务,同时还支持分解成多个进程进行处理。

  3. 渲染器进程:Chrome 浏览器中支持多个选项卡,其中每个选项卡在单独的渲染器进程中运行,渲染器进程主要用于控制和处理选项卡中的网站内容显示。

  4. 插件进程:管理 Chrome 浏览器中的各个插件。

对于“在浏览器的地址栏中输入 URL,按下回车键,到浏览器渲染页面”这个过程,浏览器内部会通过浏览器进程和渲染器进程,进行很多交互逻辑,最终才得以将页面内容显示在屏幕上。

其中,浏览器进程和渲染器进程同样支持多线程,包括以下这些线程。


这些线程其实并不陌生,在前面介绍的内容中有提到,比如:

  • 在页面的加载过程中,涉及 GUI 渲染线程与 JavaScript 引擎线程间的互斥关系,因此页面中的<script><style>元素设计不合理会影响页面加载速度;

  • 在 UI 线程、网络线程、存储线程、浏览器事件触发线程、浏览器定时器触发线程中,I/O 事件通过异步任务完成时触发的函数回调,解决了单线程的 Javascript 阻塞问题。

下面我们再来看下 Chrome 浏览器中页面的渲染过程,包括浏览器进程和线程如何通信来显示页面。

浏览器中页面的渲染过程

首先我们将浏览器中页面的渲染过程分为两部分。

  • 页面导航:用户输入 URL,浏览器进程进行请求和准备处理。

  • 页面渲染:获取到相关资源后,渲染器进程负责选项卡内部的渲染处理。

1. 页面导航过程

当用户在地址栏中输入内容时,浏览器内部会进行以下处理。

  1. 首先浏览器进程的 UI 线程会进行处理:如果是 URI,则会发起网络请求来获取网站内容;如果不是,则进入搜索引擎。

  2. 如果需要发起网络请求,请求过程由网络线程来完成。HTTP 请求响应如果是 HTML 文件,则将数据传递到渲染器进程;如果是其他文件则意味着这是下载请求,此时会将数据传递到下载管理器。

  3. 如果请求响应为 HTML 内容,此时浏览器应导航到请求站点,网络线程便通知 UI 线程数据准备就绪。

  4. 接下来,UI 线程会寻找一个渲染器进程来进行网页渲染。当数据和渲染器进程都准备好后,HTML 数据通过 IPC 从浏览器进程传递到渲染器进程中。

  5. 渲染器进程接收 HTML 数据后,将开始加载资源并渲染页面。

  6. 渲染器进程完成渲染后,通过 IPC 通知浏览器进程页面已加载。

以上是用户在地址栏输入网站地址,到页面开始渲染的整体过程。为了方便理解,我帮你梳理了一个流程图:

如果当前页面跳转到其他网站,浏览器将调用一个单独的渲染进程来处理新导航,同时保留当前渲染进程来处理像unload这类事件。

在上面的过程中可以看到,页面导航主要依赖浏览器进程。其中,上述过程中的步骤 5 便是页面的渲染部分,该过程同样依赖渲染器进程,我们一起来看看。

2. 页面渲染过程

前面说过,渲染器进程负责选项卡内部发生的所有事情,它的核心工作是将 HTML、CSS 和 JavaScript 转换为可交互的页面。

整体上,渲染器进程渲染页面的流程基本如下。

  • 解析(Parser):解析 HTML/CSS/JavaScript 代码。

  • 布局(Layout):定位坐标和大小、是否换行、各种position/overflow/z-index属性等计算。

  • 绘制(Paint):判断元素渲染层级顺序。

  • 光栅化(Raster):将计算后的信息转换为屏幕上的像素。

大致流程如下图:

我们来分别看下。

1. 解析。

渲染器进程的主线程会解析以下内容:

  • 解析 HTML 内容,产生一个 DOM 节点树;

  • 解析 CSS,产生 CSS 规则树;

  • 解析 Javascript 脚本,由于 Javascript 脚本可以通过 DOM API 和 CSSOM API 来操作 DOM 节点树和 CSS 规则树,因此该过程中会等待 JavaScript 运行完成才继续解析 HTML。

解析完成后,我们得到了 DOM 节点树和 CSS 规则树,布局过程便是通过 DOM 节点树和 CSS 规则树来构造渲染树(Render Tree)。

2. 布局。

通过解析之后,渲染器进程知道每个节点的结构和样式,但如果需要渲染页面,浏览器还需要进行布局,布局过程便是我们常说的渲染树的创建过程。

在这个过程中,像headerdisplay:none的元素,它们会存在 DOM 节点树中,但不会被添加到渲染树里。

布局完成后,将会进入绘制环节。

3. 绘制

在绘制步骤中,渲染器主线程会遍历渲染树来创建绘制记录。

需要注意的是,如果渲染树发生了改变,则渲染器会触发重绘(Repaint)和重排(Reflow)。

  • 重绘:屏幕的一部分要重画,比如某个 CSS 的背景色变了,但是元素的几何尺寸没有变。

  • 重排:元素的几何尺寸变了(渲染树的一部分或全部发生了变化),需要重新验证并计算渲染树。

为了不对每个小的变化都进行完整的布局计算,渲染器会将更改的元素和它的子元素进行脏位标记,表示该元素需要重新布局。其中,全局样式更改会触发全局布局,部分样式或元素更改会触发增量布局,增量布局是异步完成的,全局布局则会同步触发。

重排需要涉及变更的所有的结点几何尺寸和位置,成本比重绘的成本高得多的多。所以我们要注意以避免频繁地进行增加、删除、修改 DOM 结点、移动 DOM 的位置、Resize 窗口、滚动等操作,因为这些操作可能会导致性能降低。

4. 光栅化

通过解析、布局和绘制过程,浏览器获得了文档的结构、每个元素的样式、绘制顺序等信息。将这些信息转换为屏幕上的像素,这个过程被称为光栅化。

光栅化可以被 GPU 加速,光栅化后的位图会被存储在 GPU 内存中。根据前面介绍的渲染流程,当页面布局变更了会触发重排和重绘,还需要重新进行光栅化。此时如果页面中有动画,则主线程中过多的计算任务很可能会影响动画的性能。

因此,现代的浏览器通常使用合成的方式,将页面的各个部分分成若干层,分别对其进行栅格化(将它们分割成了不同的瓦片),并通过合成器线程进行页面的合成。

合成过程如下:

  1. 当主线程创建了合成层并确定了绘制顺序,便将这些信息提交给合成线程;

  2. 合成器线程将每个图层栅格化,然后将每个图块发送给光栅线程;

  3. 光栅线程栅格化每个瓦片,并将它们存储在 GPU 内存中;

  4. 合成器线程通过 IPC 提交给浏览器进程,这些合成器帧被发送到 GPU 进程处理,并显示在屏幕上。

合成的真正目的是,在移动合成层的时候不用重新光栅化。因为有了合成器线程,页面才可以独立于主线程进行流畅的滚动。

到这里,页面才真正渲染到屏幕上。

我们在绘制页面的时候,也可能会遇到很多奇怪的渲染问题,比如使用了transform:scale可能会导致某些浏览器中渲染模糊,究其原因则是由于光栅化过程导致的。像前面所说,前端开发需要频繁跟浏览器打交道,所谓知己知彼百战不殆,我们应该对其运行过程有更好的了解。

小结

今天我主要介绍了浏览器的组成,可分为用户界面、浏览器引擎、渲染引擎、网络子系统、JavaScript 解释器、XML 解析器、显示后端、数据持久性子系统八个子系统,并以 Chrome 浏览器为例,从浏览器内部分工角度来介绍页面的渲染过程。

掌握页面的渲染过程,有利于我们进行一些性能优化,尤其如果涉及动画、游戏等频繁绘制的场景,渲染性能往往是需要不断进行优化的瓶颈。

今日小作业:

  1. 你认为 Chrome 浏览器中,为什么每个选项卡都在单独的渲染器进程中运行呢?

  2. 如何检测页面是否无响应呢?

把你的想法写在留言区吧!


精选评论

**2279:

老师,看其它的一些关于浏览器渲染过程,都是将渲染树的构建看成是一个独立的阶段呢。看了你的渲染过程不由得让我产生了究竟是渲染树的构建是和布局阶段一起的呢,还是说独立的怀疑,望指正。😅

    讲师回复:

    文中介绍渲染过程,主要分为:解析(Parser)、布局(Layout)、绘制(Paint)、光栅化(Raster) 四个过程,渲染树的构建属于布局过程,应该写得比较清楚了,请问你的疑问是?

**华:

老师,是不是分层后,浏览器就能够只渲染某一层的内容,提升效率

    讲师回复:

    分层的使用场景很多是一些动画效果,比如 transform 和 opacity 的使用,它们只更改影响合成的属性,不会导致重新计算和布局,会让渲染更加流畅

**你辣条就跑:

老师,检测页面是否无响应的使用场景可以说下吗?

    讲师回复:

    可以使用 service worker 对页面进行心跳检测,当心跳断开之后就可以认为页面崩溃/无响应,将相关信息做上报就可以监测的

**哈:

现代浏览器架构网络独立成一个进程了,所以打开一个tab至少包含四个进程:浏览器主进程、渲染进程、网络进程、GPU进程。关于插件,一个插件开启一个进程。

**4344:

老师,您好!我看在栅格化之后进行合成,文中提到了合成线程和光栅线程。请问这两个线程是属于哪个进程呢?

    讲师回复:

    合成器线程、光栅线程都运行在渲染器进程内部,从而高效,流畅地渲染页面。

**波:

避免一个选项卡挂了,整个浏览器渲染进程挂了

**阳:

网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程,现在应该有五个进程了吧

    讲师回复:

    本文写作时是参照《Inside look at modern web browser》该官方文章来进行说明的,当时文章中介绍除了 GPU 进程、浏览器进程、渲染器进程、插件进程以外,还有实体进程和拓展程序进程。
如今,除了以上进程,Chrome 浏览器独立出来的进程还包括 Network、Storage、Audio 等等,如果你想查看 Chrome 中正在运行多少个进程,请点击浏览器右上角的选项菜单,选择“更多工具”,然后选择“任务管理器”,就可以查看了~

**洲:

事件驱动使编写代码的流程更加清晰,但代码量大,维护相对困难,改变一处往往要牵涉好几处的更改数据驱动从应用程序中分离出视图和模型,代码量更少,今后如果要改变某些数据,只需改变对应模型层上的数据

    讲师回复:

    赞!

*聪:
  1. 每个选项卡都在单独的渲染器进程中运行是为了各个选项卡能保持独立互不干扰,防止因某一个选项卡崩溃导致所有页面都崩溃。2.检查页面无响应是不是可以利用事件循环相关,比如判断setTimeout中的回调是否被执行了,无响应的话,应该是主线程卡死,宏任务和微任务的事件都没有机会运行
    讲师回复:

    1. 没错。2. 当前页面无响应,是否可以考虑使用跨页面的技术来支持呢?比如 worker?(啊我竟然说出来了)

09 改善编程思维:从事件驱动到数据驱动

编程是将逻辑通过代码实现的过程,因此代码的编写效率和质量往往取决于我们的逻辑思维,以及如何将思考的内容使用代码来表达。

今天我会介绍事件驱动和数据驱动两种编码思维模式,给你带来更好的开发体验。

事件驱动

首先,我们先来看看什么是事件驱动的编程方式。

前端开发在实现功能的时候,会更倾向于使用事件驱动,这是因为受到 JavaScript 语言的设计和使用场景的影响。

作为浏览器脚本语言,JavaScript 的主要用途是与用户互动、操作 DOM,实现页面 UI 和交互操作,属于 GUI(图形用户界面)编程。而 GUI 则是基于事件 I/O 模式的编程方式。

GUI 与事件

GUI 应用程序注重与用户的交互,大部分的程序执行需要等到用户的交互动作发生之后,所以 GUI 程序的执行取决于与用户的实时交互情况。

然而,用户在访问程序期间,与程序进行交互的频率并不高。若不停轮询获取用户输入(类似 HTTP 短轮询),不仅资源利用率低,还无法做到真正的同步。因此,GUI 程序会将执行流程交由用户控制,当用户触发事件的时候进行响应,调用预先绑定好的代码来对事件进行处理。

JavaScript 也一样,前面我们介绍了事件循环机制,所有的异步事件都会通过执行回调的方式来触发相应的逻辑执行。因此,前端开发在实现业务功能的时候,更容易倾向与用户交互流程(用户输入->事件响应->执行相应的代码逻辑->更新页面状态)结合,来完成与用户的交互操作。

我们在写代码实现页面功能的时候,思路常常是这样的:

  1. 编写静态页面(HTML 和样式);

  2. 在特定的元素上添加事件监听,监听用户交互(点击、输入、拖拽)等事件;

  3. 将事件绑定到对应的函数和处理逻辑,比如获取用户输入/应用状态、计算并更新状态等;

  4. 根据计算后的数据状态,更新相应的页面元素。

通俗地说,事件驱动思维是从事件响应出发,来完成应用的设计和编程。这种编程方式实现起来既简单又清晰,所以很多开发者会选择(或是下意识地)使用事件驱动方式来写代码。

我们来看看基于事件驱动的编程流程是怎样的。

事件驱动的编码流程

这里我们以实现一个提交表单的页面作为例子,如果用事件驱动的方式来实现,大致分为三个步骤。

第一步:编写静态页面。

<!-- 实现静态页面 -->
<form>
  Name:
  <p id="name-value"></p>
  <input type="text" name="name" id="name-input" />
  Email:
  <p id="email-value"></p>
  <input type="email" name="email" id="email-input" />
  <input type="submit" />
</form>

第二步:给对应的元素绑定对应的事件,例如通过addEventListener来监听input输入框的输入事件。

var nameInputEl = document.getElementById("name-input");
var emailInputEl = document.getElementById("email-input");
// 监听输入事件,此时 updateValue 函数未定义
nameInputEl.addEventListener("input", updateNameValue);
emailInputEl.addEventListener("input", updateEmailValue);

第三步:事件触发时,进行相关逻辑的处理(发起请求、更新页面内容等),并更新页面内容。我们将用户输入的内容更新到页面中展示。

var nameValueEl = document.getElementById("name-value");
var emailValueEl = document.getElementById("email-value");
// 定义 updateValue 函数,用来更新页面内容
function updateNameValue(e) {
  nameValueEl.innerText = e.srcElement.value;
}
function updateEmailValue(e) {
  emailValueEl.innerText = e.srcElement.value;
}

上述的三个步骤,便是基于事件驱动的思维实现的,是前端页面开发中很常见的编程思路。即使使用了前端框架(这里以 Vue 为例),也很容易用事件驱动的方式来实现上述功能:

<template>
  <!-- 1. 绘制 HTML -->
  <div>
    Name:
    <p>{{ name }}</p>
    <!-- 2. 使用 v-on 绑定事件,这里绑定 updateValue 方法 -->
    <input type="text" v-bind:value="name" v-on:input="updateValue" />
    <!-- 上面 input 可以简写为: -->
    <input type="text" v-model="name" />
  </div>
</template>
<script>
  export default {
    data() {
      return {
        name: "",
      };
    },
    methods: {
      // 3. change 事件触发时,更新数据
      updateValue(event) {
        this.name = event.target.value;
      },
    },
  };
</script>

这里可以看出,使用前端框架帮我们省去了元素选择、HTML 拼接并更新等这些工作,同时还可以直接在模板上绑定事件监听。至于前端框架是如何做到这些的,我们会在下一讲详细介绍。
现在,我们来回顾下事件驱动的编程思路:

  1. 开发静态页面;

  2. 在对应的元素上绑定事件;

  3. 实现被绑定的事件功能,例如获取数据、更新页面等。

代码实现思路的关注点在于触发了怎样的操作这个操作会导致什么后果(即需要做怎样的处理),因此事件驱动的思维方式会围绕着“操作”和“响应”进行。

那么,数据驱动又怎样的呢?

数据驱动

使用数据驱动的前提,在于将页面内容抽象为数据表达。基于抽象后的数据,这些数据会发生怎样的变化、又是如何被改变的,这些便是数据驱动的关注点。

数据驱动和事件驱动的最大差异是开发的视角。

  • 事件驱动会关注于“操作”和“响应”,基于流程实现编码。

  • 数据驱动则会关注于“数据”和“数据的变化”,基于状态实现编码。

下面我们同样以实现一个提交表单的页面为例,介绍数据驱动的编码流程(由于篇幅关系,以下代码会基于 Vue.js 实现)。

数据驱动的编码流程

对于提交表单的页面实现,数据驱动的编程方式同样可以分成三个步骤。

第一步:对页面进行抽象设计,使用合适的数据结构来表达。

抽象设计的内容会在第 14、15 讲内容中介绍,在这里我们先使用最简单的方式来设计:将页面中会变化和不会变化的内容隔离开,对其中会变化的内容进行抽象,再根据抽象结果来设计数据结构。

以页面中的表单为例,变化的部分包括两个输入框、两处展示输入框内容的文字。其中,输入框和展示部分关联着相同的内容,因此我们可以使用同一个数据来表达。

// 包括一个 name 和 一个 email 的值
export default {
  data() {
    return {
      name: "",
      email: "",
    };
  },
};

通过这样的方式,我们得到了两个抽象后的数据,一个是名字name,另外一个是邮件email,它们都是字符串格式。

第二步:这个表单除了具备nameemail两个数据,还包括两个分别用于改变数据的方法。因此,我们给该表单添加上更新值的方法:

export default {
  data() {
    return {
      name: "",
      email: "",
    };
  },
  methods: {
    // 更新 name 值
    updateNameValue(newName) {
      this.name = newName;
    },
    // 更新 email 值
    updateEmailValue(newEmail) {
      this.email = newEmail;
    },
  },
};

第三步:实现静态页面,并把数据和事件绑定到页面中。我们将步骤 1 中的数据绑定到页面中书输入框和展示值的地方,同时在需要监听事件的元素上绑定上述的方法。

<form>
  Name:
  <p>{{ name }}</p>
  <input
    type="text"
    name="name"
    v-bind:value="name"
    v-on:input="updateNameValue($event.target.value)"
  />
  Email:
  <p>{{ email }}</p>
  <input
    type="email"
    name="email"
    v-bind:value="email"
    v-on:input="updateEmailValue($event.target.value)"
  />
  <input type="submit" />
</form>

如果说步骤 1 和步骤 2 分别是抽象数据和抽象逻辑的过程,那么步骤 3 则是将抽象数据的逻辑具现化的过程。

通过将抽象的逻辑具现化,我们最终将抽象的结果实现为应用的功能,这就是数据驱动的实现过程。

数据驱动和事件驱动的区别

这里或许你会有些疑问,看起来只是写代码的顺序不一样而已,甚至写代码的顺序都是一样的,那事件驱动和数据驱动的区别在哪?

1. 数据驱动更容易将视图与逻辑解绑,能快速适应变更和调整。

对于数据驱动,我们在编程实现的过程中,更多的是思考数据状态的维护和处理,而无需过于考虑 UI 的变化和事件的监听。即使我们页面 UI 全部重构了,影响到的只有模板中绑定的部分(即上面的第 3 个步骤),功能逻辑并不会受到影响。

简单来说,基于数据模型设计的代码,即使经历了需求变更、页面结构调整、服务器接口调整,也可以快速地实现更新和支持。

2. 事件驱动更倾向于流程式开发,数据驱动倾向于数据状态的变更和流动。

事件驱动的特点是,以某个交互操作为起点,流程式地处理逻辑。流程式的代码,在遇到中间某个环节变更,就需要同时更新该变更点前后环节的流程交接。

例如,对于页面加载渲染的过程,可以分成加载页面逻辑->请求服务器->更新页面。如果需要在从服务器获取的基础上,新增读取本地缓存的环节,同时需要在加载页面逻辑更新页面两个环节进行衔接,并发地支持读取本地缓存请求服务器

而数据驱动的思考方式特点是,以数据为中心,思考数据的输入和输出。

  • 数据来源:比如从服务器获取、用户输入、重置清空。

  • 数据去处:比如提交给服务器。

同样的,如果我们需新增读取本地缓存的环节,在数据驱动的情况下,只是增加了一个数据来源,对于整个模型影响会小很多。

  • 数据来源:从服务器获取、用户输入、重置清空、读取本地缓存

事件驱动和数据驱动一个很重要的区别在于,事件驱动是从每个事件的触发(“操作”)为中心来设计我们的代码,数据驱动则是以数据为中心,通过接收事件触发和更新数据状态的方式来实现页面功能。

从事件驱动到数据驱动,可以理解为从用户交互为中心,调整成以数据的状态扭转为中心,来进行一些页面逻辑的实现。

事件驱动的方式相比于数据驱动,少了数据抽象设计的一部分,因此开发的时候可能很快就完成某个功能的实现。但从维护和拓展的角度来说,习惯数据驱动的方式,在遇到功能变更和迭代时可以更高效、更合理地进行调整。

小结

今天我介绍了前端开发中两种编程思维模式:事件驱动和数据驱动。其中,由于浏览器属于 GUI 编程,我们在开发过程中常常基于“事件”和“响应”的方式来理解功能,因此大多数会倾向于使用事件驱动的方式。

相比于事件驱动,数据驱动更倾向于以“数据”为中心,通过将页面抽象为数据表达,用数据状态变更的方式来表达功能逻辑。数据驱动更容易将视图与逻辑解绑,能快速适应变更和调整。

在我们日常开发中,更多时候是结合了事件驱动和数据驱动来进行编码。

Vue、Angular、React 这些前端框架的出现,处理了很多事件驱动流程上的工作,从而推动了更多开发者从事件驱动转变成数据驱动的方式,更加专注于数据的处理。

技术的迭代、工具的更新和个人的成长,有时候是相辅相成的。思维模式也好,设计模式也好,我们在一次次的开发过程中,会不断地积累和加深一些思考,适合业务场景的才是最好的。

今日思考:你认为事件驱动和数据驱动,各自的优劣分别是什么呢?


精选评论

宋:

事件驱动:1.开发更加简单直观2.少了数据抽象设计,减少工作量3.对于大量涉及到dom频繁变化的需求,如动画之类,事件驱动开发更合适4.界面和业务逻辑强耦合,代码不易于更改和扩展需求数据驱动:1.需要合理抽象数据设计2.开发工作量梢大一些3.需要好的数据状态管理工具4.界面和业务逻辑分离,方便后期更改和扩展需求

Kerita:

数据驱动可以方便地对复杂的数据进行处理,同时将数据展示在页面上。事件驱动方便与用户进行复杂交互或者展示复杂动画。

*振:

数据驱动怎么不是直接用 v-model,怎么还要绑定输入事件?

    讲师回复:

    v-model 只是一个语法糖而已,实际上便是通过 v-bind 和事件绑定实现。这里一是为了方便说明和对比,二是因为并不是所有情况都能用 v-model 解决的很多时候还是需要绑定事件的

**铭:

10 掌握前端框架模板引擎的实现原理

如今说起前端开发,基本上都离不开前端框架。随着前端技术不断迭代,前端框架相关的文档和社区日益完善,前端入门也越来越简单了。我们可以快速上手一些工具和框架,但常常会忽略其中的设计和原理。

对框架和工具的了解不够深入,会导致我们在遇到一些偏门的问题时容易找不到方向,也不利于个人的知识领域扩展,不能很好地进行技术选型。

今天,我会带你了解前端框架为什么会这么热门,以及介绍前端框架的核心能力——模板引擎的实现原理。在讲解的过程中,一些代码会以 Vue.js 作为示例。

我们先来看一下,为什么要使用前端框架。

为什么要使用前端框架

一个工具被大多数人使用、成为热门,离不开相关技术发展的历史进程。了解这些工具和框架出现的原因,我们可以及时掌握技术的发展方向,保持对技术的敏感度、更新自身的认知,这些都会成为我们自身的竞争力。

前端框架也一样。在前端框架出现之前,jQuery 也是前端开发必备的工具库,大多数项目中都会使用。短短几年间,前端开发却变得无法离开前端框架,这中间到底发生了什么呢?

前端的飞速发展

曾几何时,一提到前端,大家都会想到 jQuery。那是 jQuery 一把梭的年代,不管前端后台都会用 jQuery 完成页面开发。那时候前端开发的工作倾向于切图和重构,重页面样式而轻逻辑,工作内容常常是拼接 JSP 模板、拼 PHP 模板以及调节浏览器兼容。

为什么 jQuery 那么热门呢?除了超方便的 Sizzle 引擎元素选择器、简单易用的异步请求库 ajax,还有链式调用的编程方式使得代码如行云流水般流畅。jQuery 提供的便捷几乎满足了当时前端的大部分工作(所以说 jQuery 一把梭不是毫无道理的)。

接下来短短的几年时间,前端经历了特别多的改变。随着 Node.js 的出现、NPM 包管理的完善,再加上热闹的开源社区,前端领域获得了千千万万开发者的支援。从页面开发到工具库开发、框架开发、脚本开发、到服务端开发,单线程的 JavaScript 正在不断进行自我革新,从而将领域不断拓宽,形成了如今你所能看到的、获得赋能的前端。

那么,是什么原因导致了 jQuery 被逐渐冷落,前端框架站上了舞台中央呢?其中的原因有很多,包括业务场景的进化、技术的更新迭代,比如前端应用逐渐复杂、单页应用的出现、前端模块化等。

前端框架的出现

前面第 8 讲中,我们知道了浏览器是如何渲染页面的。从用户的角度来看,浏览器生成了最终的渲染树,并通过光栅化来将页面显示在屏幕上,页面渲染的工作就完成了。

实际上,浏览器页面更多的不只是静态页面的渲染,还包括点击、拖拽等事件操作以及接口请求、数据渲染到页面等动态的交互逻辑,因此我们还常常需要更新页面的内容。

要理解前端框架为什么如此重要,需要先看看在框架出现前,前端开发是如何实现和用户进行交互的。

这个过程跟上一讲事件驱动的内容很相似,以一个常见的表单提交作为例子,会包括编写静态页面、给对应的元素绑定对应的事件、事件触发时更新页面内容等步骤,这是最简单的页面交互。

对于更新页面内容这个步骤,如果我们页面中有很多的内容需要更新,光拼接字符串我们可能就有一大堆代码。

以下的例子,为了不占用大量的篇幅,使用了 jQuery,否则代码量会更多。

举个例子,抢答活动中常常会出现题目和多个答案进行选择,我们现在需要开发一个管理端,对这些抢答卡片进行管理。假设一个问题会包括两个答案,我们可以通过新增卡片的方式来添加一套问答,编辑卡片的过程包括这些步骤。

1. 新增一个卡片时,通过插入 DOM 节点的方式添加卡片样式。

var index = 0;
// 用来新增一个卡片,卡片内需要填写一些内容
function addCard() {
  // 获取一个id为the-dom的元素
  var body = $("#the-dom");
  // 从该元素内获取class为the-class的元素
  var addDom = body.find(".the-class");
  // 在the-class元素前方插入一个div
  addDom.before('<div class="col-lg-4" data-index="' + index + '"></div>');
  // 同时保存下来该DOM节点,方便更新内容
  var theDom = body.find('[data-index="' + index + '"]');
  theDom.innerHTML(
    `<input type="text" class="form-control question" placeholder="你的问题">
         <input type="text" class="form-control option-a" placeholder="回答1">
         <input type="text" class="form-control option-b" placeholder="回答2">
        `
  );
  // 做完上面这堆之后index自增
  index++;
  return theDom;
}

2. 卡片内编辑题目和答案时,会有字数限制(使用 jQuery 对输入框的输入事件进行监听,并限制输入内容)。

// theDom使用上面代码保存下来的引用
// 问题绑定值
theDom
  .on("keyup", ".question", function (ev) {
    ev.target.value = ev.target.value.substr(0, 20);
  })
  // 答案a绑定值
  .on("keyup", ".option-a", function (ev) {
    ev.target.value = ev.target.value.substr(0, 10);
  })
  // 答案b绑定值
  .on("keyup", ".option-b", function (ev) {
    ev.target.value = ev.target.value.substr(0, 10);
  });

3. 获取输入框内的内容(使用 jQuery 选择元素并获取内容),用于提交到后台。

// 获取卡片的输入值
// theDom 使用上面代码保存下来的引用
function getCardValue(index) {
  var body = $("#the-dom");
  var theDom = body.find('[data-index="' + index + '"]');
  var questionName = theDom.find(".question").val();
  var optionA = theDom.find(".option-a").val();
  var optionB = theDom.find(".option-b").val();
  return { questionName, optionA, optionB };
}

可以看到,仅是实现一个问答卡片的编辑就需要编写不少的代码,大多数代码内容都是为了拼接 HTML 内容、获取 DOM 节点、操作 DOM 节点。
这些代码逻辑,如果我们使用 Vue 来实现,只需要这么写:

<template>
  <div v-for="card in cards">
    <input
      type="text"
      class="form-control question"
      v-model="card.questionName"
      placeholder="你的问题"
    />
    <input
      type="text"
      class="form-control option-a"
      v-model="card.optionA"
      placeholder="回答1"
    />
    <input
      type="text"
      class="form-control option-b"
      v-model="card.optionB"
      placeholder="回答2"
    />
  </div>
</template>
<script>
  export default {
    name: "Cards",
    data() {
      return {
        cards: [],
      };
    },
    methods: {
      // 添加一个卡片
      addCard() {
        this.cards.push({
          questionName: "",
          optionA: "",
          optionB: "",
        });
      },
      // 获取卡片的输入值
      getCardValue(index) {
        return this.cards[index];
      },
    },
  };
</script>

可见,前端框架提供了便利的数据绑定、界面更新、事件监听等 API,我们不需要再手动更新前端页面的内容、维护一大堆的 HTML 和变量拼接的动态内容了。
使用前端框架对开发效率有很大的提升,同时也在一定程度上避免了代码可读性、可维护性等问题。这也是为什么前端框架这么热门,大家都会使用它来进行开发的原因。

那么,前端框架是怎么做到这些的呢?要实现这些能力,离不开其中的模板引擎。

前端框架的核心——模板引擎

当用户对页面进行操作、页面内容更新,我们需要实现的功能流程包括:

  1. 监听操作;

  2. 获取数据变量;

  3. 使用数据变量拼接成 HTML 模板;

  4. 将 HTML 内容塞到页面对应的地方;

  5. 将 HTML 片段内需要监听的点击等事件进行绑定。

可以看到,实现逻辑会比较复杂和烦琐。

如果使用前端框架,我们可以:

  • 使用将数据变量绑定到 HTML 模板的方式,来控制展示的内容;

  • 配合一些条件判断、条件循环等逻辑,控制交互的具体逻辑;

  • 通过改变数据变量,框架会自动更新页面内容。

这样,我们可以快速高效地完成功能开发,代码的可读性和维护性都远胜于纯手工实现。

如果使用数据驱动的方式,还可以通过让逻辑与 UI 解耦的方式,提升代码的可维护性。其中的数据绑定、事件绑定等功能,前端框架是依赖模板引擎的方式来实现的。

以 Vue 为例子,对于开发者编写的 Vue 代码,Vue 会将其进行以下处理从而渲染到页面中:

  1. 解析语法生成 AST 对象;

  2. 根据生成的 AST 对象,完成data数据初始化;

  3. 根据 AST 对象和data数据绑定情况,生成虚拟 DOM 对象;

  4. 将虚拟 DOM 对象生成真正的 DOM 元素插入到页面中,此时页面会被渲染。

模板引擎将模板语法进行解析,分别生成 HTML DOM,使用像 HTML 拼接的方式(在对应的位置绑定变量、指令解析获取拼接逻辑等等),同时配合事件的管理、虚拟 DOM 的设计,可以最大化地提升页面的性能。

这些便是模板引擎主要的工作,我们来分别看一下。

解析语法生成 AST 对象

抽象语法树(Abstract Syntax Tree)也称为 AST 语法树,指的是源代码语法所对应的树状结构。其实我们的 DOM 结构树,也是 AST 的一种,浏览器会对 HTML DOM 进行语法解析、并生成最终的页面。

生成 AST 的过程涉及编译器的原理,一般经过以下过程。

  1. 语法分析。模板引擎需要在这个过程中识别出特定的语法,比如v-if/v-for这样的指令,或是<MyCustomComponent>这样的自定义 DOM 标签,还有@click/:props这样的简化绑定语法等。

  2. 语义分析。这个过程会审查源程序有无语义错误,为代码生成阶段收集类型信息,一般类型检查也会在这个过程中进行。例如我们绑定了某个不存在的变量或者事件,又或者是使用了某个未定义的自定义组件等,都会在这个阶段进行报错提示。

  3. 生成 AST 对象。

以 Vue 为例,生成 AST 的过程包括 HTML 模板解析、元素检查和预处理:

/**
 *  将HTML编译成AST对象
 *  该代码片段基于Vue2.x版本
 */
export function parse(
  template: string,
  options: CompilerOptions
): ASTElement | void {
  // 返回AST对象
  // 篇幅原因,一些前置定义省略
  // 此处开始解析HTML模板
  parseHTML(template, {
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    start(tag, attrs, unary) {
      // 一些前置检查和设置、兼容处理此处省略
      // 此处定义了初始化的元素AST对象
      const element: ASTElement = {
        type: 1,
        tag,
        attrsList: attrs,
        attrsMap: makeAttrsMap(attrs),
        parent: currentParent,
        children: [],
      };
      // 检查元素标签是否合法(不是保留命名)
      if (isForbiddenTag(element) && !isServerRendering()) {
        element.forbidden = true;
        process.env.NODE_ENV !== "production" &&
          warn(
            "Templates should only be responsible for mapping the state to the " +
              "UI. Avoid placing tags with side-effects in your templates, such as " +
              `<${tag}>` +
              ", as they will not be parsed."
          );
      }
      // 执行一些前置的元素预处理
      for (let i = 0; i < preTransforms.length; i++) {
        preTransforms[i](element, options);
      }
      // 是否原生元素
      if (inVPre) {
        // 处理元素的一些属性
        processRawAttrs(element);
      } else {
        // 处理指令,此处包括v-for/v-if/v-once/key等等
        processFor(element);
        processIf(element);
        processOnce(element);
        processKey(element); // 删除结构属性
        // 确定这是否是一个简单的元素
        element.plain = !element.key && !attrs.length;
        // 处理ref/slot/component等属性
        processRef(element);
        processSlot(element);
        processComponent(element);
        for (let i = 0; i < transforms.length; i++) {
          transforms[i](element, options);
        }
        processAttrs(element);
      }
      // 后面还有一些父子节点等处理,此处省略
    },
    // 其他省略
  });
  return root;
}

到这里,Vue 将开发者的模板代码解析成 AST 对象,我们来看看这样的 AST 对象是怎样生成 DOM 元素的。

AST 对象生成 DOM 元素

前面提到,在编译解析和渲染过程中,模板引擎会识别和解析模板语法语义、生成 AST 对象,最后根据 AST 对象会生成最终的 DOM 元素。

举个例子,我们写了以下这么一段 HTML 模板:

<div>
  <a>123</a>
  <p>456<span>789</span></p>
</div>

模板引擎可以在语法分析、语义分析等步骤后,得到这样的一个 AST 对象:

thisDiv = {
  dom: {
    type: "dom",
    ele: "div",
    nodeIndex: 0,
    children: [
      {
        type: "dom",
        ele: "a",
        nodeIndex: 1,
        children: [{ type: "text", value: "123" }],
      },
      {
        type: "dom",
        ele: "p",
        nodeIndex: 2,
        children: [
          { type: "text", value: "456" },
          {
            type: "dom",
            ele: "span",
            nodeIndex: 3,
            children: [{ type: "text", value: "789" }],
          },
        ],
      },
    ],
  },
};

这个 AST 对象维护我们需要的一些信息,包括 HTML 元素里:

  • 需要绑定哪些变量(变量更新的时候需要更新该节点内容);

  • 是否有其他的逻辑需要处理(比如含有逻辑指令,如v-ifv-for等);

  • 哪些节点绑定了事件监听事件(是否匹配一些常用的事件能力支持,如@click)。

模板引擎会根据 AST 对象生成最终的页面片段和逻辑,在这个过程中会通过添加特殊标识(例如元素 ID、属性标记等)的方式来标记 DOM 节点,配合 DOM 元素选择方式、事件监听方式等,在需要更新的时候可快速定位到该 DOM 节点,并进行节点内容更新,从而实现页面内容的更新。

目前来说,前端模板渲染的实现一般分为以下两种方式。

  1. 字符串模版方式:使用拼接的方式生成 DOM 字符串,直接通过innderHTML()插入页面。

  2. 节点模版方式:使用createElement()/appendChild()/textContent等方法动态地插入 DOM 节点。

在使用字符串模版的时候,我们将nodeIndex绑定在元素属性上,主要用于在数据更新时追寻节点进行内容更新。

在使用节点模版的时候,我们可在创建节点时将该节点保存下来,直接用于数据更新:

// 假设这是一个生成 DOM 的过程,包括 innerHTML 和事件监听
function generateDOM(astObject) {
  const { dom, binding = [] } = astObject;
  // 生成DOM,这里假装当前节点是baseDom
  baseDom.innerHTML = getDOMString(dom);
  // 对于数据绑定的,来进行监听更新吧
  baseDom.addEventListener("data:change", (name, value) => {
    // 寻找匹配的数据绑定
    const obj = binding.find((x) => x.valueName == name);
    // 若找到值绑定的对应节点,则更新其值。
    if (obj) {
      baseDom.find(`[data-node-index="${obj.nodeIndex}"]`).innerHTML = value;
    }
  });
}
// 获取DOM字符串,这里简单拼成字符串
function getDOMString(domObj) {
  // 无效对象返回''
  if (!domObj) return "";
  const { type, children = [], nodeIndex, ele, value } = domObj;
  if (type == "dom") {
    // 若有子对象,递归返回生成的字符串拼接
    const childString = "";
    children.forEach((x) => {
      childString += getDOMString(x);
    });
    // dom对象,拼接生成对象字符串
    return `<${ele} data-node-index="${nodeIndex}">${childString}</${ele}>`;
  } else if (type == "text") {
    // 若为textNode,返回text的值
    return value;
  }
}

通过上面的方式,前端框架实现了将 AST 对象生成 DOM 元素,并将这些 DOM 元素渲染或更新到页面上。

或许你会觉得疑惑:原本就是一个<div>HTML 模板,经过 AST 生成一个对象,最终还是生成一个<div>DOM 节点,看起来好像挺多余的。

实际上,在这个过程中,模板引擎可以实现更多功能。

模板引擎可以做更多

将 HTML 模板解析成 AST 对象,再根据 AST 对象生成 DOM 节点,在这个过程中前端框架可以实现以下功能:

  1. 排除无效 DOM 元素(非自定义组件、也非默认组件的 DOM 元素),在构建阶段可及时发现并进行报错;

  2. 可识别出自定义组件,并渲染对应的组件;

  3. 可方便地实现数据绑定、事件绑定等功能;

  4. 为虚拟 DOM Diff 过程打下铺垫;

  5. HTML 转义(预防 XSS 漏洞)。

这里我们以第 5 点预防 XSS 漏洞为例子,详细地介绍一下模板引擎是如何避免 XSS 攻击的。

预防 XSS 漏洞

我们知道 XSS 的整个攻击过程大概为:

  1. 攻击者提交含有恶意代码的内容(比如 JavaScript 脚本);

  2. 页面渲染的时候,这些内容未被过滤就被加载处理,比如获取 Cookie、执行操作等;

  3. 其他用户在浏览页面的时候,就会在加载到恶意代码时受到攻击。

要避免网站用户受到 XSS 攻击,主要方法是将用户提交的内容进行过滤处理。大多数前端框架会自带 HTML 转义功能,从而避免的 XSS 攻击。

以 Vue 为例,使用默认的数据绑定方式(双大括号、v-bind等)会进行 HTML 转义,将数据解释为普通文本,而非 HTML 代码。

除此预防 XSS 漏洞之外,前端框架还做了一些性能、安全性等方面的优化,也提供了一些用于项目开发配套的工具,包括路由的管理、状态和数据的管理等工具。

小结

今天我带大家了解了前端框架的出现,由于前端框架帮开发者解决了很多重复性的工作(拼接 HTML 模板、DOM 元素查找、DOM 元素事件监听等),极大地提升了开发者的效率,同时还提升了代码的可读性和可维护性,因此受到很多前端开发的追捧。

除此之外,我还介绍了前端框架中模板引擎的实现原理,包括解析语法生成 AST 对象、根据 AST 对象生成 DOM 元素,并对生成的 DOM 元素进行标记,则可以在变量改变的时候,解析找到相应的 DOM 元素进行内容的更新。

在了解这些内容之后,我们可以在页面渲染遇到性能问题的时候,根据所使用框架的具体实现,找到可能导致页面渲染卡顿或是不流畅的原因。除此之外,在使用框架的过程中,遇到一些语法报错、XSS 安全漏洞等问题的时候,也可以快速找到解决办法。

今日思考:React.js 中的 JSX 和模板引擎是什么关系?在留言区写出你的想法!


精选评论

**宇:

jsx会被babel编译成React.createElement(),用来创建相应的虚拟dom对象,后面会被reactdom或者react native等不同平台的渲染库渲染成ui或者SSR。之所以发明jsx是因为,React.createElement太过繁琐,jsx可以像写html一样写ui,同时也保留了部分js的能力。题外话:其实Facebook最初并没有打算让createElement作为jsx的编译产物,因为它里面的defaultValue,propType,ref key的拦截等等逻辑比较浪费性能,对props的属性有O(n)级别的复杂度,本来只是作为让用户临时手动生产虚拟dom的一个补充手段,但因为当时只有它是最好的选择,所以才这样了,react17以后可能有变化。

*浩:

今日思考的回答:React中的jsx本质是JavaScript语法的扩展,充分具备JavaScript能力,这就能让developer像写JavaScript一样写UI,我们写的虽然是jsx,借助babel等类似的工具的转化,最终还是会编译成React.createElement()这样的函数去生成虚拟DOM,后面生成AST,再由AST生成真实DOM,这些过程应该跟老师经的一样吧,有写错的地方,望老师批评指正。

    讲师回复:

    赞!思路很清晰~

856:

jsx 是把代码转成react可以解读的代码,并没有生成dom模板引擎是通过自己的语法生成dom 相同点都是用自己的语法 写html ,底层实现操作dom

    讲师回复:

    大体上差不多,但也不一定是通过自己的语法生成 DOM,可以考虑下 jsx 是如何对接 react 和 vue 的

11 为什么小程序特立独行?

这几年小程序突然火了起来,其用完就走、无须安装的便捷设计吸引了越来越多的用户愿意使用。用户的热度加上微信给小程序提供的顶级流量入口,也吸引了不少前端开发者的加入。

但当前端开发者带着固有的认知进行小程序开发的时候,却发现很多地方都行不通,比如页面元素无法获取,只能通过setData更新页面,还有各种浏览器接口都无法正常使用。所以,很多人都难以理解,认为小程序偏偏特立独行为难开发者,其实很大程度上是因为小程序基于安全和管控考虑下的设计

那么,究竟是出于怎样的考虑,小程序才被设计成这样?它到底又做了怎样的事情,来尝试解决以上问题呢?今天,我来带你重新认识下小程序。

小程序在思考什么

在微信 App 里,小程序直接开放给所有用户使用,这意味着可能有十几亿人会用到这个工具。面对如此大的流量入口,吸引了很多有心人的眼球。

当年互联网还不成熟的时候,许多网页开发没有做好 XSS 和 CSRF 这样的漏洞保护,导致出现用户账户被盗用、财产被转移等问题。对于小程序来说,不仅需要对各种小程序进行内容的管控,同样需要给用户和开发者提供有安全保障的环境

小程序如何保障用户安全

我们知道,在 Web 开发中,开发者可以使用 JavaScript 脚本来操作 DOM,这意味着恶意攻击者同样可以通过注入 JavaScript 脚本的方式来操控用户的页面。前面提到的 XSS 攻击便是利用了这样的漏洞,从而危害用户和网站的安全。

除此之外,有些恶意的开发者也可能想要从小程序中盗取用户信息。比如,小程序提供了<open-data>组件,用于无须授权的情况下可展示用户的敏感信息(昵称、头像、性别、地理位置等),如果开发者直接通过 DOM API 获取到这些信息,意味着只要用户打开了这个小程序,在未授权的情况下自己的相关信息就会被盗取。

对于微信来说,这些都是非常危险又不可控的问题,如果可以从平台的角度解决,就可以保障用户和商户小程序的安全。在此基础上,小程序提出了双线程设计

在介绍小程序的双线程设计之前,我们先来思考一下上面提到的安全问题要怎么避免。

我们能看到,很多风险都来自 JavaScript 脚本对网页中 DOM 的访问和操作。想要解决这个风险就得将 JavaScript 代码放置在没有浏览器环境的沙箱环境中运行。

沙箱环境听起来很复杂,但其实前端开发者经常接触到:除了浏览器环境以外,JavaScript 还会被运行在 Node.js 环境中。Node.js 是基于 Chrome V8 引擎的 JavaScript 运行环境,该环境中不存在 DOM API、windowdocument等对象 API 和全局对象,因此也更无操作 DOM 节点一说。

小程序也是同样的思路,它使用 iOS 内置的 JavaScriptCore 框架和在 Android 的 JSCore 引擎(最初为腾讯 x5 内核,后来是 V8 引擎),提供了一个没有浏览器相关接口的环境,用于 JavaScript 脚本的执行

在这样的环境里,开发者无法使用浏览器相关的 API 来改变页面内容、获取敏感信息、随意跳转页面,当然也无法对用户进行恶意的攻击了。也正因为如此,在小程序里,是不存在 XSS 风险的,开发者无须主动进行防范,用户更是可以安心使用。

以上就是小程序双线程设计的背景,下面我们来看一下小程序的双线程是怎样设计的。

小程序的双线程设计

上面我们提到,小程序中使用了沙箱环境来运行 JavaScript 代码,在这个环境中无法进行一些 DOM 操作。那么,开发者如何更新页面内容、控制页面的展示呢?答案是使用setData()

为什么使用setData()可以更新页面内容呢?这是因为在小程序中,界面渲染相关任务则是由单独的 WebView 线程来完成。也就是说,在小程序中,JavaScript 脚本的执行和界面渲染不在一个线程中。

当我们在 JavaScript 中使用setData()更新数据的时候,实际上这些数据会通过客户端进行跨线程通信,然后传递到 WebView 页面中,WebView 页面则根据约定的规则来更新到页面中,过程如下图所示。

由于 WebView 页面中获取到的只是类似 JSON 格式的数据,不存在执行 JavaScript 脚本的情况。因此有效地防范了 XSS 攻击,也防止了开发者恶意爬取用户敏感信息。

现在,我们能看到,小程序中分为渲染层(由 WebView 线程管理)和逻辑层(由客户端 JavaScript 解释引擎线程管理)

这就是小程序的双线程设计。显然,它带来了一些好处:

  1. 可以防止恶意攻击者的 XSS 攻击;

  2. 可以防止开发者恶意盗取用户敏感信息;

  3. 提升页面加载性能。

关于第 3 点,我们在第 1 讲的时候就讲过,在浏览器中 GUI 渲染线程负责渲染浏览器界面 HTML 元素,JavaScript 引擎线程主要负责处理 JavaScript 脚本程序。它们之间是互斥的关系,当 JavaScript 引擎执行时,GUI 线程会被挂起。而在小程序中,由于 JavaScript 的执行和页面渲染不在一个页面中,因此也不存在阻塞的问题,页面加载得以更加流畅。

小程序开发者的痛点

虽然小程序的双线程设计解决了用户安全的问题,但同时也给开发者带来了一些问题:

  1. 在浏览器中可以运行的 DOM、BOM 等对象和 API,都无法在小程序中使用;

  2. 小程序的一些 API 使用方式与浏览器不一致(请求、缓存等);

  3. 逻辑层和渲染层的通信依赖客户端进行通信,当通信过于频繁的场景可能导致性能问题。

其中,第 1 点和第 2 点导致前端开发者无法将 Web 页面直接在小程序中复用,同时需要掌握小程序自身的 API 才能熟练地进行开发。这意味着进行小程序开发有门槛和学习成本,因此开发者体验并不会很好。

关于第 3 点,页面进行大数据和高频率的setData()时,会出现页面卡顿的问题。因此在强交互的场景下,用户体验会很糟糕。

在这样的种种情况下,我们跑在浏览器中的代码,如果想要在小程序中运行,必须要做很多兼容处理,甚至需要重新开发来实现。

那为什么一定要使用小程序呢,直接用 H5 不好吗?这是因为小程序有微信流量,微信平台提供给小程序的流量对很多开发者来说都是不愿舍弃的。因此即使无法进行从网页开发到小程序开发的平滑过渡,很多开发者依然选择了进行小程序开发。

其实小程序也做了不少的尝试去抹平小程序和 Web 的差距,从而提升小程序的开发体验,比如这些措施。

  1. 提供了Kbone解决方案,用于支持让 Web 项目同时运行在小程序端和 Web 端。Kbone 通过使用适配层的方式,模拟了一套可运行在小程序中的浏览器对象,提供了基础的 DOM/BOM API,因此 Web 应用可通过 Kbone 运行在小程序中。

  2. 由于原生组件的引入带来的渲染层级无法控制的问题,通过提供同屏渲染的方式来让开发者更好地控制组件样式。

  3. 开发者工具提供了丰富的调试能力,也提供了体验评分等功能,来引导开发者如何进行项目优化。

除了这些,小程序还做了很多事情来提升用户体验,我们一起来看一下。

小程序如何提升用户体验

目前,主流的 App 主要有 3 种,它们对应了 3 种渲染模式:

  1. Native App,使用了 Native(纯客户端原生技术)渲染;

  2. Web App,使用了 WebView(纯 Web 技术)渲染;

  3. Hybrid App,使用了 WebView+原生组件(Hybrid 技术)渲染。

小程序使用的是 WebView + 原生组件,即 Hybrid 方式。显然,这种方式结合了 Native 和 Webview 的一些优势,让开发者既可以享受 Webview 页面的低门槛和在线更新,又可以使用部分流畅的 Native 原生组件,同时通过代码包上传、审核、发布的方式来对内容进行管控。

那么,使用了 Hybrid 渲染模式的小程序,带来了哪些提升用户体验的优势呢?

引入原生组件提升用户交互体验

我们知道,小程序中每一次逻辑层和渲染层的通信,都需要经过 Native,这意味着一次的用户的交互过程会带来四次的通信:

  1. 渲染层 → Native(点击事件);

  2. Native → 逻辑层(点击事件);

  3. 逻辑层 → Native(setData);

  4. Native → 渲染层(setData)。

对于这种强交互的场景,小程序引入了原生组件,过程如下图所示。

我们可以看到,引入原生组件之后,原生组件可以直接和逻辑层通信,有效地减少逻辑层和渲染层的频繁通信问题。像<input><textarea>这些频繁交互的输入框组件,以及画布<canvas>组件、地图<map>这样交互复杂的组件,直接使用 Native 原生组件的方式既减少了客户端通信,也减轻了渲染层的工作。

引入原生组件的方式提升用户在小程序中频繁操作场景下的交互体验,依赖了 Native 技术的能力。除了这些,小程序在运行机制(包括启动和加载)上也结合客户端做了不少的体验优化工作,我们一起来看一下。

通过页面预渲染减少启动和加载耗时

前面我们介绍了小程序的双线程设计,你应该知道在小程序里 JavaScript 代码运行在逻辑层中,页面渲染的逻辑则运行在 WebView 渲染层中。

我们重新来看这张图:

我们能看到渲染层里有多个 WebView,这是因为在小程序中为了方便用户可快速地前进和回退,存在着多个界面,而每个界面都是一个单独的 WebView 线程,因此会有多个 WebView。

这和小程序的启动和加载有什么关系呢?

首先,在小程序启动之前,客户端会提前准备好一个 WebView,用于快速展示小程序首页。同时,在每次这个准备好的 WebView 被小程序使用和渲染时,客户端也都会提前准备好一个新的 WebView。因此,开发者在调用wx.navigateTo时,用户可以很快看到新的页面。

除了 WebView 的准备,小程序在启动和加载过程中,客户端还做了这些工作。

  1. 基于 JavaScript 编写的基础库,会被提前内置在客户端中。基础库提供了小程序运行的基础能力,包括渲染机制相关基础代码、封装后的内置组件、逻辑层基础 API 等,因此小程序在启动时,都会先注入基础库代码。

  2. 当用户打开小程序后,客户端开始下载业务代码,同时会在本地创建内置的基础 UI 组件,初始化小程序的首页。此时,小程序展示的是客户端提供的固定的启动界面。

  3. 步骤 2 准备完成后,客户端就会开始注入业务代码并运行。

最后,我们再来梳理下小程序的启动过程。

  • 启动前:提前准备好一个 WebView 页面,并进行初始化,在初始化过程中会注入小程序基础库,以提供小程序运行的基础环境。

  • 用户打开小程序:下载业务代码,同时初始化小程序的首页,当业务代码下载完成后,开始运行业务代码。

这个过程可以用一张图来表示。

我们可以看到,微信小程序通过基础库的内置、页面的预渲染、小程序加载时提供友好的交互界面等方式,使小程序可以尽快地加载,给到用户更好的体验。除此之外,小程序还通过使用缓存、热启动机制、提供分包加载和数据预拉取等方式,同样减少了用户的等待时间。

小结

或许对于一份工作来说,开发者只想简单快速地完成项目开发。但对于一位前端来说,小程序的设计中有很多值得学习的内容,正如我们今天所介绍的:

  • 为了保障用户的安全,提出了双线程的设计(渲染层由 WebView 线程管理、逻辑层由客户端 JavaScript 解释引擎线程管理);

  • 为了减少小程序和 Web 开发的差异,提供了 Knone 解决方案;

  • 为了降低开发者的门槛,提供了功能丰富的开发者工具,提供了小程序优化的解决方案;

  • 为了提升用户体验,小程序结合客户端的能力引入了原生组件,并优化了小程序的启动和加载过程。

小程序之所以这么特殊,并不是为了特立独行,而是为了从平台的角度来提供给开发者和用户更好的体验和安全保障。

其实,小程序的设计远不止于此。如果你继续深挖,可以看到里面还有虚拟 DOM 的设计、Shadow DOM 模型、自定义组件的渲染过程等,同时还有小程序在载入、启动、更新版本等各种流程中的一些机制。除此之外,小程序和 Serverless 的结合极大地降低了开发者的门槛,同时也降低了开发成本,带来了友好的开发体验。

小程序中的很多设计都可以作为参考,比如页面预渲染的设计可以在提升首屏加载速度时作为参考。你是否也想到在哪些场景下可以参考的设计吗?或者你认为小程序是否还有更好的优化方案呢?欢迎在留言区说说你的想法。


精选评论

**恒:

浏览器中js引擎线程和GUI渲染线程互斥这个理解是错误的吧。 js的执行和 layout计算都是在一个线程一般我们叫js主线程,这也是js执行为什么阻塞页面渲染的原因。但是真正做渲染的也就是做珊格化的是珊格化线程。而珊格化线程和js主线程是不互斥的。顺便问一下 作者的小程序书啥时候出版。

    讲师回复:

    HTML 解析器找到

小程序的书目前在印刷中啦,很快了:https://www.ituring/book/2806

**海:

而在小程序中,由于 JavaScript 的执行和页面渲染不在一个页面中,因此也不存在阻塞的问题,页面加载得以更加流畅。不在同一个页面中这个是怎么理解的

    讲师回复:

    页面渲染在 Webview 页面,JavaScript 的执行在沙箱中,不在渲染的 Webview 页面里

12 单页应用与前端路由库设计原理

在第 6 讲和第 8 讲的内容中,我介绍了浏览器中页面是如何进行请求和渲染的。在单页应用出现以前,浏览器通过 HTTP 请求向服务端请求页面内容的时候,服务端会根据页面不同的 URL 路由,拼接出相应的页面视图片段,最终以 HTML 的方式返回给浏览器,浏览器再进行解析和渲染。

这种多个页面间没有关系、各自为完整页面的应用,称之为多页应用。我们熟悉的 JSP、PHP 也都是通过拼接 HTML 模板的方式,来给浏览器提供完整的页面内容。

如今,大多数的前端应用都使用单页应用的方式来实现。那么什么是单页应用呢?使用单页应用的优势是什么?为什么单页应用流行一段时间之后,现在又有人开始回到多页应用的方式呢?

下面我们来一探究竟。

单页应用的出现

首先,单页应用与多页应用的区别在于:

  • 多页应用是由服务端进行 HTML 模板拼接的,各个页面间没有直接关系;

  • 单页应用便是将页面内容的控制权交给前端来处理,通过使用一个前端页面+多个页面片段的方式进行渲染。

当页面以多页应用的方式进行加载时,如果发生页面间的跳转,常常会导致整个页面都需要重新加载。这是因为当页面路由发生了变化,浏览器会重新向服务端获取相应的内容,而服务端则会根据新的 URL 再次进行 HTML 模板拼接,并返回给前端。

在这个过程中,用户会看到页面重新变回了白屏,然后再出现内容,体验很糟糕。使用这种方式加载页面,整个页面都需要重新加载,导致体验不够友好。

除此之外,由于页面中整个 HTML 内容都需要重新加载,多页应用还存在以下问题:

  1. 静态资源无法有效复用,包括 Javascript 脚本和 CSS 内容;

  2. 原有的页面状态、用户状态无法保留,依赖 URL、Cookie、本地缓存等获取用户数据。

其实在大多数情况下,对于同一个网站,不同 URL 的页面其实整体骨架都很相似,像网站导航栏、顶部菜单栏、底部网站相关内容等都是相同的,使用多页应用的方式会导致这些内容也重新加载。

既然如此,那么是否可以使用页面局部刷新的方式来更新页面内容呢?在第 10 讲中其实我也有介绍,如今流行的 Angular、React、Vue 等这些前端框架都是通过将某个数据变量关联到页面的某块内容展示的方式,实现页面的局部更新。

但只有局部刷新的能力还是不够的,因为即使页面内容更新了,如果页面的 URL 没有发生变化,当用户刷新页面的时候,可能会丢失当前的页面内容。因此,我们需要在前端配合控制路由的方式来控制页面展示,这就是单页应用。

显然,单页应用的出现带来以下的好处:

  1. 通用的静态资源(比如 jQuery、Axios、Boostrap 等)不需要重新加载;

  2. 页面的数据状态和用户状态依然保留;

  3. 局部页面内容更新,页面切换快,用户体验好。

但是,单页应用同样存在着一些问题,这些问题会影响着项目的选型,也因此出现了服务端渲染等解决方案,我们一起来看一下。

单页应用的问题

由于要启用单页应用,浏览器在首次打开页面的时候,除了加载固定的脚本和样式文件以外,页面流程大概是这样的。

  1. 浏览器请求服务端,服务端返回固定静态资源+基础的 HTML 内容+前端路由库。

  2. 由于是前端进行渲染,因此在一般情况下,服务端返回的 HTML 内容基本上<body>为空。前端在页面进行加载的时候,需要继续向服务端发起 Ajax 请求,获取页面渲染需要的数据。

  3. 服务端根据请求内容,给前端返回相应的数据。

  4. 前端拿到数据后,根据当前的 URL 信息来生成相应的内容,进行页面的二次渲染,此时页面才最终加载完成。

可以看到,单页应用相比多页应用的优势主要在于页面切换时。在首次打开的时候,多页应用可以直接返回用于最终渲染的页面,而单页应用则需要自行进行计算和组装,中间过程很可能还涉及数据的二次请求,因此会比多页应用慢。

除此之外,由于搜索引擎只识别 HTML 内容,单页应用更多依赖 Javascript 进行 HTML 拼接,因此对 SEO(search engine optimization 简写为 SEO,搜索引擎优化)的支持不友好,很可能会影响搜索引擎中的排名。

基于这些原因,如今不少前端框架也支持服务端渲染(SSR,Server-Side-Rendering),通过提供 Node.js 服务的方式,在服务端完成页面内容的拼接,直接返回给前端。相对的,单页应用的渲染方式,也被称为客户端渲染(CSR,Client-Side-Rendering)。

服务端渲染听起来跟多页应用很相似,都是由服务端完成 HTML 内容拼接的,那是否可以认为服务端渲染就是多页应用呢?

并不是,服务端渲染可以只用来控制首屏直出,而在页面进行切换的时候,依然使用单页应用的方式,这样的解决方案结合了多页应用和单页应用的优势,如今也在不少项目中使用。

那么,页面切换的时候,如何避免页面重新加载,又能正确渲染页面内容呢?大多数项目中都会使用前端路由库(比如 ngRouter/vue-router/react-router 等),这些路由库的设计原理又是怎样的呢?

我们一起来看一下。

前端路由库的设计与实现

页面的跳转、局部内容的刷新是 Web 应用中使用最多的场景。想象一下,如果我们只刷新了页面的内容,但是 URL 并没有改变,当用户刷新当前页面的时候,原先的内容会丢失,需要重新操作进入到对应的页面中,这是比较糟糕的一种体验。

所以,我们可以把页面的内容匹配到对应的路由信息中,即使是强制刷新,URL 信息也不会丢,用户依然可以快速恢复原先的页面浏览信息,这也是我们项目中设计和使用路由的很重要的原因。

前面说到,单页应用使用了局部刷新的能力,配合路由信息变更的时候进行局部页面内容的刷新(而不是重新加载一个完整的页面),可以让用户获取更好的体验。

要实现前端路由,离不开浏览器提供的 History API、Location API 这些 API,因此后面介绍路由能力实现时,我们也会进行一些介绍。

一般来说,前端路由的实现,会包括两种模式:

  1. History 模式

  2. Hash 模式

我们先来看看 History 模式。

History 模式

History 的路由模式,依赖了一个关键的属性window.history,该属性可用来获取用于操作浏览器历史记录的 History 对象。也就是说,通过使用window.history,我们可以实现以下与路由相关的重要能力。比如:

  • 在 history 中跳转

使用window.history.back()window.history.forward()window.history.go()方法,可以实现在用户历史记录中向后和向前的跳转。

  • 添加和修改历史记录中的条目

使用history.pushState()history.replaceState()方法,它可以操作浏览器的历史栈,同时不会引起页面的刷新(可避免页面重新加载)。

  • 监听页面路由切换

当同一个页面在历史记录间切换时,就会产生popstate事件,可以通过window.popstate监听页面路由切换的情况。

也就是说,使用pushState()replaceState()来修改路由信息,通过popstate事件监听页面路由变化,来进行页面的局部更新,这便是 History 的路由模式。

但 History 的路由模式需要依赖 HTML5 History API(IE10 以上),以及服务器的配置来支持,所以也有不少的开发者会使用 Hash 模式来管理 Web 应用的路由。

那么 Hash 模式又是怎样的呢?

Hash 模式

Hash 模式使用的是从井号(#)开始的 URL(锚)片段,主要依赖 Location 对象的 hash 属性(location.hash)和hashchange事件,包括:

  • 使用location.hash来设置和获取 hash

location.hash的设置和获取,并不会造成页面重新加载,利用这一点,我们可以记录页面关键信息的同时,提升页面体验。

  • 监听hashchange事件

当页面的 hash 改变时,hashchange事件会被触发,同时提供了两个属性:newURL(当前页面新的 URL)和oldURL(当前页面旧的 URL)。

部分浏览器不支持onhashchange事件,我们可以自行使用定时器检测和触发的方式来进行兼容,可以使用以下的代码逻辑来实现:

(function (window) {
  // 如果浏览器原生支持该事件,则退出
  if ("onhashchange" in window.document.body) {
    return;
  }
  var location = window.location,
    oldURL = location.href,
    oldHash = location.hash;
  // 每隔100ms检测一下location.hash是否发生变化
  setInterval(function () {
    var newURL = location.href,
      newHash = location.hash;
    // 如果hash发生了变化,且绑定了处理函数...
    if (newHash != oldHash && typeof window.onhashchange === "function") {
      // 执行事件触发
      window.onhashchange({
        type: "hashchange",
        oldURL: oldURL,
        newURL: newURL,
      });
      oldURL = newURL;
      oldHash = newHash;
    }
  }, 100);
})(window);

我们可以看到,Hash 路由模式使用location.hash来设置和获取 hash,并通过window.onhashchange监听基于 hash 的路由变化,来进行页面更新处理的。

路由结合前端框架

不管是 History 模式还是 Hash 模式,路由的实现原理都很简单,因此一般来说大家也都会直接使用前端框架自带的路由库。

路由库结合前端框架的工作流程是这样的:

  1. 设置监听器,监听popstate或者hashchange事件;

  2. 根据当前 URL 信息匹配设置的路径,根据路由设置加载对应模块,通过前端框架进行更新和渲染;

  3. 页面更新的同时,使用location.hash或者history.pushState/replaceState更新页面的路由信息。

以上是简单的实现,很多路由工具库还会提供除事件监听和通知之外的一些更高级的能力,比如与渲染层结合解析和处理的能力,以及路由的钩子、路由监听、路由鉴权、匹配和映射、懒加载打包等这种能力,减轻业务开发过程中的处理工作。

小结

其实,不管是单页应用/多页应用、服务端渲染/客户端渲染,还是前端路由的设计,都是在前端项目中使用频率很高的功能,也有很多成熟的解决方案和配套工具。

但在了解一个工具如何使用的同时,我们更应该了解工具的实现、为什么需要这样来使用。只有掌握和理解了工具的设计原理、相关的解决方案,我们才可以将这些知识化为己用。

你最近也有在用什么工具库吗?你是否有去了解其中的设计原理呢?欢迎在留言区留下你的思考。


精选评论

*鑫:

小姐姐,那么ssr方案只能解决首屏seo问题吗?

    讲师回复:

    不只是这样哦,SSR 可以有效地提升首屏渲染的性能,很多前端应用会使用 SSR 来优化首屏耗时

*雨:

被删,nuxt页面刷新时是node服务器端直出,那页面切换时,是如何做到让数据出现在“查看源代码”中的呢?明明没走node服务器啊!

    讲师回复:

    有点没看懂让数据出现在“查看源代码”是什么意思,nuxt 我也没研究过,但我理解路由异步加载的时候,也同样可以把数据直出在异步加载的代码里吧?

**沫:

更新太慢啦~被删小姐姐,每个星期都迫不及待啊!!!!不够看>︿<

*聪:

【部分浏览器不支持onhashchange事件,我们可以自行使用定时器检测和触发的方式来进行兼容】,既然不支持onhashchange事件了,为啥代码中还判断typeof window.onhashchange === "function"并执行呢?

    讲师回复:

    这里 window.onhashchange 是用来判断,业务代码中是否有依赖该事件的触发,如果有依赖但是浏览器又不支持的话,则需要手动触发该事件,达到 onhashchange 的效果

更多推荐

前端进阶第三天进阶 HTTP协议、页面渲染、数据驱动、模板引擎、路由库

本文发布于:2023-04-29 21:01:00,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/22bb628c7fc44ec7ca030d3b949424ca.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:进阶   路由   模板   协议   页面

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!

  • 112229文章数
  • 28548阅读数
  • 0评论数