我所知道的HTML——现成轮子都堆成山了,新特性还算新吗?(下篇)

548 阅读11分钟

往期回顾

  1. 我所知道的HTML——现成轮子都堆成山了,新特性还算新吗?(上篇)
  2. 我所知道的HTML——现成轮子都堆成山了,新特性还算新吗?(中篇)

(如果您正巧因为首页推荐的功能点进此文章,由衷地建议您先回顾往期内容,这将有助您接下来的阅读体验。)

QQ图片20240823121106.png

前言

在上一篇文章中,我们聊了一部分HTML5新特性(Web API)所带来的网页在功能层面上的提升,如:拖放、桌面通知、历史管理。

在本篇文章中,我们将会一起学习如下内容:

  • 多任务: Web Worker

  • 跨窗口通信:window.postMessage

  • 跨源资源共享(CORS): Access-Control-Allow-Origin以及衍生的网络安全问题

  • 全双工通信协议: Websocket

  • 离线应用:本地存储 localStorage 和 sessionStorage 和 manifest

Web Worker

Web Worker 使得在一个独立于 Web 应用程序主执行线程的后台线程中运行脚本操作成为可能。这样做的好处是可以在独立线程中执行费时的处理任务,使主线程(通常是 UI 线程)的运行不会被阻塞/放慢。

衍生问题

聊到主线程被阻塞的问题,其实肯定就得了解浏览器是如何渲染页面的,为什么主线程,也就是UI线程运行的时候会被阻塞?被谁阻塞了?优化阻塞的方式有哪些?了解script的几种模式吗?asyncdefer 的区别是什么?

这些都是很有可能,面试官在问你HTML相关的知识点时,衍生出来的问题。上述的几个问题如果你看了觉得心里没底的话,可以看下我写的这篇文章:

《【事件循环秘籍】人形浏览器的修炼之道(上篇)》

使用

好了,题外话聊完了,我们回到Web Worker上,聊一聊它的使用。

Web Worker,其实重点就在后半部分的 Worker 上。那么,Worker 是什么呢?它是怎么能够完成我们交给它的任务的呢?

Worker 是一个使用构造函数创建的对象(例如 Worker()),它运行一个具名 JavaScript 文件——该文件包含将在 worker 线程中运行的代码。

那么,比如我们交给了Worker线程一个任务,当Worker线程完成任务之后,该用什么方式告诉主线程,它完成任务了呢?

这里就需要实例方法postMessage和事件message了。

请注意,这里提到的postMessageWorker对象的实例方法,而不是Window.postMessage哦,这两者是不同的。

  • postMessage:可以向 worker 发送消息,此实例方法拥有2个入参:

    • message:要传递给 worker 的对象;这将在传递给 DedicatedWorkerGlobalScope.message_event 事件的 data 字段中。这可以是任何值或可以通过结构化克隆算法处理的 JavaScript 对象(可以包含循环引用)。

      如果提供 message 参数,则解析器将抛出 SyntaxError。如果要传递给 worker 的数据不重要,可以显式传递 null 或 undefined

    • transfer 可选: 一个可选的、会被转移所有权的可转移对象数组。如果一个对象的所有权被转移,它将在发送它的上下文中变为不可用(中止),而仅在接收方的 worker 中可用。

      像 ArrayBufferMessagePort 或 ImageBitmap 类的实例才是可转移对象,才能够被转移。不能将 null 作为 transfer 的值。

  • message:当 worker 的父级接收到来自其 worker 的消息时(也就是说,当 worker 通过 DedicatedWorkerGlobalScope.postMessage() 发送消息时),会在 Worker 对象上触发 message 事件。

    • 在如 addEventListener() 等方法中使用事件名称,或者使用事件处理器属性。
    
    addEventListener("message", (event) => {});
    // 下方的这种用法,是我们比较常见的
    onmessage = (event) => {};
    
    

    此事件不能取消,也不会冒泡。

代码示例

初步了解之后,我们一起来看个代码例子。当我们期望以http协议加载html文件,而非file协议加载时(二者区别在上一篇文章中提到过),我们需要在本地写一个server.js的文件,然后通过Node.js去运行它。

image.png

(当然,你也可以选择将html文件放在一些现成的脚手架项目里,比如CRA、CNA,然后创建一个Page组件,在组件内部使用iframe去加载即可)

server.js

const http = require("http");
const fs = require("fs");
const path = require("path");

const hostname = "127.0.0.1";
const port = 3000;

const server = http.createServer((req, res) => {
  console.log(`Request for ${req.url} by method${req.method}`);

  if (req.method === "GET") {
    let fileUrl;
    if (req.url === "/") {
      fileUrl = "/web-worker-demo.html";
    } else {
      fileUrl = req.url;
    }

    const filePath = path.resolve("./public" + fileUrl);
    const fileExt = path.extname(filePath);

    if (fileExt === ".html") {
      fs.access(filePath, (err) => {
        if (err) {
          res.statusCode = 404;
          res.setHeader("Content-Type", "text/html");
          res.end(
            `<html><body><h1>Error 404: ${fileUrl} not found</h1></body></html>`
          );
          return;
        }
        res.statusCode = 200;
        res.setHeader("Content-Type", "text/html");
        fs.createReadStream(filePath).pipe(res);
      });
    } else {
      res.statusCode = 404;
      res.setHeader("Content-Type", "text/html");
      res.end(
        `<html><body><h1>Error 404: ${fileUrl} is not an HTML file</h1></body></html>`
      );
    }
  } else {
    res.statusCode = 404;
    res.setHeader("Content-Type", "text/html");
    res.end(
      `<html><body><h1>Error 404: ${req.method} not supported</h1></body></html>`
    );
  }
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

现在我们假设一个业务场景,比如一个耗时较长的计算数值的任务(以经典的斐波那契数列为例)。当我们在页面中遇到多个计算任务一同出现的情况时,这将导致非常不友好的使用体验,如:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Worker Example</title>

</head>

<body>

    <button id="btn">Click Me</button>
    <script>
        const btn = document.getElementById('btn');
        btn.addEventListener("click", () => { console.log('点击成功') })
        const compute = (num) => {
            if (num === 1 || num === 2) {
                return 1
            }
            else {
                return compute(num - 2) + compute(num - 1)
            }
        }
        console.time('计算任务1执行')
        console.log(compute(45))
        console.timeEnd('计算任务1执行')
        // console.time('计算任务2执行')
        // console.log(compute(45))
        // console.timeEnd('计算任务2执行')
        // console.time('计算任务3执行')
        // console.log(compute(45))
        // console.timeEnd('计算任务3执行')

    </script>
</body>

</html>

在上面这份代码中,我监听了一个按钮的点击事件,当它被点击时,它会在控制台打印文字信息。然后我又运行了一个计算任务,这个计算任务大概耗时4秒。

👇我们可以一起来看看当这个html被加载时,表现如何👇:

webworker例子.gif

从上方的例子可以看到,在计算任务完成之前(递归计算,call stack一直被占用着),按钮的点击事件是无效的,这就是很不好的用户体验了。而且我们这里还只是开启了一个计算任务,假如把被注释掉的代码全部打开,则页面将会存在12秒左右的“无效期”。

因此,这时候我们就要使用Web Worker了,将耗时任务交给它去执行。

live-server

由于我们是本地项目,因此我们使用live-server来启动一个本地服务器。

live-server是一个Node应用程序,它为你的项目文件提供服务,并在你更改它们时自动重新加载浏览器。它支持HTTPS、CORS、代理、中间件等更多功能。

使用npm i -g live-server完成安装,而后直接在终端输入node live-server即可。

我们先写一下worker.js的内容:

const compute = (num) => {
  if (num === 1 || num === 2) {
    return 1;
  } else {
    return compute(num - 2) + compute(num - 1);
  }
};

self.onmessage = (e) => {
  console.log("从主线程接收信息", e.data);
  console.time("计算任务1执行");
  const res = compute(45);
  console.timeEnd("计算任务1执行");
  self.postMessage(res);
};

然后修改一下原来html文件中<script>内的代码逻辑:

    <script>

        const btn = document.getElementById('btn');
        btn.addEventListener("click", () => { console.log('点击成功') })
        const worker1 = new Worker("worker.js")
        // 让worker线程计算斐波那契数列的第45项
        worker1.postMessage(45)
        worker1.onmessage = (e) => {
            console.log("从子线程中接收结果", e.data)
        }

    </script>


接下来,我们一起看一下动图演示:

webworker例子2.gif

由上方的动图演示,我们可以直观地看到,当耗时的计算任务交给Worker之后,主线程将不会被阻塞 ,用户可以正常点击按钮(能够成功地在控制台打印出文字信息)。

Worker完成任务后,主线程通过onmessage事件也成功接收到了执行结果。

Worker的好处,由此可见一斑~

最后,我们可以调用实例方法Worker.terminate()来立即终止worker。

扩展

Worker还有很多种特定类型,如SharedWorkerServiceWorker,出于篇幅考虑,不在本篇文章继续深入聊下去了,后续会放在一个专题文章中和大家接着聊,敬请期待。

跨窗口通信:window.postMessage

在上一章节中,我特别提到了让大家注意区分Worker.postMessagewindow.postMessage,这俩虽然都是postMessage,但是功能可不一样哦,不要混淆了。

window.postMessage()  方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为 https),端口号(443 为 https 的默认值),以及主机 (两个页面的模数 Document.domain设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage()  方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

既然定义里提到了跨源,这里我们顺便复习一下,什么是浏览器的同源策略,然后我们再接着聊跨源和使用window.postMessage()跨源通信。

浏览器的同源策略

同源策略是一个重要的安全策略,它用于限制一个的文档或者它加载的脚本如何能与另一个源的资源进行交互。

它能帮助阻隔恶意文档,减少可能被攻击的媒介。例如,它可以防止互联网上的恶意网站在浏览器中运行 JS 脚本,从第三方网络邮件服务(用户已登录)或公司内网(因没有公共 IP 地址而受到保护,不会被攻击者直接访问)读取数据,并将这些数据转发给攻击者。

比如:

  1. Cookies和LocalStorage:如果两个网站没有同源策略的限制,恶意网站可以读取用户的Cookies和LocalStorage信息,这些信息通常包含了用户的登录凭证、偏好设置等敏感信息。
  2. 跨站请求伪造(CSRF) :如果两个网站没有同源策略的限制,攻击者可以利用用户的登录状态发起跨站请求,执行未经授权的操作。

同源、同源、同源,到底怎么样才算是同源呢?

源的定义:

如果两个 URL 的协议端口(如果有指定的话)和主机都相同的话,则这两个 URL 是同源的。这个方案也被称为“协议/主机/端口元组”,或者直接是“元组”。(“元组”是指一组项目构成的整体,具有双重/三重/四重/五重等通用形式。)

我们拿掘金的网址举个例子:https://juejin.cn

image.png

URL结果原因
https://juejin.cn/pins同源只有路径不同
https://juejin.cn/events/all同源只有路径不同
http://juejin.cn失败协议不同,一个是https,另一个是http
https://juejin.cn:442失败端口不同(https:// 默认端口是 443)
https://cn.bing.com失败主机不同

在对同源策略有个基本了解之后,我们说回window.postMessage()

与实例方法Worker.postMessage()不同,window.postMessage()有3个参数:

  • data

    • 从其他 window 中传递过来的对象。
  • origin

    • 调用 postMessage 时消息发送方窗口的 origin . 这个字符串由 协议、“://“、域名、“ : 端口号”拼接而成。例如“https://example.org (隐含端口 443)”、“http://example.net (隐含端口 80)”、“http://example.com:8080”。请注意,这个 origin 不能保证是该窗口的当前或未来 origin,因为 postMessage 被调用后可能被导航到不同的位置。
  • source

    • 对发送消息的窗口对象的引用; 你可以使用此来在具有不同 origin 的两个窗口之间建立双向通信。

代码示例

我们可以使用window.postMessage()完成当前窗口和内嵌的iframe窗口中的通信。我们也写一个例子,准备两张页面:page1.htmlpage2.html。然后将page2.html通过src引入的形式嵌入在page1.htmliframe中。

page1:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>postMessage Example</title>

</head>

<body>

    <p>另一个页面</p>
    <iframe id="myIframe" src="page2.html" style="width: 400px; height: 200px;"></iframe>

    <script>

        window.onload = function () {
            var iframeWindow = document.getElementById('myIframe').contentWindow;
            // 注意这里的第二个参数应该是 "http://127.0.0.1:8080/public/page1.html",因为这是iframe的源
            iframeWindow.postMessage('Hello from Page 1!', 'http://127.0.0.1:8080/public/page1.html');
        };

    </script>
</body>

</html>

page2:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>postMessage Example</title>

</head>

<body>

    <p>iframe页面</p>
    <script>

        window.addEventListener('message', function (event) {
            console.log('Message from Page 1: ' + event.data)
        }, false);

    </script>
</body>

</html>

我们用server.js去运行page2,再用live-server去运行page1,模拟它们不同源的情况(端口不同,因此不同源),接下来看看实机演示图:

image.png

可以看到iframe成功接收到了来自主窗口的消息。

应用场景

广告与用户互动

  • 广告播放完毕通知:主窗口可以接收来自iframe中的小广告的信息,判断用户是否观看完毕。例如,视频广告播放完成后,iframe中的广告代码可以发送一个消息到主窗口,主窗口据此解锁页面内容或记录观看行为。
  • 点击追踪:iframe中的广告被点击时,可以通知主窗口进行追踪。

微前端架构

  • 子应用消息传递:在微前端架构中,多个子应用可能嵌入到主应用中,使用 postMessage 进行状态共享或事件通知。

跨域身份验证

  • 身份验证状态共享:用户在一个iframe中登录后,可以将登录状态通过 postMessage 传递给父窗口,以便在父窗口中更新UI。

在线编辑器与预览

  • 实时预览:在在线代码编辑器中,编辑器的更改可以通过 postMessage 实时传递给预览窗口,实现所见即所得的编辑体验。

多窗口应用

  • 窗口间通信:在一个多窗口应用中,例如同时打开多个标签页,可以通过 postMessage 在这些窗口之间同步状态。

结语

咳咳,所以说,flag🚩这种东西是不能乱立的,这不,用3篇文章的想法写完同样还是天真了一些.....

第4篇,完结篇,一定完结!

下次一定

期待与你在完结篇相遇~