普通视图
栗子前端技术周刊第 94 期 - React Native 0.81、jQuery 4.0.0 RC1、Bun v1.2.20...
🌰栗子前端技术周刊第 94 期 (2025.08.11 - 2025.08.17):浏览前端一周最新消息,学习国内外优秀文章视频,让我们保持对前端的好奇心。
📰 技术资讯
-
React Native 0.81:React Native 0.81 版本新增了对 Android 16 的支持,提升了 iOS 构建速度,并进行了一系列稳定性改进。
-
jQuery 4.0.0 RC1:jQuery 4.0.0 首个候选版本已发布,团队认为它已接近完成,希望开发者进行广泛测试,若未发现需修复的问题,将直接推出正式版本 jQuery 4.0.0。新版本主要变化与亮点包括:不再支持 IE 11 以下版本、删减遗留代码与弃用 API、引入精简版本等等。
-
Bun v1.2.20:Bun v1.2.20 修复了 141 个问题,并带来了显著的性能提升,包括降低空闲状态下的 CPU 使用率,以及将
AbortSignal.timeout
的速度提升 40 倍。
📒 技术文章
-
What we learned from creating PostCSS:那些从创建 PostCSS 中所学到的经验 - 12 年前,作者创建了 PostCSS —— 一款 CSS 自动化工具,其月下载量达 4 亿次,被谷歌、维基百科、Tailwind 以及 38% 的开发者所使用。在本文中,作者将分享在维护这个热门开源项目的漫长历程中所学到的经验。
-
How to Use innerHTML, innerText, and textContent Correctly in JavaScript:如何在 JavaScript 中正确使用
innerHTML
、innerText
和textContent
- 本文将解释 JavaScript 中三个 DOM 属性的区别:innerHTML
返回包含标签的完整 HTML 内容,innerText
仅返回受 CSS 规则影响的可见样式文本,而textContent
则返回所有文本内容,无论其在 CSS 中是否可见。这三个属性在 DOM 操作中适用于不同的使用场景。 -
前端必学-完美组件封装原则:此文总结了作者多年组件封装经验,以及拜读
antd
、element-plus
、vant
、fusion
等多个知名组件库所提炼的完美组件封装的经验;是一个开发者在封装项目组件,公共组件等场景时非常有必要遵循的一些原则。
🔧 开发工具
- ReactJS Cheatsheet:一份简洁且对开发者友好的 ReactJS 速查表,汇总了核心概念、必备 Hooks、路由、性能优化技巧以及 React 18 的新特性。

- vue-scan:让你的组件在每次更新时都闪现红色边框,帮助你排查性能问题。

- react-json-view:react-json-view(简称 rjv)是一个用于展示和编辑 JavaScript 数组及 JSON 对象的 React 组件。

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。
💖 欢迎关注微信公众号:栗子前端
React状态更新踩坑记:我是这样优雅修改参数的
大家好,我是小杨,一名有6年经验的前端开发工程师。在React开发中,状态(State)和参数(Props)的修改是最基础但也最容易踩坑的部分。今天我就来分享几种常见的React参数修改方法,以及我在项目中总结的最佳实践,避免大家走弯路。
1. 直接修改State?大忌!
新手常犯的一个错误是直接修改state
,比如:
// ❌ 错误示范:直接修改state
this.state.count = 10;
React的state
是不可变(Immutable) 的,直接修改不会触发重新渲染。正确的做法是使用setState
(类组件)或useState
的更新函数(函数组件)。
2. 类组件:setState的正确姿势
在类组件里,修改状态必须用setState
:
class Counter extends React.Component {
state = { count: 0 };
increment = () => {
// ✅ 正确方式:使用setState
this.setState({ count: this.state.count + 1 });
};
render() {
return <button onClick={this.increment}>Count: {this.state.count}</button>;
}
}
注意:setState
是异步的,如果依赖前一个状态,应该用函数式更新:
this.setState((prevState) => ({ count: prevState.count + 1 }));
3. 函数组件:useState + 不可变更新
在函数组件里,我们使用useState
,同样要遵循不可变原则:
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
// ✅ 正确方式:使用useState的更新函数
setCount(count + 1);
};
return <button onClick={increment}>Count: {count}</button>;
}
如果新状态依赖旧状态,推荐使用函数式更新:
setCount((prevCount) => prevCount + 1);
4. 修改对象或数组:避免引用突变
React要求状态更新必须是不可变的,所以直接修改对象或数组的属性是不行的:
const [user, setUser] = useState({ name: 'Alice', age: 25 });
// ❌ 错误:直接修改对象
user.age = 26;
setUser(user); // 不会触发更新!
// ✅ 正确:创建新对象
setUser({ ...user, age: 26 });
数组的更新也要遵循不可变原则:
const [todos, setTodos] = useState(['Learn React', 'Write Blog']);
// ✅ 正确:使用展开运算符或map/filter
setTodos([...todos, 'New Task']); // 添加
setTodos(todos.filter((todo) => todo !== 'Learn React')); // 删除
5. 性能优化:useState vs useReducer
如果状态逻辑较复杂,useState
可能会变得臃肿,这时可以用useReducer
:
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error('Unknown action');
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
Count: {state.count}
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</div>
);
}
useReducer
适合管理复杂状态逻辑,比如表单、全局状态等。
6. 常见坑点 & 解决方案
① 连续setState不会立即更新
// ❌ 连续调用setState,count只会+1
setCount(count + 1);
setCount(count + 1);
// ✅ 使用函数式更新
setCount(prev => prev + 1);
setCount(prev => prev + 1); // 现在会+2
② useEffect依赖问题
如果useEffect
依赖state
,但忘记加进依赖数组,可能导致闭包问题:
useEffect(() => {
console.log(count); // 可能拿到旧值
}, []); // ❌ 缺少依赖
useEffect(() => {
console.log(count); // ✅ 正确
}, [count]); // 依赖正确
总结
- 不要直接修改
state
,使用setState
或useState
的更新函数 - 对象/数组更新时,创建新引用
- 复杂状态逻辑用
useReducer
- 连续更新用函数式
setState
useEffect
依赖要写全
⭐ 写在最后
请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.
✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式
✅ 认为我部分代码过于老旧,可以提供新的API或最新语法
✅ 对于文章中部分内容不理解
✅ 解答我文章中一些疑问
✅ 认为某些交互,功能需要优化,发现BUG
✅ 想要添加新功能,对于整体的设计,外观有更好的建议
✅ 一起探讨技术加qq交流群:906392632
最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!
vite和webpack打包结构控制
概述
在工程化项目中,Vite 和 Webpack 作为当前最流行的两大构建工具,它们在打包输出目录结构的配置上各有特点,webpack和vite默认打包构建的输出目录结构可能不满足我们的需求,因此需要根据实际情况进行控制。
默认输出结构对比
webpack
默认情况下,基本上都不会根据情况进行分块,所有资源都是默认被打包到了一个文件中。
dist/
├── main.js
├── index.html
|—— .....
vite
dist/
├── assets/
│ ├── index.[hash].js
│ ├── vendor.[hash].js
│ └── style.[hash].css
└── index.html
Vite目录结构精细控制
文件指纹策略
Webpack 提供了多种 hash 类型:
-
[hash]
: 项目级hash -
[chunkhash]
: chunk级hash -
[contenthash]
: 内容级hash
基础配置方案
// vite.config.js
export default {
build: {
outDir: 'dist',
assetsDir: 'static',
emptyOutDir: true
}
}
Rollup 输出配置
由于vite内部打包使用rollup,因此打包输出相关配置需参考rollup的配置
export default {
build: {
rollupOptions: {
output: {
//资源块输出目录配置
chunkFileNames: 'static/js/[name]-[hash].js',
//入口文件输出目录配置
entryFileNames: 'static/js/[name]-[hash].js',
//静态输出目录配置(图片、音频、字体)
assetFileNames: ({ name }) => {
const ext = name.split('.').pop()
//函数形式动态返回文件输出名及其位置
return `static/${ext}/[name]-[hash].[ext]`
}
}
}
}
}
webpack 目录结构精细控制
基础输出配置
// webpack.config.js
module.exports = {
output: {
path: path.resolve(__dirname, 'build'), // 修改输出目录
filename: 'js/[name].[contenthash:8].js', // JS文件输出路径
chunkFilename: 'js/[name].[contenthash:8].chunk.js', // 异步chunk
assetModuleFilename: 'media/[name].[hash:8][ext]', // 静态资源
clean: true // 构建前清空目录
}
}
高级资源管理
使用 mini-css-extract-plugin
控制 CSS 输出:
module: {
rules: [
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] },
],
},
Webpack 5 引入了资源模块类型,取代了传统的 file-loader/url-loader,用来处理之前繁琐的配置
module: {
rules: [
{
test: /.(png|jpe?g|gif|svg)$/i,
type: 'asset/resource' // 替换 file-loader
},
{
test: /.(mp4|webm|ogg)$/i,
type: 'asset/resource'
}
],
},
四种资源模块类型
类型 | 作用 | 等价 loader |
---|---|---|
asset/resource |
导出单独文件并生成 URL | file-loader |
asset/inline |
导出资源的 Data URI | url-loader |
asset/source |
导出资源的源代码 | raw-loader |
asset |
自动选择 resource 或 inline | url-loader + 限制 |
总结
上面列举的部分配置,更多的详细配置,可以查阅官网解析。
前端必会:如何创建一个可随时取消的定时器
一、原生的取消方式
JavaScript 原生就提供了取消定时器的方法。setTimeout
和 setInterval
在调用时都会返回一个数字类型的 ID,我们可以将这个 ID 传递给 clearTimeout
或 clearInterval
来取消它。
// 1. 设置一个定时器
const timerId: number = setTimeout(() => {
console.log("这个消息可能永远不会被打印");
}, 2000);
// 2. 在它触发前取消它
clearTimeout(timerId);
常见痛点:
-
timerId
变量需要被保留在组件或模块的作用域中,状态分散。 - 启动、暂停、取消的逻辑是割裂的,代码可读性和可维护性差。
二、封装一个可取消的定时器类
我们可以简单的封装一个 CancellableTimer
类,将定时器的状态和行为内聚在一起。后续可以扩展,把项目中的所有定时器进行统一管理。
// 定义定时器ID类型
type TimeoutId = ReturnType<typeof setTimeout>;
class CancellableTimer {
private timerId: TimeoutId | null = null;
constructor(private callback: () => void, private delay: number) {}
public start(): void {
// 防止重复启动
if (this.timerId !== null) {
this.cancel();
}
this.timerId = setTimeout(() => {
this.callback();
// 执行完毕后重置 timerId
this.timerId = null;
}, this.delay);
}
public cancel(): void {
if (this.timerId !== null) {
clearTimeout(this.timerId);
this.timerId = null;
}
}
}
// 使用示例
console.log('定时器将在3秒后触发...');
const myTimer = new CancellableTimer(() => {
console.log('定时器任务执行!');
}, 3000);
myTimer.start();
// 模拟在1秒后取消
setTimeout(() => {
console.log('用户取消了定时器。');
myTimer.cancel();
}, 1000);
三、实现可暂停和恢复的定时器
在很多场景下,我们需要的不仅仅是取消,还有暂停和恢复。
要实现这个功能,我们需要在暂停时记录剩余时间。
type TimeoutId = ReturnType<typeof setTimeout>;
class AdvancedTimer {
private timerId: TimeoutId | null = null;
private startTime: number = 0;
private remainingTime: number;
private callback: () => void;
private delay: number;
constructor(callback: () => void, delay: number) {
this.remainingTime = delay;
this.callback = callback;
this.delay = delay;
}
public resume(): void {
if (this.timerId) {
return; // 已经在运行
}
this.startTime = Date.now();
this.timerId = setTimeout(() => {
this.callback();
// 任务完成,重置
this.remainingTime = this.delay;
this.timerId = null;
}, this.remainingTime);
}
public pause(): void {
if (!this.timerId) {
return;
}
clearTimeout(this.timerId);
this.timerId = null;
// 计算并更新剩余时间
const timePassed = Date.now() - this.startTime;
this.remainingTime -= timePassed;
}
public cancel(): void {
if (this.timerId) {
clearTimeout(this.timerId);
}
this.timerId = null;
this.remainingTime = this.delay; // 重置
}
}
// 使用示例
console.log('定时器启动,5秒后执行...');
const advancedTimer = new AdvancedTimer(() => console.log('Done!'), 5000);
advancedTimer.resume();
setTimeout(() => {
console.log('2秒后暂停定时器');
advancedTimer.pause();
}, 2000);
setTimeout(() => {
console.log('4秒后恢复定时器 , 应该还剩3秒');
advancedTimer.resume();
}, 4000);
总结
如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货
Oxc 最新 Transformer Alpha 功能速览! 🚀🚀🚀
拯救你的app/小程序审核!一套完美避开审核封禁的URL黑名单机制
app/微信小程序审核又双叒叕被拒了?因为一个历史遗留页面导致整个小程序被封禁搜索?别让精心开发的app/小程序毁在几个不起眼的URL上!本文将揭秘我们在多次惨痛教训后总结出的终极解决方案。
前言:每个小程序开发者都经历过的噩梦
凌晨两点,微信审核通知:"您的小程序因存在违规页面,搜索功能已被限制"。看着辛苦运营的用户量断崖式下跌,排查三天才发现是因为一个早已下架但还能访问的历史页面。这不是假设,而是真实发生的灾难场景。
在经历多次微信审核失败后,我们意识到:必须有一套灵活、实时的URL黑名单机制,能够在app/微信审核发现问题前,快速屏蔽任何违规页面。这套系统需要:
-
分钟级响应:新发现的违规URL,1分钟内全局生效
-
精准打击:既能拦截整个页面,也能封禁特定参数组合
-
零误杀:确保正常页面不受影响
-
优雅降级:被拦截用户跳转到友好提示页,且可一对一设置兜底页。
下面是我们用血泪教训换来的完整解决方案,已成功帮助我们通过n多次app审核。
app/微信小程序审核的致命陷阱:你未必意识到的风险点
真实审核失败案例
-
案例1:三年前的活动页仍可通过直接URL访问(违反现行规则)
-
案例2:用户生成内容包含敏感关键词(UGC页面)
-
案例3:第三方合作伙伴的H5页面突然变更内容
-
最致命案例:历史页面被微信爬虫索引,导致整个小程序搜索功能被封禁
核心需求清单(微信审核视角)
-
实时封堵能力:无需发版即可封禁任意URL
-
精准匹配:支持完整URL和带参数的URL匹配
-
全类型覆盖:原生页面 + H5页面统一处理
-
优雅降级:被封禁用户看到友好提示而非404
-
安全兜底:系统异常时自动放行,不影响正常业务
系统架构设计:三重防护盾
核心流程:
-
所有跳转请求经过黑名单检查
-
命中规则则跳转到兜底页
-
系统异常时降级放行
-
后台配置秒级生效
核心技术实现
参数级精准打击 - 只封禁违规内容
// 黑名单配置
["pages/user/content?type=sensitive"]
// 结果:
"pages/user/content?type=normal" => 放行 ✅
"pages/user/content?type=sensitive" => 拦截 ⛔
微信审核场景:当只有特定参数组合违规时,最小化业务影响
匹配规则详解:如何应对app审核
场景1:紧急封禁整个页面(后台配置示例)
{
"YourBlackList": [
{
"nowUrl": "https://baidu.com",
"ToUrl": "www.juejin.cn"
}
]
}
只要命中 baidu.com 无论实际跳转页面后面参数是什么,都命中了黑名单,直接跳转到自己的兜底页](url)
场景2:精准封禁违规内容
// 配置黑名单
{
"YourBlackList": [
{
"nowUrl": "pages/news/detail?id=12345",
"ToUrl": "www.baidu.com"
}
]
}
// 效果:
仅拦截id=12345的新闻,如果命中,则跳转到百度(你设置的兜底页)。其他正常展示
场景3:批量处理历史内容
// 配置黑名单
{
"YourBlackList": [
{
"nowUrl": "pages/history/?year=2020",
"ToUrl": "www.baidu.com"
}
]
}
// 效果:
拦截2020年的所有历史页面,其他年份正常
实际应用:拯救审核的最后一公里
在路由跳转处拦截
async function myNavigateTo(url) {
const { isBlocked, ToUrl } = checkUrlBlacklist(url);
if (isBlocked) {
console.warn('审核风险页面被拦截:', url);
// 跳转到安全页
return wx.navigateTo({ url: ToUrl });
}
// 正常跳转逻辑...
}
性能与安全:双保险设计
二重保障机制:
- 性能优化:黑名单为空时短路返回
if (!blackUrlList.length) return { isBlocked: false };
- 频率控制:避免相同URL重复解析
更新时机
app/小程序初始化时,如果想更精细一些,可以监听app/小程序后台切到前台onShow时
// 获取阿波罗接口配置
const resp = await request({
url: 'https://你的后台配置接口',
});
// 这里blackUrlInfoList需要保存在全局,可以放在本地存储下
blackUrlInfoList = res.blackUrlInfoList || []
校验时机:每次跳转时。
具体判断逻辑在此不做阐述,
总结:从此告别审核噩梦
通过实施这套URL黑名单系统,我们实现了:
-
审核通过率从63% → 98%
-
问题响应时间从2天 → 5分钟
-
搜索封禁事故0发生
关键收获:
-
提前拦截比事后补救更重要
-
参数级控制最大化保留正常功能
-
实时配置能力是应对审核的关键
现在点击右上角收藏本文,当app审核再次亮红灯时,你会感谢今天的自己!
分享你的审核故事:你在微信/app审核中踩过哪些坑?欢迎在评论区分享你的经历和解决方案!
nextjs项目build导出静态文件
只需要在next.config.ts中配置output为export即可,可以配置三种模式。
undefined:默认的构建输出,.next目录,适用于生产模式下的next start或Vercel等托管提供商
“standalone”:一个独立的构建输出,.next/stalone目录,仅包含必要的文件/依赖项。适用于Docker容器中的自托管。
'export':导出的构建输出,输出目录,仅包含静态HTML/CSS/JS。适用于没有Node.js服务器的自托管
配置完成后,再build:
JavaScript 日期的奇妙冒险:当 UTC 遇上 el-date-picker
前情提要:程序员小明在使用 Element Plus 的日期选择器时,发现同一个时间用不同方式设置默认值,居然显示出了三个不同的日期。这到底是闹哪样?
🌍 UTC:时间界的"标准答案"
首先,我们来认识一下 UTC(协调世界时),它就像是时间界的"标准答案"。
想象一下,如果全世界的手表都不统一,那该多乱:
- 北京的程序员说:"我们下午 3 点开会"
- 纽约的程序员回复:"哪个下午 3 点?"
- 伦敦的程序员插话:"我这里已经晚上了..."
为了避免这种尴尬,聪明的人类发明了 UTC:
- UTC = 全世界公认的"标准时间"
- 可以理解为"0 时区"的时间
- 所有其他时区都是 UTC ± 几小时
// UTC 就像是时间界的"普通话"
const utcTime = new Date().toISOString(); // "2023-12-25T08:30:45.123Z"
// 末尾的 Z 就是 UTC 的标志,意思是"Zulu Time"(军事术语)
🎭 三种日期格式的"变脸"表演
第一幕:toISOString().split('T')[0]
- "我是个好孩子"
const format1 = new Date().toISOString().split('T')[0];
console.log(format1); // "2023-12-25"
这家伙的内心独白:
"我看起来很无辜,就是个普通的日期字符串。但是!我其实是基于 UTC 时间的。如果你在东八区,而现在是北京时间凌晨 2 点,我可能会给你显示昨天的日期。惊不惊喜?意不意外?"
问题所在:看起来人畜无害的 "2023-12-25"
,但 el-date-picker 会按本地时区解释,可能出现日期偏移。
第二幕:toUTCString()
- "我很正式但很难懂"
const format2 = new Date().toUTCString();
console.log(format2); // "Mon, 25 Dec 2023 08:30:45 GMT"
这家伙的内心独白:
"我是正宗的 UTC 格式,很正式很标准。但是 el-date-picker 看到我就懵了:'这是什么鬼格式?Mon?Dec?GMT?我该怎么解析你?'"
问题所在:格式太"学术",el-date-picker 可能无法正确解析或解析结果不符合预期。
第三幕:dayjs().format("DD MMM YYYY HH:mm:ss")
- "我很个性"
const format3 = dayjs(new Date()).format("DD MMM YYYY HH:mm:ss");
console.log(format3); // "25 Dec 2023 16:30:45"
这家伙的内心独白:
"我很有个性,用的是自定义格式。我基于本地时间,没有时区信息。el-date-picker 看到我:'你到底是哪个时区的?算了,我随便猜一个吧。'"
问题所在:自定义格式 + 没有时区信息 = 解析结果不可预测。
🎪 时区的"魔术表演"
让我们看看时区是如何"变魔术"的:
// 假设现在是北京时间 2023-12-25 16:30:45 (UTC+8)
const now = new Date();
console.log('北京时间:', now.toString());
// "Mon Dec 25 2023 16:30:45 GMT+0800 (中国标准时间)"
console.log('UTC时间:', now.toUTCString());
// "Mon, 25 Dec 2023 08:30:45 GMT" <- 注意:比北京时间早8小时!
console.log('ISO格式:', now.toISOString());
// "2023-12-25T08:30:45.123Z" <- Z表示UTC时间
时区换算公式:
- 北京时间 = UTC + 8 小时
- 纽约时间 = UTC - 5 小时(冬令时)
- 伦敦时间 = UTC + 0 小时
🚑 救命稻草:el-date-picker 的正确喂食方法
经过一番折腾,我们总结出了 el-date-picker 的正确"喂食"方法:
🥇 金牌方案:直接喂 Date 对象
// 最简单粗暴,不会出错
const dateValue = ref(new Date());
为什么好用:
- Date 对象自带时区信息
- el-date-picker 天生就认识它
- 不需要任何格式转换
🥈 银牌方案:完整的 ISO 字符串
// 保留完整的时区信息
const dateValue = ref(new Date().toISOString());
为什么靠谱:
- ISO 格式是国际标准
- 包含完整的时区信息(那个 Z)
- 大部分组件都认识
🥉 铜牌方案:dayjs 标准格式
// 使用标准的日期格式
const dateValue = ref(dayjs().format('YYYY-MM-DD'));
适用场景:
- 只需要日期,不需要时间
- 想要更多控制权
🏆 特别奖:精确控制方案
// 我要的就是今天的开始时间!
const dateValue = ref(dayjs().startOf('day').toDate());
// 注意:dayjs().toDate() 本质上也是 Date 对象,和 new Date() 一样安全
console.log(dayjs().toDate() instanceof Date); // true
🎯 实战代码
<template>
<div>
<h3>正确的日期选择器使用姿势</h3>
<!-- 推荐方式 -->
<el-date-picker
v-model="dateValue"
type="date"
placeholder="选择日期"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import dayjs from 'dayjs'
// 🎉 最推荐:简单直接,不会出错
const dateValue = ref(new Date())
// 其他可行方案:
// const dateValue = ref(dayjs().startOf('day').toDate()) // 精确控制
// const dateValue = ref(new Date().toISOString()) // 完整 ISO
</script>
🤔 思考题
看完这篇文章,你能回答这个问题吗:
如果现在是北京时间 2023-12-25 凌晨 02:00,new Date().toISOString().split('T')[0]
会返回什么?
答案是:"2023-12-24"
为什么?因为:
- 北京时间 2023-12-25 02:00 - 8小时 = UTC 时间 2023-12-24 18:00
-
toISOString()
返回"2023-12-24T18:00:00.000Z"
-
split('T')[0]
取日期部分:"2023-12-24"
所以虽然你的本地时间已经是 25 号了,但这个方法返回的还是 24 号!
🎊 总结
记住这个简单的口诀:
日期选择器要喂饱,
Date 对象是最好。
时区问题很头疼,
UTC 标准要记牢!
最后,如果你还在纠结用哪种格式,就直接用 new Date()
吧。简单粗暴,永不出错!
Happy Coding! 愿你的日期永远准确,时区永远正确! 🎈
React 牵手 Ollama:本地 AI 服务对接实战指南
在这个 AI 大模型如雨后春笋般涌现的时代,让前端应用与本地大模型来一场 “亲密接触”,就像给你的 React 应用装上一个 “本地智囊团”。今天,我们就来实现一个看似高深实则简单的需求:用 React 对接本地 Ollama 服务。这就好比教两个素未谋面的朋友打招呼,Ollama 是守在本地的 “AI 达人”,React 则是活泼的 “前端信使”,我们要做的就是搭建它们之间的沟通桥梁。
底层原理:通信的奥秘
在开始编码前,我们得先搞明白这两个 “朋友” 是如何交流的。Ollama 作为本地运行的大模型服务,会在你的电脑上开启一个 “通信窗口”—— 也就是 HTTP 服务器,默认情况下这个窗口的地址是 http://localhost:11434。而 React 应用要做的,就是通过 HTTP 协议向这个窗口发送 “消息”(请求),并等待 “回复”(响应)。
这就像你去餐厅吃饭,Ollama 是后厨的厨师,React 是前厅的服务员,http://localhost:11434 就是厨房的传菜口。服务员把顾客的订单(请求)通过传菜口递给厨师,厨师做好菜后再通过传菜口把菜(响应)送回给服务员。
准备工作:工具就位
在正式开始前,我们需要准备好 “食材” 和 “厨具”:
- 安装 Ollama:去 Ollama 官网下载并安装,这一步就像把厨师请到厨房里。安装完成后,打开命令行,输入 ollama run llama3 来启动一个基础模型,这里我们用 llama3 作为示例,你也可以换成其他喜欢的模型。
- 创建 React 应用:如果你还没有 React 项目,可以用 Create React App 快速创建一个,命令是 npx create-react-app ollama-demo,这就像搭建好前厅的场地。
代码实现:搭建沟通桥梁
一切准备就绪,现在我们来编写核心代码,实现 React 与 Ollama 的通信。
首先,我们需要一个发送请求的函数。在 React 组件中,我们可以用 fetch API 来发送 HTTP 请求到 Ollama 的 API 端点。Ollama 的聊天接口是 http://localhost:11434/api/chat,我们需要向这个接口发送包含模型名称和消息内容的 JSON 数据。
import { useState } from 'react';
function OllamaChat() {
const [message, setMessage] = useState('');
const [response, setResponse] = useState('');
const sendMessage = async () => {
try {
// 构建请求体,指定模型和消息
const requestBody = {
model: 'llama3',
messages: [{ role: 'user', content: message }],
stream: false // 不使用流式响应,等待完整回复
};
// 发送 POST 请求到 Ollama 的聊天接口
const response = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
// 解析响应数据
const data = await response.json();
// 提取并显示 AI 的回复
if (data.message && data.message.content) {
setResponse(data.message.content);
}
} catch (error) {
console.error('与 Ollama 通信出错:', error);
setResponse('抱歉,无法连接到 AI 服务,请检查 Ollama 是否正在运行。');
}
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h2>React × Ollama 聊天 Demo</h2>
<div style={{ marginBottom: '20px' }}>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="输入你的问题..."
style={{ width: '70%', padding: '8px', marginRight: '10px' }}
/>
<button onClick={sendMessage} style={{ padding: '8px 16px' }}>
发送
</button>
</div>
<div style={{ border: '1px solid #ccc', padding: '10px', borderRadius: '4px' }}>
<h3>AI 回复:</h3>
<p>{response}</p>
</div>
</div>
);
}
export default OllamaChat;
代码解析:庖丁解牛
让我们来仔细看看这段代码的工作原理,就像拆解一台精密的机器。
- 状态管理:我们用 useState 钩子创建了两个状态变量,message 用来存储用户输入的消息,response 用来存储 AI 的回复。这就像两个储物盒,分别存放要发送的消息和收到的回复。
- 发送消息函数:sendMessage 是核心函数,它通过 fetch 发送请求到 Ollama。请求体中指定了要使用的模型(llama3)和用户的消息。这里的 stream: false 表示我们希望一次性收到完整的回复,而不是逐字接收。
- 处理响应:当 Ollama 处理完请求后,会返回一个 JSON 格式的响应。我们从中提取出 AI 的回复内容,并更新 response 状态,这样页面上就会显示出 AI 的回答了。
- 错误处理:如果通信过程中出现错误(比如 Ollama 没有运行),我们会捕获错误并显示友好的提示信息。
运行测试:见证奇迹的时刻
现在,让我们来测试一下这个 Demo 是否能正常工作。
- 确保 Ollama 正在运行:打开命令行,输入 ollama run llama3,等待模型加载完成。
- 启动 React 应用:在项目目录下运行 npm start,打开浏览器访问 http://localhost:3000。
- 发送消息:在输入框中输入一个问题,比如 “你好,Ollama!”,然后点击 “发送” 按钮。稍等片刻,你应该就能看到 AI 的回复了。
如果一切顺利,你会看到 React 应用和 Ollama 成功 “牵手”,完成了一次愉快的对话。如果遇到问题,先检查 Ollama 是否正在正常运行,模型名称是否正确,网络连接是否通畅。
进阶思考:拓展可能性
这个简单的 Demo 只是一个开始,就像我们只是搭建了一座简陋的小桥。你可以基于这个基础进行很多拓展:
- 实现流式响应:将 stream 设置为 true,然后处理流式响应,让 AI 的回复像打字一样逐字显示,提升用户体验。
- 增加聊天历史:用状态管理存储聊天记录,让对话可以上下文连贯。
- 切换不同模型:在界面上增加模型选择功能,让用户可以根据需要切换不同的 Ollama 模型。
- 优化错误处理:增加更详细的错误提示,帮助用户排查问题。
总结:本地 AI 的魅力
通过这个 Demo,我们展示了 React 对接本地 Ollama 服务的全过程。相比于调用云端的 AI 服务,本地部署的 Ollama 具有隐私性好、响应速度快、无需网络连接等优点,就像把 AI 助手请到了自己家里,随时可以交流。
希望这篇文章能帮助你理解 React 与本地 AI 服务对接的原理和方法。现在,你可以基于这个基础,开发出更强大、更有趣的本地 AI 应用了。让我们一起探索前端与 AI 结合的无限可能吧!
Next.js 全栈开发基础:在 pages/api/*.ts 中创建接口的艺术
在 Web 开发的世界里,前端与后端就像一对需要默契配合的舞者。前端负责优雅地展示数据,后端则默默在幕后准备数据,而接口就是它们之间传递信号的乐谱。在 Next.js 的舞台上,pages/api/*.ts就是谱写这份乐谱的最佳创作室。今天,我们就来揭开在 Next.js 中创建接口的神秘面纱,用 TypeScript 为你的全栈应用搭建起高效的数据桥梁。
接口的本质:数据交换的高速公路
在深入技术细节之前,让我们先理解接口的本质。想象你在餐厅点餐,你(前端)告诉服务员(接口)想要什么,服务员把需求传达给厨房(数据库 / 业务逻辑),然后把做好的食物(数据)端给你。这个过程中,服务员就是接口,负责规范请求格式、处理业务逻辑并返回结果。
在计算机科学中,接口本质上是客户端与服务器之间约定的数据交换格式和规则。Next.js 的 API 路由之所以强大,是因为它允许我们在同一个项目中同时编写前端页面和后端接口,就像在同一个屋檐下同时拥有餐厅大堂和厨房,大大提高了开发效率。
初探 pages/api:Next.js 的接口魔法
Next.js 的 API 路由基于一个简单而强大的约定:在pages/api目录下创建的文件会自动成为 API 接口。这个机制背后其实是 Next.js 的文件系统路由在起作用,当服务器启动时,它会扫描pages/api目录下的所有文件,为每个文件创建对应的路由端点。
比如我们创建pages/api/hello.ts文件,访问http://localhost:3000/api/hello就能调用这个接口。这种设计就像给每个接口分配了独立的办公室,它们互不干扰又能协同工作。
第一个接口:Hello World 的进阶版
让我们从经典的 Hello World 开始,创建一个能返回个性化问候的接口。在pages/api目录下新建greet.ts文件,输入以下代码:
export default function handler(req, res) {
// 从请求中获取查询参数name
const { name = "World" } = req.query;
// 设置响应状态码为200(成功)
res.status(200).json({
message: `Hello, ${name}!`,
timestamp: new Date().toISOString()
});
}
这个接口做了三件事:
- 从请求的查询参数中获取 name,如果没有提供则默认使用 "World"
- 设置 HTTP 响应状态码为 200,表示请求成功
- 返回一个 JSON 对象,包含问候消息和当前时间戳
运行你的 Next.js 应用,访问http://localhost:3000/api/greet?name=Next.js,你会看到类似这样的响应:
{
"message": "Hello, Next.js!",
"timestamp": "2025-08-17T12:34:56.789Z"
}
处理不同的 HTTP 方法:接口的多面手
一个健壮的接口应该能处理不同的 HTTP 方法,就像一个多才多艺的演员能胜任不同的角色。常见的 HTTP 方法有 GET(获取数据)、POST(创建数据)、PUT(更新数据)和 DELETE(删除数据)。
让我们创建一个简单的任务管理接口,支持 GET 和 POST 方法:
// pages/api/tasks.ts
let tasks = [
{ id: 1, title: "学习Next.js", completed: false },
{ id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
// 获取请求方法
const { method } = req;
switch (method) {
case 'GET':
// 处理GET请求:返回所有任务
res.status(200).json(tasks);
break;
case 'POST':
// 处理POST请求:创建新任务
const { title } = req.body;
// 验证请求数据
if (!title) {
return res.status(400).json({ error: "任务标题不能为空" });
}
// 创建新任务
const newTask = {
id: tasks.length + 1,
title,
completed: false
};
// 添加到任务列表
tasks.push(newTask);
// 返回创建的任务,状态码201表示资源创建成功
res.status(201).json(newTask);
break;
default:
// 处理不支持的方法
res.setHeader('Allow', ['GET', 'POST']);
res.status(405).end(`方法 ${method} 不被允许`);
}
}
这个接口展示了如何根据不同的 HTTP 方法执行不同的操作:
- 当使用 GET 方法访问时,它返回所有任务列表
- 当使用 POST 方法并发送包含 title 的 JSON 数据时,它创建一个新任务
- 当使用不支持的方法(如 PUT 或 DELETE)时,它返回 405 错误
你可以使用工具如 Postman 或 curl 来测试这个接口:
# 测试GET请求
curl http://localhost:3000/api/tasks
# 测试POST请求
curl -X POST -H "Content-Type: application/json" -d '{"title":"新任务"}' http://localhost:3000/api/tasks
接口参数处理:精准获取请求数据
在实际开发中,我们经常需要从不同位置获取请求数据。Next.js 的 API 路由提供了多种方式来获取这些数据,就像有多个入口可以进入一个建筑:
- 查询参数(Query Parameters) :位于 URL 中?后面的键值对,通过req.query获取
- 路径参数(Path Parameters) :URL 路径中的动态部分,通过文件名中的[param]定义
- 请求体(Request Body) :POST、PUT 等方法发送的数据,通过req.body获取
让我们创建一个支持路径参数的接口,用于获取单个任务:
// pages/api/tasks/[id].ts
// 假设tasks数组与前面的例子相同
let tasks = [
{ id: 1, title: "学习Next.js", completed: false },
{ id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
const { id } = req.query;
// 将id转换为数字
const taskId = parseInt(id, 10);
// 验证id是否有效
if (isNaN(taskId)) {
return res.status(400).json({ error: "无效的任务ID" });
}
// 查找任务
const task = tasks.find(t => t.id === taskId);
if (task) {
res.status(200).json(task);
} else {
res.status(404).json({ error: "任务不存在" });
}
}
现在,访问http://localhost:3000/api/tasks/1会返回 ID 为 1 的任务,而访问http://localhost:3000/api/tasks/99会返回 404 错误。
错误处理:接口的安全网
就像现实生活中需要应急预案一样,接口也需要完善的错误处理机制。一个好的错误处理策略应该:
- 返回适当的 HTTP 状态码
- 提供清晰的错误信息
- 避免暴露敏感信息
让我们改进前面的任务接口,添加更完善的错误处理:
// pages/api/tasks/[id].ts(改进版)
let tasks = [
{ id: 1, title: "学习Next.js", completed: false },
{ id: 2, title: "创建API接口", completed: true }
];
export default function handler(req, res) {
try {
const { id } = req.query;
const taskId = parseInt(id, 10);
if (isNaN(taskId)) {
// 400 Bad Request:请求参数无效
return res.status(400).json({
error: "无效的任务ID",
details: "ID必须是数字"
});
}
const task = tasks.find(t => t.id === taskId);
if (task) {
// 200 OK:请求成功
res.status(200).json(task);
} else {
// 404 Not Found:资源不存在
res.status(404).json({
error: "任务不存在",
details: `没有ID为${taskId}的任务`
});
}
} catch (error) {
// 500 Internal Server Error:服务器内部错误
console.error("处理请求时出错:", error);
res.status(500).json({
error: "服务器内部错误",
details: "请稍后再试"
});
}
}
这个改进版接口使用 try-catch 块捕获可能的错误,并为不同类型的错误返回相应的状态码和详细信息,同时避免将内部错误直接暴露给客户端。
接口的性能考量:让数据流动更快
随着应用规模的增长,接口的性能变得越来越重要。以下是一些提高 API 路由性能的小贴士:
- 数据缓存:对于不经常变化的数据,可以使用缓存减少重复计算
- 请求验证:尽早验证请求数据,避免不必要的处理
- 分页处理:对于大量数据,使用分页减少数据传输量
- 异步处理:对于耗时操作,考虑使用异步处理避免阻塞
让我们实现一个带分页功能的任务列表接口:
// pages/api/tasks/paginated.ts
let tasks = [
// 假设这里有很多任务...
{ id: 1, title: "任务1", completed: false },
{ id: 2, title: "任务2", completed: true },
// ...更多任务
];
export default function handler(req, res) {
try {
// 获取分页参数,默认页码为1,每页10条
const { page = 1, limit = 10 } = req.query;
const pageNum = parseInt(page, 10);
const limitNum = parseInt(limit, 10);
// 验证分页参数
if (isNaN(pageNum) || isNaN(limitNum) || pageNum < 1 || limitNum < 1) {
return res.status(400).json({
error: "无效的分页参数",
details: "页码和每页数量必须是正整数"
});
}
// 计算总页数
const totalPages = Math.ceil(tasks.length / limitNum);
// 计算起始索引
const startIndex = (pageNum - 1) * limitNum;
// 获取当前页的任务
const paginatedTasks = tasks.slice(startIndex, startIndex + limitNum);
res.status(200).json({
data: paginatedTasks,
pagination: {
total: tasks.length,
page: pageNum,
limit: limitNum,
totalPages
}
});
} catch (error) {
console.error("分页查询出错:", error);
res.status(500).json({ error: "服务器内部错误" });
}
}
这个接口支持通过page和limit参数控制返回的数据量,减轻了服务器和网络的负担。
部署与注意事项:让接口飞向生产环境
当你的接口准备好部署到生产环境时,有几个重要的注意事项:
- 环境变量:敏感信息如数据库连接字符串应该使用环境变量,而不是硬编码在代码中
- CORS 设置:如果你的前端和后端不在同一个域名下,需要配置跨域资源共享(CORS)
- 速率限制:为了防止滥用,考虑添加速率限制功能
- 日志记录:添加适当的日志记录以便调试和监控
在 Next.js 中配置 CORS 非常简单,你可以使用cors中间件:
// pages/api/with-cors.ts
import cors from 'cors';
// 初始化cors中间件
const corsMiddleware = cors({
origin: process.env.NEXT_PUBLIC_FRONTEND_URL || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE']
});
// 辅助函数:将中间件转换为Promise
function runMiddleware(req, res, fn) {
return new Promise((resolve, reject) => {
fn(req, res, (result) => {
if (result instanceof Error) {
return reject(result);
}
return resolve(result);
});
});
}
export default async function handler(req, res) {
// 应用CORS中间件
await runMiddleware(req, res, corsMiddleware);
// 处理请求
res.status(200).json({ message: "这个接口支持跨域请求!" });
}
总结:接口开发的艺术与科学
在 Next.js 中创建 API 接口就像在构建一座连接前端和后端的桥梁,它需要扎实的技术基础,也需要对用户需求的深刻理解。通过pages/api/*.ts文件,我们可以快速创建功能完善的接口,处理各种 HTTP 方法,获取不同来源的请求数据,并返回结构化的响应。
记住,一个好的接口应该是清晰、健壮、高效且安全的。它不仅要能正确处理正常情况,还要能优雅地应对错误;不仅要能满足当前需求,还要为未来的扩展留有余地。
随着你对 Next.js API 路由的深入了解,你可以尝试更高级的功能,如数据库集成、身份验证、文件上传等。全栈开发的世界充满了可能性,而接口就是打开这个世界的钥匙。现在,拿起这把钥匙,开始构建你的全栈应用吧!
p5.js 3D 形状 "预制工厂"——buildGeometry ()
点赞 + 关注 + 收藏 = 学会了
如果你已经会用box()
、sphere()
画简单 3D 形状,想组合它们做出复杂模型,又担心画面卡顿,那么buildGeometry()
就是你的 "性能救星"。这个函数能把多个简单形状 "焊接" 成一个自定义 3D 模型,让绘制效率飙升。
什么是 buildGeometry ()?
buildGeometry()
是 p5.js 中用于组装复杂 3D 模型的工具函数。它的核心作用就像 "预制构件厂":
- 把多个简单 3D 形状(比如
box()
、sphere()
)组合成一个完整的p5.Geometry
对象(可以理解为 "自定义 3D 零件"); - 这个 "零件" 只需要在程序启动时制作一次,之后每次绘制直接调用即可,大幅减少重复计算;
- 必须在WebGL 模式下使用(和所有 3D 函数一样)。
buildGeometry()
就是来解决这个问题的:它能把多个简单 3D 形状 “打包” 成一个p5.Geometry
对象,只需创建一次,之后反复绘制都不会卡顿。就像快递打包,把多个小包裹捆成一个大包裹,搬运起来更高效~
基础用法
用buildGeometry
打包一个球体,然后绘制它。
let myShape; // 存储打包好的3D对象
function setup() {
// 开启WebGL模式(3D绘图必备)
createCanvas(400, 400, WEBGL);
// 用buildGeometry创建3D对象,回调函数是makeShape
myShape = buildGeometry(makeShape);
}
function draw() {
background(200); // 灰色背景
orbitControl(); // 允许鼠标拖动旋转视角
lights(); // 添加光照(3D物体需要光照才看得见)
model(myShape); // 绘制打包好的3D对象
}
// 回调函数:定义要打包的形状
function makeShape() {
sphere(50); // 画一个半径50的球体
}
旋转的几何花朵
用buildGeometry
组合多个锥体,形成一朵 “花”,然后让它随时间旋转并变色,展示高性能复杂 3D 动画的实现。
let flower;
let hueValue = 0; // 色相值(用于颜色变化)
function setup() {
createCanvas(600, 600, WEBGL);
// 创建几何花朵
flower = buildGeometry(makeFlower);
}
function draw() {
background(0); // 黑色背景
orbitControl(); // 允许鼠标旋转视角
lights(); // 光照
// 颜色随时间变化(HSB模式:色相、饱和度、亮度)
colorMode(HSB);
fill(hueValue % 360, 80, 90);
hueValue += 0.5;
// 整体旋转(X和Y轴同时转,更有动感)
// rotateX(frameCount * 0.005);
rotateY(frameCount * 0.008);
model(flower); // 绘制花朵
}
// 构建花朵形状的回调函数
function makeFlower() {
// 中心球体
sphere(15);
// 周围的“花瓣”:12个锥体
for (let i = 0; i < 12; i++) {
push();
// 绕Y轴均匀分布(360度/12=30度一个)
rotateY(i * PI / 6);
// 沿Z轴向外移动
translate(0, 0, 40);
// 锥体:底面半径10,高30,朝上
cone(10, 30);
pop();
}
}
以上就是本文的全部内容啦,想了解更多 P5.js 用法欢迎关注 《P5.js中文教程》。
也可以➕我 green bubble 吹吹水咯
点赞 + 关注 + 收藏 = 学会了
事件委托的深层逻辑:当冒泡不够时⁉️
前言
在项目不断扩大之时,管理用户交互变的越来越重要,为每个交互元素附加一个事件监听器是一种糟糕的做法,因为它会导致代码混乱、内存消耗增加以及性能瓶颈。这时,事件委托就派上用场了。
认识dom事件传播
三个阶段
当事件在 DOM 元素上触发时,它不会简单地到达目标并停止。相反,它会经历以下阶段:
-
捕获阶段旅程从
window
级别开始,沿着 DOM 树向下移动,经过每个祖先元素,直到到达目标的父级。带有(中的第三个参数)的事件监听器在此触发useCapture = true``addEventListener
- 目标阶段在此阶段,事件到达预期的目标元素。所有直接附加到此元素的监听器都会被触发
-
冒泡阶段命中目标后,事件会沿着 DOM 向上“冒泡”,从目标的父元素到祖父元素,依此类推,直到到达目标
window
。默认情况下,大多数事件监听器都在此阶段运行
事件在dom树中流动过程
< div id = "grandparent" >
< div id = "parent" >
< button id = "child" >点击我</ button >
</ div >
</ div >
如果您单击,则事件流程如下:<button id="child">
click
-
捕获 -
window
->document
-><html>
-><body>
-><div id="grandparent">
-><div id="parent">
-
目标-
<button id="child">
-
冒泡 -
<button id="child">
-><div id="parent">
-><div id="grandparent">
-><body>
-><html>
->document
->window
什么是事件委托
事件委托是一种将事件监听器添加到多个子元素的父元素上,而不是分别添加到每个子元素上的方法。当子元素上发生事件时,它会触发父元素上的监听器,父元素会检查是哪个子元素触发了该事件。
假设一个<ul>
包含<li>
以下项目的简单列表:
< ul id = "myList" >
< li >项目 1 </ li >
< li >项目 2 </ li >
< li >项目 3 </ li >
< li >项目 4 </ li >
</ ul >
而不是为每个添加一个点击监听器<li>
:
const listItems = document . querySelectorAll ( '#myList li' );
listItems . forEach ( item => {
item . addEventListener ( 'click' , ( event ) => { console . log ( `点击于: $ { event . target . textContent } ` );
});
});
通过事件委托,你可以将一个监听器附加到<ul>
父级:
onst myList =文档. getElementById ( 'myList' );
myList . addEventListener ( 'click' , ( event ) => {
// 检查点击的元素是否为 <li>
if ( event . target . tagName === 'LI' ) {
console . log ( ` Clicked on : $ { event . target . textContent } ` );
}
});
在此示例中,当<li>
点击任意一个时,click
事件都会冒泡到。然后,myList
上的单个事件监听器会检查是否是触发了该事件,并采取相应的措施:myList``event.target.tagName``<li>
为什么事件委托如此重要
- 无需添加数百或数千个监听器,只需几个父容器就足够了,从而大大减少内存占用
- 更少的监听器可以提高浏览器整体系统内存的使用率,并减少 JavaScript 引擎在事件管理和调度方面的工作量
- 它支持动态创建元素,这非常实用。假设在页面加载后(例如,在 API 调用后)
<li>
添加了新元素,监听器仍然有效。无需重新连接监听器。#myList``#myList
事件委托中常见的误区
event.target vs event.currentTarget
-
event.target
是触发事件的特定元素。 -
event.currentTarget
是事件监听器实际附加到的元素。
stopPropagation ()和stopImmediatePropagation ()
-
event.stopPropagation()
– 此方法仅允许事件停止沿 DOM 树向上或向下冒泡或捕获。如果在子元素的事件处理程序中执行此方法,则其祖先元素上的任何委托监听器都将无法访问该事件 -
event.stopImmediatePropagation()
这不stopPropagation()
的复制粘贴。它的相似之处仅限于添加了这个效果:它阻止进一步的事件传播,并阻止绑定到同一元素的任何其他监听器被执行。
在某些情况下,它们会破坏委托处理程序,例如:子元素的事件处理程序调用stopPropagation
将导致位于 DOM 层次结构中更高层级的任何委托监听器的功能失效。委托监听器将无法接收事件。这对于分析、集中式 UI 逻辑或可访问的自定义控件功能尤其麻烦。
非冒泡事件
最突出的非冒泡事件包括:
-
focus
– 当元素获得焦点时触发 -
blur
– 当元素失去焦点时触发 -
mouseenter
– 当指针进入元素时触发 -
mouseleave
– 当指针离开元素时触发
为什么它们不起泡
由于浏览器的工作方式以及过去的兼容性问题,通常无法触发此类事件。focus
和blur
旨在在获得或失去焦点的特定元素上触发,因此不存在冒泡。mouseenter
和mouseleave
与mouseover
和 mouseout
配对(它们会产生冒泡);但是与mouseover
和 mouseout
不同,mouseenter
和 mouseleave
仅在指针位于元素上(而不是其子元素上)时触发。
对于非冒泡事件只能通过自定义冒泡事件来替代
总结
事件委托通过将单个监听器附加到父元素来简化事件处理。当子元素触发事件时,它会向上冒泡到父元素,从而减少内存占用并简化代码。
这种技术在管理大量相似元素(例如列表项或按钮)时非常有效,尤其是在它们动态生成的情况下。父级监听器无需额外配置即可处理新添加元素的事件。
并非所有事件都会冒泡 focus
、blur
、mouseleave
等是例外。对于这些事件,可以用 focusin
、focusout
或自定义冒泡事件等替代方法。
全面解析this-理解this指向的原理
参考资料
- 《你不知道的JavaScript》- this全面解析
this 是什么
- this 是一个代词,代指一个对象
- this 提供了一种更优雅的方式来隐式的传递一个对象引用,可以让代码更加简洁易于复用
调用位置
调用位置就是函数在代码中被调用的位置(而不是声明的位置)。调用位置决定了 this 的绑定
比如下面代码:
function baz() {
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域
console.log("baz");
bar(); // <-- bar 的调用位置
}
function bar() {
// 当前调用栈是 baz -> bar
// 因此,当前调用位置在 baz 中
console.log("bar");
foo(); // <-- foo 的调用位置
}
function foo() {
// 当前调用栈是 baz -> bar -> foo // 因此,当前调用位置在 bar 中
console.log("foo");
}
baz(); // <-- baz 的调用位置
可以使用浏览器的开发工具查看调用栈
![]() |
![]() |
![]() |
---|
this的绑定规则
this的指向有以下四条特性/规则
- 默认绑定
- 隐式绑定
- 显式绑定
- new 绑定
默认绑定
当函数被独立调用时,函数的 this 指向 window
独立调用就是像这样:
foo()
的调用
比如
function foo() {
console.log(this.a);
}
var a = 2;
foo(); // 2
如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定到 undefined
隐式绑定
当函数引用有上下文对象 且被该对象调用时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象
function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
// 这里就是隐式绑定,foo函数的this绑定到了obj上
需要注意的是,对象属性引用链中只有最顶层或者说最后一层会影响调用位置
比如
function foo() {
console.log(this.a);
}
var obj2 = {
a: 42,
foo: foo,
};
var obj1 = {
a: 2,
obj2: obj2,
};
obj1.obj2.foo(); // 42
// 相当于 obj2.foo(),因为只有最后一层会影响调用位置
隐式丢失
隐式绑定的函数可能会丢失绑定对象,也就是说它会应用默认绑定
隐式丢失的几种情况:
- 函数别名
function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo,
};
// 注意这里的函数别名,会导致隐式绑定丢失,导致foo函数的this指向全局
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"
- 函数作为参数传入,并调用时
function foo() {
console.log(this.a);
}
function doFoo(fn) {
// fn 其实引用的是 foo
fn(); // <-- 调用位置!
}
var obj = {
a: 2,
foo: foo,
};
var a = "oops, global"; // a 是全局对象的属性
doFoo(obj.foo); // "oops, global"
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样
显式绑定
-
fn.call(obj, x, x, ...)
将fn函数的this指向obj,并调用,call的剩余参数是fn需要的参数
function foo(aram) {
console.log("foo", param.a); // foo 1
function bar(x, y) {
console.log("bar", this.a, x, y); // bar 1 2 3
}
bar.call(param, 2, 3);
}
foo({ a: 1 });
-
fn.apply(obj, [x, x, ...])
将fn函数的this指向obj,并调用,apply的第二个参数是一个数组
function foo(param) {
console.log("foo", param.a); // foo 1
function bar(x, y) {
console.log("bar", this.a, x, y); // bar 1 2 3
}
bar.apply(param, [2, 3]);
}
foo({ a: 1 });
-
fn.bind(obj, x, x, ...)(x, x, ...)
将fn函数的this指向obj,bind会返回一个新的函数,新的函数也可以传递参数
function foo(param) {
console.log("foo", param.a); // foo 1
function bar(x, y) {
console.log("bar", this.a, x, y); // bar 1 2 3
}
return bar.bind(param, 2);
}
const bar = foo({ a: 1 });
bar(3);
new 绑定
new 绑定 - this会绑定到新创建的对象上
function Person(name, age) {
// this 指向新创建的对象
this.name = name;
this.age = age;
// 通过new调用构造函数时,this指向新创建的对象
// 直接调用构造函数时,应用默认绑定规则,this指向全局或undefined
console.log(this);
}
// 使用 new 关键字调用构造函数
const person1 = new Person("张三", 25);
console.log(person1.name); // "张三"
console.log(person1.age); // 25
// 不使用 new 调用,this 会指向全局对象(非严格模式)或 undefined(严格模式)
const person2 = Person("李四", 30); // this 不会指向新对象
console.log(person2); // undefined (因为没有显式返回)
箭头函数
- 箭头函数没有自己的this 指向,它需要继承外层函数的this指向
- 箭头函数即使是new也无法改变this指向,因此箭头函数不能用于编写构造函数
var a = 1;
function foo() {
var obj1 = {
a: 2,
bar: function () {
console.log("bar", this.a);
var obj2 = {
a: 3,
baz: () => {
console.log("baz", this.a);
},
};
// 箭头函数不会创建自己的 this,它会捕获外层函数的 this
obj2.baz(); // baz 2
},
};
console.log("foo", this.a);
obj1.bar(); // bar 2
}
foo(); // foo 1
神奇魔法类:使用 createMagicClass 增强你的 JavaScript/Typescript 类
什么是神奇魔法类?
神奇魔法类(Magic Class)是一种特殊的类包装器,它通过createMagicClass
函数创建,能够赋予普通 JavaScript/TypeScript 类超能力。这个工具让你的类既可以作为构造函数使用,又可以作为函数调用来配置选项,大大增强了类的灵活性和可用性。
神奇特性
1. 双重身份
魔法类具有双重身份,可以:
- 作为普通类使用用于继承:
class MyClass extends MagicClass
- 作为函数调用来传入选项用于构建类:
class MyClass extends MagicClass({<options>})
2. 生命周期钩子
提供完整的生命周期钩子,让你能够精确控制实例的创建过程:
-
onBeforeInstance
: 实例创建前触发,可以阻止实例创建或修改类 -
onAfterInstance
: 实例创建后触发,可以对实例进行后处理 -
onErrorInstance
: 实例创建出错时触发,可以进行错误处理
使用示例
基本用法
import { createMagicClass } from "flex-tools/classs";
type UserCreateOptions = {
prefix?: string;
x?: number;
};
// 定义一个普通类
class User {
name: string;
prefix: string = "";
constructor(name: string) {
this.name = name;
this.prefix = getMagicClassOptions<UserCreateOptions>(this)?.prefix!;
}
get title() {
return `${this.prefix}${this.name}`;
}
toString() {
return `${this.constructor.name}<${this.name}>`;
}
}
// 创建魔术类
const MagicUser = createMagicClass<typeof User, UserCreateOptions>(User, {
prefix: "Hi,", // 默认配置
x: 1,
onBeforeInstance: (cls, args, _options) => {},
onAfterInstance: (inst, _options) => {},
});
// 直接作为类使用
class Admin extends MagicUser {}
class Guest extends MagicUser({ x: 2, prefix: "欢迎," }) {}
class Customer extends MagicUser({ prefix: "尊贵的" }) {}
const user = new User("用户");
const admin = new Admin("管理员");
const guest = new Guest("访客");
const customer = new Customer("客户");
高级用法:拦截实例创建
const ValidatedPerson = createMagicClass(Person, {
onBeforeInstance: (cls, args, options) => {
const name = args[0];
// 验证名称
if (!name || name.length < 2) {
throw new Error("Name must be at least 2 characters long");
}
// 可以修改参数
args[0] = name.charAt(0).toUpperCase() + name.slice(1);
// 返回false会阻止实例创建
// 返回一个对象会使用该对象作为实例
// 返回一个类会使用该类创建实例
},
onErrorInstance: (error, cls, options) => {
console.error("Failed to create person:", error.message);
},
});
class SitePerson extends ValidatedPerson {}
// 这将抛出错误,因为名称太短
try {
const invalid = new SitePerson("A");
} catch (e) {
console.log(e.message); // "Name must be at least 2 characters long"
}
// 这将成功,并且名称首字母会被自动大写
const valid = new SitePerson("bob"); // 实际名称将是 "Bob"
获取实例配置
你可以使用getMagicClassOptions
函数获取实例的配置选项:
import { createMagicClass, getMagicClassOptions } from "flex-tools/classs";
const MagicPerson = createMagicClass(Person, { version: "1.0" });
const ConfiguredPerson = MagicPerson({ theme: "dark" });
const person = new ConfiguredPerson("Alice");
const options = getMagicClassOptions(person);
console.log(options); // { version: '1.0', theme: 'dark' }
总结
神奇魔法类通过createMagicClass
函数提供了一种优雅而强大的方式来增强 JavaScript/TypeScript 类的能力。它不仅保留了原始类的所有功能,还添加了配置选项、生命周期钩子和灵活的实例化方式,使你的代码更加灵活、可配置且易于维护。
无论你是构建复杂的 UI 组件、可配置的工具类,还是需要精细控制实例创建过程的系统,神奇魔法类都能为你提供强大而灵活的解决方案。
详见flex-tools。
一个前端开发者的救赎之路-JS基础回顾(三)-Function函数
函数的声明
- 赋值式:var func = function() {}
- 声明式:function func() {}
- 箭头函数:()=>{}
- 三者之间的区别
a. 声明式可以在函数声明前去调用,赋值式不可以
b. 箭头函数,他们从定义自己的环境继承上下文,而不是像以其他方式定义的函数那样定义自己的调用上下文
c. 箭头函数没有prototype属性,所以它不能作为新类的构造函数
函数的调用
- 作为函数:
- 注意这个说法,函数是通过调用表达式被作为函数或方法调用的。
- 调用表达式包括求值为函数对象的函数表达式,后跟一对圆括号,圆括号中是逗号分隔的零或多个参数表达式列表
- this指向:
- 非严格模式:全局对象
- 严格模式:undefined
-
注意
: 箭头函数又有不同:它们总是继承自身定义所在环境的this值(这里即使是严格模式,this也是window) - 下面的代码可以用来判断是不是处于严格模式
// 定义并调用函数,以确定当前是不是严格模式 const strict = (function() { return !this }())
- 我有一篇文章,这里面有一个自己遇到的有趣的this指向和作用域的问题
- 这里注意条件式调用:
- 在Es2020中,可以在函数表达式的后面、圆括号的前面插入
?.
,从而只在函数不是null或undefined的情况下调用。 - 在没有副作用的前提下:f?.()<=>(f !== null && f !== undefined ? f() : undefined)
- 在Es2020中,可以在函数表达式的后面、圆括号的前面插入
- 作为方法:
-
this关键字不具有变量那样的作用域机制,除了箭头函数,嵌套函数不会继承包含函数的this值。如果嵌套函数被当做方法来用,那它的this就是调用它的对象。如果嵌套函数(不是箭头函数)被当做函数来调用,则它的this值要么是全局对象(非严格模式),要么是undefined(严格模式)
let o = { a: 1, b: 2, m: function() { let self = this; console.log(this.a); // 1 f(); function f() { console.log(this.b); // undefined } const g = () => { console.log(this.b); // 2 } g(); } } o.m();
-
- 作为构造函数
- 作为构造函数调用,上下文是这个新对象
- 通过call()或apply()方法调用
- 这两个方法允许我们指定调用时的this,这意味着可以将任意函数作为任意对象的方法来调用
- call()方法使用自己的参数列表作为函数的参数,而apply()方法则期待数组值作为参数。
- 通过JavaScript语言隐式调用
- 这里在开发中排查bug,很关键,就是我们引用了别人的三方库或者代码都有可能出现这个隐式调用
函数的参数
- 函数调用时所传的实参,可以少于形参也可以多于形参,
- 函数定义式可以用一个剩余参数来接收多余的参数,
剩余参数必须作为最后一个参数
,并且是...args
这种形式,剩余参数永远是一个数组,不会是undefined,即使没有传对应的实参,因此不需要给剩余参数默认值
JS预解析
- 对于函数的预解析和普通变量不一样,函数预解析是直接把整个函数提到顶部作用域,在预解析时会提前定义,只是不会立即执行。
- 普通变量只是把声明提升到所在作用域顶部,而不进行初始化。
- 因此,上面函数定义的时候,如果使用字面量的方式会把这个变量提升到顶部作用域并赋值undefined,所以在定义前调用的时候会报错。
-
let
和const
会提升(hoisting),但由于 TDZ(暂时性死区) 机制,在声明前访问会报错。
作用域
- 作用域在定义的时候,一个函数就会形成一个内部作用域,后来引入了let/const又有了块级作用域
- 作用域在访问的时候,是从下往上找,一直找到顶级作用域,找不到就报is not defined
- 在赋值的时候不是,如果一直到最顶级都未声明,那他就是直接在全局定义域声明并且赋值这个变量。所以即使在vue中的某一个函数直接使用xxx=来进行操作,也是会在全局作用域添加一个全局变量xxx(
内存泄漏
),千万不要这样干
函数的方法和属性
- length属性:返回声明时声明的形参的个数(不包含剩余参数),只读
- name属性:表示定义函数时使用的名字(如果是用名字定义的),如果是未命名的函数,表示在第一次创建这个函数时赋给该函数的变量名或属性名。这个属性主要用于记录调试或排错信息。只读
- prototype属性:箭头函数没有
- call()和apply()方法:
箭头函数的this不会被修改
- bind()方法:bind()方法主要目的是把函数绑定到对象,
箭头函数不起作用
- toString()方法:
- ECMAScript规定返回一个符合函数声明语句的字符串
- 实际上,多数(不是全部),都是返回函数完整源代码
- 内置函数返回的字符串中通常包含“[native code]”,表示函数体
- Function()构造函数:
- Function()构造函数可以接收
任意多个
字符串参数,其中最后一个参数
是函数体的文本。这个函数体 文本中可以包含任意JavaScript语句,相互以分号分隔。传给构造函数的其他字符串参数,将作为这个新函数的的形参。 - 注意:Function()构造函数
不接受任何指定新函数名字的参数
。与函数字面量一样,Function()构造函数创建的也是匿名函数
- Function()构造函数可以接收
深入理解 TypeScript 的 /// <reference /> 注释及其用途
在 Angular 项目中,出现的 index.d.ts
文件中包含了以下代码:
/// <reference path="./lib.app.d.ts" />
/// <reference path="./lib.page.d.ts" />
/// <reference path="./lib.component.d.ts" />
/// <reference path="./lib.mixin.d.ts" />
/// <reference path="./lib.shared.d.ts" />
/// <reference path="./lib.global.d.ts" />
这些代码的作用及其语法含义,涉及到 TypeScript 的编译器如何解析类型声明文件,以及如何通过 /// <reference />
注释建立模块或类型之间的依赖关系。
以下将逐步分析这些代码的每个部分,详细解释其功能和语法规则,并提供相关的运行示例来演示其应用。
什么是 /// <reference />
/// <reference />
是 TypeScript 中一种特殊的三斜杠注释(Triple-Slash Directive)。这类注释提供了一种方式,允许在文件之间显式声明依赖关系,指导编译器加载特定的类型定义文件。它们通常用于 .d.ts
类型声明文件。
三斜杠注释的语法如下:
/// <reference path="relative-or-absolute-path" />
这里的 path
表示要引用的文件的路径,可以是相对路径或绝对路径。路径指向一个 TypeScript 声明文件(以 .d.ts
为扩展名)。
逐个拆解代码片段的含义
///
这部分表示三斜杠注释的起始标志。三斜杠注释是一种特殊的注释类型,只能出现在文件的顶部或注释之前没有其他语句。
<reference />
这是三斜杠注释的核心指令部分,表明这是一个引用指令。指令用于引入外部文件中的类型信息。
path="./lib.app.d.ts"
path
是一个属性,指定要引用的文件路径。在这个示例中,路径为 ./lib.app.d.ts
,表示当前目录下的 lib.app.d.ts
文件。
文件路径支持以下形式:
-
相对路径:以
./
或../
开头,指向相对于当前文件的路径。 -
绝对路径:在某些项目中可以使用项目根目录的绝对路径,但这通常需要与
tsconfig.json
配合。
为什么需要 /// <reference />
在现代 TypeScript 中,/// <reference />
的使用场景较为有限,因为大多数项目依赖模块系统(如 ES Modules 或 CommonJS)自动处理文件之间的依赖关系。然而,在以下情况下仍然需要使用这种语法:
- 全局类型声明文件:如果一个类型定义文件中定义了全局变量、类型或接口,其他文件需要显式引用它以确保类型安全。
- 非模块化项目:当项目没有采用模块系统时,可以通过三斜杠注释建立文件间的依赖关系。
- 特定工具链或框架:某些工具或框架可能要求使用这种语法来声明类型依赖。
提供一个完整的运行示例
以下示例演示如何使用 /// <reference />
在项目中引入全局类型声明。
文件结构
project/
|-- tsconfig.json
|-- index.ts
|-- types/
|-- lib.app.d.ts
|-- lib.page.d.ts
tsconfig.json
配置文件用于告诉 TypeScript 编译器如何处理项目。
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "./",
"typeRoots": ["./types"],
"strict": true
}
}
types/lib.app.d.ts
declare namespace App {
interface Config {
appName: string;
version: string;
}
}
types/lib.page.d.ts
declare namespace Page {
interface Metadata {
title: string;
description: string;
}
}
index.ts
在主文件中使用三斜杠注释引用这些全局声明文件:
/// <reference path="./types/lib.app.d.ts" />
/// <reference path="./types/lib.page.d.ts" />
const appConfig: App.Config = {
appName: `MyApp`,
version: `1.0.0`
};
const pageMetadata: Page.Metadata = {
title: `Home Page`,
description: `Welcome to the home page of MyApp.`
};
console.log(appConfig, pageMetadata);
编译与运行
执行以下命令进行编译和运行:
tsc
node dist/index.js
输出结果为:
{ appName: 'MyApp', version: '1.0.0' } { title: 'Home Page', description: 'Welcome to the home page of MyApp.' }
注意事项
- 路径有效性:确保引用的路径正确,并且文件存在。
-
模块系统的替代方案:对于模块化项目,优先使用
import
和export
,而非三斜杠注释。 -
与
tsconfig.json
配合:在配置文件中设置typeRoots
和include
,可以减少手动引用的需求。
总结
三斜杠注释是 TypeScript 的一种显式依赖声明机制,用于特定场景下的类型声明管理。虽然在现代项目中应用范围有限,但它在处理全局声明和非模块化项目时依然具有重要作用。通过合理使用 /// <reference />
,可以有效组织和管理大型项目的类型定义。
面试官的 JS 继承陷阱,你能全身而退吗?🕳️
继承,是 JS 面试绕不开的灵魂拷问。本文将带你一网打尽 JS 继承的所有姿势,配合代码实例和细致讲解,助你面试不再慌张!
一、什么是继承?
继承,就是让子类可以访问到父类的属性和方法。JS 继承的实现方式多如牛毛,面试官最爱考察各种细节和坑点。
二、原型链继承
原理
子类的原型指向父类的实例。所有子类实例共享同一个父类实例。
代码演示
function Parent() {
this.name = 'parent'
this.like = ['a', 'b', 'c']
}
Child.prototype = new Parent()
function Child() {
this.age = 18
}
let c = new Child()
let d = new Child()
c.like.push('d')
console.log(c.like) // ['a', 'b', 'c', 'd']
console.log(d.like) // ['a', 'b', 'c', 'd']
优缺点
- 优点:实现简单,能访问父类属性和方法。
- 缺点:引用类型属性会被所有实例共享,互相影响,容易踩坑。
面试官小贴士
"你能说说原型链继承的缺陷吗?为什么 like 属性会被所有实例共享?"
三、构造函数继承
原理
在子类构造函数中调用父类构造函数,this 指向子类实例。
代码演示
Parent.prototype.say = function () {
console.log('hello')
}
function Parent() {
this.name = 'parent'
this.like = ['a', 'b', 'c']
}
function Child() {
this.age = 18
Parent.call(this)
}
let c = new Child()
console.log(c.say) // undefined
优缺点
- 优点:每个实例独立拥有父类属性,引用类型不再共享。
- 缺点:无法继承父类原型上的方法(如 say),只能继承构造函数里的属性。
面试官小贴士
"为什么 c.say 是 undefined?如何让子类也能继承父类原型上的方法?"
四、组合继承
原理
原型链继承 + 构造函数继承,双管齐下。
代码演示
Parent.prototype.say = function () {
console.log('hello')
}
function Parent() {
this.name = 'parent'
this.like = ['a', 'b', 'c']
}
Child.prototype = new Parent()
Child.prototype.constructor = Child
function Child() {
this.age = 18
Parent.call(this)
}
let c = new Child()
let d = new Child()
d.like.push('d')
console.log(d.like); // ['a', 'b', 'c', 'd']
console.log(c.like); // ['a', 'b', 'c']
console.log(c.say); // function
console.log(c.constructor); // Child
优缺点
- 优点:既能继承父类属性,又能继承父类原型方法,引用类型不共享。
- 缺点:父类构造函数会执行两次(一次给原型,一次给实例),有点浪费性能。
面试官小贴士
"组合继承为什么会调用两次父类构造函数?有没有更优的方案?"
五、原型式继承
原理
用 Object.create 或类似方式,以某对象为原型创建新对象。
代码演示
let parent = {
name: 'parent',
like: ['a', 'b', 'c']
}
let child1 = Object.create(parent)
let child2 = Object.create(parent)
child1.like.push('d')
console.log(child1.like); // ['a', 'b', 'c', 'd']
console.log(child2.like); // ['a', 'b', 'c', 'd']
优缺点
- 优点:实现简单,适合对象克隆。
- 缺点:引用类型属性依然共享。
面试官小贴士
"Object.create(parent) 和 new Object(parent) 有什么区别?"
六、寄生式继承
原理
在原型式继承基础上,增强返回的新对象。
代码演示
let parent = {
name: 'parent',
like: ['a', 'b', 'c']
}
function clone(origin) {
let cloneObj = Object.create(origin)
cloneObj.getLike = function() {
return this.like
}
return cloneObj
}
let child1 = clone(parent)
let child2 = clone(parent)
child1.like.push('d')
console.log(child1.like); // ['a', 'b', 'c', 'd']
console.log(child2.like); // ['a', 'b', 'c', 'd']
console.log(child1.getLike()); // ['a', 'b', 'c', 'd']
console.log(child2.getLike()); // ['a', 'b', 'c', 'd']
优缺点
- 优点:可以扩展新对象。
- 缺点:引用类型属性依然共享。
面试官小贴士
"寄生式继承和原型式继承的本质区别是什么?"
七、寄生组合式继承(最优解)
原理
只继承父类原型,不调用父类构造函数,避免性能浪费。
代码演示
Parent.prototype.getName = function() {
return this.Name
}
function Parent() {
this.Name = 'parent'
this.like = ['a', 'b', 'c']
}
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child
function Child() {
this.age = 18
Parent.call(this)
}
let c1 = new Child()
console.log(c1.getName()); // 'parent'
console.log(c1.constructor); // Child
优缺点
- 优点:只调用一次父类构造函数,性能最佳,继承属性和方法都不落下。
- 缺点:实现稍复杂,但值得!
面试官小贴士
"为什么寄生组合式继承被称为 JS 继承的终极方案?"
八、ES6 类继承
原理
用 class 和 extends 语法糖,优雅实现继承。
代码演示
class Parent {
constructor() {
this.Name = 'parent'
this.like = ['a', 'b', 'c']
}
getName() {
return this.Name
}
static say() {
console.log('hello');
}
}
class Child extends Parent {
constructor() {
super()
this.age = 18
}
}
let p = new Parent()
console.log(p.getName()); // 'parent'
let c = new Child()
console.log(c.getName()); // 'parent'
优缺点
- 优点:语法简洁,继承关系清晰,原型链自动处理。
- 缺点:底层依然是原型链,只是语法糖。
面试官小贴士
"class 继承和传统原型链继承的本质区别是什么?"
九、知识点总结与面试答题模板
继承方式对比表
方式 | 是否共享引用类型 | 是否继承原型方法 | 构造函数调用次数 | 优缺点 |
---|---|---|---|---|
原型链继承 | 是 | 是 | 1 | 引用类型共享 |
构造函数继承 | 否 | 否 | 1 | 不能继承原型方法 |
组合继承 | 否 | 是 | 2 | 性能浪费 |
原型式继承 | 是 | 是 | 0 | 引用类型共享 |
寄生式继承 | 是 | 是 | 0 | 引用类型共享 |
寄生组合式继承 | 否 | 是 | 1 | 性能最佳 |
ES6 类继承 | 否 | 是 | 1 | 语法糖 |
面试高频问题
- 说说 JS 继承的实现方式及优缺点?
- 为什么原型链继承会导致引用类型属性共享?
- 如何实现一个既能继承属性又能继承方法的子类?
- ES6 的 class 继承和传统继承有什么区别?
十、幽默收尾
JS 继承就像家庭聚会,谁家锅碗瓢盆都能借来用,但有时候大家都用同一个锅,炒出来的菜味道就不一样了!面试官最爱问的那些继承细节,你现在都能用段子和代码轻松拿下!
祝大家面试不再慌张,继承全家桶一把梭!🎉
JavaScript 运行机制详解:再谈 Event Loop
同步更新至个人站点: JavaScript 运行机制详解:再谈 Event Loop
本文从经典的 Promise 与 setTimeout 执行顺序问题入手,深入浅出地剖析了 JavaScript 的单线程模型、事件循环(Event Loop)机制。通过辨析宏任务与微任务的区别与优先级,帮助你彻底理解 JS 异步执行的底层原理,看懂页面卡顿的真相。
我常常在各种场合被问到类似下面代码的输出顺序。
console.log("start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
Promise.resolve().then(function () {
console.log("promise");
});
console.log("end");
如果你能毫不犹豫地答出 start, end, promise, setTimeout
,并解释其原因,那么你对 JS 的异步机制已经有了不错的理解。如果你还有一丝困惑,希望本文能帮助你彻底梳理清楚。
这个问题的背后,是整个 JavaScript 的运行模型(runtime model),也就是我们常说的“事件循环”(Event Loop)。理解它,是前端工程师进阶的必经之路。
为什么 JavaScript 是单线程?
首先,我们必须记住一个基本事实:JavaScript 语言是一门单线程语言。
这意味着,在任何一个时刻,JS 引擎只能执行一段代码。为什么这么设计?这与它的初衷有关。JavaScript 最初是为浏览器设计的,用于处理用户的交互,比如鼠标点击、键盘输入,以及操作 DOM。
试想一下,如果 JavaScript 是多线程的,会发生什么?一个线程要在一个 DOM 节点上增加内容,另一个线程要删除这个节点。那么浏览器应该听谁的?这会带来极其复杂的同步问题。为了避免这种复杂性,JavaScript 从诞生起就选择了单线程。
这既是它的优点,也是它的缺点。优点是简单,没有多线程的竞态、死锁等问题。缺点是,如果一个任务耗时很长,整个程序就会被“卡住”,无法响应其他操作。
浏览器:一个多进程的“操作系统”
“JS 是单线程的”这个说法其实不完全准确。准确来说,执行 JavaScript 代码的那个主线程是单线程的。
现代浏览器(以 Chrome 为例)本身是一个非常复杂的程序,它采用了多进程架构来保证稳定性和安全性。你可以打开 Chrome 的任务管理器(“更多工具” > “任务管理器”)看看,通常会看到好几个进程:
- 浏览器进程(Browser Process):负责浏览器界面的“外壳”,比如地址栏、书签、前进后退按钮,以及协调其他进程。
- 渲染进程(Renderer Process):核心部分,负责将 HTML、CSS 和 JavaScript 转换成用户可以看到的网页。我们写的 JS 代码,主要就在这个进程的主线程(Main Thread)上运行。每个标签页通常会有一个独立的渲染进程。
- 网络进程(Network Process):负责处理网络请求,比如
fetch
。- GPU 进程(GPU Process):负责处理 GPU 相关的任务,加速 3D 绘图和页面渲染。
这种设计的好处是隔离。一个标签页(渲染进程)崩溃了,不会影响到整个浏览器。
任务队列(Task Queue)和事件循环(Event Loop)
我们回到渲染进程的主线程。这个线程非常繁忙,它要做的事情包括:
- 执行 JavaScript 代码
- 渲染页面布局(Layout)
- 绘制页面(Paint)
- 响应用户交互(Click, Scroll)
如果所有任务都排队等着,一个耗时长的 JS 计算就会阻塞页面渲染和用户响应,这就是“假死”现象。
// 一个会让页面卡住的例子
document.getElementById("myButton").addEventListener("click", function () {
// 假装这是一个非常耗时的计算
const start = Date.now();
while (Date.now() - start < 5000) {
// 这5秒内,页面完全无法响应
}
console.log("计算完成!");
});
为了解决这个问题,浏览器引入了异步(asynchronous)执行模型。当遇到一些耗时操作(比如网络请求、定时器)时,主线程不会傻等,而是把这些任务“外包”给浏览器的其他线程(比如网络线程、定时器线程)。
这些“外包”任务完成后,会把一个“回调函数”(callback)放进一个叫做**任务队列(Task Queue)**的地方。主线程则继续执行自己手头的同步代码。
等到主线程的同步代码全部执行完毕,它就会去任务队列里看看,有没有需要执行的回调函数。如果有,就取出一个来执行。这个“主线程不断从任务队列里读取并执行任务”的过程,就叫做事件循环(Event Loop)。
这个模型可以用一张经典的图来表示:
微任务(Microtask)和宏任务(Macrotask)
事情还没完。任务队列其实不止一个。根据 WHATWG 规范,任务被分为两种类型:
-
宏任务(Macrotask,规范中称为 Task)
-
setTimeout
,setInterval
-
script
(整体代码块) - I/O 操作, UI 渲染
- 用户交互事件(如
click
,scroll
)
-
-
微任务(Microtask)
-
Promise.then()
,Promise.catch()
,Promise.finally()
queueMicrotask()
MutationObserver
-
事件循环的规则是,优先级更高的是微任务。主线程在执行完一个宏任务后,并不是立刻去执行下一个宏任务,而是会检查微任务队列。
完整的事件循环流程如下:
- 从宏任务队列中取出一个任务(通常是
script
脚本本身)并执行。 - 执行完毕后,检查微任务队列。
- 循环执行微任务队列中的所有任务,直到队列清空。
- 执行浏览器 UI 渲染(这一步不一定每次都会发生)。
- 回到第一步,从宏任务队列中取出下一个任务。
这个“执行一个宏任务 -> 清空所有微任务 -> 再取下一个宏任务”的循环,是理解所有异步执行顺序的关键。
回到最初的问题
现在,我们用这个模型来分析开头的代码:
console.log("start"); // 1
setTimeout(function () {
// 4
console.log("setTimeout");
}, 0);
Promise.resolve().then(function () {
// 3
console.log("promise");
});
console.log("end"); // 2
-
第一轮宏任务(script 脚本)开始执行。
- 遇到
console.log('start')
,直接执行。输出start
。 - 遇到
setTimeout
,它是一个宏任务。浏览器定时器线程接管,0ms 后将其回调函数推入宏任务队列。 - 遇到
Promise.resolve().then()
,.then()
的回调是一个微任务。它被推入微任务队列。 - 遇到
console.log('end')
,直接执行。输出end
。
- 遇到
-
第一个宏任务(script)执行完毕。
- 现在,事件循环会检查微任务队列。发现里面有一个任务(打印
promise
)。 - 取出并执行该微任务。输出
promise
。 - 微任务队列现在空了。
- 现在,事件循环会检查微任务队列。发现里面有一个任务(打印
-
开始下一轮宏任务。
- 事件循环检查宏任务队列,发现
setTimeout
的回调函数在那里。 - 取出并执行该宏任务。输出
setTimeout
。
- 事件循环检查宏任务队列,发现
至此,所有代码执行完毕。最终输出 start, end, promise, setTimeout
。
应用与思考
理解了事件循环,很多问题就迎刃而解了。
-
setTimeout(fn, 0)
为什么不是立即执行? 因为它只是把fn
尽快地推入宏任务队列,但必须等到当前主线程的同步代码和所有微任务都执行完之后,才有机会被执行。 -
页面为什么会卡顿? 通常是因为一个宏任务(比如一段 JS 计算或一个事件回调)执行时间过长,导致主线程无法脱身去处理其他宏任务(如 UI 渲染、用户点击)。
-
如何处理耗时计算? 对于真正 CPU 密集的计算,应该使用 Web Worker。它允许你在一个完全独立的后台线程中运行脚本,不会阻塞主线程。
参考链接
- MDN - Concurrency model and the event loop
- Jake Archibald: In The Loop - JSConf.Asia 2018(非常经典的视频讲解)
希望读完本文,你对 JavaScript 的运行机制有了更深入的理解。
(完)