每日一题-平衡二叉树🟢
给定一个二叉树,判断它是否是 平衡二叉树
示例 1:

输入:root = [3,9,20,null,null,15,7] 输出:true
示例 2:

输入:root = [1,2,2,3,3,null,null,4,4] 输出:false
示例 3:
输入:root = [] 输出:true
提示:
- 树中的节点数在范围
[0, 5000]内 -104 <= Node.val <= 104
给定一个二叉树,判断它是否是 平衡二叉树
示例 1:

输入:root = [3,9,20,null,null,15,7] 输出:true
示例 2:

输入:root = [1,2,2,3,3,null,null,4,4] 输出:false
示例 3:
输入:root = [] 输出:true
提示:
[0, 5000] 内-104 <= Node.val <= 104在Unity的Shader Graph系统中,Custom Depth Node(自定义深度节点)是一个功能强大的工具,专门用于访问和处理高清渲染管线(HDRP)中的自定义深度缓冲区。这个节点为着色器开发者提供了精细控制深度信息的能力,是实现高级渲染效果的基石。
Custom Depth Node在不同渲染管线中的支持情况是开发者必须首先了解的关键信息。这个节点的设计初衷是为了满足HDRP的高级渲染需求,因此在兼容性上有着明确的界限划分。
高清渲染管线(HDRP)支持
HDRP作为Unity的高端渲染解决方案,专门为需要高质量图形表现的项目设计。在这个管线中,Custom Depth Node能够完全发挥其功能:
通用渲染管线(URP)不支持
URP作为轻量级的通用渲染解决方案,在深度缓冲区的管理上采用了不同的策略:
这种兼容性差异源于两个渲染管线的设计哲学和目标平台的不同。HDRP面向高端平台,追求极致的视觉效果,而URP则注重性能和跨平台兼容性。
Custom Depth Node的端口配置决定了它如何接收输入数据和输出处理结果。深入理解每个端口的功能对于正确使用该节点至关重要。
UV输入端口
UV输入端口是Custom Depth Node的核心配置项,它决定了深度采样的位置和方式:
UV端口的正确配置需要考虑多个因素:
在实际使用中,UV输入端口的配置示例:
HLSL
// 直接使用屏幕位置
float4 screenPos = GetScreenPosition();
// 手动计算UV坐标
float2 uv = float2(input.position.x / _ScreenParams.x,
input.position.y / _ScreenParams.y);
输出端口
输出端口提供了处理后的深度数据:
输出数据的解读依赖于选择的深度采样模式,不同模式下的输出含义各不相同。开发者需要根据具体的渲染需求选择合适的采样模式。
深度采样模式决定了Custom Depth Node如何处理和输出深度信息。每种模式都有其特定的应用场景和数学特性。
Linear01采样模式
Linear01模式将深度值线性化并归一化到[0,1]范围内:
Linear01模式的数学原理:
HLSL
float Linear01Depth(float z)
{
return 1.0 / (_ZBufferParams.x * z + _ZBufferParams.y);
}
在实际应用中的优势:
Raw采样模式
Raw模式直接输出深度缓冲区中的原始数值:
Raw模式的特性分析:
Eye采样模式
Eye模式将深度值转换为视空间中的实际距离:
Eye模式的转换原理:
HLSL
float LinearEyeDepth(float z)
{
return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w);
}
这种模式在实际项目中的应用价值:
Custom Depth Node在HDRP项目中有广泛的应用场景,以下是几个典型的应用案例。
高级景深效果实现
使用Custom Depth Node可以实现电影级别的景深效果:
HLSL
// 景深效果的核心实现
void ApplyDepthOfField(float2 uv, float focusDistance, float focalLength)
{
float depth = SampleCustomDepth(uv, LINEAR_EYE);
float blurAmount = saturate(abs(depth - focusDistance) / focalLength);
// 基于深度差异应用模糊
return ApplyBlur(uv, blurAmount);
}
实现要点:
交互式水体和液体效果
Custom Depth Node在液体渲染中发挥关键作用:
HLSL
// 水体表面与场景交互
void CalculateWaterEffects(float2 uv, float waterLevel)
{
float sceneDepth = SampleCustomDepth(uv, LINEAR_EYE);
float waterDepth = max(0, sceneDepth - waterLevel);
// 基于水深调整颜色和透明度
float3 waterColor = Lerp(_ShallowColor, _DeepColor, waterDepth / _MaxDepth);
float transparency = exp(-waterDepth * _Absorption);
}
技术细节:
体积雾和大气效果
利用深度信息创建真实的体积效果:
HLSL
// 体积雾密度计算
float CalculateFogDensity(float2 uv, float3 worldPos)
{
float depth = SampleCustomDepth(uv, LINEAR_EYE);
float fogDensity = 0.0;
// 基于距离的指数雾
fogDensity = _FogDensity * exp(-depth * _FogFalloff);
// 添加高度雾
fogDensity += _HeightFogDensity * exp(-worldPos.y * _HeightFalloff);
return saturate(fogDensity);
}
优化考虑:
在使用Custom Depth Node时,性能优化是必须考虑的重要因素。
深度采样优化策略
内存带宽优化
HLSL
// 优化的深度采样模式选择
#ifndef REQUIRE_HIGH_PRECISION_DEPTH
// 使用较低精度的采样
float depth = SampleCustomDepth(uv, LINEAR01);
#else
// 需要高精度时使用完整精度
float depth = SampleCustomDepth(uv, LINEAR_EYE);
#endif
平台特定优化
不同硬件平台对深度采样的支持存在差异:
自定义深度与运动矢量结合
HLSL
// 结合深度和运动矢量实现运动模糊
void AdvancedMotionBlur(float2 uv, float2 motionVector)
{
float currentDepth = SampleCustomDepth(uv, LINEAR_EYE);
float2 prevUV = uv - motionVector;
float previousDepth = SampleCustomDepth(prevUV, LINEAR_EYE);
// 基于深度一致性验证运动矢量
if(abs(currentDepth - previousDepth) < _DepthTolerance)
{
// 应用高质量运动模糊
return ApplyMotionBlur(uv, motionVector);
}
else
{
// 回退到普通运动模糊
return FallbackMotionBlur(uv, motionVector);
}
}
深度精度问题解决
深度精度问题是深度渲染中的常见挑战:
多相机渲染中的深度管理
在复杂渲染管线中处理多相机场景:
HLSL
// 多相机深度合成
float CompositeMultiCameraDepth(float2 uv)
{
float mainCameraDepth = SampleCustomDepth(uv, LINEAR_EYE);
float secondaryCameraDepth = SampleSecondaryDepth(uv, LINEAR_EYE);
// 基于渲染优先级合成深度
return min(mainCameraDepth, secondaryCameraDepth);
}
Custom Depth Node很少单独使用,通常需要与其他Shader Graph节点配合。
与Scene Depth节点的对比使用
HLSL
// 场景深度与自定义深度的混合使用
void HybridDepthEffects(float2 uv)
{
float sceneDepth = SceneDepth(uv);
float customDepth = CustomDepth(uv, LINEAR_EYE);
// 基于特定条件选择深度源
float finalDepth = customDepth > 0 ? customDepth : sceneDepth;
// 应用深度相关效果
ApplyDepthBasedEffects(uv, finalDepth);
}
在渲染管线中的集成
Custom Depth Node需要正确集成到HDRP渲染管线中:
深度效果的调试是开发过程中的重要环节。
深度可视化工具
HLSL
// 深度值可视化
float3 VisualizeDepth(float depth, int mode)
{
switch(mode)
{
case 0: // 灰度可视化
return depth.xxx;
case 1: // 热力图
return HeatMap(depth, 0, _FarClipPlane);
case 2: // 等高线
return ContourLines(depth, _ContourSpacing);
default:
return float3(1,0,1); // 错误颜色
}
}
常见问题诊断
【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)
看完这两期视频,让你对递归的理解更上一层楼!
问:代码中的 $-1$ 是怎么产生的?怎么返回的?
答:在某次递归中,发现左右子树高度绝对差大于 $1$,我们会返回 $-1$。这个 $-1$ 会一路向上不断返回,直到根节点。
class Solution:
def isBalanced(self, root: Optional[TreeNode]) -> bool:
def get_height(node: Optional[TreeNode]) -> int:
if node is None:
return 0
left_h = get_height(node.left)
right_h = get_height(node.right)
if left_h == -1 or right_h == -1 or abs(left_h - right_h) > 1:
return -1
return max(left_h, right_h) + 1
return get_height(root) != -1
class Solution {
public boolean isBalanced(TreeNode root) {
return getHeight(root) != -1;
}
private int getHeight(TreeNode node) {
if (node == null) {
return 0;
}
int leftH = getHeight(node.left);
int rightH = getHeight(node.right);
if (leftH == -1 || rightH == -1 || Math.abs(leftH - rightH) > 1) {
return -1;
}
return Math.max(leftH, rightH) + 1;
}
}
class Solution {
int get_height(TreeNode* node) {
if (node == nullptr) {
return 0;
}
int left_h = get_height(node->left);
int right_h = get_height(node->right);
if (left_h == -1 || right_h == -1 || abs(left_h - right_h) > 1) {
return -1;
}
return max(left_h, right_h) + 1;
}
public:
bool isBalanced(TreeNode* root) {
return get_height(root) != -1;
}
};
#define MAX(a, b) ((b) > (a) ? (b) : (a))
int getHeight(struct TreeNode* node) {
if (node == NULL) {
return 0;
}
int left_h = getHeight(node->left);
int right_h = getHeight(node->right);
if (left_h == -1 || right_h == -1 || abs(left_h - right_h) > 1) {
return -1;
}
return MAX(left_h, right_h) + 1;
}
bool isBalanced(struct TreeNode* root) {
return getHeight(root) != -1;
}
func getHeight(node *TreeNode) int {
if node == nil {
return 0
}
leftH := getHeight(node.Left)
rightH := getHeight(node.Right)
if leftH == -1 || rightH == -1 || abs(leftH-rightH) > 1 {
return -1
}
return max(leftH, rightH) + 1
}
func isBalanced(root *TreeNode) bool {
return getHeight(root) != -1
}
func abs(x int) int { if x < 0 { return -x }; return x }
function getHeight(node) {
if (node === null) {
return 0;
}
const leftH = getHeight(node.left);
const rightH = getHeight(node.right);
if (leftH === -1 || rightH === -1 || Math.abs(leftH - rightH) > 1) {
return -1;
}
return Math.max(leftH, rightH) + 1;
}
var isBalanced = function(root) {
return getHeight(root) !== -1;
};
use std::rc::Rc;
use std::cell::RefCell;
impl Solution {
pub fn is_balanced(root: Option<Rc<RefCell<TreeNode>>>) -> bool {
fn get_height(node: &Option<Rc<RefCell<TreeNode>>>) -> i32 {
if let Some(node) = node {
let node = node.borrow();
let left_h = get_height(&node.left);
let right_h = get_height(&node.right);
if left_h == -1 || right_h == -1 || (left_h - right_h).abs() > 1 {
return -1;
}
return left_h.max(right_h) + 1;
}
0
}
get_height(&root) != -1
}
}
class Solution:
def isBalanced(self, root: Optional[TreeNode]) -> bool:
def get_height(node: Optional[TreeNode]) -> int:
if node is None:
return 0
left_h = get_height(node.left)
if left_h == -1:
return -1 # 提前退出,不再递归
right_h = get_height(node.right)
if right_h == -1 or abs(left_h - right_h) > 1:
return -1
return max(left_h, right_h) + 1
return get_height(root) != -1
class Solution {
public boolean isBalanced(TreeNode root) {
return getHeight(root) != -1;
}
private int getHeight(TreeNode node) {
if (node == null) {
return 0;
}
int leftH = getHeight(node.left);
if (leftH == -1) {
return -1; // 提前退出,不再递归
}
int rightH = getHeight(node.right);
if (rightH == -1 || Math.abs(leftH - rightH) > 1) {
return -1;
}
return Math.max(leftH, rightH) + 1;
}
}
class Solution {
int get_height(TreeNode* node) {
if (node == nullptr) {
return 0;
}
int left_h = get_height(node->left);
if (left_h == -1) {
return -1; // 提前退出,不再递归
}
int right_h = get_height(node->right);
if (right_h == -1 || abs(left_h - right_h) > 1) {
return -1;
}
return max(left_h, right_h) + 1;
}
public:
bool isBalanced(TreeNode* root) {
return get_height(root) != -1;
}
};
#define MAX(a, b) ((b) > (a) ? (b) : (a))
int getHeight(struct TreeNode* node) {
if (node == NULL) {
return 0;
}
int left_h = getHeight(node->left);
if (left_h == -1) {
return -1; // 提前退出,不再递归
}
int right_h = getHeight(node->right);
if (right_h == -1 || abs(left_h - right_h) > 1) {
return -1;
}
return MAX(left_h, right_h) + 1;
}
bool isBalanced(struct TreeNode* root) {
return getHeight(root) != -1;
}
func getHeight(node *TreeNode) int {
if node == nil {
return 0
}
leftH := getHeight(node.Left)
if leftH == -1 {
return -1 // 提前退出,不再递归
}
rightH := getHeight(node.Right)
if rightH == -1 || abs(leftH-rightH) > 1 {
return -1
}
return max(leftH, rightH) + 1
}
func isBalanced(root *TreeNode) bool {
return getHeight(root) != -1
}
func abs(x int) int { if x < 0 { return -x }; return x }
function getHeight(node) {
if (node === null) {
return 0;
}
const leftH = getHeight(node.left);
if (leftH === -1) {
return -1; // 提前退出,不再递归
}
const rightH = getHeight(node.right);
if (rightH === -1 || Math.abs(leftH - rightH) > 1) {
return -1;
}
return Math.max(leftH, rightH) + 1;
}
var isBalanced = function(root) {
return getHeight(root) !== -1;
};
use std::rc::Rc;
use std::cell::RefCell;
impl Solution {
pub fn is_balanced(root: Option<Rc<RefCell<TreeNode>>>) -> bool {
fn get_height(node: &Option<Rc<RefCell<TreeNode>>>) -> i32 {
if let Some(node) = node {
let node = node.borrow();
let left_h = get_height(&node.left);
if left_h == -1 {
return -1; // 提前退出,不再递归
}
let right_h = get_height(&node.right);
if right_h == -1 || (left_h - right_h).abs() > 1 {
return -1;
}
return left_h.max(right_h) + 1;
}
0
}
get_height(&root) != -1
}
}
欢迎关注 B站@灵茶山艾府
这道题中的平衡二叉树的定义是:二叉树的每个节点的左右子树的高度差的绝对值不超过 $1$,则二叉树是平衡二叉树。根据定义,一棵二叉树是平衡二叉树,当且仅当其所有子树也都是平衡二叉树,因此可以使用递归的方式判断二叉树是不是平衡二叉树,递归的顺序可以是自顶向下或者自底向上。
定义函数 $\texttt{height}$,用于计算二叉树中的任意一个节点 $p$ 的高度:
$$
\texttt{height}(p) =
\begin{cases}
0 & p \text{ 是空节点}\
\max(\texttt{height}(p.\textit{left}), \texttt{height}(p.\textit{right}))+1 & p \text{ 是非空节点}
\end{cases}
$$
有了计算节点高度的函数,即可判断二叉树是否平衡。具体做法类似于二叉树的前序遍历,即对于当前遍历到的节点,首先计算左右子树的高度,如果左右子树的高度差是否不超过 $1$,再分别递归地遍历左右子节点,并判断左子树和右子树是否平衡。这是一个自顶向下的递归的过程。
<
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
>
###Java
class Solution {
public boolean isBalanced(TreeNode root) {
if (root == null) {
return true;
} else {
return Math.abs(height(root.left) - height(root.right)) <= 1 && isBalanced(root.left) && isBalanced(root.right);
}
}
public int height(TreeNode root) {
if (root == null) {
return 0;
} else {
return Math.max(height(root.left), height(root.right)) + 1;
}
}
}
###C++
class Solution {
public:
int height(TreeNode* root) {
if (root == NULL) {
return 0;
} else {
return max(height(root->left), height(root->right)) + 1;
}
}
bool isBalanced(TreeNode* root) {
if (root == NULL) {
return true;
} else {
return abs(height(root->left) - height(root->right)) <= 1 && isBalanced(root->left) && isBalanced(root->right);
}
}
};
###Python
class Solution:
def isBalanced(self, root: TreeNode) -> bool:
def height(root: TreeNode) -> int:
if not root:
return 0
return max(height(root.left), height(root.right)) + 1
if not root:
return True
return abs(height(root.left) - height(root.right)) <= 1 and self.isBalanced(root.left) and self.isBalanced(root.right)
###C
int height(struct TreeNode* root) {
if (root == NULL) {
return 0;
} else {
return fmax(height(root->left), height(root->right)) + 1;
}
}
bool isBalanced(struct TreeNode* root) {
if (root == NULL) {
return true;
} else {
return fabs(height(root->left) - height(root->right)) <= 1 && isBalanced(root->left) && isBalanced(root->right);
}
}
###golang
func isBalanced(root *TreeNode) bool {
if root == nil {
return true
}
return abs(height(root.Left) - height(root.Right)) <= 1 && isBalanced(root.Left) && isBalanced(root.Right)
}
func height(root *TreeNode) int {
if root == nil {
return 0
}
return max(height(root.Left), height(root.Right)) + 1
}
func max(x, y int) int {
if x > y {
return x
}
return y
}
func abs(x int) int {
if x < 0 {
return -1 * x
}
return x
}
复杂度分析
时间复杂度:$O(n^2)$,其中 $n$ 是二叉树中的节点个数。
最坏情况下,二叉树是满二叉树,需要遍历二叉树中的所有节点,时间复杂度是 $O(n)$。
对于节点 $p$,如果它的高度是 $d$,则 $\texttt{height}(p)$ 最多会被调用 $d$ 次(即遍历到它的每一个祖先节点时)。对于平均的情况,一棵树的高度 $h$ 满足 $O(h)=O(\log n)$,因为 $d \leq h$,所以总时间复杂度为 $O(n \log n)$。对于最坏的情况,二叉树形成链式结构,高度为 $O(n)$,此时总时间复杂度为 $O(n^2)$。
空间复杂度:$O(n)$,其中 $n$ 是二叉树中的节点个数。空间复杂度主要取决于递归调用的层数,递归调用的层数不会超过 $n$。
方法一由于是自顶向下递归,因此对于同一个节点,函数 $\texttt{height}$ 会被重复调用,导致时间复杂度较高。如果使用自底向上的做法,则对于每个节点,函数 $\texttt{height}$ 只会被调用一次。
自底向上递归的做法类似于后序遍历,对于当前遍历到的节点,先递归地判断其左右子树是否平衡,再判断以当前节点为根的子树是否平衡。如果一棵子树是平衡的,则返回其高度(高度一定是非负整数),否则返回 $-1$。如果存在一棵子树不平衡,则整个二叉树一定不平衡。
<
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
,
>
###Java
class Solution {
public boolean isBalanced(TreeNode root) {
return height(root) >= 0;
}
public int height(TreeNode root) {
if (root == null) {
return 0;
}
int leftHeight = height(root.left);
int rightHeight = height(root.right);
if (leftHeight == -1 || rightHeight == -1 || Math.abs(leftHeight - rightHeight) > 1) {
return -1;
} else {
return Math.max(leftHeight, rightHeight) + 1;
}
}
}
###C++
class Solution {
public:
int height(TreeNode* root) {
if (root == NULL) {
return 0;
}
int leftHeight = height(root->left);
int rightHeight = height(root->right);
if (leftHeight == -1 || rightHeight == -1 || abs(leftHeight - rightHeight) > 1) {
return -1;
} else {
return max(leftHeight, rightHeight) + 1;
}
}
bool isBalanced(TreeNode* root) {
return height(root) >= 0;
}
};
###Python
class Solution:
def isBalanced(self, root: TreeNode) -> bool:
def height(root: TreeNode) -> int:
if not root:
return 0
leftHeight = height(root.left)
rightHeight = height(root.right)
if leftHeight == -1 or rightHeight == -1 or abs(leftHeight - rightHeight) > 1:
return -1
else:
return max(leftHeight, rightHeight) + 1
return height(root) >= 0
###C
int height(struct TreeNode* root) {
if (root == NULL) {
return 0;
}
int leftHeight = height(root->left);
int rightHeight = height(root->right);
if (leftHeight == -1 || rightHeight == -1 || fabs(leftHeight - rightHeight) > 1) {
return -1;
} else {
return fmax(leftHeight, rightHeight) + 1;
}
}
bool isBalanced(struct TreeNode* root) {
return height(root) >= 0;
}
###golang
func isBalanced(root *TreeNode) bool {
return height(root) >= 0
}
func height(root *TreeNode) int {
if root == nil {
return 0
}
leftHeight := height(root.Left)
rightHeight := height(root.Right)
if leftHeight == -1 || rightHeight == -1 || abs(leftHeight - rightHeight) > 1 {
return -1
}
return max(leftHeight, rightHeight) + 1
}
func max(x, y int) int {
if x > y {
return x
}
return y
}
func abs(x int) int {
if x < 0 {
return -1 * x
}
return x
}
复杂度分析
时间复杂度:$O(n)$,其中 $n$ 是二叉树中的节点个数。使用自底向上的递归,每个节点的计算高度和判断是否平衡都只需要处理一次,最坏情况下需要遍历二叉树中的所有节点,因此时间复杂度是 $O(n)$。
空间复杂度:$O(n)$,其中 $n$ 是二叉树中的节点个数。空间复杂度主要取决于递归调用的层数,递归调用的层数不会超过 $n$。
以下两种方法均基于以下性质推出:当前树的深度 等于 左子树的深度 与 右子树的深度 中的 最大值 $+1$ 。
{:width=450}
此方法为本题的最优解法,但剪枝的方法不易第一时间想到。
思路是对二叉树做后序遍历,从底至顶返回子树深度,若判定某子树不是平衡树则 “剪枝” ,直接向上返回。
函数 recur(root) :
root 左 / 右子树的深度差 $\leq 1$ :则返回当前子树的深度,即节点 root 的左 / 右子树的深度最大值 $+1$ ( max(left, right) + 1 )。root 左 / 右子树的深度差 $> 1$ :则返回 $-1$ ,代表 此子树不是平衡树 。root 为空:说明越过叶节点,因此返回高度 $0$ 。函数 isBalanced(root) :
recur(root) != -1 ,则说明此树平衡,返回 $true$ ; 否则返回 $false$ 。<
,
,
,
,
,
,
,
,
,
>
###Python
class Solution:
def isBalanced(self, root: Optional[TreeNode]) -> bool:
def recur(root):
if not root: return 0
left = recur(root.left)
if left == -1: return -1
right = recur(root.right)
if right == -1: return -1
return max(left, right) + 1 if abs(left - right) <= 1 else -1
return recur(root) != -1
###Java
class Solution {
public boolean isBalanced(TreeNode root) {
return recur(root) != -1;
}
private int recur(TreeNode root) {
if (root == null) return 0;
int left = recur(root.left);
if (left == -1) return -1;
int right = recur(root.right);
if (right == -1) return -1;
return Math.abs(left - right) < 2 ? Math.max(left, right) + 1 : -1;
}
}
###C++
class Solution {
public:
bool isBalanced(TreeNode* root) {
return recur(root) != -1;
}
private:
int recur(TreeNode* root) {
if (root == nullptr) return 0;
int left = recur(root->left);
if (left == -1) return -1;
int right = recur(root->right);
if (right == -1) return -1;
return abs(left - right) < 2 ? max(left, right) + 1 : -1;
}
};
此方法容易想到,但会产生大量重复计算,时间复杂度较高。
思路是构造一个获取当前子树的深度的函数 depth(root) ,通过比较某子树的左右子树的深度差 abs(depth(root.left) - depth(root.right)) <= 1 是否成立,来判断某子树是否是二叉平衡树。若所有子树都平衡,则此树平衡。
函数 isBalanced(root) : 判断树 root 是否平衡
root 为空,则直接返回 $true$ 。abs(self.depth(root.left) - self.depth(root.right)) <= 1 :判断 当前子树 是否是平衡树。self.isBalanced(root.left) : 先序遍历递归,判断 当前子树的左子树 是否是平衡树。self.isBalanced(root.right) : 先序遍历递归,判断 当前子树的右子树 是否是平衡树。函数 depth(root) : 计算树 root 的深度
root 为空,即越过叶子节点,则返回高度 $0$ 。<
,
,
,
,
,
>
###Python
class Solution:
def isBalanced(self, root: Optional[TreeNode]) -> bool:
if not root: return True
return abs(self.depth(root.left) - self.depth(root.right)) <= 1 and \
self.isBalanced(root.left) and self.isBalanced(root.right)
def depth(self, root):
if not root: return 0
return max(self.depth(root.left), self.depth(root.right)) + 1
###Java
class Solution {
public boolean isBalanced(TreeNode root) {
if (root == null) return true;
return Math.abs(depth(root.left) - depth(root.right)) <= 1 && isBalanced(root.left) && isBalanced(root.right);
}
private int depth(TreeNode root) {
if (root == null) return 0;
return Math.max(depth(root.left), depth(root.right)) + 1;
}
}
###C++
class Solution {
public:
bool isBalanced(TreeNode* root) {
if (root == nullptr) return true;
return abs(depth(root->left) - depth(root->right)) <= 1 && isBalanced(root->left) && isBalanced(root->right);
}
private:
int depth(TreeNode* root) {
if (root == nullptr) return 0;
return max(depth(root->left), depth(root->right)) + 1;
}
};
isBalanced(root) 遍历树所有节点,判断每个节点的深度 depth(root) 需要遍历 各子树的所有节点 。
depth(root) ,判断二叉树各层的节点的对应子树的深度,需遍历节点数量为 $N \times 1, \frac{N-1}{2} \times 2, \frac{N-3}{4} \times 4, \frac{N-7}{8} \times 8, ..., 1 \times \frac{N+1}{2}$ 。因此各层执行 depth(root) 的时间复杂度为 $O(N)$ (每层开始,最多遍历 $N$ 个节点,最少遍历 $\frac{N+1}{2}$ 个节点)。其中,$\frac{N-3}{4} \times 4$ 代表从此层开始总共需遍历 $N-3$ 个节点,此层共有 $4$ 个节点,因此每个子树需遍历 $\frac{N-3}{4}$ 个节点。
{:width=550}
本学习计划配有代码仓,内含测试样例与数据结构封装,便于本地调试。可前往我的个人主页获取。
github推出了 copilot chat,里面比较方便的是它可以很快速地搜索和获取各开源项目的上下文。除了单纯的Ask,其实还有执行任务的Agent(不过要开copilot pro),似乎目前是限制了每个月50次request。
因此我利用这个来学习源码,让AI回答关于源码的东西。下面的内容是出自copilot的回答(我认为比较好的),只是个人进行了整理。 这个博客的营养价值并不高,最大的价值可能就是告诉你有这个方便的途径去了解和学习源码。可以直接跳到完整示例,前面大抵只是一些铺垫。
根据官方文档,Blink 是 Chromium 的渲染引擎,负责页面内容的最终展示:
## Blink (Rendering Engine)
Blink 实现了所有在浏览器标签页内渲染内容的功能:
* 实现网络平台规范(如HTML标准),包括DOM、CSS和Web IDL
* 嵌入V8引擎并运行JavaScript
* 从底层网络栈请求资源
* **构建DOM树**
* **计算样式和布局**
* **嵌入Chrome Compositor并绘制图形**
Chromium 采用多进程架构:
// PrintRenderFrameHelper 处理渲染相关的工作
class PrintRenderFrameHelper
: public blink::WebPrintClient,
public content::RenderFrameObserver {
// 为打印准备页面框架
void OnFramePreparedForPrintPages();
void PrintPages();
bool RenderPagesForPrint(blink::WebLocalFrame* frame,
const blink::WebNode& node);
};
Blink 引擎在 DOM 树构建后,计算每个元素的样式(CSS)并确定其在页面中的位置和大小。
// 页面内容渲染到画布
void RenderPageContent(blink::WebLocalFrame* frame,
uint32_t page_index,
cc::PaintCanvas* canvas) {
TRACE_EVENT1("print", "RenderPageContent", "page_index", page_index);
frame->PrintPage(page_index, canvas);
}
Chrome Compositor 负责合成不同的图层,最终生成帧(Frame)。
Compositor 是 Chromium 的图形合成器,用于:
一个完整的渲染周期包括:
输入事件 → DOM 更新 → 样式计算 → 布局计算 → 绘制操作 → 栅格化 → 合成 → 显示
Chromium 内置性能监控:
// PDF 渲染的性能测量
if (!first_paint_metric_reported_ && !client_->IsPrintPreview()) {
first_paint_metric_reported_ = true;
base::UmaHistogramMediumTimes("PDF.FirstPaintTime",
begin_time - engine_creation_time_);
}
Chromium 的页面渲染是一个复杂的分阶段过程:
根据官方文档 life_of_a_frame.md 和代码分析,Compositor 分为以下主要部分:
输入:来自 Display Compositor 的 BeginFrame 信号
↓
输出:BeginFrameArgs(包含时间戳、帧号等)
职责:
BeginFrame
↓
[1] BeginMainFrame - 合成线程请求主线程更新
↓
输入:LayerTreeHost 配置、用户输入、动画状态
输出:BeginMainFrameAndCommitState
↓
[2] Animate - 主线程更新动画
↓
输入:当前时间、动画状态
输出:更新后的属性值
↓
[3] UpdateLayers - Blink 执行布局和绘制
↓
输入:DOM 树、样式树
输出:DisplayItemList(绘制操作)
↓
[4] Commit - 推送改变到合成线程
↓
输入:Layer 树、DisplayItemList、属性
输出:CommitState(原子性提交状态)
详细步骤:
1. Animate Phase
输入:LayerAnimationController (cc/animation/*)
输出:变换、不透明度等属性的新值
2. UpdateLayers Phase
输入:Layer 树,由 Blink 通过 LayerTreeHostClient 接口触发
- client_->UpdateLayerTreeHost()
- Blink 执行布局计算
- Blink 执行绘制,产生 DisplayItemList
输出:更新后的 Layer 树和 DisplayItemList
3. Commit Phase
- PushPropertiesTo():每个 Layer 推送属性到 LayerImpl
- 交换 Pending Tree(待定树)和 Active Tree(活动树)
- 同步动画状态、滚动状态等
这是 Compositor 的核心,分为五个关键阶段:
输入:CommitState
- Layer 树结构和属性
- DisplayItemList
- Scroll 状态
- 动画数据
处理:
- FinishCommit():合成线程接收提交的状态
- 更新 LayerImpl 树
- 更新属性树(Transform Tree、Clip Tree 等)
输出:LayerTreeImpl(合成线程的活动树)
输入:LayerTreeImpl、显示视口、缩放因子
处理:
- CalculateRasterScales():计算每个层的栅格化缩放
- PrepareTiles():
├─ CalculateLiveRects():计算可见瓦片范围
├─ AssignGpuMemoryToTiles():分配 GPU 内存预算
└─ ScheduleRasterTasks():安排栅格化任务队列
- TileManager 的职责:
├─ 优先级排序(视口内 > 视口外)
├─ 管理软件和 GPU 栅格化
├─ 管理图像解码
└─ 管理蛋糕层(cake layers)
输出:Raster Tasks(栅格化任务队列)
- 每个任务是:(Tile, RasterSource, Priority)
输入:Raster Tasks
- RasterSource(DisplayItemList 的包装)
- 目标��片大小
- 缩放因子
- 栅格化位置
处理过程(在工作线程执行):
1. PaintOpBuffer Playback
输入:DisplayItemList 中的 PaintOps
处理:
- GetOffsetsOfOpsToRaster():使用 R-Tree 查询需要的操作
- 创建 SkCanvas(CPU 或 GPU)
- 遍历相关 PaintOps,调用 Raster()
输出:像素数据或 GPU 命令
2. Software Raster(软件栅格化)
输入:PaintOps,输出大小
处理:
- 在内存中创建位图
- 使用 Skia 的 CPU 后端绘制
- 使用 SIMD 优化
输出:SkBitmap(CPU 内存中的像素)
3. GPU Raster(GPU 栅格化)
输入:PaintOps,GPU 上下文
处理:
- 序列化 PaintOps
- 通过 RasterInterface 发送到 GPU
- GPU 命令缓冲区执行绘制
- Skia 的 GPU 后端(Ganesh)处理
输出:GPU 纹理资源
输出:Rasterized Tiles
- 包含像素数据(软件)或 GPU 纹理(GPU)
关键类和函数:
// RasterSource:DisplayItemList 的包装,提供栅格化接口
class RasterSource {
void PlaybackToCanvas(
SkCanvas* raster_canvas,
const gfx::Rect& canvas_bitmap_rect, // 目标位置
const gfx::AxisTransform2d& raster_transform, // 缩放
const PlaybackSettings& settings);
};
// TileManager:协调栅格化
class TileManager {
void PrepareTiles(const PrepareTilesParams& params);
void ScheduleRasterTasks();
};
// DisplayItemList:包含 PaintOps
class DisplayItemList {
void Raster(SkCanvas* canvas, const PlaybackParams& params);
std::vector<size_t> OffsetsOfOpsToRaster(SkCanvas* canvas);
};
输入:Pending Tree(待定树,已栅格化)
处理:
- WaitForAllTilesToRasterize():等待所有关键瓦片栅格化完成
- ActivatePendingTree():
├─ Pending Tree → Active Tree
├─ 更新动画状态
├─ 更新页面缩放
└─ 清空已完成的栅格化队列
输出:Active Tree(已激活的树,可用于绘制)
输入:Active Tree、BeginFrameArgs
处理:
1. CalculateDrawProperties()
输入:Active Tree、视口、设备缩放因子
计算:
- 每个层的最终变换矩阵
- 剪裁区域
- 可见范围
输���:DrawProperties(每个 LayerImpl 都有)
2. GenerateCompositorFrame()
输入:Active Tree with DrawProperties
处理:
- 遍历 Layer 树,生成 Quads(四边形)
- 每个 Quad 包含:
├─ 纹理/资源 ID
├─ 变换矩阵
├─ 剪裁区域
├─ 不透明度
└─ 混合模式
- 为每个 Quad 创建 RenderPass
- 设置组合帧元数据
输出:CompositorFrame
3. CompositorFrame 结构
viz::CompositorFrame {
vector<RenderPass>:
- RenderPass[0]:第一个离屏渲染目标
- RenderPass[1]:第二个离屏渲染目标
- ...
- RenderPass[N]:最后一个输出到显示器的渲染通道
vector<TransferableResource>:引用的 GPU 纹理
CompositorFrameMetadata:元数据
- frame_token:帧标识符
- device_scale_factor:设备像素比
- latency_info:性能指标
- presentation_token:展示令牌
}
输出:CompositorFrame(包含所有绘制指令)
输入:CompositorFrame
处理:
1. SubmitCompositorFrame()
- 验证资源有效性
- 同步令牌处理
- 帧令牌生成
2. Viz 处理(viz/service/display/)
- AggregateSurfaces():合成多个 Surface
- ApplyFilters():应用滤镜效果
- Rasterize():最后栅格化(如果需要)
- GenerateDamageRect():计算脏区域
3. Display 合成
- 将多个来源的帧合成到最终输出表面
- 应用变换和效果
输出:最终的屏幕显示内容
┌─────────────────────────────────────────────────────────────────┐
│ MAIN THREAD (主线程) │
│ │
│ Layer Tree BeginMainFrame │
│ │ Animate │
│ │ UpdateLayers (Blink Layout & Paint) │
│ │ → DisplayItemList (PaintOps) │
│ │ │
│ └──→ Commit (PushPropertiesTo) │
│ CommitState ─────┐ │
│ │ │
└────────────────────────────┼────────────────────────────────────┘
│
┌────────▼──────────┐
│ ProxyImpl Bridge │
│ (Main ↔ Impl) │
└────────┬──────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ COMPOSITOR THREAD (合成线程) │
│ │
│ 1. FinishCommit() │
│ CommitState → LayerTreeImpl │
│ 更新 LayerImpl 树、属性树 │
│ │
│ 2. PrepareTiles() ◄─ Scheduler 调度 │
│ ├─ 计算栅格化缩放 │
│ ├─ 分配 GPU 内存 │
│ └─ 生成 RasterTasks 队列 │
│ │ │
│ ▼ (Post to Worker Threads) │
│
│ 3. Rasterization (Worker Threads) │
│ ├─ Software: DisplayItemList → SkBitmap │
│ └─ GPU: PaintOps → GPU Textures │
│ │ │
│ ▼ │
│
│ 4. Activation() ◄─ Scheduler 调度 │
│ Pending Tree (Rasterized) → Active Tree │
│ │
│ 5. DrawFrame() / GenerateCompositorFrame() │
│ ├─ CalculateDrawProperties() │
│ ├─ BuildQuads() │
│ ├─ 生成 RenderPass 列表 │
│ └─ 输出 CompositorFrame │
│ │ │
│ ▼ │
│
│ SubmitCompositorFrame(frame) │
│ │ │
└────────────┼──────────────────────────────────────────────────┘
│
┌────────▼───────────┐
│ LayerTreeFrameSink │
│ (GPU Channel) │
└────────┬────────────┘
│
┌────────────▼──────────────────────────────────────────────────┐
│ VIZ DISPLAY COMPOSITOR (显示合成器) │
│ │
│ 1. MaybeSubmitCompositorFrame() │
│ - 验证帧 │
│ - 应用同步令牌 │
│ │
│ 2. AggregateSurfaces() │
│ - 多个 Surface 合成 │
│ - Z-order 排序 │
│ │
│ 3. Display::Draw() │
│ - GPU 驱动程序指令 │
│ - 应用滤镜和效果 │
│ │
│ 4. Swap & Present │
│ - 缓冲交换 (Backbuffer → Frontbuffer) │
│ - VSync 同步 │
│ - 显示器显示 │
│ │
└──────────────────────────────────────────────────────────────┘
| 阶段 | 输入 | 处理器 | 输出 | 执行线程 |
|---|---|---|---|---|
| Animate | AnimationState | LayerAnimationController | 属性值 | Main |
| UpdateLayers | DOM 树、样式 | Blink | DisplayItemList | Main |
| Commit | Layer 树 | LayerTreeHost | CommitState | Main→Impl |
| FinishCommit | CommitState | LayerTreeHostImpl | LayerTreeImpl | Impl |
| PrepareTiles | LayerTreeImpl | TileManager | RasterTasks | Impl |
| Rasterization | RasterTasks | RasterWorkerPool | GPU 纹理/位图 | Worker |
| Activation | Pending Tree | LayerTreeHostImpl | Active Tree | Impl |
| DrawFrame | Active Tree | LayerTreeHostImpl | CompositorFrame | Impl |
| Display | CompositorFrame | DisplayCompositor | 屏幕输出 | Display |
ProxyMain (主线程)
├─ 负责与主线程通信
├─ 接收 BeginMainFrame 信号
├─ 管理 CommitPipelineStage
└─ 回调主线程结果
ProxyImpl (合成线程)
├─ 负责与合成线程通信
├─ 控制 Scheduler
├─ 管理 LayerTreeHostImpl
└─ 提交 CompositorFrame 到 LayerTreeFrameSink
让我们假设要在网页上绘制一个蓝色的 200×200 像素正方形,位置在 (100, 100)。
用户在 HTML/CSS 中写了:
<div style="
width: 200px;
height: 200px;
background-color: blue;
position: absolute;
top: 100px;
left: 100px;
"></div>
Blink 布局完成后,知道要绘制:
然后它创建一个 DisplayItemList 来记录这个绘制操作:
// Blink 在主线程上执行
auto display_list = base::MakeRefCounted<cc::DisplayItemList>();
display_list->StartPaint();
// 1. 保存图形状态
display_list->push<cc::SaveOp>();
// 2. 绘制蓝色正方形
cc::PaintFlags blue_flags;
blue_flags.setColor(SK_ColorBLUE); // RGB(0, 0, 255)
blue_flags.setStyle(SkPaint::kFill_Style);
display_list->push<cc::DrawRectOp>(
SkRect::MakeXYWH(100, 100, 200, 200), // x, y, width, height
blue_flags
);
// 3. 恢复图形状态
display_list->push<cc::RestoreOp>();
display_list->EndPaintOfUnpaired(gfx::Rect(100, 100, 200, 200));
// 完成记录
display_list->Finalize();
// 此时 DisplayItemList 内部包含:
//
// paint_op_buffer_ = [
// SaveOp,
// DrawRectOp(x=100, y=100, w=200, h=200, color=blue),
// RestoreOp
// ]
//
// visual_rects_ = [
// {100, 100, 200, 200}, // SaveOp 的可视范围
// {100, 100, 200, 200}, // DrawRectOp 的可视范围
// {100, 100, 200, 200} // RestoreOp 的可视范围
// ]
这个阶段的输出: 一个包含"绘制指令"的对象,说明要在 (100,100) 位置绘制一个 200×200 的蓝色矩形。但这只是指令,还没有真正的像素数据!
主线程把 DisplayItemList 包装在 CommitState 中,发送给合成线程:
// 主线程创建 CommitState
auto commit_state = std::make_unique<CommitState>();
// 把 DisplayItemList 分配给对应的 Layer
// (在实际代码中,DisplayItemList 被存储在 PictureLayerImpl 中)
commit_state->source_frame_number = 120;
commit_state->device_viewport_rect = gfx::Size(1920, 1080);
commit_state->device_scale_factor = 1.0f; // 假设普通屏幕
commit_state->background_color = SK_ColorWHITE;
// 推送到合成线程
layer_tree_host_->WillCommit(...)
// ...
layer_tree_host_->ActivateCommitState() // 原子性推送
这个阶段的输出: CommitState 对象,包含所有需要的信息,包括 DisplayItemList。
合成线程收到 CommitState,开始准备栅格化:
// 合成线程上
LayerTreeHostImpl* host_impl = ...;
// 1. 接收 CommitState,更新 LayerTreeImpl
host_impl->FinishCommit(commit_state);
// 2. 准备瓦片(Tiles)
TileManager* tile_manager = host_impl->tile_manager();
tile_manager->PrepareTiles();
// 这会为 PictureLayerImpl 创建 Tiles
// 因为我们的正方形只有 200×200,可能只需要 1 个 Tile
// 假设 Tile 大小是 256×256
struct Tile {
gfx::Rect rect; // 瓦片在页面上的位置
RasterSource* source; // 指向 DisplayItemList (包装后)
float scale; // 栅格化缩放因子
int x, y; // 瓦片的网格坐标
};
// 创建一个瓦片
Tile tile0{
.rect = gfx::Rect(0, 0, 256, 256), // 页面坐标
.source = raster_source, // 包含我们的 DisplayItemList
.scale = 1.0f,
.x = 0,
.y = 0
};
// TileManager 会创建栅格化任务
struct RasterTask {
Tile* tile;
RasterSource* raster_source;
gfx::Rect target_rect; // 在瓦片中的位置
Priority priority;
};
RasterTask task{
.tile = &tile0,
.raster_source = raster_source,
.target_rect = gfx::Rect(0, 0, 256, 256),
.priority = PRIORITY_NOW // 视口内,需要立即栅格化
};
// 提交栅格化任务给工作线程
tile_manager->ScheduleRasterTasks(&task);
这个阶段的输出: 栅格化任务队列,告诉工作线程要栅格化哪些区域。
这是把绘制指令转换成实际像素的地方:
// 工作线程上执行栅格化任务
void RasterWorkerPool::RasterizeTask(const RasterTask& task) {
// 创建一个绘制目标(画布)
// 这是一个 256×256 的缓冲区,用来存放栅格化后的像素
// 方案 A:CPU 栅格化(软件)
if (use_software_raster) {
// 创建 CPU 内存中的位图
SkBitmap bitmap;
bitmap.allocN32Pixels(256, 256); // 256×256 的 32 位 RGBA 像素
// 创建 Skia ��布,指向这个位图
SkCanvas canvas(bitmap);
// 现在我们要回放 DisplayItemList 中的绘制指令
RasterSource* source = task.raster_source;
// 关键步骤:回放绘制操作!
source->PlaybackToCanvas(
&canvas, // 目标画布
gfx::Size(1920, 1080), // 内容大小
gfx::Rect(0, 0, 256, 256), // 瓦片在内容中的位置
gfx::Rect(0, 0, 256, 256), // 需要栅格化的区域
gfx::AxisTransform2d(1.0f), // 无缩放
settings
);
// 现在 bitmap 中包含了栅格化后的像素!
// 具体来说:
// - 位置 (100, 100) 到 (300, 300) 的像素被设为蓝色
// - 其他像素是白色(背景色)
// bitmap 的内存布局示意:
//
// 位置 (0,0) ──────────────────────→ (256,0)
// │ FFFFFF FFFFFF FFFFFF ...
// │ FFFFFF FFFFFF FFFFFF ...
// │ ...
// (100,100) 开始
// │ FFFFFF FFFFFF ...
// │ ...
// │ FFFFFF 0000FF 0000FF 0000FF ... ← 蓝色像素!
// │ FFFFFF 0000FF 0000FF 0000FF ...
// │ FFFFFF 0000FF 0000FF 0000FF ...
// │ ...
// (300,300) 结束
// │ FFFFFF FFFFFF FFFFFF ...
// ↓
// (256,256)
// 上传到 GPU
UploadBitmapToGPU(&bitmap, tile);
}
// 方案 B:GPU 栅格化
else {
// 创建 GPU 纹理作为渲染目标
// 尺寸:256×256,格式:RGBA_8888
GLuint texture = gl::CreateTexture(256, 256, GL_RGBA8);
// 绑定为渲染目标
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D, texture, 0);
// 清除背景(白色)
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 创建 Skia 画布(绘制到 GPU)
SkSurface* surface = SkSurface::MakeFromBackendTexture(...);
SkCanvas* canvas = surface->getCanvas();
// 回放 DisplayItemList 的绘制操作
// 这次是发送 GPU 命令
DisplayItemList::Raster(canvas, ...);
// GPU 执行指令,在纹理中画出蓝色正方形
// 纹理内容现在是:位置 (100, 100) 到 (300, 300) 是蓝色像素
}
}
这个阶段发生了什么:
具体的像素数据示意:
CPU 内存(SkBitmap)或 GPU 纹理的内容
(每个方块代表一个像素,用 16 进制表示 RGBA 颜色值)
y=0 FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ... (全是白色)
y=1 FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...
...
y=99 FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...
y=100 FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
^ ^ (100,100) 蓝色开始 ^ (300,100) 蓝色结束
y=101 FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
y=102 FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
...
y=299 FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
y=300 FFFFFFFF ... FFFFFFFF 0000FFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFFFF ...
^ ^ (100,300) 蓝色最后一行 ^ (300,300) 蓝色结束
y=301 FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...
...
y=255 FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...
其中:
- FFFFFFFF = 白色 (RGBA: 255,255,255,255)
- 0000FFFF = 蓝色 (RGBA: 0,0,255,255)
这个阶段的输出: GPU 纹理或位图,包含 256×256 个像素的实际颜色数据。
现在我们有了栅格化后的纹理,合成线程创建最终的合成帧:
// 合成线程
void LayerTreeHostImpl::GenerateCompositorFrame() {
// 创建 CompositorFrame
viz::CompositorFrame frame;
// 设置元数据
frame.metadata.frame_token = 0x12345;
frame.metadata.device_scale_factor = 1.0f;
frame.metadata.size_in_pixels = gfx::Size(1920, 1080);
frame.metadata.begin_frame_ack = viz::BeginFrameAck(...);
// 创建渲染通道
auto render_pass = viz::CompositorRenderPass::Create();
render_pass->SetNew(
viz::CompositorRenderPassId(1),
gfx::Rect(0, 0, 1920, 1080), // 输出矩形(整个屏幕)
gfx::Rect(100, 100, 200, 200), // 脏区域(只有正方形区域需要重绘)
gfx::Transform() // 无变换
);
// 添加 Quad(纹理四边形)
auto quad = std::make_unique<viz::TextureDrawQuad>();
// 从栅格化任务中获取纹理 ID
ResourceId texture_id = 0x9999; // GPU 纹理的句柄
quad->SetNew(
nullptr, // shared_quad_state (共享状态)
gfx::Rect(0, 0, 256, 256), // Quad 在屏幕上的位置(注意:这是以瓦片的 (0,0) 开始)
gfx::Rect(0, 0, 256, 256), // 可见区域
false, // 不需要混合
texture_id, // 纹理 ID(指向我们栅格化的蓝色正方形纹理)
true, // 预乘 alpha
gfx::PointF(0, 0), // UV 左上角
gfx::PointF(1, 1), // UV 右下角
SK_ColorWHITE, // 背景色
{1.0f, 1.0f, 1.0f, 1.0f} // 混合颜色
);
// 重要!实际位置需要从瓦片坐标转换
// 瓦片 (0,0) 对应屏幕位置 (0,0)
// 但我们的正方形在 DisplayItemList 中是 (100, 100)
// 所以最终 Quad 的 rect 应该是 (100, 100, 200, 200)
// 更正:
quad->rect = gfx::Rect(100, 100, 200, 200); // 正确的屏幕位置
quad->visible_rect = gfx::Rect(100, 100, 200, 200);
quad->opacity = 1.0f;
render_pass->quad_list.push_back(std::move(quad));
// 添加资源
viz::TransferableResource resource;
resource.id = texture_id;
resource.mailbox_holder = gpu::MailboxHolder(mailbox, sync_token, target);
frame.resource_list.push_back(resource);
// 添加渲染通道
frame.render_pass_list.push_back(std::move(render_pass));
return frame;
}
// CompositorFrame 现在包含:
// {
// metadata: {
// frame_token: 0x12345,
// device_scale_factor: 1.0,
// size_in_pixels: (1920, 1080),
// ...
// },
// render_pass_list: [
// {
// output_rect: (0, 0, 1920, 1080),
// damage_rect: (100, 100, 200, 200),
// quad_list: [
// TextureDrawQuad {
// rect: (100, 100, 200, 200),
// texture_id: 0x9999,
// opacity: 1.0,
// ...
// }
// ]
// }
// ],
// resource_list: [
// {
// id: 0x9999,
// mailbox: ... (指向包含栅格化像素的 GPU 纹理)
// }
// ]
// }
这个阶段的输出: CompositorFrame,包含:
// Viz Display Compositor(显示合成器)
void Display::DrawFrame(...) {
// 1. 接收 CompositorFrame
viz::CompositorFrame frame = ...;
// 2. 遍历所有 Quads
for (auto& render_pass : frame.render_pass_list) {
for (auto& quad : render_pass->quad_list) {
if (auto texture_quad = quad.As<viz::TextureDrawQuad>()) {
// 3. 获取纹理(包含栅格化的蓝色正方形像素)
GLuint texture = GetTextureFromResourceId(texture_quad->resource_id);
// 4. 在屏幕上绘制这个纹理
// 位置:(100, 100)
// 大小:200×200
// 内容:我们栅格化的蓝色正方形纹理
glBindTexture(GL_TEXTURE_2D, texture);
glUniform2f(position_uniform, 100.0f, 100.0f);
glUniform2f(size_uniform, 200.0f, 200.0f);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); // 绘制四边形
}
}
}
// 5. 交换缓冲区
SwapBuffers();
// GPU 把缓冲区中的像素显示到屏幕上
}
这个阶段发生了什么:
┌─────────────────────────────────────────────────────────────┐
│ 1. HTML/CSS 描述 │
│ <div style="width:200px; height:200px; │
│ background-color:blue; top:100px; left:100px;">
└─────────────────────────────────────────────────────────────┘
↓ (Blink 布局)
┌─────────────────────────────────────────────────────────────┐
│ 2. DisplayItemList (绘制指令,不是像素) │
│ [SaveOp, ││ DrawRectOp(x=100, y=100, w=200, h=200, color=blue), ││ RestoreOp] │
└─────────────────────────────────────────────────────────────┘
↓ (提交)
┌─────────────────────────────────────────────────────────────┐
│ 3. CommitState │
│ (包含 DisplayItemList 和其他属性) │
└─────────────────────────────────────────────────────────────┘
↓ (栅格化)
┌─────────────────────────────────────────────────────────────┐
│ 4. GPU 纹理(实际像素)256×256 │
│ FFFFFF... FFFFFF... 蓝色(0000FF)... 蓝色... FFFFFF... │
│ (100,100) 到 (300,300) 的像素是蓝色,其他是白色 │
└─────────────────────────────────────────────────────────────┘
↓ (生成帧)
┌─────────────────────────────────────────────────────────────┐
│ 5. CompositorFrame │
│ quad: { │
│ position: (100, 100), │
│ size: (200, 200), │
│ texture_id: 0x9999 → 指向包含蓝色像素的纹理 │
│ } │
└─────────────────────────────────────────────────────────────┘
↓ (GPU 绘制)
┌─────────────────────────────────────────────────────────────┐
│ 6. 屏幕帧缓冲(1920×1080) │
│ (100, 100) 到 (300, 300) 的像素现在是蓝色 │
│ 其他像素是白色 │
│ │
│ ┌──────────────────────────────────┐ │
│ │ 白白白白白... 白白 │ │
│ │ 白白白白白... 白白 │ │
│ │ ... │ │
│ │ 白白蓝蓝蓝... 蓝白 │ ← (100,100) │
│ │ 白白蓝蓝蓝... 蓝白 │ │
│ │ 白白蓝蓝蓝... 蓝白 │ │
│ │ ... │ │
│ │ 白白蓝蓝蓝... 蓝白 │ ← (300,300) │
│ │ 白白白白白... 白白 │ │
│ └──────────────────────────────────┘ │
│ │
│ ✓ 正方形显示在屏幕上! │
└─────────────────────────────────────────────────────────────┘
整个过程就像:
好问题!栅格化是整个过程中最有趣的部分。让我用最直观的方式解释它。太好了!现在我可以用实际的代码来解释栅格化的原理。让我用一个最简单的蓝色正方形例子详细讲解:
栅格化的秘密就是:Chromium 使用一个叫 Skia 的图形库,它能把绘制指令转换成像素。
想象 Skia 就像一个虚拟画家:
// 工作线程上执行
// 假设我们要栅格化一个 256×256 的瓦片
// 方案 A:CPU 栅格化(软件方式)
// ====================================
// 创建一个 256×256 的位图(像素缓冲区)
SkBitmap bitmap;
bitmap.allocN32Pixels(256, 256);
// 现在 bitmap 中有 256*256 = 65536 个像素
// 每个像素 4 字节(RGBA),共 262,144 字节的内存
// 内存布局示意:
// bitmap.getPixels() 返回一个指向这块内存的指针
// 内存中的数据:
// [像素(0,0)的RGBA] [像素(1,0)的RGBA] [像素(2,0)的RGBA] ...
// [像素(0,1)的RGBA] [像素(1,1)的RGBA] [像素(2,1)的RGBA] ...
// ...
// [像素(255,255)的RGBA]
// 创建一个 Skia 画布,指向这个位图
SkCanvas canvas(bitmap);
// 方案 B:GPU 栅格化
// ====================================
// 创建一个 GPU 纹理作为渲染目标
GLuint framebuffer = CreateFramebuffer();
GLuint texture = CreateTexture(256, 256, GL_RGBA8);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D, texture, 0);
// 创建指向这个 GPU 纹理的 Skia 画布
SkSurface* surface = SkSurface::MakeFromBackendTexture(...);
SkCanvas* canvas = surface->getCanvas();
现在我们有了一个空白画布,所有像素都是未初始化的。
// 把所有像素设置为白色(背景色)
canvas->clear(SK_ColorWHITE);
// 底层发生了什么:
// Skia 填充整个 256×256 的像素区域
// 对于每一个像素 (x, y),设置它的值为:
// [R=255, G=255, B=255, A=255] // 白色 RGBA
// 清除后的内存示意:
// [FFFFFFFF] [FFFFFFFF] [FFFFFFFF] ... (全是 0xFFFFFFFF = 白色)
// 总共 65536 个这样的 4 字节值
现在所有像素都是白色。
这是最关键的部分!
// RasterSource::PlaybackDisplayListToCanvas()
void PlaybackDisplayListToCanvas(SkCanvas* raster_canvas,
const PlaybackSettings& settings) {
// 获取 DisplayItemList
DisplayItemList* display_list = ...; // 包含我们的绘制指令
// 创建回放参数
PlaybackParams params(settings.image_provider, SkM44());
// 关键步骤:回放 DisplayItemList
display_list->Raster(raster_canvas, params);
}
// DisplayItemList::Raster() 的实现
void DisplayItemList::Raster(SkCanvas* canvas,
const PlaybackParams& params) const {
// 1. 获取需要绘制的操作的偏移量
// (使用 R-Tree 优化:只获取与画布相交的操作)
std::vector<size_t> offsets = OffsetsOfOpsToRaster(canvas);
// offsets = [0, 8, 24] // SaveOp, DrawRectOp, RestoreOp 的偏移量
// 2. 遍历 paint_op_buffer_ 中的操作,执行它们
paint_op_buffer_.Playback(canvas, params, true, &offsets);
}
// PaintOpBuffer::Playback() 的实现
void PaintOpBuffer::Playback(SkCanvas* canvas,
const PlaybackParams& params,
bool local_ctm,
const std::vector<size_t>* offsets) const {
// 遍历所有操作
for (const PaintOp& op : PaintOpBuffer::OffsetIterator(*this, offsets)) {
// 对于每一个操作,调用它的 Raster() 函数
op.Raster(canvas, params);
// 这就是魔法发生的地方!
// Raster() 是虚函数,每个 PaintOp 子类有自己的实现
}
}
现在让我们看看具体的操作执行:
// DisplayItemList 中有这些操作(序列化的形式):
// [SaveOp] [DrawRectOp(...)] [RestoreOp]
// ============================================
// 操作 1:SaveOp::Raster()
// ============================================
void SaveOp::Raster(const SaveOp* op,
SkCanvas* canvas,
const PlaybackParams& params) {
canvas->save();
// 这保存了当前的图形状态(颜色、变换、剪裁等)
}
// ============================================
// 操作 2:DrawRectOp::Raster()(最关键!)
// ============================================
void DrawRectOp::RasterWithFlags(const DrawRectOp* op,
const PaintFlags* flags,
SkCanvas* canvas,
const PlaybackParams& params) {
// op->rect = SkRect{x=100, y=100, w=200, h=200}
// flags->color = SK_ColorBLUE = 0x0000FFFF (RGBA)
// 这是最关键的调用!
canvas->drawRect(op->rect, *flags);
// Skia 会在这一刻做什么?
// 它会遍历要填充的矩形中的每一个像素,设置它们的颜色为蓝色
}
// Skia 内部如何执行 drawRect?
// ====================================
// 伪代码,大致思路:
void SkCanvas::drawRect(const SkRect& rect, const SkPaint& paint) {
// 1. 将 SkRect 转换为像素坐标
int x1 = (int)rect.left(); // 100
int y1 = (int)rect.top(); // 100
int x2 = (int)rect.right(); // 300
int y2 = (int)rect.bottom(); // 300
SkColor color = paint.getColor(); // 0x0000FFFF (蓝色)
// 2. 遍历矩形内的所有像素
for (int y = y1; y < y2; y++) {
for (int x = x1; x < x2; x++) {
// 3. 对于每一个像素,写入蓝色颜色值
uint32_t* pixel_ptr = bitmap.getAddr32(x, y);
*pixel_ptr = color; // 设置为 0x0000FFFF
}
}
// 实际上 Skia 做的更复杂(处理反锯齿、混合等),
// 但核心思想就是这样:写入像素值
}
// 栅格化后的内存状态:
// y=0 FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ... (全是白色)
// y=99 FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ...
// y=100 FFFFFF... FFFFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFF... (开始有蓝色!)
// ^ ^ ^
// 0 100 100-300 是蓝色
// y=101 FFFFFF... FFFFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFF...
// ...
// y=299 FFFFFF... FFFFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFF...
// y=300 FFFFFF... FFFFFF 0000FFFF 0000FFFF ... 0000FFFF FFFFFF...
// y=301 FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF ... (又都是白色了)
// ============================================
// 操作 3:RestoreOp::Raster()
// ============================================
void RestoreOp::Raster(const RestoreOp* op,
SkCanvas* canvas,
const PlaybackParams& params) {
canvas->restore();
// 恢复之前保存的图形状态
}
经过这三个操作,我们已经有了一个完整的栅格化结果!
// 开始栅格化
// 输入 1:DisplayItemList(绘制指令)
DisplayItemList {
paint_op_buffer: [
SaveOp,
DrawRectOp(
rect={x:100, y:100, w:200, h:200},
color=0x0000FFFF // 蓝色
),
RestoreOp
],
visual_rects: [{100,100,200,200}, ...]
}
// 输入 2:空白画布(256×256 的位图)
SkBitmap bitmap {
width: 256,
height: 256,
pixels: [
0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF, // 第 0 行(256 个白色像素)
0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF, // 第 1 行
...
0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF // 第 255 行
]
}
// ============================================================
// 执行回放
// ============================================================
canvas->save();
canvas->drawRect(SkRect{100,100,200,200}, blue_paint);
canvas->restore();
// 栅格化后的输出:位图内容被修改了
SkBitmap bitmap {
width: 256,
height: 256,
pixels: [
// y=0 到 y=99:全是白色
0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF, // 第 0 行
0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF, // 第 1 行
...
0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF, // 第 99 行
// y=100 到 y=299:中间是蓝色,边缘是白色
0xFFFFFFFF, ..., 0xFFFFFFFF, 0x0000FFFF, 0x0000FFFF, ..., 0x0000FFFF, 0xFFFFFFFF, ...,
// |
// x=0 到 99 100 到 299(蓝色区域) 300 到 255
0xFFFFFFFF, ..., 0xFFFFFFFF, 0x0000FFFF, 0x0000FFFF, ..., 0x0000FFFF, 0xFFFFFFFF, ...,
// ... 重复 200 次(y=100 到 y=299)
// y=300 到 y=255:全是白色
0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF, // 第 300 行
0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF, // 第 301 行
...
0xFFFFFFFF, 0xFFFFFFFF, ..., 0xFFFFFFFF // 第 255 行
]
}
关键是 Skia 库的设计:
// Skia 的核心就是提供一个虚拟的 SkCanvas
// 无论你是在 CPU 上还是 GPU 上绘制,接口都是一样的
class SkCanvas {
public:
// 这些方法有多种实现:
// - CPU 实现:直接修改像素缓冲区
// - GPU 实现:生成 GPU 指令
virtual void drawRect(const SkRect& rect, const SkPaint& paint);
virtual void drawPath(const SkPath& path, const SkPaint& paint);
virtual void drawImage(const SkImage* image, ...);
// ... 等等
};
// CPU 版本的 drawRect(简化)
class SkRasterCanvas : public SkCanvas {
void drawRect(const SkRect& rect, const SkPaint& paint) override {
// 直接操作 fBitmap 的像素
uint32_t* pixels = fBitmap.getPixels();
for (int y = rect.top(); y < rect.bottom(); y++) {
for (int x = rect.left(); x < rect.right(); x++) {
int index = y * width + x;
pixels[index] = paint.getColor(); // 设置像素颜色
}
}
}
};
// GPU 版本的 drawRect(简化)
class SkGPUCanvas : public SkCanvas {
void drawRect(const SkRect& rect, const SkPaint& paint) override {
// 生成 GPU 指令
glBindFramebuffer(GL_FRAMEBUFFER, fFramebuffer);
glClearColor(paint.getColor());
glClear(GL_COLOR_BUFFER_BIT);
// ... 等等
}
};
总结:
canvas->drawRect()这就是为什么能把绘制指令转换成像素!
void RasterSource::PlaybackDisplayListToCanvas(
SkCanvas* raster_canvas,
const PlaybackSettings& settings) const {
CHECK(display_list_);
// 创建回放参数
PlaybackParams params(settings.image_provider, SkM44());
params.raster_inducing_scroll_offsets =
settings.raster_inducing_scroll_offsets;
params.destination_hdr_headroom = settings.hdr_headroom;
// 核心:回放 DisplayItemList
// 这会遍历所有 PaintOps 并执行它们
display_list_->Raster(raster_canvas, params);
}
// 这就是一切!
// display_list_->Raster() 会:
// 1. 遍历 DisplayItemList 中的每个 PaintOp
// 2. 对于每个 PaintOp,调用它的 Raster() 方法
// 3. 每个 Raster() 方法使用 Skia canvas 修改像素
// 4. 最后得到一个填满颜色的位图
大家好,我是1024小神,想进 技术群 / 私活群 / 股票群 或 交朋友都可以私信我,如果你觉得本文有用,一键三连 (点赞、评论、关注),就是对我最大的支持~
发布GitHub pages会有不同的几种状态,可以参考
409:表示已经发布过了
{
"message": "GitHub Pages is already enabled.",
"documentation_url": "https://docs.github.com/rest/pages/pages#create-a-apiname-pages-site",
"status": "409"
}
errored:表示发布失败
通常是发布过后的pages静态文件更新后,会自动重新发布,比如总的更新文件是3个,但是更新了1个之后就会出触发自动更新,但是第二个紧跟着就更新了,就会再次触发更新,这个时候第一个更新状态就会变成errored
{
"url": "https://api.github.com/repos/xxxxxx/xxxxxxx/pages",
"status": "errored",
"cname": null,
"custom_404": false,
"html_url": "https://xxxxxxx.github.io/xxxxxxx7/",
"build_type": "legacy",
"source": {
"branch": "main",
"path": "/docs"
},
"public": true,
"protected_domain_state": null,
"pending_domain_unverified_at": null,
"https_enforced": true
}
status:null 说明正在发布pages
{
"url": "https://api.github.com/repos/1024xiaoshen/PakePlus-Android-v2.1.6/pages",
"status": null,
"cname": null,
"custom_404": false,
"html_url": "https://1024xiaoshen.github.io/PakePlus-Android-v2.1.6/",
"build_type": "legacy",
"source": {
"branch": "main",
"path": "/docs"
},
"public": true,
"protected_domain_state": null,
"pending_domain_unverified_at": null,
"https_enforced": true
}
built:表示已经发布好了
{
"url": "https://api.github.com/repos/1024xiaoshen/PakePlus-Android-v2.1.6/pages",
"status": "built",
"cname": null,
"custom_404": false,
"html_url": "https://1024xiaoshen.github.io/PakePlus-Android-v2.1.6/",
"build_type": "legacy",
"source": {
"branch": "main",
"path": "/docs"
},
"public": true,
"protected_domain_state": null,
"pending_domain_unverified_at": null,
"https_enforced": true
}
如果你有好的想法或需求,都可以私信我,我这里有很多程序员朋友喜欢用代码来创造丰富多彩的计算机世界 本文是 blade-code 技术深度系列的第 1 篇,深入剖析如何从零实现一个生产级的 MCP(Model Context Protocol)客户端,包括连接管理、OAuth 认证、健康监控、工具注册等核心功能。
MCP(Model Context Protocol)是 Anthropic 推出的开放协议,用于 AI 应用与外部工具/数据源的标准化通信。它解决了以下问题:
MCP 提供了:
blade-code 的 MCP 集成采用三层架构:
graph TB
A[Agent Runtime 调用层] --> B[McpRegistry 管理层]
B --> C[McpClient 通信层]
A1[工具调用<br/>参数验证] -.-> A
B1[服务器注册/注销<br/>工具冲突处理<br/>状态监控] -.-> B
C1[连接管理<br/>协议通信<br/>错误重试<br/>OAuth 认证] -.-> C
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#ffe1f5
设计原则:
McpClient 是 MCP 集成的核心,负责与单个 MCP 服务器的通信。
MCP 支持三种传输方式,blade-code 通过工厂模式统一创建:
private async createTransport(): Promise<Transport> {
const { type, command, args, env, url, headers } = this.config;
if (type === 'stdio') {
// 子进程通信(本地工具)
return new StdioClientTransport({
command,
args: args || [],
env: { ...process.env, ...env },
stderr: 'ignore', // 忽略子进程的 stderr 输出
});
} else if (type === 'sse') {
// Server-Sent Events(远程服务)
return new SSEClientTransport(new URL(url), {
requestInit: { headers },
});
} else if (type === 'http') {
// HTTP 长轮询
const { StreamableHTTPClientTransport } = await import(
'@modelcontextprotocol/sdk/client/streamableHttp.js'
);
return new StreamableHTTPClientTransport(new URL(url), {
requestInit: { headers },
});
}
throw new Error(`不支持的传输类型: ${type}`);
}
关键点:
stdio 适合本地工具(如文件系统、数据库)sse 适合远程服务(实时推送)http 适合 RESTful API生产环境中,网络不稳定是常态。blade-code 实现了智能重试机制:
async connectWithRetry(maxRetries = 3, initialDelay = 1000): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await this.doConnect();
this.reconnectAttempts = 0; // 重置重连计数
return; // 成功连接
} catch (error) {
lastError = error as Error;
const classified = classifyError(error);
// 如果是永久性错误,不重试
if (!classified.isRetryable) {
console.error('[McpClient] 检测到永久性错误,放弃重试:', classified.type);
throw error;
}
// 指数退避
if (attempt < maxRetries) {
const delay = initialDelay * Math.pow(2, attempt - 1);
console.warn(`[McpClient] 连接失败(${attempt}/${maxRetries}),${delay}ms 后重试...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError || new Error('连接失败');
}
错误分类:
enum ErrorType {
NETWORK_TEMPORARY = 'network_temporary', // 临时网络错误(可重试)
NETWORK_PERMANENT = 'network_permanent', // 永久网络错误
CONFIG_ERROR = 'config_error', // 配置错误
AUTH_ERROR = 'auth_error', // 认证错误
PROTOCOL_ERROR = 'protocol_error', // 协议错误
UNKNOWN = 'unknown', // 未知错误
}
function classifyError(error: unknown): ClassifiedError {
const msg = error.message.toLowerCase();
// 永久性配置错误(不应重试)
const permanentErrors = [
'command not found',
'no such file',
'permission denied',
'invalid configuration',
];
if (permanentErrors.some((permanent) => msg.includes(permanent))) {
return { type: ErrorType.CONFIG_ERROR, isRetryable: false, originalError: error };
}
// 临时网络错误(可重试)
const temporaryErrors = [
'timeout',
'connection refused',
'econnreset',
'etimedout',
'503',
'429',
];
if (temporaryErrors.some((temporary) => msg.includes(temporary))) {
return { type: ErrorType.NETWORK_TEMPORARY, isRetryable: true, originalError: error };
}
// 默认视为临时错误(保守策略:允许重试)
return { type: ErrorType.UNKNOWN, isRetryable: true, originalError: error };
}
为什么这样设计?
MCP 服务器可能随时断开(进程崩溃、网络中断),blade-code 通过监听 onclose 事件自动重连:
this.sdkClient.onclose = () => {
this.handleUnexpectedClose();
};
private handleUnexpectedClose(): void {
if (this.isManualDisconnect) {
return; // 手动断开,不重连
}
if (this.status === McpConnectionStatus.CONNECTED) {
console.warn('[McpClient] 检测到意外断连,准备重连...');
this.setStatus(McpConnectionStatus.ERROR);
this.emit('error', new Error('MCP服务器连接意外关闭'));
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
console.error('[McpClient] 达到最大重连次数,放弃重连');
this.emit('reconnectFailed');
return;
}
// 指数退避:1s, 2s, 4s, 8s, 16s(最大30s)
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(async () => {
try {
await this.doConnect();
console.log('[McpClient] 重连成功');
this.reconnectAttempts = 0;
this.emit('reconnected');
} catch (error) {
const classified = classifyError(error);
if (classified.isRetryable) {
this.scheduleReconnect(); // 继续重连
} else {
this.emit('reconnectFailed'); // 永久失败
}
}
}, delay);
}
关键点:
McpRegistry 是单例模式的注册中心,管理多个 MCP 服务器。
async registerServer(name: string, config: McpServerConfig): Promise<void> {
if (this.servers.has(name)) {
throw new Error(`MCP服务器 "${name}" 已经注册`);
}
const client = new McpClient(config, name, config.healthCheck);
const serverInfo: McpServerInfo = {
config,
client,
status: McpConnectionStatus.DISCONNECTED,
tools: [],
};
// 设置客户端事件处理器
this.setupClientEventHandlers(client, serverInfo, name);
this.servers.set(name, serverInfo);
this.emit('serverRegistered', name, serverInfo);
try {
await this.connectServer(name);
} catch (error) {
console.warn(`MCP服务器 "${name}" 连接失败:`, error);
}
}
多个 MCP 服务器可能提供同名工具,blade-code 通过前缀解决冲突:
async getAvailableTools(): Promise<Tool[]> {
const tools: Tool[] = [];
const nameConflicts = new Map<string, number>();
// 第一遍:检测冲突
for (const [_serverName, serverInfo] of this.servers) {
if (serverInfo.status === McpConnectionStatus.CONNECTED) {
for (const mcpTool of serverInfo.tools) {
const count = nameConflicts.get(mcpTool.name) || 0;
nameConflicts.set(mcpTool.name, count + 1);
}
}
}
// 第二遍:创建工具(冲突时添加前缀)
for (const [serverName, serverInfo] of this.servers) {
if (serverInfo.status === McpConnectionStatus.CONNECTED) {
for (const mcpTool of serverInfo.tools) {
const hasConflict = (nameConflicts.get(mcpTool.name) || 0) > 1;
const toolName = hasConflict
? `${serverName}__${mcpTool.name}`
: mcpTool.name;
const tool = createMcpTool(serverInfo.client, serverName, mcpTool, toolName);
tools.push(tool);
}
}
}
return tools;
}
命名策略:
toolName
serverName__toolName
示例:
服务器 A: read_file
服务器 B: read_file
→ 最终工具: A__read_file, B__read_file
MCP 支持 OAuth 2.0 认证,blade-code 实现了完整的 OAuth 流程。
export class OAuthTokenStorage {
private readonly tokenFilePath: string;
constructor() {
const homeDir = os.homedir();
const configDir = path.join(homeDir, '.blade');
this.tokenFilePath = path.join(configDir, 'mcp-oauth-tokens.json');
}
async saveToken(
serverName: string,
token: OAuthToken,
clientId?: string,
tokenUrl?: string
): Promise<void> {
const credentials = await this.loadAllCredentials();
const credential: OAuthCredentials = {
serverName,
token,
clientId,
tokenUrl,
updatedAt: Date.now(),
};
credentials.set(serverName, credential);
await this.saveAllCredentials(credentials);
}
isTokenExpired(token: OAuthToken): boolean {
if (!token.expiresAt) {
return false; // 没有过期时间,认为不过期
}
// 提前 5 分钟视为过期,留出刷新时间
const buffer = 5 * 60 * 1000;
return Date.now() >= token.expiresAt - buffer;
}
}
安全措施:
0o600(仅所有者可读写)export class OAuthProvider {
private tokenStorage = new OAuthTokenStorage();
async getValidToken(
serverName: string,
oauthConfig: OAuthConfig
): Promise<string | null> {
const credentials = await this.tokenStorage.getCredentials(serverName);
if (!credentials) {
return null; // 没有令牌,需要认证
}
// 检查是否过期
if (this.tokenStorage.isTokenExpired(credentials.token)) {
// 尝试刷新
if (credentials.token.refreshToken) {
try {
const newToken = await this.refreshToken(credentials, oauthConfig);
await this.tokenStorage.saveToken(
serverName,
newToken,
credentials.clientId,
credentials.tokenUrl
);
return newToken.accessToken;
} catch (error) {
console.error('[OAuthProvider] 刷新令牌失败:', error);
return null; // 刷新失败,需要重新认证
}
}
return null; // 没有 refresh_token,需要重新认证
}
return credentials.token.accessToken;
}
async authenticate(
serverName: string,
oauthConfig: OAuthConfig
): Promise<OAuthToken> {
// 1. 生成授权 URL
const authUrl = this.buildAuthUrl(oauthConfig);
console.log(`请访问以下 URL 进行授权:\n${authUrl}`);
// 2. 启动本地回调服务器
const code = await this.startCallbackServer(oauthConfig.redirectUri);
// 3. 用授权码换取令牌
const token = await this.exchangeCodeForToken(code, oauthConfig);
// 4. 保存令牌
await this.tokenStorage.saveToken(
serverName,
token,
oauthConfig.clientId,
oauthConfig.tokenUrl
);
return token;
}
}
流程图:
graph TD
A[用户请求] --> B{检查令牌}
B -->|有效| C[返回令牌]
B -->|无效/过期| D{有 refresh_token?}
D -->|是| E[刷新令牌]
E --> F[返回新令牌]
D -->|否| G[启动 OAuth 流程]
G --> H[返回新令牌]
style C fill:#90EE90
style F fill:#90EE90
style H fill:#90EE90
生产环境中,MCP 服务器可能"僵死"(连接正常但不响应)。blade-code 实现了主动健康检查:
export class HealthMonitor extends EventEmitter {
private intervalTimer: NodeJS.Timeout | null = null;
private consecutiveFailures = 0;
constructor(
private client: McpClient,
private config: HealthCheckConfig
) {
super();
}
start(): void {
if (this.intervalTimer) {
return; // 已经启动
}
this.intervalTimer = setInterval(async () => {
try {
await this.performHealthCheck();
this.consecutiveFailures = 0; // 重置失败计数
} catch (error) {
this.consecutiveFailures++;
console.warn(
`[HealthMonitor] 健康检查失败 (${this.consecutiveFailures}/${this.config.maxFailures}):`,
error
);
if (this.consecutiveFailures >= this.config.maxFailures) {
this.emit('unhealthy', this.consecutiveFailures, error);
await this.attemptReconnect();
}
}
}, this.config.intervalMs);
}
private async performHealthCheck(): Promise<void> {
const timeout = this.config.timeoutMs || 5000;
await Promise.race([
this.client.listTools(), // 调用一个轻量级方法
new Promise((_, reject) =>
setTimeout(() => reject(new Error('健康检查超时')), timeout)
),
]);
}
private async attemptReconnect(): Promise<void> {
console.log('[HealthMonitor] 尝试重连...');
try {
await this.client.disconnect();
await this.client.connect();
this.consecutiveFailures = 0;
this.emit('reconnected');
} catch (error) {
console.error('[HealthMonitor] 重连失败:', error);
}
}
}
配置示例:
{
enabled: true,
intervalMs: 30000, // 每 30 秒检查一次
timeoutMs: 5000, // 超时时间 5 秒
maxFailures: 3 // 连续失败 3 次触发重连
}
MCP 工具需要转换为 blade-code 的 Tool 接口:
export function createMcpTool(
client: McpClient,
serverName: string,
mcpTool: McpToolDefinition,
toolName?: string
): Tool {
return {
name: toolName || mcpTool.name,
description: mcpTool.description || `MCP工具: ${mcpTool.name}`,
parameters: mcpTool.inputSchema || { type: 'object', properties: {} },
metadata: {
source: 'mcp',
serverName,
originalName: mcpTool.name,
},
async execute(args: Record<string, unknown>): Promise<ToolResult> {
try {
const response = await client.callTool(mcpTool.name, args);
return {
success: true,
output: formatMcpResponse(response),
metadata: {
serverName,
toolName: mcpTool.name,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
metadata: {
serverName,
toolName: mcpTool.name,
},
};
}
},
};
}
关键点:
originalName)用于调试blade-code 的错误处理遵循以下原则:
临时错误(可重试):
- 网络超时
- 连接被拒绝
- 速率限制(429)
- 服务不可用(503)
永久错误(不重试):
- 配置错误(命令不存在)
- 认证失败(401)
- 权限不足(403)
- 协议错误(格式错误)
指数退避:
- 第 1 次:1 秒后重试
- 第 2 次:2 秒后重试
- 第 3 次:4 秒后重试
- 第 4 次:8 秒后重试
- 第 5 次:16 秒后重试
- 最大延迟:30 秒
stateDiagram-v2
[*] --> DISCONNECTED
DISCONNECTED --> CONNECTING: connect()
CONNECTING --> CONNECTED: 成功
CONNECTING --> ERROR: 失败
ERROR --> CONNECTING: 重试
CONNECTED --> ERROR: 意外断连
CONNECTED --> DISCONNECTED: disconnect()
// 配置文件
{
"mcpServers": {
"filesystem": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/workspace"],
"env": {
"NODE_ENV": "production"
}
}
}
}
// 使用
const registry = McpRegistry.getInstance();
await registry.registerServer('filesystem', config.mcpServers.filesystem);
const tools = await registry.getAvailableTools();
// 输出: [{ name: 'read_file', ... }, { name: 'write_file', ... }]
{
"mcpServers": {
"github": {
"type": "sse",
"url": "https://api.github.com/mcp",
"oauth": {
"enabled": true,
"authUrl": "https://github.com/login/oauth/authorize",
"tokenUrl": "https://github.com/login/oauth/access_token",
"clientId": "your-client-id",
"clientSecret": "your-client-secret",
"scopes": ["repo", "user"],
"redirectUri": "http://localhost:3000/callback"
}
}
}
}
// 首次使用会自动触发 OAuth 流程
await registry.registerServer('github', config.mcpServers.github);
// 输出: 请访问以下 URL 进行授权: https://github.com/login/oauth/authorize?...
{
"mcpServers": {
"database": {
"type": "stdio",
"command": "mcp-database-server",
"healthCheck": {
"enabled": true,
"intervalMs": 30000,
"timeoutMs": 5000,
"maxFailures": 3
}
}
}
}
// 监听健康事件
registry.on('serverError', (name, error) => {
console.error(`服务器 ${name} 出错:`, error);
});
registry.on('healthMonitorReconnected', () => {
console.log('健康监控触发重连成功');
});
blade-code 的 MCP 集成实现了以下核心功能:
相关资源:
讨论:欢迎在 GitHub Issues 或我的博客评论区交流!
在JavaScript的世界里,继承不是简单的复制粘贴,而是一场关于"原型链"的奇妙冒险。想象一下:别的语言继承就像领养孩子,直接给一套新房子和新衣服;而JavaScript的继承更像是家族传承——孩子不仅有自己的家,还能随时去祖辈家里串门拿东西!
今天,就让我们一起揭开JavaScript继承的神秘面纱,亲手打造一个属于自己的"家族传承"系统。
让我们先来看看JavaScript中最"朴实"的继承方式。
假设我们有一个Animal(动物)家族:
function Animal(name, age) {
this.name = name; // 名字
this.age = age; // 年龄
}
Animal.prototype.species = '动物'; // 所有动物都有的物种属性
现在,Cat(猫)家族想要继承Animal家族的优良传统。最简单的做法是什么?
function Cat(name, age, color) {
// 先把Animal家族的基本功学过来
Animal.call(this, name, age);
this.color = color; // 猫特有的毛色
}
// 关键一步:成为Animal家族的"亲传弟子"
Cat.prototype = new Animal();
// 但别忘了改个名,不然别人还以为你是Animal
Cat.prototype.constructor = Cat;
const garfield = new Cat('加菲猫', 2, '黄色');
console.log(garfield.species); // ✅ 输出:动物(成功继承了物种!)
这里发生了什么?
Cat.prototype = new Animal():相当于Cat家族把Animal请来当顾问场景想象:你想请Animal当顾问,结果人家拖家带口、把全部家当都搬来了!new Animal()创建了一个完整的Animal实例,但我们需要的仅仅是Animal的"知识库"(原型),而不是它的全部身家。
三大痛点:
new Animal()时需要参数,但作为原型时不知道传什么有人可能想:"既然只是要原型,那直接共享不就行了?"
// 看似聪明的偷懒方法
Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;
危险!这是个陷阱!
// 猫家族想给自己加个技能
Cat.prototype.eatFish = function() {
console.log('我爱吃鱼!');
};
// 但意外发生了...
const dog = new Animal('旺财', 3);
dog.eatFish(); // 😱 输出:我爱吃鱼!(狗怎么爱吃鱼了?!)
问题所在:
Cat.prototype和Animal.prototype指向同一个对象
我们需要一个既能继承知识,又不造成混乱的方法。这就是我们的"空函数中介"模式——一个聪明的"传话筒"。
function extend(Parent, Child) {
// 1. 请一个"中间人"(空函数F)
// 它就像家族间的专业翻译,只传话,不添乱
var F = function() {};
// 2. 让中间人学习Parent的知识库
F.prototype = Parent.prototype;
// 3. 让Child拜中间人为师
Child.prototype = new F();
// 4. 给Child正名:你姓Child,不是Parent
Child.prototype.constructor = Child;
}
// 使用我们的extend函数
function Cat(name, age, color) {
// 继承Animal的"个人能力"
Animal.apply(this, [name, age]);
this.color = color; // 猫的独有特征
}
// 启动传承仪式!
extend(Animal, Cat);
// 猫家族发展自己的特色
Cat.prototype.purr = function() {
console.log('喵呜~发出呼噜声');
};
// 见证奇迹的时刻
const kitty = new Cat('小橘', 1, '橘色');
console.log(kitty.species); // ✅ "动物"(继承了Animal的物种)
kitty.purr(); // ✅ "喵呜~发出呼噜声"(猫的独有技能)
const bird = new Animal('小鸟', 0.5);
console.log(bird.purr); // ✅ undefined(完全没影响到Animal!)
三层隔离保护:
内存关系图:
kitty(猫实例)
↓ "我可以找我的家族要东西"
Cat.prototype(猫家族知识库)
↓ "我学自中间人F"
F.prototype(= Animal.prototype)
↓ "我来自Animal家族"
Animal.prototype(动物家族知识库)
↓ "我是所有对象的起点"
Object.prototype
让我们把理论变成实战代码:
// 增强版extend:更智能的传承系统
function extend(Child, Parent) {
// 1. 请专业中间人(开销极小)
var F = function() {};
// 2. 中间人学习Parent的全部知识
F.prototype = Parent.prototype;
// 3. Child拜师学艺
Child.prototype = new F();
Child.prototype.constructor = Child;
// 4. 给Child一个"家谱"(可选但很贴心)
Child.uber = Parent.prototype;
// 5. 现代JavaScript的额外支持
if (Object.setPrototypeOf) {
Object.setPrototypeOf(Child.prototype, Parent.prototype);
}
}
// 动物家族基类
function Animal(name, age) {
this.name = name;
this.age = age;
}
Animal.prototype.breathe = function() {
return '我在呼吸新鲜空气';
};
// 猫家族
function Cat(name, age, color) {
// 先学Animal的"生存技能"
Animal.call(this, name, age);
this.color = color;
}
// 启动传承
extend(Cat, Animal);
// 猫家族的独门绝技
Cat.prototype.climbTree = function() {
return '我能爬上最高的树!';
};
// 看看成果
const tom = new Cat('汤姆', 3, '蓝灰色');
console.log(tom.breathe()); // ✅ "我在呼吸新鲜空气"
console.log(tom.climbTree()); // ✅ "我能爬上最高的树!"
console.log(tom.color); // ✅ "蓝灰色"
ES6给了我们更优雅的写法:
class Animal {
constructor(name, age) {
this.name = name;
this.age = age;
}
breathe() {
return '我在呼吸新鲜空气';
}
}
class Cat extends Animal {
constructor(name, age, color) {
super(name, age); // 这行相当于 Animal.call(this, name, age)
this.color = color;
}
climbTree() {
return '我能爬上最高的树!';
}
}
重要提醒:class只是"语法糖",底层依然是我们的原型继承。理解原型,才能真正掌握JavaScript的继承精髓。
通过这次探索,我们学到了:
编程就像家族传承:
extend函数就像是找到了完美的家族信托方案如果你要继续优化这个extend函数,你会添加哪些功能?
动手挑战:尝试实现一个支持多重继承的extend函数,让一个类可以同时继承多个父类的特性。把你的代码分享到评论区,看看谁的实现最优雅!
记住:在JavaScript的世界里,理解原型链就像掌握家族的秘密通道。通过这些通道,你可以在不破坏原有结构的前提下,构建出强大而灵活的代码"家族"。现在,你也是掌握这个秘密的开发者了!
在 JavaScript 中,new 操作符是我们创建对象实例最常用的方式之一。但你真的了解 new 背后发生了什么吗?今天我们就来深入探讨一下 new 的奥秘,并亲手实现一个自己的 new 函数。
构造函数其实就是一个普通的函数,但当我们使用 new 关键字调用它时,它就变成了一个"构造函数"。
function Person(name, age) {
this.name = name;
this.age = age;
}
// 作为普通函数调用
Person('张三', 18); // this 指向全局对象(浏览器中是 window)
// 作为构造函数调用
const person = new Person('张三', 18); // this 指向新创建的对象
比喻:想象一下工厂生产产品的过程:
具体来说,new 操作符执行以下4个步骤:
const obj = {};
__proto__ 指向构造函数的 prototype
obj.__proto__ = Constructor.prototype;
this 绑定到这个新对象,并执行构造函数Constructor.apply(obj, args);
function Person(name) {
this.name = name;
// 如果没有显式返回,默认返回 this
}
function Person2(name) {
this.name = name;
return { custom: 'object' }; // 如果返回对象,则替代新创建的对象
}
const p1 = new Person('张三'); // Person {name: "张三"}
const p2 = new Person2('李四'); // {custom: "object"}
这三个方法都用于改变函数执行时的 this 指向,但使用方式略有不同。
想象你是一家公司的CEO(函数),你需要给员工(对象)下达指令:
function introduce(greeting, punctuation) {
console.log(`${greeting}, 我是${this.name}${punctuation}`);
}
const person = { name: '张三' };
// call 接受参数列表
introduce.call(person, '你好', '!'); // "你好, 我是张三!"
// apply 接受参数数组
introduce.apply(person, ['你好', '!']); // "你好, 我是张三!"
// bind 返回一个新函数,而不是立即执行
const boundIntroduce = introduce.bind(person, '你好');
boundIntroduce('!'); // "你好, 我是张三!"
| 方法 | 立即执行 | 参数形式 | 返回值 |
|---|---|---|---|
| call | 是 | 参数列表 | 函数执行结果 |
| apply | 是 | 数组 | 函数执行结果 |
| bind | 否 | 参数列表 | 新函数 |
arguments 是函数内部的一个特殊对象,它包含了函数调用时传入的所有参数。
function showArgs() {
console.log(arguments);
console.log(arguments.length);
console.log(arguments[0]);
}
showArgs(1, 2, 3);
// 输出:
// Arguments(3) [1, 2, 3]
// 3
// 1
arguments 看起来像数组,但不是真正的数组:
function checkArguments() {
console.log('长度:', arguments.length);
console.log('可索引:', arguments[0], arguments[1]);
console.log('是数组吗?', Array.isArray(arguments)); // false
console.log('类型:', Object.prototype.toString.call(arguments)); // [object Arguments]
}
checkArguments('a', 'b', 'c');
function tryArrayMethods() {
// 这些会报错
// arguments.map(item => item * 2); // ❌ 错误
// arguments.reduce((sum, num) => sum + num); // ❌ 错误
// 但可以这样遍历
for (let i = 0; i < arguments.length; i++) {
console.log(arguments[i]);
}
// 或者用 for...of(ES6+)
for (const arg of arguments) {
console.log(arg);
}
}
function convertArguments1() {
const argsArray = Array.from(arguments);
console.log(Array.isArray(argsArray)); // true
console.log(argsArray.map(x => x * 2)); // 可以正常使用数组方法
}
function convertArguments2(...args) { // 直接在参数中使用
console.log(Array.isArray(args)); // true
}
function convertArguments3() {
const argsArray = [...arguments];
console.log(Array.isArray(argsArray)); // true
}
function convertArguments4() {
const argsArray = Array.prototype.slice.call(arguments);
console.log(Array.isArray(argsArray)); // true
}
const arrowFunc = () => {
console.log(arguments); // ❌ 报错:arguments is not defined
};
// 箭头函数应该这样获取参数
const arrowFunc2 = (...args) => {
console.log(args); // ✅ 正确
};
function linkedArguments(a, b) {
console.log('a:', a, 'arguments[0]:', arguments[0]);
a = 'changed';
console.log('修改后 a:', a, 'arguments[0]:', arguments[0]);
arguments[0] = 'changed again';
console.log('再次修改后 a:', a, 'arguments[0]:', arguments[0]);
}
linkedArguments('original', 2);
// 输出:
// a: original arguments[0]: original
// 修改后 a: changed arguments[0]: changed
// 再次修改后 a: changed again arguments[0]: changed again
现在,让我们结合以上知识点,一步步实现自己的 new 函数。
function objectFactory(Constructor, ...args) {
// 1. 创建一个空对象
const obj = {};
// 2. 将新对象的原型指向构造函数的原型
obj.__proto__ = Constructor.prototype;
// 3. 将构造函数的 this 绑定到新对象,并执行构造函数
Constructor.apply(obj, args);
// 4. 返回新对象
return obj;
}
function objectFactory(Constructor, ...args) {
// 1. 创建新对象,并设置原型链
const obj = Object.create(Constructor.prototype);
// 2. 执行构造函数,绑定 this
const result = Constructor.apply(obj, args);
// 3. 判断构造函数返回的是否是对象
// 如果是对象则返回该对象,否则返回新创建的对象
return typeof result === 'object' && result !== null ? result : obj;
}
function objectFactory() {
// 1. 获取构造函数(第一个参数)
const Constructor = [].shift.call(arguments);
// 2. 创建空对象,并继承构造函数的原型
const obj = Object.create(Constructor.prototype);
// 3. 执行构造函数,将 this 指向新对象
const result = Constructor.apply(obj, arguments);
// 4. 返回结果
return typeof result === 'object' ? result : obj;
}
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayHello = function() {
console.log(`你好,我是${this.name},今年${this.age}岁`);
};
// 使用原生的 new
const person1 = new Person('张三', 18);
person1.sayHello(); // "你好,我是张三,今年18岁"
// 使用我们手写的 objectFactory
const person2 = objectFactory(Person, '李四', 20);
person2.sayHello(); // "你好,我是李四,今年20岁"
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Person); // true
console.log(person1.sayHello === person2.sayHello); // true(共享原型方法)
// 1. 构造函数返回对象的情况
function Car(model) {
this.model = model;
return { custom: 'special object' }; // 返回对象
}
const car = objectFactory(Car, 'Tesla');
console.log(car); // {custom: "special object"},而不是 Car 实例
// 2. 构造函数返回基本类型的情况
function Bike(brand) {
this.brand = brand;
return 'not an object'; // 返回基本类型,会被忽略
}
const bike = objectFactory(Bike, 'Giant');
console.log(bike); // Bike {brand: "Giant"},返回新创建的对象
许多库(如早期的 jQuery)会使用类似的技术来创建对象,避免使用 new 关键字:
// jQuery 风格的初始化
function $(selector) {
return new jQuery(selector);
}
// 或者
function $(selector) {
return objectFactory(jQuery, selector);
}
function createObjectPool(Constructor, count) {
const pool = [];
for (let i = 0; i < count; i++) {
pool.push(objectFactory(Constructor));
}
return pool;
}
// 创建 10 个默认的 Person 对象
const personPool = createObjectPool(Person, 10);
function singleton(Constructor, ...args) {
let instance = null;
return function() {
if (!instance) {
instance = objectFactory(Constructor, ...args);
}
return instance;
};
}
const getSingletonPerson = singleton(Person, '单例', 100);
const p1 = getSingletonPerson();
const p2 = getSingletonPerson();
console.log(p1 === p2); // true
通过手写 new 操作符,我们深入理解了 JavaScript 对象实例化的过程:
理解这些底层机制,不仅可以帮助我们更好地使用 JavaScript,还能在面试中脱颖而出。更重要的是,这种"知其然知其所以然"的学习方式,能够让我们在面对复杂问题时,有能力从底层原理出发,找到最优雅的解决方案。
记住,每个看似简单的 new 背后,都隐藏着 JavaScript 原型链、this 绑定、函数执行等多个核心概念的完美协作。掌握了这些,你就真正理解了 JavaScript 面向对象编程的精髓。
最近我在做一个AI聊天应用时,遇到了一个关键问题:如何让AI的回复像真人打字一样,一个字一个字地出现? 经过一番探索,我发现了流式输出 + Buffer的组合方案。今天,我就用我的实际代码,带你彻底搞懂这个技术!
想象你有一个 智能聊天机器人🤖:
生活例子📺: 想象你家电视的遥控器:
这里的 响应式 就是:按遥控器(改变数据),电视立即响应(页面更新)。
// 创建响应式数据就像给数据装上"遥控器"
const question = ref('你好'); // 创建一个能"遥控"的数据
// 在模板中显示
<div>{{ question }}</div> <!-- 显示:你好 -->
// 如果改变数据
question.value = 'Hello'; // 按下"遥控器"
// 页面自动变成
<div>Hello</div> <!-- 页面自动更新! -->
ref 是什么?ref 就是把普通数据包装成一个特殊的盒子📦:
// 普通数据
let name = "小明";
// 改变时,Vue不知道,页面不会更新
// 响应式数据
const nameRef = ref("小明");
// 实际上变成了:{ value: "小明" }
// 访问时要加 .value
console.log(nameRef.value); // "小明"
// 改变数据
nameRef.value = "小红"; // Vue 知道数据变了,会更新页面
v-model - 双向绑定双向绑定 就像 同步的记事本📝:
<!-- 创建一个输入框 -->
<input v-model="question" />
<!-- 这相当于做了两件事:
1. 输入框显示 question 的值
2. 你在输入框打字时,自动更新 question 的值
-->
实际效果:
// 你输入"你好"
question.value = "你好";
// 页面显示
<input value="你好" />
// 你再输入"大家好"
// question.value 自动变成 "大家好"
@click - 事件监听就像给按钮装上 门铃🔔:
<button @click="askLLM">提交</button>
<!-- 意思是:点击这个按钮时,执行 askLLM 函数 -->
const askLLM = async () => {
// 1. 准备问题(像写菜单)
if (!question.value) {
console.log('问题不能为空');
return;
}
// 2. 显示"思考中..."(像显示"商家接单中")
content.value = "思考中...";
// 3. 准备外卖信息
const endpoint = 'https://api.deepseek.com/chat/completions'; // 外卖平台地址
const headers = {
'Authorization': `Bearer ${你的API密钥}`, // 支付凭证
'Content-Type': 'application/json', // 说要送JSON格式
};
// 4. 下订单
const response = await fetch(endpoint, {
method: 'POST', // 点外卖用POST
headers, // 告诉商家信息
body: JSON.stringify({ // 具体订单内容
model: 'deepseek-chat',
stream: stream.value, // 要不要流式(分批送)
messages: [{
role: 'user',
content: question.value
}]
})
});
// 5. 等外卖送到并处理
// ... 后面详细讲
}
比喻🎬:
在这个应用中:
if (stream.value) { // 如果用户选了流式模式
// 第一步:清空上次的回答
content.value = ""; // 清空显示区域
// 第二步:创建"水管"和"水龙头"
const reader = response.body?.getReader();
// reader 就像水龙头,可以控制水流
const decoder = new TextDecoder();
// decoder 就像净水器,把脏水(二进制)变成干净水(文字)
let done = false; // 记录水是否流完了
let buffer = ''; // 临时水桶,装不完整的水
// 第三步:开始接水(循环读取)
while (!done) { // 只要水没流完就一直接
// 接一瓢水(读一块数据)
const { value, done: doneReading } = await reader?.read();
// value: 接到的水(二进制数据)
// doneReading: 这一瓢接完了吗?
done = doneReading; // 更新是否流完的状态
// 第四步:处理接到的水
// 把这次的水和上次没处理完的水合在一起
const chunkValue = buffer + decoder.decode(value);
buffer = ''; // 清空临时水桶
console.log("收到数据:", chunkValue);
// 数据格式类似:
// data: {"delta": {"content": "你"}}
// data: {"delta": {"content": "好"}}
// data: [DONE]
// 第五步:把一大块水分成一行一行
const lines = chunkValue.split('\n') // 按换行分割
.filter(line => line.startsWith('data: ')); // 只保留以"data: "开头的行
// 第六步:处理每一行水
for (const line of lines) {
const incoming = line.slice(6); // 去掉开头的"data: "
// 现在 incoming = '{"delta": {"content": "你"}}'
// 如果是结束标志
if (incoming === '[DONE]') {
done = true; // 停止接水
break; // 跳出循环
}
try {
// 第七步:解析JSON(把水变成能喝的东西)
const data = JSON.parse(incoming);
// data = { delta: { content: "你" } }
const delta = data.choices[0].delta.content;
// delta = "你"
if (delta) {
// 第八步:显示出来
content.value += delta; // 把"你"加到显示内容里
// 第一次:content = "你"
// 第二次:content = "你好"
// 第三次:content = "你好世"
// ... 直到完成
}
} catch (error) {
// 如果JSON解析失败(比如收到了不完整的JSON)
buffer += `data: ${incoming}`; // 存起来等下一瓢水
}
}
}
}
buffer?情景模拟: 假设AI要回复"你好世界",但网络传输时可能这样:
第一次收到:data: {"delta": {"content": "你
(JSON不完整,少了右括号)
第二次收到:好世界"}}
如果直接解析第一次的数据:
JSON.parse('{"delta": {"content": "你'); // 报错!JSON不完整
所以我们需要:
buffer = 'data: {"delta": {"content": "你'
buffer + 新数据 = 'data: {"delta": {"content": "你好世界"}}'
让我用具体的执行过程展示这个系统的精妙:
javascript
// 用户输入:"你好"
// 服务器响应流开始...
// 第1次循环:
收到数据: data: {"delta": {"content": "你"}}\n
分割成行: ['data: {"delta": {"content": "你"}}']
解析成功!→ 显示:"你"
// 第2次循环:
收到数据: data: {"delta": {"content": "好
分割成行: ['data: {"delta": {"content": "好']
JSON解析失败!→ 存入buffer: 'data: {"delta": {"content": "好'
// 第3次循环:
收到数据: "}}\n
当前数据: buffer + 新数据 = 'data: {"delta": {"content": "好"}}'
分割成行: ['data: {"delta": {"content": "好"}}']
解析成功!→ 显示:"你好"
// 第4次循环:
收到数据: data: [DONE]\n
检测到[DONE] → 结束循环
你打开页面
↓
看到输入框:[讲一个笑话]
↓
点击"提交"
↓
Vue调用 askLLM() 函数
↓
显示"思考中..."
↓
发送请求到DeepSeek
↓
AI开始思考
↓
【流式模式】
↓
收到第一个字:"有"
↓
页面显示:有
↓
收到第二个字:"个"
↓
页面显示:有个
↓
收到第三个字:"人"
↓
页面显示:有个人
↓
...(持续)
↓
收到"[DONE]"
↓
显示完整:有个人去面试...
| 概念 | 比喻 | 作用 |
|---|---|---|
ref() |
遥控器📱 | 让数据变化时页面自动更新 |
v-model |
双向镜子🪞 | 输入框和数据的双向同步 |
@click |
门铃🔔 | 点击时执行函数 |
fetch() |
外卖小哥🚴 | 发送网络请求 |
getReader() |
水龙头🚰 | 读取流式数据 |
TextDecoder() |
翻译官👨💼 | 把二进制变成文字 |
JSON.parse() |
拆包裹📦 | 把JSON字符串变成对象 |
stream.value 改成 false 看看区别console.log 里看数据变化console.log() 打印每一步的结果这个代码虽然看起来复杂,但每个部分都有明确的作用。就像搭积木一样,每块积木(函数)都有特定的功能,组合起来就实现了强大的AI聊天功能!😊
<script setup>
import { ref } from 'vue';
const question = ref('讲一个光头强和一个白富美之间的故事,20字');
const stream = ref(true);
const content = ref("");
const askLLM = async () => {
if (!question.value) {
console.log('question is empty');
return;
}
content.value = "思考中...";
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json',
};
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-chat',
stream: stream.value,
messages: [{
role: 'user',
content: question.value
}]
})
});
if (stream.value) {
content.value = "";
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';
while (!done) {
const { value, done: doneReading } = await reader?.read();
console.log(value, doneReading);
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
console.log(chunkValue);
buffer = '';
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '));
for (const line of lines) {
const incoming = line.slice(6);
if (incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) {
content.value += delta;
}
} catch (error) {
buffer += `data: ${incoming}`;
}
}
}
} else {
const data = await response.json();
console.log(data);
content.value = data.choices[0].message.content;
}
}
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question"/>
<button @click="askLLM">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream"/>
<div>{{content}}</div>
</div>
</div>
</div>
</template>
<style scoped>
* {
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
button {
padding: 0 10px;
margin-left: 6px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
</style>
大家好,我是 Sunday。
早上一起来,手机就被刷屏了...各大号主都在写 OpenAI 和 Anthropic 在昨天晚上发布的最新模型 GPT-5.3-Codex 和 Claude Opus 4.6
我看了一眼各大号主的文章,发现大家都在狂欢(真假不重要,至少文章中都在狂欢),都在发跑分截图。有的说 Claude 的 一百万(1M)上下文无敌 了,有的说 GPT-5.3 的 OSWorld 分数太吓人。
但是,Sunday 作为一个写了十几年代码、现在天天琢磨怎么用 AI 提效的“老前端”。看了这两篇长达数万字的技术文档之后,却并没有其他博主反应的那么兴奋。
为什么呢?
因为,Sunday 发现在这两篇文章中,顶级 AI 模型目前争论的焦点,已经完全不是“谁写的代码 Bug 少”这么简单了,他们正在重新定义什么是“写代码”?
所以,今天这篇文章,Sunday 就我不带大家像看热闹一样去比那些虚无缥缈的跑分(虽然跑分都很高),我想带大家从另外一个角度,来看看这次更新的核心内容。或许大家看完之后,能有不一样的收获。
先说 Anthropic 的 Claude Opus 4.6。
大家都在吹 100 万 Token 上下文(1M Context)。
很多人对这个数字没概念,觉得不就是:“可以把一个超级大的文档扔给 Claude 让他处理了吗?”
错。大错特错。
想要明白这个,我们需要先知道 AI 写代码到底是怎么去写的!
在 Opus 4.6 之前,我们用 AI 写代码是 “切片式” 的。
什么意思呢?
比如:你的项目可能有 500 个文件,但你每次只能复制粘贴那 3 个相关的文件给 AI。 AI 每次也只是读取其中的部分文件的部分代码。简单理解就是:AI 是看不见的全局架构设计的。它看不见你的隐藏依赖、架构设计方案、更不了解你 CSS 中可能会存在的全局污染问题。
所以,AI 经常会写出那种 “局部完美,全局崩盘” 的代码。
之前我们的处理方式都是:先让 AI 进行逻辑拆解,然后根据具体的步骤在一步步执行,这样会好很多。
但是现在不一样了,100 万 Token 上下文已经足够我们去理解非常大的项目代码。
除此之外,Claude 还提供了一个叫做 Context Compaction(上下文压缩) 的技术,这个技术会会自动总结并替换旧的上下文,让 Claude 能够执行更长时间的任务而不会达到限制。
这意味着什么?
这意味着:你可以把 整个 前端项目(src 目录 + 配置 + 文档)一次性扔给 Claude,他们通过上下文压缩方案,来逐步处理你的所有源代码。
除了这个之外,还有一个叫做 Agent Teams(代理团队) 的东西。这个和 Sunday 之前写的一篇文章有点相似,感兴趣的同学可以看看:我创建了一个全 AI 员工的一人公司
这玩意是啥意思呢?
简单来说就是:以前我们用 Claude Code,是一对一。现在,他可以直接创建出一个 Claude Code 的团队。 团队中每个人(Agent)各司其事,各自独立,并还可以相互写作,相互吵架。
就像 claude 的更新公告在结尾所展示的一样:这次更新是解锁了新的长期任务的开始
如果说 Claude 是在横向扩展(容量),那 OpenAI 的 GPT-5.3 Codex 就是在纵向钻孔(深度)。
这篇博客里最让我起鸡皮疙瘩的一句话是:
"GPT-5.3 Codex is the first model to play a key role in its own development." (GPT-5.3 Codex 是第一个在自身开发过程中发挥关键作用的模型。)
兄弟们,这剧本我看过,这就叫“左脚踩右脚上天”。
在 OpenAI 的程序员现在已经开始使用 GPT 来开发新的 GPT 了....
这意味着: 目前的 AI 模型已经完全可以应用在复杂的商业项目中。
而在看完整个 GPT 5.3 的更新公告之后,Sunday 发现其中有两个点是最重要的:
以前用 Agent 写代码,大家最害怕的是什么?
最怕的是它 “一条道走到黑”!
你给个需求,它跑了半小时,最后给你一坨跑不通的代码,你还不知道它是哪一步歪的。
GPT-5.3 Codex 引入了 “人机介入” 机制。
当它在构建一个复杂的 Web 游戏(比如博客里展示的那个赛车游戏)时
如果它卡住了,或者方向偏了,你可以实时暂停它,给它喂一句反馈,它能无缝接住你的思路继续干。
这解决了 Agent 落地最大的痛点:不可控性。
OSWorld 是 AI 模型中的一个专业属于,他表示的是 对视觉任务的识别程度。
通常情况下,普通人的 OSWorld 大约是在 72% 左右。
但是,这次 GPT 5.3 的更新 OSWorld 分数飙到了 64.7%。
这意味着,GPT 现在不只是只能在终端里面跑命令行了。他现在可以像人一样,打开浏览器,打开 Chrome DevTools,点击那个报错的按钮,查看 Network 面板,然后切回编辑器改代码。
这下感觉 “测试要失业了...”。之前咱们还得写个测试代码啥的,现在都已经不需要了。
我们可以直接告诉 GPT-5.3:“去把那个下单流程测一遍,如果支付失败了,截个图发我。”
它现在完全可以做到了!
不知道大家看完这些升级之后是什么感觉?
Sunday 个人的感觉是:“前端又要失业了”。毕竟 2025 年前端一年就死了 10 次...
没那么严重!
不过,大家需要注意的是:如果你还是把自己定义为 “切图仔” 或者 “API 调包侠” ,那是真完了
但是,如果你把自己定义为 “产品工程师” !那么属于你的黄金时代才刚刚开始。
为什么这么说?
以前,我们要想做一个全栈应用,门槛太高了。你要懂 Docker,要懂 K8s,要懂数据库调优。
现在,GPT-5.3 Codex 连自己的训练集群都能管理。你只要有系统设计的能力,你一个人就是一个团队(AI 虽然不便宜,但是比请人可便宜多了)。
前端开发者,凭借对 用户体验(UX) 的敏感度,加上 AI 强大的后端填补能力,将成为最容易转型为“独立开发者”或“超级个体”的人群。
在以前,我们这些程序员 80% 的时间在研究 "如何去实现某一个功能"(这个效果怎么用 CSS 实现?这个状态怎么管理?)。
但是现在,不需要了。
现在,无论是 Claude 还是 GPT 都可以帮助我们去解决如何实现功能的问题。
因此,我们需要把重点改为:我们要解决什么问题?用户的痛点在哪里?系统的数据流怎么设计才合理?
在这种场景下,对用户和需求的感知力 正变的越来越重要。
最后,我想问大家一个问题:
如果明天,你不需要再写一行具体的业务代码,只需要审核 Agent 提交的 PR,你会把你省下来的这 8 个小时,用来干什么?
是去学新的技术?还是去深入理解业务?或者,去创造一个属于你自己的产品?
这才是 2026 我们思考的事情
别光看着跑分激动了。行动起来,去申请 API,去把你的 IDE 换成 Cursor 或者 Windsurf,去感受一下这种 “与硅基生物结对编程” 的感觉。
我是 Sunday。如果你在用这两个模型的过程中遇到了什么坑,或者发现了什么新玩法,随时在评论区或者群里告诉我。我们一起研究,绝不掉队。
在前端开发中,我们经常遇到需要中途撤回请求的情况(例如:搜索框快速输入、大型文件上传取消、或是 AI 聊天流的即时中断)。传统的 Promise 一旦启动就无法在外部“叫停”,而 AbortController 的出现,完美填补了这一空白。
AbortController 是 JavaScript 内置的信号控制对象,它是实现异步操作可控制、可中止的核心。
controller.signal:一个 AbortSignal 对象实例。它充当“监听器”,将其传递给异步操作后,该操作会持续观察信号状态。
controller.abort() :触发中止的方法。调用后,signal 上的 abort 事件会被触发,同时将 signal.aborted 设为 true。
new AbortController() 生成实例。signal 属性传递给需要支持中止的异步 API(如 fetch)。controller.abort() 即可主动终止。// 1. 创建 AbortController 实例
const controller = new AbortController();
const { signal } = controller;
// 2. 发起请求并绑定信号
fetch("/api/data", { signal })
.then((response) => response.json())
.then((data) => console.log("请求成功:", data))
.catch((err) => {
// 3. 捕获中止错误
if (err.name === "AbortError") {
console.log("主动取消:请求被成功截断");
} else {
console.error("请求失败:", err);
}
});
// 2 秒后主动取消请求
setTimeout(() => {
controller.abort();
}, 2000);
如果想同时取消多个相关的请求,可以给这些请求共享同一个 signal。当调用 abort() 时,所有关联的任务都会收到中止信号。
// 使用同一个 AbortController 取消多个请求
const controller = new AbortController();
// 请求1
const request1 = fetch('url1', {
signal: controller.signal
});
// 请求2
const request2 = fetch('url2', {
signal: controller.signal
});
// 请求3
const request3 = fetch('url3', {
signal: controller.signal
});
// 同时取消所有请求
document.getElementById('cancelBtn').addEventListener('click', () => {
controller.abort();
console.log('所有请求已取消');
});
// 等待所有请求
Promise.all([request1, request2, request3])
.then(responses => Promise.all(responses.map(r => r.json())))
.then(data => console.log('所有数据:', data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('请求被取消');
}
});
fetch、Axios (v0.22+) 模块已提供支持。abort() 方法只能生效一次。多次调用虽然不会报错,但只有第一次调用会触发中止逻辑。| 特性 | 传统 Promise | 带有 AbortController 的 Promise |
|---|---|---|
| 可控性 | 开启后无法干预 | 可随时通过 abort() 中止 |
| 异常处理 | 只有成功/失败 | 增加 AbortError 类型,方便区分主动取消与网络异常 |
| 应用场景 | 简单的数据获取 | 复杂交互、流式输出、性能调优 |
写在前面
React 的痛: 在 React 中,一个 State 变了,组件就会重新执行(Re-render)。为了性能,我们不得不搞出 Fiber 架构,搞出时间切片,搞出
useMemo。这就好比:为了能在干草堆里找到一根针,React 发明了一台超级高科技的“干草堆翻找机”。Signal 的解: 细粒度响应式(Signal)的思路是:在扔针进去的时候,就给针系上一根绳子。要找针的时候,拉绳子就行了。
本篇我们将深入内核,手写一个迷你 Signal 系统,看清它的本质。
要理解 Signal,首先要理解它想革谁的命。
React 的更新模型是 Snapshot(快照) 式的。
SolidJS 或 Vue 的更新模型是 Dependency Graph(依赖图) 式的。
Signal 并不神秘,它本质上就是 “保存值的容器” + “自动依赖追踪” 。 它由两个核心动作组成:Track (追踪/读) 和 Trigger (触发/写) 。
以 SolidJS/React 风格为例,我们造一个 Signal:
// 这是一个全局变量,用来记录“当前谁在查我不?”
let activeEffect = null;
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set(); // 订阅者名单
// Getter (读)
const read = () => {
if (activeEffect) {
// 1. 依赖收集 (Track):如果有人在关注我,把他记下来
subscribers.add(activeEffect);
}
return value;
};
// Setter (写)
const write = (newValue) => {
value = newValue;
// 2. 派发更新 (Trigger):通知名单里所有人干活
subscribers.forEach(fn => fn());
};
return [read, write];
}
光有 Signal 没用,得有人“读”它,订阅关系才能建立。这就需要 createEffect(在 Vue 里叫 watchEffect)。
function createEffect(fn) {
// 把自己标记为“正在执行的副作用”
activeEffect = fn;
// 执行一次函数。
// 注意:函数内部会读取 Signal,从而触发 Signal 的 Getter,
// 进而把这个 fn 添加到 subscribers 里。
fn();
// 执行完复原
activeEffect = null;
}
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log("数字变了:", count());
});
// 输出: 数字变了:0 (初始化执行)
setCount(1);
// 输出: 数字变了:1 (自动触发!)
这就是细粒度响应式的最简内核。没有任何 VDOM,没有 Diff,只有精准的函数调用链。
Signal 系统最强大的地方在于它能自动构建依赖图。 在架构设计中,我们经常使用 computed (派生状态)。
computed 既是 消费者(它依赖别的 Signal),又是 生产者(别的 Effect 依赖它)。
细粒度框架中的 computed 通常是惰性的(Lazy)。
这是 React useMemo 永远做不到的。 React 的依赖数组 [a, b] 是手动声明的(静态)。而 Signal 的依赖是运行时动态收集的。
const [show, setShow] = createSignal(true);
const [name, setName] = createSignal("Gemini");
const [age, setAge] = createSignal(18);
createEffect(() => {
// 动态依赖!
if (show()) {
console.log(name()); // 此时依赖是 [show, name]
} else {
console.log(age()); // 此时依赖变成 [show, age]
}
});
架构意义: 这种机制保证了最小化计算。当 show 为 false 时,改变 name 根本不会触发这个 Effect,因为系统知道这一刻 name 不重要。
既然 Signal 这么好,性能这么高,为什么 React 不把 useState 换成 Signal? 这涉及到底层哲学的冲突。
React 团队认为,手动处理 .value 或者 [get, set] 是对开发者心智的负担。他们追求的是 "It just works" 。 React 正在搞的 React Compiler (React Forget) ,其实是一条殊途同归的路:
理解了原理,我们在架构设计中就能明白:
Vue 3 / Solid: 适合高性能仪表盘、即时通讯、即时编辑类应用。因为它们对 CPU 的利用率极高,没有 VDOM 的 Overhead。
React: 适合大型业务系统、生态依赖重的应用。虽然有一些性能损耗,但其编程模型的一致性(Pure Render)能降低逻辑复杂度。
趋势: 越来越多的状态管理库(MobX, Valtio, Preact Signals)允许你在 React 中使用 Signal。
Next Step: 我们搞懂了前端“怎么存数据”(Redux/Atomic)和“怎么更新数据”(Signal)。 但还有一个最大的麻烦没解决:API 数据。 我们以前总是把后端返回的 JSON 也塞进 Redux 里,导致 Redux 变得臃肿不堪。这真的是对的吗? 下一节,我们将通过 React Query (TanStack Query) 来一场架构大扫除。 请看**《第三篇:分治——把 API 赶出 Redux:服务端状态 (Server State) 与客户端状态的架构分离》**。
在现代前后端分离的 Web 应用中,权限管理(Permission Control) 已不再是“后端的专属问题”。
一个成熟的 Vue 项目,往往需要对 菜单、路由、按钮、接口调用 等多个层面进行前端权限管控。
如果没有完善的权限体系,应用就可能出现以下问题:
本文将系统讲解 Vue 项目的权限管理机制,涵盖从设计思路、技术实现到工程最佳实践,帮助你构建一个高扩展、高安全性的权限控制体系。
前端权限控制是指在用户登录成功后,根据其角色或授权信息,动态控制:
典型应用场景:
| 权限类型 | 控制对象 | 技术实现 |
|---|---|---|
| 路由权限 | 页面级访问控制 | 动态路由 / 路由守卫 |
| 菜单权限 | 导航展示项 | 过滤菜单树 |
| 按钮权限 | 组件细粒度控制 | 自定义指令(v-permission) |
| 数据权限 | 接口或字段访问 | 请求拦截或后端过滤 |
后端在用户登录后返回一份权限数据,常见格式为:
{
"roles": ["admin"],
"permissions": ["user:view", "user:edit", "order:list"]
}
Vue Router 提供了动态注册路由的能力,我们可以在登录时动态添加用户可访问的路由。
// permission.js
const allRoutes = [
{ path: '/dashboard', name: 'Dashboard', meta: { permission: 'dashboard:view' } },
{ path: '/user', name: 'User', meta: { permission: 'user:view' } },
{ path: '/user/edit', name: 'UserEdit', meta: { permission: 'user:edit' } }
]
export function filterRoutesByPermission(userPerms) {
return allRoutes.filter(route => userPerms.includes(route.meta.permission))
}
在登录成功后:
import router from '@/router'
import { filterRoutesByPermission } from './permission'
const userPerms = ['dashboard:view', 'user:view']
const accessRoutes = filterRoutesByPermission(userPerms)
accessRoutes.forEach(route => router.addRoute(route))
💡 效果:
只有被授权的用户,才能访问定义在 router 中对应 meta.permission 的页面。
基于相同的权限结构,我们可以将菜单配置与路由信息结合:
// menuConfig.js
export const menuMap = [
{ name: 'Dashboard', path: '/dashboard', permission: 'dashboard:view' },
{ name: '用户列表', path: '/user', permission: 'user:view' },
{ name: '编辑用户', path: '/user/edit', permission: 'user:edit' }
]
// 过滤菜单
export function getVisibleMenus(perms) {
return menuMap.filter(menu => perms.includes(menu.permission))
}
在模板中动态渲染菜单:
<template>
<ul>
<li v-for="item in visibleMenus" :key="item.path">
<router-link :to="item.path">{{ item.name }}</router-link>
</li>
</ul>
</template>
<script setup>
import { getVisibleMenus } from '@/config/menuConfig'
import { useUserStore } from '@/store/user'
const user = useUserStore()
const visibleMenus = getVisibleMenus(user.permissions)
</script>
在 Vue 3 中,我们可通过自定义指令实现按钮级权限控制。
// directives/permission.js
export default {
mounted(el, binding) {
const { value } = binding
const userPerms = JSON.parse(localStorage.getItem('permissions') || '[]')
if (value && !userPerms.includes(value)) {
el.parentNode && el.parentNode.removeChild(el)
}
}
}
注册指令:
import { createApp } from 'vue'
import App from './App.vue'
import permission from './directives/permission'
const app = createApp(App)
app.directive('permission', permission)
app.mount('#app')
模板使用示例:
<button v-permission="'user:edit'">编辑用户</button>
👉 当用户没有 user:edit 权限时,该按钮将不会被渲染。
在请求层面控制访问数据安全:
// axios 拦截器
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
后端验证时再结合角色或租户ID过滤查询结果,形成“前后端协同保护”。
| 项 | 优点 | 缺点 |
|---|---|---|
| 前端控制 | 响应快、体验好、可见即所得 | 安全依赖后端配合,易被篡改 |
| 后端控制 | 数据安全性高 | 增加接口设计复杂度 |
| 前后端协同 | 安全与体验兼顾 | 系统架构复杂度提升 |
user:view、order:delete;hasPermission(perms, code),便于复用;在 Vue 项目中构建完善的权限体系,是前端架构成熟度的重要标志。
它不仅仅是“限制访问”,更是“清晰定义角色职责”的手段。
通过 动态路由加载 + 指令权限验证 + 后端协同管控,我们可以:
未来,随着 Vue 与服务端协同框架(如 NestJS、GraphQL)的发展,权限管理将朝着**“策略引擎化(Policy Engine)”与“配置即规则化(Config-as-Policy)”**方向演进。
💬 一句话总结:
权限系统是 Vue 项目的“安全大脑”。
没有权限控制的前端,就像一个没有门锁的房子 —— 漂亮,但不安全。
Buffer(缓冲区)是计算机内存中用于临时存储数据的一块区域。想象一下你正在用杯子接水龙头的水:水龙头直接流到杯子里,如果水流太快,杯子可能会溢出。但如果你在中间放一个水壶(缓冲区),水先流到水壶里,再从水壶倒到杯子里,整个过程就更加可控了。
在JavaScript中,Buffer就是那个"水壶"——它帮助我们在处理二进制数据(如图片、音频、网络传输等)时更加高效和可控。
计算机中一切数据最终都以二进制形式存储,但我们在编程时通常处理的是文本(字符串)。当需要处理非文本数据时,就需要Buffer。
生活比喻:就像快递运输,文本数据就像明信片,内容直接可见;二进制数据就像密封的包裹,你需要专门的工具(Buffer)来查看和处理里面的内容。
直接操作二进制数据比操作字符串更高效,特别是在处理大量数据时。
这是HTML5提供的编码/解码工具:
// 编码:将字符串转换为二进制数据
const encoder = new TextEncoder();
const myBuffer = encoder.encode('你好 HTML5');
console.log(myBuffer); // Uint8Array(10) [228, 189, 160, 229, 165, 189, 32, 72, 84, 77, ...]
// 解码:将二进制数据转换回字符串
const decoder = new TextDecoder();
const originalText = decoder.decode(myBuffer);
console.log(originalText); // "你好 HTML5"
注意:中文字符通常占用3个字节,英文字符占用1个字节,空格也是1个字节。
// 创建一个12字节的缓冲区(就像申请一块12格的内存空间)
const buffer = new ArrayBuffer(12);
// 但ArrayBuffer本身不能直接操作,需要视图(View)来读写
ArrayBuffer就像一块空白画布,而TypedArray就是不同颜色的画笔:
const buffer = new ArrayBuffer(16); // 16字节的缓冲区
// 不同的视图类型,用不同的方式"看待"同一块内存
const uint8View = new Uint8Array(buffer); // 视为8位无符号整数(0-255)
const uint16View = new Uint16Array(buffer); // 视为16位无符号整数
const int32View = new Int32Array(buffer); // 视为32位有符号整数
// 使用Uint8Array视图操作数据
const view = new Uint8Array(buffer);
const encoder = new TextEncoder();
const data = encoder.encode('Hello');
for(let i = 0; i < data.length; i++) {
view[i] = data[i]; // 将数据复制到缓冲区
}
// 模拟AI流式输出
async function simulateAIStreaming() {
const responses = ["思考", "中", "请", "稍", "候"];
const buffer = new ArrayBuffer(100);
const view = new Uint8Array(buffer);
const decoder = new TextDecoder();
let position = 0;
for (const word of responses) {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500));
// 将每个词编码并添加到缓冲区
const encoded = new TextEncoder().encode(word);
for (let i = 0; i < encoded.length; i++) {
view[position++] = encoded[i];
}
// 实时解码已接收的部分
const receivedSoFar = decoder.decode(view.slice(0, position));
console.log(`已接收: ${receivedSoFar}`);
}
}
// 这就是streaming:true的效果——边生成边显示
// 读取图片文件并获取其二进制数据
fileInput.addEventListener('change', async (event) => {
const file = event.target.files[0];
const buffer = await file.arrayBuffer(); // 获取文件的二进制数据
// 现在可以操作这个buffer
const view = new Uint8Array(buffer);
console.log(`文件大小: ${buffer.byteLength} 字节`);
console.log(`前10个字节: ${view.slice(0, 10)}`);
});
| 概念 | 比喻 | 作用 |
|---|---|---|
| ArrayBuffer | 空白的内存空间 | 分配一块原始二进制内存 |
| TypedArray | 有刻度的量杯 | 以特定格式(如整数、浮点数)读取/写入数据 |
| DataView | 多功能测量工具 | 更灵活地读写不同格式的数据 |
| TextEncoder | 打包机 | 将文本打包成二进制 |
| TextDecoder | 拆包机 | 将二进制解包成文本 |
// 不同"眼镜"看同一数据的不同效果
const buffer = new ArrayBuffer(16);
const data = [1, 2, 3, 4];
// 使用Uint8Array:每个数字占1字节
const uint8 = new Uint8Array(buffer);
uint8.set(data);
console.log(uint8); // [1, 2, 3, 4, 0, 0, ...]
// 使用Uint16Array:每个数字占2字节
const uint16 = new Uint16Array(buffer);
console.log(uint16); // [513, 1027, 0, 0, ...]
// 为什么是513?因为1+2*256=513(小端序存储)
set()方法而不是循环赋值// 优化示例:批量操作
const source = new Uint8Array([1, 2, 3, 4, 5]);
const targetBuffer = new ArrayBuffer(10);
const targetView = new Uint8Array(targetBuffer);
// 好:批量复制
targetView.set(source);
// 不好:逐个复制
for (let i = 0; i < source.length; i++) {
targetView[i] = source[i];
}
Buffer是JavaScript处理二进制数据的核心工具,特别是在:
记住这个流程: 文本 → TextEncoder → 二进制 → ArrayBuffer → TypedArray操作 → TextDecoder → 文本
就像快递系统:商品(数据)被包装(编码)→ 运输(二进制传输)→ 拆包(解码)→ 使用。
掌握Buffer操作,你就打开了JavaScript处理二进制世界的大门!
延伸学习:
Blob对象:文件相关的二进制操作Streams API:更高级的流式数据处理WebSocket.binaryType:网络通信中的二进制传输Canvas图像数据处理:getImageData()返回的就是Uint8ClampedArray