普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月3日技术

手写 call、apply、bind 的实现

作者 1024肥宅
2025年12月3日 00:26

Call的实现

基本实现思路:
  1. 将函数设置为对象的属性
  2. 执行该函数
  3. 删除该属性
  4. 返回执行结果
Function.prototype.myCall = function (context, ...args) {
  // 如果 context 为 null 或者 undefined, 则指向全局对象 (浏览器中是window)
  context = context || globalThis;

  // 防止覆盖原有属性,使用 Symbol 作为唯一键
  const fnKey = Symbol("fn");

  // 将当前函数作为 context 的方法
  context[fnKey] = this;

  // 执行函数
  const result = context[fnKey](...args);

  // 删除临时添加的方法
  delete context[fnKey];

  return result;
};

// 更严谨的版本 (考虑更多边界情况)
Function.prototype.myCall = function (context = globalThis, ...args) {
  // 确保 context 是对象(如果不是,转换为对象包装)
  if (context === null || context === undefined) {
    context = globalThis;
  } else {
    context = Object(context);
  }

  // 使用唯一键
  const fnKey = Symbol("fn");

  // 将当前函数绑定到context
  context[fnKey] = this;

  try {
    // 执行函数
    return context[fnKey](...args);
  } finally {
    // 确保删除临时属性
    delete context[fnKey];
  }
};

apply的实现

applycall类似,只是第二个参数是数组

Function.prototype.myApply = function (context, argsArray) {
  // 处理 context
  context = context || globalThis;

  // 如果argsArray不是数组或者类数组,则当作空数组处理
  if (
    !Array.isArray(argsArray) &&
    !(argsArray && typeof argsArray === "object" && "length" in argsArray)
  ) {
    argsArray = [];
  }

  // 创建唯一键
  const fnKey = Symbol("fn");

  // 将函数绑定到 context
  context[fnKey] = this;

  try {
    // 执行函数,使用展开运算符传递参数
    const result = context[fnKey](...argsArray);
    return result;
  } finally {
    // 清理
    delete context[fnKey];
  }
};

// 更简洁的版本(基于myCall)
Function.prototype.myApply = function (context, argsArray) {
  context = context || globalThis;
  argsArray = argsArray || [];

  // 使用 myCall 的实现
  const fnKey = Symbol("fn");
  context[fnKey] = this;

  try {
    return context[fnKey](...argsArray);
  } finally {
    delete context[fnKey];
  }
};

bind的实现

bind返回一个新函数,需要处理更多边界情况

Function.prototype.myBind = function (context, ...bindArgs) {
  // 保存原函数
  const originalFunc = this;

  // 确保 context 是对象
  context = context || globalThis;

  // 返回的绑定函数
  const boundFunc = function (...callArgs) {
    // 判断是否通过 new 调用
    const isNewCall = this instanceof boundFunc;

    // 如果是 new 调用,this指向新创建的对象,而不是 context
    const thisContext = isNewCall ? this : context;

    // 合并参数
    const allArgs = bindArgs.concat(callArgs);

    // 执行原函数
    return originalFunc.apply(thisContext, allArgs);
  };

  // 维护原型关系 (为了支持 new 操作)
  // 使用一个空函数作为中介,避免直接修改boundFunc.prototype影响originalFunc.prototype
  const TempFunc = function () {};
  TempFunc.prototype = originalFunc.prototype;
  boundFunc.prototype = new TempFunc();

  return boundFunc;
};

更完整的 bind 实现(支持更多特性)

Function.prototype.myBind = function (context, ...bindArgs) {
  // 保存原函数
  const originalFunc = this;

  // 判断是否是构造函数
  if (typeof originalFunc !== "function") {
    throw new TypeError("Function.prototype.bind called on non-function");
  }

  // 返回的绑定函数
  const boundFunc = function (...callArgs) {
    // 判断是否通过 new 调用
    // 通过 new 调用时,this应该是 boundFunc 的实例
    const isNewCall = this instanceof boundFunc;

    // 确定执行上下文
    const thisContext = isNewCall ? this : Object(context || globalThis);

    // 合并参数
    const allArgs = bindArgs.concat(callArgs);

    // 调用原函数
    return originalFunc.apply(thisContext, allArgs);
  };

  // 维护原型链
  if (originalFunc.prototype) {
    // 使用 Object.create 来创建原型链, 避免直接修改
    boundFunc.prototype = Object.create(originalFunc.prototype);
    // 修正 constructor 指向
    boundFunc.prototype.constructor = boundFunc;
  }

  // 保留原函数的长度(可选,非标准)
  try {
    // 计算绑定函数的length属性(原函数参数个数 - 绑定的参数个数)
    const originalLength = originalFunc.length;
    const bindArgsLength = bindArgs.length;
    const remainingArgs = Math.max(originalLength - bindArgsLength, 0);

    // 使用 Object.defineProperty 来定义不可枚举的 length 属性
    Object.defineProperty(boundFunc, "length", {
      value: remainingArgs,
      writable: false,
      enumerable: false,
      configurable: true,
    });
  } catch (e) {
    // 忽略错误
  }

  // 保留原函数的name属性(可选)
  try {
    Object.defineProperty(boundFunc, "name", {
      value: `bound ${originalFunc.name || ""}`,
      writable: false,
      enumerable: false,
      configurable: true,
    });
  } catch (e) {
    // 忽略错误
  }

  return boundFunc;
};

ES6类实现版本

class FunctionUtils {
  static callPolyfill(fn, context, ...args) {
    const fnKey = Symbol("fn");
    const target = context || globalThis;

    // 确保 context 是对象
    const contextObj =
      target === null || target === undefined ? globalThis : Object(target);

    context[fnKey] = fn;
    try {
      return contextObj[fnKey](...args);
    } finally {
      delete contextObj[fnKey];
    }
  }

  static applyPolyfill(fn, context, argsArray) {
    return this.callPolyfill(fn, context, ...(argsArray || []));
  }

  static bindPolyfill(fn, context, ...bindArgs) {
    return function (...callArgs) {
      const isNewCall = this instanceof fn;
      const thisContext = isNewCall ? this : context || globalThis;
      const allArgs = bindArgs.concat(callArgs);

      return fn.apply(thisContext, allArgs);
    };
  }
}

// 使用示例
function test(a, b) {
  console.log(this.value + a + b);
}

const obj = { value: 10 };
FunctionUtils.callPolyfill(test, obj, 1, 2); // 13
FunctionUtils.applyPolyfill(test, obj, [1, 2]); // 13

const boundTest = FunctionUtils.bindPolyfill(test, obj, 1);
boundTest(2); // 13

总结

  1. call/apply的核心:将函数临时添加到目标对象上,然后调用它
  2. bind的核心:返回一个新函数,闭包保存原函数、上下文和预置参数
  3. new操作符的处理:bind返回的函数被new调用时,this应该指向新创建的实例
  4. 原型链维护:bind返回的函数应该能正确继承原函数的原型
  5. 边界情况:处理null/undefined上下文、参数类型等
  6. 性能考虑:使用Symbol避免属性名冲突,使用try-finally确保清理

防抖(Debounce)

作者 1024肥宅
2025年12月2日 23:19

防抖(Debounce)

防抖:在事件被触发 n 秒后再执行回调,如果在这 n 秒内又被触发,则重新计时。

应用场景
  • 搜索框输入(等待用户输入完成后再搜索)
  • 窗口大小调整(调整完成后计算布局)
  • 表单验证(输入完成后验证)
基础版本
function debounce(func, wait) {
  let timeout;
  return function () {
    const context = this;
    const args = arguments;

    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(context, args);
    }, wait);
  };
}

立即执行版本(先执行一次,再防抖)
function debounce(func, wait, imediate) {
  let timeout;
  return function () {
    const context = this;
    const args = arguments;

    if (timeout) clearTimeout(timeout);

    if (imediate) {
      // 如果已经执行过了,不再执行
      const callNow = !timeout;
      timeout = setTimeout(() => {
        timeout = null;
      }, wait);

      if (callNow) {
        func.apply(context, args);
      }
    } else {
      timeout = setTimeout(() => {
        func.apply(context, args);
      }, wait);
    }
  };
}

带返回值版本(需要Promise)
function debounce(func, wait, immediate) {
  let timeout, result;
  const debounced = function () {
    const context = this;
    const args = arguments;

    if (timeout) clearTimeout(timeout);

    if (immediate) {
      const callNow = !timeout;
      timeout = setTimeout(() => {
        timeout = null;
      }, wait);

      if (callNow) {
        result = func.apply(context, args);
      }
    } else {
      timeout = setTimeout(() => {
        func.apply(context, args);
      }, wait);
    }

    return result;
  };

  debounced.cancel = function () {
    clearTimeout(timeout);
    timeout = null;
  };

  return debounced;
}

ES6版本
class Debouncer {
  constructor(func, wait, immediate = false) {
    this.func = func;
    this.wait = wait;
    this.immediate = immediate;
    this.timeout = null;
    this.result = null;
  }

  execute(...args) {
    const context = this;

    if (this.timeout) clearTimeout(this.timeout);

    if (this.immediate) {
      const callNow = !this.timeout;
      this.timeout = setTimeout(() => {
        this.timeout = null;
      }, this.wait);

      if (callNow) {
        this.result = this.func.apply(context, args);
      }
    } else {
      this.timeout = setTimeout(() => {
        this.func.apply(context, args);
      }, this.wait);
    }

    return this.result;
  }

  cancel() {
    clearTimeout(this.timeout);
    this.timeout = null;
  }

  flush() {
    if (this.timeout) {
      clearTimeout(this.timeout);
      this.timeout = null;
      return this.result;
    }
  }
}

const debouncer = new Debouncer((msg) => {
  console.log("防抖执行:", msg, Date.now());
}, 1000);

// 快速调用
debouncer.execute("队列消息1");
debouncer.execute("队列消息2");
debouncer.execute("队列消息3"); // 只有这个会执行
更通用的类实现(同时支持防抖和节流)
class RateLimiter {
  constructor(func, wait, options = {}) {
    this.func = func;
    this.wait = wait;
    this.mode = options.mode || "throttle"; // 'throttle' 或 'debounce'

    this.options = {
      leading: true,
      trailing: true,
      maxWait: null, // 最大等待时间 (防抖专用)
    };
    Object.assign(this.options, options);

    this.timeout = null;
    this.previous = 0;
    this.args = null;
    this.context = null;
    this.lastCallTime = 0;
  }

  execute(...args) {
    const now = Date.now();
    this.context = this;
    this.args = args;
    this.lastCallTime = now;

    if (this.mode === "debounce") {
      return this._debounce(now);
    } else {
      return this._throttle(now);
    }
  }

  _debounce(now) {
    if (this.timeout) clearTimeout(this.timeout);

    // 计算延迟
    let delay = this.wait;

    // 检查是否需要立即执行
    const callNow = this.options.leading && !this.timeout;

    if (callNow) {
      this.previous = now;
      this.func.apply(this.context, this.args);
    }

    this.timeout = setTimeout(() => {
      this.timeout = null;
      if (!callNow || this.options.trailing) {
        this.func.apply(this.context, this.args);
      }
    }, delay);
  }

  _throttle(now) {
    if (!this.previous && this.options.leading === false) {
      this.previous = now;
    }

    const remaining = this.wait - (now - this.previous);

    if (remaining <= 0 || remaining > this.wait) {
      if (this.timeout) {
        clearTimeout(this.timeout);
        this.timeout = null;
      }
      this.previous = now;
      this.func.apply(this.context, this.args);
    } else if (!this.timeout && this.options.trailing !== false) {
      this.timeout = setTimeout(() => {
        this.previous = this.options.leading === false ? 0 : Date.now();
        this.timeout = null;
        this.func.apply(this.context, this.args);
      }, remaining);
    }
  }

  cancel() {
    clearTimeout(this.timeout);
    this.timeout = null;
    this.previous = 0;
  }

  flush() {
    if (this.timeout) {
      clearTimeout(this.timeout);
      this.timeout = null;
      this.func.apply(this.context, this.args);
    }
  }

  pending() {
    return !!this.timeout;
  }
}

// 使用示例
const limiter = new RateLimiter(
  (msg) => console.log(`${msg} at ${Date.now()}`),
  1000,
  { mode: "throttle" } // 或 'debounce'
);

// 切换模式
limiter.mode = "debounce";

节流(Throttle)

作者 1024肥宅
2025年12月2日 23:15

节流(Throttle)

节流:规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。

应用场景
  • 滚动加载(滚动时每间隔一段时间检查位置)
  • 按钮点击(防止重复提交)
  • 鼠标移动(mousemove事件)
  • 游戏中的按键操作
时间戳版本(立即执行)
function throttle(func, wait) {
  let previous = 0;
  return function () {
    const now = Date.now();
    const context = this;
    const args = arguments;

    if (now - previous > wait) {
      func.apply(context, args);
      previous = now;
    }
  };
}
定时器版本(延迟执行)
function throttle(func, wait) {
  let timeout;
  return function () {
    const context = this;
    const args = arguments;

    if (!timeout) {
      timeout = setTimeout(() => {
        timeout = null;
        func.apply(context, args);
      }, wait);
    }
  };
}

综合版本(先立即执行,然后节流)
function throttle(func, wait) {
  let timeout, context, args;
  let previous = 0;

  const later = function () {
    previous = Date.now();
    timeout = null;
    func.apply(context, args);
  };

  const throttled = function () {
    const now = Date.now();
    // 下次触发 func 剩余的时间
    const remaining = wait - (now - previous);
    context = this;
    args = arguments;

    // 如果没有剩余时间了或者你改了系统时间
    if (remaining <= 0 || remaining > wait) {
      if (timeout) {
        clearTimeout(timeout);
        timeout = null;
      }
      previous = now;
      func.apply(context, args);
    } else if (!timeout) {
      timeout = setTimeout(later, remaining);
    }
  };

  throttled.cancel = function () {
    clearTimeout(timeout);
    previous = 0;
    timeout = null;
  };

  return throttled;
}

ES6版本
class Throttler {
  constructor(func, wait, options = {}) {
    this.func = func;
    this.wait = wait;
    this.options = {
      leading: true, // 是否立即执行
      trailing: true, // 是否在结束后再执行一次
    };
    Object.assign(this.options, options);

    this.timeout = null;
    this.previous = 0;
    this.args = null;
    this.context = null;
  }

  execute(...args) {
    const now = Date.now();
    this.context = this;
    this.args = args;

    // 如果是第一次调用且 leading 为 false, 设置 previous 为 now
    if (!this.previous && this.options.leading === false) {
      this.previous = now;
    }

    const remaining = this.wait - (now - this.previous);

    // 时间已到货 remaining 异常(如系统时间被调整)
    if (remaining <= 0 || remaining > this.wait) {
      if (this.timeout) {
        clearTimeout(this.timeout);
        this.timeout = null;
      }
      this.previous = now;
      this.func.apply(this.context, this.args);
    }
    // 如果 trailing 为 true, 且没有定时器
    else if (!this.timeout && this.options.trailing !== false) {
      this.timeout = setTimeout(() => {
        this.previous = this.options.leading === false ? 0 : Date.now();
        this.timeout = null;
        this.func.apply(this.context, this.args);
      }, remaining);
    }
  }

  cancel() {
    clearTimeout(this.timeout);
    this.timeout = null;
    this.previous = 0;
  }

  flush() {
    if (this.timeout) {
      clearTimeout(this.timeout);
      this.timeout = null;
      this.func.apply(this.context, this.args);
    }
  }
}

// 节流使用 - 立即执行
const throttler1 = new Throttler((msg) => {
  console.log("节流执行:", msg, Date.now());
}, 1000);

// 快速调用
throttler1.execute("队列消息1"); // 立即执行
throttler1.execute("队列消息2"); // 被忽略
setTimeout(() => throttler1.execute("队列消息3"), 1000); // 再次执行

// 节流使用 - 不立即执行
const throttler2 = new Throttler(
  (msg) => console.log("节流执行:", msg, Date.now()),
  1000,
  { leading: false, trailing: true }
);

// 手动取消
const throttler3 = new Throttler(() => {
  console.log("执行中...");
}, 500);

// 开始执行
setInterval(() => throttler3.execute(), 100);

// 2秒后取消
setTimeout(() => {
  throttler3.cancel();
  console.log("已取消节流");
}, 2000);
更通用的类实现(同时支持防抖和节流)
class RateLimiter {
  constructor(func, wait, options = {}) {
    this.func = func;
    this.wait = wait;
    this.mode = options.mode || "throttle"; // 'throttle' 或 'debounce'

    this.options = {
      leading: true,
      trailing: true,
      maxWait: null, // 最大等待时间 (防抖专用)
    };
    Object.assign(this.options, options);

    this.timeout = null;
    this.previous = 0;
    this.args = null;
    this.context = null;
    this.lastCallTime = 0;
  }

  execute(...args) {
    const now = Date.now();
    this.context = this;
    this.args = args;
    this.lastCallTime = now;

    if (this.mode === "debounce") {
      return this._debounce(now);
    } else {
      return this._throttle(now);
    }
  }

  _debounce(now) {
    if (this.timeout) clearTimeout(this.timeout);

    // 计算延迟
    let delay = this.wait;

    // 检查是否需要立即执行
    const callNow = this.options.leading && !this.timeout;

    if (callNow) {
      this.previous = now;
      this.func.apply(this.context, this.args);
    }

    this.timeout = setTimeout(() => {
      this.timeout = null;
      if (!callNow || this.options.trailing) {
        this.func.apply(this.context, this.args);
      }
    }, delay);
  }

  _throttle(now) {
    if (!this.previous && this.options.leading === false) {
      this.previous = now;
    }

    const remaining = this.wait - (now - this.previous);

    if (remaining <= 0 || remaining > this.wait) {
      if (this.timeout) {
        clearTimeout(this.timeout);
        this.timeout = null;
      }
      this.previous = now;
      this.func.apply(this.context, this.args);
    } else if (!this.timeout && this.options.trailing !== false) {
      this.timeout = setTimeout(() => {
        this.previous = this.options.leading === false ? 0 : Date.now();
        this.timeout = null;
        this.func.apply(this.context, this.args);
      }, remaining);
    }
  }

  cancel() {
    clearTimeout(this.timeout);
    this.timeout = null;
    this.previous = 0;
  }

  flush() {
    if (this.timeout) {
      clearTimeout(this.timeout);
      this.timeout = null;
      this.func.apply(this.context, this.args);
    }
  }

  pending() {
    return !!this.timeout;
  }
}

// 使用示例
const limiter = new RateLimiter(
  (msg) => console.log(`${msg} at ${Date.now()}`),
  1000,
  { mode: "throttle" } // 或 'debounce'
);

// 切换模式
limiter.mode = "debounce";

【Virtual World 02】两点一线!!!

作者 大怪v
2025年12月2日 23:06

这是纯前端手搓虚拟世界第二篇。

在上一篇里,我们已经成功搭好了项目的基础骨架,并实现了最基础的单位 —— Point2D类。如果你是中途加入,点下面先补一下。

戳这里就可以【Virtual World 01】头脑热一把,带你手搓虚拟世界💪!

吐个槽

第一篇写得有点啰嗦,可能有人觉得我“明明是手搓虚拟世界,为啥从语文课开始讲起”😂。

这...总得有点仪式感嘛。

别急别急!

2fh47XSZ.gif

上代码!!!

来,直接在我们primitives文件夹中,定义一个segment类。

// ./primitives/segment.js
export default class Segment {
  // p1 p2 传入Point类 
  constructor(p1, p2) {
    this.p1 = p1;
    this.p2 = p2;
  }
}

现在看过去,这个类依旧那么简单,但如果能跟到后面,你会发现—它是构建虚拟世界里所有「形状」的基础大到建筑、道路、地形轮廓,小到网格、辅助线,全都绕不开它。

打基础的时候就是这么简单,但往往复杂的东西就是简单东西堆砌的。知道1+1然后考上了清华的朋友一定会给我点个赞的。

缺牙笑.gif

骑驴看账本--走着瞧吧

两点一线

对于创建虚拟世界,我们打小就有理论基础的。这个我坚信。

你肯定还记得小学二年级的王老师曾告诉你,两点才能成一条线!!!

嗯,好,知识充,接下来动手实操了。技术点上,主要还依赖canvasgetContext("2d")。其中有moveTolineTo

在Segment类中填上如下代码:

// ./primitives/segment.js
draw(ctx, { width = 2, color = "black" } = {}) {
    ctx.beginPath();  // 开始绘制
    ctx.lineWidth = width; // 设置线的宽度
    ctx.strokeStyle = color; // 设置线的颜色
    ctx.moveTo(this.p1.x, this.p1.y);
    ctx.lineTo(this.p2.x, this.p2.y);
    ctx.stroke();
}

代码很好理解,但我们可以靠想象力丰富下。

脑海中想一下你拿着笔,先移动笔到纸的某一点,然后下笔,手腕将笔移动到另一点,把笔拿开,线搞定!!

show一下,在/src/index.js中的display方法添加如下代码:

 // 用于绘制所有图形
  display() {
    new Segment(new Point2D(200, 200),new Point2D(400, 400)).draw(this.ctx)
  }

image.png

嗯...万里长征第二步。

粗细是个问题(但先不急)

先说明下:

属性 说明
lineWidth Canvas 只是大致控制粗细,不是像 SVG 那样精确
strokeStyle 支持颜色、渐变等
高级需求 需要结合像素密度 / DPR 做适配

目前好只是基础几何阶段先不在乎这个。后面做 坐标系 + 视口缩放 的时候,我们再一起处理 DPI 适配和缩放视觉一致性。

至于曲线,先忘掉这回事吧。

装个X

老师常常告诫我们举一反三,嗯,这就来了。

我们目前两个类:

  • Point2D
  • Segment

但理论上,可以绘制任何几何图形了,这样,简简单单搞个分形树

还在在/src/index.js中的display方法中,添加上实验代码:

 // 用于绘制所有图形
 const fractalTree = (p1, length, angle, depth, ctx) => {
      if (depth <= 0) return;

      const p2 = new Point2D(
        p1.x + Math.cos(angle) * length,
        p1.y + Math.sin(angle) * length
      );

      new Segment(p1, p2).draw(this.ctx);

      const nextLength = length * 0.7;

      fractalTree(p2, nextLength, angle - 0.5, depth - 1, ctx); // 左叉
      fractalTree(p2, nextLength, angle + 0.5, depth - 1, ctx); // 右叉
}

fractalTree(new Point2D(300, 500), 120, -Math.PI / 2, 10, this.ctx);

不出意外,艺术成分还得上几层楼。

image.png

今天就这样了!!!

源码在这里github.com/Float-none/…

昨天 — 2025年12月2日技术

Tailwind CSS详解

作者 IT橘子皮
2025年12月2日 22:17

Tailwind CSS 是一款功能强大且独特的 实用工具优先(Utility-First)的 CSS 框架。它与 Bootstrap 这类提供预置样式组件的框架不同,Tailwind 提供了大量细粒度的、单一功能的工具类(如设置颜色的 text-blue-500、控制内边距的 p-4),让你通过组合这些类来快速构建完全自定义的用户界面 。

为了让你快速抓住精髓,下面这个表格清晰地对比了 Tailwind 与传统 CSS 框架的主要区别。

特性维度 传统 CSS 框架 (如 Bootstrap) Tailwind CSS
核心理念 组件优先:提供预置样式的 UI 组件(如按钮、卡片)。 工具类优先:提供基础的样式工具类,由你自由组合。
定制灵活性 相对较低:深度定制常需覆盖框架原有样式,可能较复杂。 极高:从零开始构建,视觉外观完全由你掌控。
设计约束 遵循框架自身的设计语言,容易造成网站同质化。 基于默认设计系统,易于保持视觉一致性,但可完全自定义。
CSS 文件大小 通常较大,因为包含了所有组件的样式。 生产环境下通过 PurgeCSS 优化后通常极小(可小于10KB)。
学习曲线 学习使用现成的组件,初期上手快。 需记忆大量工具类,初期有成本,但熟练后效率极高。
典型适用场景 快速开发内部工具、对 UI 独特性要求不高的项目。 需要高度定制化设计、构建独特品牌形象的项目。

💡 核心优势与特点

除了上述对比,Tailwind CSS 还有一些非常突出的优点:

  • 高度的可定制性:你可以通过项目根目录下的 tailwind.config.js文件,轻松自定义整个设计系统的主题色彩、字体、间距、断点等,使其完美匹配你的品牌规范 。
  • 响应式设计轻而易举:Tailwind 采用移动优先的策略,内置了灵活的响应式断点系统(如 sm:, md:, lg:)。只需在类名前加上相应前缀,即可实现针对不同屏幕尺寸的样式,大大简化了响应式布局的开发 。
  • 强大的状态变体:你可以直接使用 hover:, focus:, active:等前缀来为元素的不同状态(悬停、聚焦、激活等)设置样式,甚至支持暗色模式(dark:),这让交互效果的实现非常方便 。
  • 卓越的性能表现:通过配置 PurgeCSS(现代版本已集成此功能),Tailwind 在构建生产版本时会自动移除所有你没有使用过的工具类,最终生成的 CSS 文件体积非常小,有助于提升网站加载速度 。

⚖️ 潜在的考量点

当然,没有完美的工具,Tailwind CSS 也有一些需要考虑的方面:

  • 初期的学习成本:需要记忆大量的工具类名及其对应的 CSS 属性,对于新手来说,前期可能需要频繁查阅文档 。不过,强大的编辑器插件(如 VS Code 的 Tailwind CSS IntelliSense)可以很好地缓解这个问题,提供智能提示和自动补全 。
  • HTML 结构可能显得冗杂:当组件的样式很复杂时,HTML 标签上的 class属性可能会变得很长,影响可读性 。但 Tailwind 也提供了 @apply指令,可以将重复的工具类组合提取成一个自定义的 CSS 类,在需要高度复用时使用 。

🎯 如何选择?

选择哪种框架取决于你的具体需求和场景:

  • 非常适合使用 Tailwind CSS 的情况:当你需要高度定制化的设计、追求开发效率(一旦熟悉后)、希望保持样式的一致性,并且项目性能是重要考量时 。
  • 传统框架可能更合适的情况:当需要快速原型开发、构建内部管理系统等对独特视觉设计要求不高的项目,或者团队对 Bootstrap 等框架更熟悉时 。

💎 总结

总而言之,Tailwind CSS 更像是一套用于构建设计系统的工具集,而非一个开箱即用的 UI 组件库。它赋予了开发者极大的自由度和控制力,通过组合原子化的工具类来“组装”出任何你想要的界面。虽然上手需要一些耐心,但它能带来的开发效率、设计一致性以及最终产品的性能优势,使其成为现代前端开发中一个非常受欢迎的选择 。

Headless UI详解

作者 IT橘子皮
2025年12月2日 22:14

Headless UI 是一种前端组件的设计理念,其核心在于将组件的交互逻辑(状态、行为)与视觉表现(UI样式、DOM结构)彻底分离。它为你提供完全无预设样式的“逻辑组件”,你将获得极大的控制粒度来定制其外观,使其完美契合你的设计系统。

为了让你快速把握其精髓,下面这个表格清晰地对比了它与传统UI组件库的主要区别。

特性维度 传统UI组件库 (如 Ant Design, Element-UI) Headless UI
核心设计 提供预置样式和交互的完整组件 仅提供组件的状态管理与交互逻辑,不提供样式
定制灵活性 相对较低,深度定制常需复杂覆盖或hack手段 极高,视觉层完全自主实现,无默认样式约束
与样式框架关系 通常自带设计语言,或与特定样式框架耦合 样式无关,可无缝结合任何CSS框架(如Tailwind CSS)或自定义样式
可访问性支持 因库而异,质量不一 通常内置出色的可访问性支持
适用场景 快速开发、内部工具、对UI定制要求不高的项目 品牌化要求高、需要高度定制UI、构建统一设计系统的项目
包体积 通常较大(包含样式代码) 更轻量(仅包含逻辑代码)

💡 核心概念与解决痛点

你可以将Headless UI组件理解为一个纯粹的逻辑引擎。它通过Hooks(在React中)或Composition API(在Vue中)等方式,将组件内部的状态(如开关是否开启、下拉菜单是否展开)和控制状态的函数(如切换开关、选择菜单项)提供给你。你的任务是将这些状态和函数“绑定”到自己编写的DOM元素和样式上。

它主要解决了传统组件库在高度定制化场景下的几个核心痛点:

  • 样式定制困难:当产品需要独特的品牌UI时,覆盖传统组件库的预设样式常导致CSS特异性战争(滥用 !important或深层选择器),代码难以维护。
  • 组件API膨胀:为满足各种定制需求,组件维护者需不断新增API,导致组件API日益复杂,学习成本增高。
  • 逻辑与UI耦合:传统组件库的逻辑和UI样式常紧密绑定,使得复用组件交互逻辑到不同UI设计上变得困难。

🛠️ 代表性项目

了解一些流行的Headless UI项目能帮助你更好地理解其应用:

  • Headless UI (由 Tailwind CSS 团队开发) :提供了一系列如下拉菜单、开关、模态框等基础组件的无样式版本,与Tailwind CSS集成体验极佳,并内置了完整的可访问性支持。
  • Radix UI:提供了一套高质量、可无障碍访问的原始组件,同样是Headless理念的杰出代表,著名的shadcn/ui就是基于它构建的。
  • TanStack Table:一个非常强大的表格逻辑库,它本身不渲染任何UI,你将完全掌控表格的标签和样式,从而轻松构建复杂的表格交互。

🚀 何时考虑使用

选择Headless UI通常基于以下考量:

  • 当你的项目有强烈的品牌规范,需要构建独特且一致的UI/UX时。
  • 当你计划构建一个统一的设计系统,并期望在不同产品线中复用时。
  • 当你的团队具备较强的前端能力,并且愿意在UI定制上投入更多时间。
  • 当你特别关注应用程序的无障碍访问性时

反之,如果你的目标是快速搭建原型、开发内部工具或对UI独特性要求不高,成熟的全功能UI库(如Ant Design)可能效率更高。

💎 总结

总的来说,Headless UI代表了一种关注点分离、开放且灵活的前端组件设计思想。它将最复杂的状态交互逻辑封装起来留给自己,将最大的视觉创造自由留给你。虽然它会带来一定的使用成本,但在追求高度定制化和卓越用户体验的项目中,其价值是传统组件库难以比拟的。

Vue.js 为什么要推出 Vapor Mode?

2025年12月2日 21:35

前言

Vapor ModeVue 3.6 推出的一个新的高效渲染模式,它实现了无虚拟DOM并大幅度提升性能。

先了解下 Vue 各版本的渲染机制:

  • Vue 1.0:直接操作真实 DOM。
  • Vue 3.6 之前:生成虚拟 DOM,更新时通过比对前后虚拟 DOM 的变化,来更新对应的真实 DOM。
  • Vue 3.6: Vapor Mode 模式,。

1、Vapor Mode 是什么?

Vapor Mode 是 Vue.js 3.6 版本的一个可选编译策略,它在模版编译阶段会生成高效的 JavaScript 代码,直接操作真实 DOM 节点,并通过细粒度的响应式效果(reactive effects)来更新它们。

Vapor Mode核心思想就是,在编译阶段就精确的拿到哪些节点时永不变化的静态节点,哪些是动态节点并与哪个数据源相绑定,这样我们就不需要 VNode 和 DOM Diff 的过程了。

2、Vapor Mode 相比于虚拟 DOM 的好处有哪些?

  • 节省虚拟 DOM 的内存开销:虚拟 DOM 毕竟是用 JS 对象描述 DOM 并存储在内存中,Vapor Mode 可以节省这部分内存。
  • 节省 DOM Diff 的运行开销:虚拟 DOM 在更新时需要进行 DOM Diff 比对出最小更新变化,而 Vapor Mode 也可以节省这部分比对的运行开销。
  • 更小的包大小:它去除了虚拟 DOM 的运行时代码 Virtual DOM runtime code,自然就减少了代码体积。
  • 更高的性能:通过在 effects 中直接对真实 DOM 进行细粒度的更新,性能更高。

3、如何使用 Vapor Mode?

3.1 创建一个 vue3 + ts 的项目

pnpm create vite vue3-vapor

3.2 手动修改 vue 版本

手动将 package.json 中的 vue 版本改为最新的 3.6.0-alpha.5,然后运行 pnpm i 命令安装。

{
  "dependencies": {
    "vue": "3.6.0-alpha.5"
  }
}

3.3 改造入口文件 main.ts

跟 Vue2 中组件或者指令的注册方式类似,Vapor Mode 也有两种引入方式。

方式一:通过 createVaporApp 全局引入

直接使用 createVaporApp 代替 createApp 来创建应用,这样在全局的组件都会采用 Vapor Mode 的模式,而且不会引入虚拟 DOM 的运行时代码,整体应用体积会更小。

// main.ts
import { createVaporApp } from 'vue'
import './style.css'
import App from './App.vue'

createVaporApp(App as any).use(vaporInteropPlugin).mount('#app')

方式二:通过 vaporInteropPlugin 插件注册

在入口文件 main.ts 中引入 vaporInteropPlugin 插件,然后可以在组件中按需启用 Vapor Mode

// main.ts
import { createApp, vaporInteropPlugin } from 'vue'
import './style.css'
import App from './App.vue'

createApp(App).use(vaporInteropPlugin).mount('#app')

然后我们在组件中通过在 script 标签上增加 vapor 属性,就会启用 Vapor Mode,改变这个组件的编译模式,没有加 vapor 属性的组件还是会使用虚拟 DOM。

<script setup vapor>
// ...
</script>

4、Vapor Mode 构建产物分析

导入和辅助函数的区别:

  • 虚拟 DOM 模式:会导入如 _createElementVNode_createElementBlock_toDisplayString_normalizeClass 等函数,用于创建和处理 VNode。
  • Vapor Mode 模式:会导入如 _template_renderEffect_setText_setClass_setDynamicProp 等,用于直接 DOM 创建和更新。

更新机制:

  • 虚拟 DOM 模式:在状态变化时,通过 render 函数生成新的虚拟 DOM,然后 patch 真实 DOM。
  • Vapor Mode 模式:通过 _renderEffect 包裹,每个动态部分(如类、属性、文本)独立跟踪依赖。当响应式值变化时,仅执行对应的 setter 函数(如 _setText(node, value)_setClass(node, value)),直接修改 DOM 无需 diff 过程。

下面来比较下两种模式模式编译后代码的区别。

<script setup>
import { ref } from 'vue';
const msg = ref('Hello World!');
const classes = ref('p');
const count = ref(0);
</script>

<template>
  <h1 :class="classes" @click="count++">{{ msg }}</h1>
</template>

虚拟 DOM 模式编译后的代码如下(简化版):

import { toDisplayString as _toDisplayString, normalizeClass as _normalizeClass, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue";

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("h1", {
    class: _normalizeClass(_ctx.classes),
    onClick: _cache[0] || (_cache[0] = () => _ctx.count++)
  }, _toDisplayString(_ctx.msg), 11 /* TEXT, CLASS, NEED_PATCH */));
}

会生成 VNode,使用补丁标志(如 11)标记需要更新的部分。

Vapor Mode 模式编译后的代码如下(简化版):

import { renderEffect as _renderEffect, setText as _setText, setClass as _setClass, template as _template } from "vue/vapor";

const t0 = _template("<h1></h1>");

export function render(_ctx) {
  const n0 = t0();  // 直接创建 <h1> DOM 节点
  n0.addEventListener('click', () => _ctx.count++);  // 直接事件绑定

  _renderEffect(() => _setText(n0, _ctx.msg));  // 响应式文本更新
  _renderEffect(() => _setClass(n0, _ctx.classes));  // 响应式类更新

  return n0;
}

直接使用 document.createElement 创建节点,事件通过 addEventListener 绑定,更新通过 effects 细粒度执行。

5、Vapor Mode 使用建议

  • Vapor Mode 目前还处于 alpha 版本,不建议在生产环境下使用。
  • 在对性能敏感的组件,可以选择性启动 Vapor Mode 模式提升其性能,这是一种渐进式增强的思想。

Next.js第十二章(RSC/服务端组件/客户端组件)

作者 小满zs
2025年12月2日 21:04

RSC(React Server Components)

RSC(服务器组件)是React19正式引入的一种新的组件类型,它可以在服务器端渲染,也可以在客户端渲染。

像传统的SSR他是在服务器提前把页面渲染好,然后返回给浏览器,然后进行水合,CSR则是在客户端渲染,而RSC则是吸取两方优势,分为服务器组件客户端组件

举个例子:

例如我们有一个官网的页面,上面都是静态内容,但下面留言框是需要交互的。

7.gif

此时我们就可以拆分成两个组件:

  • 服务器组件: 上面都是静态内容,例如正文,标题,图片等,这类组件之所以适合在服务端执行,核心原因在于服务端渲染HTML+CSS的速度更快,生成的内容对搜索引擎完全可见,且无需客户端额外处理交互逻辑,完美匹配静态内容的需求。
//Next.js 默认服务器组件
export default function HomePage() {
    return (
        <div>
            <h1>Home Page</h1>
        </div>
    )
}
  • 客户端组件: 下面留言框是需要交互的,例如交互功能,如点赞按钮、计数器、表单等。这类组件需要依赖浏览器DOM事件、状态管理(useState)、副作用(useEffect)等客户端能力,必须在客户端完成渲染和水合(即添加事件处理程序的过程)才能实现交互效果
'use client' //声明这是一个客户端组件
export default function HomePage() {
    return (
        <div>
            <h1>Home Page</h1>
        </div>
    )
}

渲染(RSC Payload)

SSR模式是在服务器直接渲染成HTML页面,返回给浏览器的,而RSC他是一种特殊的紧凑的格式

b2:["$","span",null,{"className":"line","children":["$","span",null,{"style":{"color":"var(--shiki-color-text)"},"children":"    })"}]}]
b3:["$","span",null,{"className":"line","children":[["$","span",null,{"style":{"color":"var(--shiki-color-text)"},"children":"  }"}],["$","span",null,{"style":{"color":"var(--shiki-token-punctuation)"},"children":","}],["$","span",null,{"style":{"color":"var(--shiki-color-text)"},"children":" [])"}]]}]
b4:["$","span",null,{"className":"line","children":" "}]
b5:["$","span",null,{"className":"line","children":[["$","span",null,{"style":{"color":"var(--shiki-color-text)"},"children":"  "}],["$","span",null,{"style":{"color":"var(--shiki-token-comment)"},"children":"// You can use `isPending` to give users feedback"}]]}]
b6:["$","span",null,{"className":"line","children":[["$","span",null,{"style":{"color":"var(--shiki-color-text)"},"children":"  "}],["$","span",null,{"style":{"color":"var(--shiki-token-keyword)"},"children":"return"}],["$","span",null,{"style":{"color":"var(--shiki-color-text)"},"children":" <"}],["$","span",null,{"style":{"color":"var(--shiki-token-string-expression)"},"children":"p"}],["$","span",null,{"style":{"color":"var(--shiki-color-text)"},"children":">Total Views: {views}</"}],["$","span",null,{"style":{"color":"var(--shiki-token-string-expression)"},"children":"p"}],["$","span",null,{"style":{"color":"var(--shiki-color-text)"},"children":">"}]]}]

那为什么这么做呢?因为我们的组件可以进行嵌套服务器组件>嵌套客户端组件>

黄色节点表示服务器组件,虚线节点表示客户端组件

2.png

在这个结构中,Next.js就会标记哪些是客户端组件并且预留好位置,但是不会进行水合。

那么Next.js发现客户端组件也会在服务器生成这个结构,那干脆直接服务器里面把客户端组件进行预渲染(不包含交互),这样我们就能快速看到数据,等他加载完成后再进行水合,所以客户端组件也会在服务器进行一次预渲染

优点

  • 将组件拆分成客户端组件和服务器组件,可以有效的减少bundle体积,因为服务器组件已经在服务器渲染好了,所以没必要打入bundle中,也就是说服务器组件所依赖的包都不会打进去,大大减少了bundle体积。

  • 局部水合,像传统的SSR同构模式, 所有的页面都要在客户端进行水合,而RSC将组件拆分出来,只会把客户端组件进行水合,避免了全量水合带来的性能损耗。

  • 流式加载,我们的HTML页面本来就支持流式加载,所以服务器组件可以边渲染边返回,提高了FCP(首次内容绘制)性能。

1.png

服务端组件(Server Components)

在默认情况下, page layout 都是服务端组件,服务端组件可以访问node.js API,包括处理数据库db。

src/app/server/page.tsx

import fs from 'node:fs' //引入fs模块
import mysql, { RowDataPacket } from 'mysql2/promise' //操作数据库 仅供演示 非最佳实践
const pool = mysql.createPool({
    host: 'localhost',
    user: 'root',
    password: '123456',
    database: 'catering',
})

export default async function ServerPage() {
    const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM goods')
    const data = fs.readFileSync('data.json', 'utf-8')
    const json = JSON.parse(data)
    return (
        <div>
            <h1>Server Page</h1>
            {json.age}///{json.name}///{json.city}
            <h3>mysql</h3>
            {rows.map((item: any) => (
                <div key={item.id}>{item.name}-{item.goodsPrice}</div>
            ))}
        </div>
    )
}

data.json

{
    "name": "John",
    "age": 30,
    "city": "New York"
}

server.png

因为是在服务端渲染的所以日志会出现在控制台,那为什么控制台也会出现,是因为Next.js在本地开发模式方便我们调试进行的输出,后续生产环境就看不到了。

log.png

服务端组件的优点

  • 安全性: 我们在服务端组件中访问一些API秘钥,令牌等其他机密,不会暴露给客户端。
  • 体积: 因为服务端组件在服务器渲染,所以不会被打包到客户端,所以体积更小。
  • 全栈:可以在服务端组件访问数据库,文件系统等其他API,实现全栈开发。
  • FCP(首次内容绘制): 因为服务端组件是流式传输,所以边渲染边返回,提高了FCP(首次内容绘制)性能。

fcp.png

服务端组件的缺点

  • 交互性: 因为服务端组件在服务器渲染,所以无法访问浏览器API,所以无法进行交互。
  • hooks: useEffect useState 等hooks在服务端组件中无法使用。

JavaScript: 是由三部分组成的(ECMAScript,DOM,BOM),在服务端组件只能使用ECMAScript部分,无法访问DOMBOM

ECMAScript: 就是我们常用的对象,数组,es6+等这些东西是通用的在客户端和服务端都能用。

如果要使用以下有交互性的功能,我们需要使用客户端组件。

import { useEffect,useState } from 'react'
export default function ServerPage() {
    const [count, setCount] = useState(0)
    useEffect(() => {
        console.log(document,window)
    }, [])
    return (
        <div>
            <h1>Server Page</h1>
            <button onClick={() => setCount(count + 1)}>点击</button>
            <p>{count}</p>
        </div>
    )
}

客户端组件(Client Components)

声明客户端组件需要在文件的顶部编写 'use client' 声明这是客户端组件,但是注意客户端组件会在服务端进行一次预渲染,所以访问document window 等API需要在useEffect中访问。

'use client'
import { useEffect,useState } from 'react'
console.log('client')
export default function ServerPage() {
    const [count, setCount] = useState(0)
    console.log('client X')
    useEffect(() => {
        console.log(document,window)
    }, [])
    return (
        <div>
            <h1>Server Page</h1>
            <button onClick={() => setCount(count + 1)}>点击</button>
            <p>{count}</p>
        </div>
    )
}

所以我们可以看到他把useState的0预渲染了出来这样可以让用户先看到页面。

pre-render.png

pre-render2.png

组件嵌套

服务端组件可以嵌套客户端组件,客户端只能嵌套不能嵌套服务端组件

error.png

why:因为客户端会把他所有的模块以及子组件认为是客户端组件,那此时如果服务端组件用了node.js的API,或者其他服务端操作,那就会报错,因为客户端组件无法访问这些API,故此客户端组件不能嵌套服务端组件。

server-only

随着Nodejs的发展,很多API已经可以跟浏览器共用了例如fetch,webSocket,未来Nodejs25支持localStorage等API,所以就会出现这种情况

下面这个函数可以在服务端组件使用,也可以在客户端组件使用,但有时候我们只想让他在服务端使用

export default function useTest(type:0 | 1) {
    if (type === 0) {
        return fetch('https://api.github.com')
    } else {
        return new WebSocket('wss://api.github.com')
    }
}
npm install server-only
or
yarn add server-only
or
pnpm add server-only

安装完成这个包之后,只需要在文件的顶部编写 import 'server-only' 声明即可,这样他就会在服务端执行,在客户端执行会报错。

import 'server-only'
export default function useTest(type:0 | 1) {
    if (type === 0) {
        return fetch('https://api.github.com')
    } else {
        return new WebSocket('wss://api.github.com')
    }
}

客户端使用报错:

error2.png

明明直接用就可以了,非要在Creator里面写???

2025年12月2日 20:51

点击上方亿元程序员+关注和★星标

图片源于网络

引言

哈喽大家好,最近笔者又又又看到有小伙伴吐槽:

最近刚入职一家公司,大佬让我负责战斗飘字相关模块,先将美术给的资源在Creator中制作成艺术字。

艺术字的制作不是有很多现成的工具吗?例如BMFont

明明直接用就可以了,非要在Creator里面写???

大佬肯定有大佬的理由,笔者就不深入探讨了,如果硬要说,可能是对小伙伴爱的考验。

言归正传,本期带大家一起来看看,如何在Cocos游戏开发中,自定义插件制作位图字体

本文源工程可在文末获取,小伙伴们自行前往。

什么是位图字体?

位图字体将整套字符预渲染为一张纹理图集;渲染时,只需根据每个字符的UV坐标从中取样并拼接即可。

位图字体有什么好处?

位图字体,其实在优化DC中也是很常见的,如果我们战斗的飘字用Label系统字体,不仅不好看,DC也会随着数量增加,应该也没人直接这么干。

位图字体如何制作?

位图字体的实现分为两步:

第一步,提前将全部字形整合到一张纹理图集中;

第二步,运行时通过查询字符的UV坐标,从该图集中提取对应图像区块并依次绘制。

自定义插件制作位图字体

接下来,我们一起来看看如何在Creator中,自定义插件实现一个位图字体制作工具。

可能有小伙伴有疑问了,那可以直接在插件中使用BMFont工具吗?可以是可以,导演不让啊!

1.资源准备

首先将美术搭子给的资源用字符命名好。

2.创建插件

想要自定义插件制作位图字体,首先要创建我们的插件,通过菜单扩展->创建扩展打开扩展创建面板,选择一个空白模板进行创建并启用插件。

3.插件整体流程

开始—>在资源管理器中选择文件夹->遍历文件夹下所有的PNG图片->获取所有散图的尺寸信息->将散图合成一张图->根据图的信息生成fnt配置文件->结束

  • 选择文件夹:首先通过let uuids = Editor.Selection.getSelected("asset")获取选中的资源管理器中的文件夹。

  • 文件夹路径:通过let asset_url = await Editor.Message.request("asset-db", "query-path", uuids[0])获取选择的文件夹路径。

  • 遍历散图:通过fs模块let files = fs.readdirSync(asset_url)获取所有散图的路径。

  • 读取散图信息:通过Jimp库(图像处理库)const image = await Jimp.read(filePath);读取散图的尺寸。

  • 合图:通过Jimp库(图像处理库)const texture = new Jimp(textureWidth, textureHeight, 0x00000000);创建画布,然后通过texture.composite(image, x, y);画图,最后通过await texture.writeAsync(texturePath);保存合图。

  • 生成fnt配置:根据获取到的散图信息,和合图信息,文件名就是对应字符,按照fnt配置的格式,填充内容,并且生成配置文件。

4.编译测试

我们直接在生成的插件模板中(偷懒,别学),执行我们的插件逻辑。

在插件目录,安装依赖npm install和构建插件npm run build

5.效果预览

将生成好的字体文件设置一下即可看到效果。

结语

往往大佬们坚持要自己开发工具,很多时候并不是因为固执,可能是对原理的执着、工具的可控或者是公司内部的约束等等。

小伙伴们的大佬们有没有什么特别的要求和规范呢?

本文源工程可通过私信发送 BMFont 获取。

我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐专栏:

知识付费专栏

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

100个Cocos实例

8年主程手把手打造Cocos独立游戏开发框架

和8年游戏主程一起学习设计模式

从零开始开发贪吃蛇小游戏到上线系列

点击下方灰色按钮+关注。

你真的理解了 javascript 中的原型及原型链?

2025年12月2日 18:04

JavaScript原型与原型链:javascript 继承的基础

引言

故事开始于

面试官:“说说你对原型以及原型链的理解”

我:原型是这样的..., 原型链是这样的..., 说的很抽象,听得也很抽象

面试官接着问:“说说javascript 怎么实现继承的

作为前端开发者,我们经常会听到「原型」和「原型链」这两个概念,但你真的理解它们吗?它们是JavaScript面向对象编程的核心机制,掌握它们对于理解JavaScript的运行原理至关重要。

本文将从基础概念出发,逐步深入解析JavaScript原型与原型链的工作原理,结合大量代码示例和可视化图解,让你轻松掌握这一核心知识点。

一、原型的基本概念

1. 什么是原型?

在JavaScript中,每个对象都有一个原型对象(__proto__),对象可以从原型中继承属性和方法。原型对象也可以有自己的原型,这样就形成了一个链式结构,称为「原型链」。

通过console控制台,可以看到foo对象_proto_对象上面有个age等于18的属性,而age又是通过构造函数Foo的prototype添加上去的。

c4c351bf-727d-4cae-b1e7-f689626c09f9.png

所以有了 foo.__proto__ === Foo.prototype

2. 原型的作用

原型主要有两个作用:

  • 属性继承:对象可以继承原型的属性
  • 方法共享:多个对象可以共享原型上的方法,节省内存空间

3. 代码示例:原型的基本使用

// 创建一个普通对象
const person = {
  name: 'John',
  age: 30
};

// 获取person的原型
const proto = Object.getPrototypeOf(person);
console.log(proto); // 输出:[Object: null prototype] {}
console.log(proto === Object.prototype); // 输出:true

二、__proto__与prototype的区别

这是初学者最容易混淆的两个概念,让我们来彻底搞清楚它们:

1. proto(隐式原型)

  • 定义:每个对象都有一个__proto__属性,指向它的原型对象
  • 作用:用于实现原型链查找
  • 注意:这是一个非标准属性,推荐使用Object.getPrototypeOf()Object.setPrototypeOf()代替

2. prototype(显式原型)

  • 定义:只有函数才有prototype属性
  • 作用:当函数作为构造函数使用时,新创建的对象会将这个prototype作为自己的__proto__
  • 组成prototype对象包含constructor属性,指向构造函数本身

3. 可视化对比

特性 proto prototype
所属对象 所有对象 只有函数
指向 对象的原型 构造函数创建的实例的原型
作用 实现原型继承 定义构造函数的实例共享属性和方法
标准性 非标准(建议使用Object.getPrototypeOf) 标准属性

4. 代码示例:__proto__与prototype

// 构造函数
function Person(name) {
  this.name = name;
}

// 构造函数的prototype属性
console.log(Person.prototype); // 输出:Person {}(包含constructor属性)

// 创建实例
const alice = new Person('Alice');

// 实例的__proto__指向构造函数的prototype
console.log(alice.__proto__ === Person.prototype); // 输出:true

// 构造函数的prototype的constructor指向构造函数
console.log(Person.prototype.constructor === Person); // 输出:true

三、构造函数与原型的关系

1. 构造函数创建实例的过程

当使用new关键字调用构造函数创建实例时,发生了以下几件事:

  1. 创建一个新的空对象
  2. 将这个新对象的__proto__指向构造函数的prototype
  3. 将构造函数的this指向这个新对象
  4. 执行构造函数体内的代码
  5. 如果构造函数没有返回对象,则返回这个新对象

2. 代码示例:构造函数创建实例

// 构造函数
function Car(brand, model) {
  this.brand = brand;
  this.model = model;
}

// 在原型上添加方法
Car.prototype.drive = function() {
  console.log(`驾驶 ${this.brand} ${this.model}`);
};

// 创建两个实例
const car1 = new Car('Toyota', 'Camry');
const car2 = new Car('Honda', 'Accord');

// 调用原型上的方法
car1.drive(); // 输出:驾驶 Toyota Camry
car2.drive(); // 输出:驾驶 Honda Accord

// 两个实例共享同一个原型方法
console.log(car1.drive === car2.drive); // 输出:true

四、原型链的形成与查找机制

1. 什么是原型链?

当访问一个对象的属性或方法时,如果该对象本身没有这个属性或方法,JavaScript会沿着__proto__属性向上查找,直到找到该属性或方法,或者到达原型链的末端(null)。这个链式查找结构就是「原型链」。

2. 原型链的末端

原型链的末端是Object.prototype,它的__proto__指向null,表示原型链的结束。

// Object.prototype是原型链的顶端之一
console.log(Object.prototype.__proto__); // 输出:null

3. 代码示例:原型链查找

// 创建对象
const obj = {};

// obj自身没有toString方法
console.log(obj.hasOwnProperty('toString')); // 输出:false

// 但可以调用toString方法,因为它继承自Object.prototype
console.log(obj.toString()); // 输出:[object Object]

// 原型链:obj -> Object.prototype -> null
console.log(obj.__proto__ === Object.prototype); // 输出:true
console.log(Object.prototype.__proto__ === null); // 输出:true

4. 完整原型链示例

// 构造函数
function Animal(type) {
  this.type = type;
}

// 原型方法
Animal.prototype.eat = function() {
  console.log('进食中...');
};

// 子类构造函数
function Dog(name, breed) {
  Animal.call(this, 'dog'); // 调用父类构造函数
  this.name = name;
  this.breed = breed;
}

// 设置Dog的原型为Animal的实例
Dog.prototype = Object.create(Animal.prototype);
// 修复constructor指向
Dog.prototype.constructor = Dog;

// Dog的原型方法
Dog.prototype.bark = function() {
  console.log('汪汪汪!');
};

// 创建实例
const myDog = new Dog('Buddy', 'Golden Retriever');

// 访问自身属性
console.log(myDog.name); // 输出:Buddy

// 访问继承自Dog.prototype的方法
myDog.bark(); // 输出:汪汪汪!

// 访问继承自Animal.prototype的方法
myDog.eat(); // 输出:进食中...

// 访问继承自Object.prototype的方法
console.log(myDog.toString()); // 输出:[object Object]

// 原型链:myDog -> Dog.prototype -> Animal.prototype -> Object.prototype -> null

五、原型链的实际应用

1. 实现继承

原型链是JavaScript实现继承的主要方式。通过将子类的原型设置为父类的实例,可以实现属性和方法的继承。

// 父类
function Parent(name) {
  this.name = name;
  this.family = 'Smith';
}

Parent.prototype.sayFamily = function() {
  console.log(`My family name is ${this.family}`);
};

// 子类
function Child(name, age) {
  Parent.call(this, name); // 继承父类属性
  this.age = age;
}

// 继承父类方法
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Child.prototype.sayAge = function() {
  console.log(`I'm ${this.age} years old`);
};

// 使用
const child = new Child('John', 10);
child.sayFamily(); // 输出:My family name is Smith
child.sayAge(); // 输出:I'm 10 years old

2. 扩展内置对象

我们可以通过修改内置对象的原型来扩展其功能:

// 扩展Array原型,添加求和方法
Array.prototype.sum = function() {
  return this.reduce((total, item) => total + item, 0);
};

// 使用扩展后的方法
const numbers = [1, 2, 3, 4, 5];
console.log(numbers.sum()); // 输出:15

// 扩展String原型,添加反转方法
String.prototype.reverse = function() {
  return this.split('').reverse().join('');
};

// 使用扩展后的方法
const str = 'hello';
console.log(str.reverse()); // 输出:olleh

注意:虽然可以扩展内置对象,但不推荐在生产环境中使用,因为可能会与其他库冲突。

3. 原型链实现对象类型检查

// 判断对象类型的函数
function getType(obj) {
  if (obj === null) return 'null';
  if (typeof obj !== 'object') return typeof obj;
  
  // 使用原型链判断具体类型
  const proto = Object.getPrototypeOf(obj);
  const constructor = proto.constructor;
  return constructor.name;
}

// 测试
console.log(getType(123)); // 输出:number
console.log(getType('hello')); // 输出:string
console.log(getType(true)); // 输出:boolean
console.log(getType(null)); // 输出:null
console.log(getType([])); // 输出:Array
console.log(getType({})); // 输出:Object

六、常见误区与注意事项

1. 误区一:所有对象都是Object的实例

正确理解:除了Object.prototype本身,所有对象都是Object的实例吗?不完全是。比如:

// 创建一个没有原型的对象
const obj = Object.create(null);
console.log(obj.__proto__); // 输出:undefined
console.log(obj instanceof Object); // 输出:false

2. 误区二:原型上的属性修改会立即反映到所有实例

正确理解:是的,但如果是直接给实例添加同名属性,会覆盖原型属性,而不是修改原型:

function Person() {}

Person.prototype.name = 'Anonymous';

const p1 = new Person();
const p2 = new Person();

console.log(p1.name); // 输出:Anonymous
console.log(p2.name); // 输出:Anonymous

// 修改原型属性
Person.prototype.name = 'Default';
console.log(p1.name); // 输出:Default
console.log(p2.name); // 输出:Default

// 给实例添加同名属性(覆盖)
p1.name = 'John';
console.log(p1.name); // 输出:John
console.log(p2.name); // 输出:Default(不受影响)

3. 注意事项:原型链查找的性能

原型链查找是有性能开销的,层级越深,查找速度越慢。因此:

  • 避免在原型链的深层定义常用属性和方法
  • 对于频繁访问的属性,可以考虑直接定义在对象本身

4. 注意事项:不要使用__proto__赋值

直接修改__proto__会影响对象的原型链,可能导致性能问题和意外行为。推荐使用:

  • Object.create()创建指定原型的对象
  • Object.setPrototypeOf()修改对象的原型

七、可视化理解原型链

为了更好地理解原型链,我们可以通过可视化的方式来呈现它的结构:

1. 简单对象的原型链

obj (实例对象)
  └── __proto__ → Object.prototype
                    └── __proto__ → null

2. 构造函数创建的对象原型链

instance (实例对象)
  └── __proto__ → Constructor.prototype
                    └── __proto__ → Object.prototype
                                      └── __proto__ → null

3. 继承关系的原型链

childInstance (子类实例)
  └── __proto__ → Child.prototype
                    └── __proto__ → Parent.prototype
                                      └── __proto__ → Object.prototype
                                                        └── __proto__ → null

4. 代码示例:可视化原型链

// 定义构造函数
function Grandparent() {
  this.grandparentProp = 'grandparent';
}

function Parent() {
  this.parentProp = 'parent';
}

function Child() {
  this.childProp = 'child';
}

// 设置继承关系
Parent.prototype = Object.create(Grandparent.prototype);
Child.prototype = Object.create(Parent.prototype);

// 创建实例
const child = new Child();

// 可视化原型链
console.log('child:', child);
console.log('child.__proto__ (Child.prototype):', child.__proto__);
console.log('child.__proto__.__proto__ (Parent.prototype):', child.__proto__.__proto__);
console.log('child.__proto__.__proto__.__proto__ (Grandparent.prototype):', child.__proto__.__proto__.__proto__);
console.log('child.__proto__.__proto__.__proto__.__proto__ (Object.prototype):', child.__proto__.__proto__.__proto__.__proto__);
console.log('child.__proto__.__proto__.__proto__.__proto__.__proto__ (null):', child.__proto__.__proto__.__proto__.__proto__.__proto__);

八、原型与现代JavaScript

1. ES6 Class与原型的关系

ES6引入了class语法,但它只是原型继承的语法糖,底层仍然是基于原型链实现的:

// ES6 Class
class Animal {
  constructor(type) {
    this.type = type;
  }
  
  eat() {
    console.log('进食中...');
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super('dog');
    this.name = name;
    this.breed = breed;
  }
  
  bark() {
    console.log('汪汪汪!');
  }
}

// 等价于原型继承
console.log(typeof Animal); // 输出:function
console.log(Dog.prototype.__proto__ === Animal.prototype); // 输出:true

2. 原型与组合继承

现代JavaScript中,我们通常使用组合继承模式,结合原型链和构造函数:

// 组合继承模式
function Parent(name) {
  this.name = name;
}

Parent.prototype.sayName = function() {
  console.log(this.name);
};

function Child(name, age) {
  // 继承属性
  Parent.call(this, name);
  this.age = age;
}

// 继承方法
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Child.prototype.sayAge = function() {
  console.log(this.age);
};

九、总结

通过本文的学习,我们已经全面了解了JavaScript原型与原型链的核心概念:

  1. 原型:每个对象都有一个原型,可以继承原型的属性和方法
  2. proto:对象的隐式原型,指向它的原型对象
  3. prototype:函数的显式原型,用于构造函数创建实例时的原型指向
  4. 原型链:对象通过__proto__形成的链式结构,用于属性和方法的查找
  5. 继承:通过原型链实现对象间的继承关系

原型与原型链是JavaScript的核心机制,掌握它们对于理解JavaScript的运行原理、实现面向对象编程至关重要。希望本文的详细解析和丰富示例能帮助你彻底理解这一知识点。

思考与练习

  1. 为什么说原型链是JavaScript实现继承的基础?
  2. 如何优化原型链查找的性能?
  3. ES6 Class和传统原型继承有什么区别?
  4. 尝试实现一个完整的原型链继承案例
  5. 解释instanceof运算符的工作原理(提示:基于原型链)

欢迎在评论区分享你的理解和思考,让我们一起进步!

参考资料


如果你觉得本文对你有帮助,欢迎点赞、收藏、分享,也欢迎关注我,获取更多前端技术干货!

Vue3 - runtime-core的渲染器初始化流程

作者 7ayl
2025年12月2日 17:42

前言

在创建一个 Vue 3 项目时,通常会看到三个核心文件: main.js:应用入口 image.png index.html:页面入口 image.png App.vue: 根组件 image.png 本文将以这三个文件为例,简述 Vue 应用的初始化流程

流程

在 main.js 中,我们导入了 createApp 函数和根组件 App.vue

一、从入口开始:createApp 与 mount

createApp(App).mount('#app')

createApp(App)调用createApp传入根组件,生成它专属的mount方法

.mount('#app')让createApp(App)这个应用实例挂载到根容器(id为app的盒子),

  • mount函数内部会基于根组件App.vue生成一个虚拟节点vnode
  • 调用render函数进行渲染,负责将虚拟DOM渲染到真实DOM image.png

二、创建虚拟节点:vnode 的结构

基于根组件来创建虚拟节点vnode

创建出来的虚拟节点vnode属性如下: image.png

三、渲染入口:render 与 patch

调用 render 函数

  1. render函数只是一个渲染器的入口,负责接收接收虚拟节点和容器,开启渲染过程

image.png

可以看见render函数内部也主要是调用patch函数,

  1. patch()主要会根据vnode.type以及shapeFlag去判断节点属于什么类型,进而调用相应类型的处理方法processxxxx()

这里App是组件类型,所以用processComponent处理

image.png

四、处理组件:processComponent 与 mountComponent

  1. 不管是什么类型的节点,都会在这个时候判断,这个节点之前是否存在,是选择初始化节点mountxxx(),还是更新节点

由于这是组件首次渲染,调用patch传下来的第一个参数应该是null,即没有n1,

所以到达processComponent之后,会先进行mountComponent

image.png

五、组件实例的创建与设置

  1. 然后进行相应的流程 mountxxx()/更新节点

mountComponent会先去创建 component instance对象,再调用setupComponent设置组件实例,最后调用setupRenderEffect设置渲染效果。 image.png

ps:也是可以粗略的看看instance对象的属性 image.png

React 的“时光胶囊”:useRef 才是那个打破“闭包陷阱”的救世主

2025年12月2日 17:08

前言:它不仅仅是 document.getElementById

如果去面试 React 开发岗位,问到 useRef 是干嘛的,90% 的候选人会说:“用来获取 DOM 元素,比如给 input 设置焦点。”

这就好比你买了一台最新的 iPhone 15 Pro Max,结果只用来打电话。

在 React 的函数式组件(Functional Component)世界里,useRef 其实是一个法外之地。 它是你在严格的“不可变数据流”和“频繁重渲染”中,唯一的逃生舱(Escape Hatch)

今天咱们不聊怎么 input.focus(),咱们来聊聊怎么用 useRef 搞定那些 useStateuseEffect 搞不定的烂摊子。


核心概念:它是一个“静音”的盒子

首先,你得把 useRef 理解成一个盒子。

  • useState:是大喇叭。你改了里面的值,React 立马大喊:“数据变了!所有组件起立,重新渲染!”
  • useRef:是静音抽屉。你偷偷把里面的值改了,React 根本不知道,组件该干嘛干嘛,不会触发重渲染。

而且,最最重要的是:组件每次重渲染,这个盒子都是同一个盒子(内存地址不变)。

这就赋予了它两个神级能力:“穿越时空”“暗度陈仓”


骚操作一:破解“闭包陷阱” (Stale Closure)

这是所有 React 新手的噩梦。

场景:你想写一个定时器,每秒打印一下当前的 count 值。

❌ 翻车现场:

const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setInterval(() => {
    // 💀 恐怖故事:这里永远打印 0
    console.log('Current Count:', count);
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依赖数组为空,effect 只跑一次

为什么? 因为 useEffect 执行的那一瞬间(Mount 时),它捕获了当时的 count(也就是 0)。就像拍了一张照片,照片里的人永远定格在那一刻。哪怕外面 count 变成了 100,定时器闭包里的 count 还是 0。

✅ useRef 救场:

我们要用 useRef 造一个“时光胶囊”,永远保存最新的值。

// 1. 创建一个胶囊
const countRef = useRef(count);

// 2. 每次渲染,都把最新的值塞进胶囊里
// 注意:修改 ref 不会触发渲染,所以这里很安全
countRef.current = count;

useEffect(() => {
  const timer = setInterval(() => {
    // 3. 定时器里读胶囊里的值,而不是读外面的快照
    console.log('Current Count:', countRef.current); 
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依然不需要依赖 count,定时器也不用重启

这就是 useRef 的“穿透”能力。它打破了闭包的限制,让你在旧的 Effect 里读到了新的 State。

骚操作二:记录“上一次”的值 (usePrevious)

在 Class 组件时代,我们有 componentDidUpdate(prevProps),可以很方便地对比新旧数据。 到了 Hooks 时代,官方竟然没给这个功能?

别急,useRef 既然能存值,那就能存“前任”。

手写一个 usePrevious Hook:

  // 创建一个 ref 来存储值
  const ref = useRef();

  // 每次渲染后,把当前值存进去
  // 注意:useEffect 是在渲染*之后*执行的
  useEffect(() => {
    ref.current = value;
  }, [value]);

  // 返回 ref 里的值
  // 注意:也就是在本次渲染时,ref.current 还是*上一次*存进去的值
  return ref.current;
}

// 使用
const Demo = () => {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>现在是: {count}</p>
      <p>刚才还是: {prevCount}</p>
    </div>
  );
};

原理分析:

  1. Render 1 (count=0) : usePrevious 返回 undefined。Render 结束,Effect 运行,ref.current 变为 0。
  2. Render 2 (count=1) : usePrevious 返回 ref.current (也就是 0)。Render 结束,Effect 运行,ref.current 变为 1。

你看,不需要任何魔法,只是利用了 React 的执行顺序,就实现了“时光倒流”。


骚操作三:防止“初次渲染”执行 Effect

有时候,我们希望 useEffect 只有在依赖变化时执行,而不要在组件刚挂载(Mount)时执行。

比如:用户修改搜索词时发请求,但刚进页面时不要发。


useEffect(() => {
  // 如果是第一次,把开关关掉,直接 return,啥也不干
  if (isFirstMount.current) {
    isFirstMount.current = false;
    return;
  }

  // 从第二次开始,这里的逻辑才会执行
  console.log('搜索词变了,发起请求...');
}, [query]);

这简直就是控制 Effect 执行时机的最强“阀门”。


总结:使用 useRef 的红线

虽然 useRef 很爽,既能穿透闭包,又能静默更新,但请记住一条铁律

永远不要在渲染期间(Rendering Logic)读取或写入 ref.current

  const count = useRef(0);
  
  // ❌ 报错警告!这是不纯的操作!
  // 在渲染过程中修改 ref,会导致行为不可预测
  count.current = count.current + 1; 

  // ❌ 也不要直接读来做渲染判断
  // 因为 ref 变了不会触发重绘,视图可能不会更新
  return <div>{count.current}</div>;
};

正确的使用姿势:

  • useEffect 里读/写。
  • Event Handler(点击事件等)里读/写。
  • 总之,别在 return JSX 之前的那个函数体里直接搞事。

useRef 是 React 留给我们的后门,当你发现 useState 让你的组件频繁渲染卡顿,或者 useEffect 的依赖数组让你头秃时,不妨想想这个静音的小盒子。

好了,收工。

下期预告:你真的以为你会写 useCallbackuseMemo 吗?我打赌你的代码里 80% 的 useMemo 都在做负优化。下一篇,我们来聊聊 React 性能优化的“安慰剂效应”。

前端跨页面通讯终极指南③:LocalStorage 用法全解析

2025年12月2日 17:06

前言

上一篇介绍了BroadcastChannel跨页面通讯的方式。今天介绍一种我们非常熟悉的方式LocalStorage 。凭浏览器原生接口就能实现数据共享,用法简洁高效。需要注意,仅支持同源页面

下面我们介绍下LocalStorage 的跨页通讯的用法。

1. LocalStorage为什么能跨页通讯?

我们都知道LocalStorage——它是浏览器提供的同源本地存储方案,数据存储在客户端,生命周期为永久(除非手动删除或清除浏览器缓存)。

它能实现跨页通讯的关键,在于两个核心机制:

1.1 同源数据共享机制

LocalStorage 的数据严格遵循“同源策略”,即同一协议(http/https)、同一域名、同一端口下的所有页面,都能读取和修改同一个 LocalStorage 实例中的数据。

1.2 storage 事件触发机制

LocalStorage 实现“通讯”而非单纯“数据存储”的核心。当一个页面修改了 LocalStorage 中的数据时,浏览器会自动向同源下的所有其他页面触发一个 storage 事件,该事件会携带修改前、修改后的数据及键名等信息。其他页面通过监听这个事件,就能实时感知数据变化,从而完成跨页通讯。

注意:当前页面修改 LocalStorage 时,自身不会触发 storage 事件,只有同源的其他页面才会收到通知!

1.3 LocalStorage通讯流程

LocalStorage 跨页通讯的核心流程是:

  1. 页面A修改 LocalStorage 数据 → 浏览器向同源其他页面发送 storage 事件
  2. 页面B/C/D 监听事件并获取数据变化。

2. 实践案例

通过上面的说明,作为数据发送方,通过修改 LocalStorage 存储数据;作为接收方,监听 storage 事件获取父页面传递的信息。我们实践一下:

2.1 步骤1:数据发送

通过 localStorage.setItem() 存储数据,触发 storage 事件。为避免数据覆盖,建议给键名添加场景标识(如 parent-to-iframe-msg)。

// 发送数据
function sendToIframe(data) {
  // 1. 存储数据到 LocalStorage,键名需唯一标识通讯场景
  localStorage.setItem('parent-to-iframe-msg', JSON.stringify({
    timestamp: Date.now(), // 防止数据缓存导致事件不触发
    content: data
  }));
  
  // 2. 可选:若需重复发送相同数据,可先删除再添加(storage 事件仅在值变化时触发)
  // localStorage.removeItem('parent-to-iframe-msg');
}

// 调用方法发送数据(示例:传递用户信息)
sendToIframe({
  username: '前端小助手',
  role: 'admin'
});

2.2 步骤2:数据接收

接收方页面通过监听 window.addEventListener('storage', callback) 捕获数据变化,解析后获取页面传递的内容。

// 监听父页面发送的数据
window.addEventListener('storage', (e) => {
  // 1. 仅处理目标键名的数据变化,避免无关事件干扰
  if (e.key !== 'parent-to-iframe-msg') return;
  
  // 2. 解析数据(注意:初始状态下 e.newValue 可能为 null)
  if (!e.newValue) return;
  
  const { timestamp, content } = JSON.parse(e.newValue);
  console.log('iframe 收到父页面数据:', content);
  
  // 3. 业务处理:如渲染用户信息
  document.getElementById('user-info').innerText = `用户名:${content.username},角色:${content.role}`;
});

// 可选:页面销毁时移除监听,避免内存泄漏
window.addEventListener('beforeunload', () => {
  window.removeEventListener('storage', handleStorage);
});

接收数据如下:

image.png

3. LocalStorage通讯注意事项

LocalStorage 用法简单,但在跨页通讯中若忽略细节,很容易出现“数据发了但收不到”的问题。以下这些坑必须提前规避:

3.1 同源策略限制:跨域页面无法通讯

LocalStorage 严格遵循同源策略,不同域名、协议或端口的页面无法共享数据,也无法触发 storage 事件。若需跨域通讯,需结合 postMessage 或服务器中转,LocalStorage 无法单独实现。

3.2 数据格式限制:仅支持字符串类型

LocalStorage 只能存储字符串,若要传递对象、数组等复杂数据,必须用 JSON.stringify() 序列化,接收时用 JSON.parse() 反序列化。注意:undefined、function 等类型无法被正常序列化,需提前处理。

3.3 storage 事件触发条件:仅值变化时触发

只有当 LocalStorage 中数据的“值”发生变化时,才会触发 storage 事件。若两次存储相同的值,事件不会触发。解决办法:在数据中添加 timestamp 时间戳或随机数,确保每次存储的值不同。

3.4 存储容量限制:避免数据过大

LocalStorage 单个域名的存储容量约为 5MB,若存储数据过大,会导致存储失败。跨页通讯应仅传递必要的核心数据(如 ID、状态),避免传递大量文本或二进制数据。

4. 总结

最后总结一下:LocalStorage只需要操作 setItemgetItem 和 监听 storage 事件就能实现同源通讯。如果是非同源,那就只能用其他方式。

正则解决Markdown流式输出不完整图片、表格、数学公式

2025年12月2日 17:04

Markdown碎片处理

在大模型SSE流式输出的时候,往往返回的是Markdown字符串。其他类型比如 # * -等,实时渲染的时候抖动是比较小的,但是像图片链接、表格、块级数学公式在渲染的时候往往会造成剧烈的页面抖动,用户体验不友好。接下来我们就一一解决这三个场景。

不完整图片链接


//不完整图片链接
![Vue Logo](https://img0.

//完整图片链接
![Vue Logo](https://img0.baidu.com/it/u=736188794,4119241415&fm=253&fmt=auto&app=120&f=JPEG?w=1140&h=760 "Vue.js Logo")


渲染效果:

image.png

处理这种不完整的链接我们可以直接正则匹配替换掉不完整的图片链接为空,等链接完整后再做渲染

/**
 * 处理图片流式碎片
 * @param {string} markdown - 原始 Markdown 字符串
 * @returns {string} 清理后的 Markdown 字符串
 */
function stripBrokenImages(md) {
  if(typeof(md) !== 'string') {
    console.log('%c v3-markdown-stream:请传正确的md字符串~ ','background:#ea2039;color:#ffffff;padding:2px 5px;')
    return '';
  }
  if(!md) {
    return '';
  }
  md = md.replace(
    /^\s*\[([^\]]+)\]:[ \t]*(\S+)(?:[ \t]+(["'])(?:(?!\3)[\s\S])*?)?$/gm,
    (s, id, src, quote) => {
      // 如果捕获到开启引号却没闭合,或者 src 后直接换行(缺引号),都认为不完整
      if (quote && !s.endsWith(quote)) return ""; // 引号没闭合
      if (!quote && /["']$/.test(src)) return ""; // src 结尾多余引号,也视为异常
      return s; // 完整定义,保留
    }
  );
  md = md.replace(
    /!\[([^\]]*)\]\(([^)]*(?:\([^)]*\)[^)]*)*)\)/g,
    (s, alt, body) => {
      const open = (body.match(/\(/g) || []).length;
      const close = (body.match(/\)/g) || []).length;
      if (open !== close) return ""; // 括号不匹配 → 不完整
      if (body.includes('"') && (body.match(/"/g) || []).length % 2) return "";
      if (body.includes("'") && (body.match(/'/g) || []).length % 2) return "";
      return s; // 完整,保留
    }
  );
  return md.replace(/!\[[^\]]*\]\([^)]*$/g, "");
}

不完整表格字符串

//不完整表格字符串
| 姓名 | 年龄 | 职业 |
|------|-----

//完整表格字符串
| 姓名 | 年龄 | 职业 |
|------|------|------|
| 张三 | 25   | 工程师 |
| 李四 | 30   | 设计师 |
| 王五 | 28   | 产品经理 |



渲染效果:

image.png

处理这种不完整的表格字符串,我们也可以使用正则替换掉不完整的表格字符串

注意:一旦分隔符和表头数量一致后就可以放行渲染,避免等待时间过长

/**
 * 过滤流式输出中结构不完整的表格字符串
 * @param {string} content - 流式输出的原始内容
 * @returns {string} 过滤后的内容(仅保留合法表格,非法表格替换为空)
 */
function filterInvalidTables(content) {
  // 表头加载完成后过滤
  // const tableRegex = /(?:^\|(?:\s*.+?\s*)?\|?$[\n\r]?)+(?:^\|(?:\s*[-:]+)+(?:\s*\|\s*[-:]+)*\s*\|?$[\n\r]?)+(?:^\|(?:\s*.+?\s*)?\|?$[\n\r]?)*(?=\n|$)/gm;
  //宽松模式过滤
  const tableRegex = /^\|(?:\s*.+?\s*)?\|?$(?:\r?\n^\|(?:\s*[-:]+)+(?:\s*\|\s*[-:]+)*\s*\|?$(?:\r?\n^\|(?:\s*.+?\s*)?\|?$)*)?/gm;
  return content.replace(tableRegex, (match) => {
    // 分割表头行和分隔符行
    const lines = match.trim().split(/[\r\n]+/).filter(line => line.trim());
    if (lines.length < 2) return ''; // 至少需要表头行 + 分隔符行
    // 最后一行表头(处理多行表头场景)
    const headerLine = lines[0].trim();
    // 分隔符行
    const separatorLine = lines[1].trim();

    // 提取表头列数:分割 | 后,过滤空字符串(处理前后 | 的情况)
    const headerColumns = headerLine.split('|').map(col => col.trim()).filter(col => col);
    const headerCount = headerColumns.length;

    // 提取分隔符列数:分割 | 后,过滤空字符串,且必须包含至少1个 -
    const separatorColumns = separatorLine.split('|')
      .map(col => col.trim())
      .filter(col => col && /-/.test(col)); // 分隔符必须包含 -
    const separatorCount = separatorColumns.length;

    // 仅当列数完全一致时保留表格,否则替换为空
    return (headerCount === separatorCount && headerCount>0 && separatorCount>0) ? match : '';
  });
}

不完整的数学公式

//完整的数学公式
$$
\\frac{n!}{k!(n-k)!} = \\binom{n}{k}
$$

//不完整的数学公式
$$
\\frac{n!}{k!(n-k)!

渲染效果:

image.png

image.png

针对这种也可以使用正则替换不完整的代码块为空

/**
 * 清除 Markdown 中未闭合的块级公式($$ 开头未闭合)
 * @param {string} markdown - 原始 Markdown 字符串
 * @returns {string} 处理后的 Markdown 字符串
 */
function clearUnclosedBlockMath(markdown) {
  // 正则说明:
  // 1. /\$\$(?!.*?\$\$).*$/s - 核心正则
  // 2. \$\$ - 匹配块级公式开始标记
  // 3. (?!.*?\$\$) - 正向否定预查:确保后面没有 $$ 闭合(非贪婪匹配任意字符)
  // 4. .*$ - 匹配从 $$ 开始到字符串结束的所有内容
  // 5. s 修饰符 - 让 . 匹配换行符(支持多行公式)
  // 6. g 修饰符 - 全局匹配(处理多个未闭合公式的极端情况)
  return markdown.replace(/\$\$(?!.*?\$\$).*$/gs, '');
}

结语

正则在处理这种问题的时候,简单粗暴但有用,有点俄式美学的味道~ 最后,如果你觉得这个文章对你有帮助,不妨点个赞并分享给更多的开发者朋友,让我们一起让 Markdown 解析变得更简单、更强大!

GitHub源码仓库地址 如果觉得好用,欢迎给个Star ⭐️ 支持一下!

我的 AI 工作流 —— project_rules.md 代码规范篇,让 AI 自省自动跑起来

作者 Legend80s
2025年12月1日 16:31

本文基于 Trae Solo IDE

https://www.trae.cn/solo

重点 TL;DR

通过 tsc-files 让 AI 在每轮代码生成后“自动”进行严格类型检测("strict": true),给 AI 添加“自省”能力,让其“闭环”自动跑出高质量代码。

Project rules

入口:Trae 右上角齿轮设置 - Rules - Create project_rules.md

image.png

或直接新建文件:.trae\rules\project_rules.md,project_rules 将分两部分

## Technical Stack
- 技术栈
- 构建工具
- 包管理器

## Code Style Guidelines

### Part 1. General Coding Guidelines
### Part 2. Frontend Coding Guidelines
### Part 3. Project Coding Guidelines

image.png

我的项目 project_rules:

## Technical Stack
- **技术栈**:React v19, Ant Design v5, TypeScript v5, tailwindcss v4,
- **构建工具**:Rsbuild
- **包管理器**:pnpm (>= 9.0.0)
- **代码格式化和 lint**:Biome
- **操作系统**:Windows,但请使用 git bash 运行命令而非 powershell

## Coding Guidelines

### Part 1. General Coding Guidelines

<!--《中文文案排版指北》可选,大家酌情增加,详见文章后半部分 -->
文案请按照中文文案排版指北,先阅读这份文档 docs/README.zh-Hans.md。

#### 1. Basic Principles
- DRY (Don't Repeat Yourself) 原则:避免重复代码。
- SRP (Single Responsibility Principle) 原则:每个模块、函数或类应该只有一个职责。
- KISS (Keep It Simple, Stupid) 原则:保持代码简单、直接,避免复杂的逻辑。

#### 2. Comments & Documentation
- Only generate necessary comments. Comments should explain why, not what.
- Provide clear documentation for public APIs.
- Update comments to reflect code changes.

### Part 2. Frontend Coding Guidelines

#### 1. Code Structure
- Use functional components with hooks.
- Organize components in a feature-based folder structure.
- Use TypeScript for type safety.

#### 2. Styling
- Use tailwindcss for utility-first styling.
- Avoid inline styles; use antd components and necessary tailwindcss classes.

### Part 3. Project Coding Guidelines
1. Use pnpm instead of npm.
1. Avoid inline styles; use antd components and necessary tailwindcss classes. If necessary, please clearly state the reason in the comment.
1. Do not use react-router-dom. This project is a react-router v7 project, so please use react-router.
1. Import types with type prefix, e.g. `IMcpServerRecord`.
1. For type checking after modifications, use: `bun scripts/tsc-files.mjs --noEmit <related_files>` (checks **only relevant files**). Avoid `npx tsc ...` as it doesn't respect project type configuration and shows errors from unrelated files, generating many false positives.
1. 新代码请使用 es-toolkit 而非 lodash。老代码 lodash 类型报错请使用 `// @ts-expect-error` 注释忽略。
1. 新代码请勿使用 `dva``connect`。比如获取 `orgId`,应该使用 `useOrgId`,它基于 `@tanstack/react-query``useQuery`。如果其他全局服务端数据没有,应该如 `useOrgId` 封装新的 `useXxx`,并且在 `src/lib/prefetchGlobalServerData.tsx` 中添加预取逻辑。

[可选]
1. 每次修改完毕执行 `npx biome check --diagnostic-level=error --write <related_files>` 检查**涉及到**的文件

project_rules github 地址

[!NOTE] 注意每次修改 project_rules.md 都需要告知 AI“重新阅读 .trae\rules\project_rules.md”。

上述中英文夹杂,建议全部中文,因为这份 rules 也需要团队成员阅读并执行。

重点解释

一、Basic Principles

- DRY (Don't Repeat Yourself) 原则:避免重复代码。
- SRP (Single Responsibility Principle) 原则:每个模块、函数或类应该只有一个职责。
- KISS (Keep It Simple, Stupid) 原则:保持代码简单、直接,避免复杂的逻辑。

我为什么只选择了这三条,甚至觉得有些都可删除,详见AI 每日心得——AI 是效率杠杆,而非培养对象 ,请大家酌情增加。

二、类型检查 tsc-files

tsc-files 类型检查工具。为什么要让 AI 检查类型?因为 AI 生成的代码如果类型有问题一般意味代码有 bug,而通常类型问题修复后代码 bug 也自行消除了,故类型检查是 AI 结束编码后保证代码质量,闭环的重要一个环节!

第二个为什么:为什么用社区的 tsc-files 而非 tscnpx tsc --noEmit file1 file2,因为 tsc 不争气呀,社区 issue#27379 一直没有处理:即当传入文件则不会使用 tsconfig.json 导致大量误报,若同时指定 -p / --project tsconfig.json 则报错 error TS5042: Option 'project' cannot be mixed with source files on a command line.,即不能既指定文件又指定配置文件。

[!TIP] tsc-files 原理:底层当然也是使用 tsc,只是复制当前项目的 tsconfig.json 配置文件清空 include 新增 files 其值为仅传入的文件,这样无需通过参数指定待检测文件却能达到检测特定文件的目的,类似运行:tsc -p tsconfig.<random-chars>.json

第三个为什么:社区有 tsc-files 为什么自定义 scripts/tsc-files.mjstsc-files 也有自己的问题。故修改如下:

  • 问题修复:tsc-files 对 tsc 二进制文件路径解析不正确导致 pnpm 项目无法工作。
  • 新增功能:仅显示指定文件的错误,若其他文件引发类型错误仍然认为类型检测成功。(tsc 会暴露其他文件问题导致 TRAE 会将 改动范围扩大化,这是不允许的! 尤其是在大型代码库中。

第四个为什么:为什么 tsgo —— Go 语言对 TypeScript 进行全面重写 而非 tsc 因为 tsgo 速度快 ⚡,详见我的另一篇文章 tsgo 相比 tsc 确实有巨大性能提升

[!NOTE]

  1. 需先下载 npm i @typescript/native-preview -D
  2. 大家可以对比下速度,将 github.com/legend80s/c… typescriptCompiler 改成 tsc 就可以体会到巨大的性能落差。

完整 scripts/tsc-files.mjs 代码见 tsc-files.mjs ~ github

使用 tsc-files 案例或效果

未告知 AI 使用 tsc-files,生成代码有问题导致 rsbuild dev 报错:

[🦀] error   Build error:
[🦀] File: D:\workspace\project\src\pages\mcpServer\details\index.tsx:1:1
[🦀]   × ESModulesLinkingError: export 'mockData' (imported as 'mockData') was not found in '../service' (possible exports: IMcpSe
rverStatus, McpServerService, McpServerStatusValueEnum)
[🦀]     ╭─[23:29]
[🦀]  21// 在真实环境中,这里会调用 API 获取数据
[🦀]  22// 现在使用 mockData 模拟
[🦀]  23const data = mockData.find((item)=>item.id === id);
[🦀]     ·                              ─────────────
[🦀]  24// 如果没有找到对应 ID 的数据,使用默认数据
[🦀]  25const defaultData = {
[🦀]     ╰────

[🦀] × ESModulesLinkingError: export 'mockData' (imported as 'mockData') was not found in '../service'

这是因为 service 并未导出 mockData,这种错误人类一眼就知有 TS error,因为我们的编辑器会标红,若安装了 Error Lens 插件则错误更加一目了然,但是 AI 不知道到,ta 还没有进化到直接看你编辑器的能力

当我们加入规则让 AI 每次生成代码后都执行 tsc-files

现在我们让 TRAE 自行进行类型检查:

image.png

截图可见 trae 执行了 bun scripts/tsc-files.mjs 并且仅对改动的代码执行,而且不止发现了一个问题。

TRAE 自动修复如下:

- import { McpServerStatusValueEnum, mockData } from '../service';
+ import { McpServerService } from '../service';

const { Title, Paragraph, Text } = Typography;

@ src/pages/mcpServer/details/index.tsx:26 @ const McpServerDetailsPage = () => {
      setLoading(true);
      try {
        // 在真实环境中,这里会调用 API 获取数据
        // 现在使用 mockData 模拟
-        const data = mockData.find(item => item.id === id);
+        // 使用service中的details方法获取数据
+        const data = await McpServerService.details({ id });

通过修复 ts 类型错误,Trae AI 甚至理解到了我们的代码意图:应该使用 service 中的 details 方法获取数据,而不是从 service 导出 mockData 然后对其操作,awsome 🤩!

三、biome check:lint & format #可选

npx biome check --diagnostic-level=error --write <related_files>

为什么 biome check 而非 eslint:biome 速度好意味着 AI 生成代码耗费时间少,其次 biome check 兼备 format 和 lint,和 tsc-files 一样消除 lint error 也能避免 bug,同时 format 能让生成的代码 style 自动符合项目要求。

为什么使用 npx:因为 pnpx、bunx 会下载远程 biome 而我们本地已安装 biome,速度反而慢。

为什么可选:这个看自己实际使用效果而定。有些问题人工修复更好否则 AI 会陷入及其复杂的代码中“不可自拔”,其次命令不是越多越好,就目前而言自执行轮数越多,Trae 可能会“罢工”让你自行停止然后开启新对话。

比如人可以通过一些指令快速消除无谓的 lint 错误:

// biome-ignore lint/security/noGlobalEval: Use 'eval' to read the JSON as regular JavaScript syntax so that comments are allowed
eval(`tsconfig = ${tsconfigContent}`);

案例说明eval 会导致 biome lint 报错,因为我们都知道 eval 是“evil 👿 的”,但这里用其实是安全的,因为项目的 tsconfig.json 不可能隐藏危险的可执行代码,而且 eval 能将 tsconfig.json 里的注释自动去掉,人类一个指令 biome-ignore lint/security/noGlobalEval 即可消除 IDE 报错,但是如果让 AI 来解决可能会生成一段极度复杂的代码 🤯。

四、中文文案排版指北 #可选

先讲下为何“可选”,因为执行统一的“文案排版规范”还是比较不现实的,虽然蚂蚁等大厂在程序员之间执行比较好(可以看看 antd 的文档),但实际业务很难。就拿“空格”而言,业务文案很难做到“中英文之间需要增加空格”,但是我们自己的文章、文档或代码注释还是可以自我约束的。所以这里还是列出来吧。

其次我对“指北”内容进行了修改,大家也可以自行根据项目调整。

1 下载

mkdir docs && cd docs
curl -O https://github.com/sparanoid/chinese-copywriting-guidelines/blob/master/README.md

2 修改:简体中文使用非直角引号

---
原文:https://github.com/sparanoid/chinese-copywriting-guidelines/blob/master/README.zh-Hans.md 
修改:简体中文使用非直角引号。根据[百度百科](https://baike.baidu.com/item/%E5%8F%8C%E5%BC%95%E5%8F%B7/10758658)将直角引号修改成大陆中文的引号 “中国大陆地区标准:先用双引号“ ”,内部如需再引用,再用单引号‘’”
删除:“工具”和“谁在这么做”
---

# 中文文案排版指北

统一中文文案、排版的相关用法,降低团队成员之间的沟通成本,增强网站气质。

Other languages:

- [英语](README.en.md)
- [繁体中文](README.md)
- [简体中文](README.zh-Hans.md)

* * *

## 空格

> “有研究显示,打字的时候不喜欢在中文和英文之间加空格的人,感情路都走得很辛苦,有七成的比例会在 34 岁的时候跟自己不爱的人结婚,而其余三成的人最后只能把遗产留给自己的猫。毕竟爱情跟书写都需要适时地留白。
>
> 与大家共勉之。”——[vinta/paranoid-auto-spacing](https://github.com/vinta/pangu.js)

### 中英文之间需要增加空格

正确:

> 在 LeanCloud 上,数据存储是围绕 `AVObject` 进行的。

错误:

> 在LeanCloud上,数据存储是围绕`AVObject`进行的。
>
> 在 LeanCloud上,数据存储是围绕`AVObject` 进行的。

完整的正确用法:

> 在 LeanCloud 上,数据存储是围绕 `AVObject` 进行的。每个 `AVObject` 都包含了与 JSON 兼容的 key-value 对应的数据。数据是 schema-free 的,你不需要在每个 `AVObject` 上提前指定存在哪些键,只要直接设定对应的 key-value 即可。

例外:“豆瓣FM”等产品名词,按照官方所定义的格式书写。

### 中文与数字之间需要增加空格

正确:

> 今天出去买菜花了 5000 元。

错误:

> 今天出去买菜花了 5000元。
>
> 今天出去买菜花了5000元。

### 数字与单位之间需要增加空格

正确:

> 我家的光纤入屋宽带有 10 Gbps,SSD 一共有 20 TB

错误:

> 我家的光纤入屋宽带有 10Gbps,SSD 一共有 20TB

例外:度数/百分比与数字之间不需要增加空格:

正确:

> 角度为 90° 的角,就是直角。
>
> 新 MacBook Pro 有 15% 的 CPU 性能提升。

错误:

> 角度为 90 ° 的角,就是直角。
>
> 新 MacBook Pro 有 15 % 的 CPU 性能提升。

### 全角标点与其他字符之间不加空格

正确:

> 刚刚买了一部 iPhone,好开心!

错误:

> 刚刚买了一部 iPhone ,好开心!
>
> 刚刚买了一部 iPhone, 好开心!

### 用 `text-spacing` 来挽救?

CSS Text Module Level 4 的 [`text-spacing`](https://www.w3.org/TR/css-text-4/#text-spacing-property) 和 Microsoft 的 [`-ms-text-autospace`](https://msdn.microsoft.com/library/ms531164(v=vs.85).aspx) 可以实现自动为中英文之间增加空白。不过目前并未普及,另外在其他应用场景,例如 macOS、iOS、Windows 等用户界面目前并不存在这个特性,所以请继续保持随手加空格的习惯。

## 标点符号

### 不重复使用标点符号

虽然中国大陆的标点符号用法允许重复使用标点符号,但是这么做会破坏句子的美观性。

正确:

> 德国队竟然战胜了巴西队!
>
> 她竟然对你说“喵”?!

错误:

> 德国队竟然战胜了巴西队!!
>
> 德国队竟然战胜了巴西队!!!!!!!!
>
> 她竟然对你说“喵”??!!
>
> 她竟然对你说“喵”?!?!??!!

## 全角和半角

不明白什么是全角(全形)与半角(半形)符号?请查看维基百科条目[全角和半角](https://zh.wikipedia.org/wiki/%E5%85%A8%E5%BD%A2%E5%92%8C%E5%8D%8A%E5%BD%A2)。

### 使用全角中文标点

正确:

> 嗨!你知道嘛?今天前台的小妹跟我说“喵”了哎!
>
> 核磁共振成像(NMRI)是什么原理都不知道?JFGI!

错误:

> 嗨! 你知道嘛? 今天前台的小妹跟我说 "喵" 了哎!
>
> 嗨!你知道嘛?今天前台的小妹跟我说"喵"了哎!
>
> 核磁共振成像 (NMRI) 是什么原理都不知道? JFGI!
>
> 核磁共振成像(NMRI)是什么原理都不知道?JFGI!

例外:中文句子内夹有英文书籍名、报刊名时,不应借用中文书名号,应以英文斜体表示。

### 数字使用半角字符

正确:

> 这个蛋糕只卖 1000 元。

错误:

> 这个蛋糕只卖 1000 元。

例外:在设计稿、宣传海报中如出现极少量数字的情形时,为方便文字对齐,是可以使用全角数字的。

### 遇到完整的英文整句、特殊名词,其内容使用半角标点

正确:

> 乔布斯那句话是怎么说的?“Stay hungry, stay foolish.”
>
> 推荐你阅读 *Hackers & Painters: Big Ideas from the Computer Age*,非常地有趣。

错误:

> 乔布斯那句话是怎么说的?“Stay hungry,stay foolish。”
>
> 推荐你阅读《Hackers&Painters:Big Ideas from the Computer Age》,非常的有趣。

## 名词

### 专有名词使用正确的大小写

大小写相关用法原属于英文书写范畴,不属于本 wiki 讨论内容,在这里只对部分易错用法进行简述。

正确:

> 使用 GitHub 登录
>
> 我们的客户有 GitHub、Foursquare、Microsoft Corporation、Google、Facebook, Inc.。

错误:

> 使用 github 登录
>
> 使用 GITHUB 登录
>
> 使用 Github 登录
>
> 使用 gitHub 登录
>
> 使用 gイんĤЦ8 登录
>
> 我们的客户有 github、foursquare、microsoft corporation、google、facebook, inc.。
>
> 我们的客户有 GITHUB、FOURSQUARE、MICROSOFT CORPORATION、GOOGLE、FACEBOOK, INC.。
>
> 我们的客户有 Github、FourSquare、MicroSoft Corporation、Google、FaceBook, Inc.。
>
> 我们的客户有 gitHub、fourSquare、microSoft Corporation、google、faceBook, Inc.。
>
> 我们的客户有 gイんĤЦ8、キouЯƧquムгє、๓เςг๏ร๏Ŧt ς๏гק๏гคtเ๏ภn、900913、ƒ4ᄃëв๏๏к, IПᄃ.。

注意:当网页中需要配合整体视觉风格而出现全部大写/小写的情形,HTML 中请使用标淮的大小写规范进行书写;并通过 `text-transform: uppercase;``text-transform: lowercase;` 对表现形式进行定义。

### 不要使用不地道的缩写

正确:

> 我们需要一位熟悉 TypeScript、HTML5,至少理解一种框架(如 React、Next.js)的前端开发者。

错误:

> 我们需要一位熟悉 Ts、h5,至少理解一种框架(如 RJS、nextjs)的 FED。

## 争议

以下用法略带有个人色彩,即:无论是否遵循下述规则,从语法的角度来讲都是**正确**的。

### 链接之间增加空格

用法:

> 请 [提交一个 issue](#) 并分配给相关同事。
>
> 访问我们网站的最新动态,请 [点击这里](#) 进行订阅!

对比用法:

> 请[提交一个 issue](#)并分配给相关同事。
>
> 访问我们网站的最新动态,请[点击这里](#)进行订阅!

### 简体中文使用非直角引号

错误:

> 「老师,『有条不紊』的『紊』是什么意思?」

正确:

> “老师,‘有条不紊’的‘紊’是什么意思?”
  1. 让 AI 记住

第一次先通过对话让 AI“短时记住”:

文案请按照中文文案排版指北,先阅读这份文档 docs/README.zh-Hans.md,给出文档所有正例和反例,等我确认你已经理解再执行代码修改。

“长期记忆”:将其纳入 project_rules.md

文案请按照中文文案排版指北,先阅读这份文档,确认你已经理解再执行代码修改。

重点“中英文之间需要增加空格”:
- 反例:“MCP服务”;正例:“MCP 服务”
- 反例:“确定删除MCP服务?”;正例:“确定删除 MCP 服务?”

Trae 的问题

不能严格遵循指令

project_rules.md

- **操作系统**:Windows,但请使用 git bash 运行命令而非 powershell

即使明确在 project_rules 或对话中告知“以后记住请使用 git bash 运行命令而非 PowerShell”。下次运行还是用了 powershell 😓 -_-||。powershell 的缺点是有些语法不支持,比如 &&,虽然 Trae 能遇到运行错误自动纠正成 ; 但是对话轮数多起来了,达到一个阈值就会让你开启新对话,当然最重要的是生成代码的时间也会无谓增加。

Model thinking limit reached, please enter 'Continue' to get more.

AI 每日心得

不要把 AI 当成需要培养的下属,指望它通过“学习”来逐步改进。对于简单的任务,比如一行调整,最好自己动手完成。AI 是工具,它的价值在于帮我们更快完成工作,而不是被反复“调教”去处理那些琐碎、非标准化的细节——你教了这次,下次它大概率还是不会。优化模型是开发者的责任,不是使用者的任务。花时间“训练” AI 适应非标准需求,往往只是浪费时间,下次遇到同样情况,你很可能还要从头再来。

记住:AI 是效率杠杆,而非培养对象。

参考

女朋友换头像比翻书快?我3天肝出一个去水印小工具

2025年12月1日 08:15

我女朋友天天泡小某书,看到好看的图就想当头像。可小某书的图都带水印,她嫌截图裁剪太麻烦。有一天直接甩给我一句:“你是程序员,给我想个办法把水印弄掉!”

得,女朋友发话,那就干呗。花三天时间,整了个去水印的小工具,挺好用。下面就是我怎么一步步搞出来的,有兴趣的可以看看。

先看效果

先给大佬们体验体验【去水印下载鸭】>>> nologo.code24.top/ ,移动端访问需要扫码跳转。

电脑端是这样的:

image.png

功能亮点

  • 小某书、某音、某手……主流平台的图片、视频都能扒
  • 完全免费,不用登录,打开就用,零广告
  • 复制分享链接→粘贴→秒出无水印素材,一步到位

后端怎么做到的

前端只是壳,真正干活的是后端:拿到分享链接后,靠爬虫把平台返回的数据里“无水印原始地址”抠出来,再回传给你。

我是前端,最顺手的组合是 Node.js + Vue3,既然后端也要有人顶,干脆一把梭:Node 写接口,语法熟、模块多,撸起来嘎嘎快。

举个例子:拿【某信公某号】来练手,它最简单了。

首先想薅无水印的资源,得先摸透平台套路。公某号最“耿直”,它直接把无水印原图塞在 HTML 里。打开文章源码,一眼就能看到 window.picture_page_info_list 这个大对象,无水印原图地址全躺在里面。

image.png

之前写过一篇文章 Node.js操作Dom ,轻松hold住简单爬虫 文章提到三方库 jsdom,它能把字符串html摸拟成Dom。

复制链接发送请求获取页面 HTML 内容,再转成模拟的 Dom,这样就能使用jquery 获取元素。

    const axios = require('axios');
    const jquery = require('jquery');
    const jsdom = require("jsdom");
    const { JSDOM } = jsdom;

    const str2Dom = (html = '') => {
        if (!html) return;
        const page = new JSDOM(html);
        const window = page.window;
        return window;
    }

    const getHtml = async (url) => {
        return new Promise((resole, reject) => {
            axios.get(url, {
                headers: {
                    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0',
                    'sec-ch-ua-platform': "macOS",
                    cookie: 'rewardsn=; wxtokenkey=777'
                }
            }).then(res => {
                resole(res.data)
            }, err => {
                reject('')
            })
        })
    }

    const getFileUrl = async (url) => {
      const window = str2Dom(await getHtml(url));
      if (!window) return;
        let $ = jquery(window);
        //省略...
    }

获取所有script 标签,挨个循环用正则捕获数据。



      const getPicturePageInfoList = ($, reversedScrips) => {
        const START_STR = 'window.picture_page_info_list = [';
        let result = null;
        $.each(reversedScrips, function (i, script) {
            let scriptContent = $(script).text() || '';
            if (scriptContent.includes(START_STR)) {
                scriptContent = scriptContent.replace('.slice(0, 20)', '')
                // 使用正则表达式捕获方括号内的内容
                const regex = /window\.picture_page_info_list\s*=\s*(\[.*?\])(?=\s*;|\s*$)/s;
                const match = scriptContent.match(regex);

                if (match && match[1]) {
                    try {
                        const fn = new Function(`return ${match[1]}`);
                        result = fn();
                    } catch (e) {
                        console.warn('JSON解析失败,返回原始内容:', e);
                        result = match[1]; // 返回原始内容
                    }
                }
                return false; // 跳出each循环
            }
        })
        return result;
    }

    const getFileUrl = async (url) => {
    //省略...
        let $ = jquery(window);
        const scrips = $('script');
        const reversedScrips = [...scrips].reverse();
        const weiXinData = getPicturePageInfoList($, reversedScrips);
     }

这个我们就能得到某信公某号无水印的图片,某信公某号是最简单,基本没做太多防爬虫机制。

其他平台较复杂点,涉及到 js 逆向,大多接口做了保密。

最后

本工具仅限于学习,请勿用于其他用途,否则后果自负。

R-HORIZON:探索长程推理边界,复旦NLP&美团LongCat联合提出LRMs能力评测新框架

复旦大学与美团LongCat联合推出 R-HORIZON——首个系统性评估与增强 LRMs 长链推理能力的评测框架与训练方法。核心创新:R-HORIZON 提出了问题组合(Query Composition)方法,通过构建问题间的依赖关系,将孤立任务转化为复杂的多步骤推理链。

美团 LongCat 发布 AMO-Bench:突破 AIME 评测饱和困境,重新定义 LLM 数学上限

美团 LongCat 团队发布数学推理评测基准—— AMO-Bench 。该评测集共包含 50 道竞赛专家原创试题,所有题目均对标甚至超越 IMO 竞赛难度。AMO-Bench 既揭示出当前大语言模型在处理复杂推理任务上的局限性,同时也为模型推理能力的进一步提升树立了新的标杆。
❌
❌