普通视图
TS 项目升级 React 18 到 19 的一些事情
React - 【useEffect 与 useLayoutEffect】 区别 及 使用场景
Qt 6 实战:C++ 调用 QML 回调方法(异步场景完整实现)
kotlin-5
太棒了!现在让我们进入 Kotlin 更深入的高级特性和实际应用场景。这部分将涵盖泛型、注解、反射、DSL等企业级开发必备技能。
Kotlin 高级特性:企业级开发实战
三十二、泛型深入:型变、星投影与 reified
1. 型变(Variance)
// 不变(Invariant)- 默认行为
class Box<T>(val value: T)
fun main() {
val stringBox = Box("Hello")
// val anyBox: Box<Any> = stringBox // 错误!Box<String> 不是 Box<Any> 的子类型
}
// 协变(Covariant)- 使用 out 关键字
class ReadOnlyBox<out T>(val value: T) {
fun get(): T = value
// fun set(newValue: T) {} // 错误!不能有消费 T 的方法
}
// 逆变(Contravariant)- 使用 in 关键字
class WriteOnlyBox<in T> {
fun set(value: T) {
println("设置值: $value")
}
// fun get(): T // 错误!不能有生产 T 的方法
}
fun testVariance() {
// 协变示例
val stringBox: ReadOnlyBox<String> = ReadOnlyBox("Text")
val anyBox: ReadOnlyBox<Any> = stringBox // 正确!因为 out 关键字
// 逆变示例
val anyWriteBox: WriteOnlyBox<Any> = WriteOnlyBox()
val stringWriteBox: WriteOnlyBox<String> = anyWriteBox // 正确!因为 in 关键字
}
2. 星投影(Star Projection)
fun printSize(list: List<*>) {
println("列表大小: ${list.size}")
// println(list[0]) // 不能安全地访问元素内容
}
fun <T> compareFirstSecond(list: List<T>, comparator: Comparator<in T>): Boolean
where T : Comparable<T> {
return if (list.size >= 2) {
comparator.compare(list[0], list[1]) > 0
} else false
}
// 使用 reified 关键字实现类型检查
inline fun <reified T> checkType(value: Any): Boolean {
return value is T
}
fun main() {
val list = listOf(1, 2, 3)
printSize(list)
println(checkType<String>("Hello")) // true
println(checkType<Int>("Hello")) // false
}
三十三、注解与反射
1. 自定义注解
// 定义注解
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class ApiVersion(val version: String)
@Target(AnnotationTarget.PROPERTY)
annotation class JsonExclude
@Target(AnnotationTarget.PROPERTY)
annotation class JsonName(val name: String)
// 使用注解
@ApiVersion("1.0")
data class User(
@JsonName("user_name")
val name: String,
val age: Int,
@JsonExclude
val password: String
)
// 注解处理器模拟
fun processAnnotations(obj: Any) {
val klass = obj::class
val apiVersion = klass.annotations.find { it is ApiVersion } as? ApiVersion
apiVersion?.let { println("API 版本: ${it.version}") }
klass.memberProperties.forEach { prop ->
prop.annotations.forEach { annotation ->
when (annotation) {
is JsonName -> println("属性 ${prop.name} 将被序列化为 ${annotation.name}")
is JsonExclude -> println("属性 ${prop.name} 将被排除")
}
}
}
}
2. 反射实战
import kotlin.reflect.full.*
import kotlin.reflect.jvm.isAccessible
class SecretClass {
private val secretValue = "机密信息"
private fun secretMethod() = "机密方法"
public fun publicMethod() = "公开方法"
}
fun reflectExample() {
val instance = SecretClass()
val klass = instance::class
println("=== 类信息 ===")
println("类名: ${klass.simpleName}")
println("成员属性: ${klass.memberProperties.map { it.name }}")
println("成员函数: ${klass.memberFunctions.map { it.name }}")
println("\n=== 访问私有成员 ===")
// 访问私有属性
val secretProperty = klass.memberProperties.find { it.name == "secretValue" }
secretProperty?.let {
it.isAccessible = true
println("私有属性值: ${it.get(instance)}")
}
// 调用私有方法
val secretMethod = klass.memberFunctions.find { it.name == "secretMethod" }
secretMethod?.let {
it.isAccessible = true
println("私有方法结果: ${it.call(instance)}")
}
}
三十四、类型安全的构建器(DSL)
1. HTML DSL 构建器
class HtmlElement(val name: String, val content: String = "") {
private val children = mutableListOf<HtmlElement>()
private val attributes = mutableMapOf<String, String>()
fun attribute(name: String, value: String) {
attributes[name] = value
}
fun child(element: HtmlElement) {
children.add(element)
}
override fun toString(): String {
val attrString = if (attributes.isNotEmpty()) {
" " + attributes.entries.joinToString(" ") { "${it.key}="${it.value}"" }
} else ""
return if (children.isEmpty()) {
"<$name$attrString>$content</$name>"
} else {
"<$name$attrString>${children.joinToString("")}$content</$name>"
}
}
}
// DSL 构建函数
fun html(block: HtmlBuilder.() -> Unit): HtmlElement {
val builder = HtmlBuilder("html")
builder.block()
return builder.build()
}
class HtmlBuilder(private val rootName: String) {
private val root = HtmlElement(rootName)
private var currentElement: HtmlElement = root
fun head(block: HeadBuilder.() -> Unit) {
val headBuilder = HeadBuilder()
headBuilder.block()
root.child(headBuilder.build())
}
fun body(block: BodyBuilder.() -> Unit) {
val bodyBuilder = BodyBuilder()
bodyBuilder.block()
root.child(bodyBuilder.build())
}
fun build(): HtmlElement = root
}
class HeadBuilder {
private val head = HtmlElement("head")
fun title(text: String) {
head.child(HtmlElement("title", text))
}
fun build(): HtmlElement = head
}
class BodyBuilder {
private val body = HtmlElement("body")
fun h1(text: String, block: H1Builder.() -> Unit = {}) {
val h1Builder = H1Builder(text)
h1Builder.block()
body.child(h1Builder.build())
}
fun p(text: String) {
body.child(HtmlElement("p", text))
}
fun build(): HtmlElement = body
}
class H1Builder(private val text: String) {
private val h1 = HtmlElement("h1", text)
fun style(css: String) {
h1.attribute("style", css)
}
fun build(): HtmlElement = h1
}
// 使用 DSL
fun createHtmlPage() {
val page = html {
head {
title("我的网页")
}
body {
h1("欢迎来到 Kotlin DSL") {
style("color: blue;")
}
p("这是一个使用 DSL 构建的 HTML 页面")
}
}
println(page)
}
2. 数据库查询 DSL
// 查询 DSL 定义
class QueryBuilder {
private var table: String = ""
private val conditions = mutableListOf<String>()
private var limit: Int? = null
private var orderBy: String? = null
fun from(table: String) {
this.table = table
}
fun where(condition: String) {
conditions.add(condition)
}
fun limit(count: Int) {
this.limit = count
}
fun orderBy(column: String) {
this.orderBy = column
}
fun build(): String {
val query = StringBuilder("SELECT * FROM $table")
if (conditions.isNotEmpty()) {
query.append(" WHERE ").append(conditions.joinToString(" AND "))
}
orderBy?.let {
query.append(" ORDER BY $it")
}
limit?.let {
query.append(" LIMIT $it")
}
return query.toString()
}
}
// DSL 入口函数
fun query(block: QueryBuilder.() -> Unit): String {
val builder = QueryBuilder()
builder.block()
return builder.build()
}
// 使用 DSL 构建查询
fun buildQueries() {
val simpleQuery = query {
from("users")
}
println("简单查询: $simpleQuery")
val complexQuery = query {
from("orders")
where("status = 'completed'")
where("amount > 100")
orderBy("created_at DESC")
limit(10)
}
println("复杂查询: $complexQuery")
}
三十五、合约(Contracts)与内联优化
import kotlin.contracts.*
// 使用合约优化智能转换
@OptIn(ExperimentalContracts::class)
fun String?.isNotNullOrEmpty(): Boolean {
contract {
returns(true) implies (this@isNotNullOrEmpty != null)
}
return this != null && this.isNotEmpty()
}
fun processText(text: String?) {
if (text.isNotNullOrEmpty()) {
// 由于合约,编译器知道 text 不为 null
println(text.length) // 不需要安全调用
println(text.uppercase())
}
}
// 内联类 - 包装类型而不产生运行时开销
@JvmInline
value class Password(private val value: String) {
init {
require(value.length >= 8) { "密码必须至少8个字符" }
}
fun mask(): String = "*".repeat(value.length)
}
@JvmInline
value class UserId(val value: Int)
fun login(userId: UserId, password: Password) {
println("用户 ${userId.value} 登录,密码: ${password.mask()}")
}
三十六、多平台项目(KMP)基础
// 公共模块 - 共享业务逻辑
expect class Platform() {
val platform: String
}
class Greeting {
private val platform = Platform()
fun greet(): String {
return "Hello from ${platform.platform}"
}
}
// Android 实现
// androidMain/Platform.kt
actual class Platform actual constructor() {
actual val platform: String = "Android"
}
// iOS 实现
// iosMain/Platform.kt
actual class Platform actual constructor() {
actual val platform: String = "iOS"
}
// 共享业务逻辑
class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun multiply(a: Int, b: Int): Int = a * b
fun calculateExpression(expression: String): Int {
return when {
expression.contains("+") -> {
val parts = expression.split("+")
add(parts[0].trim().toInt(), parts[1].trim().toInt())
}
expression.contains("*") -> {
val parts = expression.split("*")
multiply(parts[0].trim().toInt(), parts[1].trim().toInt())
}
else -> throw IllegalArgumentException("不支持的表达式")
}
}
}
三十七、性能优化与最佳实践
1. 集合操作优化
fun collectionOptimization() {
val numbers = (1..1000000).toList()
// 不好的写法:多次中间操作
val badResult = numbers
.filter { it % 2 == 0 }
.map { it * 2 }
.filter { it > 1000 }
.take(10)
// 好的写法:使用序列(惰性求值)
val goodResult = numbers.asSequence()
.filter { it % 2 == 0 }
.map { it * 2 }
.filter { it > 1000 }
.take(10)
.toList()
println("结果: $goodResult")
}
// 使用 groupBy 优化复杂操作
data class Person(val name: String, val age: Int, val city: String)
fun optimizeGrouping() {
val people = listOf(
Person("Alice", 25, "Beijing"),
Person("Bob", 30, "Shanghai"),
Person("Charlie", 25, "Beijing"),
Person("Diana", 30, "Shanghai")
)
// 按城市分组,然后按年龄分组
val grouped = people.groupBy({ it.city }, { it.age })
println("分组结果: $grouped")
// 统计每个城市的平均年龄
val averageAges = people.groupingBy { it.city }
.fold(0.0) { accumulator, element ->
accumulator + element.age
}.mapValues { it.value / people.count { p -> p.city == it.key } }
println("平均年龄: $averageAges")
}
2. 内存优化模式
// 使用对象池避免重复创建
class ConnectionPool private constructor() {
private val available = mutableListOf<Connection>()
private val inUse = mutableSetOf<Connection>()
fun getConnection(): Connection {
return available.removeFirstOrNull()?.also { inUse.add(it) }
?: Connection().also { inUse.add(it) }
}
fun releaseConnection(connection: Connection) {
inUse.remove(connection)
available.add(connection)
}
companion object {
val instance by lazy { ConnectionPool() }
}
}
class Connection {
fun connect() = println("连接建立")
fun disconnect() = println("连接关闭")
}
// 使用 use 函数自动管理资源
fun readFileSafely() {
// 模拟资源管理
val resource = object : AutoCloseable {
override fun close() = println("资源已释放")
fun read() = "文件内容"
}
val content = resource.use {
it.read()
}
println("内容: $content") // 资源会自动关闭
}
三十八、完整实战项目:构建事件总线系统
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.flow.*
import kotlin.reflect.KClass
// 事件基类
sealed class Event {
data class UserLoggedIn(val userId: String) : Event()
data class OrderCreated(val orderId: String, val amount: Double) : Event()
data class PaymentProcessed(val paymentId: String, val success: Boolean) : Event()
object SystemShutdown : Event()
}
// 事件处理器接口
interface EventHandler<T : Event> {
suspend fun handle(event: T)
}
// 事件总线
object EventBus {
private val handlers = mutableMapOf<KClass<out Event>, MutableList<EventHandler<*>>>()
private val eventChannel = Channel<Event>(Channel.UNLIMITED)
// 注册事件处理器
fun <T : Event> registerHandler(eventClass: KClass<T>, handler: EventHandler<T>) {
handlers.getOrPut(eventClass) { mutableListOf() }.add(handler as EventHandler<*>)
}
// 发布事件
fun publish(event: Event) {
eventChannel.trySend(event)
}
// 启动事件处理循环
fun start() = GlobalScope.launch {
for (event in eventChannel) {
processEvent(event)
}
}
private suspend fun processEvent(event: Event) {
val eventClass = event::class
val eventHandlers = handlers[eventClass] ?: return
eventHandlers.forEach { handler ->
try {
@Suppress("UNCHECKED_CAST")
(handler as EventHandler<Event>).handle(event)
} catch (e: Exception) {
println("事件处理失败: ${e.message}")
}
}
}
}
// 具体的事件处理器
class UserActivityLogger : EventHandler<Event.UserLoggedIn> {
override suspend fun handle(event: Event.UserLoggedIn) {
delay(100) // 模拟处理时间
println("📝 用户登录日志: ${event.userId} 在 ${System.currentTimeMillis()} 登录")
}
}
class OrderProcessor : EventHandler<Event.OrderCreated> {
override suspend fun handle(event: Event.OrderCreated) {
delay(200)
println("💰 订单处理: 订单 ${event.orderId} 金额 ${event.amount}")
}
}
class PaymentNotifier : EventHandler<Event.PaymentProcessed> {
override suspend fun handle(event: Event.PaymentProcessed) {
delay(150)
val status = if (event.success) "成功" else "失败"
println("📧 支付通知: 支付 ${event.paymentId} $status")
}
}
class SystemMonitor : EventHandler<Event.SystemShutdown> {
override suspend fun handle(event: Event.SystemShutdown) {
println("🛑 系统关闭: 开始清理资源...")
delay(500)
println("🛑 系统关闭: 资源清理完成")
}
}
// 使用事件总线
fun main() = runBlocking {
// 注册处理器
EventBus.registerHandler(Event.UserLoggedIn::class, UserActivityLogger())
EventBus.registerHandler(Event.OrderCreated::class, OrderProcessor())
EventBus.registerHandler(Event.PaymentProcessed::class, PaymentNotifier())
EventBus.registerHandler(Event.SystemShutdown::class, SystemMonitor())
// 启动事件总线
EventBus.start()
// 模拟发布事件
EventBus.publish(Event.UserLoggedIn("user123"))
EventBus.publish(Event.OrderCreated("order456", 99.99))
EventBus.publish(Event.PaymentProcessed("pay789", true))
EventBus.publish(Event.OrderCreated("order999", 49.99))
EventBus.publish(Event.PaymentProcessed("pay000", false))
delay(1000) // 等待事件处理完成
EventBus.publish(Event.SystemShutdown())
delay(1000)
println("程序结束")
}
下一步学习方向
你现在已经掌握了 Kotlin 的企业级开发技能!接下来可以深入:
- Kotlin 编译器插件开发:自定义编译期处理
- Kotlin 元编程:在编译时生成代码
- Kotlin 与 Java 互操作高级技巧:类型映射、异常处理
- Kotlin 服务端开发:使用 Ktor、Spring Boot
- Kotlin 前端开发:使用 Compose for Web
- Kotlin 移动端开发:Compose Multiplatform
这些高级特性将让你在 Kotlin 开发中游刃有余,能够构建复杂、高性能的企业级应用!
🗣️面试官: 那些常见的前端面试场景问题
1. 页面白屏如何排查?
第一步:快速分类(30秒) "页面白屏主要有五种原因:JavaScript执行错误、资源加载失败、CSS样式问题、接口异常和浏览器兼容性。其中JavaScript错误最常见,特别是SPA应用中的未捕获异常。"
第二步:排查方法(1分钟) "我的排查步骤是:首先查看Console面板的错误信息,这能快速定位JS异常;然后检查Network面板确认资源加载状态;接着用Elements面板验证DOM和样式;移动端问题会用vConsole或真机调试。生产环境结合Sentry等监控系统分析。"
第三步:预防措施(30秒) "预防方面建立错误边界、资源容错机制、统一接口异常处理、兼容性检测,同时搭建监控告警体系。"
一、基础检测流程
1. 控制台检查(Console)
// 主动捕获全局错误(放在入口文件最前面)
window.addEventListener('error', function(event) {
console.error('全局捕获:', event.error);
// 可上报到监控系统
});
// 检查console是否有以下类型错误:
// - SyntaxError (语法错误)
// - TypeError (类型错误)
// - ReferenceError (引用错误)
// - 404资源加载失败
2. 网络请求检查(Network)
-
关键指标:
- HTML文档状态码(200/304/404/500)
- JS/CSS资源加载状态
- 接口请求是否阻塞渲染
-
检测示例:
// 检查关键资源是否加载完成
const resourceCheck = () => {
const entries = performance.getEntriesByType('resource');
const criticalResources = entries.filter(entry =>
entry.initiatorType === 'script' ||
entry.initiatorType === 'css'
);
criticalResources.forEach(res => {
if(res.responseStatus >= 400) {
console.error(`资源加载失败: ${res.name}`, res);
}
});
};
window.addEventListener('load', resourceCheck);
二、深度检测方法
1. DOM渲染检测
// 检测DOM树是否正常构建
function checkDOMReady() {
return new Promise((resolve) => {
const check = () => {
if(document.body && document.body.children.length > 0) {
resolve(true);
} else {
setTimeout(check, 50);
}
};
check();
});
}
// 使用示例
checkDOMReady().then((isReady) => {
if(!isReady) {
console.error('DOM渲染超时');
// 上报白屏信息
}
});
2. 框架特定检测
Vue应用检测:
// 在main.js中添加
new Vue({
render: h => h(App),
errorCaptured(err, vm, info) {
console.error('Vue组件错误:', err, info);
// 可上报错误
return false; // 阻止错误继续向上传播
}
}).$mount('#app');
// 检查根组件挂载
if(!document.querySelector('#app').__vue__) {
console.error('Vue根实例挂载失败');
}
React应用检测:
// Error Boundary组件
class ErrorBoundary extends React.Component {
componentDidCatch(error, info) {
console.error('React组件错误:', error, info);
// 上报错误
}
render() {
return this.props.children;
}
}
// 检查React根组件
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>
);
三、性能相关检测
1. 长任务检测
// 检测阻塞渲染的长时间任务
const observer = new PerformanceObserver((list) => {
for(const entry of list.getEntries()) {
if(entry.duration > 50) { // 超过50ms的任务
console.warn('长任务影响渲染:', entry);
}
}
});
observer.observe({entryTypes: ["longtask"]});
2. 关键渲染路径监控
// 使用Performance API监控关键时间点
const perfData = window.performance.timing;
const metrics = {
domReady: perfData.domComplete - perfData.domLoading,
loadTime: perfData.loadEventEnd - perfData.navigationStart
};
if(metrics.domReady > 3000) {
console.error('DOM解析时间过长:', metrics);
}
四、自动化检测方案
1. Puppeteer检测脚本
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// 监听控制台错误
page.on('console', msg => {
if(msg.type() === 'error') {
console.log('页面错误:', msg.text());
}
});
// 设置超时检测
await Promise.race([
page.goto('https://your-site.com'),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('页面加载超时')), 5000)
)
]);
// 检查可见内容
const content = await page.evaluate(() => {
return {
bodyText: document.body.innerText,
childCount: document.body.children.length
};
});
if(content.childCount === 0 || content.bodyText.length < 10) {
console.error('检测到白屏现象');
}
await browser.close();
})();
2. 真实用户监控(RUM)
// 使用浏览器的MutationObserver监控DOM变化
const observer = new MutationObserver((mutations) => {
if(!document.querySelector('#app')?.innerHTML) {
// 上报白屏事件
beacon.send('white-screen', {
url: location.href,
ua: navigator.userAgent
});
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
五、常见白屏场景示例
-
资源加载失败:
<!-- 错误的资源路径 --> <script src="/wrong-path/app.js"></script> -
语法错误:
// 缺少括号导致整个脚本不执行 function test() { console.log('hello' } -
框架初始化失败:
// Vue示例 - 挂载元素不存在 new Vue({el: '#not-exist'}); -
CSS阻塞:
<!-- 不正确的CSS引入阻塞渲染 --> <link rel="stylesheet" href="nonexist.css"> -
第三方库冲突:
// 两个库都修改了Array原型 libraryA.modifyPrototype(); libraryB.modifyPrototype(); // 冲突导致错误 ```
2.前端埋点
一、页面生命周期埋点
1. 页面加载阶段
// 记录页面开始加载时间
const pageStartTime = Date.now();
// 监听页面加载完成
window.addEventListener('load', () => {
const loadTime = Date.now() - pageStartTime;
track('page_load', {
load_time: loadTime,
referrer: document.referrer,
resource_status: checkResources()
});
});
// 检查关键资源加载状态
function checkResources() {
return performance.getEntriesByType('resource').map(res => ({
name: res.name,
type: res.initiatorType,
duration: res.duration.toFixed(2)
}));
}
2. 用户交互阶段
// 点击事件埋点(支持事件委托)
document.body.addEventListener('click', (e) => {
const target = e.target.closest('[data-track]');
if(target) {
track('element_click', {
element_id: target.id,
track_type: target.dataset.track,
position: `${e.clientX},${e.clientY}`
});
}
});
// 滚动深度记录
let maxScroll = 0;
window.addEventListener('scroll', _.throttle(() => {
const currentScroll = window.scrollY / document.body.scrollHeight;
if(currentScroll > maxScroll) {
maxScroll = currentScroll;
track('scroll_depth', { depth: Math.round(maxScroll * 100) });
}
}, 1000));
3. 页面停留时长计算
let activeStart = Date.now();
let inactiveTime = 0;
// 用户活跃状态检测
document.addEventListener('mousemove', resetActiveTimer);
document.addEventListener('keydown', resetActiveTimer);
function resetActiveTimer() {
if(inactiveTime > 0) {
track('user_inactive', { duration: inactiveTime });
inactiveTime = 0;
}
activeStart = Date.now();
}
// 每10秒检测一次活跃状态
setInterval(() => {
if(Date.now() - activeStart > 15000) { // 15秒无操作视为不活跃
inactiveTime += 10000;
} else {
track('user_active', { duration: 10000 });
}
}, 10000);
二、特殊场景处理
1. 页面隐藏/显示
// 页面可见性变化监听
document.addEventListener('visibilitychange', () => {
if(document.hidden) {
track('page_hide', {
stay_time: Date.now() - pageStartTime,
scroll_depth: maxScroll
});
} else {
track('page_show');
}
});
2. 页面关闭前上报
// 确保页面关闭前数据上报
window.addEventListener('beforeunload', () => {
const totalStay = Date.now() - pageStartTime;
navigator.sendBeacon('/api/track', JSON.stringify({
event: 'page_close',
active_time: totalStay - inactiveTime,
scroll_depth: maxScroll
}));
});
三、数据上报优化方案
1. 批量上报机制
let eventQueue = [];
const MAX_QUEUE = 5;
const FLUSH_INTERVAL = 3000;
function addToQueue(event) {
eventQueue.push(event);
if(eventQueue.length >= MAX_QUEUE) {
flushQueue();
}
}
function flushQueue() {
if(eventQueue.length === 0) return;
const batchData = { batch: eventQueue };
navigator.sendBeacon('/api/batch', JSON.stringify(batchData));
eventQueue = [];
}
// 定时刷新队列
setInterval(flushQueue, FLUSH_INTERVAL);
2. 关键指标计算
// 计算FMP(首次有效绘制)
new PerformanceObserver((entryList) => {
const [entry] = entryList.getEntriesByName('first-contentful-paint');
track('fmp', { value: entry.startTime.toFixed(2) });
}).observe({type: 'paint', buffered: true});
// 计算LCP(最大内容绘制)
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
track('lcp', { value: lastEntry.startTime.toFixed(2) });
}).observe({type: 'largest-contentful-paint', buffered: true});
四、面试回答精简版
"我们实现全链路埋点主要分三个阶段:
-
加载阶段:
- 用
performance API采集DNS/TTFB等指标 - 监听
load事件记录完整加载时间 - 检查关键资源状态(如图片/脚本)
- 用
-
交互阶段:
- 事件委托监听全局点击(带
data-track属性) - 节流处理滚动事件计算最大深度
- 通过
mousemove/keydown检测活跃状态
- 事件委托监听全局点击(带
-
离开阶段:
-
visibilitychange处理页面切换 -
beforeunload+sendBeacon确保关闭前上报 - 计算总停留时长和有效活跃时间
-
3.那为什么大家都使用请求 GIF 图片的方式上报埋点数据呢?
-
防止跨域问题:前端监控的请求常常会遇到跨域问题,这可能会影响监控的准确性和可用性。然而,图片的src属性并不会跨域,因此使用GIF图片作为埋点可以正常发起请求,从而有效避免跨域问题。
-
防止阻塞页面加载:在创建资源节点后,通常只有当对象注入到浏览器的DOM树后,浏览器才会实际发送资源请求。但反复操作DOM会引发性能问题,且载入js/css资源会阻塞页面渲染,从而影响用户体验。与此不同,构造图片打点不需要插入DOM,只要在js中new出Image对象就能发起请求,这样就不会有阻塞问题。即使在没有js的浏览器环境中,也能通过img标签正常打点,这是其他类型的资源请求所做不到的。
-
体积小,节约流量:相比其他图片格式(如BMP和PNG),GIF图片具有更小的体积。例如,最小的BMP文件需要74个字节,PNG需要67个字节,而合法的GIF只需要43个字节。因此,使用GIF作为埋点可以显著节约流量,提高数据传输效率。
-
浏览器支持性好:所有浏览器都支持Image对象,即使不支持XMLHttpRequest对象也一样。这意味着使用GIF进行埋点上报可以在各种浏览器环境中稳定运行。
-
记录错误的过程很少出错:某些情况下,如Ajax通信过程中页面跳转,请求可能会被取消。但使用图片进行埋点上报则不会遇到这个问题,特别是在记录离开页面打点行为的时候会很有用。
120行代码,实现丝滑滚动的时间轴组件
前言
产品又看见一个非常高级的时间轴页面交互(对于前端来说,实现不难,主要是花时间),我们尝试一下让Trae 的solo来实现,最简洁的代码实现,尽可能的描述一下细节,这样才可以最准确的实现,少走弯路,不浪费我们宝贵的时间(有这时间多看几篇技术文章,不香?啊哈哈哈)
首先先搭建好一个项目,这里使用的是vite+vue3+ts,然后是对应的安装tailwind css,这些都是让Trae solo模式帮我们搭建一下,我们就不用从0搭建了
![]()
先来看看最终的效果实现,你很难相信这是一次对话就实现的
一.首先是描述细节
1.时间轴的中间是可以滚动的,然后要根据鼠标进行滚动,不要滚动条
2.奇数是在上面,时间轴的连接线比较长,偶数是在下面,连接线比较短
3.箭头在右下角固定,保证用户知道这个是时间轴
4.卡片最大宽度是240px,超出隐藏
5.线和卡片中间使用倒三角连接起来
附加上一张ui设计的截图,就可以开始提问了
![]()
等待了一会,就实现了
来看看代码,119行就实现了,看起来确实优雅,解读一下代码trae solo是如何实现这个组件的
1.卡片(奇偶项交错,下移以视觉区分),使用tailwind的mt-[210px],
<div
class="relative max-w-[230px] overflow-hidden rounded-lg bg-[#1D2129] shadow-lg"
:class="i % 2 !== 0 ? 'mt-[210px]' : ''"
>
<!-- 卡片头部:深色背景 -->
<div class="flex items-center justify-between px-3 py-2">
<span class="text-sm font-medium text-white">共7条</span>
<span class="cursor-pointer text-xs text-white">全部></span>
</div>
<!-- 卡片内容:两张图片并排 -->
<div class="flex gap-2 overflow-hidden p-3">
<div class="relative shrink-0 overflow-hidden rounded">
<img :src="item.image" class="aspect-video h-[150px] w-[89px] object-cover" alt="">
</div>
<div class="relative shrink-0 overflow-hidden rounded">
<img :src="item.image" class="aspect-video h-[150px] w-[89px] object-cover" alt="">
</div>
<div class="relative shrink-0 overflow-hidden rounded">
<img :src="item.image" class="aspect-video h-[150px] w-[89px] object-cover" alt="">
</div>
</div>
</div>
2.底部小三角形指示器,有没有勾起你当时学前端的回忆,那道经典的面试题的,你是怎么实现一个三角形的
<div class="border-x-[12px] border-t-[12px] border-[#1D2129] border-x-transparent" />
3.竖线:奇数项加长(视觉第1、3、5项),使用tailwind的h-[310px]和h-[101px]来实现连接线的长短
<div
class="w-[2px]" :class="[ isCreation ? 'bg-blue-500' : 'bg-[#685FE1]', i % 2 === 0 ? 'h-[310px]' : 'h-[101px]', ]"
/>
4.底部圆点的实现,也是使用一个div来实现的
<div
class="z-10 size-4 rounded-full border-[3px] bg-white" :class="[ isCreation ? 'border-blue-500' : 'border-[#685FE1]', ]"
/>
5.底部横向主线(黑色)贯穿整个容器
<div class="absolute inset-x-0 bottom-[35px] h-[2px] bg-black"/>
6.底部时间轴右侧箭头(固定在轴线右端,靠内边距 right-[-11px] 可调)
<div class="pointer-events-none absolute bottom-[56px] right-[-11px] z-[999]">
<!-- 向右的箭头 SVG,样式可改 -->
<svg class="size-6 text-black" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M5 12h13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M13 5l7 7-7 7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
关键代码,通过监听鼠标滚轮来实现横向滚动的
onMounted(() => {
const el = scrollRef.value
if (!el)
return
// 鼠标滚轮横向滚动
el.addEventListener('wheel', (e) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault()
el.scrollLeft += e.deltaY
}
})
})
来看看代码行数,确实很惊艳,简单的119行就实现了
![]()
总结
trae的solo确实比之前好很多,也惊艳到我了,前端可真是越来越难了,以后的时间排期也会进一步的压缩了,还是要适当的使用ai来辅助我们的编程,把一些比较容易实现的,耗时间的工作交给ai去实现,我们专注于一些工程化和架构上面去,也可以适当学一些node项目,提高我们自己的核心竞争力,加油吧,前端工程师们。
ES6+ 新特性解析:让 JavaScript 开发更优雅高效
ES6(ECMAScript 2015)是 JavaScript 语言发展的里程碑,引入了大量让代码更简洁、更易维护的新特性。本文将深入解析这些特性,并通过实际代码示例展示它们的强大之处。
解构赋值:优雅的数据提取
解构赋值让我们能够从数组或对象中快速提取值,赋给变量。
数组解构
// 基本数组解构
const [a, b, c] = [1, 2, 3];
console.log(a, b, c); // 1 2 3
// 嵌套数组解构
const [a, [b, c, [d], e]] = [1, [2, 3, [4], 5]];
console.log(a, b, c, d, e); // 1 2 3 4 5
// 剩余参数解构
const arr = [1, 2, 3, 4, 5];
const [first, ...rest] = arr;
console.log(first, rest); // 1 [2, 3, 4, 5]
// 实际应用:提取教练和球员
const users = ['Darvin Ham', 'James', 'Luka', 'Davis', 'Ayton', 'Kyle'];
const [captain, ...players] = users;
console.log(captain, players);
// Darvin Ham ['James', 'Luka', 'Davis', 'Ayton', 'Kyle']
对象解构
const sex = 'boy';
const obj = {
name: 'Darvin Ham',
age: 25,
sex, // 对象属性简写
like: {
n: '唱跳'
}
};
// 对象解构
let { name, age, like: { n } } = obj;
console.log(name, age, n); // Darvin Ham 25 唱跳
// 字符串也可以解构
const [e, r, ...u] = 'hello';
console.log(e, r, u); // h e ['l', 'l', 'o']
// 获取字符串长度
const { length } = 'hello';
console.log(length); // 5
解构的实用技巧
// 交换变量(无需临时变量)
let x = 1, y = 2;
[x, y] = [y, x];
console.log(x, y); // 2 1
// 函数参数解构
function greet({ name, greeting = 'Hello' }) {
console.log(`${greeting}, ${name}!`);
}
greet({ name: 'Bob' }); // Hello, Bob!
// 设置默认值
const { name, gender = 'unknown' } = { name: 'Alice' };
console.log(gender); // unknown(因为 user 中没有 gender 属性)
模板字符串:更优雅的字符串处理
模板字符串使用反引号(``)定义,支持多行字符串和插值表达式。
let myName = 'zhangsan';
// 传统字符串拼接
console.log('hello, i am ' + myName);
// 模板字符串
console.log(`hello, i am ${myName}`);
console.log(`hello, i am ${myName.toUpperCase()}`);
// 多行字符串
const message = `
亲爱的 ${myName}:
欢迎使用我们的服务!
祝您使用愉快!
`;
console.log(message);
更现代的循环:for...of
for...of 循环提供了更好的可读性和性能。
let myName = 'zhangsan';
// for...of 遍历字符串
for(let x of myName) {
console.log(x); // 依次输出: z h a n g s a n
}
// 与 for 循环对比
const arr = [1, 2, 3, 4, 5];
// 传统的 for 循环
for(let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
// 更简洁的 for...of
for(let item of arr) {
console.log(item);
}
性能建议:
for...of语义更好,可读性更强,性能也不会比计数循环差太多。而for...in性能较差,应尽量避免使用。
BigInt:处理大整数的新数据类型
JavaScript 的数字类型有精度限制,最大安全整数是 2^53-1。
// JavaScript 的数字精度问题
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
let num = 1234567890987654321;
console.log(num); // 1234567890987654400(精度丢失!)
// 使用 BigInt
let num2 = 1234567890987654321n;
console.log(num2, typeof num2); // 1234567890987654321n 'bigint'
// BigInt 运算
const big1 = 9007199254740991n;
const big2 = 1n;
console.log(big1 + big2); // 9007199254740992n
// 注意事项
console.log(1n + 2); // ❌ TypeError: Cannot mix BigInt and other types
console.log(Math.sqrt(16n)); // ❌ TypeError
const x = 3.14n; // ❌ SyntaxError: Invalid or unexpected token
BigInt 使用要点:
- 使用
n后缀表示 BigInt 字面量 - 不能与 Number 类型直接混合运算
- 不能使用 Math 对象的方法
- 只能表示整数,不支持小数
函数参数的增强
默认参数
function foo(x = 1, y = 1) {
return x + y;
}
console.log(foo(3)); // 4 (使用默认值 y=1)
console.log(foo(3, 5)); // 8
console.log(foo()); // 2 (使用默认值 x=1, y=1)
剩余参数 vs arguments
function foo(...args) {
console.log('剩余参数:', args, typeof args);
// [1, 2, 3, 4] 'object'
console.log('是数组吗:', Array.isArray(args)); // true
console.log('arguments:', arguments, typeof arguments);
// [Arguments] { '0': 1, '1': 2, '2': 3, '3': 4 } 'object'
console.log('arguments是数组吗:', Array.isArray(arguments)); // false
// 剩余参数支持数组方法
console.log('参数个数:', args.length);
console.log('参数总和:', args.reduce((a, b) => a + b, 0));
}
foo(1, 2, 3, 4);
剩余参数的优势:
- 是真正的数组,可以使用所有数组方法
- 更清晰的语法
- 更好的类型推断(TypeScript)
其他实用特性
指数运算符
// ES7 (2016) 引入的指数运算符
console.log(2 ** 10); // 1024
console.log(3 ** 3); // 27
// 替代 Math.pow()
console.log(Math.pow(2, 10)); // 1024 (传统方式)
对象属性简写
const name = 'Alice';
const age = 25;
// 传统写法
const obj1 = {
name: name,
age: age
};
// ES6 简写写法
const obj2 = {
name,
age
};
console.log(obj2); // { name: 'Alice', age: 25 }
总结
ES6+ 的新特性让 JavaScript 开发变得更加优雅和高效:
- 解构赋值 让数据提取更直观
- 模板字符串 让字符串处理更简洁
- for...of 提供更好的循环体验
- BigInt 解决大整数计算问题
- 函数参数增强 提供更灵活的函数设计
这些特性不仅提高了开发效率,也让代码更易读、易维护。建议在实际项目中积极采用这些现代 JavaScript 特性,提升代码质量。
学习建议:从解构赋值和模板字符串开始,逐步掌握其他特性,让 ES6+ 成为你的开发利器!
深入理解 JavaScript 异步编程:从 Ajax 到 Promise
在现代 Web 开发中,异步编程是不可或缺的核心概念。本文将通过几个实际的代码示例,带你深入理解 JavaScript 中的异步操作,从传统的 Ajax 到现代的 Promise 和 Fetch API。
1. 传统 Ajax 与现代 Fetch API
XMLHttpRequest:经典的异步请求方式
<script>
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.github.com/orgs/lemoncode/members', true);
xhr.send();
xhr.onreadystatechange = function() {
if(xhr.readyState === 4 && xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
console.log(data);
}
}
</script>
XMLHttpRequest 是传统的异步请求方式,基于回调函数实现。需要手动处理 readyState 和 status 状态码,代码相对复杂。
Fetch API:现代化的替代方案
<script>
fetch('https://api.github.com/orgs/lemoncode/members')
.then(res => res.json())
.then(data => {
console.log(data);
})
</script>
Fetch API 的优势:
- 基于 Promise 实现,支持链式调用
- 语法简洁,无需手动处理状态码
- 更符合现代 JavaScript 编程风格
2. 封装基于 Promise 的 getJSON 函数
为了将传统的 Ajax 改造成 Promise 风格,我们可以封装一个 getJSON 函数:
const getJSON = (url) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.send();
xhr.onreadystatechange = function() {
if(xhr.readyState === 4) {
if(xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
resolve(data);
} else {
reject(`HTTP错误: ${xhr.status}`);
}
}
}
xhr.onerror = function() {
reject('网络错误');
}
});
}
// 使用示例
getJSON('https://api.github.com/orgs/lemoncode/members')
.then(data => {
console.log(data);
})
.catch(err => {
console.log(err);
})
关键点说明:
- Promise 构造函数接收一个执行器函数,同步执行
- resolve() 将 Promise 状态改为 fulfilled,触发 then 回调
- reject() 将 Promise 状态改为 rejected,触发 catch 回调
- onerror 只能捕获网络错误,不能捕获 HTTP 错误状态码
3. Promise 状态管理
| 状态 (State) | 含义 | 说明 |
|---|---|---|
| pending (等待中) | 初始状态 | Promise 被创建后,尚未被兑现或拒绝时的状态 |
| fulfilled (已成功) | 操作成功完成 | 异步任务成功结束,调用了 resolve(value) |
| rejected (已失败) | 操作失败 | 异步任务出错,调用了 reject(reason) |
4. 实现 Sleep 函数:控制异步流程
在同步语言中,我们可以使用 sleep 函数暂停程序执行,但在 JavaScript 中需要借助 Promise 模拟这一行为:
// 基础版本
function sleep(n) {
let p;
p = new Promise((resolve, reject) => {
setTimeout(() => {
console.log(p); // pending 状态
resolve(); // 或 reject()
console.log(p); // fulfilled/rejected 状态
}, n);
});
return p;
}
// 简化版本
const sleep = n => new Promise(resolve => setTimeout(resolve, n));
// 使用示例
sleep(3000)
.then(() => {
console.log('3秒后执行');
})
.catch(() => {
console.log('error');
})
.finally(() => {
console.log('finally'); // 无论成功失败都会执行
});
setTimeout vs Sleep:本质区别
setTimeout - 事件调度
console.log("第1步");
setTimeout(() => {
console.log("第3步 - 在2秒后执行");
}, 2000);
console.log("第2步 - 不会等待setTimeout");
// 输出顺序:
// 第1步
// 第2步 - 不会等待setTimeout
// 第3步 - 在2秒后执行
Sleep - 流程控制
async function demo() {
console.log("第1步");
await sleep(2000); // 真正暂停函数的执行
console.log("第2步 - 在2秒后执行");
}
demo();
// 输出顺序:
// 第1步
// (等待2秒)
// 第2步 - 在2秒后执行
这么来说吧:setTimeout是告诉 JavaScript 引擎:“等 X 毫秒后,帮我执行这个函数。” 它会去做别的同步操作,不会阻塞其他代码的执行。sleep则是在一个连续的异步操作流中,插入一段等待时间。sleep 让你写出“顺序等待”的逻辑,而 setTimeout 只是“未来某个时间点做某事”。
5. JavaScript 内存管理与数据拷贝
理解 JavaScript 的内存管理对于编写高效代码至关重要:
const arr = [1, 2, 3, 4, 5, 6];
const arr2 = [].concat(arr);
arr2[0] = 0;
console.log(arr, arr2); // arr 不变,arr2 改变
// 深拷贝 - 开销大
const arr3 = JSON.parse(JSON.stringify(arr));
arr3[0] = 100;
console.log(arr, arr3); // arr 不变,arr3 改变
在数组上进行存储的时候我们应该尽量规避
JSON序列化的深拷贝,开销太大,我们可以选择用[].cancat(arr)这种巧妙的方法实现拷贝,虽然创建了一个新数组,但开销仍然小于序列化。
内存管理要点:
- JS 变量在编译阶段分配内存空间
- 简单数据类型存储在栈内存中
- 复杂数据类型存储在堆内存中,栈内存存储引用地址
- 浅拷贝只复制引用,深拷贝创建完全独立的新对象
总结
通过本文的示例和解析,我们深入探讨了:
- Ajax 与 Fetch 的对比:Fetch API 提供了更简洁的 Promise-based 接口
- 手写getJson函数:将传统的 Ajax 改造成 现代的 Promise 风格
- 手写sleep函数:使用 Promise 实现类似同步的编程体验
- 内存管理:理解变量存储方式对编写高效代码的重要性
掌握这些概念将帮助你编写更清晰、更易维护的异步 JavaScript 代码,为学习更高级的 async/await 语法打下坚实基础。
React 日历组件完全指南:从网格生成到农历转换
本文详细介绍如何从零实现一个功能完整的 React 日历组件,包括日历网格生成、农历显示和月份切换功能。
前言
在开发排班管理系统时,我们需要实现一个功能完整的日历组件。这个组件不仅要显示标准的月历网格,还要支持农历显示和流畅的月份切换。经过实践,我总结了一套完整的实现方案,适用于任何 React 项目。
一、日历网格生成
1.1 核心需求
一个标准的月历网格需要满足以下要求:
- 显示当前月份的所有日期
- 补齐上月末尾的日期(填充第一周)
- 补齐下月开头的日期(填充最后一周)
- 总是显示完整的 6 周(42 天)
- 周日为每周的第一天
1.2 实现思路
我们使用 date-fns 库来处理日期计算,整个算法分为三个步骤:
// DateService.ts
getMonthCalendarGrid(date: Date): Date[] {
// Step 1: 获取月份的起止日期
const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date);
// Step 2: 扩展到完整的周
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
// Step 3: 生成日期数组
return eachDayOfInterval({
start: calendarStart,
end: calendarEnd
});
}
关键点解析:
-
获取月份边界:使用
startOfMonth和endOfMonth获取当月的第一天和最后一天 -
扩展到完整周:使用
startOfWeek和endOfWeek确保日历从周日开始,到周六结束 -
生成连续日期:使用
eachDayOfInterval生成两个日期之间的所有日期
1.3 实际案例
以 2024 年 11 月为例:
输入:new Date(2024, 10, 15) // 2024-11-15
Step 1: 月份边界
monthStart = 2024-11-01 (周五)
monthEnd = 2024-11-30 (周六)
Step 2: 扩展到周
calendarStart = 2024-10-27 (周日)
calendarEnd = 2024-11-30 (周六)
Step 3: 生成日期
共 35 天 (5周)
渲染结果:
日 一 二 三 四 五 六
27 28 29 30 31 1 2 ← 10月27-31 + 11月1-2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
1.4 为什么是 6 周?
大多数月份只需要 5 周(35 天)就能显示完整,但某些特殊情况需要 6 周(42 天)。
需要 6 周的条件:
- 月份有 31 天
- 月初是周六(需要补充前面 6 天)
为了保持布局一致性,我们统一使用 6 周布局,这样月份切换时高度不变,动画过渡更流畅。
二、农历(阴历)显示
2.1 实现原理
农历转换使用预定义数据表 + 算法计算的方式,无需外部依赖,支持 1900-2100 年。
2.2 数据结构
农历信息表
private static lunarInfo = [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, // 1900-1904
// ... 共 201 个元素(1900-2100年)
];
每个十六进制数编码了一年的农历信息:
0x04bd8 的二进制表示:
0000 0100 1011 1101 1000
解析:
├─ 后 4 位 (1000 = 8):闰月位置(8月)
├─ 第 5 位 (1):闰月天数(1=30天,0=29天)
└─ 第 6-17 位:每月天数(1=30天,0=29天)
农历日期文本
private static lunarDays = [
'初一', '初二', '初三', ..., '廿九', '三十'
];
2.3 转换算法
公历转农历分为四个步骤:
Step 1: 计算与基准日期的天数差
基准:1900-01-31(农历1900年正月初一)
Step 2: 从1900年开始,逐年累减天数,确定农历年份
Step 3: 逐月累减天数,确定农历月份(处理闰月)
Step 4: 剩余天数 + 1 = 农历日期
2.4 实际案例
以 2024-11-24 为例:
Step 1: 天数差
(2024-11-24 - 1900-01-31) = 45590 天
Step 2: 确定农历年
1900年:354天,剩余 45236天
1901年:354天,剩余 44882天
...
2023年:384天,剩余 324天
→ 农历2024年
Step 3: 确定农历月
正月:30天,剩余 294天
二月:29天,剩余 265天
...
十月:30天,剩余 29天
→ 农历十月
Step 4: 确定农历日
29 + 1 = 30
→ 三十
结果:2024-11-24 = 农历2024年十月三十
2.5 使用方法
// 获取农历日期文本
const lunarText = LunarUtil.getLunarDateText(new Date(2024, 10, 24));
console.log(lunarText); // 输出:三十
// 在日历中应用
<div className="day-cell">
<div className="day-text">{date.getDate()}</div>
<div className="lunar-text">
{LunarUtil.getLunarDateText(date)}
</div>
</div>
渲染效果:
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│廿四 │廿五 │廿六 │廿七 │廿八 │廿九 │三十 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
三、月份切换功能
3.1 核心思路
月份切换的本质是改变当前显示的月份,然后重新生成日历网格。
核心要素:
- 维护一个
currentDate状态 - 提供切换方法(上一月/下一月)
- 根据
currentDate重新生成日历网格
3.2 状态管理
const [currentDate, setCurrentDate] = useState(new Date());
currentDate 的作用:
- 决定显示哪个月份
- 作为生成日历网格的输入
3.3 切换方法
使用 date-fns 实现月份切换:
import { addMonths, subMonths } from 'date-fns';
// 下一个月
const goToNextMonth = () => {
setCurrentDate(prevDate => addMonths(prevDate, 1));
};
// 上一个月
const goToPrevMonth = () => {
setCurrentDate(prevDate => subMonths(prevDate, 1));
};
// 通用方法
const handleMonthChange = (direction: 'next' | 'prev') => {
setCurrentDate(prevDate => {
return direction === 'next'
? addMonths(prevDate, 1)
: subMonths(prevDate, 1);
});
};
3.4 自动处理边界
JavaScript Date 构造函数会自动处理月份溢出:
// 12月 → 1月(跨年)
new Date(2024, 12, 1) // 自动变为 2025-01-01
// 1月 → 12月(跨年)
new Date(2024, -1, 1) // 自动变为 2023-12-01
3.5 响应式更新
使用 useMemo 实现响应式更新:
const MonthView: React.FC<MonthViewProps> = ({ currentDate }) => {
const currentMonthDates = useMemo(() => {
return DateService.getMonthCalendarGrid(currentDate);
}, [currentDate]); // 依赖 currentDate
// currentDate 变化 → useMemo 重新计算 → 生成新的日历网格
};
3.6 完整数据流
用户点击"下一月"
↓
setCurrentDate(新月份)
↓
useMemo 重新计算
↓
生成新的日历网格
↓
渲染新月份
四、完整实现
4.1 日历组件
import React, { useState, useMemo } from 'react';
import { addMonths, subMonths, format } from 'date-fns';
function Calendar() {
const [currentDate, setCurrentDate] = useState(new Date());
// 切换月份
const handleMonthChange = (direction: 'next' | 'prev') => {
setCurrentDate(prev => {
return direction === 'next'
? addMonths(prev, 1)
: subMonths(prev, 1);
});
};
// 生成日历网格
const dates = useMemo(() => {
return generateCalendarGrid(currentDate);
}, [currentDate]);
return (
<div className="calendar">
{/* 标题 */}
<h2>{format(currentDate, 'yyyy年MM月')}</h2>
{/* 切换按钮 */}
<button onClick={() => handleMonthChange('prev')}>上一月</button>
<button onClick={() => handleMonthChange('next')}>下一月</button>
{/* 日历网格 */}
<CalendarGrid dates={dates} />
</div>
);
}
4.2 渲染网格
function CalendarGrid({ dates }) {
// 分组为周
const weeks = [];
for (let i = 0; i < dates.length; i += 7) {
weeks.push(dates.slice(i, i + 7));
}
return (
<div className="calendar-grid">
{/* 星期头部 */}
<div className="week-header">
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="week-day">{day}</div>
))}
</div>
{/* 日历网格 */}
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="week-row">
{week.map((date, dayIndex) => (
<div key={dayIndex} className="day-cell">
{/* 公历日期 */}
<div className="day-text">{date.getDate()}</div>
{/* 农历日期 */}
<div className="lunar-text">
{LunarUtil.getLunarDateText(date)}
</div>
</div>
))}
</div>
))}
</div>
);
}
五、性能优化
5.1 使用 useMemo 缓存计算
// 缓存日历网格
const dates = useMemo(() => {
return generateCalendarGrid(currentDate);
}, [currentDate]);
5.2 使用 useCallback 缓存回调
const handleDatePress = useCallback((date: Date) => {
onDatePress(date);
}, [onDatePress]);
5.3 使用 React.memo 避免无效渲染
export default React.memo(MonthView);
六、关键要点总结
6.1 日历网格生成
核心 API:
-
startOfMonth/endOfMonth- 获取月份边界 -
startOfWeek/endOfWeek- 扩展到完整周 -
eachDayOfInterval- 生成连续日期
数据结构:
Date[] (35-42个元素)
↓ 分组
Date[][] (5-6个数组,每个7个元素)
↓ 渲染
6行 × 7列的网格
6.2 农历转换
核心算法:
- 计算天数差 - 与基准日期(1900-01-31)的差值
- 确定农历年 - 逐年累减天数
- 确定农历月 - 逐月累减天数,处理闰月
- 确定农历日 - 剩余天数 + 1
数据结构:
- 预定义表 - 201个十六进制数(1900-2100年)
- 位运算 - 高效解析农历信息
- 文本数组 - 30个农历日期名称
6.3 月份切换
核心流程:
状态变化 → 网格重新生成 → 数据重新加载 → 组件重新渲染
关键技术:
-
useState- 状态管理 -
useMemo- 缓存计算结果 -
useEffect- 监听变化,自动加载数据 - JavaScript Date - 自动处理月份边界
七、总结
通过本文,我们实现了一个功能完整的 React 日历组件,包括:
✅ 标准的月历网格生成(支持 5-6 周布局) ✅ 农历显示(支持 1900-2100 年) ✅ 流畅的月份切换(自动处理跨年) ✅ 响应式数据更新(状态驱动) ✅ 性能优化(useMemo、useCallback、React.memo)
核心思想是利用 date-fns 处理日期计算,使用 React Hooks 实现响应式更新,通过预定义数据表实现农历转换。整个实现简洁高效,易于维护和扩展。
这套方案不仅适用于 Web 应用,也可以轻松移植到 React Native 等其他 React 生态项目中。
希望这篇文章能帮助你理解日历组件的实现原理,并应用到自己的项目中。
相关资源:
简单聊聊webpack摇树的原理
Webpack 的 Tree Shaking(摇树)是一项用于消除 JavaScript 上下文中未引用代码的优化手段,它能有效减小打包体积。
核心原理
Tree Shaking 的本质是 死代码消除,它依赖 ES6 模块(ESM)的静态语法结构。
-
静态分析:ESM 的
import/export语句必须位于模块顶层(注意:模块顶层不是模块文件顶部的意思,模块顶层可以认为是模块文件中最外层的代码区,不在任何函数、类或代码块内部),且模块路径必须是字符串常量。这样, Webpack 在编译阶段就能构建出完整的模块依赖图,无需运行代码即可分析出哪些导出值未被其他模块使用 。这时有同学就会问了,那么动态 import 怎么判断呢?
其实,还是那个关键点,是否可以被“静态分析”。
// ❌ 难以静态分析,无法使用摇树优化 const componentMap = { basic: () => import('./BasicComponent'), advanced: () => import('./AdvancedComponent') }; const getComponent = componentMap[userInput]; // 运行时才能确定 // ✅ 条件明确,可以被静态分析 if (import.meta.env.VITE_APP_MODE === 'basic') { const BasicComponent = await import('./BasicComponent'); } -
标记与清除:Webpack 的 Tree Shaking 过程大致分为两步。首先,在编译阶段,Webpack 会遍历所有模块,标记(Mark) 出未被使用的导出(通常会在注释中生成类似
unused harmony export的提示)。随后,在代码压缩阶段,Terser 等压缩工具会真正将标记过的"死代码"清除(Shake) 掉 。
这些配置你是否清楚?
要让 Tree Shaking 生效,需要同时满足以下条件:
-
使用 ES6 模块语法:必须使用
import和export语句。CommonJS 的require和module.exports是动态的,无法在编译时进行静态分析,因此不支持 Tree Shaking 。 -
启用生产模式或明确配置:在 Webpack 配置中,将
mode设置为'production'生产模式下会自动开启相关的优化功能。当然也可以在开发模式下手动配置optimization.usedExports和optimization.minimize。
// webpack.config.js
module.exports = {
mode: 'production', // 生产模式自动开启优化
optimization: {
usedExports: true, // 启用使用导出分析
minimize: true // 启用代码压缩(清除死代码)
}
};
- **正确声明副作用 (
sideEffects)**:在项目的package.json中,通过sideEffects属性告知 Webpack 哪些文件是"纯净"的(无副作用),可以安全移除。这能防止具有副作用的文件(如全局样式表、polyfill)被误删 。
// package.json
{
"sideEffects": false, // 表示整个项目都没有副作用
// 或明确指定有副作用的文件
"sideEffects": [
"**/*.css",
"./src/polyfill.js"
]
}
有同学又会问了,摇树摇的不是 js 吗,样式表 css 怎么会被摇掉呢?
其实,这里指的是导入的但是没有明确导出的 css 样式表,导入导出是明确的 js 语句,css 是“副作用”,比如:
- 仅导入但未使用任何导出(如
import './style.css'),属于是无形的“使用”,可能被误删- 使用 CSS Modules(如
import styles from './Component.module.css'),被视为有被使用的对象(如styles.className),通常不会被误删
这些问题你遇到过吗?
开发过程中,以下情况仍可能导致 Tree Shaking 失效,看看你有没有遇到过:
-
Babel 配置不当:Babel 预设
@babel/preset-env可能会将 ESM 转换为 CommonJS。务必确保其modules选项设置为false,只有 ESM 可以摇树。
// .babelrc
{
"presets": [["@babel/preset-env", { "modules": false }]]
}
-
第三方库的模块版本:优先选择提供 ES6 模块版本的库(如使用
lodash-es而非lodash),并采用按需导入的方式 。
// 推荐:按需导入
import { debounce } from 'lodash-es';
// 不推荐:整体导入
import _ from 'lodash';
- 导出粒度太粗:尽量使用具名导出而非默认导出对象,有助于进行更精细的分析 。
// 推荐:细粒度导出
export function func1() {}
export function func2() {}
// 谨慎使用:粗粒度导出(不利于分析内部未使用属性)
export default { func1, func2 };
使用 svgfmt 优化 SVG 图标
前言:SVG 图标的常见问题
在日常开发中,我们经常需要使用设计师提供的 SVG 图标。然而,从 Figma 等设计工具导出的 SVG 文件往往存在一些问题:
首先是代码冗余。设计工具导出的 SVG 通常包含大量不必要的标签,如 <defs>、<g>、<clipPath> 等容器元素,这些元素在实际使用中往往是多余的。其次,颜色值被写死。SVG 中的颜色通常以 fill="#333333" 这样的形式硬编码,导致无法像 icon font 那样通过 CSS 的 color 属性动态控制图标颜色。此外,文件中还可能包含不必要的属性和元数据,使得文件体积偏大。
这些问题给开发者带来了不少困扰:无法灵活控制图标样式,手动清理每个文件效率低下且容易出错,难以构建统一的图标管理系统。
svgfmt 简介
svgfmt 是一个专门用于优化 SVG 图标的开源工具,它采用 monorepo 架构,包含两个核心包:
- @svgfmt/cli:命令行工具,提供便捷的文件处理能力,支持单文件和批量处理
- @svgfmt/core:核心库,可以轻松集成到自定义脚本和构建流程中
svgfmt 能够自动清理冗余标签、移除固定颜色值、合并路径,让 SVG 图标变得更加轻量和易于维护。
使用指南
命令行使用(@svgfmt/cli)
首先安装 CLI 工具:
npm install -g @svgfmt/cli
基础用法
# 格式化单个文件(原地修改)
svgfmt icon.svg
# 批量处理并输出到指定目录
svgfmt "icons/**/*.svg" -o dist/icons
# 处理单个文件并输出到新位置
svgfmt logo.svg -o logo-optimized.svg
自定义转换
svgfmt 支持通过 --transform 参数自定义转换逻辑:
# 使用转换文件
svgfmt icons/*.svg --transform ./transform.js
# 使用内联代码
svgfmt icons/*.svg -t 'svg => svg.replace(/<svg/, "<svg class=\"icon\"")'
转换文件示例(transform.js):
export default function(svg) {
return svg.replace(/<svg/, '<svg class="icon"');
}
// 或者使用命名导出
export function transform(svg) {
return svg.replace(/<svg/, '<svg data-processed="true"');
}
编程方式使用(@svgfmt/core)
在项目中安装核心库:
npm install @svgfmt/core
基础示例
import { format } from '@svgfmt/core';
const svgContent = `<svg>...</svg>`;
const optimizedSvg = await format(svgContent);
高级配置
import { format } from '@svgfmt/core';
const result = await format(svgContent, {
// 提高路径追踪精度(默认 600)
traceResolution: 800,
// 自定义转换函数
transform: (svg) => {
return svg.replace(/<svg/, '<svg class="custom-icon"');
}
});
对于需要异步处理的场景,transform 函数也支持异步:
const result = await format(svgContent, {
transform: async (svg) => {
// 执行异步操作
const processed = await someAsyncOperation(svg);
return processed;
}
});
优化效果对比
让我们看一个实际的优化案例:
优化前
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.6" clip-path="url(#clip0_144244_67656)">
<circle cx="6" cy="6" r="5" stroke="#333" style="stroke:#333;stroke-opacity:1;" />
<path d="M6 3.5V6L7.25 7.25" stroke="#333" style="stroke:#333;stroke-opacity:1;" />
</g>
<defs>
<clipPath id="clip0_144244_67656">
<rect width="12" height="12" fill="#333" style="fill:#333;fill-opacity:1;" />
</clipPath>
</defs>
</svg>
优化后
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.660 0.521 C 4.899 0.566,4.190 0.760,3.500 1.111 C 2.479 1.630,1.630 2.479,1.111 3.500 C 0.702 4.303,0.516 5.086,0.516 6.000 C 0.516 6.748,0.635 7.374,0.904 8.050 C 1.386 9.261,2.332 10.295,3.500 10.889 C 4.085 11.187,4.680 11.369,5.332 11.451 C 5.675 11.494,6.325 11.494,6.668 11.451 C 8.173 11.262,9.516 10.479,10.410 9.270 C 10.792 8.753,11.059 8.227,11.248 7.620 C 11.517 6.754,11.562 5.741,11.369 4.842 C 11.027 3.241,9.959 1.853,8.500 1.111 C 7.617 0.662,6.653 0.461,5.660 0.521 M6.720 1.549 C 7.447 1.673,8.126 1.965,8.720 2.408 C 8.964 2.590,9.410 3.036,9.592 3.280 C 10.040 3.880,10.330 4.559,10.454 5.298 C 10.505 5.599,10.505 6.401,10.454 6.702 C 10.330 7.441,10.040 8.120,9.592 8.720 C 9.410 8.964,8.964 9.410,8.720 9.592 C 8.120 10.040,7.441 10.330,6.702 10.454 C 6.401 10.505,5.599 10.505,5.298 10.454 C 4.559 10.330,3.880 10.040,3.280 9.592 C 3.035 9.410,2.589 8.963,2.408 8.720 C 1.955 8.111,1.671 7.445,1.546 6.702 C 1.495 6.401,1.495 5.599,1.546 5.298 C 1.671 4.556,1.955 3.891,2.408 3.280 C 2.588 3.036,3.036 2.588,3.280 2.408 C 3.968 1.898,4.680 1.618,5.560 1.512 C 5.729 1.492,6.537 1.517,6.720 1.549 M5.500 4.845 L 5.500 6.190 6.200 6.890 L 6.900 7.590 7.245 7.245 L 7.590 6.900 7.045 6.355 L 6.500 5.810 6.500 4.655 L 6.500 3.500 6.000 3.500 L 5.500 3.500 5.500 4.845"
fill-rule="evenodd"
/>
</svg>
主要改进包括:
-
移除冗余标签:去除了不必要的
<defs>、<g>、<clipPath>等容器元素 -
颜色属性移除:删除固定的
fill属性,图标将继承父元素的color,可以通过 CSS 灵活控制 - 路径合并:多个子元素被智能合并为单一路径
实践场景
集成到开发流程
在 package.json 中添加脚本命令:
{
"scripts": {
"optimize-icons": "svgfmt raw-icons/**/*.svg -o src/assets/icons",
"build": "npm run optimize-icons && vite build"
}
}
这样在每次构建前都会自动优化图标文件。
配合构建工具使用
在构建脚本中使用编程 API:
import { formatPattern } from '@svgfmt/cli';
// 在构建时自动优化图标
async function buildIcons() {
const summary = await formatPattern('icons/**/*.svg', {
output: 'dist/icons'
});
console.log(`优化完成: ${summary.success}/${summary.total}`);
// 检查单个文件结果
for (const result of summary.results) {
if (result.success) {
console.log(`✓ ${result.input} → ${result.output}`);
} else {
console.error(`✗ ${result.input}: ${result.error}`);
}
}
}
buildIcons();
自定义转换示例
创建一个转换文件 transform.js,为所有 SVG 添加统一属性:
export default function(svg) {
return svg
.replace(/<svg/, '<svg class="icon"')
.replace(/viewBox/, 'preserveAspectRatio="xMidYMid meet" viewBox');
}
然后在命令行中使用:
svgfmt icons/*.svg --transform ./transform.js -o dist/icons
注意事项
在使用 svgfmt 时,有几点需要注意:
-
仅支持单色图标:该工具专为单色图标设计。多色 SVG 在路径追踪过程中会被转换为单色,因为工具会将 SVG 转换为 PNG 再转回 SVG,这个过程会丢失颜色信息。
-
路径精度配置:
traceResolution参数控制路径追踪的精度,默认值为 600。提高该值可以获得更精细的路径,但会增加处理时间。建议的取值范围是 600-1200。 -
文件覆盖提醒:使用命令行工具时,默认会覆盖原文件。建议在处理前先备份原始文件,或使用
-o参数指定输出目录。 -
复杂图形检查:对于复杂的多色插图或渐变效果的 SVG,建议在优化后手动检查结果,确保视觉效果符合预期。
-
工具组合:svgfmt 可以与其他 SVG 工具配合使用,如 SVGO(进一步优化)、svgr(转换为 React 组件)等,构建完整的 SVG 处理流程。
总结
svgfmt 通过自动化的方式解决了 SVG 图标在实际使用中的常见问题,让图标文件更轻量、更易维护。无论是通过命令行快速处理一批图标,还是将其集成到构建流程中实现自动化优化,svgfmt 都能显著提升开发效率。
项目已在 GitHub 开源,包含完整的文档和示例。如果你在项目中遇到 SVG 图标管理的问题,不妨试试 svgfmt,欢迎使用和贡献代码。
鸣谢
博客文章
如何将一个 React SPA 项目迁移到 Next.js 服务端渲染
日期:2025年11月24日
价值
- “渐进式迁移”策略:学会最大程度保护现有投资,实现技术栈的平稳升级帮助学员使用 Next.js 实现服务端渲染 。
- 征服服务端渲染,解决核心业务痛点: 您将系统掌握 Next.js 的核心能力(SSR/SSG/ISR),从根本上解决传统 React SPA 面临的页面加载缓慢、SEO 不友好两大难题,从而提升用户体验和商业转化率。
前言
- 本文将聚焦渲染原理,不深入讲解 Next.js 的具体使用细节,而是通过一个真实案例,以战代练的方式带你将 React SPA 应用迁移至 Next.js 框架。
- 结合大量实战项目中的踩坑经验,降低上手门槛,并借助第三方生态,助你构建完整的 Next.js 基础能力体系。
- 提供一套符合落地要求的 Next.js 构建与部署实用教程。
目标
- 对服务端渲染祛魅,深入理解与掌握 SSR、SSG、CSR 等渲染模式的原理、优缺点及适用场景。
- 具备平滑迁移与架构设计手段,具备技术落地能力。
- 能够将 Next.js 应用顺利部署到云上环境。
目录/结构
- 第一个模块主要包括如何最简单的上手 Next.js,包括环境准备、基本的渲染原理、与 SPA 应用的开发差异
- 第二个模块用一个实际大型网站迁移路线,说明我们从一个大型网站如何一步一步重构成Next应用,从而实现网页性能优化的过程。
- 第三个模块包含了如何进行落地的部署方案
第一章:Next.js 最简单的上手教程
如何最低成本的上手 Next.js 呢?
内心OS:
- 之前也没有学过相关的知识。是不是很难啊?如果很难的话那算了,我还是回去画我的页面吧/_ \
第一节:前置准备
1、开发环境
基于Next 15 最新api,所以对运行环境有一定要求
- Node.js 18.18 或更高LTS版本
- macOS、Windows(含 WSL)或 Linux 系统
2、项目初始化
为了大家更好的上手,我准备了两套可用于生产环境的模版供大家使用
-
精简版本:codelab.msxf.com/public-repo…
-
能力 通用版本 精简版本 React 版本 19.2.0 18.3.0 组件库 Ant Design v5 - - 应用状态管理 Zustand - - 服务端请求 demo - - 客户端请求 demo - - CSS预处理器:Sass - - 代码检查 Typescript,Eslint 等 - - 单元测试 Jest - - 多环境部署方案 - - 自定义服务器 Koa - - css 样式烘焙 - - 前端监控 @sentry/react - - 前端埋点 UBS - - 微前端方案 qiankun 2.10.16,应用动态更新,微前端通信 模块联邦 2.0
-
第二节:认识文件系统约定
1、认识组件层级结构
-
layout.js布局文件,默认Server Components -
template.js同 layout.js,但是在导航时重新同步执行useEffect -
error.js(React 错误边界) -
loading.js(React Suspense 边界) -
not-found.js(notFound函数执行时渲染 UI) -
page.js或嵌套的layout.js(必要的)
![]()
2、了解路由嵌套关系
嵌套文件夹定义了路由结构。每个文件夹代表一个路由段,对应 URL 路径中的一个段。只有当路由段中添加了
page.js或route.js文件时,该路由才会对外可访问
-
-
最基础的文件路由关系
-
规则 说明 实例 用户URL folder 路由段 app/folder/page.js/folderfolder/folder 嵌套路由段 app/folder/folder/page.js/folder/folder
-
-
-
无法提前确定确切的路由段名称,并希望根据动态数据创建路由时
-
规则 说明 实例 用户URL [folder] 动态路由段 app/[slug]/page.js/``shop``/``shop1[...folder] 全捕获路由段 app/shop/[...slug]/page.js/shop/a``/shop/a/b``/shop/a/b/c[[...folder]] 可选全捕获路由段 通配段和可选通配段的区别在于,可选情况下,不带参数的路由也会被匹配(如上例中的 /shop)以上 - 例如,博客可以包含以下路由
app/blog/[slug]/page.js,其中[slug]是博客文章的动态段 -
export default async function Page({ params, }: { params: Promise<{ slug: string }> }) { const { slug } = await params return <div>我的文章: {slug}</div> }
-
![]()
-
- 在后退导航时关闭模态框而非返回上一路由
- 在前进导航时重新打开模态框
-
规则 说明 实例 (.)folder 拦截同级路由 - (..)folder 拦截上一级路由 - (..)(..)folder 拦截上两级路由 - (...)folder 从根路由拦截 - - 例如,当点击信息流中的照片时,你可以在模态框中显示该照片并覆盖在信息流上方。这种情况下,Next.js 会拦截
/photo/123路由,隐藏 URL 并将其覆盖在/feed之上。 
-
-
规则 说明 实例 @folder 命名插槽,一个路由命中多个页面 见下方说明
-
在一个仪表盘应用中,您可以使用并行路由同时或条件性渲染 team 和 analytics 两个页面
![]()
第三节:页面开发
1、页面概念-RSC渲染机制
Next.js 15 的 App Router 通过服务端组件RSC渲染机制(服务端组件/客户端组件)模糊了传统 SSR 和 CSR 的严格界限,通过混合渲染模式开发者只需关注“服务端逻辑”与“客户端逻辑”的划分,而非显式选择渲染模式
第一个需要改变的思维模式是从“页面级”的渲染转变为“组件级”的渲染
假如我们有一个组件如下:
// ServerComponent.js (一个 RSC)
import ClientComponent from './ClientComponent';
async function ServerComponent() {
// 在服务器上直接进行数据获取
const data = await fetch('/api/user');
return (
<div>
{/* 服务器渲染这部分 */}
<h1>我的博客</h1>
{/* 这里“嵌入”了一个客户端组件 */}
<ClientComponent initialPosts={data} />
</div>
);
}
在这个过程中发生了什么?
-
服务器执行
ServerComponent。 -
服务器从接口/数据库获取
data。 -
服务器渲染
<h1>我的博客</h1>。 -
当服务器遇到
<ClientComponent>时,它会为这个客户端组件“留出一个位置”,并将initialPosts作为 prop 序列化。 -
最终,服务器发出的流式传输响应包含:
-
ServerComponent渲染出的 HTML 结构。 - 客户端组件位置的标记。
- 客户端组件所需的初始数据(序列化的 props)。
- 指向客户端组件所需 JavaScript 的链接。
-
-
浏览器收到响应后,会立即显示由服务端渲染好的 HTML 内容(极快的首屏显示)。
-
然后,React 会进行 Hydration。但这里的 Hydration 是细粒度的。它只会下载并激活客户端组件部分的 JavaScript,使它们变得可交互。服务端组件的 JavaScript 永远不会发送到客户端。
| 时间 | 服务器行为 | 用户看到什么 |
|---|---|---|
| 0s | 开始渲染 | 空白屏 |
| 0.1s | 遇到异步操作,立即发送 loading UI | 看到静态内容部分看到 ServerComponent"加载中,请稍候..."+ 客户端下载资源、解析资源、渲染内容 |
| 2s | 数据获取完成,发送实际内容 | 看到完整的页面内容 |
思考:当接口需要2s,那是不是所有用户都需要等待 2s loading 之后才能看到页面呢?
2、服务端渲染组件
适用场景:服务端获取数据并渲染部分 UI,并将其流式传输到客户端。这些场景更适合服务端渲染
- 提升首次内容绘制 (FCP),并逐步将内容流式传输到客户端
- 从数据库或靠近数据源的 API 获取数据
- 使用 API 密钥、令牌等敏感信息而不暴露给客户端
- 减少发送到浏览器的 JavaScript 体积
默认情况下,组件都是服务端组件
// 引入客户端组件
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'
import { db, posts } from '@/lib/db'
export default async function Page({ params }: { params: { id: string } }) {
const post = await getPost(params.id)
const allPosts = await db.select().from(posts)
return (
<div>
<main>
<h1>{post.title}</h1>
{/* ... */}
<LikeButton likes={post.likes} />
<ul>
{allPosts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</main>
</div>
)
}
3、客户端渲染组件
适用场景:当需要交互性或使用浏览器 API 时
通过在文件顶部(导入语句之前)添加 "use client" 指令来创建客户端组件。
这时候将你的SPA页面复制到客户端组件中,也能完美运行。
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count} likes</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
注意: 在客户端组件中嵌套服务端渲染的 UI,需要服务端组件作为 prop 传递给客户端组件。不能在客户端组件中直接 import 引入服务端组件
'use client'
export default function Modal({ children }: { children: React.ReactNode }) {
return <div>{children}</div>
}
import Modal from './ui/modal' // 客户端组件
import Cart from './ui/cart' // 服务端组件
// 服务端组件
export default function Page() {
return (
<Modal>
<Cart />
</Modal>
)
}
思考:为什么客户端组件不能直接 import 服务端组件,反过来服务端组件就可以呢??
第二章:从 CSR 到混合渲染迁移路线
第一节:生态对比关系图
云微前端主项目 Umi 4 到 Next 15
暂时无法在唯科之家2.0文档外展示此内容
第二节:生态迁移细节
1、webpack 迁移到 Turbopack
遇到的问题
-
Q1:没有了svgr,如何支持.svg 文件并将其渲染为 React 组件呢?
-
// 使用 @svgr/webpack 加载器,该加载器支持导入 .svg 文件并将其渲染为 React 组件 module.exports = { turbopack: { rules: { '*.svg': { loaders: ['@svgr/webpack'], as: '*.js', }, }, }, }
-
-
Q2:umi 自定义插件迁移问题,通过插件启用
Brotli静态压缩- next方案不是很完美,默认情况下,当使用
next start或自定义服务器时,Next.js 会使用gzip压缩渲染内容和静态文件。如果想要用Brotli压缩代码,则需要这样做: -
module.exports = { compress: false, } - 如果您正在使用 nginx 并希望改用
brotli压缩,可以将compress选项设为false以让 nginx 处理压缩(其实也并不完美,这种方案需要依赖服务器压缩,而不是构建时压缩)。
- next方案不是很完美,默认情况下,当使用
2、antd 升级
遇到的问题
-
Q1:为什么使用在 Next.js 中直接 <Select.Option /> 、<Typography.Text />会报错,需要从路径引入这些子组件来避免错误
-
<Select.Option value="1">Option 1</Select.Option> - A:服务器组件树序列化问题,当使用
Select.Option这样的点表示法时,实际上是在引用一个对象的属性。Next.js 的 RSC 系统需要能够静态分析组件树,以便正确序列化和在客户端重建组件树。 - 说人话就是RSC 系统在编译时需要确定:哪些是服务端组件,哪些是客户端组件以及组件之间的边界关系
- 见 ant.design/docs/react/…
-
-
Q2:服务端渲染中如何注入样式
-
A:有两种方式在服务端渲染消费组件样式,各有好处
-
内联:直接将样式内联到所使用的组件上
- 好处是没有css请求
- 缺点是如果使用了多个同样的组件,会内联多份相同的样式,造成 HTML 体积增大,影响首屏渲染速度
- 见:ant.design/docs/react/…
-
烘焙:类似 antd 4,将项目中使用过的组件样式提取出一份单独的css
- 好处是打开任意页面时如传统 css 方案一样都会复用同一套 css 文件以命中缓存
- 缺点是多主题的情况下会烘焙多份样式
-
import React from 'react'; import fs from 'fs'; import { extractStyle } from '@ant-design/static-style-extract'; import AntdConfigProvider from './AntdConfigProvider'; import { IS_PRODUCTION } from '../src/constants/config'; const outputPath = IS_PRODUCTION ? './public/antd.min.css' : './public/antd.environment.css'; const css = extractStyle((node) => <AntdConfigProvider>{node}</AntdConfigProvider>); fs.writeFileSync(outputPath, css);
-
2、状态管理
从 umi 到 Zustand, 由于 Next.js 本身并不提供状态管理工具。我们需要自己选型一款稳定、简单、好用的状态管理工具
Zustand 的使用本身很简单,定义一个Hooks,然后抛出状态和改变状态的方法即可
// src/stores/counter-store.ts
import { create } from 'zustand/vanilla'
const useCountStore = create((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
// src/components/pages/home-page.tsx
import { useCountStore } from '@/providers/counter-store-provider.ts'
export const HomePage = () => {
const { count, inc} = useCountStore ((state) => state)
}
但这是在SPA中使用,在服务端渲染中对 Zustand 使用不可变数据提出了一些独特的挑战,所以我们需要一点小小的改变
-
定义一个 Provider 防止组件重复执行初始化数据
-
import { createStore } from 'zustand'; import { type ReactNode, createContext, useContext, useEffect, useRef } from 'react'; export const createUserStore = () => { return createStore<UserStore>()((set) => ({ userInfo: {}, refreshUserFn: async (userInfo?: UserState ) => { set(() => ({ ...state, userInfo: userInfo })); }, })); }; export const UserStoreContext = createContext<UserStoreApi | undefined>(undefined); /** * 创建地域上下文 * 避免 createUserStore 重复执行 */ export const UserStoreProvider = ({ children }: UserStoreProviderProps) => { const userStore = createUserStore(); const storeRef = useRef<UserStoreApi>(null); if (!storeRef.current) { storeRef.current = userStore; } return <UserStoreContext.Provider value={userStore}>{children}</UserStoreContext.Provider>; }; // 消费或初始化用户数据 export const useUserStore = <T,>(selector: (store: UserStore) => T): T => { const UserContext = useContext(UserStoreContext); if (!UserContext) { throw new Error(`useUserStore must be used within UserStoreProvider`); } return useStore(UserContext, selector); };
-
-
在全局入口挂载并初始化 Provider
-
import { UserStoreProvider } from '@/providers/userStoreProvider'; export default function Layout({children}) { return ( <UserStoreProvider> {children}</UserStoreProvider > ) }
-
-
消费/变更用户数据
-
const userInfo = useUserStore((state) => state.userInfo);
-
3、请求库
-
服务端 fetch
-
Next.js 扩展了 Web fetch() API,允许服务器上的每个请求设置自己的持久化缓存和重新验证语义。
-
新增两个api,配合 RSC 渲染实现缓存机制
-
cache :配置请求如何与 Next.js 数据缓存 (Data Cache) 交互
-
auto no cache(默认值):Next.js 会在每次请求时获取资源,且会在next build时会获取一次 -
no-store: Next.js 会在每次请求时获取资源 -
force-cache: Next.js 会在其数据缓存中查找匹配的请求。如果找到匹配且未过期,将从缓存返回。如果没有匹配或匹配已过期,Next.js 将从远程服务器获取资源并更新缓存。
-
-
revalidate: 设置资源的缓存生命周期(以秒为单位)。
-
false- 无限期缓存资源。语义上等同于revalidate: Infinity。HTTP 缓存可能会随时间推移淘汰旧资源。 -
0- 阻止资源被缓存。 -
number- (以秒为单位)指定资源的缓存生命周期最多为n秒。
-
-
-
客户端 fetch
- 同 window.fetch
4、css 预处理器
从 less 到 Sass
5、声明路由到文件路由
-
特点总结:声明式路由和文件路由各有特点,声明路由上手简单,路由结构清晰;文件路由有一定上手门槛,控制颗粒度更细
-
声明路由 文件路由 差异化 重定向 redirct 文件拦截路由 文件拦截路由支持动态拦截,功能更强大 声明嵌套路由 文件嵌套路由 无差异 动态路由 文件动态路由 功能无差异。文件路由可以按需添加Loading.js,not-found.js等处理文件,控制颗粒度会更细,声明路由需要根据pathname手动处理公共逻辑 - 文件并行路由 在设计条件路由、权限路由、标签页、URL模态框的时候比较有用
-
6、微前端
在 Next.js中实现 @umi/plugin-qiankun 插件功能
-
原先umi框架中自带了一款非常好用的插件 @umi/plugin-qiankun 这让微前端接入、更新、状态传输变的异常简单,但是在 Next.js 中我们需要自己实现这个插件及相关功能
- 暂时无法在唯科之家2.0文档外展示此内容
通过服务端server实现qiankun运行时注册,通过 Zustand + qiankun 实现子应用动态更新
第三章:部署方案
第一节:构建与部署
1、基础镜像
- base/nodejs_nginx_anolios_brotli:v22.14.0_1.21.4.1
此前的基础镜像主要面向SPA应用,承担网络代理与静态资源转发的功能。在引入Next.js服务端渲染方案后,我们还需要部署一套Node服务。按照原有部署流程,需单独申请一个应用来运行Node服务,并额外配置一个Nginx应用,用于处理代理与静态资源相关配置。
为简化部署流程,我们现已重新构建了一款Base镜像,支持在单一实例中同时启动两个进程:Nginx服务负责请求转发和静态资源处理,而Node进程则用于运行Next.js服务。
2、构建
standalone
以前使用 Docker 部署时,需要安装包中 dependencies 的所有文件才能运行 next start。从 Next.js 12 开始,可以利用 .next/ 目录中的输出文件追踪功能.nft.json,仅包含必要的文件.next 目录
我们通过开启 standlone 特性,此时 Next.js 可以根据.nft.json自动创建一个 standalone 文件夹,仅复制生产部署所需的文件,包括 node_modules 中的选定文件。还会输出一个最小化的 server.js 文件,可用于替代 next start
优化之前构建时长+镜像制作(包含打包modules时间)共计15分钟。优化之后构建时长和镜像制作约3分钟,当然启用 standalone 也为我们带来一些挑战
- 默认 next build 部署方式
![]()
- 使用
standalone构建
![]()
-
Q1 :当我们使用 pnpm 构建时,报错 Error: EPERM: operation not permitted, symlink
- A:windows系统非管理员权限问题 (相关Discussions),
- 方法一:可以通过设置,这会扁平化pnpm依赖,需要注意的是幽灵依赖问题
-
## 禁用符号链接来避免此问题,避免非管理员 standalone 构建报错 symlink=false node-linker=hoisted - 方法二:本地构建通过环境变量用 output: 'export', // 而不是 'standalone', 生产环境还是使用standalone,需要注意两种构建方式产物差异的问题,这可能会导致本地开发效果跟生产不一致
-
方法三:换电脑 or 申请管理员权限
-
Q2:
standalone为了极致的性能甚至不会复制public或.next/static文件夹,他默认我们会将这些文件内容部署到cdn,- A:当我们没有
cdn服务器的时候,需要额外的复制这些文件到.next文件夹下 -
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
- A:当我们没有
-
Q3:如何自定义端口或主机名
- A:在启动命令中添加
-
PORT=3000 HOSTNAME=0.0.0.0
3、部署
-
关于启动命令,先启动 Next.js 相关 Node 服务器(内部服务器,包含环境变量、端口、主机名称),在启动Nginx 服务(提供外部访问,含动态域名解析)
-
HOSTNAME=localhost SERVER_API_ENV=online node ./standalone/server.js & bash /opt/nginx/conf/resolve.sh && /opt/nginx/runnginx.sh
-
第二节:Nginx 配置说明
1、服务端代理
################ 网关配置 #####################
################ 网关配置 #####################
# 网关代理
location ~ ^/(noauth|portal)/ {
set $proxy_url http://gc-gw-server.mscloud.lo;
proxy_pass $proxy_url;
}
2、微前端子应用资源代理
通过子应用资源代理,子应用就不用申请公网域名
################ 主项目加载子项目静态页面配置 #####################
################ 主项目加载子项目静态页面配置 #####################
# 子应用转发
location ~ /msportal/(\w+)/(.*) {
# 静态资源根据hash名称缓存
add_header Cache-Control $cache_control_header;
proxy_pass http://gc-$1-fe.mscloud.lo/$2$is_args$args;
}
3、Next.js 资源兜底
在配置 SPA Nginx 服务器的时候,通常我们会返回一个兜底的静态页面,防止应用白屏化。同样的,我们也可以在Nginx里面将没有代理到的请求通通转向 Next.js 应用,由应用来处理各种异常情况
################ 其他所有请求交给 Next.js 处理 #####################
################ 其他所有请求交给 Next.js 处理 #####################
# 核心Next.js资源
location /_next/static {
proxy_pass http://localhost:3000;
expires 30d;
}
# 静态资源缓存设置
location /public {
proxy_pass http://localhost:3000;
expires 30d;
}
# 核心路由代理(指向 Node 应用)
location / {
add_header Cache-Control $cache_control_header;
proxy_pass http://localhost:3000;
}
告别“屎山”:用 Husky + Prettier + ESLint 打造前端项目的代码基石
一个多人参与的项目,如果没有代码规范,会导致代码样式五花八门,特别难看。
我相信对于程序员这个职业来说,一个项目中,代码格式千奇百怪,没有几个人能忍吧。
现在在github上随意看一个项目,都几乎是配备了husky的,这个工具可以hook到git的命令,然后自动运行相关脚本,进而达到自动格式化代码的目的。
今天,我们就来详细介绍如何配置 Husky,并结合 Prettier 和 ESLint,为你的项目构建一个坚实的代码规范防线。
核心工具栈一览
在深入配置之前,我们先来认识一下我们将要使用的“黄金组合”:
- Husky: Git Hooks 管理工具,负责拦截 Git 命令并在特定阶段执行脚本。
- Prettier: 强大的代码格式化工具,确保代码风格统一美观。
- ESLint: 静态代码分析工具,用于发现并修复代码中的潜在问题和不符合规范的写法。
- lint-staged: 配合 Husky 使用,只对 Git 暂存区的文件进行 Lint 和格式化,大幅提升效率。
- eslint-config-prettier: 解决 ESLint 与 Prettier 规则冲突的关键配置。
配置 Husky:自动化检查的“守门员”
husky官网,进去之后按照引导安装即可
# 安装 Husky
npm install --save-dev husky
# 初始化 Husky (会在项目根目录创建 .husky 文件夹)
npx husky init
小贴士: 如果你的项目尚未进行 Git 初始化 (git init),npx husky init 命令会报错提示 .git can't be found。请务必先初始化 Git 仓库,再进行 Husky 初始化。
![]()
安装相关依赖
这里husky只是可以hook到git的命令,具体要做什么,我们要自定义。
为了实现代码的自动化格式化和规范检查,我们需要安装 Prettier、ESLint,以及它们的重要辅助工具 lint-staged 和 eslint-config-prettier。
# 安装 prettier 和 eslint (如果还没装的话)
npm install --save-dev prettier eslint
# 安装 lint-staged
npm install --save-dev lint-staged
# 解决 Prettier 和 ESLint 规则冲突的重要插件
# (这个插件会关闭所有可能与 Prettier 冲突的 ESLint 格式化规则)
npm install --save-dev eslint-config-prettier
配置lint-staged
在我们的 package.json 中添加 lint-staged 的配置。这告诉它对不同类型的文件执行什么操作:
{
"scripts": {
// ... 其他 scripts
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix"
],
"*.{json,css,md,html}": [
"prettier --write"
]
},
// ... 其他依赖
}
修改eslint.config.js配置
eslint-config-prettier 会关闭所有可能与 Prettier 格式规则冲突的 ESLint 规则。在 Flat Config 中,配置是按数组顺序应用的,后一个配置会覆盖前一个。因此,我们需要确保 eslintConfigPrettier 处于配置数组的末尾。
// 1. 引入 prettier 配置
import eslintConfigPrettier from "eslint-config-prettier";
// 2. 并在最后添加 eslintConfigPrettier
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
eslintConfigPrettier,
]);
修改husky的hook命令
这里就是最后的一步了,在commit的时候,我们要运行什么命令
修改.husky/pre-commit,在里面添加
npx lint-staged
自定义代码规范
如果我们想修改代码的格式规范,可以在prettierrc.json文件中对其进行修改,如果没有这个文件,创建一个就好了
// .prettierrc.json
{
"tabWidth": 4, // 缩进宽度为 4 个空格
"semi": false, // 不使用分号
"singleQuote": true, // 使用单引号
"printWidth": 80, // 每行代码最多 80 个字符 (可选,但推荐)
"trailingComma": "all" // 对象或数组的最后一个元素后总是添加逗号 (可选,但推荐)
}
总结
通过 Husky、Prettier、ESLint 和 lint-staged 的强强联合,我们构建了一个高效且强大的代码规范自动化流程。这将:
- 统一代码风格: 团队成员的代码风格将高度一致,降低阅读成本。
- 提升代码质量: 及时发现潜在的 Bug 和不规范的写法。
- 优化开发体验: 格式化和检查在提交前自动完成,无需手动干预。
- 确保项目健康: 从源头杜绝“屎山”的产生,让项目代码库始终保持健康和易于维护。
Vibe Coding 实战指南:从“手写代码”到“意图设计”的前端范式转移
1. 引言:零编码实现的初体验
最近在开发需求时,频繁听到关于 AI 编程能力的讨论。借着 Qoder 测试版发布的契机,我决定在实际业务中体验一把“零编码”开发。
这次实验不仅是一次工具的试用,更是一次开发模式的范式转移。我发现,在 AI 辅助下,前端工程师的角色正在发生本质变化:我们不再仅仅是代码的编写者(Coder),而是转变为**架构师 + 产品经理 + 质量保障(QA)**的复合体。这正是 Andrej Karpathy 所提出的 "Vibe Coding"(氛围编码/直觉编码) ——你负责把握逻辑与效果(The Vibe),AI 负责具体的语法实现(The Code)。
2. 实战复盘:级联多选组件的“无中生有”
2.1 需求背景与挑战
业务场景需要一个支持多选的级联选择器(省-市-区-社区),且交互逻辑复杂:
- 省市层级单选,区与社区层级多选。
- 单选与多选逻辑混合。
-
技术难点:项目使用的 Vant UI 组件库中的
Cascader组件原生仅支持单选。
2.2 初始沟通:粗糙但有效的意图传达
这是我第一次完全依赖 AI 编程。我的第一版 Prompt 非常直白,甚至有些粗糙,直接把需求和设计稿丢给了 AI:
User Prompt:
你知道 vant 中的 Cascader 吗?组件只能支持单选,我希望它支持多选。
请把这里的 Cascader 替换为多选组件。
我可以给你提供可供参考的 UI 设计稿,你有不懂的可以随时问我。
附件:ui稿位置@index.md
2.3 AI 的惊人反馈:不仅仅是代码
让我惊讶的是,Claude 并没有直接扔给我一堆代码,而是先生成了一份**《级联多选组件设计文档》**。
这份文档包含了:
- 技术架构:使用 Mermaid 绘制的组件层次结构图。
-
数据流向:清晰的
Props和Emit接口定义。 - 逻辑边界:详细描述了“向下级联全选”和“向上级联反选”的逻辑。
💡 洞察:AI 生成的设计文档虽然完美,但实际生成的代码(MVP版本)却存在数据加载失败、Tab 无法切换等 Bug。这揭示了 Vibe Coding 的核心痛点:宏观设计完美,微观实现易错。
3. 核心方法论:如何让 AI 读懂你的“意图”
在经历了十几轮的修复和迭代后,我总结出了驾驭 AI 的三板斧。
3.1 意图定义(Define the Vibe)
放弃思考 DOM 结构,跳出传统的“实现细节”,转而描述“功能与交互”。
- ❌ Bad Vibe: "写一个红色的按钮。"
- ✅ Good Vibe: "创建一个主操作按钮,当鼠标 Hover 时有微小的缩放动画,点击时显示 Loading 状态,并且要符合我们现有的紫色品牌色调。"
3.2 规则约束(Context & Rules)
AI 就像一个才华横溢但不受约束的实习生。如果不立规矩,它会写出风格迥异的代码。我们需要通过 .cursorrules 或系统提示词来约束它。
实战经验: 我将团队的 ESLint 规则、TypeScript 规范以及 React/Vue 的最佳实践整理给 AI。例如:
-
变量命名:必须使用
const,变量名要语义化。 -
类型安全:禁止使用
any,优先使用interface而非type。 - 样式管理:明确指定使用 Tailwind CSS 还是 styled-components,防止 AI 混用。
(注:在实际操作中,建议建立项目级的 .cursorrules 文件,将编码规范固化下来,AI 会自动读取。)
3.3 微调与迭代(Review & Refine)
AI 生成的代码往往不能直接上线,这时需要进行微调。
- 精确上下文:不要让 AI 盲目重写。选中具体的代码行,告诉它:“只修改这个函数的错误处理逻辑”。
- 测试驱动:让 AI 自己生成测试用例和测试页面。我去测试页面操作,发现 Bug 后,将现象描述给 AI,让它自我修复。
4. 避坑指南与最佳实践
4.1 警惕“幻觉”与样式崩坏
- 现象:AI 可能会编造不存在的 Tailwind 类名,或者使用了项目未安装的图标库。
-
对策:在 Rules 中明确白名单。例如:“图标库请仅使用
Lucide-React,不要引入其他库。”
4.2 保持代码一致性
-
现象:同一个组件,AI 一会儿用
function定义,一会儿用箭头函数。 - 对策:One-Shot Learning(单样本学习) 。在 Prompt 中贴一段你认为完美的现有代码作为“范本”,告诉 AI:“请严格模仿这段代码的风格和结构来生成新组件。”
4.3 复杂状态管理的边界
- 现象:当涉及复杂的全局状态(如 Redux/Zustand)或核心业务流时,AI 容易逻辑混乱。
- 对策:分层开发。核心的业务逻辑(Store 设计、API 层)建议由资深工程师把控架构,UI 层和简单的逻辑处理交给 AI。
5. 总结:前端工程师的进化
Vibe Coding 模式下,我们还需要写代码吗?答案是需要的,但写的“代码”变了。
我们需要编写的不再是具体的 if-else,而是:
- Prompt:精准描述需求的自然语言。
- Rules:约束 AI 行为的规范文档。
- Tests:验证 AI 产出的验收标准。
在这个新时代,评估优秀前端的标准也随之改变:
- Prompt Engineering 能力:能否用最短的语言描述最复杂的交互?
- Code Review 能力:能否在 AI 生成的千行代码中,一眼洞察性能隐患?
- 架构设计能力:能否搭建让 AI 发挥得更好的基础设施?
"Coding is not about typing; it's about thinking."
Vibe Coding 并没有消灭编程,它只是帮我们省去了“打字”的过程,让我们终于可以回归编程的本质:思考解决问题的方法。