Skip to content

场景技巧

JavaScript 中超过 Number 最大值的数这么处理?

在 JavaScript 中,超过Number.MAX_VALUE的数被认为是Infinity,而在某些场景下我们需要正确地操作/显示超过Number.MAX_VALUE的数,在 ES2020/ES11 之前通常需要借助第三方库来实现,例如big.js

js
const big = require("big.js");
const x = new big("9007199254740993");
const y = new big("100000000000000000");
const result = x.plus(y);
console.log(result.toString()); // 输出:109007199254740993

第二种方案就是借助 ES2020/ES11 引入的新的数据类型BigInt,可以用在一个整数字面量后面加 n 的方式来定义一个 BigInt。注意,使用该方案需要考虑浏览器的兼容性,如果在不支持的环境中使用,需要使用 polyfill 或者第三方库。

MDN

BigInt是一种内置对象,它提供了一种方法来表示大于 2^53 - 1 的整数。这原本是 Javascript 中可以用 Number 表示的最大数字。BigInt可以表示任意大的整数。

js
const a = 12345678900000000000000n;

使⽤同⼀个链接,如何实现 PC 打开是 web 应⽤、⼿机打开是⼀个 H5 应⽤?

可以通过根据请求来源(User-Agent)来判断访问设备的类型,然后在服务器端进行适配。

具体实现可以参考以下步骤:

  1. 根据 User-Agent判断访问设备的类型,例如判断是否为移动设备。可以使用第三方库如ua-parser-js进行解析。

  2. 如果是移动设备,可以返回⼀个 H5 页面或接口数据。

  3. 如果是 PC 设备,可以返回⼀个 web 应用页面或接口数据。

如何解决页面请求接口大规模并发问题?

接口大规模并发包含了接口并发和前端资源请求并发,解决方案参考如下:

  1. 后端优化:可以对接口进⾏优化,采用缓存技术,对数据进行预处理,减少数据库操作等。使用集群技术,将请求分散到不同的服务器上,提高并发量。另外可以使用反向代理、负载均衡等技术,分担服务器压力。

  2. 做 BFF 聚合:把所有首屏需要依赖的接口,利用服务中间层聚合为一个接口。

  3. CDN 加速:使用 CDN 缓存技术可以有效减少服务器请求压力,提高网站访问速度,接口数据存储在 CDN 缓存服务器可以减少对原始服务器的访问,加速数据传输速度。

  4. 使用 websocket 建立一个持久的连接,避免反复连接请求。websocket 可以实现双向通信,大大降低服务区响应时间。

  5. 使用 HTTP/2.0 及以上版本,利用多路复用。

  6. 使用浏览器缓存技术:强缓存、协商缓存、离线缓存、Service Worker缓存等。

  7. 聚合一定量的静态资源,比如对图片进行雪碧图处理,多张图片只下载一张即可。

  8. 采用微前端架构:只对当前访问的页面的静态资源进行下载,而不是下载整站的静态资源。

  9. 使用 SSR 技术:在服务端组织页面结构和处理数据并返回页面渲染,避免客户端渲染完成后进行额外的数据请求和下载。

如何设计一个全站的请求耗时统计工具?

代码层面可以统计请求耗时的方案如下:

  1. Performance API是浏览器 Web API 的一种,用于测量网页性能。通过它可以获取页面各个阶段的时间、资源加载时间等,其中PerformanceResourceTiming API可以获取到每个资源的加载时间,从而计算出请求耗时。

  2. XMLHttpRequestload事件:在发送XMLHttpRequest请求时注册load事件,在请求完成时执行回调函数,从而计算出请求耗时。

  3. fetchPerformance API:类似于XMLHttpRequest,通过该 API 获取请求耗时。

  4. 自定义封装的请求函数,在请求开始和结束时记录时间,从而计算耗时。

个人推荐使用第一种方案,这是浏览器提供的 API,功能强大,结果准确。以下是单个请求的耗时计算参考:

js
performance.mark("fetch start");

fetch("https://json.myservices.com/v1/test")
  .then((res) => res.json())
  .then((res) => {
    performance.mark("fetch end");
    const info = performance.measure(
      "fetch duration",
      "fetch start",
      "fetch end"
    );

    console.log(info.duration);

    // 发送到服务器等其他操作
  });

如何实现大文件上传?

分片上传: 在文件上传的实际应用场景中,如果需要上传的文件大小过大就会导致请求时间非常地长,可能会出现超时的情况,并且失败之后有需要重新上传,用户体验非常不好。在众多成熟的落地产品中,对于大文件上传往往会先对文件进行分片处理,然后依次将文件分片上传到服务器,等所有的分片上传完毕后,服务器对分片进行一个合并操作得到原始的文件。

断点续传: 在分片上传的过程中如果出现网络问题或者手动暂停导致上传中断,在重新开始上传后对于已经上传的文件分片无需再进行操作,只需要对未上传的分片接着上传,这就需要知道哪些文件分片没有被上传,哪些已经上传,也就是说每一个文件分片需要一个具体标识。我们一般会使用文件 hash 来作为文件标识,而文件 hash 可以使用 md5 算法计算得到(可使用第三方库,例如spark-md5)。对于上传成功的分片,可以将其 hash 保存在localStorage中,也可以由服务端保存,提供一个新的接口在重新上传时返回已经上传的文件分片的 hash 数组,在重新上传文件时检查分块的 hash 是否在已上传 hash 数组中,是则跳过。

TIP

如果需要整个文件的 hash,不推荐一次性计算,可以使用增量计算,利用spark-md5append()方法。

ts
function createChunk(file: File, chunkSize: number) {
  const result = [];
  for (let i = 0; i < file.size; i += chunkSize) {
    result.push(file.slice(i, i + chunkSize));
  }
  return result;
}
ts
import SparkMD5 from "spark-md5";

function hash(chunks: Blob[]) {
  const spark = new SparkMD5();
  return new Promise((resolve) => {
    function _read(i) {
      if (i >= chunks.length) {
        resolve(spark.end());
        return;
      }
      const blob = chunks[i];
      const reader = new FileReader();
      reader.onload = (e) => {
        const bytes = e.target.result;
        spark.append(bytes);
        _read(i + 1);
      };
      reader.readAsArrayBuffer(blob);
    }
    _read(0);
  });
}

H5 如何解决移动端适配问题?

  1. 使用viewport meta标记,设置其content属性来控制页面的缩放比例和宽度以适配不同的设备。
html
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
  1. 使用 CSS3 媒体查询做响应式布局。
css
@media screen and (min-width: 768px) {
  font-size: 12px;
}
  1. 使用相对像素单位 rem 替代绝对像素单位 px:rem 大小是相对于文档根节点的字体大小,配合 CSS 媒体查询以适配不同设备。

如何实现一个网页的加载进度条?

实现一个网页加载进度条的关键在于如何获取静态资源的加载状态,然后根据这些状态动态修改进度条。

  1. 获取页面静态资源的加载状态可以借助window.performance对象来监听所有静态资源的加载,例如:
js
const resources = window.performance.getEntriesByType("resource");
const totalResources = resources.length;
let loadedResources = 0;
resources.forEach((resource) => {
  if (resource.initiatorType !== "xmlhttprequest") {
    // 排除 AJAX 请求
    resource.onload = () => {
      loadedResources++;
      const progress = Math.round((loadedResources / totalResources) * 100);
      updateProgress(progress);
    };
  }
});
function updateProgress() {
  // 更新进度条
}
  1. 实现一个进度条,可以使用 HTML5 提供的<progress>元素或借助第三方库nprogress等。
html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>原生进度条</title>
  </head>
  <body>
    <progress id="progressBar" value="0" max="100" />
    <script>
      const progressBar = document.getElementById("progressBar");
      window.addEventListener("load", () => {
        progressBar.value = 100;
      });
      document.addEventListener("readystatechange", (e) => {
        if (e.target.readyState === "loading") {
          progressBar.value = 50;
        } else if (e.target.readyState === "interactive") {
          progressBar.value = 90;
        } else {
          progressBar.value = 100;
        }
      });
    </script>
  </body>
</html>
js
// 初始化 nprogress
NProgress.configure({ showSpinner: false });
// 监听⻚⾯加载事件
window.addEventListener("load", () => {
  NProgress.done();
});
// 监听资源加载事件
document.addEventListener("readystatechange", () => {
  if (document.readyState === "interactive") {
    NProgress.start();
  } else if (document.readyState === "complete") {
    NProgress.done();
  }
});

常见的图片懒加载的方案有哪些?

图片懒加载可以延迟图片的加载,只在图片即将进入视口时才进行加载,对于有大量图片的页面可以大大减少页面的加载时间,降低带宽,提升用户体验。例如淘宝商品页、小红书、外卖平台等等,基本上都是用户滚动时进行图片懒加载。

  1. 使用IntersectionObserver API,它可以异步检查文档元素与祖先元素或视口的交叉状态,利用这个特性可以实现图片在即将进入视口时加载并作其他处理。
js
const images = document.querySelectorAll(".image");
const observer = new IntersectionObserver((entries, ob) => {
  entries.forEach((entry) => {
    console.log(entry);
    if (entry.isIntersecting) {
      entry.target.src = entry.target.dataset.src;
      ob.unobserve(entry.target); // 取消监听该元素
    }
  });
});
images.forEach((item) => observer.observe(item));
  1. 自定义事件监听器。需注意的是,应该避免在滚动事件处理函数中频繁触发图片加载,这会影响性能,而应在滚动停止时触发。
js
function lazyLoad() {
  const images = document.querySelectorAll(".image");
  const scrollTop = window.pageYOffset;
  images.forEach((img) => {
    if (img.offsetTop < window.innerHeight + scrollTop) {
      img.src = img.dataset.src;
    }
  });
}
let lazyLoadThrottleTimeout;
document.addEventListener("scroll", function () {
  if (lazyLoadThrottleTimeout) {
    clearTimeout(lazyLoadThrottleTimeout);
  }
  lazyLoadThrottleTimeout = setTimeout(lazyLoad, 20);
});

无论哪种方法,都需要为懒加载的图片添加占位符,避免出现布局错乱。

函数式编程了解多少?

函数式编程是一种编程范式,它将程序看作是一系列函数的组合,函数是基本单位。特点如下:

  1. 纯函数:函数的输出只取决于输入,没有副作用(不会修改外部的变量或状态),因此对于同样的输入永远只能得到同样的输出。纯函数可以有效地避免副作用和竟态条件,使得代码更加可靠和易于调试。

  2. 不可变性:在函数式编程中,数据通常是不可变的,也就是不允许内部修改。这样可以避免富副作用,提高程序可靠性。

  3. 函数组合:不同的函数可以组合成新的复杂的函数,从而提高代码的复用性。

  4. 高阶函数:高阶函数是指可以接收其他函数作为参数或返回其他函数的函数。应用场景包括函数柯里化和函数组合等等。

  5. 惰性计算:惰性计算是指在必要的时候才计算或执行函数,而不是在每个可能的执行路径上都执行,这样可以提高性能。

通过上述的特性可以得出函数式编程的优势:易于理解维护、更高的可靠性、更好的测试性、避免并发问题、提高代码复用

如何判断一个 DOM 元素是否在可视区域内?

  1. 使用元素对象的getBoundingClientRect方法获取该元素的大小以及相对于视口的位置。包括top,right,bottom,left四个属性,依据这四个属性可以判断该元素是否在视口内。

getBoundingClientRect

js
function isViewPort(element) {
  const rect = element.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <=
      (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}
  1. 使用IntersectionObserverAPI 来监听元素与视口的交叉情况。具体使用方法不多赘述。

如何通过设置失效时间清除本地存储的数据?

思路:在将数据存入本地localStoragesessionStorage中时,同时设置一个过期时间(时间戳或者特定日期),在读取数据时检查时间是否超过过期时间,如果超过则认为数据失效,清楚数据。

js
function setLocalStorageData(key, data, expiration) {
  localStorage.setItem(
    key,
    JSON.stringify({
      data,
      expiration,
    })
  );
}

function getLocalStorageData(key) {
  let item = localStorage.getItem(key);
  if (item) {
    item = JSON.parse(item);
    if (item.expiration && new Date().getTime() > item.expiration) {
      localStorage.removeItem(key);
      return null;
    }
    return item.data;
  }
  return null;
}

SPA 应用为什么都会提供 hash 路由?有什么好处?

SPA 通常使用 hash 路由的方式实现页面导航和路由,这种方式将路由信息存储在 URL 的标识片段中,例如https://exmaple.com/#/home,之所以使用 hash 路由的方式,主要得益于:

  1. hash 路由的兼容性非常好,基本市面上所有的主流浏览器都支持,且包括一些旧版本的浏览器。

  2. hash 路由实现简单,只需要在页面中监听hashchange事件,然后根据不同的 hash 值加载对应的内容,这种方式不需要服务器端的额外配置,在初始页面渲染后一切都由客户端控制。

  3. 防止页面刷新。使用 hash 路由可以完全防止页面的刷新,因为 hash 路由只改变 URL 标识符,不会引起整个页面的重新加载,所以用户在不同页面之间切换时不会丢失当前页面的状态。

  4. 支持前进后退。由于 hash 路由不会引起页面的刷新,因此可以方便地支持浏览器地前进和后退。

  5. 无需服务器端配置,完全由客户端控制。

TIP

hash 路由也存在一些局限性,例如 URL 不够美观,不利于 SEO 等等,而 HTML5 带来的 History API 很好地解决了第一个问题,对于注重 SEO 的页面可以考虑使用 SSR。

单点登录是什么?它的流程是怎么样的?

单点登录(SSO)⼀般都需要⼀个独立的认证中心(passport),子系统的登录均得通过 passport,子系统本身将不参与登录操作,当一个系统成功登录后,passport 将会颁发⼀个令牌给各个子系统,子系统可以拿着令牌会获取各自的受保护资源,为了减少频繁认证,各个子系统在被 passport 授权以后,会建立一个局部会话,在一定时间内可以无需再次向 passport 发起认证。

流程如下:

  1. 用户访问系统 A 的受保护资源,系统 A 发现用户未登录,跳转到 SSO 认证中心,并将自己的 url 作为参数。

  2. SSO 认证中心发现用户没有登录,跳转到登录页面。

  3. 用户提交登录信息,SSO 认证中心校验,通过后创建用户和认证中心的会话(全局会话),同时创建授权令牌。

  4. 认证中心带着令牌跳转回系统 A。

  5. 系统 A 拿着令牌去认证中心校验令牌,有效则注册系统 A。

  6. 系统 A 使用该令牌创建和用户的会话(局部会话),返回受保护资源。

  7. 用户访问系统 B 受保护资源,系统 B 发现用户未登录,跳转至认证中心,并将自己的 url 作为参数。

  8. 认证中心发现用户已登录,跳转回系统 B,并附带上令牌。

  9. 系统 B 拿到令牌,去认证中心验证令牌是否有效,有效则注册系统 B。

  10. 系统 B 使用令牌创建与用户的会话(局部会话),返回受保护资源。

用户访问页面白屏的原因是什么?如何排查?

访问页面白屏有多种原因,排查方向如下:

  1. 网络原因:用户自身网络可能存在问题而无法正确加载内容,对于这种情况应在上线之前针对性地进行不同网络状况的测试。

  2. 前端代码问题:页面代码存在错误和异常,导致页面无法正确渲染,可以检查控制台,针对错误快速锁定问题代码所在。对于 React 项目需要做好错误边界的处理,提升用户体验,亦便于更快锁定问题。

  3. 服务端问题: 服务端未正确响应用户请求导致页面无法加载。可以检查服务器运行状态、日志和错误信息。

  4. 浏览器兼容性问题: 不同的浏览器对于代码的支持程度不一样,可能导致一些 API 无法使用和代码执行错误,对于该问题需要开发者在编写代码时对于可能存在兼容性问题的 API 做polyfill处理。

  5. 第三方资源问题: 如果页面引用的第三方资源出现问题而无法加载也可能会导致页面白屏,可以检查网络请求是否正常,例如在 Chrome 中查看Network选项卡查看哪些资源加载失败。

  6. 缓存问题:浏览器中可能保存了旧版本的页面或资源从而导致新版本页面无法正常加载。

  7. 其他原因:安全策略、跨域问题、DNS 解析问题等都可能引发页面白屏。

不管是那种问题,都需要根据具体情况具体分析和逐步排查,结合浏览器的开发者工具以辅助定位问题。为了避免在生产环境出现不必要的异常,项目上线之前必须做好测试工作,充分考虑不同用户不同场景的情况。

JavaScript 中执行 100 万个任务,如何保证浏览器不卡顿?

方案一:使用Web Worker,将这些任务从主线程中分离出来从而避免主线程阻塞而卡顿。

方案二:使用requestAnimationFrame()来实现长任务的分割,它可以在浏览器的每一个渲染帧之间执行指定的回调函数,从而避免卡顿,这是一种常见的做法。

需要注意的是任务分割的细粒度requestAnimationFrame()会在每一帧渲染前执行,而对于大多数设备(60hz)来说一帧的时间为 16ms,如果分割的子任务过长导致一帧内执行任务的时间超过 16ms,反而会降低帧率,导致卡顿。因此在执行子任务时需要判断当前帧还剩余多少时间,以及动态调整待执行子任务的规模。

MDN

window.requestAnimationFrame()方法会告诉浏览器你希望执行一个动画。它要求浏览器在下一次重绘之前,调用用户提供的回调函数。

对回调函数的调用频率通常与显示器的刷新率相匹配。虽然 75hz、120hz 和 144hz 也被广泛使用,但是最常见的刷新率还是 60hz(每秒 60 个周期/帧)。为了提高性能和电池寿命,大多数浏览器都会暂停在后台选项卡或者隐藏的<iframe> 中运行的 requestAnimationFrame()

方案三:使用requestIdelCallback()来实现长任务的分割,它会在浏览器空闲时执行回调函数,这可以避免频繁地在渲染周期中执行任务而导致帧率变低,当然在某些情况下可能导致需要执行地任务延后而影响到用户体验。

实现虚拟列表

虚拟列表是一种优化长列表性能的手段,例如页面有 100w 个数据提供给用户查看,作为开发者我们肯定不能同时在页面上渲染 100w 个元素,这会导致严重的性能问题。虚拟列表的核心原理就是只渲染用户可见区域的元素DOM 复用,这样可以大大减少 DOM 的创建,提升性能。

实现虚拟列表的基本思路:

  1. 监听滚动事件,获取当前滚动位置。

  2. 根据滚动位置计算当前应该将哪些数据渲染到可视区域。

  3. 为了更好的用户体验,需要设计缓冲区,也就是在可视区域的上下方都需要有区域渲染即将进入可视区域的元素。

虚拟列表同时也分为子元素高度固定动态子元素高度两种情况,具体代码实现可以采用滚动监听和IntersectionObserverAPI 两种不同方式。

以下为简单的子元素高度固定的虚拟列表实现:

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>子元素高度固定的虚拟列表</title>
  </head>
  <style>
    body {
      display: flex;
      justify-content: center;
      align-items: center;
    }
    .visible-block {
      height: 600px;
      width: 50%;
      border: 1px solid #ccc;
      overflow-y: auto;
      scroll-behavior: smooth;
    }
    li {
      height: 50px;
      border-bottom: 1px solid #ccc;
    }
  </style>
  <body>
    <div class="visible-block">
      <ul class="container"></ul>
    </div>

    <script>
      function renderItem(index, height) {
        const item = document.createElement("li");
        item.innerHTML = `Item ${index}`;
        item.dataset.index = index;
        item.style.height = `${height}px`;
        return item;
      }

      // 节流函数, 避免频繁触发,提升性能
      function throttled(fn, delay = 500) {
        let timer = null;
        return function (...args) {
          if (!timer) {
            timer = setTimeout(() => {
              fn.apply(this, args);
              timer = null;
            }, delay);
          }
        };
      }
      const data = Array.from({ length: 1000 }, (_, i) => i);
      const totalItems = data.length;
      const itemHeight = 50;
      const bufferCount = 5; // 缓冲区大小

      const visibleBlock = document.querySelector(".visible-block");
      const container = document.querySelector(".container");

      const visibleItems = Math.floor(visibleBlock.clientHeight / itemHeight);

      let startIndex = 0,
        endIndex = startIndex + visibleItems;

      function init() {
        const fragment = document.createDocumentFragment();

        // 计算实际渲染的起始和结束索引,加上缓冲区大小
        const bufferedStartIndex = Math.max(0, startIndex - bufferCount);
        const bufferedEndIndex = Math.min(
          startIndex + visibleItems + bufferCount,
          totalItems
        );

        data.slice(bufferedStartIndex, bufferedEndIndex).forEach((item) => {
          const ele = renderItem(item, itemHeight);
          fragment.appendChild(ele);
        });

        container.style.height = `${
          itemHeight * totalItems - bufferedStartIndex * itemHeight
        }px`;
        container.appendChild(fragment);
      }

      function scrollHandler(e) {
        startIndex = Math.floor(e.target.scrollTop / itemHeight);
        endIndex = startIndex + visibleItems;

        // 计算实际渲染的起始和结束索引,加上缓冲区大小
        const bufferedStartIndex = Math.max(0, startIndex - bufferCount);
        const bufferedEndIndex = Math.min(
          startIndex + visibleItems + bufferCount,
          totalItems
        );

        // 移除所有现有的子元素
        while (container.firstChild) {
          container.removeChild(container.firstChild);
        }
        const fragment = document.createDocumentFragment();
        data
          .slice(bufferedStartIndex, bufferedEndIndex)
          .forEach((item, index) => {
            const ele = renderItem(item, itemHeight);
            fragment.appendChild(ele);
          });
        container.appendChild(fragment);
        container.style.marginTop = `${itemHeight * bufferedStartIndex}px`;
        container.style.height = `${
          (totalItems - bufferedStartIndex) * itemHeight
        }px`;
      }

      visibleBlock.addEventListener("scroll", throttled(scrollHandler));
      init();
    </script>
  </body>
</html>

使用 History API 实现一个基础的前端路由功能

借助History API中的pushState()方法和window.popstate事件可以实现。

MDN

在 HTML 文档中,history.pushState() 方法向浏览器的会话历史栈增加了一个条目。

MDN

每当激活同一文档中不同的历史记录条目时,popstate 事件就会在对应的 window对象上触发。如果当前处于激活状态的历史记录条目是由 history.pushState()方法创建的或者是由 history.replaceState() 方法修改的,则 popstate 事件的 state 属性包含了这个历史记录条目的 state 对象的一个拷贝。

WARNING

调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back() 方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Simple Router</title>
    <style>
      #app {
        padding: 20px;
      }
      .nav-link {
        margin-right: 10px;
        cursor: pointer;
        color: blue;
        text-decoration: underline;
      }
    </style>
  </head>
  <body>
    <div>
      <span class="nav-link" onclick="navigateTo('/')">Home</span>
      <span class="nav-link" onclick="navigateTo('/about')">About</span>
      <span class="nav-link" onclick="navigateTo('/contact')">Contact</span>
    </div>
    <div id="app"></div>

    <script>
      const routes = {
        "/": "This is the home page.",
        "/about": "This is the about page.",
        "/contact": "This is the contact page.",
      };

      function render(path) {
        const app = document.getElementById("app");
        const content = routes[path] || "404 - Page not found";
        app.innerHTML = content;
      }
      function navigateTo(path) {
        history.pushState({}, path, window.location.origin + path);
        render(path);
      }

      window.addEventListener("popstate", () => {
        render(window.location.pathname);
      });

      // Initialize the app
      document.addEventListener("DOMContentLoaded", () => {
        render(window.location.pathname);
      });
    </script>
  </body>
</html>
js
function cookieParser(cookie) {
  const cookieObj = {};
  cookie.split(";").forEach((item) => {
    const cleanItem = item.trim();
    const seperatorIndex = cleanItem.indexOf("=");
    if (seperatorIndex !== -1) {
      let key = cleanItem.substring(0, seperatorIndex);
      let value = cleanItem.substring(seperatorIndex + 1);
      // decode
      key = decodeURIComponent(key);
      value = decodeURIComponent(value);

      cookieObj[key] = value;
    }
  });
  return cookieObj;
}
const res = cookieParser(document.cookie);
console.log(res);

JavaScript 中如何解决递归导致的栈溢出?

  1. 使用尾递归优化。ES6 中引入了尾递归调用优化,这意味着如果递归函数的最后一步是返回函数调用,那么这个调用可以在不增加新栈帧的情况下执行。但是,目前多数的 JavaScript 引擎还没有实现针对尾递归调用的优化。

  2. 将递归转为循环。基本上大多数递归都可以转写为循环,这样可以避免出现栈溢出。例如求斐波那契数列 N 项和:

js
function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}
js
function fib(n) {
  let sum = 0,
    i = 0,
    j = 1;
  while (n-- > 1) {
    sum = i + j;
    i = j;
    j = sum;
  }
  return sum;
}
  1. 使用 Trampoline 函数,该函数是一个高阶函数,它通过在每个递归步骤中返回一个函数而不是值,然后持续调用这些函数,知道获取结果为止。Trampoline 函数不是万能的,对于复杂的或者特定的递归函数需要使用其他方式。
js
function trampoline(fn) {
  return function (...args) {
    let result = fn.apply(this, args);
    while (typeof result === "function") {
      result = result();
    }
    return result;
  };
}

function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}
const trampolinedFunction = trampoline(fib);
  1. 使用异步递归。借助Promise或生成器生成异步函数,就可以允许事件循环的介入,通过setTimeout,setImmediateprocess.nextTick(node 环境)确保每次递归调用都有机会返回控制权给事件循环,从而避免单次执行多递归调用造成的栈溢出问题。
js
function recursiveAsyncFunction(i) {
  if (i < 0) return;
  Promise.resolve();
  console.log("Recursion ", i);
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(recursiveAsyncFunction(i - 1));
    });
  });
}
recursiveAsyncFunction(50).then((res) => console.log(res));

WARNING

使用递归配合异步函数使用时对于计算斐波那契数列和是非常低效的,对于超过一定项的计算(n>15)时会导致事件循环有大量的异步操作等待而使代码无法正常运行。

考虑到开发过程中可能出现的复杂情况,应该根据具体场景选择不同的方案,并不是递归一定就会出现栈溢出。如果是性能敏感的场景建议将递归转写为循环以优化性能,如果性能影响不大,为了便于理解代码逻辑,递归是个不错的选择。

站点如何防止爬虫?

  1. 站点根目录下创建或修改robots.txt,用来告知遵守协议的爬虫应该爬取哪些页面,哪些页面不应该被爬取。这些规则不是强制性的,对于恶意爬虫无效。
txt
User-agent: *Disallow /
  1. 使用 CAPTCHA(验证码)。对于涉及表单提交的页面、敏感页面,某些关键操作增加人机验证,防止自动化脚本或机器人操作。

  2. 服务器检查用户代理(User-Agent)来决定是否屏蔽某些爬虫。但用户代理可以伪造,该方法不完全可靠。

  3. 分析用户行为,例如访问频率、访问时长、点击模式、鼠标轨迹等与正常用户对比,从而检测和屏蔽爬虫。

  4. 使用 Web 应用防火墙(WAF),该类产品提供了检测自动化脚本和爬虫的能力,可以有效帮助防止爬虫。

  5. 添加额外的 HTTP 头,这些信息对于常规爬虫是不会添加的。

  6. IP 黑名单。对于某些行为异常的 IP 加入黑名单,限制访问。

  7. 限制访问速度。通过特定时间内允许的请求次数来禁止爬虫大量快速地抓取内容。

  8. 使用 HTTPS 替代 HTTP,通过加密可以避免中间人攻击,并增加爬虫难度。

  9. API 限流。对 API 进行限制,例如根据用户、IP 等实施限速和配额。

  10. 服务端渲染和动态 Token。通过在 SSR 或动态插入 Token 到页面中,使得非浏览器的自动化工具获取网站内容变得困难。

  11. 设备指纹识别:通过收集和分析用户设备的硬件和软件信息,可以创建设备指纹,帮助识别和区分不同的用户。

  12. 使用 AI 来更加精准地识别爬虫。

  13. 定期更新防护策略,以适应不断演变地爬虫技术。

不同标签页之间的主动推送消息的机制有哪些?

在不借助服务端的帮助下,跨标签页之间通信且可以主动推送消息的方式有如下几种:

  1. BroadcastChannel API 。它是一种同源的在不同浏览器上下文之间实现简单高效通信的方式,由 HTML5 引入,这也就意味它可以在同一网站下的不同标签页或窗口之间发送消息。
js
// 创建一个连接到命名频道的对象,不同标签中都通过该方法来连接到同一个频道
const channel = new BroadcastChannel("my-channel");
// 广播一个消息
channel.postMessage("Hello!");
// 监听消息
channel.onmessage((e) => {
  console.log(e.data);
});
  1. Service Worker API 。利用Service Worker,各个标签页可以通过clients.matchAll()方法找到其他客户端(比如标签页),然后使用postMessage发送消息。该方法相比BroadcastChannel更加灵活,它可以通过FocusNavigate事件来控制页面焦点和导航等。除此之外,Service Worker还提供了后台运行脚本的能力,这些脚本可以在网络受限或者没有网络的情况下运行。
js
self.onmessage = (e) => {
  if (e.data === "Hello") {
    self.clients
      .matchAll({
        type: "window",
        includeUncontrolled: true,
      })
      .then((clients) => {
        clients.forEach((client) => {
          client.postMessage(`New message from ${client.id}`);
        });
      });
  }
};
js
// 在主页面注册service worker
if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("./server.js")
    .then((registration) => {
      console.log(`Service Worker registered with
scope: ${registration.scope}`);
    })
    .catch((err) => {
      console.error(`Failed to register service worker : ${err}`);
    });
}
// 检查页面是否被service worker主动控制并发送消息
if (navigator.serviceWorker.controller) {
  // Post a message to the ServiceWorker
  navigator.serviceWorker.controller.postMessage("Hello");
}

其他需要使用到serviceWorker的标签页(同源,同 scope)类似地注册serviceWorker后再进行使用,这样只要向serviceWorker发送了消息,其他标签页就能接收到。

  1. Shared Worker API 。它提供了一种更加传统的跨文档通信机制,能在不同浏览器上下文中共享状态和数据。
js
//创建shared worker
const sw = new SharedWorker("myworker.js");
// 发送消息给端口
sw.port.postMessage("Data from page A");
// 监听消息
sw.port.onmessage = (e) => {
  console.log(e.data);
};
js
//创建shared worker
const sw = new SharedWorker("myworker.js");
// 发送消息给端口
sw.port.postMessage("Data from page B");
// 监听消息
sw.port.onmessage = (e) => {
  console.log(e.data);
};
js
const ports = [];
// 当一个页面出创建了一个SharedWorker对象就会触发该事件并同时创建了一个单独的port
// 通过保存所有的port来遍历发送消息从而实现跨标签页的广播
self.onconnect = (e) => {
  const port = e.ports[0];
  ports.push(port);
  port.onmessage = (e) => {
    //像所有port发送消息
    ports.forEach((p) => {
      p.postMessage(e.data);
    });
  };
};

上面的代码中是往所有的port广播消息,包括发送者本身都会接收到消息,如果需要向指定的port发送消息就要获取port的唯一标识(页面标识),然而ShardWorker并没有提供类似的功能,但我们可以自己实现。思路是在connect事件中拿到port的同时生成一个随即标识符,然后将标识符和port保存为映射关系,后将这个标识符发送回该port,该port所在的页面将这个标识符保存,后续每次通信时携带。这样便可以知道消息发送自哪个页面,又将发送给哪些页面。

WARNING

SharedWorker 的作用域中没有 window 对象,所以consolealert等方法都是无法使用的。如果我们需要调试 SharedWorker,可以在浏览器地址栏中输入 chrome://inspect/#workers,这样就可以看到当前页面中的 SharedWorker。

  1. 监听localStorage事件来实现跨标签页的主动通信。该方式通过一个标签页来修改localStorage,其他标签页通过监听storage事件来接收消息从而实现类似于主动推送消息的功能。
js
window.addEventListener("storage", (e) => {
  if (e.storageArea === "localStorage" && e.key === "somekey") {
    console.log("new value is " + e.newValue);
  }
});
  1. 使用iframemessage事件,该方式关键在于父窗口和iframe页面之间的协调工作,非常灵活,可以根据自己的需要自行发送消息和监听消息。

项目版本更新,如何通知用户刷新页面?

首先需要解决的是用户如何感知前端静态资源发生变化,这就需要做静态资源版本管理,例如给 html 命名为[name+版本].html,其中引用的其他资源还是以 hash 值做标识。

主动通知客户端刷新的方式如下:

  1. 在服务端开启一个定时任务,例如每隔一分钟执行一次,看看静态资源部署的服务器上是否有新的 html 版本内容生成,如果有新的版本内容生成,并且用户访问的还是旧版本,那么服务端直接推送消息。(可以利用 SSE 实现)

  2. 使用 Websocket 连接客户端和服务器,这样服务端可以在静态资源发生变化时主动推送。

  3. 使用Service Worker,它介于浏览器与网络之间,可以控制页面的资源缓存,也可用于检测资源更新.当检测到资源更新时,可以通过主动推送通知或在网上显示更新提示。

  4. 轮询。客户端定时轮询请求,向服务端查询版本信息,如果发现新版本则提示用户刷新或自动刷新。

TIP

推荐使用Service Worker实现,该方式最为稳妥,它不仅可以对资源进行缓存和管理,也能在后台做资源检测,即使没有开启网页也能实现更新。Websocket 和轮询都对性能有影响。当然,具体情况具体场景需要考虑多种因素,选择合适的一种即可。

如何检测网页空闲状态(一定时间内无操作)?

  1. 监听鼠标mousemovemousedown事件。

  2. 监听键盘按下事件keydown

  3. 在用户进入网页后设置定时器,如果定时结束后没有触发以上事件则为空闲状态,某则定时器重置。用户离开页面(页面不可见)时移除定时器。

  4. 需要设置防抖,避免性能问题。

列表分页场景中如何解决快速翻页导致的竞态问题?

问题描述:前端分页场景很常见,在进行分页数据请求的时候如果因为翻页过快,可能上一次的数据响应还未到达,下一次的请求又发送了,这样就可能导致最后一次请求的数据还未响应,但是 UI 已经更新且数据不符合页码。

解决方案:

  1. 使用请求标识来确保请求和数据之间的对应关系。在每次发送请求时,前端存储一个唯一标识(可以是递增数字、随机数、时间戳、组合数据等等),并在请求中携带发送给服务端。服务端响应数据时同时也需要携带该标识,前端拿到数据后通过标识来处理数据。

  2. 对快速翻页进行限制或者优化,避免频繁请求。

  3. 可以缓存部分请求数据,避免不必要的请求。

如何禁止他人调试项目的前端代码?

通过无限debugger来疯狂输出断点,当控制台被打开的时候就会执行,程序被debugger阻止,因此无法进行调试。

js
// 以下代码最好加密后使用
(() => {
  function block() {
    if (window.innerHeight < 800 || window.innerWidth < 800) {
      document.body.innerHTML = "检测到非法法调试,请关闭后刷新重试!";
    }
    // setInterval(() => { debugger; }, 50);
    // 将上面代码修改如下,增加难度
    setInterval(() => {
      (function () {
        return false;
      })
        ["constructor"]("debugger")
        ["call"]();
    }, 50);
  }
  try {
    block();
  } catch (err) {}
})();

说说 OAuth2.0 是什么

OAuth2.0 是一种授权框架,用于授权第三方应用访问用户资源,它被广泛地用于身份验证和授权场景中。OAuth2.0 通过引入授权服务器、资源服务器和客户端等角色,实现了用户授权和资源访问地分离。流程如下:

  1. 用户向客户端发起请求,访问某个资源。

  2. 客户端重定向到授权服务器,并携带自己的身份凭证(客户端 ID)。

  3. 用户在授权服务器登录,并选择授权客户端访问特定的资源。

  4. 授权服务器通过身份验证后,生成访问令牌(Access Token)并发送给客户端。

  5. 客户端使用访问令牌向资源服务器请求访问资源。

  6. 资源服务器验证访问令牌的有效性,并根据权限决定是是否允许访问资源。

  7. 资源服务器通过校验后将资源返回给客户端。

客户端在这种方式下无需知道或存储用户的凭证,只需要保存并使用访问令牌代表用户向资源服务器请求资源,更加安全和便捷。

常见的授权方式

  1. 基于 session、cookie。两者都是用户登录后生成一个唯一标识,只是存储的位置不同。

  2. 使用 JWT(Json Web Token)。相比于 cookie 和 seesion 更加安全,并且不用在会话过程中保存,因为它是自包含的(包含了所有必要的信息)。用户登录后根据用户信息编码生成一个 token 并返回,后续客户端所有请求需要携带 token,以便服务端验证。

JWT 由三个部分构成,形如header.payload.signatureheader通常包括令牌的类型和所使用的签名算法(例如 SHA256 或 RSA 等),payload包含需要传递的数据,这些数据可以是用户的信息或权限等,signature是将前两者进行编码并使用特定算法或密钥生成的一串字符串。

  1. 单点登录(SSO):一种将多个应用系统进行集成的认证方式,用户只需要登录一次就可以在多个系统中完成认证,避免重复认证。常见的登录协议有 CAS(Central Authentication Service)和 SAML(Security Assertion Markup Language)。

  2. Oauth2.0 是一个授权框架,用于授权第三方应用访问资源,它通过授权服务器发放访问令牌,使得第三方应用可以代表用户获取资源而无需知道用户的登录凭证。

  3. OpenID Connect(OIDC) 是基于 OAuth2.0 的身份验证协议,通过在认证和授权过程中引入身份提供者,使得用户可以使用第三方身份提供者(例如 Google/Github/QQ 等)进行登录和授权。

  4. LDAP(Lightweight Directory Access Protocol)是一种用于访问和维护分布式目录服务的协议,在登录鉴权中通常用于验证用户身份信息。

  5. 2FA(Two-Factor Authentication):二次验证是一种提供额外安全层的身份验证方式。与传统的用户名和密码登录不同,2FA 需要用户提供第二个验证因素,例如手机号验证、指纹识别、硬件令牌等,以提高安全性。

封装一个请求超时自动重试的方法

js
/**
 * 封装的fetch请求函数,支持超时和重试
 *
 * @param {string} url 请求的URL
 * @param {object} options fetch的选项
 * @param {number} retryCount 重试次数
 * @param {number} timeout 超时时间(毫秒)
 * @returns {Promise} 返回请求的Promise
 */
async function fetchWithRetry(
  url,
  options = {},
  retryCount = 3,
  timeout = 5000
) {
  // 创建一个超时控制的Promise
  const fetchWithTimeout = (resource, opts) => {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        reject(new Error("请求超时"));
      }, timeout);

      fetch(resource, opts)
        .then((response) => {
          clearTimeout(timer);
          // 判断响应是否成功
          if (!response.ok) {
            reject(new Error(`HTTP错误: ${response.status}`));
          }
          resolve(response);
        })
        .catch((err) => {
          clearTimeout(timer);
          reject(err);
        });
    });
  };

  // 发起请求并处理重试逻辑
  for (let i = 0; i < retryCount; i++) {
    try {
      const response = await fetchWithTimeout(url, options);
      return response; // 返回成功的响应
    } catch (error) {
      if (i === retryCount - 1) {
        // 如果是最后一次重试,抛出错误
        throw error;
      }
      console.warn(`请求失败,正在重试...(${i + 1}/${retryCount})`, error);
    }
  }
}

// 使用示例
fetchWithRetry("http://localhost:8000/timeout", {
  method: "GET",
})
  .then((response) => response.json())
  .then((data) => {
    console.log("请求成功:", data);
  })
  .catch((error) => {
    console.error("请求失败:", error);
  });

组件封装的基本准则是什么

  1. 单一职责原则。一个组件应该只具有单一的功能且只负责完成该功能,避免组件逻辑过于复杂和庞杂。

  2. 高内聚低耦合。组件之间应该尽量解耦,组件内部各个部分应该紧密相关。

  3. 易用性。组件应该易于使用,提供清晰的文档说明。

  4. 复用性。组件应该是可复用的,可以在不同的项目中使用。

  5. 可扩展性。组件应该具备良好的扩展性,可以方便地添加新的功能或进行修改而不影响已有的功能。

  6. 高效性。组件应该具备高性能和低消耗的特点,不能对项目造成性能问题。

  7. 安全性。组件应该具备一定的安全性,防止篡改和攻击。

  8. 可测试性。组件应该容易进行单元测试和集成测试,以确保组件的质量和稳定。

前端日志埋点 SDK 设计思路

分析需求和目标:

  1. 自动化上报页面的 PV、UV、性能、点击路径等。

  2. 自动上报页面异常。

  3. 发送埋点信息时不能影响性能,不能阻碍页面加载和请求发送。

  4. 能够自定义日志发送。

数据发送是 SDK 设计中最为基础的,其他功能都需要依靠它来完成。一般埋点数据发送的方式有图片请求、XHR 请求、Beacon API、Websocket 等,这里推荐使用Beacon API定义的navigator.sendBeacon()方法,它的优势如下:

  1. 后台异步发送,不会阻塞页面。

  2. 高可靠性。它与常规的XMLHttpRequestfetch请求不同,后者无法保证页面卸载时将请求发送完成,而浏览器会确保navigator.sendBeacon()在页面卸载前执行完成,并且在网络不稳定的情况下也会尝试发送数据,具有更高的可靠性和稳定性,确保了数据的完整性。

  3. 自动化处理。navigator.sendBeacon()会将数据封装成 POST 请求并自动处理数据发送的细节,包括自动设置请求头、响应处理等。

  4. 支持跨域。

TIP

navigator.sendBeacon()以 POST 请求发送数据,通常会以表单或 JSON 格式对数据进行包装,服务端需要对此作相应的适配。

用户行为上报主要关注的就是一些事件,例如clickscroll等等。

性能上报主要使用Performance API获取页面性能,其中PerformanceNavigationTiming接口可以获取页面的加载时间。

错误上报分为两种,一种是运行时异常(DOM 操作错误或 JS 执行错误),通过error事件监听并上报,而 Promise 内部抛出的异常error事件无法捕获,可以通过监听unhandledrejection事件来实现上报。

React/Vue 中错误边界是当框架内部发送渲染错误时不会导致整个页面崩溃而是使用提前定义的组件来替换发生错误的部分。React 中在定义错误边界组件时可以在类组件中使用componentDidCatch()捕获错误并上报,Vue3 中可以通过onErrorCaptured()生命周期钩子来捕获。