阅读视图

发现新文章,点击刷新页面。

🔥3行代码搞定全局代理!告别插件依赖的极简方案

关键词:抓包 · 终端魔法 · 一劳永逸 · 全平台通用

🔍 你是否也经历过这些抓狂时刻?

  • 每次调试都要在浏览器里反复开关 SwitchyOmega 插件
  • 手机抓包需要单独配置代理服务器地址
  • 命令行工具的网络请求总是“漏网之鱼”
  • 无痕模式、跨浏览器抓包困难
  • 换台设备就得重新配置全套代理规则

今天分享一个极简全局代理方案,只需三行代码就能实现:

wifi look  # 查看网络服务
wifi on    # 秒开全局代理
wifi off   # 一键恢复自由
wifi help  # 查看帮助

效果演示

Kapture 2025-08-14 at 00.24.39.gif

🛠️ 原理解密:系统级代理的降维打击

传统方案 vs 系统级方案

方案 生效范围 配置复杂度 跨设备同步
浏览器插件 仅单浏览器
系统代理(本文) 全应用生效 极低

技术内核解析

graph LR
    A[终端命令] --> B{networksetup}      
    B --> C[修改系统网络配置]
    C --> D[全局流量重定向]
    D --> E[Whistle代理服务器]

🚀 三步实现终极代理方案(Mac 示例)

第一步:注入终端魔法

方法一:使用函数(推荐)

在 ~/.zshrc 中添加函数:

wifi() {
  if [[ $1 == "look" ]]; then
    networksetup -listallnetworkservices
  elif [[ $1 == "on" ]]; then
    networksetup -setwebproxy "Wi-Fi" 127.0.0.1 8899
    networksetup -setsecurewebproxy "Wi-Fi" 127.0.0.1 8899
    echo "🔛 代理已开启"
  elif [[ $1 == "off" ]]; then
    networksetup -setwebproxystate "Wi-Fi" off
    networksetup -setsecurewebproxystate "Wi-Fi" off
    echo "🔴 代理已关闭"
  else
    echo "可用命令:"
    echo "  wifi look  # 查看网络服务"
    echo "  wifi on    # 开启代理"
    echo "  wifi off   # 关闭代理"
  fi
}

方法二:使用嵌套别名

alias wifi="noglob _wifi"
_wifi() {
  if [[ $1 == "look" ]]; then
    shift
    networksetup -listallnetworkservices
  else
    echo "未知命令: wifi $@"
  fi
}

使用示例:

# 查看网络服务
wifi look

# 开启代理 (需先定义函数中的 on 逻辑)
wifi on

# 关闭代理
wifi off

增强版函数(带代理状态检查):

wifi() {
  local service="Wi-Fi"
  
  case $1 in
    look)
      networksetup -listallnetworkservices
      ;;
    on)
      networksetup -setwebproxy "$service" 127.0.0.1 8899
      networksetup -setsecurewebproxy "$service" 127.0.0.1 8899
      echo "✅ 全局代理已开启 (127.0.0.1:8899)"
      ;;
    off)
      networksetup -setwebproxystate "$service" off
      networksetup -setsecurewebproxystate "$service" off
      echo "❌ 全局代理已关闭"
      ;;
    status)
      echo "\n🛜 代理状态:"
      networksetup -getwebproxy "$service"
      echo "\n🔒 安全代理状态:"
      networksetup -getsecurewebproxy "$service"
      ;;
    *)
      echo "wifi 命令集:"
      echo "  look    ➜ 列出网络服务"
      echo "  on      ➜ 开启代理"
      echo "  off     ➜ 关闭代理"
      echo "  status  ➜ 查看代理状态"
      ;;
  esac
}

使用提示:

  1. 将上述函数添加到 ~/.zshrc 文件底部
  2. 执行 source ~/.zshrc 使配置生效
  3. 如果网络服务名称不是 "Wi-Fi",请修改 local service="Your-Network-Service-Name"

💡 函数比嵌套别名更推荐,因为它:

  1. 支持更复杂的逻辑
  2. 可以处理参数和错误输入
  3. 允许添加帮助信息和状态反馈
  4. 避免 zsh 的 glob 扩展问题(无需 noglob

执行wifi on后,PC端代理就完全生效了,无论是什么是*无痕模式*、还是其它*任意浏览器*代理都将有效


第二步:跨设备同步配置

手机端同步方案(TB):

graph LR 
    A[电脑开启热点] --> B[手机连接热点]
    B --> C[手动配置代理]
    C --> D[服务器=电脑IP]
    D --> E[端口=8899]

第三步:信任安全证书

  1. 浏览器访问 http://127.0.0.1:8899
  2. 下载 RootCA 证书
  3. 钥匙串访问 → 证书 → 信任设置为始终允许

✨ 方案优势全景图

pie 
    title 方案核心优势
    "零插件依赖": 35
    "全平台通用": 30
    "配置极简化": 25
    "终端可操作": 10

⚡ 效率提升实测

操作 传统方式 本方案
开启代理 6次点击 1命令
多设备同步 手动配置 热点的
抓包命令行请求 不可用 自动抓

💡 进阶技巧:给常用场景加特效

# 快速切换测试环境
test-env() {
  wifi on
  echo "🚦 已切换到测试环境"
}

# 自动抓包并打开控制台
debug-mode() {
  w2 start
  open http://127.0.0.1:8899
  test-env
}

🌟 小结:极简主义的胜利

为什么这个方案值得尝试

特性 传统插件方案 wifi命令方案
覆盖范围 仅浏览器 全系统应用
配置复杂度 每个浏览器单独配置 一次配置永久生效
移动端支持 基本不可用 完美支持
资源占用 每个浏览器单独进程 零额外资源
响应速度 插件加载耗时 即时生效
  • 一行函数 = 系统级代理开关
  • 零插件 = 告别浏览器依赖
  • 全平台通用 = 手机/PC无缝衔接
  • 终端集成 = 开发效率倍增器

当我们在复杂的工具链中挣扎时,往往忽略了操作系统本身的能力。这个方案的精妙之处在于:

用系统级原生能力代替第三方插件
用终端命令取代图形界面操作
用极简思维解决复杂问题

下次当你为网络调试焦头烂额时,不妨试试在终端轻轻敲下:

wifi on

让流量掌控回归本质,把时间留给真正的创造吧!🎯

注意:使用此方案的时候,浏览器的SwitchyOmega 插件不要开启,因为插件的优先级会高于系统代理本身。开启的情况下,即使执行了wifi off,代理也是生效的。
另外,手机安装完证书,一定要重启手机后抓包才能生效。


总结

技术工具演进的本质,是把复杂留给机器,把简单留给人类。这个方案最打动我的地方在于:

当你用wifi on瞬间激活整个开发环境,
当你在手机终端执行相同命令获得一致体验,
当你告别无数插件切换和配置页面...

操作丝滑流畅,工作体验极佳!

无需安装,不用付费,只要3行代码,就能解锁这样优雅的解决方案——————这大概就是它的魅力所在吧!

axios 拦截器实现用户无感刷新 access_token

概述

公司网站登录过期时间都通常有长有短(token 过期时间),有的很短(几个小时),但又想让经常活跃的用户不再次登录,于是才有这样需求,避免了用户再次输入账号密码登录。

为什么要专门用一个 refresh_token 去更新 access_token 呢?首先access_token会关联一定的用户权限,如果用户授权更改了,这个access_token也是需要被刷新以关联新的权限的,如果没有 refresh_token,也可以刷新 access_token,但每次刷新都要用户输入登录用户名与密码,多麻烦。有了 refresh_ token,可以减少这个麻烦,客户端直接用 refresh_token 去更新 access_token,无需用户进行额外的操作。

  • 需求
  1. access_token过期的时候,要用refresh_token去请求获取新的access_token,前端需要做到用户无感知的刷新access_token。比如用户发起一个请求时,如果判断access_token已经过期,那么就先要去调用刷新 token 接口拿到新的access_token,再重新发起用户请求。
  2. 如果同时发起多个用户请求,第一个用户请求去调用刷新 token 接口,当接口还没返回时,其余的用户请求也依旧发起了刷新 token 接口请求,就会导致多个请求,这些请求都要合理处理

思路

写在响应拦截器里,拦截返回后的数据。先发起用户请求,如果接口返回access_token过期,先刷新access_token,再进行一次重试。

  • 优点:无需判断时间
  • 缺点: 会消耗多一次 http 请求

实现

这里使用 axios,其中做的是请求后拦截,所以用到的是 axios 的响应拦截器

方法介绍

有用到cookie,也可以不用,目前localstorage用的最多,根据自己项目决定。

  • @util/auth.js
import Cookies from 'js-cookie'

const TOKEN_KEY = 'access_token'
const REGRESH_TOKEN_KEY = 'refresh_token'
export const REFRESH_TOKEN_STATUS_CODE='402'
export const REFRENSH_UIR='/refresh-token'
export const getToken = () => Cookies.get(TOKEN_KEY)

export const setToken = (token, params = {}) => {
  Cookies.set(TOKEN_KEY, token, params)
}

export const setRefreshToken = token => {
  Cookies.set(REGRESH_TOKEN_KEY, token)
}
  • request.js
import axios from 'axios'
import { getToken, setToken, getRefreshToken,REFRESH_TOKEN_STATUS_CODE,REFRENSH_UIR } from '@util/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post(REFRENSH_UIR, { refresh_token: getRefreshToken() }, true)
}

const instance = axios.create({
  baseURL: 'xxxx.aa/cc',
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
})

instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    // token 过期或无效,在此处理逻辑
    return Promise.reject(error)
  }
)

// 给请求头添加 access_token
const setHeaderToken = isNeedToken => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) {
    // api 请求需要携带 access_token
    if (!accessToken) {
      console.log('不存在 access_token 则跳转回登录页')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用户授权使用,则不携带 access_token;默认不携带,需要传则设置第三个参数为 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}

接下来改造 request.js 中 axios 的响应拦截器

instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    if (error.response.status === REFRESH_TOKEN_STATUS_CODE) {
      const { config } = error
      return refreshToken()
        .then(res => {
          const { access_token } = res.data
          setToken(access_token)
          config.headers.Authorization = `Bearer ${access_token}`
          return instance(config)
        })
        .catch(err => {
          console.log('登录状态已失效,请重新登录!')
          return Promise.reject(err)
        })
    }
    return Promise.reject(error)
  }
)

约定返回 402(根据情况定) 状态码表示access_token过期或者无效,如果用户发起一个请求后返回结果是access_token过期,则请求刷新access_token的接口。请求成功则进入then里面,重置配置,并刷新access_token并重新发起原来的请求。

但如果refresh_token也过期了,则请求也是返回 402。此时调试会发现函数进不到refreshToken()catch里面,那是因为refreshToken()方法内部是也是用了同个instance实例,重复响应拦截器 402 的处理逻辑,但该函数本身就是刷新access_token,故需要把该接口排除掉,即:

if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
}

上述代码就已经实现了无感刷新access_token了,当access_token没过期,正常返回;过期时,则 axios 内部进行了一次刷新 token 的操作,再重新发起原来的请求。

优化

防止多次刷新 token

如果 token 是过期的,那请求刷新access_token的接口返回也是有一定时间间隔,如果此时还有其他请求发过来,就会再执行一次刷新access_token的接口,就会导致多次刷新access_token。因此,我们需要做一个判断,定义一个标记判断当前是否处于刷新access_token的状态,如果处在刷新状态则不再允许其他请求调用该接口。

let isRefreshing = false // 标记是否正在刷新 token
instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    if (error.response.status === REFRESH_TOKEN_STATUS_CODE && !error.config.url.includes(REFRENSH_UIR)) {
      const { config } = error
      if (!isRefreshing) {
        isRefreshing = true
        return refreshToken()
          .then(res => {
            const { access_token } = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            return instance(config)
          })
          .catch(err => {
            console.log('登录状态已失效,请重新登录!')
            return Promise.reject(err)
          })
          .finally(() => {
            isRefreshing = false
          })
      }
    }
    return Promise.reject(error)
  }
)

同时发起多个请求的处理

上面做法还不够,因为如果同时发起多个请求,在 token 过期的情况,第一个请求进入刷新 token 方法,则其他请求进去没有做任何逻辑处理,单纯返回失败,最终只执行了第一个请求,这显然不合理。

比如同时发起三个请求,第一个请求进入刷新 token 的流程,第二个和第三个请求需要存起来,等到 token 更新后再重新发起请求。

在此,我们定义一个数组requests,用来保存处于等待的请求,之后返回一个Promise,只要不调用resolve方法,该请求就会处于等待状态,则可以知道其实数组存的是函数;等到 token 更新完毕,则通过数组循环执行函数,即逐个执行 resolve 重发请求。

let isRefreshing = false // 标记是否正在刷新 token
let requests = [] // 存储待重发请求的数组

instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    if (error.response.status === REFRESH_TOKEN_STATUS_CODE && !error.config.url.includes(REFRENSH_UIR)) {
      const { config } = error
      if (!isRefreshing) {
        isRefreshing = true
        return refreshToken()
          .then(res => {
            const { access_token } = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            // token 刷新后将数组的方法重新执行
            requests.forEach(cb => cb(access_token))
            requests = [] // 重新请求完清空
            return instance(config)
          })
          .catch(err => {
            console.log('登录状态已失效,请重新登录!')
            return Promise.reject(err)
          })
          .finally(() => {
            isRefreshing = false
          })
      } else {
        // 返回未执行 resolve 的 Promise
        return new Promise(resolve => {
          // 用函数形式将 resolve 存入,等待刷新后再执行
          requests.push(token => {
            config.headers.Authorization = `Bearer ${token}`
            resolve(instance(config))
          })
        })
      }
    }
    return Promise.reject(error)
  }
)

最终 request.js 代码

import axios from 'axios'
import { getToken, setToken, getRefreshToken ,REFRESH_TOKEN_STATUS_CODE,REFRENSH_UIR} from '@util/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post(REFRENSH_UIR, { refresh_token: getRefreshToken() }, true)
}

// 创建 axios 实例
const instance = axios.create({
  baseURL: process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
})

let isRefreshing = false // 标记是否正在刷新 token
let requests = [] // 存储待重发请求的数组

instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    if (error.response.status === REFRESH_TOKEN_STATUS_CODE && !error.config.url.includes(REFRENSH_UIR)) {
      const { config } = error
      if (!isRefreshing) {
        isRefreshing = true
        return refreshToken()
          .then(res => {
            const { access_token } = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            // token 刷新后将数组的方法重新执行
            requests.forEach(cb => cb(access_token))
            requests = [] // 重新请求完清空
            return instance(config)
          })
          .catch(err => {
            console.log('登录状态已失效,请重新登录!')
            return Promise.reject(err)
          })
          .finally(() => {
            isRefreshing = false
          })
      } else {
        // 返回未执行 resolve 的 Promise
        return new Promise(resolve => {
          // 用函数形式将 resolve 存入,等待刷新后再执行
          requests.push(token => {
            config.headers.Authorization = `Bearer ${token}`
            resolve(instance(config))
          })
        })
      }
    }
    return Promise.reject(error)
  }
)

// 给请求头添加 access_token
const setHeaderToken = isNeedToken => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) {
    // api 请求需要携带 access_token
    if (!accessToken) {
      console.log('不存在 access_token 则跳转回登录页')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用户授权使用,则无需携带 access_token;默认不携带,需要传则设置第三个参数为 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}

前端实现即时通讯,常用的技术

概述

前面文章介绍了websocket,sse,轮询这些前后端通讯方案,本期做一期汇总和对比。

短轮询

短轮询(Short Polling)是一种通过定期向服务器发送请求来获取最新数据的实时通讯方法。与长轮询(Long Polling)不同,短轮询不会保持持久连接,而是在每次请求时向服务器询问是否有新数据。

以下是短轮询的基本工作流程:

  1. 客户端发送请求:前端通过定时发送 HTTP 请求到服务器,以获取最新数据。这个请求通常是一个简单的 AJAX 请求。
  2. 服务器响应:服务器接收到请求后,会立即返回当前的数据状态给客户端。如果有新数据,服务器会将其包含在响应中返回;否则,服务器会返回一个空响应或者一个指示没有新数据的状态码。
  3. 客户端处理响应:客户端收到服务器的响应后,解析数据并进行相应的处理。如果服务器返回了新数据,客户端可能会更新界面显示或者执行其他逻辑。
  4. 重复请求:客户端在处理完服务器响应后,会等待一段时间(通常是几秒钟),然后再次发送新的请求,以获取最新的数据状态。
  5. 循环进行:这个过程会不断循环执行,客户端定期发送请求,服务器定期响应,从而实现实时通讯的效果。

在短轮询的实现中,客户端可以使用setInterval()函数周期性地发送请求,然后在收到响应后更新界面。以下是一个简单的示例:

function pollServerForUpdates() {
  setInterval(function() {
    fetch('http://example.com/update')
      .then(response => response.json())
      .then(data => {
        // 处理从服务器返回的数据
        console.log('收到新数据:', data);
      })
      .catch(error => {
        console.error('请求错误:', error);
      });
  }, 5000); // 每5秒发送一次请求
}

pollServerForUpdates();

在实际应用中,需要根据具体情况调整轮询的频率,并且考虑到服务器和网络的性能,以避免对系统造成过大的负担。

优缺点

优点:

  • 简单易实现:短轮询的实现相对简单,不需要特殊的服务器支持,使用简单的HTTP请求即可。
  • 兼容性好:与长轮询相比,短轮询在各种浏览器和网络环境下都能够正常工作。

缺点:

  • 效率较低:因为客户端需要频繁地发送请求,可能会造成网络流量浪费,同时也会增加服务器压力。
  • 实时性差:由于客户端需要等待固定的时间间隔才能获取更新,因此实时性不如长轮询或WebSocket。

总的来说,短轮询适用于一些对实时性要求不高,且对服务器压力要求相对较低的场景。虽然实时性和效率不如长轮询或WebSocket,但在一些简单的应用中,短轮询可以是一种简单有效的实现方式。 在前端实现即时通讯,常用的技术包括短轮询,WebSocket和SSE。

长轮询

长轮询(Long Polling)是一种改进的轮询技术,它在某些方面优于传统的短轮询。

长轮询通过客户端向服务器发送一个持续连接的请求,在有新数据可用时立即返回响应,否则保持连接直到超时或者有新数据到达。

以下是长轮询的基本工作流程

  1. 客户端发送请求:客户端向服务器发送一个长轮询请求,通常是一个普通的 HTTP 请求,但在服务器端会保持连接打开。
  2. 服务器处理请求:服务器接收到长轮询请求后,会检查是否有新的数据可用。如果有新数据,服务器会立即将其包含在响应中返回给客户端。
  3. 响应返回:如果服务器有新数据,它会立即返回响应给客户端,并关闭连接。如果没有新数据可用,服务器会保持连接打开,等待一段时间直到超时或者有新数据到达。
  4. 客户端处理响应:客户端收到服务器的响应后,解析数据并进行相应的处理。然后,客户端会立即发送一个新的长轮询请求,以便在服务器端保持持续连接。
  5. 循环进行:这个过程会不断循环执行,客户端和服务器之间保持持续连接,从而实现实时通讯的效果。

长轮询的优点是可以实现实时通讯的效果,并且相对于短轮询来说减少了不必要的 HTTP 请求次数,减少了网络流量和服务器负载。但是长轮询仍然存在一定的延迟,因为客户端需要等待服务器响应或者超时才能发送下一个请求。同时,长轮询需要服务器支持保持长连接,因此对服务器的资源消耗较大。

以下是使用 Node.js 实现的简单长轮询服务器和客户端的代码示例:

首先是服务器端的代码 (server.js):

const express = require('express');

const app = express();
const port = 3000;

let data = "Initial Data";

app.get('/data', (req, res) => {
  const timeout = 10; // 设置超时时间,单位为秒
  const startTime = new Date().getTime();

  const checkData = () => {
    // 检查数据是否发生变化或特定事件是否发生,这里假设直接使用一个全局变量 data 来模拟数据的变化
    if (data !== "Initial Data") {
      res.json({ data: data });
    } else if ((new Date().getTime() - startTime) > timeout * 1000) {
      res.json({ data: null }); // 如果超时,则返回空响应
    } else {
      setTimeout(checkData, 1000); // 等待一段时间后再次检查
    }
  };

  checkData();
});

app.post('/update', express.json(), (req, res) => {
  data = req.body.data;
  res.json({ message: "Data updated successfully" });
});

app.listen(port, () => {
  console.log(`Server is listening at http://localhost:${port}`);
});

接下来是客户端的代码 (client.js):

const fetch = require('node-fetch');

function longPolling() {
  setInterval(() => {
    fetch('http://localhost:3000/data')
      .then(response => response.json())
      .then(data => {
        if (data.data !== null) {
          console.log("Received updated data:", data.data);
        }
      })
      .catch(error => {
        console.error('Error:', error);
      });
  }, 1000); // 每秒重新发起请求
}

longPolling();

在这个示例中,服务器端使用 Express 框架创建了一个简单的 API,其中 /data 路由实现了长轮询的逻辑。客户端通过不断发送 GET 请求到 /data 路由来获取最新的数据。当数据发生变化时,服务器会立即返回响应。如果超过了设置的超时时间而没有新的数据可用,服务器会返回一个空响应,客户端会在收到空响应后立即重新发起请求。

优缺点

优点

  1. 即时性(Real-Time) 长轮询允许服务器在有新数据时立即向客户端推送信息,从而实现近乎实时的通信效果。
  2. 简单实现:与WebSocket相比,长轮询更容易实现,因为它利用了HTTP协议,无需特殊的服务器支持。
  3. 兼容性:由于长轮询基于HTTP协议,因此在大多数现代浏览器和服务器上都能够正常工作,而不需要特殊的配置或兼容性处理。

缺点

  1. 资源浪费 长轮询会造成服务器维持大量的长期连接,这可能会导致服务器资源的浪费,尤其是在大规模部署的情况下。
  2. 延迟较大 即使在有新数据时,客户端也需要等待一段时间才能收到响应,因此长轮询的延迟相对较大,不如 WebSocket 或 Server-Sent Events 实时。
  3. 连接维持开销 由于长轮询需要维持较长时间的连接,因此会增加网络和服务器的连接维持开销,尤其是在高并发情况下。
  4. 不支持全双工通信 长轮询是一种单向通信方式,即客户端发送请求等待服务器响应,而服务器不能直接向客户端发送消息。这意味着在某些场景下,长轮询无法满足双向通信的需求。

综上所述,长轮询适用于需要实时通信但对延迟要求不是很高的场景,但在高并发和大规模部署的情况下,可能会带来一定的性能和资源开销。在选择长轮询还是其他实时通信技术时,需要根据具体的业务需求和技术环境进行权衡。

SSE

SSE(Server-Sent Events) 服务器推送事件,简称 SSE,是一种服务端实时主动向浏览器推送消息的技术。

SSEHTML5 中一个与通信相关的 API,主要由两部分组成:服务端与浏览器端的通信协议(HTTP 协议)及浏览器端可供 JavaScript 使用的 EventSource 对象。

SSE是一种用于实现服务器向客户端推送数据的技术,它允许服务器端实时地向客户端发送事件流。相比于长轮询,SSE 更加轻量级且易于实现,但也有一些限制。

SSE 协议非常简单,本质是浏览器发起 http 请求,服务器在收到请求后,返回状态与数据,并附带以下 headers:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
  • SSE API规定推送事件流的 MIME 类型为 text/event-stream
  • 必须指定浏览器不缓存服务端发送的数据,以确保浏览器可以实时显示服务端发送的数据。
  • SSE 是一个一直保持开启的 TCP 连接,所以 Connection 为 keep-alive

下面是一个简单的使用 Node.js 实现 SSE 的示例代码:

const express = require('express');

const app = express();
const port = 3000;

let clients = [];

app.get('/events', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const clientId = Date.now();
  clients.push({ id: clientId, res });

  // 发送一个初始化消息
  res.write(`data: Connected\n\n`);

  // 客户端断开连接时移除对应的客户端
  req.on('close', () => {
    clients = clients.filter(client => client.id !== clientId);
  });
});

// 路由用于发送数据到所有连接的客户端
app.post('/update', express.json(), (req, res) => {
  const newData = req.body.data;

  clients.forEach(client => {
    client.res.write(`data: ${JSON.stringify({ data: newData })}\n\n`);
  });

  res.json({ message: "Data sent successfully" });
});

app.listen(port, () => {
  console.log(`Server is listening at http://localhost:${port}`);
});

在这个示例中,客户端通过向 /events 路由发送 GET 请求来订阅服务器端的事件流。服务器端会维护一个客户端列表,当有新数据需要推送时,会遍历客户端列表将数据发送给所有客户端。

客户端可以使用 JavaScript 来监听事件流:

const eventSource = new EventSource('http://localhost:3000/events');

eventSource.onmessage = function(event) {
  const data = JSON.parse(event.data);
  console.log("Received updated data:", data.data);
};

eventSource.onerror = function(error) {
  console.error('Error:', error);
};

优缺点

优点:

  1. 实时性:SSE 提供了更接近实时的数据推送,因为它是基于单个持久连接,服务器端可以实时向客户端发送数据。
  2. 轻量级:SSE 使用标准的 HTTP 协议,不需要额外的库或协议,因此实现更加轻量级。
  3. 简单易用:相比于 WebSockets,SSE 的实现和使用更加简单,无需处理复杂的连接管理和协议。

但是,SSE 也有一些限制:

  1. 单向通信:SSE 只支持服务器向客户端的单向通信,客户端无法直接向服务器发送数据。
  2. 兼容性:虽然大多数现代浏览器都支持 SSE,但并不是所有浏览器都支持。特别是一些旧版本的浏览器可能不支持 SSE。
  3. 连接断开重连:在某些情况下,连接可能会断开,需要客户端重新连接。这就需要客户端实现重连逻辑来保持连接的持久性。

总的来说,SSE 适合那些需要实时推送数据且对双向通信要求不高的应用场景,例如实时通知、实时数据更新等。在选择实时通信技术时,开发人员应根据具体的需求和场景来选择合适的技术。

应用场景

  1. 即时通知和提醒: SSE 可用于向用户发送即时通知和提醒,例如社交媒体应用中的新消息通知、电子邮件应用中的新邮件提醒等。
  2. 实时数据更新: SSE 可以用于在网页应用程序中实时更新数据,例如股票市场应用程序中的股票价格、即时聊天应用程序中的新消息等。
  3. 在线游戏: SSE 可以用于实现在线游戏中的实时通信和事件更新,例如多人在线游戏中的玩家位置更新、游戏状态变化等。
  4. 监控和警报系统: SSE 可以用于实时监控系统和警报系统,例如监控服务器状态、网络流量等,并在发生异常时向管理员发送警报。
  5. 实时数据可视化: SSE 可以用于在网页应用程序中实时更新数据可视化图表,例如实时股票走势图、实时天气预报等。
  6. 在线交易系统: SSE 可以用于在线交易系统中实时更新交易价格和订单状态,以及向用户发送交易确认和通知。
  7. 事件流处理: SSE 可以用于处理事件流数据,例如日志记录、数据分析等,实时将处理结果推送给客户端。
  8. 客服支持: SSE 可以用于实现客服支持系统,客户可以在网页上与 ChatGPT 进行实时交流,向其提出问题并获得即时的回复。

兼容性

Websocket

Websocket 是一种在客户端和服务器之间实现双向通信的技术,它通过一个持久连接客户端和服务器之间建立实时、高效的通信机制。

相比传统的 HTTP 请求,它能够提供更低的延迟更高的效率,使得实时通讯成为可能。

下面是使用 JavaScript 的简单示例:

服务端代码

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', function connection(ws) {
  console.log('A client connected');

  ws.on('message', function incoming(message) {
    console.log('Received message:', message);

    // 发送数据给所有连接的客户端
    wss.clients.forEach(function each(client) {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(message);
      }
    });
  });

  ws.on('close', function close() {
    console.log('Client disconnected');
  });
});

客户端代码

const ws = new WebSocket('ws://localhost:8080');

ws.onopen = function() {
  console.log('Connected to the server');
};

ws.onmessage = function(event) {
  console.log('Received message:', event.data);
};

ws.onclose = function() {
  console.log('Disconnected from the server');
};

// 发送数据给服务器
ws.send('Hello, server!');

特点

  • 支持双向通信,实时性更强
  • 可以发送文本,也可以发送二进制数据
  • 建立在TCP协议之上,服务端的实现比较容易
  • 数据格式比较轻量性能开销小,通信高效
  • 没有同源限制,客户端可以与任意服务器通信
  • 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL
  • 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

优缺点

优点包括:

  1. 实时性:Websocket 提供了双向通信的能力,可以实现实时的数据传输,适用于需要及时更新的应用场景,如实时聊天、在线游戏等。
  2. 高性能:Websocket 使用单个 TCP 连接,减少了握手和请求头的开销,降低了延迟,提高了性能。
  3. 跨平台支持:Websocket 是一种标准化的协议,并且受到现代浏览器的广泛支持,适用于多种平台和设备。
  4. 持久连接:Websocket 使用长期的连接,可以保持连接状态,服务器端可以主动向客户端推送数据,而不需要客户端主动发起请求。

然而,Websocket 也有一些缺点:

  1. 跨域限制 与其他跨域通信技术一样,WebSocket 也受到浏览器的同源策略限制,需要通过 CORS 或代理等方式解决跨域访问的问题。
  2. 安全性考虑 WebSocket 的持久性连接可能会增加一些安全风险,例如长时间的连接可能会增加 DoS 攻击的风险,因此需要采取相应的安全措施,如限制连接数、实施身份验证等。
  3. 网络代理限制 一些网络代理可能会阻止 WebSocket 连接,导致部分用户无法使用 WebSocket 进行通信,需要考虑兼容性和容错性。
  4. 状态管理复杂 WebSocket 的持久性连接需要服务器端维护连接状态,可能会增加服务器端的状态管理复杂性,需要考虑连接的管理和维护。
  5. 协议版本兼容性 不同的浏览器和服务器可能支持不同版本的 WebSocket 协议,需要确保客户端和服务器端之间的协议版本兼容性,以确保通信的稳定性和可靠性。

总的来说,Websocket 是一种功能强大、实时性高的通信技术,适用于需要实时双向通信的应用场景。在选择实时通信技术时,开发人员可以根据需求和情况权衡各种通信技术的优缺点来选择最适合的技术。

应用场景

WebSocket 适用于需要实时双向通信的各种应用场景,包括但不限于:

  1. 在线聊天应用: 实时性是在线聊天应用的关键需求之一,WebSocket 可以实现客户端和服务器之间的即时通信,使得用户能够实时收发消息,提高用户体验。
  2. 实时协作编辑: 对于需要多人协作编辑的应用,如 Google Docs、在线白板等,WebSocket 可以实现实时的文档同步,保持所有参与者的编辑内容同步更新。
  3. 实时游戏: 在线多人游戏需要快速、实时的数据传输,WebSocket 可以实现游戏客户端和服务器之间的实时通信,支持游戏中的角色移动、游戏事件等实时更新。
  4. 实时监控和通知: 对于需要实时监控数据或发送实时通知的应用,如监控系统、实时报警系统等,WebSocket 可以实现服务器向客户端实时推送监控数据或通知信息。
  5. 实时数据可视化: 在需要实时展示数据的应用中,如股票行情、实时交通情况等,WebSocket 可以实现数据的实时更新和展示,使用户能够及时获取最新的信息。

总的来说,Websocket 在需要实时双向通信的各种应用场景中都有广泛的应用前景,特别是对于那些需要及时更新数据、实时交互的场景来说,是一种非常值得选择的通信技术。

兼容性

与SSE对比

好的,下面是一个比较 WebSocket 和 Server-Sent Events (SSE) 的表格:

特性 WebSocket Server-Sent Events (SSE)
通信方向 双向通信 单向通信
协议 基于 TCP 基于 HTTP
使用难度 相对复杂 轻量级,使用简单
实时性 实时通信,适用于需要即时更新的应用场景 实时推送,适用于只需要服务器向客户端推送数据的简单应用场景
延迟 通常较低,由于建立了持久性连接 可能较高,受限于 HTTP 请求-响应模式
数据格式 任意类型的数据 只能传输文本数据
自动重连 不支持,需要客户端手动重连 支持,客户端断开连接时会自动尝试重新连接
连接个数 可同时支持大量连接 连接数 HTTP/1.1 6 个,HTTP/2 可协商(默认 100)
应用场景 实时双向通信、即时更新的应用 只需要服务器向客户端推送数据的简单应用

告别 any!用联合类型打造更灵活、更安全的 TS 代码

一、什么是联合类型?

联合类型使用竖线 | 作为分隔符,表示一个值可以是列出的类型中的任意一种。

// ID 只接收数字或字符串作为参数
function printId(id: number | string) {
      console.log("Your ID is: " + id);
}

printId(101);       // OK
printId("202");     // OK
printId({ id: 303 }); // 类型“{ id: number; }”的参数不能赋给类型“string | number”的参数。

二、使用类型守卫收窄类型(断言类型)

1. typeof 类型守卫

typeof 是最常见的类型守卫,一般处理 string, number, boolean, symbol, bigint, undefined, function 这些基础类型时使用。

function printId(id: number | string) {
    if (typeof id === 'string') {
        // 在这个代码块内,TypeScript 知道 id 的类型是 string
        console.log(id.toUpperCase());
    } else {
        // 在这个代码块内,TypeScript 知 id 的类型是 number
        console.log(id);
    }
}

printId('good');
printId(10);

2. instanceof 类型守卫

当处理类的实例时,使用instanceof 判断类型

class User {
  constructor(public name: string) {
    this.name =  name;
   }
}

class Product {
  constructor(public title: string) { 
    this.title = title;
  }
}

function printEntity(entity: User | Product) {
  if (entity instanceof User) {
    // entity 被收窄为 User 类型
    console.log("User: " + entity.name);
  } else {
    // entity 被收窄为 Product 类型
    console.log("Product: " + entity.title);
  }
}

let user = new User('john')
printEntity(user)
let product = new Product('title')
printEntity(product)

3. in 操作符守卫

在判断对象的属性时,常常使用in

interface Fish {
  swim: () => void;
}

interface Bird {
  fly: () => void;
}

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    // animal 被收窄为 Fish 类型
    return animal.swim();
  }
  // animal 被收窄为 Bird 类型
  return animal.fly();
}

let fish = {
  swim:()=>{
    console.log('fish is swim');
  }
}

let bird = {
  fly:()=>{
    console.log('bird fly');
  }
}

move(fish);
move(bird);

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多TypeScript开发干货

全面解析 JavaScript 类继承:方式、优缺点与应用场景

在 JavaScript 中,“继承”指的是让一个对象或类能够复用另一个对象或类的属性和方法。
由于 JavaScript 的面向对象机制是基于 原型(prototype) 而非传统类,因此继承方式丰富多样,从 ES5 时代的手动原型链,到 ES6 之后的 class 语法糖,都有不同的实现手段。

本文将系统介绍 8 种主要继承方式,分析它们的优缺点适用场景,并提供代码示例。


1. class 继承(ES6 extends

class Parent {
  greet() { console.log("Hello from Parent"); }
}
class Child extends Parent {
  greet() { super.greet(); console.log("...and from Child"); }
}
new Child().greet();

优点

  • 语法简洁,接近 Java / C# 等语言。
  • 内置 super 调用父类构造和方法。
  • 支持继承内置对象(Array, Error 等)。
  • 性能接近最佳(底层是寄生组合继承)。

缺点

  • 只是语法糖,本质仍是原型链。
  • 高级场景(动态改继承结构)需理解原型机制。

适用场景

  • 现代前端、Node.js 项目的默认选择。

2. class + 表达式继承(动态继承)

function mixin(base) {
  return class extends base {
    extra() { console.log("extra"); }
  }
}
class Parent {}
class Child extends mixin(Parent) {}
new Child().extra();

优点

  • 运行时动态决定父类。
  • 易于组合多个基类。

缺点

  • 可读性差,调试困难。

适用场景

  • 插件系统、UI 组件动态扩展。

3. 原型链继承

function Parent() { this.colors = ["red", "blue"]; }
Parent.prototype.say = function() { console.log("parent"); };

function Child() {}
Child.prototype = new Parent();
Child.prototype.constructor = Child;

优点

  • 实现简单。
  • 子类可直接访问父类原型方法。

缺点

  • 属性被所有实例共享(引用类型容易出错)。
  • 无法向父类构造传参。

适用场景

  • 学习原型链原理,已不推荐在生产使用。

4. 借用构造函数继承

function Parent(name) { this.name = name; }
function Child(name) { Parent.call(this, name); }

优点

  • 每个实例的属性独立。
  • 可传参。

缺点

  • 无法继承父类原型方法,方法需重复定义。

适用场景

  • 仅继承属性,不需要父类方法的简单对象。

5. 组合继承

function Parent(name) { this.name = name; }
Parent.prototype.say = function() { console.log(this.name); };

function Child(name) {
  Parent.call(this, name); // 继承属性
}
Child.prototype = new Parent(); // 继承方法
Child.prototype.constructor = Child;

优点

  • 继承父类属性与方法。
  • 子类实例独立。

缺点

  • 父类构造函数被调用两次。

适用场景

  • ES5 最常用方式,简单可靠。

6. 寄生组合继承

function inherit(Child, Parent) {
  Child.prototype = Object.create(Parent.prototype);
  Child.prototype.constructor = Child;
}
function Parent(name) { this.name = name; }
Parent.prototype.say = function() { console.log(this.name); };

function Child(name) {
  Parent.call(this, name);
}
inherit(Child, Parent);

优点

  • 父类构造只调用一次,性能好。
  • 完整继承属性和方法。

缺点

  • 写法比组合继承稍复杂。

适用场景

  • ES5 推荐的最佳继承方案。

7. 原型式继承

let parent = { greet() { console.log("hello"); } };
let child = Object.create(parent);
child.greet();

优点

  • 极简,无需构造函数。
  • 灵活,适合快速克隆对象。

缺点

  • 属性共享,引用类型有风险。
  • 无法传参初始化。

适用场景

  • 创建配置模板、数据对象。

8. 寄生式继承

function createChild(o) {
  let clone = Object.create(o);
  clone.say = function() { console.log("hi"); };
  return clone;
}
let parent = { greet() { console.log("hello"); } };
let child = createChild(parent);
child.say();

优点

  • 在原型式继承基础上增强对象。
  • 灵活度高。

缺点

  • 方法无法复用,浪费内存。

适用场景

  • 一次性对象增强。

9. Mixin 混入

const sayMixin = { say() { console.log("hi"); } };
class Person {}
Object.assign(Person.prototype, sayMixin);

优点

  • 模拟多继承。
  • 灵活扩展功能。

缺点

  • 命名冲突风险。
  • 方法来源分散,不易维护。

适用场景

  • 事件系统、功能扩展。

继承方式对比表

方式 优点 缺点 推荐度 场景
class 继承 语法简洁,支持 super 语法糖,本质是原型链 ⭐⭐⭐⭐⭐ 现代项目
class + 表达式 动态父类 可读性差 ⭐⭐⭐ 插件系统
原型链继承 简单直观 属性共享,不能传参 学习原理
借用构造函数 属性独立,可传参 不继承方法 ⭐⭐ 仅需属性
组合继承 属性独立+继承方法 调用两次父构造 ⭐⭐⭐⭐ ES5 常用
寄生组合继承 性能最佳 写法稍复杂 ⭐⭐⭐⭐⭐ ES5 推荐
原型式继承 极简 属性共享 ⭐⭐ 对象克隆
寄生式继承 灵活增强 无法复用方法 ⭐⭐ 一次性增强
Mixin 多继承效果 命名冲突风险 ⭐⭐⭐ 功能扩展

结语

  • 如果是 现代项目:优先使用 class extends
  • 如果是 ES5 项目:用 寄生组合继承
  • 如果只是克隆/增强对象:用 Object.createMixin
  • 学习原型链原理时,可以用最简单的原型链继承练习。

理解继承方式,不仅是写代码的技巧,更是掌握 JavaScript 对象模型的关键。

基础 | HTML语义、CSS3新特性、浏览器存储、this、防抖节流、重绘回流、date排序、calc

1. HTML 语义化标签的作用

graph TD
    A[语义化标签] --> B[SEO]
    A --> C[可访问性]
    A --> D[代码可读性]
    A --> E[自动化工具]

⚡这一步暗藏BUG?——用 <div class="header"> 也能做头部,但屏幕阅读器会迷路!

Q1: 为什么 <article><div> 更适合博客正文?
A1: <article> 自带独立内容含义,RSS/爬虫可直接识别,SEO 权重↑。

Q2: 🤯你以为懂了?那 <section><article> 能互相嵌套吗?
A2: 可以,但语义要自洽:<section> 表章节,<article> 表完整故事,别套娃到逻辑混乱。


2. 举例 CSS3 新特性

  • 布局:Flexbox、Grid
  • 视觉:border-radius、box-shadow、linear-gradient
  • 动画:transition、@keyframes、transform: translateZ(0) 硬件加速
  • 响应式:@media、clamp()

Q3: Grid 和 Flexbox 何时一起用?
A3: 外层 Grid 做二维骨架,内层 Flexbox 做一维对齐,电商商品列表常用。


3. 浏览器存储方案对比

特性 cookie localStorage sessionStorage
大小 ~4KB ~5MB ~5MB
生命周期 可设过期 永久 标签页关闭
随请求携带
适用场景 登录态 主题/缓存 表单草稿

Q4: 如何防止 localStorage 被 XSS 窃取?
A4: 存敏感信息前做加密(如 AES),并设置 Content-Security-Policy 禁止内联脚本。


4. JS 变量提升

console.log(a); // undefined(声明提升,赋值不提升)
var a = 1;

Q5: let/const 真的不提升吗?
A5: 也提升,但存在「暂时性死区」,在声明前访问直接抛 ReferenceError。


5. this 关键字 & 箭头函数

  • 普通函数:运行时绑定,谁调用指向谁
  • 箭头函数:词法作用域,定义时捕获外层 this
  • call/apply/bind:显式绑定,区别只在传参方式
const obj = { x: 1 };
function show() { console.log(this.x); }
show.call(obj);        // 1
show.apply(obj, []);   // 1
const bound = show.bind(obj);
bound();               // 1

Q6: 箭头函数能用 call 改 this 吗?
A6: 不能,箭头函数 this 固化,call/apply/bind 无效。


6. typeof vs instanceof

typeof []          // "object"(数组也是对象)
[] instanceof Array // true

Q7: 🤯如何准确判断 NaN?
A7: Number.isNaN(NaN),因为 typeof NaN === 'number'


7. 防抖 vs 节流

// 防抖:停止触发后 wait 毫秒执行
const debounce = (fn, wait) => {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
};

// 节流:每 wait 毫秒最多执行一次
const throttle = (fn, wait) => {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last > wait) {
      last = now;
      fn.apply(this, args);
    }
  };
};

Q8: 搜索框输入用哪个?
A8: 防抖,避免每敲一次就请求。


8. 重绘 vs 回流

  • 回流(reflow):几何变化,如宽高、位置
  • 重绘(repaint):外观变化,如颜色、阴影
    性能优化:读写分离、transform 合成层、避免 table 布局

Q9: 如何强制触发一次回流?
A9: 读取 offsetHeightgetComputedStyle 等会 flush 队列。


9. 代码题:表格 date 字段正序/倒序

<table id="t">
  <thead><tr><th onclick="sortTable()">Date</th></tr></thead>
  <tbody>
    <tr><td>2023-10-01</td></tr>
    <tr><td>2022-05-20</td></tr>
  </tbody>
</table>

<script>
let asc = true;
function sortTable() {
  const tbody = t.querySelector('tbody');
  const rows = [...tbody.rows].sort((a, b) => {
    const d1 = new Date(a.cells[0].textContent);
    const d2 = new Date(b.cells[0].textContent);
    return asc ? d1 - d2 : d2 - d1;
  });
  asc = !asc;
  rows.forEach(r => tbody.appendChild(r)); // 复用节点,减少回流
}
</script>

10. 代码题:calc 最大乘积拆分

function calc(n) {
  if (n < 2) return [];
  const res = [];
  let k = 2;
  while (n >= k) {
    res.push(k);
    n -= k++;
  }
  // 把余数均匀加到后面几项,避免 1
  for (let i = res.length - 1; n > 0; i--) {
    res[i]++;
    n--;
  }
  return res;
}
console.log(calc(10)); // [3,3,4] 3*3*4=36 最大

Q10: 边界:n=2 时返回 [2] 还是 [1,1]?
A10: [2],乘积更大且不含 1。

HarmonyOS 开发实战:快速更改应用名字与图标的终极指南

在这里插入图片描述

摘要

在做鸿蒙(HarmonyOS)应用开发时,很多人都会遇到一个小需求——给应用换个更酷的名字,或者换一个更符合品牌调性的图标。虽然看起来只是“换个皮”,但这是应用上线前非常关键的一步,因为用户打开手机首先看到的就是图标和名字。本文会从零开始,讲清楚在鸿蒙项目里如何快速修改应用名字和图标,并结合几个实际场景,给你可直接运行的 Demo 代码。

引言

随着鸿蒙生态越来越成熟,应用的外观和体验不仅仅是 UI 设计的事,图标和名字这种“第一印象”元素,也会直接影响用户的点击欲望和对应用的好感度。

举个例子:

  • 你开发了一个天气应用,想在不同节日换不同的名字和图标,比如春节叫“春节天气”,用个红色喜庆图标。
  • 你的应用经历了一次品牌升级,名字、LOGO 全面换新,需要快速更新到应用包里。
  • 在多版本共存的情况下,你可能需要给测试版和正式版不同的标识。

这些需求,其实都能通过简单修改项目配置文件来完成。

基础操作:修改应用名字和图标

修改名字

在鸿蒙应用工程的 config.json 文件里,有一个 appName 属性,这个就是应用显示在桌面上的名字。

{
  "app": {
    "bundleName": "com.example.myapp",
    "vendor": "zs",
    "version": {
      "code": 1,
      "name": "1.0.0"
    },
    "appName": "MyApp", // 这里就是应用名字
    "icon": "$media:app_icon"
  }
}

你只需要把 appName 改成想要的名字,比如 "春节天气",编译打包后,桌面上的名字就会更新。

修改图标

图标在鸿蒙里一般存放在 resources/base/media 目录下,config.json 里的 icon 属性会引用到它。

比如:

"icon": "$media:app_icon"

这里的 app_icon 就是资源文件名(不带后缀),对应的文件路径可能是:

resources/base/media/app_icon.png

如果你要换图标,只需要:

  1. 准备好新的 .png 文件(建议正方形,192x192 或更高分辨率)。
  2. 替换掉 resources/base/media 里的原图文件(保持文件名一致,或者改名后同步修改 config.json 的引用)。
  3. 重新编译打包。

Demo:快速修改名字和图标

假设你现在有一个默认应用,名字是 MyApp,图标是默认的蓝色圆形。

我们要改成:

  • 名字:节日天气
  • 图标:festival_icon.png

config.json 修改:

{
  "app": {
    "bundleName": "com.example.weather",
    "vendor": "zs",
    "version": {
      "code": 2,
      "name": "2.0.0"
    },
    "appName": "节日天气",
    "icon": "$media:festival_icon"
  }
}

资源目录结构:

resources/
  base/
    media/
      festival_icon.png

改好后重新编译运行,桌面就会显示新的名字和图标。

实际应用场景

场景一:品牌升级

情况:公司决定更换品牌 LOGO 和应用名称。 做法

  • 修改 appName 为新品牌名
  • 替换 icon 为新品牌 LOGO 示例代码
"appName": "新品牌助手",
"icon": "$media:new_brand_icon"

场景二:节日主题活动

情况:在春节、国庆等节日推出限时版本,让用户有参与感。 做法

  • 准备节日主题图标(例如红色、灯笼元素)
  • appName 增加节日元素 示例代码
"appName": "春节天气",
"icon": "$media:chinese_new_year_icon"

场景三:测试版与正式版区分

情况:为了方便测试团队区分版本,可以给测试包加上特殊标识。 做法

  • 名字后加 [Test]
  • 图标换成灰色或带 TEST 标识的版本 示例代码
"appName": "MyApp [Test]",
"icon": "$media:test_version_icon"

QA 环节

Q1:我改了 config.json 但桌面名字没变,为什么? A:可能是因为手机桌面对应用有缓存,可以尝试卸载旧应用再安装新包,或者清除桌面缓存。

Q2:不同分辨率的图标怎么处理? A:鸿蒙支持多分辨率资源,你可以在不同的 resources/base/media 子目录放不同尺寸的图片,比如 media-mdpimedia-hdpi,系统会自动适配。

Q3:我能动态更改名字和图标吗? A:目前鸿蒙官方不支持应用在运行时动态更改桌面图标和名字,需要打包新版本才能生效。

总结

更改鸿蒙应用的名字和图标,其实就是两步:

config.jsonappNameicon 属性 替换资源目录下的图标文件

虽然看起来简单,但背后涉及用户体验、品牌形象、版本区分等多个方面。在实际开发中,如果你能灵活运用这个技巧,就可以让你的应用在不同场景下更有个性化,让用户一眼就能认出来。

前端监测用户卡顿之INP

INP (Interaction to Next Paint) 是 Google Core Web Vitals 中的一个实验性指标,旨在衡量页面对用户交互的整体响应能力。它通过记录用户与页面进行交互(例如点击、拖动、按键)到浏览器实际绘制出视觉更新之间的时间,来评估页面的响应速度。

INP 测量原理:

一个交互的生命周期可以分解为几个阶段:

  1. 输入延迟 (Input Delay): 从用户开始交互(例如 pointerdown 事件)到浏览器主线程开始处理事件回调之间的时间。
  2. 处理时间 (Processing Time): 事件回调函数执行以及浏览器更新 DOM 所需的时间。这包括 JavaScript 执行、样式计算、布局(Layout)和绘制(Paint)等。
  3. 呈现延迟 (Presentation Delay): 从事件处理完成到浏览器实际在屏幕上呈现出视觉更新(即下一帧绘制完成)之间的时间。

INP 的值是: 在页面生命周期内,所有符合条件的交互中,最长的那次交互的持续时间(通常是 75th 百分位数,以避免极端值)。

如何测量 INP?

前端主要通过 PerformanceObserver API 来监听 event 类型的性能条目(Performance Entry)。这些条目包含了交互的关键时间戳。

PerformanceEventTiming 接口的关键属性:

  • name: 事件名称,例如 "click", "keydown", "pointerdown"。
  • entryType: 始终为 "event"。
  • startTime: 事件开始时间(用户输入发生的时间)。
  • duration: 事件总持续时间(从 startTimerenderTime)。
  • processingStart: 浏览器开始处理事件回调的时间。
  • processingEnd: 事件回调执行完成且浏览器完成样式计算和布局的时间。
  • renderTime: 浏览器完成此事件引起的视觉更新并将其绘制到屏幕上的时间。这是 INP 测量中最重要的时间点。
  • interactionId: (实验性)一个唯一标识符,用于将属于同一用户交互的多个事件(例如 pointerdownclick)关联起来。

INP 的计算公式(单次交互):

INP = renderTime - startTime

代码实现详解:

我们将创建一个 setupINPMonitor 函数,它会:

  1. 使用 PerformanceObserver 监听 event 类型的性能条目。
  2. 过滤出与用户交互相关的事件(如 click, keydown, mousedown)。
  3. 对于每个事件,计算其 renderTime - startTime 作为该事件的交互持续时间。
  4. 维护一个列表,记录所有有效交互的持续时间。
  5. 在页面即将卸载时(例如 pagehide 事件),计算所有记录的交互持续时间的 75th 百分位数,并报告最终的 INP 值。
/**
 * 计算数组的 75th 百分位数
 * @param {Array<number>} arr - 数字数组
 * @returns {number} 75th 百分位数
 */
function calculateP75(arr) {
  if (arr.length === 0) {
    return 0;
  }
  arr.sort((a, b) => a - b);
  const index = Math.floor(arr.length * 0.75);
  return arr[index];
}

/**
 * 监听 INP (Interaction to Next Paint) 指标
 * 这是一个简化的手动实现,用于理解原理。
 * 生产环境强烈建议使用 Google 官方的 'web-vitals' 库。
 *
 * @param {function(number): void} onINPChange - INP 值变化时的回调函数,参数为当前 INP 值(毫秒)
 */
function setupINPMonitor(onINPChange) {
  // 检查浏览器是否支持 PerformanceObserver 和 event entryType
  if (!('PerformanceObserver' in window) || !('event' in PerformanceObserver.supportedEntryTypes)) {
    console.warn('当前浏览器不支持 Event Timing API 或 INP 相关功能。');
    return () => {}; // 返回空函数
  }

  // 存储所有有效交互的持续时间
  const interactionDurations = [];

  // 存储当前正在进行的交互,以 interactionId 为键
  // 这样可以处理一个交互包含多个事件的情况(例如 pointerdown -> click)
  const activeInteractions = new Map();

  // 监听 PerformanceEntry
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 确保是 PerformanceEventTiming 类型
      if (entry.entryType !== 'event') {
        continue;
      }

      // 过滤掉非用户交互事件(例如,非用户触发的动画事件等)
      // 并且只关注有 renderTime 的事件,因为 INP 依赖于视觉更新
      // 常见的用户交互事件类型:click, keydown, mousedown, pointerdown
      const isUserInteraction = ['click', 'keydown', 'mousedown', 'pointerdown'].includes(entry.name);
      if (!isUserInteraction || entry.renderTime === 0) {
        continue;
      }

      // 获取交互 ID。interactionId 是用于将相关事件分组的关键。
      // 如果浏览器不支持 interactionId,则回退到使用事件的 startTime 作为唯一 ID。
      const interactionId = entry.interactionId || entry.startTime;

      // 如果是新的交互,或者该交互的 renderTime 更晚(表示更完整的视觉更新)
      // 则更新或记录该交互
      if (!activeInteractions.has(interactionId) || entry.renderTime > activeInteractions.get(interactionId).renderTime) {
        activeInteractions.set(interactionId, {
          startTime: entry.startTime,
          renderTime: entry.renderTime,
          processingEnd: entry.processingEnd, // 记录处理结束时间,可用于调试
          name: entry.name // 记录事件名称
        });
      }
    }
  });

  // 开始观察 'event' 类型的性能条目
  // buffered: true 意味着可以获取在 observer 注册之前发生的事件
  observer.observe({ type: 'event', buffered: true });

  // 页面隐藏或卸载时报告最终 INP 值
  const reportINP = () => {
    // 将所有 activeInteractions 中的交互持续时间添加到 interactionDurations 数组
    activeInteractions.forEach(interaction => {
      const duration = interaction.renderTime - interaction.startTime;
      if (duration >= 0) { // 确保持续时间是正值
        interactionDurations.push(duration);
      }
    });

    // 清空 activeInteractions,避免重复计算
    activeInteractions.clear();

    if (interactionDurations.length > 0) {
      // INP 通常取 75th 百分位数
      const finalINP = calculateP75(interactionDurations);
      console.log(`最终 INP (75th percentile): ${finalINP.toFixed(2)}ms`);
      onINPChange(finalINP);

      // 可以在这里上报最终的 INP 值到你的分析服务
      // 例如:sendToAnalytics('INP', finalINP);
    } else {
      console.log('没有检测到有效的用户交互来计算 INP。');
      onINPChange(0); // 或者其他默认值
    }

    // 停止观察
    observer.disconnect();
  };

  // 监听 pagehide 事件,这是报告最终指标的推荐时机
  // 因为它在页面卸载前触发,且比 beforeunload 更可靠
  window.addEventListener('pagehide', reportINP);

  // 也可以监听 visibilitychange 事件,当页面变为 hidden 时报告
  // 这对于单页应用 (SPA) 或长时间运行的页面可能更合适
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      reportINP();
    }
  });

  // 返回一个函数用于停止监测 (如果需要提前停止)
  return () => {
    observer.disconnect();
    window.removeEventListener('pagehide', reportINP);
    document.removeEventListener('visibilitychange', reportINP);
  };
}

// --- 示例用法 ---

// 启动 INP 监测
const stopINPMonitor = setupINPMonitor((inpValue) => {
  console.log(`报告 INP 值: ${inpValue}ms`);
  // 根据 INP 值进行判断和告警
  if (inpValue > 200) { // 超过 200ms 通常被认为是需要改进
    console.warn('INP 值过高,可能存在交互卡顿问题!');
  }
});

// 模拟一个会造成 INP 问题的按钮点击
document.addEventListener('DOMContentLoaded', () => {
  const button = document.createElement('button');
  button.textContent = '点击我(可能卡顿)';
  button.style.padding = '10px 20px';
  button.style.fontSize = '18px';
  button.style.margin = '20px';
  document.body.appendChild(button);

  const resultDiv = document.createElement('div');
  resultDiv.style.margin = '20px';
  document.body.appendChild(resultDiv);

  button.addEventListener('click', () => {
    console.log('按钮被点击了!开始执行耗时操作...');
    resultDiv.textContent = '正在处理...';

    // 模拟一个耗时操作,阻塞主线程
    let sum = 0;
    for (let i = 0; i < 500000000; i++) { // 增加循环次数,模拟长时间阻塞
      sum += i;
    }
    console.log('耗时操作完成,结果:', sum);
    resultDiv.textContent = `处理完成!结果: ${sum}`;

    // 可以在这里模拟一个 DOM 更新,确保有 renderTime 产生
    const newElement = document.createElement('p');
    newElement.textContent = '新的内容已添加!';
    resultDiv.appendChild(newElement);

    // 确保有视觉更新,否则 renderTime 可能为 0
    setTimeout(() => {
        newElement.style.color = 'blue';
    }, 0);
  });

  // 模拟一个会造成 INP 问题的键盘输入
  document.addEventListener('keydown', (event) => {
    if (event.key === 'a') {
      console.log('按下了 "a" 键!开始执行耗时操作...');
      resultDiv.textContent = '正在处理键盘输入...';
      let sum = 0;
      for (let i = 0; i < 300000000; i++) {
        sum += i;
      }
      console.log('键盘输入处理完成,结果:', sum);
      resultDiv.textContent = `键盘输入处理完成!结果: ${sum}`;
    }
  });
});

// 可以在某个时机停止监测,例如在 SPA 路由切换时
// setTimeout(() => {
//   stopINPMonitor();
//   console.log('INP 监测已停止。');
// }, 60000); // 1分钟后停止

代码讲解:

  1. calculateP75(arr) 函数:

    • 这是一个辅助函数,用于计算给定数字数组的 75th 百分位数。INP 的官方定义就是取所有交互持续时间的 75th 百分位数,而不是简单地取最大值,这能更好地反映大多数用户的体验。
  2. setupINPMonitor(onINPChange) 函数:

    • 能力检测: 首先检查 PerformanceObserverevent entryType 是否被当前浏览器支持。如果不支持,则直接返回一个空函数,避免报错。

    • interactionDurations 数组: 用于存储所有被视为有效交互的持续时间(renderTime - startTime)。最终的 INP 将从这个数组中计算得出。

    • activeInteractions Map: 这是一个关键的数据结构。由于一个用户交互(例如,一次完整的鼠标点击)可能由多个性能事件(如 pointerdown, mousedown, click)组成,并且这些事件可能在不同的时间点触发,我们需要一个机制来将它们归类到同一个逻辑交互中。

      • interactionId 属性(如果可用)是浏览器提供的一种将这些相关事件分组的方式。如果不支持,我们回退到使用 startTime 作为临时 ID。
      • activeInteractions Map 会以 interactionId 为键,存储该交互的 startTime 和最新的 renderTime。我们总是保留最晚的 renderTime,因为 INP 关注的是最终的视觉更新。
    • PerformanceObserver 实例:

      • new PerformanceObserver((list) => { ... }):创建一个观察者,当检测到符合条件的性能条目时,会执行回调函数。
      • observer.observe({ type: 'event', buffered: true }):告诉观察者我们对 event 类型的性能条目感兴趣。buffered: true 非常重要,它允许我们获取在 PerformanceObserver 注册之前就已经发生的事件,这对于捕获页面加载初期发生的交互至关重要。
    • 回调函数逻辑:

      • 过滤事件: 只处理 entry.entryType === 'event' 的条目。
      • 用户交互判断: 进一步过滤,只关注与用户直接交互相关的事件,如 click, keydown, mousedown, pointerdown。同时,entry.renderTime === 0 表示该事件没有引起视觉更新,不应计入 INP,因此也过滤掉。
      • interactionId 处理: 尝试使用 entry.interactionId 来唯一标识一个交互。如果浏览器不支持(旧版本),则使用 entry.startTime 作为回退。
      • 更新 activeInteractions 如果是新的交互 ID,或者当前事件的 renderTime 比 Map 中已记录的该交互的 renderTime 更晚,则更新 Map 中的记录。这确保我们总是捕获到该交互所导致的最终视觉更新时间。
    • reportINP() 函数:

      • 在页面即将隐藏或卸载时调用(通过 pagehidevisibilitychange 事件)。
      • 遍历 activeInteractions Map,计算每个交互的持续时间 (interaction.renderTime - interaction.startTime),并将其添加到 interactionDurations 数组中。
      • 清空 activeInteractions Map。
      • 如果 interactionDurations 数组中有数据,则计算 75th 百分位数作为最终的 INP 值,并通过 onINPChange 回调函数报告。
      • observer.disconnect():停止观察者,释放资源。
    • 事件监听:

      • window.addEventListener('pagehide', reportINP):这是报告最终 Web Vitals 指标的推荐时机,因为它在页面卸载前触发,且比 beforeunload 更可靠。
      • document.addEventListener('visibilitychange', ...):当页面可见性状态改变为 hidden 时,也触发报告。这对于单页应用(SPA)或用户切换标签页等场景很有用。

注意事项和限制:

  1. 复杂性: 手动实现 INP 监测比看起来要复杂得多。上述代码是一个简化版本,用于理解核心原理。它没有处理所有边缘情况,例如:

    • 异步任务: 如果一个交互触发了异步任务(如 fetch 请求),并且这些异步任务在事件回调结束后才导致最终的视觉更新,那么 entry.renderTime 可能无法完全捕获到整个交互的持续时间。
    • 长任务: 如果事件处理过程中有长任务阻塞主线程,renderTime 应该反映出这个阻塞。PerformanceEventTiming 旨在包含这些,但实际情况可能复杂。
    • 非交互事件: 准确区分哪些 event 条目是真正的用户交互,哪些是浏览器内部事件,需要更精细的过滤。
    • interactionId 的兼容性: interactionId 属性是实验性的,并非所有浏览器都完全支持。在不支持的浏览器中,我们的回退逻辑(使用 startTime)可能导致一些不准确的交互分组。
  2. 推荐使用 web-vitals 库:

    • 对于生产环境,强烈推荐使用 Google 官方提供的 web-vitals JavaScript 库

    • 这个库由 Google 团队维护,它封装了所有复杂的逻辑,包括对各种边缘情况的处理、浏览器兼容性、以及精确的 INP 计算(包括对 renderTime 的高级处理)。

    • 使用 web-vitals 库非常简单,只需几行代码即可:

      import { onINP } from 'web-vitals';
      
      onINP((metric) => {
        console.log('INP 报告:', metric);
        // 将 metric.value 发送到你的分析服务
      });
      
    • 它会为你处理所有的 PerformanceObserver 注册、事件过滤、交互分组、以及最终的 75th 百分位数计算和报告时机。

手动实现有助于深入理解 INP 的工作原理,但在实际项目中,为了准确性和维护性,请务必使用 web-vitals 库。

监测用户在浏览界面过程中的卡顿

在前端,监测用户在浏览界面过程中的卡顿(Jank)是优化用户体验的关键一环。卡顿通常表现为动画不流畅、滚动不平滑、点击无响应等。这通常是由于浏览器主线程被长时间占用,无法及时响应用户输入或更新渲染。

以下是多种监测卡顿的方法和代码示例,从底层 API 到高级工具:

1. 帧率监测 (FPS Monitoring)

帧率是衡量动画和滚动流畅度的最直观指标。通常,低于 60 FPS 就会开始感觉到卡顿。

原理:
利用 requestAnimationFrame (rAF) 在浏览器下一次重绘前执行回调的特性。通过计算连续两次 rAF 回调之间的时间间隔,可以推算出当前的帧率。

代码示例:

/**
 * 帧率监测器
 * @param {function(number): void} onFPSChange - FPS 变化时的回调函数,参数为当前 FPS 值
 * @param {number} interval - 报告 FPS 的间隔时间(毫秒),默认为 1000ms
 */
function setupFPSMonitor(onFPSChange, interval = 1000) {
  let frameCount = 0;
  let lastTime = performance.now();
  let rafId;

  function animate() {
    const currentTime = performance.now();
    frameCount++;

    // 每隔一定时间报告一次 FPS
    if (currentTime - lastTime >= interval) {
      const fps = Math.round((frameCount * 1000) / (currentTime - lastTime));
      onFPSChange(fps);
      frameCount = 0;
      lastTime = currentTime;
    }

    rafId = requestAnimationFrame(animate);
  }

  // 启动监测
  animate();

  // 返回一个函数用于停止监测
  return () => {
    cancelAnimationFrame(rafId);
  };
}

// 示例用法:
const stopFPSMonitor = setupFPSMonitor((fps) => {
  console.log(`当前 FPS: ${fps}`);
  // 如果 FPS 低于某个阈值,可以认为是卡顿
  if (fps < 30) {
    console.warn('检测到严重卡顿!当前 FPS:', fps);
    // 可以在这里上报卡顿事件
  }
});

// 模拟一个耗时操作来观察 FPS 下降
// setTimeout(() => {
//   let sum = 0;
//   for (let i = 0; i < 1000000000; i++) {
//     sum += i;
//   }
//   console.log('耗时操作完成', sum);
// }, 2000);

// 停止监测 (可选)
// setTimeout(() => {
//   stopFPSMonitor();
//   console.log('FPS 监测已停止。');
// }, 10000);

优缺点:

  • 优点: 简单易实现,直观反映 UI 流畅度。
  • 缺点: 无法直接定位卡顿原因,只能知道“卡了”,不知道“为什么卡”。高 FPS 并不意味着没有潜在的性能问题。

2. 长任务监测 (Long Task Monitoring)

长任务是指在浏览器主线程上运行时间超过 50 毫秒的任务。它们会阻塞主线程,导致页面无响应,是造成卡顿的主要原因。

原理:
使用 PerformanceObserver 监听 longtask 类型。当浏览器检测到长任务时,会触发回调。

代码示例:

/**
 * 长任务监测器
 * @param {function(PerformanceEntry): void} onLongTask - 检测到长任务时的回调函数
 */
function setupLongTaskMonitor(onLongTask) {
  // 检查浏览器是否支持 Long Task API
  if ('PerformanceObserver' in window && 'longtask' in PerformanceObserver.supportedEntryTypes) {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // entry.name: 通常是 "self"
        // entry.duration: 任务持续时间 (毫秒)
        // entry.startTime: 任务开始时间 (毫秒)
        // entry.attribution: 任务归因信息 (例如,哪个脚本或元素导致)
        console.warn('检测到长任务:', entry);
        onLongTask(entry);

        // 可以根据 duration 设置阈值进行上报
        if (entry.duration > 100) { // 超过 100ms 的任务被认为是严重卡顿
          console.error(`严重长任务:${entry.duration.toFixed(2)}ms`, entry);
          // 可以在这里上报长任务事件,包含 entry 的详细信息
        }
      }
    });

    observer.observe({ entryTypes: ['longtask'] });

    // 返回一个函数用于停止监测
    return () => observer.disconnect();
  } else {
    console.warn('当前浏览器不支持 Long Task API。');
    return () => {}; // 返回空函数
  }
}

// 示例用法:
const stopLongTaskMonitor = setupLongTaskMonitor((task) => {
  // console.log('长任务数据:', task);
  // 在这里可以对长任务数据进行处理或上报
});

// 模拟一个长任务
function simulateLongTask() {
  console.log('开始模拟长任务...');
  let sum = 0;
  for (let i = 0; i < 500000000; i++) { // 增加循环次数,确保超过 50ms
    sum += i;
  }
  console.log('模拟长任务结束,结果:', sum);
}

// 在某个用户交互或定时器中触发长任务
// document.getElementById('myButton').addEventListener('click', simulateLongTask);
// 或者
setTimeout(simulateLongTask, 3000);

// 停止监测 (可选)
// setTimeout(() => {
//   stopLongTaskMonitor();
//   console.log('长任务监测已停止。');
// }, 10000);

优缺点:

  • 优点: 直接定位到导致卡顿的“罪魁祸首”(长任务),提供任务的持续时间和归因信息,有助于调试。
  • 缺点: 并非所有浏览器都完全支持 attribution 属性,且无法捕获所有类型的卡顿(例如,GPU 渲染瓶颈)。

3. 交互延迟监测 (Interaction Latency Monitoring)

这衡量的是用户从输入(如点击、按键)到浏览器开始处理该输入之间的时间。Web Vitals 中的 FID (First Input Delay) 就是一个重要的指标。

原理:
使用 PerformanceObserver 监听 event 类型,特别是 first-input

代码示例:

/**
 * 交互延迟监测器 (FID)
 * @param {function(number): void} onFIDChange - FID 变化时的回调函数,参数为 FID 值(毫秒)
 */
function setupFIDMonitor(onFIDChange) {
  if ('PerformanceObserver' in window && 'event' in PerformanceObserver.supportedEntryTypes) {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'first-input') {
          // FID = start time of processing - start time of input
          const fid = entry.processingStart - entry.startTime;
          console.log(`首次输入延迟 (FID): ${fid.toFixed(2)}ms`, entry);
          onFIDChange(fid);

          if (fid > 100) { // 超过 100ms 的 FID 体验不佳
            console.warn(`首次输入延迟过高:${fid.toFixed(2)}ms`, entry);
            // 可以在这里上报 FID 事件
          }
          // 首次输入延迟通常只计算一次,所以可以断开观察
          observer.disconnect();
        }
      }
    });

    observer.observe({ type: 'event', buffered: true }); // buffered: true 获取页面加载前的事件

    return () => observer.disconnect();
  } else {
    console.warn('当前浏览器不支持 Event Timing API 或 First Input.');
    return () => {};
  }
}

// 示例用法:
const stopFIDMonitor = setupFIDMonitor((fid) => {
  // console.log('FID 数据:', fid);
  // 在这里可以对 FID 数据进行处理或上报
});

// 模拟一个阻塞主线程的场景,来观察 FID
document.addEventListener('DOMContentLoaded', () => {
  const button = document.createElement('button');
  button.textContent = '点击我(可能延迟)';
  document.body.appendChild(button);

  button.addEventListener('click', () => {
    console.log('按钮被点击了!');
    // 模拟一个长任务,来观察点击后的延迟
    let sum = 0;
    for (let i = 0; i < 200000000; i++) {
      sum += i;
    }
    console.log('点击处理完成', sum);
  });
});

// 停止监测 (可选)
// setTimeout(() => {
//   stopFIDMonitor();
//   console.log('FID 监测已停止。');
// }, 15000);

优缺点:

  • 优点: 直接反映用户感知的交互响应速度,是衡量用户体验的关键指标之一。
  • 缺点: 只测量首次输入延迟,后续交互的延迟需要其他方法(如 INP)。

4. 布局偏移监测 (Layout Shift Monitoring)

布局偏移是指页面元素在用户不知情的情况下发生移动,导致用户误操作或视觉混乱。虽然不是直接的“卡顿”,但严重影响用户体验。Web Vitals 中的 CLS (Cumulative Layout Shift) 就是衡量这个指标。

原理:
使用 PerformanceObserver 监听 layout-shift 类型。

代码示例:

/**
 * 布局偏移监测器 (CLS)
 * @param {function(number): void} onCLSChange - CLS 变化时的回调函数,参数为当前累积 CLS 值
 */
function setupCLSMonitor(onCLSChange) {
  if ('PerformanceObserver' in window && 'layout-shift' in PerformanceObserver.supportedEntryTypes) {
    let cls = 0;
    let sessionWindow = []; // 用于计算会话窗口内的 CLS

    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // 排除输入导致的布局偏移 (例如,键盘输入或点击导致的元素移动)
        if (entry.hadRecentInput) {
          continue;
        }

        const currentTime = entry.startTime;
        const currentShiftTime = entry.startTime;

        // 移除会话窗口中过期的偏移 (在 1 秒内,或与上一个偏移间隔超过 500ms)
        sessionWindow = sessionWindow.filter(shift => {
          return currentTime - shift.lastShiftTime < 1000 && currentTime - shift.startTime < 500;
        });

        // 将当前偏移添加到会话窗口
        sessionWindow.push({
          startTime: entry.startTime,
          lastShiftTime: entry.startTime,
          value: entry.value
        });

        // 计算当前会话窗口的 CLS
        let sessionValue = 0;
        for (const shift of sessionWindow) {
          sessionValue += shift.value;
        }

        // 更新总 CLS (通常是累积的)
        cls += entry.value;

        console.log(`检测到布局偏移: ${entry.value.toFixed(4)}`, entry);
        console.log(`当前累积 CLS: ${cls.toFixed(4)}`);
        onCLSChange(cls); // 报告累积 CLS
      }
    });

    observer.observe({ entryTypes: ['layout-shift'] });

    return () => observer.disconnect();
  } else {
    console.warn('当前浏览器不支持 Layout Shift API。');
    return () => {};
  }
}

// 示例用法:
const stopCLSMonitor = setupCLSMonitor((currentCLS) => {
  // console.log('当前 CLS:', currentCLS);
  if (currentCLS > 0.1) { // CLS 超过 0.1 体验不佳
    console.error(`累积布局偏移过高:${currentCLS.toFixed(4)}`);
    // 可以在这里上报 CLS 事件
  }
});

// 模拟一个布局偏移
document.addEventListener('DOMContentLoaded', () => {
  const container = document.createElement('div');
  container.style.border = '1px solid #ccc';
  container.style.padding = '10px';
  container.style.marginBottom = '20px';
  container.textContent = '这是一个容器。';
  document.body.appendChild(container);

  setTimeout(() => {
    // 插入一个图片,导致下方内容下移
    const img = document.createElement('img');
    img.src = 'https://via.placeholder.com/150'; // 替换为实际图片 URL
    img.alt = 'Placeholder Image';
    img.style.display = 'block';
    img.style.marginBottom = '10px';
    container.prepend(img); // 在容器头部插入图片
    console.log('插入图片,可能导致布局偏移。');
  }, 2000);

  setTimeout(() => {
    // 动态改变元素高度
    container.style.height = '200px';
    console.log('改变容器高度,可能导致布局偏移。');
  }, 4000);
});

// 停止监测 (可选)
// setTimeout(() => {
//   stopCLSMonitor();
//   console.log('CLS 监测已停止。');
// }, 15000);

优缺点:

  • 优点: 直接反映页面视觉稳定性,有助于发现因动态内容加载、字体加载等导致的页面跳动问题。
  • 缺点: 并非直接的“卡顿”,但与用户体验紧密相关。

5. 综合使用 Web Vitals 指标 (LCP, FID, CLS, INP)

Google 的 Web Vitals 是一组核心指标,旨在量化用户体验。它们是上述各种监测方法的综合体现。

  • LCP (Largest Contentful Paint): 最大内容绘制,衡量页面的加载性能。
  • FID (First Input Delay): 首次输入延迟,衡量页面交互性(已在上面介绍)。
  • CLS (Cumulative Layout Shift): 累积布局偏移,衡量页面视觉稳定性(已在上面介绍)。
  • INP (Interaction to Next Paint): 交互到下一次绘制,衡量页面对用户交互的整体响应能力。这是 FID 的升级版,它会测量所有交互(而不仅仅是第一次),并关注交互处理到下一次视觉更新之间的时间。

如何获取:
Google 提供了 web-vitals 库,可以方便地获取这些指标。

代码示例 (使用 web-vitals 库):

首先,安装 web-vitals
npm install web-vitalsyarn add web-vitals

然后,在你的代码中:

// web-vitals.js (或你的入口文件)
import { onCLS, onFID, onLCP, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
  console.log(`Web Vitals Metric: ${metric.name} - ${metric.value.toFixed(2)}`, metric);
  // 在这里可以将数据发送到你的后端分析服务
  // 例如:fetch('/api/metrics', { method: 'POST', body: JSON.stringify(metric) });

  // 可以根据不同的指标类型和值进行预警
  if (metric.name === 'FID' && metric.value > 100) {
    console.error('FID 警告:', metric.value);
  } else if (metric.name === 'CLS' && metric.value > 0.1) {
    console.error('CLS 警告:', metric.value);
  } else if (metric.name === 'LCP' && metric.value > 2500) {
    console.error('LCP 警告:', metric.value);
  } else if (metric.name === 'INP' && metric.value > 200) {
    console.error('INP 警告:', metric.value);
  }
}

// 注册所有核心 Web Vitals 指标的监听
onCLS(sendToAnalytics);
onFID(sendToAnalytics);
onLCP(sendToAnalytics);
onINP(sendToAnalytics); // INP 仍在实验阶段,但非常重要

console.log('Web Vitals 监测已启动。');

优缺点:

  • 优点: 官方推荐,全面覆盖用户体验的关键方面,易于集成和使用,数据标准化。
  • 缺点: 依赖第三方库,INP 等指标可能仍在演进中。

6. 浏览器开发者工具 (Browser DevTools)

虽然不是代码层面的实时监测,但浏览器自带的开发者工具是调试和分析卡顿最强大的工具。

  • Performance (性能) 面板: 记录页面活动,可视化主线程、GPU、网络等活动,可以清晰地看到长任务、布局、重绘等耗时操作。
  • Lighthouse: 自动化审计工具,提供性能、可访问性、最佳实践等方面的报告,会给出 Web Vitals 的分数和改进建议。
  • Memory (内存) 面板: 检查内存泄漏,内存泄漏也可能导致卡顿。

使用方法:

  1. 打开开发者工具 (F12 或右键检查)。
  2. 切换到 PerformanceLighthouse 面板。
  3. 点击录制按钮或运行审计。
  4. 分析报告和时间轴。

优缺点:

  • 优点: 功能强大,提供详细的性能数据和可视化,是定位问题根源的利器。
  • 缺点: 只能在开发环境或用户主动操作下使用,无法进行大规模的用户行为实时监测。

7. 第三方性能监控工具 (Real User Monitoring - RUM)

专业的 APM (Application Performance Management) 工具,如 Sentry, Datadog, New Relic, Fundebug, OneAPM 等,都提供了前端性能监控功能。

原理:
这些工具通常会在你的页面中嵌入一个 SDK,该 SDK 会自动收集上述各种性能指标(包括 Web Vitals、自定义事件耗时、资源加载时间等),并将数据上报到其云平台进行聚合、分析和可视化。

代码示例 (以 Sentry 为例,其他类似):

// main.js 或你的初始化文件
import * as Sentry from '@sentry/browser';
import { Integrations } from '@sentry/tracing'; // 如果需要追踪性能

Sentry.init({
  dsn: "YOUR_SENTRY_DSN", // 替换为你的 Sentry DSN
  integrations: [
    new Integrations.BrowserTracing(), // 启用性能追踪
  ],
  // 采样率,例如 1.0 表示捕获所有事务,0.1 表示捕获 10%
  tracesSampleRate: 1.0,
  // 也可以自定义性能指标
  // hooks: {
  //   beforeSendTransaction: (transaction) => {
  //     // 可以在这里添加自定义标签或数据
  //     return transaction;
  //   }
  // }
});

// 手动追踪一个自定义操作的性能
const transaction = Sentry.startTransaction({ name: "Custom Operation" });
const span = transaction.startChild({ op: "function_call", description: "Heavy calculation" });

// 模拟一个耗时操作
setTimeout(() => {
  let sum = 0;
  for (let i = 0; i < 100000000; i++) {
    sum += i;
  }
  console.log('自定义操作完成', sum);
  span.finish();
  transaction.finish();
}, 500);

// Sentry 还会自动捕获长任务、资源加载、路由切换等性能数据

优缺点:

  • 优点: 开箱即用,提供全面的性能数据、错误监控、告警系统,能聚合大量真实用户数据,便于发现共性问题。
  • 缺点: 通常需要付费,数据隐私和安全性考量,可能增加页面加载负担(尽管通常很小)。

总结与建议

  1. 优先级:

    • Web Vitals (尤其是 FID 和 INP): 这是衡量用户体验最权威和全面的指标,应优先关注。使用 web-vitals 库是最推荐的方式。
    • 长任务监测: 直接定位卡顿的根本原因,非常有助于调试。
    • FPS 监测: 作为辅助,直观反映流畅度,但不能提供具体原因。
    • 布局偏移监测: 关注视觉稳定性,避免意外的页面跳动。
  2. 数据上报: 无论是哪种监测方式,最终都需要将数据上报到后端服务进行存储、分析和可视化。这才能形成完整的 RUM (Real User Monitoring) 体系。

  3. 结合调试: 监测发现问题后,利用浏览器开发者工具(Performance 面板)进行深入分析和定位,找出具体的代码瓶颈。

  4. 持续优化: 性能优化是一个持续的过程,需要不断监测、分析、改进。

通过结合使用上述方法,你可以全面、准确地监测前端界面的卡顿情况,从而不断提升用户体验。

Nest 是隐藏的“设计模式大佬”​

一、Node 开发的三个“段位”

  1. 自己动手搓轮子(http.createServer):

    • 就好像你盖房子,从和泥巴、烧砖头开始搞。
    • 干啥用: 只能做贼简单的事情,比如就开个门递个纸条(比如你写个小工具自己用)。想整复杂点的?累死你,代码得写一坨,还很容易搞乱。
  2. 买套乐高拼房子(Express, Koa):

    • 这就省事儿多了!乐高块都给你准备好了(路由、中间件),你按图纸(文档)拼起来就行。砌砖?不用!和泥?免了!
    • 问题: 图纸只告诉你怎么拼积木,没管你家具怎么摆! 客厅厨房东西扔一堆?行!电线乱拉?也没人管!刚开始小房子(小项目)看着还行,但等你要盖摩天大楼(大项目)?完了!东西堆得乱七八糟,想加个电梯(新功能)都不知道该塞哪儿,找根线(查代码)能累断腿。
    • 总结: 快是快,也省心点,但太自由,容易搞成一锅粥,盖大房子风险高。
  3. 请专业装修队全包(Nest, Egg, Midway 等框架):

    • 这才是盖大酒店、大公司的搞法!人家不光给你盖房子:

      • 规矩定得死死的: 厨房必须放这儿,仓库必须是那样,代码必须按这个格式写!谁都别瞎搞!(规定写法
      • 啥都给你配齐了: 水管(数据库连接)?装好了!电线(配置管理)?布好了!大门保安(用户登录)?上岗了!连装修风格(项目结构)都帮你设计好了!(开箱即用
    • 好处:

      • 省大心了! 你不用天天琢磨怎么接电线、通水管(基础功能),专心搞你的豪华装修(业务逻辑)。
      • 不混乱! 大家(团队成员)都按一个规矩来,找东西(看代码)特好找。
      • 好扩建! 大楼要加层(项目变大)?地基(框架)够稳,结构(架构)够清晰,加就完事儿了!
    • 总结: 前期学点规矩(学框架)花点时间,但后面贼顺溜,尤其盖大房子(大项目),稳当又高效!人多也不怕乱。

一句话总结版:

  • http.createServer: 自己玩泥巴,顶多糊个小板凳。
  • Express/Koa: 给你积木和图纸,但不管房间整理,小窝还行,豪宅迟早变垃圾场。
  • Nest/Egg/Midway: 专业团队从地基到装修全包,规矩森严工具齐全,盖摩天大楼的最佳选择!

从您提供的项目结构图中,我们可以清晰地看到 NestJS 风格的模块化分层设计。以下是每个文件夹的作用及对应关系:


二、📂 socket(核心模块目录)

文件夹 用途 核心内容
controller/ 处理 HTTP/gRPC 请求,定义路由和接口响应逻辑 *.controller.ts
service/ 封装业务逻辑,被 Controller 调用(如数据库操作、第三方服务等) *.service.ts
guard/ 实现路由守卫,控制接口权限(如 JWT 验证、角色校验) *.guard.ts
interceptor/ 拦截请求/响应,统一处理数据格式(如日志、错误包装、响应时间计算) *.interceptor.ts
dto/ 定义数据传输对象(Data Transfer Object),校验和规范接口入参 *.dto.ts
filter/ 捕获全局异常,自定义错误响应(如处理 NotFoundException *.filter.ts
adapter/ 适配第三方服务协议(如将 WebSocket 消息转为 REST 格式) 适配器模式封装
interface/ 定义接口类型规范(如 Service 的抽象类、DTO 的 TypeScript 接口) *.interface.ts
**grpc/** gRPC 通信相关实现(如 proto 定义、客户端/服务端存根) *.proto / *.service.ts
**ros/** 可能用于机器人操作(Robot Operating System)或自定义协议 项目特定功能
gateway/ WebSocket 网关模块(NestJS 特有),处理双向实时通信 *.gateway.ts

🧩 模块化整合

  • socket.module.ts
    将上述所有组件整合为独立模块,通过 @Module 装饰器导入:

    typescript
    typescript
    复制
    @Module({
      imports: [GrpcModule, RosModule],
      controllers: [SocketController],
      providers: [SocketService, AuthGuard, LoggingInterceptor],
      exports: [SocketService]
    })
    export class SocketModule {}
    

🌐 项目根目录

文件 用途
app.module.ts 根模块,整合所有子模块(如 SocketModule)
main.ts 应用入口,初始化 NestJS 服务
app.environment.ts 环境变量配置(如数据库连接参数)

💡 为什么这样设计?

  1. 职责清晰
    每个模块只关注单一功能(如 guard 专注权限),避免代码臃肿。
  2. 高复用性
    Service/Interceptor 等可被多模块调用(通过 exports 共享)。
  3. 易维护性
    新增功能只需在对应目录添加文件,无需全局修改。
  4. 协议解耦
    支持混合协议(如同时提供 REST + gRPC + WebSocket),通过适配器无缝切换。

示例流程
请求 → guard 鉴权 → interceptor 记录日志 → controller 路由 → 调用 service 业务 → dto 校验数据 → 返回响应(若异常则被 filter 捕获)

这种结构是大型 Node.js 项目的黄金标准,尤其适合微服务架构。

三、Egg.js、Midway.js深度对比分析:


1. Egg.js 现状:逐渐退出历史舞台

  • TypeScript 支持薄弱
    Egg 原生设计基于 JavaScript,虽后续有 egg-ts-helper 等插件,但:

    • TS 类型推导不完整(插件行为需要手动声明)
    • 装饰器等现代特性依赖社区补丁(非官方方案)
    • 开发体验远不如原生 TS 框架流畅
  • 阿里战略调整影响
    2022 年阿里云智能大裁员中,Egg 核心团队解散,导致:

    • 官方维护停滞(GitHub 最后一个正式版本停留在 2022 年)
    • 社区贡献量断崖式下跌(Issue 堆积无响应)
  • 技术架构过时
    强约定的目录结构在微服务场景下僵化,插件机制不如 Nest 的 DI 灵活。

结论:新项目坚决不选,老项目建议迁移。


2. Midway.js 定位:阿里云生态的“备选方案”

优势

  • 原生 TypeScript 支持:深度整合 IoC 容器和装饰器,开发体验现代化
  • 云原生整合:无缝对接阿里云函数计算(FC)、Serverless 等
  • 多协议混合:同时支持 Web/Koa/Egg 风格,迁移成本低

⚠️ 致命短板

  • 生态规模悬殊(数据截至 2023 年)

    指标 NestJS Midway
    GitHub Stars 62k+ 5.3k
    npm 周下载量 280万+ 15万
    Stack Overflow 问题 25k+ 120+
  • 企业背书风险
    虽阿里仍在维护,但投入远不及 Nest 社区:

    • 文档质量波动(部分高级功能描述模糊)
    • 版本迭代缓慢(v3.0 重大更新拖延超 1 年)
  • 人才市场供需失衡
    招聘平台数据显示:

    • Nest 岗位需求 ≈12倍 于 Midway
    • 国内大厂新项目(如字节/腾讯)多选用 Nest

结论:仅在强绑定阿里云 Serverless 时考虑,否则慎用。


3. NestJS 为何成为事实标准?

🌍 不可逆的全球化趋势

  • 技术架构领先
    nestjs.com/img/archite…
    分层架构(Controller/Service/Module)+ 依赖注入 + AOP 拦截链(Interceptor/Guard/Pipe)形成完整企业级开发生态。

  • 跨技术栈融合
    可直接集成:

    • 前端框架(Nx 支持 Angular/React 同构)
    • 微服务(gRPC、Kafka、RabbitMQ 原生适配)
    • ORM(TypeORM/Prisma/Sequelize 开箱即用)
  • 社区碾压级优势

    • 教程资源覆盖 YouTube/Udemy/中文博客 全媒介
    • 企业案例:Adobe、Roche、资本集团(Capital Group)等

🇨🇳 国内本土化爆发

  • 2023 年调查显示:

    • 新增 Node.js 项目中 Nest 占比 52%(来源:掘金开发者报告)
    • 字节/美团/拼多多新项目已全面转向 Nest
  • 国内社区建设完善:

    • 中文文档、掘金小册、开源实战项目井喷
    • 培训市场出现专项 Nest 课程(价格 ≥8k)

🚀 终极决策指南

场景 推荐方案 理由
全新企业级项目 NestJS 生态/人才/维护性全面碾压
阿里云 Serverless 项目 Midway 云服务深度绑定,牺牲生态换部署便利
存量 Egg 项目维护 逐步迁移至 Nest Egg 已无未来,越晚迁移成本越高
个人学习/求职 NestJS 企业招聘硬技能要求,学 Midway 边际收益低

残酷真相
技术选型本质是押注生态位。Nest 的垄断地位类似 Spring Boot in Java——它不只赢在代码,更赢在成为行业共识。
与其担忧 “Midway 会不会是下一个 Egg”,不如拥抱确定性:用 Nest 就是站在全球开发者的肩膀上。

四、学习各种后端中间件

后端有很多中间件,比如 mysql、redis、rabbitmq、nacos、elasticsearch 等等,学习 Nest 的过程会用到这些中间件。

比如类似这种的后端架构:

image.png

五、学习优秀的架构设计

Nest 的架构很优雅,因为它用了不少设计模式。

比如 Nest 并不和 Express 耦合,你可以轻松切换到 Fastify。

就是因为它用了适配器的设计模式:

image.png

1. Nest 不认死理!

它就是个“接口狂魔”——只定规矩说:“你得按我接口(HttpServer Interface)办事!”。至于你具体用啥库干活(Express、Fastify,甚至你想自己搓个新的)?它无所谓!换个新小弟(适配器 Adapter)就照样跑。(潜台词:老板永远是你,Nest 就是个能屈能伸的狠角色)


2. Nest 是隐藏的“设计模式大佬”

你看它搞复杂对象时常用的 **Builder 模式
就像拼乐高,不用你自己吭哧吭哧找零件——有说明书(Builder)一步步教你搭出火箭🚀、城堡🏰!
这类的巧思在 Nest 里到处都是(工厂模式、依赖注入、拦截器链等等)。
用着用着你就发现:哎?这架构思想好像刻进 DNA 了!下次自己设计时,就知道哪该用啥模式,咋设计更优雅。这是真·潜移默化涨功力啊!** 🧠✨

image.png


3. 一句话总结:上 Nest 车,稳赚不赔!

你想干啥? Nest 能给你啥?
👉 学 Node 框架 最主流、最规范,没有之一
👉 玩转后端中间件(MySQL,Redis,MQ...) 保姆级整合,一次学会,走哪儿都香
👉 搞国外远程/外包 硅谷小厂同款技术,简历直接发光✨
👉 做独立产品/创业 强架构支撑,不怕后期摊大饼
👉 修炼设计/架构内功 天天看大师之作(框架源码),想不进步都难

最后一句扎心真相:
现在的 Nest,就是当年 Java 界的 Spring!🔥 风口就在这,技术红利不吃白不吃。
心动?别磨叽!赶紧上车开搞!🎯 (从 npm i -g @nestjs/cli 开始就对了)

HTML 处理以及性能对比 - Bun 单元测试系列

单元测试输出的 HTML 通常压缩在一行,没有空格和换行不利于 snapshot diff,我们需要有一个称手的工具来“美化” HTML,其次输出的路径的分隔符在 Windows 和类 Unix 系统不一样,导致本地运行正常的单测在 CI 却失败。

本文将针对这两个问题给出解决方案:

  • 利用 prettierformat(也可以用 biome,本文会讲到);
  • 利用 parse5 解析 HTML AST 将特定的节点做转换或删除,从而保持 HTML 在不同平台输出一致,即生成“稳定”的 HTML(也可以用 bun HTMLRewriter,本文也会讲到)。

最后利用 biome format 和 bun HTMLRewriter,整体性能从 125ms125ms 提升到 35.8ms35.8ms 🚀。

🌱 基础版

一、format 利用 prettier

效果

首先看看格式化前后对比:

format-html-diff.png

Before

<blockquote><p>思考部分行内公式 1 <span class="katex">...

After

<blockquote>
  <p>
    思考部分行内公式 1
    <span class="katex">
      <span class="katex-mathml">
        <math xmlns="http://www.w3.org/1998/Math/MathML">
          ...
        </semantics>
      </math>
    </span>
  </span>
  块级公式 1:
</p>
...

思路很简单使用 prettier 格式化即可。

import prettier from 'prettier'

export async function format(html: string): Promise<string> {
  const formatted = await prettier.format(html, {
    parser: 'html',
    htmlWhitespaceSensitivity: 'ignore',
  })

  return formatted.trim()
}

但是有时候我们可能需要删除某些 HTML 元素,否则可能会导致 snapshot 太多,或者抹平某些属性在不同操作系统的差异,我们需要再设计一个方法在输出前处理这些事情。

二、 filter 利用 parse5 AST 的力量

parse5 HTML parser and serializer.

parse5 的周下载量是 5千万,可以放心使用。本文后面还会告诉大家如何使用 bun 内置的 HTMLRewriter 来实现。

先设计函数,输入 HTML,和一个 ignoreAttrs,输出处理后的 HTML。

function filter(html: string, ignoreAttrs: IFilter): string
/**
 * - `true`: 过滤掉该属性
 * - `false`: 保留该属性
 * - `string`: 替换该属性值
 */
type IFilter = (
  node: { tagName: string },
  attr: { name: string; value: string },
) => true | false | string;

ignoreAttrs 是一个过滤控制器:true 过滤,false 保留,string 替换。

具体实现:

  1. 用 parse5 解析 HTML
  2. 递归遍历 AST,移除要忽略的属性
  3. 将 AST 重新序列化为 HTML
function filter(html: string, ignoreAttrs: IFilter): string {
  // 1. 用 parse5 解析 HTML
  const document = parse5.parseFragment(html)

  // 2. 遍历 AST,移除要忽略的属性
  const removeIgnoredAttrs = (node) => {
    if (node.attrs) {
      node.attrs = node.attrs.filter((attr) => {
        const shouldIgnore = ignoreAttrs(node, attr) // 自定义匹配
        let keep = !shouldIgnore

        if (typeof shouldIgnore === 'boolean') return keep

        attr.value = shouldIgnore // 自定义替换
        keep = true

        return keep
      })
    }

    if (node.childNodes) {
      node.childNodes.forEach(removeIgnoredAttrs)
    }
  }

  removeIgnoredAttrs(document)

  // 3. 将 AST 重新序列化为 HTML
  const filteredHTML = parse5.serialize(document)

  return filteredHTML
}

filter 用途,将图片路径转换成“稳定”的路径,抹平操作系统和 CI 环境本地环境的差异,比如:

  • D:\\workspace\\foo\\src\\assets\\user-2.png to user-2.png
  • /app/src/assets/submitIcon.png to submitIcon.png

/**
 * 使用 parse5 过滤 HTML 属性,再用 Prettier 格式化
 * @param html 原始 HTML
 * @param ignoreAttrs 要忽略或替换的属性规则
 * @returns 格式化后的 HTML
 */
function formatAndFilterAttr(html: string, ignoreAttrs: IFilter): Promise<string> {
  return format(filter(html, ignoreAttrs))
}

export async function toStableHTML(html: string): Promise<string> {
  const formatted = await formatAndFilterAttr(html.trim(), (node, attr) => {
    const isSrcDiskPath =
      node.tagName === 'img' &&
      attr.name === 'src' &&
      (/^[a-zA-Z]:/.test(attr.value) || attr.value.startsWith('/app/'))

    if (isSrcDiskPath) {
      // D:\\workspace\\foo\\src\\assets\\user-2.png
      // to user-2.png
      // /app/src/assets/submitIcon.png to submitIcon.png
      return `...DISK_PATH/${path.basename(attr.value)}`
    }

    // 保留,不做处理
    return false
  })

  return formatted.trim()
}
记录下性能
main.innerHTML.length: 41685

[9.99ms] filter html
[113.38ms] format html
[125.56ms] toStableHTML

formatted.length after toStableHTML: 70629

将一个 4w+ 长度的 HTML 转换成长度为 7w+ 的 HTML,总耗时 125.56ms,性能瓶颈在 prettier format 耗时占比 90%。

🎓 进阶版

一、format 的进阶 🚀:利用 biomeformat

biome 基于 Rust 一直以性能著称,让我们一探究竟。

@biomejs/biome 并未提供程序调用,但是官方提供了两个包: www.npmjs.com/package/@bi…

npm i @biomejs/js-api @biomejs/wasm-nodejs -D
import { Biome } from '@biomejs/js-api/nodejs'

const biome = new Biome()
const { projectKey } = biome.openProject('path/to/project/dir')

biome.applyConfiguration(projectKey, {
  html: {
    formatter: {
      enabled: true,
      indentStyle: 'space',
      indentWidth: 2,
    },
  },
})

export function format(html: string): Promise<string> {
  console.time('format html using biome')

  const { content: formatted } = biome.formatContent(projectKey, html, {
    // 必选,帮助 Biome 识别文件类型
    filePath: 'example.html',
  })
  console.timeEnd('format html using biome')

  return formatted.trim()
}
性能数据:
main.innerHTML.length: 41685
[11.22ms] filter html
[61.33ms] format html using biome
[74.18ms] toStableHTML
formatted.length after toStableHTML: 70085

main.innerHTML.length: 41685
[10.40ms] filter html
[48.71ms] format html using biome
[60.59ms] toStableHTML
formatted.length after toStableHTML: 70085

main.innerHTML.length: 41685
[9.93ms] filter html
[51.78ms] format html using biome
[63.14ms] toStableHTML
formatted.length after toStableHTML: 70085

三次平均值,整体性能从 125ms 提升到 65.67ms,format 从 113ms 提升到 53.67ms,整体性能提升了一倍!没有达到想象中的数倍,有点遗憾。

二、filter 的进阶 🧗‍♂️:利用 bun 内置的 HTMLRewriter

本身我们的项目单元测试运行时就是 bun,那为何不用 bun 内置的 HTMLRewriter?速度快且无依赖。

HTMLRewriter 允许你使用 CSS 选择器来转换 HTML 文档。它支持 Request、Response 以及字符串作为输入。Bun 的实现基于 Cloudflare 的 lol-html。

bun.sh/docs/api/ht…

代码:

function filter(html: string, ignoreAttrs: IFilter): string {
  // console.time("filter html using HTMLRewriter");
  const rewriter = new HTMLRewriter().on("img", {
    element(node) {
      for (const [name, value] of node.attributes) {
        const shouldIgnore = ignoreAttrs(node, { name, value }); // 自定义匹配

        if (typeof shouldIgnore === "boolean") {
          node.removeAttribute(name);
        } else {
          node.setAttribute(name, shouldIgnore); // 自定义替换
        }
      }
    },
  });

  const result = rewriter.transform(html);
  // console.timeEnd("filter html using HTMLRewriter");

  return result;
}
性能对比:
main.innerHTML.length: 41685
[0.59ms] filter html using HTMLRewriter
[31.86ms] format html using biome
[33.54ms] toStableHTML
formatted.length after toStableHTML: 69335

main.innerHTML.length: 41685
[0.60ms] filter html using HTMLRewriter
[33.85ms] format html using biome
[35.64ms] toStableHTML
formatted.length after toStableHTML: 69335

main.innerHTML.length: 41685
[0.85ms] filter html using HTMLRewriter
[33.82ms] format html using biome
[36.43ms] toStableHTML
formatted.length after toStableHTML: 69335

main.innerHTML.length: 41685
[0.58ms] filter html using HTMLRewriter
[34.67ms] format html using biome
[36.45ms] toStableHTML
formatted.length after toStableHTML: 69335

main.innerHTML.length: 41685
[0.91ms] filter html using HTMLRewriter
[37.10ms] format html using biome
[39.89ms] toStableHTML
formatted.length after toStableHTML: 69335

五次取平均值,整体性能从 125ms 提升到 35.8ms,filter 从 10ms 提升到 0.70ms,只有原来的 7100\frac{7} {100},整体耗时只有原来的 28100\frac{28} {100}

完整代码

github.com/legend80s/s…

鸿蒙Next 性能优化总结

1. UI渲染优化

UI渲染性能直接影响用户的直观体验,合理的优化能够显著提升应用流畅度。

  • 组件复用优化

使用@Observed@ObjectLink装饰器:在渲染列表时,通过这两个装饰器实现组件级别的数据观察和绑定,避免不必要的组件重建,提高列表渲染效率。 优化数据结构:确保被@Observed装饰的类结构合理,避免深层嵌套导致的性能问题。

  • 懒加载机制

长列表优化:对于包含大量数据的列表,使用LazyForEach按需加载可见区域的数据项,减少内存占用和初始化时间。 虚拟化列表:只渲染当前可视区域内的组件,滚动时动态加载和卸载组件。

  • 布局结构优化

减少嵌套层级:通过合理设计组件结构,避免过深的嵌套,降低布局计算复杂度。 使用高效布局组件:优先使用ColumnRowStack等基础布局组件,避免不必要的包装组件。 合理使用条件渲染:避免在渲染过程中进行复杂的条件判断。

  • 动画性能优化

选择合适的动画类型:优先使用平台提供的原生动画API。 避免复杂动画:在低端设备上适当降低动画复杂度,确保流畅性。 合理控制动画时长:避免过长或过于频繁的动画效果。

2. 内存管理优化

良好的内存管理是保证应用稳定运行的关键,能够有效避免内存泄漏问题。

  • 资源及时释放

生命周期管理:在组件的aboutToDisappear生命周期回调中,清理定时器、事件监听器、网络请求等占用的资源。 异步操作管理:及时取消未完成的网络请求和异步操作,避免无效回调。 订阅解绑:确保所有事件订阅在适当时机进行解绑。

  • 内存泄漏防范

避免循环引用:特别是在使用闭包和回调函数时,注意对象间的引用关系。 事件监听器管理:建立统一的事件管理机制,确保监听器能够被正确移除。 全局变量控制:谨慎使用全局变量,避免无意识的数据累积。

  • 对象复用策略

对象池模式:对于频繁创建和销毁的对象(如动画对象、计算对象),考虑使用对象池进行复用。 缓存机制:对可复用的计算结果或组件状态进行缓存,避免重复创建。

3. 网络请求优化

网络请求是影响应用响应速度的重要因素,合理的优化能够显著提升用户体验。

  • 请求合并策略

批量处理:将多个小的网络请求合并为一个批量请求,减少网络开销和连接建立时间。 请求聚合:在业务层面设计合理的请求聚合机制,避免频繁的小数据量请求。

  • 缓存机制优化

HTTP缓存策略:合理配置HTTP缓存头,利用浏览器和系统级别的缓存机制。 本地数据缓存:实现应用级别的数据缓存,对于不常变化的数据优先使用缓存。 缓存更新策略:设计合理的缓存失效和更新机制,确保数据时效性。

  • 请求优先级管理

分类处理:根据业务重要性为不同类型的请求设置优先级。 资源调度:在网络资源有限的情况下,优先处理高优先级请求。 并发控制:合理控制并发请求数量,避免网络拥塞。

4. 数据处理优化

高效的数据处理能够提升应用响应速度,改善用户体验。

  • 异步处理机制

Worker线程使用:将耗时的计算操作放到Worker线程执行,避免阻塞主线程UI渲染。 异步编程模型:使用Promiseasync/await处理异步任务,提高代码可读性和维护性。 任务分片:对于大量数据处理任务,采用分片处理方式,避免长时间阻塞。

  • 数据懒加载

分页加载:对于大量数据,采用分页方式按需加载,减少初始加载时间。 滚动加载:实现滚动到底部自动加载更多数据的功能。 预加载策略:根据用户行为预测,提前加载可能需要的数据。

  • 计算缓存优化

结果缓存:对复杂计算的结果进行缓存,避免重复计算。 记忆化技术:使用记忆化函数缓存纯函数的计算结果。 缓存失效机制:设计合理的缓存失效策略,确保数据准确性。

5. 资源管理优化

合理的资源管理能够减少应用体积,提升加载速度。

  • 图片资源优化

尺寸适配:提供合适尺寸的图片资源,避免大图缩小显示造成的资源浪费。 格式选择:采用WebP等高效图片格式,在保证质量的前提下减小文件大小。 懒加载实现:实现图片懒加载机制,只加载可见区域内的图片。 预加载策略:对关键图片资源进行预加载,提升用户体验。

  • 资源压缩处理

文件压缩:对资源文件进行压缩处理。 资源合并:合并小文件资源,减少HTTP请求数量。 Tree Shaking:移除未使用的代码,减小包体积。

  • 按需加载机制

动态导入:使用动态import实现模块的按需加载。 代码分割:将应用代码分割成多个chunk,按需加载。 路由懒加载:实现路由组件的懒加载,减少初始加载时间。

6. 启动性能优化

应用启动速度直接影响用户的第一印象,需要重点关注。

  • 冷启动优化

初始化精简:减少EntryAbility初始化时的耗时操作,将非必要操作延后执行。 核心模块优先:优先加载和初始化核心功能模块。 资源预处理:在构建阶段进行资源预处理,减少运行时计算。

  • 预加载机制

资源预加载:提前加载应用启动后可能需要的资源。 数据预取:根据用户使用习惯,预取可能需要的数据。 组件预渲染:对常用组件进行预渲染,提升响应速度。

  • 启动页优化

视觉流畅性:设计流畅的启动页过渡动画。 品牌展示:在启动页合理展示品牌形象,提升用户体验。 加载状态提示:提供清晰的加载状态反馈。

7. 电池和功耗优化

合理的功耗管理能够延长设备续航时间,提升用户满意度。

  • 后台任务管理

任务调度:合理使用系统提供的后台任务调度机制。 服务优化:及时停止不必要的后台服务,避免持续耗电。 唤醒锁管理:谨慎使用唤醒锁,及时释放系统资源。

  • 定位服务优化

按需使用:只在需要时开启定位服务,使用完毕后及时关闭。 精度控制:根据业务需求选择合适的定位精度。 频率调节:合理控制定位更新频率,避免频繁定位造成的功耗。

8. 编码层面优化

良好的编码习惯是性能优化的基础,需要在开发过程中持续关注。

  • 状态更新优化

减少不必要更新:避免频繁的状态变化触发UI重新渲染。 批量更新:将多个状态更新合并为一次批量操作。 状态比较:实现合理的状态比较机制,避免相同状态的重复更新。

  • 装饰器合理使用

状态管理:正确使用@State@Prop@Link等状态管理装饰器。 性能考虑:根据数据使用场景选择合适的装饰器,避免过度响应式。 数据流向:清晰定义组件间的数据流向,避免状态混乱。

  • 组件粒度控制

合理拆分:将复杂组件拆分为多个小组件,提升可维护性。 避免过小:避免组件过小导致的组件通信开销。 复用性考虑:设计具有高复用性的组件结构。

9. 监控和分析

持续的性能监控是优化工作的基础,能够帮助及时发现问题。

  • 性能监控体系

工具使用:使用DevEco Profiler等专业工具进行性能分析。 指标监控:持续监控FPS、内存使用、CPU占用等关键性能指标。 自动化监控:建立自动化性能监控机制,及时发现性能退化。

  • 日志分析优化

关键路径日志:在关键业务路径添加详细日志记录。 性能日志:记录关键操作的耗时信息,便于性能分析。 异常监控:建立完善的异常监控和告警机制。

  • 用户行为分析

使用习惯分析:通过数据分析用户使用习惯,优化关键路径。 性能热点识别:识别用户最常使用的功能,优先进行优化。 体验数据收集:收集用户真实使用环境下的性能数据。 通过系统性地实施以上优化措施,能够显著提升鸿蒙Next应用的整体性能表现,为用户提供更加流畅、稳定的使用体验。在实际开发过程中,需要根据具体业务场景和性能瓶颈,有针对性地选择和实施相应的优化策略。

Electron自定义菜单栏及Mac最大化无效的问题解决

Electron自定义菜单栏及Mac最大化无效的问题解决

electron的应用打包后会有一个系统标题栏,在win中包含最小化、最大化、关闭、file等其他功能按钮。这个系统标题栏的局限很大,首先就是这个标题栏是固定的像素高度,没办法做到响应式,其次就是这个菜单栏的功能没法拓展,如果需要添加其他功能就比较捉襟见肘了。

针对这个问题我们可以隐藏这个系统标题栏然后在页面中自己去编写一个菜单栏,这一点官方也给出了实例👉自定义标题栏 | Electron,接下来我们就叙述一下如何创建一个自定义菜单栏并解决一些可能出现的问题。

自定义菜单栏

1.隐藏自带菜单栏

要创建自定义菜单栏第一步就是隐藏掉electron自带的系统标题栏。在隐藏之前还是提一下关于调整系统菜单栏的样式:

 titleBarOverlay: {
   color: '#0ff', // 自定义背景色
   symbolColor: '#fff', // 自定义符号颜色
   height: 24, // 自定义高度
 }

可以看到能做的处理有限,远比不上使用html+css能做到的样式体验,所以我们需要隐藏掉这个菜单栏:

 autoHideMenuBar: true, // 是否自动隐藏菜单栏

可以简单展示一下创建窗口的相关参数:

 import { BrowserWindow } from 'electron';
 
   mainWindow = new BrowserWindow({
     width: 1400,
     height: 800,
     useContentSize: true,
     autoHideMenuBar: true, // 是否自动隐藏菜单栏
     resizable: false, // 是否允许用户手动调整大小
     transparent: false, // 是否开启透明背景
     maximizable: false, //禁止双击放大
     frame: false, // 去掉顶部操作栏
     // titleBarStyle: 'hidden', // 隐藏标题栏
     // titleBarOverlay: {
     //   color: '#0ff', // 自定义背景色
     //   symbolColor: '#fff', // 自定义符号颜色
     //   height: 24, // 自定义高度
     // },
     webPreferences: {
       preload: path.join(__dirname, 'preload.mjs'),
     },
   });
2.封装自定义菜单栏组件

接下来就是封装一个自定义菜单栏去替代系统的菜单栏,这里我只以最小化、全屏、关闭等操作为例。

 
 // SysTitleBar.tsx
 
 const SysTitleBar = (props: SysTitleBarProps) => {
   return (
     <div className={styles['system-title-bar']}>
       <div className={styles['title-bar-left']}>
         {leftContent}
       </div>
       <div className={styles['title-bar-center']}>{centerContent}</div>
       <div className={styles['title-bar-right']}>
         {rightContent}
         <div className={styles['sys-menu-icon-box']}>
           <MinimizeIcon className={styles['sys-menu-icon']} onClick={minimizeHandle} />
           <FullScreenIcon
               className={styles['sys-menu-icon']}
               onClick={() => {
                 toggleMaximize(true);
               }}
             />
           <CloseIcon className={styles['sys-menu-icon']} />
         </div>
       </div>
     </div>
   );
 };

创建好了以后我们在入口页面中引入

 // app.tsx
 
 <div className="app-container">
    <SysTitleBar />
    <div className="app-content">
      <Outlet />
    </div>
 </div>

当然了,如果布局不一样可以自行调整,这一块就按设计稿去决定怎么展示菜单栏就好了。

3.实现系统菜单栏功能

现在我们创建了自定义的菜单栏,但是现在它还是个花架子,只能看不能用,所以我们需要给他补充功能。

3.1 可拖拽

当务之急我们需要完成的第一个功能就是让这个菜单栏可以像系统菜单栏一样鼠标按住拖拽。这里用的是app-region: drag来告诉electron哪些地方时可以拖拽的。

 .system-title-bar{
   app-region: drag;
 }

到这里还没有结束,因为你这么设置以后,组件内部的所有元素都是可拖拽的了,这样点击等事件根本没办法触发,所以我们需要将这些元素排除为非可编辑区域。

 .title-bar-right{
   app-region: no-drag;
 }
3.2 最小化

最小化的功能依靠于electron提供的minimize方法,所以我们只需要通知主进程调用这个方法即可。得益于我们刚刚将自定义菜单栏的右边设置为非可拖拽区域,所以我们可以给最小化的图标添加一个点击事件并绑定一个minimizeHandle方法来告诉electron去调用minimize方法

 // SysTitleBar.tsx
 
 const minimizeHandle = () => {
   window.ipcRenderer?.minimize();
 };
 
 // preload.ts
 import { ipcRenderer, contextBridge } from 'electron';
 
 contextBridge.exposeInMainWorld('ipcRenderer', {
   minimize: () => ipcRenderer.send('window-minimize'),
 });
 
 // main.ts
 import { BrowserWindow, ipcMain } from 'electron';
 
 ipcMain.on('window-minimize', (event) => {
   const win = BrowserWindow.fromWebContents(event.sender);
   win?.minimize();
 });
3.3 全屏

这里除了需要将软件全屏外,还有一个恢复原始大小的功能。图标的切换大家自己做处理就好,这里仅展示功能实现。还是按上面最小化的方式去实现,具体的方法及使用的API如下:

 //preload.ts
 
 toggleMaximize: () => ipcRenderer.send('toggle-window-maximize'),
 
 // main.ts
 ipcMain.on('toggle-window-maximize', (event) => {
   const win = BrowserWindow.fromWebContents(event.sender);
   if (win?.isMaximized()) { // 判断当前是否是全屏状态
     win.unmaximize(); // 取消全屏
   } else {
     win?.maximize(); // 全屏
   }
 });
3.4 关闭软件

方式同上,使用的是创建的window实例上的close方法,具体使用方式如下:

 // main.ts
 ipcMain.on('window-close', (event) => {
   const win = BrowserWindow.fromWebContents(event.sender);
   win?.close();
 });

到这里基本的自定义菜单栏就OK了,自定义菜单栏也是好处多多,例如需要再关闭之前做什么操作,我们完全可以在用户点击关闭按钮时先执行我们的操作然后再通知electron去关闭。所以我们在开发应用时基本都是需要自定义菜单栏实现。

Mac的最大化无效

这里说一下我这里的环境,electron的版本是30.0,系统是win11。上述功能在我自己电脑上运行时没有问题,在其他同事电脑上也是正常。但是测试突然给我踢了一个bug说是最大化无效。这就有点难搞了,因为我比较熟悉win所以我没有选Mac,难不成以前兼容ie的噩梦又出现了?

调试

首先还是需要找出问题,于是紧急给旁边同事派了根烟让他休息半小时,我紧急调试一下。根据测试发现是判断当前是否是全屏的方法win.isMaximized() 一直返回false。去查了下发现是因为Mac和win的全屏逻辑不一样,electron的issue也提到了这个问题,这里我就不贴了感兴趣的自己可以去翻翻看。

我看了下issue给出的建议是全屏的状态有我们去控制不依赖electron的API,然后我试了下,发现没啥用,win?.maximize()这个方法不生效,看来问题不出在这,还得接着找。 于是我又去找了下原因,发现罪魁祸首是创建窗口时的一个属性——resizable。这里我不希望用户能够拖拽改变软件窗口大小,所以这里设置为fasle。但是在Mac环境下如果resizable为false的情况下,Mac没办法做到全屏,因为Mac的全屏就是resize页面到屏幕的四周,所以上面的方法就没用了。

解决

然后我就在想,可不可以在全屏的时候将resizable设置为true,方法调用完毕以后再设置为false

 ipcMain.on('toggle-window-maximize', (event, flag: boolean) => {
   const win = BrowserWindow.fromWebContents(event.sender);
   if (!win) return;
 
   // 临时打开 resizable,否则 macOS 最大化不生效
   const wasResizable = win.isResizable();
 
   // 如果页面传过来的全屏判断值为true
   if (flag) {
     // 开启resizeable
     if (!wasResizable) win.setResizable(true);
     // 这里可以不判断直接调用
     if (!win.isMaximized()) win.maximize();
   } else {
     if (win.isMaximized()) win.unmaximize();
     // 关闭resizeable
     setTimeout(() => {
       win?.setResizable(false);
     }, 100);
   }
 });

🚩之所以在调用win.maximize()以后不关闭resizeable是因为如果在调用win.maximize()以后关闭resizeable会导致windows环境下调用win.unmaximize()方法无法回到初始比例的现象,应该是会导致electron的页面基准计算错误出现这个问题

Echarts的基本使用(待更新)

echarts是常用的一个画图工具,官网链接在这

Echarts的基本使用

最基本的Echarts使用需要用到两个方法,一个是echarts的方法init去初始化一个echarts实例myChart,然后就是实例的方法setOption,去给实例配置图表数据。我们这里创建一个基础的柱状图作为案例。

我们在使用Echarts画图的时候,需要去提前设置一个容器用来当作画板。

<template>
    <div class="screen-view">
        <div class="screen-header">
            <p>这里是Echarts组件的内容。</p>
        </div>
        <!-- Echarts容器 -->
        <div class="screen-container" id="chart">
        </div>
    </div>
</template>

一般为了方便获取到这个容器的DOM,所以我们会给他一个id值作为唯一标识。

然后我们就要引入echarts,当然要先下载 npm i echarts

引用完成以后,我们要先获取容器的DOM,然后用到echartsinit方法去初始化这个实例,接下来配置图表的数据。

图表的数据配置是很重要的,它决定了我们所画出来的图表是什么样子的,其中title属性是设置图表标题的,xAxis是用来设置横坐标都有哪些数据的,yAxis是配置纵坐标这里暂时用默认值就好所以直接是一个空对象{}series是配置图表的具体数据的,比如是什么类型的图,数据是多少。

最后通过实例方法setOption把这个图表的数据传入给实例就可以了。

import * as echarts from 'echarts';
export default {
    name: 'Echarts',
    data() {
        return {}
    },
    mounted() {
        // 初始化Echarts图表
        this.initChart();
    },
    methods: {
        initChart() {
            // 1.确保Echarts容器存在
            if (!document.getElementById('chart')) {
                return;
            }
            // 2.获取容器
            const chartDom = document.getElementById('chart');
            // 3.初始化Echarts实例
            const myChart = echarts.init(chartDom);
            // 4.设置图表配置项
            const option = {
                title: {
                    text: 'Echarts示例'  // 图表的名称
                },
                tooltip: {},
                xAxis: {
                    data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子']  // 横坐标的数据
                },
                yAxis: {}, // 默认可以不设置,纵坐标的数据会根据图表的具体数据去自适应
                series: [{  // 图表的具体数据也就是我们要展示的数据
                    name: '销量',  // 该数据的名称
                    type: 'bar',  // 该数据的类型  bar是指柱状图
                    data: [5, 20, 36, 10, 10, 20]  // 具体数据
                }]
            };
            // 5.使用配置项设置图表
            myChart.setOption(option);
        }
    },
}

效果如下:

ab421297-0014-49ce-a3ea-8aa847afddb4.png

完整代码如下:

<template>
    <div class="screen-view">
        <div class="screen-header">
            <p>这里是Echarts组件的内容。</p>
        </div>
        <!-- Echarts容器 -->
        <div class="screen-container" id="chart">
        </div>
    </div>
</template>
<script>
import * as echarts from 'echarts';
export default {
    name: 'Echarts',
    data() {
        return {}
    },
    mounted() {
        // 初始化Echarts图表
        this.initChart();
    },
    methods: {
        initChart() {
            // 确保Echarts容器存在
            if (!document.getElementById('chart')) {
                return;
            }
            // 获取容器
            const chartDom = document.getElementById('chart');
            // 初始化Echarts实例
            const myChart = echarts.init(chartDom);
            // 设置图表配置项
            const option = {
                title: {  // 图表名称
                    text: 'Echarts示例'
                },
                tooltip: {},
                xAxis: {  // x轴设置
                    data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子']  // x轴的值
                },
                yAxis: {},  // y轴设置
                series: [{  // 数据设置
                    name: '销量',
                    type: 'bar',
                    data: [5, 20, 36, 10, 10, 20]
                }]
            };
            // 使用配置项设置图表
            myChart.setOption(option);
        }
    },
}
</script>
<style lang='scss' scoped>
.screen-view {
    width: 100%;
    height: 100%;
    padding: 10px;
    box-sizing: border-box;

    .screen-header {
        height: 50px;
        line-height: 50px;
        text-align: center;
        font-size: 18px;
        margin-bottom: 10px;
    }

    .screen-container {
        width: 100%;
        height: calc(100% - 60px);
        padding: 10px;
        box-sizing: border-box;
    }
}
</style>

窗口的缩放对Echarts的影响

刚才只是Echarts的最基本使用,效果是可以实现的,但是如果我们把窗口给放大或者缩小呢?会不会对图有影响?

我们把窗口放大或者缩小试试看:

动画.gif

会发现在我们放大或者缩小窗口的时候,我们的DOM容器是会跟着变化的,但是图不会随着改变,没有达到自适应的效果,这体验肯定是不好的,所以我们这里还要用到echarts实例的一个方法resize

我们创建完实例以后,可以用js方法去监听窗口的变化,然后调用resize方法去改变图的大小。

// 5.使用配置项设置图表
myChart.setOption(option);
// 6.监听窗口大小变化,调整图表大小
window.addEventListener('resize', () => {
    myChart.resize();
});

动画.gif

柱状图叠加功能

柱状图的叠加功能就是在每个横坐标有多个柱状图的时候,将柱状图叠加在一个柱状图上面进行展示。这一点主要通过stack属性去实现,每个数据里面stack值相同的会叠加在一起。

xAxis: [
    {
        data: ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
    },
],
yAxis: [
    {
        type: 'value',
        name: '单位:个',
    }
],
series: [ // 设置多个柱状图
    {
        name: '苹果',
        type: 'bar',
        data: [5, 20, 36, 10, 10, 20],
        label: {
            show: true,
        },
        stack: '1', // stack相同的会叠加在一起
    },
    {
        name: '香蕉',
        type: 'bar',
        data: [13, 21, 5, 43, 33, 18],
        label: {
            show: true,
        },
        stack: '1',  // stack相同的会叠加在一起
    },
]

动画.gif

当然如果stack的值不相同,那就不会叠加在一块。

series: [ // 设置多个柱状图
    {
        name: '苹果',
        type: 'bar',
        data: [5, 20, 36, 10, 10, 20],
        label: {
            show: true,
        },
        stack: '1',
    },
    {
        name: '香蕉',
        type: 'bar',
        data: [13, 21, 5, 43, 33, 18],
        label: {
            show: true,
        },
        stack: '1',
    },
    {
        name: '橙子',
        type: 'bar',
        data: [13, 21, 5, 43, 33, 18],
        label: {
            show: true,
        },
    },
]

image.png

还有一个stackStrategy属性,是选择什么情况可以叠加的,值有四个samesign只在要堆叠的值与当前累积的堆叠值具有相同的正负符号时才堆叠,all堆叠所有的值,positive只堆积正值,negative只堆叠负值,默认是samesign

那在这种默认的情况下,如果我们的数据的正负号不一样会出现什么情况。

series: [ // 设置多个柱状图
    {
        name: '苹果',
        type: 'bar',
        data: [5, 21, 36, 10, 10, 20],
        label: {
            show: true,
        },
        stack: '1',
    },
    {
        name: '香蕉',
        type: 'bar',
        data: [13, -20, 5, 43, 33, 18],  // 有一个数据和将要堆叠的数据正负号不一样  -20对应要堆叠的数据21
        label: {
            show: true,
        },
        stack: '1',
    },
]

image.png

可以看到此时这两个数据就没有堆叠,因为stackStrategy是默认值samesign,如果我们设置为all会怎么样。

series: [ // 设置多个柱状图
    {
        name: '苹果',
        type: 'bar',
        data: [5, 21, 36, 10, 10, 20],
        label: {
            show: true,
        },
        stack: '1',
    },
    {
        name: '香蕉',
        type: 'bar',
        data: [13, -20, 5, 43, 33, 18],  // 有一个数据和将要堆叠的数据正负号不一样  -20对应要堆叠的数据21
        label: {
            show: true,
        },
        stack: '1',
        stackStrategy: 'all', // 全部都堆叠
    },
]

动画.gif

可以看到显示的明显是有问题的,不过一般也不会这么设置因为没啥意义。

还有一个属性stackOrder是用于设置堆叠顺序的,默认是seriesAsc是指按照数据顺序堆叠,第一个数据在最下面,还有一个值是seriseDesc是反向堆叠。

折线图也可以堆叠,但是折线图堆叠起来没有柱状图比较直观所以用的比较少。

series: [
    {
        name: '苹果',
        type: 'line',
        data: [5, 21, 36, 10, 10, 20],
        label: {
            show: true,
        },
        stack: '1',
    },
    {
        name: '香蕉',
        type: 'line',
        data: [13, 20, 5, 43, 33, 18],
        label: {
            show: true,
        },
        stack: '1',
    },
]

image.png

Uncaught URIError: URI malformed 报错如何解决?

Situation

前几天遇到一个线上问题反馈,用户的某一个文件访问对应的页面时,加载不出来,其他文件都没有问题,拿到这个文件后一看,大概知道了原因,文件名称包含了 % ,name作为query参数拼接到了url后面,name没有做编码,导致浏览器自动解码时遇到 % 报错,结果给用户的表现就是页面空白,啥也没有。

decodeURIComponent('%');

image.png

运行后,浏览器抛出错误:

Uncaught URIError: URI malformed

这是因为 % 在 URL 编码中有特殊意义,而 decodeURIComponent 解析时会严格按照 百分号编码规则 来处理。

URIError 是 js 的内置错误类型,表示 URI 相关的错误。在解析 URI 编码时,如果遇到非法格式,就会抛出这个错误

Action

如何解决这个问题呢?

1、凡是query中字符串可能存在 % 等特殊字符的情况下,都需要encode编码后再传参

name: encodeURIComponent(name)

同时,在接收方解析url的query参数时,需要使用try catch包裹,避免代码报错阻塞后续流程进行。但这只是容错方式,并不是根本解决办法,比如上述线上问题还是需要编码query参数才能解决,因为报错在浏览器自动decode解码环节,代码还没执行到接收方呢


function safeDecodeURIComponent(str) {
    try {
        return decodeURIComponent(str);
    } catch (e) {
        console.warn('非法 URI 编码:', str);
        return str;
    }
}

2、使用标准 API 解析参数

URLSearchParams 是浏览器提供的一个内置 Web API,专门用来操作 URL 的query参数,可以帮我们解析、获取、设置、删除、遍历 URL 的参数,而不需要自己手写 split('?')、split('&') 这种容易出错的代码

const params = new URLSearchParams(location.search);
console.log(params.get('name'));

decodeURI和decodeURIComponent

特性 decodeURI decodeURIComponent
解码范围 解码整个 URI,但不会解码保留字符(?、&、#、= 等) 解码 URI 组件,所有非字母数字和保留字符都会被解码
作用目标 用于解析完整 URL 字符串 用于解析 URL 的片段(如 query 参数值)
是否保留结构 会保留 URL 的结构,不会破坏参数分隔符 可能会解码结构字符,从而破坏 URL 格式
输入 example.com/search?q=%E… %E4%BD%A0%E5%A5%BD
输出 example.com/search?q=你好 你好

开发过程中需求是解析query参数&保留特殊字符,所以基本都是使用的decodeURIComponent

Result

遇到 Uncaught URIError: URI malformed,不要只是 try-catch 一下了事,而是应该:

  1. 确认数据源是否正确编码
  2. 区分 decodeURI 与 decodeURIComponent
  3. 对外部数据加防御性解析



只要保证 URL 里 path字段没有裸%,就不会有 URIError。

参考

URIError:developer.mozilla.org/zh-CN/docs/…

URLSearchParams:developer.mozilla.org/zh-CN/docs/…

还不会写抽奖转盘?快来让Trae写吧

前言

作为一名前端开发者,我们经常会遇到各种有趣的交互需求。

其中最经典的就是年会抽奖、活动促销等场景中的转盘抽奖功能。

今天,我们来看看Trae是怎么实现一个地道的抽奖转盘的。

需求分析:什么是"抽奖转盘"?

从前端实现角度来看,抽奖转盘不仅仅是一个简单的随机选择器

  • 视觉设计上要体现色彩美学
  • 交互体验要符合使用习惯
  • 动画效果要有仪式感,不能太随意

这种效果在年会、商场促销、线上活动等场景中非常常见,但实现起来需要考虑很多细节。

实现过程

在Trae编辑器中,我只需简单描述需求,AI就能理解并生成相应代码,整个实现过程出乎意料地顺畅

image.png

最终的效果图 image.png

设计转盘结构

首先创建了基础的HTML结构,主要的是转盘画布、指针、控制按钮

<canvas id="wheelCanvas" width="400" height="400"></canvas>
<div class="pointer">▼</div>
<button id="spinBtn" class="spin-btn">开始抽奖</button>

配色方案

trae帮我们定义了一个list,来存放对应的奖项以及颜色背景,后续可以把这个配置弄出来,就可以让用户自定义了

this.prizes = [
    { name: '一等奖', color: '#FF6B6B' },    // 喜庆红
    { name: '二等奖', color: '#4ECDC4' },    // 翡翠绿
    { name: '三等奖', color: '#45B7D1' },    // 天空蓝
    { name: '四等奖', color: '#96CEB4' },    // 淡雅绿
    { name: '五等奖', color: '#FECA57' },    // 金黄
    { name: '谢谢参与', color: '#DDA0DD' }   // 紫气东来
];

优雅的旋转动画

使用Canvas API绘制转盘,并实现了流畅的旋转效果

// 绘制扇形区域
const anglePerPrize = (2 * Math.PI) / this.prizes.length;
this.prizes.forEach((prize, index) => {
    const startAngle = index * anglePerPrize;
    const endAngle = (index + 1) * anglePerPrize;
    
    // 绘制扇形和文字
    this.ctx.beginPath();
    this.ctx.moveTo(centerX, centerY);
    this.ctx.arc(centerX, centerY, radius, startAngle, 
    endAngle);
    this.ctx.closePath();
    this.ctx.fillStyle = prize.color;
    this.ctx.fill();
});

技术要点解析

从前端技术角度,这个转盘的实现用到了几个关键技术:

Canvas绘图技术

使用HTML5 Canvas进行2D绘图,比传统的DOM操作性能更高,能够实现更复杂的图形效果。

缓动动画函数

使用了三次缓动函数,让转盘旋转有真实的物理感,让用户的体验会更加好

const easeOut = 1 - Math.pow(1 - progress, 3);

响应式设计

通过CSS媒体查询,确保在不同设备上都有良好的显示效果,不会出现超出或者变形

@media (max-width: 600px) {
    #wheelCanvas {
        width: 300px;
        height: 300px;
    }
}

用户交互体验

为了增强仪式感,我还让Trae添加了以下交互细节:

image.png

总结

通过Trae编辑器,实现了这个既实用又美观的抽奖转盘。

整个过程不仅简单高效,而且让我对Canvas绘图和动画实现有了更深入的理解。

作为前端开发者,我们常常需要在技术实现花费大量的时间成本。Trae编辑器可以极大地缩短了这一过程,让我们能够专注于创意和用户体验的优化。

如果你的项目也需要类似的抽奖功能,不妨尝试一下Trae,它可能会给你带来意想不到的惊喜。

不想接口联调,不想写代码,那就交给Trae

上一期Trae以完美的表现,帮我们完成nest项目interview模块的点赞、收藏功能,经过我们的swagger接口文档自测,交互功能、返回的数据格式都没有问题,符合本次新增的功能,接下来,前端联调的脏活累活还是交给Trae吧,让我们拭目以待,Trae出色的表现吧~

提问:isFavorited,isLiked,likeCount后端在列表返回这三个参数,在答案下面展示一个点赞、收藏的icon,并展示数量 (这里没有把细节提问好,导致Trae生成的样式一言难尽,还好,第二次提问的效果还不错)

image.png

由于没有限制他的icon,页面不显示,所以我再次提问让他使用antd的icon,项目已经安装了antd的icon库,没有去单独制作icon,把之前的interview列表新增四个字段进行展示和交互

image.png

很好样式还不错,把后端的接口也给到Trae

image-20250723142106287

/favorite/interview/{id}和/like/interview/{id}是对应的收藏,点赞接口,对接上,然后把icon的样式调整显眼点

最终Trae也完成了,点赞和收藏的高亮也完成了,count也对应的加上

image-20250723142844916

测试过程中发现了一个bug,Trae也给出了原因和优化建议,很nice

造成 sideMenuId 变化时请求两次接口的原因是:

  • 你在 watchEffect 里监听 sideMenuId,每次变化都会执行一次 fetchInterviews。
  • 但在 onMounted 钩子里也会执行一次 initFromRoute 和 fetchInterviews,而 initFromRoute 还会修改 selectedCategory,这会导致 watchEffect 再次触发。

优化建议:

  • 只用 watch 监听 sideMenuId,不要用 watchEffect,并且不要在 onMounted 里主动请求。
  • 只在 sideMenuId 变化时,重置并请求数据。

顺便也让Trae进行修改,看看能不能把bug修复完美

image-20250723142735505

看一下控制台,很好,完美

image-20250723165121372

  1. 总结一下,Trae帮我们完成哪些任务

API对接:

  • 点赞按钮已对接 /like/interview/{id},点击后请求接口,成功才切换状态和数量。
  • 收藏按钮已对接 /favorite/interview/{id},点击后请求接口,成功才切换状态。

显眼样式:

  • 图标使用antd的icon,点击之后高亮色更明显。
  • 点赞高亮为主题蓝色,收藏高亮为黄色,未激活为灰色。

bug修复

  • 减少不必要的接口请求,优化性能

在我们团队的开发过程中,Trae代码编辑器确实发挥了巨大的作用,它凭借其强大的代码生成和优化能力,帮助我们快速完成了大部分功能的实现。无论是复杂的逻辑处理,还是繁琐的模板代码,Trae都能在短时间内提供高质量的解决方案。

我们始终认为,工具只是辅助开发的手段,而开发人员的专业素养和对代码质量的追求才是项目成功的关键。通过合理利用Trae和GPT,我们能够在保证开发效率的同时,确保代码的优雅性和高质量。同时也可以减少Trae的使用次数,毕竟花钱还是节约次数的

在未来的工作中,我们会继续坚持这种开发模式。我们会充分利用Trae等工具的优势,快速完成功能开发;也会更加注重代码的审查和优化,确保代码都符合代码规范。

Elpis全栈项目总结

Elpis全栈项目总结

在本文的第一部分,我们将深入探讨 基于 Node.js 实现服务端内核引擎 的部分,并介绍整个项目的核心架构、技术选型以及如何构建一个高效、易维护的服务端引擎。通过分层架构、模块化设计,我们能够为前端提供更清晰、更高效的接口,并确保后端的可扩展性和性能。


一、基于 Node.js 实现服务端内核引擎

在构建一个现代化的后端应用时,Node.js 的高性能和灵活性使其成为了一个理想的选择。本部分将介绍如何利用 Node.js 和 Koa2 框架来实现一个服务端引擎,该引擎基于模块化和分层架构,支持高效的请求处理、日志记录和错误管理。

1. 项目架构与技术选型

本项目的架构主要分为 展示层BFF 层(后端)数据层,如下所示:

  • 展示层:前端采用 Vue3Element Plus,确保了页面的交互性和响应速度。
  • BFF 层(后端) :后端使用 Node.js 18Koa2,提供高效、可扩展的服务端能力。Koa2 提供了更细粒度的控制,适用于需要高并发的系统。
  • 数据层:使用 MySQL 数据库,适合需要强一致性和关系型数据的系统,同时通过 Log4js 进行日志管理。

这种架构设计保证了系统的高可用性与灵活性,同时也为后期的扩展提供了良好的基础。


2. 服务端框架搭建

我们使用 Koa2 作为 Web 框架,利用其中间件机制,按照 洋葱圈模型 处理请求。Koa2 提供了灵活的路由和中间件机制,可以轻松处理不同层级的逻辑。

核心代码:
javascript
复制编辑
const Koa = require('koa');
const app = new Koa();

app.listen(8080, () => {
    console.log('Server running at http://localhost:8080');
});

通过上述代码,我们成功搭建了一个简单的 Koa2 服务端框架。在此基础上,我们添加了中间件来处理请求、响应和日志记录,确保系统能够处理高并发的请求。

中间件与路由处理

在服务端框架中,我们使用中间件进行日志记录、错误捕获等操作。比如,我们通过 koa-bodyparser 来解析请求体,通过 koa-router 来处理路由。

javascript
复制编辑
const router = require('koa-router')();
router.get('/api/project/list', projectController.getList);

这种中间件和路由的分离设计,使得代码的逻辑更加清晰,易于维护。


3. API 请求与 Controller 层

API 请求的核心流程包括路由定义、控制器处理和服务层调用。每次 API 请求都会经过以下几个步骤:

  1. 路由定义:首先,API 请求会被路由定义所匹配。
  2. 控制器处理:路由会调用相应的控制器方法进行业务逻辑处理。
  3. 服务层调用:控制器通过服务层处理更具体的任务(如数据库访问、外部服务调用等)。
示例:获取项目列表
  1. 路由定义
javascript
复制编辑
module.exports = (app, router) => {
    const { project: projectController } = app.controller;
    router.get('/api/project/list', projectController.getList.bind(projectController));
};
  1. Controller 层
javascript
复制编辑
module.exports = (app) => {
    return class ProjectController {
        async getList(ctx) {
            const { projectService } = app.service;
            const res = await projectService.getList();
            ctx.status = 200;
            ctx.body = {
                success: true,
                data: res,
                metadata: {}
            };
        }
    };
};
  1. Service 层
javascript
复制编辑
module.exports = (app) => {
    return class ProjectService {
        async getList() {
            return [
                { name: 'Project 1', desc: 'Description 1' },
                { name: 'Project 2', desc: 'Description 2' }
            ];
        }
    };
};

通过这种结构,后端逻辑被清晰地分层,避免了控制器和服务层之间的耦合,使得代码的可维护性和可扩展性大大增强。


4. 日志管理与错误处理

为了提升系统的可调试性和健壮性,我们通过 Log4js 进行日志记录,并在全局范围内捕获异常。日志管理和错误处理不仅有助于调试,还能确保在生产环境中的高可用性。

日志配置:
javascript
复制编辑
const log4js = require('log4js');

module.exports = (app) => {
    let logger;
    if (app.env.isLocal()) {
        logger = console;
    } else {
        log4js.configure({
            appenders: {
                console: { type: 'console' },
                dateFile: {
                    type: 'dateFile',
                    filename: './logs/application.log',
                    pattern: '.yyyy-MM-dd'
                }
            },
            categories: { default: { appenders: ['console', 'dateFile'], level: 'trace' } }
        });
        logger = log4js.getLogger();
    }
    return logger;
};
错误处理:
javascript
复制编辑
module.exports = (app) => {
    return async (ctx, next) => {
        try {
            await next();
        } catch (err) {
            app.logger.error('Error:', err);
            ctx.status = 500;
            ctx.body = { success: false, message: 'Internal Server Error' };
        }
    };
};

通过日志记录和错误处理,我们能够及时发现并修复系统中的潜在问题,保证系统的稳定性和可用性。


总结

通过以上的介绍,我们构建了一个基于 Node.jsKoa2 的服务端内核引擎,并通过分层架构将系统的各个部分模块化管理。这种设计不仅保证了代码的清晰与可维护性,还使得后端系统能够灵活地扩展和调整。接下来的部分,我们将继续深入探讨如何完善这个系统,确保其在实际生产环境中的高效运行。

二、Webpack5工程化

在这一部分,我们将深入探讨 Webpack5 的工程化设计。Webpack 是一个强大的 JavaScript 应用程序打包工具,它的核心功能不仅仅是打包 JavaScript 文件,还可以处理各种前端资源(如样式、图片、字体等),并通过插件和加载器提供了极大的灵活性。通过这一系列的设计与配置,我们能够在开发和生产环境中有效地优化前端构建流程。


1. Webpack5 项目架构与基本配置

首先,Webpack 作为前端构建工具,要求我们配置 entryoutputmoduleplugins 等内容。以下是 Webpack5 在一个典型项目中的基础配置:

项目入口与输出配置

Webpack 的 entry 配置决定了应用程序的入口点,而 output 配置则决定了最终产物的输出位置。

javascript
复制编辑
module.exports = {
  // 入口文件配置
  entry: './src/index.js',
  // 输出配置
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
};

2. 解析与模块打包

不同的文件(如 .vue, .js, .scss, .css 等)需要通过不同的解析引擎进行处理。Webpack 提供了强大的 loader 来实现这一点。例如,使用 babel-loader 来处理 ES6+ 的 JavaScript,使用 vue-loader 来解析 Vue 单文件组件。

配置 Module Rules:
javascript
复制编辑
module: {
  rules: [
    {
      test: /.js$/,
      use: 'babel-loader',
      exclude: /node_modules/,
    },
    {
      test: /.vue$/,
      use: 'vue-loader',
    },
    {
      test: /.css$/,
      use: ['style-loader', 'css-loader'],
    },
  ],
}

通过这些规则,Webpack 可以在打包时自动转换文件内容,保证代码在浏览器中的兼容性。

3. 模块分包与性能优化

一个典型的前端项目往往包含多个模块,如果我们将所有的代码打包成一个巨大的 JavaScript 文件,浏览器会面临性能瓶颈。为了解决这个问题,Webpack 提供了 代码分割(Code Splitting) 功能。

配置代码分割

通过 Webpack 的 splitChunks 配置,可以将重复使用的第三方库、公共模块和各个页面的代码拆分成多个文件,确保浏览器能够更高效地加载资源。

javascript
复制编辑
optimization: {
  splitChunks: {
    chunks: 'all',
    maxAsyncRequests: 10,
    maxInitialRequests: 10,
    cacheGroups: {
      vendor: {
        test: /[\/]node_modules[\/]/,
        name: 'vendor',
        priority: 10,
      },
    },
  },
}

这样,Webpack 会根据不同的规则将代码分成多个模块,使得浏览器能够更有效地缓存和加载文件。

4. 插件与自动化构建

除了基本的打包功能,Webpack 还提供了丰富的 插件,帮助我们进行自动化构建、优化输出、生成 HTML 模板等操作。

使用 HtmlWebpackPlugin 自动生成 HTML 文件
javascript
复制编辑
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      filename: 'index.html',
    }),
  ],
};

通过 HtmlWebpackPlugin,Webpack 会自动将打包后的 JavaScript 文件注入到生成的 HTML 文件中。

5. 开发与生产环境配置

Webpack 的配置通常会根据环境(开发环境和生产环境)进行调整。在开发环境中,我们通常需要启用 热模块替换(HMR)和 source-map 以便快速调试。而在生产环境中,我们需要压缩代码、优化资源,并配置更复杂的缓存策略。

开发环境配置(dev)
javascript
复制编辑
module.exports = {
  mode: 'development',
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    contentBase: './dist',
    hot: true,
    open: true,
  },
};
生产环境配置(prod)
javascript
复制编辑
module.exports = {
  mode: 'production',
  optimization: {
    minimize: true,
    splitChunks: {
      chunks: 'all',
    },
  },
  plugins: [
    new TerserWebpackPlugin(),
  ],
};

开发环境主要专注于提升开发效率,而生产环境则侧重于代码压缩和优化。


6. 其他优化与扩展功能

除了基础的打包和压缩功能,Webpack 还支持通过 Happypack 实现多线程构建,利用 MiniCssExtractPlugin 将 CSS 单独提取为文件,进一步提升构建性能和页面加载速度。

MiniCssExtractPlugin 配置
javascript
复制编辑
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'styles/[name].[contenthash].css',
    }),
  ],
};

总结

在本文中,我们探讨了如何使用 Webpack5 来实现一个现代化的前端构建系统。通过灵活的配置,Webpack 能够高效地处理不同类型的资源、优化构建过程、分离代码并进行性能优化。无论是在开发环境中实现热更新,还是在生产环境中进行代码压缩,Webpack5 都为前端工程化提供了强大的支持。这一切为后续的项目开发和维护打下了坚实的基础。

三、基于 Vue3 完成领域模型架构建设

在本文的第二部分,我们将探讨如何基于 Vue3 完成 领域模型架构 的建设。领域模型架构在大型项目中有着至关重要的作用,它帮助我们组织和管理业务逻辑,确保项目在功能扩展时保持清晰和可维护性。我们将结合 Vue3Vue Router,以高效的方式实现项目的组织结构,并通过 VuexPinia 管理全局状态和数据。


1. 领域模型设计

在项目的领域模型设计中,我们需要清晰地定义业务对象、逻辑处理和数据流。通常,领域模型的设计会根据项目的需求划分为不同的模块,每个模块拥有独立的逻辑和可复用的组件。例如,在电商系统中,我们可能会有 商品管理订单管理客户管理 等模块。

示例:电商系统的领域模型
javascript
复制编辑
module.exports = {
    model: 'dashboard', // 模型类型
    name: '电商系统',  // 系统名称
    menu: [
        {
            key: 'product',
            name: '商品管理',
            menuType: 'module',
            moduleType: 'custom',
            customConfig: {
                path: '/todo'
            }
        },
        {
            key: 'order',
            name: '订单管理',
            menuType: 'module',
            moduleType: 'iframe',
            iframeConfig: {
                path: 'http://www.baidu.com'
            }
        },
        {
            key: 'client',
            name: '客户管理',
            menuType: 'module',
            moduleType: 'custom',
            customConfig: {
                path: '/todo'
            }
        }
    ]
};

在这个领域模型中,我们定义了一个包含商品管理、订单管理和客户管理的菜单结构,并且为每个菜单项指定了不同的显示方式(iframecustom)。这种结构化设计帮助我们清晰地分隔不同的功能模块。


2. DSL 设计与解析引擎

DSL(领域特定语言)是用来描述领域模型的特定语言。我们通过设计一个领域模型的 DSL,可以快速地描述和生成项目的配置。通常,这些配置数据会被送入解析引擎,并通过该引擎生成可执行的项目结构。

示例:DSL 设计

在电商系统的领域模型中,我们为不同的模块设置了特定的菜单、功能和配置路径。以下是一个简化的 DSL 配置:

javascript
复制编辑
{
    model: 'dashboard', // 模板类型
    name: '电商系统',
    desc: '电商系统的管理后台',
    homePage: '/dashboard',
    menu: [
        {
            key: 'product',
            name: '商品管理',
            menuType: 'module',
            moduleType: 'custom',
            customConfig: {
                path: '/todo'
            }
        },
        {
            key: 'order',
            name: '订单管理',
            menuType: 'module',
            moduleType: 'iframe',
            iframeConfig: {
                path: 'http://www.baidu.com'
            }
        }
    ]
}

这种 DSL 设计通过简洁的配置,能够快速生成具体的项目结构和对应的页面路径。每个模块都可以通过不同的方式加载,提升了系统的灵活性和扩展性。


3. 实现 Vue3 领域模型架构

在 Vue3 项目中,领域模型的构建通常依赖于组件化开发和路由管理。每个模块都会被划分为不同的页面或组件,通过 Vue Router 来实现页面间的导航,通过 PiniaVuex 来管理状态。

示例:Vue3 组件化与路由配置
vue
复制编辑
<template>
  <el-container class="dashboard-container">
    <el-header>
      <header-view :proj-name="projName" />
    </el-header>
    <el-main>
      <router-view></router-view>
    </el-main>
  </el-container>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import headerView from './complex-view/header-view/header-view.vue';
import { useMenuStore } from '$store/menu.js';

const projName = ref('');
onMounted(() => {
  projName.value = '电商系统';
});
</script>

<style scoped>
.dashboard-container {
  height: 100%;
}
</style>

在这个组件中,我们通过 router-view 来动态渲染不同的页面,header-view 组件负责渲染头部菜单。通过 PiniaVuex 来管理全局状态,我们可以在不同的页面间共享数据。


4. 动态菜单与视图渲染

我们在 Vue3 项目中实现了动态菜单和视图渲染。通过 Vue RouterVuex/Pinia,我们能够根据不同的领域模型动态加载菜单和视图,实现更加灵活的界面展示。

示例:动态加载视图
javascript
复制编辑
// vue-router 配置
const routes = [
  {
    path: '/product',
    component: () => import('@/pages/ProductPage.vue'),
  },
  {
    path: '/order',
    component: () => import('@/pages/OrderPage.vue'),
  }
];

通过这种方式,我们可以根据路由配置加载不同的页面,实现懒加载和按需加载,减少首屏加载的资源大小。


5. 领域模型与后台服务的集成

通过设计领域模型的 API,我们能够在后端与前端进行良好的数据交互。在后台实现领域模型的接口时,我们遵循 RESTful API 规范,确保每个请求都能够返回所需的数据结构。

示例:RESTful API 实现
javascript
复制编辑
module.exports = (app) => {
  const BaseController = require('./base')(app);
  return class ProjectController extends BaseController {
    async getModelList(ctx) {
      const { project: projectService } = app.service;
      const modelList = await projectService.getModelList();
      this.success(ctx, modelList);
    }
  };
};

在前端,使用 axiosfetch 进行接口调用,将从后端获取的领域模型数据渲染到页面中。


6. 实现 SchemaView 和 SchemaTable

在前端开发中,SchemaViewSchemaTable 是非常重要的组成部分,它们分别负责展示配置的业务数据和展示表格。通过动态的配置和 Vue3 的组件化方式,我们能够让这些组件具有很好的复用性和扩展性。

SchemaView 组件的实现

SchemaView 组件负责将业务模型中的配置解析并渲染到视图中。它基于从后端获取的 schemaConfigtableConfig 动态生成相关的表单和表格视图。

vue
复制编辑
<template>
  <el-row class="schema-view">
    <search-panel />
    <table-panel />
  </el-row>
</template>

<script setup>
  import { provide } from 'vue';
  import SearchPanel from './complex-view/search-panel/search-panel.vue';
  import TablePanel from './complex-view/table-panel/table-panel.vue';
  import { useSchema } from './hook/schema.js';

  const {
    api,
    tableSchema,
    tableConfig
  } = useSchema();

  provide('schemaViewData', {
    api,
    tableSchema,
    tableConfig
  });
</script>

<style lang="less" scoped>
  .schema-view {
    display: flex;
    flex-direction: column;
    height: 100%;
    width: 100%;
  }
</style>

通过 useSchema hook 获取业务模型的相关配置,我们将 api, tableSchematableConfig 传递给子组件(如 SearchPanelTablePanel)。这些组件将利用这些配置动态生成页面内容。


7. 实现 SchemaSearchBar 组件

SchemaSearchBar 组件用于渲染业务模型中的搜索栏。它根据配置动态渲染各种输入组件(如 input, select, date-range 等),并提供 searchreset 操作。

vue
复制编辑
<template>
  <el-form
    v-if="schema && schema.properties"
    :inline="true"
    class="schema-search-bar"
    @submit.prevent="search"
  >
    <el-form-item
      v-for="(schemaItem,key) in schema?.properties"
      :key="key"
      :label="schemaItem.label"
    >
      <component
        :is="SearchItemConfig[schemaItem.option?.comType]?.component"
        :ref="handleSearchComList"
        :schema-key="key"
        :schema="schemaItem"
        @loaded="handleChildLoaded"
      />
    </el-form-item>
    <el-form-item>
      <el-button
        type="primary"
        plain
        class="search-btn"
        @click="search"
      >
        搜索
      </el-button>
      <el-button
        type="default"
        plain
        class="reset-btn"
        @click="reset"
      >
        重置
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, toRefs } from 'vue';
import SearchItemConfig from './search-item-config.js';

const props = defineProps({
  schema: Object,
});

const { schema } = toRefs(props);

const emit = defineEmits(['load', 'search', 'reset']);

const searchComList = ref([]);
const handleSearchComList = (el) => {
  searchComList.value.push(el);
};

const getValue = () => {
  let dtoObj = {};
  searchComList.value.forEach((component) => {
    dtoObj = { ...dtoObj, ...component?.getValue() };
  });
  return dtoObj;
};

let childComLoadedCount = 0;
const handleChildLoaded = () => {
  childComLoadedCount++;
  if (childComLoadedCount >= Object.keys(schema?.value?.properties).length) {
    emit('load', getValue());
  }
};

const search = () => {
  emit('search', getValue());
};
const reset = () => {
  searchComList.value.forEach((component) => component?.reset());
  emit('reset');
};
</script>

<style lang="less">
.schema-search-bar {
  min-width: 500px;
}
</style>

这个组件通过动态渲染不同的输入框组件来适应不同的搜索需求。所有的表单项(input, select, dynamicSelect, dateRange)都可以通过 SchemaSearchBar 的配置项传递进来。


8. 实现 SchemaTable 组件

SchemaTable 组件负责展示业务模型的表格数据,并提供一些操作按钮(如 修改, 删除)。它根据传入的 tableConfig 配置动态渲染表格和操作按钮。

vue
复制编辑
<template>
  <div class="schema-table">
    <el-table
      v-if="schema && schema.properties"
      v-loading="loading"
      :data="tableData"
      class="table"
    >
      <template v-for="(schemaItem, key) in schema.properties">
        <el-table-column
          v-if="schemaItem.option.visiable !== false"
          :key="key"
          :prop="key"
          :label="schemaItem.label"
          v-bind="schemaItem.option"
        />
      </template>
      <el-table-column
        v-if="buttons?.length > 0"
        label="操作"
        fixed="right"
        :width="operationWidth"
      >
        <template #default="scope">
          <el-button
            v-for="item in buttons"
            link
            v-bind="item"
            @click="operationHandler({ btnConfig: item, rowData: scope.row })"
          >
            {{ item.label }}
          </el-button>
        </template>
      </el-table-column>
    </el-table>
    <el-row class="pagination" justify="end">
      <el-pagination
        :current-page="currentPage"
        :page-size="pageSize"
        :total="total"
        layout="total, sizes, prev, pager, next, jumper"
        @size-change="onPageSizeChange"
        @current-change="onCurrentPageChange"
      />
    </el-row>
  </div>
</template>

<script setup>
import { ref, toRefs, computed, watch, nextTick, onMounted } from 'vue';
import $curl from '$common/curl.js';

const props = defineProps({
  schema: Object,
  api: String,
  buttons: Array,
});

const { schema, api, buttons } = toRefs(props);

const emit = defineEmits(['operate']);

const operationWidth = computed(() => {
  return buttons?.value?.length > 0
    ? buttons.value.reduce((pre, cur) => pre + cur.label.length * 18, 50)
    : 50;
});

const loading = ref(false);
const tableData = ref([]);
const currentPage = ref(1);
const pageSize = ref(50);
const total = ref(0);

onMounted(() => {
  initData();
});

watch([schema, api], () => {
  initData();
}, { deep: true });

const initData = () => {
  currentPage.value = 1;
  pageSize.value = 50;
  nextTick(async () => {
    await loadTableData();
  });
};

const loadTableData = async () => {
  if (!api.value) return;
  showLoading();
  const res = await $curl({
    method: 'get',
    url: `${api.value}/list`,
    query: {
      page: currentPage.value,
      size: pageSize.value,
    },
  });
  hideLoading();
  if (!res || !res.success || !Array.isArray(res.data)) {
    tableData.value = [];
    total.value = 0;
    return;
  }
  tableData.value = res.data;
  total.value = res.metadata.total;
};

const showLoading = () => {
  loading.value = true;
};

const hideLoading = () => {
  loading.value = false;
};

const operationHandler = ({ btnConfig, rowData }) => {
  emit('operate', { btnConfig, rowData });
};

const onPageSizeChange = async (value) => {
  pageSize.value = value;
  await loadTableData();
};

const onCurrentPageChange = async (value) => {
  currentPage.value = value;
  await loadTableData();
};

defineExpose({
  initData,
  loadTableData,
  showLoading,
  hideLoading,
});
</script>

<style scoped>
.schema-table {
  flex: 1;
  display: flex;
  flex-direction: column;

  .table {
    flex: 1;
  }

  .pagination {
    margin: 10px 0;
  }
}
</style>

通过这种方式,我们能够动态地从后端获取表格数据,并根据模型配置来展示对应的列和操作按钮。用户点击按钮后,operationHandler 方法会触发相应的操作。


9. 领域模型的接口和数据交互

每个领域模型的接口都可以在 Vue3 中通过 axiosfetch 进行封装,实现与后端的交互。通过动态的 API 请求,我们可以实现业务模型的 增、删、改、查 功能,并通过 SchemaViewSchemaTable 组件渲染数据。

示例:后端接口调用
javascript
复制编辑
const getProductList = async () => {
  const res = await $curl({
    method: 'get',
    url: '/api/proj/product/list',
    query: {
      page: 1,
      size: 50,
    },
  });
  return res;
};

通过这种方式,我们能够保证前端和后端的数据交互高效且简洁。

总结

在本部分中,我们探讨了如何基于 Vue3 完成领域模型架构建设。从 DSL 设计Vue3 组件化,再到 路由和视图渲染的动态加载,我们展示了如何构建一个灵活、高效且可维护的前端系统。通过与后台服务的良好集成,我们能够实现完整的前后端分离架构,提升开发效率并优化用户体验

四、基于 Vue3 完成动态组件库建设

在本文的第四部分,我们将深入探讨如何基于 Vue3 构建一个动态组件库。通过这一组件库,我们可以灵活地为不同的业务需求创建可复用的组件,这些组件能够根据配置动态生成表单、面板、表格等,极大地提高开发效率和代码的复用性。


1. 动态组件库的需求分析

在企业级应用中,我们经常需要处理表单、表格、面板等 UI 组件的展示和交互。传统的做法是为每个功能模块编写独立的组件,但随着项目复杂度的增加,这种做法容易导致重复代码和低效率。因此,我们需要一个动态组件库,它能够根据不同的配置和需求,动态生成相应的 UI 组件。

关键需求:
  • 动态生成表单:根据业务模型和配置,动态生成表单(如新增、编辑、详情表单)。
  • 动态生成面板:通过配置展示详情面板,显示业务对象的详细信息。
  • 复用性和扩展性:组件库应具备良好的复用性,可以通过不同的配置生成不同的组件实例。

2. 动态表单组件实现

SchemaForm 组件是我们动态组件库的核心,它根据传入的 schema 配置生成表单项,并提供表单校验、数据收集等功能。我们将通过 SchemaForm 结合 CreateFormEditForm,实现表单的创建与编辑功能。

示例:SchemaForm 组件实现
vue
复制编辑
<template>
  <el-row class="schema-form">
    <template v-for="(item, key) in schema.properties">
      <component
        :is="FormItemConfig[item.option.comType]?.component"
        :key="key"
        :schema="item"
        :model="model[key]"
        :schema-key="key"
      />
    </template>
  </el-row>
</template>

<script setup>
import { ref } from 'vue';
import FormItemConfig from './form-item-config';

const props = defineProps({
  schema: Object,
  model: Object
});

const formComList = ref([]);

const validate = () => {
  return formComList.value.every(component => component.validate());
};

const getValue = () => {
  return formComList.value.reduce((dtoObj, component) => {
    return { ...dtoObj, ...component.getValue() };
  }, {});
};

defineExpose({
  validate,
  getValue,
});
</script>

<style scoped>
.schema-form {
  display: flex;
  flex-wrap: wrap;
}
</style>
  • 动态表单生成:根据 schema 配置动态渲染表单项。
  • 表单验证和数据收集validate 方法校验表单数据,getValue 方法收集表单数据。

3. 编辑表单与详情面板实现

EditFormDetailPanel 组件是基于 SchemaForm 的两个衍生组件,分别用于表单的编辑和详情数据的展示。

示例:EditForm 组件实现
vue
复制编辑
<template>
  <el-drawer v-model="isShow" direction="rtl" :size="550">
    <template #header>
      <h3>{{ title }}</h3>
    </template>
    <template #default>
      <schema-form
        ref="schemaFormRef"
        v-loading="loading"
        :schema="components[name].schema"
        :model="dtoModel"
      />
    </template>
    <template #footer>
      <el-button type="primary" @click="save">{{ saveBtnText }}</el-button>
    </template>
  </el-drawer>
</template>

<script setup>
import { ref, inject } from 'vue';
import SchemaForm from '$widgets/schema-form/schema-form.vue';

const name = ref('editForm');
const schemaFormRef = ref(null);
const isShow = ref(false);
const loading = ref(false);
const title = ref('');
const saveBtnText = ref('');
const dtoModel = ref({});

const { api, components } = inject('schemaViewData');
const emit = defineEmits(['command']);

const show = (rowData) => {
  const { config } = components.value[name.value];
  title.value = config.title;
  saveBtnText.value = config.saveBtnText;
  dtoModel.value = {};
  isShow.value = true;
  fetchFormData();
};

const fetchFormData = async () => {
  if (loading.value) return;
  loading.value = true;
  const res = await fetchData(api.value, { product_id: rowData.product_id });
  loading.value = false;
  if (res) dtoModel.value = res.data;
};

const save = async () => {
  if (loading.value) return;
  if (!schemaFormRef.value.validate()) return;
  loading.value = true;
  const res = await fetchData(api.value, { ...dtoModel.value });
  loading.value = false;
  if (res) {
    emit('command', { event: 'loadTableData' });
  }
};

defineExpose({
  name,
  show
});
</script>
  • 动态渲染与编辑:根据 schema 动态生成表单,并通过 dtoModel 传递数据。
  • 保存与回显:通过 fetchFormData 获取数据并回显,通过 save 方法保存编辑数据。

4. 动态面板组件实现

DetailPanel 组件用于展示详情数据,通常通过接口获取数据并在面板中显示。

示例:DetailPanel 组件实现
vue
复制编辑
<template>
  <el-drawer v-model="isShow" direction="rtl" :size="550">
    <template #header>
      <h3>{{ title }}</h3>
    </template>
    <template #default>
      <el-card v-loading="loading" class="detail-panel">
        <el-row v-for="(item, key) in components[name].schema.properties" :key="key">
          <el-row class="item-label">{{ item.label }}:</el-row>
          <el-row class="item-value">{{ dtoModel[key] }}</el-row>
        </el-row>
      </el-card>
    </template>
  </el-drawer>
</template>

<script setup>
import { ref, inject } from 'vue';

const isShow = ref(false);
const loading = ref(false);
const title = ref('');
const dtoModel = ref({});

const { api, components } = inject('schemaViewData');
const name = ref('detailPanel');

const show = (rowData) => {
  const { config } = components.value[name.value];
  title.value = config.title;
  dtoModel.value = {};
  isShow.value = true;
  fetchFormData(rowData);
};

const fetchFormData = async (rowData) => {
  loading.value = true;
  const res = await fetchData(api.value, { product_id: rowData.product_id });
  loading.value = false;
  if (res) dtoModel.value = res.data;
};

defineExpose({
  name,
  show
});
</script>
  • 数据展示:通过 dtoModel 展示详情数据。
  • 动态获取数据:根据主键 product_id 请求详情数据并在面板中展示。

5. 组件库的扩展性和复用

通过以上组件的实现,我们的动态组件库可以方便地扩展和复用。每个组件(如 SchemaForm, EditForm, DetailPanel)都基于 schema 配置动态生成,能够根据不同的业务需求生成对应的表单和面板。此外,组件的组合(如 createFormeditForm)可以通过配置轻松地在多个地方复用。

通过这种方式,我们能够大幅度提高开发效率,避免重复造轮子,同时保证项目的可维护性和扩展性。


总结

在本部分中,我们介绍了如何基于 Vue3 构建一个动态组件库,包括 动态表单编辑表单详情面板 的实现。这些组件基于 schema 配置动态生成,提供了强大的灵活性和扩展性,适用于各种不同的业务需求。通过这种组件化和配置化的方式,我们可以大大提高开发效率,并保持代码的高复用性。

五、完成框架 NPM 包抽离封装并发布

在本文的第五部分,我们将详细介绍如何将 Elpis 框架的核心逻辑进行抽离并封装为一个 NPM 包,然后将其发布到 NPM 上。这样,其他项目可以直接通过 npm install @aodi/elpis 来使用这个框架,并能在自己的项目中进行集成与扩展。


1. 抽离核心代码并封装为 NPM 包

首先,我们需要将 Elpis 框架的核心代码抽离出来,使其能够作为一个独立的模块进行使用。为了实现这一点,我们将 Elpis 中的业务逻辑、服务端的框架部分以及相关的配置文件抽离到一个单独的包中。

步骤:
  1. elpis 项目中,将相关的文件移动到独立的目录下,确保核心代码与业务逻辑分离。例如,将 elpis-core 放入一个单独的文件夹。
  2. 修改 package.json 文件,为包提供名称、版本号和依赖信息。需要将框架的相关逻辑暴露出来,使其他项目能够通过 NPM 安装并使用。
json
复制编辑
{
  "name": "@aodi/elpis", // 采用 NPM 组织 + 项目命名的格式
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "build": "node --max_old_space_size=4096 ./app/webpack/prod.js"
  },
  "dependencies": {
    "koa": "^2.7.0",
    "vue": "^3.3.4",
    "vuex": "^4.1.0"
  },
  "devDependencies": {
    "webpack": "^5.88.1",
    "vue-loader": "^17.2.2",
    "babel-loader": "^8.0.4"
  }
}
  1. 通过运行 npm login 登录到 NPM,准备发布包。
bash
复制编辑
npm login
  1. 然后,执行 npm publish 命令将包发布到 NPM 仓库中:
bash
复制编辑
npm publish

2. 在 elpis-demo 项目中集成并使用 NPM 包

一旦 Elpis 被成功发布为 NPM 包,其他项目就可以通过 npm install @aodi/elpis 命令安装并使用它。

步骤:
  1. elpis-demo 项目中,首先通过 NPM 安装 Elpis
bash
复制编辑
npm install @aodi/elpis
  1. elpis-demo 的代码中,直接引入并使用 Elpis 提供的功能模块:
javascript
复制编辑
const { serverStart } = require('@aodi/elpis');

// 启动服务
const app = serverStart({
  name: 'ElpisDemo',
  homePage: '/view/dashboard'
});

这样,elpis-demo 项目就可以直接通过引入 Elpis 核心包来实现框架的启动与服务的管理。


3. 配置 Webpack 打包与 NPM 包依赖问题

elpis-demo 中使用 Elpis 时,有时需要修改 Webpack 配置或者手动安装某些依赖,特别是当 Elpis 的某些依赖没有安装时。为了确保 elpis-demo 项目能够顺利运行,以下是必要的步骤:

修改 Webpack 配置:
  1. elpis-demo 中,确保 webpack.config.js 中设置了正确的别名,以便能够正确加载 elpis-demo 自定义的别名:
javascript
复制编辑
module.exports = {
  resolve: {
    alias: {
       '$demo': 'demo'
    }
  }
};

4. 在 elpis-demo 中配置并发布

为了使 elpis-demo 项目更容易使用 Elpis,你可以通过在 elpis-demopackage.json 中添加一些启动脚本,并配置不同的环境(如开发、生产等)来使得包管理更加灵活。

json
复制编辑
{
  "scripts": {
    "dev": "set _ENV=local&& nodemon ./server.js",
    "prod": "set _ENV=production&& node ./server.js",
    "build:dev": "set _ENV=local&& node --max_old_space_size=4096 ./build.js"
  }
}

5. 总结

通过将 Elpis 核心逻辑抽离为一个 NPM 包,我们能够将其发布到 NPM 上,其他开发者和团队可以方便地通过 npm install @aodi/elpis 来使用这个框架。这样,不仅减少了重复造轮子的工作,还能方便团队之间共享和管理代码。通过简单的配置,Elpis 可以集成到不同的项目中,实现服务端与前端的有效协作。

六、框架应用与项目实践

在本文的第六部分,我们将探讨如何应用 Elpis 框架并在实际项目中进行开发和部署。通过人员管理模块、登录校验和登出功能的实现,结合持续集成(CI)和持续部署(CD)等工具的应用,我们可以构建一个完整的业务系统。


1. 人员管理模块实践

Elpis 框架中,人员管理模块是最常见的模块之一。我们通过 schemaAPI 配置,结合 Elpis 提供的自动化组件,快速实现人员管理系统的增删改查(CRUD)功能。

人员管理模块的领域模型

首先,定义了一个简单的 人员管理模块。在 elpis-demo/model/people/model.js 文件中,我们为人员管理系统配置了模型。

javascript
复制编辑
module.exports = {
  module: 'people',
  name: '人员管理系统',
  menu: [{
    key: 'user',
    name: '人员管理',
    menuType: 'module',
    moduleType: 'schema',
    schemaConfig: {
      api: '/api/proj/user',
      schema: {
        type: 'object',
        properties: {
          user_id: { type: 'string', label: '用户ID' },
          username: { type: 'string', label: '账号' },
          nickname: { type: 'string', label: '昵称' },
          sex: {
            type: 'number',
            label: '性别',
            searchOption: { comType: 'select', enumList: [{ label: '男', value: 1 }, { label: '女', value: 2 }] }
          },
          create_time: { type: 'string', label: '创建时间', searchOption: { comType: 'dateRange' } }
        }
      },
      required: ['username', 'nickname', 'sex']
    },
    tableConfig: {
      headerButtons: [{
        label: '新增用户',
        eventKey: 'showComponent',
        eventOption: { comName: 'createForm' },
        type: 'primary'
      }],
      rowButtons: [{
        label: '查看详情',
        eventKey: 'showComponent',
        eventOption: { comName: 'detailPanel' },
        type: 'primary'
      }, {
        label: '修改',
        eventKey: 'showComponent',
        eventOption: { comName: 'editForm' },
        type: 'warning'
      }, {
        label: '删除',
        eventKey: 'remove',
        eventOption: { params: { user_id: 'schema::user_id' } },
        type: 'danger'
      }]
    },
    componentConfig: {
      createForm: { title: '新增用户', saveBtnText: '保存' },
      editForm: { title: '修改用户', saveBtnText: '保存' },
      detailPanel: { title: '用户详情' }
    }
  }]
}
API 接口配置

elpis-demo/app/router-schema/user.js 文件中,我们定义了 RESTful API 接口,规范了用户的增删改查操作。

javascript
复制编辑
module.exports = {
  '/api/proj/user/list': {
    get: { query: { page: { type: 'string' }, size: { type: 'string' } } }
  },
  '/api/proj/user': {
    post: {
      body: {
        type: 'object',
        properties: {
          username: { type: 'string' },
          nickname: { type: 'string' },
          sex: { type: 'number' },
          desc: { type: 'string' }
        }
      }
    },
    put: { body: { properties: { user_id: { type: 'string' }, nickname: { type: 'string' }, sex: { type: 'number' } } } },
    delete: { body: { properties: { user_id: { type: 'string' } } } },
    get: { query: { properties: { user_id: { type: 'string' } } } }
  }
}

2. 登录与登出功能

为了增强系统的安全性和用户管理,Elpis 框架集成了登录与登出功能。登录功能使用 JWT 生成令牌,而登出功能则是通过清除令牌并重定向用户到登录页面来实现。

登录功能

elpis-demo/app/pages/auth/complex-view/login/login.vue 中实现了简单的登录表单,通过 axios 向后端发送请求。

vue
复制编辑
<template>
  <el-row v-loading="loading">
    <el-input v-model="username" placeholder="请输入账号" class="username"></el-input>
    <el-input v-model="password" placeholder="请输入密码" class="password" show-password></el-input>
    <el-button type="primary" @click="login" class="login-btn">登录</el-button>
  </el-row>
</template>

<script setup>
import { ref } from 'vue';
import { ElMessage } from 'element-plus';
import $curl from '$elpisCurl';

const loading = ref(false);
const username = ref('admin');
const password = ref('123456');

const login = async () => {
  loading.value = true;
  const res = await $curl({
    method: 'post',
    url: '/api/auth/login',
    data: { username: username.value, password: password.value }
  });
  loading.value = false;
  if (!res || !res.success) return;
  ElMessage.success('登录成功');
  localStorage.setItem('nickname', res?.data?.nickname);
  window.location = '/view/project-list';
};
</script>
登出功能

登出功能的实现通过删除 JWT 令牌并重定向到登录页面。

vue
复制编辑
<template>
  <el-dropdown @command="handleUserCommand">
    <span class="username">{{ userName }}</span> <i class="el-icon-arrow-down el-icon--right" />
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item command="logout">退出登录</el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<script setup>
import { ref } from 'vue';
const userName = ref(localStorage.getItem('nickname') || '管理员');

const handleUserCommand = function (event) {
  if (event === 'logout') {
    window.location = '/api/auth/logout';
  }
};
</script>

3. 持续集成与持续部署(CI/CD)

持续集成(CI)

Elpis 的开发过程中,持续集成(CI)是非常重要的,它确保每次代码提交后都能进行自动化构建、测试和推送。通过 Jenkins 等工具,我们可以设置自动化流水线来构建和测试代码。

shell
复制编辑
npm install --production
npm run build:prod
持续部署(CD)

CD 流程中,我们将代码部署到生产环境。通过 DockerKubernetes,我们能够实现自动化的部署和扩展,确保服务的高可用性和自动化管理。

yaml
复制编辑
apiVersion: apps/v1
kind: Deployment
metadata:
  name: elpis-demo-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: elpis-demo
  template:
    metadata:
      labels:
        app: elpis-demo
    spec:
      containers:
        - image: 'your-docker-image'
          name: elpis-demo-container
          ports:
            - containerPort: 8081

4. 总结

通过 Elpis 框架,我们可以实现快速的项目开发和部署。通过 人员管理模块 的实现,我们能够在项目中轻松实现业务逻辑。结合 持续集成(CI)持续部署(CD) ,我们能够高效地将代码推送到生产环境,确保项目的稳定性和可扩展性。

React useMemo 深度指南:原理、误区、实战与 2025 最佳实践

把“为什么用、怎么用、用错了怎么办”一次讲透,附 React 19 自动优化前瞻


一、useMemo 是什么?

一句话:
useMemo = 记住(缓存)昂贵计算结果,只在依赖变化时重新计算。

const memoValue = useMemo(() => {
  return heavyCompute(a, b);
}, [a, b]);
  • 第 1 个参数:计算函数,必须返回一个值。
  • 第 2 个参数:依赖数组,React 用 Object.is 比对。
  • 返回值:缓存值,依赖不变就复用。

二、使用场景 3+1

场景 示例 收益
昂贵计算 大数据过滤 / 排序 / 图表计算 避免每次渲染重算
稳定引用 把对象/数组传给子组件 配合 React.memo 减少子组件重渲染
函数缓存 返回函数时用 useCallback 语法糖 防止子组件 props 变化
React 19 之前 手动优化瓶颈 React 19 编译器将自动处理

三、实战:Todo 列表性能优化

问题:每次添加 todo,都重新过滤 1000 条数据

function TodoList({ todos, filter }) {
  // ❌ 每次渲染都执行 filterTodos
  const visibleTodos = filterTodos(todos, filter);

  return visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>);
}

解决:useMemo 缓存过滤结果

function TodoList({ todos, filter }) {
  const visibleTodos = useMemo(
    () => filterTodos(todos, filter),
    [todos, filter]
  );

  return visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>);
}

依赖 [todos, filter] 不变,filterTodos 只在依赖变化时运行。


四、常见误区与对策

误区 后果 对策
依赖项漏写 缓存值不更新 使用 ESLint react-hooks/exhaustive-deps
过度使用 缓存成本 > 计算成本 先写简单代码,后优化
返回函数 误用 useMemo(() => fn) 改用 useCallback(fn, deps)

五、React 19 之后:还要手动用吗?

React 19 编译器 已能 自动记忆化

  • 大部分场景 无需手写 useMemo/useCallback
  • 仅在 第三方库要求引用稳定极端昂贵计算 时手动使用。

六、一键记忆化模板

import { useMemo } from 'react';

export function useExpensiveValue<T>(factory: () => T, deps: any[]): T {
  return useMemo(factory, deps);
}

// 用法
const data = useExpensiveValue(() => heavyCompute(a, b), [a, b]);

七、一句话总结

useMemo = “计算缓存”,只在依赖变化时重算;React 19 之前手动优化瓶颈,之后让编译器兜底

❌