乐闻世界logo
搜索文章和话题

面试题手册

Next.js 应用的完整测试策略是什么?

Next.js 的测试策略对于确保应用质量和稳定性至关重要。Next.js 应用需要测试多个层面,包括组件、页面、API 路由、数据获取逻辑等。测试工具选择1. 主要测试框架// package.json{ "devDependencies": { "@testing-library/react": "^14.0.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/user-event": "^14.0.0", "jest": "^29.0.0", "jest-environment-jsdom": "^29.0.0", "@playwright/test": "^1.40.0", "msw": "^2.0.0", "vitest": "^1.0.0", "@vitejs/plugin-react": "^4.0.0" }}2. Jest 配置// jest.config.jsconst nextJest = require('next/jest')const createJestConfig = nextJest({ dir: './',})const customJestConfig = { setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { '^@/(.*)$': '<rootDir>/$1', }, collectCoverageFrom: [ 'app/**/*.{js,jsx,ts,tsx}', 'components/**/*.{js,jsx,ts,tsx}', 'lib/**/*.{js,jsx,ts,tsx}', '!**/*.d.ts', '!**/node_modules/**', ], testMatch: [ '**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)' ],}module.exports = createJestConfig(customJestConfig)// jest.setup.jsimport '@testing-library/jest-dom'组件测试1. 基础组件测试// components/__tests__/Button.test.jsimport { render, screen } from '@testing-library/react'import userEvent from '@testing-library/user-event'import Button from '../Button'describe('Button Component', () => { it('renders button with text', () => { render(<Button>Click me</Button>) expect(screen.getByText('Click me')).toBeInTheDocument() }) it('calls onClick handler when clicked', async () => { const user = userEvent.setup() const handleClick = jest.fn() render(<Button onClick={handleClick}>Click me</Button>) await user.click(screen.getByText('Click me')) expect(handleClick).toHaveBeenCalledTimes(1) }) it('is disabled when disabled prop is true', () => { render(<Button disabled>Click me</Button>) expect(screen.getByRole('button')).toBeDisabled() }) it('applies correct variant styles', () => { const { rerender } = render(<Button variant="primary">Button</Button>) expect(screen.getByRole('button')).toHaveClass('btn-primary') rerender(<Button variant="secondary">Button</Button>) expect(screen.getByRole('button')).toHaveClass('btn-secondary') })})2. 表单组件测试// components/__tests__/LoginForm.test.jsimport { render, screen, waitFor } from '@testing-library/react'import userEvent from '@testing-library/user-event'import LoginForm from '../LoginForm'describe('LoginForm Component', () => { it('renders email and password inputs', () => { render(<LoginForm />) expect(screen.getByLabelText(/email/i)).toBeInTheDocument() expect(screen.getByLabelText(/password/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument() }) it('shows validation errors for empty fields', async () => { const user = userEvent.setup() render(<LoginForm />) const submitButton = screen.getByRole('button', { name: /login/i }) await user.click(submitButton) await waitFor(() => { expect(screen.getByText(/email is required/i)).toBeInTheDocument() expect(screen.getByText(/password is required/i)).toBeInTheDocument() }) }) it('submits form with valid data', async () => { const user = userEvent.setup() const handleSubmit = jest.fn() render(<LoginForm onSubmit={handleSubmit} />) await user.type(screen.getByLabelText(/email/i), 'test@example.com') await user.type(screen.getByLabelText(/password/i), 'password123') await user.click(screen.getByRole('button', { name: /login/i })) await waitFor(() => { expect(handleSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123' }) }) })})3. 带有 API 调用的组件测试// components/__tests__/UserList.test.jsimport { render, screen, waitFor } from '@testing-library/react'import { rest } from 'msw'import { setupServer } from 'msw/node'import UserList from '../UserList'const server = setupServer( rest.get('/api/users', (req, res, ctx) => { return res( ctx.json([ { id: 1, name: 'John Doe', email: 'john@example.com' }, { id: 2, name: 'Jane Smith', email: 'jane@example.com' } ]) ) }))beforeAll(() => server.listen())afterEach(() => server.resetHandlers())afterAll(() => server.close())describe('UserList Component', () => { it('displays loading state initially', () => { render(<UserList />) expect(screen.getByText(/loading/i)).toBeInTheDocument() }) it('displays users after successful fetch', async () => { render(<UserList />) await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument() expect(screen.getByText('Jane Smith')).toBeInTheDocument() }) }) it('displays error message on failed fetch', async () => { server.use( rest.get('/api/users', (req, res, ctx) => { return res(ctx.status(500)) }) ) render(<UserList />) await waitFor(() => { expect(screen.getByText(/failed to load users/i)).toBeInTheDocument() }) })})页面测试1. 静态页面测试// app/__tests__/page.test.jsimport { render, screen } from '@testing-library/react'import Home from '../page'describe('Home Page', () => { it('renders hero section', () => { render(<Home />) expect(screen.getByText(/welcome to our website/i)).toBeInTheDocument() }) it('renders navigation links', () => { render(<Home />) expect(screen.getByRole('link', { name: /about/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /contact/i })).toBeInTheDocument() })})2. 动态页面测试// app/posts/[slug]/__tests__/page.test.jsimport { render, screen, waitFor } from '@testing-library/react'import { rest } from 'msw'import { setupServer } from 'msw/node'import PostPage from '../page'const server = setupServer( rest.get('/api/posts/:slug', (req, res, ctx) => { const { slug } = req.params return res( ctx.json({ id: 1, slug, title: 'Test Post', content: 'This is a test post content', author: 'John Doe' }) ) }))beforeAll(() => server.listen())afterEach(() => server.resetHandlers())afterAll(() => server.close())describe('Post Page', () => { it('renders post content', async () => { const params = { slug: 'test-post' } render(<PostPage params={params} />) await waitFor(() => { expect(screen.getByText('Test Post')).toBeInTheDocument() expect(screen.getByText('This is a test post content')).toBeInTheDocument() }) }) it('displays author information', async () => { const params = { slug: 'test-post' } render(<PostPage params={params} />) await waitFor(() => { expect(screen.getByText(/by john doe/i)).toBeInTheDocument() }) })})API 路由测试1. GET 请求测试// app/api/users/__tests__/route.test.jsimport { GET } from '../route'import { NextRequest } from 'next/server'describe('/api/users GET endpoint', () => { it('returns list of users', async () => { const request = new NextRequest('http://localhost:3000/api/users') const response = await GET(request) expect(response.status).toBe(200) const data = await response.json() expect(Array.isArray(data)).toBe(true) expect(data.length).toBeGreaterThan(0) }) it('includes proper headers', async () => { const request = new NextRequest('http://localhost:3000/api/users') const response = await GET(request) expect(response.headers.get('content-type')).toBe('application/json') })})2. POST 请求测试// app/api/users/__tests__/route.test.jsimport { POST } from '../route'import { NextRequest } from 'next/server'describe('/api/users POST endpoint', () => { it('creates a new user', async () => { const userData = { name: 'John Doe', email: 'john@example.com' } const request = new NextRequest('http://localhost:3000/api/users', { method: 'POST', body: JSON.stringify(userData), headers: { 'Content-Type': 'application/json' } }) const response = await POST(request) expect(response.status).toBe(201) const data = await response.json() expect(data.id).toBeDefined() expect(data.name).toBe(userData.name) expect(data.email).toBe(userData.email) }) it('validates required fields', async () => { const request = new NextRequest('http://localhost:3000/api/users', { method: 'POST', body: JSON.stringify({ name: 'John' }), headers: { 'Content-Type': 'application/json' } }) const response = await POST(request) expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBeDefined() })})E2E 测试(Playwright)1. 基础 E2E 测试// e2e/home.spec.tsimport { test, expect } from '@playwright/test'test.describe('Home Page', () => { test('loads homepage successfully', async ({ page }) => { await page.goto('/') await expect(page).toHaveTitle(/My Next.js App/) await expect(page.getByText('Welcome')).toBeVisible() }) test('navigates to about page', async ({ page }) => { await page.goto('/') await page.click('text=About') await expect(page).toHaveURL('/about') await expect(page.getByText('About Us')).toBeVisible() }) test('search functionality works', async ({ page }) => { await page.goto('/') await page.fill('input[name="search"]', 'Next.js') await page.click('button[type="submit"]') await expect(page).toHaveURL('/search?q=Next.js') await expect(page.getByText('Search Results')).toBeVisible() })})2. 表单提交测试// e2e/login.spec.tsimport { test, expect } from '@playwright/test'test.describe('Login Flow', () => { test('successful login', async ({ page }) => { await page.goto('/login') await page.fill('input[name="email"]', 'test@example.com') await page.fill('input[name="password"]', 'password123') await page.click('button[type="submit"]') await expect(page).toHaveURL('/dashboard') await expect(page.getByText('Welcome back')).toBeVisible() }) test('shows error for invalid credentials', async ({ page }) => { await page.goto('/login') await page.fill('input[name="email"]', 'invalid@example.com') await page.fill('input[name="password"]', 'wrongpassword') await page.click('button[type="submit"]') await expect(page.getByText('Invalid credentials')).toBeVisible() await expect(page).toHaveURL('/login') })})3. 响应式测试// e2e/responsive.spec.tsimport { test, expect } from '@playwright/test'test.describe('Responsive Design', () => { test('mobile navigation works', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }) await page.goto('/') const menuButton = page.getByRole('button', { name: /menu/i }) await expect(menuButton).toBeVisible() await menuButton.click() await expect(page.getByText('Home')).toBeVisible() await expect(page.getByText('About')).toBeVisible() }) test('desktop navigation is always visible', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 720 }) await page.goto('/') await expect(page.getByText('Home')).toBeVisible() await expect(page.getByText('About')).toBeVisible() })})测试最佳实践1. 测试组织结构├── __tests__/│ ├── setup.js│ └── utils.js├── components/│ └── __tests__/│ ├── Button.test.js│ └── LoginForm.test.js├── app/│ ├── __tests__/│ │ └── page.test.js│ └── api/│ └── __tests__/│ └── route.test.js├── lib/│ └── __tests__/│ └── utils.test.js└── e2e/ ├── home.spec.ts ├── login.spec.ts └── responsive.spec.ts2. 测试覆盖率// vitest.config.tsimport { defineConfig } from 'vitest/config'import react from '@vitejs/plugin-react'export default defineConfig({ plugins: [react()], test: { coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'e2e/', '**/*.config.*', '**/*.test.*', '**/*.spec.*' ], thresholds: { lines: 80, functions: 80, branches: 75, statements: 80 } } }})3. 测试工具函数// __tests__/utils.jsimport { render } from '@testing-library/react'import { RouterContext } from 'next/dist/shared/lib/router-context'export function renderWithRouter(ui, { route = '/' } = {}) { return render( <RouterContext.Provider value={{ router: { asPath: route } }}> {ui} </RouterContext.Provider> )}export function waitForLoadingToFinish(screen) { return screen.findByText(/loading/i).then(() => { return screen.findByText(/loading/i).then(el => { return !el }) })}完整的测试策略包括单元测试、集成测试和端到端测试,确保 Next.js 应用的各个层面都得到充分验证。
阅读 0·2月17日 22:53

TailwindCSS 的 @apply 指令如何使用?有哪些最佳实践?

TailwindCSS 的 @apply 指令允许开发者在 CSS 文件中复用 TailwindCSS 的工具类,将多个工具类组合成一个可重用的类。这是一个强大的功能,但需要谨慎使用。@apply 指令基础基本语法/* 在 CSS 文件中使用 @apply */.btn { @apply bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded;}.card { @apply bg-white rounded-lg shadow-md p-6;}在 HTML 中使用<!-- 使用定义的类 --><button class="btn">点击按钮</button><div class="card"> <h3>卡片标题</h3> <p>卡片内容</p></div>使用场景1. 创建可复用组件/* 按钮组件 */.btn { @apply px-4 py-2 rounded font-bold transition-colors duration-200;}.btn-primary { @apply bg-blue-500 hover:bg-blue-600 text-white;}.btn-secondary { @apply bg-gray-200 hover:bg-gray-300 text-gray-800;}.btn-danger { @apply bg-red-500 hover:bg-red-600 text-white;}2. 表单元素样式/* 输入框样式 */.input { @apply w-full px-4 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;}.input-error { @apply border-red-500 focus:ring-red-500;}/* 文本域 */.textarea { @apply w-full px-4 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none;}3. 布局容器/* 容器样式 */.container { @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;}/* 网格布局 */.grid-container { @apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;}/* Flex 布局 */.flex-center { @apply flex items-center justify-center;}高级用法1. 响应式 @apply/* 响应式按钮 */.responsive-btn { @apply px-4 py-2 text-sm; @apply md:px-6 md:py-3 md:text-base; @apply lg:px-8 lg:py-4 lg:text-lg;}/* 响应式网格 */.responsive-grid { @apply grid grid-cols-1 gap-4; @apply md:grid-cols-2 md:gap-6; @apply lg:grid-cols-3 lg:gap-8;}2. 状态变体/* 带状态的按钮 */.interactive-btn { @apply bg-blue-500 hover:bg-blue-600 active:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;}/* 表单输入状态 */.form-input { @apply border-gray-300 focus:border-blue-500 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed;}3. 条件组合/* 条件样式 */.conditional-card { @apply bg-white rounded-lg shadow-md p-6;}.conditional-card.active { @apply ring-2 ring-blue-500;}.conditional-card.disabled { @apply opacity-50 cursor-not-allowed;}与 CSS 变量结合1. 使用 CSS 变量/* 定义 CSS 变量 */:root { --primary-color: #3b82f6; --secondary-color: #10b981; --spacing-md: 1rem;}/* 在 @apply 中使用 */.variable-btn { @apply px-[var(--spacing-md)] py-[var(--spacing-md)] rounded; background-color: var(--primary-color);}.variable-btn:hover { background-color: var(--secondary-color);}2. 动态主题/* 主题变量 */[data-theme="dark"] { --bg-color: #1f2937; --text-color: #f9fafb;}[data-theme="light"] { --bg-color: #ffffff; --text-color: #1f2937;}/* 主题化组件 */.themed-card { @apply rounded-lg shadow-md p-6; background-color: var(--bg-color); color: var(--text-color);}最佳实践1. 何时使用 @apply适合使用 @apply 的场景:需要在多个地方重复使用的复杂样式组合创建语义化的组件类名减少重复的工具类提高代码可读性不适合使用 @apply 的场景:只使用一次的样式简单的样式组合需要频繁调整的样式2. 命名规范/* 使用语义化命名 *//* ✅ 推荐 */.user-avatar { @apply w-12 h-12 rounded-full object-cover;}.nav-link { @apply px-4 py-2 hover:bg-gray-100 transition-colors;}/* ❌ 不推荐 */.style1 { @apply w-12 h-12 rounded-full;}.blue-button { @apply bg-blue-500 text-white;}3. 保持简洁/* ✅ 推荐:简洁的组合 */.card { @apply bg-white rounded-lg shadow-md p-6;}/* ❌ 不推荐:过度使用 @apply */.card { @apply bg-white; @apply rounded-lg; @apply shadow-md; @apply p-6; @apply hover:shadow-lg; @apply transition-shadow; @apply duration-300;}常见问题1. @apply 与内联类名的选择<!-- 使用 @apply 定义的类 --><button class="btn-primary">按钮</button><!-- 直接使用工具类 --><button class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded"> 按钮</button>选择原则:重复使用的样式 → 使用 @apply一次性样式 → 使用工具类需要语义化命名 → 使用 @apply快速原型开发 → 使用工具类2. @apply 的性能影响/* @apply 会在编译时展开为实际的 CSS */.btn { @apply bg-blue-500 hover:bg-blue-600 text-white;}/* 编译后相当于 */.btn { background-color: #3b82f6;}.btn:hover { background-color: #2563eb;}.btn { color: white;}性能考虑:@apply 本身不会影响运行时性能编译时会展开为实际的 CSS 规则过度使用可能导致 CSS 文件增大合理使用可以提高代码可维护性3. 与 CSS 模块结合/* 在 CSS 模块中使用 @apply */.button { @apply px-4 py-2 rounded font-bold transition-colors;}.button--primary { @apply bg-blue-500 hover:bg-blue-600 text-white;}.button--secondary { @apply bg-gray-200 hover:bg-gray-300 text-gray-800;}注意事项编译时展开:@apply 在编译时展开,不是运行时动态生成优先级问题:@apply 生成的样式可能与直接编写的 CSS 冲突可维护性:过度使用 @apply 可能降低代码可读性团队协作:确保团队对 @apply 的使用达成共识性能监控:定期检查生成的 CSS 文件大小总结@apply 指令是 TailwindCSS 的一个强大功能,可以帮助开发者创建可复用的样式组合。正确使用 @apply 可以:提高代码可读性和可维护性减少重复代码创建语义化的组件类名保持 HTML 的简洁性但需要注意:不要过度使用 @apply保持命名语义化确保团队协作的一致性监控 CSS 文件大小
阅读 0·2月17日 22:53

Next.js 与微前端架构如何结合使用?

Next.js 与微前端架构的结合是构建大型企业级应用的重要方案。微前端架构允许将大型应用拆分为多个独立开发、部署和维护的小型前端应用。微前端架构概述1. 微前端核心概念微前端是一种架构风格,将前端应用分解为更小、更简单的块,这些块可以由不同的团队独立开发和部署。核心优势:独立开发和部署技术栈无关增量升级团队自治代码隔离Next.js 微前端实现方案1. Module Federation(模块联邦)// next.config.js - 主应用配置const NextFederationPlugin = require('@module-federation/nextjs-mf');module.exports = { webpack(config, options) { const { isServer } = options; config.plugins.push( new NextFederationPlugin({ name: 'main_app', filename: 'static/chunks/remoteEntry.js', remotes: { productApp: 'product_app@https://product.example.com/_next/static/chunks/remoteEntry.js', cartApp: 'cart_app@https://cart.example.com/_next/static/chunks/remoteEntry.js', userApp: 'user_app@https://user.example.com/_next/static/chunks/remoteEntry.js', }, shared: { react: { singleton: true, requiredVersion: false, }, 'react-dom': { singleton: true, requiredVersion: false, }, next: { singleton: true, requiredVersion: false, }, }, extraOptions: { automaticAsyncBoundary: true, }, }) ); return config; },};// next.config.js - 子应用配置(productApp)const NextFederationPlugin = require('@module-federation/nextjs-mf');module.exports = { webpack(config, options) { const { isServer } = options; config.plugins.push( new NextFederationPlugin({ name: 'product_app', filename: 'static/chunks/remoteEntry.js', exposes: { './ProductList': './components/ProductList', './ProductDetail': './components/ProductDetail', './ProductSearch': './components/ProductSearch', }, shared: { react: { singleton: true, requiredVersion: false, }, 'react-dom': { singleton: true, requiredVersion: false, }, next: { singleton: true, requiredVersion: false, }, }, }) ); return config; },};// 主应用中使用远程组件// app/products/page.js'use client';import dynamic from 'next/dynamic';const ProductList = dynamic(() => import('productApp/ProductList'), { loading: () => <div>Loading products...</div>, ssr: false,});const ProductSearch = dynamic(() => import('productApp/ProductSearch'), { loading: () => <div>Loading search...</div>, ssr: false,});export default function ProductsPage() { return ( <div> <h1>Products</h1> <ProductSearch /> <ProductList /> </div> );}2. iframe 方案// components/IframeWrapper.js'use client';import { useState, useEffect, useRef } from 'react';export default function IframeWrapper({ src, title, onMessage }) { const iframeRef = useRef(null); const [isLoaded, setIsLoaded] = useState(false); useEffect(() => { const iframe = iframeRef.current; const handleMessage = (event) => { // 验证消息来源 if (event.origin !== new URL(src).origin) return; onMessage?.(event.data); }; window.addEventListener('message', handleMessage); return () => { window.removeEventListener('message', handleMessage); }; }, [src, onMessage]); const handleLoad = () => { setIsLoaded(true); }; const sendMessage = (message) => { if (iframeRef.current && iframeRef.current.contentWindow) { iframeRef.current.contentWindow.postMessage(message, new URL(src).origin); } }; return ( <div className="iframe-container"> {!isLoaded && <div className="loading">Loading...</div>} <iframe ref={iframeRef} src={src} title={title} onLoad={handleLoad} style={{ border: 'none', width: '100%', height: '100%', display: isLoaded ? 'block' : 'none' }} allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" /> </div> );}// 使用 iframe 集成子应用// app/dashboard/page.js'use client';import IframeWrapper from '@/components/IframeWrapper';export default function DashboardPage() { const handleMessage = (data) => { console.log('Message from iframe:', data); if (data.type === 'NAVIGATION') { // 处理导航事件 } else if (data.type === 'AUTH') { // 处理认证事件 } }; return ( <div className="dashboard"> <nav> <a href="/">Home</a> <a href="/dashboard">Dashboard</a> </nav> <main> <IframeWrapper src="https://cart.example.com" title="Shopping Cart" onMessage={handleMessage} /> </main> </div> );}3. Web Components 方案// components/MicroFrontendWrapper.js'use client';import { useEffect, useRef } from 'react';export default function MicroFrontendWrapper({ name, host, history, onNavigate, onUnmount}) { const ref = useRef(null); useEffect(() => { const scriptId = `micro-frontend-script-${name}`; const renderMicroFrontend = () => { window[name] = { mount: (container, history) => { console.log(`Mounting ${name}`); // 调用子应用的 mount 方法 }, unmount: (container) => { console.log(`Unmounting ${name}`); onUnmount?.(); }, }; if (window[name] && window[name].mount) { window[name].mount(ref.current, history); } }; const loadScript = () => { if (document.getElementById(scriptId)) { renderMicroFrontend(); return; } const script = document.createElement('script'); script.id = scriptId; script.src = `${host}/main.js`; script.onload = renderMicroFrontend; document.head.appendChild(script); }; loadScript(); return () => { if (window[name] && window[name].unmount) { window[name].unmount(ref.current); } }; }, [name, host, history, onUnmount]); return <div ref={ref} />;}// 使用 Web Components 集成// app/micro/page.js'use client';import MicroFrontendWrapper from '@/components/MicroFrontendWrapper';export default function MicroFrontendPage() { const handleNavigate = (location) => { console.log('Navigate to:', location); window.history.pushState({}, '', location); }; const handleUnmount = () => { console.log('Micro frontend unmounted'); }; return ( <div> <h1>Micro Frontend Integration</h1> <MicroFrontendWrapper name="productApp" host="https://product.example.com" history={window.history} onNavigate={handleNavigate} onUnmount={handleUnmount} /> </div> );}4. 单体仓库(Monorepo)方案// 使用 Turborepo 管理 monorepo// turbo.json{ "$schema": "https://turbo.build/schema.json", "globalDependencies": ["**/.env.*local"], "pipeline": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "dev": { "cache": false, "persistent": true }, "lint": { "dependsOn": ["^lint"] }, "test": { "dependsOn": ["^build"], "outputs": ["coverage/**"] } }}// pnpm-workspace.yamlpackages: - 'apps/*' - 'packages/*'// 目录结构// apps/// main-app/ # 主应用// product-app/ # 产品子应用// cart-app/ # 购物车子应用// user-app/ # 用户子应用// packages/// ui/ # 共享 UI 组件// utils/ # 共享工具函数// types/ # 共享类型定义// config/ # 共享配置// apps/main-app/package.json{ "name": "main-app", "dependencies": { "next": "^14.0.0", "react": "^18.0.0", "react-dom": "^18.0.0", "@workspace/ui": "workspace:*", "@workspace/utils": "workspace:*" }}// apps/product-app/package.json{ "name": "product-app", "dependencies": { "next": "^14.0.0", "react": "^18.0.0", "react-dom": "^18.0.0", "@workspace/ui": "workspace:*", "@workspace/utils": "workspace:*" }}状态管理和通信1. 跨应用状态管理// packages/shared-state/src/store.jsimport { createStore } from 'zustand/vanilla';export const createSharedStore = (initialState) => { return createStore((set, get) => ({ ...initialState, update: (key, value) => set({ [key]: value }), reset: () => set(initialState), }));};// 创建共享状态export const userStore = createSharedStore({ user: null, isAuthenticated: false, cart: [],});export const productStore = createSharedStore({ products: [], filters: {}, sortBy: 'name',});// 主应用中使用// app/layout.js'use client';import { userStore } from '@workspace/shared-state';import { useEffect } from 'react';export default function RootLayout({ children }) { useEffect(() => { // 监听用户状态变化 const unsubscribe = userStore.subscribe((state) => { console.log('User state changed:', state); // 通知其他应用 window.postMessage({ type: 'USER_STATE_CHANGE', state }, '*'); }); return () => unsubscribe(); }, []); return ( <html lang="en"> <body>{children}</body> </html> );}// 子应用中使用// product-app/components/UserInfo.js'use client';import { userStore } from '@workspace/shared-state';import { useEffect, useState } from 'react';export default function UserInfo() { const [user, setUser] = useState(null); useEffect(() => { // 订阅用户状态 const unsubscribe = userStore.subscribe((state) => { setUser(state.user); }); return () => unsubscribe(); }, []); if (!user) { return <div>Please login</div>; } return <div>Welcome, {user.name}</div>;}2. 事件总线通信// packages/event-bus/src/index.jsclass EventBus { constructor() { this.events = {}; } on(event, callback) { if (!this.events[event]) { this.events[event] = []; } this.events[event].push(callback); } off(event, callback) { if (!this.events[event]) return; this.events[event] = this.events[event].filter(cb => cb !== callback); } emit(event, data) { if (!this.events[event]) return; this.events[event].forEach(callback => { callback(data); }); } once(event, callback) { const onceCallback = (data) => { callback(data); this.off(event, onceCallback); }; this.on(event, onceCallback); }}export const eventBus = new EventBus();// 定义事件类型export const Events = { USER_LOGIN: 'USER_LOGIN', USER_LOGOUT: 'USER_LOGOUT', CART_UPDATE: 'CART_UPDATE', PRODUCT_ADD: 'PRODUCT_ADD', NAVIGATION: 'NAVIGATION',};// 主应用中监听事件// app/_components/EventListeners.js'use client';import { useEffect } from 'react';import { eventBus, Events } from '@workspace/event-bus';import { useRouter } from 'next/navigation';export default function EventListeners() { const router = useRouter(); useEffect(() => { const handleNavigation = (data) => { console.log('Navigation event:', data); router.push(data.path); }; const handleCartUpdate = (data) => { console.log('Cart updated:', data); // 更新购物车 UI }; eventBus.on(Events.NAVIGATION, handleNavigation); eventBus.on(Events.CART_UPDATE, handleCartUpdate); return () => { eventBus.off(Events.NAVIGATION, handleNavigation); eventBus.off(Events.CART_UPDATE, handleCartUpdate); }; }, [router]); return null;}// 子应用中发送事件// product-app/components/AddToCart.js'use client';import { eventBus, Events } from '@workspace/event-bus';export default function AddToCart({ product }) { const handleAddToCart = () => { eventBus.emit(Events.PRODUCT_ADD, { product }); eventBus.emit(Events.CART_UPDATE, { type: 'ADD', product }); }; return ( <button onClick={handleAddToCart}> Add to Cart </button> );}样式隔离1. CSS Modules 隔离// product-app/components/ProductCard.module.css.productCard { border: 1px solid #ddd; padding: 16px; border-radius: 8px; background: white;}.productCard__title { font-size: 18px; font-weight: bold; margin-bottom: 8px;}.productCard__price { color: #e44d26; font-size: 20px; font-weight: bold;}// product-app/components/ProductCard.jsimport styles from './ProductCard.module.css';export default function ProductCard({ product }) { return ( <div className={styles.productCard}> <h3 className={styles.productCard__title}>{product.name}</h3> <p className={styles.productCard__price}>${product.price}</p> </div> );}2. CSS-in-JS 隔离// product-app/components/ProductCard.js'use client';import styled from 'styled-components';const Card = styled.div` border: 1px solid #ddd; padding: 16px; border-radius: 8px; background: white;`;const Title = styled.h3` font-size: 18px; font-weight: bold; margin-bottom: 8px;`;const Price = styled.p` color: #e44d26; font-size: 20px; font-weight: bold;`;export default function ProductCard({ product }) { return ( <Card> <Title>{product.name}</Title> <Price>${product.price}</Price> </Card> );}3. Shadow DOM 隔离// components/ShadowDOMWrapper.js'use client';import { useEffect, useRef } from 'react';export default function ShadowDOMWrapper({ children, styles }) { const containerRef = useRef(null); const shadowRootRef = useRef(null); useEffect(() => { if (!containerRef.current) return; // 创建 Shadow DOM shadowRootRef.current = containerRef.current.attachShadow({ mode: 'open' }); // 添加样式 if (styles) { const styleElement = document.createElement('style'); styleElement.textContent = styles; shadowRootRef.current.appendChild(styleElement); } // 添加内容 const content = document.createElement('div'); content.className = 'shadow-content'; shadowRootRef.current.appendChild(content); return () => { if (shadowRootRef.current) { containerRef.current.removeChild(shadowRootRef.current); } }; }, [styles]); useEffect(() => { if (shadowRootRef.current) { const content = shadowRootRef.current.querySelector('.shadow-content'); if (content) { // 使用 ReactDOM 渲染到 Shadow DOM import('react-dom/client').then(({ createRoot }) => { const root = createRoot(content); root.render(children); }); } } }, [children]); return <div ref={containerRef} />;}// 使用 Shadow DOM// app/micro/page.js'use client';import ShadowDOMWrapper from '@/components/ShadowDOMWrapper';const shadowStyles = ` .product-card { border: 1px solid #ddd; padding: 16px; border-radius: 8px; background: white; } .product-title { font-size: 18px; font-weight: bold; }`;export default function MicroFrontendPage() { return ( <ShadowDOMWrapper styles={shadowStyles}> <div className="product-card"> <h3 className="product-title">Product Name</h3> <p>$99.99</p> </div> </ShadowDOMWrapper> );}部署策略1. 独立部署// Vercel 配置 - 主应用// vercel.json{ "framework": "nextjs", "buildCommand": "pnpm build", "outputDirectory": ".next", "routes": [ { "src": "/(.*)", "dest": "/$1" } ]}// Vercel 配置 - 子应用// product-app/vercel.json{ "framework": "nextjs", "buildCommand": "pnpm build", "outputDirectory": ".next", "routes": [ { "src": "/(.*)", "dest": "/$1" } ]}// Docker 部署配置// DockerfileFROM node:18-alpine AS base# 依赖安装FROM base AS depsWORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN npm install -g pnpm && pnpm install --frozen-lockfile# 构建FROM base AS builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN pnpm build# 运行FROM base AS runnerWORKDIR /appENV NODE_ENV productionCOPY --from=builder /app/public ./publicCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/staticEXPOSE 3000CMD ["node", "server.js"]2. CI/CD 流程// .github/workflows/deploy.ymlname: Deployon: push: branches: [main]jobs: deploy-main: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install pnpm uses: pnpm/action-setup@v2 with: version: 8 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build run: pnpm --filter main-app build - name: Deploy to Vercel uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.ORG_ID }} vercel-project-id: ${{ secrets.PROJECT_ID }} working-directory: ./apps/main-app deploy-product: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install pnpm uses: pnpm/action-setup@v2 with: version: 8 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build run: pnpm --filter product-app build - name: Deploy to Vercel uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.ORG_ID }} vercel-project-id: ${{ secrets.PRODUCT_PROJECT_ID }} working-directory: ./apps/product-app最佳实践选择合适的方案: Module Federation 适合技术栈统一的项目,iframe 适合完全隔离的场景共享依赖: 使用 monorepo 管理共享代码和依赖状态管理: 使用事件总线或共享状态管理跨应用通信样式隔离: 使用 CSS Modules、CSS-in-JS 或 Shadow DOM 避免样式冲突独立部署: 每个子应用独立构建和部署版本管理: 使用语义化版本管理子应用依赖监控和日志: 统一监控和日志收集性能优化: 按需加载子应用,避免重复依赖测试策略: 集成测试覆盖跨应用场景文档和规范: 建立清晰的开发规范和文档Next.js 与微前端架构的结合为企业级应用提供了灵活、可扩展的解决方案。
阅读 0·2月17日 22:52

npm生态里有哪些实用工具?它们如何提升开发效率?

npm 生态系统包含许多第三方工具和插件,可以扩展 npm 的功能。了解这些工具可以帮助开发者更高效地管理项目。包管理工具1. npm-check-updates检查并更新 package.json 中的依赖版本。# 安装npm install -g npm-check-updates# 检查更新ncu# 更新 package.jsonncu -u# 检查特定类型的依赖ncu --dep prodncu --dep devncu --dep dev,peer# 使用特定注册表ncu --registry https://registry.npmmirror.com输出示例:Checking package.json[====================] Severity:minor lodash ^4.17.20 → ^4.17.21minor express ^4.17.1 → ^4.18.0major react ^17.0.0 → ^18.0.0Run ncu -u to upgrade package.json2. npm-check交互式检查包的状态和更新。# 安装npm install -g npm-check# 检查包npm-check# 交互式更新npm-check -u# 跳过更新npm-check -y# 忽略特定包npm-check --ignore-unused3. depcheck检查未使用的依赖和缺失的依赖。# 安装npm install -g depcheck# 检查未使用的依赖depcheck# 忽略特定包depcheck --ignore-missing=package-name# 使用自定义配置depcheck --config depcheck-config.json# 检查特定目录depcheck ./src输出示例:Unused dependencies* lodash* momentUnused devDependencies* eslintMissing dependencies* axios (used in src/api.js)安全工具1. Snyk强大的安全漏洞扫描和修复工具。# 安装npm install -g snyk# 认证snyk auth# 扫描漏洞snyk test# 修复漏洞snyk wizard# 监控项目snyk monitor# CI/CD 集成snyk test --severity-threshold=highGitHub Actions 集成:- name: Run Snyk to check for vulnerabilities uses: snyk/actions/node@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}2. npm audit fixnpm 内置的安全修复工具。# 自动修复npm audit fix# 强制修复npm audit fix --force# 只修复生产依赖npm audit fix --production# 查看修复详情npm audit fix --dry-run3. retire.js检查 JavaScript 库中的已知漏洞。# 安装npm install -g retire# 扫描项目retire --path <project-path># 输出 JSON 格式retire --outputformat json# 使用自定义数据库retire --outputpath <custom-db>性能工具1. npm-packlist确定哪些文件会被包含在发布的包中。# 安装npm install -g npm-packlist# 列出文件npm-packlist# 输出 JSON 格式npm-packlist --json# 检查特定包npm-packlist <package-path>2. bundlephobia分析包的大小和性能影响。# 在线使用# 访问 https://bundlephobia.com/# 查看包的大小# 输入包名,如 "lodash"3. cost-of-modules计算项目依赖的维护成本。# 安装npm install -g cost-of-modules# 计算成本cost-of-modules# 输出 JSON 格式cost-of-modules --json# 按成本排序cost-of-modules --sort代码质量工具1. npm-check-updates除了检查更新,还可以帮助维护代码质量。# 检查更新并自动更新ncu -u# 只更新补丁版本ncu -u --target patch# 只更新次版本ncu -u --target minor2. npm-run-all并行或顺序运行多个 npm scripts。# 安装npm install -g npm-run-all# 并行运行run-p lint test# 顺序运行run-s clean build test# 混合运行run-s clean run-p lint testpackage.json 示例:{ "scripts": { "clean": "rimraf dist", "lint": "eslint src/", "test": "jest", "build": "webpack", "all": "run-s clean run-p lint test build" }}3. concurrently同时运行多个命令。# 安装npm install -g concurrently# 运行多个命令concurrently "npm run dev" "npm run test:watch"# 使用前缀concurrently --names "API,WEB" --prefix-colors "blue,green" "npm run api" "npm run web"# 成功时杀死其他进程concurrently --kill-others "npm run dev" "npm run test"文档工具1. jsdoc生成 JavaScript 文档。# 安装npm install -g jsdoc# 生成文档jsdoc src/# 使用配置文件jsdoc -c jsdoc.conf.json# 输出到特定目录jsdoc src/ -d docs/2. documentation.js现代 JavaScript 文档生成器。# 安装npm install -g documentation# 生成文档documentation build src/ -f html -o docs/# 生成 Markdowndocumentation build src/ -f md > API.md# 生成 JSONdocumentation build src/ -f json > api.json测试工具1. nycIstanbul 的命令行界面,用于代码覆盖率。# 安装npm install -g nyc# 运行测试并生成覆盖率nyc npm test# 生成报告nyc report --reporter=html# 覆盖率阈值nyc --check-coverage --lines 80 npm test2. testdouble用于测试的替身(test double)库。# 安装npm install -g testdouble# 创建替身const td = require('testdouble');const fn = td.function();fn('arg1', 'arg2');td.verify(fn('arg1', 'arg2'));构建工具1. webpack现代 JavaScript 应用程序的模块打包器。# 安装npm install -g webpack webpack-cli# 构建webpack# 使用配置文件webpack --config webpack.config.js# 监听模式webpack --watch# 生产模式webpack --mode production2. rollup下一代 JavaScript 模块打包器。# 安装npm install -g rollup# 构建rollup src/main.js -f cjs -o bundle.js# 使用配置文件rollup -c rollup.config.js# 监听模式rollup -c -w开发工具1. nodemon自动重启 Node.js 应用程序。# 安装npm install -g nodemon# 运行应用nodemon app.js# 监听特定文件nodemon --watch src/ app.js# 忽略特定文件nodemon --ignore tests/ app.js# 延迟重启nodemon --delay 2 app.jspackage.json 示例:{ "scripts": { "dev": "nodemon app.js", "dev:debug": "nodemon --inspect app.js" }}2. live-server简单的开发服务器,支持热重载。# 安装npm install -g live-server# 启动服务器live-server# 指定端口live-server --port=8080# 指定根目录live-server --root=dist/# 忽略特定文件live-server --ignore=node_modules/发布工具1. np更好的 npm 发布工具。# 安装npm install -g np# 发布np# 发布特定版本np 1.2.3# 发布到特定标签np --tag beta# 跳过测试np --yolo# 跳过 Git 提交np --no-cleanup2. semantic-release自动化版本管理和发布。# 安装npm install -g semantic-release# 配置# .releaserc.json{ "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/npm", "@semantic-release/github" ]}CI/CD 工具1. huskyGit hooks 管理工具。# 安装npm install -g husky# 初始化husky install# 添加 hookhusky add .husky/pre-commit "npm test"# 添加 commit-msg hookhusky add .husky/commit-msg 'commitlint -E HUSKY_GIT_PARAMS'package.json 示例:{ "scripts": { "prepare": "husky install" }}2. lint-staged对暂存的文件运行 linter。# 安装npm install -g lint-staged# 配置# .lintstagedrc.json{ "*.js": ["eslint --fix", "git add"], "*.css": ["stylelint --fix", "git add"]}package.json 示例:{ "husky": { "hooks": { "pre-commit": "lint-staged" } }}最佳实践1. 选择合适的工具包管理:npm-check-updates、npm-check安全:Snyk、npm audit性能:bundlephobia、cost-of-modules测试:nyc、testdouble构建:webpack、rollup开发:nodemon、live-server2. 集成到工作流# 在 package.json 中添加脚本{ "scripts": { "check": "npm-check", "update": "ncu -u", "audit": "snyk test", "test:coverage": "nyc npm test" }}3. 定期运行# 每周运行一次更新检查ncu# 每次提交前运行安全审计npm audit# 每次构建前运行代码检查npm run lint4. 文档化工具使用在 README 中说明项目使用的工具:## Development ToolsThis project uses the following tools:- **npm-check-updates**: Check for dependency updates- **Snyk**: Security vulnerability scanning- **nodemon**: Auto-restart development server- **webpack**: Module bundler掌握这些 npm 生态系统工具可以显著提高开发效率和代码质量。
阅读 0·2月17日 22:52