阅读视图

发现新文章,点击刷新页面。

JS-手写系列:树与数组相互转换

前言

在前端业务中,后端返回的扁平化数组(Array)往往需要转换为树形结构(Tree)来适配 UI 组件(如 Element UI 的 Tree 或 Cascader)。掌握多种转换思路及性能差异,是进阶高级前端的必备技能。

一、 核心概念:结构对比

  • 数组结构:每一项通过 parentId 指向父级。

      const nodes = [
        { id: 3, name: '节点C', parentId: 1 },
        { id: 6, name: '节点F', parentId: 3 },
        { id: 0, name: 'root', parentId: null },
        { id: 1, name: '节点A', parentId: 0 },
        { id: 8, name: '节点H', parentId: 4 },
        { id: 4, name: '节点D', parentId: 1 },
        { id: 2, name: '节点B', parentId: 0 },
        { id: 5, name: '节点E', parentId: 2 },
        { id: 7, name: '节点G', parentId: 2 },
        { id: 9, name: '节点I', parentId: 5 },
      ];
    
  • 树形结构:父级通过 children 数组包裹子级。

      let tree = [
        {
          id: 1,
          name: 'text1',
          parentId: 1,
          children: [
            {
              id: 2,
              name: 'text2',
              parentId: 1,
              children: [
                {
                  id: 4,
                  name: 'text4',
                  parentId: 2,
                },
              ],
            },
            {
              id: 3,
              name: 'text3',
              parentId: 1,
            },
          ],
        },
      ];
    

二、 数组转树

1. 递归思路

原理

  1. 首先需要传递给函数两个参数:数组、当前的父节点id
  2. 设置一个结果数组res,遍历数组,先找到子元素的父节点id与父节点id一致的子项
  3. 将这个子项的id作为父节点id传入函数,继续遍历
  4. 将遍历的结果作为children返回,并给当前项添加children
  5. 将这个当前项,插入到res里面,并返回

注意:如果不想影响原数组,需要先深拷贝一下数组。const cloneArr = JSON.parse(JSON.stringify (arr))

  const nodes = [
    { id: 3, name: '节点C', parentId: 1 },
    { id: 6, name: '节点F', parentId: 3 },
    { id: 0, name: 'root', parentId: null },
    { id: 1, name: '节点A', parentId: 0 },
    { id: 8, name: '节点H', parentId: 4 },
    { id: 4, name: '节点D', parentId: 1 },
    { id: 2, name: '节点B', parentId: 0 },
    { id: 5, name: '节点E', parentId: 2 },
    { id: 7, name: '节点G', parentId: 2 },
    { id: 9, name: '节点I', parentId: 5 },
  ];
  //递归写法
  const arrToTree1 = (arr, id) => {
    const res = [];
    arr.forEach((item) => {
      if (item.parentId === id) {
        const children = arrToTree1(arr, item.id);
        //如果希望每个元素都有children属性,可以直接赋值
        if (children.length !== 0) {
          item.children = children;
        }
        res.push(item);
      }
    });
    return res;
  };
  console.log(arrToTree1(nodes, null));

2. 非递归思路

原理:利用 filter 进行二次筛选。虽然写法简洁,但在大数据量下性能较差(O(n2)O(n^2))。

  1. 函数只需要接受一个参数,也就是需要转换的数组arr
  2. 第一层过滤数组,直接返回一个parentId为根id的元素
  3. 但是在返回之间,需要再根据当前id过滤里面的每一项(过滤规则为如果子项的paentId为当前的id,则在当前项的children插入这个子项)
  const arrToTree2 = (arr) => {
    return arr.filter((father) => {
      const childrenArr = arr.filter((children) => {
        return children.parentId === father.id;
      });
      //如果希望每个元素都有children属性,可以直接赋值
      if (childrenArr.length !== 0) {
        father.children = childrenArr;
      }
      return father.parentId === null;
    });
  };
  console.log(arrToTree2(nodes));

3. Map 对象方案(O(n)O(n) 时间复杂度)

原理:利用对象的引用性质。先将数组转为 Map,再遍历一次即可完成。这是在大数据量下的首选方案。

  const arrToTree3 = (arr) => {
    const map = {};
    const res = [];

    // 1. 建立映射表
    arr.forEach((item) => {
      map[item.id] = { ...item, children: [] };
    });

    // 2. 组装树结构
    arr.forEach((item) => {
      const node = map[item.id];
      if (item.parentId === null) {
        res.push(node);
      } else {
        if (map[item.parentId]) {
          map[item.parentId].children.push(node);
        }
      }
    });
    return res;
  };
  console.log(arrToTree3(nodes));

三、 树转数组

1. 递归遍历思路

原理:定义一个结果数组,递归遍历树的每一层,将节点信息(排除 children)推入数组。

  1. 首先定义一个结果数组res,遍历传入的树
  2. 直接将当前项的id、name、parentId包装在一个新对象里插入
  3. 判断是否有children属性,如果有则遍历children属性每一项,继续执行2、3步骤
  let tree = [
    {
      id: 1,
      name: 'text1',
      parentId: 1,
      children: [
        {
          id: 2,
          name: 'text2',
          parentId: 1,
          children: [
            {
              id: 4,
              name: 'text4',
              parentId: 2,
            },
          ],
        },
        {
          id: 3,
          name: 'text3',
          parentId: 1,
        },
      ],
    },
  ];
  const treeToArr = (tree) => {
    const res = [];
    tree.forEach((item) => {
      const loop = (data) => {
        res.push({
          id: data.id,
          name: data.name,
          parseId: data.parentId,
        });
        if (data.children) {
          data.children.forEach((itemChild) => {
            loop(itemChild);
          });
        }
      };
      loop(item);
    });
    return res;
  };
  console.log(treeToArr(tree));

四、 注意事项:深拷贝的必要性

在处理这些转换时,由于 JS 的对象是引用类型,直接修改 item.children 会改变原始数组的内容。

  • 快捷方案const cloneArr = JSON.parse(JSON.stringify(arr))
  • 避坑点:如果数组项中包含 Date 对象、RegExpFunctionJSON.parse 会导致数据失真,此时应使用其他深拷贝方案。

如果你正在使用 Tiptap 做协同编辑器,那么我建议你使用 Monorepo 架构是最舒服的选择

昨天把 DocFlow 重构成了 Monorepo 架构,主要是为了解决协同编辑中的 Schema 同步问题。

20260205105346

项目使用 Tiptap 做协同编辑,自定义节点较多,而 Yjs 传递的是二进制数据。像警告框 Alert 这类自定义节点,在前端是具体的 UI 组件,但在 Hocuspocus 后端必须有对应的 Transformer 逻辑,才能将二进制数据准确还原成 JSON 或 HTML。

没有 Monorepo 时,每加一个新功能(如 alert.ts),都要在前端和后端分别维护一套 Schema。一旦漏掉同步,后端解析时就不认识这个节点,辛辛苦苦存的数据可能直接丢失。

采用 Monorepo 后,架构清晰多了:

  • 原子化解耦:每个自定义节点如 @syncflow/alert 都是独立包,职责单一,
  • 逻辑共享:transformer 包统一组装这些节点,导出一个全能的解析器
  • 多端复用:前端编辑器用它来渲染,后端 Hocuspocus 用它做数据转换

最终实现一套 Schema 定义,全链路通用,改一下 alert.ts 的规则,全端自动生效,维护效率大幅提升。

SVG标签中path路径参数学习

大家好,我的开源项目PakePlus可以将网页/Vue/React项目打包为桌面/手机应用并且小于5M只需几分钟,官网地址:pakeplus.com

官方文档链接:developer.mozilla.org/zh-CN/docs/…

路径命令是对要绘制的路径的说明。每一个命令由代表命令的字母和代表参数的数字组成。

SVG 定义了六种路径命令类型,一共 20 条命令:

MoveTo:M、m
LineTo:L、l、H、h、V、v
三次贝塞尔曲线:C、c、S、s
二次贝塞尔曲线:Q、q、T、t
椭圆曲线:A、a
ClosePath:Z、z
备注:命令是大小写敏感的。大写的命令指定绝对坐标,而小写命令指定相对(于当前位置的)坐标。

备注:命令是大小写敏感的。大写的命令指定绝对坐标,而小写命令指定相对(于当前位置的)坐标。

始终可以将负值作为命令的参数:

负的角度是逆时针的;
绝对坐标中,负的 x 和 y 将被解释为负坐标;
相对坐标中,负的 x 值为向左移动,负的 y 值为向上移动。

Lineto 路径命令

Lineto 指令将绘制一条直线段。这个直线段从当前位置Po; {xoyo})移到指定位置Pn; {xnyn})。然后,指定位置Pn)将变成下一个命令中的当前位置Po′)。

命令 参数 备注
L (xy)+

当前位置指定位置 x,y 之间绘制一条线段。后续子坐标序列将被解释为隐式的绝对位置的 LineTo(L)命令的参数。

<p><strong>公式:</strong>&nbsp;Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {<code>x</code>,&nbsp;<code>y</code>}</p>
</td>
</tr>
<tr>
<th scope="row">l</th>
<td>(<code>dx</code>,&nbsp;<code>dy</code>)+</td>
<td>
<p><em>当前位置</em><em>指定位置</em>之间绘制一条线段,<em>指定位置</em><em>当前位置</em>沿 x 轴偏移&nbsp;<code>dx</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy</code>&nbsp;处。后续子坐标序列将被解释为隐式的相对位置的 LineTo(<code>L</code>)命令的参数。</p>

<p><strong>公式:</strong>&nbsp;Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {xo&nbsp;+&nbsp;<code>dx</code>,&nbsp;yo&nbsp;+&nbsp;<code>dy</code>}</p>
</td>
</tr>
<tr>
<th scope="row">H</th>
<td><code>x</code>+</td>
<td>
<p><em>当前位置</em><em>指定位置</em>之间绘制一条水平线段。<em>指定位置</em>&nbsp;<code>x</code>&nbsp;参数和<em>当前位置</em>&nbsp;<code>y</code>&nbsp;坐标指定。后续子序列的值将被解释为隐式的绝对位置的 LineTo(<code>H</code>)命令的参数。</p>

<p><strong>公式:</strong>&nbsp;Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {<code>x</code>,&nbsp;yo}</p>
</td>
</tr>
<tr>
<th scope="row">h</th>
<td><code>dx</code>+</td>
<td>
<p><em>当前位置</em><em>指定位置</em>之间绘制一条水平线段。<em>指定位置</em><em>当前位置</em>沿 x 轴偏移&nbsp;<code>dx</code>&nbsp;&nbsp;<code>x</code>&nbsp;坐标和<em>当前位置</em>&nbsp;<code>y</code>&nbsp;坐标指定。后续子序列的值将被解释为隐式的相对位置的 LineTo(<code>h</code>)命令的参数。</p>

<p><strong>公式:</strong>&nbsp;Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {xo&nbsp;+&nbsp;<code>dx</code>,&nbsp;yo}</p>
</td>
</tr>
<tr>
<th scope="row">V</th>
<td><code>y</code>+</td>
<td>
<p><em>当前位置</em><em>指定位置</em>之间绘制一条垂直线段。<em>指定位置</em>&nbsp;<code>y</code>&nbsp;参数和<em>当前位置</em>&nbsp;<code>x</code>&nbsp;坐标指定。后续子序列的值将被解释为隐式的绝对位置的 LineTo(<code>V</code>)命令的参数。</p>

<p><strong>公式:</strong>&nbsp;Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {xo,&nbsp;<code>y</code>}</p>
</td>
</tr>
<tr>
<th scope="row">v</th>
<td><code>dy</code>+</td>
<td>
<p><em>当前位置</em><em>指定位置</em>之间绘制一条垂直线段。<em>指定位置</em><em>当前位置</em>沿 y 轴偏移&nbsp;<code>dy</code>&nbsp;&nbsp;<code>y</code>&nbsp;坐标和<em>当前位置</em>&nbsp;<code>x</code>&nbsp;坐标指定。后续子序列的值将被解释为隐式的相对位置的 LineTo(<code>v</code>)命令的参数。</p>

<p><strong>公式:</strong>&nbsp;Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {xo,&nbsp;yo&nbsp;+&nbsp;<code>dy</code>}</p>
</td>
</tr>
</tbody>

示例

<svg viewBox="0 0 200 100" xmlns="http://www.w3.org/2000/svg">
  <!-- 使用绝对坐标的 LineTo 命令 -->
  <path
    fill="none"
    stroke="red"
    d="M 10,10
           L 90,90
           V 10
           H 50" />

  <!-- 使用相对坐标的 LineTo 命令 -->
  <path
    fill="none"
    stroke="red"
    d="M 110,10
           l 80,80
           v -80
           h -40" />
</svg>

三次贝塞尔曲线

三次贝塞尔曲线是使用四个点定义的平滑曲线:

起始点(当前位置)

(Po = {xoyo})

终点

(Pn = {xnyn})

起始控制点

(Pcs = {xcsycs})(控制在起点附近的曲线的曲率)

终点控制点

(Pce = {xceyce})(控制在终点附近的曲线的曲率)

绘制后,终点Pn)将成为下一个命令中的当前位置Po′)。

命令 参数 备注
C (x1,y1x2,y2x,y)+

当前位置终点 x,y 之间绘制一条三次贝塞尔曲线。起始控制点通过 x1,y1 指定,而终点控制点通过 x2,y2 指定。后续的三元组坐标序列将被解释为隐式的绝对位置的三次贝塞尔曲线(C)命令的参数。

<p><a data-link-icon="https://csdnimg.cn/release/blog_editor_html/release2.4.5/ckeditor/plugins/CsdnLink/icons/icon-default.png?t=PBP8" data-link-title="公式:" href="https://developer.mozilla.org/zh-CN/docs/Web/SVG/Reference/Attribute/d#%E5%85%AC%E5%BC%8F%EF%BC%9A" title="公式:">公式:</a></p>

<p>Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {<code>x</code>,&nbsp;<code>y</code>} ;<br />
Pcs&nbsp;= {<code>x1</code>,&nbsp;<code>y1</code>} ;<br />
Pce&nbsp;= {<code>x2</code>,&nbsp;<code>y2</code>}</p>
</td>
</tr>
<tr>
<th scope="row">c</th>
<td>(<code>dx1</code>,<code>dy1</code>,&nbsp;<code>dx2</code>,<code>dy2</code>,&nbsp;<code>dx</code>,<code>dy</code>)+</td>
<td>
<p><em>当前位置</em><em>终点</em><em>终点</em><em>当前位置</em>沿 x 轴偏移&nbsp;<code>dx</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy</code>&nbsp;处)之间绘制一条三次贝塞尔曲线。<em>起始控制点</em><em>当前位置</em>沿 x 轴偏移&nbsp;<code>dx1</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy1</code>&nbsp;处;而<em>终点控制点</em><em>当前位置</em>沿 x 轴偏移&nbsp;<code>dx2</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy2</code>&nbsp;处。后续的三元组坐标序列将被解释为隐式的相对位置的三次贝塞尔曲线(<code>c</code>)命令的参数。</p>

<p><a data-link-icon="https://csdnimg.cn/release/blog_editor_html/release2.4.5/ckeditor/plugins/CsdnLink/icons/icon-default.png?t=PBP8" data-link-title="公式:" href="https://developer.mozilla.org/zh-CN/docs/Web/SVG/Reference/Attribute/d#%E5%85%AC%E5%BC%8F%EF%BC%9A_2" title="公式:">公式:</a></p>

<p>Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {xo&nbsp;+&nbsp;<code>dx</code>,&nbsp;yo&nbsp;+&nbsp;<code>dy</code>} ;<br />
Pcs&nbsp;= {xo&nbsp;+&nbsp;<code>dx1</code>,&nbsp;yo&nbsp;+&nbsp;<code>dy1</code>} ;<br />
Pce&nbsp;= {xo&nbsp;+&nbsp;<code>dx2</code>,&nbsp;yo&nbsp;+&nbsp;<code>dy2</code>}</p>
</td>
</tr>
<tr>
<th scope="row">S</th>
<td>(<code>x2</code>,<code>y2</code>,&nbsp;<code>x</code>,<code>y</code>)+</td>
<td><em>当前位置</em><em>终点</em>&nbsp;<code>x</code>,<code>y</code>&nbsp;之间绘制一条平滑的三次贝塞尔曲线。<em>终点控制点</em>通过&nbsp;<code>x2</code>,<code>y2</code>&nbsp;指定。<em>起始控制点</em>是上一条曲线命令的<em>终点控制点</em><em>当前位置</em>上的反射点;若上一条命令不是曲线命令,则其与曲线的起始点(<em>当前位置</em>)相同。后续成对的坐标序列将被解释为隐式的绝对位置的平滑三次贝塞尔曲线(<code>S</code>)命令的参数。</td>
</tr>
<tr>
<th scope="row">s</th>
<td>(<code>dx2</code>,<code>dy2</code>,&nbsp;<code>dx</code>,<code>dy</code>)+</td>
<td><em>当前位置</em><em>终点</em><em>终点</em><em>当前位置</em>沿 x 轴偏移&nbsp;<code>dx</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy</code>&nbsp;处)之间绘制一条平滑的三次贝塞尔曲线。<em>终点控制点</em><em>当前位置</em>沿 x 轴偏移&nbsp;<code>dx2</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy2</code>&nbsp;处。<em>起始控制点</em>是上一条曲线命令的<em>终点控制点</em><em>当前位置</em>上的反射点;若上一条命令不是曲线命令,则其与曲线的起始点(<em>当前位置</em>)相同。后续成对的坐标序列将被解释为隐式的相对位置的平滑三次贝塞尔曲线(<code>s</code>)命令的参数。</td>
</tr>
</tbody>

示例

<svg
  viewBox="0 0 200 100"
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink">
  <!-- 使用绝对坐标的三次贝塞尔曲线 -->
  <path
    fill="none"
    stroke="red"
    d="M 10,90
           C 30,90 25,10 50,10
           S 70,90 90,90" />

  <!-- 使用相对坐标的三次贝塞尔曲线 -->
  <path
    fill="none"
    stroke="red"
    d="M 110,90
           c 20,0 15,-80 40,-80
           s 20,80 40,80" />

  <!-- 高亮显示曲线顶点和控制点 -->
  <g id="ControlPoints">
    <!-- 第一段三次贝塞尔曲线的控制点 -->
    <line x1="10" y1="90" x2="30" y2="90" stroke="lightgrey" />
    <circle cx="30" cy="90" r="1.5" />

    <line x1="50" y1="10" x2="25" y2="10" stroke="lightgrey" />
    <circle cx="25" cy="10" r="1.5" />

    <!-- 第二段平滑三次贝塞尔曲线的控制点(第一个是隐式的) -->
    <line
      x1="50"
      y1="10"
      x2="75"
      y2="10"
      stroke="lightgrey"
      stroke-dasharray="2" />
    <circle cx="75" cy="10" r="1.5" fill="lightgrey" />

    <line x1="90" y1="90" x2="70" y2="90" stroke="lightgrey" />
    <circle cx="70" cy="90" r="1.5" />

    <!-- 曲线顶点 -->
    <circle cx="10" cy="90" r="1.5" />
    <circle cx="50" cy="10" r="1.5" />
    <circle cx="90" cy="90" r="1.5" />
  </g>
  <use xlink:href="#ControlPoints" x="100" />
</svg>

二次贝塞尔曲线

二次贝塞尔曲线是使用三个点定义的平滑曲线:

起始点(当前位置)

Po = {xoyo}

终点

Pn = {xnyn}

控制点

Pc = {xcyc}(控制曲率)

绘制后,终点Pn)将成为下一个命令中的当前位置Po′)。

命令 参数 备注
Q (x1,y1x,y)+

当前位置终点 x,y 之间绘制一条二次贝塞尔曲线。控制点通过 x1,y1 指定。后续成对的坐标序列将被解释为隐式的绝对位置的二次贝塞尔曲线(Q)命令的参数。

<p><a data-link-icon="https://csdnimg.cn/release/blog_editor_html/release2.4.5/ckeditor/plugins/CsdnLink/icons/icon-default.png?t=PBP8" data-link-title="公式:" href="https://developer.mozilla.org/zh-CN/docs/Web/SVG/Reference/Attribute/d#%E5%85%AC%E5%BC%8F%EF%BC%9A_3" title="公式:">公式:</a></p>

<p>Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {<code>x</code>,&nbsp;<code>y</code>} ;<br />
Pc&nbsp;= {<code>x1</code>,&nbsp;<code>y1</code>}</p>
</td>
</tr>
<tr>
<th scope="row">q</th>
<td>(<code>dx1</code>,<code>dy1</code>,&nbsp;<code>dx</code>,<code>dy</code>)+</td>
<td>
<p><em>当前位置</em><em>终点</em><em>终点</em><em>当前位置</em>沿 x 轴偏移&nbsp;<code>dx</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy</code>&nbsp;处)之间绘制一条二次贝塞尔曲线。<em>控制点</em><em>当前位置</em>(曲线的起始点)沿 x 轴偏移&nbsp;<code>dx1</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy1</code>&nbsp;处。后续成对的坐标序列将被解释为隐式的相对位置的二次贝塞尔曲线(<code>q</code>)命令的参数。</p>

<p><a data-link-icon="https://csdnimg.cn/release/blog_editor_html/release2.4.5/ckeditor/plugins/CsdnLink/icons/icon-default.png?t=PBP8" data-link-title="公式:" href="https://developer.mozilla.org/zh-CN/docs/Web/SVG/Reference/Attribute/d#%E5%85%AC%E5%BC%8F%EF%BC%9A_4" title="公式:">公式:</a></p>

<p>Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {xo&nbsp;+&nbsp;<code>dx</code>,&nbsp;yo&nbsp;+&nbsp;<code>dy</code>} ;<br />
Pc&nbsp;= {xo&nbsp;+&nbsp;<code>dx1</code>,&nbsp;yo&nbsp;+&nbsp;<code>dy1</code>}</p>
</td>
</tr>
<tr>
<th scope="row">T</th>
<td>(<code>x</code>,<code>y</code>)+</td>
<td>
<p><em>当前位置</em><em>终点</em>&nbsp;<code>x</code>,<code>y</code>&nbsp;之间绘制一条平滑的二次贝塞尔曲线。<em>控制点</em>是上一条曲线命令的<em>控制点</em><em>当前位置</em>上的反射点;若上一条命令不是曲线命令,则其与曲线的起始点(<em>当前位置</em>)相同。后续的坐标序列将被解释为隐式的绝对位置的平滑二次贝塞尔曲线(<code>T</code>)命令的参数。</p>

<p><a data-link-icon="https://csdnimg.cn/release/blog_editor_html/release2.4.5/ckeditor/plugins/CsdnLink/icons/icon-default.png?t=PBP8" data-link-title="公式:" href="https://developer.mozilla.org/zh-CN/docs/Web/SVG/Reference/Attribute/d#%E5%85%AC%E5%BC%8F%EF%BC%9A_5" title="公式:">公式:</a></p>

<p>Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {<code>x</code>,&nbsp;<code>y</code>}</p>
</td>
</tr>
<tr>
<th scope="row">t</th>
<td>(<code>dx</code>,<code>dy</code>)+</td>
<td>
<p><em>当前位置</em><em>终点</em><em>终点</em><em>当前位置</em>沿 x 轴偏移&nbsp;<code>dx</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy</code>&nbsp;处)之间绘制一条平滑的二次贝塞尔曲线。<em>控制点</em>是上一条曲线命令的<em>控制点</em><em>当前位置</em>上的反射点;若上一条命令不是曲线命令,则其与曲线的起始点(<em>当前位置</em>)相同。后续的坐标序列将被解释为隐式的相对位置的平滑二次贝塞尔曲线(<code>t</code>)命令的参数。</p>

<p><a data-link-icon="https://csdnimg.cn/release/blog_editor_html/release2.4.5/ckeditor/plugins/CsdnLink/icons/icon-default.png?t=PBP8" data-link-title="公式:" href="https://developer.mozilla.org/zh-CN/docs/Web/SVG/Reference/Attribute/d#%E5%85%AC%E5%BC%8F%EF%BC%9A_6" title="公式:">公式:</a></p>

<p>Po&prime;&nbsp;=&nbsp;Pn&nbsp;= {xo&nbsp;+&nbsp;<code>dx</code>,&nbsp;yo&nbsp;+&nbsp;<code>dy</code>}</p>
</td>
</tr>
</tbody>

示例

<svg
  viewBox="0 0 200 100"
  xmlns="http://www.w3.org/2000/svg"
  xmlns:xlink="http://www.w3.org/1999/xlink">
  <!-- 二次贝塞尔曲线,带有隐式重复 -->
  <path
    fill="none"
    stroke="red"
    d="M 10,50
           Q 25,25 40,50
           t 30,0 30,0 30,0 30,0 30,0" />

  <!-- 高亮显示曲线顶点和控制点 -->
  <g>
    <polyline points="10,50 25,25 40,50" stroke="rgba(0,0,0,.2)" fill="none" />
    <circle cx="25" cy="25" r="1.5" />

    <!-- 曲线顶点 -->
    <circle cx="10" cy="50" r="1.5" />
    <circle cx="40" cy="50" r="1.5" />

    <g id="SmoothQuadraticDown">
      <polyline
        points="40,50 55,75 70,50"
        stroke="rgba(0,0,0,.2)"
        stroke-dasharray="2"
        fill="none" />
      <circle cx="55" cy="75" r="1.5" fill="lightgrey" />
      <circle cx="70" cy="50" r="1.5" />
    </g>

    <g id="SmoothQuadraticUp">
      <polyline
        points="70,50 85,25 100,50"
        stroke="rgba(0,0,0,.2)"
        stroke-dasharray="2"
        fill="none" />
      <circle cx="85" cy="25" r="1.5" fill="lightgrey" />
      <circle cx="100" cy="50" r="1.5" />
    </g>

    <use xlink:href="#SmoothQuadraticDown" x="60" />
    <use xlink:href="#SmoothQuadraticUp" x="60" />
    <use xlink:href="#SmoothQuadraticDown" x="120" />
  </g>
</svg>

椭圆曲线

椭圆曲线是定义为椭圆的一部分的曲线。有时,使用椭圆曲线绘制高度规则的曲线会比使用贝塞尔曲线更容易。

命令 参数 备注
A (rx ry angle large-arc-flag sweep-flag x y)+

在当前位置和坐标 x,y 之间绘制一条椭圆曲线。用于绘制圆弧的椭圆中心根据命令的其他参数确定:

<ul>
<li><code>rx</code>&nbsp;<code>ry</code>&nbsp;是椭圆的两个半径;</li>
<li><code>angle</code>&nbsp;表示椭圆相对于 x 轴的旋转角度;</li>
<li><code>large-arc-flag</code>&nbsp;&nbsp;<code>sweep-flag</code>&nbsp;允许选择必须绘制的弧线,因为其他参数可以绘制 4 条可能的弧线。
<ul>
<li><code>large-arc-flag</code>&nbsp;允许选择一个大弧线(<code>1</code>)或一个小弧线(<code>0</code>),</li>
<li><code>sweep-flag</code>&nbsp;允许选择一条顺时针旋转的弧线(<code>1</code>)或一条逆时针旋转的弧线(<code>0</code></li>
</ul>
</li>
</ul>
坐标&nbsp;<code>x</code>,<code>y</code>&nbsp;将成为下一个命令中的当前位置。后续参数集合的序列将被解释为隐式的绝对位置的椭圆曲线(<code>A</code>)命令的参数。</td>
</tr>
<tr>
<th scope="row">a</th>
<td>(<code>rx</code>&nbsp;<code>ry</code>&nbsp;<code>angle</code>&nbsp;<code>large-arc-flag</code>&nbsp;<code>sweep-flag</code>&nbsp;<code>dx</code>&nbsp;<code>dy</code>)+</td>
<td>
<p>在当前位置和指定位置之间绘制一条椭圆曲线。指定位置为当前位置沿 x 轴偏移&nbsp;<code>dx</code>&nbsp;以及沿 y 轴偏移&nbsp;<code>dy</code>&nbsp;处。用于绘制圆弧的椭圆中心根据命令的其他参数确定:</p>

<ul>
<li><code>rx</code>&nbsp;&nbsp;<code>ry</code>&nbsp;是椭圆的两个半径;</li>
<li><code>angle</code>&nbsp;表示椭圆相对于 x 轴的旋转角度;</li>
<li><code>large-arc-flag</code>&nbsp;&nbsp;<code>sweep-flag</code>&nbsp;允许选择必须绘制的弧线,因为其他参数可以绘制 4 条可能的弧线。
<ul>
<li><code>large-arc-flag</code>&nbsp;允许选择一个大弧线(<code>1</code>)或一个小弧线(<code>0</code>),</li>
<li><code>sweep-flag</code>&nbsp;允许选择一条顺时针旋转的弧线(<code>1</code>)或一条逆时针旋转的弧线(<code>0</code></li>
</ul>
</li>
</ul>
当前位置沿 x 轴偏移&nbsp;<code>dx</code>&nbsp;以及沿 y 轴偏移后的位置将成为下一个命令中的当前位置。后续参数集合的序列将被解释为隐式的相对位置的椭圆曲线(<code>a</code>)命令的参数。</td>
</tr>
</tbody>

示例

<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
  <!-- 通过弧形标志绘制不同的弧形路径 -->
  <path
    fill="none"
    stroke="red"
    d="M 6,10
           A 6 4 10 1 0 14,10" />

  <path
    fill="none"
    stroke="lime"
    d="M 6,10
           A 6 4 10 1 1 14,10" />

  <path
    fill="none"
    stroke="purple"
    d="M 6,10
           A 6 4 10 0 1 14,10" />

  <path
    fill="none"
    stroke="pink"
    d="M 6,10
           A 6 4 10 0 0 14,10" />
</svg>

ClosePath

ClosePath 命令将从当前位置绘制一条直线到路径中的第一个点。

命令 参数 备注
Z, z 通过连接路径的最后一个点与路径的起始点来闭合当前的子路径。如果这两个点的坐标不同,则在两者之间绘制一条直线。

备注:使用 ClosePath 命令闭合的形状的外观可能与使用其他命令向起始点绘制一条线而闭合的形状不同,因为前者是将线条的末端连接在一起(根据 stroke-linejoin 的设置),而不是仅仅绘制到坐标点上。

示例

<svg viewBox="0 -1 30 11" xmlns="http://www.w3.org/2000/svg">
  <!--
  一个起点和终点不同的开放形状
  -->
  <path
    stroke="red"
    d="M 5,1
           l -4,8 8,0" />

  <!--
  一个起点和终点相同的开放形状
  -->
  <path
    stroke="red"
    d="M 15,1
           l -4,8 8,0 -4,-8" />

  <!--
  一个起点和终点不同的闭合形状
  -->
  <path
    stroke="red"
    d="M 25,1
           l -4,8 8,0
           z" />
</svg>

JS-手写系列:call、apply、bind

前言

在 JavaScript 中,this 的指向总是让人捉摸不透。callapplybind 作为改变 this 指向的三大杀手锏,其底层实现原理是面试中的高频考点。本文将带你通过手写实现,彻底搞懂它们背后的逻辑。

一、 手写 call

1. 核心思路

利用“对象调用方法时,方法内部 this 指向该对象”这一隐式绑定规则。

  • 将函数设为目标对象的一个属性。
  • 执行该函数。
  • 删除该临时属性,返回结果。

2. 实现

 Function.prototype.myCall = function (target, ...args) {
    // 1. 处理 target 为空的情况,默认为 window
    if (target === undefined || target === null) {
      target = window;
    }
    // 2. 创建唯一键,避免覆盖目标对象原有属性
    const fnKey = Symbol('fn');
    // 3. 将当前函数(this)指向目标对象的属性
    target[fnKey] = this;
    // 4. 执行函数并展开参数
    const result = target[fnKey](...args);
    // 5. 善后处理:删除临时属性
    delete target[fnKey];

    return result;
  };
  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };
  obj.getName.call(); // undefined undefined
  obj.getName.call({ name: 'b' }, 1, 2, 3); // b 1,2

  obj.getName.myCall(); // undefined undefined
  obj.getName.myCall({ name: 'b' }, 1, 2, 3); // b,1,2
};

二、 手写 apply

思路与call一致,都是利用“对象调用方法时,方法内部 this 指向该对象”这一隐式绑定规则

1. 实现

  //唯一区别:参数处理方式,call需要使用...展开
  Function.prototype.myApply = function (target, args) {
    // 1. 处理 target 为空的情况,默认为 window
    if (target === undefined || target === null) {
      target = window;
    }
    // 2. 创建唯一键,避免覆盖目标对象原有属性
    const fnKey = Symbol('fn');
    // 3. 将当前函数(this)指向目标对象的属性
    target[fnKey] = this;
    // 4. 执行函数并展开参数
    const result = target[fnKey](...(args || []));
    // 5. 善后处理:删除临时属性
    delete target[fnKey];

    return result;
  };
  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };
  obj.getName.apply(); // undefined undefined
  obj.getName.apply({ name: 'b' }, [1, 2, 3]); // b 1,2

  obj.getName.myApply(); // undefined undefined
  obj.getName.myApply({ name: 'b' }, [1, 2, 3]); // b,1,2

二、 手写 bind

bind 的实现比前两者复杂,因为它涉及两个核心特性:闭包返回函数支持 new 实例化。当 bind 返回的函数被用作 new 构造函数时:

  • this 绑定失效:生成的实例 this 应该指向 new 创建的对象,而非 bind 绑定的对象。
  • 原型链继承:实例需要能够访问到原函数原型(prototype)上的属性和方法。

2. 实现

  Function.prototype.myBind = function (fn, ...args1) {
    const self = this; // 保存原函数
    const bound = function (...args2) {
      // 如果 this 是 bound 的实例,说明是 new 调用,此时 fn 应该失效
      return self.apply(this instanceof bound ? this : fn, [
        ...args1,
        ...args2,
      ]);
    };
    // 修改原型链,使实例能继承原函数原型, 使用 Object.create 避免直接修改导致相互影响
    bound.prototype = Object.create(self.prototype);
    bound.prototype.constructor = self;
    return bound;
  };

  const obj = {
    age: 18,
    name: 'a',
    getName: function (job, hobby) {
      console.log(this.name, job, hobby);
    },
  };

  const boundGetName1 = obj.getName.bind({ name: 'b' }, 7, 8);
  const boundGetName2 = obj.getName.myBind({ name: 'b' }, 7, 8);
  boundGetName1(); // b 7 8
  boundGetName2(); // b 7 8

  let newFunc1 = obj.getName.bind({ name: 'aa' }, 7, 8);
  let newFunc2 = obj.getName.myBind({ name: 'aa' }, 7, 8);
  newFunc1(); // aa 7 8
  newFunc2(); // aa 7 8

三、 总结与核心差异

方法 参数传递 返回值 核心原理
call 参数列表 (obj, a, b) 函数执行结果 临时属性挂载(隐式绑定)
apply 数组/类数组 (obj, [a, b]) 函数执行结果 临时属性挂载(隐式绑定)
bind 参数列表 (obj, a) 返回新函数 闭包 + apply

🔥Promise 从入门到精通:彻底解决前端异步回调问题!!!

Promise 从入门到精通:彻底解决前端异步回调问题

🚀 一、Promise 核心介绍

1. 什么是 Promise?

Promise 是 ES6(ECMAScript 2015)引入的异步编程解决方案,是一个用于封装异步操作并管理其结果的对象。它解决了传统异步编程中回调地狱(Callback Hell) 的嵌套问题,让异步代码的逻辑更清晰、更易维护,同时提供了统一的异步错误处理机制,是现代前端异步编程的基础(async/await 正是基于 Promise 实现的语法糖)。

简单来说:Promise 就像一个异步操作的“承诺” —— 异步操作执行前,它处于等待状态;异步操作完成后,它会兑现“承诺”(返回成功结果)或拒绝“承诺”(返回失败原因),且这个结果一旦确定就不可修改

2. 核心特性

  • 状态不可逆:Promise 有且仅有三种状态,状态一旦改变,就会永久保持该状态,不会再发生变化;
  • 统一的异步范式:将不同类型的异步操作(AJAX、定时器、文件操作等)封装为统一的 Promise 接口,解决异步操作格式不统一的问题;
  • 链式调用:支持 .then().catch().finally() 链式调用,替代多层回调嵌套,让异步代码线性化;
  • 集中错误处理:支持全局错误捕获,一个 .catch() 可捕获链式调用中所有前置操作的错误,避免单独处理每个异步操作的异常;
  • 非阻塞执行:Promise 封装的异步操作不会阻塞浏览器主线程,保证页面交互的流畅性;
  • 一次性执行:Promise 内部的异步操作一旦执行,就会完成整个流程,不会被重复触发。

3. 解决的核心问题:回调地狱

传统异步编程依赖回调函数,当多个异步操作存在顺序依赖(后一个操作依赖前一个操作的结果)时,会出现多层回调嵌套,形成回调地狱,代码表现为「层层缩进的金字塔结构」,存在可读性差、维护困难、错误无法统一捕获等问题。

回调地狱示例(定时器嵌套)

// 需求:依次执行三个异步操作,后一个操作等待前一个完成
setTimeout(() => {
  console.log('第一步操作完成');
  setTimeout(() => {
    console.log('第二步操作完成');
    setTimeout(() => {
      console.log('第三步操作完成');
      // 更多嵌套...
    }, 1000);
  }, 1000);
}, 1000);

Promise 改造后(链式调用,无嵌套)

// 封装Promise版定时器
function delay(time) {
  return new Promise(resolve => setTimeout(resolve, time));
}

// 线性链式调用,逻辑清晰
delay(1000)
  .then(() => console.log('第一步操作完成'))
  .then(() => delay(1000))
  .then(() => console.log('第二步操作完成'))
  .then(() => delay(1000))
  .then(() => console.log('第三步操作完成'));

对比可见:Promise 彻底摆脱了回调嵌套,让异步代码的执行流程与同步代码一致,大幅提升了代码的可读性和可维护性。

4. Promise 与传统异步方案的对比

特性 Promise 传统回调函数 async/await
代码结构 链式调用,无嵌套 多层嵌套,回调地狱 同步化写法,最简洁
错误处理 统一 .catch() 捕获 每个回调单独处理错误 try/catch 捕获,更符合直觉
异步流程控制 原生支持(.then 链式) 手动嵌套控制 同步流程控制(基于Promise)
学习成本 中等 低(但复杂场景易出错) 高(需先掌握Promise)
依赖关系 无(ES6原生) 依赖Promise(ES7)
适用场景 简单至复杂异步流程 简单异步操作(无依赖) 复杂异步流程、多请求依赖

核心结论:async/await 是 Promise 的语法糖,传统回调是基础,Promise 是现代前端异步编程的核心桥梁

🎯 二、Promise 核心概念

1. 三种状态与状态转换

Promise 的核心是状态管理,其一生仅有三种状态,且状态转换不可逆、仅能发生一次,这是 Promise 解决异步不确定性的关键。

三种基础状态
  1. pending(进行中):初始状态,异步操作尚未完成,此时既未成功也未失败;
  2. fulfilled(已成功):异步操作顺利完成,Promise 兑现承诺,返回成功结果;
  3. rejected(已失败):异步操作执行失败,Promise 拒绝承诺,返回失败原因(错误对象)。
唯一的两种状态转换路径

Promise 只能从初始状态向最终状态转换,且转换后状态永久固定,无法反向转换,也无法在成功/失败之间切换

  • 路径1:pendingfulfilled(异步操作成功,调用 resolve() 触发);
  • 路径2:pendingrejected(异步操作失败,调用 reject() 触发)。

核心注意:一旦状态变为 fulfilledrejected,就会成为定型状态(settled),后续再调用 resolve()reject() 均无效。

2. 两个核心回调函数

Promise 构造函数接收一个执行器函数(executor),该函数会在 Promise 创建时立即同步执行,且接收两个内置的核心回调函数作为参数,用于手动触发状态转换:

  1. resolve(result):将 Promise 状态从 pending 转为 fulfilled,并将异步操作的成功结果传递出去(result 可为任意类型:基本类型、对象、数组,甚至另一个 Promise);
  2. reject(reason):将 Promise 状态从 pending 转为 rejected,并将异步操作的失败原因传递出去(reason 通常为 Error 对象,便于后续错误捕获和栈追踪)。

核心注意:执行器函数是同步执行的,内部的异步操作是异步执行的,这是容易混淆的关键点,示例:

const p = new Promise((resolve, reject) => {
  console.log('Promise 执行器函数:同步执行'); // 立即输出
  setTimeout(() => {
    console.log('异步操作:异步执行'); // 1秒后输出
    resolve('异步操作成功');
  }, 1000);
});
console.log('Promise 创建完成'); // 执行器后立即输出

输出顺序:Promise 执行器函数 → Promise 创建完成 → 异步操作:异步执行。

3. 定型状态(Settled)

定型状态是 Promise 的最终状态统称,指 Promise 已完成状态转换,不再处于 pending 状态,包含两种情况:

  • fulfilled(已成功)是定型状态;
  • rejected(已失败)也是定型状态。

后续的 .then().catch().finally() 方法,本质都是监听 Promise 的定型状态,一旦状态定型,就会执行对应的回调函数。

📁 三、Promise 实例的核心方法

Promise 实例提供了 .then().catch().finally() 三个核心原型方法,均支持链式调用(核心原因:每个方法执行后都会返回一个新的 Promise 实例,而非原实例),这是解决回调地狱的关键。

所有方法的回调函数都会被加入微任务队列,在浏览器主线程同步代码执行完成后、宏任务执行前执行(Promise 属于微任务,这是事件循环的重要知识点)。

1. then(onFulfilled, onRejected):处理成功/失败结果

.then() 是 Promise 最核心的方法,用于监听 Promise 的定型状态,接收两个可选的回调参数:

  • onFulfilled(result):可选,Promise 状态为 fulfilled 时执行,参数 resultresolve() 传递的成功结果;
  • onRejected(reason):可选,Promise 状态为 rejected 时执行,参数 reasonreject() 传递的失败原因。
核心特性
  1. 链式调用基础.then() 执行后返回新的 Promise 实例,新实例的状态由当前回调函数的执行结果决定;
  2. 参数可选:可只传 onFulfilled(仅处理成功),也可只传 onRejected(但更推荐用 .catch() 处理失败);
  3. 值的传递:若回调函数返回一个普通值(非 Promise、非抛出错误),新 Promise 会以 fulfilled 状态将该值传递给下一个 .then()
  4. 错误透传:若回调函数抛出错误,新 Promise 会以 rejected 状态将错误传递给后续的 .catch().then()onRejected
基础使用示例
const p = new Promise((resolve, reject) => {
  const random = Math.random();
  if (random > 0.5) {
    resolve(`成功:随机数${random.toFixed(2)}`);
  } else {
    reject(new Error(`失败:随机数${random.toFixed(2)}`));
  }
});

// 处理成功和失败
p.then(
  (res) => console.log('then成功回调:', res),
  (err) => console.log('then失败回调:', err.message)
);
链式调用示例(核心)
// 链式调用:依次处理,值的传递
new Promise((resolve) => resolve(1))
  .then((num) => {
    console.log('第一步:', num); // 1
    return num + 1; // 返回普通值,传递给下一个then
  })
  .then((num) => {
    console.log('第二步:', num); // 2
    return new Promise(resolve => setTimeout(() => resolve(num + 1), 1000)); // 返回Promise
  })
  .then((num) => {
    console.log('第三步:', num); // 3(1秒后)
    throw new Error('主动抛出错误'); // 抛出错误,触发后续catch
  })
  .then(
    (num) => console.log('第四步:', num), // 不会执行
    (err) => console.log('then捕获错误:', err.message) // 可捕获,但推荐用catch
  );

2. catch(onRejected):专门处理失败结果

.catch().then(null, onRejected)语法糖,专门用于捕获 Promise 链式调用中所有前置操作的错误(包括 reject() 触发的失败、回调函数中抛出的错误、同步代码错误),是 Promise 错误处理的推荐方式

核心特性
  1. 全局错误捕获:一个 .catch() 可捕获链式调用中所有前置 .then() 的错误,无需在每个 .then() 中单独处理;
  2. 链式调用.catch() 执行后也会返回新的 Promise 实例,若在 .catch() 中返回普通值,可继续链式调用 .then()
  3. 错误兜底:若 Promise 链式调用中没有 .catch(),未捕获的错误会触发浏览器的 unhandledrejection 事件,导致控制台报错(生产环境需避免);
  4. 捕获范围:不仅捕获 Promise 自身的 reject(),还捕获所有回调函数中的同步错误异步错误(如回调中调用未定义的变量)。
基础使用示例
new Promise((resolve, reject) => {
  reject(new Error('异步操作失败'));
})
  .then((res) => console.log('成功:', res)) // 不会执行
  .catch((err) => console.log('catch捕获错误:', err.message)); // 输出:异步操作失败
全局错误捕获示例(链式核心)
// 一个catch捕获所有前置错误
new Promise((resolve) => {
  resolve(1);
})
  .then((num) => {
    console.log(num);
    a++; // 引用未定义变量,同步错误
  })
  .then((num) => console.log(num)) // 不会执行
  .catch((err) => {
    console.log('catch捕获所有错误:', err.message); // 输出:a is not defined
    return 10; // 返回普通值,继续链式调用
  })
  .then((num) => console.log('catch后继续执行:', num)); // 输出:10

3. finally(onFinally):处理最终收尾操作

.finally() 是 ES2018 引入的方法,用于指定无论 Promise 状态是成功还是失败,都会执行的收尾操作,比如关闭加载弹窗、释放资源、取消定时器等。

核心特性
  1. 无参数.finally() 的回调函数不接收任何参数,因为它无需关心 Promise 的执行结果(成功/失败),仅做通用收尾;
  2. 必然执行:无论 Promise 是 fulfilled 还是 rejected,也无论链式调用中是否有 .catch().finally() 都会执行;
  3. 链式调用.finally() 也会返回新的 Promise 实例,且会透传前置 Promise 的成功结果或失败原因(即不改变原有的结果);
  4. 无返回值影响.finally() 的回调函数返回的值会被忽略,不会影响后续链式调用的参数。
基础使用示例(最常用场景:关闭加载)
// 模拟接口请求
function requestData() {
  return new Promise((resolve, reject) => {
    console.log('显示加载弹窗');
    setTimeout(() => {
      const isSuccess = Math.random() > 0.5;
      if (isSuccess) {
        resolve('请求成功:获取到数据');
      } else {
        reject(new Error('请求失败:网络错误'));
      }
    }, 1000);
  });
}

// 执行请求,finally关闭加载
requestData()
  .then((res) => console.log(res))
  .catch((err) => console.log(err.message))
  .finally(() => {
    console.log('关闭加载弹窗'); // 无论成功/失败,都会执行
  });
透传结果示例
// finally透传成功结果
new Promise((resolve) => resolve('成功数据'))
  .finally(() => {
    console.log('执行finally');
    return 'finally的返回值'; // 会被忽略
  })
  .then((res) => console.log('最终结果:', res)); // 输出:成功数据

// finally透传失败原因
new Promise((reject) => reject(new Error('失败原因')))
  .finally(() => console.log('执行finally'))
  .catch((err) => console.log('最终错误:', err.message)); // 输出:失败原因

🚀 四、Promise 构造函数的静态方法

Promise 构造函数本身提供了多个静态方法,用于快速创建 Promise 实例或批量管理多个 Promise 实例,是处理多异步操作流程控制的核心,日常开发中使用频率极高。

1. Promise.resolve(value):快速创建成功的 Promise

用于快速创建一个状态为 fulfilled 的 Promise 实例,等价于 new Promise(resolve => resolve(value)),适合将普通值、非 Promise 异步操作转为 Promise 格式,实现统一的异步接口。

核心特性
  • 若参数 value普通值(基本类型、对象),新 Promise 直接以该值为成功结果;
  • 若参数 value另一个 Promise 实例,则直接返回该实例(状态和结果均透传);
  • 若参数 value具有 then 方法的对象(thenable),则会执行其 then 方法,根据 then 方法的执行结果确定新 Promise 的状态。
使用示例
// 1. 传入普通值
const p1 = Promise.resolve(123);
p1.then(res => console.log(p1)); // 123

// 2. 传入Promise实例
const p2 = new Promise(resolve => resolve('原Promise'));
const p3 = Promise.resolve(p2);
console.log(p2 === p3); // true(直接返回原实例)

// 3. 传入thenable对象
const thenable = {
  then(resolve) {
    resolve('thenable执行成功');
  }
};
Promise.resolve(thenable).then(res => console.log(res)); // thenable执行成功

2. Promise.reject(reason):快速创建失败的 Promise

用于快速创建一个状态为 rejected 的 Promise 实例,等价于 new Promise((resolve, reject) => reject(reason)),适合快速抛出异步错误。

核心特性
  • 无论参数 reason 是什么类型(普通值、Promise 实例、thenable 对象),都会直接作为失败原因传递给 .catch()不会透传 Promise 实例的状态(与 Promise.resolve 不同)。
使用示例
// 1. 传入普通错误
Promise.reject('简单错误').catch(err => console.log(err)); // 简单错误

// 2. 传入Error对象(推荐)
Promise.reject(new Error('标准错误')).catch(err => console.log(err.message)); // 标准错误

// 3. 传入Promise实例(不会透传,直接作为原因)
const p = Promise.resolve('成功的Promise');
Promise.reject(p).catch(err => console.log(err === p)); // true

3. Promise.all(iterable):所有异步操作都成功才成功

核心场景:处理并行的多个异步操作,且所有操作都必须成功,才返回所有结果;只要有一个操作失败,立即返回该失败原因(快速失败机制)。

核心特性
  • 参数 iterable:可迭代对象(如数组),每个元素都是 Promise 实例;
  • 成功结果:当所有 Promise 都变为 fulfilled,新 Promise 以 fulfilled 状态返回结果数组,数组顺序与传入的 Promise 顺序一致(与执行完成顺序无关);
  • 失败机制:只要有一个 Promise 变为 rejected,新 Promise 立即以 rejected 状态返回该失败原因,其余未完成的 Promise 仍会执行,但结果会被忽略;
  • 空数组:若传入空数组,会立即成功,返回空数组。
使用示例(并行请求多个接口)
// 模拟3个并行的接口请求
const request1 = Promise.resolve('接口1数据');
const request2 = new Promise(resolve => setTimeout(() => resolve('接口2数据'), 1000));
const request3 = Promise.resolve('接口3数据');

// 所有请求都成功才返回结果
Promise.all([request1, request2, request3])
  .then(res => {
    console.log('所有请求成功:', res); // 输出:['接口1数据', '接口2数据', '接口3数据'](1秒后)
    const [data1, data2, data3] = res; // 按顺序解构
  })
  .catch(err => console.log('有请求失败:', err.message));

// 一个请求失败的情况
const request4 = Promise.reject(new Error('接口4请求失败'));
Promise.all([request1, request4, request3])
  .then(res => console.log(res)) // 不会执行
  .catch(err => console.log(err.message)); // 立即输出:接口4请求失败

4. Promise.race(iterable):第一个完成的异步操作决定结果

核心场景:处理并行的多个异步操作谁先完成(成功/失败),就取谁的结果,其余未完成的 Promise 仍会执行,但结果会被忽略(“赛跑”机制)。

核心特性
  • 参数 iterable:可迭代对象,每个元素都是 Promise 实例;
  • 结果由“第一个定型”的 Promise 决定:无论第一个完成的是 fulfilled 还是 rejected,新 Promise 都会继承其状态和结果;
  • 空数组:若传入空数组,新 Promise 会一直处于 pending 状态,永不定型。
经典使用示例:接口请求超时控制
// 模拟接口请求
function request() {
  return new Promise((resolve) => {
    setTimeout(() => resolve('接口请求成功'), 2000); // 2秒后完成
  });
}

// 模拟超时器(1.5秒后失败)
function timeout() {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('请求超时:1.5秒未响应')), 1500);
  });
}

// 赛跑:请求和超时器,谁先完成取谁的结果
Promise.race([request(), timeout()])
  .then(res => console.log(res)) // 不会执行,因为超时器先完成
  .catch(err => console.log(err.message)); // 输出:请求超时:1.5秒未响应

5. Promise.allSettled(iterable):等待所有异步操作都完成

核心场景:处理并行的多个异步操作无论成功还是失败,都会等待所有操作完成,并返回每个操作的详细结果(包含状态和值/原因),解决了 Promise.all 快速失败的问题(适合需要知道所有操作结果的场景,如批量上传)。

核心特性
  • ES2020 引入,现代浏览器均支持;
  • 参数 iterable:可迭代对象,每个元素都是 Promise 实例;
  • 成功结果:当所有 Promise 都定型(settled),新 Promise 一定是 fulfilled 状态,返回结果数组,数组顺序与传入顺序一致;
  • 每个结果对象包含两个属性:
    • status:字符串,值为 fulfilledrejected
    • value:仅 statusfulfilled 时存在,是成功结果;
    • reason:仅 statusrejected 时存在,是失败原因。
使用示例(批量上传,需知道每个文件的上传结果)
// 模拟3个文件上传的异步操作(2成功1失败)
const upload1 = Promise.resolve('文件1上传成功');
const upload2 = Promise.reject(new Error('文件2上传失败:文件过大'));
const upload3 = Promise.resolve('文件3上传成功');

// 等待所有上传操作完成,获取每个操作的结果
Promise.allSettled([upload1, upload2, upload3])
  .then(results => {
    console.log('所有上传结果:', results);
    // 筛选成功/失败的结果
    const successList = results.filter(item => item.status === 'fulfilled').map(item => item.value);
    const failList = results.filter(item => item.status === 'rejected').map(item => item.reason.message);
    console.log('成功的上传:', successList); // ['文件1上传成功', '文件3上传成功']
    console.log('失败的上传:', failList); // ['文件2上传失败:文件过大']
  });

6. Promise.any(iterable):第一个成功的异步操作决定结果

核心场景:处理并行的多个异步操作忽略失败的操作,等待第一个成功的操作返回结果;若所有操作都失败,才返回聚合错误(适合多节点请求,取最快成功的节点数据)。

核心特性
  • ES2021 引入,现代浏览器均支持;
  • 参数 iterable:可迭代对象,每个元素都是 Promise 实例;
  • 成功机制:只要有一个 Promise 变为 fulfilled,新 Promise 立即继承其成功结果,其余未完成的 Promise 仍会执行,结果被忽略;
  • 失败机制:若所有 Promise 都变为 rejected,新 Promise 会变为 rejected,抛出 AggregateError 错误(包含所有失败原因)。
使用示例(多节点请求,取最快成功的结果)
// 模拟3个节点的接口请求(2失败1成功,成功的节点最慢)
const requestNode1 = Promise.reject(new Error('节点1请求失败'));
const requestNode2 = Promise.reject(new Error('节点2请求失败'));
const requestNode3 = new Promise(resolve => setTimeout(() => resolve('节点3请求成功,获取数据'), 1000));

// 取第一个成功的请求结果
Promise.any([requestNode1, requestNode2, requestNode3])
  .then(res => console.log('最快成功的节点:', res)) // 输出:节点3请求成功,获取数据
  .catch(err => {
    console.log('所有节点都失败:', err);
    console.log('所有失败原因:', err.errors.map(e => e.message));
  });

// 所有操作都失败的情况
Promise.any([Promise.reject('失败1'), Promise.reject('失败2')])
  .catch(err => {
    console.log(err instanceof AggregateError); // true
    console.log(err.errors); // ['失败1', '失败2']
  });

⌛ 五、Promise 经典实战场景

1. 封装 AJAX 请求(Promise 版)

传统 AJAX 基于回调,封装为 Promise 后支持链式调用和统一错误处理,是前端最基础的实战场景:

/**
 * Promise 版 AJAX 封装
 * @param {string} url - 请求地址
 * @param {string} method - 请求方法:GET/POST
 * @param {Object} data - 请求参数
 * @returns {Promise}
 */
function ajax({ url, method = 'GET', data = {} }) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    // 处理GET参数
    if (method.toUpperCase() === 'GET' && Object.keys(data).length > 0) {
      const params = new URLSearchParams(data).toString();
      url += '?' + params;
    }
    xhr.open(method, url, true);
    // 设置POST请求头
    if (method.toUpperCase() === 'POST') {
      xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
    }
    // 响应处理
    xhr.onload = function() {
      if (xhr.status >= 200 && xhr.status < 300) {
        try {
          const res = JSON.parse(xhr.responseText); // 解析JSON
          resolve(res);
        } catch (err) {
          reject(new Error('响应数据解析失败:' + err.message));
        }
      } else {
        reject(new Error(`请求失败:状态码${xhr.status}${xhr.statusText}`));
      }
    };
    // 网络错误
    xhr.onerror = function() {
      reject(new Error('网络错误:无法连接到服务器'));
    };
    // 发送请求
    if (method.toUpperCase() === 'POST') {
      xhr.send(new URLSearchParams(data).toString());
    } else {
      xhr.send();
    }
  });
}

// 使用示例
ajax({
  url: '/api/user',
  method: 'GET',
  data: { id: 1 }
})
  .then(res => console.log('请求成功:', res))
  .catch(err => console.log('请求失败:', err.message));

2. 封装定时器(Promise 版)

将 setTimeout/setInterval 封装为 Promise,支持 async/await 调用,解决定时器嵌套问题:

/**
 * Promise 版定时器(延迟执行)
 * @param {number} time - 延迟时间(毫秒)
 * @param {*} value - 延迟后返回的值
 * @returns {Promise}
 */
function delay(time, value) {
  return new Promise(resolve => {
    setTimeout(() => resolve(value), time);
  });
}

// 使用示例:async/await 调用
async function doTask() {
  console.log('开始执行任务');
  await delay(1000); // 延迟1秒
  console.log('1秒后执行下一步');
  const data = await delay(1000, '延迟2秒的返回值');
  console.log(data); // 延迟2秒的返回值
}
doTask();

3. 异步流程控制:串行/并行执行

3.1 串行执行(依次执行,后一个依赖前一个结果)

适合有顺序依赖的异步操作(如先获取token,再用token获取用户信息):

// 模拟两个有依赖的异步操作
function getToken() {
  return new Promise(resolve => setTimeout(() => resolve('user_token_123'), 1000));
}
function getUserInfo(token) {
  return new Promise(resolve => setTimeout(() => resolve({ token, name: '张三', age: 18 }), 1000));
}

// 串行执行:async/await 更简洁(基于Promise)
async function getUserData() {
  const token = await getToken(); // 先获取token
  const userInfo = await getUserInfo(token); // 再用token获取用户信息
  console.log('用户数据:', userInfo);
}
getUserData(); // 总耗时2秒
3.2 并行执行(同时执行,无依赖)

适合无依赖的异步操作,提升执行效率(如同时请求多个无关联的接口):

// 模拟3个无依赖的接口请求
function getArticleList() { return delay(1000, '文章列表'); }
function getCommentList() { return delay(1000, '评论列表'); }
function getLikeList() { return delay(1000, '点赞列表'); }

// 并行执行:Promise.all 实现,总耗时1秒(而非3秒)
async function getHomeData() {
  console.time('总耗时');
  // 同时执行,等待所有完成
  const [articles, comments, likes] = await Promise.all([
    getArticleList(),
    getCommentList(),
    getLikeList()
  ]);
  console.log('首页数据:', { articles, comments, likes });
  console.timeEnd('总耗时'); // 总耗时:约1000ms
}
getHomeData();

⚠️ 六、Promise 常见坑与避坑指南

1. 忘记写 return 导致链式调用断链

问题.then() 中未写 return,导致下一个 .then() 接收的参数是 undefined,且执行时机提前(因为返回的是默认的 Promise.resolve(undefined))。

// 错误示例
Promise.resolve(1)
  .then(num => {
    console.log(num); // 1
    // 忘记return,默认返回undefined
  })
  .then(num => console.log(num)); // undefined(立即执行)

// 正确示例
Promise.resolve(1)
  .then(num => {
    console.log(num); // 1
    return num + 1; // 必须写return
  })
  .then(num => console.log(num)); // 2

2. 错误未被捕获(控制台报 UnhandledPromiseRejection)

问题:Promise 被 reject 后,没有任何 .catch().then() 的 onRejected 处理,导致浏览器抛出未捕获错误。

// 错误示例:无错误处理
Promise.reject(new Error('未捕获的错误')); // 控制台报错:UnhandledPromiseRejection

// 正确示例:必须添加catch
Promise.reject(new Error('已捕获的错误'))
  .catch(err => console.log(err.message)); // 正常输出

3. 认为 Promise 执行器是异步的

问题:Promise 构造函数的执行器函数是同步立即执行的,内部的异步操作才是异步的,容易导致变量赋值顺序错误。

// 错误认知:认为执行器是异步的
let a = 0;
new Promise((resolve) => {
  a = 1; // 同步执行,立即赋值
  resolve();
});
console.log(a); // 1(而非0)

// 正确理解:执行器同步,内部操作异步
let b = 0;
new Promise((resolve) => {
  setTimeout(() => {
    b = 1; // 异步执行,1秒后赋值
    resolve();
  }, 1000);
});
console.log(b); // 0(立即输出)

4. Promise.all 传入非 Promise 数组

问题Promise.all 传入的数组中包含普通值,会被自动转为 Promise.resolve(普通值),看似没问题,但如果包含同步错误,会立即触发失败。

// 无害情况:普通值被转为成功的Promise
Promise.all([1, 2, Promise.resolve(3)])
  .then(res => console.log(res)); // [1,2,3]

// 危险情况:同步错误立即触发失败
Promise.all([1, 2, JSON.parse('{')]) // JSON.parse同步报错
  .then(res => console.log(res))
  .catch(err => console.log(err.message)); // Unexpected token } in JSON at position 1

避坑:确保 Promise.all 数组中的元素要么是 Promise 实例,要么是安全的普通值(无同步错误)。

5. 多次调用 resolve/reject 无效

问题:认为多次调用 resolve/reject 可以改变 Promise 状态,实则状态一旦定型,后续调用均无效。

new Promise((resolve, reject) => {
  resolve('第一次resolve');
  reject('reject'); // 无效
  resolve('第二次resolve'); // 无效
})
  .then(res => console.log(res)) // 第一次resolve
  .catch(err => console.log(err)); // 不会执行

⏫ 七、Promise 高级知识点:微任务与事件循环

Promise 的回调函数(.then()/.catch()/.finally())属于微任务(Microtask),这是 Promise 执行时机的关键,也是前端面试的高频考点。

1. 微任务与宏任务的区别

浏览器的事件循环中,任务分为宏任务(Macrotask)微任务(Microtask),执行顺序为:先执行同步代码 → 执行所有微任务 → 执行一个宏任务 → 再执行所有微任务 → 循环

  • 宏任务:setTimeout、setInterval、AJAX、DOM 事件、script 整体代码;
  • 微任务:Promise 回调、async/await、MutationObserver、queueMicrotask。

2. Promise 微任务执行示例

console.log('1. 同步代码开始'); // 同步

setTimeout(() => {
  console.log('4. 宏任务:setTimeout'); // 宏任务,最后执行
}, 0);

new Promise((resolve) => {
  console.log('2. Promise执行器:同步'); // 同步
  resolve();
}).then(() => {
  console.log('3. 微任务:Promise.then'); // 微任务,同步后执行
});

console.log('5. 同步代码结束'); // 同步

输出顺序:1→2→5→3→4(核心:微任务在宏任务前执行)。

📕 八、Promise 与 async/await 结合使用

async/await 是 ES7 引入的Promise 语法糖,它让异步代码的写法完全同步化,是目前前端异步编程的最优方案,但它的底层依然是 Promise,必须掌握 Promise 才能真正理解 async/await。

1. 核心规则

  • async 修饰的函数,返回值一定是 Promise 实例(即使返回普通值,也会被转为 Promise.resolve(普通值));
  • await 只能在 async 函数中使用,用于等待 Promise 定型,暂停函数执行,直到 Promise 返回结果;
  • await 后面可以跟任意值,若不是 Promise,会被自动转为 Promise.resolve(值);
  • 错误处理:使用 try/catch 捕获 await 后的 Promise 错误(等价于 Promise 的 .catch())。

2. 结合使用示例(最简洁的异步代码)

// 模拟异步操作
const fetchData = () => delay(1000, '获取到数据');
const fetchUser = () => delay(1000, { name: '张三' });

// async/await 写法,同步化流程
async function main() {
  try {
    console.log('开始执行');
    const data = await fetchData(); // 等待第一个异步操作
    console.log(data); // 获取到数据
    const user = await fetchUser(); // 等待第二个异步操作
    console.log(user); // { name: '张三' }
    console.log('所有操作完成');
  } catch (err) {
    console.log('错误:', err.message); // 捕获所有异步错误
  } finally {
    console.log('收尾操作'); // 无论成功/失败都执行
  }
}

main();

📌 九、总结

  1. Promise 是 ES6 核心的异步编程解决方案,解决了回调地狱问题,提供了统一的异步接口和集中的错误处理机制;
  2. Promise 有三种不可逆状态:pending、fulfilled、rejected,仅能通过 resolve/reject 完成两次状态转换,状态定型后不可修改;
  3. 实例方法 .then()/.catch()/.finally() 支持链式调用,核心原因是每个方法都返回新的 Promise 实例.catch() 统一捕获错误,.finally() 处理通用收尾;
  4. 构造函数静态方法是异步流程控制的核心:all(所有成功)、race(第一个完成)、allSettled(所有完成)、any(第一个成功)、resolve/reject(快速创建);
  5. Promise 回调属于微任务,执行顺序在同步代码后、宏任务前,这是理解 Promise 执行时机的关键;
  6. async/await 是 Promise 的语法糖,让异步代码同步化,是目前最优的异步写法,但底层依赖 Promise;
  7. 开发中避免常见坑:忘记 return 断链、未捕获错误、混淆执行器同步特性、多次调用 resolve/reject;
  8. Promise 是现代前端开发的必备技能,是 Vue、React 等框架异步操作、接口请求、状态管理的基础,也是前端面试的高频考点。

掌握 Promise 不仅能写出更优雅、更易维护的异步代码,更能理解现代前端异步编程的底层逻辑,为后续学习 async/await、事件循环、前端工程化打下坚实的基础。

react18+那些不常用hooks

前言

react18+那些不常用的hooks汇总

useActionState

官方描述:useActionState 是一个可以根据某个表单动作的结果更新 state 的 Hook。 使用场景:如果需要提交表单,尤其是使用原生表单功能,你需要提交表单后仍在当前页面,同时记录下提交的信息,那么这个hooks会很有帮助。

function MyComponent() {  
const [state, formAction, isPending] = useActionState(fn, initialState, permalink);
// formAction 用于与form绑定
// state 提交后的状态记录
// isPending 可以在处理 action 的过程中使用它  
return (  
<form action={formAction}>  
{/* ... */}  
</form>  
);  
}

useFormStatus

官方描述:useFormStatus 是一个提供上次表单提交状态信息的 Hook。 使用场景:useFormStatus 通常会和useActionState同时使用。

  • useActionState 告诉页面:“上次提交返回了什么?” → 决定是否显示“密码错误”
  • useFormStatus 告诉按钮:“现在是不是在提交?” → 决定按钮显示“登录中...“
function Submit() {  
const status = useFormStatus();  //可以看到不需要传递任何参数
return <button disabled={status.pending}>提交</button>  

} 

export default function App() {  
return (  
<form action={action}>  
<Submit />  
</form>  
);  
}

useSyncExternalStore

官方描述:useSyncExternalStore 是一个让你订阅外部 store 的 React Hook。 它的作用是连接外部库之后,外部库的状态改变,能知道他们被更新了。这个hooks我认为在基础开发的过程并没有必要,但如果想自己动手造一个类似redux的库,那么这个特性可以加入到你的库中。在最新的redux Tooltik 或者zustand中都是在库的内部封装好的。 这里举一个其他例子

监听窗口尺寸(响应式布局)

// 自定义 Hook:获取窗口宽高 
function useWindowSize() { 
return useSyncExternalStore((callback) => { 
window.addEventListener('resize', callback); 
return () => 
window.removeEventListener('resize', callback); }, () => ({ 
width: window.innerWidth, height: window.innerHeight 
}), () => ({ 
width: 1200, height: 800 }) // SSR 默认值 
)}
// 使用 
function App() { 
const { width } = useWindowSize(); 
return <div>当前宽度: {width}px</div>; 
}

订阅 localStorage(跨标签同步)

// 封装 localStorage 订阅
const localStorageStore = {
  getSnapshot: () => localStorage.getItem('theme') || 'light',
  subscribe: (callback) => {
    const handleStorage = (e) => {
      if (e.key === 'theme') callback();
    };
    window.addEventListener('storage', handleStorage);
    return () => window.removeEventListener('storage', handleStorage);
  }
};

function useTheme() {
  return useSyncExternalStore(
    localStorageStore.subscribe,
    localStorageStore.getSnapshot
  );
}

// 改变主题(其他标签页会自动更新)
function setTheme(theme) {
  localStorage.setItem('theme', theme);
  // 触发 storage 事件(同域下其他标签页会收到)
  window.dispatchEvent(new StorageEvent('storage', { key: 'theme' }));
}

useDebugValue

官方描述:useDebugValue 是一个 React Hook,可以让你在 React 开发工具中为自定义 Hook 添加标签。 hooks特性:解决开发者排查问题的痛点

  1. 不会污染控制台
  2. 不会和其他console.log混在一起
  3. react dev tools 中展示
  4. 和组件树、propsstate 同屏查看 也就是说它能更好的排查是哪个hooks出现的问题。最好的方式是useDebugValueconsole.logdebugger协同使用
import { useDebugValue } from 'react';  

function useOnlineStatus() {  
// ...  
useDebugValue(isOnline ? 'Online' : 'Offline');  
// ...  
}

useEffectEvent

官方描述:useEffectEvent 是一个 React Hook,它可以让你将 Effect 中的非响应式逻辑提取到一个可复用的函数中,这个函数称为 Effect Event。

import { useEffect, useContext, useEffectEvent } from 'react';  

function Page({ url }) {  
const { items } = useContext(ShoppingCartContext);  
const numberOfItems = items.length;  
const onNavigate = useEffectEvent((visitedUrl) => {  
logVisit(visitedUrl, numberOfItems);  
});  
  
useEffect(() => {  
onNavigate(url);  
}, [url]);  
// ...  
}

useEffect中调用一个函数的时候,这个函数有可能不是最新的,这个时候就要使用到了。这是一个看起来很奇怪的工具,因为大多数时候我们是用不上的。 场景 1:函数只依赖 props,且 props 变化时 effect 本就应该重跑

function UserProfile({ userId }) {
  useEffect(() => {
    fetchUser(userId); //  直接用 userId,把 userId 放进依赖
  }, [userId]); 
}

场景 2:函数是纯工具函数,不读任何组件状态

const formatDate = (date) => date.toISOString();

useEffect(() => {
  console.log(formatDate(new Date())); //无副作用、无闭包问题
}, []);

场景 3:函数通过参数传入最新值

useEffect(() => {
  const timer = setInterval(() => {
    checkStatus(roomId); // roomId 作为参数传入,没问题
  }, 5000);
  return () => clearInterval(timer);
}, [roomId]); 

真正需要 useEffectEvent 的典型场景(极少数)

  • 监听全局事件(如 window.addEventListener),回调中要读最新 state
  • 启动长期运行的定时器(如心跳、轮询),且需要访问最新数据
  • 与第三方库集成,其回调无法控制调用时机

useInsertionEffect

官方描述:useInsertionEffect 可以在布局副作用触发之前将元素插入到 DOM 中。useInsertionEffect 是为 [[CSS-in-JS]] 库的作者特意打造的。除非你正在使用 CSS-in-JS 库并且需要注入样式,否则你应该使用 useEffect 或者 useLayoutEffect。 在 React 18 之前,有些 CSS-in-JS 库(比如早期的 styled-components)会在 组件渲染时 或 useLayoutEffect 中动态插入 <style> 标签。 但这会导致性能问题:

  • 如果在 渲染期间 插入:每帧都可能触发浏览器重新计算样式(非常慢)
  • 如果在 useLayoutEffect 插入:虽然在 DOM 更新后,但在 layout 计算前,可能让布局读取到“还没生效”的样式,导致错误

于是 React 团队加了一个更早的时机在 DOM 更新之后、layout 计算之前,专门给 CSS-in-JS 注入样式用。所以普通业务无需关注。

useImperativeHandle

官方描述:useImperativeHandle 是 React 中的一个 Hook,它能让你自定义由 ref暴露出来的句柄。 简单的说就是子组件提供一个对外开放的API可以供父组件操作,他解决了不想暴露整个DOM节点而是只暴露其中几个特定方法。  举个经典例子:自定义输入框

//  默认情况下,父组件拿到的是整个 <input> DOM 节点
function MyInput(props) {
  return <input {...props} />;
}

// 父组件
const inputRef = useRef();
<input ref={inputRef} /> // inputRef.current 就是原生 input 元素

但你可能不想让父组件直接操作 DOM(比如改样式、读 value),而只允许它调用 focus() 或 clear()。 这时就用 useImperativeHandle

import { forwardRef, useImperativeHandle, useRef } from 'react'; 
const MyInput = forwardRef((props, ref) => { 
const inputRef = useRef(null); 
//  关键:自定义 ref 暴露的内容 
useImperativeHandle(ref, () => ({ 
focus: () => { 
inputRef.current.focus(); 
}, 
clear: () => { inputRef.current.value = ''; } 
// 不暴露 inputRef.current 本身! 
})); 
return <input ref={inputRef} {...props} />; });

父组件使用:

const myInputRef = useRef(); // 只能调用你允许的方法 
myInputRef.current.focus(); // 可以 
myInputRef.current.clear(); // 可以 
myInputRef.current.style.color = 'red'; // 报错!没有 style 属性

这并不是一个万能的方式,其实它很容易被props所替代,但是如果正在做一个极其复杂的组件,那么可以尝试使用这个hooks。

useOptimistic

官方描述:useOptimistic 是一个 React Hook,它可以帮助你更乐观地更新用户界面。 官方说了和没说一样,但用人话说就是虽然还没到可以更新的时候,我先更新了,可以增加用户体验。这种做法让界面响应速度与网络延迟实现了脱钩。最典型的例子就是“点赞”按钮。 举个例子,我们现在有一个开关,每次改变开关都会调一次接口,我们根据接口的返回值判断是否更新页面渲染。但是这个时候大概率会一卡一卡的,尤其是接口没返回就点击下一次,那么最终的状态可能和我们理想的状态不一致。 传统的做法是我们再维护一个状态为pending,没有结束的时候不能点击。此时如果业务逻辑已经很复杂了,添加的状态只会让代码更加复杂。 useOptimistic做的事情是先改变状态,再请求到结果,如果调用失败或者返回失败那么再变回去。比如说点赞红心。 虽然useOptimistic正在被react逐渐推向一等公民的身份,但是仍然有问题,它更适合做一些简单可回滚的操作,比如说点赞,并没有很广的适用性。

useDeferredValue

官方描述:useDeferredValue 是一个 React Hook,可以让你延迟更新 UI 的某些部分。 这是一个面试常考的hooks,作为性能优化的一种方式。 举个例子,当你在输入框中进行输入,下面的列表就跟着渲染,此时再react的渲染机制中他们的权重是一致的,一旦表单发生改变,输入框与大列表同时渲染那么就会卡卡的。useDeferredValue做的事就是让列表的优先级放低,此时优先输入框渲染。 但是如果是数据量没有那么大的情况下使用useDeferredValue还会反向影响。

import { useState, useDeferredValue } from 'react';  

function SearchPage() {  
const [query, setQuery] = useState('');  
const deferredQuery = useDeferredValue(query);  
// ...  
}

useTransition

官方描述:useTransition 是一个让你可以在后台渲染部分 UI 的 React Hook。 这个同样是作为优化的新特性,面试会考。useTransition 的核心原理基于 React 的并发模式(Concurrent Mode)。在并发模式下,React 可以暂停、中止或重新启动渲染任务,根据任务的优先级灵活调度。useTransition 会将更新任务标记为低优先级,使得高优先级的用户交互事件能够优先得到处理,避免界面出现假死或卡顿现象。

import {useState, useTransition} from 'react';  

function CheckoutForm() {  
const [isPending, startTransition] = useTransition();  
// ……  
}
  • startTransition:用于包裹非紧急状态更新的函数
  • isPending:布尔值,表示过渡是否进行中
场景 推荐 Hook
用户输入后需要更新大量列表(如搜索) 两者都可用,但 useDeferredValue 更简洁
多个状态更新中,只想延迟其中一部分 useTransition
不想改动状态更新逻辑,只延迟渲染结果 useDeferredValue
需要显示“加载中”状态 useTransition(自带 isPending

useTransition 控制“更新是否紧急”,useDeferredValue 控制“值是否立即渲染”。

总结

这些hooks可以分为四类

类型 hooks
处理表单类 useActionState、useFormStatus
hooks衍生类 useEffectEvent、useInsertionEffect、useImperativeHandle
提升用户体验类 useOptimistic、useDeferredValue、useTransition
协助开发类 useSyncExternalStore、useDebugValue

总体而言这些hooks都是为了解决特殊场景的特殊方案,不具有普适性,当项目应用范围小,数据量低等场景,基础的hooks就能满足需求。之后我会汇总react性能优化方案,将useOptimisticuseDeferredValueuseTransition详细介绍。对一些衍生类的hooks进行拆解,解析其中的原理。

解读多重身份验证(MFA)绕过码:风险与最佳实践

多因素认证(MFA)被广泛视为身份保护的黄金标准。然而,尽管其应用已十分普遍,攻击者仍在不断寻找MFA部署中的漏洞。其中一个常被误解的攻击向量便是MFA绕过码——这是一种一次性的替代认证方式,允许用户在特定、可控的情况下,无需提供主要第二验证因素即可完成登录流程。

本文将深入探讨MFA绕过码的定义、其引入的安全风险、攻击者利用绕过码的常见手段,以及最重要的——企业如何缓解这些威胁。同时,我们还将分析ADSelfService Plus等工具如何帮助企业在维持强效MFA防护的同时,安全管理必要的备用验证机制。

一、什么是MFA绕过码?

MFA绕过码是一种替代认证令牌,允许用户(在某些情况下也包括管理员)在特定场景下绕过标准的MFA验证。典型的合法使用场景包括:

用户丢失、更换验证设备,或无法获取短信/邮件验证码;
当用户的主要MFA验证方式不可用时,管理员发放临时绕过码;
部分系统允许可信设备或可信网络在可控条件下绕过MFA,例如“记住我”功能或短期会话豁免机制。
若绕过码部署得当——具备短期有效性、一次性使用属性、可审计性,且仅在经过严格身份验证后发放,就能在不削弱MFA安全优势的前提下,为企业运营提供灵活性。反之,若管理松散、配置不当或过度使用,则会引入显著安全风险。

二、为何绕过码成为攻击目标?攻击者如何利用?

绕过码在保障业务连续性方面发挥着重要作用,但也催生了攻击者可能利用的替代认证路径,尤其是当这些机制缺乏严格管控时。以下将解析为何绕过码在当前威胁环境中备受关注,以及攻击者的常见滥用手段。

人为因素与社会工程攻击
攻击者往往将目标聚焦于人而非系统。他们可能冒充IT人员或终端用户向服务台索要绕过码,或诱骗用户泄露自身的绕过码。尽管MFA疲劳攻击不会直接生成绕过码,但会向用户施压,迫使其批准欺诈性MFA验证请求,进而为攻击者利用恢复流程或可信设备逻辑创造可乘之机。

配置不当与遗留认证路径
薄弱或过时的配置可能无意中产生MFA绕过条件,例如:

条件访问规则自动信任特定位置或设备,降低MFA验证要求;
邮局协议(POP)、互联网邮件访问协议(IMAP)等遗留协议完全无法强制执行MFA验证。
若这些遗留路径未被禁用,实际上就等同于MFA绕过机制,极易成为攻击者规避MFA验证的目标。

脆弱的MFA恢复流程
重置验证应用、更换可信设备等恢复流程,其安全性往往低于MFA验证本身。薄弱的身份验证、过时的服务台流程或不安全的恢复渠道,都可能让攻击者无需获取用户设备或验证码就能绕过MFA。

绕过码生命周期管理不善
若绕过码不具备一次性使用属性、有效期过长、发放渠道不安全,或缺乏完整日志记录,就更容易被拦截、复用或未授权发放。而审计不足则会进一步降低对绕过码相关可疑活动的可见性。

管理员账户泄露或权限过度
管理员通常拥有生成绕过码的权限,这使其账户成为高价值攻击目标。一旦攻击者攻陷管理员账户,或管理员持有不必要的过高权限,就能绕开MFA验证直接发放绕过码,进而获取受保护系统的访问权限。

绕过码本身并非天生不安全,风险核心在于管理薄弱。若能将其严格管控为短期有效、一次性使用、可审计,且与强效身份验证绑定,就能在不削弱MFA防御价值的前提下,提升系统韧性。

三、风险缓解:MFA绕过码管理最佳实践

为保护企业免受MFA绕过机制相关威胁,建议部署以下最佳实践:

为所有账户强制执行MFA验证,尤其是特权账户和服务账户。除非有充分合理的理由,否则避免选择性豁免,减少对MFA绕过码的不必要依赖;
定期审查条件访问规则、会话设置和可信设备策略,减少可信设备与可信位置的绕过场景,确保不存在可作为“隐性绕过路径”的意外或隐藏MFA豁免规则;
严格管控绕过码:

监控异常MFA行为,例如重复的MFA验证请求、多次申请绕过码、来自陌生或高风险设备/位置的登录尝试;
开展用户安全教育,强调绕过码属于敏感凭证,严禁共享、通过邮件传输或存储在不安全位置。

四、ADSelfService Plus如何强化MFA防护并管控绕过风险?

ADSelfService Plus提供强大且灵活的MFA框架,旨在降低绕过风险,为各类企业环境提供全面的强效认证保障。

1. 绕过码可视化与审计报告

ADSelfService Plus具备全面的审计日志和报告功能,让管理员能全程掌握MFA活动及绕过码使用情况。其中,MFA使用审计报告可追踪每一次认证尝试,包括认证成败、时间戳、终端设备和IP地址等信息,助力快速识别异常行为。

专属的备用码使用报告(ADSelfService Plus中,备用码即受控MFA绕过码)会记录所有备用码的生成与使用实例,捕获用户信息、关联策略、终端设备、IP地址以及操作发起方(用户或管理员)等细节。结合导出功能和定时推送机制,企业能对备用认证流程实现强效监管,减少对不安全恢复方式的依赖,严格管控绕过码,在保障可用性的同时维持稳健的身份安全。

image.png

2. 全面的终端与访问覆盖

ADSelfService Plus支持在Windows、macOS、Linux设备上部署MFA,覆盖域加入账户和本地账户;同时保障远程访问路径安全,包括RDP、VPN(通过RADIUS协议)和Outlook Web Access,确保所有终端均能实现一致的MFA验证。值得注意的是,该解决方案为Windows和macOS提供离线MFA功能,即便设备脱离网络,也能实现安全登录。

3. 自适应MFA

管理员可基于IP地址、设备类型、访问时间和地理位置定义条件访问策略。ADSelfService Plus会根据这些条件动态调整MFA验证的级别或类型,在平衡安全性与可用性的同时,避免过度使用备用或绕过机制。

4. 丰富的认证因素选择

ADSelfService Plus支持20种认证方式,包括生物识别(指纹识别、人脸识别)、YubiKey等FIDO2密钥、基于TOTP的验证应用以及短信/邮件一次性验证码。通过支持安全密钥和设备密钥,企业可部署抗钓鱼的无密码认证方案。这些强效认证因素能减少对绕过码等备用机制的需求,显著提升整体身份安全水平。

image.png

5. 精细化策略控制

管理员可基于组织单元、安全组或域结构制定精细化MFA策略,确保敏感部门或特权用户采用更严格的认证要求,降低MFA被绕过或过度使用绕过码的风险。强制注册规则能确保用户及时完成验证工具注册,而策略级限制可让管理员管控用户可使用的认证因素类型。结合条件访问规则,企业可根据风险等级或访问场景执行差异化的MFA流程。

MFA虽不可或缺,但并非无懈可击。绕过码、可信设备豁免、遗留协议以及社会工程攻击等,均会增加MFA被绕过的风险,因此对备用机制的严格管控至关重要。通过执行严格的绕过码管控、监控绕过码使用模式、部署抗钓鱼认证方案,企业可大幅降低绕过码滥用带来的风险。ADSelfService Plus等解决方案通过强化备用认证路径、提供全面的威胁可见性,助力企业抵御新兴威胁。在身份成为新安全边界的当下,同时保障主MFA流程与备用机制的安全,是实现真正安全韧性的核心要求。

<<github 加星 Taimili.com 艾米莉 >> 前端面试题-JavaScript高级篇

一、 V8引擎工作原理与垃圾回收 (GC)

理解JavaScript的执行环境是高级优化的前提。

V8引擎核心流程

Taimili 艾米莉 ( 一款专业的 GitHub star 管理和github 加星涨星工具taimili.com )

艾米莉 是一款优雅便捷的 GitHub star 管理和github 加星 涨星工具,基于 PHP & javascript 构建, 能对github star fork follow watch 刷星管理和提升,最适合github 的深度用户

WX20251021-210346@2x.png

  1. 解析 (Parsing) : V8将JavaScript源代码解析成抽象语法树 (AST)
  2. 解释 (Interpretation) : Ignition (V8的解释器) 将AST转换成字节码并执行。同时,Ignition会收集分析信息,用于后续的优化。
  3. 编译 (Compilation) : 对于被频繁执行的代码(热点代码),TurboFan (V8的优化编译器) 会介入,利用分析信息将字节码编译成高度优化的机器码,以提升执行效率。这个过程被称为JIT (Just-In-Time) 编译。如果优化的假设失败(如函数参数类型改变),会进行去优化 (Deoptimization) ,回退到字节码执行。

垃圾回收 (Garbage Collection)

V8采用分代回收 (Generational Collection)的策略,将堆内存分为新生代 (New Generation)老生代 (Old Generation)

新生代 (Scavenger算法)

  • 空间小,存活对象少。采用Scavenger算法,将空间一分为二(From-Space 和 To-Space)。
  • 回收时,将From-Space中的存活对象复制到To-Space,然后清空From-Space。最后,From-Space和To-Space角色互换。
  • 对象若经历多轮回收仍存活,则被**晋升 (Promotion)**到老生代。

老生代

  • 空间大,存活对象多。采用标记-清除算法。
  • 标记阶段: 从根对象(如全局对象)开始,遍历所有可达对象并打上标记。
  • 清除阶段: 清除非标记对象所占用的内存。
  • 整理阶段 : 为解决内存碎片化问题,在清除后,会将所有存活对象向一端移动,形成连续的内存空间。

代码示例 (导致内存泄漏的场景):

高级开发者需要能够识别并解释内存泄漏。闭包引用了已分离的DOM节点是典型案例。

js
 体验AI代码助手
 代码解读
复制代码
function createLeakingElement() {
  const container = document.getElementById('container');
  const detachedElement = document.createElement('div');
  detachedElement.textContent = 'This is a potentially leaking element.';
  container.appendChild(detachedElement);

  // 关键:一个外部可访问的函数,通过闭包持有了对 detachedElement 的引用
  const leakingClosure = function() {
    // 即使 detachedElement 从DOM树中移除,只要 leakingClosure 存在,
    // detachedElement 就不会被GC回收。
    console.log(detachedElement.textContent);
  };

  // 从DOM中移除元素
  container.removeChild(detachedElement);

  // 返回这个闭包
  return leakingClosure;
}

// globalLeaker 现在持有了对 detachedElement 的间接引用
// 即使它在DOM中已不可见,它依然存在于内存中
window.globalLeaker = createLeakingElement();

// 只要 window.globalLeaker 不被设为 null,这块内存就永远无法被回收

二、 事件循环 (Event Loop)

高级面试会深入到Node.js环境,考察对Event Loop各阶段的理解。

  • 浏览器 vs. Node.js: 两者模型相似,但Node.js的事件循环有更明确的阶段划分。

  • Node.js 事件循环的六个阶段:

    1. timers: 执行 setTimeout()setInterval() 的回调。
    2. pending callbacks: 执行上一轮循环中延迟到本轮执行的I/O回调。
    3. idle, prepare: 仅内部使用。
    4. poll: 核心阶段。检索新的I/O事件;执行与I/O相关的回调。如果队列不为空,会遍历执行;如果为空,会在此阻塞等待,直到有新的I/O事件或到达 timers 设定的阈值。
    5. check: 执行 setImmediate() 的回调。
    6. close callbacks: 执行如 socket.on('close', ...) 的回调。
  • process.nextTick() 与微任务 (Micro-task) :

    • process.nextTick() 有自己独立的队列,其优先级高于所有微任务。
    • 在一个阶段执行完毕后,事件循环会立即清空 nextTick 队列,然后才清空微任务队列,之后才进入下一个阶段。

代码示例 (Node.js环境下):

js
 体验AI代码助手
 代码解读
复制代码
const fs = require('fs');

console.log('1. Script Start');

// Timers 阶段
setTimeout(() => {
  console.log('7. setTimeout');
}, 0);

// Check 阶段
setImmediate(() => {
  console.log('8. setImmediate');
});

// Micro-task
Promise.resolve().then(() => {
  console.log('5. Promise.then');
});

// process.nextTick 队列 (最高优先级)
process.nextTick(() => {
  console.log('4. process.nextTick');
});

// I/O 操作,其回调将在 Poll 阶段执行
fs.readFile(__filename, () => {
  console.log('6. I/O (readFile) callback');

  // I/O回调内部的调度
  setTimeout(() => console.log('11. I/O -> setTimeout'), 0);
  setImmediate(() => console.log('9. I/O -> setImmediate'));
  process.nextTick(() => console.log('10. I/O -> nextTick'));
});

console.log('2. Script End');
console.log('3. Poll phase may start here...');

// 理论输出顺序:
// 1. Script Start
// 2. Script End
// 3. Poll phase may start here...
// 4. process.nextTick
// 5. Promise.then
// 6. I/O (readFile) callback
// 10. I/O -> nextTick
// 9. I/O -> setImmediate
// 7. setTimeout
// 8. setImmediate
// 11. I/O -> setTimeout
// (注意:9, 7, 8, 11 的确切顺序可能因I/O耗时和系统调度而有细微变化,但基本规律如此)

三、 高级性能优化

Tree Shaking (摇树优化)

原理

  • 依赖ES Modules (import/export) 的静态结构,在编译时分析代码,移除未被实际引用的“死代码”(dead-code)。

实践

  • Webpack, Rollup等现代打包工具在生产模式下默认开启。开发者需保证代码遵循ESM规范,并避免有副作用的模块导入。

Code Splitting (代码分割)

目的

  • 将巨大的单体bundle分割成多个小块(chunks),按需加载,以减小首屏加载体积,提升用户体验。

策略

  1. 按路由分割: 每个页面或路由对应一个chunk。
  2. 按组件分割: 对于非首屏、或需要交互才出现的大型组件(如弹窗、图表)进行懒加载。
  3. 公共库分离 (Vendor Splitting) : 将不常变动的第三方库(如React, Lodash)打包成独立的vendor chunk,利用浏览器缓存。

利用浏览器渲染路径

  • 关键渲染路径 : 优化CSS加载(内联关键CSS)、减少阻塞渲染的脚本、使用 async/defer
  • 硬件加速: 尽量使用 transformopacity 属性进行动画,它们能被提升到单独的合成层(Compositor Layer),由GPU处理,避免触发重排(Reflow)和重绘(Repaint)。

代码示例 (React中的代码分割)

jsx
 体验AI代码助手
 代码解读
复制代码
import React, { Suspense, lazy } from 'react';

// 使用 React.lazy 和动态 import() 来实现组件的懒加载
const HeavyComponent = lazy(() => import('./components/HeavyComponent'));
const AnotherLazyComponent = lazy(() => import('./components/AnotherLazyComponent'));

function App() {
  const [showHeavy, setShowHeavy] = React.useState(false);

  return (
    <div>
      <h1>My App</h1>
      <button onClick={() => setShowHeavy(true)}>Load Heavy Component</button>

      {/* 
        Suspense 组件用于在懒加载组件下载和解析期间,显示一个fallback UI。
        只有当 showHeavy 为 true 时,浏览器才会去请求 HeavyComponent.js。
      */}
      <Suspense fallback={<div>Loading...</div>}>
        {showHeavy && <HeavyComponent />}
        
        {/* 假设这是另一个需要懒加载的组件 */}
        {/* <AnotherLazyComponent /> */}
      </Suspense>
    </div>
  );
}

四、 内存管理与诊断

内存泄漏的常见原因

  1. 意外的全局变量: 未经声明的变量被赋值,成为全局对象的属性。
  2. 遗忘的定时器或回调: setInterval 未被清除,其回调函数及其闭包环境无法被回收。
  3. 分离的DOM节点引用: 如第一节的代码示例。
  4. 闭包的滥用: 闭包会使其外部函数的作用域持续存在,如果作用域中包含大量数据,则可能造成内存占用过高。

诊断工具 (Chrome DevTools)

  • Performance Monitor: 实时监控CPU使用率、JS堆大小、DOM节点数等。

  • Memory Tab:

    • Heap Snapshot (堆快照) : 拍摄堆内存的快照,用于分析对象分布、查找分离的DOM树、定位内存泄漏。
    • Allocation Instrumentation on Timeline: 记录内存分配的时间线,用于定位是哪个函数或操作导致了频繁的内存分配或内存激增。

代码示例 (遗忘的定时器):

js
 体验AI代码助手
 代码解读
复制代码
class PulsingDot {
  constructor() {
    this.size = 0;
    this.isGrowing = true;

    // 定时器通过闭包持有了对 this (PulsingDot实例) 的引用
    this.intervalId = setInterval(() => {
      if (this.isGrowing) {
        this.size += 1;
        if (this.size >= 10) this.isGrowing = false;
      } else {
        this.size -= 1;
        if (this.size <= 0) this.isGrowing = true;
      }
    }, 100);
  }

  // 必须提供一个销毁方法来清除定时器
  destroy() {
    clearInterval(this.intervalId);
    console.log('PulsingDot destroyed and interval cleared.');
  }
}

let dot = new PulsingDot();

// 假设在某个时间点,我们不再需要这个 dot 实例
dot = null;

// 问题:虽然 dot 变量被设为 null,但 PulsingDot 实例无法被回收,
// 因为 setInterval 的回调函数仍然持有对它的引用,定时器还在不停地运行。
// 正确做法:在销毁对象前,调用 dot.destroy()。

五、 软件设计模式

高级开发者应能将设计模式思想融入日常编码,以构建可维护、可扩展的系统。

  • 单例模式 (Singleton) : 确保一个类只有一个实例,并提供一个全局访问点。
  • 观察者模式 (Observer / Pub/Sub) : 定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知并自动更新。
  • 工厂模式 (Factory) : 定义一个用于创建对象的接口,让子类决定实例化哪一个类。
  • 装饰器模式 (Decorator) : 动态地给一个对象添加一些额外的职责。
  • 代理模式 (Proxy) : 为其他对象提供一种代理以控制对这个对象的访问。

代码示例 (观察者模式/发布-订阅):

js
 体验AI代码助手
 代码解读
复制代码
class EventBus {
  constructor() {
    this.listeners = {};
  }

  // 订阅
  on(eventName, callback) {
    if (!this.listeners[eventName]) {
      this.listeners[eventName] = [];
    }
    this.listeners[eventName].push(callback);
  }

  // 取消订阅
  off(eventName, callback) {
    if (!this.listeners[eventName]) return;
    this.listeners[eventName] = this.listeners[eventName].filter(
      listener => listener !== callback
    );
  }

  // 发布
  emit(eventName, ...args) {
    if (!this.listeners[eventName]) return;
    this.listeners[eventName].forEach(listener => {
      try {
        listener(...args);
      } catch (e) {
        console.error(`Error in listener for event "${eventName}":`, e);
      }
    });
  }
}

// --- 使用场景 ---
const bus = new EventBus();

function onUserLogin(userData) {
  console.log('Analytics Service: User logged in', userData.name);
}

function updateNavbar(userData) {
  console.log('UI Service: Updating navbar for', userData.name);
}

bus.on('user:login', onUserLogin);
bus.on('user:login', updateNavbar);

// 某处登录成功后...
bus.emit('user:login', { id: 1, name: 'Mickey' });

// 用户退出时,可以取消订阅
// bus.off('user:login', onUserLogin);

基于 AI 生成高质量 Mock 数据的实践

在前后端分离的研发模式下,Mock 数据是并行开发的基础设施。本文介绍一套 Mock 工具的技术实现,核心解决四个问题:请求拦截、规则匹配、数据质量、团队共享。

背景

在实际业务开发中,前端和测试同学经常遇到这些问题:

  • 联调阻塞:后端接口未就绪时,前端只能等待或硬编码 if (true) { ... }
  • 场景覆盖难:想模拟边缘场景(异常、空数据、分页),往往需要后端改数据库
  • Mock 数据质量差:Mock.js 生成的 @cname@integer 缺乏业务语义,和真实数据差距大
  • 数据难共享:Mock 数据存在本地,换台电脑或换个同事就用不了

我们需要一套工具解决这些问题:

  • 不侵入业务代码,npm 包引入即可
  • 支持参数级别的规则匹配,同一接口可配置多个场景
  • 根据接口文档自动生成符合业务语义的数据
  • 支持团队共享 Mock 配置

业界常见方案

方案 优点 不足
硬编码 Mock 简单直接 侵入业务代码,发布前需手动删除
Mock.js 有数据生成能力 规则配置繁琐,数据缺乏业务语义
whistle 代理 不侵入业务,使用简单 数据保存在本地,无法团队共享,无法匹配复杂场景
MSW (Service Worker) 网络层拦截 需注册 sw.js,依赖 HTTPS,接入成本高
浏览器插件 使用简单 无侵入式 规则在本地,无法接入内部接口平台

这些方案都无法同时满足"低侵入"、"规则灵活"、"数据质量高"、"团队可共享"四个需求。


整体架构

┌─────────────────────────────────────────────────────────────┐
│                        业务项目                              │
│   import { mockInit } from '@zz-common/ai_mock'             │
│   mockInit({ rules: ['api.example.com'] })                  │
└────────────────────────────┬────────────────────────────────┘
                             │
┌────────────────────────────▼────────────────────────────────┐
│                    ai_mock (npm 包)                         │
│      XHR/Fetch 拦截  →  规则匹配引擎  →  返回 Mock 数据         │
└────────────────────────────┬────────────────────────────────┘
                             │ 动态加载 sdk + CustomEvent 通信
┌────────────────────────────▼────────────────────────────────┐
│                  mock-sdk (可视化面板)                        │
│        请求列表  |  规则管理  |  Monaco Editor 编辑器           │
└────────────────────────────┬────────────────────────────────┘
                             │ HTTP
┌────────────────────────────▼────────────────────────────────┐
│                   node (后端服务)                            │
│         接口文档获取  →  AI 生成  →  数据持久化                 │
└─────────────────────────────────────────────────────────────┘

核心设计:

  • ai_mock 只负责拦截和匹配,不包含 UI 代码
  • mock-sdk 通过 CDN 动态注入,不增加业务包体积
  • 两者通过 CustomEvent 松耦合通信
  • 仅在非生产环境启用

核心实现一:请求拦截

问题

现代前端应用混合使用 XMLHttpRequestfetch。拦截时需要解决:

  1. 同时拦截 XHR 和 Fetch,且不破坏第三方库(如 Sentry)的监听逻辑
  2. XHR 的 readyStatestatusresponseText 是只读属性,无法直接赋值
  3. 拦截后发送真实请求会再次进入拦截逻辑,造成无限递归

实现

通过重写 XMLHttpRequest.prototype.sendwindow.fetch 实现拦截:

// 保存原始方法
const xhrSendNative = XMLHttpRequest.prototype.send
const originalFetch = window.fetch

// 防递归:标记真实请求
const isRealRequest = new WeakMap<XMLHttpRequest, boolean>()

XMLHttpRequest.prototype.send = function(...args) {
  const xhr = this
  const url = sliceUrlPath(xhr.originRequestUrl)
  
  // 检查是否为标记的真实请求,防止无限递归
  if (isRealRequest.get(xhr)) {
    return xhrSendNative.apply(xhr, args)
  }
  
  // 检查是否命中 Mock 规则
  if (mockInterface[url]?.isOpen) {
    const mockResult = getMockData(url, requestData)
    
    if (mockResult.matched) {
      // 1. 立即返回 Mock 响应给业务层
      applyMockResponseToXhr(xhr, mockResult.data, mockResult.httpStatusCode)
      
      // 2. 后台发送真实请求(用于数据对照,不触发业务回调)
      const realXhr = cloneXHR(xhr, false)  // 不复制事件监听器
      isRealRequest.set(realXhr, true)       // 标记,防止递归
      xhrSendNative.apply(realXhr, args)
      return
    }
  }
  
  // 未命中:执行原生请求
  xhrSendNative.apply(this, args)
}

覆写只读属性:XHR 的 readyStatestatus 等属性是只读的,通过 Object.defineProperties 解决:

const applyMockResponseToXhr = (
  xhr: XMLHttpRequest,
  responseData: any,
  statusCode: number
) => {
  const responseText = typeof responseData === 'string' 
    ? responseData 
    : JSON.stringify(responseData)
  
  // 通过 defineProperties 覆写只读属性
  Object.defineProperties(xhr, {
    readyState: { get: () => 4, configurable: true },
    status: { get: () => statusCode, configurable: true },
    response: { get: () => responseData, configurable: true },
    responseText: { get: () => responseText, configurable: true }
  })
  
  // 触发标准事件序列
  xhr.dispatchEvent(new Event('readystatechange'))
  xhr.dispatchEvent(new Event('load'))
  xhr.dispatchEvent(new Event('loadend'))
}

立即响应 + 真实请求:开启 Mock 后,业务层立即拿到 Mock 数据,同时后台会发送一份真实请求用于数据对照。面板提供「加载真实数据」按钮,可以一键将真实响应填入编辑器,方便基于真实数据微调。


核心实现二:规则匹配引擎

问题

实际业务中,同一个接口需要根据不同参数返回不同数据:

  • page=1 返回第一页,page=2 返回第二页
  • userId=vip 返回 VIP 数据,userId=normal 返回普通数据
  • header 中带某个标识时返回调试数据

参数可能在 URL Query、Request Body、Header、Cookie、Path 中,需要一套灵活的规则匹配机制。

规则数据结构

interface MockRule {
  id: string;
  url: string;
  name: string;
  priority: number;  // 优先级,数字越小越优先
  enabled: boolean;
  type: 1 | 2 | 3;   // 1=本地草稿,2=个人云端,3=团队共享
  paramConditions: ParamCondition[];  // 参数条件
  mockData: any;
  httpStatusCode: number;
  delay?: number;
}

interface ParamCondition {
  location: 'query' | 'body' | 'header' | 'cookie' | 'path';
  paramName: string;
  operator: 'equals' | 'notEquals' | 'contains' | 'notContains' | 
            'greaterThan' | 'lessThan' | 'greaterOrEqual' | 'lessOrEqual';
  value: any;
}

匹配算法

function matchRule(rules: MockRule[], request: RequestInfo): MatchResult {
  // 只匹配已启用的期望,按优先级排序
  const enabledRules = rules
    .filter(rule => rule.enabled)
    .sort((a, b) => a.priority - b.priority);

  const matched = enabledRules.find(rule => isRuleMatched(rule, request));
  
  return matched 
    ? { matched: true, rule: matched, delay: matched.delay ?? 0 }
    : { matched: false };
}

function isRuleMatched(rule: MockRule, request: RequestInfo): boolean {
  // 没有参数条件,匹配所有请求
  if (!rule.paramConditions?.length) return true;
  
  // 所有条件都满足才匹配(AND 关系)
  return rule.paramConditions.every(condition =>
    matchParamCondition(condition, request)
  );
}

参数值获取

根据 location 从不同位置取值:

function getParamValue(location: string, paramName: string, request: RequestInfo): any {
  switch (location) {
    case 'query':
      return request.query?.[paramName];
    case 'body':
      // 支持嵌套路径,如 body.user.id
      return getNestedValue(parseBody(request.body), paramName);
    case 'header':
      return request.headers?.[paramName];
    case 'cookie':
      return parseCookie(request.headers?.cookie, paramName);
    case 'path':
      return extractPathParam(request.url, paramName);
    default:
      return undefined;
  }
}

比较时会做类型兼容处理,比如 1"1" 视为相等,避免因类型不一致导致匹配失败。

使用示例

请求参数 规则配置 结果
?page=1 query.page equals 1 返回第一页数据
?page=2 query.page equals 2 返回第二页数据
body: {"user":{"type":"vip"}} body.user.type equals "vip" 返回 VIP 数据
Header: X-Debug: true header.X-Debug equals "true" 返回调试数据

核心实现三:AI 生成高质量数据

问题

Mock.js 生成的数据缺乏业务语义:

// Mock.js 生成
{ name: "xxx", age: 82, status: 3 }

// 期望的业务数据
{ name: "张三", age: 28, status: 1 }  // status: 0=待审核, 1=已通过, 2=已拒绝

我们希望根据接口文档中的字段描述、枚举说明、字段命名来生成符合业务语义的数据, 这样文档备注越详细 字段名定义越清晰 生成数据会越准确。

实现思路

后端服务从 API文档平台 获取接口的 JSON Schema,构建 Prompt 调用 AI 生成数据。

核心 Prompt 设计

const systemPrompt = `
你是 Mock 数据生成专家,根据 JSON Schema 生成符合业务场景的数据。

【生成规则 - 按优先级排序】
1. 若 description 中存在枚举说明(如 "0:成功, 1:失败"),优先使用枚举值本身(如 0、1)
2. 若无 description,则根据字段名的语义生成合理值
3. 数组类型默认生成 5 条数据
4. 若 description 中枚举值较多,数组应覆盖所有枚举值

【默认值规则】
- respCode / code:成功场景为 0,失败场景为 -1
- errorMsg:成功场景为 null,失败场景为 "系统异常"
- 图片 URL:使用统一的占位图地址
- 普通 URL:使用统一的域名地址

【输出格式】
仅输出 JSON,不附加任何解释文字
`;

关键设计点

  1. description 优先:接口文档中 status: 0=待审核, 1=已通过 这类描述会被优先使用
  2. 字段名语义userName 生成中文姓名,price 生成合理价格
  3. 枚举覆盖:数组字段会尽量覆盖所有枚举值,便于测试
  4. 默认成功场景:除非用户指定,否则生成正常数据

数据校验

AI 生成的 JSON 可能格式有问题,通过 jsonrepair 验证修复:

const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
  return jsonrepair(jsonMatch[0]);
}

生成后对比 Schema 和实际数据,字段缺失时发送告警。

生成模式

  • 整体生成:根据完整 Schema 生成所有字段
  • 选区生成:只替换选中的字段,保留其他字段不变
  • 自定义 Prompt:用户可输入额外要求,如"生成 VIP 用户数据"、"价格在 100-500 之间"、"生成10条数据"、"生成某个场景值的数据"等

核心实现四:三层作用域

问题

Mock 数据存在本地的问题:

  • 换台电脑就没了
  • 同事想用同一套数据,只能手动复制
  • 团队标准测试数据无法统一管理

设计

三层作用域解决不同场景的需求:

作用域 存储位置 可见性 典型场景
本地草稿 IndexedDB 仅当前设备 临时调试
个人云端 远程数据库 仅创建者 跨设备同步
团队共享 远程数据库 所有团队成员 标准测试数据

关键设计

  1. 优先级和启用状态仅在本地维护:避免团队成员互相干扰
  2. 云端只存期望内容:每个人独立控制"哪些期望启用、优先级如何排序"
  3. 规则缓存:加载后缓存在 window.__mockRulesCache,避免重复读取 IndexedDB

数据结构

interface MockRule {
  type: 1 | 2 | 3;  // 1=本地草稿,2=个人云端,3=团队共享
  enabled: boolean; // 本地维护
  priority: number; // 本地维护
  // ... 其他字段
}

事件通信

SDK 与 UI 面板通过 CustomEvent 通信:

事件名 方向 用途
mock-request-end SDK → UI 请求完成上报
mock-interface-switch UI → SDK 单接口开关控制
mock-rules-updated UI → SDK 规则更新通知

数据清理

自动清理 30 天未使用的本地数据,自动清理非活跃的数据 避免存储膨胀:

await cleanupInactiveData(30);

动态模板语法

支持在 Mock 数据中引用请求参数,实现"响应随请求变化":

语法 说明 示例
{{Date.now()}} 当前时间戳 1706432400000
{{uuid()}} 生成 UUID "a1b2c3d4-..."
{{request.query.xxx}} 获取 URL 参数 {{request.query.page}}
{{request.body.xxx}} 获取请求体字段 {{request.body.userId}}
{{request.headers.xxx}} 获取请求头 {{request.headers.token}}

示例:

{
  "code": 0,
  "data": {
    "requestId": "{{uuid()}}",
    "timestamp": "{{Date.now()}}",
    "userId": "{{request.body.userId}}",
    "page": "{{request.query.page}}"
  }
}

快速开始

// 1. 安装
npm install @zz-common/ai_mock

// 2. 初始化
import { mockInit } from '@zz-common/ai_mock'

mockInit({
  rules: ['api.example.com'],      // 拦截的域名
  excludeRules: [/static/, /cdn/]  // 排除的资源
})

推荐工作流

下面演示从请求采集到 AI 生成 Mock 数据的完整流程:

24182e81-01bc-11f1-83a0-e2f0e015208e.gif

  1. 接入 npm 包:在非生产环境启用拦截
  2. 从真实请求创建 Mock:打开面板,点击请求日志的"创建期望"
  3. 配置参数条件:同一接口不同参数返回不同数据
  4. AI 生成补充:用 AI 生成符合业务语义的数据
  5. 团队共享:将稳定的测试数据推送到团队共享

总结

本文介绍的 Mock 工具解决了四个核心问题:

问题 解决方案
请求拦截 Monkey Patch 重写 XHR/Fetch,通过 WeakMap 防递归
规则匹配 支持 5 种参数位置 × 8 种操作符,按优先级排序
数据质量 AI 根据接口文档的 description、字段名语义生成数据
团队共享 三层作用域,本地维护启用状态和优先级

目前这套工具已在公司内部使用,可以减少联调阻塞、提高场景覆盖率。

未来规划

  • 流量录制回放:基于真实流量生成 Mock 数据,数据更贴近线上场景
  • AI生成数据时基于真实数据:进一步提升AI生成数据质量 贴近真实业务场景
  • 移动端支持:真机环境下配合 PC 端使用 Mock 数据验证
  • 规则推荐:基于历史请求,自动推荐可能需要的 Mock 场景

转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

Promise对象、同步和异步代码、回调地狱的讲解

简单粗暴一点的说 Promise 就是一个为了解决异步代码的东西,它可以让代码按照你想要的顺序去执行。

我们先来说说什么是同步代码,什么是异步代码。

  • 同步代码就是按顺序执行,如:12345 按顺序往下走。只有前一条代码执行完毕之后才会去执行后一条代码。
  • 异步代码就是可以不按照顺序执行的代码,如:213465,例如:网络请求、定时器、文件的读写等均是异步的。我们直接上代码直观的去感受一下
setTimeout(() => {
  
}, 1000)

↑这是一个定时器,这是一个可以将包裹在方法体内的代码延迟 1000 毫秒以后再执行的定时器。

console.log('任务1', moment(new Date()).format('HH:mm:ss'))
setTimeout(() => {
  console.log('任务2', moment(new Date()).format('HH:mm:ss'))
}, 1000)

//moment (new Date ()).format ('HH:mm:ss') 如果你是初学者不明白 moment () 是什么,
//直接复制代码发现报错,没关系。并不影响你继续学习,这条代码的功能就是输出博主当前执行代码的时间,
//你可以删掉这条代码,只输出 ' 任务1、任务2' 即可。感兴趣的同学可以自行搜索 moment 的使用方法学习

image.png

可以看到任务 2 比任务 1 的打印时间晚了一秒

前面我们说到定时器是一个异步的代码,我们来验证一下。

setTimeout(() => {
  console.log('任务2', moment(new Date()).format('HH:mm:ss'))
}, 1000)
console.log('任务1', moment(new Date()).format('HH:mm:ss'))

按照代码自上而下的执行顺序,在控制台中应该是一秒钟以后先输出的任务 2 再输出的任务 1

image.png

从控制台的输出得知并不是这样的。代码跳过了任务 2 先输出的任务 1,然后一秒钟之后才输出的任务 2,证实定时器确实是一个异步的代码,它并没有按照顺序执行。

异步代码的好处就在于不会造成代码的堵塞

  • 例如:现在我们是交通参与者的身份开着车行驶在路上,突然前方出现了交通事故,此时边上有辅道可以绕过去,难道我们就要等到这起交通事故处理完成之后才能通行吗?当然是没有这个必要的,我们可以先通过边上的辅道绕过去,无需等到交通事故处理完毕之后再通行,如果等到交通事故处理之后再通行那就会造成交通瘫痪大堵车的结局。

还有的时候异步任务也需要同步的去执行。还是以这个交通事故举例子,例如:我们是交警,我们接到了调度中心发来的任务请求,告知我们某路段发生交通事故需要我们前往现场进行处理。在接警没多久之后调度中心又发来请求,告知我们现在警力紧张,在我们的不远处还有一起交通事故希望我们处理完手里的这个事故之后立马前往下一个路段处理第二起交通事故。那此时任务二就要等待任务一执行完毕之后才能执行。其他的交通参与者当然不需要等待交警的任务执行完毕之后才能通过,可以自行从边上绕过去。上代码,我们把这个小故事以代码的形式呈现出来

console.log('社会车辆1')
console.log('社会车辆2')
setTimeout(() => {
  console.log('交警处理事故1', moment(new Date()).format('HH:mm:ss'))
  setTimeout(() => {
    console.log('交警处理事故2', moment(new Date()).format('HH:mm:ss'))
  }, 1000)
}, 1000)
console.log('社会车辆3')
console.log('社会车辆4')

image.png

通过控制台的输出我们可以看到社会车辆 1、2、3、4 先行通过了,交警处理事故 1 之后一秒钟事故 2 才执行处理。

但其实这种写法并不美观,如果有很多事故需要处理呢,那是不是就要嵌套很多层呢。

console.log('社会车辆1')
console.log('社会车辆2')
setTimeout(() => {
  console.log('交警处理事故1', moment(new Date()).format('HH:mm:ss'))
  setTimeout(() => {
    console.log('交警处理事故2', moment(new Date()).format('HH:mm:ss'))
    setTimeout(() => {
      console.log('交警处理事故3', moment(new Date()).format('HH:mm:ss'))
      setTimeout(() => {
        console.log('交警处理事故4', moment(new Date()).format('HH:mm:ss'))
        setTimeout(() => {
          console.log('交警处理事故5', moment(new Date()).format('HH:mm:ss'))
          setTimeout(() => {
            console.log('交警处理事故6', moment(new Date()).format('HH:mm:ss'))
          }, 1000)
        }, 1000)
      }, 1000)
    }, 1000)
  }, 1000)
}, 1000)
console.log('社会车辆3')
console.log('社会车辆4')

这样的写法会导致代码的可读性非常的差,你可能说我现在一眼就能看出哪儿条输出语句对应着哪儿个代码块。没错,就以现在的这个代码是可以做到。但如果当每一个代码块中的代码复杂起来多起来了以后呢。你还能一眼就看出来吗?当然是很费力的嘛。这就是我们常说的回调地狱

Promise 的出现就可以很好的规避掉这个问题,让我们来进入 Promise 的学习吧。

new Promise()

Promise 就长这样↑

Promise 接收一个函数,在函数中接收两个参数:resolvereject

new Promise((resolve, reject) => {})

resolvereject 是由 Promise 对象传入的。resolve 直译过来的意思是:决定、解决,是在程序执行成功的时候调用的,而 reject 直译过来的意思是:拒绝,是在程序调用失败的时候调用的,故此你可以将

  • resolve 理解为成功
  • reject 理解为失败

例如:现在我们正在登陆某一个网站,当你的用户名或密码输入错误之时,身份验证不通过就会返回错误信息,此时就可以调用 reject。反之用户名和密码都正确身份认证通过了,此时便可调用 resolve

咱们先打印一下这个 Promise 对象,看看长什么样

const promiseObj = new Promise((resolve, reject) => {})
 
console.log(promiseObj)

image.png

我们看到在控制台打印出来的数据中有一个pending(等待的意思),promise 对象中有一个状态的概念。你看现在的状态是一个默认状态 pending,你可以理解为这个 promise 里的 resolvereject 一个都没有被触发。

我们来触发 resolve 参数看看状态

const promiseObj = new Promise((resolve, reject) => {
  resolve()
})
console.log(promiseObj)

image.png

观察发现 PromiseStatepending 等待变成了fulfilledfulfilled 就表示完成或者成功

我们再来触发一下 reject 参数看看状态

const promiseObj = new Promise((resolve, reject) => {
  reject()
})
console.log(promiseObj)

image.png

观察发现 PromiseState 的状态变成了rejected 表示拒绝或者失败了。

promise 对象给我们提供了三个方法,咱们自己可以如图一样通过对象点的形式来看看是不是出来 catchfinallythen 这三个方法。

简单粗暴的讲一下这三个方法。还是以登陆某一网站为例:

  • 假设用户名和密码都填写正确则会走到 then() 方法里,你可以理解为then() 方法是成功时候调用的。假设用户名和密码有一个或者全都填写错误时服务器便会返回错误信息,此时代码就会走到 catch() 方法里,你可以理解为catch()方法是在错误时候调用的。那么finally() 方法则是无论服务器返回的是 true 还是 false 都会走到 finally 里面,你可以理解为不论对错都会调用 finally。用户名密码填写正确,走完 then() 还会走 finally。用户名密码填写错误,走完 catch() 也还会走 finally()

让我们来一一验证一下:

const promiseObj = new Promise((resolve, reject) => {
  resolve('身份认证通过!')
})
promiseObj
  .then((data) => { //data就是resolve传过来的内容 名称没有规定自定义即可
    console.log(data)
  })
  .catch((error) => {
    console.log(error)
  })
  .finally(() => {
    console.log('我是finally')
  })

image.png

通过代码可以看得出这三个方法接收的都是一个回调函数。通过控制台的输出可以得知当我们调用了 resolve 时代码走到了then() 方法之后又走到了 finally() 方法,验证通过。

const promiseObj = new Promise((resolve, reject) => {
  reject('身份认证失败!')
})
promiseObj
  .then((data) => { 
    console.log(data)
  })
  .catch((error) => { //error就是reject传过来的内容  名称没有规定自定义即可
    console.log(error)
  })
  .finally(() => {
    console.log('我是finally')
  })

image.png

通过控制台的输出可以得知当我们调用了 reject 时代码走到了 catch() 方法之后又走到了 finally() 方法,验证通过。

到这儿为止你已经对 Promise 的使用有了一个简单的认识。我们现在用 Promise 来解决一下前面举例说明的小故事。看看你是否会觉得优雅很多,代码更可读。

虽然 promise 本身是同步的,但是 promise.then().catch (). finally () 这些方法中的回调是异步的,所以在这里我们就不用定时器了。

console.log('社会车辆1')
console.log('社会车辆2')
const promiseObj = new Promise((resolve, reject) => {
  resolve('交警处理事故1')
})
promiseObj
  .then((data) => {
    console.log(data)
    return new Promise((resolve, reject) => {
      resolve('交警处理事故2')
    })
  })
  .then((data) => {
    console.log(data)
    return new Promise((resolve, reject) => {
      resolve('交警处理事故3')
    })
  })
  .then((data) => {
    console.log(data)
    return new Promise((resolve, reject) => {
      resolve('交警处理事故4')
    })
  })
  .then((data) => {
    console.log(data)
  })
  .catch()
console.log('社会车辆3')
console.log('社会车辆4')

image.png

有的人会说 这也嵌套了呀。没错这确实是也嵌套了,但是只有一层,无论多少次都只有一层

promiseObj.then().then().then().then().then().then().catch()

↑这样看是不是就更直观了呢。

还有另一种写法可以给每一个.then() 都设置一个单独的 catch() 直接上代码

image.png 从这张图↑可以看到.then() 里面其实可以传递两个参数的,第一个表示成功时候的调用,第二个表示失败时候的调用

promiseObj
  .then((data) => {}, (error) => {})
  .then((data) => {}, (error) => {})
  .then((data) => {}, (error) => {})
  .then((data) => {}, (error) => {})
  .then((data) => {}, (error) => {})

↑这便是大致的一个结构

const promiseObj = new Promise((resolve, reject) => {
  resolve('交警处理事故1')
})
console.log('社会车辆1')
console.log('社会车辆2')
promiseObj
  .then(
    (data) => {
      console.log(data)
      return new Promise((resolve, reject) => {
        resolve('交警处理事故2')
      })
    },
    (error) => {
      console.log(error)
    }
  )
  .then(
    (data) => {
      console.log(data)
      let errorBtn = false
      return new Promise((resolve, reject) => {
        if (errorBtn) {
          resolve('交警处理事故3')
        } else {
          reject('交通事故处理时候遇到特殊状况')
        }
      })
    },
    (error) => {
      console.log(error)
    }
  )
  .then(
    (data) => {
      console.log(data)
      return new Promise((resolve, reject) => {
        resolve('交警处理事故4')
      })
    },
    (error) => {
      console.log(error)
    }
  )
  .then()
 
console.log('社会车辆3')
console.log('社会车辆4')

我们在交警事故处理 3 处加了一个 reject 的调用

image.png

通过控制台的输出可以得知咱们的 reject 起作用了。当交警处理事故 3 处报错时代码就终止了,后面的程序就不执行了。

以上就是本章的知识点讲解分享,感谢大家的耐心观看学习,欢迎大家在评论区讨论纠错,与大家共勉。

后端正常返回文件流,前端下载下来的文件始终打开失败!

后端正常返回文件流,前端下载下来的文件始终打开失败:

Microsoft Word Word在试图打开文件时遇到错误。 请尝试下列方法: 检查文档或驱动器的文件权限。确保有足够的内存和磁盘空间。用文本恢复转换器打开文件。

试验了很多下载方法,引入了其他库,均打开失败!

最终发现,问题在于项目在初始构建时,引入过mock!!!

问题分析

  1. 1、main.js 中引入了 require("./mock/mock")
  2. 2、mock.js 使用 Mock.mock() 拦截了多个接口
  3. 3、当你调用 oneDownload() 时,Mock.js 会拦截这个请求并返回模拟数据
  4. 4、由于Mock.js默认返回JSON格式数据,而你的前端代码期望的是文件流,导致下载失败

image.png

Tauri 项目:交互流程与开发指南

一、项目文件结构

DZMRustTauriBaseProject/
├── index.html                    # 前端 HTML 入口
├── package.json                  # 前端依赖与脚本
├── vite.config.ts                # Vite 构建配置
├── tsconfig.json
│
├── src/                          # 【前端】主要开发区
│   ├── main.ts                   # Vue 应用挂载
│   ├── App.vue                   # 根组件 / 主页面
│   ├── assets/                   # 静态资源
│   └── vite-env.d.ts
│
├── src-tauri/                    # 【后端/桌面壳】主要开发区
│   ├── Cargo.toml                # Rust 依赖
│   ├── tauri.conf.json           # 窗口、前端地址、构建命令等
│   ├── build.rs                  # 构建时脚本
│   ├── capabilities/
│   │   └── default.json          # 权限:允许前端调用的 API
│   ├── src/
│   │   ├── main.rs               # 程序入口,启动 Tauri
│   │   └── lib.rs                # 业务逻辑 + 暴露给前端的命令(核心)
│   └── icons/                    # 应用图标
│
└── docs/                         # 文档(本文件所在目录)

二、前后端交互流程

2.1 整体流程(一次调用)

┌─────────────────────────────────────────────────────────────────────────┐
│  用户操作(如点击 Greet)                                                  │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│  前端 (Vue / src/App.vue)                                                │
│  • 调用: invoke("greet", { name: name.value })                           │
│  • 使用: @tauri-apps/api/core 的 invoke                                  │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                                    │  (Tauri 内部:序列化参数 → IPC → 后端)
                                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│  后端 (Rust / src-tauri/src/lib.rs)                                      │
│  • 匹配命令名 "greet" → 执行 fn greet(name: &str) -> String               │
│  • 返回值序列化后通过 IPC 回传                                            │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│  前端 (Vue)                                                              │
│  • await invoke(...) 得到 Rust 返回的 String                              │
│  • 写入 greetMsg,界面更新                                                │
└─────────────────────────────────────────────────────────────────────────┘

2.2 代码对应关系

环节 位置 做什么
前端调用 src/App.vue invoke("greet", { name }),命令名必须与 Rust 中一致
命令注册 src-tauri/src/lib.rs #[tauri::command] fn greet(...) + generate_handler![greet]
权限 src-tauri/capabilities/default.json 允许使用 core(含 invoke)等能力

三、后续开发:主要操作区域与流程

3.1 加一个「新能力」(新命令)

  1. Rust 端src-tauri/src/lib.rs

    • 写一个新函数,加上 #[tauri::command]
    • tauri::Builder.invoke_handler(tauri::generate_handler![..., 新命令]) 里注册。
  2. 前端

    • 在需要的地方 import { invoke } from "@tauri-apps/api/core",然后 await invoke("新命令名", { 参数 })
  3. 权限(若用到敏感 API)

    • capabilities/ 里为对应能力添加 permission(多数纯业务命令用默认即可)。

3.2 改界面

  • 只动 src/:Vue 组件、路由、状态、样式等,和普通 Vue 项目一致。

3.3 改窗口/打包/配置

  • src-tauri/tauri.conf.json:窗口大小、标题、前端 devUrl、构建命令等。

3.4 开发时跑起来

  • 只跑网页:npm run dev
  • 跑桌面客户端(前端 + 壳):npm run tauri dev

四、交互原理简述

  • 前端运行在 Tauri 提供的 WebView 里,和普通网页一样写 Vue/TS。
  • 通信:前端不直接访问 Rust,而是通过 Tauri 的 invoke 发「命令名 + 参数」;Tauri 在 Rust 端根据命令名路由到对应的 #[tauri::command] 函数,执行完后把返回值通过 IPC 传回前端。
  • 安全:前端只能调用你在 Rust 里显式注册的 command,以及 capabilities 里允许的 API;没有「整个 Node/系统」暴露给前端,所以不需要像 Electron 那样用 contextBridge 做一层暴露。

五、和 Electron 在流程上的区别

5.1 Electron 的典型做法

  • 主进程:Node 环境,可 require、访问系统。
  • 渲染进程:浏览器环境,默认不能直接 require、不能随意调主进程。
  • 所以要用 contextBridge + ipcRenderer
    • 主进程里用 preload 脚本,通过 contextBridge.exposeInMainWorld('xxx', { ... }) 只暴露有限 API 给渲染进程。
    • 渲染进程里不直接 require('electron'),而是用挂到 window 上的对象(例如 window.xxx.invoke('channel', data)),内部再转成 ipcRenderer.send / ipcRenderer.invoke 与主进程通信。
  • 流程:渲染进程 → preload(contextBridge 暴露的 API)→ ipcRenderer → 主进程(ipcMain 监听 channel)→ 主进程逻辑

也就是说:Electron 需要你自己用 preload + contextBridge 定义「前端能调什么」,再用 ipcRenderer / ipcMain 在进程间传 channel + 数据。

5.2 Tauri 的对应关系

概念 Electron Tauri
前端调「后端」 通过 preload 暴露的 API 通过 invoke("command", args)
暴露方式 contextBridge.exposeInMainWorld 无需 preload,直接 invoke 命令名
后端实现 ipcMain.on('channel', handler) #[tauri::command] fn xxx()
通信模型 自己定 channel 名、自己序列化 命令名 + 参数,Tauri 负责序列化/路由

5.3 核心区别总结

  1. Electron

    • 渲染进程不能直接用 require('electron'),所以要 preload + contextBridge 暴露安全 API,再用 ipcRenderer 发 channel,主进程用 ipcMain 收。
    • 流程是你自己设计 channel 和参数格式。
  2. Tauri

    • 前端直接用 invoke("命令名", 参数),不需要 preload,也没有 contextBridge。
    • 「暴露什么」由 Rust 里注册的 commandcapabilities 决定,安全性在框架层就按「白名单命令」设计好了。

所以:Electron 的 contextBridge + ipcRenderer 那一套,在 Tauri 里被简化为「前端 invoke 命令名 + Rust 端 command 注册」,不需要你写 preload,也不用手动管 channel 名,只要在 Rust 里加 command、在前端调 invoke 即可。


六、对照小结

  • 项目结构src/ = 前端,src-tauri/ = 桌面壳与后端逻辑;后续开发主要就是在这两块加命令和改界面。
  • 交互流程:用户操作 → 前端 invoke("命令", 参数) → Tauri 路由到 Rust #[tauri::command] → 返回值回前端。
  • 和 Electron 的区别:Tauri 用「invoke + command」替代了 Electron 的「contextBridge + ipcRenderer + ipcMain」;前端只调命令名,不碰 preload,也不定义 channel。

文档结束。

基于 Starlight 文档站点接入 Microsoft Clarity 的完整实践指南

从数据洞察到用户增长:HagiCode 博客接入 Clarity Analytics 的完整指南

本文将分享如何在 Starlight 文档站点中优雅地接入 Microsoft Clarity,不仅能看清用户行为,还能确保隐私合规。这套方案是我们在 HagiCode 项目中实践总结出来的,希望能给同样在折腾数据统计的你一点参考。

背景:为什么我们需要 Clarity?

以下代码展示了如何在 Astro 集成中根据环境变量动态注入 Microsoft Clarity 脚本,仅在生效时进行生产环境加载。

105 | interface Props {
106 |   // 未来可扩展: 允许手动覆盖 Project ID
107 |   projectId?: string;
108 | }
109 | 
110 | const {
111 |   projectId = import.meta.env.CLARITY_PROJECT_ID,
112 | } = Astro.props;
113 | 
114 | const isProduction = import.meta.env.PROD;
115 | ---
116 | 
117 | {isProduction && projectId && (
118 |   <script is:inline define:vars={{projectId}}>
119 |     (function(c,l,a,r,i,t,y){

文件:openspec/changes/archive/2026-01-30-microsoft-clarity-integration/design.md

在运营 HagiCode 的过程中,我们一直面临一个"盲盒"问题:我们产出内容,但不清楚用户是如何阅读的。虽然 GitHub 能看到 Star 数,但这太滞后了。我们需要知道:

  • 用户到底有没有看完我们的教程?
  • 那些复杂的配置文档,是在哪一步劝退了用户的?
  • 我们的 SEO 优化是否真的带来了有效流量?

市面上有很多分析工具,比如 Google Analytics(GA)和 Microsoft Clarity。GA 功能强大但配置复杂,且受到隐私法规(如 GDPR)的严格限制。而 Clarity 作为微软推出的免费热力图工具,不仅功能直观,而且在隐私合规上相对宽松,非常适合技术文档站点。

我们的目标很明确:在 HagiCode 的文档站点中无缝集成 Clarity,既要在所有页面生效,又要给用户留有"退出"的权利(隐私合规)。

关于 HagiCode

HagiCode 主题初始化逻辑:优先读取本地存储,回退至系统偏好,默认暗色。

67 | function getInitialTheme(): Theme {
68 |   // 1. 检查 localStorage
69 |   const stored = localStorage.getItem('hagicode-theme');
70 |   if (stored) return stored as Theme;
71 | 
72 |   // 2. 检测系统偏好
73 |   const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
74 |   if (systemDark) return 'dark';
75 | 
76 |   // 3. 默认暗色
77 |   return 'dark';
78 | }
79 | ```
80 | 
81 | ### 决策 3:主题应用方式
82 | 
83 | **选择**:在 `<html>` 根元素设置 `data-theme` 属性
84 | 
85 | **对比方案**:
86 | 

文件:openspec/changes/archive/2026-01-29-theme-toggle-implementation/design.md

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个基于 AI 的代码辅助工具,在开发过程中,我们需要维护大量的技术文档和博客。为了更好地理解用户需求,我们探索并实施了这套数据接入方案。

技术选型与探索

起初,我们在 Proposal 阶段讨论了多种集成方式。既然我们使用的是 Starlight(基于 Astro 的文档框架),最直观的想法是利用 Astro 的 Hooks。

我们首先尝试了修改 astro.config.mjs,计划在构建时注入 Clarity 脚本。虽然这种方式能保证全局覆盖,但缺乏灵活性——我们无法根据用户的偏好动态加载或卸载脚本。

考虑到用户体验和隐私控制,我们最终决定采用 组件覆盖 的方案。Starlight 允许开发者覆盖其内部组件,这意味着我们可以接管 <footer><head> 的渲染逻辑,从而精细控制 Clarity 的加载时机。

这里有一个小插曲:原本我们想创建一个名为 StarlightWrapper.astro 的布局包装器。但在实际调试中发现,Starlight 的路由机制并不会自动调用这个自定义 Wrapper,这导致脚本在部分页面失效。这算是一个典型的"想当然"踩坑经历,提醒我们必须深入理解框架的渲染流程,而不是盲目套用通用框架模式

核心方案:Footer 组件覆盖

为了确保 Clarity 脚本在所有页面(包括文档和博客)加载,并且不破坏原有的页面结构,我们选择了覆盖 Starlight 的 Footer 组件。

为什么是 Footer?

  1. 全局性:Footer 几乎在所有标准页面都会出现。
  2. 非侵入性:将脚本放在 Footer 区域(实际渲染在 body 底部)不会阻塞页面的关键渲染路径(LCP),对性能影响最小。
  3. 逻辑集中:可以在组件内部统一处理 Cookie 同意逻辑。

实施步骤

1. 准备 Clarity 项目

首先,你需要在 Microsoft Clarity 注册并创建一个新项目。获取你的 Project ID(类似 k8z2ab3xxx 这样的字符串)。

2. 环境变量配置

下面通过环境变量配置与日期判断代码,实现新年期间的逻辑控制,请参考具体实现。

46 |         function isLunarNewYearPeriod() {
47 |           const now = new Date();
48 |           const year = now.getFullYear();
49 |           const month = now.getMonth() + 1; // 1-12
50 |           const day = now.getDate();
51 | 
52 |           // 2025年蛇年新年期间 (1月29日 - 2月12日)
53 |           if (year === 2025) {
54 |             if (month === 1 && day >= 29) return true;
55 |             if (month === 2 && day <= 12) return true;
56 |           }
57 |           // 2026年马年新年期间 (2月17日 - 3月3日)
58 |           if (year === 2026) {
59 |             if (month === 2 && day >= 17) return true;
60 |             if (month === 3 && day <= 3) return true;
61 |           }
62 |           return false;
63 |         }
64 | 
65 |         const stored = localStorage.getItem('starlight-theme');

文件:src/pages/index.astro

为了安全起见,不要硬编码 ID。建议将 ID 存入环境变量。

在项目根目录创建 .env 文件:

# Microsoft Clarity ID
PUBLIC_CLARITY_ID="你的_Clarity_ID"
3. 创建覆盖组件

以下是监听系统主题变化的实现代码,展示了如何仅在未手动设置时跟随系统切换主题。

445 |     const handleChange = (e: MediaQueryListEvent) => {
446 |       // 仅在用户未手动设置时跟随系统
447 |       if (!localStorage.getItem(THEME_KEY)) {
448 |         setThemeState(e.matches ? 'dark' : 'light');
449 |       }
450 |     };
451 | 
452 |     mediaQuery.addEventListener('change', handleChange);
453 |     return () => mediaQuery.removeEventListener('change', handleChange);
454 |   }, []);
455 | 
456 |   return { theme, toggleTheme, setTheme: manuallySetTheme };
457 | }
458 | ```
459 | 
460 | #### 3. `src/components/ThemeButton.tsx` - 按钮组件
461 | 
462 | **职责**:渲染主题切换按钮,处理用户交互
463 | 
464 | **组件接口**

文件:openspec/changes/archive/2026-01-29-theme-toggle-implementation/design.md

src/components/ 目录下创建文件 StarlightFooter.astro。Starlight 会自动识别这个文件并覆盖默认的 Footer。

核心代码逻辑如下:

---
// src/components/StarlightFooter.astro
// 1. 引入原始组件以保留其默认功能
import DefaultFooter from '@astrojs/starlight/components/StarlightFooter.astro';

// 2. 获取环境变量
const clarityId = import.meta.env.PUBLIC_CLARITY_ID;

// 3. 定义简单的注入脚本(内联方式)
// 注意:生产环境建议将此逻辑抽离到单独的 .js 文件中以利用缓存
const initScript = `
(function(c,l,a,r,i,t,y){
    c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
    t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
    y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${clarityId}");
`;
---

<DefaultFooter {...Astro.props} />

{/* 仅在生产环境且 ID 存在时注入脚本 */}
{import.meta.env.PROD && clarityId && (
  <script is:inline define:vars={{ clarityId }}>
    {initScript}
  </script>
)}

关键点解析

  • is:inline:告诉 Astro 不要处理这个 script 标签内的内容,直接输出到 HTML。这对第三方统计脚本至关重要,否则 Astro 的打包优化可能会导致脚本失效。
  • define:vars:这是 Astro 3+ 的特性,允许在作用域内安全地注入变量。
  • import.meta.env.PROD:确保在本地开发时(除非为了调试)不产生无效统计,保持数据纯净。

进阶:隐私合规与 Cookie 控制

仅仅加上代码是不够的,特别是在 GDPR 管辖区域。我们需要尊重用户的选择。

HagiCode 的做法是提供一个简单的开关。虽然这不是全功能的 Cookie Banner,但对于纯展示的技术文档站点来说,通常属于"必要"或"统计"类 Cookie,可以通过隐私声明告知并默认开启,或者在 Footer 链接到隐私设置页面。

如果需要更严谨的控制,你可以结合 localStorage 来记录用户的选择:

本文将介绍用于主题切换与持久化的 TypeScript 工具函数,通过类型安全与环境检测实现严谨控制。

367 | export function getInitialTheme(): Theme;
368 | export function getSystemTheme(): Theme;
369 | export function setTheme(theme: Theme): void;
370 | export function applyTheme(theme: Theme): void;
371 | ```
372 | 
373 | **设计原则**:
374 | - **纯函数**:无副作用(除了 `setTheme` 和 `applyTheme`)
375 | - **类型安全**:完整的 TypeScript 类型推导
376 | - **环境检测**:SSR 安全(`typeof window` 检查)
377 | - **单一职责**:每个函数只做一件事
378 | 
379 | **关键实现**:
380 | ```typescript
381 | export function getInitialTheme(): Theme {
382 |   if (typeof window === 'undefined') return 'dark';
383 | 
384 |   const stored = localStorage.getItem(THEME_KEY);
385 |   if (stored === 'light' || stored === 'dark') return stored;
386 | 

文件:openspec/changes/archive/2026-01-29-theme-toggle-implementation/design.md

// 简单示例:检查用户是否拒绝统计
const consent = localStorage.getItem('clarity_consent');
if (consent !== 'denied') {
    // 执行上面的 Clarity 初始化代码
    window.clarity('start', clarityId);
}

经验总结与坑点

在将这套方案落地到 HagiCode 的过程中,我们总结了几个容易被忽视的细节:

  1. StarlightWrapper.astro 是个陷阱: 如前所述,不要试图去创建一个全局 Wrapper 来注入脚本,这在 Starlight 中行不通。老老实实覆盖特定组件(如 StarlightFooter.astroStarlightHead.astro)才是正解。

  2. 脚本位置的性能考量: 虽然 Clarity 建议放在 <head> 中以确保数据准确性,但对于文档站点,首屏加载速度(LCP)直接影响了 SEO 和用户留存。我们选择了放在 Footer(Body 底部),这会轻微丢失极少量"秒退"用户的数据,但换来了更快的页面加载体验,这是一个值得的权衡。

  3. 开发环境的干扰: 一定要加上 import.meta.env.PROD 判断。在开发模式下,你会频繁刷新页面,这会产生大量无意义的测试数据,污染你的 Clarity 仪表盘。

效果验证

部署完成后,你可以在 Clarity 控制台查看实时数据。通常在几分钟内,你就能看到用户的heatmap(热力图)和 recordings(录屏)。

对于 HagiCode 来说,通过这些数据我们发现:

  • 很多用户会反复查看"快速开始"章节,说明我们的安装指引可能还不够直观。
  • "API 参考"页面的停留时间最长,证实了我们核心用户群体的需求。

总结

接入 Microsoft Clarity 并不需要复杂的服务端改造,也不需要引入沉重的 SDK。

利用 Starlight 的组件覆盖机制,我们仅通过一个轻量级的 StarlightFooter.astro 组件,就实现了全局数据统计。这种"微集成"的方式,既保证了代码的整洁,又赋予了我们洞察用户行为的能力。

如果你也在运营技术类项目,特别是像 HagiCode 这样需要不断迭代文档的项目,强烈建议尝试接入 Clarity。数据会告诉你,用户真正的痛点在哪里。

参考资料


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

由vite项目引起的Nginx学习

一直对nginx处于比较浅显的了解,趁着这次调试项目,对了解到的nginx也做一次总结。

问题背景

本地vite项目运行正常,但是部署到对应环境后,页面无法正常访问。

图片

 预计此问题是未正常请求到资源导致返回了兜底的html内容,然而请求的是js文件,得到的却是html文件,所以导致的此报错。

查看了vite.config.ts,发现当前base配置是./【相对路径】,将其修改为/【绝对路径】后,希望在本地确认一下是否正常后再部署到对应环境,就有了以下流程。

调试过程

先在本地执行对应环境的打包命令:pnpm run build:test,会在项目根目录下生成dist文件。

由于Vite中内置的Vite preview不支持代理,所以无法使用pnpm run preview直接运行dist

此时选择使用Nginx运行本地dist

在本地nginx配置中添加对此项目的代理:

# 所有行都需要 ; 结尾
...
server {
    # 监听端口号
    listen 3333;
    # 配置后在浏览器中的地址就是localhost:3333
    server_name localhost;

    # 静态文件目录【使用绝对路径】
    root E:/work/object/dist;

    index index.html;

    # 静态资源缓存
    location~* .(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|wasm)$ {
        expires 1y;
    }
    
    # API 代理
    location /api/ {
        proxy_pass http://1.1.1.1:1111;
                client_max_body_size 500m; # 允许上传最大 500MB 文件
        proxy_connect_timeout 600; # 连接超时 600 秒
        proxy_send_timeout 600;
        proxy_read_timeout 600; # 读取超时 600 秒
    }
    
    # SPA 路由支持
    location / {
        # 文件查找顺序 先找精确路径(/aa.html) 再找目录(/aa/) 最后用备用页面(回退到首页)
        try_files $uri $uri/ /index.html;
    }
    
    error_page 500 502 503 504 /50x.html;
    location = 50x.html {
        root html;
    }
}
...

重启nginx

# 进入nginx.exe所在目录

# 1. 停止Nginx
./nginx -s stop

# 2. 重新加载配置
./nginx -s reload

# 3. 检查 Nginx 进程
tasklist | findstr nginx

除了以上方法也可以在任务管理器中手动停止对应的nginx进行后,再双击nginx.exe启动

nginx正常运行后,就可以在浏览器中直接访问了http:localhost:3333。发现修改base的设置后,确实解决了上述问题。就可以正常提交代码并在对应环境部署了。

问题解析

此次问题是因为新增了一个二级路由,当以base: "./"设置访问子路由/second/page时,

当前URL为:http://localhost:3333/second/page

资源路径为:./assets/xxx.js

解析结果为:http://localhost:3333/second/assets/xxx.js

浏览器会基于当前路径解析相对路径,./assets/xxx.js被解析成了second/assets/xxx.js,而实际文件在/assets/xxx.js,所以导致资源文件404,服务器返回了index.htmlSPA fallback】,但浏览器期望js文件,因此报错Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of 'text/html'

这个问题没有在本地运行时报错,是因为vite开发服务器会动态处理所有请求,不受base配置影响。

其他补充

  • 正向代理: 

    Nginx作为客户端代理,通常用于VPN、翻墙、公司内网代理

    # 公司内网代理配置
    # 员工电脑配置代理:192.168.1.100:3128
    # 所有外网请求都通过这个代理
    server {
        listen 3128; # 常用代理端口
        server_name proxy.example.com;
        
        resolver 8.8.8.8; # DNS 服务器地址
    
        # 允许的客户端IP(安全)
        allow 192.168.1.0/24; 只允许内网
        deny all;
    
        location / { 
            proxy_pass http://$http_host$request_uri; # 动态目标
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_buffering off; # 关闭缓冲,提高性能
            # 日志
            access_log logs/proxy_access.log;
        }
    }
    
  • 反向代理:

    Nginx作为服务器代理,上面的例子就是反向代理

    localtion /api/ {
        proxy_pass http://1.1.1.1:7788; # 转给后端
    }
    
  • 负载均衡:

    可以设置自动轮询,设置权重,或者自定义

    # 多个后端,按权重分配请求
    upstream backend {
        server localhost:8080 weight=3; # 权重 3
        server localhost:8081 weight=1; # 权重 1
        server localhost:8082 backup; # 备用服务器
    }
    server {
        location /api/ {
       proxy_pass http://backend; # 使用负载均衡
        }
    }
    
  • 静态文件处理:

    server {
        root E:/work/app/dist; # 文件仓库【绝对路径】
    
        location / {
       try_files $uri $uri/ /index.html;
        }
    }
    
  • Gzip压缩

    传输前压缩,可以减少带宽

    图片、视频、PDF等如果已经压缩过,就不需要再压缩了,再次压缩效果差

    小文件压缩收益小,反而增加CPU开销

    http {
        gzip on; # 开启压缩
        
        # 压缩级别(1-9,数字越大压缩越好但越慢)
        gzip_comp_level 6;  # 推荐 6,平衡压缩率和速度
        
        # 大于 1KB 才压缩,太小压缩意义不大
        gzip_min_length 1024;
        
        # 压缩的文件类型
        gzip_types
            text/plain           # 纯文本
            text/css             # CSS
            text/javascript      # JavaScript
            application/javascript
            application/json     # JSON
            text/xml             # XML
            application/xml
            application/xml+rss
            text/html;           # HTML
        
        # 压缩缓冲区大小
        gzip_buffers 16 8k;  # 16个8KB缓冲区
        
        # 是否添加 Vary: Accept-Encoding 头
        # 告诉缓存服务器支持压缩  支持压缩的浏览器给压缩版,不支持的给原版
        gzip_vary on;  
        
        # 禁用某些浏览器的压缩(旧版 IE)
        gzip_disable "msie6";  # 禁用 IE6
    }
    
  • 缓存控制

    静态资源缓存,减少重复请求,减轻服务器压力

    第一次请求:浏览器 -> 服务器 -> 返回文件 -> 浏览器(同时缓存)

    第二次请求:浏览器 -> nginx -> 直接返回缓存

    # 静态资源(JS、CSS、图片)- 长期缓存
    location ~* .(js|css|png|jpg)$ {
        expires 1y; # 缓存1年  -1【不缓存】| epoch【不缓存】 | max【最大缓存时间10年】| 1y【一年】| 1M【一个月】...
    # 更详细的缓存规则 公共缓存,1年有效,不会改变
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;  # 不记录访问日志(可选)
    }
    
    # HTML文件 - 短期缓存或不缓存
    location ~* .html$ {
        expires 1h; # 缓存1小时
        add_header Cache-Control "public, max-age=3600";
    }
    
    # API响应 - 不缓存
    location /api {
        proxy_pass http://localhost:8080;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }
    
    # 其他文件
    location / {
        try_files $uri $uri/ /index.html;
    }
    

    Cache-Control参数说明:

    指令 含义 比喻
    public 可以被任何缓存存储 公共货架,谁都能用
    private 只能被浏览器缓存 私人货架,只有你能用
    max-age=秒数 缓存有效期(秒) 保质期
    immutable 文件不会改变 永久不变
    no-cache 需要验证才能使用 每次都要检查
    no-store 不缓存 不放在货架上
  • HTTPS【SSL】

    server {
        listen 443 ssl; # HTTPS 端口
        server_name example.com;
    
        # SSL证书配置
        ssl_certificate /path/to/certificate.crt; # 证书文件
        ssl_certificate_key /path/to/private.key; # 私钥文件
    
    }
    
  • 限流

    防止请求过多,保护服务器

    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
    
    location /api/ {
        limit_req zone=api_limit burst=20; # 每秒10个请求
        proxy_pass http://localhost:8080;
    }
    
  • 跨域【CORS】

    允许不同域名的前端访问API

    location /api {
        add_header Access-Control-Allow-Origin *;
        add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE';
        add_header Access-Control-Allow-Headers 'Content-Type,     Authorization';
    
        proxy_pass http://localhost:8080;
    }
    
  • 重定向

    URL变更,自动跳转

    # 301 永久重定向
    # 浏览器行为:记住新地址,下次直接访问;适用于域名变更或URL永久变更
    location /old {
        return 301 /new;
    }
    
    # 302 临时重定向
    # 浏览器行为:不记住,每次都询问;适用于临时维护、A/B测试
    location /temp {
        return 302 /new;
    }
    
  • 请求头修改

    location /api {
        proxy_set_header Host $host# 设置Host
        proxy_set_header X-Real-IP $remote_addr# 真实IP
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    
        proxy_pass http://localhost:8080;
    }
    

前端工程化 - Vite初始化Vue项目及代码规范配置

前端工程化是通过工具和规范,提升开发效率、代码质量和团队协作的系统化方案。大致包含以下内容:

  • 代码规范
  • Git Hooks
  • 环境变量
  • 构建优化

本文内容包含

  1. 使用 vite 创建 vue 项目
  2. 配置代码规范及相关格式化

一、使用vite创建vue项目

初始化项目

pnpm create vue

图片

按需完善项目结构

图片

设置别名

修改vite.config.ts

import path from 'path'

...
resolve: {
    alias: {
        '@': path.resolve(__dirname, 'src'),
    }
}
...

修改tsconfig.app.json

{
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"]
        },
    }
}

为项目添加自动导入

pnpm add -D unplugin-auto-import unplugin-vue-components

修改vite.config.ts

import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'

export default defineConfig({
    plugins: [
        vue(),
        // 新增
        AutoImport({
            imports: ['vue'],
            dts: './src/auto-imports.d.ts',
            eslintrc: {
                enabled: true,
                filepath: './src/.eslintrc-auto-import.json',
            }
        }),
        // 新增
        Components({
            dirs: ['src/components'],
            extensions: ['vue'],
            deep: true,
            dts: './src/components.d.ts',
            resolvers: []
        })
    ]
})

修改tsconfig.app.json

{
  ...
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "src/auto-imports.d.ts",   // 新增
    "src/components.d.ts"     // 新增
  ]
}

二、配置代码规范及相关格式化

配置格式化校验

统一代码风格,自动检查常见错误和潜在问题

  • ESLint: 代码质量检查(语法、最佳实践)

    ESLint 9.x 不再支持 .eslintrc.*,需要使用新的扁平配置格式 eslint.config.js

  • Prettier: 代码格式化(缩紧、引号、分号等)

  • 依赖:

pnpm add -D \
eslint \
@typescript-eslint/parser \
@typescript-eslint/eslint-plugin \
eslint-plugin-vue \
@eslint/js \
vue-eslint-parser \
prettier \
eslint-config-prettier \
eslint-plugin-prettier
  • 配置文件: eslint.config.js.prettierrc.cjs.prettierignore
  • 脚本:在package.json中添加检验和格式化命令

添加eslint.config.js

import js from '@eslint/js'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
import vuePlugin from 'eslint-plugin-vue'
import prettierConfig from 'eslint-config-prettier'
import prettierPlugin from 'eslint-plugin-prettier'

exportdefault [
    // 基础配置
    js.configs.recommended,

    // 全局忽略
    {
        ignores: ['node_modules/**', 'dist/**', '*.config.*', 'pnpm-lock.yaml'],
    },

    // vue文件配置
    {
        files: ['**/*.vue'],
        languageOptions: {
            parser: vueParser,
            parserOptions: {
                parser: tsParser,
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                console: 'readonly',
                process: 'readonly',
            },
        },
        plugins: {
            vue: vuePlugin,
            '@typescript-eslint': tsPlugin,
            prettier: prettierPlugin,
        },
        /**
         * "off" 或 0    ==>  关闭规则
         * "warn" 或 1   ==>  打开的规则作为警告(不影响代码执行)
         * "error" 或 2  ==>  规则作为一个错误(代码不能执行,界面报错)
         */
        rules: {
            ...prettierConfig.rules,

            // eslint 规则
            'no-var': 'error', // 要求使用 let 或 const 而不是 var
            'no-multiple-empty-lines': ['error', { max: 1 }], // 不允许多个空行
            'prefer-const': 'off', // 使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const
            'no-use-before-define': 'off', // 禁止在 函数/类/变量 定义之前使用它们
            'no-param-reassign': ['error', { props: false }], // 禁止修改函数参数
            'max-classes-per-file': 'off', // 禁止类超过一个文件

            // typescript 规则
            '@typescript-eslint/no-unused-vars': 'error', // 禁止定义未使用的变量
            '@typescript-eslint/no-empty-function': 'error', // 禁止空函数
            '@typescript-eslint/prefer-ts-expect-error': 'error', // 禁止使用 @ts-ignore
            '@typescript-eslint/ban-ts-comment': 'error', // 禁止 @ts-<directive> 使用注释或要求在指令后进行描述
            '@typescript-eslint/no-inferrable-types': 'off', // 禁止对初始化为数字、字符串或布尔值的变量或参数进行显式类型声明
            '@typescript-eslint/no-namespace': 'off', // 禁止使用 namespace 声明
            '@typescript-eslint/no-explicit-any': 'off', // 禁止使用 any 类型
            '@typescript-eslint/ban-types': 'off', // 禁止使用 any 类型
            '@typescript-eslint/no-var-requires': 'off', // 禁止使用 require 语句
            '@typescript-eslint/no-non-null-assertion': 'off', // 禁止使用 ! 断言
            '@typescript-eslint/no-use-before-define': [
              'error',
              {
                functions: false,
              },
            ],

            // vue 规则
            // 'vue/script-setup-uses-vars': 'error', // 要求在 script setup 中使用已定义的变量
            'vue/v-slot-style': 'error', // 要求 v-slot 指令的写法正确
            'vue/no-mutating-props': 'error', // 禁止修改组件的 props
            'vue/custom-event-name-casing': 'error', // 要求自定义事件名称符合 kebab-case 规范
            'vue/html-closing-bracket-newline': 'off', // 要求 HTML 闭合标签换行
            'vue/attribute-hyphenation': 'error', // 对模板中的自定义组件强制执行属性命名样式:my-prop="prop"
            'vue/attributes-order': 'off', // vue api使用顺序,强制执行属性顺序
            'vue/no-v-html': 'off', // 禁止使用 v-html
            'vue/require-default-prop': 'off', // 此规则要求为每个 prop 为必填时,必须提供默认值
            'vue/multi-word-component-names': 'off', // 要求组件名称始终为 “-” 链接的单词
            'vue/no-setup-props-destructure': 'off', // 禁止解构 props 传递给 setup
            'vue/max-len': 0, // 强制所有行都小于 80 个字符
            'vue/singleline-html-element-content-newline': 0, // 强制单行元素的内容折行
            
            // Prettier 规则
            'prettier/prettier': 'error', // 强制使用 prettier 格式化代码
        }
    },
    // js文件配置
    {
        files: ['**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts}'],
        languageOptions: {
            parser: tsParser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                console: 'readonly',
                process: 'readonly',
            }
        },
        plugins: {
            '@typescript-eslint': tsPlugin,
            prettier: prettierPlugin,
        },
        rules: {
            ...prettierConfig.rules,

            // eslint 规则
            'no-var': 'error', // 要求使用 let 或 const 而不是 var
            'no-multiple-empty-lines': ['error', { max: 1 }], // 不允许多个空行
            'prefer-const': 'off', // 使用 let 关键字声明但在初始分配后从未重新分配的变量,要求使用 const
            'no-use-before-define': 'off', // 禁止在 函数/类/变量 定义之前使用它们
            'prettier/prettier': 'error', // 强制使用 prettier 格式化代码

            // TypeScript 规则
            '@typescript-eslint/no-unused-vars': 'error',
            '@typescript-eslint/no-empty-function': 'error',
            '@typescript-eslint/prefer-ts-expect-error': 'error',
            '@typescript-eslint/ban-ts-comment': 'error',
            '@typescript-eslint/no-inferrable-types': 'off',
            '@typescript-eslint/no-namespace': 'off',
            '@typescript-eslint/no-explicit-any': 'off',
            '@typescript-eslint/ban-types': 'off',
            '@typescript-eslint/no-var-requires': 'off',
            '@typescript-eslint/no-non-null-assertion': 'off',
            '@typescript-eslint/no-use-before-define': [
                'error',
                {
                    functions: false,
                },
            ],
            
            'prettier/prettier': 'error',
        }
    }
]

添加.prettierrc.cjs

/**
 * Prettier 代码格式化配置
 * 文档:https://prettier.io/docs/en/configuration.html
 */
module.exports= {
  // 是否在语句末尾添加分号
  semi: false,
  // 是否使用单引号
  singleQuote: true,
  // 设置缩进
  tabWidth: 2,
  // 尾随逗号
  trailingComma: 'es5',
  // 每行最大字符数
  printWidth: 120,
  // 箭头函数参数括号: avoid( 避免 ) | always( 总是 )
  arrowParens: 'avoid',
  // 文件行尾: lf( 换行 ) | crlf( 回车换行 ) | auto( 自动 )
  endOfLine: 'lf',
}

添加.prettierignore

node_modules
dist
*.specstory
*.local
pnpm-lock.yaml
package-lock.json
.DS_Store
coverage
.vscode
.idea
public

package.json中添加相关scripts

...
"scripts": {
  ...
    "lint": "eslint . --fix",
    "format": "prettier --write "src/**/*.{js,ts,vue,json,css,scss,md}"",
    "lint:check": "eslint .",
    "format:check": "prettier --check "src/**/*.{js,ts,vue,json,css,scss,md}""
},
...

配置css格式校验及其他

  • Stylelint: css/scss样式校验和格式化,统一样式代码风格,发现样式错误
  • EditorConfig: 统一编辑器配置,保证跨编辑器保持一致的编码风格
  • Commitlint: Git 提交信息格式校验,规范提交信息,便于追踪和生成changeling
  • Husky + lint-staged: Git hooks 自动化校验,代码提交前自动检查,避免提交不符合规范的代码
  1. 安装相关依赖

    # 基础依赖(必需)
    # stylelint-config-html: HTML/Vue模板样式格式化
    # stylelint-config-recess-order: css属性书写顺序
    # stylelint-config-recommended-vue: Vue推荐配置
    pnpm add -D \
      stylelint \
      stylelint-config-standard \
      stylelint-config-standard-vue \
      stylelint-config-prettier \
      stylelint-config-html \ 
      stylelint-config-recess-order \  
      stylelint-config-recommended-vue \ 
      @commitlint/cli \
      @commitlint/config-conventional \
      husky \
      lint-staged \
      postcss-html 
    
    
    # 可选依赖(根据项目需要)
    # 如果使用 Tailwind CSS
    pnpm add -D stylelint-config-tailwindcss
    
    # 如果使用SCSS
    pnpm add -D stylelint-config-standard-scss stylelint-scss
    
  1. 创建.stylelintrc.cjs

    module.exports= {
      // 继承规则
      extends: [
        'stylelint-config-standard', // 配置 stylelint 拓展插件
        'stylelint-config-html/vue', // 配置 vue 中 template 样式格式化
        'stylelint-config-recess-order', // 配置 stylelint css 属性书写顺序插件,
        'stylelint-config-standard-scss', // 配置 stylelint scss 插件
        'stylelint-config-recommended-vue/scss', // 配置 vue 中 scss 样式格式化
        'stylelint-config-tailwindcss',
      ],
      overrides: [
        // 扫描 .vue/html 文件中的 <style> 标签内的样式
        {
          files: ['**/*.{vue,html}'],
          // 使用 postcss-html 解析器
          customSyntax: 'postcss-html',
        },
      ],
      rules: {
        'keyframes-name-pattern': null, // 强制关键帧名称的格式
        'custom-property-pattern': null, // 强制自定义属性的格式
        'selector-id-pattern': null, // 强制选择器 ID 的格式
        'declaration-block-no-redundant-longhand-properties': null, // 禁止冗余的长属性
        'function-url-quotes': 'always', // URL 的引号 "always(必须加上引号)"|"never(没有引号)"
        'color-hex-length': 'long', // 指定 16 进制颜色的简写或扩写 "short(16进制简写)"|"long(16进制扩写)"
        'rule-empty-line-before': 'never', // 要求或禁止在规则之前的空行 "always(规则之前必须始终有一个空行)"|"never(规则前绝不能有空行)"|"always-multi-line(多行规则之前必须始终有一个空行)"|"never-multi-line(多行规则之前绝不能有空行)"
        'font-family-no-missing-generic-family-keyword': null, // 禁止在字体族名称列表中缺少通用字体族关键字
        'property-no-unknown': null, // 禁止未知的属性
        'no-empty-source': null, // 禁止空源码
        'selector-class-pattern': null, // 强制选择器类名的格式
        'value-no-vendor-prefix': null, // 关闭 vendor-prefix (为了解决多行省略 -webkit-box)
        'no-descending-specificity': null, // 不允许较低特异性的选择器出现在覆盖较高特异性的选择器
        // 禁止未知的伪类
        'selector-pseudo-class-no-unknown': [
          true,
          {
            ignorePseudoClasses: ['global', 'v-deep', 'deep'],
          },
        ],
        // 禁止未知的 at-rule
        'scss/at-rule-no-unknown': [
          true,
          {
            ignoreAtRules: ['tailwind', 'apply'],
          },
        ],
        // 禁止未知的函数
        'function-no-unknown': [
          true,
          {
            ignoreFunctions: ['constant'],
          },
        ],
      },
      ignoreFiles: ['**/*.js', '**/*.ts', '**/*.jsx', '**/*.tsx', 'node_modules/**', 'dist/**'],
    }
    
  1. 创建.editorconfig

    # EditorConfig 是帮助多个编辑器和 IDE 维护一致的编码样式的配置文件
    # https://editorconfig.org
    
    root = true
    
    [*] # 表示所有文件适用
    charset = utf-8 # 设置文件字符集为 utf-8
    end_of_line = lf # 设置文件行尾为 LF
    indent_style = space # 缩进风格(tab | space)
    indent_size = 2 # 缩进大小
    insert_final_newline = true # 在文件末尾插入一个新行
    trim_trailing_whitespace = true # 删除行尾的空格
    max_line_length = 130 # 最大行长度
    
    [*.md] # 表示仅对 md 文件适用以下规则
    max_line_length = off # 关闭最大行长度限制
    trim_trailing_whitespace = false # 关闭末尾空格修剪
    
    [*.{yml,yaml}]
    indent_size = 2 # 设置 yaml 文件的缩进大小为 2
    
    [Makefile]
    indent_style = tab # 设置 Makefile 文件的缩进风格为 tab
    
  1. 创建commitlint.config.js文件

    exportdefault {
        extends: ['@commitlint/config-conventional'],
        rules: {
            'type-enum': [
                2,
                'always',
                [
                    'feat', // 新功能
                    'fix', // 修复问题
                    'docs', // 文档更新
                    'style', // 代码格式(不影响代码运行的变动)
                    'refactor', // 重构代码(既不是新增功能,也不是修复问题的代码变动)
                    'perf', // 性能优化
                    'test', // 添加测试
                    'chore', // 构建过程或辅助工具的变动
                    'build', // 打包
                    'ci', // CI配置
                    'revert', // 回退
                    'release', // 发布
                    'wip', // 开发中
                ]
            ],
            // 类型必须小写
            'type-case': [
                2,
                'always',
                'lower-case'
            ],
            // 类型不能为空
            'type-empty': [2, 'never'],
            // 作用域必须小写
            'scope-case': [
                2,
                'always',
                'lower-case'
            ],
            // 主题必须小写
            'subject-case': [
                2,
                'always',
                'lower-case'
            ],
            // 头部最大长度为 100 个字符
            'header-max-length': [
                2,
                'always',
                100
            ],
            // 主体前面必须有一个空行
            'body-leading-blank': [
                2,
                'always'
            ],
        }
    }
    
  1. 创建.lintstagedrc.js

    exportdefault {
      '*.{js,jsx,ts,tsx,vue}': ['eslint --fix', 'prettier --write'],
      '*.{css,scss,less,styl}': ['stylelint --fix', 'prettier --write'],
      '*.{json,md,yml,yaml}': ['prettier --write'],
    }
    
  1. 更新package.json

    {
    ...
    "scripts": {
        "lint": "eslint . --fix",
        "format": "prettier --write "src/**/*.{js,ts,vue,json,css,scss,md}"",
        "lint:check": "eslint .",
        "format:check": "prettier --check "src/**/*.{js,ts,vue,json,css,scss,md}"",
    
        "lint:style": "stylelint "**/*.{css,scss,vue}" --fix",
        "lint:style:check": "stylelint "**/*.{css,scss,vue}"",
    
        "type-check": "vue-tsc --noEmit",
    
        "check": "pnpm lint:check && pnpm format:check && pnpm lint:style:check && pnpm type-check",
        "fix": "pnpm lint && pnpm format && pnpm lint:style",
    
        "prepare": "husky install"
    },
    ...
    }
    
  2. 初始化Husky(Git Hooks)

    pnpm prepare
    

    这会在根目录下生成.husky目录,其中包含了_子目录,将子目录下的commit-msgpre-commit文件拷贝到.husky目录下,并修改文件内容如下:

    .husky/commit-msg文件内容

    #!/usr/bin/env sh
    . "$(dirname -- "$0") /_/husky.sh"
    
    npx --no -- commitlint --edit $1
    

    .husky/pre-commit文件内容

    #!/usr/bin/env sh
    . "$(dirname -- "$0")/_/husky.sh"
    
    pnpm lint-staged
    
  3. 验证配置文件语法

    如果某些验证失败,请检查:

    • 依赖是否已正确安装

    • 配置文件语法是否正确

    • 文件路径是否正确

    # 1. 验证 ESLint 配置
    pnpm exec eslint --print-config src/App.vue > /dev/null && echo "✅ ESLint 配置正确" || echo "❌ ESLint 配置有误"
    
    # 2. 验证 Prettier 配置
    pnpm exec prettier --check . > /dev/null 2>&1 && echo "✅ Prettier 配置正确" || echo "⚠️  Prettier 发现格式问题(这是正常的)"
    
    # 3. 验证 Stylelint 配置
    pnpm exec stylelint --print-config src/style.css > /dev/null && echo "✅ Stylelint 配置正确" || echo "❌ Stylelint 配置有误"
    
    # 4. 验证 Commitlint 配置
    pnpm exec commitlint --help > /dev/null && echo "✅ Commitlint 已安装" || echo "❌ Commitlint 未安装"
    
    # 5. 验证 TypeScript 配置
    pnpm exec vue-tsc --version && echo "✅ vue-tsc 已安装" || echo "❌ vue-tsc 未安装"
    

    图片

  1. 运行检查命令

    # 1. 检查代码格式(ESLint)
    pnpm lint:check
    
    # 2. 检查代码格式(Prettier)
    pnpm format:check
    
    # 3. 检查样式格式(Stylelint)
    pnpm lint:style:check
    
    # 4. 检查 TypeScript 类型
    pnpm type-check
    
    # 5. 综合检查(运行所有检查)
    pnpm check
    
    # 6. 自动修复
    pnpm fix
    

配置文件保存时自动格式化

  1. 安装相关插件
    • Prettier - Code formatter
    • ESLint
    • Stylelint
    • Volar
    • TypeScript Vue Plugin

图片

  1. 创建.vscode/setting.json

    {
      // 编辑器基础配置
      "editor.formatOnSave": true,
      "editor.defaultFormatter": "esbenp.prettier-vscode",
      "editor.codeActionsOnSave": {
        "source.fixAll.eslint": "explicit",
        "source.fixAll.stylelint": "explicit"
      },
    
      // Vue 文件特殊配置 - 使用 Volar 格式化
      "[vue]": {
        "editor.defaultFormatter": "Vue.volar",
        "editor.formatOnSave": true,
        "editor.codeActionsOnSave": {
          "source.fixAll.eslint": "explicit",
          "source.fixAll.stylelint": "explicit"
        }
      },
    
      // Volar 配置
      "volar.formatting.printWidth": 120,
      "volar.formatting.singleQuote": true,
      "volar.formatting.semi": false,
      "volar.formatting.tabSize": 2,
      "volar.formatting.trailingComma": "es5",
      "volar.formatting.arrowParens": "avoid",
      "volar.formatting.endOfLine": "lf",
    
      // 或者使用 Prettier 格式化 Vue(需要配置)
      // "[vue]": {
      //   "editor.defaultFormatter": "esbenp.prettier-vscode",
      //   "editor.formatOnSave": true
      // },
    
      // 文件类型特定配置
      "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[typescript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[typescriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[json]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[jsonc]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[css]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[scss]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[less]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[html]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
      "[markdown]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "editor.formatOnSave": true
      },
    
      // ESLint 配置
      "eslint.enable": true,
      "eslint.validate": [
        "javascript",
        "javascriptreact",
        "typescript",
        "typescriptreact",
        "vue"
      ],
      "eslint.format.enable": true,
      "eslint.codeAction.showDocumentation": {
        "enable": true
      },
    
      // Stylelint 配置
      "stylelint.enable": true,
      "stylelint.validate": [
        "css",
        "scss",
        "less",
        "vue"
      ],
    
      // Prettier 配置
      "prettier.enable": true,
      "prettier.requireConfig": true,
      "prettier.configPath": ".prettierrc.cjs",
    
      // 使用 Prettier 格式化 Vue(如果使用 Prettier 而不是 Volar)
      "prettier.documentSelectors": ["**/*.vue"],
    
      // 其他编辑器配置
      "files.eol": "\n",
      "files.insertFinalNewline": true,
      "files.trimTrailingWhitespace": true,
      "files.encoding": "utf8",
    
      // Vue 相关配置 - 禁用 Vetur(如果安装了)
      "vetur.format.enable": false,
      "vetur.validation.template": false,
      "vetur.validation.script": false,
      "vetur.validation.style": false,
    
      // TypeScript 配置
      "typescript.tsdk": "node_modules/typescript/lib",
      "typescript.enablePromptUseWorkspaceTsdk": true
    }
    
  1. 验证配置

    打开任意.vuets.js文件,故意写一些格式不规范的代码(例如:多余空格,缺少分号等),保存文件,检查代码是否自动格式化

常见问题:

1. 保存时格式化不生效

  • 检查 VSCode 扩展是否已安装
  • 检查 .vscode/settings.json 是否正确配置
  • 重启 VSCode 或重新加载窗口

2. ESLint 报错找不到模块

  • 运行 pnpm install 重新安装依赖
  • 检查 eslint.config.js 中的导入路径

3. Git Hooks 不生效

  • 检查 .husky/pre-commit.husky/commit-msg 文件是否存在且可执行
  • 运行 chmod +x .husky/pre-commit .husky/commit-msg 添加执行权限

总结

通过以上配置,我们已经为 Vue 3 + TypeScript + Vite 项目搭建了完整的代码规范体系:

代码质量检查:ESLint + TypeScript 类型检查

代码格式化:Prettier

样式规范:Stylelint + EditorConfig

提交规范:Commitlint + Husky + lint-staged

开发体验:VSCode 保存自动格式化

配置清单

项目根目录下应包含以下配置文件:

  • eslint.config.js - ESLint 配置
  • .prettierrc.cjs - Prettier 配置
  • .prettierignore - Prettier 忽略文件
  • .stylelintrc.cjs - Stylelint 配置
  • .editorconfig - 编辑器配置
  • commitlint.config.js - Commitlint 配置
  • .lintstagedrc.js - lint-staged 配置
  • .husky/pre-commit - Git pre-commit hook
  • .husky/commit-msg - Git commit-msg hook
  • .vscode/settings.json - VSCode 工作区配置

相关资源

📦 完整示例: GitHub 仓库地址

🔥🔥🔥 React18 源码学习 - hook 原理 - useState & useReducer

前言

本文的React代码版本为18.2.0

可调试的代码仓库为:GitHub - yyyao-hh/react-debug at master-pure

React16.8引入Hooks之前,状态逻辑复用一直困扰着React开发者。Hooks的诞生不仅解决了状态逻辑复用的问题,更重要的是统一了函数组件与类组件的能力边界。作为React中最基础也最核心的两个HooksuseStateuseReducer共同构建了函数组件的状态管理体系。

useState 源码

接下来我们看一下useState的源码。在Hook那一章节讲过,对于Hooks的处理会分为mountupdate两个阶段,然后不同阶段调用不同函数对Hook进行处理。

所以我们接下来直接去看不同阶段处理方法的内部实现

/* react/packages/react-reconciler/src/ReactFiberHooks.old.js */

// mount 阶段
const HooksDispatcherOnMount: Dispatcher = {
  useEffect: mountEffect,
  useRef: mountRef,
  useState: mountState,
  ...
};

// update 阶段
const HooksDispatcherOnUpdate: Dispatcher = {
  useEffect: updateEffect,
  useRef: updateRef,
  useState: updateState,
  ...
};

挂载阶段

因此我们直接看mountState的实现,它是useState在组件首次渲染时的初始化逻辑。它的任务不是计算状态,而是搭建好整个状态管理所需的“基础设施”。

  1. 首先mountWorkInProgressHook的作用是:创建新的Hook对象并添加到正在渲染的Fiber节点的Hook链表末尾(Hook原理一文详细提到过)
  2. 然后计算初始状态值,并放到hook对象的memoizedStatebaseState属性上
    1. memoizedState:当前渲染周期的状态值,组件渲染时使用这个值(即state状态)
    2. baseState:基础状态,用于计算更新
    3. 两者初始值相同,但在更新过程中可能不同(特别是存在跳过的更新时)
  1. 然后创建更新队列放在hook对象的queue属性,管理状态更新
  2. 最后通过dispatchSetState创建dispatch函数(即setState函数)
/* src/react/packages/react-reconciler/src/ReactFiberHooks.old.js */

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // 创建新的Hook对象并添加到正在渲染的Fiber节点的Hook链表末尾
  const hook = mountWorkInProgressHook();
  
  // 计算初始状态值 (支持惰性初始化)
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,     // 指向更新队列中最后一个更新 (环形链表)
    interleaved: null, // 在并发渲染中, 用于存储被中断后插入的更新 (环形链表)
    lanes: NoLanes,    // 表示当前更新队列的优先级 (车道模型)
    dispatch: null,    // 用于保存dispatch函数, 在mountReducer中会被赋值
    lastRenderedReducer: basicStateReducer, // 用于计算新状态的reducer
    lastRenderedState: (initialState: any), // 上一次渲染的状态
  };
  hook.queue = queue;
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

然后看一下dispatchSetState函数主要做了什么?

  1. 首先获取本次更新的优先级 ,使用requestUpdateLane确定更新的优先级
  2. 之后创建一个更新对象Updatependinginterleaved都是环形链表)
    1. 这个更新如果在渲染阶段发生,会被挂载在hook对象的更新队列queuepending属性上
    2. 如果是正常更新:如果新状态与当前状态相同,直接跳过更新,避免不必要渲染的关键优化。这个更新会被挂载在hook对象的更新队列queueinterleaved属性上
  1. 最后会调度更新(scheduleUpdateOnFiber),安排渲染工作
/* src/react/packages/react-reconciler/src/ReactFiberHooks.old.js */

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  const lane = requestUpdateLane(fiber);

  const update: Update<S, A> = {
    lane,   // 优先级车道
    action, // 更新动作 (值或函数)
    hasEagerState: false, // 是否已计算急切状态
    eagerState: null,     // 预先计算的状态 (优化用)
    next: (null: any),    // 指向下一个更新 (形成环状链表)
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher;
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            enqueueConcurrentHookUpdateAndEagerlyBailout(
              fiber,
              queue,
              update,
              lane,
            );
            return;
          }
        }
      }
    }

    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      const eventTime = requestEventTime();
      scheduleUpdateOnFiber(root, fiber, lane, eventTime);
      entangleTransitionUpdate(root, queue, lane);
    }
  }
}
/* src/react/packages/react-reconciler/src/ReactFiberHooks.old.js */

function enqueueRenderPhaseUpdate<S, A>(
  queue: UpdateQueue<S, A>,
  update: Update<S, A>,
) {
  const pending = queue.pending;
  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;
}


/* src/react/packages/react-reconciler/src/ReactFiberConcurrentUpdates.old.js */
export function enqueueConcurrentHookUpdate<S, A>(
  fiber: Fiber,
  queue: HookQueue<S, A>,
  update: HookUpdate<S, A>,
  lane: Lane,
) {
  const interleaved = queue.interleaved;
  if (interleaved === null) {
    update.next = update;
    pushConcurrentUpdateQueue(queue);
  } else {
    update.next = interleaved.next;
    interleaved.next = update;
  }
  queue.interleaved = update;
}

更新阶段

mount阶段初始化了hook对象,然后给开发者返回了statesetState/dispath函数。

而返回的setState函数我们上面已经分析过了,主要的作用就是将传入的action放到hook对象的更新队列queue中。

更新阶段我们看updateState的实现:updateState直接委托给了updateReducer,这揭示了一个重要事实:useState在更新阶段就是一个预定义reduceruseReducer

/* src/react/packages/react-reconciler/src/ReactFiberHooks.old.js */

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}
  1. 队列合并:函数首先会将新加入的、尚未处理的更新队列 (queue.pending),合并到当前正在处理的基础队列 (current.baseQueue) 中。这个合并操作确保了所有更新都在一个环状链表里按顺序排队。
  2. 优先级分拣与状态计算(函数会遍历合并后的队列,对每一个更新进行检查)
    1. 如果优先级不足:此更新被跳过,但它会被克隆一份,加入到 newBaseQueue 中。这个被跳过的队列,连同计算到此刻的状态 (newBaseState),将成为下一次渲染时计算的起点(即新的baseStatebaseQueue)。
    2. 如果优先级足够:此更新被处理。如果它之前已被预计算则直接采用计算结果(性能优化);否则调用reducer函数,传入当前累积的newState和更新的action,计算出新的状态。
  1. 交错更新处理:循环结束后,函数会检查是否存在interleaved更新(通常由高优先级更新中断低优先级渲染产生)。注意:这里并不会处理这些更新的具体内容(action),而只是将它们所携带的优先级(lane)合并到当前 Fiber中,确保系统知道“还有这些优先级的任务待办”。

遍历完所有可以执行的任务后,得到一个新的state,返回新的statesetStatus/dispath方法。

function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook(); // 获取当前正在处理的 WIP Hook
  const queue = hook.queue; // 获取该 Hook的更新队列

  queue.lastRenderedReducer = reducer; // 更新 reducer(对 useState就是 basicStateReducer)

  const current: Hook = (currentHook: any);

  let baseQueue = current.baseQueue;  // baseQueue: 上一次渲染中被跳过的低优先级更新(环形链表: 指针指向最后一个节点)
  const pendingQueue = queue.pending; // pending: 本次渲染周期新产生、尚未处理的更新(环形链表: 指针指向最后一个节点)

  // 合并队列: 将新更新(pendingQueue)合并到 baseQueue
  if (pendingQueue !== null) {
    if (baseQueue !== null) { // 两个环状链表合并
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue; // 将合并后的队列设为新的 baseQueue
    queue.pending = null; // 清空更新队列
  }

  // 处理队列: 遍历并计算新状态(核心循环)
  if (baseQueue !== null) {
    const first = baseQueue.next;
    let newState = current.baseState;

    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast = null;
    let update = first;
    do {
      const updateLane = update.lane;
      // 判断优先级: 当前更新的优先级是否足够在当前渲染中执行? - 优先级不足
      if (!isSubsetOfLanes(renderLanes, updateLane)) {
        const clone: Update<S, A> = {
          lane: updateLane,
          action: update.action,
          hasEagerState: update.hasEagerState,
          eagerState: update.eagerState,
          next: (null: any),
        };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
        markSkippedUpdateLanes(updateLane);
      } else {
        // This update does have sufficient priority.

        if (newBaseQueueLast !== null) {
          const clone: Update<S, A> = {
            lane: NoLane,
            action: update.action,
            hasEagerState: update.hasEagerState,
            eagerState: update.eagerState,
            next: (null: any),
          };
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }

        if (update.hasEagerState) {
          newState = ((update.eagerState: any): S);
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }
      }
      update = update.next;
    } while (update !== null && update !== first);

    if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = (newBaseQueueFirst: any);
    }

    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }

    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;

    queue.lastRenderedState = newState;
  }

  const lastInterleaved = queue.interleaved;
  if (lastInterleaved !== null) {
    let interleaved = lastInterleaved;
    do {
      const interleavedLane = interleaved.lane;
      currentlyRenderingFiber.lanes = mergeLanes(
        currentlyRenderingFiber.lanes,
        interleavedLane,
      );
      markSkippedUpdateLanes(interleavedLane);
      interleaved = ((interleaved: any).next: Update<S, A>);
    } while (interleaved !== lastInterleaved);
  } else if (baseQueue === null) {
    queue.lanes = NoLanes;
  }

  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

useReducer 源码

useReducer其实就是可以自定义更新方法的useState

function mountReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  // 创建新的Hook对象并添加到正在渲染的Fiber节点的Hook链表末尾
  const hook = mountWorkInProgressHook();
  // 计算初始状态值
  let initialState;
  if (init !== undefined) {
    initialState = init(initialArg);
  } else {
    initialState = ((initialArg: any): S);
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, A> = {
    pending: null,
    interleaved: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: reducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  const dispatch: Dispatch<A> = (queue.dispatch = (dispatchReducerAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

总结

React Hooks 设计的精妙之处

  1. 最小化 API,最大化能力:通过极简的API设计,提供了强大的状态管理能力
  2. 函数式理念的贯彻:纯函数组件 + 不可变状态更新 = 可预测的UI
  3. 性能优化的内置:自动的bail-out机制,减少不必要的渲染

下一章我们将了解其他hook的实现方式

BFF 与 Next.js 的联系与组合

最近在写一个Next.js的应用,其中API Routes被我当做轻量BFF,去做一些数据加工与处理,那么BFF 与 Next.js到底有什么样的联系呢?

一、概念先厘清

1. BFF(Backend for Frontend)

BFF 是为前端专门提供的一层后端,职责包括:

  • 接口聚合:把多个后端/微服务的调用合并成前端需要的少数接口。
  • 数据裁剪与适配:把后端 DTO 转成前端需要的结构,减少字段、改名、扁平化等。
  • 鉴权与会话:在 BFF 统一做登录态校验、权限校验,前端只关心「调一个接口」。
  • 协议与形态统一:前端只和 BFF 用 REST/JSON 等约定好的方式通信,不直接面对内部 RPC、消息队列等。

BFF 不负责 HTML 渲染,只负责「给前端提供数据接口」。

2. Next.js 与 SSR

Next.js 是一个 React 全栈框架,除了做前端 SPA,还提供:

  • 服务端渲染(SSR):在服务器上执行 React,生成 HTML 再返回给浏览器,首屏即完整内容,利于 SEO 和首屏性能。
  • API Routes:在 pages/api 下写接口,运行在 Node 里,对外提供 HTTP API,可当「小后端」用。

所以:

  • SSR 解决的是「谁在哪儿把页面渲染出来」的问题(服务器渲染 vs 浏览器渲染)。
  • BFF 解决的是「谁给前端提供接口、怎么聚合与适配」的问题。

二者一个偏「渲染与页面」,一个偏「接口与数据」,可以单独用,也可以一起用。


二、A项目中的 BFF 层:

在我们工作中,前端使用的接口由JAVA编写的BFF分层应用提供,在分层应用中去调用真正的内网接口。

2.1 架构概览

  • Web 层:Controller 暴露 HTTP 接口,路径多为 /xxx/api
  • Service 层:Facade 封装对下游业务服务的调用。

前端/移动端只认 BFF 的 URL,不直接调业务中台。

2.2 Controller:面向前端的接口与鉴权

以xxx申报记录查询为例(MobilexxxController):

@Controller
@RequestMapping("/xxx/xxxx/api")
public class MobilexxxController extends MobilexxxBaseController {

    @RequestMapping(value = "/query", method = RequestMethod.POST)
    @ResponseBody
    public MobilexxxDTO<Object> query(@RequestBody MobileuxxDTO<ZxxxRequestDTO> requestDTO) {
        // 1. 获取用户账户信息
        AccountDTO accountDTO = getAccount();
        // 2. 验证用户会话(BFF 统一鉴权)
        validateHelper.accountSessionCheck(accountDTO, ...);
        // 3. 从统一入参中取出业务数据,并注入用户信息
        ZxxxRequestDTO request = requestDTO.getData();
        request.setXm(accountDTO.getAccount().getName());
        // ...
        // 4. 调用 Facade,再统一封装返回格式
        Response<List<ZxxxResponseDTO>> response = mobilexxxFacade.query(request);
        return ResponseHandler.procResponseResult(response);
    }
}

可以看到 BFF 在这里做了:

  • 统一入参/出参MobilexxxDTO / MobileuxxDTO,前端只面对一种请求/响应形态。
  • 会话与鉴权getAccount()validateHelper.accountSessionCheck() 在 BFF 完成,前端无感知。
  • 数据注入:把当前用户姓名、证件号等写入请求再交给下游,前端不用传敏感信息。

2.3 Facade:聚合下游服务

Facade 不实现业务,只做「转发 + 聚合」:

@Service
public class MobilexxxFacade {
    @Autowired
    private RestfulClient restfulClient;
    @Autowired
    private RestfulUrlHelper restfulUrlHelper;

    public Response<List<ZxxResponseDTO>> query(ZxxRequestDTO request) {
        return restfulClient.postForJson(
            restfulUrlHelper.getxxxnwUrl("xxxx/query"),
            request, Response.class, List.class, ZxxResponseDTO.class);
    }
}

其他 Facade同理:BFF 通过 RestfulClient 调远端服务,对前端只暴露「一个接口、一种协议」。

小结:app 这一层是典型的 BFF——独立部署的 Java 服务,只提供 API,不负责页面渲染;职责是鉴权、聚合、适配,方便前端/移动端调用。


三、B项目中的 Next.js 与 SSR:

xxx-Next 是 Next.js 应用,同时用到了 SSRAPI Routes(充当轻量 BFF)

3.1 SSR:首屏服务端取数与渲染

使用 getServerSideProps 每次请求时在服务端拉取数据并渲染:

export default function GetData(props: PropsType) {
  const { err, data, msg = '' } = props
  // ...
  return (
    // ...
  )
}

export async function getServerSideProps(context: any) {
  const { id = '' } = context.params
  const data = await getDataById(id)  // 服务端请求接口
  return { props: data }
}

流程是:

  1. 用户请求 /data/xxx;
  2. Next 在服务器执行 getServerSideProps,调用 getDataById(id);
  3. 拿到数据后,在服务器上渲染 React,得到 HTML;
  4. 把 HTML 和序列化好的 props 一起返回给浏览器;

这样首屏就是「带数据的完整 HTML」,无需等客户端再发请求才出内容,这就是 SSR。这里 Next.js 的角色是「渲染层 + 在服务端发起数据请求」,数据来源也可以换成调用上面那套 Java BFF。

3.2 API Routes:"薄" BFF

点击提交按钮时,指向的是 Next 自己的接口:

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  // 数据加工
  const data = genData(req.body)  // 把表单 body 转成后端需要的结构
  const resData = await postData(data)
  // ...
}

这里 Next.js 的 API Route 做的是:

  • 接收: 传入的数据,如表单数据;
  • 转换genData(req.body) 把表单字段整理成接口需要的结构;
  • 转发postData(data) 再请求真正的后端。

也就是说,API Route 在这里扮演了一层薄 BFF:对前端暴露简单的一个 POST /api/data,内部做参数适配和转发,前端不直接调外部后端。

3.3 数据流小结

  • :浏览器 → Next 服务端 → getServerSidePropsgetDataById → 外部接口→ 服务端渲染 HTML → 返回。
  • :浏览器 POST 到 Next 的 /api/data → API Route 整理参数并 postData() → 再请求后端→ 根据结果处理。

SSR 解决「从哪拿数据、在哪渲染」;API Route 解决「提交时谁来做参数转换和转发」。两者都在 Next 里,但职责不同。


四、BFF 与 Next.js(SSR)的区别

维度 BFF(如 app 中的 Java 层) Next.js(SSR + API Routes)
主要职责 为前端提供聚合/适配后的 API 渲染 HTML 页面 + 可选的 API 端点
是否渲染 不渲染,只返回 JSON 等数据 服务端执行 React,输出 HTML
典型部署 独立服务(如 Java 进程、容器) Node 进程,同一应用里既有页面也有 API
技术栈 任意后端语言(本项目为 Java) Node + React(Next.js)
鉴权位置 常在 BFF 统一做 可在 API Route 或 getServerSideProps 里做
适用场景 多端复用同一套接口、多后端聚合 需要 SEO、首屏性能的前端应用

简言之:BFF 是「专门给前端的接口层」;Next.js SSR 是「在服务器上把页面渲染出来的方式」。一个偏数据与接口,一个偏页面与渲染。


五、BFF 与 Next.js 的联系与组合

  1. Next.js API Routes 本身可以当作轻量 BFF
    如 xxx-Next 的 /api/data:接收整理参数,再调后端。适合逻辑简单、不需要多语言/多后端聚合的场景。

  2. Next.js 的 SSR 可以消费 BFF
    getServerSidePropsgetStaticProps 里,用 getDataById 这类函数去请求「真正的 BFF」(例如 app 里的 xxx/xxx/api),而不是直接调业务中台。这样:

    • 鉴权、聚合在 BFF 完成;
    • Next 只负责「要什么数据、怎么渲染」,职责清晰。
  3. 组合方式示例

    • 把 xxx-Next 里 ajax.tsHOSTgetDataById 的 URL 指向 app 的 BFF 地址,数据就从 Java BFF 来。
    • 提交仍可先到 Next 的 /api/data,再由 API Route 调 BFF 的提交接口,这样前端只和 Next 打交道,BFF 由 Next 在服务端/API 里调用。

于是可以形成:浏览器 ↔ Next.js(SSR + API Routes)↔ BFF(app)↔ 业务服务。BFF 管「接口与数据」,Next 管「页面与一次转发/适配」。


六、总结

  • BFF:面向前端的接口层,做聚合、适配、鉴权,不负责渲染;本项目中 app 的 Controller + Facade 是典型实现。
  • Next.js SSR:在服务端按请求拉数据并渲染 React 成 HTML,首屏快、利于 SEO;xxx-NextgetServerSideProps 就是 SSR。
  • 区别:BFF 是「接口层」,Next.js(SSR)是「渲染方式」;BFF 可独立于前端技术栈部署,Next 是前端框架自带服务端能力。
  • 联系:Next 的 API Routes 可当薄 BFF 用;SSR 的数据来源可以、也适合来自 BFF;二者组合能同时获得「清晰的数据接口层」和「更好的首屏与 SEO」。

结合项目:app 负责「给前端/移动端提供统一、安全、好用的 API」;xxx-Next 负责「用 Next.js 把页面做出来(SSR),并用 API Route 做提交时的薄 BFF」。理解这一点,就能在架构上分清 BFF 与 Next.js(SSR)各自解决什么问题、如何配合使用。

JS-手写系列:new操作符

前言

在 JavaScript 中,new 关键字就像是一个“工厂加工器”。虽然它看起来只是简单地创建了一个实例,但其背后涉及到了原型链接、上下文绑定以及返回值的特殊处理。掌握 new 的实现原理,是通往 JS 高级开发者的必经之路。

一、 new 操作符的 4 个核心步骤

当我们执行 new Constructor() 时,JavaScript 引擎在后台完成了以下四件事:

  1. 开辟空间:创建一个全新的空对象。
  2. 原型链接:将该对象的隐式原型(__proto__)指向构造函数的显式原型(prototype)。
  3. 绑定 this:执行构造函数,并将其内部的 this 绑定到这个新对象上。
  4. 返回结果:根据构造函数的返回值类型,决定最终返回的对象。

二、 代码实现

在实现中,我们不仅要处理常规逻辑,还要兼容构造函数可能返回引用类型的情况。

  function myNew(Constructor, ...args) {
    // 1. 创建一个空对象,并将其原型指向构造函数的 prototype
      const obj = {};
      obj.__proto__ = Constructor.prototype;
    // 2. 执行构造函数,并将 this 绑定到新创建的对象上
    const result = Constructor.apply(obj, args);

    // 3. 处理返回值逻辑:如果构造函数显式返回了一个对象或函数,则返回该结果; 否则,返回我们创建的新对象 obj
    const isObject = typeof result === 'object' && result !== null;
    const isFunction = typeof result === 'function';

    return (isObject || isFunction) ? result : obj;
  }

  // 测试用例
  function Person(name, age) {
    this.name = name;
    this.age = age;
  }

  const per1 = new (Person)('ouyange', 23);
  const per2 = myNew(Person, 'ouyange', 23);

  console.log('原生 new 结果:', per1);
  console.log('手写 myNew 结果:', per2);

三、 细节解析

1. 构造函数返回值的坑

  • 如果构造函数 return 123(原始类型),new 会忽略它,依然返回实例对象。

  • 如果构造函数 return { a: 1 }(对象类型),new 会丢弃原本生成的实例,转而返回这个对象。

❌