阅读视图

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

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 会导致数据失真,此时应使用其他深拷贝方案。

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

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 会丢弃原本生成的实例,转而返回这个对象。

JavaScript 框架展望 2026

这一年变化很多,但更多是一种视角的变化。若说 AI 过去还不够主流,那么过去一年它已完全主导了讨论。以至于几乎没人再谈新的 JavaScript 框架或框架特性。但这并不代表事情没有进展。

从递归爆炸到闭包优化:彻底搞懂斐波那契数列的性能演进

在前端算法面试中,斐波那契数列(Fibonacci Sequence)不仅仅是一道考察递归逻辑的数学题,更是一块考察候选人对时间复杂度记忆化(Memoization)以及JavaScript 闭包机制理解深度的试金石。

本文将基于一段代码的三个演进版本,带你深入理解如何将一个 

O(2n) ->2的n次方

 的“灾难级”代码优化为 

O(n)

 的生产级代码。

一、 数学定义与暴力递归的陷阱

斐波那契数列的定义非常简洁:从 0 和 1 开始,后续每一项都等于前两项之和。
即:

f(n)=f(n−1)+f(n−2)f(n)=f(n−1)+f(n−2)

将这个公式直接翻译成代码,就是我们最常见的递归写法:

JavaScript

// 版本 1:暴力递归
function fib(n) {
    // 退出条件
    if(n <= 1){
        return n;
    }
    // 递归公式
    return fib(n-1) + fib(n-2);
}

为什么这种写法在面试中只能得 50 分?

虽然代码逻辑正确,但它存在严重的性能问题。当你执行 fib(10) 时,计算很快;但一旦尝试 fib(50),浏览器可能会直接卡死。

原因是大量的重复计算

为了计算 fib(5),程序需要计算 fib(4) 和 fib(3)。
而在计算 fib(4) 时,又需要计算 fib(3) 和 fib(2)。
注意到了吗?fib(3) 被重复计算了多次。随着 n 的增加,这种重复计算呈指数级爆炸,时间复杂度为 

O(2n) ->2的n次方

image.png

二、 核心优化思想:用空间换时间

既然瓶颈在于“重复计算”,解决思路就是:凡是算过的,都记下来。下次再遇到相同的参数,直接读取结果,不再重新递归。这就是“记忆化(Memoization)”。

优化第一步:引入全局缓存

我们可以引入一个对象作为缓存容器(HashMap):

JavaScript

// 版本 2:外部缓存
const cache = {}; // 用空间换时间

function fib(n) {
    // 1. 先查缓存
    if(n in cache){
        return cache[n];
    }
    // 2. 边界条件
    if(n <= 1){
        cache[n] = n;
        return n;
    }
    // 3. 计算并写入缓存
    const result = fib(n-1) + fib(n-2);
    cache[n] = result;
    return result;
}

通过引入 cache,每个数字只会被计算一次。时间复杂度从指数级 

O(2n) ->2的n次方

 降维到了线性 

O(n)

。这是一个巨大的性能飞跃。

image.png

三、 进阶实现:闭包与 IIFE 的完美结合

版本 2 虽然解决了性能问题,但在工程上有一个致命缺陷:cache 变量定义在函数外部,是一个全局变量(或模块级变量)。这意味着任何外部代码都可以修改它,导致程序不安全,且污染了作用域。

在 JavaScript 中,完美的解决方案是结合 IIFE(立即执行函数表达式)  和 闭包(Closure)

JavaScript

// 版本 3:闭包封装
const fib = (function() {
    // 闭包内的私有变量,外部无法访问
    const cache = {}; 
    
    // 返回实际执行的函数
    return function(n) {
        if(n in cache){
            return cache[n];
        }
        if(n <= 1){
            cache[n] = n;
            return n;
        }
        // 注意:这里的 fib 指向的是外部接收 IIFE 返回值的那个变量
        cache[n] = fib(n-1) + fib(n-2);
        return cache[n];
    }
})()

代码解析

  1. IIFE (function(){...})() :函数定义后立即执行,创建了一个独立的作用域。
  2. 闭包:IIFE 返回的内部函数引用了外层作用域的 cache 变量。即便 IIFE 执行完毕,cache 依然驻留在内存中,不会被销毁,也不会被外部访问。
  3. 数据持久化:当你多次调用 fib(10)、fib(20) 时,它们共享同一个 cache,计算过的结果可以跨函数调用被复用。

这种写法不仅实现了性能优化,还体现了优秀的代码封装思想,是面试中的高分回答。

image.png

四、 面试总结

当面试官让你手写斐波那契数列时,建议按照以下逻辑展开:

  1. 先写基本解法:快速写出递归版本,并主动指出其 

    O(2n) ->2的n次方
    

     的复杂度问题和爆栈风险。

  2. 提出优化方案:阐述“用空间换时间”的记忆化思想。

  3. 展示语言特性:使用 IIFE + 闭包 的方式实现记忆化函数。这能展示你对 JavaScript 作用域链和闭包机制的深刻理解。

  4. 扩展思维:如果面试官进一步追问,可以提到通用记忆化函数(Memoize Helper)的编写,或者提到使用迭代(循环)方式来进一步避免递归深度的限制。

掌握这段代码的演进过程,不仅是为了解决一道算法题,更是为了掌握 JavaScript 函数式编程中“状态保持”的核心模式。

CSS 绘制几何图形原理与实战指南

在前端面试中,CSS 绘制图形(尤其是三角形)是一个考察基础知识深度的经典问题。这个问题看似简单,实则可以考察开发者对 盒模型(Box Model)  的底层理解,以及对 现代 CSS 属性(如 clip-path, transform)  的掌握程度。

本文将从原理、实战代码及面试回答策略三个维度进行解析。

一、 经典方案:利用 Border(边框)挤压

这是面试中最常被问到的方案,也是兼容性最好的方案(支持 IE6+)。核心在于理解 CSS 边框在盒模型中的渲染机制。

原理分析

在标准盒模型中,边框是围绕在内容区(Content)之外的。当一个元素的 width 和 height 都设置为 0 时,内容区域消失,元素的大小完全由边框决定。

此时,如果设置了较粗的边框,四条边框会在中心汇聚。由于边框连接处需要平滑过渡,浏览器在渲染时,会以 45度角(正方形时)或根据宽高比计算的斜角 对边框交界处进行斜切处理。

如果不设置颜色,边框看起来是一个矩形;但如果给四条边框设置不同的颜色,视觉上会呈现出四个三角形拼合在一起的效果。

代码实战

CSS

.triangle-border {
    width: 0;
    height: 0;
    /* 核心步骤1:设置足够宽的边框,并将其颜色设为透明 */
    border: 50px solid transparent; 
    /* 核心步骤2:单独指定某一个方向的边框颜色 */
    /* 想要箭头朝上,就给下边框上色 */
    border-bottom-color: #007bff; 
}

面试考察点

  1. 为什么宽高要设为 0?
    是为了消除中间的内容矩形区域,让四条边框在中心直接接触,从而利用边框交界处的斜切特性形成尖角。

  2. 如何调整三角形形状?
    通过调节不同方向 border-width 的数值。

    • 等腰/等边三角形:保持左右边框宽度一致。
    • 直角三角形:将某一条相邻边框的宽度设为 0(例如 border-top: 0),利用剩余边框的挤压形成直角。

image.png

二、 现代方案:利用 Clip-path(裁剪路径)

随着浏览器技术的发展,clip-path 成为了绘制不规则图形的最优解。与 Border 法利用“副作用”不同,Clip-path 是“声明式”的绘图方式。

原理分析

clip-path 属性会在元素内部创建一个裁剪区域:区域内的内容可见,区域外的内容被隐藏。

使用 polygon() 函数可以通过定义一系列坐标点 (x y) 来绘制多边形。坐标系以元素的左上角为原点 (0, 0),右下角为 (100%, 100%)。

代码实战

CSS

.triangle-clip {
    /* 与 Border 法不同,这里需要元素有实际宽高 */
    width: 100px;
    height: 100px;
    background-color: #007bff;
    /* 定义三个顶点:顶部中间、右下、左下 */
    clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

优缺点对比(面试加分项)

  • Border 法:兼容性极好,但在处理背景图片、渐变色或阴影时非常困难,本质上是 Hack 手段。
  • Clip-path 法:语义清晰,支持背景图片裁剪、支持渐变色,且不影响盒模型实际占据的布局空间。

image.png

三、 实用变体:利用 Transform 与 Border-radius

除了基础三角形,面试中常涉及箭头、扇形等图形,这些通常结合 transform 和 border-radius 实现。

1. 空心箭头 (Chevron)

常用于下拉菜单或翻页按钮。
原理:创建一个正方形,只保留相邻的两条边框(如上边和右边),然后旋转 45 度。

CSS

.arrow {
    width: 10px;
    height: 10px;
    border: 2px solid #333;
    /* 隐藏两条边 */
    border-bottom: none;
    border-left: none;
    /* 旋转 */
    transform: rotate(45deg);
}

2. 扇形 (Sector)

原理:利用 border-radius 可以单独控制每个角半径的特性。将正方形的一个角设为圆形(半径等于边长),其余角为 0。

CSS

.sector {
    width: 50px;
    height: 50px;
    background-color: red;
    /* 顺序:左上 右上 右下 左下 */
    /* 将左上角设为半径,形成 1/4 圆 */
    border-radius: 50px 0 0 0; 
}

image.png

总结:面试回答策略

如果在面试中被问到“如何用 CSS 画三角形”,建议按以下逻辑条理清晰地回答:

  1. 首选方案(Border 法) :首先演示 Border 法,因为这是最基础的 CSS 原理。重点解释“宽高为 0”和“透明边框”如何利用盒模型渲染机制形成三角形。
  2. 进阶方案(Clip-path 法) :随后补充说明,如果场景需要显示背景图片或渐变色,clip-path 是更现代、更规范的解决方案,这能体现你对新特性的关注。
  3. 特殊场景:如果是画空心箭头,指出使用 transform: rotate 配合单侧边框是最高效的方法。
  4. 避坑指南:提及尽量避免使用 linear-gradient 拼凑三角形,因为其计算复杂且容易产生锯齿,维护成本高。
❌