阅读视图
Lodash源码阅读-initCloneByTag
Lodash源码阅读-cloneSymbol
Lodash源码阅读-cloneRegExp
大文件上传之切片上传以及开发全流程之前端篇
一、文件选择交互
1. 多文件选择
<input type="file" multiple>
-
核心功能:支持同时选择多个文件
-
技术实现:
- 通过
$0.files
获取伪数组 file对象 - 遍历文件对象:
Array.from(files).forEach(file => ...)
- 通过
-
注意事项:
- 建议添加文件类型过滤(如
accept="image/*,application/pdf"
)防止用户上传不必要或者不被允许的文件类型 - 文件大小限制需在前端 / 后端同时验证
-
两端验证的作用
-
前端验证:主要是为了提升用户体验。在用户选择文件时,前端就可以快速检测文件大小是否超出限制,并及时给出提示,避免用户等待长时间上传后才被告知文件过大。
-
后端验证:前端验证可被绕过,所以后端验证是保障系统安全和稳定的最后一道防线。后端可以防止恶意用户绕过前端限制上传超大文件,避免对服务器资源造成过度占用。
-
- 建议添加文件类型过滤(如
2. 文件夹选择
<!-- chrome 火狐 opera 浏览器 -->
<input type="file" webkitdirectory mozdirectory odirectory>
-
核心功能:支持选择整个文件夹(需 Chrome 火狐 opera浏览器)
-
兼容性:
- 仅 Chrome/Firefox/Opera 支持
二、拖拽交互
1. 拖拽文件 / 文件夹
-
技术实现:
-
通过 每个
DataTransferItem 实例 对象上的 webkitGetAsEntry()
获取文件系统入口 -
递归遍历文件夹:
container.ondragenter = (e) => e.preventDefault();
container.ondragover = (e) => e.preventDefault();
container.ondrop = (e) => {
e.preventDefault();
const files = e.dataTransfer.items;
Array.from(files).forEach(file => {
const entry = file.webkitGetAsEntry();
if (entry.isDirectory) traverseDirectory(entry);
else if (entry.isFile) entry.file(processFile);
});
};
function traverseDirectory(entry) {
const reader = entry.createReader();
reader.readEntries(entries => {
entries.forEach(entry => {
if (entry.isDirectory) traverseDirectory(entry);
else if (entry.isFile) entry.file(file => processFile(file));
});
});
}
三、切片上传核心原理与代码结构
代码解析实现大文件切片上传的核心逻辑,重点包括:
- 文件切片算法
- 并发上传控制
- 基于 FormData 的 HTTP 传输
<script>
// 核心变量声明
let file = {};
let chunkList = [];
const MAX_CHUNK_SIZE = 2 * 1024 * 1024; // 2MB切片大小
</script>
(一)、文件切片处理
- 切片生成逻辑
function createChunk(f, size = MAX_CHUNK_SIZE) {
let cur = 0;
while (cur < f.size) {
chunkList.push({
file: f.slice(cur, cur + size),
});
cur += size;
}
}
-
使用 Blob.slice () 方法进行二进制切片
-
Blob
(Binary Large Object)即二进制大对象,它是 JavaScript 中用于表示不可变的、原始数据的对象。Blob
对象可以包含多种类型的数据,例如文本、图像、音频、视频等,它通常用于处理二进制数据,比如文件上传、下载、处理图像等场景。 -
Blob
对象有以下特点: -
不可变性:一旦创建,
Blob
对象的内容就不能被修改。 -
可以切片:
Blob
对象提供了slice()
方法,允许你从一个Blob
对象中提取出一部分数据,形成一个新的Blob
对象。 -
可以用于文件操作:在文件上传时,
File
对象实际上是Blob
对象的一个子类,因此File
对象继承了Blob
的所有属性和方法。 -
f
是一个File
对象(因为File
是Blob
的子类,所以可以调用Blob
的方法),f.slice(cur, cur + size)
就是在调用Blob
的slice()
方法来对文件进行切片操作。
-
-
循环处理直至整个文件分割完毕
-
每个切片包含原始文件引用和分块索引
- 切片元数据封装
const uploadList = chunkList.map(({file:blobfile},index) => ({
file: blobfile,
chunkName: `${file.name}-${index}`,
fileName: file.name,
index
}));
(二)、并发上传控制
- 请求构建
const requsetList = list.map(({file,fileName,index,chunkName}) => {
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', fileName);
formData.append('index', index);
formData.append('chunkName', chunkName);
return axios({
method: 'post',
url: 'http://localhost:3000/upload',
data: formData,
});
});
- 并发执行
await Promise.all(requsetList)
console.log('所有切片上传完成');
- 使用 Promise.all 实现并发控制
- 建议生产环境增加并发数限制(如 5 个同时上传)
- 需配合后端实现切片合并逻辑
如果您觉得这篇文章对您有帮助,欢迎点赞和收藏,大家的支持是我继续创作优质内容的动力🌹🌹🌹也希望您能在😉😉😉我的主页 😉😉😉找到更多对您有帮助的内容。
- 致敬每一位赶路人
简单回顾下pc端与mobile端的适配问题
一、响应式布局方案(单页面适配)
核心原理:通过CSS媒体查询动态调整布局,配合vw/rem单位实现跨设备适配
完整示例:
/* 移动端基准尺寸 */
:root {
--base-font: calc(100vw / 37.5); /* 以375px设计稿为基准 */
}
@media (max-width: 768px) {
.container {
padding: 0.2rem;
font-size: calc(var(--base-font) * 14);
}
}
@media (min-width: 769px) {
.container {
max-width: 1200px;
margin: 0 auto;
font-size: calc(var(--base-font) * 16);
}
}
关键步骤:
- 设置视口标签:
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- 使用
clamp()
函数实现弹性布局:width: clamp(300px, 80%, 1200px)
- 图片使用
srcset
属性适配不同分辨率1
二、两套独立页面方案
核心原理:通过UA检测动态加载PC/Mobile页面,适合复杂业务场景
Vue项目实现:
// 路由配置
const routes = [
{ path: '/', component: () => import('./PC/Home.vue') },
{ path: '/mobile', component: () => import('./Mobile/Home.vue') }
]
// 设备检测中间件
router.beforeEach((to, from, next) => {
const isMobile = /mobile|android|iphone/i.test(navigator.userAgent)
if(isMobile && !to.path.includes('/mobile')) {
next('/mobile')
} else {
next()
}
})
关键配置:
- 独立维护
/pc
和/mobile
目录结构 - 后端配合Nginx进行UA识别转发
- 共用API接口,差异化样式处理
三、动态REM适配方案
核心技术:通过JS动态计算根字体大小,结合PostCSS自动转换单位
完整配置:
- 安装依赖:
npm install amfe-flexible postcss-pxtorem -D
- 修改
flexible.js
源码:
// 注释掉540px限制
if (width / dpr > 540) {
width = width * dpr // 原代码为 width = 540 * dpr
}
- 创建
postcss.config.js
:
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 75, // 750设计稿时设为75
propList: ['*', '!border'],
selectorBlackList: ['el-'] // 排除element组件
}
}
}
四、Viewport动态缩放方案
核心原理:通过Viewport单位实现等比缩放,适合高保真设计需求
Vite项目配置:
// vite.config.js
import px2viewport from 'postcss-px-to-viewport'
export default {
css: {
postcss: {
plugins: [
px2viewport({
viewportWidth: 1920, // PC基准尺寸
viewportHeight: 1080,
unitPrecision: 3,
viewportUnit: 'vw',
selectorBlackList: ['.ignore'],
minPixelValue: 1
})
]
}
}
}
使用示例:
/* 设计稿1920px中标注200px的元素 */
.box {
width: calc(200 / 19.2 * 1vw); /* 200/1920*100 = 10.416vw */
height: calc(100vh - 10vw);
}
方案对比选择建议:
方案类型 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
响应式布局 | 内容型网站 | 维护成本低 | 复杂交互适配困难 |
独立页面 | 大型管理系统 | 体验最佳 | 双倍开发量 |
REM适配 | 移动端为主 | 兼容性好 | PC需源码改造 |
Viewport | 全终端项目 | 缩放精准 | 需PostCSS配合 |
刷刷题49(react中几个常见的性能优化问题)
一、React Server Components中如何防止客户端组件暴露敏感信息?
- 通过构建工具隔离环境变量(Webpack/Vite/Rspack)
- 使用
.server.js
后缀强制服务端组件标识 - 构建时静态分析依赖关系树
Webpack配置方案:
// next.config.js
module.exports = {
webpack(config) {
config.plugins.push(new webpack.DefinePlugin({
'process.env.API_KEY': JSON.stringify(process.env.SERVER_SIDE_API_KEY)
}));
config.externals = [...config.externals, 'aws-sdk']; // 排除客户端不需要的模块
return config;
}
}
Vite配置方案:
// vite.config.js
export default defineConfig({
define: {
'import.meta.env.SERVER_KEY': JSON.stringify(process.env.SERVER_KEY)
},
build: {
rollupOptions: {
external: ['node-fetch'] // 排除客户端打包
}
}
})
关键差异:
- Webpack通过
externals
字段显式排除,Vite使用Rollup的external
配置 - 环境变量注入方式不同,Vite使用
import.meta.env
,Webpack使用process.env
- 服务端组件检测机制不同,Next.js通过文件约定,Vite需手动配置插件
二、使用useTransition和Suspense实现平滑页面过渡
实现方案:
function App() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleNavigation = (path) => {
startTransition(() => {
router.push(path);
});
};
return (
<div className={isPending ? 'navigation-pending' : ''}>
<nav>
<button onClick={() => handleNavigation('/')}>Home</button>
<button onClick={() => handleNavigation('/dashboard')}>Dashboard</button>
</nav>
<Suspense fallback={<PageSkeleton />}>
<main className={`page-transition ${isPending ? 'fade-out' : 'fade-in'}`}>
<Outlet /> {/* React Router的出口组件 */}
</main>
</Suspense>
</div>
);
}
优化技巧:
- 动画协调:通过CSS变量控制过渡时长
:root {
--transition-duration: 300ms;
}
.page-transition {
transition: opacity var(--transition-duration) ease;
}
.fade-out {
opacity: 0.5;
pointer-events: none;
}
.fade-in {
opacity: 1;
}
- 竞态处理:使用AbortController取消旧请求
const abortControllerRef = useRef();
const loadData = async (url) => {
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const res = await fetch(url, {
signal: abortControllerRef.current.signal
});
// ...处理数据
}
三、大型应用Context性能优化方案
分层解决方案:
// 一级Context:用户认证信息(高频变更)
const AuthContext = createContext();
export const useAuth = () => useContext(AuthContext);
// 二级Context:UI主题配置(低频变更)
const ThemeContext = createContext();
export const useTheme = () => useContext(ThemeContext);
// 三级Context:本地化配置(极少变更)
const LocaleContext = createContext();
export const useLocale = () => useContext(LocaleContext);
function AppProvider({ children }) {
return (
<AuthProvider>
<ThemeProvider>
<LocaleProvider>
{children}
</LocaleProvider>
</ThemeProvider>
</AuthProvider>
);
}
优化策略对比:
方案 | 适用场景 | 实现复杂度 | 性能增益 |
---|---|---|---|
Context分层 | 不同类型状态隔离 | 低 | 40%-60% |
use-context-selector | 精确订阅特定状态字段 | 中 | 70%-80% |
Zustand | 复杂跨组件状态管理 | 高 | 90%+ |
代码示例(使用use-context-selector) :
import { createContext, useContextSelector } from 'use-context-selector';
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
// 组件精确订阅
function Profile() {
const username = useContextSelector(UserContext,
(ctx) => ctx.user?.name
);
return <div>{username}</div>;
}
四、支持撤销/重做的自定义Hook
核心实现:
import { useCallback, useRef } from 'react';
type HistoryAction<T> = {
past: T[];
present: T;
future: T[];
};
export function useHistory<T>(initialState: T) {
const history = useRef<HistoryAction<T>>({
past: [],
present: initialState,
future: []
});
const canUndo = useCallback(() => history.current.past.length > 0, []);
const canRedo = useCallback(() => history.current.future.length > 0, []);
const undo = useCallback(() => {
if (!canUndo()) return;
const { past, present, future } = history.current;
const newPresent = past[past.length - 1];
const newPast = past.slice(0, -1);
history.current = {
past: newPast,
present: newPresent,
future: [present, ...future]
};
return newPresent;
}, [canUndo]);
const redo = useCallback(() => {
if (!canRedo()) return;
const { past, present, future } = history.current;
const newPresent = future;
const newFuture = future.slice(1);
history.current = {
past: [...past, present],
present: newPresent,
future: newFuture
};
return newPresent;
}, [canRedo]);
const update = useCallback((newState: T) => {
history.current = {
past: [...history.current.past, history.current.present],
present: newState,
future: []
};
}, []);
return {
state: history.current.present,
undo,
redo,
canUndo,
canRedo,
update
};
}
扩展(防抖记录) :
function useDebouncedHistory<T>(initialState: T, delay = 500) {
const { update, ...rest } = useHistory<T>(initialState);
const debouncedUpdate = useDebounce(update, delay);
return {
...rest,
update: debouncedUpdate
};
}
// 使用示例
const { state, update } = useDebouncedHistory(0);
<input
type="number"
value={state}
onChange={(e) => update(Number(e.target.value))}
/>
五、React Query与SWR联合数据管理
混合策略实现:
import { useQuery } from 'react-query';
import useSWR from 'swr';
type HybridFetcher<T> = {
queryKey: string;
swrKey: string;
fetcher: () => Promise<T>;
};
export function useHybridFetch<T>({
queryKey,
swrKey,
fetcher
}: HybridFetcher<T>) {
// React Query主请求
const query = useQuery(queryKey, fetcher, {
staleTime: 60_000,
cacheTime: 300_000
});
// SWR后台刷新
const swr = useSWR(swrKey, fetcher, {
refreshInterval: 120_000,
revalidateOnFocus: true,
onSuccess: (data) => {
queryClient.setQueryData(queryKey, data);
}
});
// 错误合并
const error = query.error || swr.error;
return {
data: query.data,
isLoading: query.isLoading,
isRefreshing: swr.isValidating,
error
};
}
// 使用示例
const { data, isLoading } = useHybridFetch({
queryKey: 'user-data',
swrKey: '/api/user',
fetcher: () => fetchUserData()
});
数据流对比:
textCopy Code
┌─────────────┐
│ React Query │
└──────┬──────┘
│
┌─────────────────▼─────────────────┐
│ Query Cache │
└─────────────────┬─────────────────┘
│
┌─────────────────▼─────────────────┐
│ SWR Background Refresh │
└───────────────────────────────────┘
│
┌─────────────────▼─────────────────┐
│ UI Components │
└───────────────────────────────────┘
混合方案优势:
- React Query负责主数据流和缓存策略
- SWR处理后台静默刷新和焦点重验证
- 共享缓存避免重复请求
- 错误处理优先级:主请求错误 > 刷新错误
深入浅出:JavaScript ArrayBuffer 的使用与应用
什么是 ArrayBuffer?
ArrayBuffer
是 JavaScript 中用于表示原始二进制数据缓冲区的对象。 它是一个固定长度的内存区域,可以用来存储各种类型的数据。 与 JavaScript 数组不同,ArrayBuffer
不能直接读取或写入数据。 它只是一个字节容器。
ArrayBuffer 的特点:
- 固定长度: 创建后长度不可变。
- 原始二进制数据: 存储的是字节数据,没有特定的数据类型。
-
不可直接访问: 需要通过
TypedArray
或DataView
来访问和操作数据。
如何创建 ArrayBuffer?
// 创建一个 16 字节的 ArrayBuffer
const buffer = new ArrayBuffer(16);
console.log(buffer.byteLength); // 输出: 16
TypedArray:赋予 ArrayBuffer 数据类型
TypedArray
是一组用于操作 ArrayBuffer
的类型化数组。 它们允许你以特定的数据类型(例如:整数、浮点数)来读取和写入 ArrayBuffer
中的数据。
常见的 TypedArray
类型包括:
-
Int8Array
: 8 位有符号整数 -
Uint8Array
: 8 位无符号整数 -
Int16Array
: 16 位有符号整数 -
Uint16Array
: 16 位无符号整数 -
Int32Array
: 32 位有符号整数 -
Uint32Array
: 32 位无符号整数 -
Float32Array
: 32 位浮点数 -
Float64Array
: 64 位浮点数
示例:使用 Uint8Array 操作 ArrayBuffer
// 创建一个 8 字节的 ArrayBuffer
const buffer = new ArrayBuffer(8);
// 创建一个 Uint8Array 视图,指向 ArrayBuffer
const uint8Array = new Uint8Array(buffer);
// 设置 ArrayBuffer 中的值
uint8Array[0] = 10;
uint8Array[1] = 20;
uint8Array[2] = 30;
console.log(uint8Array); // 输出: Uint8Array(8) [10, 20, 30, 0, 0, 0, 0, 0]
console.log(buffer); // 输出: ArrayBuffer(8) { byteLength: 8 }
DataView:更灵活的数据访问
DataView
提供了更灵活的方式来读取和写入 ArrayBuffer
中的数据。 它可以让你以任意字节偏移量和数据类型来访问数据,而无需像 TypedArray
那样必须从缓冲区的开头开始。
示例:使用 DataView 操作 ArrayBuffer
// 创建一个 8 字节的 ArrayBuffer
const buffer = new ArrayBuffer(8);
// 创建一个 DataView 视图,指向 ArrayBuffer
const dataView = new DataView(buffer);
// 设置 ArrayBuffer 中的值 (以不同的数据类型)
dataView.setInt8(0, 10); // 从偏移量 0 开始,写入一个 8 位有符号整数
dataView.setUint16(1, 256, true); // 从偏移量 1 开始,写入一个 16 位无符号整数 (小端字节序)
dataView.setFloat32(3, 3.14, false); // 从偏移量 3 开始,写入一个 32 位浮点数 (大端字节序)
console.log(dataView.getInt8(0)); // 输出: 10
console.log(dataView.getUint16(1, true)); // 输出: 256
console.log(dataView.getFloat32(3, false)); // 输出: 3.140000104904175
希望这篇文章能够帮助你更好地理解和使用 ArrayBuffer
! 别忘了点赞、评论和分享哦!
React-router v7 第二章(路由模式)
路由模式
在React RouterV7 中,是拥有不同的路由模式,路由模式的选择将直接影响你的整个项目。React Router 提供了四种核心路由创建函数:
createBrowserRouter
、createHashRouter
、createMemoryRouter
和 createStaticRouter
1. createBrowserRouter(推荐)
核心特点:
- 使用HTML5的history API (pushState, replaceState, popState)
- 浏览器URL比较纯净 (/search, /about, /user/123)
- 需要服务器端支持(nginx, apache,等)否则会刷新404
使用场景:
- 大多数现代浏览器环境
- 需要服务器端支持
- 需要URL美观
2. createHashRouter
核心特点:
- 使用URL的hash部分(#/search, #/about, #/user/123)
- 不需要服务器端支持
- 刷新页面不会丢失
使用场景:
- 静态站点托管例如(github pages, netlify, vercel)
- 不需要服务器端支持
3. createMemoryRouter
核心特点:
- 使用内存中的路由表
- 刷新页面会丢失状态
- 切换页面路由不显示URL
使用场景:
- 非浏览器环境例如(React Native, Electron)
- 单元测试或者组件测试(Jest, Vitest)
4. createStaticRouter
核心特点:
- 专为服务端渲染(SSR)设计
- 在服务器端匹配请求路径,生成静态 HTML
- 需与客户端路由器(如 createBrowserRouter)配合使用
使用场景:
- 服务端渲染应用(如 Next.js 的兼容方案)
- 需要SEO优化的页面
解决刷新404问题
当使用createBrowserRouter
时,如果刷新页面会丢失状态,这是因为浏览器默认会去请求服务器上的资源,如果服务器上没有资源,就会返回404。
要解决这个问题就需要在服务器配置一个回退路由,当请求的资源不存在时,返回index.html
。
- Nginx(推荐)
下载地址:Nginx
location / {
try_files $uri $uri/ /index.html;
}
- Apache
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
- Vercel
{
"rewrites": [{ "source": "/:path*", "destination": "/index.html" }]
}
- Nodejs
const http = require('http')
const fs = require('fs')
const httpPort = 80
http
.createServer((req, res) => {
fs.readFile('index.html', 'utf-8', (err, content) => {
if (err) {
console.log('We cannot open "index.html" file.')
}
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(content)
})
})
.listen(httpPort, () => {
console.log('Server listening on: http://localhost:%s', httpPort)
})
前端开发中常见的 SEO 优化
一、SEO概述
1. 什么是SEO?
SEO(Search Engine Optimization)即搜索引擎优化,是通过技术手段和内容策略提升网站在搜索引擎自然搜索结果中排名的过程。其核心目标是让网站结构、内容和用户体验更符合搜索引擎的索引规则,从而获取更多免费流量。
2. 为什么前端需要关注SEO?
-
流量入口控制:超过70%的用户流量通过搜索引擎进入网站
-
技术实现基础:前端代码是搜索引擎爬虫直接解析的内容载体
-
用户体验关联:页面加载速度、移动适配等SEO指标直接影响用户留存率
-
商业价值提升:优化后的网站可降低获客成本,提升转化率(研究表明,搜索结果首位的点击率是第二位的2.5倍)
二、开发中应注意哪些
1. TDK元数据规范
TDK(Title、Description、Keywords)需与页面内容高度关联,且动态适配多端场景。并且这部分标准是不断变化的,如 Google 算法更新后,标题长度超过 60 字符的页面排名下降了 20% 左右(仅参考数据,实际上不同搜索引擎对标题显示的截断规则和排名权重存在一定差异,且排名受多种因素影响)。
(1) Title标签优化
<!-- 错误示例 -->
<title>首页 | 公司官网</title>
<!-- 正确示例 -->
<title>智能家居解决方案_智能门锁_全屋智能系统-XX科技</title>
规则:
-
长度控制在50-65字符(中文约18-25字)
-
主关键词前置,层级用英文短横线分隔
-
移动端优先显示核心信息
(2) Keywords设置
目前在 Google 等搜索引擎中,meta keywords 已经作用已经很小,更多只对部分搜索引擎有帮助。
<meta name="keywords" content="智能家居,智能门锁,全屋智能,智能家居系统">
规则:
-
关键词不超过5个,用英文逗号分隔
-
避免堆砌无关关键词(如"优惠,促销"等营销词)
-
栏目页采用"栏目名+核心长尾词"组合
(3) Description优化
<meta name="description" content="XX科技提供专业智能家居解决方案,涵盖智能门锁、全屋智能控制系统等产品,已服务1000+家庭用户,免费获取智能家居设计方案。">
规则:
-
长度控制在150字符内
-
包含主关键词和行动号召语
-
避免重复Title内容
这部分提到的关键词,也是非常重要的一部分内容,包括关键词的筛选、数据分析、效果验证等,不同的网站也会有不同的处理,如小网站因为低流量,就可以把长尾关键词的优化放到低优先级。由于关键词研究大量工作并不在前端,就不过多展开了。
2. HTML语义化标签
(1) 标题及结构层级规范
<h1>智能家居解决方案</h1>
<h2>核心产品</h2>
<h3>智能门锁系列</h3>
<h3>环境控制系统</h3>
<h2>成功案例</h2>
原则:
-
每个页面H1标签谨慎滥用
-
层级关系严格递进(H1→H2→H3)
-
避免跳过层级(如H1直接接H3)
-
还可用
<section>
、<article>
等语义标签的嵌套,提升内容结构化
(2) 图片优化
<!-- 基础优化 -->
<img src="smart-lock.jpg" alt="XX智能门锁V3-Pro版"
loading="lazy" width="800" height="600">
<!-- 高级优化(WebP格式+响应式) -->
<picture>
<source srcset="smart-lock.webp" type="image/webp">
<img src="smart-lock.jpg" alt="支持指纹识别的智能门锁">
</picture>
规范:
-
必须添加alt属性(描述图片功能而非外观)
-
使用WebP等新型图像格式
-
添加width/height属性防止布局偏移(CLS优化)
3. 移动优先优化
(1) Viewport设置
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=5.0, minimum-scale=1.0, viewport-fit=cover">
(2) 响应式布局示例(CSS)
/* 移动优先写法 */
.product-card {
padding: 1rem;
margin-bottom: 2rem;
}
@media (min-width: 768px) {
.product-card {
display: grid;
grid-template-columns: 300px 1fr;
}
}
核心指标:
-
LCP(最大内容渲染)<2.5秒
-
FID(首次输入延迟)<100ms(Chrome 团队已提升 INP 作为 Core Web Vitals 中的一项新的响应性指标,并从 2024 年 3 月起取代 FID)
-
CLS(累积布局偏移)<0.1
4. 技术优化方案
(1) 预加载关键资源
<link rel="preload" href="critical.css" as="style">
<link rel="preload" href="main.js" as="script">
(2) 结构化数据标记(Schema)
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "智能门锁V3-Pro",
"image": "https://example.com/smart-lock.jpg",
"description": "支持指纹/密码/NFC三种开锁方式...",
"brand": {
"@type": "Brand",
"name": "XX科技"
},
"offers": {
"@type": "Offer",
"priceCurrency": "CNY",
"price": "1999"
}
}
</script>
支持类型:
-
产品页:Product
-
文章页:Article
-
面包屑导航:BreadcrumbList
5. 高级优化策略
(1) 动态内容SEO处理(SPA)
// Vue.js路由配置示例
const router = new VueRouter({
mode: 'history',
routes: [...],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { x: 0, y: 0 }
}
}
})
// 添加Prerender预渲染
npm install prerender-spa-plugin --save-dev
(2) AMP加速页面
AMP使用率在下降,优先采用Web Vitals优化,AMP仅用于特定媒体场景
三、工具与监控
1. 推荐工具
-
代码检测 | Lighthouse v12.0 | SEO评分+性能分析
-
关键词分析 | SEMrush/Ahrefs | 长尾词挖掘
-
结构化数据验证 | Google Structured Data | Schema标记检测
-
日志分析 | Screaming Frog SEO Spider | 爬虫模拟与死链检测 ,分析标签完整性
2. 监控代码示例
<!-- 百度统计 -->
<script>
var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?xxxxxxxx";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
<!-- Google Search Console -->
<meta name="google-site-verification" content="xxxxx">
四、最新演进
1. SEO趋势
-
AI生成内容检测:比如需添加
<meta name="generator" content="human-writer">
声明(目前还并非一个公认或普遍执行的规范) -
AI优化SEO: 引入AI工具,如智能内容生成、动态关键词优化等,提升SEO的精准性与效率
-
视频SEO优化:使用
<video>
标签的structured data标记 -
Core Web Vitals 2.0:新增INP(Interaction to Next Paint)指标(衡量用户交互(如点击或按键)后到下次在页面上看到视觉更新之间经过的时间)
2. 避坑行为清单
-
禁止使用
display:none
隐藏关键词 -
禁止滥用JSON-LD重复标记相同内容
-
禁止未声明AI生成内容(违反Google 准则)
-
禁用iframe框架加载核心内容,防止爬虫抓取失败
-
定期审核robots.txt规则,避免误屏蔽重要目录
五、总结
通过遵循语义化HTML编码规范、优化核心元数据、实施移动优先策略以及利用结构化数据等前沿技术,前端开发者可显著提升网站的搜索引擎友好度。可以结合工具链持续监控优化效果,关注每年核心算法更新(如谷歌和百度的重要算法迭代),保证SEO策略的稳定有效。
推荐阅读
- 《SEO 实战密码》
- Google《SEO Starter Guide》
ElementPlus按需加载 + 配置中文避坑(干掉1MB冗余代码)
最近优化项目性能时发现,之前为了省事直接全量引入了ElementPlus(明明只用了个日历组件!),首页直接白嫖了1MB资源包。必须按需加载!!
成果如下:资源体积-1MB / FCP-0.3s,记录关键步骤
解法核心:
用两个插件(unplugin-auto-import
负责自动导入API,unplugin-vue-components
负责自动注册组件),只打包你用到的组件和样式。
1. 安装全家桶
pnpm add element-plus # 库本体
pnpm add -D unplugin-auto-import unplugin-vue-components # 核心工具:自动导API+组件
2. Vite配置抄作业(10 秒搞定)
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
AutoImport({ resolvers: [ElementPlusResolver()] }), // 自动导API
Components({ resolvers: [ElementPlusResolver()] }) // 自动导组件
]
})
3. 中文配置(如果需要)
由于 Elment-Plus 默认是英文版的,我们还需要给他加个中文配置
只需要用ElConfigProvider来给他进行个笼罩,配置 locale 属性即可。
// App.vue
<template>
<el-config-provider :locale="zhCn"> <!-- 这里塞中文包 -->
<router-view />
</el-config-provider>
</template>
<script setup>
import { RouterView } from 'vue-router'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
📦 Rollup
概述
1. 是什么?
Rollup >> 是一个用于 JavaScript 的模块打包工具,旨在通过优化打包过程,提高代码的执行效率。与 Webpack 等工具相比,Rollup 特别适合用于打包工具库、组件库以及单一用途的模块,特别是在支持 ES6 模块时,Rollup 可以做到更高效的 Tree Shaking 和更小的输出体积。
2. 有何优势?
Rollup 核心优势体现在:
-
Tree Shaking 优化
Rollup 通过 静态分析 ES6 模块的导入导出关系,自动剔除未使用的代码(如未调用的函数或变量),生成更精简的打包文件。
-
多格式输出支持
支持输出
ESM
、CommonJS
、UMD
等模块格式,适合库开发和跨环境兼容。 -
插件生态
提供丰富的插件
-
专注库开发的优化设
- 原生支持代码分割(Code Splitting)和按需加载。
- 通过插件系统(如 @rollup/plugin-node-resolve)实现灵活的依赖解析。
- 构建速度比 Webpack 快 2-3 倍,特别适合 Monorepo 项目。
3. Rollup vs. Webpack
Rollup 和 Webpack 的区别,简单来说:
-
Tree Shaking
- Rollup 采用基于 ES Modules 的 静态分析(即在编译时就确定模块的依赖关系),清除更测底。
- Webpack 采用 动态分析(即在运行时根据模块加载的情况来确定依赖关系),支持动态等高级特性,需 配合 package.json 的 sideEffects 标记优化。
-
输出体积
- Rollup 输出体积更小
- Webpack 输出体积较大(含运行时代码)
-
适用场景
- Rollup 适合库开发
- Webpack 适合需要复杂功能(如代码分割、热更新)的应用场景
-
生态扩展性
- Webpack 拥有更完善的插件体系
- Rollup 的插件系统更轻量但功能完整
一句话概括:
-
Rollup = 小而精,专为库优化
-
Webpack = 大而全,适合复杂应用
技术选型建议:
对于库开发者,建议采用 Rollup 作为主要构建工具;对于应用开发者,Webpack 仍是更全面的选择。在现代化构建方案中,Vite 等工具已经实现了两者的优势互补。
提示:
配置文件
rollup.config.js
import { defineConfig } from 'rollup';
export default defineConfig({
input, // 入口配置(必需)
output, // 输出配置(必需)
plugins, // 插件系统
external, // 外部依赖
watch, // 监听模式
cache // 构建缓存
});
提示:有关每个选项的详细信息,请参阅 选项大全 >>
常用Plugins
-
rollup:核心库
-
cross-env:跨环境设置环境变量
-
@rollup/plugin-commonjs:将CommonJS转为ES模块
-
@rollup/plugin-json:支持直接导入JSON文件
-
@rollup/plugin-node-resolve:解析第三方模块依赖 —— (!) Unresolved dependencies
-
@rollup/plugin-eslint:代码质量检查
-
rollup-plugin-filesize:显示打包体积
-
rollup-plugin-delete:构建前清理目录
-
rollup-plugin-progress:显示构建进度条
-
rollup-plugin-serve:开发服务器
-
@rollup/plugin-alias:路径别名
-
@rollup/plugin-replace:在 Rollup 打包过程中替换代码中的字符串或表达式(如环境变量、配置参数等)
-
rollup-plugin-delete:每次打包之前清除输出目录(del)
-
rollup-plugin-generate-package-json:生成 package.json 文件
-
rollup-plugin-visualizer:可视化分析包内容
-
@rollup/plugin-babel:用于使用 Babel 进行转译
-
@babel/core:Babel 核心库
-
@babel/preset-env:Babel 预设配置
-
core-js:polyfills,从Babel 7.4.0开始,推荐直接安装
core-js
即可,处理Promise等语法 -
@rollup/plugin-typescript:用于将 TypeScript 文件编译为 JavaScript
-
@babel/preset-typescript:TypeScript 预设配置
-
tslib:TypeScript 编译后的 js 代码的运行时库
插件选择建议:
- 优先使用Rollup官方维护的
@rollup/plugin-*
系列- 复杂项目建议配合
unplugin
统一插件系统- 注意插件顺序:resolve → commonjs → babel → terser
- 常用插件列表参考:点击前往 >>
- 推荐阅读:一文入门rollup🪀!13组demo带你轻松驾驭
实战
接下来,我们将以实操的形式手把手带着大家基于rollup 封装并发布一个 js 库。
1. 创建目录结构
$ mkdir -p rollup-examples/src && cd rollup-examples && touch src/index.ts && pnpm init && code .
目录结构如下:
rollup-examples
.
├── src
│└── index.ts
└── package.json
提示:
- 项目名
rollup-examples
根据需要修改。- 包管理工具使用
pnpm
,至于为什么使用它,推荐阅读 前端包管理工具演进史:从 npm 到 pnpm 的技术革新 >>
2. 定义开发规范
代码规范检查与修复
使用工具:ESLint
1️⃣ 安装 & 配置 ESLint
$ pnpm create @eslint/config@latest
? How would you like to use ESLint? …
To check syntax only
❯ To check syntax and find problems
? What type of modules does your project use? …
❯ JavaScript modules (import/export)
CommonJS (require/exports)
None of these
? Which framework does your project use? …
React
Vue.js
❯ None of these
? Does your project use TypeScript? …
No
❯ Yes
? Where does your code run? … (Press <space> to select, <a> to toggle all, <i> to invert selection)
✔ Browser
Node
eslint, globals, @eslint/js, typescript-eslint
? Would you like to install them now? › No / [Yes]
? Which package manager do you want to use? …
npm
yarn
❯ pnpm
bun
上述操作,将在根目录生成 eslint.config.mjs
配置文件,内容如下:
import { defineConfig } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
export default defineConfig([
{ files: ['**/*.{js,mjs,cjs,ts}'] },
{ files: ['**/*.{js,mjs,cjs,ts}'], languageOptions: { globals: globals.browser } },
{ files: ['**/*.{js,mjs,cjs,ts}'], plugins: { js }, extends: ['js/recommended'] },
{ ignores: ['node_modules/', 'dist/', 'build/', '**/*.test.js'] },
tseslint.configs.recommended,
{
env: { node: true },
rules: {
// TODO:你可以在这里添加你自己的规则
},
},
])
提示:ESLint 规则参考,点击 这里 >>
2️⃣ 设置指令 package.json
{
"scripts": {
"lint": "eslint --fix --quiet ."
}
}
-
--fix
:自动修复可修复的代码问题(如缩进、引号等格式问题) -
--quiet
:仅报告错误(error),忽略警告(warning) -
.
:检查当前目录及子目录下的所有文件(需配合--ext
指定文件类型)
代码风格
使用工具:Prettier
1️⃣ 安装
$ pnpm add --save-dev --save-exact prettier
2️⃣ 创建配置文件
$ node --eval "fs.writeFileSync('.prettierrc','{}\n')"
{
"printWidth": 120,
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"objectWrap": "preserve",
"bracketSameLine": true,
"arrowParens": "avoid",
"proseWrap": "always",
"htmlWhitespaceSensitivity": "css",
"vueIndentScriptAndStyle": false,
"endOfLine": "lf",
"singleAttributePerLine": false
}
提示:完整配置参考 Prettier 官方文档 🔗
3️⃣ 创建忽略文件:
$ node --eval "fs.writeFileSync('.prettierignore','# Ignore artifacts:\nbuild\ncoverage\n')"
4️⃣ 集成到编辑器(VSCode)
安装扩展:Prettier - Code formatter
配置编辑:
- 搜索 Editor:Default formatter,将值设置为 Prettier - Code formatter
- 搜索 Editor:Format On Save,☑️ 在保存时格式化文件。
5️⃣ 与 ESLint 配合:使用 eslint-config-prettier 插件关闭与 Prettier 冲突的 ESLint 规则
$ pnpm add -D eslint-config-prettier
eslint.config.mjs
import eslintConfigPrettier from 'eslint-config-prettier'
export default defineConfig([
...
eslintConfigPrettier,
])
Git 规范检查
使用工具: commitlint
+ husky
+ lint-staged
1️⃣ 创建 git 仓库,然后在根目录创建忽略文件 .gitignore
$ git init
# .gitignore
node_modules
dist
build
*.local
logs
*.log
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
2️⃣ 安装依赖
$ pnpm add husky lint-staged @commitlint/{cli,config-conventional} -D
-
husky
:Git钩子管理(需Node.js v18+) -
commitlint
:提交信息规范校验(推荐@commitlint/config-conventional
规则) -
lint-staged
:仅检查暂存区文件
4️⃣ 初始化 husky
自动创建 .husky
目录并设置 Git Hook
$ pnpm exec husky init
5️⃣ 配置 pre-commit
钩子
配置 pre-commit
钩子,在提交时运行 lint-staged
,只检查暂存区的文件:
$ echo "npx lint-staged" > .husky/pre-commit && chmod +x .husky/pre-commit
在 package.json
中配置 lint-staged
:
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write --cache",
"eslint --fix --quiet"
]
},
这样,当你执行 git commit
时,lint-staged
会自动运行 pnpm lint
来检查暂存区中的文件。
6️⃣ 配置 commit-msg
钩子
配置 commit-msg
钩子,检查提交信息是否符合规范:
$ echo "npx --no-install commitlint --edit \$1" > .husky/commit-msg && chmod +x .husky/commit-msg
创建 commitlint.config.js
文件来配置 commitlint
:
echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.js
提示:在 package.json 文件中设置
"type": "module"
现在,当你执行 git commit
时,husky
会自动触发以下钩子:
-
pre-commit
钩子:运行lint-staged
,对暂存区的文件进行代码风格检查。 -
commit-msg
钩子:运行commitlint
,检查提交信息是否符合规范。
这样配置后,你的项目将能够在提交时自动进行代码风格和提交信息的检查,确保代码质量和提交信息的规范性。
注意:
-
lint-staged
会自动将修改后的文件添加到暂存区,因此不要在lint-staged
配置中显式调用git add
。 - 如果
pnpm lint
检查失败,提交会被阻止。请根据错误提示修复代码后重新提交。 - 如果需要自定义提交信息规范,可以修改
commitlint.config.js
文件,添加自定义规则。
📖 扩展:conventional
规范
格式:
<type>: <subject> → 提交的类型: 摘要信息
常用的 type
值包括如下:
- feat:添加新功能
- fix:修复 Bug
- chore:一些不影响功能的更改
- docs:专指文档的修改
- perf:性能方面的优化
- refactor:代码重构
- test:添加一些测试代码等等
提交时的代码格式:git commit -m "feat: xxx"
注意:
feat:
后面跟一个空格。
配置 tsconfig.json
{
"compileOnSave": true,
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"useDefineForClassFields": true,
"moduleResolution": "Node",
"resolveJsonModule": true,
"noEmit": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"declaration": true,
"declarationDir": "dist",
"sourceMap": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": false,
"skipLibCheck": true
},
"include": ["src/**/*", "dist"],
"exclude": ["node_modules", "dist"]
}
3. 安装依赖
$ pnpm add cross-env rollup @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-babel @babel/core @babel/preset-env core-js @rollup/plugin-typescript @babel/preset-typescript tslib @rollup/plugin-alias @rollup/plugin-eslint @rollup/plugin-alias rollup-plugin-delete @rollup/plugin-terser rollup-plugin-generate-package-json -D
4. 配置
package.json
{
"name": "<rollup-examples>",
"version": "1.0.0",
"description": "<A modern TypeScript library built with Rollup>",
"type": "module",
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
"types": "./dist/index.d.ts",
"exports": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist/**/*",
"README.md"
],
"scripts": {
"lint": "eslint --fix --quiet .",
"prepare": "husky",
"build:dev": "cross-env NODE_ENV=development rollup --config rollup.config.mjs --bundleConfigAsCjs",
"build:pro": "cross-env NODE_ENV=production rollup --config rollup.config.mjs --bundleConfigAsCjs"
},
"buildOptions": {
"formats": [
"iife",
"cjs",
"umd",
"esm"
],
"name": "<module-name"
},
"keywords": [
"typescript",
"rollup",
"library"
],
"author": "<Your Name <your.email@example.com>>",
"license": "ISC",
"repository": {
"type": "git",
"url": "https://github.com/<username>/<rollup-examples>.git"
},
"bugs": {
"url": "https://github.com/<username>/<rollup-examples>/issues"
},
"homepage": "https://github.com/<username>/<rollup-examples>#readme",
"devDependencies": {
...
}
}
注意:上述示例中,
name
、description
、buildOptions.name
、keywords
、author
、repository
、bugs
以及homepage
等字段请根据实际情况修改。
字段简介:
-
name
:包名,唯一标识,由小写英文字母、数字和下划线组成,不能包含空格*(必填项)* -
version
:包版本号,遵循:主版本.次版本.修订号
格式*(必填项)* -
description
:包的功能描述*(强烈推荐字段)* -
type
:默认模块类型,可选值:commonjs,module,umd*(强烈推荐字段)* -
main
:CommonJS 入口,Node.jsrequire()
加载的路径,如./dist/index.cjs.js
-
module
:ESM 入口,现代打包工具(如 Rollup)优先使用的路径 -
types
:TS类型声明入口,提供类型提示,入:./dist/index.d.ts
(强烈推荐字段) -
exports
:条件导出(Node 12+)(强烈推荐字段)-
import
:ESM 环境 -
require
:CJS 环境 -
types
:类型声明
-
-
files
:发布白名单,指定发布到 npm 的文件,如["dist/**/*"]
-
scripts
:自定义脚本命令 -
buildOptions
:自定义构建配置,非标准字段,通常被构建工具读取:-
formats
:输出格式(ESM/CJS/UMD) -
name
:UMD 全局变量名
-
-
keywords
:npm 搜索关键字 -
author
:作者信息,格式:名字 <邮箱> 或对象形式 -
license
:开源协议,常用值:MIT
/ISC
/Apache-2.0
。(强烈推荐字段) -
repository
:代码仓库,Git 地址,如https://github.com/xxx.git
-
bugs
:问题反馈链接,通常指向 issues 页面 -
homepage
:项目主页,文档或官网地址
bable.config.json
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": 3,
"modules": false
}
],
"@babel/preset-typescript"
]
}
.browserslistrc
(Options)
.browserlistrc
文件是 Autoprefixer、Babel 等前端工具用来确定需要兼容哪些浏览器版本的配置文件,配置内容如下:
> 1% # 全球使用率超过1%的浏览器
last 2 version # 每个浏览器的最后2个版本
not dead # 排除已停止维护的浏览器(如IE10以下)
提示:
- 在项目根目录运行
npx browserslist
指令可查询所有与配置匹配的浏览器列表。- 访问 browserslist.dev 输入你的配置,可实时查看匹配结果
你可以在 这里 >> 查看当前各主流浏览器的兼容性情况,当我们在打包样式和脚本时,将根据这里的配置进行兼容。
rollup.config.mjs
import { readFileSync } from 'fs'
import { defineConfig } from 'rollup'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import { babel } from '@rollup/plugin-babel'
import typescript from '@rollup/plugin-typescript'
import commonjs from '@rollup/plugin-commonjs'
import alias from '@rollup/plugin-alias'
import del from 'rollup-plugin-delete'
import eslint from '@rollup/plugin-eslint'
import terser from '@rollup/plugin-terser'
import generatePackageJson from 'rollup-plugin-generate-package-json'
// -- 工具函数
const resolvePath = filePath => new URL(filePath, import.meta.url).pathname
// -- 变量信息
const isProduction = process.env.NODE_ENV === 'production'
const distPath = resolvePath('dist')
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
// -- 公共配置
const commonPlugins = [
del({ targets: 'dist/*' }),
nodeResolve(),
commonjs(),
alias({
entries: [{ find: '@', replacement: './src' }],
}),
eslint({
include: ['.'],
exclude: ['node_modules/**'],
throwOnError: true, // 出现ESLint错误时,打断打包进程
throwOnWarning: true, // 出现ESLint警告时,打断打包进程
}),
typescript({
tsconfig: resolvePath('tsconfig.json'),
sourceMap: !isProduction,
}),
babel({
extensions: ['.js', '.ts'],
exclude: 'node_modules/**',
babelHelpers: 'bundled',
}),
generatePackageJson({
inputFolder: resolvePath('.'),
outputFolder: resolvePath('dist'),
baseContents: pkg => ({
name: pkg.name,
description: pkg.description,
version: pkg.version,
types: pkg.types,
main: pkg.main,
module: pkg.module,
exports: pkg.exports,
}),
}),
]
// -- 开发配置
const devPlugins = []
// -- 生产配置
const proPlugins = [
terser({
compress: {
drop_console: isProduction,
drop_debugger: isProduction,
},
format: {
comments: (_, comment) => {
return /eslint\-disable/.test(comment.value) // 不删除eslint的注释
},
},
}),
]
// -- 配置
export default defineConfig({
input: resolvePath('src/index.ts'),
output: pkg.buildOptions.formats.map(format => ({
file: `${distPath}/index.${format}.js`,
format,
sourcemap: !isProduction,
banner: '/* eslint-disable */\n',
name: ['iife', 'umd'].includes(format) ? pkg.buildOptions.name : undefined,
})),
plugins: [...commonPlugins, ...(isProduction ? proPlugins : devPlugins)],
})
5. 源码
// src/index.js
export default class vTools {
/**
* SUM:求和
* @param a
* @param b
* @returns
*/
static sum(a: number, b: number) {
return a + b;
}
}
6. 打包
$ pnpm build:pro
7. 发布
在正式发布之前,建议全局安装 package-json-validator
,它是一个用于自动检查 package.json
文件格式规范性和完整性的 npm 插件,能快速识别缺失字段、无效值或不推荐写法,确保配置符合 npm 发布标准。
# 安装
$ npm install package-json-validator -g
# 检查
$ pjv
没问题之后,执行如下命令进行发布:
$ npm publish --access public
重要提示:发布 npm 包时必须使用官方源(registry.npmjs.org/),若当前配置为淘宝镜…
操作步骤:
-
检查当前源
$ npm config get registry
-
临时切换至官方源
$ npm config set registry https://registry.npmjs.org/
-
发布完成后可回复淘宝源(可选)
$ npm config set registry https://registry.npmmirror.com
自动发布脚本配置
{
"scripts": {
"preversion": "npm run lint && npm run build:pro", // 前置检查:代码规范 + 生产构建
"version": "npm pkg fix && git add package.json package-lock.json", // 自动修复格式并暂存文件
"postversion": "npm publish --access public", // 自动发布到 npm
"postpublish": "git push origin HEAD --follow-tags && echo '✅ Published!'" // 安全推送代码和标签
}
}
提示:根据需要调整指令,触发指令之后的执行流程为
preversion
→version
→postversion
→postpublish
1️⃣ 触发命令
$ npm version patch|minor|major # 选择版本升级类型
2️⃣ 自动化流程
graph TB
A[用户执行 npm version] --> B[preversion]
B --> C[lint+构建生产包]
C --> D[version]
D --> E[修复package.json格式]
E --> F[提交版本文件]
F --> G[postversion]
G --> H[发布到npm]
H --> I[postpublish]
I --> J[推送代码+标签]
深入解析 Rollup 输出格式:前端构建的核心原理
为什么需要掌握 Rollup 的打包格式?随着 Vite.js 在前端工程中的日益普及,这个问题变成了工作生产和面试问答时经常被提及的问题。作为 Vite 构建核心的 Rollup.js,其输出格式直接决定了项目的运行机制。在学习这些工具之前,深入理解 JavaScript 模块化规范至关重要——因为只有明白打包产物的本质和运行原理,才能真正掌握前端构建的精髓。在编程学习中,盲目崇拜工具"魔法"是不可取的,保持对技术原理的好奇心和探索欲,才是持续进步的阶梯。现在,让我们解开神秘的面纱,看看那些打包后的代码,都是些什么玩意儿!
接下来,我们将通过一个完整示例,帮助大家理解模块化规范的几种主要格式。
准备工作
首先创建项目并初始化配置:
$ mkdir rollup-formats && cd rollup-formats && npm init -y && mkdir src && touch src/{index,answer}.js rollup.config.mjs && pnpm add rollup -D && pnpm add lodash lodash-es jquery && code .
项目目录结构如下:
rollup-formats
.
├── node_modules
├── src
│ ├── anwser.js
│ └── index.js
├── package.json
└── rollup.config.js
其中 index.js
和 answer.js
是业务代码,将被作为打包对象。具体代码如下:
anwser.js
export default 30;
index.js
import answer from "./answer";
import { repeat } from "lodash";
// -- 定义一个无用变量,测试tree-shaking
const unUsedVar = "Hello, Rollup!";
export const printAnswer = () => {
// 1. 打印输出
console.log(`The answer is ${answer}.`);
// 2. 测试 loadash 的能力,打印30个1
console.log(repeat("1", answer));
};
rollup.config.mjs
import { defineConfig } from "rollup";
export default defineConfig({
// 外部依赖声明(不打包lodash)
external: ["lodash"],
// 入口文件
input: new URL("src/index.js", import.meta.url).pathname,
// 多格式输出配置
output: [
// IIFE 格式(浏览器直接使用)
{
file: "dist/iife/bundle.js",
format: "iife",
name: "Test", // 全局变量名
globals: { lodash: "lodash" }, // 外部依赖全局变量映射
},
// CommonJS 格式
{
file: "dist/cjs/bundle.js",
format: "cjs",
},
// AMD 格式
{
file: "dist/amd/bundle.js",
format: "amd",
amd: { id: "Test" }, // 模块ID
},
// ESM 格式
{
file: "dist/esm/bundle.js",
format: "esm",
},
// UMD 格式(通用模块定义)
{
file: "dist/umd/bundle.js",
format: "umd",
name: "Test", // 全局变量名
globals: { lodash: "lodash" },
amd: { id: "Test" }, // 同时支持AMD
},
// SystemJS 格式
{
file: "dist/system/bundle.js",
format: "system",
},
],
});
package.json
{
"name": "rollup-formats",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"build": "rollup -c --bundleConfigAsCjs"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"rollup": "^4.39.0"
},
"dependencies": {
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21"
}
}
执行打包命令:
$ pnpm build
模块解读
1. IIFE(立即执行函数)
打包结果分析
dist/iife/bundle.js
内容
var Test = (function (exports, lodash) {
"use strict";
var answer = 30;
const printAnswer = () => {
// 1. 打印输出
console.log(`The answer is ${answer}.`);
// 2. 测试 loadash 的能力,打印30个1
console.log(lodash.repeat("1", answer));
};
exports.printAnswer = printAnswer;
return exports;
})({}, lodash);
产物分析:
// -- exports 是第一个入参,依赖的 lodash 是第二个入参
var Test = (function (exports, lodash) {
// -- 自带严格模式,避免一些奇怪的兼容性问题
"use strict";
// -- 下面代码因为没有被使用,被 tree-shaking 掉了
// const unUsedVar = 'Hello, Rollup!';
// -- 业务中被单一引用的模块,被直接抹平
var answer = 30;
const printAnswer = () => {
// 1. 打印输出
console.log(`The answer is ${answer}.`);
// 2. 测试 loadash 的能力,打印30个1
console.log(lodash.repeat("1", answer));
};
// -- 把要export的属性挂在到exports上
exports.printAnswer = printAnswer;
return exports;
})({}, lodash);
IIFE 是前端模块化早期的产物,其核心思路是:
- 构建一个匿名函数
- 立即执行这个匿名函数,外部依赖通过入参形式传入
- 返回模块输出
运行方式
IIFE 的运行其实很简单,如果它没有其他依赖,只需要去引入文件,然后在 window 上取相应的变量即可,比如 jQuery:
<script src="http://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
<script>window.$</script>
但是如果你像本示例中那样依赖了其他的模块(这里引用了 lodash),那就必须保证以下两点才能正常运行:
- 依赖包已预先加载
- 全局变量名与IIFE入参一致
以本示例中 IIFE 构建结果为例:
- 它前置依赖了
lodash
,因此需要在它加载之前完成lodash
的加载。 - 此
IIFE
的第二个入参是lodash
,作为前置条件,我们需要让window.lodash
也指向lodash
。
因此,运行时,代码如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IIFE</title>
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script>window.lodash = window._;</script>
<script src="../dist/iife/bundle.js"></script>
</head>
<body>
<script>
window.Test.printAnswer();
</script>
</body>
</html>
优缺点
- 优点:
- 通过闭包创建私有命名空间
- 简单易懂
- 对代码体积影响小
- 缺点:
- 输出的变量可能影响全局变量 / 引入依赖包时依赖全局变量。
- 需要手动维护script加载顺序。
优点就不细说了,缺点详细解释一下。
缺点一:输出的变量可能影响全局变量 / 引入依赖包时依赖全局变量。
前半句:输出的变量可能影响全局变量 其实很好理解,以上面示例的输出为例: window.Test
就已经被影响了。这种明显的副作用在程序中其实是有隐患的。
后半句:引入依赖包时依赖全局变量 我们为了让示例正常运行,因此加了一行代码让 window.lodash
也指向 lodash
,但它确实是太脆弱了。
<!-- 没有这一行,示例就无法正常运行 -->
<script>window.lodash = window._</script>
你瞧,IIFE 的执行对环境的依赖是苛刻的,除非它完全不依赖外部包。(jQuery: 正是在下!)
虽然 IIFE 的缺点很多,但并不妨碍它在 jQuery 时代极大地推动了Web开发的进程,因为它确实解决了 js 本身存在的很多问题。
那么?后续是否还有 更为优秀 的前端模块化方案问世呢?当然有,往下看吧。
2. CommonJS
打包结果分析
dist/cjs/bundle.js
内容:
'use strict';
var lodash = require('lodash');
var answer = 30;
const printAnswer = () => {
// 1. 打印输出
console.log(`The answer is ${answer}.`);
// 2. 测试 loadash 的能力,打印30个1
console.log(lodash.repeat("1", answer));
};
exports.printAnswer = printAnswer;
CommonJS 规范特点:
- 通过
require
引入模块 - 通过
exports
或module.exports
输出模块
为了解决 Node.js 在模块化上的缺失, 2009年10月,CommonJS 规范首次被提出。
注意这个关键词: Node.js,是的,CommonJS 并不是在浏览器环境运行的规范,而是在 Node.js 环境下运行的。
运行方式
创建测试文件并执行:
// run.js
const Test = require('../dist/cjs/bundle');
Test.printAnswer();
# 执行脚本
node ./examples/run.js
# 输出内容
> The answer is 30.
> 111111111111111111111111111111
优缺点
- 优点:完善的模块化方案,解决了 IIFE 的各种缺点。
- 缺点:同步加载,浏览器不支持。
因此,前端界迫切地需要一种能在浏览器环境运行的模块化方案。
3. AMD & require.js
2011年,AMD(Asynchronous Module Definition)规范正式发布,为浏览器端带来了成熟的模块化方案。
打包结果分析
dist/amd/bundle.js
内容:
define('Test', ['exports', 'lodash'], (function (exports, lodash) { 'use strict';
var answer = 30;
const printAnswer = () => {
// 1. 打印输出
console.log(`The answer is ${answer}.`);
// 2. 测试 loadash 的能力,打印30个1
console.log(lodash.repeat("1", answer));
};
exports.printAnswer = printAnswer;
}));
关键特性解析:
-
模块定义:通过全局
define
函数声明模块 -
依赖声明:数组形式声明外部依赖(
exports
和lodash
) - 工厂函数:接收依赖项作为参数,返回模块实现
require.js 是 AMD 标准实现方案,在使用时,一般遵循以下四步法:
- 在浏览器内引入
require.js
- 通过
requirejs.config
方法定义全局的依赖 - 通过
requirejs.define
注册模块 - 通过
requirejs()
完成模块引入。
运行方式
使用 require.js 加载,你可以在 这里 >> 下载
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AMD</title>
<!-- 1. 引入.requirejs -->
<script src="./requirejs.js"></script>
<!-- 2. 定义全局依赖 -->
<script>
window.requirejs.config({
paths: {
lodash: "https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.min",
},
});
</script>
<!-- 3. 定义模块 -->
<script src="../dist/amd/bundle.js"></script>
</head>
<body>
<!-- 4. 消费模块 -->
<script>
window.requirejs(["Test"], function (test) {
test.printAnswer();
});
</script>
</body>
</html>
优缺点
-
优点
- 异步加载:避免阻塞页面渲染
- 依赖管理:自动处理模块依赖关系
- 路径映射:支持自定义模块路径
-
缺点:代码组织方式不够直观
但好在我们拥有了各类打包工具,浏览器内的代码可读性再差也并不影响我们写出可读性ok的代码。
现在,我们拥有了面向 Node.js 的 CommonJs 和 面向浏览器的 AMD 两套标准。
如果我希望我写出的代码能同时被 浏览器 和 Node.js 识别,我应该怎么做呢?
4. UMD 伟大的整合
它没有做什么突破性的创造,但它是集大成者。
它可以在 <script>
标签中执行,被 CommonJS 模块加载器加载、被 AMD 模块加载器加载。
打包结果分析
UMD 格式构建出来的代码的可读性进一步降低了,我相信任何正常人看到下面这段代码都会感到一阵头大。
dist/umd/bundle.js
内容:
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('lodash')) :
typeof define === 'function' && define.amd ? define('Test', ['exports', 'lodash'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Test = {}, global.lodash));
})(this, (function (exports, lodash) { 'use strict';
var answer = 30;
const printAnswer = () => {
// 1. 打印输出
console.log(`The answer is ${answer}.`);
// 2. 测试 loadash 的能力,打印30个1
console.log(lodash.repeat("1", answer));
};
exports.printAnswer = printAnswer;
}));
UMD 特点:
- 兼容 AMD 和 CommonJS
- 自动判断环境选择加载方式
是的,整整一大段代码,只是在处理兼容性问题,判断当前应该使用AMD还是CommonJS。
因此UMD的代码和实现不在此进行过多分析,它所做的无非便是让同一段代码兼容了AMD和CommonJS规范。
运行方式
与 AMD 和 CommonJS 相同
优缺点
- 优点:跨环境兼容
- 缺点:生成代码冗余
虽然在社区的不断努力下,CommonJS
、 AMD
、 UMD
都给业界交出了自己的答卷。
但很显然,它们都是不得已的选择。
浏览器应该有自己的加载标准。
ES6 草案里,虽然描述了模块应该如何被加载,但它没有 “加载程序的规范”。
5. SystemJS
因此,WHATWG(Web Hypertext Application Technology Working Group,即网页超文本应用技术工作小组)提出了一套更有远见的规范:whatwg/loader。该规范定义了JavaScript模块的加载行为,并提供了拦截加载过程和自定义加载行为的API。作为这一规范的最佳实践者,SystemJS应运而生。
dist/system/bundle.js
内容:
System.register(['lodash'], (function (exports) {
'use strict';
var repeat;
return {
setters: [function (module) {
repeat = module.repeat;
}],
execute: (function () {
var answer = 30;
const printAnswer = exports("printAnswer", () => {
// 1. 打印输出
console.log(`The answer is ${answer}.`);
// 2. 测试 loadash 的能力,打印30个1
console.log(repeat("1", answer));
});
})
};
}));
与AMD相比,SystemJS的优势不仅体现在语法上:
-
按需加载:通过
System.import()
实现真正的懒加载,避免一次性加载所有bundle - 面向未来:基于WHATWG标准设计,代表模块加载的发展方向
- 动态能力:支持运行时模块加载和依赖解析
6. ESM
ESM 被认为是 未来,但cjs仍然在社区和生态系统中占有重要地位。ESM 对打包工具来说更容易正确地进行 treeshaking,因此对于库来说,拥有这种格式很重要。或许在将来的某一天,你的库只需要输出 esm。
打包结果分析
dist/esm/bundle.js
内容:
import { repeat } from 'lodash';
var answer = 30;
const printAnswer = () => {
// 1. 打印输出
console.log(`The answer is ${answer}.`);
// 2. 测试 loadash 的能力,打印30个1
console.log(repeat('1', answer));
};
export { printAnswer };
ESM 特点:
- 原生模块语法
- 静态分析友好
在 ESM 被提出来之前,js 一直没有真正意义上的模块体系。
它的规范是通过 export
命令显式指定输出的代码,再通过 import
命令输入。
// 导出命令
export { foo };
// 导入模块
import { foo } from 'bar';
这也是我们日常开发中最为熟悉的写法,因此,ESM 格式打出来的包,可读性确实非常棒,和阅读我们平时所写的业务代码完全没有区别。
运行方式
现代浏览器直接支持:
<script type="module">
import { printAnswer } from "../dist/esm/bundle.js";
printAnswer();
</script>
提示:
运行时,你应该会在控制台看到错误信息:
Uncaught TypeError: Failed to resolve module specifier "lodash". Relative references must start with either "/", "./", or "../".
这是因为默认的 lodash 并不是输出的 ESM 格式,为了演示,我们需要调整下
dist/esm/bundle.js
代码,如下:- import { repeat } from "lodash"; + import repeat from '../../node_modules/lodash-es/repeat.js'; ...
总结:分别适合在什么场景使用?
-
IIFE :适合作为SDK使用,特别是需要挂载到window的场景
-
CommonJS: 仅Node.js环境使用的库
-
AMD: 纯浏览器端使用
-
UMD: 跨浏览器和Node.js环境使用
-
SystemJs: 需要动态加载的场景
-
ESM:
- 会被二次编译的库(如组件库)
- 现代浏览器直接运行
- 对tree-shaking要求高的场景
随着ESM的普及,未来前端模块化将越来越倾向于使用原生ESM格式。
模拟 Koa 中间件机制与洋葱模型
通过一个简化的 Koa
类来理解 Node.js 的 http
模块以及 Koa.js 框架核心的中间件机制,特别是其著名的“洋葱模型”和异步流程控制。
1. 基础 HTTP 服务器回顾
在深入 Koa 机制之前,我们首先需要一个基础的 HTTP 服务器。Node.js 内建的 http
模块允许我们轻松创建。
const http = require("http");
const hostname = "127.0.0.1";
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.end("Hello World\n");
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
这个基础服务器展示了如何监听端口、接收请求 (req
) 并发送响应 (res
)。然而,当业务逻辑变得复杂时,直接在 createServer
的回调中处理所有事情会变得难以维护。这就是中间件模式发挥作用的地方。
2. Koa
类:模拟 Koa 的核心
为了模拟 Koa 的行为,我们创建了一个 Koa
类。
class Koa {
constructor() {
this.middleware = []; //中间件栈
}
use(fn) {
if (typeof fn !== "function")
throw new TypeError("middleware must be a function!");
this.middleware.push(fn);
return this; // 支持链式调用 .use(fn1).use(fn2)
}
// 创建上下文对象
createContext(req, res) {
const context = {};
context.req = req;
context.res = res;
context.url = req.url;
context.method = req.method;
// 可以在这里添加更多 Koa ctx 上的常用属性或方法,例如 context.body
context.body = "Not Found"; // 默认响应体
res.statusCode = 404; // 默认状态码
return context;
}
// 处理请求的核心回调
handleRequest(ctx, fnMiddleware) {
const handleResponse = () => {
// 根据ctx.body的类型设置Content-Type
if (typeof ctx.body === "string") {
ctx.res.setHeader("Content-Type", "text/plain; charset=utf-8");
} else if (typeof ctx.body === "object" && ctx.body !== null) {
ctx.res.setHeader("Content-Type", "application/json; charset=utf-8");
ctx.body = JSON.stringify(ctx.body); // 对象转 JSON 字符串
}
// 如果没有设置 body 但状态码是 404,则设置 body
if (ctx.res.statusCode === 404 && ctx.body === "Not Found") {
ctx.body = 'Not Found';
ctx.res.setHeader('Content-Type', 'text/plain; charset=utf-8');
}
ctx.res.end(ctx.body);
}
// 执行中间件组合函数
return fnMiddleware(ctx)
.then(handleResponse) //所有中间件执行完毕,成功后处理响应
.catch((error) => { // 捕获中间件链中的错误
console.error("Middleware Error:", error);
ctx.res.statusCode = 500;
ctx.res.setHeader("Content-Type", "text/plain");
ctx.res.end("Internal Server Error");
});
}
// 启动服务器
listen(...args) {
// 注册中间件 -> 应为组合中间件
const fnMiddleware = compose(this.middleware); // 组合所有注册的中间件
// 当一个http请求进来时,回调触发
const server = http.createServer((req, res) => {
// 为每个请求创建独立的上下文对象
const ctx = this.createContext(req, res);
// 处理请求
this.handleRequest(ctx, fnMiddleware);
});
return server.listen(...args); // 将 listen 参数传给 http.server.listen
}
}
-
constructor
: 初始化一个空数组this.middleware
用于存储所有注册的中间件函数。 -
use(fn)
: 这是注册中间件的方法。它接收一个函数fn
,校验其类型后将其添加到this.middleware
数组中。返回this
以支持链式调用 (app.use(mw1).use(mw2)
). -
createContext(req, res)
: Koa 的核心概念之一是Context
(上下文) 对象,通常表示为ctx
。这个方法为每个进入的请求创建一个ctx
对象,将原始的req
(请求) 和res
(响应) 对象封装起来,并提供一些便捷的属性和方法(这里简化了,只添加了url
,method
,body
和默认状态码)。这使得中间件访问请求和修改响应更加方便,而无需直接操作req
和res
。 -
handleRequest(ctx, fnMiddleware)
: 这是处理请求流程的核心。它接收创建好的ctx
对象和 组合后 的中间件函数fnMiddleware
。它调用fnMiddleware(ctx)
来启动中间件链的执行。-
.then(handleResponse)
: 当中间件链成功执行完毕 (Promise resolved) 时,调用handleResponse
来发送最终的 HTTP 响应。handleResponse
会根据ctx.body
的类型和ctx.res.statusCode
来设置正确的响应头并发送内容。 -
.catch((error) => { ... })
: 如果在中间件执行过程中任何地方抛出错误 (Promise rejected),这个.catch
会捕获它,记录错误日志,并发送一个标准的 500 内部服务器错误响应。这是 Koa 健壮性的体现,提供了一个统一的错误处理机制。
-
-
listen(...args)
: 这个方法负责启动 HTTP 服务器。- 它首先调用
compose(this.middleware)
来获取一个 单一的、组合后的 中间件处理函数fnMiddleware
。 - 然后,它使用
http.createServer
创建服务器实例。对于每一个进来的请求 (req
,res
):- 调用
this.createContext(req, res)
创建该请求独有的ctx
对象。 - 调用
this.handleRequest(ctx, fnMiddleware)
来执行中间件链并处理响应。
- 调用
- 最后,调用底层
http
服务器的listen
方法,开始监听指定的端口和主机。
- 它首先调用
3. compose
函数:中间件的“指挥官”
compose
函数是 Koa (以及我们模拟的 Koa
) 中间件机制的灵魂。它负责将注册的多个中间件函数按照正确的顺序串联起来执行。
/*
* 复习一下JS中async/await和Promise的工作方式
* async函数特性会返回一个Promise对象,即使内部没有return,也会被自动包装在一个resolved的Promise种
* 而await的作用是等待一个Promise对象的状态变为settled,即resolved或rejected,如果某一个Promise还处于
* pending未完成的状态,当前的async函数就会暂停执行,然后跳出当前async函数执行其他同步代码,这些任务包括,
* 1. setTimeout/setInterval回调
* 2. Promise的.then或.catch回调
* 3. 用户交互事件等
* 一旦某个Promise状态变成了resolved,async函数就会在之前暂停的地方恢复执行
*/
// 中间件组合函数
function compose(middleware) {
// 确保 middleware 是一个数组
if (!Array.isArray(middleware))
throw new TypeError("Middleware stack must be an arry!");
// 确保中间件数组中的每个元素都是函数
for (const fn of middleware) {
if (typeof fn !== "function")
throw new TypeError("Middleware must be composed of functions!");
}
// 返回一个最终要执行的函数,接收 context 和一个可选的 next 函数(通常是 http 服务器的结束处理)
return function (context, next) {
let index = -1; // 用于检测 next() 是否被多次调用
// 定义 dispatch 函数,用于递归调用中间件
function dispatch(i) {
// 如果一个中间件内多次调用 next(),则 index 会小于 i
if (i <= index)
return Promise.reject(new Error("next() called multiple times"));
index = i;
let fn = middleware[i];
// 当 i 等于中间件数量时,说明所有中间件已执行完毕
// 如果有传入 next(通常没有,或者是一个最终处理),则执行它
if (i === middleware.length) fn = next;
// 如果 fn 不存在(到达末尾且没有 next),则直接 resolve
if (!fn) return Promise.resolve();
try {
// 执行当前中间件 fn
// 传入 context 和下一个中间件的 dispatch 调用 (dispatch.bind(null, i + 1)) 作为 next 参数
// 使用 Promise.resolve 包装,确保即使中间件不是 async 函数也能正常工作
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (error) {
// 捕获中间件执行中的同步错误
return Promise.reject(error);
}
}
// 开始执行第一个中间件
return dispatch(0);
};
}
-
输入与输出:
compose
接收一个中间件函数数组middleware
,返回一个 新的函数。这个返回的函数才是最终在handleRequest
中被调用的,它接收context
对象作为参数。 -
dispatch(i)
: 这是compose
内部的核心递归函数。它的作用是执行第i
个中间件。 -
index
变量: 用于确保在一个中间件函数内部,next()
(即dispatch(i+1)
) 只被有效调用一次。如果尝试调用多次,会抛出错误。这是 Koa 严格控制流程的一部分。 -
递归调用与
next
:compose
最巧妙的部分在于return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
。- 它执行当前的中间件
fn
。 - 关键是第二个参数
dispatch.bind(null, i + 1)
:它创建了一个 新的函数,这个新函数调用dispatch
时,索引i
会自动加 1。这个新函数就是传递给当前中间件fn
的next
参数! - 所以,当中间件内部调用
await next()
时,实际上是在调用await dispatch(i + 1)
,从而触发下一个中间件的执行。
- 它执行当前的中间件
-
Promise.resolve()
包装: 使用Promise.resolve()
包裹fn(...)
的调用,是为了兼容同步和异步中间件。即使fn
不是async
函数,compose
也能基于 Promise 正确地处理执行链。如果fn
是async
函数,它本身返回的就是 Promise,Promise.resolve()
对其没有影响。如果fn
是普通函数,Promise.resolve()
会将其返回值(或undefined
)包装成一个 resolved Promise。 -
处理链末端: 当
i
到达middleware.length
时,意味着所有注册的中间件都执行完了它们next()
调用之前的部分。如果compose
调用时传入了第二个参数next
(在我们的MyKoa
例子中通常没有),则会执行这个next
。否则,dispatch
会返回Promise.resolve()
,标志着中间件链向前传递的部分结束。
4. 中间件示例与“洋葱模型”
现在我们来看具体的中间件如何协同工作:
const app = new Koa();
app
.use(async (ctx, next) => {
const start = Date.now();
console.log(`--> MW1 Start ${ctx.method} ${ctx.url}`);
await next(); // 调用下一个中间件 (暂停 MW1, 执行 MW2)
// await next(); // 在这里再次调用 next() 会触发 "next() called multiple times" 错误
const ms = Date.now() - start;
console.log(`<-- MW1 End (${ms}ms)`); // MW2 和 MW3 完全结束后才会执行这里
// 可以在这里设置响应头等
ctx.res.setHeader("X-Response-Time", `${ms}ms`);
})
.use(async (ctx, next) => {
console.log(` --> MW2 Start`);
// 模拟一个异步数据库查询或 API 调用
await new Promise((resolve) => setTimeout(resolve, 100)); // 模拟耗时操作
console.log(` <-- MW2 Async Done`);
await next(); // 调用下一个中间件 (暂停 MW2, 执行 MW3)
console.log(`<-- MW2 End`); // MW3 完全结束后才会执行这里
})
.use(async (ctx, next) => {
console.log(` --> MW3 Start`);
if (ctx.url === "/") {
ctx.body = "Hello from MyKoa!";
ctx.res.statusCode = 200;
} else if (ctx.url === "/json") {
ctx.body = { message: "This is JSON" };
ctx.res.statusCode = 200;
}
// 即使这个中间件是最后一个,也可以选择性地调用 next()
// 如果后面没有中间件了,调用 await next() 会立即返回 Promise.resolve()
// await next(); // 在这里调用 next() 没有实际效果,因为后面没有中间件了,但不会报错
console.log(`<-- MW3 End`); // next() 之后(或没有调用 next())的代码
});
const hostname = "127.0.0.1";
const port = 3000;
app.listen(port, hostname, () => {
console.log(`MyKoa server running at http://${hostname}:${port}/`);
});
执行流程:
-
请求进入:
handleRequest
调用fnMiddleware(ctx)
,实际上是dispatch(0)
。 -
MW1 开始:
dispatch(0)
执行 MW1 (app.use
的第一个函数)。- 记录
start
时间。 - 打印
--> MW1 Start GET /
。 - 遇到
await next()
,它实际上是await dispatch(1)
。MW1 的执行暂停。
- 记录
-
MW2 开始:
dispatch(1)
执行 MW2。- 打印
--> MW2 Start
。 - 遇到
await new Promise(...)
,模拟异步操作。MW2 的执行暂停,等待setTimeout
完成。此时 JavaScript 事件循环可以处理其他任务。 -
setTimeout
完成后,Promise resolve,await
结束。 - 打印
<-- MW2 Async Done
。 - 遇到
await next()
,即await dispatch(2)
。MW2 的执行再次暂停。
- 打印
-
MW3 开始:
dispatch(2)
执行 MW3。- 打印
--> MW3 Start
。 - 判断
ctx.url === '/'
为true
。 - 设置
ctx.body = 'Hello from MyKoa!'
和ctx.res.statusCode = 200
。 - 打印
<-- MW3 End
。 - MW3 函数执行完毕。由于没有
await next()
或者next()
指向的是一个空的 Promise.resolve(),dispatch(2)
返回的 Promise resolve。
- 打印
-
MW2 恢复:
await next()
(即await dispatch(2)
) 在 MW2 中结束。- 打印
<-- MW2 End
。 - MW2 函数执行完毕。
dispatch(1)
返回的 Promise resolve。
- 打印
-
MW1 恢复:
await next()
(即await dispatch(1)
) 在 MW1 中结束。- 计算耗时
ms
。 - 打印
<-- MW1 End (${ms}ms)
。 - 设置响应头
X-Response-Time
。 - MW1 函数执行完毕。
dispatch(0)
返回的 Promise resolve。
- 计算耗时
-
响应发送:
handleRequest
中的.then(handleResponse)
被触发,handleResponse
读取ctx.res.statusCode
(200) 和ctx.body
('Hello from MyKoa!'),设置Content-Type
并调用ctx.res.end()
发送响应给客户端。
这就是“洋葱模型”:
- 请求像剥洋葱一样,按顺序穿过每个中间件的
await next()
之前的部分(从 MW1 到 MW3)。 - 到达最内层(MW3 处理响应逻辑)后,控制权再像穿洋葱一样,按相反的顺序依次经过每个中间件
await next()
之后的部分(从 MW3 回到 MW1)。
我的观点与理解:
- 关注点分离 (Separation of Concerns): 洋葱模型极大地促进了代码的模块化。每个中间件可以专注于一个特定的任务,如日志记录 (MW1)、身份验证、数据校验、压缩、最终响应处理 (MW3) 等。
-
可预测的异步流程:
async/await
和compose
的结合,使得即使存在复杂的异步操作(如 MW2 的setTimeout
),整个请求-响应的生命周期流程仍然是清晰和可预测的。await next()
确保了后续中间件(包括其内部的异步操作)完成后,控制权才会返回。 -
强大的控制力: 中间件不仅可以在
next()
之前操作请求 (ctx
),还可以在next()
之后操作响应 (ctx
)。例如,MW1 在所有内部中间件执行完毕后计算总耗时并添加到响应头,这是洋葱模型回流阶段的典型应用。 -
灵活性: 中间件可以选择性地调用
next()
。如果不调用next()
,请求处理流程将在当前中间件处终止(后续中间件不会执行),这对于实现路由、权限控制等非常有用(虽然本例中没有显式展示终止流程)。 -
错误处理:
compose
和handleRequest
提供的try...catch
和 Promise.catch
机制,为整个中间件链提供了一个集中的错误处理点,简化了错误管理。
总而言之,通过模拟 Koa 的 compose
函数和中间件执行流程,我们能深刻理解其设计的精妙之处,特别是它如何利用 async/await
和 Promise 优雅地解决了 Node.js 异步编程中的流程控制难题,形成了富有表现力且易于维护的洋葱模型。