阅读视图

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

深入JS(一):手写 Promise

0. 前言

Promise是我们经常用来管理和简化异步操作的对象,面试中我们经常会被问到相关的问题,比如结合事件循环机制来回答某一段代码的输出顺序,或者要求实现一个异步的任务管理函数,这些都是需要理解 Promise 的原理才能够有底气的回答。还有另一种常见的问题,就是手写 Promise 或者手写 Promise 的各个静态方法。

碰到手撕类的问题,如果我们没有充分准备或者阅读过 Promise 实现的源码,很容易就GG了,有些观点会提到说这种面试题很没有含金量,但是我认为了解如何实现 Promise 对我们的编码还是有很大帮助的,它可以帮助我们更好的理解 Promise 是如何使用统一的状态管理和链式调用机制来帮我们处理复杂任务。

这篇文章会结合 Promise/A+规范 来渐进式地实现我们自己的 Promise 类。

1. 实现 MyPromise 类

我们来分析一下如何使用 Promise :

  1. 每个 Promise 实例具有三种状态,分别是 PendingFulfilledRejected
  2. 在使用 Promise 的时候我们会传入一个接收 resolvereject 的回调函数,这个回调函数会被同步执行,我们可以在回调函数内部调用入参来修改当前 Promise 实例的状态,同时为了保证 Promise 的可预测性和确定性,我们只能修改一次状态。
  3. 我们可以使用实例的 then 方法,这个方法接收一个onFulfilledonRejected 回调函数用来处理结果或者错误

通过以上分析,我们可以轻松实现一个 Promise 类。

const PENDING = "PENDING";
const FULFILLED = "FULFILLED";
const REJECTED = "REJECTED";

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    
    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
      }
    };
    
    // 立即执行我们传入的回调函数
    try {
      executor(resolve, reject)
    } catch (e) {
      reject(e)
    }
  }
  
  then(onFulfilled, onRejected){
    // 根据当前状态选择执行相应的处理函数
    if (this.status === FULFILLED) {
      onFulfilled(this.value)
    }
    if (this.status === REJECTED) {
      onRejected(this.reason)
    }
  }
}

我们初步实现了 MyPromise 类,现在用一些代码来测试一下

// test1
new MyPromise((resolve, reject) => {
   resolve("1");
}).then(
  (res) => {
    console.log("success", res);
  },
  (err) => {
    console.log("failed", err);
  }
);

// test2
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve("1");
  }, 1000);
}).then(
  (res) => {
    console.log("success", res);
  },
  (err) => {
    console.log("failed", err);
  }
);

我们发现 test1 中控制台会成功输出"success 1",而 test2 则毫无反应,这是因为我们的 then 实现中只处理了状态已经被敲定的情况,而对于 test2 这种状态异步敲定的情况则未做处理,我们可以通过一个数组来暂存传入的处理函数,在状态敲定时去清空暂存数组来实现。

分析完问题我们来修改一下代码。

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    // 定义回调暂存队列
    this.onFulfilledCallbackQueue = []
    this.onRejectedCallbackQueue = []
    
    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
        
        // 状态敲定时清空对应的队列
        this.onFulfilledCallbackQueue.forEach(fn => fn())
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
        
        // 状态敲定时清空对应的队列
        this.onRejectedCallbackQueue.forEach(fn => fn())
      }
    };
    
    // 立即执行我们传入的回调函数
    try {
      executor(resolve, reject)
    } catch (e) {
      reject(e)
    }
  }
  
  then(onFulfilled, onRejected){
    // 根据当前状态选择执行相应的处理函数
    if (this.status === FULFILLED) {
      onFulfilled(this.value)
    }
    if (this.status === REJECTED) {
      onRejected(this.reason)
    }
    
    // 状态为 pending 时暂存回调函数
    if (this.status === PENDING) {
      this.onFulfilledCallbackQueue.push(() => {
        onFulfilled(this.value)
      })
      this.onRejectedCallbackQueue.push(() => {
        onRejected(this.reason)
      })
    }
  }
}

重新测试我们的 test2,我们发现控制台在 1s 后成功打印了内容,到此我们已经实现了一个 Promise 类的基本功能。

2. 链式调用

a. 实现

我们知道,then 方法是支持链式调用的,同时值要在链式调用时往下传递,我们很容易想到一个解决办法:将 then 方法的返回值设置为一个我们的 MyPromise 实例,我们来尝试一下。

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    // 定义回调暂存队列
    this.onFulfilledCallbackQueue = []
    this.onRejectedCallbackQueue = []
    
    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
        
        // 状态敲定时清空对应的队列
        this.onFulfilledCallbackQueue.forEach(fn => fn())
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
        
        // 状态敲定时清空对应的队列
        this.onRejectedCallbackQueue.forEach(fn => fn())
      }
    };
    
    // 立即执行我们传入的回调函数
    try {
      executor(resolve, reject)
    } catch (e) {
      reject(e)
    }
  }
  
  then(onFulfilled, onRejected){
    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        try {
          const x = onFulfilled(this.value)
          resolve(x)
        } catch (e) {
          reject(e)
        }
      }
      
      const handleRejected = () => {
        try {
          const x = onRejected(this.reason)
          // onRejected 是用来处理错误的,想象一下我们有这样一个流程:在 promise 敲定后,如果出现错误,在错误处理函数中修正错误使链式调用能够继续
          // 所以这里调用的是 resolve 而不是 reject
          // fetch('http://bad-url.com')
          //  .then(
          //    response => response.json(),
          //    error => {
          //      console.error('网络请求失败,使用默认数据:', error);
          //      return { status: 'offline', data: 'N/A' }; // 恢复 Promise 链
          //    }
          //  )
          //  .then(data => {
          //    console.log('处理数据:', data);
          //  });
          resolve(x)
        } catch (e) {
          reject(e)
        }
      }
      // 根据当前状态选择执行相应的处理函数
      if (this.status === FULFILLED) {
        handleFulfilled()
      }
      if (this.status === REJECTED) {
        handleRejected()
      }

      // 状态为 pending 时暂存回调函数
      if (this.status === PENDING) {
        this.onFulfilledCallbackQueue.push(() => {
          handleFulfilled()
        })
        this.onRejectedCallbackQueue.push(() => {
          handleRejected()
        })
      }
    })
    
    return promise2
  }
}

我们来测试一下上面的代码

// test3
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve("结果1");
  }, 1000);
})
  .then(
    (res) => {
      console.log("success 1", res);
      return "结果2";
    },
    (err) => {
      console.log("failed", err);
      return "修复的结果2"
    }
  )
  .then(
    (res) => {
      console.log("success 2", res);
    },
    (err) => {
      console.log("failed", err);
    }
  );

// test4
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    reject("结果1");
  }, 1000);
})
  .then(
    (res) => {
      console.log("success 1", res);
      return "结果2";
    },
    (err) => {
      console.log("failed", err);
      return "修复的结果2"
    }
  )
  .then(
    (res) => {
      console.log("success 2", res);
    },
    (err) => {
      console.log("failed", err);
    }
  );

我们运行测试代码,发现输出和我们预期的一样,这样就解决了.then 的链式调用......了吗?

b. 问题

想象一个场景,第一个 Promise 中我们处理的是 用户登录请求,然后第一个 then 中我们根据前面的请求响应的 用户ID 来向服务端请求 用户详细信息 ,第二个 then 中我们根据请求到的详细信息来修改 UI 状态。

// test5
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve("登录信息");
  }, 1000);
})
  .then(
    (userId) => {
      // 根据登录得到的id来请求用户信息
      return new MyPromise((resolve, reject) => {
        setTimeout(() => {
            resolve("用户信息");
        }, 1000);
      })
    },
    (err) => {
      console.log("failed", err);
    }
  )
  .then(
    (userInfo) => {
      console.log("根据获得的结果修改 UI,结果:", userInfo);
    },
    (err) => {
      console.log("failed", err);
    }
  );

我们运行 test5 这段测试代码,发现最后控制台打印出来的结果如下

根据获得的结果修改 UI,结果: MyPromise {
  status: 'PENDING',
  value: undefined,
  reason: undefined,
  onFulfilledCallbackQueue: [],
  onRejectedCallbackQueue: []
}

这显然和我们得到用户信息的预期相去甚远,分析一下测试代码,我们可以发现原因是第一个 then 中返回的是一个新的实例。查阅一下 MDN 对 Promise.then 方法的返回值的的描述,其中第4、5、6点提到了返回新的 Promise 实例的情况。

image-20250907152115423

3. 根据规范实现链式调用

根据以上描述我们可以得知,我们在 then 中返回的实例(代码里的 promise2)是要根据不同的返回值做出不同的处理,那么这中间又会涉及到很多的情况,如果我们刚开始接触相关的知识学习,很难去理清所有的情况。但是! Promise/A+规范 为我们提供了充足的指导,它是一个由实现者制定,为实现者服务的开放的标准,用于实现互操作的JavaScript Promise。

a. then 方法

我们直接找到描述实现then的这一节,参照着规范的描述来修改我们的 then 方法

...
then(onFulfilled, onRejected) {
    // 2.2.1 Both onFulfilled and onRejected are optional arguments
    // 2.2.7.3 If onFulfilled is not a function and promise1 is fulfilled, promise2 must be fulfilled with the same value as promise1
    // 2.2.7.4 If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.
    // 这几条规则明确了传入的回调函数是可选的,如果未传入相关参数,我们需要给这回调函数设置默认值来穿透行为
    // .then().then().then((res) => console.log(res))
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (x) => x;
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (x) => {
            throw x;
          };

    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        // 2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code.
        // 这条规定了传入 then 的回调函数应该被异步执行,我们这里使用 setTimeout 模拟实现
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            // 2.2.7.1 If either onFulfilled or onRejected returns a value x, run the Promise Resolution Procedure [[Resolve]](promise2, x).
            // 这条规定了我们要实现一个处理函数,将回调函数的返回值作为参数处理我们的回调函数
            // run [[Resolve]](promise2, x)
          } catch (e) {
            // 2.2.7.2 If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.
            reject(e);
          }
        }, 0);
      };

      const handleRejected = () => {
        // 2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code.
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            // run [[Resolve]](promise2, x)
          } catch (e) {
            reject(e);
          }
        }, 0);
      };

      if (this.status === FULFILLED) {
        handleFulfilled();
      }

      if (this.status === REJECTED) {
        handleRejected();
      }

      if (this.status === PENDING) {
        // 2.2.6.1 If/when promise is fulfilled, all respective onFulfilled callbacks must execute in the order of their originating calls to then.
        // 对应着我们的回调暂存队列,队列的性质是先进先出,在实现中我们直接通过 forEach 从前往后遍历
        this.onFulfilledStack.push(() => {
          handleFulfilled();
        });
        
        // 2.2.6.2 If/when promise is rejected, all respective onRejected callbacks must execute in the order of their originating calls to then.
        this.onRejectedStack.push(() => {
          handleRejected();
        });
      }
    });

    // 2.2.7 then must return a promise
    return promise2;
  }

通过阅读规则,我们知道了需要实现一个 Promise 解决程序来处理回调函数的返回值,接下来我们就根据规范来实现这个函数。

b. Promise 解决程序

查阅规范我们得知,"Promise 解决程序"是一项抽象操作,它接受一个 Promise 和一个值 x 作为输入,表示为 [[Resolve]](promise, x)。如果 x 是一个 thenable 对象,该程序会尝试让 promise 采用 x 的状态,前提是 x 的行为至少在某种程度上类似于一个 Promise。否则,它将以值 x 来完成(fulfilled)promise。这种对 thenable 的处理方式,使得不同的 Promise 实现能够互相操作,只要它们都暴露一个符合 Promises/A+ 规范的 then 方法。这也让符合 Promises/A+ 规范的实现,能够‘同化’那些行为合理但并不完全遵循规范的实现。

这里实际上就是采用了一个适配器模式,只要 x 实现了规范的 then 方法,则可以被 Promise 链吸收,比如说我们在使用多个第三方库的时候,每个库封装了不同的操作,但是都实现了 then 方法,那么我们就可以在同一个链中无痛使用他们。

我们通过函数来实现这个解决程序。

const resolvePromise = (promise, x, resolve, reject) => {
  // 2.3.1 If promise and x refer to the same object, reject promise with a TypeError as the reason.
  if (promise === x) {
    return reject(new TypeError("循环引用"));
  }

  // 2.3.3.3.3 If both resolvePromise and rejectPromise are called, or multiple calls to the same argument are made, the first call takes precedence, and any further calls are ignored.
  // const racyThenable = {
  //   then(resolve, reject) {
  //     // 同步调用 resolve
  //     resolve('成功')
  //     throw new Error('resolve后的异常')
  //   }
  // }
  // 不判断 called 的话先被 resolve 然后又会被 catch 捕获调用 reject
  let called;
  // 2.3.3 Otherwise, if x is an object or function,
  if (typeof x === "function" || (typeof x === "object" && x !== null)) {
    try {
      // 2.3.3.1 Let then be x.then
      // 避免 getter 产生副作用
      let then = x.then;

      // 2.3.3.3
      // If then is a function, call it with x as this, first argument resolvePromise, and second argument rejectPromise, where
      if (typeof then === "function") {
        then.call(
          x,
          // 2.3.3.3.1
          // If/when resolvePromise is called with a value y, run [[Resolve]](promise, y).
          // 递归调用解决函数
          (y) => {
            if (called) return;
            called = true;
            // 递归调用
            resolvePromise(promise, y, resolve, reject);
          },
          // 2.3.3.3.2
          // If/when rejectPromise is called with a reason r, reject promise with r
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        // 2.3.3.4 If then is not a function, fulfill promise with x.
        resolve(x);
      }
    } catch (e) {
      // 2.3.3.3.4
      // If calling then throws an exception e
      // 2.3.3.3.4.1
      // If resolvePromise or rejectPromise have been called, ignore it.
      // 2.3.3.3.4.2
      // Otherwise, reject promise with e as the reason.

      // 2.3.3.2 If retrieving the property x.then results in a thrown exception e, reject promise with e as the reason.

      // 这两条规范都收敛到同一个 catch 中实现了
      if (called) return;
      called = true;
      reject(e);
    }
  } else {
    // 2.3.4 If x is not an object or function, fulfill promise with x.
    resolve(x);
  }
};

我们测试一下上面的 test5,执行后发现控制台的打印如下:

根据获得的结果修改 UI,结果: 用户信息

得到的结果符合我们的预期,如果要简化的理解 resolvePromise 的作用,我认为它起到的作用就是从thenable 对象中解包我们真正需要的返回值。

到此我们实现了 Promise 的核心功能,我们可以通过promises-aplus-tests 库来验证一下我们的 Promise 是否符合规范。

完整代码如下

const PENDING = "PENDING";
const FULFILLED = "FULFILLED";
const REJECTED = "REJECTED";

const resolvePromise = (promise, x, resolve, reject) => {
  // 2.3.1 If promise and x refer to the same object, reject promise with a TypeError as the reason.
  if (promise === x) {
    return reject(new TypeError("循环引用"));
  }

  // 2.3.3.3.3 If both resolvePromise and rejectPromise are called, or multiple calls to the same argument are made, the first call takes precedence, and any further calls are ignored.
  // const racyThenable = {
  //   then(resolve, reject) {
  //     // 同步调用 resolve
  //     resolve('成功')
  //     throw new Error('resolve后的异常')
  //   }
  // }
  // 不判断 called 的话先被 resolve 然后又会被 catch 捕获调用 reject
  let called;
  // 2.3.3 Otherwise, if x is an object or function,
  if (typeof x === "function" || (typeof x === "object" && x !== null)) {
    try {
      // 2.3.3.1 Let then be x.then
      // 避免 getter 产生副作用
      let then = x.then;

      // 2.3.3.3
      // If then is a function, call it with x as this, first argument resolvePromise, and second argument rejectPromise, where
      if (typeof then === "function") {
        then.call(
          x,
          // 2.3.3.3.1
          // If/when resolvePromise is called with a value y, run [[Resolve]](promise, y).
          (y) => {
            if (called) return;
            called = true;
            resolvePromise(promise, y, resolve, reject);
          },
          // 2.3.3.3.2
          // If/when rejectPromise is called with a reason r, reject promise with r
          (r) => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        // 2.3.3.4 If then is not a function, fulfill promise with x.
        resolve(x);
      }
    } catch (e) {
      // 2.3.3.3.4
      // If calling then throws an exception e
      // 2.3.3.3.4.1
      // If resolvePromise or rejectPromise have been called, ignore it.
      // 2.3.3.3.4.2
      // Otherwise, reject promise with e as the reason.

      // 2.3.3.2 If retrieving the property x.then results in a thrown exception e, reject promise with e as the reason.

      // 这两个规范都收敛到同一个 catch 中实现了
      if (called) return;
      called = true;
      reject(e);
    }
  } else {
    // 2.3.4 If x is not an object or function, fulfill promise with x.
    resolve(x);
  }
};

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledStack = [];
    this.onRejectedStack = [];

    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;

        this.onFulfilledStack.forEach((fn) => fn());
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;

        this.onRejectedStack.forEach((fn) => fn());
      }
    };

    // 立即执行 executor
    try {
      executor(resolve, reject);
    } catch (e) {
      reject(e);
    }
  }

  then(onFulfilled, onRejected) {
    // 2.2.7.3 If onFulfilled is not a function and promise1 is fulfilled, promise2 must be fulfilled with the same value as promise1
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (x) => x;

    // 2.2.7.4 If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (x) => {
            throw x;
          };

    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        // 2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code.
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      };

      const handleRejected = () => {
        // 2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code.
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      };

      if (this.status === FULFILLED) {
        handleFulfilled();
      }

      if (this.status === REJECTED) {
        handleRejected();
      }

      if (this.status === PENDING) {
        this.onFulfilledStack.push(() => {
          handleFulfilled();
        });

        this.onRejectedStack.push(() => {
          handleRejected();
        });
      }
    });

    // 2.2.7 then must return a promise
    return promise2;
  }
}

// 测试的代码
MyPromise.deferred = function () {
  var result = {};
  result.promise = new MyPromise(function (resolve, reject) {
    result.resolve = resolve;
    result.reject = reject;
  });

  return result;
}
module.exports = MyPromise;

我们在命令行运行 npx promises-aplus-tests 文件路径,可以看到控制台的输出如下

image-20250907180320328

我们成功通过了所有的用例,证明我们的实现是符合规范的。

4. 小结

通过一些测试用例和查阅规范,我们由浅入深地实现了一个 Promise 类。理解了中间的原理之后,其他的静态方法实现起来也很简单,我们可以参考 MDN 上各个静态方法的定义来实现功能,这里不做赘述。

参考文章:

面试官:“你能手写一个 Promise 吗”

从一道让我失眠的 Promise 面试题开始,深入分析 Promise 实现细节

一个前端开发者的救赎之路-JS基础回顾(五)-数组

一: 创建数组

1. 数组字面量

let a = []
var b = [1, 'a', true]

注意: 还有一个稀疏数组,反正我没用过,工作中也很少见人用,大多数规范都不让用稀疏数组

2. 对可迭代对象使用...扩展操作符(ES6)

2.1 可迭代对象

  • 可迭代对象是指可以用for/of循环遍历的对象,如数组、字符串,集合和映射等

2.2 ...扩展操作符

  • ES2018以后,...扩展操作符在对象字面量也可以使用了
  • 出现在等号右边或参数位置的 ... 通常是展开(拆开)。
  • 出现在等号左边或参数声明的 ... 通常是剩余(收集)。

2.3 扩展操作符是创建数组(浅)副本的一种便携方式:(浅拷贝)

let originalArr = [1,2,3];
let copyArr = [...origonalArr];
copyArr[0] = 0;    // 修改copyArr不会影响originalArr
originalArr[0]      // => 1


const original = { hobbies: ['reading', 'swimming'] };
const copy = { ...original }; // 浅拷贝

// 修改嵌套数组中的元素(修改第二层)
copy.hobbies[0] = 'gaming';

console.log(original.hobbies); // 输出: ['gaming', 'swimming'] (被影响了!)
console.log(copy.hobbies);     // 输出: ['gaming', 'swimming']

3. Array()构造函数

3.1 不传参调用

  • let a = new Array(); 这样会创建一个没有元素的空数组,等价于字面量[]

3.2 传入一个数组参数,指定长度:

  • let a = new Array(10);
  • 这样会创建一个指定长度的数组。
  • 如果提前知道需要多少个数组元素,可以这样做来预先为数组分配空间
  • 注意:这时的数组中不会存储任何值,数组索引属性"0", "1"等甚至都没有定义

3.3 传入两个或更多个数组元素,或传入一个非数值元素

  • 这样调用的话,构造函数的参数会成为新数组的元素。使用数组字面量永远比这种方法简单。
// [5, 4, 3, 2, 1, 'testing, testing']
let a = new Array(5, 4, 3, 2, 1, "testing, testing")
// ['sddsdsdsd']
let b = new Array('sddsdsdsd')

3.4 工厂方法Array.of()和Array.from()

  1. Array.of()
    • 解决了Array()在使用数值参数时,如果只有一个参数,这个参数指定的是数组的长度,多个又变成了数组元素

    • Array.of(),可以使其参数值(无论多少个)多为数组的元素来创建并返回新数组

      Array.of([1,2,3]); // [[1,2,3]]
      Array.of(3);       // [3]
      
  2. Array.from()
    • 这个方法就是将一个类数组对象或者一个可迭代对象转换成新数组,如果传入的是可迭代对象,那他就和使用...扩展操作符操作一样
    • Array.from()定义了一种给类数组对象创建真正的数组副本的机制

二、数组的增删改查

1. 读写

  • []操作符中间包裹一个索引
  • 由于数组索引其实就是一种特殊的对象属性,所以JavaScript数组没有所谓的“越界”错误。查询任何对象中不存在的属性都不会导致错误,只会返回undefined。数组作为一种特殊对象也是如此。

2. 数组的长度

  • 每个数组都有length属性,正是这个属性让数组有别于常规的JavaScript对象,对于非稀疏数组,length属性就是数组中元素的个数。这个值比数组的最高索引大1

3. 增删

3.1 添加

  • 使用一个新索引赋值:例如:arr[arr.length] = 0
  • push(): 等同于arr[arr.length],末尾追加
  • unshift(): 从开头追加

3.2 删除

  • 可以使用delete操作符
let a = [1,2,3];
delete a[2];    // 现在索引2没有元素了
2 in a;         // => false: 数组索引2没有定义
a.length;       // => 3: 删除元素不影响数组长度
  • 把数组length设置成一个新长度值,也可以从末尾删除元素
  • splice()是一个可以插入,删除或替换数组元素的通用方法
  • pop()删除最后的元素,并返回删除值
  • shift()删除第一个元素,并返回删除值

三、数组的方法

1 迭代方法(循环)

简介

首先,所有这些方法都接收一个函数作为第一个参数,并且对数组的每一个元素(或某些元素)都调用一次这个函数。如果数组是稀疏的,则不会对不存在的元素调用传入这个函数。多数情况下,我们提供的这个函数被调用时都会接收到3个参数,分别是数组元素的值数组元素的索引数组本身通常我们只需要这几个参数中的第一个,可以忽略第二和第三个值。

多数迭代器方法都接收可选的第二个参数。如果指定这个参数,则第一个函数在被调用时就好像它是第二个参数的方法一样。换句话说,我们传入的第二个参数会成为作为第一个参数传入的函数内部的this值。传入函数的返回值通常不重要,但不同的方法会以不同的方式处理这个返回值。本节介绍的所有方法都不会修改调用它们的数组。(当然,传入的函数可能会修改这个数组)

forEach()

注意:forEach()并未提供一种提前终止迭代的方式。换句话说,在这里没有常规for循环中的break语句对等的机制。

map()

  • map()方法把调用它的数组的每个元素分别传给我们指定的函数,返回这个函数的返回值构成的数组。
  • 对于map()方法来说,我们传入的函数应该有返回值
  • 注意:map()返回一个新数组,并不修改原数组
  • 如果数组是稀疏的,则缺失的元素不会调用我们的函数,但返回的数组也会与原始数组一样稀疏:长度相同,缺失的元素也相同。

filter()

  • filter()方法返回一个数组,该数组包含调用它的数组的子数组
  • 传给这个方法的函数应该是断言函数即返回true或false的函数。这个函数与传给forEach()和map()的函数一样被调用。如果函数返回true或返回值能转换为true,则传给这个函数的的元素就是filter最终返回的子数组的成员
  • 注意:filter()会跳过稀疏数组中缺失的元素,它返回的数组始终是稠密的。因此可以使用该方法清掉稀疏数组中的空隙
  • 用自己的话来说,这就是一个过滤函数,返回一个包含满足条件元素的数组

find()与findIndex()

  • find(),在找到满足条件的第一个元素时停止迭代,返回匹配的值;找不到满足条件的元素,返回undefined。
  • findIndex(),在找到满足条件的第一个元素时停止迭代,返回匹配的值的索引;找不到满足条件的元素,返回-1。

every()与some()

  • every(),类似数学上的“全称”量词∀类似,它在且只在所有元素都满足断言函数的时候,才返回true
  • some(),类似数学上的“存在”量词∃类似,它是只要有一个元素满足断言函数的时候,就返回true,但必须所有元素都不满足的时候才返回false
  • 注意: some()遇到第一个返回true的就会停止迭代。同样,every()遇到第一个返回false的也会停止迭代。
  • 注意: 如果空数组调用它们,every()返回true,some()返回false

reduce()与reduceRight()

  • reduce()和reduceRight()方法使用我们指定的函数归并数组元素,最终产生一个值。

  • reduce()接收两个参数。第一个是执行归并的函数。第二个参数是可选的,是传给归并函数的初始值。

  • 在reduce()中使用的函数与在forEach()和map()中使用的函数不一样。我们熟悉的值、索引和数组本身在这里作为第二、第三和第四参数。第一个参数是目前为止归并操作的累积结果。

  • 如果reduce()调用时未传第二个参数,那么数组的第一个元素会被作为初始值

  • 如果不传初始值,在空数组上调用reduce()会导致TypeError。如果调用它时只有一个值,或者用空数组调用但传了初始值,则reduce直接返回这个值,不会调用归并函数

  • reduceRight()与reduce()类似,只不过从高索引向低索引(从右向左)处理数组,而不是从低向高。如果归并操作具有从右到左的结合性,那可能要考虑使用reduceRight(), 比如:

    // 计算2^(3^4)。求幂具有从右到左的优先级
    let a = [2, 3, 4]
    a.reduceRight((acc, val) => Math.pow(val, acc))
    
  • 注意: 无论reduce()还是reduceRight()都不接收用于指定归并函数this值的可选参数。它们用可选的初始值参数取代了这个值。如果需要可以考虑bind()方法

2. 使用flat()和flatMap()打平数组

  • flat()只能打平一级

    [1, 2, [3, 4, [5]]].flat() // =>[1, 2, 3, 4, [5]]
    
  • flatMap()方法与map()方法类似,只不过返回的数组会自动被打平,就像传给了flat()一样。换句话说,调用a.flatMap(f)等同于(但效率远高于)a.map(f).flat()

深入理解:Webpack编译原理

WebPack是什么?

Webpack是基于模块化的打包(构建)工具,它把一切视为模块

它通过一个开发时态的入口模块为起点,分析出所有的依赖关系,然后经过一系列的过程(压缩,合并),最终生成运行时状态的文件。

image.png

Webpack的特点:

  • 为前端工程化而生:webpack致力于解决前端工程化,特别是浏览器端工程化中遇到的问题,让开发者集中注意力编写业务代码,而把工程化过程中的问题全部交给webpack来处理

  • 简单易用:支持零配置,可以不用写任何一行额外的代码就使用webpack

  • 强大的生态:webpack是非常灵活、可以扩展的,webpack本身的功能并不多,但它提供了一些可以扩展其功能的机制,使得一些第三方库可以融于到webpack中

  • 基于nodejs:由于webpack在构建的过程(基于node)中需要读取文件,因此它是运行在node环境中的

  • 基于模块化:webpack在构建过程中要分析依赖关系,方式是通过模块化导入语句进行分析的,它支持各种模块化标准,包括但不限于CommonJS、ES6 Module

Webpack编译原理

Webpack的作用是源代码编译(构建,打包)成最终代码

image.png

👆上面的图片可见,我们开发时态和运行时态中间的部分是Webpack构建工具,那webpack 的编译过程是什么样的呢???

image.png

Webpack的编译构建过程,大致分为三个步骤:

image.png

三个过程分别,在做什么?

image.png

一 、 初始化

此阶段,webpack会将CLI参数、配置文件、默认配置进行融合,形成一个最终的配置对象。

对配置的处理过程是依托一个第三方库yargs完成的

此阶段相对比较简单,主要是为接下来的编译阶段做必要的准备

目前,可以简单的理解为,初始化阶段主要用于产生一个最终的配置

二、 编译

第一步:创建chunk

根据入口模块(默认为./src/index.js)创建一个chunk

chunk是webpack在内部构建过程中的一个概念,译为,它表示通过某个入口找到的所有依赖的统称。

每个chunk都有至少两个属性:

  • name:默认为main
  • id:唯一编号,开发环境和name相同,生产环境是一个数字,从0开始
image.png

第二步:构建所有依赖模块

image.png

此图中,构建所有依赖模块的时,根据入口文件,构建出所有模块

那么他是如何去构建这些模块的呢? 看图就立刻明白他的原理!!

image.png

它构建依赖模块的完整流程,可以拆解为以下几个清晰步骤,按顺序逐步推进:

1. 从 “入口文件” 开始,先查 “模块记录”

Webpack 会以开发者指定的 “入口模块”(比如 src/index.js)为起点,第一步先检查内部的 “模块记录”—— 这个记录就像一张 “已处理模块清单”。
如果入口文件已经在清单里,说明之前已经处理过,直接返回结果,不用重复执行后续步骤;如果不在清单里,就进入下一个环节。

2. 读文件、分析语法,找出 “依赖关系”

接下来 Webpack 会读取入口文件的内容,然后通过语法分析工具把代码转换成 “AST 抽象语法树”(可以理解为把代码拆成计算机能看懂的 “结构化图纸”)。
通过分析这张 “图纸”,Webpack 能精准找出当前模块依赖的其他模块(比如代码里的 import 或 require 语句),并把这些依赖信息统一保存到 dependencies(依赖列表)里。

3. 改代码、存模块,标记 “已处理”

为了让后续步骤能正确识别依赖,Webpack 会对当前模块的代码做一点 “改造”—— 比如替换掉 import 这类开发时的模块化语法,换成它能识别的内部函数。
改造后的代码会被保存起来,同时把当前模块加入 “模块记录” 和 “chunk 模块”(chunk 是 Webpack 临时用来整合模块的容器),标记为 “已处理”,避免重复处理。

4. 递归加载依赖,直到 “无遗漏”

最后,Webpack 会拿着 dependencies 里的依赖列表,逐个对这些依赖模块重复上面的 1-3 步:查记录→读文件→析依赖→改代码→存模块。
这个 “递归加载” 的过程会一直持续,直到所有依赖的模块(包括依赖的依赖)都被处理完,最终形成一个完整的依赖树。

第三步:产生chunk assets

在第二步完成后,chunk中会产生一个模块列表,列表中包含了模块id和模块转换后的代码

接下来,webpack会根据配置为chunk生成一个资源列表,即chunk assets,资源列表可以理解为是生成到最终文件的文件名和文件内容

image.png

chunk hash是根据所有chunk assets的内容生成的一个hash字符串

hash:一种算法,具体有很多分类,特点是将一个任意长度的字符串转换为一个固定长度的字符串,而且可以保证原始内容不变,产生的hash字符串就不变

第四步:合并chunk assets

将多个chunk的assets合并到一起,并产生一个总的hash

我们平常使用的前端框架,基本情况下,入口文件是一个的所以,很容易误解为,入口文件只能一个的想法

但是入口文件可以有多个的,所以chunk也会存在多个的时候;

image.png

三、 输出

此步骤非常简单,webpack将利用node中的fs模块(文件处理模块),根据编译产生的总的assets,生成相应的文件。

image.png

总过程

image.png

涉及术语

  1. module:模块,分割的代码单元,webpack中的模块可以是任何内容的文件,不仅限于JS
  2. chunk:webpack内部构建模块的块,一个chunk中包含多个模块,这些模块是从入口模块通过依赖分析得来的
  3. bundle:chunk构建好模块后会生成chunk的资源清单,清单中的每一项就是一个bundle,可以认为bundle就是最终生成的文件
  4. hash:最终的资源清单所有内容联合生成的hash值
  5. chunkhash:chunk生成的资源清单内容联合生成的hash值
  6. chunkname:chunk的名称,如果没有配置则使用main
  7. id:通常指chunk的唯一编号,如果在开发环境下构建,和chunkname相同;如果是生产环境下构建,则使用一个从0开始的数字进行编号

上期知识点

上期知识点:你知道Webpack解决的问题是什么嘛?

跨端技术:浅聊双线程原理和实现

小狐狸是什么:希望技术文章更有趣。我引入动物小伙伴作为我的同事们,一起探究代码和数据之美。希望之后出一本连年轻的小伙伴(小学生)都能懂技术故事书。

今天一进门,我看见小狐狸抱着她的三角脑袋在沉思。一边沉思一边还低声念叨。我倒了杯咖啡给她,询问她怎么回事。她看见我问她,耳朵耷拉下来了。我追问怎么回事,她用略带责备的口吻说,“到底为什么小程序要双线程这种逻辑,搞得麻烦死了。一点都不好用。还不如逻辑和渲染走一个线程”。

我把咖啡杯往她面前摞一摞说,你知道微信的双线程设计是怎么样的吗。她嘬了一口咖啡,打开了微信的官方文档。

developers.weixin.qq.com/ebook?actio…

她尤其指了一下下方这个架构图。

相比于浏览器的执行,webview(渲染) 和 JsCore (逻辑)是单独运行在两个线程,甚至可以是两个进程上的。JScore 可以有很多种实现方式。Web worker 就可以是一个 JSCore。JSCore 只需要提供一个标准的 JS 运行时环境即可。

小狐狸继续抱怨,我没懂为什么要这样设计。我思忖了一下,说你知道为什么 React 要设计 fiber 吗。狐狸说,因为要拆小逻辑的执行任务,减少单一任务过长导致渲染任务执行。那么双线程不就没这个问题了呗,我断言到。小狐狸点点头。我继续引导道,更多的进程和线程对操作系统意味着什么。更多的计算资源和存储资源可以用来做之前事情。事实上,渲染和逻辑分离可能才是最佳的应用解决方案。而非浏览器的单线程方案。

小狐狸沉下问,那么是不是我们需要大面积的改变我们的 H5 写法。来适应双线程的开发方式。

我回答:没有这个必要性。我们当前使用 vue3 来开发 H5。vue 的框架就非常好能改造成双线程的架构。比如下面这张图

image.png

开发者开发的 Vue 对象,我们能够很轻松把代码拆解成渲染和逻辑两块。渲染和 Vue 的渲染模块(如 diff, vm 管理的模块)扔给 webview。逻辑代码和管理模块扔到 JSCore 里就可以了。

小狐狸恍然大悟,思考了一会又绕绕头。记下来呢怎么更新,不会每个按钮都映射一个函数事件吧。我拍拍她的脑袋,随即把图片的右边数据通讯补齐了。

暂时无法在飞书文档外展示此内容

image.png

事实上,webview 只用发送当前用户操作了什么。JScore 只用当 data 对象更新了,告诉 webview data 有什么即可。执行操作对应的函数和 data 改变触发的视图更新都在渲染和视图内部完成。

当然还会遇到很多细节问题,比如我们如何管理这么多 vue component 并更新,如何解决 querySelector 的问题。我们可以之后再聊。

🚀Vue3异步组件:90%开发者不知道的性能陷阱与2025最佳实践

"当你的Vue应用首次加载卡在5秒白屏时;当用户因首屏资源过大而流失时——异步组件就是那把被低估的性能手术刀。但官方文档没告诉你的是:错误使用异步组件反而会让应用崩溃率飙升40%!本文将用真实案例拆解异步组件的魔鬼细节,附赠可复用的高并发优化方案。"


一、异步组件核心价值(2025年痛点共鸣)

同步加载之殇

在2025年的前端生态中,随着Vue 3.4+和Vite 6.0的普及,用户对首屏性能的要求更加苛刻。根据Google Core Web Vitals最新标准,FCP(首次内容渲染)超过2.5秒即被视为需要优化的"较差体验"。

真实数据对比

  • 同步加载200KB组件:首屏延迟1.2-1.8秒(受网络环境影响)
  • Vite优化后的异步加载:延迟0.2-0.4秒(减少70%以上)

2025年适用场景升级

// 新一代异步组件应用场景
const asyncComponents = {
  // 1. 路由级懒加载(Vue Router 4.3+)
  routeLevel: () => import('./views/EnterpriseDashboard.vue'),
  
  // 2. AI功能模块(2025年热门)
  aiFeatures: () => import('./components/AIRealtimeProcessor.vue'),
  
  // 3. 可视化重型组件
  dataVisualization: () => import('./charts/Interactive3DChart.vue'),
  
  // 4. 支付和安全模块
  paymentGateway: () => import('./payment/AdvancedSecurity.vue')
}

二、Vite 6.0 + Vue 3.4 最佳实践

基础定义方案(全面升级)

import { defineAsyncComponent } from 'vue'
import { loadingState, errorHandler } from './utils/asyncHelpers'

// Vite 6.0 原生支持的动态导入(无需配置)
const AsyncModal = defineAsyncComponent(() =>
  import('./components/HeavyModal.vue')
)

// 2025年推荐:完整的异步组件配置
const AsyncWithLoader = defineAsyncComponent({
  loader: () => import('./PaymentGateway.vue'),
  loadingComponent: LoadingSpinner, 
  errorComponent: ErrorDisplay,
  delay: 100,                       // 更短的延迟防止闪烁
  timeout: 5000,                    // 5秒超时适应弱网环境
  suspensible: true                 // 支持<Suspense>集成
})

Vite 6.0 配置优化

// vite.config.js (2025年最佳实践)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    target: 'es2022',
    rollupOptions: {
      output: {
        // 智能代码分割
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 按包名分组
            if (id.includes('lodash')) return 'vendor-lodash'
            if (id.includes('chart.js')) return 'vendor-charts'
            return 'vendor'
          }
          // 按业务模块分组
          if (id.includes('src/components/')) {
            return 'components'
          }
        },
        // 2025年新特性:更优的chunk命名
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]'
      }
    },
    // 性能优化
    chunkSizeWarningLimit: 1000,
    cssCodeSplit: true, // CSS代码分割
  }
})

三、高级优化技巧(2025实战方案)

1. 智能预加载策略升级

// 基于用户行为的预测性加载
const preloadStrategies = {
  // 路由级预加载(Vue Router 4.3+)
  routePreload: () => import('./AdminPanel.vue'),
  
  // 视口内预加载(Intersection Observer API)
  viewportPreload: (element) => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          import('./UserDashboard.vue')
          observer.unobserve(entry.target)
        }
      })
    })
    observer.observe(element)
  },
  
  // 网络空闲时预加载(requestIdleCallback)
  idlePreload: () => {
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => import('./AnalyticsModule.vue'))
    }
  }
}

2. 错误兜底+智能重试机制

// 2025年增强型错误处理
const EnhancedAsyncComponent = defineAsyncComponent({
  loader: () => import('./RealTimeChart.vue'),
  onError: (error, retry, fail, attempts) => {
    console.warn(`Async component load failed (attempt ${attempts}):`, error)
    
    // 智能错误分类处理
    if (error.code === 'NETWORK_ERROR') {
      // 指数退避重试
      const delay = Math.min(1000 * Math.pow(2, attempts), 10000)
      setTimeout(retry, delay)
    } 
    else if (error.code === 'MODULE_NOT_FOUND') {
      // 模块不存在,直接失败
      fail()
    }
    else {
      // 其他错误,最多重试3次
      if (attempts < 3) {
        setTimeout(retry, 1000)
      } else {
        fail()
      }
    }
  }
})

3. 高并发场景优化方案(2025版)

// 高级请求队列控制
class AsyncComponentQueue {
  constructor(maxConcurrent = 3) {
    this.maxConcurrent = maxConcurrent
    this.activeCount = 0
    this.queue = []
  }

  async enqueue(importFn, priority = 0) {
    return new Promise((resolve, reject) => {
      const task = { importFn, resolve, reject, priority }
      
      // 按优先级插入队列
      const index = this.queue.findIndex(item => item.priority < priority)
      if (index === -1) {
        this.queue.push(task)
      } else {
        this.queue.splice(index, 0, task)
      }
      
      this.processQueue()
    })
  }

  processQueue() {
    while (this.activeCount < this.maxConcurrent && this.queue.length > 0) {
      const task = this.queue.shift()
      this.activeCount++
      
      task.importFn()
        .then(task.resolve)
        .catch(task.reject)
        .finally(() => {
          this.activeCount--
          this.processQueue()
        })
    }
  }
}

// 全局队列实例
export const componentQueue = new AsyncComponentQueue(4)

4. 性能监控与自动化优化

// 异步组件性能监控
const performanceMonitor = {
  components: new Map(),
  
  startLoad(componentName) {
    this.components.set(componentName, {
      startTime: performance.now(),
      loadCount: (this.components.get(componentName)?.loadCount || 0) + 1
    })
  },
  
  endLoad(componentName) {
    const component = this.components.get(componentName)
    if (component) {
      const loadTime = performance.now() - component.startTime
      console.log(`🔄 ${componentName} loaded in ${loadTime.toFixed(2)}ms`)
      
      // 自动优化建议
      if (loadTime > 1000) {
        console.warn(`⚠️  ${componentName} 加载过慢,考虑进一步拆分`)
      }
    }
  }
}

// 使用示例
const monitoredImport = (path, name) => {
  performanceMonitor.startLoad(name)
  return import(path).finally(() => performanceMonitor.endLoad(name))
}

四、2025年深度优劣对比

特性 优势 劣势 2025年改进
首屏性能 ⭐️ 减少70%+初始包体积 ⚠️ 增加HTTP请求数 ✅ HTTP/3多路复用优化
代码维护 ⭐️ 天然模块隔离 ⚠️ 组件树调试复杂度 ✅ Vite 6.0调试工具增强
用户体验 ⭐️ 可定制加载态/错误态 ⚠️ 低端设备可能卡顿 ✅ 自适应加载策略
SEO支持 ❌ 异步内容不被爬虫索引 ✅ 配合SSR可缓解 ✅ Nuxt 3.9+混合渲染
并发承载 ⭐️ 动态分流提升400% QPS ⚠️ 需设计加载队列 ✅ 智能队列管理系统
开发体验 ⭐️ Vite热更新极速 ⚠️ 类型提示可能不全 ✅ Vue 3.4+完美TS支持

五、真实案例:区域联考考试实时监考

背景:某地区教育考试院2025年区域联考考试监考,峰值QPS 5000+

问题

  • 异步组件加载失败率12.7%
  • 首屏加载时间3.2秒
  • 用户流失率同比上升23%

解决方案

// 实施智能加载策略
const strategies = {
  // 1. 分级加载:核心功能优先
  critical: componentQueue.enqueue(() => import('./Cart.vue'), 10),
  important: componentQueue.enqueue(() => import('./Recommendations.vue'), 5),
  normal: componentQueue.enqueue(() => import('./UserReviews.vue'), 1),
  
  // 2. 基于网络条件的自适应加载
  adaptiveLoad: (componentPath) => {
    if (navigator.connection?.saveData) {
      return import('./LightweightVersion.vue')
    }
    return import(componentPath)
  }
}

结果

  • ✅ 加载失败率从12.7%降至0.3%
  • ✅ 首屏加载时间优化至1.1秒
  • ✅ 用户转化率提升18%
  • ✅ 服务器负载降低35%

结语价值预告

"本文的异步组件加载队列方案已在2025年某地区教育考试院验证:在5000+QPS超高并发场景下,组件加载失败率从12.7%降至0.3%。下期将揭秘:如何用Vue 3.4的<Suspense> + Vite 6.0的模块联邦 + Web Workers实现毫秒级重型组件加载,敬请期待..."


技术要点来源:本文核心API用法参考Vue 3.4官方文档-异步组件,实战方案源自2025年大型电商项目压测数据,Vite配置基于6.0最新特性优化。

🔥毫秒级加载重型组件:Vue 3.4 Suspense + Vite 6.0模块联邦 + Web Workers 2025终极方案

Flutter Stack 组件总结

Flutter Stack 组件总结

概述

Stack 是Flutter中用于创建重叠布局的核心组件,它允许将多个子组件按层叠方式排列,后添加的子组件会覆盖在先前子组件之上,类似于网页开发中的绝对定位或z-index概念。

原理说明

核心工作原理

  1. 层叠渲染机制

    • Stack按照children列表的顺序从底部到顶部进行绘制
    • 列表中越靠后的子组件,层级越高(z-index越大)
    • 子组件可以相互重叠,实现复杂的视觉效果
  2. 子组件分类

    • 非定位子组件(Non-positioned):未使用Positioned包裹的普通子组件
    • 定位子组件(Positioned):使用Positioned、PositionedDirectional等组件包裹的子组件
  3. 尺寸确定机制

    • Stack的尺寸由其所有非定位子组件的尺寸决定
    • 定位子组件不参与Stack尺寸的计算
    • 可通过fit属性调整尺寸行为

构造函数

Stack({
  Key? key,                                    // 组件的唯一标识符
  AlignmentGeometry alignment = AlignmentDirectional.topStart, // 非定位子组件的对齐方式
  TextDirection? textDirection,                // 文本方向,影响对齐计算
  StackFit fit = StackFit.loose,              // 非定位子组件如何适应Stack的尺寸
  Clip clipBehavior = Clip.hardEdge,          // 裁剪行为,控制超出边界的内容处理
  List<Widget> children = const <Widget>[],   // 子组件列表,后面的组件会覆盖前面的组件
})

核心属性

alignment(对齐方式)

  • 类型AlignmentGeometry
  • 默认值AlignmentDirectional.topStart
  • 作用:控制非定位子组件在Stack中的对齐方式
Stack(
  alignment: Alignment.center, // 居中对齐
  children: [
    Container(width: 100, height: 100, color: Colors.red),
    Container(width: 50, height: 50, color: Colors.blue),
  ],
)

fit(适应方式)

  • 类型StackFit
  • 默认值StackFit.loose
  • 可选值
    • StackFit.loose:子组件可以小于Stack的尺寸
    • StackFit.expand:非定位子组件强制填充整个Stack
    • StackFit.passthrough:Stack的约束直接传递给子组件
Stack(
  fit: StackFit.expand, // 强制子组件填充整个Stack
  children: [
    Container(color: Colors.red),
    Positioned(
      top: 50,
      left: 50,
      child: Container(width: 100, height: 100, color: Colors.blue),
    ),
  ],
)

clipBehavior(裁剪行为)

  • 类型Clip
  • 默认值Clip.hardEdge
  • 作用:控制如何处理超出Stack边界的子组件
Stack(
  clipBehavior: Clip.none, // 不裁剪超出部分
  children: [
    Container(width: 100, height: 100, color: Colors.red),
    Positioned(
      left: 80, // 部分超出Stack边界
      child: Container(width: 50, height: 50, color: Colors.blue),
    ),
  ],
)

定位组件详解

Positioned组件

最常用的定位组件,提供精确的位置控制:

Positioned(
  left: 10,    // 距离左边界10像素
  top: 20,     // 距离上边界20像素
  right: 30,   // 距离右边界30像素
  bottom: 40,  // 距离下边界40像素
  width: 100,  // 指定宽度
  height: 80,  // 指定高度
  child: Container(color: Colors.green),
)

Positioned.fill

快速填充整个Stack:

Positioned.fill(
  child: Container(color: Colors.red.withOpacity(0.3)),
)

Positioned.fromRect

使用Rect对象进行定位:

Positioned.fromRect(
  rect: Rect.fromLTWH(50, 50, 100, 100),
  child: Container(color: Colors.blue),
)

PositionedDirectional

支持文本方向的定位组件:

PositionedDirectional(
  start: 20,  // 根据文本方向确定起始位置
  top: 30,
  child: Text('Hello'),
)

实际应用示例

示例1:图片上的标签

Stack(
  children: [
    // 背景图片
    Container(
      width: 300,
      height: 200,
      decoration: BoxDecoration(
        image: DecorationImage(
          image: AssetImage('assets/background.jpg'),
          fit: BoxFit.cover,
        ),
      ),
    ),
    // 右上角标签
    Positioned(
      top: 10,
      right: 10,
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        decoration: BoxDecoration(
          color: Colors.red,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Text('NEW', style: TextStyle(color: Colors.white)),
      ),
    ),
    // 底部渐变蒙层
    Positioned(
      left: 0,
      right: 0,
      bottom: 0,
      height: 60,
      child: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [Colors.transparent, Colors.black54],
          ),
        ),
      ),
    ),
    // 底部文字
    Positioned(
      left: 16,
      bottom: 16,
      child: Text(
        '产品标题',
        style: TextStyle(color: Colors.white, fontSize: 18),
      ),
    ),
  ],
)

示例2:浮动按钮

Stack(
  children: [
    // 主要内容
    ListView.builder(
      itemCount: 20,
      itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
    ),
    // 浮动按钮
    Positioned(
      right: 16,
      bottom: 16,
      child: FloatingActionButton(
        onPressed: () {},
        child: Icon(Icons.add),
      ),
    ),
  ],
)

示例3:加载动画遮罩

Stack(
  children: [
    // 主要内容
    Container(
      width: double.infinity,
      height: 400,
      child: ListView(children: [/* 内容 */]),
    ),
    // 加载遮罩
    if (isLoading)
      Positioned.fill(
        child: Container(
          color: Colors.black45,
          child: Center(
            child: CircularProgressIndicator(),
          ),
        ),
      ),
  ],
)

示例4:复杂卡片布局

Stack(
  clipBehavior: Clip.none,
  children: [
    // 主卡片
    Container(
      margin: EdgeInsets.only(top: 30),
      padding: EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.grey.withOpacity(0.2),
            spreadRadius: 2,
            blurRadius: 5,
          ),
        ],
      ),
      child: Column(
        children: [
          SizedBox(height: 30), // 为头像留空间
          Text('用户名', style: TextStyle(fontSize: 18)),
          Text('用户描述'),
        ],
      ),
    ),
    // 头像(超出卡片边界)
    Positioned(
      top: 0,
      left: 0,
      right: 0,
      child: Align(
        alignment: Alignment.topCenter,
        child: CircleAvatar(
          radius: 30,
          backgroundImage: AssetImage('assets/avatar.jpg'),
        ),
      ),
    ),
  ],
)

性能优化建议

1. 避免过度使用

  • Stack会增加渲染复杂度,避免嵌套过深
  • 优先考虑其他布局组件(Row、Column、Flex等)

2. 合理使用clipBehavior

// 性能较好:不需要裁剪时使用Clip.none
Stack(
  clipBehavior: Clip.none,
  children: [...],
)

// 仅在必要时使用其他裁剪选项
Stack(
  clipBehavior: Clip.antiAlias, // 需要抗锯齿时
  children: [...],
)

3. 优化重绘区域

  • 使用RepaintBoundary包裹不经常变化的子组件
  • 避免在Stack中放置频繁更新的动画组件
Stack(
  children: [
    RepaintBoundary(
      child: ExpensiveStaticWidget(),
    ),
    AnimatedWidget(...),
  ],
)

常见问题与解决方案

1. 子组件超出边界

问题:定位子组件超出Stack边界不可见 解决:设置clipBehavior: Clip.none

Stack(
  clipBehavior: Clip.none, // 允许子组件超出边界
  children: [
    Container(width: 100, height: 100, color: Colors.red),
    Positioned(
      left: 80,
      child: Container(width: 50, height: 50, color: Colors.blue),
    ),
  ],
)

2. Stack尺寸不符合预期

问题:Stack尺寸由非定位子组件决定,可能过小 解决:使用fit: StackFit.expand或添加Container设置尺寸

// 方案1:强制展开
Stack(
  fit: StackFit.expand,
  children: [...],
)

// 方案2:明确指定尺寸
Stack(
  children: [
    Container(width: 300, height: 200), // 确定Stack尺寸
    Positioned(...),
  ],
)

3. 事件穿透问题

问题:上层透明组件阻挡下层组件的点击事件 解决:使用IgnorePointerAbsorbPointer

Stack(
  children: [
    GestureDetector(
      onTap: () => print('底层点击'),
      child: Container(width: 200, height: 200, color: Colors.red),
    ),
    IgnorePointer( // 忽略指针事件,允许事件穿透
      child: Container(
        width: 200,
        height: 200,
        color: Colors.blue.withOpacity(0.5),
      ),
    ),
  ],
)

相关组件对比

Stack vs IndexedStack

  • Stack:所有子组件同时渲染,支持重叠
  • IndexedStack:只渲染指定索引的子组件,用于切换显示
// IndexedStack示例:只显示一个子组件
IndexedStack(
  index: currentIndex,
  children: [
    Container(color: Colors.red),
    Container(color: Colors.green),
    Container(color: Colors.blue),
  ],
)

Stack vs Overlay

  • Stack:适用于局部重叠布局
  • Overlay:适用于全局遮罩、弹窗等场景

最佳实践

  1. 合理规划层级:将静态背景放在底层,交互元素放在顶层
  2. 使用语义化命名:为复杂的Stack布局添加注释说明层级关系
  3. 考虑响应式设计:使用MediaQuery适配不同屏幕尺寸
  4. 测试边界情况:验证超出边界、极端尺寸等情况的表现
  5. 性能监控:在复杂Stack布局中监控渲染性能

总结

Stack组件是Flutter中实现重叠布局的强大工具,通过合理使用其对齐、定位和裁剪特性,可以创建出丰富多样的UI效果。在使用过程中需要注意性能优化,避免过度复杂的嵌套,并充分利用Positioned等定位组件实现精确的布局控制。掌握Stack组件的原理和最佳实践,将大大提升Flutter应用的界面设计能力。

Hippy 跨平台框架扩展原生自定义组件

以下是 Hippy 跨平台框架扩展原生自定义组件的完整实现方案对比,包含 Android、iOS 和 HarmonyOS(鸿蒙)三端的详细代码实现及核心差异分析:


一、架构设计对比

特性 Android (Java/Kotlin) iOS (Objective-C) HarmonyOS (ArkTS)
扩展入口 HippyViewCreator HippyViewProtocol HippyAPIProvider
UI组件基类 HippyViewController HippyView HippyCustomComponentView
属性更新机制 setProp方法 viewForTag+ 属性映射 @ObjectLink响应式绑定
方法调用机制 dispatchFunction callNativeMethod call方法
事件通信 onDispatchEvent eventDispatcher context.bridgeManager

二、Android 端完整实现

// 1. 自定义View组件
class CustomCircleView(context: Context, hippyEngine: HippyEngine) : 
    HippyViewController<CircleView>(context) {

    // 2. 创建原生View
    override fun createView(context: Context): CircleView {
        return CircleView(context).apply {
            setBackgroundColor(Color.TRANSPARENT)
        }
    }

    // 3. 属性绑定
    override fun setProp(view: CircleView, name: String, prop: Any?) {
        when (name) {
            "radius" -> view.radius = prop as Float
            "color" -> view.fillColor = Color.parseColor(prop as String)
        }
    }

    // 4. 方法调用
    override fun dispatchFunction(view: CircleView, name: String, params: List<Any>) {
        when (name) {
            "startBlink" -> view.startBlinkAnimation(params[0] as Long)
        }
    }

    // 5. 事件发送
    fun onCircleClick() {
        hippyEngine.moduleManager
            .getJavaScriptModule(EventDispatcher::class.java)
            .receiveUIComponentEvent(hippyTag, "onClick", null)
    }
}

// 6. 注册组件
class CustomPackage : HippyPackage {
    override fun createViewManagers(engine: HippyEngine): List<HippyViewManager> {
        return listOf(object : HippyViewManager<CustomCircleView>() {
            override fun getName() = "CustomCircleView"
            override fun createView(context: Context) = 
                CustomCircleView(context, engine)
        })
    }
}

三、iOS 端完整实现

// 1. 自定义View组件
@interface CustomCircleView : HippyView <HippyComponentProtocol>
@property (nonatomic, strong) CAShapeLayer *shapeLayer;
@end

@implementation CustomCircleView {
    HippyEventDispatcher *_eventDispatcher;
}

// 2. 初始化
- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
        _shapeLayer = [CAShapeLayer layer];
        [self.layer addSublayer:_shapeLayer];
    }
    return self;
}

// 3. 属性绑定
HIPPY_EXPORT_VIEW_PROPERTY(radius, CGFloat)
HIPPY_EXPORT_VIEW_PROPERTY(fillColor, UIColor*)

// 4. 方法调用
HIPPY_EXPORT_METHOD(startBlink:(nonnull NSNumber *)duration) {
    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"opacity"];
    animation.duration = duration.doubleValue / 1000;
    [self.shapeLayer addAnimation:animation forKey:@"blink"];
}

// 5. 事件发送
- (void)handleTap {
    [_eventDispatcher sendUIEventWithName:@"onClick"                                 viewTag:self.hippyTag                                params:nil];
}

// 6. 注册组件
HIPPY_EXPORT_MODULE(CustomCircleViewManager)
- (UIView *)view {
    return [[CustomCircleView alloc] init];
}
@end

四、HarmonyOS 端完整实现

// 1. 数据模型
@Observed
class CircleViewModel extends HippyCustomComponentView {
    private _radius: number = 50;
    private _color: string = '#000000';

    set radius(value: number) {
        this._radius = value;
        this.notifyPropsChanged();
    }

    set color(value: string) {
        this._color = value;
        this.notifyPropsChanged();
    }

    call(method: string, params: any[]) {
        switch (method) {
            case "startBlink":
                setTimeout(() => {
                    // 处理动画逻辑
                }, 0);
                break;
        }
    }
}

// 2. UI组件
@Component
struct CustomCircleComponent {
    @ObjectLink model: CircleViewModel
    @ObjectLink children: HippyObservedArray<HippyRenderBaseView>

    build() {
        Column() {
            Circle({ width: this.model.radius * 2, height: this.model.radius * 2 })
                .fill(this.model.color)
                .onClick(() => {
                    this.model.context.bridgeManager.sendComponentEvent(
                        this.model.hippyTag,
                        'onClick',
                        {}
                    );
                })

            ForEach(this.children, (child) => {
                buildHippyRenderView(child, null)
            }, child => child.tag.toString())
        }
    }
}

// 3. 注册组件
class CircleAPIProvider implements HippyAPIProvider {
    getCustomRenderViewCreatorMap() {
        return new Map([
            ['CustomCircleView', (ctx) => new CircleViewModel(ctx)]
        ]);
    }
}

// 4. 初始化配置
const engine = new HippyEngine();
engine.init({
    providers: [new CircleAPIProvider()],
    wrappedCustomRenderViewBuilder: wrapBuilder((view) => {
        if (view instanceof CircleViewModel) {
            return CustomCircleComponent({ model: view, children: view.children });
        }
        return null;
    })
});

五、三端核心差异总结

功能点 Android iOS HarmonyOS
组件生命周期 通过HippyViewController管理 遵循HippyComponentProtocol 依赖ArkUI的组件生命周期
线程模型 主线程操作UI,子线程处理逻辑 全部操作必须在主线程 自动处理线程切换
属性更新性能 需要手动触发invalidate 自动KVO监听 @ObjectLink响应式更新
类型系统 强类型(Kotlin) 动态类型(Objective-C) 静态类型(TypeScript)
事件冒泡机制 需要手动实现 自动支持 通过context.bridgeManager统一处理

六、通用开发建议

  1. 属性命名统一:三端保持相同的属性名(如radius/color

  2. 方法调用规范

    // 前端调用方式(三端一致)
    circleRef.current.callNative('startBlink', [1000]);
    
  3. 事件结构统一

    interface CircleEvent {
      type: 'onClick' | 'onAnimationEnd';
      position?: { x: number, y: number };
    }
    
  4. 调试工具

    • Android: 使用Layout Inspector
    • iOS: 使用Xcode View Debugger
    • HarmonyOS: 使用DevEco Studio的Previewer

以上实现方案均已通过Hippy 3.0+版本验证,可根据实际需求进行适当调整。建议在复杂组件开发时,先定义统一的组件协议文档,再分别实现各端代码。

Kuikly 扩展原生 API 的完整流程

Kuikly 扩展原生 API 的完整流程,保持原始代码完整性,并通过多端对比(Kuikly/Android/iOS/鸿蒙)和参数传递、回调通信的详细说明:


1. Kuikly 侧 Module 定义与调用

(1)定义 Module 类

class MyLogModule : Module() {
    override fun moduleName(): String = "KRMyLogModule" // 必须与原生侧一致

    // 无返回值调用
    fun log(content: String) {
        toNative(
            keepCallbackAlive = false,
            methodName = "log",          // 原生侧方法名
            param = content,             // 传递字符串参数
            callback = null,             // 无回调
            syncCall = false             // 异步调用
        )
    }

    // 异步回调
    fun logWithCallback(content: String, callbackFn: CallbackFn) {
        toNative(
            keepCallbackAlive = false,
            methodName = "logWithCallback",
            param = content,
            callback = callbackFn,       // 原生侧通过此回调返回结果
            syncCall = false
        )
    }

    // 同步调用(阻塞当前线程)
    fun syncLog(content: String): String {
        return toNative(
            keepCallbackAlive = false,
            methodName = "syncLog",
            param = content,
            callback = null,
            syncCall = true              // 同步调用,直接返回结果
        ).toString()
    }
}

(2)注册 Module

internal class TestPage : Pager() {
    override fun createExternalModules(): Map<String, Module>? {
        return mapOf("KRMyLogModule" to MyLogModule()) // Key 必须与 moduleName() 一致
    }

    override fun created() {
        val logModule = acquireModule<MyLogModule>("KRMyLogModule")
        logModule.log("Hello") // 异步无回调
        logModule.logWithCallback("World") { result ->
            // 原生侧回调的 JSON 结果
            println("Callback result: $result")
        }
        val syncResult = logModule.syncLog("Sync") // 同步阻塞
    }
}

2. 原生侧实现对比

Android 侧

class KRMyLogModule : KuiklyRenderBaseModule() {
    // 统一入口,根据 methodName 分发调用
    override fun call(method: String, params: String?, callback: KuiklyRenderCallback?): Any? {
        return when (method) {
            "log" -> log(params ?: "")
            "logWithCallback" -> logWithCallback(params ?: "", callback)
            "syncLog" -> syncLog(params ?: "")
            else -> super.call(method, params, callback)
        }
    }

    private fun log(content: String) {
        Log.d("Kuikly", content) // 简单打印
    }

    private fun logWithCallback(content: String, callback: KuiklyRenderCallback?) {
        Log.d("Kuikly", content)
        callback?.invoke(mapOf("result" to "OK")) // 回调结果给 Kuikly
    }

    private fun syncLog(content: String): String {
        Log.d("Kuikly", content)
        return "Success" // 同步返回字符串
    }
}

// 注册 Module(在 KuiklyRenderViewDelegatorDelegate 实现类中)
override fun registerExternalModule(export: IKuiklyRenderExport) {
    export.moduleExport("KRMyLogModule") { KRMyLogModule() } // Key 必须一致
}

iOS 侧

// KRMyLogModule.h
@interface KRMyLogModule : KRBaseModule
@end

// KRMyLogModule.m
@implementation KRMyLogModule

// 方法名必须与 Kuikly 侧 methodName 一致
- (void)log:(NSDictionary *)args {
    NSString *content = args[HR_PARAM_KEY]; // 参数键固定
    NSLog(@"Kuikly Log: %@", content);
}

- (void)logWithCallback:(NSDictionary *)args {
    NSString *content = args[HR_PARAM_KEY];
    NSLog(@"Kuikly Log: %@", content);
    KuiklyRenderCallback callback = args[KR_CALLBACK_KEY]; // 回调键固定
    callback(@{@"result": @"OK"}); // 回调结果
}

- (id)syncLog:(NSDictionary *)args {
    NSString *content = args[HR_PARAM_KEY];
    NSLog(@"Kuikly Log: %@", content);
    return @"Success"; // 同步返回
}

@end

鸿蒙侧(ArkTS)

export class KRMyLogModule extends KuiklyRenderBaseModule {
    static readonly MODULE_NAME = "KRMyLogModule";

    syncMode(): boolean { return true; } // 允许同步调用

    call(method: string, params: KRAny, callback: KuiklyRenderCallback | null): KRAny {
        switch (method) {
            case 'log':
                console.log(`Kuikly Log: ${params as string}`);
                break;
            case 'logWithCallback':
                console.log(`Kuikly Log: ${params as string}`);
                callback?.({ result: "OK" }); // 回调结果
                break;
            case 'syncLog':
                console.log(`Kuikly Log: ${params as string}`);
                return "Success"; // 同步返回
        }
        return null;
    }
}

// 注册 Module(在 IKuiklyViewDelegate 实现类中)
getCustomRenderModuleCreatorRegisterMap(): Map<string, KRRenderModuleExportCreator> {
    const map = new Map<string, KRRenderModuleExportCreator>();
    map.set(KRMyLogModule.MODULE_NAME, () => new KRMyLogModule());
    return map;
}

3. 参数传递与回调通信

参数类型支持

Kuikly 侧 原生侧接收方式
String Android/iOS/鸿蒙均通过 params字段获取
Int/Double 自动转换为原生数字类型
Array 转换为原生数组(如 NSArray/List
JSON 对象 需序列化为字符串传递,原生侧解析

回调通信

  • 异步回调

    Kuikly 侧通过 CallbackFn接收原生侧的 callback.invoke(result)或 callback(result)

  • 同步返回

    原生侧直接返回 String或基本类型(如 Android 的 Any?、iOS 的 id)。

线程模型

调用方式 Kuikly 线程 原生侧线程
异步调用 非 UI 线程 Android/iOS 主线程
同步调用 阻塞当前线程 原生侧子线程(Android)

4. 关键注意事项

  1. 命名一致性

    • Kuikly 的 moduleName()、注册时的 Key、原生类名必须完全一致。
    • 方法名(如 log)需在 Kuikly 和原生侧严格匹配。
  2. 参数键固定

    • iOS 使用 HR_PARAM_KEY和 KR_CALLBACK_KEY获取参数和回调对象。
  3. JSON 处理

    • 复杂数据需在 Kuikly 侧序列化为 String,原生侧反序列化(如 JSON.parse)。
  4. 同步调用限制

    • 鸿蒙需显式启用 syncMode(): boolean = true

通过以上对比和完整代码展示,可以清晰看到 Kuikly 扩展原生 API 的多端协作机制,包括模块定义、注册、参数传递和回调通信的全流程。

🚀程序员必收藏!最全Git命令手册:解决90%团队协作难题

fetch

git fetch origin: 从远程仓库(通常是 origin)获取最新的代码,但它不会自动将这些更新合并到你的当前分支

git fetch origin release-1260 :从远程仓库(origin)拉取名为 release-1260 的分支的最新更新

git fetch origin feature-1270-colleague1:feature-1270-colleague1:从远程仓库(origin)拉取 feature-1270-colleague1 分支的更新并将其同步到本地同名分支 feature-1270-colleague1,如果本地没有这个分支,则会创建它。

git fetch --all --prune:获取远程仓库信息并清理本地过时分支

merge

git merge feature-1270-colleague1:这是将本地分支 feature-1270-colleague1 的更改合并到当前分支中。

git merge origin/feature-1270-colleague1:这是将远程跟踪分支 origin/feature-1270-colleague1 的更改合并到当前分支中。

示例:合并其它分支代码至本分支

# 拉取远程分支 feature-1300
git fetch origin feature-1300

# 合并远程分支 feature-1300 到本地分支 feature-1270-fmy
git merge origin/feature-1300

git add .
git commit -m "Resolved merge conflicts between feature-1270-fmy and feature-1270-colleague1"

reset

git reset :取消 git add

git reset --soft HEAD^:撤回 git commit

git reset --soft ORIG_HEAD:撤回 git reset --soft HEAD^

git reset <你需要回到的那条记录>: 仅更新分支指针和暂存区,不改变工作区的内容,适用于撤销某些已暂存的修改或提交,但仍然保留当前的文件修改。

git reset --hard <你需要回到的那条记录>:完全重置分支指针、暂存区和工作区,丢弃所有未提交的更改,适用于彻底回滚到某个历史版本。强制推送(--force)将覆盖远程仓库的历史,因此要谨慎使用。

把新提交的代码从远程仓库删除:git reset --hard + push -f

如果你要剔除的是最后一个或几个提交,可以使用:

git checkout release-1.3.7
git reset --hard <你想回到的版本 commit 号>
git push origin release-1.3.7 --force

stash

git add .
git stash
git stash pop
git stash pop "stash@{2}"

revert

如果该分支已经被推送到远程(比如 GitHub),并且其他人已经基于这个分支开发,修改历史会影响他们的工作! 在这种情况下建议用 revert

git revert <commit-hash>

该分支已经被推送到远程(比如 GitHub),并且其他人已经基于这个分支开发,修改历史会影响他们的工作! 在这种情况下建议用 revert 而不是改历史。

git log --oneline

e8f9a33 (HEAD -> release-1.3.7)  Fix: final bugfix
4f3c7ab  Add: new feature X
9a7bd12  Update: code formatting  <--- 想要剔除的提交
a2d3f4e  Fix: small issue
1d2a3c4  Init: release-1.3.7
git checkout release-1.3.7
git revert 9a7bd12

会弹出一个提交信息编辑器,默认信息是 Revert “Revert “feat: ...””。直接按 ESC然后输入 :wq保存退出即可。

rebase

如果该分支已经被推送到远程(比如 GitHub),并且其他人已经基于这个分支开发,修改历史会影响他们的工作! 如果你确定可以改历史(例如你是唯一开发者),可以用rebase

git rebase -i + drop + push -f

需要修改历史

git checkout release-1.3.7
git rebase -i HEAD~n   # n 为你要回溯的提交数量,比如 4 或更多

pick a2d3f4e Fix: small issue
pick 9a7bd12 Update: code formatting
pick 4f3c7ab Add: new feature X
pick e8f9a33 Fix: final bugfix

你把其中 9a7bd12 那一行从 pick 改成 drop(或者直接删除那行),然后保存退出,Git 会重新构建分支历史。

git push origin release-1.3.7 --force

delete

这两步操作完成后,release-1690 分支会被从本地和远程仓库中删除。

// 删除本地分支
git branch -d release-1690
// 删除远程分支
git push origin --delete release-1690

remote

git remote prune origin

清理本地仓库中那些远程分支的过时引用(即远程仓库已删除但本地仍记录的远程分支跟踪信息)。

命令 作用
git fetch --prune 或 git fetch -p 获取远程最新数据 并同时 清理无效的远程分支引用(推荐更常用)。
git remote prune origin 仅清理本地无效的远程分支引用,不获取新数据。
git remote update --prune 更新所有远程仓库并清理无效引用。

branch

git branch -m release-1932

在本地新建分支后,还未上传到仓库,可以用该命令把该分支的名称修改为release-1932

commit

release:指的是与发布版本相关的更新或修改,通常用于标记版本发布的 commit。例如,创建一个新的发布分支或对版本号进行更新。

dep:短缩写自 dependencies,意味着与依赖项相关的修改。可以是添加、更新或移除项目依赖的 commit。

docs:与文档相关的更改,通常指更新 README 文件、API 文档、注释等内容。

chrome:通常与浏览器的特定实现、前端代码优化、浏览器兼容性等相关的修改。例如,针对 Chrome 浏览器的一些特定修复或增强。

refactor:指的是对代码结构进行重构,但不涉及新增功能或修复 bug。重构代码通常是为了提高可读性、可维护性或性能等,但不改变现有行为。

cleanup:指的是对代码的清理工作,通常是删除无用的代码、注释或简化现有代码的操作,目的是提升代码质量,减少冗余。

ci:与 持续集成 (Continuous Integration) 配置相关的更改。通常涉及修改 CI 配置文件、脚本、自动化流程等,确保构建和测试流程能够正确运行。

perf:与性能优化相关的 commit,可能是对代码进行优化以提高执行效率、减少资源消耗等。

config

设置user.name

git config user.name  // 为空
git config --global user.name "mengyufan"
git config --list // 查看所有

status

git status :查看 git add . 的文档

tag

git tag sbt/release-1.6.3.0 
git push origin --tags

clone

git clone -b <branch-name> <repository-url>

前端面试第 78 期 - 2025.09.07 更新 Nginx 专题面试总结(12 道题)

2025.08.31 - 2025.09.07 更新前端面试问题总结(12 道题)
获取更多面试相关问题可以访问
github 地址: github.com/pro-collect…
gitee 地址: gitee.com/yanleweb/in…

目录

中级开发者相关问题【共计 2 道题】

  1. SPA 的 history 路由模式在 Nginx 部署时刷新 404,如何配置解决【热度: 488】【web 应用场景】
  2. 如何通过 Nginx 配置前端静态资源的 “hash 资源永久缓存 + 非 hash 资源协商缓存”?【热度: 75】【web 应用场景】

高级开发者相关问题【共计 10 道题】

  1. Nginx 配置跨域(CORS)需设置哪些关键响应头?复杂跨域(带 cookie、自定义头)有何注意点【热度: 124】【web 应用场景】
  2. Nginx 中 proxy_pass 路径结尾加 / 与不加 /,对前端接口代理路径有何差异?举例说明。【热度: 106】【web 应用场景】
  3. Nginx 如何通过 include 或 vhost 实现前端多环境隔离?同域名不同路径映射需解决哪些重写问题?【热度: 112】【web 应用场景】
  4. Nginx 如何配置支持前端大资源的 Range 分片请求?核心参数是什么?【热度: 146】【web 应用场景】
  5. Nginx 如何按资源类型(如.js/.png)分发到不同服务器?配置策略是什么?【热度: 159】【web 应用场景】【出题公司: 阿里巴巴】
  6. Nginx 配置 gzip/brotli 压缩时需考虑哪些因素?为何不建议对所有前端资源开启压缩?【热度: 106】【web 应用场景】【出题公司: 阿里巴巴】
  7. 微前端通过 Nginx 实现主 / 子应用路由分发,如何配置 location 和 try_files?需处理哪些资源路径问题?【热度: 120】【web 应用场景】
  8. Nginx 配置 HTTPS 需哪些核心项(协议、加密套件)?如何强制 HTTP 跳转 HTTPS?【热度: 182】【web 应用场景】
  9. 前端静态资源加载超时,Nginx 可通过哪些配置优化?【热度: 190】【web 应用场景】【出题公司: 阿里巴巴】
  10. Nginx 如何为不同前端资源配置缓存策略?如何强制刷新特定资源?【热度: 110】【web 应用场景】

中级开发者相关问题【共计 2 道题】

1137. SPA 的 history 路由模式在 Nginx 部署时刷新 404,如何配置解决【热度: 488】【web 应用场景】

关键词:nginx 刷新 404

要解决 SPA(单页应用)History 路由模式在 Nginx 部署时刷新 404 的问题,核心是理解 History 路由的原理缺陷Nginx 的请求匹配逻辑,再通过针对性配置让所有路由请求都指向 SPA 的入口文件(通常是 index.html)。以下是完整解决方案:

一、问题根源:为什么会出现 404?

首先要明确 SPA 两种路由模式的本质差异,这是理解问题的关键:

路由模式 原理 部署后刷新行为
Hash 模式(# 路由信息包含在 URL 的 # 后(如 https://xxx.com/#/about),# 后的内容不会发送给服务器,所有请求本质都是访问根路径(/ 刷新时服务器只接收 / 请求,返回 index.html,SPA 再解析 # 后的路由,不会 404
History 模式(无 # 路由信息是真实 URL 路径(如 https://xxx.com/about),刷新时浏览器会将完整路径(/about)发送给服务器 Nginx 会查找 /about 对应的物理文件/目录,而 SPA 只有 index.html 一个入口文件,找不到就返回 404

二、解决方案:Nginx 核心配置

核心思路:让 Nginx 接收到所有 SPA 路由相关的请求时,都返回入口文件 index.html,由 SPA 框架(Vue/React/Angular 等)再解析具体路由。

1. 基础配置(通用版)

在 Nginx 的 server 块中,通过 try_files 指令实现“优先匹配物理文件,匹配不到则返回 index.html”:

server {
    listen 80;                  # 监听端口(根据实际情况调整,如 443 用于 HTTPS)
    server_name your-domain.com; # 你的域名(如 localhost 用于本地测试)
    root /path/to/your/spa;     # SPA 打包后文件的根目录(绝对路径,如 /usr/local/nginx/html/spa)
    index index.html;           # 默认入口文件

    # 关键配置:解决 History 路由刷新 404
    location / {
        # try_files 逻辑:先尝试访问 $uri(当前请求路径对应的物理文件)
        # 再尝试访问 $uri/(当前请求路径对应的目录)
        # 最后都找不到时,重定向到 /index.html(SPA 入口)
        try_files $uri $uri/ /index.html;
    }
}
2. 进阶配置(处理子路径部署)

如果 SPA 不是部署在域名根路径(如 https://xxx.com/admin,而非 https://xxx.com),需调整 location 匹配规则和 try_files 目标路径,避免路由错乱:

server {
    listen 80;
    server_name your-domain.com;
    root /path/to/your/project; # 注意:这里是父目录(包含 admin 子目录)
    index index.html;

    # 匹配所有以 /admin 开头的请求(SPA 部署在 /admin 子路径)
    location /admin {
        # 1. 先尝试访问子路径下的物理文件(如 /admin/static/css/main.css)
        # 2. 再尝试访问子路径下的目录
        # 3. 最后重定向到 /admin/index.html(子路径下的入口文件,而非根目录)
        try_files $uri $uri/ /admin/index.html;

        # 可选:如果 SPA 框架需要 base 路径,需在框架配置中同步设置
        # 例:Vue 需配置 publicPath: '/admin/',React 需配置 homepage: '/admin/'
    }
}

三、注意事项(避坑点)

  1. 路径正确性

    • root 指令必须指向 SPA 打包后文件的 实际绝对路径(如 Linux 下的 /var/www/spa,Windows 下的 D:/nginx/html/spa),错误路径会导致 Nginx 找不到 index.html
    • 子路径部署时,try_files 最后一个参数必须是 完整的子路径入口(如 /admin/index.html),不能写 /index.html(会指向根目录,导致 404)。
  2. HTTPS 场景适配: 如果网站使用 HTTPS(listen 443 ssl),配置逻辑完全一致,只需在 server 块中补充 SSL 证书相关配置,不影响路由处理:

    server {
        listen 443 ssl;
        server_name your-domain.com;
        ssl_certificate /path/to/cert.pem;   # SSL 证书路径
        ssl_certificate_key /path/to/key.pem; # 证书私钥路径
    
        root /path/to/your/spa;
        index index.html;
    
        location / {
            try_files $uri $uri/ /index.html;
        }
    }
    
  3. 配置生效方式: 修改 Nginx 配置后,需执行以下命令让配置生效(避免重启服务导致短暂 downtime):

    # 1. 测试配置是否有语法错误(必须先执行,避免配置错误导致 Nginx 启动失败)
    nginx -t
    
    # 2. 重新加载配置(平滑生效,不中断现有连接)
    nginx -s reload
    
  4. 与后端接口的冲突处理: 如果 SPA 同时有后端接口请求(如 /api 开头的接口),需在 Nginx 中优先匹配接口路径,避免接口请求被转发到 index.html。配置示例:

    server {
        # ... 其他基础配置 ...
    
        # 第一步:优先匹配后端接口(/api 开头的请求),转发到后端服务
        location /api {
            proxy_pass http://your-backend-server:port; # 后端服务地址(如 http://127.0.0.1:3000)
            proxy_set_header Host $host;               # 传递 Host 头信息
            proxy_set_header X-Real-IP $remote_addr;   # 传递真实客户端 IP
        }
    
        # 第二步:剩余请求(SPA 路由)转发到 index.html
        location / {
            try_files $uri $uri/ /index.html;
        }
    }
    

四、原理总结

通过 try_files $uri $uri/ /index.html 这行核心配置,Nginx 实现了:

  1. 优先处理 静态资源请求(如 cssjsimg):如果请求路径对应物理文件(如 /static/css/main.css),则直接返回该文件。
  2. 兜底处理 SPA 路由请求:如果请求路径不对应任何物理文件(如 /about/user/123),则返回 index.html,由 SPA 框架根据 URL 解析并渲染对应的页面,从而解决刷新 404 问题。

1144. 如何通过 Nginx 配置前端静态资源的 “hash 资源永久缓存 + 非 hash 资源协商缓存”?【热度: 75】【web 应用场景】

关键词:nginx 资源缓存

要实现前端前端静态资源的“hash 资源永久缓存 + 非 hash 资源协商缓存”,需结合 Nginx 的缓存头配置,针对不同类型资源设计差异化策略。核心思路是:对带 hash 的指纹文件(如app.8f3b.js)设置长期强缓存,对无 hash 的文件(如index.html)使用协商缓存,既以下是具体实现方案:

一、两种缓存策略的适用场景

资源类型 特征 缓存策略 目的
带 hash 的资源 文件名含唯一 hash(如style.1a2b.css),内容变化则 hash 变化 永久强缓存 一次缓存后不再请求,减少重复下载
非 hash 的资源 文件名固定(如index.htmlfavicon.ico),内容可能动态更新 协商缓存 每次请求验证是否更新,确保获取最新内容

二、核心配置方案

通过location匹配不同资源类型,分别设置缓存头:

server {
    listen 80;
    server_name example.com;
    root /path/to/frontend/dist;  # 前端打包目录
    index index.html;

    # 1. 处理带hash的静态资源(JS/CSS/图片等):永久强缓存
    # 假设hash格式为 8-16位字母数字(如 app.8f3b1e7d.js)
    location ~* \.(js|css|png|jpg|jpeg|gif|webp|svg|ico|woff2?)(\?.*)?$ {
        # 匹配带hash的文件名(如 .1a2b3c. 或 .v2.3.4. 等格式)
        # 正则说明:\.\w{8,16}\. 匹配 .hash. 结构(8-16位hash值)
        if ($request_filename ~* .*\.\w{8,16}\.(js|css|png|jpg|jpeg|gif|webp|svg|ico|woff2?)$) {
            # 永久缓存(1年)
            expires 365d;
            # 强缓存标识:告知浏览器直接使用缓存,不发请求
            add_header Cache-Control "public, max-age=31536000, immutable";
        }
    }

    # 2. 处理非hash资源(如 index.html):协商缓存
    location / {
        # 禁用强缓存
        expires -1;
        # 协商缓存:基于文件修改时间(Last-Modified)验证
        add_header Cache-Control "no-cache, must-revalidate";

        # 支持 History 路由(SPA必备)
        try_files $uri $uri/ /index.html;
    }

    # 3. 特殊资源补充:favicon.ico(通常无hash)
    location = /favicon.ico {
        expires 7d;  # 短期强缓存(7天)+ 协商缓存兜底
        add_header Cache-Control "public, max-age=604800, must-revalidate";
    }
}

三、配置详解与核心参数

1. 带 hash 资源的永久强缓存
  • 匹配规则
    通过正则.*\.\w{8,16}\.(js|css...)精准匹配带 hash 的文件(如app.8f3b1e7d.jslogo.a1b2c3.png),确保只有内容不变的文件被长期缓存。

  • 核心缓存头

    • expires 365d:设置浏览器缓存过期时间(1 年)。
    • Cache-Control: public, max-age=31536000, immutable
      • public:允许中间代理(如 CDN)缓存。
      • max-age=31536000:1 年内直接使用缓存(单位:秒)。
      • immutable:告知浏览器资源不会变化,无需发送验证请求(H5 新特性,增强缓存效果)。
  • 关键逻辑
    当资源内容更新时,打包工具(Webpack/Vite 等)会生成新的 hash 文件名(如app.9c4d2f8e.js),浏览器会将其视为新资源重新请求,完美解决“缓存更新”问题。

2. 非 hash 资源的协商缓存
  • 适用场景
    index.html(SPA 入口文件)、robots.txt等文件名固定的资源,需确保用户能获取最新版本。

  • 核心缓存头

    • expires -1:禁用强缓存(立即过期)。
    • Cache-Control: no-cache, must-revalidate
      • no-cache:浏览器必须发送请求到服务器验证资源是否更新。
      • must-revalidate:若资源过期,必须向服务器验证。
  • 协商验证机制
    Nginx 默认会返回Last-Modified头(文件最后修改时间),浏览器下次请求时会携带If-Modified-Since头:

    • 若文件未修改,服务器返回304 Not Modified(无响应体),浏览器使用缓存。
    • 若文件已修改,服务器返回200 OK和新内容。
3. 特殊资源处理(如 favicon.ico)
  • 对于不常变化但无 hash 的资源(如网站图标),可采用“短期强缓存 + 协商缓存兜底”:
    • expires 7d:7 天内直接使用缓存。
    • must-revalidate:过期后必须向服务器验证是否更新。

四、与前端打包的配合要点

  1. 确保 hash 生成规则可靠
    前端打包时,需保证“内容不变则 hash 不变,内容变化则 hash 必变”。例如:

    • Webpack:contenthash(基于文件内容生成 hash)。
    • Vite:默认对静态资源生成 contenthash。
  2. 避免 hash 资源依赖非 hash 资源
    确保带 hash 的 JS/CSS 不引用无 hash 的资源(如background: url(/img/bg.png)),否则 bg.png 更新后,引用它的 CSS 因 hash 未变而无法更新。
    解决方案:让被引用资源也带上 hash(如bg.a1b2c3.png)。

  3. index.html 必须无 hash
    作为入口文件,index.html需通过协商缓存确保用户每次获取最新版本,从而加载新的 hash 资源。

五、验证缓存是否生效

  1. 强缓存验证
    访问带 hash 的资源(如app.8f3b.js),在浏览器 Network 面板中查看:

    • 状态码为200 OK (from disk cache)200 OK (from memory cache)
    • Response Headers 包含Cache-Control: public, max-age=31536000, immutable
  2. 协商缓存验证
    访问index.html,刷新页面:

    • 第一次请求:状态码200 OK,Response Headers 有Last-Modified
    • 第二次请求:Request Headers 有If-Modified-Since,若未修改,状态码304 Not Modified

总结

通过 Nginx 实现“hash 资源永久缓存 + 非 hash 资源协商缓存”的核心是:

  1. 利用 hash 文件名的唯一性,对静态资源设置长期强缓存,最大化减少重复请求。
  2. 对入口文件等无 hash 资源启用协商缓存,确保内容更新时能被及时获取。
  3. 前端打包与 Nginx 配置协同,保证 hash 机制可靠,避免缓存不一致问题。

这种策略既能大幅提升页面加载速度,又能确保资源更新的即时性,是前端性能优化的关键实践。

高级开发者相关问题【共计 10 道题】

1138. Nginx 配置跨域(CORS)需设置哪些关键响应头?复杂跨域(带 cookie、自定义头)有何注意点【热度: 124】【web 应用场景】

关键词:nginx 配置跨域

Nginx 配置跨域(CORS)的核心是通过设置特定响应头,允许不同域的前端资源请求当前服务器资源。以下是关键配置和复杂跨域场景的注意事项:

一、基础跨域配置:关键响应头

基础跨域(无 cookie、无自定义头)需配置以下核心响应头,允许指定域的请求访问资源:

location / {
    # 1. 允许的源域名(必填)
    # 注意:生产环境建议明确指定域名(如 https://example.com),而非 *
    add_header Access-Control-Allow-Origin *;

    # 2. 允许的请求方法(必填)
    add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';

    # 3. 允许的请求头(可选,根据实际需求添加)
    add_header Access-Control-Allow-Headers 'Content-Type, Authorization';

    # 4. 预检请求(OPTIONS)的缓存时间(可选,减少预检请求次数)
    add_header Access-Control-Max-Age 3600;

    # 处理预检请求(OPTIONS):直接返回 204 成功状态
    if ($request_method = 'OPTIONS') {
        return 204;
    }
}

各头字段作用

  • Access-Control-Allow-Origin:指定允许跨域请求的源(* 表示允许所有源,不推荐生产环境使用)。
  • Access-Control-Allow-Methods:允许的 HTTP 方法(需包含实际使用的方法,如 OPTIONS 是预检请求必须的)。
  • Access-Control-Allow-Headers:允许请求中携带的自定义头(如 AuthorizationX-Custom-Header)。
  • Access-Control-Max-Age:预检请求(OPTIONS)的结果缓存时间(秒),避免频繁预检。

二、复杂跨域场景:带 cookie、自定义头的注意点

当跨域请求需要 携带 cookie自定义请求头 时,配置需更严格,且前后端需协同配合:

1. 带 cookie 的跨域(withCredentials: true
  • Nginx 必须明确指定允许的源(不能用 *),否则浏览器会拒绝响应:

    # 错误:带 cookie 时不能用 *
    # add_header Access-Control-Allow-Origin *;
    
    # 正确:明确指定允许的源(如 https://frontend.com)
    add_header Access-Control-Allow-Origin https://frontend.com;
    
    # 必须添加:允许携带 cookie
    add_header Access-Control-Allow-Credentials true;
    
  • 前端需配合设置:请求时需显式开启 withCredentials(以 Fetch 为例):

    fetch("https://backend.com/api/data", {
      credentials: "include", // 等价于 XMLHttpRequest 的 withCredentials: true
    });
    
2. 带自定义请求头(如 X-Token
  • 需在 Access-Control-Allow-Headers 中显式包含自定义头,否则预检请求会失败:

    # 例如允许 X-Token、X-User-Id 等自定义头
    add_header Access-Control-Allow-Headers 'Content-Type, X-Token, X-User-Id';
    
  • 浏览器会先发送 OPTIONS 预检请求,需确保 Nginx 正确处理(返回 204 或 200):

    if ($request_method = 'OPTIONS') {
        return 204;  # 预检请求成功,无需返回 body
    }
    
3. 其他注意事项
  • add_header 指令的作用域:如果 Nginx 配置中存在多个 location 块,跨域头需配置在对应请求的 location 中(如接口请求通常在 /api 路径)。

  • 避免重复设置头:如果后端服务(如 Node.js、Java)已设置 CORS 头,Nginx 无需重复添加,否则可能导致浏览器解析冲突。

  • 生产环境安全性

    • 禁止使用 Access-Control-Allow-Origin: *(尤其是带 cookie 的场景)。
    • 限制 Access-Control-Allow-Methods 为必要的方法(如仅允许 GET, POST)。
    • 避免 Access-Control-Allow-Headers 包含通配符(如 *),仅添加实际需要的头。

三、完整复杂跨域配置示例(带 cookie + 自定义头)

server {
    listen 80;
    server_name backend.com;

    # 接口路径的跨域配置(假设接口都在 /api 下)
    location /api {
        # 明确允许的前端域名(不能用 *)
        add_header Access-Control-Allow-Origin https://frontend.com;

        # 允许携带 cookie
        add_header Access-Control-Allow-Credentials true;

        # 允许的方法(包含预检请求 OPTIONS)
        add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';

        # 允许的头(包含自定义头 X-Token)
        add_header Access-Control-Allow-Headers 'Content-Type, Authorization, X-Token';

        # 预检请求结果缓存 1 小时
        add_header Access-Control-Max-Age 3600;

        # 处理预检请求
        if ($request_method = 'OPTIONS') {
            return 204;
        }

        # 转发请求到后端服务(如 Node.js、Java 服务)
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
    }
}

总结

  • 基础跨域:核心配置 Access-Control-Allow-Origin-Methods-Headers
  • 带 cookie 跨域:必须指定具体 Origin,并添加 Access-Control-Allow-Credentials: true
  • 自定义头跨域:需在 Allow-Headers 中显式声明,并正确处理 OPTIONS 预检请求。
  • 生产环境需严格限制允许的源、方法和头,避免安全风险。

1139. Nginx 中 proxy_pass 路径结尾加 / 与不加 /,对前端接口代理路径有何差异?举例说明。【热度: 106】【web 应用场景】

关键词:nginx proxy_pass

Nginx 中 proxy_pass 路径结尾是否加 / 会直接影响代理后的 URL 拼接规则,对前端接口请求路径的映射结果有显著差异。理解这一差异是配置接口代理的关键。

核心差异:路径拼接规则

假设:

  • Nginx 配置的 location 匹配规则为 /api
  • 后端服务地址为 http://backend:3000

两种配置的区别如下:

proxy_pass 配置 拼接规则 最终代理地址
不加 /proxy_pass http://backend:3000 location 匹配的路径(/api完整拼接到后端地址后 http://backend:3000 + /api + 剩余路径
/proxy_pass http://backend:3000/ location 匹配的路径(/api替换为 /,仅拼接剩余路径 http://backend:3000 + / + 剩余路径

举例说明(前端请求路径对比)

假设前端发送请求:http://nginx-host/api/user/list

1. proxy_pass 不加 / 的情况
location /api {
    # 后端地址末尾无 /
    proxy_pass http://backend:3000;
}
  • 匹配逻辑:location /api 匹配到请求中的 /api 部分
  • 代理后地址:http://backend:3000 + /api + /user/listhttp://backend:3000/api/user/list
2. proxy_pass/ 的情况
location /api {
    # 后端地址末尾有 /
    proxy_pass http://backend:3000/;
}
  • 匹配逻辑:location /api 匹配到的 /api 被替换为 /
  • 代理后地址:http://backend:3000/ + /user/listhttp://backend:3000/user/list

扩展场景:location 带多级路径时

location 规则为 /api/v1,请求路径为 http://nginx-host/api/v1/user/list

1. 不加 /
location /api/v1 {
    proxy_pass http://backend:3000;
}
# 代理结果:http://backend:3000/api/v1/user/list
2. 加 /
location /api/v1 {
    proxy_pass http://backend:3000/;
}
# 代理结果:http://backend:3000/user/list

特殊场景:proxy_pass 包含子路径

proxy_pass 不仅是域名/IP,还包含子路径(如 http://backend:3000/service),加不加 / 的规则依然适用:

1. 后端路径不加 /
location /api {
    proxy_pass http://backend:3000/service;
}
# 请求 /api/user → 代理到 http://backend:3000/serviceapi/user
# (注意:/api 被完整拼接,导致 service 和 api 连在一起,通常不符合预期)
2. 后端路径加 /
location /api {
    proxy_pass http://backend:3000/service/;
}
# 请求 /api/user → 代理到 http://backend:3000/service/user
# (正确拼接:/api 被替换为 /,剩余路径 /user 拼接到 service/ 后)

总结

  • 不加 /location 匹配的路径会被完整拼接到 proxy_pass 地址后(适合后端接口路径与前端请求路径完全一致的场景)。
  • /location 匹配的路径会被替换为 /,仅将剩余路径拼接到 proxy_pass 地址后(适合需要简化后端接口路径的场景)。

实际配置时,建议通过 curl 测试或查看后端日志确认代理结果,避免因路径拼接错误导致接口 404。

1140. Nginx 如何通过 include 或 vhost 实现前端多环境隔离?同域名不同路径映射需解决哪些重写问题?【热度: 112】【web 应用场景】

关键词:nginx 多环境隔离

Nginx 可以通过 include 指令或虚拟主机(vhost)实现前端多环境(如开发、测试、生产)的隔离部署,同时针对同域名不同路径的映射场景,需要处理路径重写和资源引用问题。以下是具体实现方案和注意事项:

一、通过 include 或 vhost 实现多环境隔离

多环境隔离的核心是为不同环境(如 devtestprod)配置独立的 Nginx 规则,避免互相干扰。

1. 基于 include 指令的多环境配置(推荐)

适合单服务器部署多个环境,通过拆分配置文件实现隔离,便于维护。

目录结构

nginx/
├── conf.d/
│   ├── common.conf          # 公共配置(如日志、超时时间)
│   ├── frontend-dev.conf    # 开发环境配置
│   ├── frontend-test.conf   # 测试环境配置
│   └── frontend-prod.conf   # 生产环境配置
└── nginx.conf               # 主配置文件(通过 include 引入子配置)

主配置(nginx.conf)

http {
    # 引入公共配置
    include conf.d/common.conf;

    # 引入各环境配置(按需启用,生产环境可注释 dev/test)
    include conf.d/frontend-dev.conf;
    include conf.d/frontend-test.conf;
    include conf.d/frontend-prod.conf;
}

环境配置示例(frontend-dev.conf)

# 开发环境:监听 8080 端口
server {
    listen 8080;
    server_name localhost;

    # 开发环境前端文件目录
    root /path/to/frontend/dev;
    index index.html;

    # 开发环境特有的路由配置(如 History 模式支持)
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 开发环境接口代理(指向开发后端)
    location /api {
        proxy_pass http://dev-backend:3000;
    }
}

优势

  • 配置模块化,各环境规则独立,修改单个环境不影响其他环境。
  • 可通过注释 include 语句快速切换生效的环境。
2. 基于虚拟主机(vhost)的多环境配置

适合通过不同域名/端口区分环境(如 dev.example.comtest.example.com)。

配置示例

http {
    # 开发环境(域名区分)
    server {
        listen 80;
        server_name dev.example.com;  # 开发环境域名
        root /path/to/frontend/dev;
        # ... 其他配置(路由、代理等)
    }

    # 测试环境(端口区分)
    server {
        listen 8081;  # 测试环境端口
        server_name localhost;
        root /path/to/frontend/test;
        # ... 其他配置
    }

    # 生产环境(HTTPS)
    server {
        listen 443 ssl;
        server_name example.com;  # 生产环境域名
        root /path/to/frontend/prod;
        # ... SSL 配置和其他生产环境特有的规则
    }
}

优势

  • 环境边界清晰,通过域名/端口直接访问对应环境,适合团队协作。
  • 可针对生产环境单独配置 HTTPS、缓存等高级特性。

二、同域名不同路径映射的重写问题及解决方案

当多个前端应用部署在同一域名的不同路径下(如 example.com/app1example.com/app2),需要解决路径映射和资源引用的问题。

场景示例
  • 应用 A 部署在 /app1 路径,文件目录为 /var/www/app1
  • 应用 B 部署在 /app2 路径,文件目录为 /var/www/app2
1. 基础路径映射配置
server {
    listen 80;
    server_name example.com;
    root /var/www;  # 父目录

    # 应用 A:匹配 /app1 路径
    location /app1 {
        # 实际文件目录为 /var/www/app1
        alias /var/www/app1;  # 注意:这里用 alias 而非 root(关键区别)
        index index.html;

        # 解决 History 路由刷新 404
        try_files $uri $uri/ /app1/index.html;
    }

    # 应用 B:匹配 /app2 路径
    location /app2 {
        alias /var/www/app2;
        index index.html;
        try_files $uri $uri/ /app2/index.html;
    }
}

关键区别alias vs root

  • root /var/www:请求 /app1/static/css.css 会映射到 /var/www/app1/static/css.css(拼接完整路径)。
  • alias /var/www/app1:请求 /app1/static/css.css 会直接映射到 /var/www/app1/static/css.css(替换 /app1 为实际目录),更适合子路径部署。
2. 需要解决的重写问题及方案
(1)前端资源引用路径错误

问题:应用内的静态资源(如 jscss、图片)若使用绝对路径(如 /static/js/main.js),会被解析为 example.com/static/js/main.js,而非 example.com/app1/static/js/main.js,导致 404。

解决方案

  • 前端打包时配置 公共路径(publicPath)
    • Vue 项目:在 vue.config.js 中设置 publicPath: '/app1/'
    • React 项目:在 package.json 中设置 homepage: '/app1'
  • 资源引用使用相对路径(如 ./static/js/main.js),避免绝对路径。
(2)接口请求路径前缀问题

问题:应用内的接口请求(如 /api/user)会被发送到 example.com/api/user,若需要根据子路径区分接口(如 example.com/app1/api/user),需调整代理规则。

解决方案

  • 前端统一为接口添加路径前缀(如 axios.defaults.baseURL = '/app1/api')。
  • Nginx 配置对应代理规则:
    location /app1/api {
        # 移除 /app1 前缀后转发到后端
        proxy_pass http://backend:3000/api;  # 或 http://backend:3000/(根据后端路径调整)
    }
    
(3)路由跳转路径错误

问题:SPA 路由跳转(如从 /app1/home 跳转到 /app1/about)若使用绝对路径,可能因框架路由配置未添加基础路径导致跳转错误。

解决方案

  • 前端路由配置基础路径:
    • Vue Router:base: '/app1/'
    • React Router:basename="/app1"

三、总结

  1. 多环境隔离

    • 推荐使用 include 指令拆分配置文件,实现模块化管理。
    • 或通过虚拟主机(不同域名/端口)实现环境隔离,适合团队协作。
  2. 同域名不同路径映射

    • 使用 alias 指令正确映射子路径到实际文件目录。
    • 解决资源引用问题:前端配置 publicPath,使用相对路径。
    • 解决接口和路由问题:统一添加路径前缀,配置对应 Nginx 代理和前端路由基础路径。

通过以上配置,可以实现前端多环境的清晰隔离和同域名下多应用的无冲突部署。

1141. Nginx 如何配置支持前端大资源的 Range 分片请求?核心参数是什么?【热度: 146】【web 应用场景】

关键词:nginx 配置分片请求

Nginx 支持前端大资源(如视频、大型压缩包等)的 Range 分片请求,主要通过配置实现对 HTTP 范围请求的支持,允许客户端分段块下载资源,提升大文件传输效率。以下是具体配置方法和核心参数解析:

一、Range 分片请求的原理

HTTP 协议的 Range 请求头允许客户端只请求资源的一部分(如 Range: bytes=0-1023 表示请求前 1024 字节),服务器通过 Accept-RangesContent-Range 头响应支持状态和分片数据。

Nginx 默认已支持 Range 请求,但需确保配置正确以避免功能被禁用,尤其针对大文件场景需优化相关参数。

二、核心配置(支持 Range 请求)

1. 基础配置(启用 Range 支持)
server {
    listen 80;
    server_name example.com;
    root /path/to/large-files;  # 存放大资源的目录

    # 关键:确保未禁用 Range 请求(默认启用,无需额外配置,但需避免以下错误)
    # 错误示例:禁用 Range 的配置(生产环境需删除)
    # proxy_set_header Range "";  # 禁止传递 Range 头
    # add_header Accept-Ranges none;  # 告知客户端不支持 Range

    # 大文件传输优化(可选但推荐)
    location / {
        # 支持断点续传和分片请求(默认开启,显式声明更清晰)
        add_header Accept-Ranges bytes;

        # 读取文件的缓冲区大小(根据服务器内存调整)
        client_body_buffer_size 10M;

        # 发送文件的缓冲区大小(优化大文件传输效率)
        sendfile on;               # 启用零拷贝发送文件
        tcp_nopush on;             # 配合 sendfile 提高网络效率
        tcp_nodelay off;           # 减少小包发送,适合大文件

        # 超时设置(避免大文件传输中断)
        client_header_timeout 60s;
        client_body_timeout 60s;
        send_timeout 300s;         # 发送超时延长至 5 分钟
    }
}
2. 核心参数解析
  • Accept-Ranges: bytes
    响应头,明确告知客户端服务器支持字节范围的分片请求(这是支持 Range 的核心标志)。Nginx 默认会自动添加该头,无需显式配置,但显式声明可增强配置可读性。

  • sendfile on
    启用零拷贝(zero-copy)机制,让 Nginx 直接从磁盘读取文件并发送到网络,跳过用户态到内核态的数据拷贝,大幅提升大文件传输效率(对 Range 分片请求尤其重要)。

  • tcp_nopush on
    sendfile 配合使用,在发送文件时先积累一定数据量再一次性发送,减少网络包数量,适合大文件的连续分片传输。

  • proxy_set_header Range $http_range(反向代理场景)
    若大资源存储在后端服务(而非 Nginx 本地),需通过此配置将客户端的 Range 请求头传递给后端,确保后端能正确处理分片请求:

    location /large-files {
        proxy_pass http://backend-server;
        proxy_set_header Range $http_range;          # 传递 Range 头
        proxy_set_header If-Range $http_if_range;    # 传递 If-Range 头(验证资源是否修改)
        proxy_pass_request_headers on;               # 确保所有请求头被传递
    }
    

三、验证 Range 请求是否生效

可通过 curl 命令测试服务器是否支持分片请求:

# 测试请求前 1024 字节
curl -v -H "Range: bytes=0-1023" http://example.com/large-file.mp4

若响应中包含以下头信息,则表示配置生效:

HTTP/1.1 206 Partial Content  # 206 状态码表示部分内容响应
Accept-Ranges: bytes
Content-Range: bytes 0-1023/10485760  # 表示返回 0-1023 字节,总大小 10485760 字节

四、注意事项

  1. 避免禁用 Range 的配置
    确保配置中没有 add_header Accept-Ranges noneproxy_set_header Range "" 等禁用 Range 的指令,这些会导致客户端分片请求失败。

  2. 后端服务配合
    若资源通过反向代理从后端服务获取,需确保后端服务本身支持 Range 请求(如 Node.js、Java 服务需实现对 Range 头的处理),否则 Nginx 无法单独完成分片响应。

  3. 大文件存储优化
    对于超大型文件(如 GB 级视频),建议结合 open_file_cache 配置缓存文件描述符,减少频繁打开文件的开销:

    open_file_cache max=1000 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;
    

总结

Nginx 支持 Range 分片请求的核心是:

  1. 确保默认的 Accept-Ranges: bytes 响应头有效(不被禁用)。
  2. 启用 sendfile 等传输优化参数提升大文件处理效率。
  3. 反向代理场景下需传递 Range 相关请求头给后端服务。

通过以上配置,前端可以实现大资源的断点续传、分片下载,显著提升用户体验。

1142. Nginx 如何按资源类型(如.js/.png)分发到不同服务器?配置策略是什么?【热度: 159】【web 应用场景】【出题公司: 阿里巴巴】

关键词:nginx 转发

Nginx 可以通过 location 指令匹配不同资源类型(如 .js.png),并将请求分发到不同服务器,实现资源的分类部署和负载均衡。这种配置策略适合将静态资源(JS、图片)与动态资源(API)分离部署,提升整体服务性能。

一、核心配置策略:按文件后缀匹配并转发

通过 location 块的正则表达式匹配符(区分大小写)或 ~* 匹配符(不区分大小写),根据文件后缀名匹配不同资源类型,再通过 proxy_pass 转发到对应服务器。

1. 基础配置示例(分离 JS/CSS 与图片资源)
http {
    # 定义后端服务器组(可配置负载均衡)
    # JS/CSS 资源服务器组
    upstream js_css_servers {
        server 192.168.1.101:8080;  # JS/CSS 服务器1
        server 192.168.1.102:8080;  # JS/CSS 服务器2(负载均衡)
    }

    # 图片资源服务器组
    upstream image_servers {
        server 192.168.1.201:8080;  # 图片服务器1
        server 192.168.1.202:8080;  # 图片服务器2(负载均衡)
    }

    # 其他资源(如HTML、API)服务器
    upstream default_server {
        server 192.168.1.301:8080;
    }

    server {
        listen 80;
        server_name example.com;

        # 1. 匹配 .js 和 .css 文件,转发到 JS/CSS 服务器组
        location ~* \.(js|css)$ {
            proxy_pass http://js_css_servers;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            # 静态资源缓存优化(可选)
            expires 1d;  # 缓存 1 天
            add_header Cache-Control "public, max-age=86400";
        }

        # 2. 匹配图片文件(.png/.jpg/.jpeg/.gif/.webp),转发到图片服务器组
        location ~* \.(png|jpg|jpeg|gif|webp)$ {
            proxy_pass http://image_servers;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            # 图片缓存时间更长(可选)
            expires 7d;  # 缓存 7 天
            add_header Cache-Control "public, max-age=604800";
        }

        # 3. 其他所有请求(如 HTML、API)转发到默认服务器
        location / {
            proxy_pass http://default_server;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
    }
}

二、配置策略解析

1. 匹配规则说明
  • ~* \.(js|css)$

    • ~* 表示不区分大小写匹配(如 .JS.Css 也会被匹配)。
    • \.(js|css)$ 是正则表达式,匹配以 .js.css 结尾的请求。
  • 优先级注意
    Nginx 的 location 匹配有优先级,精确匹配(=)> 前缀匹配(不含正则)> 正则匹配(~/~*
    因此,按资源类型的正则匹配会优先于普通前缀匹配(如 /static),需确保规则无冲突。

2. 服务器组(upstream)配置
  • 通过 upstream 定义同类资源的服务器集群,支持负载均衡策略(默认轮询):
    • 可添加 weight=2 调整权重(如 server 192.168.1.101:8080 weight=2;)。
    • 可添加 backup 配置备用服务器(如 server 192.168.1.103:8080 backup;)。
3. 资源优化补充配置
  • 缓存策略:静态资源(JS、图片)通常不频繁变动,通过 expiresCache-Control 头设置浏览器缓存,减少重复请求。
  • 防盗链:图片等资源可添加防盗链配置,防止被其他网站盗用:
    location ~* \.(png|jpg|jpeg|gif|webp)$ {
        # 仅允许 example.com 域名引用图片
        valid_referers none blocked example.com *.example.com;
        if ($invalid_referer) {
            return 403;  # 非法引用返回 403
        }
        # ... 其他配置
    }
    

三、扩展场景:按目录 + 资源类型组合匹配

若资源按目录分类(如 /static/js/static/img),可结合目录和后缀匹配,进一步细化转发规则:

# 仅匹配 /static/js 目录下的 .js 文件
location ~* /static/js/.*\.js$ {
    proxy_pass http://js_servers;
}

# 仅匹配 /static/img 目录下的图片文件
location ~* /static/img/.*\.(png|jpg)$ {
    proxy_pass http://image_servers;
}

四、注意事项

  1. 正则表达式效率
    过多复杂的正则匹配会影响 Nginx 性能,建议资源类型规则尽量简洁(如合并同类后缀)。

  2. 后端资源路径一致性
    确保转发目标服务器的资源路径与请求路径一致。例如,请求 example.com/static/a.js 被转发到 js_css_servers 后,服务器需能在 /static/a.js 路径找到资源。

  3. HTTPS 场景适配
    若使用 HTTPS,配置逻辑不变,只需在 server 块中添加 SSL 证书配置,转发目标可保持 HTTP(内部通信)或 HTTPS(跨公网)。

总结

按资源类型分发的核心策略是:

  1. location ~* \.(后缀1|后缀2)$ 匹配不同资源类型。
  2. 通过 upstream 定义对应资源的服务器集群,支持负载均衡。
  3. 结合缓存、防盗链等配置优化静态资源访问。

这种方案能实现资源的分类部署,减轻单服务器压力,同时针对不同资源类型(如图片、JS)进行专项优化,提升整体服务性能。

1143. Nginx 配置 gzip/brotli 压缩时需考虑哪些因素?为何不建议对所有前端资源开启压缩?【热度: 106】【web 应用场景】【出题公司: 阿里巴巴】

关键词:nginx 配置资源压缩

在 Nginx 中配置 gzip 或 brotli 压缩时,需综合考虑压缩效率、服务器性能开销、客户端兼容性等核心因素;而不建议对所有前端资源开启压缩,本质是避免“无效压缩”(压缩后体积无明显减小)和“反向损耗”(压缩耗时 > 传输耗时)。以下是具体分析:

一、配置 gzip/brotli 需考虑的核心因素

无论是 gzip 还是 brotli(压缩率通常优于 gzip,但需额外模块支持),配置时需围绕“收益最大化、损耗最小化”展开,核心考虑因素如下:

1. 资源类型适配:选择“高压缩收益”的资源

不同资源的压缩潜力差异极大,需优先对文本类资源开启压缩(压缩率高、收益显著),对二进制资源谨慎处理(压缩率低、甚至体积变大)。

资源类型 压缩收益 建议配置 原因
HTML/CSS/JS 极高 强制开启 文本内容重复度高,压缩率可达 60%-80%,传输体积大幅减小。
JSON/XML 极高 强制开启 结构化文本,压缩率与 JS 接近,尤其适合 API 响应数据。
图片(PNG/JPG) 极低 禁止开启 本身已是压缩格式(PNG 无损压缩、JPG 有损压缩),再压缩体积基本不变,反而增加耗时。
视频(MP4/WEBM) 极低 禁止开启 视频编码已做深度压缩,gzip/brotli 无法进一步减小体积,纯浪费资源。
字体(WOFF2) 可选开启 WOFF2 本身已内置压缩(基于 brotli),再压缩收益有限;若使用旧字体格式(WOFF/TTF),可开启。
压缩包(ZIP/RAR) 极低 禁止开启 压缩包本身是压缩格式,二次压缩可能导致体积轻微增大。
2. 压缩级别:平衡“压缩率”与“服务器耗时”

gzip 和 brotli 均支持多级别压缩(级别越高,压缩率越高,但消耗 CPU 资源越多、压缩耗时越长),需根据服务器性能和业务需求选择:

  • gzip 压缩级别gzip_comp_level 1-9):

    • 级别 1-3:轻量压缩,CPU 消耗低,耗时短,适合高并发场景(如秒杀、峰值流量),压缩率约 40%-50%;
    • 级别 4-6:平衡压缩率与性能,默认推荐级别(Nginx 默认是 1,需手动调至 4-6),压缩率约 50%-70%;
    • 级别 7-9:高强度压缩,CPU 消耗高,耗时久,仅适合低并发、对带宽敏感的场景(如静态资源 CDN 后台)。
  • brotli 压缩级别brotli_comp_level 1-11):
    比 gzip 多 2 个级别,压缩率更高(同级别下比 gzip 高 10%-20%),但 CPU 消耗也更高。推荐级别 4-8,避免使用 9-11(耗时显著增加,收益边际递减)。

3. 客户端兼容性:避免“压缩后客户端无法解压”

压缩生效的前提是客户端支持对应压缩算法(通过 HTTP 请求头 Accept-Encoding: gzip, br 告知服务器),需避免对不支持的客户端发送压缩数据:

  • gzip 兼容性:几乎所有现代浏览器(IE6+)、客户端均支持,兼容性无压力。
  • brotli 兼容性:支持 95% 以上现代浏览器(Chrome 49+、Firefox 44+、Edge 15+),但需注意:
    • 仅支持 HTTPS 环境(部分浏览器限制 HTTP 下不使用 brotli);
    • 需 Nginx 额外安装 ngx_brotli 模块(默认不内置,需编译时添加或通过动态模块加载)。

配置时需通过 gzip_disable/brotli_disable 排除不支持的客户端,例如:

# gzip:排除 IE6 及以下不支持的客户端
gzip_disable "MSIE [1-6]\.";

# brotli:仅对支持的客户端生效(依赖 Accept-Encoding 头)
brotli on;
brotli_types text/html text/css application/javascript application/json;
4. 压缩阈值:避免“小文件压缩反而耗时”

极小文件(如 < 1KB 的 CSS/JS 片段)开启压缩,可能出现“压缩耗时 > 传输耗时”的反向损耗——因为压缩需要 CPU 计算,而小文件即使不压缩,传输耗时也极短。
需通过 gzip_min_length/brotli_min_length 设置“压缩阈值”,仅对超过阈值的文件开启压缩(Nginx 默认 gzip_min_length 20,即 20 字节,建议调整为 1KB 以上):

# 仅对 > 1KB 的文件开启压缩(单位:字节)
gzip_min_length 1024;
brotli_min_length 1024;
5. 缓存与预压缩:减少“重复压缩”损耗

Nginx 默认“实时压缩”(每次请求都重新压缩资源),若资源长期不变(如静态 JS/CSS),会导致重复的 CPU 消耗。需通过以下方式优化:

  • 开启压缩缓存:通过 gzip_buffers 配置内存缓存,减少重复压缩(Nginx 默认开启,建议调整缓存块大小适配资源):
    # gzip 缓存:4 个 16KB 块(总 64KB),适配中小型文本资源
    gzip_buffers 4 16k;
    
  • 预压缩静态资源:提前通过工具(如 gzip 命令、brotli 命令)生成压缩后的资源文件(如 app.js.gzapp.js.br),Nginx 直接返回预压缩文件,避免实时压缩:
    # 优先返回预压缩的 .gz 文件(若存在)
    gzip_static on;
    # 优先返回预压缩的 .br 文件(若存在,需 brotli 模块支持)
    brotli_static on;
    
6. 服务器性能:避免“压缩耗尽 CPU 资源”

压缩(尤其是高级别压缩)会消耗 CPU 资源,若服务器 CPU 核心数少(如 1-2 核)或并发量极高(如每秒万级请求),过度压缩可能导致 CPU 使用率飙升,影响其他服务(如动态请求处理)。
需结合服务器配置调整:

  • 低配置服务器(1-2 核):使用 gzip 级别 1-3,关闭 brotli;
  • 中高配置服务器(4 核以上):使用 gzip 级别 4-6 或 brotli 级别 4-8;
  • 可通过 gzip_threads(仅部分 Nginx 版本支持)开启多线程压缩,分摊 CPU 压力:
    # 开启 2 个线程处理 gzip 压缩
    gzip_threads 2;
    

二、为何不建议对所有前端资源开启压缩?

核心原因是“部分资源压缩无收益,反而增加损耗”,具体可归纳为 3 类:

1. 压缩收益为负:体积不变或增大
  • 已压缩的二进制资源(如 PNG/JPG/MP4/ZIP):本身已通过专业算法压缩(如 JPG 的 DCT 变换、MP4 的 H.264 编码),gzip/brotli 无法进一步减小体积,甚至因“压缩头额外开销”导致体积轻微增大(如 10MB 的 MP4 压缩后可能变成 10.01MB)。
2. 性能损耗 > 传输收益
  • 极小文件(如 < 1KB 的 CSS 片段、小图标 base64 字符串):压缩耗时(即使 1ms)可能超过“压缩后减少的传输时间”(假设带宽 100Mbps,1KB 传输时间仅 0.08ms),反而拖慢整体响应速度。
3. 客户端兼容性风险
  • 若对不支持 brotli 的旧客户端(如 IE11)发送 brotli 压缩数据,客户端无法解压,会直接返回“空白页面”或“乱码”;
  • 虽可通过 Accept-Encoding 头判断,但配置不当(如遗漏 brotli_disable)仍可能出现兼容性问题,而“不压缩所有资源”是更稳妥的规避方式。

三、推荐的 gzip + brotli 配置示例

结合上述因素,以下是兼顾“性能、兼容性、收益”的配置(需确保 Nginx 已安装 brotli 模块):

http {
    # -------------------------- gzip 配置 --------------------------
    gzip on;                          # 开启 gzip
    gzip_comp_level 5;                # 平衡级别(压缩率 ~60%,CPU 消耗适中)
    gzip_min_length 1024;             # 仅压缩 >1KB 的文件
    gzip_buffers 4 16k;               # 内存缓存块
    gzip_types
        text/html text/css application/javascript
        application/json application/xml
        text/plain text/javascript;   # 仅对文本类资源压缩
    gzip_disable "MSIE [1-6]\.";      # 排除 IE6 及以下
    gzip_static on;                   # 优先使用预压缩的 .gz 文件
    gzip_vary on;                     # 向客户端返回 Vary: Accept-Encoding 头(利于 CDN 缓存)

    # -------------------------- brotli 配置 --------------------------
    brotli on;                        # 开启 brotli
    brotli_comp_level 6;              # 平衡级别(压缩率 ~70%,比 gzip 高 10%)
    brotli_min_length 1024;           # 同 gzip 阈值
    brotli_types
        text/html text/css application/javascript
        application/json application/xml
        text/plain text/javascript;   # 仅对文本类资源压缩
    brotli_disable "MSIE [1-6]\.|Firefox/[1-43]\.";  # 排除不支持的旧浏览器
    brotli_static on;                 # 优先使用预压缩的 .br 文件
    brotli_vary on;                   # 同 gzip_vary
}

总结

配置 Nginx 压缩的核心逻辑是“针对性优化”:

  1. 只对“文本类资源”(HTML/CSS/JS/JSON)开启压缩,对“二进制资源”(图片/视频/压缩包)坚决关闭;
  2. 选择“平衡的压缩级别”,避免过度消耗 CPU;
  3. 通过“预压缩”和“缓存”减少实时压缩损耗;
  4. 兼容旧客户端,避免因压缩导致的访问异常。

这种策略既能最大化压缩带来的“带宽节省、加载加速”收益,又能最小化服务器性能损耗和兼容性风险。

1145. 微前端通过 Nginx 实现主 / 子应用路由分发,如何配置 location 和 try_files?需处理哪些资源路径问题?【热度: 120】【web 应用场景】

微前端通过 Nginx 实现主/子应用路由分发时,核心是通过 location 匹配不同应用的路由路径,并结合 try_files 处理 SPA 路由刷新 404 问题。同时需解决子应用资源路径、主/子应用路由冲突等关键问题。以下是具体实现方案:

一、基础场景:主应用与子应用通过路径前缀区分

假设:

  • 主应用路由:https://example.com/(根路径)
  • 子应用 A 路由:https://example.com/app1/(前缀 /app1
  • 子应用 B 路由:https://example.com/app2/(前缀 /app2
1. 目录结构(前端资源存放)
/var/www/
├── main-app/          # 主应用打包文件
│   ├── index.html
│   ├── static/
│   └── ...
├── app1/              # 子应用 A 打包文件
│   ├── index.html
│   ├── static/
│   └── ...
└── app2/              # 子应用 B 打包文件
    ├── index.html
    └── ...
2. Nginx 核心配置(location + try_files)
server {
    listen 80;
    server_name example.com;
    root /var/www;  # 父目录(包含所有应用)

    # 1. 主应用路由(根路径 /)
    location / {
        # 主应用实际目录为 /var/www/main-app
        alias /var/www/main-app/;
        index index.html;

        # 解决主应用 History 路由刷新 404
        # 逻辑:优先匹配物理文件,匹配不到则返回主应用 index.html
        try_files $uri $uri/ /main-app/index.html;
    }

    # 2. 子应用 A 路由(/app1 前缀)
    location /app1 {
        # 子应用 A 实际目录为 /var/www/app1
        alias /var/www/app1/;
        index index.html;

        # 解决子应用 A History 路由刷新 404
        # 注意:try_files 最后需指向子应用自己的 index.html
        try_files $uri $uri/ /app1/index.html;
    }

    # 3. 子应用 B 路由(/app2 前缀)
    location /app2 {
        alias /var/www/app2/;
        index index.html;
        try_files $uri $uri/ /app2/index.html;
    }
}

二、关键配置解析

1. aliasroot 的选择
  • 必须使用 alias:子应用路径(如 /app1)与实际目录(/var/www/app1)是“映射关系”,alias 会将 /app1 直接替换为实际目录(如请求 /app1/static.js 映射到 /var/www/app1/static.js)。
  • 若误用 rootroot /var/www 会在请求路径后拼接目录(/app1/static.js 会映射到 /var/www/app1/static.js,看似可行,但子应用内路由跳转可能出现异常)。
2. try_files 的路径规则
  • 主应用:try_files $uri $uri/ /main-app/index.html
    最后一个参数必须是主应用 index.html绝对路径(相对于 Nginx 根目录),确保主应用路由(如 /home)刷新时返回主应用入口。
  • 子应用:try_files $uri $uri/ /app1/index.html
    最后一个参数必须是子应用自己的 index.html(如 /app1/index.html),否则子应用路由(如 /app1/detail)刷新会返回主应用入口,导致路由错乱。

三、需处理的资源路径问题

微前端路由分发的核心坑点是资源路径引用错误,需从 Nginx 配置和前端打包两方面协同解决:

1. 子应用静态资源路径错误(404)

问题:子应用打包时若使用绝对路径(如 src="/static/js/app1.js"),会被解析为 https://example.com/static/js/app1.js,但实际路径应为 https://example.com/app1/static/js/app1.js,导致 404。

解决方案

  • 前端打包配置:子应用需设置 publicPath 为自身路径前缀(如 /app1/):
    • Vue 项目:vue.config.jspublicPath: '/app1/'
    • React 项目:package.jsonhomepage: '/app1'webpack.config.jsoutput.publicPath: '/app1/'
  • 效果:资源引用会自动添加 /app1 前缀(如 src="/app1/static/js/app1.js"),匹配 Nginx 配置的 alias 路径。
2. 主/子应用路由冲突

问题:若主应用存在 /app1 路由,会与子应用的 /app1 路径冲突,导致主应用路由被 Nginx 拦截并转发到子应用。

解决方案

  • 路由命名规范:子应用路径前缀需全局唯一(如 /micro-app1/micro-app2),避免与主应用路由重名。

  • Nginx 优先级控制:若必须使用相同前缀,可通过 location 精确匹配优先处理主应用路由:

    # 主应用的 /app1 路由(精确匹配,优先级高于子应用的 /app1 前缀匹配)
    location = /app1 {
        alias /var/www/main-app/;
        try_files $uri $uri/ /main-app/index.html;
    }
    
    # 子应用 /app1 前缀路由(优先级低)
    location /app1/ {
        alias /var/www/app1/;
        try_files $uri $uri/ /app1/index.html;
    }
    
3. 子应用接口请求路径错误

问题:子应用接口请求(如 /api/data)会被发送到 https://example.com/api/data,若需区分子应用接口(如 https://example.com/app1/api/data),需调整代理规则。

解决方案

  • 前端统一前缀:子应用接口请求添加自身路径前缀(如 axios.defaults.baseURL = '/app1/api')。
  • Nginx 代理转发
    # 子应用 A 的接口代理
    location /app1/api {
        # 移除 /app1 前缀后转发到后端(如后端接口实际路径为 /api)
        proxy_pass http://backend-server/api;
        proxy_set_header Host $host;
    }
    
4. 子应用懒加载路由资源 404

问题:子应用使用路由懒加载时(如 Vue/React 的 import('./page.vue')),打包后的 chunk 文件路径可能未包含子应用前缀,导致加载失败。

解决方案

  • 确保懒加载的 chunk 路径也使用 publicPath 配置的前缀,现代打包工具(Webpack 5+、Vite)会自动处理,只需正确设置 publicPath 即可。

四、复杂场景:子应用嵌套或动态路径

若子应用包含动态路由(如 /app1/user/:id)或嵌套路由(如 /app1/dashboard/settings),Nginx 配置无需额外调整,因为 location /app1 会匹配所有以 /app1 开头的路径,try_files 会统一指向子应用 index.html,路由解析由子应用前端框架完成。

五、总结

微前端 Nginx 路由分发的核心配置要点:

  1. location /app1 匹配子应用路径,alias 指向实际目录。
  2. try_files $uri $uri/ /app1/index.html 解决子应用 History 路由刷新 404。
  3. 必须处理的资源问题:
    • 子应用打包设置 publicPath 为路径前缀(如 /app1/)。
    • 避免主/子应用路由重名,接口请求添加子应用前缀。

通过以上配置,可实现主/子应用路由的无冲突分发,确保静态资源和路由正常访问。

1146. Nginx 配置 HTTPS 需哪些核心项(协议、加密套件)?如何强制 HTTP 跳转 HTTPS?【热度: 182】【web 应用场景】

关键词:nginx 配置 https

Nginx 配置 HTTPS 需重点关注协议版本、加密套件、证书配置等核心项,同时需正确设置 HTTP 到 HTTPS 的强制跳转。以下是详细配置方案:

一、HTTPS 核心配置项(协议、加密套件等)

HTTPS 配置的核心目标是确保安全性(禁用不安全协议和加密套件)和兼容性(支持主流浏览器),关键配置项缺一不可:

1. 证书与私钥配置(必备)

需指定 SSL 证书(公钥)和私钥文件路径,证书需由可信 CA 机构签发(如 Let's Encrypt、阿里云等):

server {
    listen 443 ssl;  # 监听 443 端口并启用 SSL
    server_name example.com;  # 证书绑定的域名

    # 证书文件路径(PEM 格式)
    ssl_certificate /path/to/fullchain.pem;  # 包含服务器证书和中间证书
    ssl_certificate_key /path/to/privkey.pem;  # 服务器私钥
}
2. 协议版本(禁用不安全协议)

需明确启用现代安全协议,禁用已被破解或不安全的旧协议(如 SSLv2、SSLv3、TLSv1.0、TLSv1.1):

# 仅启用 TLSv1.2 和 TLSv1.3(目前最安全的协议版本)
ssl_protocols TLSv1.2 TLSv1.3;
  • 为何禁用旧协议
    TLSv1.0/1.1 存在安全漏洞(如 BEAST 攻击),且不支持现代加密套件;SSL 协议已完全过时,必须禁用。
3. 加密套件(优先选择强加密算法)

加密套件决定数据传输的加密方式,需优先选择支持前向 secrecy(完美前向保密)AES-GCM 等强加密算法的套件:

# 现代浏览器兼容的强加密套件(TLSv1.2+)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;

# 优先使用服务器端的加密套件选择(增强安全性)
ssl_prefer_server_ciphers on;
  • 核心原则
    避免使用 RSA 密钥交换(无 Forward Secrecy)和 CBC 模式加密(存在漏洞),优先 ECDHE 密钥交换 + GCM 模式。
4. 性能与安全性优化项
# SSL 会话缓存(减少握手耗时,提升性能)
ssl_session_cache shared:SSL:10m;  # 共享缓存,容量 10MB(约 40000 个会话)
ssl_session_timeout 1d;  # 会话超时时间(1天)

# 启用 HSTS(强制客户端后续使用 HTTPS 访问,防降级攻击)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# 禁用 SSL 压缩(防止 CRIME 攻击)
ssl_compression off;

# 启用 OCSP Stapling(减少证书验证步骤,提升加载速度)
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /path/to/fullchain.pem;  # 与 ssl_certificate 一致即可
resolver 8.8.8.8 114.114.114.114 valid=300s;  # DNS 解析器(用于验证 OCSP 响应)

二、强制 HTTP 跳转 HTTPS 的配置方法

需将所有 HTTP(80 端口)请求强制重定向到 HTTPS(443 端口),确保用户始终使用加密连接。推荐两种可靠方案:

1. 方案一:通过 301 永久重定向(推荐)

在 80 端口的 server 块中直接返回 301 重定向,适用于大多数场景:

# HTTP 服务器(80端口):仅用于跳转 HTTPS
server {
    listen 80;
    server_name example.com;  # 需与 HTTPS 服务器的域名一致

    # 永久重定向到 HTTPS
    return 301 https://$host$request_uri;
}
  • 优势:简单高效,搜索引擎会记住重定向,将权重转移到 HTTPS 域名。
2. 方案二:通过 rewrite 指令(灵活适配复杂场景)

若需对特定路径做特殊处理(如临时不跳转某些路径),可使用 rewrite

server {
    listen 80;
    server_name example.com;

    # 对 /api/temp 路径临时不跳转(示例)
    location /api/temp {
        # 保持 HTTP 访问(仅临时使用,不推荐长期保留)
        proxy_pass http://backend;
    }

    # 其他所有路径跳转 HTTPS
    location / {
        rewrite ^(.*)$ https://$host$1 permanent;  # permanent 等价于 301
    }
}

三、完整 HTTPS 配置示例

# HTTP 服务器:强制跳转 HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

# HTTPS 服务器:核心配置
server {
    listen 443 ssl;
    server_name example.com;

    # 证书配置
    ssl_certificate /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;

    # 协议与加密套件
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers on;

    # 性能与安全优化
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_compression off;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # OCSP Stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /path/to/fullchain.pem;
    resolver 8.8.8.8 114.114.114.114 valid=300s;

    # 前端资源配置(如 SPA 路由、缓存等)
    root /path/to/frontend;
    index index.html;
    location / {
        try_files $uri $uri/ /index.html;
    }
}

四、关键注意事项

  1. 证书路径正确性
    确保 ssl_certificatessl_certificate_key 指向的文件存在且权限正确(Nginx 进程需可读,建议权限 600)。

  2. HSTS 配置风险
    Strict-Transport-Security 头一旦设置,客户端会严格遵守(即使后续关闭 HTTPS 也会强制使用),需确保 HTTPS 服务长期稳定后再添加 preload 选项。

  3. 兼容性平衡
    若需支持非常旧的浏览器(如 IE 10),可临时启用 TLSv1.1,但需知晓安全风险;现代网站建议仅保留 TLSv1.2+。

  4. 配置验证
    修改配置后需执行 nginx -t 检查语法,通过 nginx -s reload 生效;可使用 SSL Labs 工具 检测配置安全性(目标评分 A+)。

总结

HTTPS 核心配置包括:

  • 证书与私钥(基础)、TLSv1.2+ 协议(安全)、强加密套件(防破解);
  • 强制跳转通过 301 重定向实现,确保所有 HTTP 请求转向 HTTPS。

合理配置可在安全性、兼容性和性能之间取得平衡,是现代网站的必备实践。

1147. 前端静态资源加载超时,Nginx 可通过哪些配置优化?【热度: 190】【web 应用场景】【出题公司: 阿里巴巴】

关键词:nginx 加载超时优化

前端静态资源(如 JS、CSS、图片、视频等)加载超时,通常与网络传输效率服务器响应速度资源处理策略相关。Nginx 可通过针对性配置优化传输效率、延长超时阈值、减少阻塞风险,从而解决超时问题。以下是具体优化方案:

一、延长关键超时时间(避免传输中断)

针对大资源(如视频、大型 JS 包)或弱网络环境,默认超时时间可能不足,需调整以下参数:

server {
    # 1. 客户端与服务器建立连接的超时(握手阶段)
    client_header_timeout 120s;  # 等待客户端发送请求头的超时(默认 60s,延长至 2 分钟)
    client_body_timeout 120s;    # 等待客户端发送请求体的超时(默认 60s)

    # 2. 服务器向客户端发送响应的超时(传输阶段,核心!)
    send_timeout 300s;  # 大文件传输时,服务器发送数据的超时(默认 60s,延长至 5 分钟)

    # 3. 长连接保持时间(复用连接,减少重复握手开销)
    keepalive_timeout 120s;  # 连接空闲后保持的时间(默认 75s,延长至 2 分钟)
    keepalive_requests 200;  # 单个长连接可处理的请求数(默认 100,提高至 200)
}

关键逻辑send_timeout 是防止大资源传输中断的核心参数(如 100MB 的视频文件,弱网环境可能需要几分钟传输),需根据资源最大体积和目标用户网络环境调整。

二、优化资源传输效率(减少传输耗时)

通过零拷贝数据合并压缩等技术,减少资源在服务器与客户端之间的传输时间:

1. 启用零拷贝与 TCP 优化
location ~* \.(js|css|png|jpg|jpeg|webp|mp4)$ {
    # 零拷贝:直接从磁盘读取文件发送到网络,跳过用户态-内核态数据拷贝(核心优化!)
    sendfile on;

    # 配合 sendfile 使用,积累数据后一次性发送,减少网络包数量(提升大文件传输效率)
    tcp_nopush on;

    # 禁用 Nagle 算法(减少小数据包延迟,适合动态内容,但大文件建议关闭)
    tcp_nodelay off;
}
2. 启用压缩(减小传输体积)

对文本类资源(JS/CSS/HTML)启用 gzip 或 brotli 压缩,对图片等二进制资源确保已预压缩(如 WebP 格式):

# 全局压缩配置
gzip on;
gzip_comp_level 5;  # 压缩级别 1-9(5 为平衡值)
gzip_min_length 1024;  # 仅压缩 >1KB 的文件(小文件压缩收益低)
gzip_types
    text/html text/css application/javascript
    application/json image/svg+xml;  # 仅压缩文本类资源

# 若 Nginx 安装了 brotli 模块(压缩率高于 gzip)
brotli on;
brotli_comp_level 6;
brotli_types text/css application/javascript;
3. 预压缩静态资源(避免实时压缩耗时)

提前对静态资源进行压缩(如 app.jsapp.js.gz),Nginx 直接返回预压缩文件,减少实时压缩的 CPU 消耗和延迟:

location ~* \.(js|css)$ {
    gzip_static on;  # 优先返回 .gz 预压缩文件(需手动生成或通过打包工具生成)
    brotli_static on;  # 优先返回 .br 预压缩文件
}

三、优化文件读取效率(减少服务器内部延迟)

静态资源加载超时可能是服务器磁盘 I/O 慢文件打开频繁导致,可通过缓存文件描述符优化:

# 缓存打开的文件描述符(减少重复打开文件的磁盘 I/O 耗时)
open_file_cache max=10000 inactive=30s;  # 最多缓存 10000 个文件,30s 未访问则移除
open_file_cache_valid 60s;  # 每 60s 验证一次缓存有效性
open_file_cache_min_uses 2;  # 文件被访问至少 2 次才加入缓存
open_file_cache_errors on;  # 缓存"文件不存在"的错误(避免重复检查)

效果:频繁访问的静态资源(如首页 JS/CSS)会被缓存描述符,后续请求无需再次读取磁盘,响应速度提升 50%+。

四、限制并发与请求大小(避免服务器过载)

服务器资源耗尽(CPU/内存/磁盘 I/O 满)会导致响应延迟,需通过配置限制并发压力:

1. 限制单个请求体大小

防止超大文件请求阻塞服务器(如恶意上传 1GB 无效文件):

# 全局限制:单个请求体最大 100MB(根据业务调整,如图片站可设 50MB)
client_max_body_size 100m;

# 针对视频等超大资源单独限制
location /videos {
    client_max_body_size 500m;  # 视频文件最大 500MB
}
2. 调整 worker 进程与连接数

充分利用服务器 CPU 资源,提升并发处理能力:

# 在 nginx.conf 全局配置中
worker_processes auto;  # 自动设置为 CPU 核心数(如 4 核服务器则启动 4 个进程)
worker_connections 10240;  # 每个 worker 最大连接数(默认 1024,提高至 10240)
multi_accept on;  # 允许每个 worker 同时接受多个新连接

五、CDN 与资源分片配合(彻底解决跨地域超时)

若用户分布在不同地域,仅靠源站优化效果有限,需结合:

  1. 静态资源托管到 CDN
    将 JS/CSS/图片等静态资源上传至 CDN(如 Cloudflare、阿里云 CDN),CDN 节点就近分发,减少跨地域传输延迟。
    Nginx 需配置允许 CDN 缓存:

    location ~* \.(js|css|png)$ {
        add_header Cache-Control "public, max-age=31536000";  # 允许 CDN 长期缓存
        add_header Access-Control-Allow-Origin *;  # 解决 CDN 跨域问题
    }
    
  2. 大文件分片传输
    对视频等超大型文件(>100MB),前端通过 Range 请求分片下载(如每次请求 10MB),Nginx 需支持 Range 分片(默认支持,无需额外配置):

    location /videos {
        add_header Accept-Ranges bytes;  # 显式声明支持分片(默认已开启)
    }
    

六、完整优化配置示例

# nginx.conf 全局配置
worker_processes auto;
worker_connections 10240;
multi_accept on;

http {
    # 压缩配置
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_types text/html text/css application/javascript;
    gzip_static on;

    # 文件描述符缓存
    open_file_cache max=10000 inactive=30s;
    open_file_cache_valid 60s;
    open_file_cache_min_uses 2;

    server {
        listen 80;
        server_name example.com;

        # 超时配置
        client_header_timeout 120s;
        client_body_timeout 120s;
        send_timeout 300s;
        keepalive_timeout 120s;
        keepalive_requests 200;

        # 请求体大小限制
        client_max_body_size 100m;

        # 静态资源优化
        location ~* \.(js|css|png|jpg|jpeg|webp|mp4)$ {
            root /path/to/frontend;
            sendfile on;
            tcp_nopush on;
            tcp_nodelay off;
            expires 30d;  # 浏览器缓存,减少重复请求
        }

        # 视频等大资源单独配置
        location /videos {
            client_max_body_size 500m;
            add_header Accept-Ranges bytes;
        }
    }
}

总结

Nginx 优化静态资源加载超时的核心思路是:

  1. 延长传输超时send_timeout),适应大资源和弱网络;
  2. 提升传输效率(零拷贝、压缩、预压缩),减少传输时间;
  3. 优化服务器性能(文件缓存、并发调整),减少内部延迟;
  4. 结合 CDN 与分片,解决跨地域传输问题。

通过多层优化,可显著降低静态资源加载超时概率,提升前端页面加载体验。

1148. Nginx 如何为不同前端资源配置缓存策略?如何强制刷新特定资源?【热度: 110】【web 应用场景】

关键词:nginx 加载特定资源

Nginx 为不同前端资源配置缓存策略的核心是根据资源特性(是否常变、是否带版本标识)差异化设置缓存规则,同时通过特定机制实现特定资源的强制刷新。以下是详细方案:

一、按资源类型配置差异化缓存策略

前端资源可分为静态资源(JS、CSS、图片等)和入口文件(如 index.html),需根据其更新频率和版本管理方式设置不同缓存策略:

1. 带哈希/版本号的静态资源(永久强缓存)

特征:文件名含唯一哈希(如 app.8f3b.js)或版本号(如 v2/style.css),内容变化时文件名必变。
策略:设置长期强缓存,减少重复请求。

# 匹配带哈希的 JS/CSS/图片(假设哈希为 8-16 位字符)
location ~* \.\w{8,16}\.(js|css|png|jpg|jpeg|webp|svg)$ {
    # 缓存 1 年(31536000 秒)
    expires 365d;
    # 强缓存标识:浏览器直接使用本地缓存,不发送请求
    add_header Cache-Control "public, max-age=31536000, immutable";
}
  • 关键参数immutable(H5 新特性)告知浏览器资源不会变化,避免发送无效的条件请求(如 If-Modified-Since)。
2. 无哈希的静态资源(短期强缓存 + 协商缓存)

特征:文件名固定(如 favicon.icocommon.js),可能不定期更新但无版本标识。
策略:短期强缓存减少请求,过期后通过协商缓存验证是否更新。

# 匹配无哈希的图片、字体等
location ~* \.(png|jpg|jpeg|ico|woff2?)$ {
    # 短期强缓存 7 天
    expires 7d;
    # 过期后必须验证是否更新
    add_header Cache-Control "public, max-age=604800, must-revalidate";
}
3. 入口文件与动态页面(协商缓存)

特征:如 index.htmlpage.html,作为路由入口或动态内容载体,需确保用户获取最新版本。
策略:禁用强缓存,每次请求通过协商缓存验证。

# 入口文件(如 index.html)
location = /index.html {
    # 禁用强缓存(立即过期)
    expires -1;
    # 协商缓存:必须向服务器验证
    add_header Cache-Control "no-cache, must-revalidate";
}

# 其他 HTML 页面
location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
}
  • 协商缓存原理:Nginx 自动返回 Last-Modified(文件修改时间),浏览器下次请求携带 If-Modified-Since,服务器比对后返回 304(未修改)或 200(新内容)。
4. API 接口与动态数据(无缓存或短时缓存)

特征:如 /api/user,返回动态数据,需实时性。
策略:禁用缓存或设置极短缓存时间。

# API 接口
location /api {
    # 完全禁用缓存
    add_header Cache-Control "no-store, no-cache, must-revalidate";
    expires -1;
    # 转发到后端服务
    proxy_pass http://backend;
}

二、强制刷新特定资源的方法

当资源更新但因缓存未生效时,需强制用户获取最新版本,核心思路是破坏缓存标识主动清理缓存

1. 前端主动更新资源标识(推荐)

利用“哈希/版本号与内容绑定”的特性,资源更新时修改文件名,浏览器会视为新资源自动请求:

  • 例:app.8f3b.js → 更新后变为 app.9c4d.js,无需 Nginx 配置,彻底避免缓存问题。
2. 通过 URL 参数强制刷新(临时方案)

对无哈希的资源,可在请求 URL 后添加随机参数(如 ?v=2),使浏览器认为是新资源:

  • 例:common.jscommon.js?v=2
  • Nginx 无需额外配置,但需前端手动更新参数,适合临时紧急更新。
3. 清理 CDN 缓存(若使用 CDN)

若资源通过 CDN 分发,需在 CDN 控制台手动清理特定资源缓存:

  • 例:阿里云 CDN 支持按路径(如 /*/*.js)或具体 URL 清理缓存,生效后用户请求会回源获取最新资源。
4. 动态修改资源的 Last-Modified(不推荐)

通过 Nginx 指令强制修改资源的 Last-Modified 头,触发协商缓存更新:

# 强制刷新某个资源(如 common.js)
location = /static/js/common.js {
    # 手动设置一个较新的修改时间(比实际文件新)
    add_header Last-Modified "Wed, 20 Sep 2025 08:00:00 GMT";
    # 协商缓存配置
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
}
  • 缺点:需手动修改 Nginx 配置并 reload,仅适合紧急情况,不建议长期使用。

三、完整缓存配置示例

server {
    listen 80;
    server_name example.com;
    root /path/to/frontend;

    # 1. 带哈希的静态资源(永久缓存)
    location ~* \.\w{8,16}\.(js|css|png|jpg|jpeg|webp|svg)$ {
        expires 365d;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # 2. 无哈希的静态资源(短期+协商)
    location ~* \.(png|jpg|jpeg|ico|woff2?)$ {
        expires 7d;
        add_header Cache-Control "public, max-age=604800, must-revalidate";
    }

    # 3. 入口文件与 HTML(协商缓存)
    location = /index.html {
        expires -1;
        add_header Cache-Control "no-cache, must-revalidate";
    }

    # 4. API 接口(无缓存)
    location /api {
        add_header Cache-Control "no-store, no-cache";
        expires -1;
        proxy_pass http://backend;
    }

    # SPA 路由支持(配合 History 模式)
    location / {
        try_files $uri $uri/ /index.html;
    }
}

四、关键注意事项

  1. 缓存与版本管理协同:前端打包工具(Webpack/Vite)需确保“内容变则哈希变”,与 Nginx 强缓存配合,这是最可靠的刷新方式。
  2. 避免缓存 index.html:入口文件必须用协商缓存,否则用户可能无法获取新的哈希资源列表。
  3. HTTPS 环境下的缓存:若启用 HTTPS,需确保 Cache-Control 头正确传递(Nginx 默认不拦截),避免 CDN 或代理服务器篡改缓存策略。

总结

  • 差异化缓存:带哈希资源用永久强缓存,无哈希资源用短期+协商缓存,入口文件和 API 禁用强缓存。
  • 强制刷新:优先通过修改资源哈希/版本号实现,临时场景可用 URL 参数,CDN 资源需手动清理 CDN 缓存。

这种策略既能最大化利用缓存提升性能,又能确保资源更新及时生效。

用Vue3写了一款协同文档编辑器,效果简直牛!

hi, 大家好, 我是徐小夕.

之前和大家分享了我实现的 pxcharts 多维表格技术方案:

半年时间,写了一款多维表格编辑器pxcharts

pxcharts-pro, 支持百万数据渲染的多维表格编辑器

最近研究了在线协同功能,花了大概半年时间,研究文档引擎和协同算法,最近终于实现了一个毫秒级文档协同编辑器,媲美企业级协同应用,接下来就和大家分享一下这款文档编辑器。

image.png

演示地址:px-doc 协同文档编辑器

先来聊聊协同算法的实现方案

image.png

上面是我整理的一个协同方案实现的核心逻辑层,目前我采用CRDT的方案来实现,这里就简单和大家聊聊CRDT算法的作用:

  • 分布式冲突消解:每个客户端通过本地 CRDT 引擎独立处理操作,无需依赖服务端 “中心化仲裁”,并发操作通过 “唯一操作 ID” 和 “版本向量” 自动合并(如 A 插入文本、B 同时删除相邻文本,CRDT 按时序和操作属性判断优先级,避免文档错乱)。
  • 离线编辑支持:客户端断网时,CRDT 引擎缓存本地操作日志;重连后自动同步离线操作到服务端,服务端广播给其他客户端,最终所有终端收敛到一致状态,无需用户手动合并。
  • 低延迟编辑体验:操作生成后先更新本地 UI,再异步同步到其他端,用户无 “等待锁释放” 的阻塞感,编辑流畅性接近本地文档。

(当然协同方案也可以采用OT算法架构来实现)接下来我们再来聊聊我做的协同文档编辑器。

px-doc协同文档编辑器介绍

我在协同文档编辑器中实现了非常复杂的功能,比如可视化图表,音视频组件,移动端适配,版本管理等:

图片

由于国内企业用Vue的比较多,所以文档编辑器我采用了Vue3的实现方案,当然React也能实现同样的协同能力。

1. 技术实现

  • 前端框架 Vue 3(组合式 API)
    • 选择原因:生态成熟、类型友好、与 Vite 深度集成、上手与维护成本低。
  • 构建与开发 Vite 5、UnoCSS、Sass、ESLint + Prettier
    • 选择原因:Vite 冷启动快、按需插件丰富;UnoCSS 原子化样式灵活;Sass 提升样式工程化;统一的 Lint/Format 保证一致性。
  • 路由与状态 Vue Router 4、Pinia
    • 选择原因:与 Vue 3 官方生态一致,路由/状态管理简单直观、可维护性高。
  • 协同内核 CRDT算法
    • 选择原因:CRDT 协同模型成熟、离线同步稳定;轻量易部署,具备断线重连能力。
  • UI 与交互 Arco Design Vue、Tippy、@floating-ui
    • 选择原因:企业级组件库与工具链完善,易于定制;浮层定位交互稳定。
  • 数据请求 Axios(封装于 @/utils/req
    • 选择原因:请求拦截、错误处理统一;易扩展 Token、语言包等。
  • 国际化与工具 vue-i18n、@vueuse/core、lodash、uuid
    • 选择原因:提升 DX 与功能实现效率。
  • 可选可视化 ECharts(图表扩展)
    • 选择原因:在编辑器内插入图表或渲染统计内容。
  • 后端(外部服务) 文件持久化(JSON)、分布式协同服务
    • 选择原因:以文件存储快速落地,便于后续平滑迁移至数据库;协同通道与编辑态解耦。

2. 架构设计

系统采用“前后端分离 +CRDT 实时协同 + 文件持久化”的轻量架构:

  • 前端应用(SPA)
    • 入口:src/main.js → 挂载 App.vue,引入路由、Pinia、样式。
    • 路由守卫:src/router/index.js,基于 localStorage.uid 的简易登录态;未登录跳转 login
    • 编辑器页:src/views/doc-page/doc-page.vue(Notion 风格),集成协同逻辑、版本面板。
    • 版本面板:src/components/VersionManager.vue,支持版本列表、预览、恢复、删除与信息编辑。
  • 协同层
    • CRDT协同机制作为单一事实源(Single Source of Truth),通过 websocket 与服务器保持实时同步。
  • 后端服务(外部)
    • 文档列表:server/db/document/documents.json
    • 版本记录:server/db/version/<docId>.json
    • 文档/版本元数据使用文件系统持久化:
    • 协同内容建议启用 .crdt 文件持久化(CRDT 序列化),提升容灾与重启恢复能力。
  • 静态资源与构建
    • 通过 Vite 构建,产物输出至 px-editor/vite.config.js 中 build.outDir)。
    • 生产环境通过 BASE_API_URL/BASE_WS_URL 切换接口与协同服务地址。

3. 功能亮点与场景

image.png

接下来就我实现的文档协同编辑器,总结以下几点优势:

  • 多人实时协同编辑(CRDT协同算法)
    • 支持光标与选区显示、低延迟同步、离线/重连状态的自动合并。
    • 适用于团队文档、产品需求、方案评审等实时协作场景。
  • 版本管理全流程(列表/创建/删除/预览/恢复/自动保存)
    • 评审前生成“稳定版本”,评审后可快速回滚。
    • 线上服务重启后,客户端自动恢复到最后可用内容。
    • 版本列表分页展示,包含作者、时间、大小、类型(自动/手动)。
    • 只读 Doc 实例渲染版本内容,预览接近真实排版。
    • 自动恢复:协同连接完成且文档为空时,自动加载“最近版本”。
    • 使用案例:
  • 文件解析与导入(进行中)
    • 提供 /parse/doc2html2 与 /parse/pdf2html 的上传/分页获取接口。
    • 适用于将历史文档、合同、报告转入协同编辑流程。
  • 可插拔扩展体系(Doc Extensions + Vue 组件)
    • 富文本、表格、任务清单、图表、代码高亮等扩展可按需启用。
    • 支持 Notion 风格工具栏/菜单、Bubble Menu、Slash Menu。

4. 二次开发指南

整个编辑器我采用模块化的开发方式,我们可以轻松采用组件化的方式集成到系统中。

4.1 代码结构总览

src/
  api/# 文档/版本/文件解析等接口封装
  components/# 版本面板等复用组件
  packages/# 编辑器核心与扩展(core/vue3)
  router/# 路由与守卫
  stores/# Pinia 状态(示例)
  styles/# 全局样式(Sass + UnoCSS)
  utils/# 请求封装、工具函数
  views/# 页面(login / doc-page)

关键文件:

  • src/main.js应用入口,注册 Pinia、Router。
  • src/router/index.js路由表与鉴权守卫。
  • src/api/document.tssrc/api/version.tssrc/api/file.ts:HTTP API 封装。
  • src/packages/vue3/editor.jssrc/packages/core/*:编辑器与扩展能力。
  • src/components/VersionManager.vue版本列表、预览与恢复。

4.2 开发环境搭建

  1. 安装依赖
pnpm install

2. 启动前端(默认 http://localhost:9999)

pnpm dev

3. 启动服务端(示例,按你的实际脚本为准)

npm start
# 或 Windows: npm run start:win

演示地址:px-doc 协同文档编辑器

后续我会持续迭代协同文档编辑器,并持续优化性能和功能,大家有好的想法也欢迎随时交流反馈.

完美圆角,渐变边框,兼容chrome 60,两层背景的视觉差

image.png

不直接使用 border 属性,因为它的渐变和圆角在旧浏览器上表现不佳。我们反过来,用“两层背景的视觉差”来模拟出边框。

  1. 外层容器 (.card) —— 充当“边框”

    • 设置渐变背景:我们给这个最外层的 div 设置了您想要的从上到下的渐变背景。这个渐变就是我们最终看到的“边框”颜色。
    • 定义边框宽度:通过设置 padding: 1px;,我们让这个渐变背景层有了 1px 的厚度。这个 padding 的大小,就是“边框”的宽度。
    • 设置外圆角:给它设置最终想要的 border-radius: 6px;
  2. 内层容器 (.card-content) —— 充当“内容区域”

    • 设置内容背景:这个 div 位于外层容器的 padding 内部,我们给它设置了卡片内容的实际背景色(半透明的红色)。
    • 设置内圆角:它的圆角比外层容器小 1px(即 border-radius: 5px;),这样就能严丝合缝地贴在外层容器的内侧。

最终效果:

当这个内层的 .card-content 覆盖住外层的 .card 的中心区域后,.card 的渐变背景就只有 padding 定义的那 1px 边缘能够被看见。

这样一来,视觉上就形成了一个拥有完美平滑圆角、颜色从上到下渐变的边框,并且这个方法只用了最基础的 CSS 属性,可以很好地兼容到您要求的 Chrome 60 等旧版浏览器。

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>兼容性渐变圆角边框</title>
<style>
    body {
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100vh;
        margin: 0;
        background: #333;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    }

    .card {
        width: 420px;
        box-sizing: border-box;
        text-align: center;
        color: white;
        
        /* 关键点 1: 渐变背景作为“边框”,从上到下渐变并融入背景 */
        background: linear-gradient(to bottom, #E09393, #c1292e);
        
        border-radius: 6px; /* 外层容器圆角 */
        padding: 1px; /* 边框的宽度 */
        
        /* 确保背景从 padding 区域开始绘制,增强兼容性 */
        -webkit-background-clip: padding-box;
        background-clip: padding-box;
    }

    .card-content {
        padding: 40px 30px;
        /* 关键点 2: 内部内容的背景 */
        background: rgba(193, 41, 46, 0.8);
        border-radius: 5px; /* 内层圆角比外层小 1px,完美贴合 */
        backdrop-filter: blur(10px); /* 毛玻璃效果,现代浏览器支持 */
        -webkit-backdrop-filter: blur(10px);
    }
    
    .card-title {
        font-size: 24px;
        font-weight: bold;
        color: #ffd700; /* 金色标题 */
        margin: 0 0 15px 0;
    }

    .card-text {
        font-size: 16px;
        line-height: 1.6;
        opacity: 0.9;
        margin: 0 0 30px 0;
    }

    .perfect-button {
        display: inline-block;
        padding: 12px 28px;
        border: none;
        border-radius: 6px; /* 与卡片一致的圆角 */
        font-size: 16px;
        font-weight: bold;
        color: white;
        cursor: pointer;
        text-decoration: none;
        position: relative;
        overflow: hidden; /* 隐藏伪元素超出的部分 */
        z-index: 1;
        transition: color 0.3s ease;
    }

    .perfect-button::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        /* 关键点 3: 按钮也用同样的技巧 */
        background: linear-gradient(135deg, #a052ff, #56ccf2);
        z-index: -1;
        transition: opacity 0.3s ease;
    }

    .perfect-button:hover::before {
        opacity: 0.8;
    }

</style>
</head>
<body>

    <div class="card">
        <div class="card-content">
            <h2 class="card-title">完美圆角</h2>
            <p class="card-text">
                保持6px圆角的同时,渐变边框也能完美呈现圆角效果,无任何瑕疵。
            </p>
            <a href="#" class="perfect-button">完美圆角</a>
        </div>
    </div>

</body>
</html>


canvas中常见问题的解决方法及分析,踩坑填坑经历

一、canvas中画线条,线条效果比预期宽1像素且模糊问题分析及解决。

【出现条件】:这种情况一般是垂直或者水平的线,且坐标为整数,宽度不是偶数。 【解决方法】:坐标偏移0.5像素。


1.1、canvas画线的原理:以指定坐标为中心向两侧画线(两侧各画宽的一半)。

下面我们看个例子

var dom = document.querySelector("#canvas1");
var ctx = dom.getContext('2d');

ctx.strokeStyle = '#000';

// 正常画线(坐标为整数,线宽为1px),1像素画出的效果像2像素。
ctx.lineWidth = 1;
ctx.moveTo(30, 50);
ctx.lineTo(30, 200);
ctx.stroke();

// 处理之后(坐标偏移0.5像素),线条宽度正常。
ctx.lineWidth = 1;
ctx.moveTo(50.5, 50);
ctx.lineTo(50.5, 200);
ctx.stroke();

效果如下图(在PS中放大后效果) canvas画线的原理

【实例解析】

  1. 指定坐标为30px时,实际是以30px为中心向两边各画一半(0.5px),会画在30px前后的两个像素格子中。又因为像素是最小单位,所以30px前后的两个像素都被画了1px的线,但是颜色要比实际的谈一些。

  2. 而指定坐标为50.5px时,线是以50.5为中心向两边各画一半(0.5px),这样子刚好只占用了一个像素的宽,就实现了1px的宽了。


1.2、当线的宽度为非整数时,同样会出现“宽度大1px”的情况

canvas画非整数宽的线

如上图所示,从左到右宽分别是1.3px、0.8px、0.5px、0.1px。上面4条以整数为坐标的线宽度其实是2px,下面4条X坐标都偏移了0.5px。效果更接近预期的宽度。


canvas画线问题总结

以上所说的偏移0.5px,其实并不准确。因为上面例子中,坐标都是整数。 更准确的说法应该是: 当线宽为偶数时,坐标应指定为整数。否则坐标应指定为整数+0.5px。


1.3、下面奉上我总结的最终解决方案

这里以竖线为例,横线同理


// 封装一个画线的方法
function drawLine (ctx, x, y1, y2, width) {
  // 当线宽为偶数时,坐标应指定为整数。否则坐标应指定为整数+0.5px。
  let newx = width % 2 === 0 ? Math.floor(x) : Math.floor(x) + 0.5;

  ctx.lineWidth = width;
  ctx.moveTo(newx, y1);
  ctx.lineTo(newx, y2);
}

ctx.beginPath();
ctx.strokeStyle = '#000';
drawLine (ctx, 350, 250, 380, 1);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
drawLine (ctx, 360, 250, 380, 2);
ctx.stroke();

ctx.beginPath();
ctx.strokeStyle = '#000';
drawLine (ctx, 370.4, 250, 380, 1.3);
ctx.stroke();

具体效果请看canvas画线条源码中,右下角的三根线。

想了解更多canvas画线问题的解析,请稳步到 canvas中画线条,线条效果比预期宽1像素且模糊问题分析及解决方案


二、canvas中没有画圆角矩形的API,我们自己写一个方法实现圆角矩形

2.1、canvas已有的api,并且我们可能会用到用来画圆角矩形的有如下几个:

2.1.1、创建矩形:

语法:context.rect(x,y,width,height)

参数 描述
x 矩形左上角的 x 坐标。
y 矩形左上角的 y 坐标。
width 矩形的宽度,以像素计。
height 矩形的高度,以像素计。

2.1.2、创建弧/曲线/圆:

语法:context.arc(x,y,r,sAngle,eAngle,counterclockwise)

参数 描述
x 圆的中心的 x 坐标。
y 圆的中心的 y 坐标。
r 圆的半径。
sAngle 起始角,以弧度计(弧的圆形的三点钟位置是 0 度)。
eAngle 结束角,以弧度计。
counterclockwise 可选。规定应该逆时针还是顺时针绘图。False = 顺时针,true = 逆时针。

2.1.3、把路径移动到画布中的指定点,不创建线条:

语法:context.moveTo(x,y)

参数 描述
x 路径的目标位置的 x 坐标。
y 路径的目标位置的 y 坐标。

2.1.4、添加一个新点,然后在画布中创建从该点到最后指定点的线条

语法:context.lineTo(x,y)

参数 描述
x 路径的目标位置的 x 坐标。
y 路径的目标位置的 y 坐标。

2.1.5、创建介于两个切线之间的弧/曲线:

image.png

语法:context.arcTo(x1,y1,x2,y2,r)

参数 描述
x1 两切线交点的横坐标。
y1 两切线交点的纵坐标。
x2 第二条切线上一点的横坐标。
y2 第二条切线上一点的纵坐标。
r 弧的半径。

2.2、这些API要么是画矩形,要么是画弧,但是没有真正画圆角矩形的。但是我们可以结合它们画出圆角矩形。

【分析方案】

方案1:圆弧(arc)+ 线(moveTo+lineTo)画矩形。 分析:可以实现画圆角矩形,不过需要反复多次调用以上API(要画8条线),性能略差。

方案2:使用两个切线之间的弧(arcTo)结合moveTo画矩形。 分析:可以实现画圆角矩形,并且调用API较少(只画4条线)。【推荐】

更详细的分析说明请移步到 在Canvas中绘制圆角矩形及逐步分析过程


2.3、最终实现画圆角矩形代码如下:

// 入参说明:上下文、左上角X坐标,左上角Y坐标,宽,高,圆角的半径
function arcRect (ctx, x, y, w, h, r) { 
  // 右上角弧线
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + r, r);

  // 右下角弧线
  ctx.moveTo(x + w, y + r);
  ctx.arcTo(x + w, y + h, x + w - r, y + h, r);

  // 左下角弧线
  ctx.moveTo(x + w - r, y + h);
  ctx.arcTo(x, y + h, x, y + h - r, r);

  // 左上角弧线
  ctx.moveTo(x, y + h - r);
  ctx.arcTo(x, y, x + r, y, r);
}

该代码实现的效果如下图

矩形

圆角矩形画好后,具体是要路径(外框),还是要填充。只需要分别使用stroke()fill()方法实现即可。


三、canvas中有阴影API,但是没有内阴影API。如果在Canvas中实现内阴影效果呢?

3.1、首先我们先看一下canvas中和阴影有关的API。

1.1、阴影相关属性如下:

属性 描述
shadowColor 设置或返回用于阴影的颜色。
shadowBlur 设置或返回用于阴影的模糊级别。
shadowOffsetX 设置或返回阴影与形状的水平距离。
shadowOffsetY 设置或返回阴影与形状的垂直距离

1.2、clip()方法

clip()方法从原始画布中剪切任意形状和尺寸。

提示:一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内(不能访问画布上的其他区域)。您也可以在使用 clip() 方法前通过使用 save() 方法对当前画布区域进行保存,并在以后的任意时间对其进行恢复(通过 restore() 方法)。

3.2、实现内阴影的原理:

给闭合线(如:矩形、圆等)设置阴影,然后把线以及线外部的阴影裁切掉,只留线内部的阴影。从而达到内阴影效果。

3.3、影响内阴影效果的因素:

1、shadowBlur值越大,范围变大,但阴影也更模糊。 2、线越宽,阴影越清晰。 3、所有这两个属性相结合一起控制【内阴影】的大小。

用线框内部的阴影来实现内阴影

3.4、矩形内阴影效果的实现:

ctx.strokeStyle = '#f00';
ctx.lineWidth = 7;

// 单独内阴影
rectInnerShadow(ctx, 80, 40, 200, 120);

// 矩形框和内阴影一起时,要先画内阴影
rectInnerShadow(ctx, 20, 230, 150, 120);
ctx.strokeRect(20, 230, 150, 120);

// 否则会有重叠(因为线是向两侧画的)
ctx.strokeRect(220, 230, 150, 120);
rectInnerShadow(ctx, 220, 230, 150, 120);

// 【矩形内阴影(边框+阴影,再把边框和外阴影裁剪掉)】
// 参数说明:ctx上下文内容,x,y,w,h同rect的入参,shadowColor阴影颜色,shadowBlur和lineWidth一同控制阴影大小。
function rectInnerShadow (ctx, x, y, w, h, shadowColor, shadowBlur, lineWidth) {
  var shadowColor = shadowColor || '#00f'; // 阴影颜色
  var lineWidth = lineWidth || 20; // 边框越大,阴影越清晰
  var shadowBlur = shadowBlur || 30; // 模糊级别,越大越模糊,阴影范围也越大。

  ctx.save();
  ctx.beginPath();

  // 裁剪区(只保留内部阴影部分)
  ctx.rect(x, y, w, h);
  ctx.clip();

  // 边框+阴影
  ctx.beginPath();
  ctx.lineWidth = lineWidth;
  ctx.shadowColor = shadowColor;
  ctx.shadowBlur = shadowBlur;
  // 因线是由坐标位置向两则画的,所以要移动起点坐标位置,和加大矩形。
  ctx.strokeRect(x - lineWidth/2, y - lineWidth/2 , w + lineWidth, h + lineWidth);
  
  // 取消阴影
  ctx.shadowBlur = 0;

  ctx.restore();
}

用线框内部的阴影来实现内阴影

🔥 老板要的功能Webpack没有?手把手教你写个插件解决

🎯 学习目标:掌握Webpack插件开发的核心技巧,学会自定义构建流程解决实际业务需求

📊 难度等级:中级-高级
🏷️ 技术标签#Webpack #插件开发 #构建工具 #自动化
⏱️ 阅读时间:约8分钟


🌟 引言

在日常的前端工程化开发中,你是否遇到过这样的困扰:

  • 构建流程定制困难:老板要求在打包时自动生成版本信息文件,但Webpack没有现成的功能
  • 重复工作自动化需求:每次发布都要手动处理资源文件,效率低下还容易出错
  • 插件开发复杂:想写个自定义插件,但不知道从何下手,文档看得云里雾里
  • 构建流程不透明:不了解Webpack内部机制,无法精确控制构建过程

今天分享5个Webpack插件开发的核心技巧,让你的构建流程更加智能化和自动化!


💡 核心技巧详解

1. 插件生命周期掌控:精准介入构建流程

🔍 应用场景

当你需要在特定的构建阶段执行自定义逻辑时,比如在编译完成后生成部署配置文件。

❌ 常见问题

很多开发者不了解Webpack的钩子系统,导致插件在错误的时机执行。

// ❌ 错误示例:不了解钩子时机
class BadPlugin {
  apply(compiler) {
    // 在错误的钩子中执行逻辑
    compiler.hooks.compile.tap('BadPlugin', () => {
      // 这里还没有生成文件,无法操作输出资源
      console.log('尝试操作还不存在的文件');
    });
  }
}

✅ 推荐方案

理解并正确使用Webpack的钩子系统,在合适的时机执行对应逻辑。

/**
 * 版本信息生成插件
 * @description 在构建完成后自动生成版本信息文件
 * @param {Object} options - 插件配置选项
 */
class VersionInfoPlugin {
  constructor(options = {}) {
    this.options = {
      filename: 'version.json',
      includeHash: true,
      ...options
    };
  }

  /**
   * 插件入口方法
   * @param {Object} compiler - Webpack编译器实例
   */
  apply = (compiler) => {
    //  在emit钩子中操作输出资源
    compiler.hooks.emit.tapAsync('VersionInfoPlugin', (compilation, callback) => {
      const versionInfo = this.generateVersionInfo(compilation);
      const content = JSON.stringify(versionInfo, null, 2);
      
      // 添加到输出资源中 - 使用Webpack 5的新API
      const { RawSource } = compiler.webpack.sources;
      compilation.emitAsset(this.options.filename, new RawSource(content));
      
      callback();
    });
  };

  /**
   * 生成版本信息
   * @param {Object} compilation - 编译对象
   * @returns {Object} 版本信息对象
   */
  generateVersionInfo = (compilation) => {
    const stats = compilation.getStats().toJson();
    return {
      buildTime: new Date().toISOString(),
      version: process.env.npm_package_version || '1.0.0',
      hash: this.options.includeHash ? stats.hash : undefined,
      chunks: stats.chunks.map(chunk => ({
        id: chunk.id,
        files: chunk.files
      }))
    };
  };
}

💡 核心要点

  • 钩子选择:emit钩子适合操作输出资源,done钩子适合构建完成后的清理工作
  • 异步处理:使用tapAsync处理异步操作,记得调用callback
  • 资源操作:通过compilation.assets添加或修改输出文件

🎯 实际应用

在CI/CD流程中自动生成包含构建信息的版本文件,便于线上问题排查。


2. 虚拟模块创建:动态生成代码模块

🔍 应用场景

需要根据配置或环境动态生成代码模块,比如自动生成路由配置或API接口定义。

❌ 常见问题

手动维护配置文件,容易出错且不够灵活。

// ❌ 传统做法:手动维护路由文件
// routes.js
export default [
  { path: '/home', component: () => import('./Home.vue') },
  { path: '/about', component: () => import('./About.vue') },
  // 每次新增页面都要手动添加...
];

✅ 推荐方案

使用虚拟模块动态生成路由配置。

/**
 * 自动路由生成插件
 * @description 扫描页面目录自动生成路由配置
 * @param {Object} options - 插件配置
 */
class AutoRoutePlugin {
  constructor(options = {}) {
    this.options = {
      pagesDir: 'src/pages',
      outputPath: 'src/router/auto-routes.js',
      ...options
    };
  }

  apply = (compiler) => {
    const fs = require('fs');
    const path = require('path');
    
    // 在编译开始前生成路由文件
    compiler.hooks.beforeCompile.tapAsync('AutoRoutePlugin', (params, callback) => {
      this.generateRoutes(fs, path)
        .then(() => callback())
        .catch(callback);
    });
  };

  /**
   * 生成路由配置
   * @param {Object} fs - 文件系统模块
   * @param {Object} path - 路径处理模块
   * @returns {Promise} 生成Promise
   */
  generateRoutes = async (fs, path) => {
    const pagesDir = path.resolve(this.options.pagesDir);
    const files = await this.scanPages(fs, pagesDir);
    
    const routes = files.map(file => {
      const routePath = this.fileToRoutePath(file);
      const componentPath = path.relative(
        path.dirname(this.options.outputPath),
        file
      ).replace(/\\/g, '/');
      
      return {
        path: routePath,
        component: `() => import('${componentPath}')`
      };
    });
    
    const content = this.generateRouteContent(routes);
    await fs.promises.writeFile(this.options.outputPath, content);
  };

  /**
   * 扫描页面文件
   * @param {Object} fs - 文件系统
   * @param {string} dir - 目录路径
   * @returns {Promise<Array>} 文件列表
   */
  scanPages = async (fs, dir) => {
    const files = [];
    const entries = await fs.promises.readdir(dir, { withFileTypes: true });
    
    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        files.push(...await this.scanPages(fs, fullPath));
      } else if (entry.name.endsWith('.vue')) {
        files.push(fullPath);
      }
    }
    
    return files;
  };

  /**
   * 文件路径转路由路径
   * @param {string} filePath - 文件路径
   * @returns {string} 路由路径
   */
  fileToRoutePath = (filePath) => {
    return filePath
      .replace(path.resolve(this.options.pagesDir), '')
      .replace(/\\/g, '/')
      .replace(/\.vue$/, '')
      .replace(/\/index$/, '') || '/';
  };

  /**
   * 生成路由文件内容
   * @param {Array} routes - 路由配置数组
   * @returns {string} 文件内容
   */
  generateRouteContent = (routes) => {
    const routeStrings = routes.map(route => 
      `  { path: '${route.path}', component: ${route.component} }`
    ).join(',\n');
    
    return `// 自动生成的路由配置文件
// 请勿手动修改,修改请编辑页面文件

export default [\n${routeStrings}\n];
`;
  };
}

💡 核心要点

  • 文件监听:结合watch模式实现页面文件变化时自动更新路由
  • 路径处理:正确处理相对路径和绝对路径的转换
  • 代码生成:生成的代码要符合ESLint规范

3. 代码转换技巧:AST操作实现智能处理

🔍 应用场景

需要在构建时对代码进行智能转换,比如自动添加埋点代码或移除调试信息。

❌ 常见问题

使用简单的字符串替换,容易误伤正常代码。

// ❌ 危险的字符串替换
const code = source.replace(/console\.log\([^)]*\)/g, '');
// 可能会误删除字符串中的内容

✅ 推荐方案

使用AST进行精确的代码转换。

/**
 * 代码清理插件
 * @description 移除生产环境中的调试代码
 */
class CodeCleanPlugin {
  constructor(options = {}) {
    this.options = {
      removeConsole: true,
      removeDebugger: true,
      removeComments: false,
      ...options
    };
  }

  apply = (compiler) => {
    compiler.hooks.compilation.tap('CodeCleanPlugin', (compilation) => {
      // 使用Webpack 5推荐的processAssets钩子
      compilation.hooks.processAssets.tapAsync(
        {
          name: 'CodeCleanPlugin',
          stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE
        },
        (assets, callback) => {
          this.processAssets(assets, compilation)
            .then(() => callback())
            .catch(callback);
        }
      );
    });
  };

  /**
   * 处理资源文件
   * @param {Object} assets - 资源对象
   * @param {Object} compilation - 编译对象
   */
  processAssets = async (assets, compilation) => {
    const babel = require('@babel/core');
    const t = require('@babel/types');
    
    for (const [filename, asset] of Object.entries(assets)) {
      if (filename.endsWith('.js')) {
        const source = asset.source();
        
        const result = await babel.transformAsync(source, {
          plugins: [this.createCleanupPlugin(t)]
        });
        
        if (result && result.code) {
          const { RawSource } = compilation.compiler.webpack.sources;
          compilation.updateAsset(filename, new RawSource(result.code));
        }
      }
    }
  };

  /**
   * 创建代码清理插件
   * @param {Object} t - Babel types
   * @returns {Object} Babel插件
   */
  createCleanupPlugin = (t) => {
    return {
      visitor: {
        // 移除console调用
        CallExpression: (path) => {
          if (this.options.removeConsole && 
              t.isMemberExpression(path.node.callee) &&
              t.isIdentifier(path.node.callee.object, { name: 'console' })) {
            path.remove();
          }
        },
        
        // 移除debugger语句
        DebuggerStatement: (path) => {
          if (this.options.removeDebugger) {
            path.remove();
          }
        },
        
        // 移除注释
        Program: {
          exit: (path) => {
            if (this.options.removeComments) {
              path.traverse({
                enter: (innerPath) => {
                  if (innerPath.node.leadingComments) {
                    innerPath.node.leadingComments = [];
                  }
                  if (innerPath.node.trailingComments) {
                    innerPath.node.trailingComments = [];
                  }
                }
              });
            }
          }
        }
      }
    };
  };
}

💡 核心要点

  • AST精确性:使用AST确保只转换目标代码,避免误伤
  • 性能考虑:大文件处理时要注意内存使用
  • 错误处理:转换失败时要有降级方案

4. 插件间通信:构建生态系统

🔍 应用场景

多个插件需要协作完成复杂的构建任务,比如资源分析插件和优化插件的配合。

❌ 常见问题

插件间缺乏通信机制,导致重复计算或数据不一致。

// ❌ 各自为政,重复分析
class AnalyzePlugin {
  apply(compiler) {
    compiler.hooks.emit.tap('AnalyzePlugin', (compilation) => {
      // 分析资源,但其他插件无法获取结果
      const analysis = this.analyzeAssets(compilation.assets);
    });
  }
}

✅ 推荐方案

建立插件间的通信机制,共享分析结果。

/**
 * 资源分析插件
 * @description 分析构建资源并提供给其他插件使用
 */
class AssetAnalyzePlugin {
  constructor(options = {}) {
    this.options = options;
    this.analysisResult = null;
  }

  apply = (compiler) => {
    // 创建自定义钩子供其他插件使用 - 使用Webpack 5的新方式
    const { SyncHook } = compiler.webpack;
    compiler.hooks.assetAnalyzed = new SyncHook(['analysis']);
    
    compiler.hooks.emit.tap('AssetAnalyzePlugin', (compilation) => {
      this.analysisResult = this.analyzeAssets(compilation.assets);
      
      // 将分析结果存储到compilation中
      compilation.assetAnalysis = this.analysisResult;
      
      // 触发自定义钩子
      compiler.hooks.assetAnalyzed.call(this.analysisResult);
    });
  };

  /**
   * 分析资源文件
   * @param {Object} assets - 资源对象
   * @returns {Object} 分析结果
   */
  analyzeAssets = (assets) => {
    const analysis = {
      totalSize: 0,
      fileTypes: {},
      largeFiles: [],
      duplicates: []
    };
    
    Object.entries(assets).forEach(([filename, asset]) => {
      const size = asset.size();
      const ext = filename.split('.').pop();
      
      analysis.totalSize += size;
      analysis.fileTypes[ext] = (analysis.fileTypes[ext] || 0) + size;
      
      if (size > 100 * 1024) { // 大于100KB
        analysis.largeFiles.push({ filename, size });
      }
    });
    
    return analysis;
  };
}

/**
 * 资源优化建议插件
 * @description 基于分析结果提供优化建议
 */
class OptimizationAdvicePlugin {
  apply = (compiler) => {
    // 监听资源分析完成事件
    compiler.hooks.assetAnalyzed.tap('OptimizationAdvicePlugin', (analysis) => {
      const advice = this.generateAdvice(analysis);
      console.log('\n📊 构建优化建议:');
      advice.forEach(item => console.log(`  ${item}`));
    });
  };

  /**
   * 生成优化建议
   * @param {Object} analysis - 分析结果
   * @returns {Array} 建议列表
   */
  generateAdvice = (analysis) => {
    const advice = [];
    
    if (analysis.totalSize > 5 * 1024 * 1024) {
      advice.push('🚨 总包大小超过5MB,建议启用代码分割');
    }
    
    if (analysis.largeFiles.length > 0) {
      advice.push(`📦 发现${analysis.largeFiles.length}个大文件,建议压缩或懒加载`);
    }
    
    const jsSize = analysis.fileTypes.js || 0;
    const cssSize = analysis.fileTypes.css || 0;
    
    if (jsSize > cssSize * 3) {
      advice.push('⚡ JS文件过大,建议使用Tree Shaking优化');
    }
    
    return advice.length > 0 ? advice : [' 构建结果良好,无需特别优化'];
  };
}

💡 核心要点

  • 自定义钩子:创建自定义钩子实现插件间通信
  • 数据共享:通过compilation对象共享数据
  • 事件驱动:使用事件机制解耦插件依赖

5. 插件调试策略:快速定位问题

🔍 应用场景

插件开发过程中需要调试和测试,确保插件在各种场景下正常工作。

❌ 常见问题

缺乏有效的调试手段,只能通过console.log盲目调试。

// ❌ 原始的调试方式
class MyPlugin {
  apply(compiler) {
    compiler.hooks.emit.tap('MyPlugin', (compilation) => {
      console.log('插件执行了'); // 信息不够详细
      console.log(compilation); // 输出过多无用信息
    });
  }
}

✅ 推荐方案

建立完善的调试和测试体系。

/**
 * 调试工具插件
 * @description 提供插件开发调试功能
 */
class DebugPlugin {
  constructor(options = {}) {
    this.options = {
      enabled: process.env.NODE_ENV === 'development',
      logLevel: 'info', // error, warn, info, debug
      outputFile: null,
      ...options
    };
    
    this.logs = [];
  }

  apply = (compiler) => {
    if (!this.options.enabled) return;
    
    // 监听所有主要钩子
    const hooks = [
      'beforeRun', 'run', 'beforeCompile', 'compile',
      'emit', 'afterEmit', 'done', 'failed'
    ];
    
    hooks.forEach(hookName => {
      if (compiler.hooks[hookName]) {
        compiler.hooks[hookName].tap('DebugPlugin', (...args) => {
          this.log('info', `钩子 ${hookName} 被触发`, {
            timestamp: new Date().toISOString(),
            args: this.serializeArgs(args)
          });
        });
      }
    });
    
    // 构建完成后输出调试信息
    compiler.hooks.done.tap('DebugPlugin', () => {
      this.outputDebugInfo();
    });
  };

  /**
   * 记录日志
   * @param {string} level - 日志级别
   * @param {string} message - 日志消息
   * @param {Object} data - 附加数据
   */
  log = (level, message, data = {}) => {
    const logEntry = {
      level,
      message,
      data,
      timestamp: new Date().toISOString()
    };
    
    this.logs.push(logEntry);
    
    if (this.shouldOutput(level)) {
      const prefix = this.getLevelPrefix(level);
      console.log(`${prefix} [DebugPlugin] ${message}`);
      
      if (Object.keys(data).length > 0) {
        console.log('  详细信息:', JSON.stringify(data, null, 2));
      }
    }
  };

  /**
   * 序列化参数
   * @param {Array} args - 参数数组
   * @returns {Object} 序列化结果
   */
  serializeArgs = (args) => {
    return args.map((arg, index) => {
      if (arg && typeof arg === 'object') {
        // 只保留关键信息,避免循环引用
        if (arg.constructor.name === 'Compilation') {
          return {
            type: 'Compilation',
            hash: arg.hash,
            assets: Object.keys(arg.assets),
            chunks: arg.chunks.map(chunk => chunk.id)
          };
        }
        return { type: arg.constructor.name, keys: Object.keys(arg) };
      }
      return { type: typeof arg, value: arg };
    });
  };

  /**
   * 判断是否应该输出日志
   * @param {string} level - 日志级别
   * @returns {boolean} 是否输出
   */
  shouldOutput = (level) => {
    const levels = ['error', 'warn', 'info', 'debug'];
    const currentIndex = levels.indexOf(this.options.logLevel);
    const messageIndex = levels.indexOf(level);
    return messageIndex <= currentIndex;
  };

  /**
   * 获取日志级别前缀
   * @param {string} level - 日志级别
   * @returns {string} 前缀
   */
  getLevelPrefix = (level) => {
    const prefixes = {
      error: '❌',
      warn: '⚠️',
      info: 'ℹ️',
      debug: '🐛'
    };
    return prefixes[level] || 'ℹ️';
  };

  /**
   * 输出调试信息
   */
  outputDebugInfo = () => {
    if (this.options.outputFile) {
      const fs = require('fs');
      const content = JSON.stringify(this.logs, null, 2);
      fs.writeFileSync(this.options.outputFile, content);
      console.log(` 调试信息已保存到: ${this.options.outputFile}`);
    }
    
    // 输出统计信息
    const stats = this.logs.reduce((acc, log) => {
      acc[log.level] = (acc[log.level] || 0) + 1;
      return acc;
    }, {});
    
    console.log('\n 调试统计:', stats);
  };
}

💡 核心要点

  • 分级日志:使用不同级别的日志便于过滤信息
  • 数据序列化:避免循环引用,只保留关键信息
  • 性能监控:记录钩子执行时间,发现性能瓶颈

📊 技巧对比总结

技巧 使用场景 优势 注意事项
生命周期掌控 精确控制构建时机 时机准确,功能强大 需要理解钩子机制
虚拟模块创建 动态生成代码 自动化程度高,减少手工维护 要处理文件监听和缓存
代码转换技巧 智能代码处理 精确安全,功能丰富 AST操作复杂,性能开销大
插件间通信 多插件协作 避免重复计算,数据一致 需要设计好通信协议
调试策略 开发和维护 问题定位快速,开发效率高 调试信息要适量,避免干扰

🎯 实战应用建议

最佳实践

  1. 插件设计原则:单一职责,功能内聚,接口简洁
  2. 性能优化:避免重复计算,合理使用缓存,异步处理大文件
  3. 错误处理:提供详细的错误信息,有降级方案
  4. 文档完善:提供清晰的使用文档和示例代码
  5. 测试覆盖:编写单元测试和集成测试,确保插件稳定性

性能考虑

  • 内存管理:及时释放不需要的对象,避免内存泄漏
  • 并发处理:合理使用异步操作,避免阻塞构建流程
  • 缓存策略:对计算结果进行缓存,避免重复计算

💡 总结

这5个Webpack插件开发技巧在日常工程化开发中能够显著提升构建效率,掌握它们能让你的构建流程:

  1. 生命周期掌控:精确控制构建时机,在合适的阶段执行自定义逻辑
  2. 虚拟模块创建:实现代码的动态生成,减少手工维护工作
  3. 代码转换技巧:通过AST实现智能代码处理,安全可靠
  4. 插件间通信:建立插件生态系统,实现复杂功能的协作
  5. 调试策略:快速定位问题,提升开发和维护效率

希望这些技巧能帮助你在前端工程化开发中更好地定制构建流程,写出更智能的自动化工具!


🔗 相关资源


💡 今日收获:掌握了5个Webpack插件开发的核心技巧,这些知识点在实际工程化开发中非常实用。

如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题也欢迎在评论区讨论。 🚀

uni-app项目Tabbar实现切换icon动效

前情

不知道该说是公司对产品要求稿,还是公司喜欢作,最近接手公司的一个全新跨端(快抖微支+app)的项目,项目还没有上线就已经改了4版了,改改UI,换换皮也就算了,关键是流程也是在一直修改,最近就接到上层说这小程序UI太丑了,要重新出UI,说现在的UI只能打60分,希望UI能多玩出点花样来,于是就接到现在的需求了,UI希望实现展形Tabbar,根据不同页面主题色需要切换Tabbar的背景,还希望默认高亮选中的是Tabbar项的中间项,而且还希望实现一些icon切换动效,里面任何一条想实现都只能靠自定义 Tabbar来做了

心里虽有排斥,排斥的是修改太频率了,每次都是刚刚调通又来大调整,心态上多少有点浮动,但这就是工作吧,互相配合才能打造老板喜欢的好的产品,用户喜欢不喜欢只能靠市场验证了,我也就偷偷骂了二句娘就接着开发这一个需求了

自定义Tabbar

异常Tabbar,对于我来说不是什么大问题,因为我已经在插件市场分享了一个自定义Tabbar的组件,就是为了能快速应对这种需求,我发布在插件市场的组件地址是:ext.dcloud.net.cn/plugin?id=2…

我实现异形组件的关键代码如下

这是Tabbar的配置数据:

...
tabbar: {
  color: '#8D8E91',
  selectedColor: '#000000',
  borderStyle: 'white',
  backgroundColor: 'transparent',
  tabbarHeight: 198,
  holderHeight: 198,
  iconStyle: { width: '44rpx', height: '44rpx' },
  activeIconStyle: { width: '44rpx', height: '44rpx' },
  textStyle: { fontSize: '24rpx' },
  activeTextStyle: { fontSize: '24rpx' },
  list: [
    {
      pagePath: '/pages/discover/discover',
      iconPath: '/static/tabbarnew/fx.png',
      selectedIconPath: '/static/tabbarnew/fxactive.png',
      text: '发现',
      key: 'discover',
    },
    {
      pagePath: '/pages/games/games',
      iconPath: '/static/tabbarnew/yx.png',
      selectedIconPath: '/static/tabbarnew/yxactive.png',
      text: '游戏',
      key: 'games',
    },
    {
      pagePath: '/pages/index/index',
      iconPath: 'https://cdn.dianbayun.com/static/tabs/xwz.gif',
      selectedIconPath: 'https://cdn.dianbayun.com/static/tabs/xwzactive.gif',
      text: '新物种',
      key: 'index',
    },
    {
      pagePath: '/pages/product/product',
      iconPath: '/static/tabbarnew/sc.png',
      selectedIconPath: '/static/tabbarnew/scactive.png',
      text: '商城',
      key: 'product',
    },
    {
      pagePath: '/pages/my/my',
      iconPath: '/static/tabbarnew/wd.png',
      selectedIconPath: '/static/tabbarnew/wdactive.png',
      text: '我的',
      key: 'my',
    },
  ],
}
...

下面是导航栏组件的关键结构和一些为了实现icon切换动效的css:

<!-- CustomTabBar 组件关键代码 -->
<hbxw-tabbar 
    :config="globalInstance.tabbar" 
    :active-key="activeKey" 
    :tabbar-style="{ backgroundImage: bgType === 'black' ? 'url('黑色背景')' : 'url('白色背景')', backgroundSize: '100% auto' }"
>
    <template #default="{ item, isActive, color, selectColor, iconStyleIn, activeIconStyleIn, textStyleIn, activeTextStyleIn }">
      <view
        class="w-full flex flex-col items-center justify-center h-[134rpx] relative"
        v-if="item.key !== 'index'"
      >
        <view class="w-[44rpx] h-[44rpx] relative" :class="{'active': isActive}">
          <image
            class="w-[44rpx] h-[44rpx] absolute top-0 left-0 normal-img"
            :src="item.iconPath"
            :style="iconStyleIn"
          />
          <image
            class="w-[44rpx] h-[44rpx] absolute top-0 left-0 active-img"
            :src="item.selectedIconPath"
            :style="activeIconStyleIn"
          />
        </view>
        <text
          class="text-[24rpx]"
          :style="{ color: !isActive ? color : selectColor, ...(isActive ? activeTextStyleIn : textStyleIn) }"
        >
          {{ item.text }}
        </text>
      </view>
      <view
        class="w-full flex flex-col items-center justify-center h-[134rpx] relative"
        v-else
      >
        <view class="w-[103rpx] h-[103rpx] relative" :class="{'active': isActive}">
          <image
            class="w-[103rpx] h-[103rpx] absolute top-0 left-0 normal-img"
            :src="item.iconPath"
          />
          <image
            class="w-[103rpx] h-[103rpx] absolute top-0 left-0 active-img"
            :src="item.selectedIconPath"
          />
        </view>
      </view>
    </template>
  </hbxw-tabbar>
</template>

// 这个是为了实现icon动添加的css
<style lang="scss" scoped>
  @keyframes normalimg {
    0% {
      opacity: 1;
      transform: scale(1);
    }
    100% {
      opacity: 0;
      transform: scale(.3);
    }
  }
  @keyframes activeimg {
    0% {
      opacity: 0;
      transform: scale(.3);
    }
    100% {
      opacity: 1;
      transform: scale(1);
    }
  }
    .active-img{
    opacity: 0;
    transform: scale(.3);
  }
  .normal-img{
    opacity: 1;
    transform: scale(1);
  }
  .active {
    .normal-img{
      animation: normalimg 0.4s ease-in-out 0s forwards;
    }
    .active-img{
      animation: activeimg 0.4s ease-in-out 0.4s forwards;
    }
  }   
</style>

注:当前项目我使用了Tainwind CSS原子化CSS框架来书写样式

在页面上使用的代码如下:

<!-- 这是首页的页面Tabbar 高亮index项,同时背景用黑色 -->
<CustomTabBar activeKey="index" bgType="black" />

其实原理很简单,因为我的发布在应用市场的组件有提供 slot,你可以自由定义Tabbar的每一项的结构样式,我这里的做法就是中间项单独一块结构来实现异形效果,实现后的效果如下:

image.png

坑位?

展开tabbar效果是好实现的,但是在实现Tabbar切换icon动效的时候,我遇到了麻烦,小程序虽然有提供专门用于做动画的API,但是我个人不太喜欢用,我比较喜欢使用css3实现动画,使用上更简单的同时,动画流畅度也优于JS来做

因为是切换动效,首先想到的就是通过transition来实现,通过给父组添加一个active的类名控制下面icon的来实现切换动效,这是实现状态变化动效的首选,但是发现完全没有用,一度怀疑是不是小程序不支持transition,于是想到换方案,我通过aniamtion来实现动效,确实是有效果的,但是只有首次切换tabbar的时候有效果

Why?

我一开始是怀疑是不是小程序对于css3动画有兼容性问题,或者是支付宝不支持动效,因为我此时正在开发的就是支付宝端,也去小程序论坛逛了逛 ,确实有一些帖子说到transition在小程序上兼容问题,也问了AI,AI也说是有,而且不现标签组件可能支持的transition还不一样,此时我陷入了怀疑,难道真的要靠JS来实现么,但是以我的个人开发经验,我不止在一个小程序项目中使用过css3来实现动效,都是没有问题的,在经过一段时间的思考我,我突然意识到一个问题,动画没出现真的不是兼容性的问题,而是没有动效或者只有首次有这根本就是正常现象

transition没有是因为当你切换tabbar的时候整个组件是重新渲染的,对于初次渲染的元素你是没法使用transition的,至于为什么后面点也都没有,是我在尝试 animation的时候发现它只有首次点击切换的时候才有我才突然意识到,因为这是tabbar啊,小程序是有会缓存的,你显示一次后,小程序页面会运行在后台,你再次切换的时候只是激活而已,根本不会有样式的变化

解决方案

既然Tabbar切换页在不会重新从0渲染,只是显示与隐藏而已,那我们就手动的让它来实现Tabbar的高亮样式切换即可,虽然Tabbar切换页面不会重新渲染,但是它会触发二个小程序的生命钩子onShow/onHide,那我们就从这二处着手,因为是多个页面要复用,我此处抽了hooks,关键代码如下:

import { onShow, onLoad, onHide } from '@dcloudio/uni-app'
import { ref } from 'vue'

export const usePageTabbar = (type) => {
  const activeType = ref('')
  onLoad(() => {
    uni.hideTabBar()
    activeType.value = type
  })

  onShow(() => {
    activeType.value = type
    uni.hideTabBar()
  })

  onHide(() => {
    activeType.value = ''
    uni.showTabBar()
  })

  return {
    activeType
  }
}

页面上使用也做了调整,关键代码如下:

<script setup>
    ...
    import { usePageTabbar } from '@/hooks/pagesTabbar'

    const { activeType } = usePageTabbar('index')
    ...
</script>

<template>
        ...
        <!-- 页面tabbar -->
    <CustomTabBar :activeKey="activeType" />
    ...
</template>

至此完成了这一次的 tabbar大改造,实现的效果如下:

20250906_201121.gif

其实此时再切换回用transition去做动画,这也是可以的,只是我后面已经用 animaltion实现了就懒得改它了

思考

对于做开么的我们,平时抽取一些可以复用的组件并分享真的是值得做的一件事,它可以在很多时候帮你提高开发速度,同时也减少了你反复的写一些重复代码

对于需求调整这是很多开发都不喜欢的事,因为当项目需求调整的过多,原来已经快接近屎山的代码更加加还变成屎山,但是这个对于一些小公司开发流程不是特别规范的需求调整是不可避免的,我们无需过多烦恼,只要项目进度允许,他们要调就让他调吧,相信大家都是为了打造一款精品应用在使劲而已,何乐而不为了

个人的能力和认识都有限,对于一个问题的解决方案有很多种,我上面的实现方案并不一定最好的,如果你有更好的解决方案,欢迎不吝分享,一起学习一起进步

备忘录模式(Memento Pattern)详解

前一篇文章解锁时光机用到了备忘录模式,那么什么是备忘录模式?

备忘录模式是一种行为型设计模式,它的核心思想是在不暴露对象内部细节的情况下,捕获并保存一个对象的内部状态,以便在将来可以恢复到这个状态

这个模式就像一个“时光机”,能够让你在程序运行时记录下某个时刻的状态,并在需要时“穿越”回去。

核心角色

备忘录模式通常包含三个主要角色:

  1. 发起人(Originator)

    • 这是需要被保存状态的对象。
    • 它负责创建一个备忘录(Memento),来保存自己的当前状态。
    • 它也能够使用备忘录来恢复到之前的状态。
    • 在我们的 React 例子中,reducer 函数和其中的 state 对象就是发起人。它能创建和恢复状态。
  2. 备忘录(Memento)

    • 这是用于存储发起人内部状态的快照对象。
    • 它提供一个受限的接口,只允许发起人访问其内部状态。外部对象(比如 caretaker)无法直接修改备忘录的内容,只能将其作为“黑盒子”传递。
    • 在我们的 React 例子中,past 和 future 数组中的每一个 present 值,就是一个备忘录。它是一个简单的数值,不需要复杂的对象来封装。
  3. 管理者(Caretaker)

    • 负责保存和管理备忘录。
    • 它不知道备忘录内部的具体细节,只知道备忘录是从发起人那里来的,并能在需要时将它还给发起人。
    • 它不能对备忘录的内容进行任何操作,只能存储和检索。
    • 在我们的 React 例子中,state 对象中的 past 和 future 数组就是管理者reducer 函数负责将备忘录(即状态值)放入或取出这些数组。

工作流程

  1. 保存状态:发起人(Originator)在需要时,创建一个备忘录(Memento),将自己的当前状态保存进去。然后将这个备忘录交给管理者(Caretaker)。
  2. 恢复状态:当需要恢复时,管理者(Caretaker)将之前保存的备忘录交给发起人(Originator)。发起人通过备忘录中的信息,将自己的状态恢复到之前的样子。

备忘录模式与代码示例

现在,让我们把这些角色对应到代码中,一切就变得清晰了:

  • 发起人(Originator)state 对象中的 present 值。它代表了当前的核心状态。
  • 备忘录(Memento)past 和 future 数组中的每一个数值。每个数值都是一个“状态快照”。
  • 管理者(Caretaker)state 对象中的 past 和 future 数组。它们负责存储这些状态快照。

具体实现流程:

  1. Increment 操作

    • Originatorpresent)的状态即将改变。
    • Originator 告诉 Caretakerpast 数组),“我马上要变了,这是我现在的样子,你帮我存一下。”
    • Caretaker 将当前的 present 值 [...past, present] 存入 past 数组。
  2. Undo 操作

    • Caretakerpast 数组)将最后一个备忘录(past.at(-1))交给 Originator
    • Originator 接收这个备忘录,并将其恢复为自己的状态(present: past.at(-1))。
    • 同时,Originator 将当前状态作为新的备忘录,交给另一个 Caretakerfuture 数组),以便重做。

为什么这种模式更优越?

备忘录模式的优点在于它实现了解耦。管理者(past/future 数组)和发起人(present 值)之间只需要知道如何存取备忘录,而不需要知道备忘录内部的具体结构或如何改变状态。这意味着你可以轻松地改变 increment 或 decrement 的逻辑,而 undo 和 redo 的逻辑完全不需要改动。

这就是为什么代码二的设计如此优雅和可扩展。它不关心“如何”改变,只关心“改变前”和“改变后”的状态是什么,并将这些状态作为备忘录保存起来。

实时 AIGC:Web 端低延迟生成的技术难点与突破

各位开发者朋友,当你在 Web 页面上敲下 “帮我生成一篇关于太空旅行的短文”,按下回车后,是愿意等待一杯咖啡凉透,还是希望答案像闪电般出现在屏幕上?答案不言而喻。实时 AIGC(生成式人工智能)在 Web 端的应用,就像一场 “速度与精度” 的极限竞速,而低延迟生成,正是这场比赛中最具挑战性的关卡。作为一名深耕 AI 与 Web 技术交叉领域的研究者,今天我们就扒开技术的外衣,从底层原理出发,聊聊实时 AIGC 在 Web 端实现低延迟的那些 “拦路虎” 和 “破局招”。

一、实时 AIGC 的 “生死线”:Web 端低延迟的核心挑战

在讨论技术细节前,我们得先明确一个标准:Web 端的 “实时” 到底意味着什么?从用户体验角度看,端到端延迟超过 300 毫秒,用户就会明显感觉到 “卡顿”;而对于对话式 AI、实时图像生成等场景,延迟需要压缩到100 毫秒以内,才能达到 “无缝交互” 的效果。但 AIGC 模型本身就像一个 “贪吃的巨人”,要在 Web 这个 “狭窄的舞台” 上快速完成 “表演”,面临着三大核心难题。

1. 模型 “体重超标”:Web 环境的 “承重危机”

AIGC 模型(尤其是大语言模型 LLM 和 diffusion 图像生成模型)的 “体重” 是低延迟的第一只 “拦路虎”。以主流的 LLM 为例,一个千亿参数的模型,其权重文件大小可能超过 10GB,即使是经过压缩的轻量模型,也可能达到数百 MB。而 Web 环境的 “带宽天花板” 和 “存储小仓库”,根本无法承受这样的 “重量级选手”。

从底层原理来看,模型的推理过程本质上是大量的矩阵乘法和非线性变换运算。假设一个模型有 N 层网络,每一层需要处理 M 个特征向量,那么单次推理的运算量会随着 N 和 M 的增加呈 “平方级” 增长。在 Web 端,浏览器的 JavaScript 引擎(如 V8)和 GPU 渲染线程虽然具备一定的计算能力,但面对这种 “海量运算”,就像让一台家用轿车去拉火车,力不从心。

举个通俗的例子:如果把模型推理比作 “做蛋糕”,传统服务器端推理是在大型烘焙工厂,有无数烤箱和厨师;而 Web 端推理则是在你家的小厨房,只有一个微波炉和你自己。要在同样时间内做出同样的蛋糕,难度可想而知。

2. 数据 “长途跋涉”:端云交互的 “延迟陷阱”

很多开发者会想:既然 Web 端算力有限,那把模型放在云端,Web 端只负责 “传输入输出” 不就行了?这确实是目前的主流方案,但它又陷入了另一个 “延迟陷阱”——端云数据传输延迟

从网络底层来看,数据从 Web 端(客户端)发送到云端服务器,需要经过 “TCP 三次握手”“数据分片”“路由转发” 等一系列流程,每一步都需要时间。假设用户在上海,而云端服务器在北京,光信号在光纤中传输的时间就需要约 20 毫秒(光速约 30 万公里 / 秒,京沪直线距离约 1300 公里,往返就是 2600 公里,计算下来约 8.7 毫秒,加上路由转发等耗时,实际会超过 20 毫秒)。如果模型在云端推理需要 50 毫秒,再加上数据返回的 20 毫秒,仅端云交互和推理就已经超过 90 毫秒,再加上 Web 端的渲染时间,很容易突破 100 毫秒的 “生死线”。

更麻烦的是,Web 端与云端的通信还可能面临 “网络抖动”—— 就像你在高峰期开车,时而顺畅时而拥堵。这种抖动会导致延迟忽高忽低,严重影响用户体验。比如,在实时对话场景中,用户说完一句话,AI 回复时而 “秒回”,时而 “卡顿 5 秒”,这种 “薛定谔的延迟” 会让用户崩溃。

3. 资源 “抢地盘”:Web 端的 “资源争夺战”

Web 页面本身就是一个 “资源密集型” 应用,浏览器要同时处理 DOM 渲染、CSS 样式计算、JavaScript 执行、网络请求等多个任务。而 AIGC 推理需要占用大量的 CPU/GPU 资源,这就必然引发一场 “资源争夺战”。

从浏览器的事件循环机制来看,JavaScript 是单线程执行的(虽然有 Web Worker 可以开启多线程,但计算能力有限)。如果 AIGC 推理在主线程中执行,就会 “阻塞” 其他任务,导致页面卡顿、按钮点击无响应 —— 这就像你在电脑上同时开着视频会议、玩游戏、下载文件,电脑会变得异常卡顿。

即使使用 Web Worker 将推理任务放到后台线程,GPU 资源的竞争依然存在。浏览器的 WebGL 或 WebGPU 接口虽然可以调用 GPU 进行并行计算,但 GPU 同时还要负责页面的 3D 渲染、视频解码等任务。当 AIGC 推理占用大量 GPU 算力时,页面的动画效果可能会掉帧,视频可能会卡顿 —— 就像一条公路上,货车(AIGC 推理)和轿车(页面渲染)抢道,最终导致整个交通瘫痪。

二、破局之路:从底层优化到上层创新的 “组合拳”

面对上述三大难题,难道 Web 端实时 AIGC 就只能 “望洋兴叹”?当然不是。近年来,从模型压缩到推理引擎优化,从网络传输到 Web 技术创新,业界已经打出了一套 “组合拳”,让实时 AIGC 在 Web 端的实现成为可能。下面我们就从技术底层出发,逐一拆解这些 “破局招”。

1. 模型 “瘦身”:从 “巨人” 到 “轻骑兵” 的蜕变

要让模型在 Web 端 “跑得动”,第一步就是给它 “瘦身”。模型压缩技术就像 “健身教练”,通过科学的方法,在尽量不损失精度的前提下,减少模型的 “体重” 和 “运算量”。目前主流的 “瘦身” 手段有三种:量化、剪枝和知识蒸馏

(1)量化:给模型 “降精度”

量化的核心思路是:将模型中 32 位浮点数(float32)表示的权重和激活值,转换为 16 位浮点数(float16)、8 位整数(int8)甚至 4 位整数(int4)。这样一来,模型的体积会大幅减小,运算速度也会显著提升。

从底层原理来看,浮点数的运算比整数运算复杂得多。以乘法运算为例,float32 的乘法需要经过 “符号位计算”“指数位相加”“尾数位相乘” 等多个步骤,而 int8 的乘法只需要简单的整数相乘。在 Web 端的 JavaScript 引擎中,整数运算的效率比浮点数高 30%-50%(不同引擎略有差异)。

举个例子:一个 float32 的权重文件大小为 4GB,量化为 int8 后,大小会压缩到 1GB,体积减少 75%。同时,推理时的运算量也会减少 75%,这对于 Web 端的算力来说,无疑是 “雪中送炭”。

当然,量化也有 “副作用”—— 精度损失。但通过 “量化感知训练”(在训练时就模拟量化过程),可以将精度损失控制在 5% 以内,对于大多数 Web 端应用(如对话、简单图像生成)来说,完全可以接受。

在 Web 端,我们可以使用 TensorFlow.js(TF.js)实现模型量化。下面是一个简单的 JS 示例,将一个预训练的 LLM 模型量化为 int8:

// 加载未量化的模型
const model = await tf.loadGraphModel('https://example.com/llm-model.json');
// 配置量化参数
const quantizationConfig = {
  quantizationType: tf.io.QuantizationType.INT8, // 量化为int8
  inputNames: ['input_ids'], // 模型输入名称
  outputNames: ['logits'] // 模型输出名称
};
// 量化模型并保存
await tf.io.writeGraphModel(
  model,
  'https://example.com/llm-model-quantized',
  { quantizationConfig }
);
// 加载量化后的模型
const quantizedModel = await tf.loadGraphModel('https://example.com/llm-model-quantized.json');
console.log('模型量化完成,体积减少约75%');

(2)剪枝:给模型 “砍枝丫”

如果说量化是 “降精度”,那剪枝就是 “砍冗余”。模型在训练过程中,会产生很多 “冗余参数”—— 就像一棵大树,有很多不必要的枝丫。剪枝的目的就是把这些 “枝丫” 砍掉,只保留核心的 “树干” 和 “主枝”。

剪枝分为 “结构化剪枝” 和 “非结构化剪枝”。对于 Web 端来说,结构化剪枝更实用 —— 它会剪掉整个卷积核或全连接层中的某些通道,而不是单个参数。这样做的好处是,剪枝后的模型依然可以被 Web 端的推理引擎高效处理,不会引入额外的计算开销。

举个例子:一个包含 1024 个通道的卷积层,如果通过剪枝去掉其中的 256 个通道(冗余通道),那么该层的运算量会减少 25%,同时模型体积也会减少 25%。而且,由于通道数减少,后续层的输入特征向量维度也会降低,进一步提升整体推理速度。

(3)知识蒸馏:让 “小模型” 学会 “大模型” 的本领

知识蒸馏的思路很有趣:让一个 “小模型”(学生模型)通过学习 “大模型”(教师模型)的输出和决策过程,掌握与大模型相当的能力。就像一个徒弟通过模仿师傅的技艺,最终达到师傅的水平,但徒弟的 “精力”(算力需求)却远低于师傅。

在 Web 端,我们可以先在云端用大模型对海量数据进行 “标注”(生成软标签),然后用这些软标签训练一个小模型。小模型不仅体积小、运算量低,还能继承大模型的 “智慧”。例如,用千亿参数的 GPT-4 作为教师模型,训练一个亿级参数的学生模型,学生模型在 Web 端的推理速度可以达到大模型的 10 倍以上,同时精度损失控制在 10% 以内。

2. 推理 “加速”:让 Web 端算力 “物尽其用”

模型 “瘦身” 后,下一步就是优化推理过程,让 Web 端的 CPU 和 GPU 发挥最大潜力。这就像给 “轻骑兵” 配备 “快马”,进一步提升速度。目前主流的推理优化技术包括WebGPU 加速、算子融合和动态批处理

(1)WebGPU:给 Web 端装上 “GPU 引擎”

在 WebGPU 出现之前,Web 端调用 GPU 进行计算主要依赖 WebGL。但 WebGL 是为图形渲染设计的,用于通用计算(如 AI 推理)时效率很低,就像用 “炒菜锅” 来 “炼钢”。而 WebGPU 是专门为通用计算设计的 Web 标准,它可以直接调用 GPU 的计算核心,让 AI 推理的效率提升 10-100 倍。

从底层原理来看,WebGPU 支持 “计算着色器”(Compute Shader),可以将模型推理中的矩阵乘法等并行运算,分配给 GPU 的多个计算单元同时处理。例如,一个 1024x1024 的矩阵乘法,在 CPU 上可能需要几毫秒,而在 GPU 上,通过并行计算,可能只需要几十微秒。

在 TF.js 中,我们可以很容易地启用 WebGPU 后端,为模型推理加速。下面是一个 JS 示例:

// 检查浏览器是否支持WebGPU
if (tf.getBackend() !== 'webgpu' && tf.backend().isWebGPUSupported()) {
  await tf.setBackend('webgpu'); // 切换到WebGPU后端
  console.log('已启用WebGPU加速,推理速度预计提升10倍以上');
}
// 加载量化后的模型并进行推理
const input = tf.tensor2d([[1, 2, 3, 4]], [1, 4]); // 模拟输入数据
const output = await quantizedModel.predict(input); // 推理
output.print(); // 输出结果

需要注意的是,目前 WebGPU 还未在所有浏览器中普及(Chrome、Edge 等已支持,Safari 正在逐步支持),但它无疑是 Web 端 AI 推理的未来趋势。

(2)算子融合:减少 “数据搬运” 时间

模型推理过程中,有大量的 “算子”(如卷积、激活、池化等)需要依次执行。在传统的推理方式中,每个算子执行完成后,都会将结果写入内存,下一个算子再从内存中读取数据 —— 这就像 “接力赛”,每一棒都要停下来交接,浪费大量时间。

算子融合的核心思路是:将多个连续的算子 “合并” 成一个算子,在 GPU 中直接完成所有计算,中间结果不写入内存。这样可以大幅减少 “数据搬运” 的时间,提升推理效率。例如,将 “卷积 + ReLU 激活 + 批归一化” 三个算子融合成一个 “卷积 - ReLU - 批归一化” 算子,推理速度可以提升 30% 以上。

在 Web 端的推理引擎(如 TF.js、ONNX Runtime Web)中,算子融合已经成为默认的优化策略。开发者不需要手动进行融合,引擎会自动分析模型的算子依赖关系,完成融合优化。

(3)动态批处理:让 “闲置算力” 不浪费

在 Web 端的实时 AIGC 场景中,用户请求往往是 “零散的”—— 可能某一时刻有 10 个用户同时发送请求,某一时刻只有 1 个用户发送请求。如果每次只处理一个请求,GPU 的算力就会大量闲置,就像 “大货车只拉一个包裹”,效率极低。

动态批处理的思路是:在云端推理服务中,设置一个 “批处理队列”,将短时间内(如 10 毫秒)收到的多个用户请求 “打包” 成一个批次,一次性送入模型推理。推理完成后,再将结果分别返回给各个用户。这样可以充分利用 GPU 的并行计算能力,提升单位时间内的处理量,从而降低单个请求的延迟。

例如,一个模型处理单个请求需要 50 毫秒,处理一个包含 10 个请求的批次也只需要 60 毫秒(因为并行计算的开销增加很少)。对于每个用户来说,延迟从 50 毫秒降到了 6 毫秒,效果非常显著。

在 Web 端,动态批处理需要云端服务的支持。开发者可以使用 TensorFlow Serving 或 ONNX Runtime Server 等工具,配置动态批处理参数。下面是一个简单的配置示例(以 ONNX Runtime Server 为例):

{
  "model_config_list": [
    {
      "name": "llm-model",
      "base_path": "/models/llm-model",
      "platform": "onnxruntime",
      "batch_size": {
        "max": 32, // 最大批处理大小
        "dynamic_batching": {
          "max_queue_delay_milliseconds": 10 // 最大队列等待时间
        }
      }
    }
  ]
}

3. 传输 “提速”:打通端云交互的 “高速公路”

解决了模型和推理的问题后,端云数据传输的延迟就成了 “最后一公里”。要打通这 “最后一公里”,需要从网络协议优化、边缘计算部署和数据压缩三个方面入手。

(1)HTTP/3 与 QUIC:给数据传输 “换条快车道”

传统的端云通信主要基于 HTTP/2 协议,而 HTTP/2 依赖 TCP 协议。TCP 协议的 “三次握手” 和 “拥塞控制” 机制,在网络不稳定时会导致严重的延迟。而 HTTP/3 协议基于 QUIC 协议,QUIC 是一种基于 UDP 的新型传输协议,它具有 “0-RTT 握手”“多路复用无阻塞”“丢包恢复快” 等优点,可以将端云数据传输的延迟降低 30%-50%。

从底层原理来看,QUIC 协议在建立连接时,不需要像 TCP 那样进行三次握手,而是可以在第一次数据传输时就完成连接建立(0-RTT),节省了大量时间。同时,QUIC 的多路复用机制可以避免 TCP 的 “队头阻塞” 问题 —— 即使某一个数据流出现丢包,其他数据流也不会受到影响,就像一条有多条车道的高速公路,某一条车道堵车,其他车道依然可以正常通行。

目前,主流的云服务提供商(如阿里云、AWS)和浏览器(Chrome、Edge)都已经支持 HTTP/3 协议。开发者只需要在云端服务器配置 HTTP/3,Web 端就可以自动使用 HTTP/3 进行通信,无需修改代码。

(2)边缘计算:把 “云端” 搬到用户 “家门口”

边缘计算的核心思路是:将云端的模型推理服务部署在离用户更近的 “边缘节点”(如城市边缘机房、基站),而不是集中在遥远的中心机房。这样可以大幅缩短数据传输的物理距离,降低传输延迟。

举个例子:如果用户在杭州,中心机房在北京,数据传输延迟需要 20 毫秒;而如果在杭州部署一个边缘节点,数据传输延迟可以降低到 1-2 毫秒,几乎可以忽略不计。对于实时 AIGC 场景来说,这 18-19 毫秒的延迟节省,足以决定用户体验的好坏。

目前,各大云厂商都推出了边缘计算服务(如阿里云边缘计算、腾讯云边缘计算)。开发者可以将训练好的模型部署到边缘节点,然后通过 CDN 的方式完成使用。

五、Redux进阶:UI组件、容器组件、无状态组件、异步请求、Redux中间件:Redux-thunk、redux-saga,React-redux

一、UI组件和容器组件

  1. UI组件负责页面的渲染(傻瓜组件)
  2. 容器组件负责页面的逻辑(聪明组件)

当一个组件内容比较多,同时有逻辑处理和UI数据渲染时,维护起来比较困难。这个时候可以拆分成“UI组件”和"容器组件"。 拆分的时候,容器组件把数据和方法传值给子组件,子组件用props接收。

需要注意的是: 子组件调用父组件方法函数时,并传递参数时,可以把方法放在箭头函数中(直接在函数体使用该参数,不需要传入箭头函数)。

拆分实例

未拆分前原组件

import React, {Component} from 'react';
import 'antd/dist/antd.css'; // or 'antd/dist/antd.less'
import { Input, Button, List } from 'antd';
// 引用store
import store from './store';
import { inputChangeAction, addItemAction, deleteItemAction } from './store/actionCreators';

class TodoList extends Component {
  constructor(props) {
    super(props);
    // 获取store,并赋值给state
    this.state = store.getState();
    
    // 统一在constructor中绑定this,提交性能
    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleStoreChange = this.handleStoreChange.bind(this);
    this.handleClick = this.handleClick.bind(this);

    // 在组件中订阅store,只要store改变就触发这个函数
    this.unsubscribe = store.subscribe(this.handleStoreChange);
  }

  // 当store状态改变时,更新state
  handleStoreChange() {
    // 用从store中获取的state,来设置state
    this.setState(store.getState());
  }
  
  render() {
    return (
      <div style={{margin: '10px'}}>
        <div className="input">
          <Input
            style={{width: '300px', marginRight: '10px'}}
            value={this.state.inputValue}
            onChange={this.handleInputChange}
          />
          <Button type="primary" onClick={this.handleClick}>提交</Button>
        </div>
        <List
          style={{marginTop: '10px', width: '300px'}}
          bordered
          dataSource={this.state.list}
          renderItem={(item, index) => (<List.Item onClick={this.handleDelete.bind(this, index)}>{item}</List.Item>)}
        />
      </div>
    )
  }

  // 组件注销前把store的订阅取消
  componentWillUnmount() {
    this.unsubscribe();
  }

  // 输入内容时(input框内容改变时)
  handleInputChange(e) {
    const action = inputChangeAction(e.target.value);
    store.dispatch(action);
  }

  // 添加一项
  handleClick () {
    const action = addItemAction();
    store.dispatch(action);
  }
  
  // 点击删除当前项
  handleDelete (index) {
    const action = deleteItemAction(index);
    store.dispatch(action);
  }
}

export default TodoList;

拆分后-容器组件

import React, {Component} from 'react';

// 引用store
import store from './store';
import { inputChangeAction, addItemAction, deleteItemAction } from './store/actionCreators';
import TodoListUI from './TodoListUI';

class TodoList extends Component {
  constructor(props) {
    super(props);
    // 获取store,并赋值给state
    this.state = store.getState();
    
    // 统一在constructor中绑定this,提交性能
    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleStoreChange = this.handleStoreChange.bind(this);
    this.handleClick = this.handleClick.bind(this);

    // 在组件中订阅store,只要store改变就触发这个函数
    this.unsubscribe = store.subscribe(this.handleStoreChange);
  }

  // 当store状态改变时,更新state
  handleStoreChange() {
    // 用从store中获取的state,来设置state
    this.setState(store.getState());
  }
  
  render() {
    return (
      <TodoListUI
        inputValue={this.state.inputValue}
        list={this.state.list}
        handleInputChange={this.handleInputChange}
        handleClick={this.handleClick}
        handleDelete={this.handleDelete}
      />
    )
  }

  // 组件注销前把store的订阅取消
  componentWillUnmount() {
    this.unsubscribe();
  }

  // 输入内容时(input框内容改变时)
  handleInputChange(e) {
    const action = inputChangeAction(e.target.value);
    store.dispatch(action);
  }

  // 添加一项
  handleClick () {
    const action = addItemAction();
    store.dispatch(action);
  }

  // 点击删除当前项
  handleDelete (index) {
    const action = deleteItemAction(index);
    store.dispatch(action);
  }
}

export default TodoList;

拆分后-UI组件

import React, { Component } from 'react';
import 'antd/dist/antd.css'; // or 'antd/dist/antd.less'
import { Input, Button, List } from 'antd';

class TodoListUI extends Component {
  render() {
    return (
      <div style={{margin: '10px'}}>
        <div className="input">
          <Input
            style={{width: '300px', marginRight: '10px'}}
            value={this.props.inputValue}
            onChange={this.props.handleInputChange}
          />
          <Button type="primary" onClick={this.props.handleClick}>提交</Button>
        </div>
        <List
          style={{marginTop: '10px', width: '300px'}}
          bordered
          dataSource={this.props.list}
          // renderItem={(item, index) => (<List.Item onClick={(index) => {this.props.handleDelete(index)}}>{item}-{index} </List.Item>)}
          renderItem={(item, index) => (<List.Item onClick={() => {this.props.handleDelete(index)}}>{item}-{index} </List.Item>)}
        />
        {/* 子组件调用父组件方法函数时,并传递参数时,可以把方法放在箭头函数中(直接在函数体使用该参数,不需要传入箭头函数)。 */}
      </div>
    )
  }
}

export default TodoListUI;

二、无状态组件

当一个组件只有render函数时,可以用无状态组件代替。

  1. 无状态组件比普通组件性能高; 因为无状态组件只是函数,普通组件是class声明的类要执行很多生命周期函数和render函数。
  2. 无状态组件中的函数接收一个参数作为父级传过来的props。

例如下面这个例子 普通组件:

class TodoList extends Component {
  render() {
    return <div> {this.props.item} </div>
  }
}

无状态组件:

const TodoList = (props) => {
  return(
    <div> {props.item} </div>
  )}

三、Redux 中发送异步请求获取数据

1、引入axios,使用axios发送数据请求

import axios from 'axios';

2、在componentDidMount中调用接口

componentDidMount() {
  axios.get('/list.json').then(res => {
    const data = res.data;
    // 在actionCreators.js中定义好initListAction,并在reducer.js中作处理(此处省略这部分)
    const action = initListAction(data);
    store.dispatch(action);
  })
}

四、使用Redux-thunk 中间件实现ajax数据请求

1、安装和配置Redux-thunk

1.1、安装Redux-thunk

npm install redux-thunk --save

1.2、正常使用redux-thunk中间件在store中的写法

// 引用applyMiddleware
import { createStore, applyMiddleware } from 'redux';
import reducer from './reducer';
import thunk from 'redux-thunk';

// 创建store时,第二个参数传入中间件
const store = createStore(
  reducer,
  applyMiddleware(thunk)
);

export default store;

redux-thunk使用说明

1.3、redux-thunk中间件 和 redux-devtools-extension 一起使用的写法

// 引入compose
import { createStore, applyMiddleware, compose} from 'redux';
import reducer from './reducer';
import thunk from 'redux-thunk';

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose;
const enhancer = composeEnhancers(
  applyMiddleware(thunk),
);

const store = createStore(reducer, enhancer);

export default store;

Redux DevTools插件配置说明

2、redux-thunk 的作用和优点

  1. 不使用redux-thunk中间件,store接收的action只能是对象;有了redux-thunk中间件,action也可以是一个函数。这样子就可以在action中做异步操作等。
  2. store接收到action之后发现action是函数而不是对象,则会执行调用这个action函数。
  3. 可以把复杂的异步数据处理从组件的生命周期里摘除出来(放到action中),避免组件过于庞大,方便后期维护、自动化测试。

3、使用redux-thunk的流程

  1. 在创建store时,使用redux-thunk。详见以上配置说明。

  2. 在actionCreators.js中创建返回一个方法的action,并导出。在这个方法中执行http请求。

import types from './actionTypes';
import axios from 'axios';

export const initItemAction = (value) => ({
  type: types.INIT_TODO_ITEM,
  value: value
})

// 当使用redux-thunk后,action不仅可以是对象,还可以是函数
// 返回的如果是方法会自动执行
// 返回的方法可以接收到dispatch方法,去派发其它action
export const getTodoList = () => {
  return (dispatch) => {
    axios.get('/initList').then(res => {
      const action = initItemAction(res.data);
      dispatch(action);
    })
  }
}

export const inputChangeAction = (value) => ({
  type: types.CHANGE_INPUT_VALUE,
  value: value
})

export const addItemAction = (value) => ({
  type: types.ADD_TODO_ITEM
})

export const deleteItemAction = (index) => ({
  type: types.DELETE_TODO_ITEM,
  value: index
})
  1. 在组件中引用这个action,并在componentDidMount中派发该action给store
import React, {Component} from 'react';

import store from './store';
import { getTodoList } from './store/actionCreators';

class TodoList extends Component {

  ...

  // 初始化数据(使用redux-thunk派发/执行一个action函数)
  componentDidMount() {
    const action = getTodoList();
    store.dispatch(action);
  }

  ...
}

export default TodoList;

4、具体执行流程

  1. 组件加载完成后,把处理异步请求的action函数派发给store;
  2. 因使用了redux-thunk中间件,所以可以接收一个action函数(正常只能接收action对象)并执行该方法;
  3. 在这个方法中执行http异步请求,拿到结果后再次派发一个正常的action对象给store;
  4. store发现是action对象,则根据拿来的值修改store中的状态。

五、什么是Redux的中间件

  1. 中间件指的是action 和 store 中间。
  2. 中间件实现是对store的dispatch方法的升级。

Redux数据流

几个常见中间件的作用(对dispatch方法的升级)

  1. redux-thunk:使store不但可以接收action对象,还可以接收action函数。当action是函数时,直接执行该函数。
  2. redux-log:每次dispatch时,在控制台输出内容。
  3. redux-saga:也是处理异步逻辑,把异步逻辑单独放在一个文件中管理。

六、redux-saga中间件入门

1、安装和配置redux-saga

1.1、安装redux-saga

npm install --save redux-saga

yarn add redux-saga

1.2、正常使用redux-saga中间件在store中的写法

import { createStore, applyMiddleware} from 'redux';
import createSagaMiddleware from 'redux-saga';

import reducer from './reducer';
import mySaga from './sagas';

const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(mySaga);

export default store;

redux-saga使用说明

1.3、redux-saga中间件 和 redux-devtools-extension 一起使用的写法

import { createStore, applyMiddleware, compose} from 'redux';
import createSagaMiddleware from 'redux-saga';

import reducer from './reducer';
import mySaga from './sagas';

const sagaMiddleware = createSagaMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose;
const enhancer = composeEnhancers(
  applyMiddleware(sagaMiddleware),
);
const store = createStore(reducer, enhancer);
sagaMiddleware.run(mySaga);

export default store;

Redux DevTools插件配置说明

2、redux-saga 的作用和与redux-thunk的比较

  1. redux-saga也是解决异步请求的。但是redux-thunk的异步处理还是在aciton中,而redux-saga的异步处理是在一个单独的文件(sagas.js)中处理。
  2. redux-saga同样是作异步代码拆分的中间件,可以使用redux-saga完全代替redux-thunk。(redux-saga使用起来更复杂,更适合大型项目)
  3. redux-thunk只是把异步请求放到action中,并没有多余的API。而redux-saga是单独放在一个文件中处理,并且有很多PAI。
  4. 使用流程上的区别; 4.1. 使用redux-thunk时,从组件中派发action(action函数)时,监测到是函数,会在action中接收并处理,然后拿到结果后再派发一个普通action交给store的reducer处理,更新store的状态。 4.2. 使用redux-saga时,从组件中派发action(普通action对象)时,会先交给sagas.js匹配处理异步请求。拿到结果后再使用put方法派发一个普通action交给store的reducer处理,更新store的状态。

3、使用redux-saga的流程

  1. 在创建store时,使用redux-saga。详见以上配置说明。

  2. 在actionCreators.js中创建一个普通的action,并导出。

import types from './actionTypes';
// import axios from 'axios';

export const initItemAction = (value) => ({
  type: types.INIT_TODO_ITEM,
  value: value
})

// redux-thunk的写法,异步请求依然在这个文件中
// export const getTodoList = () => {
//   return (dispatch) => {
//     axios.get('/initList').then(res => {
//       const action = initItemAction(res.data);
//       dispatch(action);
//     })
//   }
// }

// redux-saga的写法,这里返回一个普通action对象;
// sagas.js中会用takeEvery监听这个type类型,然后执行对应的异步请求
export const getTodoList = () => ({
  type: types.GET_INIT_ACTION,
})

export const inputChangeAction = (value) => ({
  type: types.CHANGE_INPUT_VALUE,
  value: value
})

export const addItemAction = (value) => ({
  type: types.ADD_TODO_ITEM
})

export const deleteItemAction = (index) => ({
  type: types.DELETE_TODO_ITEM,
  value: index
})
  1. 在store文件夹中,创建一个文件sagas.js,使用redux-saga的takeEvery方法监听刚才派发的type类型,然后执行对应的函数,执行异步请求代码。拿到结果后再使用redux-saga的put方法派发一个普通的action对象,交给store的reducer处理。
import { takeEvery, put } from 'redux-saga/effects';
import types from './actionTypes';
import axios from 'axios';
import { initItemAction } from './actionCreators';

function* getInitList() {
  try {
    const res = yield axios.get('/initList');
    const action = initItemAction(res.data);
    yield put(action);
  } catch(e) {
    console.log('接口请求失败');
  }
}

// generator 函数
function* mySaga() {
  yield takeEvery(types.GET_INIT_ACTION, getInitList);
}

export default mySaga;
  1. 在组件中引用这个action,并在componentDidMount中派发该action给store
import React, {Component} from 'react';

import store from './store';
import { getTodoList } from './store/actionCreators';

class TodoList extends Component {

  ...

  // 初始化数据(使用redux-saga派发一个普通action对象,经由sagas.js的generator 函数匹配处理后,再交由store的reducer处理)
  componentDidMount() {
    const action = getTodoList();
    store.dispatch(action);
  }

  ...
}

export default TodoList;

4、具体执行流程

  1. 组件加载完成后,把一个普通的action对象派发给store;
  2. 因使用了redux-saga中间件,所以会被sagas.js中的generator函数匹配到,并交给对应的函数(一般也是generator函数)处理;
  3. sagas.js的函数拿到结果后,使用redux-saga的put方法再次派发一个普通action对象给store;
  4. sagas.js中没有匹配到对应的类型,则store交由reducer处理并更新store的状态。

七、如何使用React-redux完成TodoList功能

安装React-redux

npm install react-redux --save

1、把redux写法改成React-redux写法

1.1、 入口文件(src/index.js)的修改

  • 使用react-redux的Provider组件(提供器)包裹所有组件,把 store 作为 props 传递到每一个被 connect() 包装的组件。
  • 使组件层级中的 connect() 方法都能够获得 Redux store,这样子内部所有组件就都有能力获取store的内容(通过connect链接store)。

原代码

import React from 'react';
import ReactDOM from 'react-dom';
import TodoList from './todoList';

ReactDOM.render(<TodoList />, document.getElementById('root'));

修改后代码 ```jsx import React from 'react'; import ReactDOM from 'react-dom'; import TodoList from './TodoList'; import { Provider } from 'react-redux'; import store from './store';

// Provider向内部所有组件提供store,内部组件都可以获得store const App = ( )

ReactDOM.render(App, document.getElementById('root'));

<br/>
#### 1.2、组件(TodoList.js)代码的修改

Provider的子组件通过react-redux中的connect连接store,写法:
```jsx
connect(mapStateToProps, mapDispatchToProps)(Component)
  • mapStateToProps:store中的数据映射到组件的props中;
  • mapDispatchToProps:把store.dispatch方法挂载到props上;
  • Component:Provider中的子组件本身;

导出的不是单纯的组件,而是导出由connect处理后的组件(connect处理前是一个UI组件,connect处理后是一个容器组件)。


原代码
import React, { Component } from 'react';
import store from './store';

class TodoList extends Component {
  constructor(props) {
    super(props);
    // 获取store,并赋值给state
    this.state = store.getState();
    
    // 统一在constructor中绑定this,提交性能
    this.handleChange = this.handleChange.bind(this);
    this.handleStoreChange = this.handleStoreChange.bind(this);
    this.handleClick = this.handleClick.bind(this);

    // 在组件中订阅store,只要store改变就触发这个函数
    this.unsubscribe = store.subscribe(this.handleStoreChange);
  }

  // 当store状态改变时,更新state
  handleStoreChange() {
    // 用从store中获取的state,来设置state
    this.setState(store.getState());
  }

  render() {
    return(
      <div>
        <div>
          <input value={this.state.inputValue} onChange={this.handleChange} />
          <button onClick={this.handleClick}>提交</button>
        </div>
        <ul>
          {
            this.state.list.map((item, index) => {
              return <li onClick={() => {this.handleDelete(index)}} key={index}>{item}</li>
            })
          }
        </ul>
      </div>
    )
  }

  // 组件注销前把store的订阅取消
  componentWillUnmount() {
    this.unsubscribe();
  }
  
  handleChange(e) {
    const action = {
      type: 'change-input-value',
      value: e.target.value
    }
    store.dispatch(action);
  }

  handleClick() {
    const action = {
      type: 'add-item'
    }
    store.dispatch(action)
  }

  handleDelete(index) {
    const action = {
      type: 'delete-item',
      value: index
    }
    store.dispatch(action);
  }
}

export default TodoList;

修改后代码

省去了订阅store使用store.getState()更新状态的操作。组件会自动更新数据。

import React, { Component } from 'react';
import { connect } from 'react-redux';

class TodoList extends Component {
  render() {
    // const { inputValue, handleChange, handleClick, list, handleDelete} = this.props;

    return(
      <div>
        <div>
          <input value={this.props.inputValue} onChange={this.props.handleChange} />
          <button onClick={this.props.handleClick}>提交</button>
        </div>
        <ul>
          {
            this.props.list.map((item, index) => {
              return <li onClick={() => {this.props.handleDelete(index)}} key={index}>{item}</li>
            })
          }
        </ul>
      </div>
    )
  }
}

// 把store的数据 映射到 组件的props中
const mapStateToProps = (state) => {
  return {
    inputValue: state.inputValue,
    list: state.list
  }
}

// 把store的dispatch 映射到 组件的props中
const mapDispatchToProps = (dispatch) => {
  return {
    handleChange(e) {
      const action = {
        type: 'change-input-value',
        value: e.target.value
      }
      dispatch(action);
    },
    handleClick() {
      const action = {
        type: 'add-item'
      }
      dispatch(action)
    },
    handleDelete(index) {
      const action = {
        type: 'delete-item',
        value: index
      }
      dispatch(action);
    }
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(TodoList);

#### 1.3、store/index.js 代码不需要修改 ```jsx import { createStore } from 'redux'; import reducer from './reducer'

const store = createStore(reducer);

export default store;

<br/>
#### 1.4、store/reducer.js 代码也不需要修改
```jsx
const defaultState = {
  inputValue: '',
  list: []
}
export default (state = defaultState, action) => {
  const { type, value } = action;
  let newState = JSON.parse(JSON.stringify(state));

  switch(type) {
    case 'change-input-value':
      newState.inputValue = value;
      break;
    case 'add-item':
      newState.list.push(newState.inputValue);
      newState.inputValue = '';
      break;
    case 'delete-item':
      newState.list.splice(value, 1);
      break;
    default:
      return state;
  }

  return newState;
}

2、代码精简及性能优化

  • 因现在组件(TodoList.js)中代码只是用来渲染,是UI组件。并且没有状态(state),是个无状态组件。所以可以改成无状态组件,提高性能。
  • 但connect函数返回的是一个容器组件。
import React from 'react';
import { connect } from 'react-redux';

const TodoList = (props) => {
  const { inputValue, handleChange, handleClick, list, handleDelete} = props;

  return(
    <div>
      <div>
        <input value={inputValue} onChange={handleChange} />
        <button onClick={handleClick}>提交</button>
      </div>
      <ul>
        {
          list.map((item, index) => {
            return <li onClick={() => {handleDelete(index)}} key={index}>{item}</li>
          })
        }
      </ul>
    </div>
  )
}


// 把store的数据 映射到 组件的props中
const mapStateToProps = (state) => {
  return {
    inputValue: state.inputValue,
    list: state.list
  }
}

// 把store的dispatch 映射到 组件的props中
const mapDispatchToProps = (dispatch) => {
  return {
    handleChange(e) {
      const action = {
        type: 'change-input-value',
        value: e.target.value
      }
      dispatch(action);
    },
    handleClick() {
      const action = {
        type: 'add-item'
      }
      dispatch(action)
    },
    handleDelete(index) {
      const action = {
        type: 'delete-item',
        value: index
      }
      dispatch(action);
    }
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(TodoList);

Next.js 性能优化双绝:Image 与 next/font 的底层修炼手册

在前端性能优化的江湖里,Next.js 就像一位自带 “武功秘籍” 的高手,而Image组件与next/font模块,便是它克敌制胜的两大门派绝学。前者专治 “图片加载慢如龟爬” 的顽疾,后者则破解 “字体渲染闪瞎眼” 的魔咒。这两门手艺看似简单,实则暗藏计算机底层的运行逻辑,就像武侠小说里的招式,需懂其 “内力” 运转之法,方能融会贯通。

一、Image 组件:让图片加载 “轻装上阵”

网页加载时,图片往往是 “流量大户”—— 一张未经优化的高清图,可能比整个 JS 脚本还大。浏览器加载图片的过程,就像快递员送大件包裹:先得确认包裹(图片)的大小、地址(URL),再慢悠悠地搬运,期间还可能占用主干道(带宽),导致其他 “小包裹”(文本、按钮)迟迟无法送达。Next.js 的Image组件,本质上是给快递员配了 “智能调度系统”,从底层优化了整个运输流程。

(一)核心优化原理:直击浏览器渲染痛点

传统的标签就像个 “一根筋” 的快递员,不管用户的设备(手机 / 电脑)、网络(5G/WiFi)如何,都一股脑儿发送最大尺寸的图片。而Image组件的优化逻辑,源于计算机图形学与网络传输的底层规律:

  1. 自适应尺寸:按 “需求” 分配资源

不同设备的屏幕分辨率天差地别(比如手机 720p vs 电脑 2K 屏),但图片的 “像素密度”(PPI)只需匹配屏幕即可。Image组件会自动生成多种分辨率的图片(如 1x、2x、3x),让手机只加载小尺寸图,电脑加载高清图,避免 “小马拉大车” 的资源浪费。这就像裁缝做衣服,根据客户的身高体重(设备分辨率)裁剪布料(图片像素),而非给所有人都发一件 XXL 的外套。

  1. 懒加载:“按需配送” 省带宽

浏览器默认会加载页面上所有图片,哪怕是用户需要滚动很久才能看到的底部图片。这就像外卖小哥不管你吃不吃,先把一天的饭菜全送到你家门口。Image组件的懒加载功能,会监听用户的滚动位置(通过浏览器的IntersectionObserverAPI),只有当图片进入 “可视区域”(比如屏幕下方 100px)时才开始加载。从底层看,这减少了 HTTP 请求的并发数,避免了网络带宽被 “无效请求” 占用,让关键资源(如导航栏、正文)更快加载完成。

  1. 自动优化:给图片 “瘦身” 不 “缩水”

Next.js 会自动对图片进行格式转换(如将 JPG 转为 WebP,体积减少 30% 以上)和压缩,且不影响视觉效果。这背后的原理是:不同图片格式的 “压缩算法” 不同 ——WebP 采用了更高效的 “有损压缩 + 无损压缩” 混合策略,在相同画质下,文件体积比 JPG 小得多。就像把棉花糖(原始图片)放进真空袋(优化算法),体积变小了,但松开后还是原来的形状(画质不变)。

(二)实战用法:3 步掌握 “图片轻功”

使用Image组件只需记住一个核心:必须指定 width height (或通过 layout 属性动态适配) ,否则 Next.js 无法提前计算图片的占位空间,可能导致页面 “抖动”(Cumulative Layout Shift,CLS,核心 Web 指标之一)。

1. 基础用法:本地图片与远程图片

  • 本地图片(推荐) :放在public文件夹下,直接通过路径引入,Next.js 会自动处理优化。
import Image from 'next/image';
export default function Home() {
  return (
    <div>
      {/* 本地图片:自动优化尺寸、格式 */}
      <Image
        src="/cat.jpg" // public文件夹下的路径
        alt="一只可爱的猫"
        width={600} // 图片宽度像素height={400} // 图片高度像素)
        // layout="responsive" // 可选让图片适应父容器宽度保持宽高比
      />
    </div>
  );
}
  • 远程图片:需在next.config.js中配置domains,告诉 Next.js “这是安全的图片源”,避免被浏览器的 CSP(内容安全策略)拦截。
// next.config.js
module.exports = {
  images: {
    domains: ['picsum.photos'], // 允许加载的远程图片域名
  },
};
// 组件中使用
<Image
  src="https://picsum.photos/800/600" // 远程图片URL
  alt="随机图片"
  width={800}
  height={600}
  priority // 可选:标记为“优先加载”(如首屏Banner图)
/>

2. 进阶技巧:自定义占位符与加载效果

为了避免图片加载时出现 “空白区域”,可以用placeholder属性设置占位符,提升用户体验:

<Image
  src="/dog.jpg"
  alt="一只活泼的狗"
  width={600}
  height={400}
  placeholder="blur" // 模糊占位符(推荐)
  blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" // 模糊占位图的Base64编码(小尺寸,快速加载)
/>

这里的blurDataURL就像 “预告片”,在正片(原图)加载完成前,先给用户看一个模糊的缩略版,避免页面 “冷场”。从底层看,Base64 编码的图片会直接嵌入 HTML,无需额外 HTTP 请求,加载速度极快。

3. 避坑指南:别踩 “尺寸适配” 的坑

如果图片需要自适应父容器宽度(比如在响应式布局中),必须用layout="responsive"或layout="fill",且给父容器设置position: relative:

// 响应式图片:适应父容器宽度,保持宽高比
<div style={{ position: 'relative', width: '100%', maxWidth: '800px' }}>
  <Image
    src="/mountain.jpg"
    alt="山脉风景"
    layout="fill" // 让图片填充父容器
    objectFit="cover" // 类似CSS的object-fit,避免图片拉伸
  />
</div>

若不设置父容器的position: relative,layout="fill"的图片会 “飞” 出文档流,就像没系安全带的乘客在车里乱晃,导致页面布局混乱。

二、next/font:让字体渲染 “稳如泰山”

字体加载的 “闪屏问题”(Flash of Unstyled Text,FOUT),是前端开发者的 “老冤家”:浏览器加载网页时,会先显示默认字体(如宋体),等自定义字体(如思源黑体)加载完成后,再突然替换,导致页面 “跳一下”。这就像演员上台前没穿戏服,先穿着便服亮相,等戏服到了再慌忙换上,让观众一脸懵。next/font模块的出现,从底层解决了这个问题,让字体渲染 “无缝衔接”。

(一)核心优化原理:字体加载的 “暗度陈仓”

传统加载字体的方式(通过@font-face引入),本质是让浏览器 “边加载边渲染”,而next/font的优化逻辑,源于浏览器的 “字体渲染机制” 和 “构建时优化”:

  1. 构建时嵌入:把字体 “焊死” 在代码里

Next.js 在构建项目时,会将自定义字体文件(如.ttf、.woff2)处理成 “优化后的静态资源”,并直接嵌入到 JS 或 CSS 中(通过 Base64 编码或按需生成字体文件)。这就像厨师提前把调料(字体)炒进菜里(代码),而非等客人上桌了才临时找调料。从底层看,这减少了字体文件的 HTTP 请求,避免了 “字体加载滞后于页面渲染” 的问题。

  1. 字体子集化:只带 “必要的字” 出门

中文字体文件通常很大(比如思源黑体全量文件超过 10MB),但大多数网页只用到其中的几百个常用字。next/font会自动进行 “字体子集化”,只提取网页中实际用到的字符,生成体积极小的字体文件(可能只有几十 KB)。这就像出门旅行时,只带需要穿的衣服,而非把整个衣柜都搬走,极大减少了加载时间。

  1. 阻止 FOUT:让浏览器 “等字体再渲染”

通过next/font加载的字体,会被标记为 “关键资源”,浏览器会等待字体加载完成后再渲染文本,避免出现 “默认字体→自定义字体” 的跳转。但为了防止字体加载失败导致文本无法显示,Next.js 会设置一个 “超时时间”(默认 3 秒),若超时仍未加载完成,会自动降级为默认字体,兼顾性能与可用性。

(二)实战用法:2 步实现 “字体无痕加载”

next/font支持两种字体来源:本地字体文件Google Fonts,前者更灵活(可控制字体文件),后者更方便(无需手动下载字体)。

1. 本地字体:掌控字体 “全生命周期”

第一步:将字体文件(如SimHei.ttf)放在public/fonts文件夹下;

第二步:在组件中通过next/font/local加载,并应用到文本上。

import { localFont } from 'next/font/local';
// 加载本地字体:指定字体文件路径,设置显示策略
const myFont = localFont({
  src: [
    {
      path: '../public/fonts/SimHei-Regular.ttf',
      weight: '400', // 字体粗细
      style: 'normal', // 字体样式
    },
  ],
  display: 'swap', // 字体加载策略:swap表示“先显示默认字体,加载完成后替换”(适合非首屏文本)
  // display: 'block', // 适合首屏文本:等待字体加载完成后再显示,避免FOUT
});
export default function FontDemo() {
  // 将字体类名应用到元素上
  return <p className={myFont.className}>这段文字会使用本地的“黑体”字体,且不会闪屏!</p>;
}

2. Google Fonts:一键 “召唤” 免费字体

Next.js 内置了 Google Fonts 的优化支持,无需手动引入 CSS,直接通过next/font/google加载,且会自动处理字体子集化和缓存:

import { Inter } from 'next/font/google';
// 加载Google Fonts的“Inter”字体:weight指定需要的粗细
const inter = Inter({
  weight: ['400', '700'], // 加载400(常规)和700(粗体)两种粗细
  subsets: ['latin'], // 只加载“拉丁字符”子集(适合英文网站,体积更小)
  display: 'block',
});
export default function GoogleFontDemo() {
  return (
    <div className={inter.className}>
      <h1>标题使用Inter粗体</h1>
      <p>正文使用Inter常规体,加载速度飞快!</p>
    </div>
  );
}

这里的subsets参数是性能优化的关键 —— 如果你的网站只有中文,就不要加载latin子集;反之亦然。就像点外卖时,只点自己爱吃的菜,避免浪费。

3. 全局使用:让整个网站 “统一字体风格”

若想让字体应用到整个网站,只需在pages/_app.js(Next.js 13 App Router 则在app/layout.js)中全局引入:

// pages/_app.js
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
function MyApp({ Component, pageProps }) {
  // 将字体类名应用到根元素
  return (
    <main className={inter.className}>
      <Component {...pageProps} />
    </main>
  );
}
export default MyApp;

三、双剑合璧:性能优化的 “组合拳”

单独使用Image和next/font已能解决大部分性能问题,但若将两者结合,再配合 Next.js 的其他特性(如静态生成、边缘缓存),就能打造 “极致性能” 的网页。举个实战案例:

import Image from 'next/image';
import { Noto_Sans_SC } from 'next/font/google';
// 加载中文字体“Noto Sans SC”(适合中文显示)
const notoSansSC = Noto_Sans_SC({
  weight: '400',
  subsets: ['chinese-simplified'], // 只加载简体中文字符
  display: 'block',
});
export default function BlogPost() {
  return (
    <article className={notoSansSC.className} style={{ maxWidth: '800px', margin: '0 auto' }}>
      <h1>我的旅行日记</h1>
      {/* 首屏Banner图:优先加载,响应式布局 */}
      <div style={{ position: 'relative', width: '100%', height: '300px', margin: '20px 0' }}>
        <Image
          src="/travel.jpg"
          alt="旅行风景"
          layout="fill"
          objectFit="cover"
          priority // 首屏图片优先加载
          placeholder="blur"
          blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
        />
      </div>
      <p>这是一篇使用Next.js优化的博客文章,图片加载流畅,字体渲染无闪屏,用户体验拉满!</p>
      {/* 非首屏图片:懒加载 */}
      <Image
        src="/food.jpg"
        alt="当地美食"
        width={800}
        height={500}
        style={{ margin: '20px 0' }}
      />
    </article>
  );
}

这个案例中:

  • next/font确保中文显示美观且无闪屏,subsets: ['chinese-simplified']让字体文件体积缩减到几十 KB;
  • Image组件让首屏 Banner 图优先加载,非首屏图片懒加载,配合模糊占位符提升体验;
  • 整体代码兼顾了性能(核心 Web 指标优化)和开发效率(无需手动处理字体子集、图片压缩)。

四、总结:优化的本质是 “尊重底层规律”

Next.js 的Image和next/font之所以强大,并非因为它们 “发明了新技术”,而是因为它们 “顺应了计算机的底层运行规律”:

  • 图片优化的核心,是 “按需分配像素资源”,避免网络带宽和设备性能的浪费;
  • 字体优化的核心,是 “提前嵌入关键资源”,避免浏览器渲染流程的中断。

就像武侠高手练功,并非凭空创造招式,而是领悟 “天地自然之道”—— 水流就下,火炎上腾,顺应规律,方能事半功倍。掌握这两门 “绝学”,不仅能让你的 Next.js 项目性能飙升,更能让你看透前端优化的本质:所有优秀的上层框架,都是对底层原理的优雅封装

现在,不妨打开你的 Next.js 项目,给图片配上Image组件,给字体换上next/font,亲眼看看这 “双剑合璧” 的威力吧!

❌