阅读视图
前端路由详解:Hash vs History
手写 call、apply、bind 的实现
防抖(Debounce)
节流(Throttle)
【Virtual World 02】两点一线!!!
Tailwind CSS详解
Headless UI详解
Vue.js 为什么要推出 Vapor Mode?
Next.js第十二章(RSC/服务端组件/客户端组件)
明明直接用就可以了,非要在Creator里面写???
你真的理解了 javascript 中的原型及原型链?
JavaScript原型与原型链:javascript 继承的基础
引言
故事开始于
面试官:“说说你对原型以及原型链的理解”
我:原型是这样的..., 原型链是这样的..., 说的很抽象,听得也很抽象
面试官接着问:“说说javascript 怎么实现继承的”
作为前端开发者,我们经常会听到「原型」和「原型链」这两个概念,但你真的理解它们吗?它们是JavaScript面向对象编程的核心机制,掌握它们对于理解JavaScript的运行原理至关重要。
本文将从基础概念出发,逐步深入解析JavaScript原型与原型链的工作原理,结合大量代码示例和可视化图解,让你轻松掌握这一核心知识点。
一、原型的基本概念
1. 什么是原型?
在JavaScript中,每个对象都有一个原型对象(__proto__),对象可以从原型中继承属性和方法。原型对象也可以有自己的原型,这样就形成了一个链式结构,称为「原型链」。
通过console控制台,可以看到foo对象_proto_对象上面有个age等于18的属性,而age又是通过构造函数Foo的prototype添加上去的。
所以有了 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关键字调用构造函数创建实例时,发生了以下几件事:
- 创建一个新的空对象
- 将这个新对象的
__proto__指向构造函数的prototype - 将构造函数的
this指向这个新对象 - 执行构造函数体内的代码
- 如果构造函数没有返回对象,则返回这个新对象
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原型与原型链的核心概念:
- 原型:每个对象都有一个原型,可以继承原型的属性和方法
- proto:对象的隐式原型,指向它的原型对象
- prototype:函数的显式原型,用于构造函数创建实例时的原型指向
- 原型链:对象通过__proto__形成的链式结构,用于属性和方法的查找
- 继承:通过原型链实现对象间的继承关系
原型与原型链是JavaScript的核心机制,掌握它们对于理解JavaScript的运行原理、实现面向对象编程至关重要。希望本文的详细解析和丰富示例能帮助你彻底理解这一知识点。
思考与练习
- 为什么说原型链是JavaScript实现继承的基础?
- 如何优化原型链查找的性能?
- ES6 Class和传统原型继承有什么区别?
- 尝试实现一个完整的原型链继承案例
- 解释
instanceof运算符的工作原理(提示:基于原型链)
欢迎在评论区分享你的理解和思考,让我们一起进步!
参考资料
如果你觉得本文对你有帮助,欢迎点赞、收藏、分享,也欢迎关注我,获取更多前端技术干货!
Vue3 - runtime-core的渲染器初始化流程
前言
在创建一个 Vue 3 项目时,通常会看到三个核心文件:
main.js:应用入口
index.html:页面入口
App.vue: 根组件
本文将以这三个文件为例,简述 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
二、创建虚拟节点:vnode 的结构
基于根组件来创建虚拟节点vnode
创建出来的虚拟节点vnode属性如下:
三、渲染入口:render 与 patch
调用 render 函数
- render函数只是一个渲染器的入口,负责接收接收虚拟节点和容器,开启渲染过程
可以看见render函数内部也主要是调用patch函数,
- patch()主要会根据vnode.type以及shapeFlag去判断节点属于什么类型,进而调用相应类型的处理方法processxxxx()
这里App是组件类型,所以用processComponent处理
四、处理组件:processComponent 与 mountComponent
- 不管是什么类型的节点,都会在这个时候判断,这个节点之前是否存在,是选择初始化节点mountxxx(),还是更新节点
由于这是组件首次渲染,调用patch传下来的第一个参数应该是null,即没有n1,
所以到达processComponent之后,会先进行mountComponent
五、组件实例的创建与设置
- 然后进行相应的流程 mountxxx()/更新节点
mountComponent会先去创建 component instance对象,再调用setupComponent设置组件实例,最后调用setupRenderEffect设置渲染效果。
ps:也是可以粗略的看看instance对象的属性
React 的“时光胶囊”:useRef 才是那个打破“闭包陷阱”的救世主
前言:它不仅仅是 document.getElementById
如果去面试 React 开发岗位,问到 useRef 是干嘛的,90% 的候选人会说:“用来获取 DOM 元素,比如给 input 设置焦点。”
这就好比你买了一台最新的 iPhone 15 Pro Max,结果只用来打电话。
在 React 的函数式组件(Functional Component)世界里,useRef 其实是一个法外之地。
它是你在严格的“不可变数据流”和“频繁重渲染”中,唯一的逃生舱(Escape Hatch)。
今天咱们不聊怎么 input.focus(),咱们来聊聊怎么用 useRef 搞定那些 useState 和 useEffect 搞不定的烂摊子。
核心概念:它是一个“静音”的盒子
首先,你得把 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>
);
};
原理分析:
-
Render 1 (count=0) :
usePrevious返回undefined。Render 结束,Effect 运行,ref.current变为 0。 -
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 的依赖数组让你头秃时,不妨想想这个静音的小盒子。
好了,收工。
下期预告:你真的以为你会写
useCallback和useMemo吗?我打赌你的代码里 80% 的useMemo都在做负优化。下一篇,我们来聊聊 React 性能优化的“安慰剂效应”。
前端跨页面通讯终极指南③:LocalStorage 用法全解析
前言
上一篇介绍了BroadcastChannel跨页面通讯的方式。今天介绍一种我们非常熟悉的方式LocalStorage 。凭浏览器原生接口就能实现数据共享,用法简洁高效。需要注意,仅支持同源页面。
下面我们介绍下LocalStorage 的跨页通讯的用法。
1. LocalStorage为什么能跨页通讯?
我们都知道LocalStorage——它是浏览器提供的同源本地存储方案,数据存储在客户端,生命周期为永久(除非手动删除或清除浏览器缓存)。
它能实现跨页通讯的关键,在于两个核心机制:
1.1 同源数据共享机制
LocalStorage 的数据严格遵循“同源策略”,即同一协议(http/https)、同一域名、同一端口下的所有页面,都能读取和修改同一个 LocalStorage 实例中的数据。
1.2 storage 事件触发机制
LocalStorage 实现“通讯”而非单纯“数据存储”的核心。当一个页面修改了 LocalStorage 中的数据时,浏览器会自动向同源下的所有其他页面触发一个 storage 事件,该事件会携带修改前、修改后的数据及键名等信息。其他页面通过监听这个事件,就能实时感知数据变化,从而完成跨页通讯。
注意:当前页面修改 LocalStorage 时,自身不会触发 storage 事件,只有同源的其他页面才会收到通知!
1.3 LocalStorage通讯流程
LocalStorage 跨页通讯的核心流程是:
- 页面A修改 LocalStorage 数据 → 浏览器向同源其他页面发送 storage 事件
- 页面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);
});
接收数据如下:
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只需要操作 setItem、getItem 和 监听 storage 事件就能实现同源通讯。如果是非同源,那就只能用其他方式。
正则解决Markdown流式输出不完整图片、表格、数学公式
Markdown碎片处理
在大模型SSE流式输出的时候,往往返回的是Markdown字符串。其他类型比如 # * -等,实时渲染的时候抖动是比较小的,但是像图片链接、表格、块级数学公式在渲染的时候往往会造成剧烈的页面抖动,用户体验不友好。接下来我们就一一解决这三个场景。
不完整图片链接
//不完整图片链接

渲染效果:
处理这种不完整的链接我们可以直接正则匹配替换掉不完整的图片链接为空,等链接完整后再做渲染
/**
* 处理图片流式碎片
* @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 | 产品经理 |
渲染效果:
处理这种不完整的表格字符串,我们也可以使用正则替换掉不完整的表格字符串
注意:一旦分隔符和表头数量一致后就可以放行渲染,避免等待时间过长
/**
* 过滤流式输出中结构不完整的表格字符串
* @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)!
渲染效果:
针对这种也可以使用正则替换不完整的代码块为空
/**
* 清除 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 自省自动跑起来
女朋友换头像比翻书快?我3天肝出一个去水印小工具
我女朋友天天泡小某书,看到好看的图就想当头像。可小某书的图都带水印,她嫌截图裁剪太麻烦。有一天直接甩给我一句:“你是程序员,给我想个办法把水印弄掉!”
得,女朋友发话,那就干呗。花三天时间,整了个去水印的小工具,挺好用。下面就是我怎么一步步搞出来的,有兴趣的可以看看。
先看效果
先给大佬们体验体验【去水印下载鸭】>>> nologo.code24.top/ ,移动端访问需要扫码跳转。
电脑端是这样的:
功能亮点
- 小某书、某音、某手……主流平台的图片、视频都能扒
- 完全免费,不用登录,打开就用,零广告
- 复制分享链接→粘贴→秒出无水印素材,一步到位
后端怎么做到的
前端只是壳,真正干活的是后端:拿到分享链接后,靠爬虫把平台返回的数据里“无水印原始地址”抠出来,再回传给你。
我是前端,最顺手的组合是 Node.js + Vue3,既然后端也要有人顶,干脆一把梭:Node 写接口,语法熟、模块多,撸起来嘎嘎快。
举个例子:拿【某信公某号】来练手,它最简单了。
首先想薅无水印的资源,得先摸透平台套路。公某号最“耿直”,它直接把无水印原图塞在 HTML 里。打开文章源码,一眼就能看到 window.picture_page_info_list 这个大对象,无水印原图地址全躺在里面。
之前写过一篇文章 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 逆向,大多接口做了保密。
最后
本工具仅限于学习,请勿用于其他用途,否则后果自负。
前端下午茶!看看炫酷的动画,轻松一下!
前言
之前说了会更新 gsap 动画教程,我们先来个开胃菜,看看最近练习的 demo 是否有同学愿意一起学习和交流。
既然你都点进来看帖子了,来都来了,留下来喝个小茶,看个小动画再走呗!
视频滚动动画
视频被切为了动画帧,随着滚动鼠标滚动或者触控板滑动而不断播放视频。
图片滚动动画
图片随着滚动鼠标滚动或者触控板滑动而不断变化,第二屏会有一定的视差滚动的效果。
主要涉及 clip-path 属性的变化。
transform 动画
图片随着滚动鼠标滚动或者触控板滑动而不断变化,主要是 transform 属性上的变化。
欢迎加入交流群
欢迎加入交流群一起进步!