04 客户端容器 | 青训营笔记

111 阅读35分钟

课程目标

本节课程主要着重介绍浏览器的架构以及运行原理,并以一道八股文为例,讲解在Chrome浏览器里,网页是如何加载并且渲染成我们所见的画面。通过对本节课的学习,我们了解到在浏览器里JS引擎和渲染引擎如何协同工作,如何从多个角度优化前端的性能体验。再以webview容器为扩展,认识一些常见的跨端方案。

浏览器架构

浏览器架构演进

  1. 单进程架构:所有模块运行在同一个进程里,包含网络、插件、JavaScript 运行环境等。
  2. 多进程架构:主进程、网络进程、渲染进程、GPU 进程、插件进程。
  3. 面向服务架构:将原来的 UI、数据库、文件、设备、网络等,作为一个独立的基础服务。

page5_image4.png

浏览器架构对比

架构类型扩展性安全性稳定性流畅度
单进程架构,所有模块运行在同一进程里,访问同一块内存区域,数据没有隔离,新增模块可能会影响原有功能,三方插件可直接访问操作系统里任意资源,三方插件漏洞或者某个 tab 页面 JavaScript 脚本问题可能导致浏览器崩溃卡顿,所有页面运行在同一进程中,开启多个页面时明显卡顿
多进程架构,各进程分配独立的内存区域,有些进程功能较大,耦合度高,运行在独立沙箱中,不能访问系统敏感资源,进程相互隔离,当一个页面或者插件崩溃时,不会影响其他进程流畅,每个页面运行在独立的渲染进程中,充分利用系统资源
面向服务架构, 服务模块划分更细,更内聚,耦合性低,易于扩展,运行在独立沙箱中,不能访问系统敏感资源,进程相互隔离,当一个页面或者插件崩溃时,不会影响其他进程流畅,每个页面运行在独立的渲染进程中,充分利用系统资源

从任务管理器查看资源占用情况

page7_image6.jpg

多进程分工

进程名称进程描述
浏览器(主进程)主要负责页面展示逻辑,用户交互,子进程管理;包括地址栏、书签、前进、后退、收藏夹等
GPU 进程负责 UI 绘制,包含整个浏览器全部 UI
网络进程网络服务进程,负责网络资源加载
==标签页(渲染进程)==控制 tab 内的所有内容,将 Html、Css 和 JavaScript 转换为用户可交互的网页
插件进程控制网站运行的插件,比如 flash、ModHeader 等
其他进程如上图所示:适用程序 Storage/Network/Audio Service 等

思考:

  • 为什么会有单进程架构
  • 面向服务架构是否会提点多进程架构

渲染进程

常见浏览器内核

image.png

多线程架构

内部是多线程实现,主要负责页面渲染,脚本执行,事件处理,网络请求等

page12_image6.png

线程功能
JS 引擎负责解析 js 脚本、运行 js 程序,每个渲染进程下面只有一个 js 引擎线程。与 GUI 渲染进程互斥,如果 js 任务执行事件过长,会导致页面卡顿
GUI 渲染负责渲染浏览器界面,解析 html、css,构建 dom 树和 render 树、布局、绘制、和 js 引擎线程互斥,GUI 更新会在 js 引擎空闲时立即执行
定时器触发定时器所在线程,setTimeout、setInterval 计时完毕后,将回调添加到事件队列,等待 js 引擎执行
网络线程在 XHR、Fetch 等发起请求后新开一个网络线程请求,如果设置了回调函数,在状态变更时,将回调放入事件队列,等待 js 引擎执行
事件触发由宿主环境提供,用于控制事件循环,不断的从事件队列例取出任务执行

JS 引擎 vs 渲染引擎

  1. 解析执行 JS
  2. XML 解析生成渲染树,显示在屏幕
  3. 桥接方式通信

page13_image7.png

注:字节码->机器码 是一种优化,因为机器码可被操作系统直接执行,所以这种优化提高了效率。

多线程工作流程

  1. 网络线程负责加载网页资源
  2. JS 引擎解析 JS 脚本并且执行
  3. JS 解析引擎空闲时,渲染线程立即工作
  4. 用户交互、定时器操作等产生回调函数放入任务队列中
  5. 事件线程进行事件循环,将队列例的任务取出交给 JS 引擎执行

page14_image6.png

JS 执行顺序

event loop

Event Loop 是一个很重要的概念,指的是计算机系统的一种运行机制。JavaScript 语言就采用这种机制,来解决单线程运行带来的一些问题。

接下来我们要了解 event loop 的执行顺序:

  1. 首先执行 script 宏任务
  2. 执行 同步任务,遇见微任务进入微任务队列,遇见宏任务进入宏任务队列。
  3. 当前 宏任务执行完出队,检查 微任务 列表,有则依次执行,直到全部执行完。
  4. 执行浏览器 UI 线程的 渲染 工作。
  5. 检查是否有 Web Worker 任务,有则执行。
  6. 执行 下一个宏任务,回到第二步,依此循环,直到宏任务和微任务队列都为空。

微任务包括MutationObserverPromise.then()catch()、Promise为基础开发的其它技术,比如 fetch API、V8的垃圾回收过程、Node独有的 process.nextTick 、 Object.observe(已废弃;Proxy 对象替代)。

宏任务包括: script 、setTimeout、setInterval 、setImmediate 、I/O 、UI rendering 、 postMessage 、 MessageChannel

下面是一些例题(都是在浏览器环境下执行)

Promise 相关知识

同步 + Promise

例一

var promise = new Promise((resolve, reject) => {
    console.log(1)
    resolve()
    console.log(2)
})
promise.then(()=>{
    console.log(3)
})
console.log(4)
  
//输出结果: 
// 1
// 2
// 4
// 3

解析

  • 首先明确,Promise 构造函数是同步执行的,then 方法是异步执行的
  • 开始 new Promise,执行构造函数同步代码,输出 1
  • resolve(),将 promise 的状态改为了 resolved,并且将 resolve 值保存下来,此处没有传值
  • 执行构造函数同步代码,输出 2
  • 跳出 promise,往下执行,碰到 promise.then 这个微任务,将其加入微任务队列
  • 执行同步代码,输出 4
  • 此时宏任务执行完毕,开始检查微任务队列,执行 promise.then 微任务,输出 3

例二

var promise = new Promise((resolve, reject) => {
    console.log(1)
})
promise.then(()=>{
    console.log(2)
})
console.log(3)
  
//输出结果: 
// 1
// 3

解析

  • 开始 new Promise,执行构造函数同步代码,输出 1
  • promise.then,因为 promise 中并没有 resolve,所以 then 方法不会执行
  • 执行同步代码,输出 3

例三

var promise = new Promise((resolve, reject) => {
    console.log(1)
})
promise.then(console.log(2))
console.log(3)
  
//输出结果: 
// 1
// 2
// 3

解析

  • 首先明确,.then 或者 .catch 的参数期望是函数,传入非函数则会发生值透传 (value => value)
  • 开始 new Promise,执行构造函数同步代码,输出 1
  • 然后 then() 的参数是一个 console.log(2) (注意:并不是一个函数),是立即执行的,输出 2
  • 执行同步代码,输出 3

例四

Promise.resolve(1)
  .then(2)
  .then(Promise.resolve(3))
  .then(console.log)
    
//输出结果: 
// 1

解析

  • then(2)、then(Promise.resolve(3)) 发生了值穿透,直接执行最后一个 then输出 1

例五

var promise = new Promise((resolve, reject) => {
    console.log(1)
    resolve()
    reject()
})
promise.then(()=>{
    console.log(2)
}).catch(()=>{
    console.log(3)
})
console.log(4)  

//输出结果: 
// 1
// 4
// 2

解析

  • 开始 new Promise,执行构造函数同步代码,输出 1
  • resolve(),将 promise 的状态改为了 resolved,并且将 resolve 值保存下来,此处没有传值
  • reject(),此时 promise 的状态已经改为了 resolved,不能再重新翻转(状态转变只能是 pending -> resolved 或者 pending -> rejected,状态转变不可逆)
  • 跳出 promise,往下执行,碰到 promise.then 这个微任务,将其加入微任务队列
  • 往下执行,碰到 promise.catch 这个微任务,此时 promise 的状态为 resolved(非 rejected),忽略 catch 方法
  • 执行同步代码,输出 4
  • 此时宏任务执行完毕,开始检查微任务队列,执行 promise.then 微任务,输出 2

例六

Promise.resolve(1)
  .then(res => {
    console.log(res);
    return 2;
  })
  .catch(err => {
    return 3;
  })
  .then(res => {
    console.log(res);
  });
  
//输出结果: 
// 1
// 2

解析

  • 首先 resolve(1),状态改为了 resolved,并且将 resolve 值保存下来
  • 执行 console.log(res) 输出 1
  • 返回 return 2,实际上是包装成了 resolve(2)
  • 状态为 resolvedcatch 方法被忽略
  • 最后 then输出 2

同步 + Promise + setTimeout

例一

setTimeout(() => {
  console.log(1)
})
Promise.resolve().then(() => {
  console.log(2)
})
console.log(3)
  
//输出结果: 
// 3
// 2
// 1

解析

在解析宏任务与微任务时,我们先使用一个公式来理解执行顺序:

同步宏任务 > 微任务 > 微任务队列 > 宏任务队列

  • 首先 setTimeout 被放入宏任务队列
  • Promise.resolve().thenthen 方法被放入微任务队列
  • 执行同步代码,输出 3
  • 此时宏任务执行完毕,开始检查微任务队列,执行 then 微任务,输出 2
  • 微任务队列执行完毕,检查执行一个宏任务
  • 发现 setTimeout 宏任务,执行 输出 1

例二

var promise = new Promise((resolve, reject) => {
  console.log(1)
  setTimeout(() => {
    console.log(2)
    resolve()
  }, 1000)
})

promise.then(() => {
  console.log(3)
})
promise.then(() => {
  console.log(4)
})
console.log(5)
  
//输出结果: 
// 1
// 5
// 2
// 3
// 4

解析

  • 首先明确,当遇到 promise.then 时,如果当前的 Promise 还处于 pending 状态,我们并不能确定调用 resolved 还是 rejected,只有等待 promise 的状态确定后,再做处理,所以我们需要把两种情况的处理逻辑做成 callback 放入 promise 的回调函数组内,当 promise 状态翻转为 resolved 时,才将之前的 promise.then 推入微任务队列
  • 开始,Promise 构造函数同步执行,输出 1,执行 setTimeout
  • setTimeout 加入到宏任务队列中
  • 然后,第一个 promise.then 放入 promise 的回调数组内
  • 第二个 promise.then 放入 promise 的回调函数内
  • 执行同步代码,输出 5
  • 检查微任务队列,为空
  • 检查宏任务队列,执行 setTimeout 宏任务,输入 2 ,执行 resolvepromise 状态翻转为 resolved ,将之前的 promise.then 推入微任务队列
  • setTimeout 宏任务出队,检查微任务队列
  • 执行第一个微任务,输出 3
  • 执行第二个微任务,输出 4

最后

var date = new Date() 
 
console.log(1, new Date() - date) 
 
setTimeout(() => {
    console.log(2, new Date() - date)
}, 500) 
 
Promise.resolve().then(console.log(3, new Date() - date)) 
 
while(new Date() - date < 1000) {} 
 
console.log(4, new Date() - date)

解析:

  • 首先执行同步代码,输出 1 0
  • 遇到 setTimeout ,定时 500ms 后执行,此时,将 setTimeout 交给异步线程,主线程继续执行下一步,异步线程执行 setTimeout
  • 主线程执行 Promise.resolve().then , .then 的参数不是函数,发生( value => value ) ,输出 3 1
  • 主线程继续执行同步任务 whlie ,等待 1000ms ,在此期间,setTimeout 定时 500ms 完成,异步线程将 setTimeout 回调事件放入宏任务队列中
  • 继续执行下一步,输出 4 1000
  • 检查微任务队列,为空
  • 检查宏任务队列,执行 setTimeout 宏任务,输入 2 1000

总结

  • Promise 构造函数是 同步执行 的, then 方法是 异步执行
  • .then 或者 .catch 的参数期望是函数,传入非函数则会发生值透传
  • Promise的状态一经改变就不能再改变,构造函数中的 resolvereject 只有第一次执行有效,多次调用没有任何作用
  • .then方法是能接收两个参数的,第一个是处理成功的函数,第二个是处理失败的函数,再某些时候你可以认为catch.then第二个参数的简便写法
  • 当遇到 promise.then 时, 如果当前的 Promise 还处于 pending 状态,我们并不能确定调用 resolved 还是 rejected ,只有等待 promise 的状态确定后,再做处理,所以我们需要把我们的两种情况的处理逻辑做成 callback 放入 promise 的回调数组内,当 promise 状态翻转为 resolved 时,才将之前的 promise.then 推入微任务队列

Chrome 运行原理

如何展示网页

浏览器地址输入 URL 后发生了什么?

page17_image7.png

  1. 输入解析:浏览器首先解析输入的内容,判断它是一个有效的URL还是搜索查询。如果输入的内容被识别为搜索查询,浏览器将使用默认的搜索引擎进行搜索。
  2. DNS 查询:如果输入的内容是一个有效的 URL,浏览器将进行域名系统(DNS)查询以将域名解析为对应的 IP 地址。浏览器会首先检查本地 DNS 缓存,如果找不到对应的记录,它会向 DNS 服务器发送查询请求。
  3. 建立连接:浏览器与目标服务器建立一个 TCP 连接。这通常包括三次握手过程,以确保双方都准备好进行数据传输。
  4. 发送 HTTP 请求:TCP 连接建立后,浏览器向服务器发送一个 HTTP 请求。请求通常包括请求方法(如 GET 或 POST)、请求的资源路径、HTTP 协议版本、请求头(包含诸如用户代理、接受的编码和语言等信息)以及可能的请求体(如 POST 请求所包含的表单数据)。
  5. 接收响应:服务器处理 HTTP 请求,并将响应数据发送回浏览器。响应通常包括 HTTP 状态码(如 200 表示成功,404 表示未找到等)、响应头(包含诸如内容类型、内容长度等信息)以及响应体(通常是 HTML 文档)。
  6. 关闭或重用连接:一旦浏览器接收到完整的响应数据,它可以选择关闭 TCP 连接或将其保持在活动状态以用于后续请求。
  7. 解析 HTML:浏览器解析 HTML 文档,构建 DOM 树。在解析过程中,浏览器可能遇到 <script> 标签或其他需要立即执行的脚本,这时浏览器将暂停解析,执行脚本,然后继续解析。
  8. 请求其他资源:HTML 文档通常包含其他资源的引用,如 CSS、JavaScript 和图片等。浏览器将发送额外的 HTTP 请求来获取这些资源。这些请求可能与初始请求的服务器相同,也可能涉及其他服务器。
  9. 构建渲染树:浏览器解析 CSS 样式,并将其应用于 DOM 树,生成渲染树。渲染树包含了所有可见元素及其样式信息。
  10. 布局:浏览器计算渲染树中每个元素的位置和大小,生成布局信息。
  11. 绘制:浏览器根据渲染树和布局信息将元素绘制到屏幕上。这个过程称为绘制或渲染。浏览器会将各个层的元素绘制到位图中,然后将这些位图合成到屏幕上显示的最终图像。
  12. 交互:在完成页面绘制后,浏览器开始接收和处理用户的交互,如点击、滚动、输入等。这些交互可能会触发 JavaScript 事件处理程序,从而修改 DOM 或应用新的样式。这些修改可能会导致浏览器重新布局和绘制页面的部分或全部内容。
  13. 关闭或卸载:当用户导航到其他页面或关闭浏览器选项卡时,浏览器将触发相应的页面卸载事件,如 beforeunloadunload。这给开发者一个机会来执行清理操作,如保存用户数据或取消挂起的网络请求。一旦完成这些操作,浏览器将卸载页面并释放相关资源。

输入处理

输入处理:浏览器主进程会根据用户输入的URL进行解析和处理,包括检查URL的有效性、判断是否需要使用搜索引擎、判断是否需要使用HTTPS等等。在处理完输入之后,浏览器主进程将发起开始导航的请求。

  1. 用户 URL 框输入内容后,UI 线程会 判断输入 的是一个 URL 地址还是一个 query 查询条件
  2. 如果是 URL,则直接请求站点资源。
  3. 如果是 query,将输入发送给搜索引擎

page18_image7.jpg

开始导航

开始导航:浏览器主进程会向网络进程发送开始导航的请求,以便进行后续的页面加载操作。在发送请求之前,浏览器主进程会先判断当前页面是否支持复用,如果支持,则会重用渲染进程,否则会创建一个新的渲染进程来加载页面。

  1. 当用户按下回车,UI 线程通知网络线程 发起一个网络请求,来获取站点内容。
  2. 请求过程中,tab 处于 loading 状态。

page19_image7.jpg

读取响应

读取响应:一旦网络进程开始加载页面,它会读取服务器响应的数据,并将其返回给浏览器主进程。在读取响应的过程中,网络进程会进行数据解码和安全检查等操作。

  1. 网络线程接收到 HTTP 响应后,先 检查 响应头的 媒体类型 (MIME Type)。
  2. 如果响应主体是一个 HTML 文件,浏览器将内容交给渲染进程处理。
  3. 如果拿到的是其他类型文件,比如 zip、exe 等,则交给下载管理器处理。

page20_image7.png

寻找渲染进程

寻找渲染进程:一旦浏览器主进程收到网络进程返回的响应数据,它会根据响应数据中的渲染进程ID来寻找对应的渲染进程。如果已经存在对应的渲染进程,则将响应数据发送给该进程进行处理;否则,浏览器主进程会创建一个新的渲染进程来处理该页面。

  1. 网络线程做完所有检查后,会告知主进程数据已准备完毕,主进程确认后为这个站点寻找一个渲染进程。
  2. 主进程通过 IPC 消息告知 渲染进程区 处理本次导航。
  3. 渲染进程开始接收数据并告知主进程自己已经开始处理,导航结束,进入文档加载阶段

page21_image7.png

渲染进程

资源加载

  1. 收到主进程的消息后,开始加载 HTML 文档。
  2. 除此之外,还需要加载子资源,比如一些图片,CSS 样式文件以及 JavaScript 脚本。

page22_image6.jpg

构建渲染树

  1. 构建 DOM 树,将 HTML 文本转化成浏览器能够理解的结构。
  2. 构建 CSSOM 树,浏览器同样不认识 CSS,需要将 CSS 代码转化为可理解的 CSSOM。
  3. 构建渲染树,渲染树是 DOM 树和 CSSOM 树的结合。

page23_image6.png

页面布局

  1. 根据渲染树计算每个节点的位置和大小。
  2. 在浏览器页面区域绘制元素边框。
  3. 遍历渲染树,将元素以盒模型的形式写入文档流。

page24_image3.png

页面绘制

  1. 构建图层:为特定的节点生成专用图层。
  2. 绘制图层:一个图层分成很多绘制指令,然后将这些指令按顺序组成一个绘制列表,交给 合成线程
  3. 合成线程接收指令生成 图块
  4. 栅格线程将图块进行栅格化。
  5. 展示在屏幕上。

page25_image7.jpg

page25_image3.jpg

前端性能 performance

Performance是一个做前端性能监控离不开的API,最好在页面完全加载完成之后再使用,因为很多值必须在页面完全加载之后才能得到。最简单的办法是在window.onload事件中读取各种数据。

page26_image5.jpg

performance对象中常用的属性和方法:

方法结果
navigationStart浏览器开始导航到当前页面的时间戳。
fetchStart浏览器开始检索文档的时间戳。
responseStart浏览器收到第一个字节的时间戳。
domLoading浏览器开始解析文档的时间戳。
domInteractive浏览器完成解析文档的时间戳。
domContentLoadedEventStart浏览器完成解析文档的时间戳。
domComplete文档解析完成的时间戳。
loadEventStart文档加载事件开始的时间戳。
loadEventEnd文档加载事件结束的时间戳。

最常用的是 navigationStartloadEventEndnavigationStart 表示浏览器开始导航到当前页面的时间戳,loadEventEnd 表示文档加载事件结束的时间戳。这两个时间戳之差就是页面的加载时间。

时间都花在哪?

performance.timing

这个 API 能帮我们得到整个页面请求的时间,如下图,在 ChromeConsole 是可以直接运行的

以下解释这些数据代表什么:

  • fetchStart:发起获取当前文档的时间点,我的理解是浏览器收到发起页面请求的时间点;
  • domainLookupStart:返回浏览器开始 DNS 查询的时间,如果此请求没有 DNS 查询过程,如长连接、资源 cache、甚至是本地资源等,那么就返回 fetchStart 的值;
  • domainLookupEnd:返回浏览器结束 DNS 查询的时间,如果没有 DNS 查询过程,同上;
  • connectStart:浏览器向服务器请求文档,开始建立连接的时间,如果此连接是一个长连接,或者无需与服务器连接(命中缓存),则返回 domainLookupEnd 的值;
  • connectEnd:浏览器向服务器请求文档,建立连接成功的时间;
  • requestStart:开始请求文档的时间(注意没有 requestEnd);
  • responseStart:浏览器开始接收第一个字节数据的时间,数据可能来自于服务器、缓存、或本地资源;
  • unloadEventStart:卸载上一个文档开始的时间;
  • unloadEventEnd:卸载上一个文档结束的时间;
  • domLoading:浏览器把 document.readyState 设置为 “loading” 的时间点,开始构建 dom 树的时间点;
  • responseEnd:浏览器接收最后一个字节数据的时间,或连接被关闭的时间;
  • domInteractive:浏览器把 document.readyState 设置为 'interactive' 的时间点,DOM 树创建结束;
  • domContentLoadedEventStart:文档发生 DOMContentLoaded 事件的时间;
  • domContentLoadedEventEnd:文档的 DOMContentLoaded 事件结束的时间;
  • domComplete:浏览器把 document.readyState 设置为 "complete" 的时间点;
  • loadEventStart:文档触发 load 事件的时间;
  • loadEventEnd:文档出发 load 事件结束后的时间;

从以上的分析,我们就可以得到一些时间的计算:

  • 准备新页面耗时fetchStart - navigationStart
  • 重定向时间redirectEnd - redirectStart
  • App Cache时间domainLookupStart - fetchStart
  • DNS解析时间domainLookupEnd -domainLookupStart
  • TCP连接时间connectEnd - connectStart
  • request时间responseEnd - requestStart这个计算是代表请求响应加起来的时间
  • 请求完毕到DOM树加载domInteractive -responseEnd
  • 构建与解析DOM树,加载资源时间domCompleter -domInteractive
  • load时间loadEventEnd - loadEventStart
  • 整个页面加载时间loadEventEnd -navigationStart
  • 白屏时间responseStart-navigationStart

performance.getEntries()

这个API能帮我们获得资源的请求时间,包括JS、CSS、图片等。

如上图可以看到这个 API 请求返回的是一个数组,这个数组包括整个页面所有的资源加载,上图打开了一个其中一个资源,可以看到如下信息:

  • entryType:类型为resource
  • name:资源的url
  • initiatorType:资源是link
  • 资源时间:duration的值,是responseEnd - startTime得到的

什么情况下卡顿?

卡顿一般是由于浏览器在渲染页面时遇到了阻塞或耗时操作导致的。以下是一些可能导致卡顿的情况:

  1. JavaScript 执行时间过长:JavaScript 的执行会阻塞页面的渲染。如果脚本执行时间过长,会导致页面无响应或卡顿现象。
  2. 大量的 DOM 操作:DOM 操作会影响页面的渲染,特别是在大量DOM操作时。如果需要频繁地更新DOM元素,可以考虑使用虚拟 DOM 等技术来减少 DOM 操作次数。
  3. 大量的网络请求:浏览器在渲染页面时需要下载和解析HTML、CSS、JavaScript和图像等资源。如果有大量的资源需要下载,会导致页面加载时间过长。
  4. 大量的样式和布局计算:如果页面包含大量的样式和布局计算,会影响页面的渲染性能。
  5. 阻塞渲染的 JavaScript:如果 脚本阻塞了页面的渲染,就会导致卡顿或页面无响应

首屏优化

  1. 压缩、分包、删除无用代码

    通过压缩代码、分包加载和删除无用代码等技术,可以减小页面的体积,加快页面的加载速度。

  2. 静态资源分离

    将页面中的静态资源(如CSS、JavaScript和图像等)与 HTML 文档分离,可以使得浏览器可以并行加载这些资源,从而提高页面的加载速度。

  3. JS 脚本非阻塞加载

    将 JS 脚本异步加载,可以减少页面的渲染阻塞,从而提高页面的加载速度。可以使用 deferasync 等属性来实现 JS 的非阻塞加载。

  4. 缓存策略

    合理地设置缓存策略,可以减少对服务器的请求,加快页面的加载速度。可以使用 HTTP 响应头中的 Cache-ControlExpires 等属性来设置缓存策略。

  5. SSR

    服务器端渲染(Server Side Rendering)可以在服务器端生成 HTML 文档,减少客户端渲染的工作量,从而提高页面的加载速度。SSR适用于复杂的单页面应用或对 SEO 有要求的应用。

  6. 预置loading、骨架屏

    在页面加载过程中,可以预置一个 loading 动画或骨架屏,以提高用户体验。这些技术可以在页面加载完成之前,先显示一些占位元素,给用户一个等待的感觉,从而减少用户等待的焦虑和不安。

渲染优化

  1. GPU 加速

    将复杂的图形处理任务交给GPU来处理,可以加快页面的渲染速度。可以使用 CSS3 的 transformopacity 等属性来开启GPU加速。

  2. 减少回流、重绘

    回流和重绘是影响页面性能的主要因素之一。可以通过避免使用影响布局的属性、批量修改 DOM 元素等技术来减少回流和重绘操作。

  3. 离屏渲染

    离屏渲染是将页面中的部分内容在单独的图层中进行渲染,从而减少对主渲染线程的阻塞。可以使用 CSS3 的 transformposition 等属性来开启离屏渲染。

  4. 懒加载

    将页面中的非必要资源(如图片和视频等)延迟加载,可以加快页面的加载速度。可以使用 Intersection ObserverLazyload 等技术来实现懒加载。

JS 优化

  1. 防止内存泄漏

    • 有可能出现内存泄漏的场景

      • 全局变量:全局变量会一直存在于内存中,直到程序结束才会被释放。如果程序中定义了大量的全局变量,就会导致内存占用过多,从而导致内存泄漏。
      • 闭包:闭包会在函数中保存局部变量和参数,如果函数执行后,闭包中的变量没有被释放,就会导致内存泄漏。为了避免内存泄漏,应该合理使用闭包,并注意释放不需要的变量。
      • 循环引用:循环引用是指两个或多个对象之间相互引用,形成了一个死循环,导致内存无法释放。为了避免循环引用,应该及时释放不需要的引用,并使用垃圾回收机制来自动释放内存。
      • 定时器和事件监听器:定时器和事件监听器会持续占用内存,直到被清除或被解除绑定。如果程序中存在大量的定时器和事件监听器,就会导致内存占用过多,从而导致内存泄漏。
      • DOM 节点:DOM 节点也会占用内存空间,如果程序中存在大量的 DOM 节点,就会导致内存占用过多,从而导致内存泄漏。为了避免内存泄漏,应该及时清除不需要的 DOM 节点
    • 内存泄漏会导致不必要的内存占用和程序崩溃。可以使用 letconst 关键字声明变量,避免变量污染和内存泄漏

  2. 循环尽早 break

    在循环中,如果已经找到了需要的结果,可以使用 break 语句尽早结束循环,避免无用的迭代和计算。

  3. 合理使用闭包

    闭包可以在函数中保存局部变量和参数,避免全局变量的污染和泄漏。但是,如果使用不当,也会导致内存泄漏和性能下降。

  4. 减少 Dom 访问

    DOM 操作是 JavaScript 性能的一个瓶颈。可以使用缓存和批量操作等技术来减少 DOM 访问次数,从而提高 JavaScript 的性能。

  5. 防抖、节流

    防抖(debounce): 防抖是指在一定时间内,如果连续触发事件,那么只执行一次目标函数。常用于输入框实时搜索、窗口大小调整等场景。

    防抖的实现原理:设置一个定时器,在指定的延迟时间内,如果再次触发事件,则重新计时。只有在延迟时间内没有再次触发事件时,才会执行目标函数。

    节流(throttle): 节流是指在一定时间内,无论触发多少次事件,目标函数都只执行一次。常用于滚动事件、鼠标移动等场景。

    节流的实现原理是:设置一个间隔时间,在这个时间内,无论事件触发多少次,都只执行一次目标函数。一旦超过这个间隔时间,就会再次执行目标函数。

    总结: 防抖和节流的 主要区别 在于,防抖 是在一定时间内只执行一次目标函数,而 节流 是在一定时间内控制目标函数执行次数。它们都可以有效地减少函数执行频率,降低性能开销,提高用户体验。

    防抖和节流是用来控制函数调用频率的技术。可以使用 setTimeout requestAnimationFrame 等 API 来实现防抖和节流(或者用第三方库也行)。

  6. Web Workers

    Web Workers 是一种在后台线程中执行 JavaScript 代码的技术。可以将耗时的计算任务和数据处理等操作放到 Web Workers 中执行,避免阻塞主线程,提高页面的响应速度。

跨端容器

为什么需要跨端

  1. 开发成本、效率

    跨端开发可以帮助降低成本和提高开发效率。使用跨端技术,开发者只需编写一份代码,就可以在多个平台(如iOS、Android和Web)上运行。这可以减少开发和维护的工作量,节省时间和资源。同时,开发团队可以更快地推出新功能和修复问题,因为他们只需关注一份代码库。

  2. 一致性体验

    跨端开发可以确保在不同平台上提供一致的用户体验。使用跨端技术,开发者可以更容易地保持应用的外观和功能一致,无论用户在什么设备上使用。这有助于提高用户满意度和用户留存率。

  3. 前端开发生态

    跨端开发受益于强大的前端生态系统。许多流行的前端框架和库,如 React Native、Flutter和Ionic,都支持跨端开发。这些工具为开发者提供了丰富的资源和丰富的社区支持,帮助他们更轻松地实现跨端功能。

page31_image4.png

有哪些跨端方案

page32_image4.png

WebView

简介

WebView,即网页视图,用于加载网页 URL,并展示其内容的控件。可以嵌在移动端 App 内,实现前端混合开发,大多数混合框架都是基于 Webview 的二次开发:比如 lonic、Cordova

优点

  1. 跨平台兼容性:使用 WebView,开发者可以利用 Web 技术为多个平台(如iOS和Android)创建应用。这可以节省开发时间和成本,一次开发处处使用,学习成本低,同时确保应用在各个平台上提供一致的用户体验。
  2. 代码重用:WebView 允许开发者在移动应用中重用现有的 Web 代码。这意味着,对于已有 Web 应用的公司来说,可以更容易地将其产品扩展到移动平台。
  3. 简化更新过程:随时发布,即时更新,不用下载安装包。WebView使得应用内容的更新变得更加简单。因为 WebView 直接加载 Web 内容,开发者可以在服务器端更新应用内容,而无需重新提交整个应用到应用商店进行审核。
  4. 通过 JSBridge 和原生系统交互,能够实现一些更加复杂的功能

局限性

性能:相比于原生应用,基于 WebView 的应用性能可能较差。这主要是因为 WebView 需要加载和运行 Web 内容,这会消耗更多的系统资源。在某些情况下,这可能导致较慢的加载速度和不流畅的用户体验。

原生功能访问限制:虽然 WebView 提供了一些与原生功能交互的能力,但它仍然受到一定的限制。为了实现某些特定的原生功能,开发者可能需要编写额外的平台特定代码。

用户体验差异:尽管 WebView 可以确保跨平台的一致性,但它可能无法完全符合不同平台的设计规范。因此,在某些情况下,基于 WebView 的应用可能无法提供与原生应用相同水平的用户体验。

WebView 分离

常用 webview:Android、IOS、国产 Android。

  1. WebView(Android):这是 Android 平台的原生 WebView 组件,用于在 Android 应用中加载并显示 Web 内容。根据 Android 版本和设备制造商的不同,WebView 的表现可能会有所差异。这可能导致一些兼容性和性能问题。
  2. X5 WebView(腾讯X5内核):这是由腾讯公司推出的一种 WebView 解决方案,用于解决 Android 系统上 WebView 的碎片化问题。X5 内核基于腾讯 QQ 浏览器的内核,提供了更稳定、更高性能的 WebView 组件。它可以在各种 Android 设备和系统版本上提供一致的表现,减少兼容性问题。
  3. UIWebView(iOS,已弃用):这是 iOS 平台的原生 WebView 组件,用于在iOS应用中加载并显示 Web 内容。 UIWebView 自 iOS 2.0 开始引入,但在 iOS 8.0 中被 WKWebView 取代。自 iOS 12.0 以来,UIWebView 已被官方弃用,不再推荐使用。
  4. WKWebView(iOS):这是 iOS 平台上的新一代 WebView 组件,取代了已弃用的 UIWebView。WKWebView 自 iOS 8.0 开始引入,它具有更好的性能和更丰富的功能,如支持多进程、JavaScript 性能改进等。苹果公司推荐开发者使用 WKWebView 来在 iOS 应用中加载并显示 Web 内容。

使用原生能力

JavaScript 调用 Native

  • API 注入:Native 获取 Javascipt 环境上下文,对其挂载的对象或者方法进行拦截
  • 使用 Webview URL Scheme 跳转拦截
  • IOS 上 window.webkit.messageHandler 直接通信

Native 调用 JavaScript

  • 直接通过 webview 暴露的 API 执行 JS 代码
  • IOS webview.stringByEvaluatingJavaScriptFromString
  • Android webview.evaluateJavascript

WebView 与 Native 通信

  1. JS 环境中提供通信的 JSBridge
  2. Native 端提供 SDK 响应的 JSBridge 发出的调用
  3. 前端和客户端分别实现对应功能模块

page37_image6.png

实现一个简易 JSBridge

interface CallAge {
  callId: string	// 调用Id,唯一标识
  module: string	// 调用模块
  method: string	// 调用方法
  data: any	// 参数
}
const Callbacks = {}	// 存放回调函数 callId 为 key
function applyNative = (payload:CallArgs, callback:Function)=> {
  const callId = prefix + callTime++
  Callbacks[callId] = callback
  const Args0: CallArgs = {
    callId: callId,
    module: payload.module || 'layout',
    method: payload.method || 'randomSize',
    data: payload.data,
  }
  if (IOS) {
    return window.webkitURL.messageHandler.postMessage(JSON.stringify(Args0))
  } else {
    // Android 对 window 上约定的对象进行拦截
    return window.AndroidBridge(JSON.stringify(Args0))
  }
}
interface RemponseArgs {
  responseId: string	// 回调Id,与 callId 对应
  errCode: number
  errMsg?: string
  data: unknow
}
// native 端调用 webview,参数都经过序列化
const applyWebview = (res:string)=> {
  const response = JSON.parse(res) as ResponseArgs
  const {responseId} = response
  // 从 Callbacks 找到对应的回调处理方法
  if (type of Callbacks[responseId] === 'function') {
    Callbacks[responseId](response)
    // 回调后删除该次回调函数
    delete Callbacks[responseId]
  }
}
window.JSBridge = {
  applyNative,
  applyWebview	// 挂载在 window 上,供 native 直接调用
}

小程序

  1. 微信、支付宝、百度小程序、小米直达号
  2. 渲染层 - webview
  3. 双线程,多 webview 架构
  4. 数据通信,Native 转发

page39_image6.png

React Naive / WeeX

React Native

React Native 是 Facebook 开发的一个开源框架,它允许开发者使用 React 和 JavaScript 编写原生移动应用。React Native 的主要特点是 "Learn once, write anywhere"(学习一次,随处编写),这意味着开发者可以在不同平台(如 iOS 和 Android)上使用相同的技术栈构建原生应用。

优势

  • 代码复用:React Native 允许开发者在多个平台上复用大部分代码,从而减少开发时间和成本。
  • 热重载:React Native 支持热重载功能,允许开发者在不重新编译整个应用的情况下查看代码更改的效果,提高开发效率。
  • 原生性能:虽然 React Native 使用 JavaScript 编写,但它将 JavaScript 代码转换为原生组件,从而提供了接近原生应用的性能。
  • 丰富的生态系统:React Native 拥有庞大的社区支持,开发者可以利用许多第三方库和组件来加速开发过程。

WeeX

Weex 是由阿里巴巴开发的一个开源框架,它允许开发者使用 Vue.js 和 JavaScript 编写原生移动应用。Weex 的目标是实现 "Write once, run everywhere"(编写一次,随处运行),即使用一套代码构建多个平台的原生应用。

优势

  • 代码复用:类似于 React Native,Weex 也支持在不同平台上复用代码,从而降低开发成本。
  • 原生渲染:Weex 将 Vue.js 组件转换为原生组件进行渲染,从而实现了较高的性能。
  • 插件系统:Weex 提供了一个丰富的插件系统,开发者可以使用这些插件来轻松实现原生功能,如地图、支付等。
  • 模块化:Weex 允许开发者将应用拆分为多个模块,这有助于实现高度模块化的开发过程,提高代码可维护性。

渲染流程

  1. 原生组件渲染
  2. React / Vue 框架
  3. virtual dom
  4. JSBridge

page40_image6.png

Lynx

  1. 基于 Vue 框架

    Lynx 采用 Vue.js 作为其基础框架,允许开发者使用 Vue.js 编写应用。Vue.js 是一种渐进式 JavaScript 框架,它使开发者能够轻松地构建可扩展、高性能的应用。基于 Vue.js 的设计原则,Lynx 可以提供简洁、模块化的代码结构,以及良好的开发体验。

  2. 绑定于 JS Core / V8

    Lynx 选择 JS Core(iOS平台)或 V8(Android平台)作为其 JavaScript 引擎。这意味着 Lynx 应用在运行时,JavaScript 代码将在高性能的 JavaScript 引擎中执行。通过使用这些优秀的 JavaScript 引擎,Lynx 能够确保应用在不同平台上具有稳定、高性能的运行表现。

  3. JSBinding

    Lynx 使用 JSBinding 技术实现 JavaScript 与原生代码之间的通信。这种技术允许 JavaScript 直接调用原生方法,并使原生代码能够执行 JavaScript 回调。通过 JSBinding,Lynx 实现了高效的原生与 JavaScript 之间的通信,降低了性能损失。

  4. Native UI / Skia

    Lynx 使用 Native UI 组件和 Skia 作为其渲染引擎。Native UI 组件意味着 Lynx 应用在运行时,界面将使用原生组件进行渲染。这可以确保应用具有接近原生应用的性能和用户体验。同时,Lynx 还采用了 Skia 图形库,它是一种高性能的 2D 图形渲染引擎,用于绘制图形和文本。Skia 使 Lynx 应用在渲染复杂界面时能够保持流畅的帧率和高质量的视觉效果。

page41_image5.png

Flutter

Flutter 是 Google 开发的一个 开源 UI 工具包,旨在为开发者提供一种构建优美、高性能的跨平台应用的解决方案。Flutter 具有一些独特的特点,包括基于 Widget 的设计、Dart VM 以及使用 Skia 图形库。

  1. wideget

    在 Flutter 中,所有UI元素都被称为Widget。Widget 是 Flutter 应用的基本构建块,它们可以嵌套、组合以及自定义,从而创建复杂的用户界面。Flutter 提供了丰富的预制 Widget,如文本、按钮、列表等,开发者可以直接使用这些 Widget,也可以通过组合和扩展它们来构建自定义的 Widget。

  2. dart vm

    Flutter 使用 Dart 语言进行开发,Dart 是一种强类型、面向对象的编程语言,它既可以编译成 JavaScript 代码(用于Web应用),也可以编译成机器码(用于移动应用)。

    在移动端,Flutter 应用运行在 Dart VM(虚拟机)中。Dart VM 提供了即时编译(JIT)和预编译(AOT)两种编译方式。在开发过程中,Dart VM 采用即时编译,这使得 Flutter 具有热重载功能,开发者可以在不重新编译整个应用的情况下查看代码更改的效果。在发布应用时,Dart VM 会采用预编译,将 Dart 代码编译成高效的机器码,以提高应用的性能

  3. skia 图形库

    Flutter 使用 Skia 图形库进行 UI 渲染。Skia 是一种高性能的 2D 图形渲染引擎,用于绘制图形和文本。由于 Flutter 直接使用 Skia 进行渲染,它无需依赖于原生 UI 组件,可以实现统一的跨平台 UI 渲染。这使得 Flutter 应用具有高度的可定制性,同时还保持了流畅的性能和优美的视觉效果。

page42_image5.png

通用原理

  1. UI 组件
  2. 渲染引擎
  3. 逻辑控制引擎
  4. 通信桥梁
  5. 底层 API 抹平表现差异

image.png

跨端方案对比

思考:

  1. 同样是基于 webview 渲染,为什么小程序体验比 webview 流畅
  2. 未来的跨端方案是什么

总结

page46_image3.png