阅读视图

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

JavaScript-小游戏-2048

需求

开局生成随机靠边方块 点击方向键移动方块 相邻方块且数值相同发生合并 方块占满界面触发游戏结束提示

游戏界面

标签结构HTML

area区域和death区域分别表示游戏区域和死亡提示区域

 <!-- 页面 -->
  <div class="area"></div>
  <!-- 死亡提示区域 -->
  <div class="death"></div>

层叠样式 css

设置游戏区域为flex布局 主轴默认是水平 flex-wrap: wrap;设置主轴自动换行 注意这里游戏区域的大小是200px*200px的 后面可以算出每个方块的大小

 .area {
      display: flex;
      flex-wrap: wrap;
      width: 200px;
      height: 200px;
      font-size: 30px;
    }

获取元素 js

获取两个游戏区域和游戏结束提示区域的div

   const area = document.querySelector('.area')
    const death = document.querySelector('.death')

数据分析

二维数组arr[y][x]第一个索引表示y坐标 第二个索引表示x坐标 数组的值表示方块的数值(2,4,8...)没有值就表示这个坐标位置没有方块 这里还要初始化一个rArr数组是用来将数组旋转结果存入新数组防止重复处理的

后面的移动和合并都是需要旋转数组进行操作

let arr = [] //方块坐标和数值
    let rArr = [] //旋转数组
    //初始化
    for (let i = 0; i < 5; i++) {
      arr[i] = []
      rArr[i] = []
    }

功能实现

渲染方法

  • 像素点的思路渲染游戏区域为5*5个方块 所以算出每个方块 的大小为40px*40px 渲染
  • 如果有方块 就渲染为粉色 方块的数值${arr[y][x]}也渲染在div
  • 其余部分就是画布
  • block字符串累加完毕之后渲染到页面上
 //渲染页面
    function render() {
      block = ''
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            block += `<div class="square"style="width: 40px;height: 40px;text-align: center; line-height: 40px; background-color: pink;">${arr[y][x]}</div>`
          }
          else {
            block += ' <div class="block" style="width: 40px;height: 40px;background-color: antiquewhite;"></div>'
          }
        }
      }
      area.innerHTML = block
    }
    render()

游戏区域页面如下 游戏区域

开局随机生成两个方块

这里的方块坐标不能重复

  • 声明两个数字类型的全局变量 用来表示两个随机生成的坐标
  • 这里的randomStart函数用来随机生成不重复的两个坐标 ac变量表示随机生成[0,4]之间的整数坐标 如果生成的随机整数坐标a c重复 就重新调用randomStart函数生成
  • 这里运用了三元运算符 来根据 Math.random() 随机生成[0,1)范围内的两个小数的大小 决定这两个方块的坐标位置 这里坐标位置可以重复
  • 第一个方块Math.random()> 0.5 执行前面的表达式arr[0][a] = 2 方块随机在上面的边 否则执行后面的表达式 arr[a][0] = 2方块随机在左边的边 第二个方块同理

三元运算符

条件?表达式A:表达式B 条件为真执行前面的表达式A 条件为假执行后面的表达式B三元运算符例常见用法是处理null值 这里表示 如果传入的参数是假的话name的值为 'stranger' 如果是真的话就是参数name属性的值

 //三元运算符
    //以箭头函数形式 定义greeting函数 这里传入的参数是对象
    const greeting = (person) => {
      const name = person ? person.name : 'stranger'
      return `hello ${name}`
    }
    console.log(greeting({ age: 18, name: 'a' })); //hello a

    //这里三元运算符是真 
    // 但是字符串本身没有name属性
    console.log(greeting('b')); //undefined

    //传入的都是字面量 是最简单的表达式 可以通过代码直接看出值
    //这些是假值 
    //数字字面量
    console.log(greeting(0)); //hello stranger
    //null字面量
    console.log(greeting(null)); //hello stranger
    //字符串字面量
    console.log(greeting('')); //hello stranger

最终开局随机生成两个方块功能代码如下

//随机生成两个不相同随机坐标
    let a = 0, c = 0
    function randomStart() {
      //生成的随机数不能重复 连续两次的话
      a = Math.floor(Math.random() * 5)  //0-5
      c = Math.floor(Math.random() * 5)
      if (c === a) {
        randomStart()
      }
    }
    //开局生成两个不重复的方块
    randomStart()
    Math.random() > 0.5 ? arr[0][a] = 2 : arr[a][0] = 2
    Math.random() > 0.5 ? arr[0][c] = 2 : arr[c][0] = 2

效果如下 随机

点击方向键移动方块

分析点击方向键之后方块变化

这里有上下左右四个方向 每个方向都要写移动逻辑的话代码冗余了 所以可以根据要旋转的方向不同对数组进行旋转处理 然后再左移 这时候再进行左移这个方向的合并判断 最后把数组旋转回去 移动处理完毕之后还需要生成随机新方块

这里的设计不太符合单一职责(一个函数 模块只负责一件事)事件监听承受了太多功能 目前还不知道怎么优化比较好

根据分析将功能拆分

  • 除了向左移动的情况 其他方向都是 点击键盘方向键之后先更改type的值再根据type调用rotate()函数旋转数组 move()往左移动 add()判断合并最后再rotate()旋转回去

这里的rotate()要传入具体的type参数 因为在上下移动过程中出现了顺时针和逆时针的旋转 这里将数组旋转回去的时候要传入的type就不同了

  • 向左移动的情况很简单 只需要直接move()移动再执行合并 移动完之后
  • 生成新随机方块 random()
  • 此时arr已经处理完毕 rander()渲染页面
  • 判断是否死亡 deathJudge() 键盘按键事件如下
let type = ''//移动方向
    //每次移动完生成一个边缘新方块
    document.addEventListener('keydown', function (e) {
      if (e.key === 'ArrowUp') {
        type = 'up'
        rotate('up')//旋转
        move()//移动
        add()
        rotate('down')//旋转回去
      }

      //这里处理错了down
      else if (e.key === 'ArrowDown') {
        type = 'down'
        rotate('down')//旋转
        move()//移动
        add()
        rotate('up')//旋转回去
      }
      else if (e.key === 'ArrowLeft') {
        type = 'left'
        move()
        add()
      }
      else if (e.key === 'ArrowRight') {
        type = 'right'
        rotate('right')//旋转
        move()//移动
        add()
        //因为这里是翻转 所以和之前一样处理翻转回去就行
        rotate('right')
      }
      //移动处理完毕生成新随机方块
      random()
      render()
      deathJudge()
    })

旋转处理

  • 如果有值就根据type不同旋转 right 这里相当于翻转 所以旋转回去只需要再翻转一次rotate('right')就行rightdown这里相当于顺时针旋转90度 所以旋转回去需要逆时针旋转90度也就是rotate(up)down

up这里相当于逆时针旋转90度 所以旋转回去需要顺时针旋转90度也就是rotate('down')up

  • 旋转完毕后 删除已经处理完旋转的元素 便于后面将旋转之后的数组给arr

这里删除处理也可以用delete 不会破坏索引!delete

  • 然后遍历rArr把数值给arr 再把rArr相应的元素删除方便下次rotate()旋转

放进新数组是为了防止旋转后的元素被重复旋转遍历 最终代码如下

  //翻转数组 变成左移
    function rotate(type) {
      console.log('rotate', type);
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            //翻转
            if (type === 'right') {
              //这里旋转结果必须放进新数组防止重复处理
              rArr[y][4 - x] = arr[y][x]
            }
            //顺时针90
            else if (type === 'down') {
              rArr[x][4 - y] = arr[y][x]
            }
            //逆时针90
            else if (type === 'up') {
              rArr[4 - x][y] = arr[y][x]
            }
            //删除原位置元素
            // arr[y][x] = 0
            delete arr[y][x]
          }
        }
      }
      //把旋转之后的数组给arr 方便转回去
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          if (rArr[j][i]) {
            arr[j][i] = rArr[j][i]
            delete rArr[j][i]
          }
        }
      }
      // console.log('rotate旋转处理之后的arr', arr);
    }

移动处理

旋转完毕后 数组只需要左移动就可以

  • 首先遍历arr数组 如果这行出现空位 !arr[y][x]true 表示 arr[y][x]为false也就是为空和0的时候 执行后面的语句
  • 遍历这一行空位后面的部分 这里循环的起始点是i = x 如果后面有值就给前面空位 这里记得删掉后面的值 然后直接break跳出空位后元素遍历循环继续对这一行进行空位搜索

补充说明breakcontinue的区别 break只跳出一层循环 这里相当于 x>=1的情况下都不执行内层循环了 breakbreakcontinue跳过当前 然后进行下一个迭代 这里相当于只有在x===1的情况下才不执行内层循环 其他情况是正常执行的 continuecontinue

    // 移动逻辑
    //所有数组都旋转处理成左移判断
    function move() {
      console.log('move');
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (!arr[y][x]) {
            //后面如果后面有值就给前面空位
            for (let i = x; i < 5; i++) {
              //只执行一次 找出最靠近空位的值
              if (arr[y][i]) {
                arr[y][x] = arr[y][i]
                arr[y][i] = 0
                //这里用哪个符号比较好
                break //跳出for i 循环么
              }
            }
          }
        }
      }
    }

点击方向键移动方块效果如下 移动

合并判断

这里合并判断也会根据方向不同 判断的方向不同 所以这里放在数组旋转回去之前 所以无论方向如何都是对左移进行合并判断

  • 合并这里是向左合并的 判断的是arr[j][i] 和右边相邻位置 arr[j][i + 1]的元素数值 这里的判断范围缩小i < 4 如果相等就左边方块数值累加然后右边方块数值清空

注意 这里只完成了数值的合并 合并完之后还要向左移动 如图例合并之后移动

  • 全部合并完之后要都向左移动move()
 //合并
    function add() {
      console.log('合并add', type, arr);
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 4; i++) {
          //相邻检测
          if (arr[j][i]) {
            if (arr[j][i] === arr[j][i + 1]) {
              console.log('找到左移方块', j, i);
              arr[j][i] += arr[j][i]
              arr[j][i + 1] = 0
            }
          }
        }
      }
      //全部合并完之后再向左移动
      move()
    }

合并效果如下 合并

移动完毕生成新随机方块

这些方块是从没有方块的坐标中随机生成的

  • scope数组表示没有方块的坐标 第一个索引表示方块序号可以用来随机选取方块 第二个索引表示方块的x或者y值 scope
  • 循环遍历arr把没有值的坐标放进scope数组
  • index表示随机的scope索引 范围为[0,scope.length-1]

Math.floor Math.random 表示从[a,b]随机整数 Math.floor(Math.random() * (b - a + 1)) + a; Math.random()随机生成[0,1)之间的随机小数 Math.random() * (b - a + 1) 生成[0,b-a+1) Math.floor 对小数向下取整得到[0,b-a]之间的整数 最后再加上a 范围偏移成[a,b]

  • 这里randomY表示随机到的y坐标 也就是scope[index][0]index个方块的第0个坐标
  • 最后随机生成的方块数值可能为2和4 继续用Math.random()随机生成的小数和0.5的大小比较决定随机生成方块的数值
//移动之后再随机生成一个方块 数值可能是4或者2
    function random() {
      let scope = []
      //因为可能后面没有值 长度实际上小于10
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          //方块不重复
          //把没有值的坐标放进数组 然后再随机索引进行选择
          if (!arr[j][i]) {
            scope.push([j, i])
          }
        }
      }
      //随机索引
      let index = Math.floor(Math.random() * scope.length)
      let randomY = scope[index][0]
      let randomX = scope[index][1]

      //随机数值
      Math.random() > 0.5 ? arr[randomY][randomX] = 2 : arr[randomY][randomX] = 4
    }

移动完毕生成新随机方块效果如下 新方块

死亡功能

死亡判断

开局只生成两个方块不需要判断 之后每次移动完毕都要判断一次

  • death表示是否出现死亡 Boolean类型初始值是true
  • 遍历arr 如果位置上元素没有值 就还没有死亡 death = false 这个是要找的非常态 出现这种情况就知道最终结果了 所以初始值是常态true 元素上有值还要继续往下找

这里不能用!arr[j].every((value) => value > 1) 因为every对稀疏数组的空槽是不执行的 在random()randomStart()给随机坐标赋值的时候造成了arr[j]是稀疏数组 会导致判断失误 补充说明 稀疏数组创建方式稀疏数组

  • 最终的death值就能表示死亡情况
  • 如果death === true就执行死亡效果
   //死亡判断
    function deathJudge() {
      let death = true //表示是否进入死亡
      for (let y = 0; y < 5; y++) {
        //如果这一行有空位 就跳出循环
        //这里不是表示每个都大于
        //不能用every因为稀疏数组不执行
        for (let x = 0; x < 5; x++) {
          //有一个位置上的元素没有值 就不是死亡
          if (!arr[y][x]) {
            death = false
          }
        }
      }
      console.log(death, '死亡判断');
      if (death === true) {
        deachCss()  //死亡效果
      }
    }

死亡效果

要找出方块数值最大值打印在游戏结束区域

  • maxArr数组用来放每行的最大值
  • maxX表示每行的最大值 找出来之后放进maxArr数组
  • arr[y]采用线性遍历 像直线一样逐个排查 这里默认每行的第一个元素为最大值 然后遍历后面的元素 如果比maxX大 就更新 把值给maxX

后面还会更新数组最大值的一些比较方法

  • 把每行的最大值pushmaxArr数组
  • 然后再对maxArr进行线性遍历 从而找到整个arr数组的最大值
  • 最后把最大数值渲染到页面
 function deachCss() {
      //找到数组中的最大数值
      let maxArr = []
      let maxX = 0
      for (let y = 0; y < 5; y++) {
        maxX = arr[y][0]
        for (let x = 1; x < 5; x++) {
          if (arr[y][x] > maxX) {
            maxX = arr[y][x]
          }
        }
        maxArr.push(maxX) //每行的最大
      }
      console.log(maxArr);
      //再从每行的最大里面找
      let max = maxArr[0]
      for (let a = 1; a < maxArr.length; a++) {
        if (max < maxArr[a]) {
          max = maxArr[a]
        }
      }
      //死亡效果
      death.innerText = `游戏结束 最大方块为${max}`
    }

死亡效果如下 死亡效果

最终代码

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

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

<body>
  <!-- 页面 -->
  <div class="area"></div>
  <!-- 死亡提示区域 -->
  <div class="death"></div>
  <style>
    .area {
      display: flex;
      flex-wrap: wrap;
      width: 200px;
      height: 200px;
      font-size: 25px;
    }
  </style>
  <script>
    const area = document.querySelector('.area')
    const death = document.querySelector('.death')
    let arr = [] //方块坐标和数值
    let rArr = [] //旋转数组
    //初始化
    for (let i = 0; i < 5; i++) {
      arr[i] = []
      rArr[i] = []
    }
    deathJudge() //开局只随机生成两个方块不需要判断死亡

    //随机生成两个不相同随机坐标
    let a = 0, c = 0
    function randomStart() {
      //生成的随机数不能重复 连续两次的话
      a = Math.floor(Math.random() * 5)  //0-5
      c = Math.floor(Math.random() * 5)
      if (c === a) {
        randomStart()
      }
    }
    //开局生成两个不重复的方块
    randomStart()
    Math.random() > 0.5 ? arr[0][a] = 2 : arr[a][0] = 2
    Math.random() > 0.5 ? arr[0][c] = 2 : arr[c][0] = 2

    //移动之后再随机生成一个方块 数值可能是4或者2
    function random() {
      let scope = []
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          //方块不重复
          //把没有值的坐标放进数组 然后再随机索引进行选择
          if (!arr[j][i]) {
            scope.push([j, i])
          }
        }
      }
      //随机索引
      let index = Math.floor(Math.random() * scope.length)
      let randomY = scope[index][0]
      let randomX = scope[index][1]

      //随机数值
      Math.random() > 0.5 ? arr[randomY][randomX] = 2 : arr[randomY][randomX] = 4
    }


    let type = ''//移动方向
    //每次移动完生成一个边缘新方块

    document.addEventListener('keydown', function (e) {
      if (e.key === 'ArrowUp') {
        type = 'up'
        rotate('up')//旋转
        move()//移动
        add()
        rotate('down')//旋转回去

      }

      //这里处理错了down
      else if (e.key === 'ArrowDown') {
        type = 'down'
        rotate('down')//旋转
        move()//移动
        add()
        rotate('up')//旋转回去

      }
      else if (e.key === 'ArrowLeft') {
        type = 'left'
        move()
        add()
      }
      else if (e.key === 'ArrowRight') {
        type = 'right'
        rotate('right')//旋转
        move()//移动
        add()
        //因为这里是翻转 所以和之前一样处理翻转回去就行
        rotate('right')
      }
      //移动处理完毕生成新随机方块
      random()
      render()
      deathJudge()
    })

    //翻转数组 变成左移
    function rotate(type) {
      console.log('rotate', type);
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            //翻转
            if (type === 'right') {
              //这里旋转结果必须放进新数组防止重复处理
              rArr[y][4 - x] = arr[y][x]
            }
            //顺时针90
            else if (type === 'down') {
              rArr[x][4 - y] = arr[y][x]
            }
            //逆时针90
            else if (type === 'up') {
              rArr[4 - x][y] = arr[y][x]
            }
            //删除原位置元素
            // arr[y][x] = 0
            delete arr[y][x]
          }
        }
      }
      //把旋转之后的数组给arr 方便转回去
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          if (rArr[j][i]) {
            arr[j][i] = rArr[j][i]
            delete rArr[j][i]
          }
        }
      }
      // console.log('rotate旋转处理之后的arr', arr);
    }


    // 移动逻辑
    //所有数组都旋转处理成左移判断
    function move() {
      console.log('move');
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (!arr[y][x]) {
            //后面如果后面有值就给前面空位
            for (let i = x; i < 5; i++) {
              //只执行一次 找出最靠近空位的值
              if (arr[y][i]) {
                arr[y][x] = arr[y][i]
                arr[y][i] = 0
                //这里用哪个符号比较好
                break //跳出for i 循环么
              }
            }
          }
        }
      }
    }

    //合并
    function add() {
      console.log('合并add', type, arr);
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 4; i++) {
          //相邻检测
          if (arr[j][i]) {
            if (arr[j][i] === arr[j][i + 1]) {
              console.log('找到左移方块', j, i);
              arr[j][i] += arr[j][i]
              arr[j][i + 1] = 0
            }
          }
        }
      }
      //全部合并完之后再向左移动
      move()
    }

    //死亡判断
    function deathJudge() {
      let death = true //表示是否进入死亡
      for (let y = 0; y < 5; y++) {
        //如果这一行有空位 就跳出循环
        //这里不是表示每个都大于
        //不能用every因为稀疏数组不执行
        for (let x = 0; x < 5; x++) {
          //有一个位置上的元素没有值 就不是死亡
          if (!arr[y][x]) {
            death = false
          }
        }
      }
      console.log(death, '死亡判断');
      if (death === true) {
        deachCss()  //死亡效果
      }
    }


    function deachCss() {
      //找到数组中的最大数值
      let maxArr = []
      let maxX = 0
      for (let y = 0; y < 5; y++) {
        maxX = arr[y][0]
        for (let x = 1; x < 5; x++) {
          if (arr[y][x] > maxX) {
            maxX = arr[y][x]
          }
        }
        maxArr.push(maxX) //每行的最大
      }
      console.log(maxArr);
      //再从每行的最大里面找
      let max = maxArr[0]
      for (let a = 1; a < maxArr.length; a++) {
        if (max < maxArr[a]) {
          max = maxArr[a]
        }
      }
      //死亡效果
      death.innerText = `游戏结束 最大方块为${max}`
    }


    //渲染页面
    function render() {
      block = ''
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            block += `<div class="square"style="width: 40px;height: 40px;text-align: center; line-height: 40px; background-color: pink;">${arr[y][x]}</div>`
          }
          else {
            block += ' <div class="block" style="width: 40px;height: 40px;background-color: antiquewhite;"></div>'

          }
        }
      }
      area.innerHTML = block
    }
    render()


  </script>
</body>

</html>

JavaScript-实现函数方法-改变this指向call apply bind

this

  • 函数执行时决定的不是定义时决定的
  • this只和函数的调用有关*
  • obj.fn() fnthis就是obj arr[0] () this就是arr

重定义

call

  • 会直接执行函数
  • 函数的参数逐个传进去 call
实现
  • 首先先判断thisArg参数 如果为空就默认window 这里运用了||运算符 有一个t结果就为t 所以短路效果为前面的表达式结果为t 后面的表达式就不执行

补充说明&&运算符 前面一个f结果就为f 所以短路效果是前面为f后面不执行

  • symbol(基本数据类型)有唯一性 可以保证属性与原对象不冲突 let key = Symbol('temp')创建一个symbol类型的变量key其中temp是这个变量的描述符
  • 利用对象的方法中的this指向对象 :通过把这个函数给thisArg对象的方法再通过这个对象调用就改变了函数this的指向 其中利用数组的展开运算符把value(剩余参数数组)挨个传进这个方法完成了mycall改变this指向和直接执行函数的功能
    //实现call
    Function.prototype.myCall = function (thisArg, ...value) {
      //如果参数为空 就默认是window
      //利用||特性
      thisArg = thisArg || window

      let key = Symbol('temp')
      thisArg[key] = this
      //利用...数组的展开运算符把value的所有元素 逐个全部传入
      thisArg[key](...value)
      delete thisArg[key]

    }
  • 测试用例
//测试用例
    //this指向对象
    function fnCall(a, b) {
      console.log(this, a, b);
    }
    fnCall.myCall(obj, 1, 2)
    //this指向函数
    function fn1() {
      console.log('这是测试函数');
    }
    fnCall.myCall(fn1, 1, 2)

apply

  • 也会直接执行函数
  • 参数以对象(一般是数组的形式传进去) apply
实现

mycall的基本思想是一样的区别如下

  • 因为参数是数组形式 所以myApply中的value参数不需要用剩余参数数组...value直接获取传入的数组value即可 然后还是使用...展开运算符把参数数组展开传入
 //实现apply
     Function.prototype.myApply = function (thisArg, value) {
      thisArg = thisArg || window
      let key = Symbol('temp')
      thisArg[key] = this
      //利用...数组的展开运算符把value的所有元素 逐个全部传入
      thisArg[key](...value)
      delete thisArg[key]
    }
    fnCall.myApply(obj, [1, 2])
  • 测试用例同mycall

bind

  • 参数逐个传入
  • 返回新数组 调用之后才执行(参数自动传进去 this改变) bind
实现

不会立即执行函数 而是返回新的函数

  • 因为返回的新函数中的this就不是调用函数了 所以要把this放进fn函数里
  • 返回一个新函数 先给thisArg对象添加fn方法(就是要改变this的函数) 然后把原函数的返回结果也就是对象方法的结果原函数的返回结果放进result作为新函数的返回结果 删除属性避免对原thisArg造成干扰 最后把result变量return出去

举例子(没有this干扰)理解result变量的必要性 没有result 这里把内部函数的返回值return出去 就能实现调用outside函数返回一个inside函数并且把他的她的返回值也复制了有result最终代码

 //实现bind
    //创建新函数 this绑定到指定对象上
    Function.prototype.myBind = function (thisArg, ...value) {
      //不会立即执行函数 而是返回新的函数
      thisArg = thisArg || window
      let fn = this //把调用的函数存起来
      return function () {
        let key = Symbol('temp') //每次调用都创建新的临时键
        thisArg[key] = fn
        const result = thisArg[key](...value)
        delete thisArg.fn
        return result  //返回原函数的执行结果
      }
    }
  • 测试用例
//生成新的函数
    function bindFn(a, b) {
      console.log('myBind结果', this, a, b);
    }
    let Person = {
      name: 'a'
    }
    const newBindfn = bindFn.myBind(Person, 2, 1)
//传入thisArg为空的情况
    const newBindfn1 = bindFn.myBind('', 2, 3)  //this指向window
//新生成的函数返回值
    function fn() {
      return '函数返回结果'
    }
    console.log(fn.myBind(Person)());//函数返回结果

第4章:布局类组件 —— 4.8 LayoutBuilder、AfterLayout

4.8 LayoutBuilder、AfterLayout

📚 章节概览

本章节是第4章的最后一节,将学习如何在布局过程中动态构建UI,以及如何获取组件的实际尺寸和位置:

  • LayoutBuilder - 布局过程中获取约束信息
  • BoxConstraints - 约束信息详解
  • 响应式布局 - 根据约束动态构建
  • AfterLayout - 布局完成后获取尺寸
  • RenderAfterLayout - 自定义RenderObject
  • localToGlobal - 坐标转换
  • Build和Layout - 交错执行机制

🎯 核心知识点

LayoutBuilder vs AfterLayout

特性 LayoutBuilder AfterLayout
执行时机 布局阶段(Layout) 布局完成后(Post-Layout)
获取信息 约束信息(BoxConstraints) 实际尺寸和位置
主要用途 响应式布局 尺寸获取
性能 较好 稍差(额外回调)

1️⃣ LayoutBuilder(布局构建器)

1.1 什么是LayoutBuilder

LayoutBuilder 可以在布局过程中拿到父组件传递的约束信息(BoxConstraints),然后根据约束信息动态地构建不同的布局。

1.2 构造函数

LayoutBuilder({
  Key? key,
  required Widget Function(BuildContext, BoxConstraints) builder,
})

1.3 基础用法

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    // 打印约束信息(调试用)
    print('LayoutBuilder约束: $constraints');
    print('  maxWidth: ${constraints.maxWidth}');
    print('  maxHeight: ${constraints.maxHeight}');
    
    // constraints包含父组件传递的约束信息
    if (constraints.maxWidth > 600) {
      return DesktopLayout();
    } else {
      return MobileLayout();
    }
  },
)

控制台输出示例:

LayoutBuilder约束: BoxConstraints(0.0<=w<=392.7, 0.0<=h<=Infinity)
  maxWidth: 392.7272644042969
  maxHeight: Infinity

1.4 BoxConstraints(约束信息)

class BoxConstraints {
  final double minWidth;   // 最小宽度
  final double maxWidth;   // 最大宽度
  final double minHeight;  // 最小高度
  final double maxHeight;  // 最大高度
  
  bool get isTight;        // 是否为固定约束
  bool get isNormalized;   // 是否标准化
  // ... 更多方法
}

常用属性:

  • minWidth / maxWidth:宽度范围
  • minHeight / maxHeight:高度范围
  • isTight:是否固定尺寸(min == max)
  • biggest:最大可用尺寸
  • smallest:最小可用尺寸

2️⃣ 响应式布局实战

2.1 响应式Column

根据可用宽度动态切换单列/双列布局:

class ResponsiveColumn extends StatelessWidget {
  const ResponsiveColumn({super.key, required this.children});

  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        if (constraints.maxWidth < 200) {
          // 最大宽度小于200,显示单列
          return Column(
            children: children,
            mainAxisSize: MainAxisSize.min,
          );
        } else {
          // 大于200,显示双列
          var widgets = <Widget>[];
          for (var i = 0; i < children.length; i += 2) {
            if (i + 1 < children.length) {
              widgets.add(Row(
                children: [children[i], children[i + 1]],
                mainAxisSize: MainAxisSize.min,
              ));
            } else {
              widgets.add(children[i]);
            }
          }
          return Column(
            children: widgets,
            mainAxisSize: MainAxisSize.min,
          );
        }
      },
    );
  }
}

使用示例:

ResponsiveColumn(
  children: [
    Text('Item 1'),
    Text('Item 2'),
    Text('Item 3'),
    Text('Item 4'),
  ],
)

2.2 响应式断点

常见的响应式断点:

enum DeviceType { mobile, tablet, desktop }

DeviceType getDeviceType(double width) {
  if (width < 600) {
    return DeviceType.mobile;    // 手机
  } else if (width < 1200) {
    return DeviceType.tablet;    // 平板
  } else {
    return DeviceType.desktop;   // 桌面
  }
}

// 使用
LayoutBuilder(
  builder: (context, constraints) {
    final deviceType = getDeviceType(constraints.maxWidth);
    
    switch (deviceType) {
      case DeviceType.mobile:
        return MobileLayout();
      case DeviceType.tablet:
        return TabletLayout();
      case DeviceType.desktop:
        return DesktopLayout();
    }
  },
)

2.3 自适应网格

根据宽度自动调整列数:

LayoutBuilder(
  builder: (context, constraints) {
    // 计算列数
    final cardWidth = 120.0;
    final spacing = 8.0;
    final columns = (constraints.maxWidth / (cardWidth + spacing))
        .floor()
        .clamp(1, 6);  // 最少1列,最多6列
    
    return GridView.builder(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: columns,
        crossAxisSpacing: spacing,
        mainAxisSpacing: spacing,
      ),
      itemBuilder: (context, index) => Card(...),
    );
  },
)

3️⃣ AfterLayout(布局后回调)

3.1 什么是AfterLayout

AfterLayout 是一个自定义组件,用于在布局完成后获取组件的实际尺寸和位置信息。

3.2 实现原理

通过自定义 RenderObject,在 performLayout 方法中添加回调:

class AfterLayout extends SingleChildRenderObjectWidget {
  const AfterLayout({
    super.key,
    required this.callback,
    super.child,
  });

  final ValueChanged<RenderAfterLayout> callback;

  @override
  RenderAfterLayout createRenderObject(BuildContext context) {
    return RenderAfterLayout(callback: callback);
  }

  @override
  void updateRenderObject(
    BuildContext context,
    RenderAfterLayout renderObject,
  ) {
    renderObject.callback = callback;
  }
}

class RenderAfterLayout extends RenderProxyBox {
  RenderAfterLayout({required this.callback});

  ValueChanged<RenderAfterLayout> callback;

  @override
  void performLayout() {
    super.performLayout();
    // 布局完成后触发回调
    WidgetsBinding.instance.addPostFrameCallback((_) {
      callback(this);
    });
  }

  /// 获取组件在屏幕中的偏移坐标
  Offset get offset => localToGlobal(Offset.zero);
}

3.3 基础用法

AfterLayout(
  callback: (RenderAfterLayout ral) {
    print('AfterLayout回调:');
    print('  尺寸: ${ral.size}');        // Size(105.0, 17.0)
    print('  位置: ${ral.offset}');      // Offset(42.5, 290.0)
  },
  child: Text('flutter@wendux'),
)

控制台输出:

AfterLayout回调:
  尺寸: Size(105.0, 17.0)
  位置: Offset(42.5, 290.0)

3.4 获取相对坐标

使用 localToGlobal 方法获取相对于某个父组件的坐标:

Builder(builder: (context) {
  return Container(
    color: Colors.grey.shade200,
    width: 100,
    height: 100,
    child: AfterLayout(
      callback: (RenderAfterLayout ral) {
        // 获取相对于Container的坐标
        Offset offset = ral.localToGlobal(
          Offset.zero,
          ancestor: context.findRenderObject(),
        );
        print('占用空间范围: ${offset & ral.size}');
      },
      child: Text('A'),
    ),
  );
})

4️⃣ RenderAfterLayout详解

4.1 继承关系

RenderObject
    ↓
RenderBox
    ↓
RenderProxyBox
    ↓
RenderAfterLayout

4.2 主要方法

方法/属性 说明 返回值
size 组件尺寸 Size
offset 屏幕坐标 Offset
localToGlobal(Offset) 转换为全局坐标 Offset
localToGlobal(..., ancestor) 转换为相对坐标 Offset
paintBounds 绘制边界 Rect

4.3 坐标转换

// 转换为屏幕坐标
Offset screenOffset = ral.localToGlobal(Offset.zero);

// 转换为相对于ancestor的坐标
Offset relativeOffset = ral.localToGlobal(
  Offset.zero,
  ancestor: ancestorRenderObject,
);

// 计算占用空间
Rect bounds = offset & size;  // Rect.fromLTWH(x, y, width, height)

5️⃣ Build和Layout的交错执行

5.1 执行流程

graph TB
    A[开始Build] --> B[遇到LayoutBuilder]
    B --> C[进入Layout阶段]
    C --> D[执行LayoutBuilder.builder]
    D --> E[返回新Widget]
    E --> F[继续Build新Widget]
    F --> G[完成]
    
    style A fill:#e1f5ff
    style C fill:#ffe1e1
    style F fill:#e1f5ff

关键点:

  • Build 和 Layout 不是严格按顺序执行的
  • LayoutBuilder 的 builder 在 Layout 阶段执行
  • builder 中可以返回新 Widget,触发新的 Build

5.2 执行顺序示例

print('1. 开始Build');

LayoutBuilder(
  builder: (context, constraints) {
    print('3. 执行LayoutBuilder.builder(Layout阶段)');
    return Column(
      children: [
        Text('Hello'),  // 4. 触发新的Build
      ],
    );
  },
)

print('2. LayoutBuilder创建完成');

输出顺序:

1. 开始Build
2. LayoutBuilder创建完成
3. 执行LayoutBuilder.builder(Layout阶段)
4. Build Text Widget

🤔 常见问题(FAQ)

Q1: LayoutBuilder和MediaQuery的区别?

A:

特性 LayoutBuilder MediaQuery
获取信息 父组件约束 屏幕尺寸
作用范围 当前组件 全局
响应变化 父约束变化 屏幕尺寸变化
使用场景 组件级响应式 全局响应式
// LayoutBuilder - 父组件约束
LayoutBuilder(
  builder: (context, constraints) {
    // constraints来自父组件
    return Text('宽度: ${constraints.maxWidth}');
  },
)

// MediaQuery - 屏幕尺寸
final screenWidth = MediaQuery.of(context).size.width;

Q2: 如何在StatefulWidget中使用AfterLayout?

A: 使用 addPostFrameCallback 避免在 build 中调用 setState

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  Size _size = Size.zero;

  @override
  Widget build(BuildContext context) {
    return AfterLayout(
      callback: (RenderAfterLayout ral) {
        // ✅ 正确:使用 addPostFrameCallback
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (mounted) {
            setState(() {
              _size = ral.size;
            });
          }
        });
      },
      child: Text('Hello'),
    );
  }
}

Q3: LayoutBuilder的builder何时执行?

A: 在以下情况会执行:

  1. 首次布局:组件首次被添加到树中
  2. 约束变化:父组件传递的约束发生变化
  3. 重新布局:调用 markNeedsLayout()
LayoutBuilder(
  builder: (context, constraints) {
    print('Builder执行,约束: $constraints');
    return Container();
  },
)

Q4: 如何优化LayoutBuilder性能?

A:

  1. 避免过度嵌套
  2. 缓存计算结果
  3. 使用const构造函数
LayoutBuilder(
  builder: (context, constraints) {
    // ❌ 每次都创建新Widget
    return Column(
      children: [
        Text('Item 1'),
        Text('Item 2'),
      ],
    );
    
    // ✅ 使用const
    return const Column(
      children: [
        Text('Item 1'),
        Text('Item 2'),
      ],
    );
  },
)

Q5: AfterLayout会影响性能吗?

A: 会有轻微影响,因为:

  1. 额外的回调开销
  2. 可能触发额外的 setState
  3. 每次布局都会执行回调

优化建议:

  • 只在必要时使用
  • 避免在回调中进行重量级操作
  • 使用防抖/节流

🎯 跟着做练习

练习1:实现一个响应式导航栏

目标: 宽度>600显示完整标签,否则显示图标

步骤:

  1. 使用 LayoutBuilder
  2. 判断 constraints.maxWidth
  3. 返回不同的UI
💡 查看答案
class ResponsiveNavigationBar extends StatelessWidget {
  const ResponsiveNavigationBar({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final showLabels = constraints.maxWidth > 600;
        
        return Container(
          height: 60,
          color: Colors.blue,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildNavItem(
                icon: Icons.home,
                label: '首页',
                showLabel: showLabels,
              ),
              _buildNavItem(
                icon: Icons.search,
                label: '搜索',
                showLabel: showLabels,
              ),
              _buildNavItem(
                icon: Icons.person,
                label: '我的',
                showLabel: showLabels,
              ),
            ],
          ),
        );
      },
    );
  }

  Widget _buildNavItem({
    required IconData icon,
    required String label,
    required bool showLabel,
  }) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: Colors.white),
        if (showLabel) ...[
          const SizedBox(height: 4),
          Text(
            label,
            style: const TextStyle(color: Colors.white, fontSize: 12),
          ),
        ],
      ],
    );
  }
}

练习2:实现文本溢出检测

目标: 检测Text是否溢出,显示"展开"按钮

步骤:

  1. 使用 AfterLayout 获取Text尺寸
  2. 计算是否溢出
  3. 显示/隐藏展开按钮
💡 查看答案
class ExpandableText extends StatefulWidget {
  const ExpandableText({super.key, required this.text});

  final String text;

  @override
  State<ExpandableText> createState() => _ExpandableTextState();
}

class _ExpandableTextState extends State<ExpandableText> {
  bool _expanded = false;
  bool _isOverflow = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        AfterLayout(
          callback: (RenderAfterLayout ral) {
            // 检查是否溢出
            final textPainter = TextPainter(
              text: TextSpan(text: widget.text),
              maxLines: _expanded ? null : 3,
              textDirection: TextDirection.ltr,
            )..layout(maxWidth: ral.size.width);

            WidgetsBinding.instance.addPostFrameCallback((_) {
              if (mounted) {
                setState(() {
                  _isOverflow = textPainter.didExceedMaxLines;
                });
              }
            });
          },
          child: Text(
            widget.text,
            maxLines: _expanded ? null : 3,
            overflow: TextOverflow.ellipsis,
          ),
        ),
        if (_isOverflow)
          TextButton(
            onPressed: () {
              setState(() {
                _expanded = !_expanded;
              });
            },
            child: Text(_expanded ? '收起' : '展开'),
          ),
      ],
    );
  }
}

📋 小结

核心概念

组件 用途 执行时机
LayoutBuilder 获取约束,响应式布局 Layout阶段
AfterLayout 获取尺寸和位置 Layout完成后
BoxConstraints 约束信息 Layout阶段传递

LayoutBuilder使用场景

场景 示例
响应式布局 根据宽度显示不同UI
自适应网格 动态调整列数
断点设计 手机/平板/桌面切换
动态组件 根据空间大小选择组件

AfterLayout使用场景

场景 示例
尺寸获取 获取组件实际大小
位置计算 计算组件坐标
溢出检测 判断Text是否溢出
动画准备 获取起始位置

记忆技巧

  1. LayoutBuilder:Layout阶段构建UI
  2. AfterLayout:Layout之后获取信息
  3. Build和Layout:可以交错执行
  4. BoxConstraints:约束向传递
  5. RenderObject:渲染树的节点

🔗 相关资源


JavaScript-小游戏-单词消消乐

需求

生成六个按钮 按钮上的内容随机生成 点到匹配的按钮 那两个按钮就隐藏 (之后会做从单词库随机选取单词的进阶版消消乐)

游戏界面

标签结构(html)

创建一个div类名为game 里面嵌套了六个按钮

 <div class="game">
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
  </div>

层叠样式(css)

  • 先用通配符选择器 * 匹配页面中所有的元素清除默认边距和边框
  • 设置button的宽高 写border-radius设计成圆角的按钮 背景色粉色 字体上网找了一个萌一点的字体 字的颜色为白色
 /* 清除默认样式 */
    * {
      padding: 0;
      margin: 0;
      border: 0;
    }

    button {
      width: 100px;
      height: 100px;
      border-radius: 15%;
      background-color: pink;
      font-size: 15px;
      color: white;
      font-family: Verdana, sans-serif;
    }

获取元素

界面结构写好之后获取div区域 获取按钮用的是querySelectorAll将页面中所有的按钮都获取 这里获得的是NodeList 类数组

document.querySelectorAll('div')得到的数组是div元素

 const btns = document.querySelectorAll('div')
    console.log(btns);  //NodeList [div.game]

获取元素代码如下

//获取
    let game = document.querySelector('.game')
    let buttons = document.querySelectorAll('button')

游戏界面如下 在这里插入图片描述

数据分析

判断数组

用两个数组a和b分别存放英文和中文 确保这两个数组对应的单词和翻译的索引是一样的 定义的

这两个数组可以简化代码 在判断消除的时候不需要把每个可能都罗列上去 而且可以直接拼接数组 为后面打乱顺序使用

每次刷新按钮上都会打乱顺序生成这些英文和中文 所以另一个数组arr由英文数组a和中文数组b拼接而成

  //用来对应判断的数组
    let a = ['understand', 'peace', 'forget']
    let b = ['理解', '和平', '忘记']
    //用来打乱的数组
    let arr = a.concat(b)

功能实现

按钮内容随机

随机打乱数组

采用洗牌法 从后往前遍历 当前元素和自己以及前面的元素随机交换 循环的示意图如下 在这里插入图片描述

循环过程中 交换范围[0-i]是闭区间的原因
  • 如果是[0-i) 不包含自身来交换 就会打乱平衡在这里插入图片描述

  • [0-i]是闭区间保证了元素在每个位置的概率均等 而且每个元素在某个位置的概率也均等![[1958b7eac80038003f846ec6bc8514e2.png]]

交换

传统的元素交换方式就是创建一个temp变量 交换本质上不会改变值 只会改变空间的指向 值可以被多个空间指向 ![[Pasted image 20251021105630.png]]

以下是数组元素交换的代码举例

 arr = [0, 1]
    //元素交换
    let t = arr[0]
    arr[0] = arr[1]
    arr[1] = t
    console.log(arr, t);  //[1, 0] 0
随机打乱数组最终代码如下

这里采用的是传统的交换方式

  //从后往前遍历 拿到的元素和前面的随机交换
    for (let i = arr.length - 1; i > 0; i--) {
      let r = Math.floor(Math.random() * i)
      //元素交换
      let t = arr[i]
      arr[i] = arr[r]
      arr[r] = t
    }

渲染到按钮上

设置buttons数组元素的innerText属性为随机打乱后的数组 渲染到按钮上

 //随机交换之后渲染到页面
    for (let i = 0; i < arr.length; i++) {
      buttons[i].innerText = arr[i]
    }

最终效果如下 点击刷新按钮之后随机打乱的数组会渲染到按钮上 完成了按钮随机功能 ![[刷新.gif]]

消除功能

数据分析

一开始存在了两个数组里 后面发现存在对象里更合适

对象是一种无序的数据集合 有属性和方法 这里采用的是属性来存储 属性是以键值对(Key-Value)的形式存在的每个属性由一个键(key)和一个与之关联的值(value)组成。 数组是特殊的对象 属性名是索引是有序的所以是有序的数据集合

按钮内容存为对象属性的key用于判断中英文是否匹配,对应的e.target作为value用于匹配成功之后隐藏按钮 都存在对象里 当判断完毕之后清空对象也更方便

点击事件

btns是NodeList类数组 可以用forEach方法遍历数组然后添加点击事件

    //map给按钮添加点击事件
    btns.forEach((value) => {
      value.addEventListener('click', () => {
        console.log('click');
      })
    }
    )

以下是遍历数组然后添加点击事件效果图示 在这里插入图片描述

类数组中没有map方法 如果要使用map方法 需要先 Array.from把btns转换为数组

 //map给按钮添加点击事件
    Array.from(btns).map((value, index) => {
      value.addEventListener('click', () => {
        console.log('click');
      })
    }
    )
  • 最终利用for循环把所有的按钮都添加点击事件
  • 对象的e.target.innerText属性对应值为e.target 属性名用于判断 属性值用于判断成功之后隐藏对应的按钮
  • 如果judge方法判断匹配 返回true 就把相应的e.target的可见值改为hidden
  • 然后清空对象 便于下次判断
let obj = {}  //把内容和按钮存在对象里
    for (let i = 0; i < buttons.length; i++) {
      buttons[i].addEventListener('click', (e) => {
        obj[e.target.innerText] = e.target //重复点击也不会有重复的元素
        //点击两个不同的元素才判断
        if (judge()) {
          Object.values(obj)[0].style.visibility = 'hidden'
          Object.values(obj)[1].style.visibility = 'hidden'
          obj = {}
        }
      })
    }

判断方法

  • 遍历a英文数组或者b中文数组 (这里遍历其中任意一个数组就可以 因为这两个数组索引是相对应的 长度也相等) 不需要每种可能都罗列上去
  • 调用Object.keys(obj)获取这个对象中所有的属性名 返回一个数组
  • 如果这个数组中有对应的英文且也有对应的中文 就返回true

这里判断条件改为arr.some((value) => value === a[i] && value===b[i])是不行因为这里的value表示的是当前的元素这个表达式的返回值固定为false当前的元素不可能又等于a[i]又等于b[i] 所以只能分别判断 都为真就返回真

 //判断方法
    function judge() {
      for (let i = 0; i < a.length; i++) {
        if (Object.keys(obj).some((values) => values === a[i])
          && Object.keys(obj).some((values) => values === b[i])) {
          return true
        }
      }
    }

消除功能最终效果如下

![[最终 1.gif]]

最终代码


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

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>单词消消乐</title>
</head>

<body>
  <div class="game">
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
  </div>
  <style>
    /* 清除默认样式 */
    * {
      padding: 0;
      margin: 0;
      border: 0;
    }

    button {
      width: 100px;
      height: 100px;
      border-radius: 15%;
      background-color: pink;
      font-size: 15px;
      color: white;
      font-family: Verdana, sans-serif;
    }
  </style>

  <script>
    //获取
    let game = document.querySelector('.game')
    let buttons = document.querySelectorAll('button')
    const btns = document.querySelectorAll('div')
    console.log(btns);  //NodeList [div.game]

    //用来对应判断的数组
    let a = ['understand', 'peace', 'forget']
    let b = ['理解', '和平', '忘记']
    //用来打乱的数组
    let arr = a.concat(b)


    //1每次打开页面 按钮位置随机
    //从后往前遍历 拿到的元素和前面的随机交换
    for (let i = arr.length - 1; i > 0; i--) {
      let r = Math.floor(Math.random() * i)
      //元素交换
      let t = arr[i]
      arr[i] = arr[r]
      arr[r] = t
    }

    //随机交换之后渲染到页面
    for (let i = 0; i < arr.length; i++) {
      buttons[i].innerText = arr[i]
    }


    //2 点击对应的两个按钮 就消除
    let obj = {}  //把内容和按钮存在对象里
    for (let i = 0; i < buttons.length; i++) {
      buttons[i].addEventListener('click', (e) => {
        obj[e.target.innerText] = e.target //重复点击也不会有重复的元素
        //点击两个不同的元素才判断
        if (judge()) {
          Object.values(obj)[0].style.visibility = 'hidden'
          Object.values(obj)[1].style.visibility = 'hidden'
          obj = {}
        }
      })
    }

    //判断方法
    function judge() {
      for (let i = 0; i < a.length; i++) {
        if (Object.keys(obj).some((values) => values === a[i])
          && Object.keys(obj).some((values) => values === b[i])) {
          return true
        }
      }
    }

  </script>

</body>

</html>

干了10年前端,才学会使用IntersectionObserver

IntersectionObserver 是 JavaScript 原生 API,用于异步监听目标元素与视口(或指定容器元素)的交叉状态(即元素是否进入 / 离开视口、交叉比例多少)。核心优势是性能优异(浏览器原生优化,避免 scroll 事件的高频触发),常见场景:懒加载图片 / 视频、滚动加载列表、曝光统计、元素进入视口时触发动画等。

一、核心概念

  1. 目标元素(target) :需要监听的 DOM 元素(如图片、列表项)。
  2. 根元素(root) :作为 “视口” 的参考容器,默认是浏览器视口(null),必须是目标元素的祖先元素。
  3. 根边界(rootMargin) :根元素的 “扩展 / 收缩边距”,用于提前 / 延迟触发监听(如提前 100px 检测元素即将进入视口)。
  4. 阈值(threshold) :触发回调的 “交叉比例阈值”(0~1),可传单个值或数组(如 [0, 0.5, 1] 表示元素刚进入、一半进入、完全进入时都触发)。
  5. 交叉状态(intersectionRatio) :目标元素与根元素的交叉比例(0 = 完全不交叉,1 = 完全交叉)。

二、基本使用步骤

1. 语法结构

// 1. 创建观察器实例,传入回调函数和配置项
const observer = new IntersectionObserver((entries, observer) => {
  // entries:所有被监听元素的交叉状态数组(每个元素是 IntersectionObserverEntry 对象)
  entries.forEach(entry => {
    // entry:单个元素的交叉状态信息
    if (entry.isIntersecting) {
      // 元素进入视口(交叉比例 > 0)
      console.log('元素进入视口', entry.target);
      // 执行业务逻辑(如懒加载、触发动画)
      // 可选:只监听一次,触发后取消观察
      observer.unobserve(entry.target);
    } else {
      // 元素离开视口(交叉比例 = 0)
      console.log('元素离开视口', entry.target);
    }
  });
}, {
  root: null, // 根元素,默认视口(null)
  rootMargin: '0px', // 根元素边距(格式:上 右 下 左,支持 px/%)
  threshold: 0 // 阈值(默认 0,元素刚进入视口时触发)
});

// 2. 监听目标元素(可监听多个)
const target1 = document.querySelector('.target1');
const target2 = document.querySelector('.target2');
observer.observe(target1);
observer.observe(target2);

// 3. 可选:停止监听单个元素
observer.unobserve(target1);

// 4. 可选:销毁观察器(所有监听都停止)
observer.disconnect();

2. 关键参数详解

参数 说明
entries 数组,每个元素是 IntersectionObserverEntry 对象,包含单个目标的交叉信息:- isIntersecting:布尔值,是否正在交叉(进入视口)- intersectionRatio:交叉比例(0~1)- target:被监听的 DOM 元素- boundingClientRect:目标元素的位置信息- rootBounds:根元素的位置信息
root 参考容器(DOM 元素),默认 null(浏览器视口),必须是目标元素的祖先。
rootMargin 根元素的边距,用于扩展 / 收缩根元素的 “有效视口”,格式同 CSS 边距。例:rootMargin: '50px 0px' → 根元素上下扩展 50px,提前 50px 触发监听。
threshold 触发回调的交叉比例阈值,可传数组:- threshold: 0(默认)→ 元素刚进入视口(交叉比例 >0)时触发- threshold: 1 → 元素完全进入视口(交叉比例 =1)时触发- threshold: [0, 0.5, 1] → 元素刚进入、一半进入、完全进入时各触发一次

三、常见场景示例

示例 1:图片懒加载(核心场景)

需求:页面滚动时,图片进入视口后再加载真实图片(优化首屏加载速度)。

<!-- HTML:占位图 + 真实图片地址存放在 data-src 属性 -->
<img class="lazy-img" src="placeholder.jpg" data-src="real-img1.jpg" alt="懒加载图片">
<img class="lazy-img" src="placeholder.jpg" data-src="real-img2.jpg" alt="懒加载图片">
// 1. 创建观察器
const lazyLoadObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // 元素进入视口,加载真实图片
      const img = entry.target;
      img.src = img.dataset.src; // 替换 src 为真实地址
      img.classList.add('loaded'); // 可选:添加加载完成样式
      lazyLoadObserver.unobserve(img); // 只加载一次,取消监听
    }
  });
}, {
  rootMargin: '100px 0px', // 提前 100px 开始加载(优化体验,避免白屏)
  threshold: 0.1 // 元素 10% 进入视口时触发
});

// 2. 监听所有懒加载图片
document.querySelectorAll('.lazy-img').forEach(img => {
  lazyLoadObserver.observe(img);
});

示例 2:滚动加载更多(无限滚动)

需求:滚动到页面底部的 “加载更多” 按钮时,请求下一页数据。

<ul class="list"></ul>
<div class="load-more">加载更多</div>
const loadMoreBtn = document.querySelector('.load-more');
const list = document.querySelector('.list');
let page = 1;

// 创建观察器(监听“加载更多”按钮)
const loadMoreObserver = new IntersectionObserver((entries) => {
  const [entry] = entries;
  if (entry.isIntersecting && !isLoading) { // isLoading 防止重复请求
    isLoading = true;
    loadMoreBtn.textContent = '加载中...';
    // 模拟请求下一页数据
    fetch(`/api/data?page=${page}`)
      .then(res => res.json())
      .then(data => {
        data.forEach(item => {
          const li = document.createElement('li');
          li.textContent = item.content;
          list.appendChild(li);
        });
        page++;
        isLoading = false;
        loadMoreBtn.textContent = '加载更多';
      });
  }
}, { rootMargin: '50px 0px' }); // 提前 50px 触发,优化体验

// 监听“加载更多”按钮
loadMoreObserver.observe(loadMoreBtn);

示例 3:元素进入视口触发动画

需求:元素滚动进入视口时,添加 “淡入” 动画。

/* CSS:初始状态(透明、偏移)+ 动画状态 */
.fade-in {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.5s, transform 0.5s;
}
.fade-in.active {
  opacity: 1;
  transform: translateY(0);
}
<div class="fade-in">元素 1:进入视口淡入</div>
<div class="fade-in">元素 2:进入视口淡入</div>
// 创建观察器
const animationObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('active'); // 触发动画
      animationObserver.unobserve(entry.target); // 只触发一次
    }
  });
}, { threshold: 0.3 }); // 元素 30% 进入视口时触发

// 监听所有需要动画的元素
document.querySelectorAll('.fade-in').forEach(el => {
  animationObserver.observe(el);
});

四、注意事项

  1. 兼容性:支持 Chrome 51+、Firefox 55+、Safari 12.1+、Edge 16+,不支持 IE(需兼容可使用 polyfill)。
  2. 异步特性IntersectionObserver 是异步的,回调函数不会阻塞主线程(性能优势),但无法同步获取元素交叉状态。
  3. root 必须是祖先元素:如果指定 root,必须确保它是目标元素的父级 / 祖先元素,否则监听无效。
  4. 动态元素监听:如果目标元素是动态创建的(如通过 JS 新增的列表项),创建后需调用 observer.observe(新元素) 手动添加监听。
  5. rootMargin 单位:支持 px 或 %,但不能混合单位(如 50px 10% 是允许的,50 10px 不允许)。

五、总结

IntersectionObserver 的核心价值是高效监听元素交叉状态,替代传统的 scroll + getBoundingClientRect() 方案(后者高频触发,性能较差)。使用时只需三步:

  1. 创建观察器(配置回调和参数);
  2. 监听目标元素;
  3. 触发后执行业务逻辑(并可选取消监听)。

常见应用:懒加载、无限滚动、曝光统计、滚动动画,是前端性能优化和交互体验提升的重要 API。

React源码学习准备工作①——什么是Fiber

引言

在 React 16 以前,React的更新方式是这样的:一次性、不可中断的深度递归更新。这种更新方式:

  • 采用调用递归树来遍历组件树
  • 根节点开始递归render
  • 中间过程不能暂停、不能打断
  • 一旦开始,就必须一次性执行到结束

这种更新方式被称作:Stack Reconciler(调用栈调和器)。 其优点是显而易见的:实现简单,逻辑清晰,理论上来说性能不会太差,小更新速度非常快,不可打断的一次性能保证渲染的一致性,包括UI的一致性和生命周期顺序的固定可靠。

但是,也正是因为这种不可打断的一次性,带来了非常致命的问题:大组件树会卡死主线程。

React 的更新逻辑是运行在 JS 主线程中的,但是 JS 主线程除了负责 React 的更新,还需要去负责:

  • UI渲染
  • 用户输入事件
  • 动画
  • 网络回调
  • 等等

如果我们需要渲染一个巨型的组件树, React 就会一次性递归计算组件树的所有节点,占用了 JS 主线程,并且中途无法暂停,从而导致 JS 堵塞,页面卡死,用户的交互事件也没有反应——因为主线程都被哪去给 React 渲染组件树了。

不仅于此,Raact 阻塞进程还会导致浏览器无法响应更高优先级的任务,在 React 16 以前,React 不会让用户输入等更高优先级的任务去插队响应,JS 线程必须等待当前组件树 render 完成,才能去响应其他任务——给用户的体验就是:页面卡顿、掉帧、输出延迟

此外,因为递归栈形式的遍历不能中断,因此也很难去保存当前执行的上下文,记录执行到哪一步从而恢复执行,这种方式完全无法实现渐进式渲染,包括分片等功能自然也无法实现。

总结:React 16 以前使用的Stack Reconciler,优点是实现简单、一次性渲染保证一致性;缺点是不能中断、不能分片、不能恢复,导致长任务会阻塞主线程,引发页面卡顿,因此被Fiber替代。

React 开发团队也正是为了实现 “可中断、可恢复”的渲染,从而引入了Fiber的概念。

Fiber 的定义

什么是 Fiber

一句话概括:Fiber 是 React 用来执行“可中断更新”的一种数据结构(FiberNode)+调度机制(Workloop)。

Fiber 既是一个轻量级数据结构(描述组件的工作单元),又是一个任务调度系统(让更新分片执行,不卡主线程)。

可以这么说,Fiber=数据结构+调度系统

Fiber 的引入使得渲染过程可以中断,并且根据需要重新调度任务,这种可中断可分片的优化使得 React 可以更好利用 JS 主线程的空闲时间,优化性能,提升用户体验。

Fiber 是一种数据结构

为什么这么说呢?我们可以看看FiberNode的源码。

export class FiberNode {
  constructor(tag, pendinfProps, key, mode){
    this.tag = tag;
    this.key = key;
    this.elementType = null;
    this.type = null;
    this.stateNode = null;

    // 树形结构
    this.return = null;
    this.child = null;
    this.sibling = null;

    // props
    this.pendingProps = pendingProps;
    this,memoizedProps = null;

    // 状态
    this.memoizedState = null;
    this.updateQueue = null;

    // Effect 相关
    this.flags = NoFlags;
    this.subtreeFlags = NoFlags;
    this.deletions = null;

    // 调度 lane
    this.lanes = NoLanes;
    this.childLanes = NoLanes;

    // 双缓存树
    this.alternate = null;
  }
}

我们可以重点关注以下几个内容。

1. child / sibling / return

Fiber 是一个非常典型的链表结构+树结构的混合,而非一棵标准的多叉树。

child → 第一个子节点  
sibling → 右兄弟节点  
return → 父节点  

2. alternate

Fiber 使用的是“双缓存”机制,alternate 是双缓存树的核心。

Fiber 会同时维护两棵 Fiber 树:

  • 一棵是当前页面正在用的树(current)
  • 另一棵是正在计算下一帧UI的树(workInProgress)

计算完成后,就用一次性“切换指针”的方式把新的树换成 current,渲染过程可以实现无闪烁、可中断、不卡顿。双缓存依赖 alternate 形成两棵树,alternate 相当于两棵树切换的桥梁,能让 React 同时维护 current 树和 workInProgress 树,并在它们之间随时切换,无需重新创建整棵树。

每个 Fiber 节点都有两个版本,alternate 字段去记录它们的映射关系:

currentFiber.alternate = workInProgressFiber
workInProgressFiber.alternate = currentFiber

这种桥梁关系可以使 React 快速从 current 得到对应的 WIP,计算下一帧UI树时也不需要 new 新的树,而是直接复用已有的节点,在更新的过程中也会保存上一次的状态。

这里延伸一下:alternate 为什么可以让 Fiber 重用已有的节点?这里可以直接参考源码:

if (current.alternate !== null) {
  // reuse
  workInProgress = current.alternate;
} else {
  // create a new fiber
}

这意味着,在 current.alternate 存在时,Fiber 会直接复用已有的节点,而不是创建一个新的Tree Node,这将大大减少性能。

这也意味着,WIP 的构建是可以随时产生随时停止的,只需要根据 current 即可随时构造,那么,在 JS 线程中,一旦有更高优先级的任务发生,WIP 可以立即被丢弃,等待更高优先级的任务完成,然后再重新算,期间不会对 current 造成任何影响,用户只能看到 current 的内容,页面体验也不会受到影响。

在 WIP 构建完成后,UI 切换并不是重新创建,而是“调换指针”。

root.current = finishedWork;

Alternate 保证两棵树的节点是一一对应的,那么只需要切换指针,就能实现将 WIP 推到页面上。

用一句话总结:Alternate 是双缓存的核心,因为它让 React 能维持 current 和 workInProgress 两棵并行的 Fiber 树。所有的更新都在WIP上进行,最终通过 alternate 快速切换指针使页面更新。 这种方式带来了可中断、可恢复、可丢弃、低卡顿的并发渲染能力。

3. lans

React 17 内部引入 lanes 取代 expirationTime,但 lanes 的真实能力在 React 18 并发模式中才真正向开发者开放。

Lans 模型决定:

  • 哪些任务可以中断
  • 哪些任务可以合并
  • 哪些任务需要优先执行

这是并发模式的基础。

Fiber 是一种调度系统

说 Fiber 是一种调度系统,本质上是抓住了 Fiber 的设计核心:Fiber 不是一个单纯的树结构,而是一个可以拆分任务、按优先级执行、暂停和恢复的工作调度引擎。上文提到的 lans 很好地揭示了这样的一种行为。

为什么说 Fiber 是一种调度系统

1. 可中断/可恢复

React 16 以前,渲染是“递归+同步”的渲染:一旦开始渲染,就无法停止。Fiber 支持将渲染工作拆分成小单元,渲染间隙可以将 JS 线程让给优先级更高的任务,等待任务完成后再回来继续渲染。

这种机制非常像操作系统到的 CPU 调度:Fiber 是任务线程/进程,React 根据需求去调度。

2. 优先级机制

操作系统的 CPU 调度会提到优先级这个概念,根据优先级安排任务调度顺序,保证高优先级的任务优先被执行。

Fiber 也可以实现类似的优先级调度机制,支持不同优先级的任务,实现原理就是上文提到的 Lans,通过优先级调度系统,React 可以“插队”执行高优先级任务,例如在渲染过程中来了一个用户交互任务,React 可以暂停渲染先去处理交互任务,完成后再返回继续执行渲染任务。

上下文的储存也会保证不会丢失以前的渲染结果。这个优先级机制是并发渲染的基础。

3. 时间分片

Fiber 可以通过“时间分片”的思想,将大的渲染任务分解为小的任务,每一帧都只完成部分任务。这样 JS 主线程不会长时间被渲染任务占据,浏览器调度 API 在此基础上可以决定什么时候继续渲染任务,这样可以保证用户交互、动画等高优先级的任务不会被低优先级的渲染任务卡住。

4. 调度器

React 有一个单独的调度层(Scheduler),与 Fiber 结合,从而实现决定何时执行任务。这个调度层支持优先级任务、超时、挂起、恢复等,是一个真正的任务调度系统。

5. 可撤销/可放弃任务

如果正在执行的渲染任务优先级低于高优先级的用户交互任务,则当前的渲染任务可以被暂停,断点执行时机可以被推后或者放弃。渲染进度会被保存,下次可以从中断处恢复,这样,React 的渲染就不是一次性的必须全部成功或者全部失败,而是有“弹性”的。

从调度系统角度看 Fiber 的优势

一句话总结:更好地实现可中断、可恢复、低卡顿的渲染与页面交互。

  • 更好响应页面交互:高优先级的用户交互任务被优先执行,低优先级任务可以被打断,使用户的页面使用体验更流畅。
  • 提升页面性能:渲染任务被分片分时渲染,减少了 React 长任务占用 JS 主线程的时间,浏览器可以及时进行重绘/回流。
  • 支持并发特性:Fiber 是 React 并发模式的基础,调度系统为并发渲染提供了核心与基石。
  • 错误恢复能力更强:Fiber 会记住渲染的上下文,渲染任务中断或放弃不会影响现有页面与整个渲染任务,可以根据上下文恢复渲染任务。

Fiber 如何实现渲染

这里我们要研究实现渲染的关键函数。

1. 初次渲染:创建双缓存树

初次渲染时没有 alternate,于是 React 会创建一棵新的 workInProgress Fiber 树。

这里可以去看一看 Fiber 的源码 ReactFiber.js

function createWorkInProgress(current, pendingProps) {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    // 初次渲染:current 没有 alternate,会去创建一个新的 Fiber
    workInProgress = new FiberNode(current.tag, pendingProps, current.key);
    workInProgress.stateNode = current.stateNode;
    workInProgress.alternate = current;
    current.alternate = workInProgress;
  }
  return workInProgress;
}

初次渲染之后,current 和 WIP 两棵树就建好了,彼此通过 alternate 连接,相互复用,减少了后续节点构建的压力(因为后续就复用了)。

2. 更新阶段:利用双缓存机制构建新的UI

更新流程发生在 ReactFiberWorkLoop.js 中。

核心步骤如下:

  1. 从 current 树中获取对应 Fiber;
  2. 通过 createWorkInProgress 创建或复用 WIP 树;
  3. 所有的计算、diff、effect 都在 WIP 树种进行;
  4. 完成后执行 commit 阶段;
  5. 用改变指针的方式交换两棵树的角色:WIP 变成新的 current。

① Render 阶段:构建 WIP 树

循环由 performUnitOfWork(WIP) 驱动实现。

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;
  let next = beginWork(current, unitOfWork);
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    completeUnitOfWork(unitOfWork);
  }
  return next;
}

渲染都在WIP上计算,如果节点有子节点,那就进入子节点继续处理,直到节点没有子节点了(叶子节点),则向上归并进入 complete 阶段。

② Commit 阶段:切换缓存树

commit 阶段有两个特点:

  1. 同步执行,不可中断
  2. 会真正地更新 DOM/UI

在 commitLayoutEffects 阶段,React 会在浏览器执行绘制(paint)之前执行所有 Layout 副作用,统一在同一帧内完成,以避免 layout thrashing(布局抖动)。

由 commitRoot 执行角色交换,交换方式是指针指向修改,commit 阶段又被拆成三个阶段。来自ReactFiberWorkLoop.js

function commitRoot(root) {
  const finishedWork = root.finishedWork;
  root.finishedWork = null;

  // 阶段1:mutation 前
  commitBeforeMutationEffects(root, finishedWork);

  // 阶段2:mutation
  commitMutationEffects(root, finishedWork);
  root.current = finishedWork ;
  
  // 阶段3:layout 阶段
  commitLayoutEffects(root, finishedWork);
}

阶段一:mutation 前

该阶段发生在更新 DOM 前,用于为 mutation 做准备。

function commitBeforeMutationEffects(root, firstChild) {
  let nextEffect = firstChild;
  while (nextEffect !== null) {
    const flags = nextEffect.flags;
    if (flags & Snapshot) {
      commitBeforeMutationEffectOnFiber(nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}

在这个阶段,React 会遍历所有带 Snapshot 标记的 Fiber,Snapshot 标记对应 getSnapshotBeforeUpdate。

这么做的目的是让我们在 DOM 更新前拿到更新前的 DOM 的真实状态的“快照”,如果在更新后再获取,就不完全准确了。

React 是一个增量更新的 Fiber 树,不是一个单纯的 DOM 树,可能会出现如下情况:

  • 多个组件有 Snapshot 副作用
  • 有些嵌套组件会产生 Snapshot
  • 某些组件的子组件没有更新,但子组件仍然需要 Snapshot

因此 React 不能只处理当前节点,它需要确保:每一个 Fiber 节点,只要在本次更新中被标记为 Snapshot,都必须在 DOM 更新前执行快照。

在这个阶段,只处理 Snapshot,不需要考虑其它flags,因为其它 flags 不是必须在 DOM 更新前执行。比如:

  • Placement(插入 DOM):mutation 阶段
  • Update(更新属性):mutation 阶段
  • Deletion(删除DOM):mutation 阶段
  • useEffect:Layout 后异步执行

总结:如果被问到 commitBeforeMutationEffects 要遍历所有 Snapshot Fiber 的原因,可以这么回答:

因为 Snapshot 对应 getSnapshotBeforeUpdate,它的语义要求“读取旧 DOM 状态”。
所以必须在 mutation 阶段(DOM 更新)之前执行。
React 通过遍历 effectList 上所有带 Snapshot 标记的 Fiber,确保所有组件在 DOM 被修改之前完成快照读取。
这保证了组件在 update 阶段能获得精准的 DOM 变化前的状态,使滚动恢复、光标位置保存、布局测量等成为可能。

阶段二:mutation

这个阶段是真正更新 DOM 的阶段,所有的 DOM 变动都在这一步完成。

function commitMutationEffects(root, finishedWork) {
  let nextEffect = finishedWork

  while (nextEffect !== null) {
    const flags = nextEffect.flags

    // 删除节点
    if (flags & Deletion) {
      commitDeletion(root, nextEffect)
    }

    // 插入或移动节点
    if (flags & Placement) {
      commitPlacement(nextEffect)
    }

    // 更新属性或文本
    if (flags & Update) {
      commitWork(nextEffect)
    }

    nextEffect = nextEffect.nextEffect
  }
}

React 用 flags 标记 effect,常见的与 mutation 相关的 flags 有:

  • Placement:需要插入(mount)或移动节点
  • Deletion:需要删除(unmount)节点
  • Update:更新属性/文本
  • Snapshot:在 before-mutation 阶段处理(已在前面完成)
  • Ref:需要更新/清理 ref(通常在 layout 阶段也会处理)
  • Passive:useEffect(但 cleanup 在 mutation 阶段需要先执行其 cleanup,再在 layout/after 写入)

处理顺序也有先后,遵循Deletion → Placement → Update 的顺序,这样可以保证 DOM 父节点的稳定性。

阶段三:layout 阶段

在 DOM 更新后执行:

  • 类组件的 componentDidMount
  • 类组件的 componentDidUpdate
  • hook 的 useLayoutEffect create 部分
  • 调用 ref 回调
function commitLayoutEffects(root, finishedWork) {
  let nextEffect = finishedWork;
  
  while (nextEffect !== null) {
    if (nextEffect.flags & Update) {
      commitLayoutEffectOnFiber(root, nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}

主要作用就是:执行所有需要“看到最新 DOM”的副作用,从而实现同步、强制、阻塞,保证顺序一致,且能访问最新 DOM。

主流程如下:

function commitLayoutEffects(finishedWork, root, committedLanes) {
  // ① 清理上次的 passive destroy(useEffect cleanup)
  flushSyncCallbacksOnlyInLegacyMode();

  // ② 遍历 layoutEffects
  commitLayoutEffectsOnFiber(root, finishedWork, committedLanes);
}

function commitLayoutEffectsOnFiber(root, finishedWork, lanes) {
  let nextEffect = finishedWork.firstEffect;
  while (nextEffect !== null) {
    // 执行 layout 的 work
    commitLayoutEffectOnFiber(
      root,
      nextEffect.alternate,
      nextEffect,
      lanes
    );
    nextEffect = nextEffect.nextEffect;
  }
}

其中,我们重点关注一下commitLayoutEffectsOnFiber,这个阶段是根据 flags 分类处理副作用:

function commitLayoutEffectOnFiber(root, current, finishedWork, lanes) {
  const flags = finishedWork.flags;

  if (flags & Update) {
    // 处理 class 组件的 didUpdate 生命周期
  }

  if (flags & Callback) {
    // setState 的回调
  }

  if (flags & Ref) {
    // 安装 ref
  }

  // 重点:处理 Layout effects
  if (flags & LayoutMask) {
    commitHookEffectListMount(HookLayout, finishedWork)
  }
}

其中 LayoutMask 包括 useLayoutEffect 的销毁和创建。对于 useLayoutEffect,React会先执行 destory,再执行 create,这里的先后顺序永远不会被打破。

强调!useLayoutEffect 必须同步执行?

因为这一阶段的 DOM 已经更新,依然可以同步读取,但是还没有渲染在浏览器上,这时 useLayoutEffect 可以进行 DOM 的尺寸测量和位置计算、修改 DOM ,以及注入同步逻辑。 React 故意将 useLayoutEffect 安排在渲染浏览器之前,从而避免渲染在页面的 DOM 会出现闪烁,这是一个同步行为。

如果时会被问到这样的问题——为什么 useEffect 不在这个阶段执行?我们可以这么回答:

因为 useLayoutEffect 是同步 + 阻塞的,而 useEffect 是异步 + 不阻塞渲染的(调度到微任务/宏任务),所以 useEffect 不会在这个阶段的layout 执行,而是在下一轮事件循环执行。

以上阶段内容总结:

commitLayoutEffects 是 React commit 阶段的“布局阶段”,在 DOM 已更新但浏览器尚未绘制时执行。
它同步执行 useLayoutEffect 的 destroy 与 create、类组件的 didMount/didUpdate、ref 赋值等所有需要访问最新 DOM 的副作用。
React 通过 effect list 遍历仅更新的 Fiber,保证副作用按严格顺序执行,并确保 DOM 状态一致性

补充:为什么 Fiber 是“最小可工作单元”

因为 Fiber 以前是不可中断、不可暂停的“递归调用栈”,Fiber 的出现把递归变成了“可迭代的单链表结构”,每个 FiberNode 本质上是一个工作单元,这就是 Fiber “可中断”的根本原因。

React Fiber 的本质就是:把组件树从递归改写成可遍历的链表,让渲染变成可拆分的工作单元,从而可以暂停、恢复、丢弃、重做。

总结

React 16 引入 Fiber,本质上是用一套“可中断、可恢复”的调度系统来替代原先不可中断的 Stack Reconciler(递归调用栈)。Fiber 既是一种轻量级的数据结构(FiberNode),又是一套完整的渲染与调度机制(WorkLoop + Scheduler)。它通过:

  • 双缓存机制
  • 单链表化的树节点FiberNode
  • 可分片与可中断的工作单元
  • 基于优先级的调度模型

让 React 能够在渲染期间暂停、恢复、放弃任务,并在合适的时机继续执行,不去长时间的占据 JS 主线程,从而避免卡顿。

Fiber 的引入彻底改变了 React 的渲染模型,使得:

  • 渲染变得可控、不再阻塞主线程
  • 更高优先级的用户交互可以“插队”
  • 更新可以分片完成(时间切片)
  • DOM 更新被拆解成 before-mutation → mutation → layout 三阶段,更可预测、更加精细

可以说:Fiber 是 React 并发特性的基础,也是 React 性能优化的核心。

React 从同步递归 → 可调度的 Fiber 架构,是一次从“函数调用”到“任务调度”的根本性转变,为后续的 Concurrent Mode、Suspense 等特性奠定了全部基础。

以上为个人在学习过程中的一些理解和感悟,部分有参考,如有不足,欢迎指正。

学习React-DnD:实现多任务项拖拽-useDrop处理

在上一篇技术分享中,我们聚焦于useDrag钩子实现Todo任务项的拖动触发逻辑,完成了任务的选中与拖拽启动功能。而拖拽交互的闭环,必然离不开放置接收环节——当用户将任务拖拽到目标位置时,如何精准判断拖动类型(单个/多个)并执行对应的排序逻辑,才是确保交互流畅性的核心。本文将详细拆解这一环节的实现思路,重点解析批量排序操作的设计与hover事件的逻辑处理。

SelectTodosDrop.gif

核心问题:区分拖动类型,匹配差异化逻辑

任务拖拽的放置接收环节,首要解决的问题是“识别当前拖动场景”。当用户拖拽任务时,存在两种典型场景:单个任务独立拖动、多个已选中任务批量拖动。这两种场景的排序逻辑存在本质差异:单个任务只需处理“源位置”与“目标位置”的双向交换;而批量任务则需要先提取所有选中项,再整体插入目标位置,同时保持选中项内部的相对顺序。

针对这一差异,我们的技术方案分为两步:一是新增批量排序的Context操作类型,专门处理多任务排序逻辑;二是在useDrop的hover事件中添加类型判断,根据是否为批量拖动执行对应逻辑。

第一步:实现批量排序操作BATCH_REORDER_TODOS

单个任务排序可通过简单的数组元素交换实现,但批量排序需要处理“选中项提取-目标位置计算-选中项插入”三个核心步骤。为此,我们新增BATCH_REORDER_TODOS操作类型,封装完整的批量排序逻辑。

1.1 核心设计思路

批量排序的核心需求是:将所有选中的任务作为一个整体,移动到目标位置,并保持选中项在原始数组中的相对顺序。具体思路如下:

  • 边界校验:排除“无选中任务”“目标位置越界”等无效场景;
  • 选中项排序:确保选中任务的顺序与原始数组一致,避免排序混乱;
  • 分离数组:将原始任务数组拆分为“选中任务”和“非选中任务”两个集合;
  • 目标位置校准:计算选中任务在非选中数组中的实际插入位置;
  • 数组重组:将选中任务整体插入目标位置,形成新的任务数组。

1.2 完整代码实现与解析

以下是BATCH_REORDER_TODOS在Context reducer中的实现代码,关键步骤已添加详细注释:

case ActionTypes.BATCH_REORDER_TODOS:
  {
    // 从action中获取目标位置索引和移动方向
    const { destinationIndex, direction } = action.payload;

    // 边界条件检查:无选中任务/目标位置越界则返回原状态
    if (!state.selectedTodos.length || destinationIndex < 0 || destinationIndex >= state.todos.length) {
      return state;
    }

    // 复制原始任务数组,避免直接修改state
    let newTodos = [...state.todos];

    // 关键:按选中任务在原始数组中的顺序重新排序
    // 通过findIndex匹配id,确保排序与原始位置一致
    let tempSelectedTodos = [...state.selectedTodos].sort((a, b) => {
      return newTodos.findIndex(todo => todo.id === a.id) - newTodos.findIndex(todo => todo.id === b.id);
    });

    // 创建选中任务ID集合,用于快速过滤非选中任务
    const selectedTodoIds = new Set(tempSelectedTodos.map(todo => todo.id));

    // 从原始数组中移除所有选中任务,得到非选中任务数组
    const nonSelectedTodos = newTodos.filter(todo => !selectedTodoIds.has(todo.id));

    // 计算选中任务在非选中数组中的实际插入位置
    // 原理:通过目标位置的任务ID,找到其在非选中数组中的索引
    let actualDestinationIndex = nonSelectedTodos.findIndex(todo => todo.id === state.todos[destinationIndex].id);

    // 确保目标位置不超出非选中数组范围
    actualDestinationIndex = Math.min(actualDestinationIndex, nonSelectedTodos.length);

    // 根据移动方向微调目标位置:UP表示向上移动,需将插入位置后移一位
    if(direction === 'UP'){
      actualDestinationIndex++;
    }

    // 重组数组:将选中任务整体插入目标位置
    const resultTodos = [
      ...nonSelectedTodos.slice(0, actualDestinationIndex), // 目标位置前的非选中任务
      ...tempSelectedTodos, // 选中任务整体插入
      ...nonSelectedTodos.slice(actualDestinationIndex) // 目标位置后的非选中任务
    ];

    // 返回新状态,保持选中状态方便用户继续操作
    return {
      ...state,
      todos: resultTodos,
      selectedTodos: [...state.selectedTodos],
    }
  }

1.3 配套Action创建函数

为了在组件中调用批量排序逻辑,我们需要创建对应的action创建函数,将目标位置和移动方向作为参数传递:

// 批量重新排序任务的action创建函数
batchReorderTodos: (destinationIndex, direction) => {
  dispatch({
    type: ActionTypes.BATCH_REORDER_TODOS,
    payload: { destinationIndex, direction },
  });
},

第二步:优化useDrop的hover事件,区分拖动类型

useDrop钩子的hover事件是处理“放置接收”的关键——当拖拽的任务悬停在目标任务上时,需要实时计算位置并触发排序。我们在此处添加“是否为批量拖动”的判断,分别执行单个和批量排序逻辑。

2.1 核心交互逻辑

hover事件的核心需求是“精准判断插入位置”:

  • 批量拖动时:根据鼠标在目标任务上的垂直位置(上半部分/下半部分),确定整体插入方向(上方/下方);
  • 单个拖动时:直接匹配源任务与目标任务的位置,执行交换排序。

同时需要避免“自我交换”问题——当拖拽的任务与目标任务为同一任务(单个拖动),或目标任务属于选中任务集合(批量拖动)时,不执行任何操作。

2.2 完整hover事件代码实现

hover: (item, monitor) => {
  if (item.selected) {
    // 场景一:批量拖拽逻辑
    // 避免将选中任务拖到自身集合内
    if (item.selectedTodos.some(selectedTodo => selectedTodo.id === todo.id)) return;

    // 获取目标任务在原始数组中的索引
    const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);

    // 计算鼠标在目标任务元素上的垂直偏移量
    // monitor.getClientOffset().y:鼠标在视口中的Y坐标
    // divRef.current.getBoundingClientRect().top:目标元素顶部在视口中的Y坐标
    const hoverOffset = monitor.getClientOffset().y - divRef.current.getBoundingClientRect().top;
    // 目标元素的半高,作为判断插入方向的阈值
    const halfHeight = divRef.current.offsetHeight / 2;

    if (hoverOffset > halfHeight) {
      // 鼠标在目标元素下半部分:将选中任务插入到目标元素下方
      batchReorderTodos(destinationIndex, 'UP');
    } else {
      // 鼠标在目标元素上半部分:将选中任务插入到目标元素上方
      batchReorderTodos(destinationIndex, 'DOWN');
    }
  } else {
    // 场景二:单个拖拽逻辑
    // 避免任务自我交换
    if (item.id === todo.id) return;

    // 关键:通过ID获取实时索引,而非依赖初始索引(避免快速拖拽导致的索引混乱)
    const sourceIndex = todos.findIndex(oneTodo => oneTodo.id === item.id);
    const destinationIndex = todos.findIndex(oneTodo => oneTodo.id === todo.id);

    // 执行单个任务排序
    reorderTodos(sourceIndex, destinationIndex);
  }
},

2.3 关键技术点解析

上述代码中,有两个极易踩坑的技术点需要重点关注:

  1. 索引获取方式:放弃“依赖初始索引”的方式,改用findIndex通过任务ID获取实时索引。这是因为快速拖拽过程中,任务数组顺序会动态变化,初始索引会失效,而ID作为唯一标识能确保索引精准。
  2. 批量拖动的位置判断:通过“鼠标垂直偏移量+元素半高”的组合,实现“hover上半部分插上方,hover下半部分插下方”的自然交互。这种设计符合用户直觉,避免了“拖拽到任务边缘时位置判断模糊”的问题。

功能闭环:从拖动到放置的交互优化

通过新增批量排序操作和优化hover事件逻辑,我们完成了Todo任务拖拽的完整功能闭环。实际使用中,用户可通过以下流程完成拖拽操作:

  1. 单个任务拖拽:直接拖动目标任务,悬停到目标位置即可完成排序;
  2. 批量任务拖拽:先选中多个任务(可通过Ctrl/Shift键辅助),拖动任意选中任务,整体悬停到目标位置,根据鼠标位置完成批量插入。

这种实现方式既保证了操作的灵活性,又通过边界校验(如越界判断、自我交换排除)确保了功能的稳定性。下图为批量拖拽的实际效果演示:

总结

本文通过“批量排序操作封装+hover事件类型区分”的技术方案,解决了Todo任务拖拽中“放置接收”的核心问题。核心亮点在于:

  • 用ID作为索引匹配的唯一标识,避免了动态排序中的索引混乱问题;
  • 批量排序时保持选中项的原始顺序,符合用户操作预期;
  • 基于鼠标位置的精细判断,提升了拖拽交互的流畅性。

打包票!前端和小白一定明白的人工智能基础概念!

AI时代,不知道你是否和我有同样的经历:搜索了大量号称“小白也能看懂”的AI科普文章,结果点进去,仍有90%的内容让人一头雾水。

这篇文章,是我在阅读众多资料后,整理出的一份更易懂的总结。它不强求全面,但力求逻辑清晰、层层递进——从基础概念逐步引出更复杂的内容,而不是一上来就抛出“神经网络”“深度学习”或“ChatGPT预测模型”这样的术语。

我相信,只要你具备初中知识水平,就能轻松理解。让我们开始吧!

一、人工智能的起源

1956年,一群科学家在达特茅斯会议上首次提出“人工智能”这一概念。他们讨论的核心问题是:如何制造出能够学习并模拟人类智能的机器。

从此,人工智能作为一个独立的研究领域正式诞生。

但问题在于,机器处理信息的方式与人类截然不同。机器接收的所有数据最终都会转化为数字(包括文字)。

简单解释为什么文字在计算机内部也是数字表示的,就涉及到编码的知识,例如 ASCII 编码,字母 a 在计算机内部表示 97。而 97 最终会被解释而 2 进制,因为计算机本身就是 2 进制的。它只能认识 0 和 1。然后我们将数字和文字做个映射,例如 01100001 表示字母 a ,而 01100001 的 10 进制就是 97.

我们抽象一下计算机的思考方式,简单来说就是:

f(x)=y

  • 我们向计算机输入参数 x

  • 计算机将参数转为数字(这就是为什么很多文章说什么向量这个概念,向量可以简单理解为数字组成的多维数组,也就是说例如 “苹果” 这个词,最终要转化为数字,计算机才能理解),然后通过函数 f 处理并计算

  • 最终输出结果 y

但这显然不是人类思考问题的方式。那么,如何让机器具备类似人类的判断与学习能力,成为真正的“智能机器”呢?

科学家们提出了不同的思路。

二、符号主义:用规则模拟智能

在人工智能的早期阶段,符号主义(Symbolism)是一种主流思路。它认为,可以通过数学逻辑来模拟人类的推理过程。

举个例子,我们设计一个判断是否下雨的机器:

  • 参数 aa:是否为阴天

  • 参数 bb:湿度是否大于70%

只有当 a 和 b 同时为“真”时,机器才输出“要下雨”,否则输出“不下雨”。

这种思路本质上就是编程中的 if...else... 逻辑。

别小看符号主义,它的成功应用之一就是“专家系统”。比如在医疗诊断中:

  • 从 头疼 + 发热 + 咳嗽 的症状 → 能推测出得了流感

  • 从 腹痛 + 尿血 → 能推测出得了 肾结石

通过一系列规则组合,专家系统能够模拟人类专家的决策过程,并在特定领域取得了显著成果。

但它也有明显的局限:

  1. 规则难以统一:比如面对同一张股票走势图,不同专家可能做出完全相反的判断。

  2. 无法自主学习:系统本身不具备学习能力,依赖人工更新规则。

随着研究的深入,另一种思路逐渐兴起:与其预设所有规则,不如让机器自己从数据中学习。这就是“联结主义”(Connectionism)。

三、联结主义:让机器自己学习

这种模式有点像训狗,你说坐下,它坐下你就奖励零食,如果错了,就跟它一飞腿,这样你就能训练出一个会听坐下指令的狗了。

我们把狗换成机器,也可以用同样的方式训练,让它在某个任务下完成任务。例如说现在要训练一个能识别苹果图片的智能。

举例:识别苹果

那么机器肯定要识别苹果的特征,才能区别别的图片,假设我们设置了如下维度

  • 直径: 苹果直径大约10cm

  • 颜色: 苹果是红色

  • 形状: 苹果是球形

例如在某些条件下,直径,颜色,形状都符合苹果特性的条件下,才是苹果,但是我们之前说了,计算机只认识数字,只能通过计算来判断,所以我们需要结合一些数学公式来把 直径,颜色,形状,映射为数字,通过数字的计算映射它们在现实生活的是否对应。

既然文字也可以通过数字映射,例如 97 代表数字 a,那么其它属性也可以,例如(我乱说的,就是表达一种意思),我们把形状,颜色,和直径,都理解为权重。什么意思呢?我们举个例子:

假设我们给每个特征分配一个权重(weight),代表这个特征对“是否是苹果”的重要程度:

  • 直径:苹果直径约 10cm 权重 = +0.6(越接近 10cm 越可能是苹果)

  • 颜色:红色程度(0 不是红,1 是红) 权重 = +0.3(红色对判断有贡献)

  • 形状:球形程度(0 不是球形,1 是球形) 权重 = +0.4(球形对判断有贡献)

然后,我们设计一个简单的“苹果得分”公式:

苹果得分=(直径得分)×0.6+(颜色得分)×0.3+(形状得分)×0.4

然后得出来的值,如果大于 1 就是苹果,如果小于 1 就不是苹果。

计算例子

例1:一个红苹果(直径 10cm,红色,球形)

  • 直径得分 = 1
  • 颜色得分 = 1
  • 形状得分 = 1

苹果得分 = 1×0.6+1×0.3+1×0.4=1.3

例2:一个橙子(直径 8cm,橙色,球形)

  • 直径得分 = 0.8(假设 8cm 离 10cm 差 2cm,得分 0.8)
  • 颜色得分 = 0(不是红色)
  • 形状得分 = 1

苹果得分 = 0.8×0.6+0×0.3+1×0.4=0.48+0.4=0.88

大家应该明白上面的意思了吧。我们再次抽象为数学公式,也就是变为 1 次函数。将得分用 x 表示,将权重用 w 表示,如下:

z = (w1 × x1) + (w2 × x2) + (w3 × x3) + b

其中:

  • w1,w2,w3 是各特征的权重(重要性)

  • b 是偏置项(可理解为判断门槛)

所以

  • 如果 z≥0,判定为苹果

  • 如果 z<0,判定为非苹果

因为有 w1,w2,w3 3个参数,不利于我们后面的讲解,我们再次简化公式,来帮助我们理解后面的概念。

z = (w1 × x1) + (w2 × x2) + b

变为只有两个参数来决定是否是苹果,其实这是这是初中数学中的 线性方程,它的图像是一条直线。如下:

这条直线下方的就是就是非苹果,上方的就是苹果。

接下来有人会问,你说形状,直径这些特征的值,是怎么来的呢?

它们当然不是天然存在的,而是需要我们人为设计和提取的。这个过程在传统机器学习中被称为 “特征工程”。我们又要举一个粗糙的例子了,我们拿颜色得分来举例:

  • 思路: 苹果通常是红色、绿色或黄色。我们需要量化“红色程度”。

  • 设计方法:

    • 如果是从图片中提取: 计算机可以分析图片的所有像素点。

      • 将图片从RGB颜色空间转换到 HSV 颜色空间(H代表色调,能更好地表示颜色本身)。
      • 统计所有像素中,色调(H)在红色范围内(比如0-10度和350-360度)的像素比例。比例越高,x2越接近1。
    • 如果是从文字描述提取: 如果我们的数据是文字“深红色”,我们可以建立一个颜色词典:

      • “深红色” -> x2 = 0.9

      • “浅红色” -> x2 = 0.7

      • “绿色” -> x2 = 0.3(因为青苹果也存在)

      • “蓝色” -> x2 = 0

好了,特征得分我们解决了,然后就是训练,调整 w1 和 w2 参数,从而找出一个分界线,在分界线范围内的就是苹果,范围外的就是其它。当然这个分界线不一定是直线,也可以是很复杂的曲线范围,我们只是为了引出核心概念,就是:

“机器学习”!

虽然没有直接说出“机器学习”四个字,但已经完整地描绘了它的核心思想:通过数据(苹果的特征)和反馈(得分是否大于阈值),让机器自动调整内部参数(权重 w 和偏置 b),从而学会一项任务(识别苹果)。

我们接着聊,刚才我们举得例子非常粗糙,但可以很容易的理解大概的意思。

上面这种机器学习的思路,称为联结主义,可这种思路在最初曾一度被整个世界称为骗子思路!

为什么是骗子?

刚才我们举了一个非常简单的例子,让机器根据 直径、颜色 来判断是不是苹果。

我们最终把它抽象成一个数学公式:

z = (w1 × x1) + (w2 × x2) + b

本质上,它就是一个一次函数(二维下是一条直线,三维下是一张平面)。

在很多任务中,这种模型真的能工作。

比如“苹果 vs 不是苹果”,

如果苹果的数据大多集中在同一块区域,那么一条直线(或平面)确实能把它们区分开来。

如果有一个任务,根本无法用一条直线分开呢?

科学家们很快就发现:

并不是所有问题都像识别苹果一样简单。

其中最典型的例子就是 异或 XOR )问题

先看什么是异或:

输入 A 输入 B 输出
0 0 0
0 1 1
1 0 1
1 1 0

也就是输入是一样的情况,例如都是 0 或者都是 1 ,得到的结果是 0,否则得到的结果是 1。

如下图,我们是无法找到一条直线,分割红色点和蓝色点的

这就意味着,简单的线性模型,有些情况是无法模拟的!

也就是说 ❌ 再怎么调整 w1、w2、b,这个模型也永远无法学会异或。

这导致了人工智能历史上第一次寒冬(AI Winter)。

四、突破:引入非线性!—— 神经网络的诞生

后来科学家们发现:

如果在两个线性模型中间,再加上一层“非线性函数”,就能解开异或。

你可以把思想理解成:

  • 一条直线不能分的

  • 两条直线可以

  • 让模型像搭积木一样把“多条直线”组合起来 → 就能拼出复杂的边界

什么意思呢,我们可以简单用下入理解:

如上图右边,是不是边界变成了不是一条直线,而是两个曲线去分割呢,借助这这种思路就解决了异或问题。

这就是**神经网络(Neural Network)**最核心的思想:

多个简单模型叠在一起,中间加上激活函数(非线性), 就能表达更复杂的决策边界。

我们从开始的线性模型,也就是单层结构是:

输入 → 权重加权 → 激活函数 → 输出

简单来说就是输入 x1, x2 的得分 —> 跟 w1,w2 权重计算 -> 跟阈值对比,例如大于 1 就是苹果 -> 输出是否是苹果

然后再看看新的多层结构:

输入 → [权重加权 + 激活函数] → [权重加权 + 激活函数] → 输出

好了至此,我们就明白了神经网络的概念,神经网络也是联结主义的一部分。此时再次解释以下联结主义的主张:

“智能来自大量简单单元(神经元)的连接和学习,而不是手写规则。”

我们再来一个小小结,就是从符号主义,这种依靠人自身写规则到联结主义,到神经网络,我们逐渐步入了新的人工智能时代。

五、从神经网络到深度学习:当网络变得“深不可测”

好了,现在我们明白了神经网络——它通过多层连接,巧妙地解决了简单模型无法处理的复杂问题(如异或问题)。那么,深度学习又是什么呢?

其实答案出乎意料的简单:

深度学习 = 特别“深”的神经网络。

这里的“深”,指的不是哲学的深邃,而是字面意思上的层数非常多。

我们可以做一个直观的对比:

  • 传统的神经网络:可能只有几层(比如一个输入层、一个隐藏层、一个输出层)。就像一个简单的三明治。

  • 深度学习模型:则拥有十几层、上百层甚至上千层。这就像一个巨无霸汉堡,拥有无数层馅料和面包。

为什么层数多了就厉害?

还记得我们之前手动设计“特征”的麻烦吗?(比如要自己写规则计算“颜色得分”、“形状得分”)深度学习的强大之处在于,它能够自动完成这件事,而且做得比我们好得多。

我们拿一个识别狗的例子举例:

我们可以把一个深度网络理解成一个分工极其精细的流水线工厂,用来识别一张“狗”的图片:

  1. 第一层(最基础的工人):只负责检测图像中最简单的边缘和色块。比如这里是横线,那里是竖线,那片是黑色。
  2. 中间几层(初级组装工):接收下一层的“边缘和色块”,把它们组合成更复杂的局部特征。比如,“两个圆圈”可能是眼睛,“一个三角形”可能是耳朵。
  3. 最后层(最终决策者):基于前面所有层传递过来的、已经高度抽象化的信息(比如“这是一个有胡须、尖耳朵、竖瞳的动物面部”),最终做出判断:“这是狗。”

这个过程,就是一个“逐层抽象,不断精炼”的过程。 每一层都在前一层的基础上,学习并提取更复杂、更核心的特征。网络越深,能学到的特征就越抽象,解决问题的能力也就越强。

到此我相信大部分应该明白了几个很基础的概念,就是机器学习,神经网络,深度学习的概念。我是一名前端开发技术专家,目前除了开始接触 ai 部分的知识,前端部分正在写关于 headless 组件库教程 这是网站首页,欢迎大家给个赞哦,感谢!同时也在写酷炫的动画教程,有兴趣的同学可以看这篇文章

下一章我将简单介绍一下 chatgpt 的基本原理,也是面相纯小白,也绝对包票你能看懂。

详解React组件状态管理useState

1. 前言

在 React 的世界里,组件是构建用户界面的基石,而状态(state)则赋予了这些组件动态变化的能力。其中,useState作为 React 提供的一种强大的状态管理工具,极大地简化了函数式组件中的状态处理,成为了 React 开发者不可或缺的利器。本文将带你深入理解useState的工作原理、使用方法及常见场景,帮助你在 React 开发中更加得心应手地管理组件状态。

2. useState介绍

useState是 React 提供的一个 Hook 函数,专门用于在函数式组件中添加状态。在 React 早期,状态管理主要依赖于类组件(class components),通过this.state来定义和管理状态。然而,类组件的语法相对复杂,代码结构不够简洁。React Hook 的出现改变了这一局面,useState让函数式组件也能轻松拥有状态管理能力,使得代码更加简洁、易读和维护。

2.1 基本语法

const [state, setState] = useState(initialState);

这里,useState接受一个参数initialState,即状态的初始值。它返回一个数组,数组的第一个元素state是当前状态的值,第二个元素setState是一个函数,用于更新状态。

举个简单的例子,创建一个计数器组件:

import React, { useState } from'react';

function Counter() {
  // 使用useState创建一个名为count的状态变量,初始值为0
  // setCount是用来更新count状态的函数
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      {/* 点击按钮时调用setCount来更新count状态 */}
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

export default Counter;

在这个例子中,count初始值为 0,当用户点击按钮时,setCount(count + 1)会将count的值加 1,并触发组件重新渲染,从而在页面上显示更新后的count值。

2.2. 动态设置初始状态

initialState不仅可以是一个固定的值,还可以是一个函数。当initialState是函数时,这个函数只会在组件初始渲染时执行一次,其返回值作为状态的初始值。这种方式在初始状态需要通过复杂计算或依赖其他变量时非常有用。

const [count, setCount] = useState(() => {
  // 进行一些复杂计算
  const initialCount = someExpensiveComputation();
  return initialCount;
});

2.3. 更新状态的方式

状态更新有好几种方式,可以根据需求采用不同的方式。

  • 直接更新
setCount(5);

这是最常见的方式,直接传入新的值给setState函数。这种方式会直接用新值替换当前的状态值。但在实际应用中,更多时候我们需要基于当前状态进行更新。

  • 基于前一个状态更新

当更新状态依赖于前一个状态时,应该传入一个函数给setState。这个函数接受前一个状态作为参数,并返回新的状态值。

setCount(prevCount => prevCount + 1);

这种方式确保了状态更新是基于最新的状态,避免了在异步更新或多次连续更新时可能出现的问题。例如,在下面的代码中,如果连续调用setCount并传入固定值,可能无法得到预期的结果,如下错误示例:

// 错误示例
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

因为 React 的状态更新是异步的,在这三次调用时,count的值可能还是初始值,最终只会更新一次,结果并非我们期望的增加了 3次。下面是一个正确示例:

// 正确示例
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);

React 会依次处理这些更新函数,确保每次更新都是基于前一个状态,最终count会正确地增加 3。

3. 组件渲染

当调用setState更新状态时,React 会触发组件的重新渲染。在重新渲染过程中,useState会返回最新的状态值,组件会根据这些新值重新计算并更新 UI。

需要注意的是,React 的渲染机制是高效的,它会进行浅比较(shallow comparison)来判断是否需要真正更新 DOM。对于复杂数据类型(如对象和数组),如果直接修改内部属性而不改变其引用,React 可能无法检测到状态的变化,从而不会触发重新渲染。例如下面:

const [person, setPerson] = useState({ name: 'John', age: 30 });

// 错误方式,不会触发重新渲染
person.age = 31;
setPerson(person);

// 正确方式,通过创建新对象来更新状态
setPerson({...person, age: 31 });

在更新对象或数组状态时,应该始终创建新的对象或数组,以确保 React 能够检测到状态变化并触发重新渲染。

4. 常见应用场景

  • 表单处理

在处理表单输入时,useState可以方便地管理输入值。例如,创建一个文本输入框组件:

import React, { useState } from'react';

function Input() {
  const [inputValue, setInputValue] = useState('');

  const handleChange = (e) => {
    setInputValue(e.target.value);
  };

  return (
    <div>
      <input type="text" value={inputValue} onChange={handleChange} />
      <p>You typed: {inputValue}</p>
    </div>
  );
}

export default Input;

在这个例子中,inputValue状态存储了输入框的值,每次输入框内容变化时,通过setInputValue更新状态,同时也更新了输入框的显示值。

  • 切换布尔状态

useState也常用于切换布尔类型的状态,比如控制元素的显示与隐藏、按钮的选中状态等。以下是一个简单的开关按钮示例:

import React, { useState } from'react';

function ToggleButton() {
  const [isOn, setIsOn] = useState(false);

  const handleClick = () => {
    setIsOn(!isOn);
  };

  return (
    <button onClick={handleClick}>
      {isOn? 'ON' : 'OFF'}
    </button>
  );
}

export default ToggleButton;

点击按钮时,setIsOn(!isOn)会取反isOn的状态,从而切换按钮的显示文本。

5. 常见面试要点

  • 同一个组件渲染多次会互相影响吗?
    • State 是组件实例内部的状态,隔离且私有的。如果你渲染同一个组件两次,每个组件都会有完全隔离的 state,改变其中一个不会影响另一个。
  • 为什么要在setState中使用回调函数更新状态?
    • 当状态更新依赖前一个状态时,此时值可能还是初始值。而使用回调函数能确保获取到最新的状态值进行更新,避免异步更新带来的问题。
  • 如何正确更新对象或数组类型的状态?
    • 通过创建新的对象或数组,使用展开运算符(...)等方式,确保状态的引用发生变化,从而触发 React 的重新渲染。
  • useState和类组件中的this.state有什么区别?
    • useState用于函数式组件,语法更简洁,基于 Hook 机制;而类组件中的this.state通过this.setState更新状态,语法相对复杂,还涉及生命周期函数等概念。

6. 实现原理和仿写

useState的实现原理与 React 的底层架构和运行机制紧密相关,具体实现如下:

  • 状态存储:在 React 内部,每个函数式组件都维护着一个状态链表。当组件首次渲染时,useState会将传入的initialState存入这个链表中。每调用一次useState,就会在链表中新增一个状态节点,并且useState返回对应的状态值和更新函数。比如在一个组件中多次使用useState分别管理不同状态,这些状态就会依次存入链表,彼此独立又有序关联 。其实,在 React 内部,为每个组件保存了一个数组,其中每一项都是一个 state 对。它维护当前 state 对的索引值,在渲染之前将其设置为 “0”。每次调用 useState 时,React 都会为你提供一个 state 对并增加索引值。
  • 更新机制:当setState被调用时,React 并不会立即更新状态。而是会将更新任务放入一个队列中,等到合适的时机(如浏览器空闲时)进行批量处理。React 会根据更新的顺序找到状态链表中对应的状态节点,更新其值。更新完成后,React 会触发组件的重新渲染。在重新渲染过程中,useState会从状态链表中获取最新的状态值并返回,以便组件根据新值重新计算并更新 UI。
  • Fiber 架构:React 的更新机制基于Fiber架构,Fiber是一种数据结构,它为 React 带来了可中断和恢复的渲染能力。setState调用后,React 会基于Fiber进行调度和协调更新任务。这使得 React 在面对复杂交互场景和长时间运行任务时,能够合理分配渲染时间,避免主线程阻塞,提升应用的响应性能和用户体验。同时,Fiber架构也帮助 React 更高效地管理组件的更新过程,确保状态更新和重新渲染的准确性与稳定性。

这个是官方的实现Demo,可以让我们了解 useState 在内部是如何工作的:传送门

6. 总结

useState作为 React 函数式组件中状态管理的核心工具,为开发者提供了简洁而强大的状态处理能力。通过理解其基本用法、更新机制以及在常见场景中的应用,你可以更加高效地构建动态、交互式的 React 应用。而在项目中使用useState时,每个useState尽量只管理一个逻辑相关的状态,保持状态的单一职责。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

大部分人都错了!这才是chrome插件多脚本通信的正确姿势

昨天一个实习生同事来找我:“哥们,我的Chrome插件遇到个奇怪问题,为什么我插入到页面的内容脚本content.js,重写页面脚本的方法没有生效?”

其实类似的问题我也经常碰到,比如:“为什么popup页面调用不了content.js的函数?”、“插入到页面的内容脚本为啥访问不到Vue实例?”、“background脚本为啥不能访问页面的dom节点”?………这些问题在插件开发里真的挺常见的,大家都容易踩坑。

所以今天,我想用最通俗的方式,把自己遇到的问题和一些小经验分享出来:

  • 为什么Chrome要把插件分成这么多脚本?它们之间到底啥关系?
  • 那些“看起来应该能用,但就是不行”的通信方式,背后的原因是什么?
  • 怎么让插件的各个部分配合得更顺畅?

放心,没有复杂的术语,用图解的方式来梳理脉络,希望这些内容能帮到大家,也欢迎大家一起交流!

前置知识:

要搞懂插件通信,首先得了解浏览器的架构。现在的 Chrome 浏览器采用的是“多进程架构”,什么意思呢?直接看图:

上图中,我们浏览器架构是由、渲染进程、插件进程、网路进程、浏览器主进程、gpu进程等组成的

  • 浏览器主进程: 相当于公司的大老板,负责整个公司的运转。比如你要开新窗口、切换标签、下载文件、弹出权限提示,都是浏览器主进程安排的。

    其他进程有啥事也都得跟主进程报备,主进程来调度。

  • 渲染进程:平时我们使用浏览器打开的每一个网页,都是由渲染进程进行渲染的。插件注入的内容脚本也在这里运行,不过处于“隔离世界”,与页面脚本相互隔离,这个后面我们会详细讲

  • 网络进程:处理所有页面与扩展的请求,我们网页中或者插件中的接口请求这种脏活累活都由它来干

  • GPU进程:网页中有炫酷动画、视频、3D效果啥的,GPU进程就会帮你画的又快又漂亮。

  • 插件进程:运行我们平时所装的浏览器插件,我们装的不同的插件都会分配到不同的插件进程中,互不干扰,谁家插件出了问题,最多自己崩,不会影响到其他插件和页面的运行

Chrome 浏览器其实就是把各种工作分开来做,谁负责啥都很清楚。主进程管大局,渲染进程负责把网页内容展示出来,网络进程专门搞数据传输,GPU进程让动画和视频更流畅,插件进程则让你装的各种扩展各自独立运行。大家各干各的,互不影响,这样浏览器用起来才又快又稳还安全。

插件各部分的角色与能力

浏览器插件开发实践 - 不一样的少年_的专栏 - 掘金

大家看到我的插件专栏里的插件目录,大致是这样:

demo
├── manifest.json # 扩展的"身份证"
├── background.js # 插件后台脚本
├── injected.js # 注入页面脚本
├── content.js # 内容脚本
└── popup.html # 弹窗页面

我们来简单聊聊每个文件的作用:

manifest.json —— 插件的“身份证”

这个文件就像插件的身份证,里面写明了插件的名字、版本、权限、各个脚本的入口。比如你这个插件是弹窗类型,还是需要内容脚本、后台脚本,都要在这里声明清楚。没有它,浏览器都不认你这个插件。

popup.html —— 弹窗页面

这个文件就是你点浏览器右上角插件图标弹出来的小窗口。写法跟普通网页一样,可以放按钮、输入框、结果展示区。

如下红色框住的就是popup页面:

这个popup页面,浏览器会分配一个渲染进程来进行渲染,如下图:

background.js —— 后台脚本

后台脚本是插件的大脑,负责处理各种“后台任务”。比如监听消息、和服务器通信、管理数据。它不直接操作页面内容,但能帮你做很多“脏活累活”,比如跨域请求、定时任务、权限控制。弹窗页面、内容脚本都可以和它发消息,让它帮忙干活。

这个后台脚本,浏览器会开一个service worker服务进程来运行,如下图:

举例popup页面和插件后台的通信

比如你做了一个“天气查询”插件,用户在弹窗页面输入城市名,点击“查询”按钮,弹窗页面就会通过 chrome.runtime.sendMessage 把城市名发给后台脚本,后台脚本收到后去请求天气接口,然后把结果返回给弹窗页面显示。

图解

  • 用户操作 popup 页面

  • popup 页面用 chrome.runtime.sendMessage 发消息给后台脚本

  • 后台脚本处理请求,返回结果给 popup 页面

虽然都popup页面和service worker属于同一个扩展,但它们运行在不同进程/沙箱中,无法直接共享内存或调用函数,必须通过浏览器主进程的消息路由(IPC)中转,如下图:

代码示例:

popup.html

<!DOCTYPE html>
<html>
  <body>
    <input id="city" placeholder="城市名" />
    <button id="btn">查天气</button>
    <div id="result"></div>
    <script>
    const btn = document.getElementById('btn')
      btn.onclick = function() {
        const city = document.getElementById('city').value;
        // popup向background.js发送查询消息
        chrome.runtime.sendMessage({ type: 'getWeather', city }, function(response) {
            // background.js返回的消息
          const data =  response.weather 
          document.getElementById('result').textContent = data || '查询失败';
        });
      };
    </script>
  </body>
</html>

background.js

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'getWeather') {
    // 这里只做演示,实际开发可以用 fetch 去请求真实接口
    const fakeWeather = `${msg.city}:晴,25°C`;
    sendResponse({ weather: fakeWeather });
    // 如果是异步操作(比如 fetch),需要 return true
  }
});

这样,弹窗页面和后台脚本就能通过消息机制实现通信,弹窗页面只负责收集用户输入和展示结果,后台脚本负责处理数据和业务逻辑,分工明确,开发起来也很清晰!

我们打开chrome的任务管理器看刚刚上面提到的popup插件,来看看service worker和popup是否在不同的进程中运行

如下图:

image.pngimage.png

可以看出service worker和popup确实是分别在两个进程中运行

content.js —— 内容脚本

内容脚本是插件派到网页里的“卧底”,所以这个content.js是在页面的渲染进程中运行的,不是在插件中运行的

我们画个图来解释下:

当我们用浏览器打开掘金网站时,浏览器会启动一个渲染进程来负责页面的展示。掘金网站自己的 JS 变量和运行环境都在 Main World(页面脚本环境)里,比如你用 React/Vue 写的代码、window 上挂的变量,都是属于这个世界。

如果你这时候装了一个护眼插件,想让页面变成护眼模式(比如把背景色调成绿色),插件会通过内容脚本来操作页面的 DOM,比如直接修改 body 的样式。这段内容脚本其实是在另一个独立的 JS 环境里运行,也就是图里的 Isolated World(内容脚本环境)。

虽然内容脚本和页面脚本的执行环境是隔离开的,互相访问不到对方的变量,但它们可以一起操作和共享页面上的 DOM(比如 document.body),所以内容脚本能帮你改页面样式、加按钮、弹提示,但没法直接拿到页面里的 JS 变量。如果内容脚本真要和页面脚本通信,可以用 window.postMessage 这种方式来“搭桥”。

这样设计既保证了安全,又能让插件灵活地扩展页面功能。

内容脚本是怎么进到页面里的呢?

内容脚本是插件派到网页里的“卧底”,其实它的“卧底”过程也是有讲究的:

有两种方式,第一种是声明式的,另一种是编程式的

声明式:

浏览器根据插件的 manifest.json 配置,自动帮你注入到指定网页的渲染进程里。 比如你在 manifest.json 里写了:

"content_scripts": [
  {
    "matches": ["https://juejin.cn/*"],
    "js": ["content.js"]
  }
]

只要你打开掘金网站,浏览器就会自动把 content.js 注入到页面的渲染进程里,让它在 Isolated World(隔离环境)里运行。

编程式(按需注入)

这里有个很重要的概念: 浏览器里不同进程之间(比如主进程、渲染进程、插件进程)要互相传递消息,靠的就是 IPC(Inter-Process Communication)机制,翻译过来就是“进程间通信”。

  • 比如你在后台脚本(background.js)或弹窗页面(popup)里调用 chrome.scripting.executeScript ,就能按需把内容脚本注入到指定网站页面里。

  • 这个过程其实是:插件发起注入请求后,浏览器主进程会先受理这个请求,然后通过 IPC机制,把要注入的内容脚本安全地传递给目标页面的渲染进程。

  • 渲染进程收到脚本后,就会在页面的隔离环境(Isolated World)里执行内容脚本,这样内容脚本就真正“落地”到页面中了。

所以,整个流程就是: 插件调用 chrome.scripting.executeScript → 浏览器主进程受理并通过 IPC 转发 → 页面渲染进程执行内容脚本。

injected.js —— 注入页面脚本

前面说过,页面的 JS 环境和内容脚本的 JS 环境是互相隔离的,彼此访问不到对方的变量和方法,但它们共享同一个 DOM 树。 如果你想直接改页面里的 JS 环境,比如重写 fetch 或 XMLHttpRequest,实现像 mock 接口这样的功能,就不能只靠内容脚本了。

这时候就需要用“注入脚本”的方式: 我们可以在内容脚本里创建一个 script 标签,把要改写的代码(比如新的 fetch 实现)写进去,然后把这个标签插入到页面的 DOM 里。这样,页面会像加载普通 JS 文件一样执行这段代码,最终就能覆盖页面原生的 fetch 和 xhr 方法。

这种做法的好处是:

  • 能直接影响页面自己的 JS 环境,实现更强的功能扩展

  • 只要 DOM 是共享的,插入 script 标签就能让页面执行我们的代码

  • 很适合做 mock、日志劫持、性能监控等插件功能

比如我最近写的一个基础的 mock 插件(juejin.cn/post/757098…), 就是用这种方式,在页面加载前偷偷把 fetch 和 XMLHttpRequest 替换成我重写的,让所有网络请求都能被插件拦截和处理。

跨环境通信流程举例

有时候,我们的注入页面脚本(injected.js)需要用到插件进程里的数据,比如判断某个 fetch 请求是否命中插件配置的规则。但页面环境是拿不到插件进程里的配置的,不过内容脚本可以帮忙“中转”。

比如我们在 injected.js 里重写了 fetch 方法,页面发起请求后,需要查一下插件里有没有对应的 mock 规则。整个数据流可以这样走:

    1. 注入脚本(injected.js)拦截到 fetch 请求,发现需要插件里的规则配置。
    1. 注入脚本用 window.postMessage 向内容脚本发消息,请求规则数据。
    1. 内容脚本收到消息后,再用 chrome.runtime.sendMessage 向插件进程(比如后台脚本)发起请求,获取最新规则。
    1. 插件进程通过 IPC 机制把规则数据返回给内容脚本。
    1. 内容脚本拿到数据后,再用 window.postMessage 把结果传回 injected.js。
    1. 注入脚本拿到规则,判断请求是否命中,然后做后续处理(比如 mock、拦截、日志等)。

这样一来,页面脚本、内容脚本、插件进程就能通过消息链路把数据安全地串联起来,实现复杂的功能扩展。 整个过程就像“接力传话”,每个环节各司其职,既保证了安全,又让插件和页面能灵活协作

总结:插件多脚本通信,没那么难!

看到这里,相信你已经对Chrome插件中的多脚本通信有了清晰的认识。让我们简单回顾一下今天学到的关键知识:

  1. 为什么需要多脚本?
  • Chrome的多进程架构是为了安全和稳定性,不是为了故意为难开发者
  1. 各脚本的角色:
  • background.js :常驻后台,负责大部分逻辑和权限,是插件的大脑。
  • content.js :嵌入页面,能操作DOM,但和页面JS是隔离的,像是派驻到页面的特工。
  • popup.html :弹窗页面,主要负责UI交互,关闭就“失忆”,但用起来很方便。
  • injected.js :直接注入页面,能访问和修改页面环境,类似卧底。
  1. 通信秘诀:
  • background ↔ popup :用 chrome.runtime.sendMessage
  • background ↔ content :用 chrome.tabs.sendMessage 或 chrome.runtime.sendMessage
  • content ↔ 页面JS :用 window.postMessage

还记得开头那位苦恼的同事吗?现在你不仅知道为什么他的变量“死活拿不到”,更重要的是,掌握了正确的解决方法!

希望这篇文章能帮你少踩坑、多收获。如果觉得有用,欢迎点赞、收藏,你的支持是我持续分享的动力!

有任何问题,欢迎在评论区讨论。下期见!👋

如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下!

往期实战推荐:

优化:如何避免 React Context 引起的全局挂载节点树重新渲染

最近项目中一个React Context在不断的接受websocket事件,然后一直修改state,导致重复渲染过多,比较卡顿

TM的这状态管理真乱,趁机总结一下React Context的使用的注意事项

React Context 用于在组件树中传递数据,而不必手动地通过 props 逐层传递。然而,它的便利性也带来了一个常见的性能陷阱:当 Context 的值发生变化时,所有依赖该 Context 的消费组件都会重新渲染,即使它们只使用了 Context 值中的一小部分。

如果处理不当,这种全局性的重新渲染可能会拖慢你的应用,尤其是在 Context Provider 位于组件树顶层,并且其值包含频繁变动的数据时。

Context的重新渲染机制

当我们使用 useContext(MyContext)<MyContext.Consumer> 时,React 会在内部建立一个订阅关系。

  1. 当 Provider 的 value 属性发生变化时:React 会检查新旧值是否严格相等(===)。
  2. 如果值不相等:React 会通知所有订阅了该 Context 的 Consumer 组件执行重新渲染。

React 并没有对 Consumer 实际使用了 Context 值中的哪部分属性进行细粒度分析。只要 Context 的 value 对象本身 引用发生了变化,所有 Consumer 都会触发更新。

// ❌ 常见但易导致全局渲染的模式
const MyContext = createContext({ user: null, settings: {} });

function App() {
  // state 只要更新,value 对象就会创建一个新的引用
  const [appState, setAppState] = useState({ user: { name: 'Gemini' }, theme: 'dark' });

  // 每次 App 渲染,这个对象都是一个新的引用
  const contextValue = useMemo(() => appState, [appState]);

  return (
    <MyContext.Provider value={contextValue}>
      <Header />
      <Content />
      <Footer />
    </MyContext.Provider>
  );
}

// 假设 Header 只使用了 appState.user
function Header() {
  const { user } = useContext(MyContext);
  // ... 其他代码
  return <h1>Welcome, {user.name}</h1>;
}

// 假设 Footer 只使用了 appState.theme
function Footer() {
  const { theme } = useContext(MyContext);
  // ... 其他代码
  return <p>Current theme: {theme}</p>;
}

// ⚡️ 陷阱:即使只有 theme 变化,Header 也会重新渲染!

避免全局重新渲染

我们可以通过以下几种策略,将 Context 的重新渲染范围限制在真正需要更新的组件。

1. 拆分 Context

这是最简单、最有效的策略之一。与其将所有状态都塞入一个“大 Context”中,不如根据数据的更新频率耦合关系将其拆分成多个独立的 Context。

  • 高频更新 / 独立的 Context:例如,用户交互状态(IsLoadingContext)。
  • 低频更新 / 共享的 Context:例如,全局配置和静态数据(ThemeContext)。
// ✅ 拆分成多个独立的 Context
const UserContext = createContext(null);
const ThemeContext = createContext(null);

function App() {
  const [user, setUser] = useState({ name: 'Gemini' });
  const [theme, setTheme] = useState('dark');

  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <Header /> {/* 仅消费 UserContext */}
        <Footer /> {/* 仅消费 ThemeContext */}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

// 优化效果:
// 1. user 变化,只有 Header 及其子树可能重新渲染。
// 2. theme 变化,只有 Footer 及其子树可能重新渲染。互不影响。

2. 使用 Custom Hook 和 memo 结合

这种方法适用于你无法拆分 Context,但又想防止 Consumer 重新渲染的情况

通过 useMemo 或自定义 Hook 仅提取 Context 中需要的属性,并结合 React.memo 来跳过不必要的渲染

// 针对 Header 组件的自定义 Hook
const useUser = () => {
  const context = useContext(MyContext);
  // 仅返回 user 部分,确保只有 user 改变时才返回新引用
  return useMemo(() => context.user, [context.user]); 
};

// 结合 React.memo
const MemoizedHeader = React.memo(function Header() {
  const user = useUser(); // 即使 MyContext 整体变了,只要 user 不变,useUser 就会返回旧引用

  // ... 渲染逻辑
});

// ⚡️ 陷阱规避:
// 1. MyContext 整体改变,MemoizedHeader 接收新的 props (即 useUser 返回的值)。
// 2. 但由于 useUser() 对 user 属性进行了 useMemo 优化,如果 user 对象引用没有变化,
// 3. React.memo 就会发挥作用,跳过 Header 的渲染。

END

祝大家暴富!!!

跟着TRAE SOLO全链路看看项目部署服务器全流程吧

跟着TRAE SOLO全链路看看项目部署服务器全流程吧

接下来我们新建一个项目,然后将项目部署到服务器上,并且配置好以后可以在外网进行访问

安装nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash

1、简单服务器环境搭建

接下来我们就实现把 Node.js 项目部署到 /opt/nexus-node-api 并配置外部访问

进入服务器以后安装环境

# 更新包列表
sudo apt update

# 安装 Node.js 和 npm
sudo apt install nodejs npm

# 验证安装
node --version
npm --version

项目创建

# 创建目录
sudo mkdir -p /opt/nexus-node-api

# 设置所有者和权限
sudo chown -R $USER:$USER /opt/nexus-node-api
chmod -R 755 /opt/nexus-node-api

# 进入目录
cd /opt/nexus-node-api

# 创建一个项目
nano app.js

项目内容

const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello World!\n');
});
server.listen(3000, '0.0.0.0', () => {
    console.log(`Server running on port 3000`);
});

测试运行以及外网访问

注意点:一定要注意这个时候必须保证你的服务器里面的防火墙(安全组)规则里面有3000这个端口号

node app.js

现在访问 http://你的服务器IP:3000 应该能看到 "Hello World!"

2、正式项目配置

卸载node环境

这里我们使用nvm来配置我们的环境,如果已经有的,我们删除一下已经有的环境

# 卸载 nodejs 和 npm
sudo apt-get remove nodejs npm
sudo apt-get purge nodejs npm

安装nvm

// 安装 nvm
# 建议安装 nvm,方便版本管理
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash

配置环境变量

# 编辑 .bashrc
nano ~/.bashrc

//添加配置 ---一般系统会自动为我们添加
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"

// 重新加载配置
source ~/.bashrc

// 验证 nvm 安装
nvm --version

安装稳定版本node

ubuntu为例子
// 查看可以安装的稳定版本
nvm ls-remote

// 这里我安转版本
nvm install v22.12.0
// 使用
nvm use v22.12.0

// 设置默认版本
nvm alias default v22.12.0
//   pm2
npm i -g pm2

使用pm2守护进程

PM2 是 Node 应用的进程管理工具,能保证服务在后台持续运行:要不然关闭窗口之后,就无法访问了

# 全局安装 PM2
npm install pm2 -g

# 启动服务并命名(方便管理)
pm2 start app.js --name "node-api-nexus"

# 查看服务状态
pm2 list  # 若 Status 为 online 则表示启动成功

这个时候不管怎么刷新我们的页面或者窗口,可以始终稳定访问我们的接口

pm2 重启对应的服务
pm2 restart "node-api-nexus"

3、服务器安装mysql数据库

环境搭建

接下来我们在服务器上安装mysql数据库,这里需要我们输入服务器密码

# 更新包列表
sudo apt update

# 安装 MySQL 服务器 
// 安装 MySQL 8.0(Ubuntu 默认源即提供 MySQL 8.0)
sudo apt install mysql-server -y

# 安装过程中可能会提示输入服务器密码

# 确认 MySQL 版本
mysql --version

MYSQl数据库安全配置

调整 MySQL 服务器的安全性
# 安全配置(可选但推荐)
sudo mysql_secure_installation

测试可以都选n


按照提示配置:
是否启用强密码   // y 
设置 root 密码   // Le@1996#Lin
移除匿名用户
禁止远程 root 登录(可选)
删除测试数据库
重新加载权限表
登录 MySQL
sudo mysql -u root -p
输入密码即可

配置远程访问(可选)

配置 MySQL 允许本地连接:

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

配置信息
[mysqld]
# 确保绑定到本地
bind-address = 127.0.0.1

# 设置端口
port = 3306

# 设置字符集
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
修改mysql数据库配置
[mysqld]
# 注释掉原来的 bind-address 或改为 0.0.0.0
# bind-address = 127.0.0.1
bind-address = 0.0.0.0
port = 3306
重启服务,登录mysql创建远程连接用户
// 重启mysql服务
sudo systemctl restart mysql

// 登录
sudo mysql -u root -p

// 密码
123456

-- 创建远程用户(% 表示允许任何IP连接)
CREATE USER '账号'@'%' IDENTIFIED BY '密码';

-- 授予权限
GRANT ALL PRIVILEGES ON *.* TO '密码'@'%' WITH GRANT OPTION;

-- 或者只授权特定数据库(跳过)
-- GRANT ALL PRIVILEGES ON your_database.* TO 'remote_user'@'%';

-- 刷新权限
FLUSH PRIVILEGES;

-- 查看用户
SELECT User, Host FROM mysql.user;

-- 退出
EXIT;

// 重启 MySQL
sudo systemctl restart mysql

// 设置开机自启(默认应已设置)
sudo systemctl enable mysql

4、navicat远程mysql数据库

切记:一定要保证我们的服务器已经添加了我们的端口3306

服务器允许我们远程连接

# 开放 3306 端口
sudo ufw allow 3306

# 或者只允许特定IP访问(更安全)
sudo ufw allow from 你的本地IP to any port 3306

# 查看防火墙状态
sudo ufw status

远程连接

本地远程mysql数据库,我使用的是navicat工具,这里直接输入我们的信息

连接名:远程服务器,随便起名字
主机:服务器IP
用户名:上面设置的
密码:上面设置的

测试一下,服务器的数据库已经连接成功了

数据库连接测试

新建一个数据库,这里我的名称是nexus

数据库名:nexus
字符集:utf8mb3
排序规则:utf8mb3_bin

新建一个表

DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`  (
  `user_id` int(0) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '姓名',
  `age` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '年龄',
  `sex` int(0) NULL DEFAULT NULL COMMENT '用户性别 1男 2女 ',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `address` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '用户的地址',
  `state` tinyint(0) NULL DEFAULT NULL COMMENT '1 正常  0 2  禁用',
  `phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '手机号',
  `username` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '用户的登录账号',
  `password` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL DEFAULT '123456' COMMENT '用户的登录密码',
  `avatar` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '头像地址',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  `user_height` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '身高',
  `user_weight` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '体重',
  `disease` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL COMMENT '健康状况,是否有疾病',
  PRIMARY KEY (`user_id`, `password`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 55 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;

本地运行项目测试

这里我们现在就本地启动项目连接我们服务器,然后进行测试,这里我以开源的Node项目为例,主要修改四个参数

const dbhost='xx'; // 数据库主机地址,如果是本地数据库则使用localhost
const dbdatabase='xx'; // 数据库名称
const dbuser='xx'; // 数据库用户名
const dbpassword='xxx'; // 数据库密码

本地测试一下,我们的线上数据库已经可以使用了

5、Node项目部署

接下来我们将node项目部署进我们的服务器,首先把我们项目都扔进去

配置环境

这里我用的是yarn,安装一下

npm install yarn -g 


// 配置环境
yarn

// 启动pm2
pm2 start app.js --name "node-api-nexus"

// 重新启动pm2 设置开机自启
pm2 startup
pm2 save

查看详细日志

pm2 logs node-api-nexus

启动以后我们就可以直接在浏览器打开地址对我们的系统后台进行访问了

http://XXXXXX:3200/

6、前端部署

环境安装

接下来我们继续部署我们的前端应用,先用我们的项目连接一下我们的数据库尝试一下 OK,没什么问题,然后我们开始部署前端项目

项目名称为nexus-vue,项目打包好的路径位于 /opt/nexus-vue 下面

// 打包前端项目
yarn build

// 更新和安装nginx 
// 更新可以跳过 之前我们已经进行过
sudo apt update
sudo apt install nginx

// 查看版本
nginx -V

配置nginx

sudo nano /etc/nginx/sites-available/nexus-vue

// 配置如下
server {
    listen 80;
    server_name localhost;  # 替换为你的域名或IP

    root /opt/nexus-vue;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # 如果需要代理API请求
    location /api {
        proxy_pass http://localhost:3000;
    }
}
server {
    listen 8080;
    server_name localhost;  # 替换为你的域名或IP

    # 前端静态文件
    root /opt/nexus-vue;
    index index.html;

    # 前端路由
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 后端API请求
    location /api {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # WebSocket连接
    location /ws {
        proxy_pass http://localhost:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

开放接口

sudo ufw allow 8080

sudo systemctl restart nginx

处理日志错误

// 检查nginx错误日志
sudo tail -f /var/log/nginx/error.log


//开放文件权限
sudo chmod -R 755 /opt/nexus-vue

// 检查配置
sudo nano /etc/nginx/sites-available/nexus-vue

// 重新启动nginx
sudo systemctl restart nginx

部署

写一个测试页面扔进去

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>我是测试页面</title>
</head>
<body>
<h1>我是测试页面</h1>
</body>
</html>

访问我们的地址http://域名IP:8080/

这个时候已经可以看到我们的项目已经部署上去了

重新加载以后,ok,到这里我们全链路都部署上去了

把 16MB 中文字体压到 400KB:我写了一个 Vite 字体子集插件

一个真实的前端项目里,我遇到一个很常见却又容易被忽视的问题:一套中文界面,为了保证视觉效果引入了整套中文字体文件,结果单字体就占了十几 MB,构建产物和安装包体积都被严重拖累。现有方案要么依赖运行时,要么在工程化集成上不太符合我的需求。于是我决定写一个专门面向 Vite 的字体子集化插件 @fe-fast/vite-plugin-font-subset:在构建阶段自动收集实际用到的字符集,生成子集化后的 woff2 字体,并无缝替换原有资源,不侵入业务代码。在这篇文章里,我会分享这个插件诞生的背景、设计目标、关键实现思路,以及在真实项目中带来的体积优化效果,希望能给同样被“中文字体体积”困扰的你一些参考。

一、项目背景:16MB 的中文字体,把包体积拖垮了

我日常主要做的一个项目,是基于 Vue3 + Vite + Pinia + Electron 的桌面应用(电力行业业务系统)。
这个项目有两个典型特点:

  • 中文界面 + 大量业务术语:几乎所有页面都是中文,且有不少专业名词
  • 离线/弱网场景:不仅要打成 Electron 安装包,还要支持在弱网环境下更新

随着功能越来越多,我开始频繁在构建日志里看到这样一段“刺眼”的内容:

  • src/SiYuanHeiTi 目录里有两份 SourceHanSansCN OTF 字体
  • 每一份大概 8MB+,加起来就是 16MB+ 的纯字体资源

哪怕我已经做了一些优化:

  • 图片用 vite-plugin-imagemin 压缩
  • 代码做了基础的拆包和懒加载

构建产物里字体资源仍然是绝对大头
简单说:用户只是打开一个中文界面,却要被迫下载完整一套 GBK 字库,这显然太浪费了。

二、降体积的几种思路,对比一下

在真正动手写插件之前,我先把可能的方案都过了一遍,权衡了一下利弊。

方案 1:换成系统字体 / 常见 Web 字体

  • 优势
    • 不需要额外的字体文件,体积几乎为 0
  • 劣势
    • 设计同学辛苦做的 UI 风格会被破坏
    • 跨平台(Windows/macOS)渲染效果不可控,特别是复杂表格、图形界面
  • 适用场景
    • 对视觉统一要求不高的后台系统、管理台
  • 实现难度:⭐

方案 2:直接引入现成的字体子集化工具 / 在线服务

  • 优势
    • 现有方案成熟,不用自己“造轮子”
  • 劣势
    • 有些是在线服务,不适合公司内网/离线场景
    • 一些工具只关注命令行,不关注 Vite 构建流程 的无缝集成
  • 适用场景
    • 纯 Web 项目、对 CI/CD 环境更自由的团队
  • 实现难度:⭐⭐⭐

方案 3:使用已有的 Vite 字体子集插件

我也尝试过社区已有的 vite-plugin-font-subset 等插件,但踩到了两个坑:

  1. ESM-only 与现有工程的兼容问题
    • 有的插件是纯 ESM 包,而我当时的构建链路里,

      vite.config.js 仍然是以 CJS 方式被 esbuild 处理

    • 直接 import 会在加载配置阶段就报:

      ESM file cannot be loaded by require

  2. “大量中文 + 特殊字符”场景需要更多可配置性
  • 优势
    • 理论上“开箱即用”,几行配置就能跑
  • 劣势
    • 在我的项目环境里,兼容性和可扩展性都有一些限制
  • 适用场景
    • Node / Vite 配置已经完全 ESM 化的新项目
  • 实现难度:⭐⭐

推荐选择 & 我的决策

  • 在综合权衡之后,我选择了:
    “在 Vite 插件体系内,写一个适配自己项目的字体子集化插件,并抽象成通用插件发布出来”

  • 于是就有了今天的这个包:
    **

    fe-fast/vite-plugin-font-subset**

三、我给插件定下的几个目标

在真正敲代码之前,我给这个插件定了几个很具体的目标:

  1. 零运行时开销

    • 所有工作都在 vite build 阶段完成
    • 运行时只加载子集后的 woff/woff2 文件
  2. 对现有项目“侵入感”足够低

    • 只需要在 vite.config 里增加一个插件配置
    • 不要求你改动业务代码里的 font-family 或静态资源引用方式
  3. 兼容我当前的工程形态

    • 支持 Electron + Vite 的场景
    • 避免“ESM-only 插件 + CJS 配置”这种加载失败问题
  4. 默认就能解决“中文大字体”问题

    • 在不配置任何参数的情况下,对于常规的中文页面,能直接减掉大部分无用字形

四、核心思路:从字符集到子集字体的流水线

具体实现细节线上可以看源码,这里更侧重讲清楚“思路”,方便大家自己扩展或实现类似插件。

整个插件的执行链路,大致可以拆成四步:

1. 收集可能会用到的字符集

  • 扫描构建产物(或者源码)里的:
    • 模板中的中文文案
    • 国际化文案 JSON
    • 常见 UI 组件中的静态字符
  • 做一些去重和过滤,得到一个 相对完整但不过度膨胀的字符集合

这里的关键是平衡:

  • 集合太小:生产环境会出现“口口口/小方块”
  • 集合太大:子集化收益会变差

2. 调用子集化引擎生成子集字体

  • 将“原始 OTF/TTF 字体文件 + 上面的字符集”交给子集工具
  • 输出一份或多份新的字体文件(优先 woff2)

在我的项目中,最终生成的结果类似构建日志中的这一行:

textSourceHanSansCN-Normal-xxxx.woff2    223.97 kBSourceHanSansCN-Medium-xxxx.woff2    224.79 kB

相比最初 两份 8MB+ 的 OTF 文件,体积已经被压到了大约十分之一左右。

3. 更新 CSS / 资源引用

  • 在原有的

    font-face 声明基础上,修改 src 指向子集化后的文件

  • 对于 Vite 生成的静态资源目录(如 dist/prsas_static),保持输出路径稳定,避免破坏现有引用

这一部分的目标是:对业务代码完全透明,你仍然可以这样写:

cssbody {  font-family: 'SourceHanSansCN', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;}

只是最终加载的资源,不再是原来那两个 8MB 的 OTF,而是几百 KB 的子集 woff2。

4. 和 Vite 构建流程集成

  • 通过 Vite 插件 API,在合适的生命周期(如 configResolvedgenerateBundle 等)
    • 拿到最终输出目录
    • 触发上面的子集化流水线
    • 将生成的文件写回到 Rollup 构建产物中

核心原则就是:不打破 Vite 原有工作流,只是“在尾部插一个子集化步骤”

五、在真实项目中的效果

以我这个 Electron + Vite 项目为例,启用

fe-fast/vite-plugin-font-subset 之后:

  • 原来两份 8MB+ 的 OTF 中文字体
  • 变成两份 两百多 KB 的 woff2 子集字体
  • 对比结果非常直观:
    • 安装包体积明显下降
    • 首次加载速度、增量更新速度都有肉眼可见的提升
    • 用户几乎感受不到视觉上的差异

配合 vite-plugin-imagemin 对 PNG 等图片资源的压缩,整体构建体验也变成了:

  • 构建时间长一点(多了字体子集化和图片压缩),但属于“可接受的离线计算”
  • 换来的是 更小的安装包、更快的首屏体验,尤其适合弱网和内网环境

六、如何使用这个插件

简单说一下使用方式(仅作示意,具体参数可以看 README):

bashnpm install -D @fe-fast/vite-plugin-font-subset

ts// vite.config.ts / vite.config.jsimport fontSubsetPlugin from '@fe-fast/vite-plugin-font-subset'export default defineConfig({  plugins: [    vue(),    fontSubsetPlugin({      // 一些可选配置,例如:      // fonts: [{ path: 'src/SiYuanHeiTi/SourceHanSansCN-Normal.otf', name: 'SourceHanSansCN' }],      // include: ['src/**/*.vue', 'src/**/*.ts'],      // ...    }),  ],})

做到这一点之后,剩下的事情就交给构建阶段处理即可。

七、过程中的几个坑 & 经验

在开发这个插件的过程中,也遇到了一些值得记录的坑:

  • ESM vs CJS 的兼容

    • 之前用其他字体插件时,遇到过 ESM file cannot be loaded by require 的报错
    • 这直接促使我在发布这个插件时,特别注意构建目标和导出形式,让它能更好地兼容现有工程
  • 字符集过于激进会导致“缺字”

    • 一开始我只统计了模板里的中文字符,结果线上发现某些动态内容会出现“口口口”
    • 最终方案是:适度保守 + 预留一部分常用汉字范围
  • 构建时间和体验的平衡

    • 字体子集化本身是一个“CPU 密集型”的过程
    • 在开发环境我默认关闭了子集化,仅在 vite build 时启用,保证日常开发体验

八、总结与展望

fe-fast/vite-plugin-font-subset 其实不是一个“炫技”的轮子,而是从真实业务需求里长出来的:

  • 它解决的是一个非常具体的问题:中文项目中,字体资源过大导致包体积和加载体验变差
  • 它也体现了我在做前端工程化时的一些偏好:
    • 用好现有工具链(Vite 插件体系)
    • 优先选择“构建时处理”,而不是在运行时增加复杂性
    • 遇到兼容性问题时,适当地“自己造一个更适合现有工程的轮子”

后续我还希望在这个插件上做几件事:

  • 更智能的字符集分析(结合路由拆分、按需子集)
  • 提供简单的可视化报告,让你一眼看到“字体减肥”前后的体积对比
  • 增强对多语言项目的支持

Quill 2.x 从 0 到 1 实战 - 为 AI+Quill 深度结合铺路

引言

在AIGC浪潮席卷各行各业的今天,为应用注入AI能力已从“锦上添花”变为“核心竞争力”。打造一个智能写作助手,深度融合AI与富文本编辑器,无疑是抢占下一代内容创作高地的关键一步。

而一切智能编辑的基石,在于一个稳定、强大且高度可定制的基础编辑器。本文将深度解析 ‌Quill 2.x——这个在现代Web开发中备受青睐的富文本编辑器解决方案。快来开始Quill2.x的教程吧!

本文将从概念解析到实战落地,补充核心原理、汉化方案和避坑指南,帮你真正吃透 Quill 2.x,看完就能直接应用到项目中。

一、Quill 核心概念:它到底是什么?

在动手之前,先搞懂 Quill 的核心定位,避免用错场景:

Quill 是一款「API 驱动的富文本编辑器」,核心设计理念是「让开发者能精准控制编辑行为」。它不同于传统编辑器(如 TinyMCE、CKEditor)的「配置式黑盒」,而是通过暴露清晰的 API 和内部状态,让开发者像操作 DOM 一样操作编辑器内容。

几个关键概念需要明确:

  • 容器(Container) :用于承载编辑器的DOM元素,Quill会接管该元素并渲染编辑区域
  • 模块(Modules) :编辑器的功能单元(如工具栏、代码块),2.x 中模块需显式注册。
  • 主题(Themes) :编辑器外观,官方提供 snow(带固定工具栏)和 bubble(悬浮工具栏)两种,支持自定义样式。
  • Delta:Quill 独创的内容描述格式(类似 JSON),用于表示内容本身和内容变化,是实现协同编辑、版本控制的核心。
  • 格式(Formats) :描述内容的样式属性(如加粗、颜色、链接),可通过 API 或工具栏触发,支持自定义扩展。

二、原理解析:Quill 是如何工作的?

理解底层原理,能帮你更灵活地解决问题。Quill 的核心工作流程可分为三部分:

1. 内容表示:Delta 格式

传统编辑器用 HTML 字符串描述内容,但 HTML 存在「同内容多表示」(如 <b> 和 <strong> 都表示加粗)、「难以 diff 对比」等问题。而 Delta 用极简的结构解决了这些问题:

Delta 本质是一个包含 ops 数组的对象,每个 op 由 insert(内容)和 attributes(样式)组成。例如:

// 表示「Hello 加粗文本」的 Delta
{
  ops: [
    { insert: '这是一段 ' },
    { insert: '加粗文本', attributes: { bold: true } }
  ]
}

image.png

  • 优势 1:唯一性 —— 同一内容只有一种 Delta 表示,避免歧义。
  • 优势 2:可合并 —— 两个 Delta 可通过算法合并(如用户 A 和用户 B 同时编辑的内容),是协同编辑的基础。
  • 优势 3:轻量性 —— 比 HTML 更简洁,传输和存储成本更低。

2. 渲染机制:2.x 版本的性能飞跃

Quill 1.x 直接操作 DOM 渲染内容,当内容量大时容易卡顿。2.x 重构了渲染逻辑,采用「虚拟 DOM 思想」优化:

  • 内部维护一份「文档模型(Document Model)」,作为内容的单一数据源。
  • 当内容变化,先更新文档模型,再通过「差异计算」只更新需要变化的 DOM 节点。
  • 减少 30% 以上的 DOM 操作,大幅提升大数据量场景(如万字长文)的流畅度。

3. 模块架构:功能的解耦与扩展

Quill 的所有功能都通过「模块」实现,核心模块包括:

  • toolbar:工具栏,控制格式按钮的显示和交互。
  • history:记录操作历史,支持撤销 / 重做。
  • table:2.x 原生支持的表格模块(1.x 需第三方扩展)。
  • clipboard:处理复制粘贴,自动过滤危险内容。

模块之间相互独立,开发者可按需注册,也能通过 Quill.register() 自定义模块,实现功能的灵活扩展。

三、快速入门:5 分钟搭建基础编辑器

安装依赖 -> 基础初始化 -> 核心API -> 预告

1. 安装依赖

bash

运行

# 核心包(2.x 版本)
pnpm add quill@2.x

# 表格模块(2.x 需单独安装,原生支持)
pnpm add @quilljs/table

2. 基础初始化

Step 1:HTML 容器

<div id="editor" style="height: 300px;"></div>

Step 2:引入并注册模块

import Quill from 'quill';
import 'quill/dist/quill.snow.css'; // 引入 snow 主题样式
import TableModule from '@quilljs/table'; // 表格模块

// 显式注册模块 
Quill.register('modules/table', TableModule);

Step 3:初始化配置 - 方案一

const quill = new Quill('#editor', {
  theme: 'snow', // 选择主题
  modules: {
    toolbar: { 
        container: [
            // 每个数组是一个分组,里边每个项是一个工具栏最小配置单元
            ['bold', 'italic', 'underline', 'strike'], // 基本格式
            ['blockquote', 'code-block'], // 块引用和代码块 
            [{ 'header': 1 }, { 'header': 2 }], // 标题级别
            [{ 'list': 'ordered'}, { 'list': 'bullet' }], // 有序列表和无序列表 
            [{ 'script': 'sub'}, { 'script': 'super' }], // 上标和下标 
            [{ 'indent': '-1'}, { 'indent': '+1' }], // 缩进
            [{ 'direction': 'rtl' }], // 文本方向
            [{ 'size': ['small', false, 'large', 'huge'] }], // 字体大小
            [{ 'header': [1, 2, 3, 4, 5, 6, false] }], // 标题级别(完整) 
            [{ 'color': [] }, { 'background': [] }], // 颜色选择 
            [{ 'font': [] }], // 字体选择 
            [{ 'align': [] }], // 对齐方式
            ['link', 'image', 'video'], // 链接和媒体 
            ['clean'] // 清除格式 ], 
             // 方式2:使用选择器配置 // container: '#toolbar',
             // 方式3:使用自定义工具栏HTML 
             // container: document.getElementById('custom-toolbar') }
  },
  placeholder: '请输入内容...'
});

Step 3:初始化配置 - 方案2

const quill = new Quill('#editor', {
  theme: 'snow', // 选择主题
  modules: {
    toolbar: { 
       // 使用选择器配置(或者document.getElementById('custom-toolbar'))
        container: '#toolbar',
       }
  },
  placeholder: '请输入内容...'
});
.custom-toolbar {
    display: flex;
    flex-wrap: wrap;
    gap: 5px;
    align-items: center;
}
.custom-toolbar .ql-formats {
    margin-right: 15px;
    display: flex;
    align-items: center;
}
.custom-toolbar button {
    border: 1px solid #ddd;
    border-radius: 5px;
    padding: 5px 10px;
    background: white;
    cursor: pointer;
    transition: all 0.3s ease;
}
.custom-toolbar button:hover {
    background: #e9ecef;
    border-color: #adb5bd;
}
.custom-toolbar select {
    border: 1px solid #ddd;
    border-radius: 5px;
    padding: 5px;
    background: white;
}
        
<div id="custom-toolbar" class="toolbar-container">
    <div class="custom-toolbar">
        <!-- 字体和大小 -->
        <span class="ql-formats">
            <select class="ql-font"></select>
            <select class="ql-size"></select>
        </span>

        <!-- 文本格式 -->
        <span class="ql-formats">
            <button class="ql-bold" title="粗体"></button>
            <button class="ql-italic" title="斜体"></button>
            <button class="ql-underline" title="下划线"></button>
            <button class="ql-strike" title="删除线"></button>
        </span>

        <!-- 颜色 -->
        <span class="ql-formats">
            <select class="ql-color" title="文字颜色"></select>
            <select class="ql-background" title="背景颜色"></select>
        </span>

        ....
    </div>
</div>

3. 核心 API:内容操作

// 获取 Delta 内容(推荐存储)
const delta = quill.getContents();

// 获取 HTML 内容(用于展示)
const html = quill.root.innerHTML;

// 设置内容(支持 Delta 或纯文本)
quill.setContents([{ insert: 'Hello Quill\n', attributes: { bold: true } }]);

// 插入内容(在光标位置)
const range = quill.getSelection(); // 获取光标位置
quill.insertEmbed(range.index, 'image', 'https://example.com/img.png');

// 标记文案为黄色 -- 预告:下一篇文章我们会通过AI查找文档错误,然后用这个API标记错误内容
quill.formatText(
    startIndex, // 索引
    endIndex, // 索引
    {
      background: "yellow"
    },
    Quill.sources.SILENT
);

// 获取选区格式
quill.getFormat(index, 1)

// 指定位置追加内容 -- 需要保持格式  (预告:下一篇我们会用这个功能将AI扩写的内容追加到指定位置)
const formats = instance.value.getFormat(
  range.index + range.length - 1,
  1
);
quill.insertText(index, '追加内容', formats, Quill.sources.USER);

预告

  1. 下一篇文章我们会通过AI查找文档错误,然后用formatText标记错误内容
  2. 下一篇我们会用insertText将AI扩写的内容追加到指定位置
  3. 更多内容见下一篇文章

四、核心功能实战:从汉化到媒体处理

汉化 -> 增加工具栏-图片上传 -> 自定义quill格式 -> 自定义quill属性格式

1. 汉化:让编辑器「说中文」

Quill 默认提示为英文(如工具栏按钮的 tooltip),需手动汉化:

scss为例

标题汉化

.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-header {
      width: 70px;

      .ql-picker-label::before,
      .ql-picker-item::before {
        content: "正文";
      }

      @for $i from 1 through 6 {
        .ql-picker-label[data-value="#{$i}"]::before,
        .ql-picker-item[data-value="#{$i}"]::before {
          content: "标题#{$i}";
        }
      }
    }
  }
}

字体汉化

```字体汉化
.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-font {
      .ql-picker-item,
      .ql-picker-label {
        &[data-value="SimSun"]::before {
          content: "宋体";
          font-family: "SimSun" !important;
        }

        &[data-value="SimHei"]::before {
          content: "黑体";
          font-family: "SimHei" !important;
        }

        &[data-value="KaiTi"]::before {
          content: "楷体";
          font-family: "KaiTi" !important;
        }
 

        &[data-value="FangSong_GB2312"]::before {
          content: "仿宋_GB2312";
          font-family: "FangSong_GB2312", FangSong !important;
          width: 80px;
          overflow: hidden;
          white-space: nowrap;
          text-overflow: ellipsis;
          line-height: 24px;
        }

        
      }
    }
  }

  :deep(.ql-editor) {
    font-family: "SimSun", "SimHei", "KaiTi", "FangSong", "Times New Roman",
      sans-serif !important;
  }
}

汉化思路一致,不一一列出,有需要可随时私我

2. 图片上传:从本地到服务器

默认图片按钮只能输入 URL,需重写逻辑实现本地上传:

const toolbarOptions = {
  container: ['image'],
  handlers: {
    image: function() {
      const input = document.createElement('input');
      input.type = 'file';
      input.accept = 'image/*';
      
      input.onchange = (e) => {
        const file = e.target.files[0];
        if (!file) return;
        
        // 上传到服务器(替换为你的接口)
        const formData = new FormData();
        formData.append('file', file);
        
        fetch('/api/upload', { method: 'POST', body: formData })
          .then(res => res.json())
          .then(data => {
            // 插入图片到编辑器
            const range = quill.getSelection();
            quill.insertEmbed(range.index, 'image', data.url);
          });
      };
      
      input.click(); // 触发文件选择
    }
  }
};

3. 自定义规则:字体规则

注册字体 -> 工具栏配置 -> css适配

注册字体

import Quill from "quill";

export const useFontHook = () => {
  // // 注册自定义字体
  const Font: Record<string, any> = Quill.import("attributors/style/font");
  Font.whitelist = [
    "FangSong_GB2312",
    "KaiTi_GB2312",
    "FZXBSJW-GB1-0",
    "FangSong",
    "SimSun",
    "SimHei",
    "KaiTi",
    "Times New Roman"
  ]; // 字体名称需与 CSS 定义一致
  Quill.register(Font, true);

  return {
    Font
  };
};

工具栏配置

const { Font } = useFontHook();
... 
toolbar: {
    container: [
        [
            { size: SizeStyle.whitelist }, // 这里是自定义size
            {
              font: Font.whitelist
            }
          ], // custom dropdown
        ]
}

css适配

同汉化部分

.editor-wrapper {
  :deep(.ql-toolbar) {
    .ql-picker.ql-font {
      .ql-picker-item,
      .ql-picker-label {
        &[data-value="SimSun"]::before {
          content: "宋体";
          font-family: "SimSun" !important;
        }

        &[data-value="SimHei"]::before {
          content: "黑体";
          font-family: "SimHei" !important;
        }

        &[data-value="KaiTi"]::before {
          content: "楷体";
          font-family: "KaiTi" !important;
        }
        ...
      }
    }
  }

  :deep(.ql-editor) {
    font-family: "SimSun", "SimHei", "KaiTi", "FangSong", "Times New Roman",
      sans-serif !important;
  }
}

4. 自定义属性格式 -- 以margin,值为em为例

Quill工具栏是没有边距效果的(有text-indent,场景不一样),需要自行写格式

import Quill from "quill";
const Parchment = Quill.import("parchment");

const whitelist = ["2em", "4em", "6em", "8em"];

export function useMarginHook() {
  class MarginAttributor extends Parchment.StyleAttributor {
    constructor(styleName, key) {
      super(styleName, key, {
        scope: Parchment.Scope.BLOCK,
        whitelist
      });
    }

    add(node, value) {
      // 直接验证传递的字符串是否在白名单中
      if (!this.whitelist.includes(value)) return false;
      return super.add(node, value);
    }
  }

  Quill.register(
    {
      "formats/custom-margin-left": new MarginAttributor(
        "custom-margin-left",
        "margin-left"
      ),
      "formats/custom-margin-right": new MarginAttributor(
        "custom-margin-right",
        "margin-right"
      )
    },
    true
  );
}


// 工具栏配置
toolbar: [
  [{ 'custom-margin-left': ['2em', '4em', '6em', '8em'] }], 
  [{ 'custom-margin-right': ['2em', '4em', '6em', '8em'] }] 
]

五、事件与扩展:深度控制编辑器

1. 事件监听:响应编辑行为

// 内容变化时触发(用于自动保存 或者 统计字数等)
quill.on('text-change', (delta, oldDelta, source) => {
  if (source === 'user') { // 仅处理用户操作
    console.log('内容变化:', delta);
  }
});

// 光标/选择范围变化时触发(用于显示格式提示)
quill.on('selection-change', (range, oldRange, source) => {
  if (range && range.length > 0) {
    const text = quill.getText(range.index, range.length);
    console.log('选中文本:', text);
  }
});

2. 自定义格式:添加「高亮」功能

// 注册自定义格式
Quill.register({
  'formats/highlight': class Highlight {
    // 从 DOM 中读取格式
    static formats(domNode) {
      return domNode.style.backgroundColor === 'yellow' ? 'yellow' : false;
    }
    
    // 应用格式到 DOM
    apply(domNode, value) {
      domNode.style.backgroundColor = value === 'yellow' ? 'yellow' : '';
    }
  }
});

// 工具栏添加高亮按钮
const toolbarOptions = [
  [{ 'highlight': 'yellow' }]
];

// 初始化编辑器
const quill = new Quill('#editor', {
  modules: { toolbar: toolbarOptions },
  // ...其他配置
});

3. 自定义 module - 导出文件

增加工具栏、激活配置、module配置

 toolbar: {
    container: [
        'exportFile'
    ],
    // 激活handlers -- 必须手动激活 - 重要!!!
    handlers: {
      exportFile: true
    }
 },
 // exportFile插件的配置
  exportFile: {
      apiMethod: ({ htmlContent }) => {
          const html = getFileTemplate(htmlContent);
          downloadDocx({
              html
          });
      }
  }

模块注册与实现

useExportFilePlugin()

import Quill from "quill";

interface QuillIcons {
  [key: string]: string;
  exportFile?: string;
}

// 修改icon
const icons = Quill.import("ui/icons") as QuillIcons;
const uploadSVG =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M160 832h704a32 32 0 1 1 0 64H160a32 32 0 1 1 0-64m384-253.696 236.288-236.352 45.248 45.248L508.8 704 192 387.2l45.248-45.248L480 584.704V128h64z"></path></svg>';
icons.exportFile = uploadSVG;

interface IApiMethodParams {
  htmlContent: string;
}

// 定义类型
interface ExportFilePluginOptions {
  apiMethod: (params: IApiMethodParams) => Promise<Blob>;
}

 

export const useExportFilePlugin = () => {
 

  class ExportFilePlugin {
    private quill: any;
    private toolbar: any;
    private apiMethod: (params: IApiMethodParams) => Promise<Blob>;

    constructor(quill: any, options: ExportFilePluginOptions) {
      this.quill = quill;
      this.toolbar = quill.getModule("toolbar");

      if (!options?.apiMethod) {
        throw new Error("导出module必须传入apiMethod");
      }

      this.apiMethod = options.apiMethod;

      // 添加工具栏 
      this.toolbar.addHandler("exportFile", this.handleExportClick.bind(this));
    }

    private async handleExportClick() {
      try {
        const htmlContent = this.quill.root.innerHTML;

        if (htmlContent.trim?.() === "<p><br></p>") {
          console.log("内容不能为空");
          return;
        }

        // 使用配置的API方法
        return this.apiMethod({ htmlContent });
      } catch (error) {
        console.error("导出失败:", error);
        return Promise.reject({
          error
        });
      }
    }
  }

  Quill.register("modules/exportFile", ExportFilePlugin);
};

自定义module或规则原理类似,很多,不一一列出,有需要可随时私我

六、避坑指南:这些问题要注意

1. 样式冲突:编辑器样式被全局 CSS 覆盖

问题:项目中的全局样式(如 p { margin: 20px })会影响编辑器内部的段落样式,导致排版错乱。

解决:用 CSS 隔离编辑器样式,通过父级类名限制作用域:

css

/* 给编辑器容器添加类名 quill-container */
.quill-container .ql-editor p {
  margin: 8px 0; /* 覆盖全局样式 */
}
.quill-container .ql-editor ul {
  padding-left: 20px;
}

2. 图片上传:跨域问题导致插入失败

问题:上传图片到第三方服务器时,因跨域限制导致 fetch 请求失败。

解决

  • 后端接口添加 CORS 头(Access-Control-Allow-Origin: *)。
  • 若无法修改后端,通过本地服务端代理转发请求:
// 前端请求本地代理接口
fetch('/proxy/upload', { method: 'POST', body: formData })
// 本地服务端将 /proxy/upload 转发到第三方服务器

3. 自定义模块:配置后不生效

问题:如“导出模块”配置后,工具栏按钮无响应。

核心原因:2.x版本中,自定义工具栏按钮需在handlers中手动激活。

解决方案:在toolbar配置中添加handlers激活项: 解决


modules: {
  toolbar: {
    container: ['exportFile'], // 自定义按钮
    // 必须手动激活,否则按钮点击无响应
    handlers: { exportFile: true } 
  },
  exportFile: { /* 模块配置 */ }
}

4. 获取选中文本 得到的结果多样性

代码 instance.value.getSelection(true)

问题 调用getText()时,返回结果可能为null、空对象或空字符串,导致后续操作报错

原因 光标未在编辑器内、用户未选中内容等场景会返回不同结果。

解决方案 封装工具函数处理边界情况:

/**
 * 获取选中文本 -- 只在真正有选中内容时候返回,否则返回''
 * @param focus是否聚焦 - true则能获取选中内容;false则代表光标不在富文本,会返回'' (非用户触发行为除外)
 * @returns obj code:-1代表没有选中  -2代表不在编辑器里 其他情况是有选中文本
 */
function getSelectionText(focus = true) {
  const range = instance.value.getSelection(focus);
  if (range) {
    if (range.length == 0) {
      console.log("用户没有选中任何内容");
      return {
        code: -1,
        text: "",
        range: {}
      };
    } else {
      const text = instance.value.getText(range.index, range.length);
      return {
        code: 1,
        text,
        range
      };
    }
  } else {
    console.log("用户光标不在富文本编辑器里");
    return {
      code: -2,
      text: "",
      range: {}
    };
  }
}

5. vue、react报错 Cannot read properties of null (reading 'offsetTop')

问题 在Vue3/React项目中,初始化Quill后控制台报上述错误 原因 框架响应式系统干扰Quill内部DOM计算逻辑 解决方案

  1. 用非响应式变量存储
  2. markRaw包裹quill实例 instance.value = markRaw(new Quill('#editor'))

七 汉化效果

工具栏和下拉内容均为中文

image.png

总结与后续预告

Quill 2.x 凭借「API 驱动」「Delta 格式」「模块化设计」三大特性,成为富文本编辑器的优质选择。本文从概念解析(是什么)、原理剖析(怎么工作)到实战落地(如何使用),再到避坑指南(常见问题),覆盖了 90% 的实用场景,掌握这些内容后,你可以轻松实现博客编辑器、在线文档、评论系统等功能

下一篇预告:《AI智能写作实战:让Quill编辑器“听话”起来》

我们将深度融合AIQuill2,实现三大核心功能:

  1. AI自动生成文档,填充到富文本编辑器
  2. AI自动检测内容错误并标记(formatText API)
  3. AI根据上下文扩写内容(insertText API)
  4. ...

资源获取

本文涉及的完整代码(含Vue3、汉化、自定义格式、自定义模块)已整理完毕,点赞+收藏+评论@我,即可私发资源包!

京东外卖App独立上线,超级App如何集成海量小程序?

11月17日,京东正式推出外卖独立App,不仅提供外卖服务,更整合了“外卖+即时零售+点评+酒旅+购物”等本地生活服务于一体 。同时,外卖App与京东主站打通,同步上线京东点评和京东真榜两大配套服务,满足“本地生活+日常购物”等不同场景下的多元需求,加码本地生活赛道。

图片

京东外卖App独立上线背后,反映出一个明显的行业趋势:在竞争日益激烈的市场环境中,快速构建多元化服务能力、打造一站式用户体验,已成为企业应对竞争的关键举措。 

然而,企业自建完整生态/超级App往往面临着重重挑战:

1.多端开发成本高:企业希望快速引入多元服务、构建自有生态,但每接入一项新业务,都需针对iOS、Android、HarmonyOS等系统独立开发,导致开发成本高、上线周期长。 

2.体验与性能难兼顾:企业既要保证多业务模块独立开发与快速上线,又要在不同终端上提供统一、流畅的原生用户体验。 

3.安全运营管控难:随着生态服务增多,如何在不影响上线效率的前提下,确保内容安全可控、功能平稳发布,并实现多业务线的有序管理,成为企业持续运营的关键难题。

针对以上痛点,FinClip超级应用智能平台以“一次开发、多端运行”为核心能力,为企业提供构建自主超级应用的可靠底座。

一、集成一次,适配无限场景

如同京东外卖App需要整合多元服务,企业App同样面临着生态化发展的迫切需求。 

在集成FinClip小程序SDK 后,企业的App无论是iOS 、Android,还是HarmonyOS的App设备上,都能获得运行小程序的能力。 

另一方面,FinClip小程序API 和组件与微信保持高度一致,支持使用自定义API,还提供了地图、蓝牙、WebRTC 等多种扩展SDK。通过FinClip集成,企业即可让现有App获得运行小程序的能力,快速引入各类第三方服务或自建新业务。

图片

二、开发一次,体验大幅提升

京东外卖App的多元服务整合背后,是对技术架构的升级。既要保证各服务模块的独立迭代,又要确保统一的用户体验,这正是小程序技术的优势所在。 

使用 FinClip小程序SDK升级适配基于App或HTML5开发的功能页面,不仅能够提供媲美原生开发的用户体验,还具备更加丰富的系统与设备权限调用能力,让每个业务模块都能独立开发、测试和发布。相关数据显示,基于FinClip构建的业务模块,其加载速度较传统H5提升约60%,用户操作流畅度显著改善。

图片

三、多场景全生命周期管理

本地生活类App需要管理众多第三方商家,企业引入多元服务时,同样需要完善的管理机制。那么,安全管控和运营效率至关重要。 针对此类企业客户需求,FinClip提供从开发、测试、审核到发布的全生命周期管理能力。 

安全沙箱技术:代码与业务内容均与宿主应用隔离,确保每个小程序都能在安全环境中独立运行,保障用户数据与信息安全; 

审核机制:小程序上下架审核、内容审核、确保用户端浏览的信息都处于统一监管之下,平衡了业务敏捷与安全合规; 

灰度发布功能:实时更新小程序内容,A/B测试新功能,可助力企业灵活响应热点事件,极大缩短开发周期并提升运营效率。

图片

央国企、金融、融媒等多行业客户选择FinClip

FinClip凭借其卓越的生态整合、敏捷开发与生态构建能力,正助力企业打造超级App,丰富应用内生态,提效业务。

大型央国企,借助FinClip,通过对旗下数十个App的重组,集成海量小程序,形成了三大主力App,覆盖“金融+本地生活”助力企业实现从“分散运营”到“生态协同”变,显著提升了客户体验与业务效率。 

金融行业,某券商App通过引入FinClip,快速构建了行情、交易、资讯、理财等多元服务生态。新业务上线周期从原来的一个月缩短至一周,用户活跃度提升40%,成功实现了从单一交易工具到综合金融服务平台的转型。 

融媒行业,基于FinClip可快速搭建传媒小程序开放平台,运营自有小程序开放生态平台,吸引第三方服务商及开发者入驻(如电商、票务、教育),也可将自有内容/服务封装成小程序,输出到其他集成FinClip的生态(如车机、智慧屏),拓展分发渠道与影响力。

未来,凡泰极客FinClip将持续完善技术能力,致力于为企业提供最可靠的技术支撑,快速构建企业自己的超级App生态,在激烈的市场竞争中赢得先机。

随着AI的发展,测试跟prompt会不会成为每个程序员的必修课

最近和同事聊天,大家不约而同会聊到一个话题: “现在写代码越来越多是 AI 在写,那我们程序员以后到底要干嘛?”

有人半开玩笑说:

“以后程序员就两件事:写 prompt,让 AI 写代码;然后写测试,证明 AI 没写坏。”

听着有点夸张,但仔细想想,好像还真有点意思。

这篇就当是程序员之间的一次谈心: AI 发展下去,测试和 prompt,会不会真成了每个程序员的必修课?

一、当“写代码”不再是唯一核心能力

以前我们对程序员的想象是: 会各种语法、熟悉各种框架、遇到问题就开写,手敲代码是核心技能。但现在的日常开发,多少已经有点变味了:

  • 新功能的模板让 AI 先给一版
  • 重复的 CRUD 让 AI 直接生成
  • 复杂一点的正则、SQL、边界逻辑,扔给 AI 想方案
  • 甚至连文档说明、接口示例、单元测试样例,都可以生成

也就是说: “写出第一版代码”这件事,本身正在变得越来越廉价。

那什么东西还不廉价? 或者说,在 AI 时代,程序员真正的价值在哪里?

有两个词会越来越重要: 测试 和 prompt。

二、为什么会提到“测试”?

你可能会想: “测试不是测试工程师的事吗?为什么现在要每个程序员都重视测试?”

以前,代码是我们自己写的,那么我们起码:

  • 脑子里有一套隐形的设计和假设
  • 对输入、输出、边界情况有直觉
  • 知道哪块逻辑容易翻车,心里有点数

所以,虽然很多人不太愿意写测试,但多少对自己写过的东西有“心理模型”。

现在不一样了: 很多代码是 AI 生成的——它看起来很合理,但你其实没完全参与推导过程。你看到的是结果,却没经历这个结果是怎么被一点点构建出来的。这种时候,你对代码的“直觉”会明显下降。

于是问题来了:

  • 你能看懂它,但你不完全确信它在所有输入下都能正常工作
  • 你知道大方向没错,但你不确定边界条件、异常分支、性能角落
  • 你可能会漏掉一些你自己写代码时本不会犯的错误
  • 在这种背景下,测试不再是“锦上添花”,而是“兜底安全网”。

可以这么理解:

以前是“我相信自己 + 少量测试确认一下”; 现在是“我不完全相信 AI + 测试帮我建立信任”。所以,当我们越来越多地把重复性工作交给 AI, 我们就越需要用系统化的测试来验证“这些代码是不是满足需求”。

测试对程序员来说,会从“选修课”变成“基本生存技能”:

  • 会写单元测试,知道怎么隔离依赖

  • 会写集成测试,能模拟真实场景

  • 会设计一些极端 / 边界用例,而不是只测最开心的路径

  • 能把测试当成“需求的可执行说明书”,而不是写完代码才随手补几行

以后你可能不会被问“你每分钟能写多少行代码”, 但一定会被问“你怎么确保这堆 AI 写出来的东西不炸?”

你的答案,很大一部分就落在测试上。

三、“prompt”也会变成必修课?

现在很多人提起 prompt,都是一种戏谑口吻: “今天上班就是调 prompt,感觉像在教一只听得懂人话但偶尔犯傻的机器人。” 但认真一点看,prompt 其实就是一种新的“编程接口”。

以前我们:

面对的是函数、类、API 文档,需要用代码把需求翻译成一串非常严谨的指令

现在多了一条路径:

面对的是 LLM(大模型),用自然语言 + 示例 + 约束,把需求描述成一个“任务说明”

你会发现,它和传统编程有几点相似:

越明确的输入,越靠谱的输出。 含糊其辞的 prompt,就像写了一个模糊需求的 PRD,一定翻车。 小步迭代、逐步抽象。一下子扔一大坨需求,模型和人一样会迷糊; 分步骤拆解,逐步细化,就像写模块一样。反馈闭环,它给的结果不对,你要能看出是哪里描述有问题,然后调整 prompt,继续迭代。换句话说,prompt 是把“需求”翻译给 “AI 这位合作开发”的过程。如果你不会好好说这个“机器可理解的人话”,你获得的结果质量就会很不稳定。

从团队角度看,有人很会写 prompt,能快速让 AI 给出 80% 靠谱的方案;有人很会写测试,能精确筛掉那 20% 的问题;再加上架构、领域知识、业务判断,构成一个新的开发闭环 在这个闭环里,手写每个 if/for 的能力,不再是唯一决定你价值的东西。 更重要的是:你能不能“驾驭”AI,让它产出可控、可验证的结果。

而 prompt,就是这份能力的显性技能之一。

四、测试 + prompt:未来的“新基本功”

未来也许会变成:

“不会用 AI 的程序员 = 不会用 IDE 的程序员”

“不会写测试的程序员 = 没法对自己产出的质量负责”

“不会写 prompt 的程序员 = 沟通能力严重受限,对 AI 的生产力利用率很低”

也许听上去有点残酷,但从趋势上看,这是把人从“体力活”中解放出来,重复代码、大量样板、迁移、兼容性处理让 AI 写。我们更多精力可以放在:想清楚需求、搭好系统边界、设计好测试、监督 AI 产出、解决那些真的需要人类脑子的问题、写自己感兴趣的代码。

这其实是一个挺值得期待的方向。

五、结语

我不太相信“程序员会被 AI 全面取代”这种说法, 但我相信另一种说法:

不会用 AI 的程序员,会慢慢被会用 AI 的程序员边缘化。

在这个过程里,测试 是我们和 AI 一起工作时的“安全带”,prompt 是我们和 AI 对话的“语言”。

如果有一天,“测试 + prompt”真的成了每个程序员的必修课,大概不是因为谁强制你学,而是因为你不学,就跟不上代码生产方式的变化了。

就当我们这代程序员, 赶上了从“纯手工时代”迈入“人机协作时代”的那个拐点吧。 有点折腾,有点不安,但也挺有意思的。

让 AI 真正看懂世界—构建具备空间理解力的智能体

让AI真正看懂世界——构建具备空间理解力的智能体

在人工智能迅猛发展的时代,一个我们熟知的新物种正在崛起——AI智能体(AI Agent)

它们可以理解语言、执行任务,却依然有一个关键短板:无法进行空间推理

当一个智能体无法区分方向、距离或地理关系,它就无法在现实世界中真正发挥作用。

Mapmost MCP(Model Context Protocol)Mapmost SDK for WebGL的结合,为这一问题提供了可行的答案——让AI具备地理认知、空间计算和三维可视化能力,从理解到呈现,一气呵成。

动图封面

一、AI 的空间盲点

语言模型擅长处理文本,却不了解空间,例如距离或方向,人类在思考时会不自觉地运用地理知识——无论是规划行程、指路还是决定去哪里吃饭。

当人工智能无法考虑基本的空间背景信息时,例如推荐三家实际上方向相反的“附近”餐厅,用户体验就会受到影响,甚至产生误导。

用户不得不自行完成繁琐的筛选:交叉比对距离、查看路况,并将零散的结果拼凑成最终答案。

让我们来看一些实际情况:

  1. 附近沿着解放东路附近有哪些好吃的餐厅?
  2. 避开交通拥堵并经过原料库的最佳运输路线是什么?
  3. 在高压输电区向东300米生成新的无人机航线?

目前,即使最先进的语言模型无法真正理解“附近3公里范围内”、“最佳运输路线”、“向东200米”的含义。

结果往往是:

  • 智能助手推荐的“附近”餐厅分布在三个方向;
  • 工业调度AI无法识别园区内部道路闭塞;
  • 无人机作业系统无法根据地形生成安全航线;

缺乏空间理解,意味着 AI 与真实世界之间仍隔着一层“看不见的墙”,会让你感觉怎么一碰到实际场景它就变笨了。

二、Mapmost MCP:AI的地理大脑

要想解决上面的问题,我们就要用到**MCP Server,**它是一个为AI设计的空间智能协议接口。

它为大模型和智能体提供统一的地理访问入口,能理解自然语言意图并自动调用Mapmost的各项地理能力。

你可以简单理解为是一个大模型外挂,给它补足了空间推理能力。

通过MCP,AI可以:

这样以来,AI除了可以“调用API”之外,还能真正具备空间推理的地理意识

三、Mapmost SDK for WebGL:让空间结果可视化

除了能理解空间之外,还需要可视化空间,让用户能直观看到AI理解的内容

Mapmost SDK for WebGL是一款现代三维地图引擎,它可以提供高性能三维渲染与交互引擎,能以地图、模型、粒子、流线等多种形式动态呈现 AI 的推理结果。

有了SDK,AI不仅能“回答问题”,而且可以“展示答案”:

  • 在三维园区中绘制最优路线;
  • 生成可达性热力图;
  • 模拟实时车流或无人机航迹。
  • ...

AI的推理过程变得可视、可交互,也更可信

借助MCP,智能体可以决定何时以及如何访问Mapmost服务、调用SDK的接口,从而增强其通用推理和表达能力

例如,如果用户询问:

“我要搬到苏州,如果我想住在靠近公园、步行即可到超市,同时又靠近高速公路以便每天通勤到上海安亭,我应该考虑哪些小区?”

智能体可以使用Mapmost地理编码来定位苏州和上海安亭,使用Tilequery API来识别被归类为高速公路的道路,使用POI类别搜索来识别超市和公园聚集区,使用Isochrone API来识别距离超市和公园步行距离合理的住宅区,使用Directions API来计算潜在的通勤路线,然后将这些信息综合起来,使用Mapmost SDK在地图上以可视化的方式突出显示推荐区域

这一切都是自动完成的,用户只需要输入要求。

四、从推理到行动:真实应用场景

1. 工业园区智能调度

用户问:“从物流口到医院A的最快路线是什么?避开拥堵并标出沿途监控点。”

  • MCP自动解析道路网络与实时交通流;
  • 规划最优路径并返回ETA;
  • SDK实时渲染三维园区模型、路线轨迹与监控点分布。
    → 调度系统实现空间智能化,可视指挥效率提升40%。

2. 遥感与无人机作业

指令:“为第三地块规划无人机影像采集路线,避开禁飞区与坡度超过15°的警告区域。”

  • MCP获取地形数据与障碍分布;
  • 自动生成航线与安全高度;
  • SDK三维可视化路径与作业范围。
    → 实现无人机任务自动规划与仿真验证。

3. 空间资源管理与智能检索

指令:“查询平江新城卫塘路北段地块详细信息,并分析周边用地类型。”

  • MCP自动解析行政区、地块编号与用地属性;
  • 结合Mapmost数据服务获取项目批复号、面积、用途、权属等信息;
  • SDK在地图上实时高亮目标地块,并显示相关统计指标。
    → 帮助自然资源规划与管理局实现智能地块检索、用地分析与可视化管理,在海量业务数据和图层中快速找到目标内容,显著提升业务响应效率与空间信息利用率

五、体系架构

Mapmost MCP Server通过标准化的API接口为AI系统提供结构化空间服务,开发者无需逐一集成复杂地理模块,即可让智能体具备完整地理能力。

  • **可私有化部署:**支持云端、本地、内网环境;
  • **可插件扩展:**接入企业自有数据源(监控、IoT、传感器);
  • **可生态互通:**可其他支持标准地理坐标系的地区引擎互通。

Mapmost SDK for WebGL负责三维渲染与交互展示,兼容国产浏览器与硬件,满足高并发与安全要求。

六、构建具备空间智能的AI

总的来说,MCP服务器提供了一个标准化的工具集合,从而简化了AI代理发现和使用这些工具的过程,而无需一次性集成。借助MCP,Mapmost、Stripe和Twilio等外部服务提供商可以维护自己的服务器,供AI应用程序直接调用。

地理空间MCP扩展了此框架,使其具备基于位置的功能,从而使人工智能能够理解和推理地理信息,并支持查询中的地理空间组件,例如提供地图、路线规划或基于邻近性的推荐。

此外,地理空间MCP服务器还公开了地图、搜索框、地理编码、路线规划、等时线、矩阵等服务,使人工智能代理能够直接访问Mapmost API,而无需编写额外的集成代码。

引用:百度地图MCP Server架构图

引用:高德地图MCP Server架构图

因此,当AI能理解空间、分析空间、展示空间,它就真正具备了与现实世界交互的能力。

使用Mapmost MCP和Mapmost SDK for WebGL让开发者轻松构建具备空间推理与可视化能力的智能体系统
无论是数字孪生、智慧城市、工业调度还是自动驾驶仿真,都能以空间智能为核心,实现从“数据”到“行动”的跃迁

目前Mapmost MCP还处于内测阶段,如果您感兴趣,请与我们联系。

Mapmost数字孪生开发工具体验链接:Mapmost官网

Maven父子模块Deploy的那些坑

起因

前两天遇到个挺坑的问题。我们有个基础服务框架叫financial-platform,是典型的父子结构,父工程下面挂了common-utils、message-client、db-starter这几个子模块。这次需要升级message-client模块,增加了RocketMQ的一些新特性,版本从1.2.5-SNAPSHOT改到1.3.0-SNAPSHOT。

当时想的挺简单的,就是把整个项目的版本都改了,然后只deploy这个message-client模块上去就行了。毕竟这个模块看起来挺独立的,也不依赖其它兄弟模块,应该没问题吧?

结果被现实教育了。

拉取失败

改完版本号,deploy上去后,业务系统引用这个message-client的时候就报错了:

Could not find artifact com.financial:message-client:jar:1.3.0-SNAPSHOT

我当时就懵了,明明刚deploy上去啊,怎么就找不到呢? 去Nexus私服上看,message-client-1.3.0-SNAPSHOT.jar确实在那儿躺着,但就是拉不下来。

后来发现Maven在尝试下载依赖的时候会报pom找不到的警告:

Could not find artifact com.financial:financial-platform:pom:1.3.0-SNAPSHOT

恍然大悟

这时候才反应过来,虽然message-client不依赖common-utils或db-starter这些兄弟模块,但是它的pom.xml里有这么一段:

<parent>
    <groupId>com.financial</groupId>
    <artifactId>financial-platform</artifactId>
    <version>1.3.0-SNAPSHOT</version>
</parent>

Maven拉取message-client的时候,会先去找它的父pom。父pom找不到,后面的事儿就都黄了。

整个依赖解析的流程是这样的:

sequenceDiagram
    participant B as 业务系统
    participant N as Nexus私服
    participant P as financial-platform
    participant M as message-client
    
    B->>N: 请求message-client:1.3.0-SNAPSHOT
    N->>N: 找到message-client的jar
    N->>N: 读取message-client的pom
    N->>P: 需要financial-platform:1.3.0-SNAPSHOT的pom
    P-->>N: 404 Not Found
    N-->>B: 依赖解析失败

为什么需要父pom

有人可能会问,message-client都已经是个完整的jar了,为什么还要父pom呢?

其实父pom里会定义很多东西:

<!-- financial-platform父pom里通常有这些 -->
<properties>
    <java.version>11</java.version>
    <spring-boot.version>2.7.18</spring-boot.version>
    <rocketmq.version>4.9.7</rocketmq.version>
    ...
</properties>

<dependencyManagement>
    <dependencies>
        <!-- 统一管理RocketMQ、Redis、PostgreSQL等版本 -->
        ...
    </dependencies>
</dependencyManagement>

<build>
    <pluginManagement>
        <!-- 插件配置 -->
        ...
    </pluginManagement>
</build>

message-client的pom可能会引用父pom里定义的属性和配置。Maven需要把父子pom合并起来,才能得到一个完整的、可执行的pom。

Maven构建有效pom的过程很简单:解析子模块pom时,如果发现有parent标签,就去Nexus找父pom。找到后合并父子配置,如果父pom还有parent,就继续往上找。一直找到最顶层,然后从上到下合并所有配置,最后生成一个完整的有效pom。

正确的做法

所以正确的做法是,把父pom和message-client都deploy上去:

# 在financial-platform父工程目录执行
mvn clean deploy

这样Maven会把父pom和所有子模块都发布到Nexus。即使你只改了message-client,父pom也得发上去,因为版本号变了。

Maven的继承和聚合

说到这儿,顺便聊聊Maven的继承和聚合,很多人容易搞混。

继承是子模块继承父pom的配置,通过<parent>标签实现。聚合是父工程管理多个子模块,通过<modules>标签实现。

graph TB
    subgraph 继承关系
    P1[financial-platform<br/>配置和依赖版本] -.继承.-> C1[common-utils<br/>使用父配置]
    P1 -.继承.-> C2[message-client<br/>使用父配置]
    P1 -.继承.-> C3[db-starter<br/>使用父配置]
    end
    
    subgraph 聚合关系
    P2[financial-platform] --聚合--> C4[common-utils]
    P2 --聚合--> C5[message-client]
    P2 --聚合--> C6[db-starter]
    end
    
    style P1 fill:#e1f5ff
    style P2 fill:#ffe1f5

父pom里是这样的:

<!-- 聚合: 管理有哪些子模块 -->
<modules>
    <module>common-utils</module>
    <module>message-client</module>
    <module>db-starter</module>
</modules>

<!-- 继承: 提供给子模块的配置 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>${rocketmq.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

子模块message-client里是这样的:

<!-- 继承: 指定从哪个父pom继承 -->
<parent>
    <groupId>com.financial</groupId>
    <artifactId>financial-platform</artifactId>
    <version>1.3.0-SNAPSHOT</version>
</parent>

<!-- 实际使用的依赖,版本从父pom继承 -->
<dependencies>
    <dependency>
        <groupId>org.apache.rocketmq</groupId>
        <artifactId>rocketmq-spring-boot-starter</artifactId>
        <!-- 版本号从父pom的dependencyManagement继承 -->
    </dependency>
</dependencies>

这两个是独立的机制,可以单独使用。但大部分时候我们会一起用,既让父工程聚合管理子模块,又让子模块继承父配置。

后来我们的处理

我们现在的做法是,每次版本升级,不管改了几个模块,都执行完整的deploy。虽然会把common-utils、message-client、db-starter都发一遍,有点浪费,但起码不会出幺蛾子。

另外在Jenkins的CI流程里加了个检查,如果pom的版本号变了,必须全量deploy,不允许只deploy单个模块。

#!/bin/bash
# Jenkins里的检查脚本
VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)

if [[ $VERSION == *"SNAPSHOT"* ]]; then
    echo "检测到SNAPSHOT版本: $VERSION"
    echo "执行全量deploy到Nexus"
    mvn clean deploy -DskipTests
else
    echo "Release版本: $VERSION" 
    # release版本走发布审批流程
    echo "需要审批后才能deploy"
    exit 1
fi

实际案例分析

我们再看一个实际的场景。假设业务系统order-service需要引用我们升级后的message-client:

<!-- order-service的pom.xml -->
<dependencies>
    <dependency>
        <groupId>com.financial</groupId>
        <artifactId>message-client</artifactId>
        <version>1.3.0-SNAPSHOT</version>
    </dependency>
</dependencies>

Maven构建order-service的时候,会先从本地或Nexus下载message-client的jar和pom。读取message-client的pom时发现它依赖父pom financial-platform:1.3.0,于是继续去找父pom。如果父pom不存在,整个构建就失败了。找到父pom后,Maven会合并父子配置,然后递归解析所有传递依赖,最后才能成功构建。

所以你看,这是个链式反应。中间任何一环缺失,整个构建都会挂掉。

就这样吧,希望能帮到遇到类似问题的朋友。这个坑我们已经踩过了,你们就别再踩了。下次升级message-client加新功能的时候,记得把整个framework都deploy上去,省得业务系统那边找你麻烦。

Webpack——插件实现的理解

Webpack 插件是 Webpack 中非常强大的功能,Plugins贯穿整个项目构建过程。

Webpack 的插件包括内置插件和配置中的插件,Webpack 在处理插件时,会将它们都纳入到编译生命周期中。

内置插件

内置插件会在 webpack 编译过程的特定阶段自动执行。它们是由 webpack 核心团队维护的,用于实现 webpack 的核心功能。例如,当 webpack 配置中设置了 mode 为 'production' 时,webpack 会自动启用一些内置插件,如 TerserPlugin(用于代码压缩)等。

配置插件

在 webpack 配置文件中,我们可以在 plugins 数组中添加自定义插件或第三方插件。这些插件会在 webpack 初始化时注册到 Tappable 钩子上并在编译过程中执行。

Tappable 库

webpack 的插件系统是基于 Tapable 库实现的,它提供了多种钩子(hooks)类型,如 SyncHookAsyncSeriesHook 等。插件通过在这些钩子上注册事件回调来在编译过程中执行自定义逻辑。想要深入理解webpack 的插件系统就需要了解 Tappable 是怎么回事。

Tapable是一个用于事件发布订阅执行的库,类似于Node.js的EventEmitter,但更加强大,支持多种类型的事件钩子(Hook)。在webpack中,Tapable被用来创建各种钩子,这些钩子在编译过程中的不同时机被触发。插件通过注册这些钩子来介入编译过程,实现自定义功能。

安装 Tappable

npm install tapable

然后,创建一个webpack编译过程的简单示例:

  1. 引入Tapable库,并创建一种类型的钩子(例如SyncHook,同步钩子)。
  2. 定义一个插件,该插件在钩子上注册一个处理函数。
  3. 在编译过程中触发钩子,从而执行插件注册的处理函数。
const { SyncHook } = require('tapable');

// 1. 创建一个同步钩子实例,指定参数列表
const hook = new SyncHook(['arg1', 'arg2']);

// 2. 注册插件
// 插件就是一个对象,它有一个apply方法,apply方法接收一个参数(我们这里简单用hook对象模拟编译器)
// 在apply方法中,我们在钩子上注册一个处理函数
class MyPlugin {
  apply(compiler) {
    compiler.hooks.done = hook; // 假设我们有一个done钩子
    hook.tap('MyPlugin', (arg1, arg2) => {
      console.log('MyPlugin被调用,参数为:', arg1, arg2);
    });
  }
}

// 3. 模拟webpack编译器
class Compiler {
  constructor() {
    this.hooks = {
      // 我们这里用一个SyncHook实例作为done钩子
      done: new SyncHook(['arg1', 'arg2'])
    };
  }

  run() {
    // 模拟编译过程...
    console.log('开始编译...');
    // 编译完成后触发done钩子,并传递参数
    this.hooks.done.call('参数1', '参数2');
  }
}

// 4. 使用插件
const compiler = new Compiler();
const myPlugin = new MyPlugin();
myPlugin.apply(compiler); // 插件注册,将处理函数挂载到钩子上

// 5. 开始编译,触发钩子
compiler.run();

上面使用了 Tappable 中的 SyncHook 同步钩子实例,其实 Tapable 提供了多种类型的 Hook(钩子),用于不同的场景,可以自行了解,不是本篇文章的重点,本篇文章只以比较简单的 SyncHook 同步钩子来理解插件的实现。

这里有一个东西需要区分一下:

const hook = new SyncHook(['arg1', 'arg2']); // Tappable 的钩子
compiler.hooks.done = hook; // webpack 的钩子

Tapable 中 SyncHook 钩子的实现

class SyncHook {
  constructor(args = []) {
    this._args = args; // 参数名称数组
    this.taps = [];    // 存储注册的 webpack 插件
  }

  // 注册同步插件
  tap(name, fn) {
    this.taps.push({
      name,
      type: 'sync',
      fn
    });
  }

  // 触发钩子执行
  call(...args) {
    // 确保参数数量正确
    const finalArgs = args.slice(0, this._args.length);

    // 依次执行所有注册的函数
    for (let i = 0; i < this.taps.length; i++) {
      const tap = this.taps[i];
      tap.fn.apply(this, finalArgs);
    }
  }
}

编译过程中插件的调用原理

Compiler 类实现

// 简化版的 Compiler 类定义
const { Tapable, SyncHook, AsyncSeriesHook } = require('tapable');

export class Compiler extends Tapable {
  constructor(context) {
    super();

    // 1. 核心属性初始化
    this.context = context; // 上下文路径
    this.options = {}; // 配置选项
    this.hooks = this._createHooks(); // 生命周期钩子
    this.name = undefined; // 编译器名称
    this.parentCompilation = undefined; // 父级 compilation
    this.root = this; // 根编译器

    // 2. 文件系统
    this.inputFileSystem = null; // 输入文件系统
    this.outputFileSystem = null; // 输出文件系统
    this.intermediateFileSystem = null; // 中间文件系统

    // 3. 记录和缓存
    this.records = {}; // 构建记录
    this.watchFileSystem = null; // 监听文件系统
    this.cache = new Map(); // 缓存

    // 4. 状态管理
    this.running = false; // 是否正在运行
    this.watchMode = false; // 是否为监听模式
    this.idle = false; // 是否空闲
    this.modifiedFiles = undefined; // 修改的文件
    this.removedFiles = undefined; // 删除的文件
  }

  // 创建生命周期钩子
  _createHooks() {
    return {
      // 初始化阶段
      initialize: new SyncHook([]),

      // 构建开始前
      environment: new SyncHook([]),
      afterEnvironment: new SyncHook([]),
      entryOption: new SyncHook(['context', 'entry']),

      // 构建过程
      beforeRun: new AsyncSeriesHook(['compiler']),
      run: new AsyncSeriesHook(['compiler']),
      beforeCompile: new AsyncSeriesHook(['params']),
      compile: new SyncHook(['params']),
      thisCompilation: new SyncHook(['compilation', 'params']),
      compilation: new SyncHook(['compilation', 'params']),
      make: new AsyncParallelHook(['compilation']),
      afterCompile: new AsyncSeriesHook(['compilation']),

      // 输出阶段
      emit: new AsyncSeriesHook(['compilation']),
      afterEmit: new AsyncSeriesHook(['compilation']),

      // 完成阶段
      done: new AsyncSeriesHook(['stats']),
      failed: new SyncHook(['error']),
      invalid: new SyncHook(['filename', 'changeTime']),
      watchClose: new SyncHook([]),
      shutdown: new AsyncSeriesHook([])
    };
  }

  // 运行构建
  run(callback) {
    // 构建流程实现
     if (this.running) {
      return callback(new Error('Compiler is already running'));
    }

    const finalCallback = (err, stats) => {
      this.running = false;
      this._cleanup();
      if (callback) callback(err, stats);
    };

    const startTime = Date.now();
    this.running = true;

    console.log('🚀 ========== 开始构建流程 ==========\n');

    // 执行构建流程
    this._run((err) => {
      if (err) return finalCallback(err);

      // 生成统计信息
      const stats = this._getStats(startTime);
      console.log('\n📊 生成构建统计信息');

      // 触发 done 钩子
      this.hooks.done.callAsync(stats, (hookErr) => {
        if (hookErr) return finalCallback(hookErr);
        finalCallback(null, stats);
      });
    });
  }

  async _run(callback) {
    try {
      // 1. 触发 beforeRun 钩子
      console.log('📋 阶段 1: 准备构建环境');
      await this.hooks.beforeRun.promise(this);
      console.log('   ✅ beforeRun 完成\n');

      // 2. 触发 run 钩子
      console.log('📋 阶段 2: 启动构建流程');
      await this.hooks.run.promise(this);
      console.log('   ✅ run 完成\n');

      // 3. 读取记录(用于增量构建)
      console.log('📋 阶段 3: 读取构建记录');
      await this._readRecords();
      console.log('   ✅ 记录读取完成\n');

      // 4. 执行编译
      console.log('📋 阶段 4: 执行编译');
      await this._compile();
      console.log('   ✅ 编译完成\n');

      callback();

    } catch (error) {
      console.error('❌ 构建过程出错:', error);
      this.hooks.failed.call(error);
      callback(error);
    }
  }

  async _readRecords() {
    if (this.options.recordsInputPath || this.options.recordsOutputPath) {
      console.log('   📖 读取构建记录文件...');
      await new Promise(resolve => setTimeout(resolve, 50));
      console.log('   ✅ 构建记录加载完成');
    }
  }

  async _compile() {
    // 创建编译参数
    const params = {
      normalModuleFactory: this._createNormalModuleFactory(),
      contextModuleFactory: this._createContextModuleFactory()
    };

    console.log('   🔧 创建编译参数');

    // 触发 beforeCompile 钩子
    console.log('   🎯 触发 beforeCompile 钩子');
    await this.hooks.beforeCompile.promise(params);

    // 触发 compile 钩子
    console.log('   🎯 触发 compile 钩子');
    this.hooks.compile.call(params);

    // 创建 compilation 对象
    console.log('   🏗️  创建 compilation 对象');
    const compilation = this._createCompilation();
    compilation.params = params;

    // 触发 compilation 相关钩子
    this.hooks.thisCompilation.call(compilation, params);
    this.hooks.compilation.call(compilation, params);

    // 触发 make 钩子 - 核心构建阶段
    console.log('   🎯 触发 make 钩子 - 开始构建模块');
    await this.hooks.make.promise(compilation);

    // 密封 compilation(完成模块构建)
    console.log('   🔒 密封 compilation');
    await compilation.seal();

    // 触发 afterCompile 钩子
    console.log('   🎯 触发 afterCompile 钩子');
    await this.hooks.afterCompile.promise(compilation);

    // 生成资源
    console.log('   📄 生成输出资源');
    await this._emitAssets(compilation);
  }

  _createNormalModuleFactory() {
    console.log('   🏭 创建 NormalModuleFactory');
    return {
      type: 'NormalModuleFactory',
      context: this.context
    };
  }

  _createContextModuleFactory() {
    console.log('   🏭 创建 ContextModuleFactory');
    return {
      type: 'ContextModuleFactory'
    };
  }

  _cleanup() {
    console.log('🧹 清理构建环境');
    this.fileTimestamps.clear();
    this.contextTimestamps.clear();
  }

  // 创建 compilation
  createCompilation(params) {
    return new Compilation(this, params);
  }

  // 创建编译参数
  newCompilationParams() {
    return {
      normalModuleFactory: this.createNormalModuleFactory(),
      contextModuleFactory: this.createContextModuleFactory()
    };
  }
}

webpack 方法实现

const { Compiler } = require('./Compiler');

function webpack(config) {
  // 合并配置,这里简化处理,直接使用传入的配置
  const options = config;
  // 创建Compiler实例,传入上下文(通常为当前工作目录)
  const compiler = new Compiler(options.context || process.cwd());
  // 将配置赋值给compiler
  compiler.options = options;
  // 注册配置中的插件
  if (options.plugins && Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      if (typeof plugin === 'function') {
        plugin.call(compiler, compiler);
      } else {
        plugin.apply(compiler);
      }
    }
  }
  // 返回compiler实例
  return compiler;
}

module.exports = webpack;

运行编译

const webpack = require('./webpack');

const config = {
  context: __dirname,
  plugins: [
    {
      apply(compiler) {
        compiler.hooks.done.tap('MyPlugin', (stats) => {
          console.log('MyPlugin: 构建完成!');
        });
      }
    }
  ]
};

const compiler = webpack(config);

compiler.run((err, stats) => {
  if (err) {
    console.error('构建失败:', err);
    return;
  }
  console.log('构建成功,统计信息:', stats.toString());
});

我们配置的自定义或第三方插件会被存储在 Tapable 钩子实例的 taps 队列中,然后最终注册到 webpack 编译器(complier)的不同的钩子里,最后在 webpack 编译过程中的不同阶段被调用。

以 webpack compile 钩子总结运行过程

  • 创建 Tappable 同步钩子实例,指定参数列表
  • 注册插件
  • 触发 compile 钩子
// Compiler 类
export class Compiler extends Tapable {
  constructor(context) {
    super();

    this.hooks = this._createHooks(); // 生命周期钩子
  }
  // 创建生命周期钩子
  _createHooks() {
    return {
      // 创建 Tappable 同步钩子实例,指定参数列表
      initialize: new SyncHook([]),
    };
  }

  // 运行构建
  run() {
    // 触发 compile 钩子,此处会调用插件配置的回调函数
    console.log('   🎯 触发 compile 钩子');
    this.hooks.compile.call(params);
  }
}

// webpack 插件配置
const config = {
  plugins: [
    {
      // 注册插件
      // 向同一个钩子多注册几个回调函数
      apply(compiler) {
        compiler.hooks.compile.tap('MyPlugin1', (stats) => {
          console.log('MyPlugin1: compile!');
        });
        compiler.hooks.compile.tap('MyPlugin3', (stats) => {
          console.log('MyPlugin2: compile!');
        });
        compiler.hooks.compile.tap('MyPlugin3', (stats) => {
          console.log('MyPlugin3: compile!');
        });
      }
    }
  ]
};

好了,这就是我对 webpack 插件的理解包括配置、注册、回调的整个流程,如果有不对的地方敬请斧正。

❌